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}