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