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}