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.

feat: persist oauthService

+202 -26
+1 -1
docs/designs/login.html
··· 163 163 <svg viewBox="0 0 24 24" fill="currentColor"> 164 164 <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/> 165 165 </svg> 166 - Continue to BlueSky 166 + Continue 167 167 </button> 168 168 169 169 <div class="divider-with-text">Or</div>
+11 -1
lib/core/database/app_database.dart
··· 26 26 static const activeAccountDidSettingKey = 'active_account_did'; 27 27 28 28 @override 29 - int get schemaVersion => 15; 29 + int get schemaVersion => 16; 30 30 31 31 @override 32 32 MigrationStrategy get migration => MigrationStrategy( ··· 104 104 } 105 105 if (from < 15) { 106 106 await migrator.createTable(likedPosts); 107 + } 108 + if (from < 16) { 109 + await migrator.addColumn(accounts, accounts.oauthService); 110 + await customStatement(''' 111 + UPDATE accounts 112 + SET oauth_service = 'bsky.social' 113 + WHERE oauth_service IS NULL 114 + AND dpop_public_key IS NOT NULL 115 + AND dpop_private_key IS NOT NULL 116 + '''); 107 117 } 108 118 }, 109 119 );
+54
lib/core/database/app_database.g.dart
··· 44 44 type: DriftSqlType.string, 45 45 requiredDuringInsert: false, 46 46 ); 47 + static const VerificationMeta _oauthServiceMeta = const VerificationMeta('oauthService'); 48 + @override 49 + late final GeneratedColumn<String> oauthService = GeneratedColumn<String>( 50 + 'oauth_service', 51 + aliasedName, 52 + true, 53 + type: DriftSqlType.string, 54 + requiredDuringInsert: false, 55 + ); 47 56 static const VerificationMeta _accessTokenMeta = const VerificationMeta('accessToken'); 48 57 @override 49 58 late final GeneratedColumn<String> accessToken = GeneratedColumn<String>( ··· 124 133 handle, 125 134 displayName, 126 135 service, 136 + oauthService, 127 137 accessToken, 128 138 refreshToken, 129 139 dpopPublicKey, ··· 158 168 if (data.containsKey('service')) { 159 169 context.handle(_serviceMeta, service.isAcceptableOrUnknown(data['service']!, _serviceMeta)); 160 170 } 171 + if (data.containsKey('oauth_service')) { 172 + context.handle(_oauthServiceMeta, oauthService.isAcceptableOrUnknown(data['oauth_service']!, _oauthServiceMeta)); 173 + } 161 174 if (data.containsKey('access_token')) { 162 175 context.handle(_accessTokenMeta, accessToken.isAcceptableOrUnknown(data['access_token']!, _accessTokenMeta)); 163 176 } else if (isInserting) { ··· 203 216 handle: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}handle'])!, 204 217 displayName: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}display_name']), 205 218 service: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}service']), 219 + oauthService: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}oauth_service']), 206 220 accessToken: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}access_token'])!, 207 221 refreshToken: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}refresh_token']), 208 222 dpopPublicKey: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}dpop_public_key']), ··· 228 242 final String handle; 229 243 final String? displayName; 230 244 final String? service; 245 + final String? oauthService; 231 246 final String accessToken; 232 247 final String? refreshToken; 233 248 final String? dpopPublicKey; ··· 241 256 required this.handle, 242 257 this.displayName, 243 258 this.service, 259 + this.oauthService, 244 260 required this.accessToken, 245 261 this.refreshToken, 246 262 this.dpopPublicKey, ··· 260 276 } 261 277 if (!nullToAbsent || service != null) { 262 278 map['service'] = Variable<String>(service); 279 + } 280 + if (!nullToAbsent || oauthService != null) { 281 + map['oauth_service'] = Variable<String>(oauthService); 263 282 } 264 283 map['access_token'] = Variable<String>(accessToken); 265 284 if (!nullToAbsent || refreshToken != null) { ··· 288 307 handle: Value(handle), 289 308 displayName: displayName == null && nullToAbsent ? const Value.absent() : Value(displayName), 290 309 service: service == null && nullToAbsent ? const Value.absent() : Value(service), 310 + oauthService: oauthService == null && nullToAbsent ? const Value.absent() : Value(oauthService), 291 311 accessToken: Value(accessToken), 292 312 refreshToken: refreshToken == null && nullToAbsent ? const Value.absent() : Value(refreshToken), 293 313 dpopPublicKey: dpopPublicKey == null && nullToAbsent ? const Value.absent() : Value(dpopPublicKey), ··· 306 326 handle: serializer.fromJson<String>(json['handle']), 307 327 displayName: serializer.fromJson<String?>(json['displayName']), 308 328 service: serializer.fromJson<String?>(json['service']), 329 + oauthService: serializer.fromJson<String?>(json['oauthService']), 309 330 accessToken: serializer.fromJson<String>(json['accessToken']), 310 331 refreshToken: serializer.fromJson<String?>(json['refreshToken']), 311 332 dpopPublicKey: serializer.fromJson<String?>(json['dpopPublicKey']), ··· 324 345 'handle': serializer.toJson<String>(handle), 325 346 'displayName': serializer.toJson<String?>(displayName), 326 347 'service': serializer.toJson<String?>(service), 348 + 'oauthService': serializer.toJson<String?>(oauthService), 327 349 'accessToken': serializer.toJson<String>(accessToken), 328 350 'refreshToken': serializer.toJson<String?>(refreshToken), 329 351 'dpopPublicKey': serializer.toJson<String?>(dpopPublicKey), ··· 340 362 String? handle, 341 363 Value<String?> displayName = const Value.absent(), 342 364 Value<String?> service = const Value.absent(), 365 + Value<String?> oauthService = const Value.absent(), 343 366 String? accessToken, 344 367 Value<String?> refreshToken = const Value.absent(), 345 368 Value<String?> dpopPublicKey = const Value.absent(), ··· 353 376 handle: handle ?? this.handle, 354 377 displayName: displayName.present ? displayName.value : this.displayName, 355 378 service: service.present ? service.value : this.service, 379 + oauthService: oauthService.present ? oauthService.value : this.oauthService, 356 380 accessToken: accessToken ?? this.accessToken, 357 381 refreshToken: refreshToken.present ? refreshToken.value : this.refreshToken, 358 382 dpopPublicKey: dpopPublicKey.present ? dpopPublicKey.value : this.dpopPublicKey, ··· 368 392 handle: data.handle.present ? data.handle.value : this.handle, 369 393 displayName: data.displayName.present ? data.displayName.value : this.displayName, 370 394 service: data.service.present ? data.service.value : this.service, 395 + oauthService: data.oauthService.present ? data.oauthService.value : this.oauthService, 371 396 accessToken: data.accessToken.present ? data.accessToken.value : this.accessToken, 372 397 refreshToken: data.refreshToken.present ? data.refreshToken.value : this.refreshToken, 373 398 dpopPublicKey: data.dpopPublicKey.present ? data.dpopPublicKey.value : this.dpopPublicKey, ··· 386 411 ..write('handle: $handle, ') 387 412 ..write('displayName: $displayName, ') 388 413 ..write('service: $service, ') 414 + ..write('oauthService: $oauthService, ') 389 415 ..write('accessToken: $accessToken, ') 390 416 ..write('refreshToken: $refreshToken, ') 391 417 ..write('dpopPublicKey: $dpopPublicKey, ') ··· 404 430 handle, 405 431 displayName, 406 432 service, 433 + oauthService, 407 434 accessToken, 408 435 refreshToken, 409 436 dpopPublicKey, ··· 421 448 other.handle == this.handle && 422 449 other.displayName == this.displayName && 423 450 other.service == this.service && 451 + other.oauthService == this.oauthService && 424 452 other.accessToken == this.accessToken && 425 453 other.refreshToken == this.refreshToken && 426 454 other.dpopPublicKey == this.dpopPublicKey && ··· 436 464 final Value<String> handle; 437 465 final Value<String?> displayName; 438 466 final Value<String?> service; 467 + final Value<String?> oauthService; 439 468 final Value<String> accessToken; 440 469 final Value<String?> refreshToken; 441 470 final Value<String?> dpopPublicKey; ··· 450 479 this.handle = const Value.absent(), 451 480 this.displayName = const Value.absent(), 452 481 this.service = const Value.absent(), 482 + this.oauthService = const Value.absent(), 453 483 this.accessToken = const Value.absent(), 454 484 this.refreshToken = const Value.absent(), 455 485 this.dpopPublicKey = const Value.absent(), ··· 465 495 required String handle, 466 496 this.displayName = const Value.absent(), 467 497 this.service = const Value.absent(), 498 + this.oauthService = const Value.absent(), 468 499 required String accessToken, 469 500 this.refreshToken = const Value.absent(), 470 501 this.dpopPublicKey = const Value.absent(), ··· 482 513 Expression<String>? handle, 483 514 Expression<String>? displayName, 484 515 Expression<String>? service, 516 + Expression<String>? oauthService, 485 517 Expression<String>? accessToken, 486 518 Expression<String>? refreshToken, 487 519 Expression<String>? dpopPublicKey, ··· 497 529 if (handle != null) 'handle': handle, 498 530 if (displayName != null) 'display_name': displayName, 499 531 if (service != null) 'service': service, 532 + if (oauthService != null) 'oauth_service': oauthService, 500 533 if (accessToken != null) 'access_token': accessToken, 501 534 if (refreshToken != null) 'refresh_token': refreshToken, 502 535 if (dpopPublicKey != null) 'dpop_public_key': dpopPublicKey, ··· 514 547 Value<String>? handle, 515 548 Value<String?>? displayName, 516 549 Value<String?>? service, 550 + Value<String?>? oauthService, 517 551 Value<String>? accessToken, 518 552 Value<String?>? refreshToken, 519 553 Value<String?>? dpopPublicKey, ··· 529 563 handle: handle ?? this.handle, 530 564 displayName: displayName ?? this.displayName, 531 565 service: service ?? this.service, 566 + oauthService: oauthService ?? this.oauthService, 532 567 accessToken: accessToken ?? this.accessToken, 533 568 refreshToken: refreshToken ?? this.refreshToken, 534 569 dpopPublicKey: dpopPublicKey ?? this.dpopPublicKey, ··· 556 591 if (service.present) { 557 592 map['service'] = Variable<String>(service.value); 558 593 } 594 + if (oauthService.present) { 595 + map['oauth_service'] = Variable<String>(oauthService.value); 596 + } 559 597 if (accessToken.present) { 560 598 map['access_token'] = Variable<String>(accessToken.value); 561 599 } ··· 593 631 ..write('handle: $handle, ') 594 632 ..write('displayName: $displayName, ') 595 633 ..write('service: $service, ') 634 + ..write('oauthService: $oauthService, ') 596 635 ..write('accessToken: $accessToken, ') 597 636 ..write('refreshToken: $refreshToken, ') 598 637 ..write('dpopPublicKey: $dpopPublicKey, ') ··· 3870 3909 required String handle, 3871 3910 Value<String?> displayName, 3872 3911 Value<String?> service, 3912 + Value<String?> oauthService, 3873 3913 required String accessToken, 3874 3914 Value<String?> refreshToken, 3875 3915 Value<String?> dpopPublicKey, ··· 3886 3926 Value<String> handle, 3887 3927 Value<String?> displayName, 3888 3928 Value<String?> service, 3929 + Value<String?> oauthService, 3889 3930 Value<String> accessToken, 3890 3931 Value<String?> refreshToken, 3891 3932 Value<String?> dpopPublicKey, ··· 3916 3957 ColumnFilters<String> get service => 3917 3958 $composableBuilder(column: $table.service, builder: (column) => ColumnFilters(column)); 3918 3959 3960 + ColumnFilters<String> get oauthService => 3961 + $composableBuilder(column: $table.oauthService, builder: (column) => ColumnFilters(column)); 3962 + 3919 3963 ColumnFilters<String> get accessToken => 3920 3964 $composableBuilder(column: $table.accessToken, builder: (column) => ColumnFilters(column)); 3921 3965 ··· 3960 4004 3961 4005 ColumnOrderings<String> get service => 3962 4006 $composableBuilder(column: $table.service, builder: (column) => ColumnOrderings(column)); 4007 + 4008 + ColumnOrderings<String> get oauthService => 4009 + $composableBuilder(column: $table.oauthService, builder: (column) => ColumnOrderings(column)); 3963 4010 3964 4011 ColumnOrderings<String> get accessToken => 3965 4012 $composableBuilder(column: $table.accessToken, builder: (column) => ColumnOrderings(column)); ··· 4003 4050 4004 4051 GeneratedColumn<String> get service => $composableBuilder(column: $table.service, builder: (column) => column); 4005 4052 4053 + GeneratedColumn<String> get oauthService => 4054 + $composableBuilder(column: $table.oauthService, builder: (column) => column); 4055 + 4006 4056 GeneratedColumn<String> get accessToken => 4007 4057 $composableBuilder(column: $table.accessToken, builder: (column) => column); 4008 4058 ··· 4053 4103 Value<String> handle = const Value.absent(), 4054 4104 Value<String?> displayName = const Value.absent(), 4055 4105 Value<String?> service = const Value.absent(), 4106 + Value<String?> oauthService = const Value.absent(), 4056 4107 Value<String> accessToken = const Value.absent(), 4057 4108 Value<String?> refreshToken = const Value.absent(), 4058 4109 Value<String?> dpopPublicKey = const Value.absent(), ··· 4067 4118 handle: handle, 4068 4119 displayName: displayName, 4069 4120 service: service, 4121 + oauthService: oauthService, 4070 4122 accessToken: accessToken, 4071 4123 refreshToken: refreshToken, 4072 4124 dpopPublicKey: dpopPublicKey, ··· 4083 4135 required String handle, 4084 4136 Value<String?> displayName = const Value.absent(), 4085 4137 Value<String?> service = const Value.absent(), 4138 + Value<String?> oauthService = const Value.absent(), 4086 4139 required String accessToken, 4087 4140 Value<String?> refreshToken = const Value.absent(), 4088 4141 Value<String?> dpopPublicKey = const Value.absent(), ··· 4097 4150 handle: handle, 4098 4151 displayName: displayName, 4099 4152 service: service, 4153 + oauthService: oauthService, 4100 4154 accessToken: accessToken, 4101 4155 refreshToken: refreshToken, 4102 4156 dpopPublicKey: dpopPublicKey,
+1
lib/core/database/tables.dart
··· 6 6 TextColumn get handle => text()(); 7 7 TextColumn get displayName => text().nullable()(); 8 8 TextColumn get service => text().nullable()(); 9 + TextColumn get oauthService => text().nullable()(); 9 10 TextColumn get accessToken => text()(); 10 11 TextColumn get refreshToken => text().nullable()(); 11 12 TextColumn get dpopPublicKey => text().nullable()();
+2
lib/features/account/cubit/account_switcher_cubit.dart
··· 50 50 handle: account.handle, 51 51 displayName: account.displayName, 52 52 service: account.service, 53 + oauthService: account.oauthService, 53 54 dpopNonce: account.dpopNonce, 54 55 dpopPublicKey: account.dpopPublicKey, 55 56 dpopPrivateKey: account.dpopPrivateKey, ··· 95 96 handle: Value(tokens.handle), 96 97 displayName: tokens.displayName != null ? Value(tokens.displayName!) : const Value.absent(), 97 98 service: tokens.service != null ? Value(tokens.service!) : const Value.absent(), 99 + oauthService: tokens.oauthService != null ? Value(tokens.oauthService!) : const Value.absent(), 98 100 accessToken: Value(tokens.accessToken), 99 101 refreshToken: tokens.refreshToken != null ? Value(tokens.refreshToken!) : const Value.absent(), 100 102 dpopPublicKey: tokens.dpopPublicKey != null ? Value(tokens.dpopPublicKey!) : const Value.absent(),
+59 -16
lib/features/auth/data/auth_repository.dart
··· 68 68 return null; 69 69 } 70 70 71 + final authMethod = account.dpopPrivateKey != null && account.dpopPublicKey != null 72 + ? AuthMethod.oauth 73 + : AuthMethod.appPassword; 74 + 71 75 return AuthTokens( 72 76 accessToken: account.accessToken, 73 77 refreshToken: account.refreshToken, ··· 76 80 handle: account.handle, 77 81 displayName: account.displayName, 78 82 service: account.service, 83 + oauthService: authMethod == AuthMethod.oauth ? normalizeAtprotoServiceHost(account.oauthService) : null, 79 84 dpopNonce: account.dpopNonce, 80 85 dpopPublicKey: account.dpopPublicKey, 81 86 dpopPrivateKey: account.dpopPrivateKey, 82 - authMethod: account.dpopPrivateKey != null && account.dpopPublicKey != null 83 - ? AuthMethod.oauth 84 - : AuthMethod.appPassword, 87 + authMethod: authMethod, 85 88 ); 86 89 } 87 90 ··· 120 123 handle: Value(tokens.handle), 121 124 displayName: tokens.displayName != null ? Value(tokens.displayName) : const Value.absent(), 122 125 service: tokens.service != null ? Value(tokens.service) : const Value.absent(), 126 + oauthService: tokens.oauthService != null ? Value(tokens.oauthService) : const Value.absent(), 123 127 accessToken: Value(tokens.accessToken), 124 128 refreshToken: tokens.refreshToken != null ? Value(tokens.refreshToken) : const Value.absent(), 125 129 dpopPublicKey: tokens.dpopPublicKey != null ? Value(tokens.dpopPublicKey) : const Value.absent(), ··· 221 225 privateKey: privateKey, 222 226 ); 223 227 final oauthServices = _oauthRefreshServiceCandidates( 224 - storedService: currentSession.service, 228 + storedAuthService: currentSession.oauthService, 225 229 issuer: restoredSession.accessTokenJwt.iss, 226 230 ); 227 231 228 232 Object? lastAttemptError; 229 233 StackTrace? lastAttemptStackTrace; 234 + String? successfulOauthService; 235 + final failedAttemptSummaries = <String>[]; 230 236 OAuthSession? refreshedSession; 231 237 for (final oauthService in oauthServices) { 232 238 try { ··· 240 246 privateKey: privateKey, 241 247 ), 242 248 ); 249 + successfulOauthService = oauthService; 243 250 break; 244 251 } catch (error, stackTrace) { 245 252 lastAttemptError = error; 246 253 lastAttemptStackTrace = stackTrace; 254 + final summary = _summarizeOAuthRefreshError(error); 255 + failedAttemptSummaries.add('$oauthService=$summary'); 247 256 log.w( 248 - 'AuthRepository: OAuth refresh attempt failed using auth service $oauthService', 257 + 'AuthRepository: OAuth refresh attempt failed using auth service $oauthService ($summary)', 249 258 error: error, 250 259 stackTrace: stackTrace, 251 260 ); ··· 256 265 Error.throwWithStackTrace( 257 266 Exception( 258 267 'OAuth refresh failed across ${oauthServices.length} auth service candidate(s). ' 259 - 'Last error: $lastAttemptError', 268 + 'Attempts: ${failedAttemptSummaries.join(' | ')}. Last error: $lastAttemptError', 260 269 ), 261 270 lastAttemptStackTrace ?? StackTrace.current, 262 271 ); ··· 266 275 final refreshedTokens = await _buildOAuthTokens( 267 276 refreshedSession, 268 277 fallbackHandle: currentSession.handle, 269 - oauthService: fallbackPdsHost, 278 + fallbackPdsHost: fallbackPdsHost, 279 + oauthService: successfulOauthService ?? currentSession.oauthService ?? _oauthService, 270 280 ); 271 281 272 282 await saveSession( 273 283 refreshedTokens, 274 284 makeActive: await _database.getSetting(AppDatabase.activeAccountDidSettingKey) == currentSession.did, 275 285 ); 276 - log.i('AuthRepository: OAuth session refresh succeeded for ${refreshedTokens.handle}'); 286 + log.i( 287 + 'AuthRepository: OAuth session refresh succeeded for ${refreshedTokens.handle} ' 288 + 'using auth service ${refreshedTokens.oauthService ?? successfulOauthService ?? 'unknown'}', 289 + ); 277 290 return refreshedTokens; 278 291 } catch (error, stackTrace) { 279 292 log.e('AuthRepository: OAuth session refresh failed', error: error, stackTrace: stackTrace); ··· 426 439 ); 427 440 final oauthSession = await oauthClient.callback(callbackUrl, oauthContext); 428 441 log.i('AuthRepository: OAuth token exchange succeeded for DID ${oauthSession.sub}'); 429 - final tokens = await _buildOAuthTokens(oauthSession, fallbackHandle: fallbackHandle, oauthService: service); 442 + final tokens = await _buildOAuthTokens( 443 + oauthSession, 444 + fallbackHandle: fallbackHandle, 445 + fallbackPdsHost: _fallbackService, 446 + oauthService: service, 447 + ); 430 448 await saveSession(tokens, makeActive: true); 431 449 log.i('AuthRepository: OAuth login completed for ${tokens.handle}'); 432 450 return tokens; ··· 435 453 Future<AuthTokens> _buildOAuthTokens( 436 454 OAuthSession session, { 437 455 required String fallbackHandle, 456 + required String fallbackPdsHost, 438 457 required String oauthService, 439 458 }) async { 440 459 var resolvedHandle = fallbackHandle; ··· 444 463 'AuthRepository: OAuth session will target PDS ' 445 464 '${session.atprotoPdsEndpoint ?? 'unknown'} via auth service $oauthService', 446 465 ); 447 - final pdsHost = normalizeAtprotoServiceHost(session.atprotoPdsEndpoint) ?? oauthService; 466 + final pdsHost = normalizeAtprotoServiceHost(session.atprotoPdsEndpoint) ?? fallbackPdsHost; 467 + final normalizedOauthService = 468 + normalizeAtprotoServiceHost(session.accessTokenJwt.iss) ?? 469 + normalizeAtprotoServiceHost(oauthService) ?? 470 + _oauthService; 448 471 449 472 try { 450 473 final authSession = await createAtProtoForOAuthSession(session).server.getSession(); ··· 472 495 handle: resolvedHandle, 473 496 displayName: displayName, 474 497 service: pdsHost, 498 + oauthService: normalizedOauthService, 475 499 dpopNonce: session.$dPoPNonce, 476 500 dpopPublicKey: session.$publicKey, 477 501 dpopPrivateKey: session.$privateKey, ··· 699 723 return oauthClient.refresh(session); 700 724 } 701 725 702 - static List<String> _oauthRefreshServiceCandidates({required String? storedService, required String? issuer}) { 726 + String _summarizeOAuthRefreshError(Object error) { 727 + final message = error.toString().replaceAll('\n', ' ').trim(); 728 + 729 + if (message.contains('<!DOCTYPE html>')) { 730 + return 'non_json_html_response'; 731 + } 732 + if (error is FormatException) { 733 + return 'json_parse_error'; 734 + } 735 + if (message.isEmpty) { 736 + return error.runtimeType.toString(); 737 + } 738 + 739 + return message.length <= 240 ? message : '${message.substring(0, 237)}...'; 740 + } 741 + 742 + static List<String> _oauthRefreshServiceCandidates({required String? storedAuthService, required String? issuer}) { 703 743 final candidates = <String>{}; 704 744 final issuerHost = normalizeAtprotoServiceHost(issuer); 705 745 if (issuerHost != null) { 706 746 candidates.add(issuerHost); 707 747 } 708 748 709 - final storedHost = normalizeAtprotoServiceHost(storedService); 710 - if (storedHost != null) { 711 - candidates.add(storedHost); 749 + final storedAuthHost = normalizeAtprotoServiceHost(storedAuthService); 750 + if (storedAuthHost != null) { 751 + candidates.add(storedAuthHost); 712 752 } 713 753 714 754 candidates.add(_oauthService); ··· 717 757 } 718 758 719 759 @visibleForTesting 720 - static List<String> oauthRefreshServiceCandidatesForTest({required String? storedService, required String? issuer}) { 721 - return _oauthRefreshServiceCandidates(storedService: storedService, issuer: issuer); 760 + static List<String> oauthRefreshServiceCandidatesForTest({ 761 + required String? storedAuthService, 762 + required String? issuer, 763 + }) { 764 + return _oauthRefreshServiceCandidates(storedAuthService: storedAuthService, issuer: issuer); 722 765 } 723 766 724 767 @visibleForTesting
+5
lib/features/auth/data/models/auth_models.dart
··· 11 11 required this.handle, 12 12 this.displayName, 13 13 this.service, 14 + this.oauthService, 14 15 this.dpopNonce, 15 16 this.dpopPublicKey, 16 17 this.dpopPrivateKey, ··· 23 24 final String handle; 24 25 final String? displayName; 25 26 final String? service; 27 + final String? oauthService; 26 28 final String? dpopNonce; 27 29 final String? dpopPublicKey; 28 30 final String? dpopPrivateKey; ··· 36 38 String? handle, 37 39 String? displayName, 38 40 String? service, 41 + String? oauthService, 39 42 String? dpopNonce, 40 43 String? dpopPublicKey, 41 44 String? dpopPrivateKey, ··· 49 52 handle: handle ?? this.handle, 50 53 displayName: displayName ?? this.displayName, 51 54 service: service ?? this.service, 55 + oauthService: oauthService ?? this.oauthService, 52 56 dpopNonce: dpopNonce ?? this.dpopNonce, 53 57 dpopPublicKey: dpopPublicKey ?? this.dpopPublicKey, 54 58 dpopPrivateKey: dpopPrivateKey ?? this.dpopPrivateKey, ··· 72 76 handle, 73 77 displayName, 74 78 service, 79 + oauthService, 75 80 dpopNonce, 76 81 dpopPublicKey, 77 82 dpopPrivateKey,
+1 -1
lib/features/auth/presentation/login_screen.dart
··· 121 121 child: CircularProgressIndicator(strokeWidth: 2), 122 122 ) 123 123 : const Icon(Icons.language), 124 - label: Text(state.isLoading ? 'Starting sign in...' : 'Continue to BlueSky'), 124 + label: Text(state.isLoading ? 'Starting sign in...' : 'Continue'), 125 125 style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 18)), 126 126 ); 127 127 },
+18
test/core/database/app_database_test.dart
··· 1 1 import 'package:drift/native.dart'; 2 + import 'package:drift/drift.dart' show Value; 2 3 import 'package:flutter_test/flutter_test.dart'; 3 4 import 'package:lazurite/core/database/app_database.dart'; 4 5 ··· 164 165 expect(retrieved!.accessToken, equals('new_token')); 165 166 expect(retrieved.refreshToken, equals('new_refresh')); 166 167 expect(retrieved.dpopNonce, equals('nonce-1')); 168 + }); 169 + 170 + test('should persist oauth service separately from pds service', () async { 171 + final account = AccountsCompanion.insert( 172 + did: 'did:plc:oauth123', 173 + handle: 'oauth-user.bsky.social', 174 + accessToken: 'access-token', 175 + service: const Value('porcini.us-east.host.bsky.network'), 176 + oauthService: const Value('bsky.social'), 177 + ); 178 + 179 + await database.insertAccount(account); 180 + final retrieved = await database.getAccount('did:plc:oauth123'); 181 + 182 + expect(retrieved, isNotNull); 183 + expect(retrieved!.service, equals('porcini.us-east.host.bsky.network')); 184 + expect(retrieved.oauthService, equals('bsky.social')); 167 185 }); 168 186 }); 169 187
+1 -1
test/core/router/app_router_test.dart
··· 345 345 await tester.pump(); 346 346 await tester.pumpAndSettle(); 347 347 348 - expect(find.text('Continue to BlueSky'), findsOneWidget); 348 + expect(find.text('Continue'), findsOneWidget); 349 349 expect(tester.takeException(), isNull); 350 350 351 351 router.dispose();
+2
test/features/account/cubit/account_switcher_cubit_test.dart
··· 289 289 accessToken: 'token', 290 290 did: 'did:plc:newuser', 291 291 handle: 'new.bsky.social', 292 + oauthService: 'bsky.social', 292 293 dpopPublicKey: 'public-key', 293 294 dpopPrivateKey: 'private-key', 294 295 authMethod: AuthMethod.oauth, ··· 312 313 expect(captured, hasLength(1)); 313 314 expect(captured.single.dpopPublicKey.value, 'public-key'); 314 315 expect(captured.single.dpopPrivateKey.value, 'private-key'); 316 + expect(captured.single.oauthService.value, 'bsky.social'); 315 317 }); 316 318 317 319 blocTest<AccountSwitcherCubit, AccountSwitcherState>(
+43 -5
test/features/auth/data/auth_repository_test.dart
··· 42 42 did: 'did:plc:abc123', 43 43 handle: 'user.bsky.social', 44 44 service: 'bsky.social', 45 + oauthService: null, 45 46 accessToken: 'access_token', 46 47 refreshToken: 'refresh_token', 47 48 dpopPublicKey: null, ··· 66 67 expect(result.service, equals('bsky.social')); 67 68 expect(result.authMethod, AuthMethod.appPassword); 68 69 }); 70 + 71 + test('should read oauthService for oauth-backed account', () async { 72 + final account = Account( 73 + did: 'did:plc:oauth123', 74 + handle: 'oauth-user.bsky.social', 75 + service: 'porcini.us-east.host.bsky.network', 76 + oauthService: 'bsky.social', 77 + accessToken: 'access_token', 78 + refreshToken: 'refresh_token', 79 + dpopPublicKey: 'public-key', 80 + dpopPrivateKey: 'private-key', 81 + dpopNonce: 'nonce', 82 + displayName: 'OAuth User', 83 + expiresAt: null, 84 + createdAt: DateTime.now(), 85 + updatedAt: DateTime.now(), 86 + ); 87 + 88 + when(() => mockDatabase.getActiveAccount()).thenAnswer((_) async => account); 89 + 90 + final result = await authRepository.getStoredSession(); 91 + 92 + expect(result, isNotNull); 93 + expect(result!.usesOAuth, isTrue); 94 + expect(result.oauthService, equals('bsky.social')); 95 + }); 69 96 }); 70 97 71 98 group('saveSession', () { ··· 129 156 }); 130 157 131 158 group('oauth refresh', () { 132 - test('orders issuer host before stored host and deduplicates candidates', () { 159 + test('orders issuer host before stored auth host and deduplicates candidates', () { 133 160 final candidates = AuthRepository.oauthRefreshServiceCandidatesForTest( 134 - storedService: 'https://porcini.us-east.host.bsky.network', 161 + storedAuthService: 'https://bsky.social', 135 162 issuer: 'https://bsky.social', 136 163 ); 137 164 138 - expect(candidates, equals(['bsky.social', 'porcini.us-east.host.bsky.network'])); 165 + expect(candidates, equals(['bsky.social'])); 166 + }); 167 + 168 + test('uses stored oauth auth host when issuer is unavailable', () { 169 + final candidates = AuthRepository.oauthRefreshServiceCandidatesForTest( 170 + storedAuthService: 'https://oauth.custom.example', 171 + issuer: null, 172 + ); 173 + 174 + expect(candidates, equals(['oauth.custom.example', 'bsky.social'])); 139 175 }); 140 176 141 177 test('retries OAuth refresh against fallback auth service hosts', () async { ··· 186 222 did: 'did:plc:abc123', 187 223 handle: 'user.bsky.social', 188 224 service: 'porcini.us-east.host.bsky.network', 225 + oauthService: 'porcini.us-east.host.bsky.network', 189 226 dpopNonce: 'nonce', 190 227 dpopPublicKey: 'public-key', 191 228 dpopPrivateKey: 'private-key', ··· 206 243 expect(refreshed, isNotNull); 207 244 expect(refreshed!.did, equals(currentSession.did)); 208 245 expect(attemptedServices, equals(['porcini.us-east.host.bsky.network', 'bsky.social'])); 246 + expect(refreshed.oauthService, equals('bsky.social')); 209 247 verifyNever(() => mockDatabase.deleteAccount(any())); 210 248 verify(() => mockDatabase.insertAccount(any())).called(1); 211 249 }); ··· 369 407 'sub': sub, 370 408 'exp': expEpochSeconds, 371 409 'iat': iatEpochSeconds, 372 - if (aud != null) 'aud': aud, 373 - if (iss != null) 'iss': iss, 410 + 'aud': ?aud, 411 + 'iss': ?iss, 374 412 'scope': 'atproto', 375 413 }); 376 414
+4 -1
test/features/auth/data/models/auth_models_test.dart
··· 11 11 handle: 'user.bsky.social', 12 12 displayName: 'User Name', 13 13 service: 'bsky.social', 14 + oauthService: 'bsky.social', 14 15 ); 15 16 16 17 expect(tokens.accessToken, equals('access_token')); ··· 19 20 expect(tokens.handle, equals('user.bsky.social')); 20 21 expect(tokens.displayName, equals('User Name')); 21 22 expect(tokens.service, equals('bsky.social')); 23 + expect(tokens.oauthService, equals('bsky.social')); 22 24 }); 23 25 24 26 test('should create AuthTokens without optional fields', () { ··· 34 36 test('should copy with new values', () { 35 37 const tokens = AuthTokens(accessToken: 'old_token', did: 'did:plc:abc123', handle: 'user.bsky.social'); 36 38 37 - final newTokens = tokens.copyWith(accessToken: 'new_token', displayName: 'New Name'); 39 + final newTokens = tokens.copyWith(accessToken: 'new_token', displayName: 'New Name', oauthService: 'bsky.social'); 38 40 39 41 expect(newTokens.accessToken, equals('new_token')); 40 42 expect(newTokens.did, equals('did:plc:abc123')); 41 43 expect(newTokens.displayName, equals('New Name')); 44 + expect(newTokens.oauthService, equals('bsky.social')); 42 45 }); 43 46 44 47 test('should identify oauth-backed sessions', () {