this repo has no description
at main 153 lines 4.8 kB view raw
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}