Main coves client
1import 'package:dio/dio.dart';
2import 'package:flutter/foundation.dart' hide Key;
3
4import '../dpop/fetch_dpop.dart';
5import '../errors/oauth_response_error.dart';
6import '../errors/token_refresh_error.dart';
7import '../runtime/runtime.dart';
8import '../runtime/runtime_implementation.dart';
9import '../types.dart';
10import 'authorization_server_metadata_resolver.dart' show GetCachedOptions;
11import 'client_auth.dart';
12import 'oauth_resolver.dart';
13
14/// Represents a token set returned from OAuth token endpoint.
15class TokenSet {
16 /// Issuer (authorization server URL)
17 final String iss;
18
19 /// Subject (DID of the user)
20 final String sub;
21
22 /// Audience (PDS URL)
23 final String aud;
24
25 /// Scope (space-separated list of scopes)
26 final String scope;
27
28 /// Refresh token (optional)
29 final String? refreshToken;
30
31 /// Access token
32 final String accessToken;
33
34 /// Token type (must be "DPoP" for ATPROTO)
35 final String tokenType;
36
37 /// Expiration time (ISO date string)
38 final String? expiresAt;
39
40 const TokenSet({
41 required this.iss,
42 required this.sub,
43 required this.aud,
44 required this.scope,
45 this.refreshToken,
46 required this.accessToken,
47 required this.tokenType,
48 this.expiresAt,
49 });
50
51 Map<String, dynamic> toJson() {
52 return {
53 'iss': iss,
54 'sub': sub,
55 'aud': aud,
56 'scope': scope,
57 if (refreshToken != null) 'refresh_token': refreshToken,
58 'access_token': accessToken,
59 'token_type': tokenType,
60 if (expiresAt != null) 'expires_at': expiresAt,
61 };
62 }
63
64 factory TokenSet.fromJson(Map<String, dynamic> json) {
65 return TokenSet(
66 iss: json['iss'] as String,
67 sub: json['sub'] as String,
68 aud: json['aud'] as String,
69 scope: json['scope'] as String,
70 refreshToken: json['refresh_token'] as String?,
71 accessToken: json['access_token'] as String,
72 tokenType: json['token_type'] as String,
73 expiresAt: json['expires_at'] as String?,
74 );
75 }
76}
77
78/// DPoP nonce cache type.
79typedef DpopNonceCache = SimpleStore<String, String>;
80
81/// Agent for interacting with an OAuth authorization server.
82///
83/// This class handles:
84/// - Token exchange (authorization code → tokens)
85/// - Token refresh (refresh token → new tokens)
86/// - Token revocation
87/// - DPoP proof generation and nonce management
88/// - Client authentication
89///
90/// All token requests include DPoP proofs to bind tokens to keys.
91class OAuthServerAgent {
92 final ClientAuthMethod authMethod;
93 final Key dpopKey;
94 final Map<String, dynamic> serverMetadata;
95 final ClientMetadata clientMetadata;
96 final DpopNonceCache dpopNonces;
97 final OAuthResolver oauthResolver;
98 final Runtime runtime;
99 final Keyset? keyset;
100 final Dio _dio;
101 final ClientCredentialsFactory _clientCredentialsFactory;
102
103 /// Creates an OAuth server agent.
104 ///
105 /// Throws [AuthMethodUnsatisfiableError] if the auth method cannot be satisfied.
106 OAuthServerAgent({
107 required this.authMethod,
108 required this.dpopKey,
109 required this.serverMetadata,
110 required this.clientMetadata,
111 required this.dpopNonces,
112 required this.oauthResolver,
113 required this.runtime,
114 this.keyset,
115 Dio? dio,
116 }) : // CRITICAL: Always create a NEW Dio instance to avoid duplicate interceptors
117 // If we reuse a shared Dio instance, each OAuthServerAgent will add its
118 // interceptors to the same instance, causing duplicate requests!
119 _dio = Dio(dio?.options ?? BaseOptions()),
120 _clientCredentialsFactory = createClientCredentialsFactory(
121 authMethod,
122 serverMetadata,
123 clientMetadata,
124 runtime,
125 keyset,
126 ) {
127 // Add debug logging interceptor (runs before DPoP interceptor)
128 if (kDebugMode) {
129 _dio.interceptors.add(
130 InterceptorsWrapper(
131 onRequest: (options, handler) {
132 if (options.uri.path.contains('/token')) {
133 print(
134 '📤 [BEFORE DPoP] Request headers: ${options.headers.keys.toList()}',
135 );
136 }
137 handler.next(options);
138 },
139 ),
140 );
141 }
142
143 // Add DPoP interceptor
144 _dio.interceptors.add(
145 createDpopInterceptor(
146 DpopFetchWrapperOptions(
147 key: dpopKey,
148 nonces: dpopNonces,
149 sha256: runtime.sha256,
150 isAuthServer: true,
151 ),
152 ),
153 );
154
155 // Add final logging interceptor (runs after DPoP interceptor)
156 if (kDebugMode) {
157 _dio.interceptors.add(
158 InterceptorsWrapper(
159 onRequest: (options, handler) {
160 if (options.uri.path.contains('/token')) {
161 print(
162 '📤 [AFTER DPoP] Request headers: ${options.headers.keys.toList()}',
163 );
164 if (options.headers.containsKey('dpop')) {
165 print(
166 ' DPoP header present: ${options.headers['dpop']?.toString().substring(0, 50)}...',
167 );
168 } else if (options.headers.containsKey('DPoP')) {
169 print(
170 ' DPoP header present: ${options.headers['DPoP']?.toString().substring(0, 50)}...',
171 );
172 } else {
173 print(' ⚠️ DPoP header MISSING!');
174 }
175 }
176 handler.next(options);
177 },
178 onError: (error, handler) {
179 if (error.requestOptions.uri.path.contains('/token')) {
180 print('📥 Token request error: ${error.message}');
181 }
182 handler.next(error);
183 },
184 ),
185 );
186 }
187 }
188
189 /// The issuer (authorization server URL).
190 String get issuer => serverMetadata['issuer'] as String;
191
192 /// Revokes a token.
193 ///
194 /// Errors are silently ignored as revocation is best-effort.
195 Future<void> revoke(String token) async {
196 try {
197 await _request('revocation', {'token': token});
198 } catch (_) {
199 // Don't care if revocation fails
200 }
201 }
202
203 /// Pre-fetches a DPoP nonce from the token endpoint.
204 ///
205 /// This is critical for authorization code exchange because:
206 /// 1. First token request without nonce → PDS consumes code + returns use_dpop_nonce error
207 /// 2. Retry with nonce → "Invalid code" because already consumed
208 ///
209 /// Solution: Get a nonce BEFORE attempting code exchange.
210 ///
211 /// We make a lightweight invalid request that will fail but return a nonce.
212 /// The server responds with a nonce in the DPoP-Nonce header, which the
213 /// interceptor automatically caches for subsequent requests.
214 Future<void> _prefetchDpopNonce() async {
215 final tokenEndpoint = serverMetadata['token_endpoint'] as String?;
216 if (tokenEndpoint == null) return;
217
218 final origin = Uri.parse(tokenEndpoint);
219 final originKey =
220 '${origin.scheme}://${origin.host}${origin.hasPort ? ':${origin.port}' : ''}';
221
222 // Clear any stale nonce from previous sessions
223 try {
224 await dpopNonces.del(originKey);
225 if (kDebugMode) {
226 print('🧹 Cleared stale DPoP nonce from cache');
227 }
228 } catch (_) {
229 // Ignore deletion errors
230 }
231
232 if (kDebugMode) {
233 print('⏱️ Pre-fetch starting at: ${DateTime.now().toIso8601String()}');
234 }
235
236 try {
237 // Make a minimal invalid request to trigger nonce response
238 // Use an invalid grant_type that will fail fast without side effects
239 await _dio.post<Map<String, dynamic>>(
240 tokenEndpoint,
241 data: 'grant_type=invalid_prefetch',
242 options: Options(
243 headers: {'Content-Type': 'application/x-www-form-urlencoded'},
244 validateStatus: (status) => true, // Accept any status
245 ),
246 );
247 } catch (_) {
248 // Ignore all errors - we just want the nonce from the response headers
249 // The DPoP interceptor will have cached it in onError or onResponse
250 }
251
252 if (kDebugMode) {
253 print('⏱️ Pre-fetch completed at: ${DateTime.now().toIso8601String()}');
254 final cachedNonce = await dpopNonces.get(originKey);
255 print('🎫 DPoP nonce pre-fetch result:');
256 print(
257 ' Cached nonce: ${cachedNonce != null ? "✅ ${cachedNonce.substring(0, 20)}..." : "❌ not found"}',
258 );
259 }
260 }
261
262 /// Exchanges an authorization code for tokens.
263 ///
264 /// This is called after the user completes authorization and you receive
265 /// the authorization code in the callback.
266 ///
267 /// [code] is the authorization code from the callback.
268 /// [codeVerifier] is the PKCE code verifier (if PKCE was used).
269 /// [redirectUri] is the redirect URI used in the authorization request.
270 ///
271 /// Returns a [TokenSet] with access token, optional refresh token, and metadata.
272 ///
273 /// IMPORTANT: This method verifies the issuer before returning tokens.
274 /// If verification fails, the access token is automatically revoked.
275 Future<TokenSet> exchangeCode(
276 String code, {
277 String? codeVerifier,
278 String? redirectUri,
279 }) async {
280 // CRITICAL: DO NOT pre-fetch! Exchange immediately!
281 // The pre-fetch adds ~678ms delay, during which the browser re-navigates
282 // and invalidates the authorization code. We need to exchange within ~270ms.
283 // If we get a nonce error, we'll handle it via the interceptor (though PDS
284 // doesn't seem to require nonces for initial token exchange).
285
286 final now = DateTime.now();
287
288 final tokenResponse = await _request('token', {
289 'grant_type': 'authorization_code',
290 'redirect_uri': redirectUri ?? clientMetadata.redirectUris.first,
291 'code': code,
292 if (codeVerifier != null) 'code_verifier': codeVerifier,
293 });
294
295 try {
296 // CRITICAL: Verify issuer before trusting the sub
297 // The tokenResponse MUST always be valid before the "sub" can be trusted
298 // See: https://atproto.com/specs/oauth
299 final aud = await _verifyIssuer(tokenResponse['sub'] as String);
300
301 return TokenSet(
302 aud: aud,
303 sub: tokenResponse['sub'] as String,
304 iss: issuer,
305 scope: tokenResponse['scope'] as String,
306 refreshToken: tokenResponse['refresh_token'] as String?,
307 accessToken: tokenResponse['access_token'] as String,
308 tokenType: tokenResponse['token_type'] as String,
309 expiresAt:
310 tokenResponse['expires_in'] != null
311 ? now
312 .add(Duration(seconds: tokenResponse['expires_in'] as int))
313 .toIso8601String()
314 : null,
315 );
316 } catch (err) {
317 // If verification fails, revoke the access token
318 await revoke(tokenResponse['access_token'] as String);
319 rethrow;
320 }
321 }
322
323 /// Refreshes a token set using the refresh token.
324 ///
325 /// [tokenSet] is the current token set with a refresh_token.
326 ///
327 /// Returns a new [TokenSet] with fresh tokens.
328 ///
329 /// Throws [TokenRefreshError] if refresh fails or no refresh token is available.
330 ///
331 /// IMPORTANT: This method verifies the issuer before returning tokens.
332 Future<TokenSet> refresh(TokenSet tokenSet) async {
333 if (tokenSet.refreshToken == null) {
334 throw TokenRefreshError(tokenSet.sub, 'No refresh token available');
335 }
336
337 // CRITICAL: Verify issuer BEFORE refresh to avoid unnecessary requests
338 // and ensure the sub is still valid for this issuer
339 final aud = await _verifyIssuer(tokenSet.sub);
340
341 final now = DateTime.now();
342
343 final tokenResponse = await _request('token', {
344 'grant_type': 'refresh_token',
345 'refresh_token': tokenSet.refreshToken,
346 });
347
348 return TokenSet(
349 aud: aud,
350 sub: tokenSet.sub,
351 iss: issuer,
352 scope: tokenResponse['scope'] as String,
353 refreshToken: tokenResponse['refresh_token'] as String?,
354 accessToken: tokenResponse['access_token'] as String,
355 tokenType: tokenResponse['token_type'] as String,
356 expiresAt:
357 tokenResponse['expires_in'] != null
358 ? now
359 .add(Duration(seconds: tokenResponse['expires_in'] as int))
360 .toIso8601String()
361 : null,
362 );
363 }
364
365 /// Verifies that the sub (DID) is indeed issued by this authorization server.
366 ///
367 /// This is CRITICAL for security. We must verify that the DID's PDS
368 /// is protected by this authorization server before trusting tokens.
369 ///
370 /// Returns the user's PDS URL (the resource server).
371 ///
372 /// Throws if:
373 /// - DID resolution fails
374 /// - Issuer mismatch (user may have switched PDS or attack detected)
375 Future<String> _verifyIssuer(String sub) async {
376 final cancelToken = CancelToken();
377 final resolved = await oauthResolver
378 .resolveFromIdentity(
379 sub,
380 GetCachedOptions(
381 noCache: true,
382 allowStale: false,
383 cancelToken: cancelToken,
384 ),
385 )
386 .timeout(
387 const Duration(seconds: 10),
388 onTimeout: () {
389 cancelToken.cancel();
390 throw TimeoutException('Issuer verification timed out');
391 },
392 );
393
394 if (issuer != resolved.metadata['issuer']) {
395 // Best case: user switched PDS
396 // Worst case: attack attempt
397 // Either way: MUST NOT allow this token to be used
398 throw FormatException('Issuer mismatch');
399 }
400
401 return resolved.pds.toString();
402 }
403
404 /// Makes a request to an OAuth endpoint (public API).
405 ///
406 /// This is a generic method for making OAuth endpoint requests with proper typing.
407 /// Currently supports: token, revocation, pushed_authorization_request.
408 ///
409 /// [endpoint] is the endpoint name.
410 /// [payload] is the request body parameters.
411 ///
412 /// Returns the parsed JSON response.
413 /// Throws [OAuthResponseError] if the server returns an error.
414 Future<Map<String, dynamic>> request(
415 String endpoint,
416 Map<String, dynamic> payload,
417 ) async {
418 return _request(endpoint, payload);
419 }
420
421 /// Makes a request to an OAuth endpoint (internal implementation).
422 ///
423 /// [endpoint] is the endpoint name (e.g., 'token', 'revocation', 'pushed_authorization_request').
424 /// [payload] is the request body parameters.
425 ///
426 /// Returns the parsed JSON response.
427 /// Throws [OAuthResponseError] if the server returns an error.
428 Future<Map<String, dynamic>> _request(
429 String endpoint,
430 Map<String, dynamic> payload,
431 ) async {
432 final url = serverMetadata['${endpoint}_endpoint'];
433 if (url == null) {
434 throw StateError('No $endpoint endpoint available');
435 }
436
437 final auth = await _clientCredentialsFactory();
438
439 final fullPayload = {...payload, ...auth.payload.toJson()};
440 final encodedData = _wwwFormUrlEncode(fullPayload);
441
442 if (kDebugMode && endpoint == 'token') {
443 print('🌐 Token exchange HTTP request:');
444 print(' ⏱️ Request starting at: ${DateTime.now().toIso8601String()}');
445 print(' URL: $url');
446 print(' Payload keys: ${fullPayload.keys.toList()}');
447 print(' grant_type: ${fullPayload['grant_type']}');
448 print(' client_id: ${fullPayload['client_id']}');
449 print(' redirect_uri: ${fullPayload['redirect_uri']}');
450 print(' code: ${fullPayload['code']?.toString().substring(0, 20)}...');
451 print(
452 ' code_verifier: ${fullPayload['code_verifier']?.toString().substring(0, 20)}...',
453 );
454 print(' Headers: ${auth.headers?.keys.toList() ?? []}');
455 }
456
457 try {
458 final response = await _dio.post<Map<String, dynamic>>(
459 url as String,
460 data: encodedData,
461 options: Options(
462 headers: {
463 if (auth.headers != null) ...auth.headers!,
464 'Content-Type': 'application/x-www-form-urlencoded',
465 },
466 ),
467 );
468
469 final data = response.data;
470 if (data == null) {
471 throw OAuthResponseError(response, {'error': 'empty_response'});
472 }
473
474 if (kDebugMode && endpoint == 'token') {
475 print(' ✅ Token exchange successful!');
476 }
477
478 return data;
479 } on DioException catch (e) {
480 final response = e.response;
481 if (response != null) {
482 if (kDebugMode && endpoint == 'token') {
483 print(' ❌ Token exchange failed:');
484 print(' Status: ${response.statusCode}');
485 print(' Response: ${response.data}');
486 }
487 throw OAuthResponseError(response, response.data);
488 }
489 rethrow;
490 }
491 }
492
493 /// Encodes a map as application/x-www-form-urlencoded.
494 String _wwwFormUrlEncode(Map<String, dynamic> payload) {
495 final entries = payload.entries
496 .where((e) => e.value != null)
497 .map((e) => MapEntry(e.key, _stringifyValue(e.value)));
498
499 return Uri(queryParameters: Map.fromEntries(entries)).query;
500 }
501
502 /// Converts a value to string for form encoding.
503 String _stringifyValue(dynamic value) {
504 if (value is String) return value;
505 if (value is num) return value.toString();
506 if (value is bool) return value.toString();
507 // For complex types, use JSON encoding
508 return value.toString();
509 }
510}
511
512/// Timeout exception.
513class TimeoutException implements Exception {
514 final String message;
515 TimeoutException(this.message);
516
517 @override
518 String toString() => 'TimeoutException: $message';
519}