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