[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: aip for auth

+2102 -387
+2 -1
.env.example
··· 1 1 VIDEO_SERVICE_URL=https://video.sprk.so 2 2 SPRK_APPVIEW_URL=https://api.sprk.so 3 - MESSAGES_SERVICE_URL=https://chat.sprk.so 3 + MESSAGES_SERVICE_URL=https://chat.sprk.so 4 + AIP_BASE_URL=https://auth.sprk.so
+179
lib/src/core/auth/data/models/aip_session_response.dart
··· 1 + import 'dart:convert'; 2 + import 'dart:typed_data'; 3 + 4 + import 'package:atproto_core/atproto_core.dart' show restoreOAuthSession; 5 + import 'package:atproto_oauth/atproto_oauth.dart'; 6 + import 'package:spark/src/core/auth/data/models/auth_snapshot.dart'; 7 + 8 + class AipAtprotocolSessionResponse { 9 + const AipAtprotocolSessionResponse({ 10 + required this.did, 11 + required this.handle, 12 + required this.accessToken, 13 + required this.tokenType, 14 + required this.scopes, 15 + required this.pdsEndpoint, 16 + required this.expiresAt, 17 + this.dpopKey, 18 + this.dpopJwk, 19 + }); 20 + 21 + factory AipAtprotocolSessionResponse.fromJson(Map<String, dynamic> json) { 22 + return AipAtprotocolSessionResponse( 23 + did: json['did'] as String, 24 + handle: json['handle'] as String, 25 + accessToken: json['access_token'] as String, 26 + tokenType: json['token_type'] as String, 27 + scopes: (json['scopes'] as List<dynamic>? ?? const []) 28 + .map((scope) => scope as String) 29 + .toList(), 30 + pdsEndpoint: json['pds_endpoint'] as String, 31 + expiresAt: DateTime.fromMillisecondsSinceEpoch( 32 + (json['expires_at'] as int) * 1000, 33 + isUtc: true, 34 + ), 35 + dpopKey: json['dpop_key'] as String?, 36 + dpopJwk: json['dpop_jwk'] == null 37 + ? null 38 + : AipDpopJwk.fromJson(json['dpop_jwk'] as Map<String, dynamic>), 39 + ); 40 + } 41 + 42 + final String did; 43 + final String handle; 44 + final String accessToken; 45 + final String tokenType; 46 + final List<String> scopes; 47 + final String pdsEndpoint; 48 + final DateTime expiresAt; 49 + final String? dpopKey; 50 + final AipDpopJwk? dpopJwk; 51 + } 52 + 53 + class AipDpopJwk { 54 + const AipDpopJwk({ 55 + required this.kty, 56 + required this.crv, 57 + required this.x, 58 + required this.y, 59 + this.d, 60 + }); 61 + 62 + factory AipDpopJwk.fromJson(Map<String, dynamic> json) { 63 + return AipDpopJwk( 64 + kty: json['kty'] as String, 65 + crv: json['crv'] as String, 66 + x: json['x'] as String, 67 + y: json['y'] as String, 68 + d: json['d'] as String?, 69 + ); 70 + } 71 + 72 + final String kty; 73 + final String crv; 74 + final String x; 75 + final String y; 76 + final String? d; 77 + } 78 + 79 + class AipExportedSessionException implements Exception { 80 + const AipExportedSessionException(this.message); 81 + 82 + final String message; 83 + 84 + @override 85 + String toString() => message; 86 + } 87 + 88 + PdsSessionCache buildPdsSessionCacheFromAipResponse( 89 + AipAtprotocolSessionResponse response, { 90 + String dpopNonce = '', 91 + }) { 92 + final dpopJwk = response.dpopJwk; 93 + if (dpopJwk == null || dpopJwk.d == null || dpopJwk.d!.isEmpty) { 94 + throw const AipExportedSessionException( 95 + 'AIP-exported session is missing DPoP private key material.', 96 + ); 97 + } 98 + 99 + final clientId = extractClientIdFromAccessToken(response.accessToken); 100 + if (clientId == null || clientId.isEmpty) { 101 + throw const AipExportedSessionException( 102 + 'AIP-exported token is incompatible with direct-PDS mode: missing client_id.', 103 + ); 104 + } 105 + 106 + return PdsSessionCache( 107 + accessToken: response.accessToken, 108 + expiresAt: response.expiresAt.toIso8601String(), 109 + did: response.did, 110 + handle: response.handle, 111 + pdsEndpoint: response.pdsEndpoint, 112 + scope: response.scopes.join(' '), 113 + dpopNonce: dpopNonce, 114 + publicKey: encodeDpopPublicKey(dpopJwk.x, dpopJwk.y), 115 + privateKey: encodeDpopPrivateKey(dpopJwk.d!), 116 + ); 117 + } 118 + 119 + OAuthSession restorePdsOAuthSessionFromCache(PdsSessionCache cache) { 120 + final clientId = extractClientIdFromAccessToken(cache.accessToken); 121 + if (clientId == null || clientId.isEmpty) { 122 + throw const AipExportedSessionException( 123 + 'AIP-exported token is incompatible with direct-PDS mode: missing client_id.', 124 + ); 125 + } 126 + 127 + return restoreOAuthSession( 128 + accessToken: cache.accessToken, 129 + refreshToken: '', 130 + clientId: clientId, 131 + dPoPNonce: cache.dpopNonce, 132 + publicKey: normalizeDpopKeyEncoding(cache.publicKey), 133 + privateKey: normalizeDpopKeyEncoding(cache.privateKey), 134 + ); 135 + } 136 + 137 + String encodeDpopPublicKey(String x, String y) { 138 + final xBytes = base64Url.decode(base64Url.normalize(x)); 139 + final yBytes = base64Url.decode(base64Url.normalize(y)); 140 + final buffer = Uint8List(xBytes.length + yBytes.length) 141 + ..setAll(0, xBytes) 142 + ..setAll(xBytes.length, yBytes); 143 + 144 + return base64Url.encode(buffer); 145 + } 146 + 147 + String encodeDpopPrivateKey(String d) { 148 + final bytes = base64Url.decode(base64Url.normalize(d)); 149 + return base64Url.encode(bytes); 150 + } 151 + 152 + String normalizeDpopKeyEncoding(String value) { 153 + return base64Url.normalize(value); 154 + } 155 + 156 + String? extractClientIdFromAccessToken(String accessToken) { 157 + final payload = decodeJwtPayload(accessToken); 158 + final clientId = payload['client_id']; 159 + if (clientId is String && clientId.isNotEmpty) { 160 + return clientId; 161 + } 162 + 163 + return null; 164 + } 165 + 166 + Map<String, dynamic> decodeJwtPayload(String token) { 167 + final parts = token.split('.'); 168 + if (parts.length < 2) { 169 + throw const AipExportedSessionException('Invalid exported JWT.'); 170 + } 171 + 172 + final payloadBytes = base64Url.decode(base64Url.normalize(parts[1])); 173 + final payload = json.decode(utf8.decode(payloadBytes)); 174 + if (payload is! Map<String, dynamic>) { 175 + throw const AipExportedSessionException('Invalid exported JWT payload.'); 176 + } 177 + 178 + return payload; 179 + }
+254
lib/src/core/auth/data/models/auth_snapshot.dart
··· 1 + import 'dart:convert'; 2 + 3 + const Object _missingValue = Object(); 4 + 5 + class AuthSnapshot { 6 + const AuthSnapshot({ 7 + this.version = currentVersion, 8 + this.aipClientRegistration, 9 + this.aipGrant, 10 + this.pdsSessionCache, 11 + }); 12 + 13 + factory AuthSnapshot.fromJson(Map<String, dynamic> json) { 14 + final version = json['version'] as int?; 15 + if (version != currentVersion) { 16 + throw const FormatException('Unsupported auth snapshot version.'); 17 + } 18 + 19 + return AuthSnapshot( 20 + version: version!, 21 + aipClientRegistration: json['aipClientRegistration'] == null 22 + ? null 23 + : AipClientRegistration.fromJson( 24 + json['aipClientRegistration'] as Map<String, dynamic>, 25 + ), 26 + aipGrant: json['aipGrant'] == null 27 + ? null 28 + : AipGrant.fromJson(json['aipGrant'] as Map<String, dynamic>), 29 + pdsSessionCache: json['pdsSessionCache'] == null 30 + ? null 31 + : PdsSessionCache.fromJson( 32 + json['pdsSessionCache'] as Map<String, dynamic>, 33 + ), 34 + ); 35 + } 36 + 37 + factory AuthSnapshot.fromJsonString(String jsonString) { 38 + return AuthSnapshot.fromJson( 39 + json.decode(jsonString) as Map<String, dynamic>, 40 + ); 41 + } 42 + 43 + static const int currentVersion = 2; 44 + 45 + final int version; 46 + final AipClientRegistration? aipClientRegistration; 47 + final AipGrant? aipGrant; 48 + final PdsSessionCache? pdsSessionCache; 49 + 50 + bool get isEmpty => 51 + aipClientRegistration == null && 52 + aipGrant == null && 53 + pdsSessionCache == null; 54 + 55 + Map<String, dynamic> toJson() { 56 + return { 57 + 'version': version, 58 + 'aipClientRegistration': aipClientRegistration?.toJson(), 59 + 'aipGrant': aipGrant?.toJson(), 60 + 'pdsSessionCache': pdsSessionCache?.toJson(), 61 + }; 62 + } 63 + 64 + String toJsonString() => json.encode(toJson()); 65 + 66 + AuthSnapshot copyWith({ 67 + Object? aipClientRegistration = _missingValue, 68 + Object? aipGrant = _missingValue, 69 + Object? pdsSessionCache = _missingValue, 70 + }) { 71 + return AuthSnapshot( 72 + version: version, 73 + aipClientRegistration: identical(aipClientRegistration, _missingValue) 74 + ? this.aipClientRegistration 75 + : aipClientRegistration as AipClientRegistration?, 76 + aipGrant: identical(aipGrant, _missingValue) 77 + ? this.aipGrant 78 + : aipGrant as AipGrant?, 79 + pdsSessionCache: identical(pdsSessionCache, _missingValue) 80 + ? this.pdsSessionCache 81 + : pdsSessionCache as PdsSessionCache?, 82 + ); 83 + } 84 + } 85 + 86 + class AipClientRegistration { 87 + const AipClientRegistration({ 88 + required this.clientId, 89 + this.clientSecret, 90 + this.registrationAccessToken, 91 + this.clientSecretExpiresAt, 92 + }); 93 + 94 + factory AipClientRegistration.fromJson(Map<String, dynamic> json) { 95 + return AipClientRegistration( 96 + clientId: json['clientId'] as String, 97 + clientSecret: json['clientSecret'] as String?, 98 + registrationAccessToken: json['registrationAccessToken'] as String?, 99 + clientSecretExpiresAt: json['clientSecretExpiresAt'] as String?, 100 + ); 101 + } 102 + 103 + final String clientId; 104 + final String? clientSecret; 105 + final String? registrationAccessToken; 106 + final String? clientSecretExpiresAt; 107 + 108 + DateTime? get clientSecretExpiresAtDateTime { 109 + final value = clientSecretExpiresAt; 110 + if (value == null || value.isEmpty) return null; 111 + return DateTime.parse(value); 112 + } 113 + 114 + Map<String, dynamic> toJson() { 115 + return { 116 + 'clientId': clientId, 117 + 'clientSecret': clientSecret, 118 + 'registrationAccessToken': registrationAccessToken, 119 + 'clientSecretExpiresAt': clientSecretExpiresAt, 120 + }; 121 + } 122 + } 123 + 124 + class AipGrant { 125 + const AipGrant({required this.credentialsJson}); 126 + 127 + factory AipGrant.fromJson(Map<String, dynamic> json) { 128 + return AipGrant(credentialsJson: json['credentialsJson'] as String); 129 + } 130 + 131 + final String credentialsJson; 132 + 133 + Map<String, dynamic> toJson() { 134 + return {'credentialsJson': credentialsJson}; 135 + } 136 + } 137 + 138 + class PdsSessionCache { 139 + const PdsSessionCache({ 140 + required this.accessToken, 141 + required this.expiresAt, 142 + required this.did, 143 + required this.handle, 144 + required this.pdsEndpoint, 145 + required this.scope, 146 + required this.dpopNonce, 147 + required this.publicKey, 148 + required this.privateKey, 149 + }); 150 + 151 + factory PdsSessionCache.fromJson(Map<String, dynamic> json) { 152 + return PdsSessionCache( 153 + accessToken: json['accessToken'] as String, 154 + expiresAt: json['expiresAt'] as String, 155 + did: json['did'] as String, 156 + handle: json['handle'] as String, 157 + pdsEndpoint: json['pdsEndpoint'] as String, 158 + scope: json['scope'] as String, 159 + dpopNonce: json['dpopNonce'] as String? ?? '', 160 + publicKey: json['publicKey'] as String, 161 + privateKey: json['privateKey'] as String, 162 + ); 163 + } 164 + 165 + final String accessToken; 166 + final String expiresAt; 167 + final String did; 168 + final String handle; 169 + final String pdsEndpoint; 170 + final String scope; 171 + final String dpopNonce; 172 + final String publicKey; 173 + final String privateKey; 174 + 175 + DateTime get expiresAtDateTime => DateTime.parse(expiresAt); 176 + 177 + Map<String, dynamic> toJson() { 178 + return { 179 + 'accessToken': accessToken, 180 + 'expiresAt': expiresAt, 181 + 'did': did, 182 + 'handle': handle, 183 + 'pdsEndpoint': pdsEndpoint, 184 + 'scope': scope, 185 + 'dpopNonce': dpopNonce, 186 + 'publicKey': publicKey, 187 + 'privateKey': privateKey, 188 + }; 189 + } 190 + 191 + PdsSessionCache copyWith({ 192 + String? accessToken, 193 + String? expiresAt, 194 + String? did, 195 + String? handle, 196 + String? pdsEndpoint, 197 + String? scope, 198 + String? dpopNonce, 199 + String? publicKey, 200 + String? privateKey, 201 + }) { 202 + return PdsSessionCache( 203 + accessToken: accessToken ?? this.accessToken, 204 + expiresAt: expiresAt ?? this.expiresAt, 205 + did: did ?? this.did, 206 + handle: handle ?? this.handle, 207 + pdsEndpoint: pdsEndpoint ?? this.pdsEndpoint, 208 + scope: scope ?? this.scope, 209 + dpopNonce: dpopNonce ?? this.dpopNonce, 210 + publicKey: publicKey ?? this.publicKey, 211 + privateKey: privateKey ?? this.privateKey, 212 + ); 213 + } 214 + } 215 + 216 + class PendingAipAuthContext { 217 + const PendingAipAuthContext({ 218 + required this.clientId, 219 + required this.state, 220 + required this.codeVerifier, 221 + required this.redirectUri, 222 + }); 223 + 224 + factory PendingAipAuthContext.fromJson(Map<String, dynamic> json) { 225 + return PendingAipAuthContext( 226 + clientId: json['clientId'] as String, 227 + state: json['state'] as String, 228 + codeVerifier: json['codeVerifier'] as String, 229 + redirectUri: json['redirectUri'] as String, 230 + ); 231 + } 232 + 233 + factory PendingAipAuthContext.fromJsonString(String jsonString) { 234 + return PendingAipAuthContext.fromJson( 235 + json.decode(jsonString) as Map<String, dynamic>, 236 + ); 237 + } 238 + 239 + final String clientId; 240 + final String state; 241 + final String codeVerifier; 242 + final String redirectUri; 243 + 244 + Map<String, dynamic> toJson() { 245 + return { 246 + 'clientId': clientId, 247 + 'state': state, 248 + 'codeVerifier': codeVerifier, 249 + 'redirectUri': redirectUri, 250 + }; 251 + } 252 + 253 + String toJsonString() => json.encode(toJson()); 254 + }
+3 -2
lib/src/core/auth/data/repositories/auth_repository.dart
··· 28 28 /// Returns the authorization URL that the user should be redirected to 29 29 Future<String> initiateOAuth(String handle); 30 30 31 - /// Initiates the OAuth flow without a handle, using a specific service 31 + /// Initiates the OAuth flow without a handle. 32 32 /// 33 - /// [service] - The OAuth service host (e.g., 'pds.sprk.so') 33 + /// [service] is retained as a compatibility parameter and is ignored in 34 + /// AIP-backed auth mode. 34 35 /// 35 36 /// Returns the authorization URL that the user should be redirected to 36 37 Future<String> initiateOAuthWithService(String service);
+748 -372
lib/src/core/auth/data/repositories/auth_repository_impl.dart
··· 1 1 import 'dart:async'; 2 2 import 'dart:convert'; 3 + import 'dart:math'; 3 4 4 5 import 'package:atproto/atproto.dart'; 5 - import 'package:atproto_core/atproto_core.dart' show restoreOAuthSession; 6 - import 'package:atproto_core/atproto_oauth.dart'; 6 + import 'package:atproto_oauth/atproto_oauth.dart'; 7 7 import 'package:get_it/get_it.dart'; 8 8 import 'package:http/http.dart' as http; 9 - import 'package:spark/src/core/auth/data/models/account.dart'; 9 + import 'package:oauth2/oauth2.dart' as oauth2; 10 + import 'package:spark/src/core/auth/data/models/aip_session_response.dart'; 11 + import 'package:spark/src/core/auth/data/models/auth_snapshot.dart'; 10 12 import 'package:spark/src/core/auth/data/models/login_result.dart'; 11 13 import 'package:spark/src/core/auth/data/repositories/auth_repository.dart'; 14 + import 'package:spark/src/core/config/app_config.dart'; 12 15 import 'package:spark/src/core/storage/storage.dart'; 13 - import 'package:spark/src/core/utils/did_utils.dart'; 14 16 import 'package:spark/src/core/utils/logging/log_service.dart'; 15 17 import 'package:spark/src/core/utils/logging/logger.dart'; 16 - import 'package:spark/src/core/utils/oauth_resolver.dart'; 17 18 18 - /// OAuth client metadata URL 19 - const String _clientMetadataUrl = 'https://sprk.so/oauth-client-metadata.json'; 19 + typedef AtprotoSessionFetcher = 20 + Future<({String did, String handle})> Function(ATProto atproto); 20 21 21 - /// Cached OAuth client metadata to avoid repeated network calls 22 - OAuthClientMetadata? _cachedClientMetadata; 22 + const String _redirectUriValue = 'sprk://oauth-callback'; 23 + const String _aipScope = 'atproto transition:generic'; 24 + const String _clientName = 'Spark Mobile App'; 25 + const String _clientUri = 'https://sprk.so'; 26 + const String _softwareId = 'spark-mobile'; 27 + const String _softwareVersion = '1.0.0'; 28 + const Duration _refreshLeeway = Duration(minutes: 5); 29 + const String _randomCharset = 30 + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; 23 31 24 - /// Implementation of the authentication repository for AT Protocol using OAuth 25 32 class AuthRepositoryImpl implements AuthRepository { 26 - AuthRepositoryImpl() { 33 + AuthRepositoryImpl({ 34 + LocalStorageInterface? secureStorage, 35 + http.Client? httpClient, 36 + SparkLogger? logger, 37 + DateTime Function()? now, 38 + AtprotoSessionFetcher? fetchSessionInfo, 39 + }) : _secureStorage = secureStorage ?? StorageManager.instance.secure, 40 + _httpClient = httpClient ?? http.Client(), 41 + _logger = logger ?? _buildLogger(), 42 + _now = now ?? DateTime.now, 43 + _fetchSessionInfo = fetchSessionInfo ?? _defaultFetchSessionInfo, 44 + _aipBaseUri = _normalizeBaseUri(AppConfig.aipBaseUrl) { 27 45 _initialize(); 28 46 } 29 47 48 + final LocalStorageInterface _secureStorage; 49 + final http.Client _httpClient; 50 + final SparkLogger _logger; 51 + final DateTime Function() _now; 52 + final AtprotoSessionFetcher _fetchSessionInfo; 53 + final Uri _aipBaseUri; 54 + final Completer<void> _initCompleter = Completer<void>(); 55 + 56 + Future<bool>? _refreshInFlight; 57 + int _authGeneration = 0; 58 + AuthSnapshot? _snapshot; 30 59 OAuthSession? _oauthSession; 31 60 ATProto? _atProto; 32 61 String? _did; 33 62 String? _handle; 34 63 String? _pdsEndpoint; 35 - String? _oauthServer; 36 - 37 - /// Pending OAuth context during authorization flow 38 - OAuthContext? _pendingContext; 39 - OAuthClient? _oauthClient; 40 - 41 - final SparkLogger _logger = GetIt.instance<LogService>().getLogger( 42 - 'AuthRepository', 43 - ); 44 - 45 - final Completer<void> _initCompleter = Completer<void>(); 46 64 47 65 @override 48 66 Future<void> get initializationComplete => _initCompleter.future; 49 67 50 - /// Gets cached OAuth client metadata, fetching once if needed 51 - Future<OAuthClientMetadata> _getCachedClientMetadata() async { 52 - if (_cachedClientMetadata != null) { 53 - return _cachedClientMetadata!; 54 - } 55 - _cachedClientMetadata = await getClientMetadata(_clientMetadataUrl); 56 - return _cachedClientMetadata!; 57 - } 58 - 59 68 @override 60 69 bool get isAuthenticated => 61 70 _oauthSession != null && _atProto != null && _did != null; ··· 78 87 if (!_initCompleter.isCompleted) { 79 88 _initCompleter.complete(); 80 89 } 81 - } catch (e) { 82 - _logger.e('AuthRepository initialization failed', error: e); 90 + } catch (e, stackTrace) { 91 + _logger.e( 92 + 'AuthRepository initialization failed', 93 + error: e, 94 + stackTrace: stackTrace, 95 + ); 83 96 if (!_initCompleter.isCompleted) { 84 - _initCompleter.completeError(e); 97 + _initCompleter.completeError(e, stackTrace); 85 98 } 86 99 } 87 100 } 88 101 89 - /// Fetches a DID document, handling both did:plc and did:web methods. 90 - Future<Map<String, dynamic>> _fetchDidDocument(String did) async { 91 - final url = DidUtils.buildDidDocumentUrl(did); 92 - final response = await http.get(url); 102 + Future<void> _loadSavedSession() async { 103 + final snapshot = await _loadSnapshotFromStorage(); 104 + if (snapshot == null) { 105 + return; 106 + } 93 107 94 - if (response.statusCode != 200) { 95 - throw Exception('Failed to fetch DID document: ${response.statusCode}'); 108 + final cachedSession = snapshot.pdsSessionCache; 109 + if (cachedSession != null && _isFresh(cachedSession.expiresAtDateTime)) { 110 + try { 111 + _applyCachedPdsSession(cachedSession); 112 + return; 113 + } catch (e, stackTrace) { 114 + _logger.w( 115 + 'Failed to restore cached PDS session, falling back to AIP bootstrap', 116 + error: e, 117 + stackTrace: stackTrace, 118 + ); 119 + } 96 120 } 97 121 98 - return json.decode(response.body) as Map<String, dynamic>; 122 + if (snapshot.aipGrant != null) { 123 + final refreshed = await _refreshAuthState(); 124 + if (!refreshed) { 125 + await _clearSessionState(preserveRegistration: true); 126 + } 127 + return; 128 + } 129 + 130 + if (snapshot.pdsSessionCache != null) { 131 + await _clearSessionState( 132 + preserveRegistration: snapshot.aipClientRegistration != null, 133 + ); 134 + } 99 135 } 100 136 101 - String? _extractPdsEndpoint(Map<String, dynamic> doc) { 102 - final services = doc['service'] as List<dynamic>?; 103 - if (services == null || services.isEmpty) { 137 + Future<AuthSnapshot?> _loadSnapshotFromStorage() async { 138 + final snapshotJson = await _secureStorage.getString(StorageKeys.account); 139 + if (snapshotJson == null) { 140 + _snapshot = null; 104 141 return null; 105 142 } 106 143 107 - final pdsService = services.firstWhere( 108 - (s) => s['id'] == '#atproto_pds', 109 - orElse: () => null, 144 + try { 145 + final payload = _decodeJsonObject(snapshotJson); 146 + final snapshot = await _parseStoredAuthSnapshot(payload); 147 + _snapshot = snapshot; 148 + return snapshot; 149 + } catch (e, stackTrace) { 150 + _logger.w( 151 + 'Clearing legacy or invalid auth snapshot', 152 + error: e, 153 + stackTrace: stackTrace, 154 + ); 155 + await _removeAllAuthStorage(); 156 + _snapshot = null; 157 + return null; 158 + } 159 + } 160 + 161 + Future<AuthSnapshot> _parseStoredAuthSnapshot( 162 + Map<String, dynamic> payload, 163 + ) async { 164 + final version = payload['version']; 165 + if (version != null) { 166 + return AuthSnapshot.fromJson(payload); 167 + } 168 + 169 + throw const FormatException( 170 + 'Legacy auth payloads are no longer supported.', 110 171 ); 172 + } 111 173 112 - if (pdsService == null) { 113 - return null; 174 + void _applyCachedPdsSession(PdsSessionCache cache) { 175 + final normalizedCache = cache.copyWith( 176 + publicKey: normalizeDpopKeyEncoding(cache.publicKey), 177 + privateKey: normalizeDpopKeyEncoding(cache.privateKey), 178 + ); 179 + _oauthSession = restorePdsOAuthSessionFromCache(normalizedCache); 180 + _did = normalizedCache.did; 181 + _handle = normalizedCache.handle; 182 + _pdsEndpoint = normalizedCache.pdsEndpoint; 183 + _atProto = ATProto.fromOAuthSession( 184 + _oauthSession!, 185 + service: Uri.parse(normalizedCache.pdsEndpoint).host, 186 + ); 187 + if (normalizedCache.publicKey != cache.publicKey || 188 + normalizedCache.privateKey != cache.privateKey) { 189 + _snapshot = (_snapshot ?? const AuthSnapshot()).copyWith( 190 + pdsSessionCache: normalizedCache, 191 + ); 192 + unawaited(_saveSnapshot()); 114 193 } 194 + } 115 195 116 - return pdsService['serviceEndpoint'] as String?; 196 + Future<void> _saveSnapshot() async { 197 + final snapshot = _snapshot; 198 + if (snapshot == null || snapshot.isEmpty) { 199 + await _secureStorage.remove(StorageKeys.account); 200 + return; 201 + } 202 + 203 + await _secureStorage.setString( 204 + StorageKeys.account, 205 + snapshot.toJsonString(), 206 + ); 117 207 } 118 208 119 - Future<void> _loadSavedSession() async { 209 + Future<void> _saveCurrentPdsSession() async { 210 + final oauthSession = _oauthSession; 211 + final did = _did; 212 + final handle = _handle; 213 + final pdsEndpoint = _pdsEndpoint; 214 + if (oauthSession == null || 215 + did == null || 216 + handle == null || 217 + pdsEndpoint == null) { 218 + return; 219 + } 220 + 221 + _snapshot = (_snapshot ?? const AuthSnapshot()).copyWith( 222 + pdsSessionCache: PdsSessionCache( 223 + accessToken: oauthSession.accessToken, 224 + expiresAt: oauthSession.expiresAt.toIso8601String(), 225 + did: did, 226 + handle: handle, 227 + pdsEndpoint: pdsEndpoint, 228 + scope: oauthSession.scope, 229 + dpopNonce: oauthSession.$dPoPNonce, 230 + publicKey: oauthSession.$publicKey, 231 + privateKey: oauthSession.$privateKey, 232 + ), 233 + ); 234 + await _saveSnapshot(); 235 + } 236 + 237 + Future<void> _clearPendingContext() async { 238 + await _secureStorage.remove(StorageKeys.pendingAuthContext); 239 + } 240 + 241 + Future<void> _removeAllAuthStorage() async { 242 + await _secureStorage.remove(StorageKeys.account); 243 + await _secureStorage.remove(StorageKeys.pendingAuthContext); 244 + await _secureStorage.remove(StorageKeys.userSession); 245 + } 246 + 247 + void _invalidateInFlightRefreshes() { 248 + _authGeneration += 1; 249 + } 250 + 251 + bool _isCurrentAuthGeneration(int generation) { 252 + return generation == _authGeneration; 253 + } 254 + 255 + Future<void> _waitForInFlightRefresh() async { 256 + final inFlight = _refreshInFlight; 257 + if (inFlight == null) { 258 + return; 259 + } 260 + 120 261 try { 121 - // Load account as single JSON object - much faster than multiple reads 122 - final accountJson = await StorageManager.instance.secure.getString( 123 - StorageKeys.account, 124 - ); 262 + await inFlight; 263 + } catch (_) { 264 + // Ignore the refresh outcome here. Callers are invalidating it anyway. 265 + } 266 + } 125 267 126 - if (accountJson == null) { 127 - return; 128 - } 268 + Future<void> _clearSessionState({required bool preserveRegistration}) async { 269 + _invalidateInFlightRefreshes(); 270 + await _waitForInFlightRefresh(); 129 271 130 - final account = Account.fromJsonString(accountJson); 272 + final registration = preserveRegistration 273 + ? _snapshot?.aipClientRegistration 274 + : null; 131 275 132 - _oauthSession = restoreOAuthSession( 133 - accessToken: account.accessToken, 134 - refreshToken: account.refreshToken, 135 - clientId: account.clientId ?? _clientMetadataUrl, 136 - dPoPNonce: account.dpopNonce, 137 - publicKey: account.publicKey, 138 - privateKey: account.privateKey, 139 - ); 276 + _snapshot = registration == null 277 + ? null 278 + : AuthSnapshot(aipClientRegistration: registration); 140 279 141 - // Parse expiresAt, default to epoch if not found (will trigger refresh) 142 - DateTime expiresAt; 143 - try { 144 - expiresAt = account.expiresAt != null 145 - ? DateTime.parse(account.expiresAt!) 146 - : DateTime.fromMillisecondsSinceEpoch(0); 147 - } catch (e) { 148 - _logger.w( 149 - 'Failed to parse expiresAt "${account.expiresAt}", defaulting to epoch', 150 - ); 151 - expiresAt = DateTime.fromMillisecondsSinceEpoch(0); 152 - } 280 + await _clearPendingContext(); 281 + await _secureStorage.remove(StorageKeys.userSession); 282 + await _saveSnapshot(); 283 + _resetInMemorySession(); 284 + } 153 285 154 - _did = account.did; 155 - _handle = account.handle; 156 - _pdsEndpoint = account.pdsEndpoint; 157 - _oauthServer = account.server; 286 + void _resetInMemorySession() { 287 + _oauthSession = null; 288 + _atProto = null; 289 + _did = null; 290 + _handle = null; 291 + _pdsEndpoint = null; 292 + } 158 293 159 - // Check if token needs refresh (5 minutes before expiration per README) 160 - final tokenNeedsRefresh = expiresAt.isBefore( 161 - DateTime.now().add(const Duration(minutes: 5)), 162 - ); 294 + bool _isFresh(DateTime expiresAt) { 295 + return expiresAt.isAfter(_now().toUtc().add(_refreshLeeway)); 296 + } 163 297 164 - // Only fetch OAuth client metadata if we need to refresh the token 165 - // This avoids a blocking network call on app start when token is valid 166 - if (tokenNeedsRefresh && _oauthServer != null) { 167 - final metadata = await _getCachedClientMetadata(); 168 - _oauthClient = OAuthClient(metadata, service: _oauthServer!); 169 - final refreshed = await refreshToken(); 170 - if (!refreshed) { 171 - await _clearSavedSession(); 172 - _oauthSession = null; 173 - _atProto = null; 174 - _did = null; 175 - _handle = null; 176 - _pdsEndpoint = null; 177 - _oauthServer = null; 178 - _oauthClient = null; 179 - return; 180 - } 181 - } 298 + bool _credentialsNeedRefresh(oauth2.Credentials credentials) { 299 + final expiration = credentials.expiration; 300 + if (expiration == null) { 301 + return false; 302 + } 182 303 183 - // Extract just the host from the PDS endpoint 184 - final pdsHost = _pdsEndpoint != null 185 - ? Uri.parse(_pdsEndpoint!).host 186 - : null; 304 + return expiration.isBefore(_now().toUtc().add(_refreshLeeway)); 305 + } 187 306 188 - _atProto = ATProto.fromOAuthSession(_oauthSession!, service: pdsHost); 189 - } catch (e) { 190 - _logger.e('Error loading saved account', error: e); 307 + bool _registrationNeedsRefresh(AipClientRegistration registration) { 308 + final expiration = registration.clientSecretExpiresAtDateTime; 309 + if (expiration == null) { 310 + return false; 191 311 } 312 + 313 + return expiration.isBefore(_now().toUtc().add(_refreshLeeway)); 192 314 } 193 315 194 - Future<void> _saveSession() async { 195 - if (_oauthSession == null) return; 316 + Future<_AipOAuthMetadata> _discoverOAuthMetadata() async { 317 + final response = await _httpClient.get( 318 + _aipBaseUri.resolve('/.well-known/oauth-authorization-server'), 319 + ); 196 320 197 - try { 198 - final account = Account( 199 - accessToken: _oauthSession!.accessToken, 200 - refreshToken: _oauthSession!.refreshToken, 201 - publicKey: _oauthSession!.$publicKey, 202 - privateKey: _oauthSession!.$privateKey, 203 - clientId: _oauthSession!.$clientId ?? _clientMetadataUrl, 204 - dpopNonce: _oauthSession!.$dPoPNonce, 205 - expiresAt: _oauthSession!.expiresAt.toIso8601String(), 206 - did: _did, 207 - handle: _handle, 208 - pdsEndpoint: _pdsEndpoint, 209 - server: _oauthServer, 321 + if (response.statusCode < 200 || response.statusCode >= 300) { 322 + throw Exception( 323 + 'Failed to fetch AIP OAuth metadata: ' 324 + '${response.statusCode} ${response.reasonPhrase ?? ''}' 325 + .trim(), 210 326 ); 327 + } 328 + 329 + return _AipOAuthMetadata.fromJson( 330 + _decodeJsonObject(response.body), 331 + baseUri: _aipBaseUri, 332 + ); 333 + } 211 334 212 - await StorageManager.instance.secure.setString( 213 - StorageKeys.account, 214 - account.toJsonString(), 335 + Future<AipClientRegistration> _ensureClientRegistration( 336 + _AipOAuthMetadata metadata, 337 + ) async { 338 + final existing = _snapshot?.aipClientRegistration; 339 + if (existing != null && !_registrationNeedsRefresh(existing)) { 340 + return existing; 341 + } 342 + 343 + final response = await _httpClient.post( 344 + metadata.registrationEndpoint, 345 + headers: {'Content-Type': 'application/json'}, 346 + body: json.encode({ 347 + 'client_name': _clientName, 348 + 'client_uri': _clientUri, 349 + 'redirect_uris': [_redirectUriValue], 350 + 'response_types': ['code'], 351 + 'grant_types': ['authorization_code', 'refresh_token'], 352 + 'token_endpoint_auth_method': 'client_secret_post', 353 + 'scope': _aipScope, 354 + 'software_id': _softwareId, 355 + 'software_version': _softwareVersion, 356 + }), 357 + ); 358 + 359 + if (response.statusCode < 200 || response.statusCode >= 300) { 360 + throw Exception( 361 + 'AIP client registration failed: ${_responseError(response)}', 215 362 ); 216 - } catch (e) { 217 - _logger.e('Failed to save account', error: e); 218 363 } 364 + 365 + final registration = _AipClientRegistrationResponse.fromJson( 366 + _decodeJsonObject(response.body), 367 + ).toStoredRegistration(); 368 + 369 + final previousClientId = existing?.clientId; 370 + _snapshot = (_snapshot ?? const AuthSnapshot()).copyWith( 371 + aipClientRegistration: registration, 372 + aipGrant: previousClientId == registration.clientId 373 + ? _snapshot?.aipGrant 374 + : null, 375 + ); 376 + await _saveSnapshot(); 377 + return registration; 219 378 } 220 379 221 - Future<void> _clearSavedSession() async { 222 - try { 223 - await StorageManager.instance.secure.remove(StorageKeys.account); 224 - await StorageManager.instance.secure.remove( 225 - StorageKeys.pendingAuthContext, 226 - ); 227 - // Also clear old session format if exists 228 - await StorageManager.instance.secure.remove(StorageKeys.userSession); 229 - } catch (e) { 230 - _logger.e('Failed to clear account', error: e); 380 + oauth2.AuthorizationCodeGrant _createAuthorizationGrant( 381 + _AipOAuthMetadata metadata, 382 + AipClientRegistration registration, 383 + String codeVerifier, 384 + ) { 385 + return oauth2.AuthorizationCodeGrant( 386 + registration.clientId, 387 + metadata.authorizationEndpoint, 388 + metadata.tokenEndpoint, 389 + secret: registration.clientSecret, 390 + basicAuth: false, 391 + httpClient: _httpClient, 392 + codeVerifier: codeVerifier, 393 + ); 394 + } 395 + 396 + Future<void> _storePendingContext(PendingAipAuthContext context) async { 397 + await _secureStorage.setString( 398 + StorageKeys.pendingAuthContext, 399 + context.toJsonString(), 400 + ); 401 + } 402 + 403 + Future<PendingAipAuthContext?> _readPendingContext() async { 404 + final raw = await _secureStorage.getString(StorageKeys.pendingAuthContext); 405 + if (raw == null) { 406 + return null; 231 407 } 408 + 409 + return PendingAipAuthContext.fromJsonString(raw); 410 + } 411 + 412 + String _generateRandomToken(int length) { 413 + final random = Random.secure(); 414 + return List.generate( 415 + length, 416 + (_) => _randomCharset[random.nextInt(_randomCharset.length)], 417 + ).join(); 232 418 } 233 419 234 420 @override 235 421 Future<String> initiateOAuth(String handle) async { 236 - try { 237 - // Resolve handle to DID 238 - final at = ATProto.anonymous(service: 'public.api.bsky.app'); 239 - final didRes = await at.identity.resolveHandle(handle: handle); 240 - final resolvedDid = didRes.data.did; 422 + return _startOAuthFlow(loginHint: handle.trim()); 423 + } 241 424 242 - final didDoc = await _fetchDidDocument(resolvedDid); 243 - final pdsEndpoint = _extractPdsEndpoint(didDoc); 244 - 245 - if (pdsEndpoint == null) { 246 - _logger.e('PDS endpoint not found in DID document'); 247 - throw Exception('PDS endpoint not found in DID document'); 248 - } 425 + @override 426 + Future<String> initiateOAuthWithService(String service) async { 427 + if (service.isNotEmpty) { 428 + _logger.d( 429 + 'Ignoring initiateOAuthWithService service hint in AIP auth mode: $service', 430 + ); 431 + } 249 432 250 - // Store user info for later 251 - _did = resolvedDid; 252 - _handle = handle; 253 - _pdsEndpoint = pdsEndpoint; 433 + return _startOAuthFlow(); 434 + } 254 435 255 - // Get client metadata (cached) 256 - final metadata = await _getCachedClientMetadata(); 257 - // Resolve OAuth server from PDS endpoint 258 - _oauthServer = await resolveOAuthServer(pdsEndpoint); 259 - _oauthClient = OAuthClient(metadata, service: _oauthServer!); 436 + Future<String> _startOAuthFlow({String? loginHint}) async { 437 + try { 438 + final metadata = await _discoverOAuthMetadata(); 439 + final registration = await _ensureClientRegistration(metadata); 440 + final state = _generateRandomToken(32); 441 + final codeVerifier = _generateRandomToken(64); 442 + final redirectUri = Uri.parse(_redirectUriValue); 443 + final grant = _createAuthorizationGrant( 444 + metadata, 445 + registration, 446 + codeVerifier, 447 + ); 260 448 261 - // Start OAuth authorization 262 - final (authUrl, ctx) = await _oauthClient!.authorize(handle); 263 - _pendingContext = ctx; 449 + var authorizationUri = grant.getAuthorizationUrl( 450 + redirectUri, 451 + scopes: _aipScope.split(' '), 452 + state: state, 453 + ); 264 454 265 - // Extract state parameter from authorization URL for restoration 266 - final authUri = Uri.parse(authUrl.toString()); 267 - final stateParam = authUri.queryParameters['state']; 455 + if (loginHint != null && loginHint.isNotEmpty) { 456 + authorizationUri = authorizationUri.replace( 457 + queryParameters: { 458 + ...authorizationUri.queryParameters, 459 + 'login_hint': loginHint, 460 + }, 461 + ); 462 + } 268 463 269 - // Store pending context in case app is killed during OAuth flow 270 - await StorageManager.instance.secure.setString( 271 - StorageKeys.pendingAuthContext, 272 - json.encode({ 273 - 'handle': handle, 274 - 'did': resolvedDid, 275 - 'pdsEndpoint': pdsEndpoint, 276 - 'server': _oauthServer, 277 - 'state': stateParam, 278 - }), 464 + await _storePendingContext( 465 + PendingAipAuthContext( 466 + clientId: registration.clientId, 467 + state: state, 468 + codeVerifier: codeVerifier, 469 + redirectUri: redirectUri.toString(), 470 + ), 279 471 ); 280 472 281 - return authUrl.toString(); 282 - } catch (e) { 283 - _logger.e('Failed to initiate OAuth', error: e); 473 + return authorizationUri.toString(); 474 + } catch (e, stackTrace) { 475 + _logger.e( 476 + 'Failed to initiate AIP OAuth', 477 + error: e, 478 + stackTrace: stackTrace, 479 + ); 284 480 rethrow; 285 481 } 286 482 } 287 483 288 484 @override 289 - Future<String> initiateOAuthWithService(String service) async { 485 + Future<LoginResult> completeOAuth(String callbackUrl) async { 290 486 try { 291 - // Store service for later 292 - _pdsEndpoint = 'https://$service'; 293 - _oauthServer = service; 487 + await initializationComplete; 294 488 295 - // Get client metadata (cached) 296 - final metadata = await _getCachedClientMetadata(); 297 - _oauthClient = OAuthClient(metadata, service: _oauthServer!); 489 + final context = await _readPendingContext(); 490 + if (context == null) { 491 + return LoginResult.failed( 492 + 'OAuth session was interrupted. Please start again.', 493 + ); 494 + } 298 495 299 - // Start OAuth authorization without login hint 300 - final (authUrl, ctx) = await _oauthClient!.authorize(); 301 - _pendingContext = ctx; 496 + _snapshot ??= await _loadSnapshotFromStorage(); 497 + final registration = _snapshot?.aipClientRegistration; 498 + if (registration == null || registration.clientId != context.clientId) { 499 + return LoginResult.failed( 500 + 'OAuth client registration was lost. Please try again.', 501 + ); 502 + } 302 503 303 - // Extract state parameter from authorization URL for restoration 304 - final authUri = Uri.parse(authUrl.toString()); 305 - final stateParam = authUri.queryParameters['state']; 504 + final metadata = await _discoverOAuthMetadata(); 505 + final grant = _createAuthorizationGrant( 506 + metadata, 507 + registration, 508 + context.codeVerifier, 509 + ); 306 510 307 - // Store pending context in case app was killed during OAuth flow 308 - await StorageManager.instance.secure.setString( 309 - StorageKeys.pendingAuthContext, 310 - json.encode({ 311 - 'pdsEndpoint': _pdsEndpoint, 312 - 'server': _oauthServer, 313 - 'state': stateParam, 314 - }), 511 + grant.getAuthorizationUrl( 512 + Uri.parse(context.redirectUri), 513 + scopes: _aipScope.split(' '), 514 + state: context.state, 515 + ); 516 + 517 + final client = await grant.handleAuthorizationResponse( 518 + Uri.parse(callbackUrl).queryParameters, 519 + ); 520 + 521 + _snapshot = (_snapshot ?? const AuthSnapshot()).copyWith( 522 + aipGrant: AipGrant(credentialsJson: client.credentials.toJson()), 523 + ); 524 + await _saveSnapshot(); 525 + 526 + final bootstrapped = await _bootstrapPdsSession( 527 + client.credentials, 528 + authGeneration: _authGeneration, 315 529 ); 530 + if (!bootstrapped) { 531 + await _clearSessionState(preserveRegistration: true); 532 + return LoginResult.failed( 533 + 'Failed to bootstrap a direct PDS session from AIP.', 534 + ); 535 + } 316 536 317 - return authUrl.toString(); 318 - } catch (e) { 319 - _logger.e('Failed to initiate OAuth with service', error: e); 320 - rethrow; 537 + return LoginResult.success(); 538 + } catch (e, stackTrace) { 539 + _logger.e('AIP OAuth callback failed', error: e, stackTrace: stackTrace); 540 + await _clearSessionState(preserveRegistration: true); 541 + return LoginResult.failed(e.toString()); 542 + } finally { 543 + await _clearPendingContext(); 321 544 } 322 545 } 323 546 324 - @override 325 - Future<LoginResult> completeOAuth(String callbackUrl) async { 547 + Future<oauth2.Credentials?> _loadAipCredentials() async { 548 + final aipGrant = _snapshot?.aipGrant; 549 + if (aipGrant == null) { 550 + return null; 551 + } 552 + 553 + return oauth2.Credentials.fromJson(aipGrant.credentialsJson); 554 + } 555 + 556 + Future<oauth2.Credentials?> _ensureFreshAipGrant({ 557 + required int authGeneration, 558 + }) async { 559 + final credentials = await _loadAipCredentials(); 560 + if (credentials == null) { 561 + return null; 562 + } 563 + 564 + if (!_credentialsNeedRefresh(credentials)) { 565 + return credentials; 566 + } 567 + 568 + return _refreshStoredAipGrant( 569 + credentials: credentials, 570 + authGeneration: authGeneration, 571 + ); 572 + } 573 + 574 + Future<oauth2.Credentials?> _refreshStoredAipGrant({ 575 + oauth2.Credentials? credentials, 576 + required int authGeneration, 577 + }) async { 578 + final currentCredentials = credentials ?? await _loadAipCredentials(); 579 + final registration = _snapshot?.aipClientRegistration; 580 + if (currentCredentials == null || registration == null) { 581 + return null; 582 + } 583 + 584 + if (!currentCredentials.canRefresh) { 585 + throw Exception('Stored AIP credentials cannot be refreshed.'); 586 + } 587 + 588 + final client = oauth2.Client( 589 + currentCredentials, 590 + identifier: registration.clientId, 591 + secret: registration.clientSecret, 592 + basicAuth: false, 593 + httpClient: _httpClient, 594 + ); 595 + 326 596 try { 327 - if (_oauthClient == null || _pendingContext == null) { 328 - // Try to restore context if app was killed 329 - final savedContext = await StorageManager.instance.secure.getString( 330 - StorageKeys.pendingAuthContext, 331 - ); 332 - if (savedContext != null) { 333 - final contextData = json.decode(savedContext) as Map<String, dynamic>; 334 - _handle = contextData['handle'] as String?; 335 - _did = contextData['did'] as String?; 336 - _pdsEndpoint = contextData['pdsEndpoint'] as String?; 337 - _oauthServer = contextData['server'] as String?; 338 - final savedState = contextData['state'] as String?; 597 + await client.refreshCredentials(); 598 + final refreshed = client.credentials; 599 + if (!_isCurrentAuthGeneration(authGeneration)) { 600 + return null; 601 + } 339 602 340 - // Verify state parameter matches if present 341 - if (savedState != null) { 342 - final callbackUri = Uri.parse(callbackUrl); 343 - final callbackState = callbackUri.queryParameters['state']; 344 - if (callbackState != savedState) { 345 - _logger.e( 346 - 'OAuth state mismatch. ' 347 - 'Expected: $savedState, got: $callbackState', 348 - ); 349 - // Clear invalid context 350 - await StorageManager.instance.secure.remove( 351 - StorageKeys.pendingAuthContext, 352 - ); 353 - return LoginResult.failed( 354 - 'OAuth state verification failed. Please try again.', 355 - ); 356 - } 357 - } 603 + _snapshot = (_snapshot ?? const AuthSnapshot()).copyWith( 604 + aipGrant: AipGrant(credentialsJson: refreshed.toJson()), 605 + ); 606 + await _saveSnapshot(); 607 + return refreshed; 608 + } finally { 609 + client.close(); 610 + } 611 + } 358 612 359 - // Recreate OAuth client with the correct OAuth server 360 - if (_oauthServer != null) { 361 - final metadata = await _getCachedClientMetadata(); 362 - _oauthClient = OAuthClient(metadata, service: _oauthServer!); 363 - } 364 - } 365 - if (_pendingContext == null) { 366 - _logger.e( 367 - 'No pending OAuth context found. ' 368 - 'App may have been killed during OAuth flow.', 369 - ); 370 - // Clear any partial context data 371 - await StorageManager.instance.secure.remove( 372 - StorageKeys.pendingAuthContext, 373 - ); 374 - return LoginResult.failed( 375 - 'OAuth session was interrupted. ' 376 - 'Please start the sign-up process again.', 377 - ); 378 - } 613 + Future<AipAtprotocolSessionResponse> _fetchAtprotocolSession( 614 + oauth2.Credentials credentials, 615 + ) async { 616 + final response = await _httpClient.get( 617 + _aipBaseUri.resolve('/api/atprotocol/session'), 618 + headers: {'Authorization': 'Bearer ${credentials.accessToken}'}, 619 + ); 620 + 621 + if (response.statusCode < 200 || response.statusCode >= 300) { 622 + throw Exception( 623 + 'AIP session request failed: ${_responseError(response)}', 624 + ); 625 + } 379 626 380 - if (_oauthClient == null) { 381 - _logger.e('OAuth client could not be restored'); 382 - return LoginResult.failed('OAuth client initialization failed'); 383 - } 627 + return AipAtprotocolSessionResponse.fromJson( 628 + _decodeJsonObject(response.body), 629 + ); 630 + } 631 + 632 + Future<bool> _bootstrapPdsSession( 633 + oauth2.Credentials credentials, { 634 + required int authGeneration, 635 + }) async { 636 + try { 637 + final sessionResponse = await _fetchAtprotocolSession(credentials); 638 + if (!_isCurrentAuthGeneration(authGeneration)) { 639 + return false; 384 640 } 385 641 386 - // Complete OAuth flow 387 - _oauthSession = await _oauthClient!.callback( 388 - callbackUrl, 389 - _pendingContext!, 642 + final existingNonce = 643 + _oauthSession?.$dPoPNonce ?? 644 + _snapshot?.pdsSessionCache?.dpopNonce ?? 645 + ''; 646 + final pdsSession = buildPdsSessionCacheFromAipResponse( 647 + sessionResponse, 648 + dpopNonce: existingNonce, 390 649 ); 391 650 392 - // Create ATProto client from OAuth session 393 - if (_pdsEndpoint != null) { 394 - final pdsHost = Uri.parse(_pdsEndpoint!).host; 395 - _atProto = ATProto.fromOAuthSession(_oauthSession!, service: pdsHost); 396 - } else { 397 - _logger.e('PDS endpoint is null, cannot create ATProto client'); 398 - return LoginResult.failed('PDS endpoint not found'); 651 + _snapshot = (_snapshot ?? const AuthSnapshot()).copyWith( 652 + pdsSessionCache: pdsSession, 653 + ); 654 + await _saveSnapshot(); 655 + if (!_isCurrentAuthGeneration(authGeneration)) { 656 + return false; 399 657 } 400 658 401 - // Fetch session info to get DID and handle if not already set 402 - // This is needed for registration flow where handle/DID aren't known upfront 403 - if (_did == null || _handle == null) { 404 - try { 405 - final sessionResponse = await _atProto!.server.getSession(); 406 - _did = sessionResponse.data.did; 407 - _handle = sessionResponse.data.handle; 408 - } catch (e) { 409 - _logger.e('Failed to fetch session info', error: e); 410 - return LoginResult.failed('Failed to get session info: $e'); 411 - } 659 + _applyCachedPdsSession(pdsSession); 660 + return true; 661 + } catch (e, stackTrace) { 662 + _logger.e( 663 + 'Failed to bootstrap direct PDS session from AIP', 664 + error: e, 665 + stackTrace: stackTrace, 666 + ); 667 + return false; 668 + } 669 + } 670 + 671 + Future<bool> _refreshAuthState() async { 672 + final inFlight = _refreshInFlight; 673 + if (inFlight != null) { 674 + return inFlight; 675 + } 676 + 677 + final future = _refreshAuthStateInternal(); 678 + _refreshInFlight = future; 679 + 680 + try { 681 + return await future; 682 + } finally { 683 + if (identical(_refreshInFlight, future)) { 684 + _refreshInFlight = null; 412 685 } 686 + } 687 + } 413 688 414 - // Save session 415 - await _saveSession(); 689 + Future<bool> _refreshAuthStateInternal() async { 690 + final authGeneration = _authGeneration; 416 691 417 - // Clear pending context 418 - await StorageManager.instance.secure.remove( 419 - StorageKeys.pendingAuthContext, 692 + try { 693 + final credentials = await _ensureFreshAipGrant( 694 + authGeneration: authGeneration, 420 695 ); 421 - _pendingContext = null; 696 + if (credentials == null) { 697 + return false; 698 + } 422 699 423 - return LoginResult.success(); 700 + return _bootstrapPdsSession(credentials, authGeneration: authGeneration); 424 701 } catch (e, stackTrace) { 425 - _logger.e('OAuth callback failed', error: e, stackTrace: stackTrace); 426 - return LoginResult.failed(e.toString()); 702 + _logger.e( 703 + 'Failed to refresh auth state from AIP', 704 + error: e, 705 + stackTrace: stackTrace, 706 + ); 707 + return false; 427 708 } 428 709 } 429 710 430 711 @override 431 712 Future<void> logout() async { 432 713 try { 433 - await _clearSavedSession(); 434 - _oauthSession = null; 435 - _atProto = null; 436 - _did = null; 437 - _handle = null; 438 - _pdsEndpoint = null; 439 - _oauthServer = null; 440 - _oauthClient = null; 441 - _pendingContext = null; 442 - } catch (e) { 443 - _logger.e('Logout failed', error: e); 714 + await _clearSessionState(preserveRegistration: true); 715 + } catch (e, stackTrace) { 716 + _logger.e('Logout failed', error: e, stackTrace: stackTrace); 444 717 } 445 718 } 446 719 447 720 @override 448 721 Future<bool> validateSession() async { 449 - // Wait for initialization to complete first 450 722 await initializationComplete; 451 723 452 - if (_atProto == null || 453 - _oauthSession == null || 454 - _did == null || 455 - _did!.isEmpty) { 724 + final atProto = _atProto; 725 + final did = _did; 726 + if (atProto == null || did == null || did.isEmpty) { 456 727 return false; 457 728 } 458 729 459 730 try { 460 - final sessionResponse = await _atProto!.server.getSession(); 461 - 462 - if (sessionResponse.data.did != _did) { 463 - _logger.w( 464 - 'Session DID mismatch. ' 465 - 'Expected $_did but got ${sessionResponse.data.did}', 466 - ); 731 + final session = await _fetchSessionInfo(atProto); 732 + if (session.did != did) { 733 + _logger.w('Session DID mismatch. Expected $did but got ${session.did}'); 467 734 await logout(); 468 735 return false; 469 736 } 470 737 471 - final latestHandle = sessionResponse.data.handle; 472 - if (latestHandle.isNotEmpty && latestHandle != _handle) { 473 - _handle = latestHandle; 474 - await _saveSession(); 738 + if (session.handle.isNotEmpty) { 739 + _handle = session.handle; 475 740 } 476 - 741 + await _saveCurrentPdsSession(); 477 742 return true; 478 - } catch (e) { 479 - // Try to refresh the token before giving up 480 - final refreshed = await refreshToken(); 481 - if (refreshed) { 482 - try { 483 - final sessionResponse = await _atProto!.server.getSession(); 743 + } catch (e, stackTrace) { 744 + _logger.w( 745 + 'Direct PDS session validation failed, attempting AIP rebootstrap', 746 + error: e, 747 + stackTrace: stackTrace, 748 + ); 484 749 485 - if (sessionResponse.data.did != _did) { 486 - _logger.w( 487 - 'Session DID mismatch after refresh. ' 488 - 'Expected $_did but got ${sessionResponse.data.did}', 489 - ); 490 - await logout(); 491 - return false; 492 - } 750 + final refreshed = await _refreshAuthState(); 751 + if (!refreshed || _atProto == null) { 752 + await logout(); 753 + return false; 754 + } 493 755 494 - final latestHandle = sessionResponse.data.handle; 495 - if (latestHandle.isNotEmpty && latestHandle != _handle) { 496 - _handle = latestHandle; 497 - await _saveSession(); 498 - } 499 - 500 - return true; 501 - } catch (refreshError) { 502 - _logger.e( 503 - 'Session validation failed after token refresh', 504 - error: refreshError, 756 + try { 757 + final session = await _fetchSessionInfo(_atProto!); 758 + if (session.did != did) { 759 + _logger.w( 760 + 'Session DID mismatch after AIP rebootstrap. ' 761 + 'Expected $did but got ${session.did}', 505 762 ); 763 + await logout(); 764 + return false; 506 765 } 766 + 767 + if (session.handle.isNotEmpty) { 768 + _handle = session.handle; 769 + } 770 + await _saveCurrentPdsSession(); 771 + return true; 772 + } catch (refreshError, refreshStackTrace) { 773 + _logger.e( 774 + 'Session validation failed after AIP rebootstrap', 775 + error: refreshError, 776 + stackTrace: refreshStackTrace, 777 + ); 778 + await logout(); 779 + return false; 507 780 } 508 - 509 - await logout(); 510 - return false; 511 781 } 512 782 } 513 783 514 784 @override 515 785 Future<bool> refreshToken() async { 516 - try { 517 - if (_oauthSession == null || _oauthClient == null) { 518 - // Try to recreate OAuth client if we have a session but no client 519 - if (_oauthSession != null && _oauthServer != null) { 520 - final metadata = await _getCachedClientMetadata(); 521 - _oauthClient = OAuthClient(metadata, service: _oauthServer!); 522 - } else { 523 - return false; 524 - } 525 - } 786 + await initializationComplete; 526 787 527 - final refreshedSession = await _oauthClient!.refresh(_oauthSession!); 528 - _oauthSession = refreshedSession; 788 + final refreshed = await _refreshAuthState(); 789 + if (!refreshed) { 790 + await _clearSessionState(preserveRegistration: true); 791 + } 792 + 793 + return refreshed; 794 + } 795 + } 796 + 797 + Future<({String did, String handle})> _defaultFetchSessionInfo( 798 + ATProto atproto, 799 + ) async { 800 + final sessionResponse = await atproto.server.getSession(); 801 + return (did: sessionResponse.data.did, handle: sessionResponse.data.handle); 802 + } 529 803 530 - final pdsHost = _pdsEndpoint != null 531 - ? Uri.parse(_pdsEndpoint!).host 532 - : null; 533 - _atProto = ATProto.fromOAuthSession(_oauthSession!, service: pdsHost); 804 + SparkLogger _buildLogger() { 805 + final getIt = GetIt.instance; 806 + if (getIt.isRegistered<LogService>()) { 807 + return getIt<LogService>().getLogger('AuthRepository'); 808 + } 534 809 535 - await _saveSession(); 536 - return true; 537 - } catch (e) { 538 - _logger.e('OAuth token refresh failed', error: e); 539 - // Don't logout here - let the caller decide what to do 540 - return false; 810 + return SparkLogger(name: 'AuthRepository'); 811 + } 812 + 813 + Uri _normalizeBaseUri(String rawValue) { 814 + final uri = Uri.parse(rawValue); 815 + final trimmedPath = uri.path.length > 1 && uri.path.endsWith('/') 816 + ? uri.path.substring(0, uri.path.length - 1) 817 + : uri.path; 818 + return uri.replace(path: trimmedPath); 819 + } 820 + 821 + Map<String, dynamic> _decodeJsonObject(String body) { 822 + final decoded = json.decode(body); 823 + if (decoded is! Map<String, dynamic>) { 824 + throw const FormatException('Expected a JSON object.'); 825 + } 826 + 827 + return decoded; 828 + } 829 + 830 + String _responseError(http.Response response) { 831 + try { 832 + final body = _decodeJsonObject(response.body); 833 + final description = body['error_description'] ?? body['error']; 834 + if (description is String && description.isNotEmpty) { 835 + return description; 541 836 } 837 + } catch (_) { 838 + if (response.body.trim().isNotEmpty) { 839 + return response.body.trim(); 840 + } 841 + } 842 + 843 + return '${response.statusCode} ${response.reasonPhrase ?? ''}'.trim(); 844 + } 845 + 846 + class _AipOAuthMetadata { 847 + const _AipOAuthMetadata({ 848 + required this.authorizationEndpoint, 849 + required this.tokenEndpoint, 850 + required this.registrationEndpoint, 851 + }); 852 + 853 + factory _AipOAuthMetadata.fromJson( 854 + Map<String, dynamic> json, { 855 + required Uri baseUri, 856 + }) { 857 + Uri resolveEndpoint(String value) { 858 + final endpoint = Uri.parse(value); 859 + if (endpoint.hasScheme) { 860 + return endpoint; 861 + } 862 + 863 + return baseUri.resolveUri(endpoint); 864 + } 865 + 866 + final registrationValue = 867 + json['registration_endpoint'] as String? ?? 868 + baseUri.resolve('/oauth/clients/register').toString(); 869 + 870 + return _AipOAuthMetadata( 871 + authorizationEndpoint: resolveEndpoint( 872 + json['authorization_endpoint'] as String, 873 + ), 874 + tokenEndpoint: resolveEndpoint(json['token_endpoint'] as String), 875 + registrationEndpoint: resolveEndpoint(registrationValue), 876 + ); 877 + } 878 + 879 + final Uri authorizationEndpoint; 880 + final Uri tokenEndpoint; 881 + final Uri registrationEndpoint; 882 + } 883 + 884 + class _AipClientRegistrationResponse { 885 + const _AipClientRegistrationResponse({ 886 + required this.clientId, 887 + this.clientSecret, 888 + this.registrationAccessToken, 889 + this.clientSecretExpiresAt, 890 + }); 891 + 892 + factory _AipClientRegistrationResponse.fromJson(Map<String, dynamic> json) { 893 + return _AipClientRegistrationResponse( 894 + clientId: json['client_id'] as String, 895 + clientSecret: json['client_secret'] as String?, 896 + registrationAccessToken: json['registration_access_token'] as String?, 897 + clientSecretExpiresAt: json['client_secret_expires_at'] as int?, 898 + ); 899 + } 900 + 901 + final String clientId; 902 + final String? clientSecret; 903 + final String? registrationAccessToken; 904 + final int? clientSecretExpiresAt; 905 + 906 + AipClientRegistration toStoredRegistration() { 907 + final secretExpiry = clientSecretExpiresAt; 908 + final expiryDateTime = secretExpiry == null || secretExpiry <= 0 909 + ? null 910 + : DateTime.fromMillisecondsSinceEpoch(secretExpiry * 1000, isUtc: true); 911 + 912 + return AipClientRegistration( 913 + clientId: clientId, 914 + clientSecret: clientSecret, 915 + registrationAccessToken: registrationAccessToken, 916 + clientSecretExpiresAt: expiryDateTime?.toIso8601String(), 917 + ); 542 918 } 543 919 }
+29 -4
lib/src/core/config/app_config.dart
··· 21 21 static String get messagesServiceUrl => 22 22 _getStringValue('MESSAGES_SERVICE_URL', 'http://localhost:3000'); 23 23 24 + /// Base URL for the AIP OAuth server. 25 + static String get aipBaseUrl => _getStringValue( 26 + 'AIP_BASE_URL', 27 + _getStringValue('OAUTH_ISSUER_URL', 'https://auth.sprk.so'), 28 + ); 29 + 24 30 /// Service DID for the chat service (used for service auth). 25 31 static String get chatServiceDid => 26 32 _getStringValue('CHAT_SERVICE_DID', 'did:web:chat.sprk.so'); ··· 47 53 48 54 /// Helper method to retrieve string values from environment with defaults 49 55 static String _getStringValue(String key, String defaultValue) { 50 - return dotenv.env[key] ?? defaultValue; 56 + try { 57 + return dotenv.env[key] ?? defaultValue; 58 + } catch (_) { 59 + return defaultValue; 60 + } 51 61 } 52 62 53 63 /// Helper method to retrieve boolean values from environment with defaults 54 64 static bool _getBoolValue(String key, bool defaultValue) { 55 - final value = dotenv.env[key]?.toLowerCase(); 65 + String? value; 66 + try { 67 + value = dotenv.env[key]?.toLowerCase(); 68 + } catch (_) { 69 + return defaultValue; 70 + } 56 71 if (value == null) return defaultValue; 57 72 return value == 'true' || value == '1' || value == 'yes'; 58 73 } 59 74 60 75 /// Helper method to retrieve integer values from environment with defaults 61 76 static int _getIntValue(String key, int defaultValue) { 62 - final value = dotenv.env[key]; 77 + String? value; 78 + try { 79 + value = dotenv.env[key]; 80 + } catch (_) { 81 + return defaultValue; 82 + } 63 83 if (value == null) return defaultValue; 64 84 return int.tryParse(value) ?? defaultValue; 65 85 } 66 86 67 87 /// Helper method to retrieve double values from environment with defaults 68 88 static double _getDoubleValue(String key, double defaultValue) { 69 - final value = dotenv.env[key]; 89 + String? value; 90 + try { 91 + value = dotenv.env[key]; 92 + } catch (_) { 93 + return defaultValue; 94 + } 70 95 if (value == null) return defaultValue; 71 96 return double.tryParse(value) ?? defaultValue; 72 97 }
+3 -2
lib/src/features/auth/providers/auth_providers.dart
··· 90 90 } 91 91 } 92 92 93 - /// Initiates the OAuth flow without a handle, using a specific service 93 + /// Initiates the OAuth flow without a handle. 94 94 /// 95 - /// [service] - The OAuth service host (e.g., 'pds.sprk.so') 95 + /// [service] is retained as a compatibility parameter and is ignored in 96 + /// AIP-backed auth mode. 96 97 /// 97 98 /// Returns the authorization URL that the user should be redirected to 98 99 Future<String> initiateOAuthWithService(String service) async {
+2 -4
lib/src/features/auth/ui/pages/register_page.dart
··· 29 29 final authNotifier = ref.read(authProvider.notifier); 30 30 31 31 try { 32 - // Initiate OAuth flow without handle, using pds.sprk.so service 33 - final authUrl = await authNotifier.initiateOAuthWithService( 34 - 'pds.sprk.so', 35 - ); 32 + // Start the AIP OAuth flow without a login hint for account creation. 33 + final authUrl = await authNotifier.initiateOAuthWithService(''); 36 34 37 35 if (!mounted) return; 38 36
+10 -2
pubspec.lock
··· 115 115 source: git 116 116 version: "1.2.0" 117 117 atproto_oauth: 118 - dependency: "direct overridden" 118 + dependency: "direct main" 119 119 description: 120 120 path: "packages/atproto_oauth" 121 121 ref: oauth-client-id ··· 452 452 source: hosted 453 453 version: "0.3.5+2" 454 454 crypto: 455 - dependency: transitive 455 + dependency: "direct main" 456 456 description: 457 457 name: crypto 458 458 sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf ··· 1159 1159 url: "https://pub.dev" 1160 1160 source: hosted 1161 1161 version: "2.0.2" 1162 + oauth2: 1163 + dependency: "direct main" 1164 + description: 1165 + name: oauth2 1166 + sha256: "890a032ba1b44fa8dcfeba500e613df0ecbe16aeace13bb0fe1d25eb42cda5b8" 1167 + url: "https://pub.dev" 1168 + source: hosted 1169 + version: "2.0.5" 1162 1170 objective_c: 1163 1171 dependency: transitive 1164 1172 description:
+3
pubspec.yaml
··· 17 17 path: assets 18 18 atproto: ^1.4.1 19 19 atproto_core: ^1.2.0 20 + atproto_oauth: ^0.2.0 20 21 audio_waveforms: ^2.0.2 21 22 audioplayers: ^6.6.0 22 23 auto_route: ^11.1.0 ··· 25 26 cached_network_image: ^3.4.1 26 27 camera: ^0.12.0+1 27 28 collection: ^1.19.1 29 + crypto: ^3.0.7 28 30 firebase_core: ^4.6.0 29 31 firebase_messaging: ^16.1.3 30 32 fluentui_system_icons: ^1.1.273 ··· 54 56 path: ^1.9.1 55 57 path_provider: ^2.1.5 56 58 photo_manager: ^3.9.0 59 + oauth2: ^2.0.3 57 60 pool: ^1.5.2 58 61 posthog_flutter: ^5.23.0 59 62 pro_image_editor: ^12.0.13
+122
test/src/core/auth/data/models/aip_session_response_test.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:spark/src/core/auth/data/models/aip_session_response.dart'; 5 + 6 + void main() { 7 + group('AIP session mapping', () { 8 + test('maps dpop_jwk into atproto.dart key format', () { 9 + final xBytes = List<int>.generate(32, (index) => index + 1); 10 + final yBytes = List<int>.generate(32, (index) => index + 33); 11 + final dBytes = List<int>.generate(32, (index) => index + 65); 12 + final response = _sessionResponse( 13 + accessToken: _jwt(clientId: 'spark-client'), 14 + x: _base64UrlNoPadding(xBytes), 15 + y: _base64UrlNoPadding(yBytes), 16 + d: _base64UrlNoPadding(dBytes), 17 + ); 18 + 19 + final cache = buildPdsSessionCacheFromAipResponse(response); 20 + 21 + expect(cache.publicKey, base64Url.encode([...xBytes, ...yBytes])); 22 + expect(cache.privateKey, base64Url.encode(dBytes)); 23 + }); 24 + 25 + test('accepts empty initial nonce and preserves later nonce updates', () { 26 + final cache = buildPdsSessionCacheFromAipResponse( 27 + _sessionResponse(accessToken: _jwt(clientId: 'spark-client')), 28 + ); 29 + 30 + expect(cache.dpopNonce, isEmpty); 31 + 32 + final restored = restorePdsOAuthSessionFromCache( 33 + cache.copyWith(dpopNonce: 'nonce-123'), 34 + ); 35 + 36 + expect(restored.$dPoPNonce, 'nonce-123'); 37 + }); 38 + 39 + test('normalizes legacy unpadded cached keys on restore', () { 40 + final normalizedCache = buildPdsSessionCacheFromAipResponse( 41 + _sessionResponse(accessToken: _jwt(clientId: 'spark-client')), 42 + ); 43 + final legacyCache = normalizedCache.copyWith( 44 + publicKey: normalizedCache.publicKey.replaceAll('=', ''), 45 + privateKey: normalizedCache.privateKey.replaceAll('=', ''), 46 + ); 47 + 48 + final restored = restorePdsOAuthSessionFromCache(legacyCache); 49 + 50 + expect(restored.$publicKey, normalizedCache.publicKey); 51 + expect(restored.$privateKey, normalizedCache.privateKey); 52 + }); 53 + 54 + test('rejects responses without private DPoP key material', () { 55 + final response = _sessionResponse( 56 + accessToken: _jwt(clientId: 'spark-client'), 57 + d: null, 58 + ); 59 + 60 + expect( 61 + () => buildPdsSessionCacheFromAipResponse(response), 62 + throwsA( 63 + isA<AipExportedSessionException>().having( 64 + (error) => error.message, 65 + 'message', 66 + contains('DPoP private key material'), 67 + ), 68 + ), 69 + ); 70 + }); 71 + 72 + test('rejects exported PDS access tokens without client_id', () { 73 + final response = _sessionResponse(accessToken: _jwt(clientId: null)); 74 + 75 + expect( 76 + () => buildPdsSessionCacheFromAipResponse(response), 77 + throwsA( 78 + isA<AipExportedSessionException>().having( 79 + (error) => error.message, 80 + 'message', 81 + contains('missing client_id'), 82 + ), 83 + ), 84 + ); 85 + }); 86 + }); 87 + } 88 + 89 + AipAtprotocolSessionResponse _sessionResponse({ 90 + required String accessToken, 91 + String? d = 'AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI', 92 + String x = 'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE', 93 + String y = 'AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM', 94 + }) { 95 + return AipAtprotocolSessionResponse( 96 + did: 'did:plc:test', 97 + handle: 'test.sprk.so', 98 + accessToken: accessToken, 99 + tokenType: 'dpop', 100 + scopes: const ['atproto'], 101 + pdsEndpoint: 'https://pds.sprk.so', 102 + expiresAt: DateTime.utc(2030, 1, 1), 103 + dpopKey: 'did:key:test', 104 + dpopJwk: AipDpopJwk(kty: 'EC', crv: 'P-256', x: x, y: y, d: d), 105 + ); 106 + } 107 + 108 + String _jwt({String? clientId}) { 109 + final payload = <String, Object?>{ 110 + 'sub': 'did:plc:test', 111 + 'exp': DateTime.utc(2030, 1, 1).millisecondsSinceEpoch ~/ 1000, 112 + 'iat': DateTime.utc(2029, 1, 1).millisecondsSinceEpoch ~/ 1000, 113 + 'scope': 'atproto', 114 + 'client_id': clientId, 115 + }; 116 + 117 + return '${_base64UrlNoPadding(utf8.encode(json.encode({'alg': 'none', 'typ': 'JWT'})))}.${_base64UrlNoPadding(utf8.encode(json.encode(payload)))}.signature'; 118 + } 119 + 120 + String _base64UrlNoPadding(List<int> value) { 121 + return base64Url.encode(value).replaceAll('=', ''); 122 + }
+747
test/src/core/auth/data/repositories/auth_repository_impl_test.dart
··· 1 + import 'dart:async'; 2 + import 'dart:convert'; 3 + 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:http/http.dart' as http; 6 + import 'package:http/testing.dart'; 7 + import 'package:oauth2/oauth2.dart' as oauth2; 8 + import 'package:spark/src/core/auth/data/models/account.dart'; 9 + import 'package:spark/src/core/auth/data/models/aip_session_response.dart'; 10 + import 'package:spark/src/core/auth/data/models/auth_snapshot.dart'; 11 + import 'package:spark/src/core/auth/data/repositories/auth_repository_impl.dart'; 12 + import 'package:spark/src/core/storage/preferences/local_storage_interface.dart'; 13 + import 'package:spark/src/core/storage/preferences/storage_constants.dart'; 14 + import 'package:spark/src/core/utils/logging/logger.dart'; 15 + 16 + void main() { 17 + group('AuthRepositoryImpl', () { 18 + test('startup with valid cached PDS session does not call AIP', () async { 19 + final storage = _InMemoryStorage(); 20 + await _storeSnapshot( 21 + storage, 22 + AuthSnapshot( 23 + aipClientRegistration: const AipClientRegistration( 24 + clientId: 'client-1', 25 + ), 26 + pdsSessionCache: _pdsSessionCache( 27 + accessToken: _pdsJwt(clientId: 'client-1'), 28 + expiresAt: DateTime.utc(2030, 1, 1), 29 + ), 30 + ), 31 + ); 32 + 33 + var networkCalls = 0; 34 + final client = MockClient((request) async { 35 + networkCalls += 1; 36 + return http.Response('unexpected request', 500); 37 + }); 38 + 39 + final repository = AuthRepositoryImpl( 40 + secureStorage: storage, 41 + httpClient: client, 42 + logger: SparkLogger(name: 'AuthRepositoryTest'), 43 + ); 44 + 45 + await repository.initializationComplete; 46 + 47 + expect(repository.isAuthenticated, isTrue); 48 + expect(repository.did, 'did:plc:test'); 49 + expect(networkCalls, 0); 50 + }); 51 + 52 + test( 53 + 'startup clears legacy account payload and requires re-login', 54 + () async { 55 + final storage = _InMemoryStorage(); 56 + final cache = _pdsSessionCache( 57 + accessToken: _pdsJwt(clientId: 'client-1'), 58 + expiresAt: DateTime.utc(2030, 1, 1), 59 + ); 60 + await storage.setString( 61 + StorageKeys.account, 62 + Account( 63 + accessToken: cache.accessToken, 64 + refreshToken: 'legacy-refresh', 65 + publicKey: cache.publicKey, 66 + privateKey: cache.privateKey, 67 + clientId: 'client-1', 68 + dpopNonce: cache.dpopNonce, 69 + expiresAt: cache.expiresAt, 70 + did: cache.did, 71 + handle: cache.handle, 72 + pdsEndpoint: cache.pdsEndpoint, 73 + server: 'https://auth.sprk.so', 74 + ).toJsonString(), 75 + ); 76 + 77 + final repository = AuthRepositoryImpl( 78 + secureStorage: storage, 79 + httpClient: MockClient((_) async => http.Response('unexpected', 500)), 80 + logger: SparkLogger(name: 'AuthRepositoryTest'), 81 + ); 82 + 83 + await repository.initializationComplete; 84 + 85 + expect(repository.isAuthenticated, isFalse); 86 + expect(repository.did, isNull); 87 + expect(await storage.getString(StorageKeys.account), isNull); 88 + }, 89 + ); 90 + 91 + test('startup with stale cached PDS session refreshes via AIP', () async { 92 + final storage = _InMemoryStorage(); 93 + await _storeSnapshot( 94 + storage, 95 + AuthSnapshot( 96 + aipClientRegistration: const AipClientRegistration( 97 + clientId: 'client-1', 98 + ), 99 + aipGrant: AipGrant( 100 + credentialsJson: oauth2.Credentials( 101 + 'aip-access', 102 + expiration: DateTime.utc(2030, 1, 1), 103 + ).toJson(), 104 + ), 105 + pdsSessionCache: _pdsSessionCache( 106 + accessToken: _pdsJwt( 107 + clientId: 'client-1', 108 + exp: DateTime.utc(2020, 1, 1), 109 + ), 110 + expiresAt: DateTime.utc(2020, 1, 1), 111 + ), 112 + ), 113 + ); 114 + 115 + var sessionCalls = 0; 116 + final client = MockClient((request) async { 117 + if (request.url.path == '/api/atprotocol/session') { 118 + sessionCalls += 1; 119 + expect(request.headers['authorization'], 'Bearer aip-access'); 120 + return http.Response( 121 + json.encode(_sessionResponseBody(_pdsJwt(clientId: 'client-1'))), 122 + 200, 123 + ); 124 + } 125 + 126 + return http.Response('unexpected request', 500); 127 + }); 128 + 129 + final repository = AuthRepositoryImpl( 130 + secureStorage: storage, 131 + httpClient: client, 132 + logger: SparkLogger(name: 'AuthRepositoryTest'), 133 + ); 134 + 135 + await repository.initializationComplete; 136 + 137 + expect(repository.isAuthenticated, isTrue); 138 + expect(repository.pdsEndpoint, 'https://pds.sprk.so'); 139 + expect(sessionCalls, 1); 140 + }); 141 + 142 + test( 143 + 'refreshToken refreshes AIP grant before fetching a new PDS session', 144 + () async { 145 + final storage = _InMemoryStorage(); 146 + await _storeSnapshot( 147 + storage, 148 + AuthSnapshot( 149 + aipClientRegistration: const AipClientRegistration( 150 + clientId: 'client-1', 151 + clientSecret: 'secret-1', 152 + ), 153 + aipGrant: AipGrant( 154 + credentialsJson: oauth2.Credentials( 155 + 'expired-aip-access', 156 + refreshToken: 'refresh-1', 157 + tokenEndpoint: Uri.parse('https://auth.sprk.so/oauth/token'), 158 + expiration: DateTime.utc(2020, 1, 1), 159 + ).toJson(), 160 + ), 161 + pdsSessionCache: _pdsSessionCache( 162 + accessToken: _pdsJwt(clientId: 'client-1'), 163 + expiresAt: DateTime.utc(2030, 1, 1), 164 + ), 165 + ), 166 + ); 167 + 168 + var tokenCalls = 0; 169 + var sessionCalls = 0; 170 + final client = MockClient((request) async { 171 + if (request.url.path == '/oauth/token') { 172 + tokenCalls += 1; 173 + return http.Response( 174 + json.encode({ 175 + 'access_token': 'fresh-aip-access', 176 + 'refresh_token': 'refresh-2', 177 + 'token_type': 'Bearer', 178 + 'expires_in': 3600, 179 + }), 180 + 200, 181 + headers: {'content-type': 'application/json'}, 182 + ); 183 + } 184 + 185 + if (request.url.path == '/api/atprotocol/session') { 186 + sessionCalls += 1; 187 + expect(request.headers['authorization'], 'Bearer fresh-aip-access'); 188 + return http.Response( 189 + json.encode(_sessionResponseBody(_pdsJwt(clientId: 'client-1'))), 190 + 200, 191 + ); 192 + } 193 + 194 + return http.Response('unexpected request', 500); 195 + }); 196 + 197 + final repository = AuthRepositoryImpl( 198 + secureStorage: storage, 199 + httpClient: client, 200 + logger: SparkLogger(name: 'AuthRepositoryTest'), 201 + ); 202 + 203 + await repository.initializationComplete; 204 + final refreshed = await repository.refreshToken(); 205 + 206 + expect(refreshed, isTrue); 207 + expect(tokenCalls, 1); 208 + expect(sessionCalls, 1); 209 + 210 + final savedSnapshot = AuthSnapshot.fromJsonString( 211 + (await storage.getString(StorageKeys.account))!, 212 + ); 213 + expect(savedSnapshot.aipGrant, isNotNull); 214 + expect( 215 + savedSnapshot.aipGrant!.credentialsJson, 216 + contains('fresh-aip-access'), 217 + ); 218 + }, 219 + ); 220 + 221 + test('concurrent refreshToken calls share one in-flight refresh', () async { 222 + final storage = _InMemoryStorage(); 223 + await _storeSnapshot( 224 + storage, 225 + AuthSnapshot( 226 + aipClientRegistration: const AipClientRegistration( 227 + clientId: 'client-1', 228 + clientSecret: 'secret-1', 229 + ), 230 + aipGrant: AipGrant( 231 + credentialsJson: oauth2.Credentials( 232 + 'expired-aip-access', 233 + refreshToken: 'refresh-1', 234 + tokenEndpoint: Uri.parse('https://auth.sprk.so/oauth/token'), 235 + expiration: DateTime.utc(2020, 1, 1), 236 + ).toJson(), 237 + ), 238 + pdsSessionCache: _pdsSessionCache( 239 + accessToken: _pdsJwt(clientId: 'client-1'), 240 + expiresAt: DateTime.utc(2030, 1, 1), 241 + ), 242 + ), 243 + ); 244 + 245 + final tokenRequestStarted = Completer<void>(); 246 + final releaseTokenRefresh = Completer<void>(); 247 + var tokenCalls = 0; 248 + var sessionCalls = 0; 249 + final client = MockClient((request) async { 250 + if (request.url.path == '/oauth/token') { 251 + tokenCalls += 1; 252 + if (!tokenRequestStarted.isCompleted) { 253 + tokenRequestStarted.complete(); 254 + } 255 + await releaseTokenRefresh.future; 256 + return http.Response( 257 + json.encode({ 258 + 'access_token': 'fresh-aip-access', 259 + 'refresh_token': 'refresh-2', 260 + 'token_type': 'Bearer', 261 + 'expires_in': 3600, 262 + }), 263 + 200, 264 + headers: {'content-type': 'application/json'}, 265 + ); 266 + } 267 + 268 + if (request.url.path == '/api/atprotocol/session') { 269 + sessionCalls += 1; 270 + expect(request.headers['authorization'], 'Bearer fresh-aip-access'); 271 + return http.Response( 272 + json.encode(_sessionResponseBody(_pdsJwt(clientId: 'client-1'))), 273 + 200, 274 + ); 275 + } 276 + 277 + return http.Response('unexpected request', 500); 278 + }); 279 + 280 + final repository = AuthRepositoryImpl( 281 + secureStorage: storage, 282 + httpClient: client, 283 + logger: SparkLogger(name: 'AuthRepositoryTest'), 284 + ); 285 + 286 + await repository.initializationComplete; 287 + 288 + final firstRefresh = repository.refreshToken(); 289 + await tokenRequestStarted.future; 290 + final secondRefresh = repository.refreshToken(); 291 + 292 + await Future<void>.delayed(Duration.zero); 293 + releaseTokenRefresh.complete(); 294 + 295 + final results = await Future.wait([firstRefresh, secondRefresh]); 296 + 297 + expect(results, everyElement(isTrue)); 298 + expect(tokenCalls, 1); 299 + expect(sessionCalls, 1); 300 + }); 301 + 302 + test('logout invalidates an in-flight refresh', () async { 303 + final storage = _InMemoryStorage(); 304 + await _storeSnapshot( 305 + storage, 306 + AuthSnapshot( 307 + aipClientRegistration: const AipClientRegistration( 308 + clientId: 'client-1', 309 + clientSecret: 'secret-1', 310 + ), 311 + aipGrant: AipGrant( 312 + credentialsJson: oauth2.Credentials( 313 + 'expired-aip-access', 314 + refreshToken: 'refresh-1', 315 + tokenEndpoint: Uri.parse('https://auth.sprk.so/oauth/token'), 316 + expiration: DateTime.utc(2020, 1, 1), 317 + ).toJson(), 318 + ), 319 + pdsSessionCache: _pdsSessionCache( 320 + accessToken: _pdsJwt(clientId: 'client-1'), 321 + expiresAt: DateTime.utc(2030, 1, 1), 322 + ), 323 + ), 324 + ); 325 + 326 + final tokenRequestStarted = Completer<void>(); 327 + final releaseTokenRefresh = Completer<void>(); 328 + var tokenCalls = 0; 329 + var sessionCalls = 0; 330 + final client = MockClient((request) async { 331 + if (request.url.path == '/oauth/token') { 332 + tokenCalls += 1; 333 + if (!tokenRequestStarted.isCompleted) { 334 + tokenRequestStarted.complete(); 335 + } 336 + await releaseTokenRefresh.future; 337 + return http.Response( 338 + json.encode({ 339 + 'access_token': 'fresh-aip-access', 340 + 'refresh_token': 'refresh-2', 341 + 'token_type': 'Bearer', 342 + 'expires_in': 3600, 343 + }), 344 + 200, 345 + headers: {'content-type': 'application/json'}, 346 + ); 347 + } 348 + 349 + if (request.url.path == '/api/atprotocol/session') { 350 + sessionCalls += 1; 351 + return http.Response( 352 + json.encode(_sessionResponseBody(_pdsJwt(clientId: 'client-1'))), 353 + 200, 354 + ); 355 + } 356 + 357 + return http.Response('unexpected request', 500); 358 + }); 359 + 360 + final repository = AuthRepositoryImpl( 361 + secureStorage: storage, 362 + httpClient: client, 363 + logger: SparkLogger(name: 'AuthRepositoryTest'), 364 + ); 365 + 366 + await repository.initializationComplete; 367 + 368 + final refreshFuture = repository.refreshToken(); 369 + await tokenRequestStarted.future; 370 + final logoutFuture = repository.logout(); 371 + 372 + await Future<void>.delayed(Duration.zero); 373 + releaseTokenRefresh.complete(); 374 + 375 + final refreshed = await refreshFuture; 376 + await logoutFuture; 377 + 378 + expect(refreshed, isFalse); 379 + expect(tokenCalls, 1); 380 + expect(sessionCalls, 0); 381 + expect(repository.isAuthenticated, isFalse); 382 + 383 + final savedSnapshot = AuthSnapshot.fromJsonString( 384 + (await storage.getString(StorageKeys.account))!, 385 + ); 386 + expect(savedSnapshot.aipClientRegistration?.clientId, 'client-1'); 387 + expect(savedSnapshot.aipGrant, isNull); 388 + expect(savedSnapshot.pdsSessionCache, isNull); 389 + }); 390 + 391 + test('validateSession reboots from AIP after direct PDS failure', () async { 392 + final storage = _InMemoryStorage(); 393 + await _storeSnapshot( 394 + storage, 395 + AuthSnapshot( 396 + aipClientRegistration: const AipClientRegistration( 397 + clientId: 'client-1', 398 + ), 399 + aipGrant: AipGrant( 400 + credentialsJson: oauth2.Credentials( 401 + 'aip-access', 402 + expiration: DateTime.utc(2030, 1, 1), 403 + ).toJson(), 404 + ), 405 + pdsSessionCache: _pdsSessionCache( 406 + accessToken: _pdsJwt(clientId: 'client-1'), 407 + expiresAt: DateTime.utc(2030, 1, 1), 408 + ), 409 + ), 410 + ); 411 + 412 + var sessionCalls = 0; 413 + var fetchCalls = 0; 414 + final client = MockClient((request) async { 415 + if (request.url.path == '/api/atprotocol/session') { 416 + sessionCalls += 1; 417 + return http.Response( 418 + json.encode(_sessionResponseBody(_pdsJwt(clientId: 'client-1'))), 419 + 200, 420 + ); 421 + } 422 + 423 + return http.Response('unexpected request', 500); 424 + }); 425 + 426 + final repository = AuthRepositoryImpl( 427 + secureStorage: storage, 428 + httpClient: client, 429 + logger: SparkLogger(name: 'AuthRepositoryTest'), 430 + fetchSessionInfo: (_) async { 431 + fetchCalls += 1; 432 + if (fetchCalls == 1) { 433 + throw Exception('Unauthorized'); 434 + } 435 + 436 + return (did: 'did:plc:test', handle: 'updated.sprk.so'); 437 + }, 438 + ); 439 + 440 + await repository.initializationComplete; 441 + final isValid = await repository.validateSession(); 442 + 443 + expect(isValid, isTrue); 444 + expect(fetchCalls, 2); 445 + expect(sessionCalls, 1); 446 + expect(repository.handle, 'updated.sprk.so'); 447 + }); 448 + 449 + test( 450 + 'logout clears active auth state but preserves registration', 451 + () async { 452 + final storage = _InMemoryStorage(); 453 + await _storeSnapshot( 454 + storage, 455 + AuthSnapshot( 456 + aipClientRegistration: const AipClientRegistration( 457 + clientId: 'client-1', 458 + clientSecret: 'secret-1', 459 + ), 460 + aipGrant: AipGrant( 461 + credentialsJson: oauth2.Credentials( 462 + 'aip-access', 463 + expiration: DateTime.utc(2030, 1, 1), 464 + ).toJson(), 465 + ), 466 + pdsSessionCache: _pdsSessionCache( 467 + accessToken: _pdsJwt(clientId: 'client-1'), 468 + expiresAt: DateTime.utc(2030, 1, 1), 469 + ), 470 + ), 471 + ); 472 + 473 + final repository = AuthRepositoryImpl( 474 + secureStorage: storage, 475 + httpClient: MockClient((_) async => http.Response('unexpected', 500)), 476 + logger: SparkLogger(name: 'AuthRepositoryTest'), 477 + ); 478 + 479 + await repository.initializationComplete; 480 + await repository.logout(); 481 + 482 + expect(repository.isAuthenticated, isFalse); 483 + 484 + final savedSnapshot = AuthSnapshot.fromJsonString( 485 + (await storage.getString(StorageKeys.account))!, 486 + ); 487 + expect(savedSnapshot.aipClientRegistration?.clientId, 'client-1'); 488 + expect(savedSnapshot.aipGrant, isNull); 489 + expect(savedSnapshot.pdsSessionCache, isNull); 490 + }, 491 + ); 492 + 493 + test( 494 + 'initiateOAuth and completeOAuth bootstrap a direct PDS session', 495 + () async { 496 + final storage = _InMemoryStorage(); 497 + var registrationCalls = 0; 498 + var tokenCalls = 0; 499 + var sessionCalls = 0; 500 + 501 + final client = MockClient((request) async { 502 + switch (request.url.path) { 503 + case '/.well-known/oauth-authorization-server': 504 + return http.Response( 505 + json.encode({ 506 + 'authorization_endpoint': 507 + 'https://auth.sprk.so/oauth/authorize', 508 + 'token_endpoint': 'https://auth.sprk.so/oauth/token', 509 + 'registration_endpoint': 510 + 'https://auth.sprk.so/oauth/clients/register', 511 + }), 512 + 200, 513 + ); 514 + case '/oauth/clients/register': 515 + registrationCalls += 1; 516 + final registrationBody = 517 + json.decode(request.body) as Map<String, dynamic>; 518 + expect( 519 + registrationBody['grant_types'], 520 + containsAll(<String>['authorization_code', 'refresh_token']), 521 + ); 522 + return http.Response( 523 + json.encode({ 524 + 'client_id': 'client-1', 525 + 'client_secret': 'secret-1', 526 + 'registration_access_token': 'reg-token', 527 + }), 528 + 201, 529 + ); 530 + case '/oauth/token': 531 + tokenCalls += 1; 532 + return http.Response( 533 + json.encode({ 534 + 'access_token': 'aip-access', 535 + 'refresh_token': 'aip-refresh', 536 + 'token_type': 'Bearer', 537 + 'expires_in': 3600, 538 + }), 539 + 200, 540 + headers: {'content-type': 'application/json'}, 541 + ); 542 + case '/api/atprotocol/session': 543 + sessionCalls += 1; 544 + return http.Response( 545 + json.encode( 546 + _sessionResponseBody(_pdsJwt(clientId: 'client-1')), 547 + ), 548 + 200, 549 + ); 550 + default: 551 + return http.Response('unexpected request', 500); 552 + } 553 + }); 554 + 555 + final repository = AuthRepositoryImpl( 556 + secureStorage: storage, 557 + httpClient: client, 558 + logger: SparkLogger(name: 'AuthRepositoryTest'), 559 + ); 560 + 561 + await repository.initializationComplete; 562 + final authUrl = await repository.initiateOAuth('alice.sprk.so'); 563 + final authUri = Uri.parse(authUrl); 564 + 565 + expect(authUri.queryParameters['login_hint'], 'alice.sprk.so'); 566 + expect(authUri.queryParameters['code_challenge'], isNotEmpty); 567 + expect(authUri.queryParameters['state'], isNotEmpty); 568 + 569 + final callbackUrl = Uri.parse(_redirectUri) 570 + .replace( 571 + queryParameters: { 572 + 'code': 'code-123', 573 + 'state': authUri.queryParameters['state']!, 574 + }, 575 + ) 576 + .toString(); 577 + final result = await repository.completeOAuth(callbackUrl); 578 + 579 + expect(result.isSuccess, isTrue); 580 + expect(repository.isAuthenticated, isTrue); 581 + expect(registrationCalls, 1); 582 + expect(tokenCalls, 1); 583 + expect(sessionCalls, 1); 584 + }, 585 + ); 586 + 587 + test('initiateOAuthWithService omits login_hint in AIP mode', () async { 588 + final storage = _InMemoryStorage(); 589 + final client = MockClient((request) async { 590 + switch (request.url.path) { 591 + case '/.well-known/oauth-authorization-server': 592 + return http.Response( 593 + json.encode({ 594 + 'authorization_endpoint': 595 + 'https://auth.sprk.so/oauth/authorize', 596 + 'token_endpoint': 'https://auth.sprk.so/oauth/token', 597 + 'registration_endpoint': 598 + 'https://auth.sprk.so/oauth/clients/register', 599 + }), 600 + 200, 601 + ); 602 + case '/oauth/clients/register': 603 + return http.Response(json.encode({'client_id': 'client-1'}), 201); 604 + default: 605 + return http.Response('unexpected request', 500); 606 + } 607 + }); 608 + 609 + final repository = AuthRepositoryImpl( 610 + secureStorage: storage, 611 + httpClient: client, 612 + logger: SparkLogger(name: 'AuthRepositoryTest'), 613 + ); 614 + 615 + await repository.initializationComplete; 616 + final authUrl = await repository.initiateOAuthWithService('pds.sprk.so'); 617 + 618 + expect( 619 + Uri.parse(authUrl).queryParameters.containsKey('login_hint'), 620 + isFalse, 621 + ); 622 + }); 623 + }); 624 + } 625 + 626 + const String _redirectUri = 'sprk://oauth-callback'; 627 + 628 + Future<void> _storeSnapshot( 629 + _InMemoryStorage storage, 630 + AuthSnapshot snapshot, 631 + ) async { 632 + await storage.setString(StorageKeys.account, snapshot.toJsonString()); 633 + } 634 + 635 + PdsSessionCache _pdsSessionCache({ 636 + required String accessToken, 637 + required DateTime expiresAt, 638 + }) { 639 + return buildPdsSessionCacheFromAipResponse( 640 + AipAtprotocolSessionResponse.fromJson( 641 + _sessionResponseBody(accessToken) 642 + ..['expires_at'] = expiresAt.millisecondsSinceEpoch ~/ 1000, 643 + ), 644 + ); 645 + } 646 + 647 + Map<String, dynamic> _sessionResponseBody(String accessToken) { 648 + return { 649 + 'did': 'did:plc:test', 650 + 'handle': 'test.sprk.so', 651 + 'access_token': accessToken, 652 + 'token_type': 'dpop', 653 + 'scopes': ['atproto'], 654 + 'pds_endpoint': 'https://pds.sprk.so', 655 + 'dpop_key': 'did:key:test', 656 + 'dpop_jwk': { 657 + 'kty': 'EC', 658 + 'crv': 'P-256', 659 + 'x': _base64UrlNoPadding(List<int>.generate(32, (index) => index + 1)), 660 + 'y': _base64UrlNoPadding(List<int>.generate(32, (index) => index + 33)), 661 + 'd': _base64UrlNoPadding(List<int>.generate(32, (index) => index + 65)), 662 + }, 663 + 'expires_at': DateTime.utc(2030, 1, 1).millisecondsSinceEpoch ~/ 1000, 664 + }; 665 + } 666 + 667 + String _pdsJwt({required String clientId, DateTime? exp}) { 668 + final payload = <String, Object?>{ 669 + 'sub': 'did:plc:test', 670 + 'exp': (exp ?? DateTime.utc(2030, 1, 1)).millisecondsSinceEpoch ~/ 1000, 671 + 'iat': DateTime.utc(2029, 1, 1).millisecondsSinceEpoch ~/ 1000, 672 + 'scope': 'atproto', 673 + 'client_id': clientId, 674 + }; 675 + 676 + return '${_base64UrlNoPadding(utf8.encode(json.encode({'alg': 'none', 'typ': 'JWT'})))}.${_base64UrlNoPadding(utf8.encode(json.encode(payload)))}.signature'; 677 + } 678 + 679 + String _base64UrlNoPadding(List<int> value) { 680 + return base64Url.encode(value).replaceAll('=', ''); 681 + } 682 + 683 + class _InMemoryStorage implements LocalStorageInterface { 684 + final Map<String, Object?> _values = <String, Object?>{}; 685 + 686 + @override 687 + Future<void> clear() async { 688 + _values.clear(); 689 + } 690 + 691 + @override 692 + Future<bool> containsKey(String key) async => _values.containsKey(key); 693 + 694 + @override 695 + Future<bool?> getBool(String key) async => _values[key] as bool?; 696 + 697 + @override 698 + Future<double?> getDouble(String key) async => _values[key] as double?; 699 + 700 + @override 701 + Future<int?> getInt(String key) async => _values[key] as int?; 702 + 703 + @override 704 + Future<T?> getObject<T>(String key) async => _values[key] as T?; 705 + 706 + @override 707 + Future<String?> getString(String key) async => _values[key] as String?; 708 + 709 + @override 710 + Future<List<String>?> getStringList(String key) async => 711 + (_values[key] as List<dynamic>?)?.cast<String>(); 712 + 713 + @override 714 + Future<void> remove(String key) async { 715 + _values.remove(key); 716 + } 717 + 718 + @override 719 + Future<void> setBool(String key, bool value) async { 720 + _values[key] = value; 721 + } 722 + 723 + @override 724 + Future<void> setDouble(String key, double value) async { 725 + _values[key] = value; 726 + } 727 + 728 + @override 729 + Future<void> setInt(String key, int value) async { 730 + _values[key] = value; 731 + } 732 + 733 + @override 734 + Future<void> setObject<T>(String key, T value) async { 735 + _values[key] = value; 736 + } 737 + 738 + @override 739 + Future<void> setString(String key, String value) async { 740 + _values[key] = value; 741 + } 742 + 743 + @override 744 + Future<void> setStringList(String key, List<String> value) async { 745 + _values[key] = List<String>.from(value); 746 + } 747 + }