-153
lib/dpop_client.dart
-153
lib/dpop_client.dart
···
1
-
import 'dart:convert';
2
-
3
-
import 'package:crypto/crypto.dart';
4
-
import 'package:http/http.dart' as http;
5
-
import 'package:jose/jose.dart';
6
-
import 'package:uuid/uuid.dart';
7
-
8
-
class DpopHttpClient {
9
-
final JsonWebKey dpopKey;
10
-
final Map<String, String> _nonces = {}; // origin -> nonce
11
-
12
-
DpopHttpClient({required this.dpopKey});
13
-
14
-
/// Extract origin (scheme + host + port) from a URL
15
-
String _extractOrigin(String url) {
16
-
final uri = Uri.parse(url);
17
-
final portPart = (uri.hasPort && uri.port != 80 && uri.port != 443) ? ':${uri.port}' : '';
18
-
return '${uri.scheme}://${uri.host}$portPart';
19
-
}
20
-
21
-
/// Strip query and fragment from URL per spec
22
-
String _buildHtu(String url) {
23
-
final uri = Uri.parse(url);
24
-
return '${uri.scheme}://${uri.host}${uri.path}';
25
-
}
26
-
27
-
/// Calculate ath claim: base64url(sha256(access_token))
28
-
String _calculateAth(String accessToken) {
29
-
final hash = sha256.convert(utf8.encode(accessToken));
30
-
return base64Url.encode(hash.bytes).replaceAll('=', '');
31
-
}
32
-
33
-
/// Calculate the JWK Thumbprint for EC or RSA keys per RFC 7638.
34
-
/// The input [jwk] is the public part of your key as a Map`<String, dynamic>`.
35
-
///
36
-
/// For EC keys, required fields are: crv, kty, x, y
37
-
/// For RSA keys, required fields are: e, kty, n
38
-
String calculateJwkThumbprint(Map<String, dynamic> jwk) {
39
-
late Map<String, String> ordered;
40
-
41
-
if (jwk['kty'] == 'EC') {
42
-
ordered = {'crv': jwk['crv'], 'kty': jwk['kty'], 'x': jwk['x'], 'y': jwk['y']};
43
-
} else if (jwk['kty'] == 'RSA') {
44
-
ordered = {'e': jwk['e'], 'kty': jwk['kty'], 'n': jwk['n']};
45
-
} else {
46
-
throw ArgumentError('Unsupported key type for thumbprint calculation');
47
-
}
48
-
49
-
final jsonString = jsonEncode(ordered);
50
-
51
-
final digest = sha256.convert(utf8.encode(jsonString));
52
-
return base64Url.encode(digest.bytes).replaceAll('=', '');
53
-
}
54
-
55
-
/// Build the DPoP JWT proof
56
-
Future<String> _buildProof({
57
-
required String htm,
58
-
required String htu,
59
-
String? nonce,
60
-
String? ath,
61
-
}) async {
62
-
final now = (DateTime.now().millisecondsSinceEpoch / 1000).floor();
63
-
final jti = Uuid().v4();
64
-
65
-
final publicJwk = Map<String, String>.from(dpopKey.toJson())..remove('d');
66
-
67
-
final payload = {
68
-
'htu': htu,
69
-
'htm': htm,
70
-
'iat': now,
71
-
'jti': jti,
72
-
if (nonce != null) 'nonce': nonce,
73
-
if (ath != null) 'ath': ath,
74
-
};
75
-
76
-
final builder = JsonWebSignatureBuilder()
77
-
..jsonContent = payload
78
-
..addRecipient(dpopKey, algorithm: dpopKey.algorithm)
79
-
..setProtectedHeader('typ', 'dpop+jwt')
80
-
..setProtectedHeader('jwk', publicJwk);
81
-
82
-
final jws = builder.build();
83
-
return jws.toCompactSerialization();
84
-
}
85
-
86
-
/// Public method to send requests with DPoP proof, retries once on use_dpop_nonce error
87
-
Future<http.Response> send({
88
-
required String method,
89
-
required Uri url,
90
-
required String accessToken,
91
-
Map<String, String>? headers,
92
-
Object? body,
93
-
}) async {
94
-
final origin = _extractOrigin(url.toString());
95
-
final nonce = _nonces[origin];
96
-
97
-
final htu = _buildHtu(url.toString());
98
-
final ath = _calculateAth(accessToken);
99
-
100
-
final proof = await _buildProof(htm: method.toUpperCase(), htu: htu, nonce: nonce, ath: ath);
101
-
102
-
// Compose headers, allowing override of Content-Type for raw uploads
103
-
final requestHeaders = <String, String>{
104
-
'Authorization': 'DPoP $accessToken',
105
-
'DPoP': proof,
106
-
if (headers != null) ...headers,
107
-
};
108
-
109
-
http.Response response;
110
-
switch (method.toUpperCase()) {
111
-
case 'GET':
112
-
response = await http.get(url, headers: requestHeaders);
113
-
break;
114
-
case 'POST':
115
-
response = await http.post(url, headers: requestHeaders, body: body);
116
-
break;
117
-
case 'PUT':
118
-
response = await http.put(url, headers: requestHeaders, body: body);
119
-
break;
120
-
case 'DELETE':
121
-
response = await http.delete(url, headers: requestHeaders, body: body);
122
-
break;
123
-
default:
124
-
throw UnsupportedError('Unsupported HTTP method: $method');
125
-
}
126
-
127
-
final newNonce = response.headers['dpop-nonce'];
128
-
if (newNonce != null && newNonce != nonce) {
129
-
// Save new nonce for origin
130
-
_nonces[origin] = newNonce;
131
-
}
132
-
133
-
if (response.statusCode == 401) {
134
-
final wwwAuth = response.headers['www-authenticate'];
135
-
if (wwwAuth != null &&
136
-
wwwAuth.contains('DPoP') &&
137
-
wwwAuth.contains('error="use_dpop_nonce"') &&
138
-
newNonce != null &&
139
-
newNonce != nonce) {
140
-
// Retry once with updated nonce
141
-
return send(
142
-
method: method,
143
-
url: url,
144
-
accessToken: accessToken,
145
-
headers: headers,
146
-
body: body,
147
-
);
148
-
}
149
-
}
150
-
151
-
return response;
152
-
}
153
-
}
···