···
2
-
import 'package:dio/dio.dart';
3
-
import 'package:flutter/foundation.dart';
5
-
import '../constants.dart';
6
-
import '../dpop/fetch_dpop.dart' show InMemoryStore;
7
-
import '../errors/auth_method_unsatisfiable_error.dart';
8
-
import '../errors/oauth_callback_error.dart';
9
-
import '../errors/token_revoked_error.dart';
10
-
import '../identity/constants.dart';
11
-
import '../identity/did_helpers.dart' show assertAtprotoDid;
12
-
import '../identity/did_resolver.dart' show DidCache;
13
-
import '../identity/handle_resolver.dart' show HandleCache;
14
-
import '../identity/identity_resolver.dart';
15
-
import '../oauth/authorization_server_metadata_resolver.dart' as auth_resolver;
16
-
import '../oauth/client_auth.dart';
17
-
import '../oauth/oauth_resolver.dart';
18
-
import '../oauth/oauth_server_agent.dart';
19
-
import '../oauth/oauth_server_factory.dart';
20
-
import '../oauth/protected_resource_metadata_resolver.dart';
21
-
import '../oauth/validate_client_metadata.dart';
22
-
import '../platform/flutter_key.dart';
23
-
import '../runtime/runtime.dart' as runtime_lib;
24
-
import '../runtime/runtime_implementation.dart';
25
-
import '../session/oauth_session.dart'
26
-
show OAuthSession, Session, SessionGetterInterface;
27
-
import '../session/session_getter.dart';
28
-
import '../session/state_store.dart';
29
-
import '../types.dart';
30
-
import '../util.dart';
32
-
// Re-export types needed for OAuthClientOptions
33
-
export '../identity/did_resolver.dart' show DidCache, DidResolver;
34
-
export '../identity/handle_resolver.dart' show HandleCache, HandleResolver;
35
-
export '../identity/identity_resolver.dart' show IdentityResolver;
36
-
export '../oauth/authorization_server_metadata_resolver.dart'
37
-
show AuthorizationServerMetadataCache;
38
-
export '../oauth/oauth_server_agent.dart' show DpopNonceCache;
39
-
export '../oauth/protected_resource_metadata_resolver.dart'
40
-
show ProtectedResourceMetadataCache;
41
-
export '../runtime/runtime_implementation.dart' show RuntimeImplementation, Key;
42
-
export '../oauth/client_auth.dart' show Keyset;
43
-
export '../session/session_getter.dart'
44
-
show SessionStore, SessionUpdatedEvent, SessionDeletedEvent;
45
-
export '../session/state_store.dart' show StateStore, InternalStateData;
46
-
export '../types.dart' show ClientMetadata, AuthorizeOptions, CallbackOptions;
48
-
/// OAuth response mode.
49
-
enum OAuthResponseMode {
50
-
/// Parameters in query string (default, most compatible)
53
-
/// Parameters in URL fragment (for single-page apps)
54
-
fragment('fragment');
57
-
const OAuthResponseMode(this.value);
60
-
String toString() => value;
63
-
/// Options for constructing an OAuthClient.
65
-
/// This includes all configuration, storage, and service dependencies
66
-
/// needed to implement the complete OAuth flow.
67
-
class OAuthClientOptions {
69
-
/// Response mode for OAuth (query or fragment)
70
-
final OAuthResponseMode responseMode;
72
-
/// Client metadata (validated before use)
73
-
final Map<String, dynamic> clientMetadata;
75
-
/// Optional keyset for confidential clients (private_key_jwt)
76
-
final Keyset? keyset;
78
-
/// Whether to allow HTTP connections (for development only)
81
-
/// - OAuth authorization/resource servers
82
-
/// - did:web document fetching
84
-
/// Note: PLC directory connections are controlled separately.
85
-
final bool allowHttp;
88
-
/// Storage for OAuth state during authorization flow
89
-
final StateStore stateStore;
91
-
/// Storage for session tokens
92
-
final SessionStore sessionStore;
94
-
/// Optional cache for authorization server metadata
95
-
final auth_resolver.AuthorizationServerMetadataCache?
96
-
authorizationServerMetadataCache;
98
-
/// Optional cache for protected resource metadata
99
-
final ProtectedResourceMetadataCache? protectedResourceMetadataCache;
101
-
/// Optional cache for DPoP nonces
102
-
final DpopNonceCache? dpopNonceCache;
104
-
/// Optional cache for DID documents
105
-
final DidCache? didCache;
107
-
/// Optional cache for handle → DID resolutions
108
-
final HandleCache? handleCache;
111
-
/// Platform-specific cryptographic operations
112
-
final RuntimeImplementation runtimeImplementation;
114
-
/// Optional HTTP client (Dio instance)
117
-
/// Optional custom identity resolver
118
-
final IdentityResolver? identityResolver;
120
-
/// PLC directory URL (for DID resolution)
121
-
final String? plcDirectoryUrl;
123
-
/// Handle resolver URL (for handle → DID resolution)
124
-
final String? handleResolverUrl;
126
-
const OAuthClientOptions({
127
-
required this.responseMode,
128
-
required this.clientMetadata,
130
-
this.allowHttp = false,
131
-
required this.stateStore,
132
-
required this.sessionStore,
133
-
this.authorizationServerMetadataCache,
134
-
this.protectedResourceMetadataCache,
135
-
this.dpopNonceCache,
138
-
required this.runtimeImplementation,
140
-
this.identityResolver,
141
-
this.plcDirectoryUrl,
142
-
this.handleResolverUrl,
146
-
/// Result of a successful OAuth callback.
147
-
class CallbackResult {
148
-
/// The authenticated session
149
-
final OAuthSession session;
151
-
/// The application state from the original authorize call
152
-
final String? state;
154
-
const CallbackResult({required this.session, this.state});
157
-
/// Options for fetching client metadata from a discoverable client ID.
158
-
class OAuthClientFetchMetadataOptions {
159
-
/// The discoverable client ID (HTTPS URL)
160
-
final String clientId;
162
-
/// Optional HTTP client
165
-
/// Optional cancellation token
166
-
final CancelToken? cancelToken;
168
-
const OAuthClientFetchMetadataOptions({
169
-
required this.clientId,
175
-
/// Main OAuth client for atProto OAuth flows.
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
185
-
/// final client = OAuthClient(
186
-
/// clientMetadata: {
187
-
/// 'client_id': 'https://example.com/client-metadata.json',
188
-
/// 'redirect_uris': ['myapp://oauth/callback'],
189
-
/// 'scope': 'atproto',
191
-
/// responseMode: OAuthResponseMode.query,
192
-
/// stateStore: MyStateStore(),
193
-
/// sessionStore: MySessionStore(),
194
-
/// runtimeImplementation: MyRuntimeImplementation(),
197
-
/// // Start authorization
198
-
/// final authUrl = await client.authorize('alice.bsky.social');
200
-
/// // Handle callback
201
-
/// final result = await client.callback(callbackParams);
202
-
/// print('Signed in as: ${result.session.sub}');
204
-
/// // Restore session later
205
-
/// final session = await client.restore('did:plc:abc123');
207
-
/// // Revoke session
208
-
/// await client.revoke('did:plc:abc123');
210
-
class OAuthClient extends CustomEventTarget<Map<String, dynamic>> {
212
-
/// Validated client metadata
213
-
final ClientMetadata clientMetadata;
215
-
/// OAuth response mode (query or fragment)
216
-
final OAuthResponseMode responseMode;
218
-
/// Optional keyset for confidential clients
219
-
final Keyset? keyset;
222
-
/// Runtime for cryptographic operations
223
-
final runtime_lib.Runtime runtime;
228
-
/// OAuth resolver for identity → metadata
229
-
final OAuthResolver oauthResolver;
231
-
/// Factory for creating OAuth server agents
232
-
final OAuthServerFactory serverFactory;
235
-
/// Session management with automatic refresh
236
-
final SessionGetter _sessionGetter;
238
-
/// OAuth state storage
239
-
final StateStore _stateStore;
242
-
final StreamController<SessionUpdatedEvent> _updatedController =
243
-
StreamController<SessionUpdatedEvent>.broadcast();
244
-
final StreamController<SessionDeletedEvent> _deletedController =
245
-
StreamController<SessionDeletedEvent>.broadcast();
247
-
/// Stream of session update events
248
-
Stream<SessionUpdatedEvent> get onUpdated => _updatedController.stream;
250
-
/// Stream of session deletion events
251
-
Stream<SessionDeletedEvent> get onDeleted => _deletedController.stream;
253
-
/// Constructs an OAuthClient with the given options.
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,
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);
276
-
_sessionGetter.onDeleted.listen((event) {
277
-
_deletedController.add(event);
278
-
dispatchCustomEvent('deleted', event);
282
-
/// Creates the OAuth resolver.
283
-
static OAuthResolver _createOAuthResolver(OAuthClientOptions options) {
284
-
final dio = options.dio ?? Dio();
286
-
return OAuthResolver(
288
-
options.identityResolver ??
289
-
AtprotoIdentityResolver.withDefaults(
291
-
options.handleResolverUrl ?? 'https://bsky.social',
292
-
plcDirectoryUrl: options.plcDirectoryUrl,
294
-
didCache: options.didCache,
295
-
handleCache: options.handleCache,
297
-
protectedResourceMetadataResolver: OAuthProtectedResourceMetadataResolver(
298
-
options.protectedResourceMetadataCache ??
299
-
InMemoryStore<String, Map<String, dynamic>>(),
301
-
config: OAuthProtectedResourceMetadataResolverConfig(
302
-
allowHttpResource: options.allowHttp,
305
-
authorizationServerMetadataResolver:
306
-
auth_resolver.OAuthAuthorizationServerMetadataResolver(
307
-
options.authorizationServerMetadataCache ??
308
-
InMemoryStore<String, Map<String, dynamic>>(),
311
-
auth_resolver.OAuthAuthorizationServerMetadataResolverConfig(
312
-
allowHttpIssuer: options.allowHttp,
318
-
/// Creates the OAuth server factory.
319
-
static OAuthServerFactory _createServerFactory(OAuthClientOptions options) {
320
-
return OAuthServerFactory(
321
-
clientMetadata: validateClientMetadata(
322
-
options.clientMetadata,
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>(),
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),
342
-
/// Fetches client metadata from a discoverable client ID URL.
344
-
/// This is a static helper method for fetching metadata before
345
-
/// constructing the OAuthClient.
347
-
/// See: https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
348
-
static Future<Map<String, dynamic>> fetchMetadata(
349
-
OAuthClientFetchMetadataOptions options,
351
-
final dio = options.dio ?? Dio();
352
-
final clientId = options.clientId;
355
-
final response = await dio.getUri<Map<String, dynamic>>(
356
-
Uri.parse(clientId),
358
-
followRedirects: false,
359
-
validateStatus: (status) => status == 200,
360
-
responseType: ResponseType.json,
362
-
cancelToken: options.cancelToken,
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');
372
-
final data = response.data;
373
-
if (data == null) {
374
-
throw FormatException('Empty client metadata response');
379
-
if (e is DioException) {
380
-
throw Exception('Failed to fetch client metadata: ${e.message}');
386
-
/// Exposes the identity resolver for convenience.
387
-
IdentityResolver get identityResolver => oauthResolver.identityResolver;
389
-
/// Returns the public JWKS for this client (for confidential clients).
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>>[]};
397
-
return keyset!.toJSON();
400
-
/// Initiates an OAuth authorization flow.
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
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")
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.)
423
-
/// Throws [FormatException] if parameters are invalid.
424
-
/// Throws [OAuthResolverError] if resolution fails.
425
-
Future<Uri> authorize(
427
-
AuthorizeOptions? options,
428
-
CancelToken? cancelToken,
430
-
final opts = options ?? const AuthorizeOptions();
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');
438
-
// Resolve input to OAuth metadata
439
-
final resolved = await oauthResolver.resolve(
441
-
auth_resolver.GetCachedOptions(cancelToken: cancelToken),
444
-
final metadata = resolved.metadata;
447
-
final pkce = await runtime.generatePKCE();
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],
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');
461
-
final generatedDpopJkt = await runtime.calculateJwkThumbprint(bareJwk);
463
-
// Negotiate client authentication method
464
-
final authMethod = negotiateClientAuthMethod(
470
-
// Generate state parameter
471
-
final state = await runtime.generateNonce();
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 ?? {};
479
-
print('🔑 Storing DPoP key for authorization flow');
482
-
await _stateStore.set(
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,
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,
501
-
'response_mode': responseMode.value,
502
-
'response_type': 'code',
503
-
'scope': opts.scope ?? clientMetadata.scope ?? 'atproto',
504
-
'dpop_jkt': opts.dpopJkt ?? generatedDpopJkt,
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;
514
-
parameters['login_hint'] = did;
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!;
528
-
// Build authorization URL
529
-
final authorizationUrl = Uri.parse(
530
-
metadata['authorization_endpoint'] as String,
533
-
// Validate authorization endpoint protocol
534
-
if (authorizationUrl.scheme != 'https' &&
535
-
authorizationUrl.scheme != 'http') {
536
-
throw FormatException(
537
-
'Invalid authorization endpoint protocol: ${authorizationUrl.scheme}',
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;
547
-
if (parEndpoint != null) {
548
-
// Server supports PAR, use it
549
-
final server = await serverFactory.fromMetadata(
555
-
final parResponse = await server.request(
556
-
'pushed_authorization_request',
560
-
final requestUri = parResponse['request_uri'] as String;
562
-
// Return simplified URL with just request_uri
563
-
return authorizationUrl.replace(
565
-
'client_id': clientMetadata.clientId!,
566
-
'request_uri': requestUri,
569
-
} else if (requiresPar) {
571
-
'Server requires pushed authorization requests (PAR) but no PAR endpoint is available',
574
-
// No PAR support, use direct authorization request
575
-
final fullUrl = authorizationUrl.replace(queryParameters: parameters);
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)');
587
-
/// Handles the OAuth callback after user authorization.
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
598
-
/// The [params] should be the query parameters from the callback URL.
600
-
/// The [options] can specify:
601
-
/// - redirectUri: Must match the one used in authorize()
603
-
/// Returns a [CallbackResult] with the session and application state.
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,
611
-
final opts = options ?? const CallbackOptions();
613
-
// Check for JARM (not supported)
614
-
final responseJwt = params['response'];
615
-
if (responseJwt != null) {
616
-
throw OAuthCallbackError(params, message: 'JARM not supported');
619
-
// Extract parameters
620
-
final issuerParam = params['iss'];
621
-
final stateParam = params['state'];
622
-
final errorParam = params['error'];
623
-
final codeParam = params['code'];
625
-
// Validate state parameter
626
-
if (stateParam == null) {
627
-
throw OAuthCallbackError(params, message: 'Missing "state" parameter');
630
-
// Retrieve internal state
631
-
final stateData = await _stateStore.get(stateParam);
632
-
if (stateData == null) {
633
-
throw OAuthCallbackError(
635
-
message: 'Unknown authorization session "$stateParam"',
639
-
// Prevent replay attacks - delete state immediately
640
-
await _stateStore.del(stateParam);
643
-
// Check for error response
644
-
if (errorParam != null) {
645
-
throw OAuthCallbackError(params, state: stateData.appState);
648
-
// Validate authorization code
649
-
if (codeParam == null) {
650
-
throw OAuthCallbackError(
652
-
message: 'Missing "code" query param',
653
-
state: stateData.appState,
657
-
// Create OAuth server agent
659
-
stateData.authMethod != null
660
-
? ClientAuthMethod.fromJson(
661
-
stateData.authMethod as Map<String, dynamic>,
663
-
: const ClientAuthMethod.none(); // Legacy fallback
665
-
// Restore dpopKey from stored private JWK
666
-
// Restore DPoP key with error handling for corrupted JWK data
667
-
final FlutterKey dpopKey;
669
-
dpopKey = FlutterKey.fromJwk(stateData.dpopKey as Map<String, dynamic>);
671
-
print('🔓 DPoP key restored successfully for token exchange');
675
-
'Failed to restore DPoP key from stored state: $e. '
676
-
'The stored key may be corrupted. Please try authenticating again.',
680
-
final server = await serverFactory.fromIssuer(
684
-
auth_resolver.GetCachedOptions(cancelToken: cancelToken),
687
-
// Validate issuer if provided
688
-
if (issuerParam != null) {
689
-
if (server.issuer.isEmpty) {
690
-
throw OAuthCallbackError(
692
-
message: 'Issuer not found in metadata',
693
-
state: stateData.appState,
696
-
if (server.issuer != issuerParam) {
697
-
throw OAuthCallbackError(
699
-
message: 'Issuer mismatch',
700
-
state: stateData.appState,
704
-
.serverMetadata['authorization_response_iss_parameter_supported'] ==
706
-
throw OAuthCallbackError(
708
-
message: 'iss missing from the response',
709
-
state: stateData.appState,
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;
722
-
print('🔄 Exchanging authorization code for tokens:');
723
-
print(' Code: ${codeParam.substring(0, 20)}...');
725
-
' Code verifier: ${stateData.verifier?.substring(0, 20) ?? "none"}...',
727
-
print(' Redirect URI: $redirectUriForExchange');
729
-
' Redirect URI source: ${stateData.redirectUri != null ? "stored" : "fallback"}',
731
-
print(' Issuer: ${server.issuer}');
734
-
final tokenSet = await server.exchangeCode(
736
-
codeVerifier: stateData.verifier,
737
-
redirectUri: redirectUriForExchange,
742
-
print('💾 Storing session for: ${tokenSet.sub}');
746
-
await _sessionGetter.setStored(
749
-
dpopKey: stateData.dpopKey,
750
-
authMethod: authMethod.toJson(),
751
-
tokenSet: tokenSet,
756
-
print('✅ Session stored successfully');
757
-
print('🎯 Creating session wrapper...');
760
-
// Create session wrapper
761
-
final session = _createSession(server, tokenSet.sub);
764
-
print('✅ Session wrapper created');
765
-
print('🎉 OAuth callback complete!');
768
-
return CallbackResult(session: session, state: stateData.appState);
769
-
} catch (err, stackTrace) {
770
-
// If session storage failed, revoke the tokens
772
-
print('❌ Session storage/creation failed:');
773
-
print(' Error: $err');
774
-
print(' Stack trace: $stackTrace');
776
-
await server.revoke(tokenSet.refreshToken ?? tokenSet.accessToken);
779
-
} catch (err, stackTrace) {
780
-
// Ensure appState is available in error
782
-
print('❌ Callback error (outer catch):');
783
-
print(' Error type: ${err.runtimeType}');
784
-
print(' Error: $err');
785
-
print(' Stack trace: $stackTrace');
787
-
throw OAuthCallbackError.from(err, params, stateData.appState);
791
-
/// Restores a stored session.
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
800
-
/// The [sub] is the user's DID.
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)
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(
812
-
dynamic refresh = 'auto',
813
-
CancelToken? cancelToken,
815
-
// Validate DID format
816
-
assertAtprotoDid(sub);
818
-
// Get session (automatically refreshes if needed based on refresh param)
819
-
final session = await _sessionGetter.getSession(sub, refresh);
822
-
// Determine auth method (with legacy fallback)
824
-
session.authMethod != null
825
-
? ClientAuthMethod.fromJson(
826
-
session.authMethod as Map<String, dynamic>,
828
-
: const ClientAuthMethod.none(); // Legacy
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;
835
-
dpopKey = FlutterKey.fromJwk(session.dpopKey as Map<String, dynamic>);
837
-
// If key is corrupted, delete the session and force re-authentication
838
-
await _sessionGetter.delStored(
840
-
Exception('Corrupted DPoP key in stored session: $e'),
843
-
'Failed to restore DPoP key for session. The stored key is corrupted. '
844
-
'Please authenticate again.',
848
-
// Create server agent
849
-
final server = await serverFactory.fromIssuer(
850
-
session.tokenSet.iss,
853
-
auth_resolver.GetCachedOptions(
854
-
noCache: refresh == true,
855
-
allowStale: refresh == false,
856
-
cancelToken: cancelToken,
860
-
return _createSession(server, sub);
862
-
// If auth method can't be satisfied, delete the session
863
-
if (err is AuthMethodUnsatisfiableError) {
864
-
await _sessionGetter.delStored(sub, err);
870
-
/// Revokes a session.
873
-
/// 1. Retrieves session from storage
874
-
/// 2. Calls token revocation endpoint
875
-
/// 3. Deletes session from storage
877
-
/// The [sub] is the user's DID.
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);
885
-
// Get session (allow stale tokens for revocation)
886
-
final session = await _sessionGetter.get(
888
-
const GetCachedOptions(allowStale: true),
891
-
// Try to revoke tokens on the server
894
-
session.authMethod != null
895
-
? ClientAuthMethod.fromJson(
896
-
session.authMethod as Map<String, dynamic>,
898
-
: const ClientAuthMethod.none(); // Legacy
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;
905
-
dpopKey = FlutterKey.fromJwk(session.dpopKey as Map<String, dynamic>);
907
-
// If key is corrupted, skip server-side revocation
908
-
// The finally block will still delete the local session
910
-
print('⚠️ Cannot revoke on server: corrupted DPoP key ($e)');
911
-
print(' Local session will still be deleted');
916
-
final server = await serverFactory.fromIssuer(
917
-
session.tokenSet.iss,
920
-
auth_resolver.GetCachedOptions(cancelToken: cancelToken),
923
-
await server.revoke(session.tokenSet.accessToken);
925
-
// Always delete local session, even if revocation failed
926
-
await _sessionGetter.delStored(sub, TokenRevokedError(sub));
930
-
/// Creates an OAuthSession wrapper.
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);
937
-
return OAuthSession(
940
-
sessionGetter: sessionGetterWrapper,
944
-
/// Disposes of resources used by this client.
946
-
/// Call this when the client is no longer needed to prevent memory leaks.
949
-
_updatedController.close();
950
-
_deletedController.close();
951
-
_sessionGetter.dispose();
956
-
/// Wrapper to adapt SessionGetter to SessionGetterInterface
957
-
class _SessionGetterWrapper implements SessionGetterInterface {
958
-
final SessionGetter _getter;
960
-
_SessionGetterWrapper(this._getter);
963
-
Future<Session> get(String sub, {bool? noCache, bool? allowStale}) async {
964
-
return _getter.get(
967
-
noCache: noCache ?? false,
968
-
allowStale: allowStale ?? false,
974
-
Future<void> delStored(String sub, [Object? cause]) {
975
-
return _getter.delStored(sub, cause);