refactor: remove dpop

Changed files
-153
lib
-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 - }