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 for authorization flow');
480 }
481
482 await _stateStore.set(
483 state,
484 InternalStateData(
485 iss: metadata['issuer'] as String,
486 dpopKey: dpopKeyJwk,
487 authMethod: authMethod.toJson(),
488 verifier: pkce['verifier'] as String,
489 redirectUri: redirectUri, // Store the exact redirectUri used in PAR
490 appState: opts.state,
491 ),
492 );
493
494 // Build authorization request parameters
495 final parameters = <String, String>{
496 'client_id': clientMetadata.clientId!,
497 'redirect_uri': redirectUri,
498 'code_challenge': pkce['challenge'] as String,
499 'code_challenge_method': pkce['method'] as String,
500 'state': state,
501 'response_mode': responseMode.value,
502 'response_type': 'code',
503 'scope': opts.scope ?? clientMetadata.scope ?? 'atproto',
504 'dpop_jkt': opts.dpopJkt ?? generatedDpopJkt,
505 };
506
507 // Add login hint if we have identity info
508 if (resolved.identityInfo != null) {
509 final handle = resolved.identityInfo!.handle;
510 final did = resolved.identityInfo!.did;
511 if (handle != handleInvalid) {
512 parameters['login_hint'] = handle;
513 } else {
514 parameters['login_hint'] = did;
515 }
516 }
517
518 // Add optional parameters from options
519 if (opts.nonce != null) parameters['nonce'] = opts.nonce!;
520 if (opts.display != null) parameters['display'] = opts.display!;
521 if (opts.prompt != null) parameters['prompt'] = opts.prompt!;
522 if (opts.maxAge != null) parameters['max_age'] = opts.maxAge.toString();
523 if (opts.uiLocales != null) parameters['ui_locales'] = opts.uiLocales!;
524 if (opts.idTokenHint != null) {
525 parameters['id_token_hint'] = opts.idTokenHint!;
526 }
527
528 // Build authorization URL
529 final authorizationUrl = Uri.parse(
530 metadata['authorization_endpoint'] as String,
531 );
532
533 // Validate authorization endpoint protocol
534 if (authorizationUrl.scheme != 'https' &&
535 authorizationUrl.scheme != 'http') {
536 throw FormatException(
537 'Invalid authorization endpoint protocol: ${authorizationUrl.scheme}',
538 );
539 }
540
541 // Use PAR (Pushed Authorization Request) if supported
542 final parEndpoint =
543 metadata['pushed_authorization_request_endpoint'] as String?;
544 final requiresPar =
545 metadata['require_pushed_authorization_requests'] as bool? ?? false;
546
547 if (parEndpoint != null) {
548 // Server supports PAR, use it
549 final server = await serverFactory.fromMetadata(
550 metadata,
551 authMethod,
552 dpopKey,
553 );
554
555 final parResponse = await server.request(
556 'pushed_authorization_request',
557 parameters,
558 );
559
560 final requestUri = parResponse['request_uri'] as String;
561
562 // Return simplified URL with just request_uri
563 return authorizationUrl.replace(
564 queryParameters: {
565 'client_id': clientMetadata.clientId!,
566 'request_uri': requestUri,
567 },
568 );
569 } else if (requiresPar) {
570 throw Exception(
571 'Server requires pushed authorization requests (PAR) but no PAR endpoint is available',
572 );
573 } else {
574 // No PAR support, use direct authorization request
575 final fullUrl = authorizationUrl.replace(queryParameters: parameters);
576
577 // Check URL length (2048 byte limit for some browsers)
578 final urlLength = fullUrl.toString().length;
579 if (urlLength >= 2048) {
580 throw Exception('Login URL too long ($urlLength bytes)');
581 }
582
583 return fullUrl;
584 }
585 }
586
587 /// Handles the OAuth callback after user authorization.
588 ///
589 /// This method:
590 /// 1. Validates the state parameter
591 /// 2. Retrieves stored internal state
592 /// 3. Checks for error responses
593 /// 4. Validates issuer (if provided)
594 /// 5. Exchanges authorization code for tokens
595 /// 6. Creates and stores session
596 /// 7. Cleans up state
597 ///
598 /// The [params] should be the query parameters from the callback URL.
599 ///
600 /// The [options] can specify:
601 /// - redirectUri: Must match the one used in authorize()
602 ///
603 /// Returns a [CallbackResult] with the session and application state.
604 ///
605 /// Throws [OAuthCallbackError] if the callback contains errors or is invalid.
606 Future<CallbackResult> callback(
607 Map<String, String> params, {
608 CallbackOptions? options,
609 CancelToken? cancelToken,
610 }) async {
611 final opts = options ?? const CallbackOptions();
612
613 // Check for JARM (not supported)
614 final responseJwt = params['response'];
615 if (responseJwt != null) {
616 throw OAuthCallbackError(params, message: 'JARM not supported');
617 }
618
619 // Extract parameters
620 final issuerParam = params['iss'];
621 final stateParam = params['state'];
622 final errorParam = params['error'];
623 final codeParam = params['code'];
624
625 // Validate state parameter
626 if (stateParam == null) {
627 throw OAuthCallbackError(params, message: 'Missing "state" parameter');
628 }
629
630 // Retrieve internal state
631 final stateData = await _stateStore.get(stateParam);
632 if (stateData == null) {
633 throw OAuthCallbackError(
634 params,
635 message: 'Unknown authorization session "$stateParam"',
636 );
637 }
638
639 // Prevent replay attacks - delete state immediately
640 await _stateStore.del(stateParam);
641
642 try {
643 // Check for error response
644 if (errorParam != null) {
645 throw OAuthCallbackError(params, state: stateData.appState);
646 }
647
648 // Validate authorization code
649 if (codeParam == null) {
650 throw OAuthCallbackError(
651 params,
652 message: 'Missing "code" query param',
653 state: stateData.appState,
654 );
655 }
656
657 // Create OAuth server agent
658 final authMethod =
659 stateData.authMethod != null
660 ? ClientAuthMethod.fromJson(
661 stateData.authMethod as Map<String, dynamic>,
662 )
663 : const ClientAuthMethod.none(); // Legacy fallback
664
665 // Restore dpopKey from stored private JWK
666 // Restore DPoP key with error handling for corrupted JWK data
667 final FlutterKey dpopKey;
668 try {
669 dpopKey = FlutterKey.fromJwk(stateData.dpopKey as Map<String, dynamic>);
670 if (kDebugMode) {
671 print('🔓 DPoP key restored successfully for token exchange');
672 }
673 } catch (e) {
674 throw Exception(
675 'Failed to restore DPoP key from stored state: $e. '
676 'The stored key may be corrupted. Please try authenticating again.',
677 );
678 }
679
680 final server = await serverFactory.fromIssuer(
681 stateData.iss,
682 authMethod,
683 dpopKey,
684 auth_resolver.GetCachedOptions(cancelToken: cancelToken),
685 );
686
687 // Validate issuer if provided
688 if (issuerParam != null) {
689 if (server.issuer.isEmpty) {
690 throw OAuthCallbackError(
691 params,
692 message: 'Issuer not found in metadata',
693 state: stateData.appState,
694 );
695 }
696 if (server.issuer != issuerParam) {
697 throw OAuthCallbackError(
698 params,
699 message: 'Issuer mismatch',
700 state: stateData.appState,
701 );
702 }
703 } else if (server
704 .serverMetadata['authorization_response_iss_parameter_supported'] ==
705 true) {
706 throw OAuthCallbackError(
707 params,
708 message: 'iss missing from the response',
709 state: stateData.appState,
710 );
711 }
712
713 // Exchange authorization code for tokens
714 // CRITICAL: Use the EXACT same redirectUri that was used during authorization
715 // The redirectUri in the token exchange MUST match the one in the PAR request
716 final redirectUriForExchange =
717 stateData.redirectUri ??
718 opts.redirectUri ??
719 clientMetadata.redirectUris.first;
720
721 if (kDebugMode) {
722 print('🔄 Exchanging authorization code for tokens:');
723 print(' Code: ${codeParam.substring(0, 20)}...');
724 print(
725 ' Code verifier: ${stateData.verifier?.substring(0, 20) ?? "none"}...',
726 );
727 print(' Redirect URI: $redirectUriForExchange');
728 print(
729 ' Redirect URI source: ${stateData.redirectUri != null ? "stored" : "fallback"}',
730 );
731 print(' Issuer: ${server.issuer}');
732 }
733
734 final tokenSet = await server.exchangeCode(
735 codeParam,
736 codeVerifier: stateData.verifier,
737 redirectUri: redirectUriForExchange,
738 );
739
740 try {
741 if (kDebugMode) {
742 print('💾 Storing session for: ${tokenSet.sub}');
743 }
744
745 // Store session
746 await _sessionGetter.setStored(
747 tokenSet.sub,
748 Session(
749 dpopKey: stateData.dpopKey,
750 authMethod: authMethod.toJson(),
751 tokenSet: tokenSet,
752 ),
753 );
754
755 if (kDebugMode) {
756 print('✅ Session stored successfully');
757 print('🎯 Creating session wrapper...');
758 }
759
760 // Create session wrapper
761 final session = _createSession(server, tokenSet.sub);
762
763 if (kDebugMode) {
764 print('✅ Session wrapper created');
765 print('🎉 OAuth callback complete!');
766 }
767
768 return CallbackResult(session: session, state: stateData.appState);
769 } catch (err, stackTrace) {
770 // If session storage failed, revoke the tokens
771 if (kDebugMode) {
772 print('❌ Session storage/creation failed:');
773 print(' Error: $err');
774 print(' Stack trace: $stackTrace');
775 }
776 await server.revoke(tokenSet.refreshToken ?? tokenSet.accessToken);
777 rethrow;
778 }
779 } catch (err, stackTrace) {
780 // Ensure appState is available in error
781 if (kDebugMode) {
782 print('❌ Callback error (outer catch):');
783 print(' Error type: ${err.runtimeType}');
784 print(' Error: $err');
785 print(' Stack trace: $stackTrace');
786 }
787 throw OAuthCallbackError.from(err, params, stateData.appState);
788 }
789 }
790
791 /// Restores a stored session.
792 ///
793 /// This method:
794 /// 1. Retrieves session from storage
795 /// 2. Checks if tokens are expired
796 /// 3. Automatically refreshes tokens if needed (based on [refresh])
797 /// 4. Creates OAuthServerAgent
798 /// 5. Returns live OAuthSession
799 ///
800 /// The [sub] is the user's DID.
801 ///
802 /// The [refresh] parameter controls token refresh:
803 /// - `true`: Force refresh even if not expired
804 /// - `false`: Use cached tokens even if expired
805 /// - `'auto'`: Refresh only if expired (default)
806 ///
807 /// Throws [Exception] if session doesn't exist.
808 /// Throws [TokenRefreshError] if refresh fails.
809 /// Throws [AuthMethodUnsatisfiableError] if auth method can't be satisfied.
810 Future<OAuthSession> restore(
811 String sub, {
812 dynamic refresh = 'auto',
813 CancelToken? cancelToken,
814 }) async {
815 // Validate DID format
816 assertAtprotoDid(sub);
817
818 // Get session (automatically refreshes if needed based on refresh param)
819 final session = await _sessionGetter.getSession(sub, refresh);
820
821 try {
822 // Determine auth method (with legacy fallback)
823 final authMethod =
824 session.authMethod != null
825 ? ClientAuthMethod.fromJson(
826 session.authMethod as Map<String, dynamic>,
827 )
828 : const ClientAuthMethod.none(); // Legacy
829
830 // Restore dpopKey from stored private JWK with error handling
831 // CRITICAL FIX: Use the stored key instead of generating a new one
832 // This ensures DPoP proofs match the token binding
833 final FlutterKey dpopKey;
834 try {
835 dpopKey = FlutterKey.fromJwk(session.dpopKey as Map<String, dynamic>);
836 } catch (e) {
837 // If key is corrupted, delete the session and force re-authentication
838 await _sessionGetter.delStored(
839 sub,
840 Exception('Corrupted DPoP key in stored session: $e'),
841 );
842 throw Exception(
843 'Failed to restore DPoP key for session. The stored key is corrupted. '
844 'Please authenticate again.',
845 );
846 }
847
848 // Create server agent
849 final server = await serverFactory.fromIssuer(
850 session.tokenSet.iss,
851 authMethod,
852 dpopKey,
853 auth_resolver.GetCachedOptions(
854 noCache: refresh == true,
855 allowStale: refresh == false,
856 cancelToken: cancelToken,
857 ),
858 );
859
860 return _createSession(server, sub);
861 } catch (err) {
862 // If auth method can't be satisfied, delete the session
863 if (err is AuthMethodUnsatisfiableError) {
864 await _sessionGetter.delStored(sub, err);
865 }
866 rethrow;
867 }
868 }
869
870 /// Revokes a session.
871 ///
872 /// This method:
873 /// 1. Retrieves session from storage
874 /// 2. Calls token revocation endpoint
875 /// 3. Deletes session from storage
876 ///
877 /// The [sub] is the user's DID.
878 ///
879 /// Token revocation is best-effort - even if the revocation request fails,
880 /// the local session is still deleted.
881 Future<void> revoke(String sub, {CancelToken? cancelToken}) async {
882 // Validate DID format
883 assertAtprotoDid(sub);
884
885 // Get session (allow stale tokens for revocation)
886 final session = await _sessionGetter.get(
887 sub,
888 const GetCachedOptions(allowStale: true),
889 );
890
891 // Try to revoke tokens on the server
892 try {
893 final authMethod =
894 session.authMethod != null
895 ? ClientAuthMethod.fromJson(
896 session.authMethod as Map<String, dynamic>,
897 )
898 : const ClientAuthMethod.none(); // Legacy
899
900 // Restore dpopKey from stored private JWK with error handling
901 // CRITICAL FIX: Use the stored key instead of generating a new one
902 // This ensures DPoP proofs match the token binding
903 final FlutterKey dpopKey;
904 try {
905 dpopKey = FlutterKey.fromJwk(session.dpopKey as Map<String, dynamic>);
906 } catch (e) {
907 // If key is corrupted, skip server-side revocation
908 // The finally block will still delete the local session
909 if (kDebugMode) {
910 print('⚠️ Cannot revoke on server: corrupted DPoP key ($e)');
911 print(' Local session will still be deleted');
912 }
913 return;
914 }
915
916 final server = await serverFactory.fromIssuer(
917 session.tokenSet.iss,
918 authMethod,
919 dpopKey,
920 auth_resolver.GetCachedOptions(cancelToken: cancelToken),
921 );
922
923 await server.revoke(session.tokenSet.accessToken);
924 } finally {
925 // Always delete local session, even if revocation failed
926 await _sessionGetter.delStored(sub, TokenRevokedError(sub));
927 }
928 }
929
930 /// Creates an OAuthSession wrapper.
931 ///
932 /// Internal helper for creating session objects from server agents.
933 OAuthSession _createSession(OAuthServerAgent server, String sub) {
934 // Create a wrapper that implements SessionGetterInterface
935 final sessionGetterWrapper = _SessionGetterWrapper(_sessionGetter);
936
937 return OAuthSession(
938 server: server,
939 sub: sub,
940 sessionGetter: sessionGetterWrapper,
941 );
942 }
943
944 /// Disposes of resources used by this client.
945 ///
946 /// Call this when the client is no longer needed to prevent memory leaks.
947 @override
948 void dispose() {
949 _updatedController.close();
950 _deletedController.close();
951 _sessionGetter.dispose();
952 super.dispose();
953 }
954}
955
956/// Wrapper to adapt SessionGetter to SessionGetterInterface
957class _SessionGetterWrapper implements SessionGetterInterface {
958 final SessionGetter _getter;
959
960 _SessionGetterWrapper(this._getter);
961
962 @override
963 Future<Session> get(String sub, {bool? noCache, bool? allowStale}) async {
964 return _getter.get(
965 sub,
966 GetCachedOptions(
967 noCache: noCache ?? false,
968 allowStale: allowStale ?? false,
969 ),
970 );
971 }
972
973 @override
974 Future<void> delStored(String sub, [Object? cause]) {
975 return _getter.delStored(sub, cause);
976 }
977}