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(
670 stateData.dpopKey as Map<String, dynamic>,
671 );
672 if (kDebugMode) {
673 print('🔓 DPoP key restored successfully for token exchange');
674 }
675 } catch (e) {
676 throw Exception(
677 'Failed to restore DPoP key from stored state: $e. '
678 'The stored key may be corrupted. Please try authenticating again.',
679 );
680 }
681
682 final server = await serverFactory.fromIssuer(
683 stateData.iss,
684 authMethod,
685 dpopKey,
686 auth_resolver.GetCachedOptions(cancelToken: cancelToken),
687 );
688
689 // Validate issuer if provided
690 if (issuerParam != null) {
691 if (server.issuer.isEmpty) {
692 throw OAuthCallbackError(
693 params,
694 message: 'Issuer not found in metadata',
695 state: stateData.appState,
696 );
697 }
698 if (server.issuer != issuerParam) {
699 throw OAuthCallbackError(
700 params,
701 message: 'Issuer mismatch',
702 state: stateData.appState,
703 );
704 }
705 } else if (server
706 .serverMetadata['authorization_response_iss_parameter_supported'] ==
707 true) {
708 throw OAuthCallbackError(
709 params,
710 message: 'iss missing from the response',
711 state: stateData.appState,
712 );
713 }
714
715 // Exchange authorization code for tokens
716 // CRITICAL: Use the EXACT same redirectUri that was used during authorization
717 // The redirectUri in the token exchange MUST match the one in the PAR request
718 final redirectUriForExchange =
719 stateData.redirectUri ??
720 opts.redirectUri ??
721 clientMetadata.redirectUris.first;
722
723 if (kDebugMode) {
724 print('🔄 Exchanging authorization code for tokens:');
725 print(' Code: ${codeParam.substring(0, 20)}...');
726 print(
727 ' Code verifier: ${stateData.verifier?.substring(0, 20) ?? "none"}...',
728 );
729 print(' Redirect URI: $redirectUriForExchange');
730 print(
731 ' Redirect URI source: ${stateData.redirectUri != null ? "stored" : "fallback"}',
732 );
733 print(' Issuer: ${server.issuer}');
734 }
735
736 final tokenSet = await server.exchangeCode(
737 codeParam,
738 codeVerifier: stateData.verifier,
739 redirectUri: redirectUriForExchange,
740 );
741
742 try {
743 if (kDebugMode) {
744 print('💾 Storing session for: ${tokenSet.sub}');
745 }
746
747 // Store session
748 await _sessionGetter.setStored(
749 tokenSet.sub,
750 Session(
751 dpopKey: stateData.dpopKey,
752 authMethod: authMethod.toJson(),
753 tokenSet: tokenSet,
754 ),
755 );
756
757 if (kDebugMode) {
758 print('✅ Session stored successfully');
759 print('🎯 Creating session wrapper...');
760 }
761
762 // Create session wrapper
763 final session = _createSession(server, tokenSet.sub);
764
765 if (kDebugMode) {
766 print('✅ Session wrapper created');
767 print('🎉 OAuth callback complete!');
768 }
769
770 return CallbackResult(session: session, state: stateData.appState);
771 } catch (err, stackTrace) {
772 // If session storage failed, revoke the tokens
773 if (kDebugMode) {
774 print('❌ Session storage/creation failed:');
775 print(' Error: $err');
776 print(' Stack trace: $stackTrace');
777 }
778 await server.revoke(tokenSet.refreshToken ?? tokenSet.accessToken);
779 rethrow;
780 }
781 } catch (err, stackTrace) {
782 // Ensure appState is available in error
783 if (kDebugMode) {
784 print('❌ Callback error (outer catch):');
785 print(' Error type: ${err.runtimeType}');
786 print(' Error: $err');
787 print(' Stack trace: $stackTrace');
788 }
789 throw OAuthCallbackError.from(err, params, stateData.appState);
790 }
791 }
792
793 /// Restores a stored session.
794 ///
795 /// This method:
796 /// 1. Retrieves session from storage
797 /// 2. Checks if tokens are expired
798 /// 3. Automatically refreshes tokens if needed (based on [refresh])
799 /// 4. Creates OAuthServerAgent
800 /// 5. Returns live OAuthSession
801 ///
802 /// The [sub] is the user's DID.
803 ///
804 /// The [refresh] parameter controls token refresh:
805 /// - `true`: Force refresh even if not expired
806 /// - `false`: Use cached tokens even if expired
807 /// - `'auto'`: Refresh only if expired (default)
808 ///
809 /// Throws [Exception] if session doesn't exist.
810 /// Throws [TokenRefreshError] if refresh fails.
811 /// Throws [AuthMethodUnsatisfiableError] if auth method can't be satisfied.
812 Future<OAuthSession> restore(
813 String sub, {
814 dynamic refresh = 'auto',
815 CancelToken? cancelToken,
816 }) async {
817 // Validate DID format
818 assertAtprotoDid(sub);
819
820 // Get session (automatically refreshes if needed based on refresh param)
821 final session = await _sessionGetter.getSession(sub, refresh);
822
823 try {
824 // Determine auth method (with legacy fallback)
825 final authMethod =
826 session.authMethod != null
827 ? ClientAuthMethod.fromJson(
828 session.authMethod as Map<String, dynamic>,
829 )
830 : const ClientAuthMethod.none(); // Legacy
831
832 // Restore dpopKey from stored private JWK with error handling
833 // CRITICAL FIX: Use the stored key instead of generating a new one
834 // This ensures DPoP proofs match the token binding
835 final FlutterKey dpopKey;
836 try {
837 dpopKey = FlutterKey.fromJwk(
838 session.dpopKey as Map<String, dynamic>,
839 );
840 } catch (e) {
841 // If key is corrupted, delete the session and force re-authentication
842 await _sessionGetter.delStored(
843 sub,
844 Exception('Corrupted DPoP key in stored session: $e'),
845 );
846 throw Exception(
847 'Failed to restore DPoP key for session. The stored key is corrupted. '
848 'Please authenticate again.',
849 );
850 }
851
852 // Create server agent
853 final server = await serverFactory.fromIssuer(
854 session.tokenSet.iss,
855 authMethod,
856 dpopKey,
857 auth_resolver.GetCachedOptions(
858 noCache: refresh == true,
859 allowStale: refresh == false,
860 cancelToken: cancelToken,
861 ),
862 );
863
864 return _createSession(server, sub);
865 } catch (err) {
866 // If auth method can't be satisfied, delete the session
867 if (err is AuthMethodUnsatisfiableError) {
868 await _sessionGetter.delStored(sub, err);
869 }
870 rethrow;
871 }
872 }
873
874 /// Revokes a session.
875 ///
876 /// This method:
877 /// 1. Retrieves session from storage
878 /// 2. Calls token revocation endpoint
879 /// 3. Deletes session from storage
880 ///
881 /// The [sub] is the user's DID.
882 ///
883 /// Token revocation is best-effort - even if the revocation request fails,
884 /// the local session is still deleted.
885 Future<void> revoke(String sub, {CancelToken? cancelToken}) async {
886 // Validate DID format
887 assertAtprotoDid(sub);
888
889 // Get session (allow stale tokens for revocation)
890 final session = await _sessionGetter.get(
891 sub,
892 const GetCachedOptions(allowStale: true),
893 );
894
895 // Try to revoke tokens on the server
896 try {
897 final authMethod =
898 session.authMethod != null
899 ? ClientAuthMethod.fromJson(
900 session.authMethod as Map<String, dynamic>,
901 )
902 : const ClientAuthMethod.none(); // Legacy
903
904 // Restore dpopKey from stored private JWK with error handling
905 // CRITICAL FIX: Use the stored key instead of generating a new one
906 // This ensures DPoP proofs match the token binding
907 final FlutterKey dpopKey;
908 try {
909 dpopKey = FlutterKey.fromJwk(
910 session.dpopKey as Map<String, dynamic>,
911 );
912 } catch (e) {
913 // If key is corrupted, skip server-side revocation
914 // The finally block will still delete the local session
915 if (kDebugMode) {
916 print('⚠️ Cannot revoke on server: corrupted DPoP key ($e)');
917 print(' Local session will still be deleted');
918 }
919 return;
920 }
921
922 final server = await serverFactory.fromIssuer(
923 session.tokenSet.iss,
924 authMethod,
925 dpopKey,
926 auth_resolver.GetCachedOptions(cancelToken: cancelToken),
927 );
928
929 await server.revoke(session.tokenSet.accessToken);
930 } finally {
931 // Always delete local session, even if revocation failed
932 await _sessionGetter.delStored(sub, TokenRevokedError(sub));
933 }
934 }
935
936 /// Creates an OAuthSession wrapper.
937 ///
938 /// Internal helper for creating session objects from server agents.
939 OAuthSession _createSession(OAuthServerAgent server, String sub) {
940 // Create a wrapper that implements SessionGetterInterface
941 final sessionGetterWrapper = _SessionGetterWrapper(_sessionGetter);
942
943 return OAuthSession(
944 server: server,
945 sub: sub,
946 sessionGetter: sessionGetterWrapper,
947 );
948 }
949
950 /// Disposes of resources used by this client.
951 ///
952 /// Call this when the client is no longer needed to prevent memory leaks.
953 @override
954 void dispose() {
955 _updatedController.close();
956 _deletedController.close();
957 _sessionGetter.dispose();
958 super.dispose();
959 }
960}
961
962/// Wrapper to adapt SessionGetter to SessionGetterInterface
963class _SessionGetterWrapper implements SessionGetterInterface {
964 final SessionGetter _getter;
965
966 _SessionGetterWrapper(this._getter);
967
968 @override
969 Future<Session> get(String sub, {bool? noCache, bool? allowStale}) async {
970 return _getter.get(
971 sub,
972 GetCachedOptions(
973 noCache: noCache ?? false,
974 allowStale: allowStale ?? false,
975 ),
976 );
977 }
978
979 @override
980 Future<void> delStored(String sub, [Object? cause]) {
981 return _getter.delStored(sub, cause);
982 }
983}