mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

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

fix: auth host resolution

+229 -10
+105 -10
lib/features/auth/data/auth_repository.dart
··· 18 18 typedef LaunchUrlWithMode = Future<bool> Function(Uri url, LaunchMode mode); 19 19 typedef CloseInAppBrowser = Future<void> Function(); 20 20 typedef SupportsCloseForMode = Future<bool> Function(LaunchMode mode); 21 + typedef OAuthRefreshSession = 22 + Future<OAuthSession> Function({ 23 + required OAuthClientMetadata metadata, 24 + required String service, 25 + required OAuthSession session, 26 + }); 21 27 22 28 class AuthRepository { 23 29 AuthRepository({ ··· 25 31 LaunchUrlWithMode launchUrlWithMode = _defaultLaunchUrlWithMode, 26 32 CloseInAppBrowser closeInAppBrowser = closeInAppWebView, 27 33 SupportsCloseForMode supportsCloseForMode = supportsCloseForLaunchMode, 34 + OAuthRefreshSession oauthRefreshSession = _defaultOAuthRefreshSession, 35 + Future<OAuthClientMetadata> Function(String clientId) loadClientMetadata = getClientMetadata, 28 36 }) : _database = database, 29 37 _launchUrlWithMode = launchUrlWithMode, 30 38 _closeInAppBrowser = closeInAppBrowser, 31 - _supportsCloseForMode = supportsCloseForMode; 39 + _supportsCloseForMode = supportsCloseForMode, 40 + _oauthRefreshSession = oauthRefreshSession, 41 + _loadClientMetadata = loadClientMetadata; 32 42 33 43 static const String kClientId = 'https://lazurite.stormlightlabs.org/client-metadata.json'; 34 44 static const String _oauthService = 'bsky.social'; ··· 39 49 final LaunchUrlWithMode _launchUrlWithMode; 40 50 final CloseInAppBrowser _closeInAppBrowser; 41 51 final SupportsCloseForMode _supportsCloseForMode; 52 + final OAuthRefreshSession _oauthRefreshSession; 53 + final Future<OAuthClientMetadata> Function(String clientId) _loadClientMetadata; 42 54 43 55 HttpServer? _callbackServer; 44 56 StreamSubscription<HttpRequest>? _callbackSubscription; ··· 135 147 _pendingService = _oauthService; 136 148 log.i('AuthRepository: Starting OAuth login for ${_pendingHandle!}'); 137 149 138 - final metadata = await getClientMetadata(kClientId); 150 + final metadata = await _loadClientMetadata(kClientId); 139 151 log.d('AuthRepository: Loaded client metadata with redirect URIs: ${metadata.redirectUris.join(', ')}'); 140 152 final redirectUriTemplate = Uri.parse(metadata.redirectUris.first); 141 153 final redirectUri = await _startCallbackServer(redirectUriTemplate); ··· 202 214 } 203 215 204 216 try { 205 - final metadata = await getClientMetadata(kClientId); 206 - final oauthClient = OAuthClient(metadata, service: currentSession.service ?? _fallbackService); 207 - final restoredSession = atcore.restoreOAuthSession( 208 - accessToken: currentSession.accessToken, 209 - refreshToken: currentSession.refreshToken!, 210 - dPoPNonce: currentSession.dpopNonce, 217 + final metadata = await _loadClientMetadata(kClientId); 218 + final restoredSession = _restoreOAuthSession( 219 + currentSession: currentSession, 211 220 publicKey: publicKey, 212 221 privateKey: privateKey, 213 222 ); 223 + final oauthServices = _oauthRefreshServiceCandidates( 224 + storedService: currentSession.service, 225 + issuer: restoredSession.accessTokenJwt.iss, 226 + ); 214 227 215 - final refreshedSession = await oauthClient.refresh(restoredSession); 228 + Object? lastAttemptError; 229 + StackTrace? lastAttemptStackTrace; 230 + OAuthSession? refreshedSession; 231 + for (final oauthService in oauthServices) { 232 + try { 233 + log.d('AuthRepository: Attempting OAuth refresh using auth service $oauthService'); 234 + refreshedSession = await _oauthRefreshSession( 235 + metadata: metadata, 236 + service: oauthService, 237 + session: _restoreOAuthSession( 238 + currentSession: currentSession, 239 + publicKey: publicKey, 240 + privateKey: privateKey, 241 + ), 242 + ); 243 + break; 244 + } catch (error, stackTrace) { 245 + lastAttemptError = error; 246 + lastAttemptStackTrace = stackTrace; 247 + log.w( 248 + 'AuthRepository: OAuth refresh attempt failed using auth service $oauthService', 249 + error: error, 250 + stackTrace: stackTrace, 251 + ); 252 + } 253 + } 254 + 255 + if (refreshedSession == null) { 256 + Error.throwWithStackTrace( 257 + Exception( 258 + 'OAuth refresh failed across ${oauthServices.length} auth service candidate(s). ' 259 + 'Last error: $lastAttemptError', 260 + ), 261 + lastAttemptStackTrace ?? StackTrace.current, 262 + ); 263 + } 264 + 265 + final fallbackPdsHost = normalizeAtprotoServiceHost(currentSession.service) ?? _fallbackService; 216 266 final refreshedTokens = await _buildOAuthTokens( 217 267 refreshedSession, 218 268 fallbackHandle: currentSession.handle, 219 - oauthService: currentSession.service ?? _fallbackService, 269 + oauthService: fallbackPdsHost, 220 270 ); 221 271 222 272 await saveSession( ··· 625 675 626 676 @visibleForTesting 627 677 String buildCallbackPageHtmlForTest() => _buildCallbackPageHtml(_appReopenUri); 678 + 679 + OAuthSession _restoreOAuthSession({ 680 + required AuthTokens currentSession, 681 + required String publicKey, 682 + required String privateKey, 683 + }) { 684 + return atcore.restoreOAuthSession( 685 + accessToken: currentSession.accessToken, 686 + refreshToken: currentSession.refreshToken!, 687 + dPoPNonce: currentSession.dpopNonce, 688 + publicKey: publicKey, 689 + privateKey: privateKey, 690 + ); 691 + } 692 + 693 + static Future<OAuthSession> _defaultOAuthRefreshSession({ 694 + required OAuthClientMetadata metadata, 695 + required String service, 696 + required OAuthSession session, 697 + }) { 698 + final oauthClient = OAuthClient(metadata, service: service); 699 + return oauthClient.refresh(session); 700 + } 701 + 702 + static List<String> _oauthRefreshServiceCandidates({required String? storedService, required String? issuer}) { 703 + final candidates = <String>{}; 704 + final issuerHost = normalizeAtprotoServiceHost(issuer); 705 + if (issuerHost != null) { 706 + candidates.add(issuerHost); 707 + } 708 + 709 + final storedHost = normalizeAtprotoServiceHost(storedService); 710 + if (storedHost != null) { 711 + candidates.add(storedHost); 712 + } 713 + 714 + candidates.add(_oauthService); 715 + candidates.add(_fallbackService); 716 + return candidates.toList(growable: false); 717 + } 718 + 719 + @visibleForTesting 720 + static List<String> oauthRefreshServiceCandidatesForTest({required String? storedService, required String? issuer}) { 721 + return _oauthRefreshServiceCandidates(storedService: storedService, issuer: issuer); 722 + } 628 723 629 724 @visibleForTesting 630 725 Future<Uri> startCallbackServerForTest(Uri redirectUriTemplate) => _startCallbackServer(redirectUriTemplate);
+124
test/features/auth/data/auth_repository_test.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:atproto_oauth/atproto_oauth.dart'; 1 4 import 'package:flutter/foundation.dart'; 2 5 import 'package:flutter_test/flutter_test.dart'; 3 6 import 'package:lazurite/core/database/app_database.dart'; ··· 125 128 }); 126 129 }); 127 130 131 + group('oauth refresh', () { 132 + test('orders issuer host before stored host and deduplicates candidates', () { 133 + final candidates = AuthRepository.oauthRefreshServiceCandidatesForTest( 134 + storedService: 'https://porcini.us-east.host.bsky.network', 135 + issuer: 'https://bsky.social', 136 + ); 137 + 138 + expect(candidates, equals(['bsky.social', 'porcini.us-east.host.bsky.network'])); 139 + }); 140 + 141 + test('retries OAuth refresh against fallback auth service hosts', () async { 142 + final attemptedServices = <String>[]; 143 + final nowEpochSeconds = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; 144 + final expiredAccessToken = _buildJwt( 145 + sub: 'did:plc:abc123', 146 + expEpochSeconds: nowEpochSeconds - 3600, 147 + iatEpochSeconds: nowEpochSeconds - 7200, 148 + aud: 'did:web:porcini.us-east.host.bsky.network', 149 + ); 150 + final refreshedAccessToken = _buildJwt( 151 + sub: 'did:plc:abc123', 152 + expEpochSeconds: nowEpochSeconds + 3600, 153 + iatEpochSeconds: nowEpochSeconds, 154 + aud: 'did:web:porcini.us-east.host.bsky.network', 155 + iss: 'https://bsky.social', 156 + ); 157 + 158 + authRepository = AuthRepository( 159 + database: mockDatabase, 160 + loadClientMetadata: (_) async => _testClientMetadata(), 161 + oauthRefreshSession: 162 + ({required OAuthClientMetadata metadata, required String service, required OAuthSession session}) async { 163 + attemptedServices.add(service); 164 + if (service == 'porcini.us-east.host.bsky.network') { 165 + throw const FormatException('Unexpected character (at character 1)'); 166 + } 167 + 168 + expect(service, equals('bsky.social')); 169 + return OAuthSession( 170 + accessToken: refreshedAccessToken, 171 + refreshToken: session.refreshToken, 172 + tokenType: 'DPoP', 173 + scope: 'atproto', 174 + expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)), 175 + sub: session.sub, 176 + $dPoPNonce: 'new-nonce', 177 + $publicKey: session.$publicKey, 178 + $privateKey: session.$privateKey, 179 + ); 180 + }, 181 + ); 182 + 183 + const currentSession = AuthTokens( 184 + accessToken: 'REPLACE_ME', 185 + refreshToken: 'refresh-token', 186 + did: 'did:plc:abc123', 187 + handle: 'user.bsky.social', 188 + service: 'porcini.us-east.host.bsky.network', 189 + dpopNonce: 'nonce', 190 + dpopPublicKey: 'public-key', 191 + dpopPrivateKey: 'private-key', 192 + authMethod: AuthMethod.oauth, 193 + ); 194 + final sessionWithJwt = currentSession.copyWith(accessToken: expiredAccessToken); 195 + 196 + when( 197 + () => mockDatabase.getSetting(AppDatabase.activeAccountDidSettingKey), 198 + ).thenAnswer((_) async => currentSession.did); 199 + when(() => mockDatabase.insertAccount(any())).thenAnswer((_) async => 1); 200 + when( 201 + () => mockDatabase.setSetting(AppDatabase.activeAccountDidSettingKey, currentSession.did), 202 + ).thenAnswer((_) async => 1); 203 + 204 + final refreshed = await authRepository.refreshSession(sessionWithJwt); 205 + 206 + expect(refreshed, isNotNull); 207 + expect(refreshed!.did, equals(currentSession.did)); 208 + expect(attemptedServices, equals(['porcini.us-east.host.bsky.network', 'bsky.social'])); 209 + verifyNever(() => mockDatabase.deleteAccount(any())); 210 + verify(() => mockDatabase.insertAccount(any())).called(1); 211 + }); 212 + }); 213 + 128 214 group('clearSession', () { 129 215 test('should delete all accounts', () async { 130 216 when(() => mockDatabase.deleteAllAccounts()).thenAnswer((_) async => 1); ··· 252 338 }); 253 339 }); 254 340 } 341 + 342 + OAuthClientMetadata _testClientMetadata() { 343 + return const OAuthClientMetadata( 344 + clientId: AuthRepository.kClientId, 345 + applicationType: 'native', 346 + clientName: 'Lazurite Test', 347 + clientUri: 'https://lazurite.stormlightlabs.org', 348 + redirectUris: ['http://127.0.0.1/callback'], 349 + responseTypes: ['code'], 350 + grantTypes: ['authorization_code', 'refresh_token'], 351 + scope: 'atproto', 352 + tokenEndpointAuthMethod: 'none', 353 + ); 354 + } 355 + 356 + String _buildJwt({ 357 + required String sub, 358 + required int expEpochSeconds, 359 + required int iatEpochSeconds, 360 + String? aud, 361 + String? iss, 362 + }) { 363 + String encodePart(Map<String, Object?> value) { 364 + return base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); 365 + } 366 + 367 + final header = encodePart(const {'alg': 'none', 'typ': 'JWT'}); 368 + final payload = encodePart({ 369 + 'sub': sub, 370 + 'exp': expEpochSeconds, 371 + 'iat': iatEpochSeconds, 372 + if (aud != null) 'aud': aud, 373 + if (iss != null) 'iss': iss, 374 + 'scope': 'atproto', 375 + }); 376 + 377 + return '$header.$payload.signature'; 378 + }