Main coves client
1import 'dart:async';
2import 'package:dio/dio.dart';
3import 'package:flutter/foundation.dart';
4
5import '../constants.dart';
6import '../dpop/fetch_dpop.dart' show InMemoryStore;
7import '../errors/auth_method_unsatisfiable_error.dart';
8import '../errors/oauth_callback_error.dart';
9import '../errors/token_revoked_error.dart';
10import '../identity/constants.dart';
11import '../identity/did_helpers.dart' show assertAtprotoDid;
12import '../identity/did_resolver.dart' show DidCache;
13import '../identity/handle_resolver.dart' show HandleCache;
14import '../identity/identity_resolver.dart';
15import '../oauth/authorization_server_metadata_resolver.dart' as auth_resolver;
16import '../oauth/client_auth.dart';
17import '../oauth/oauth_resolver.dart';
18import '../oauth/oauth_server_agent.dart';
19import '../oauth/oauth_server_factory.dart';
20import '../oauth/protected_resource_metadata_resolver.dart';
21import '../oauth/validate_client_metadata.dart';
22import '../platform/flutter_key.dart';
23import '../runtime/runtime.dart' as runtime_lib;
24import '../runtime/runtime_implementation.dart';
25import '../session/oauth_session.dart'
26 show OAuthSession, Session, SessionGetterInterface;
27import '../session/session_getter.dart';
28import '../session/state_store.dart';
29import '../types.dart';
30import '../util.dart';
31
32// Re-export types needed for OAuthClientOptions
33export '../identity/did_resolver.dart' show DidCache, DidResolver;
34export '../identity/handle_resolver.dart' show HandleCache, HandleResolver;
35export '../identity/identity_resolver.dart' show IdentityResolver;
36export '../oauth/authorization_server_metadata_resolver.dart'
37 show AuthorizationServerMetadataCache;
38export '../oauth/oauth_server_agent.dart' show DpopNonceCache;
39export '../oauth/protected_resource_metadata_resolver.dart'
40 show ProtectedResourceMetadataCache;
41export '../runtime/runtime_implementation.dart' show RuntimeImplementation, Key;
42export '../oauth/client_auth.dart' show Keyset;
43export '../session/session_getter.dart'
44 show SessionStore, SessionUpdatedEvent, SessionDeletedEvent;
45export '../session/state_store.dart' show StateStore, InternalStateData;
46export '../types.dart' show ClientMetadata, AuthorizeOptions, CallbackOptions;
47
48/// OAuth response mode.
49enum OAuthResponseMode {
50 /// Parameters in query string (default, most compatible)
51 query('query'),
52
53 /// Parameters in URL fragment (for single-page apps)
54 fragment('fragment');
55
56 final String value;
57 const OAuthResponseMode(this.value);
58
59 @override
60 String toString() => value;
61}
62
63/// Options for constructing an OAuthClient.
64///
65/// This includes all configuration, storage, and service dependencies
66/// needed to implement the complete OAuth flow.
67class OAuthClientOptions {
68 // Config
69 /// Response mode for OAuth (query or fragment)
70 final OAuthResponseMode responseMode;
71
72 /// Client metadata (validated before use)
73 final Map<String, dynamic> clientMetadata;
74
75 /// Optional keyset for confidential clients (private_key_jwt)
76 final Keyset? keyset;
77
78 /// Whether to allow HTTP connections (for development only)
79 ///
80 /// This affects:
81 /// - OAuth authorization/resource servers
82 /// - did:web document fetching
83 ///
84 /// Note: PLC directory connections are controlled separately.
85 final bool allowHttp;
86
87 // Stores
88 /// Storage for OAuth state during authorization flow
89 final StateStore stateStore;
90
91 /// Storage for session tokens
92 final SessionStore sessionStore;
93
94 /// Optional cache for authorization server metadata
95 final auth_resolver.AuthorizationServerMetadataCache?
96 authorizationServerMetadataCache;
97
98 /// Optional cache for protected resource metadata
99 final ProtectedResourceMetadataCache? protectedResourceMetadataCache;
100
101 /// Optional cache for DPoP nonces
102 final DpopNonceCache? dpopNonceCache;
103
104 /// Optional cache for DID documents
105 final DidCache? didCache;
106
107 /// Optional cache for handle → DID resolutions
108 final HandleCache? handleCache;
109
110 // Services
111 /// Platform-specific cryptographic operations
112 final RuntimeImplementation runtimeImplementation;
113
114 /// Optional HTTP client (Dio instance)
115 final Dio? dio;
116
117 /// Optional custom identity resolver
118 final IdentityResolver? identityResolver;
119
120 /// PLC directory URL (for DID resolution)
121 final String? plcDirectoryUrl;
122
123 /// Handle resolver URL (for handle → DID resolution)
124 final String? handleResolverUrl;
125
126 const OAuthClientOptions({
127 required this.responseMode,
128 required this.clientMetadata,
129 this.keyset,
130 this.allowHttp = false,
131 required this.stateStore,
132 required this.sessionStore,
133 this.authorizationServerMetadataCache,
134 this.protectedResourceMetadataCache,
135 this.dpopNonceCache,
136 this.didCache,
137 this.handleCache,
138 required this.runtimeImplementation,
139 this.dio,
140 this.identityResolver,
141 this.plcDirectoryUrl,
142 this.handleResolverUrl,
143 });
144}
145
146/// Result of a successful OAuth callback.
147class CallbackResult {
148 /// The authenticated session
149 final OAuthSession session;
150
151 /// The application state from the original authorize call
152 final String? state;
153
154 const CallbackResult({required this.session, this.state});
155}
156
157/// Options for fetching client metadata from a discoverable client ID.
158class OAuthClientFetchMetadataOptions {
159 /// The discoverable client ID (HTTPS URL)
160 final String clientId;
161
162 /// Optional HTTP client
163 final Dio? dio;
164
165 /// Optional cancellation token
166 final CancelToken? cancelToken;
167
168 const OAuthClientFetchMetadataOptions({
169 required this.clientId,
170 this.dio,
171 this.cancelToken,
172 });
173}
174
175/// Main OAuth client for atProto OAuth flows.
176///
177/// This is the primary class that developers interact with. It orchestrates:
178/// - Authorization flow (authorize → callback)
179/// - Session restoration (restore)
180/// - Token revocation (revoke)
181/// - Session lifecycle events
182///
183/// Usage:
184/// ```dart
185/// final client = OAuthClient(
186/// clientMetadata: {
187/// 'client_id': 'https://example.com/client-metadata.json',
188/// 'redirect_uris': ['myapp://oauth/callback'],
189/// 'scope': 'atproto',
190/// },
191/// responseMode: OAuthResponseMode.query,
192/// stateStore: MyStateStore(),
193/// sessionStore: MySessionStore(),
194/// runtimeImplementation: MyRuntimeImplementation(),
195/// );
196///
197/// // Start authorization
198/// final authUrl = await client.authorize('alice.bsky.social');
199///
200/// // Handle callback
201/// final result = await client.callback(callbackParams);
202/// print('Signed in as: ${result.session.sub}');
203///
204/// // Restore session later
205/// final session = await client.restore('did:plc:abc123');
206///
207/// // Revoke session
208/// await client.revoke('did:plc:abc123');
209/// ```
210class OAuthClient extends CustomEventTarget<Map<String, dynamic>> {
211 // Config
212 /// Validated client metadata
213 final ClientMetadata clientMetadata;
214
215 /// OAuth response mode (query or fragment)
216 final OAuthResponseMode responseMode;
217
218 /// Optional keyset for confidential clients
219 final Keyset? keyset;
220
221 // Services
222 /// Runtime for cryptographic operations
223 final runtime_lib.Runtime runtime;
224
225 /// HTTP client
226 final Dio dio;
227
228 /// OAuth resolver for identity → metadata
229 final OAuthResolver oauthResolver;
230
231 /// Factory for creating OAuth server agents
232 final OAuthServerFactory serverFactory;
233
234 // Stores
235 /// Session management with automatic refresh
236 final SessionGetter _sessionGetter;
237
238 /// OAuth state storage
239 final StateStore _stateStore;
240
241 // Event streams
242 final StreamController<SessionUpdatedEvent> _updatedController =
243 StreamController<SessionUpdatedEvent>.broadcast();
244 final StreamController<SessionDeletedEvent> _deletedController =
245 StreamController<SessionDeletedEvent>.broadcast();
246
247 /// Stream of session update events
248 Stream<SessionUpdatedEvent> get onUpdated => _updatedController.stream;
249
250 /// Stream of session deletion events
251 Stream<SessionDeletedEvent> get onDeleted => _deletedController.stream;
252
253 /// Constructs an OAuthClient with the given options.
254 ///
255 /// Throws [FormatException] if client metadata is invalid.
256 /// Throws [TypeError] if keyset configuration is incorrect.
257 OAuthClient(OAuthClientOptions options)
258 : keyset = options.keyset,
259 responseMode = options.responseMode,
260 runtime = runtime_lib.Runtime(options.runtimeImplementation),
261 dio = options.dio ?? Dio(),
262 _stateStore = options.stateStore,
263 clientMetadata = validateClientMetadata(
264 options.clientMetadata,
265 options.keyset,
266 ),
267 oauthResolver = _createOAuthResolver(options),
268 serverFactory = _createServerFactory(options),
269 _sessionGetter = _createSessionGetter(options) {
270 // Proxy session events from SessionGetter
271 _sessionGetter.onUpdated.listen((event) {
272 _updatedController.add(event);
273 dispatchCustomEvent('updated', event);
274 });
275
276 _sessionGetter.onDeleted.listen((event) {
277 _deletedController.add(event);
278 dispatchCustomEvent('deleted', event);
279 });
280 }
281
282 /// Creates the OAuth resolver.
283 static OAuthResolver _createOAuthResolver(OAuthClientOptions options) {
284 final dio = options.dio ?? Dio();
285
286 return OAuthResolver(
287 identityResolver:
288 options.identityResolver ??
289 AtprotoIdentityResolver.withDefaults(
290 handleResolverUrl:
291 options.handleResolverUrl ?? 'https://bsky.social',
292 plcDirectoryUrl: options.plcDirectoryUrl,
293 dio: dio,
294 didCache: options.didCache,
295 handleCache: options.handleCache,
296 ),
297 protectedResourceMetadataResolver: OAuthProtectedResourceMetadataResolver(
298 options.protectedResourceMetadataCache ??
299 InMemoryStore<String, Map<String, dynamic>>(),
300 dio: dio,
301 config: OAuthProtectedResourceMetadataResolverConfig(
302 allowHttpResource: options.allowHttp,
303 ),
304 ),
305 authorizationServerMetadataResolver:
306 auth_resolver.OAuthAuthorizationServerMetadataResolver(
307 options.authorizationServerMetadataCache ??
308 InMemoryStore<String, Map<String, dynamic>>(),
309 dio: dio,
310 config:
311 auth_resolver.OAuthAuthorizationServerMetadataResolverConfig(
312 allowHttpIssuer: options.allowHttp,
313 ),
314 ),
315 );
316 }
317
318 /// Creates the OAuth server factory.
319 static OAuthServerFactory _createServerFactory(OAuthClientOptions options) {
320 return OAuthServerFactory(
321 clientMetadata: validateClientMetadata(
322 options.clientMetadata,
323 options.keyset,
324 ),
325 runtime: runtime_lib.Runtime(options.runtimeImplementation),
326 resolver: _createOAuthResolver(options),
327 dio: options.dio ?? Dio(),
328 keyset: options.keyset,
329 dpopNonceCache: options.dpopNonceCache ?? InMemoryStore<String, String>(),
330 );
331 }
332
333 /// Creates the session getter.
334 static SessionGetter _createSessionGetter(OAuthClientOptions options) {
335 return SessionGetter(
336 sessionStore: options.sessionStore,
337 serverFactory: _createServerFactory(options),
338 runtime: runtime_lib.Runtime(options.runtimeImplementation),
339 );
340 }
341
342 /// Fetches client metadata from a discoverable client ID URL.
343 ///
344 /// This is a static helper method for fetching metadata before
345 /// constructing the OAuthClient.
346 ///
347 /// See: https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
348 static Future<Map<String, dynamic>> fetchMetadata(
349 OAuthClientFetchMetadataOptions options,
350 ) async {
351 final dio = options.dio ?? Dio();
352 final clientId = options.clientId;
353
354 try {
355 final response = await dio.getUri<Map<String, dynamic>>(
356 Uri.parse(clientId),
357 options: Options(
358 followRedirects: false,
359 validateStatus: (status) => status == 200,
360 responseType: ResponseType.json,
361 ),
362 cancelToken: options.cancelToken,
363 );
364
365 // Validate content type
366 final contentType = response.headers.value('content-type');
367 final mime = contentType?.split(';')[0].trim();
368 if (mime != 'application/json') {
369 throw FormatException('Invalid client metadata content type: $mime');
370 }
371
372 final data = response.data;
373 if (data == null) {
374 throw FormatException('Empty client metadata response');
375 }
376
377 return data;
378 } catch (e) {
379 if (e is DioException) {
380 throw Exception('Failed to fetch client metadata: ${e.message}');
381 }
382 rethrow;
383 }
384 }
385
386 /// Exposes the identity resolver for convenience.
387 IdentityResolver get identityResolver => oauthResolver.identityResolver;
388
389 /// Returns the public JWKS for this client (for confidential clients).
390 ///
391 /// This is the JWKS that should be published at the client's jwks_uri
392 /// or included in the client metadata.
393 Map<String, dynamic> get jwks {
394 if (keyset == null) {
395 return {'keys': <Map<String, dynamic>>[]};
396 }
397 return keyset!.toJSON();
398 }
399
400 /// Initiates an OAuth authorization flow.
401 ///
402 /// This method:
403 /// 1. Resolves the input (handle, DID, or URL) to OAuth metadata
404 /// 2. Generates PKCE parameters
405 /// 3. Generates DPoP key
406 /// 4. Negotiates client authentication method
407 /// 5. Stores internal state
408 /// 6. Uses PAR (Pushed Authorization Request) if supported
409 /// 7. Returns the authorization URL to open in a browser
410 ///
411 /// The [input] can be:
412 /// - An atProto handle (e.g., "alice.bsky.social")
413 /// - A DID (e.g., "did:plc:...")
414 /// - A PDS URL (e.g., "https://pds.example.com")
415 /// - An authorization server URL (e.g., "https://auth.example.com")
416 ///
417 /// The [options] can specify:
418 /// - redirectUri: Override the default redirect URI
419 /// - state: Application state to preserve
420 /// - scope: Override the default scope
421 /// - Other OIDC parameters (prompt, display, etc.)
422 ///
423 /// Throws [FormatException] if parameters are invalid.
424 /// Throws [OAuthResolverError] if resolution fails.
425 Future<Uri> authorize(
426 String input, {
427 AuthorizeOptions? options,
428 CancelToken? cancelToken,
429 }) async {
430 final opts = options ?? const AuthorizeOptions();
431
432 // Validate redirect URI
433 final redirectUri = opts.redirectUri ?? clientMetadata.redirectUris.first;
434 if (!clientMetadata.redirectUris.contains(redirectUri)) {
435 throw FormatException('Invalid redirect_uri: $redirectUri');
436 }
437
438 // Resolve input to OAuth metadata
439 final resolved = await oauthResolver.resolve(
440 input,
441 auth_resolver.GetCachedOptions(cancelToken: cancelToken),
442 );
443
444 final metadata = resolved.metadata;
445
446 // Generate PKCE
447 final pkce = await runtime.generatePKCE();
448
449 // Generate DPoP key
450 final dpopAlgs = metadata['dpop_signing_alg_values_supported'] as List?;
451 final dpopKey = await runtime.generateKey(
452 dpopAlgs?.cast<String>() ?? [fallbackAlg],
453 );
454
455 // Compute DPoP JWK thumbprint for authorization requests.
456 // Required by RFC 9449 §7 to bind the subsequently issued code to this key.
457 final bareJwk = dpopKey.bareJwk;
458 if (bareJwk == null) {
459 throw StateError('DPoP key must provide a public JWK representation');
460 }
461 final generatedDpopJkt = await runtime.calculateJwkThumbprint(bareJwk);
462
463 // Negotiate client authentication method
464 final authMethod = negotiateClientAuthMethod(
465 metadata,
466 clientMetadata,
467 keyset,
468 );
469
470 // Generate state parameter
471 final state = await runtime.generateNonce();
472
473 // Store internal state for callback validation
474 // IMPORTANT: Store the FULL private JWK, not just bareJwk (public key only)
475 // We need the private key to restore the DPoP key during token exchange
476 final dpopKeyJwk = (dpopKey as dynamic).privateJwk ?? dpopKey.bareJwk ?? {};
477
478 if (kDebugMode) {
479 print('🔑 Storing DPoP key:');
480 print(' Has privateJwk: ${(dpopKey as dynamic).privateJwk != null}');
481 print(' Has bareJwk: ${dpopKey.bareJwk != null}');
482 print(' Stored JWK has "d" (private): ${dpopKeyJwk.containsKey('d')}');
483 print(' Stored JWK keys: ${dpopKeyJwk.keys.toList()}');
484 }
485
486 await _stateStore.set(
487 state,
488 InternalStateData(
489 iss: metadata['issuer'] as String,
490 dpopKey: dpopKeyJwk,
491 authMethod: authMethod.toJson(),
492 verifier: pkce['verifier'] as String,
493 redirectUri: redirectUri, // Store the exact redirectUri used in PAR
494 appState: opts.state,
495 ),
496 );
497
498 // Build authorization request parameters
499 final parameters = <String, String>{
500 'client_id': clientMetadata.clientId!,
501 'redirect_uri': redirectUri,
502 'code_challenge': pkce['challenge'] as String,
503 'code_challenge_method': pkce['method'] as String,
504 'state': state,
505 'response_mode': responseMode.value,
506 'response_type': 'code',
507 'scope': opts.scope ?? clientMetadata.scope ?? 'atproto',
508 'dpop_jkt': opts.dpopJkt ?? generatedDpopJkt,
509 };
510
511 // Add login hint if we have identity info
512 if (resolved.identityInfo != null) {
513 final handle = resolved.identityInfo!.handle;
514 final did = resolved.identityInfo!.did;
515 if (handle != handleInvalid) {
516 parameters['login_hint'] = handle;
517 } else {
518 parameters['login_hint'] = did;
519 }
520 }
521
522 // Add optional parameters from options
523 if (opts.nonce != null) parameters['nonce'] = opts.nonce!;
524 if (opts.display != null) parameters['display'] = opts.display!;
525 if (opts.prompt != null) parameters['prompt'] = opts.prompt!;
526 if (opts.maxAge != null) parameters['max_age'] = opts.maxAge.toString();
527 if (opts.uiLocales != null) parameters['ui_locales'] = opts.uiLocales!;
528 if (opts.idTokenHint != null) {
529 parameters['id_token_hint'] = opts.idTokenHint!;
530 }
531
532 // Build authorization URL
533 final authorizationUrl = Uri.parse(
534 metadata['authorization_endpoint'] as String,
535 );
536
537 // Validate authorization endpoint protocol
538 if (authorizationUrl.scheme != 'https' &&
539 authorizationUrl.scheme != 'http') {
540 throw FormatException(
541 'Invalid authorization endpoint protocol: ${authorizationUrl.scheme}',
542 );
543 }
544
545 // Use PAR (Pushed Authorization Request) if supported
546 final parEndpoint =
547 metadata['pushed_authorization_request_endpoint'] as String?;
548 final requiresPar =
549 metadata['require_pushed_authorization_requests'] as bool? ?? false;
550
551 if (parEndpoint != null) {
552 // Server supports PAR, use it
553 final server = await serverFactory.fromMetadata(
554 metadata,
555 authMethod,
556 dpopKey,
557 );
558
559 final parResponse = await server.request(
560 'pushed_authorization_request',
561 parameters,
562 );
563
564 final requestUri = parResponse['request_uri'] as String;
565
566 // Return simplified URL with just request_uri
567 return authorizationUrl.replace(
568 queryParameters: {
569 'client_id': clientMetadata.clientId!,
570 'request_uri': requestUri,
571 },
572 );
573 } else if (requiresPar) {
574 throw Exception(
575 'Server requires pushed authorization requests (PAR) but no PAR endpoint is available',
576 );
577 } else {
578 // No PAR support, use direct authorization request
579 final fullUrl = authorizationUrl.replace(queryParameters: parameters);
580
581 // Check URL length (2048 byte limit for some browsers)
582 final urlLength = fullUrl.toString().length;
583 if (urlLength >= 2048) {
584 throw Exception('Login URL too long ($urlLength bytes)');
585 }
586
587 return fullUrl;
588 }
589 }
590
591 /// Handles the OAuth callback after user authorization.
592 ///
593 /// This method:
594 /// 1. Validates the state parameter
595 /// 2. Retrieves stored internal state
596 /// 3. Checks for error responses
597 /// 4. Validates issuer (if provided)
598 /// 5. Exchanges authorization code for tokens
599 /// 6. Creates and stores session
600 /// 7. Cleans up state
601 ///
602 /// The [params] should be the query parameters from the callback URL.
603 ///
604 /// The [options] can specify:
605 /// - redirectUri: Must match the one used in authorize()
606 ///
607 /// Returns a [CallbackResult] with the session and application state.
608 ///
609 /// Throws [OAuthCallbackError] if the callback contains errors or is invalid.
610 Future<CallbackResult> callback(
611 Map<String, String> params, {
612 CallbackOptions? options,
613 CancelToken? cancelToken,
614 }) async {
615 final opts = options ?? const CallbackOptions();
616
617 // Check for JARM (not supported)
618 final responseJwt = params['response'];
619 if (responseJwt != null) {
620 throw OAuthCallbackError(params, message: 'JARM not supported');
621 }
622
623 // Extract parameters
624 final issuerParam = params['iss'];
625 final stateParam = params['state'];
626 final errorParam = params['error'];
627 final codeParam = params['code'];
628
629 // Validate state parameter
630 if (stateParam == null) {
631 throw OAuthCallbackError(params, message: 'Missing "state" parameter');
632 }
633
634 // Retrieve internal state
635 final stateData = await _stateStore.get(stateParam);
636 if (stateData == null) {
637 throw OAuthCallbackError(
638 params,
639 message: 'Unknown authorization session "$stateParam"',
640 );
641 }
642
643 // Prevent replay attacks - delete state immediately
644 await _stateStore.del(stateParam);
645
646 try {
647 // Check for error response
648 if (errorParam != null) {
649 throw OAuthCallbackError(params, state: stateData.appState);
650 }
651
652 // Validate authorization code
653 if (codeParam == null) {
654 throw OAuthCallbackError(
655 params,
656 message: 'Missing "code" query param',
657 state: stateData.appState,
658 );
659 }
660
661 // Create OAuth server agent
662 // TODO: Implement proper Key reconstruction from stored bareJwk
663 // For now, we regenerate the key with the same algorithms
664 // This works but is not ideal - we should restore the exact same key
665 final authMethod =
666 stateData.authMethod != null
667 ? ClientAuthMethod.fromJson(
668 stateData.authMethod as Map<String, dynamic>,
669 )
670 : const ClientAuthMethod.none(); // Legacy fallback
671
672 // Restore dpopKey from stored private JWK
673 // Import FlutterKey to access fromJwk factory
674 if (kDebugMode) {
675 print('🔓 Restoring DPoP key:');
676 print(
677 ' Stored JWK has "d" (private): ${(stateData.dpopKey as Map).containsKey('d')}',
678 );
679 print(
680 ' Stored JWK keys: ${(stateData.dpopKey as Map).keys.toList()}',
681 );
682 }
683
684 final dpopKey = FlutterKey.fromJwk(
685 stateData.dpopKey as Map<String, dynamic>,
686 );
687
688 if (kDebugMode) {
689 print(' ✅ DPoP key restored successfully');
690 }
691
692 final server = await serverFactory.fromIssuer(
693 stateData.iss,
694 authMethod,
695 dpopKey,
696 auth_resolver.GetCachedOptions(cancelToken: cancelToken),
697 );
698
699 // Validate issuer if provided
700 if (issuerParam != null) {
701 if (server.issuer.isEmpty) {
702 throw OAuthCallbackError(
703 params,
704 message: 'Issuer not found in metadata',
705 state: stateData.appState,
706 );
707 }
708 if (server.issuer != issuerParam) {
709 throw OAuthCallbackError(
710 params,
711 message: 'Issuer mismatch',
712 state: stateData.appState,
713 );
714 }
715 } else if (server
716 .serverMetadata['authorization_response_iss_parameter_supported'] ==
717 true) {
718 throw OAuthCallbackError(
719 params,
720 message: 'iss missing from the response',
721 state: stateData.appState,
722 );
723 }
724
725 // Exchange authorization code for tokens
726 // CRITICAL: Use the EXACT same redirectUri that was used during authorization
727 // The redirectUri in the token exchange MUST match the one in the PAR request
728 final redirectUriForExchange =
729 stateData.redirectUri ??
730 opts.redirectUri ??
731 clientMetadata.redirectUris.first;
732
733 if (kDebugMode) {
734 print('🔄 Exchanging authorization code for tokens:');
735 print(' Code: ${codeParam.substring(0, 20)}...');
736 print(
737 ' Code verifier: ${stateData.verifier?.substring(0, 20) ?? "none"}...',
738 );
739 print(' Redirect URI: $redirectUriForExchange');
740 print(
741 ' Redirect URI source: ${stateData.redirectUri != null ? "stored" : "fallback"}',
742 );
743 print(' Issuer: ${server.issuer}');
744 }
745
746 final tokenSet = await server.exchangeCode(
747 codeParam,
748 codeVerifier: stateData.verifier,
749 redirectUri: redirectUriForExchange,
750 );
751
752 try {
753 if (kDebugMode) {
754 print('💾 Storing session for: ${tokenSet.sub}');
755 }
756
757 // Store session
758 await _sessionGetter.setStored(
759 tokenSet.sub,
760 Session(
761 dpopKey: stateData.dpopKey,
762 authMethod: authMethod.toJson(),
763 tokenSet: tokenSet,
764 ),
765 );
766
767 if (kDebugMode) {
768 print('✅ Session stored successfully');
769 print('🎯 Creating session wrapper...');
770 }
771
772 // Create session wrapper
773 final session = _createSession(server, tokenSet.sub);
774
775 if (kDebugMode) {
776 print('✅ Session wrapper created');
777 print('🎉 OAuth callback complete!');
778 }
779
780 return CallbackResult(session: session, state: stateData.appState);
781 } catch (err, stackTrace) {
782 // If session storage failed, revoke the tokens
783 if (kDebugMode) {
784 print('❌ Session storage/creation failed:');
785 print(' Error: $err');
786 print(' Stack trace: $stackTrace');
787 }
788 await server.revoke(tokenSet.refreshToken ?? tokenSet.accessToken);
789 rethrow;
790 }
791 } catch (err, stackTrace) {
792 // Ensure appState is available in error
793 if (kDebugMode) {
794 print('❌ Callback error (outer catch):');
795 print(' Error type: ${err.runtimeType}');
796 print(' Error: $err');
797 print(' Stack trace: $stackTrace');
798 }
799 throw OAuthCallbackError.from(err, params, stateData.appState);
800 }
801 }
802
803 /// Restores a stored session.
804 ///
805 /// This method:
806 /// 1. Retrieves session from storage
807 /// 2. Checks if tokens are expired
808 /// 3. Automatically refreshes tokens if needed (based on [refresh])
809 /// 4. Creates OAuthServerAgent
810 /// 5. Returns live OAuthSession
811 ///
812 /// The [sub] is the user's DID.
813 ///
814 /// The [refresh] parameter controls token refresh:
815 /// - `true`: Force refresh even if not expired
816 /// - `false`: Use cached tokens even if expired
817 /// - `'auto'`: Refresh only if expired (default)
818 ///
819 /// Throws [Exception] if session doesn't exist.
820 /// Throws [TokenRefreshError] if refresh fails.
821 /// Throws [AuthMethodUnsatisfiableError] if auth method can't be satisfied.
822 Future<OAuthSession> restore(
823 String sub, {
824 dynamic refresh = 'auto',
825 CancelToken? cancelToken,
826 }) async {
827 // Validate DID format
828 assertAtprotoDid(sub);
829
830 // Get session (automatically refreshes if needed based on refresh param)
831 final session = await _sessionGetter.getSession(sub, refresh);
832
833 try {
834 // Determine auth method (with legacy fallback)
835 final authMethod =
836 session.authMethod != null
837 ? ClientAuthMethod.fromJson(
838 session.authMethod as Map<String, dynamic>,
839 )
840 : const ClientAuthMethod.none(); // Legacy
841
842 // TODO: Implement proper Key reconstruction from stored bareJwk
843 // For now, we regenerate the key
844 final dpopKey = await runtime.generateKey([fallbackAlg]);
845
846 // Create server agent
847 final server = await serverFactory.fromIssuer(
848 session.tokenSet.iss,
849 authMethod,
850 dpopKey,
851 auth_resolver.GetCachedOptions(
852 noCache: refresh == true,
853 allowStale: refresh == false,
854 cancelToken: cancelToken,
855 ),
856 );
857
858 return _createSession(server, sub);
859 } catch (err) {
860 // If auth method can't be satisfied, delete the session
861 if (err is AuthMethodUnsatisfiableError) {
862 await _sessionGetter.delStored(sub, err);
863 }
864 rethrow;
865 }
866 }
867
868 /// Revokes a session.
869 ///
870 /// This method:
871 /// 1. Retrieves session from storage
872 /// 2. Calls token revocation endpoint
873 /// 3. Deletes session from storage
874 ///
875 /// The [sub] is the user's DID.
876 ///
877 /// Token revocation is best-effort - even if the revocation request fails,
878 /// the local session is still deleted.
879 Future<void> revoke(String sub, {CancelToken? cancelToken}) async {
880 // Validate DID format
881 assertAtprotoDid(sub);
882
883 // Get session (allow stale tokens for revocation)
884 final session = await _sessionGetter.get(
885 sub,
886 const GetCachedOptions(allowStale: true),
887 );
888
889 // Try to revoke tokens on the server
890 try {
891 final authMethod =
892 session.authMethod != null
893 ? ClientAuthMethod.fromJson(
894 session.authMethod as Map<String, dynamic>,
895 )
896 : const ClientAuthMethod.none(); // Legacy
897
898 // TODO: Implement proper Key reconstruction from stored bareJwk
899 // For now, we regenerate the key
900 final dpopKey = await runtime.generateKey([fallbackAlg]);
901
902 final server = await serverFactory.fromIssuer(
903 session.tokenSet.iss,
904 authMethod,
905 dpopKey,
906 auth_resolver.GetCachedOptions(cancelToken: cancelToken),
907 );
908
909 await server.revoke(session.tokenSet.accessToken);
910 } finally {
911 // Always delete local session, even if revocation failed
912 await _sessionGetter.delStored(sub, TokenRevokedError(sub));
913 }
914 }
915
916 /// Creates an OAuthSession wrapper.
917 ///
918 /// Internal helper for creating session objects from server agents.
919 OAuthSession _createSession(OAuthServerAgent server, String sub) {
920 // Create a wrapper that implements SessionGetterInterface
921 final sessionGetterWrapper = _SessionGetterWrapper(_sessionGetter);
922
923 return OAuthSession(
924 server: server,
925 sub: sub,
926 sessionGetter: sessionGetterWrapper,
927 );
928 }
929
930 /// Disposes of resources used by this client.
931 ///
932 /// Call this when the client is no longer needed to prevent memory leaks.
933 @override
934 void dispose() {
935 _updatedController.close();
936 _deletedController.close();
937 _sessionGetter.dispose();
938 super.dispose();
939 }
940}
941
942/// Wrapper to adapt SessionGetter to SessionGetterInterface
943class _SessionGetterWrapper implements SessionGetterInterface {
944 final SessionGetter _getter;
945
946 _SessionGetterWrapper(this._getter);
947
948 @override
949 Future<Session> get(String sub, {bool? noCache, bool? allowStale}) async {
950 return _getter.get(
951 sub,
952 GetCachedOptions(
953 noCache: noCache ?? false,
954 allowStale: allowStale ?? false,
955 ),
956 );
957 }
958
959 @override
960 Future<void> delStored(String sub, [Object? cause]) {
961 return _getter.delStored(sub, cause);
962 }
963}