1import 'dart:convert';
2
3import 'package:crypto/crypto.dart';
4import 'package:http/http.dart' as http;
5import 'package:jose/jose.dart';
6import 'package:uuid/uuid.dart';
7
8class 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}