From 53d0acdbc930fe617cd7c8efdbea66205346bef1 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 14 Apr 2026 11:17:33 -0500 Subject: [PATCH] feat: persist oauthService --- docs/designs/login.html | 2 +- lib/core/database/app_database.dart | 12 ++- lib/core/database/app_database.g.dart | 54 +++++++++++++ lib/core/database/tables.dart | 1 + .../account/cubit/account_switcher_cubit.dart | 2 + lib/features/auth/data/auth_repository.dart | 75 +++++++++++++++---- .../auth/data/models/auth_models.dart | 5 ++ .../auth/presentation/login_screen.dart | 2 +- test/core/database/app_database_test.dart | 18 +++++ test/core/router/app_router_test.dart | 2 +- .../cubit/account_switcher_cubit_test.dart | 2 + .../auth/data/auth_repository_test.dart | 48 ++++++++++-- .../auth/data/models/auth_models_test.dart | 5 +- 13 files changed, 202 insertions(+), 26 deletions(-) diff --git a/docs/designs/login.html b/docs/designs/login.html index 1315456..8d445f5 100644 --- a/docs/designs/login.html +++ b/docs/designs/login.html @@ -163,7 +163,7 @@ - Continue to BlueSky + Continue
Or
diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index 4d8e2c8..b48a0b1 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -26,7 +26,7 @@ class AppDatabase extends _$AppDatabase { static const activeAccountDidSettingKey = 'active_account_did'; @override - int get schemaVersion => 15; + int get schemaVersion => 16; @override MigrationStrategy get migration => MigrationStrategy( @@ -105,6 +105,16 @@ class AppDatabase extends _$AppDatabase { if (from < 15) { await migrator.createTable(likedPosts); } + if (from < 16) { + await migrator.addColumn(accounts, accounts.oauthService); + await customStatement(''' + UPDATE accounts + SET oauth_service = 'bsky.social' + WHERE oauth_service IS NULL + AND dpop_public_key IS NOT NULL + AND dpop_private_key IS NOT NULL + '''); + } }, ); diff --git a/lib/core/database/app_database.g.dart b/lib/core/database/app_database.g.dart index 17ce23a..50938e5 100644 --- a/lib/core/database/app_database.g.dart +++ b/lib/core/database/app_database.g.dart @@ -44,6 +44,15 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _oauthServiceMeta = const VerificationMeta('oauthService'); + @override + late final GeneratedColumn oauthService = GeneratedColumn( + 'oauth_service', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _accessTokenMeta = const VerificationMeta('accessToken'); @override late final GeneratedColumn accessToken = GeneratedColumn( @@ -124,6 +133,7 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { handle, displayName, service, + oauthService, accessToken, refreshToken, dpopPublicKey, @@ -158,6 +168,9 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { if (data.containsKey('service')) { context.handle(_serviceMeta, service.isAcceptableOrUnknown(data['service']!, _serviceMeta)); } + if (data.containsKey('oauth_service')) { + context.handle(_oauthServiceMeta, oauthService.isAcceptableOrUnknown(data['oauth_service']!, _oauthServiceMeta)); + } if (data.containsKey('access_token')) { context.handle(_accessTokenMeta, accessToken.isAcceptableOrUnknown(data['access_token']!, _accessTokenMeta)); } else if (isInserting) { @@ -203,6 +216,7 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { handle: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}handle'])!, displayName: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}display_name']), service: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}service']), + oauthService: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}oauth_service']), accessToken: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}access_token'])!, refreshToken: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}refresh_token']), dpopPublicKey: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}dpop_public_key']), @@ -228,6 +242,7 @@ class Account extends DataClass implements Insertable { final String handle; final String? displayName; final String? service; + final String? oauthService; final String accessToken; final String? refreshToken; final String? dpopPublicKey; @@ -241,6 +256,7 @@ class Account extends DataClass implements Insertable { required this.handle, this.displayName, this.service, + this.oauthService, required this.accessToken, this.refreshToken, this.dpopPublicKey, @@ -261,6 +277,9 @@ class Account extends DataClass implements Insertable { if (!nullToAbsent || service != null) { map['service'] = Variable(service); } + if (!nullToAbsent || oauthService != null) { + map['oauth_service'] = Variable(oauthService); + } map['access_token'] = Variable(accessToken); if (!nullToAbsent || refreshToken != null) { map['refresh_token'] = Variable(refreshToken); @@ -288,6 +307,7 @@ class Account extends DataClass implements Insertable { handle: Value(handle), displayName: displayName == null && nullToAbsent ? const Value.absent() : Value(displayName), service: service == null && nullToAbsent ? const Value.absent() : Value(service), + oauthService: oauthService == null && nullToAbsent ? const Value.absent() : Value(oauthService), accessToken: Value(accessToken), refreshToken: refreshToken == null && nullToAbsent ? const Value.absent() : Value(refreshToken), dpopPublicKey: dpopPublicKey == null && nullToAbsent ? const Value.absent() : Value(dpopPublicKey), @@ -306,6 +326,7 @@ class Account extends DataClass implements Insertable { handle: serializer.fromJson(json['handle']), displayName: serializer.fromJson(json['displayName']), service: serializer.fromJson(json['service']), + oauthService: serializer.fromJson(json['oauthService']), accessToken: serializer.fromJson(json['accessToken']), refreshToken: serializer.fromJson(json['refreshToken']), dpopPublicKey: serializer.fromJson(json['dpopPublicKey']), @@ -324,6 +345,7 @@ class Account extends DataClass implements Insertable { 'handle': serializer.toJson(handle), 'displayName': serializer.toJson(displayName), 'service': serializer.toJson(service), + 'oauthService': serializer.toJson(oauthService), 'accessToken': serializer.toJson(accessToken), 'refreshToken': serializer.toJson(refreshToken), 'dpopPublicKey': serializer.toJson(dpopPublicKey), @@ -340,6 +362,7 @@ class Account extends DataClass implements Insertable { String? handle, Value displayName = const Value.absent(), Value service = const Value.absent(), + Value oauthService = const Value.absent(), String? accessToken, Value refreshToken = const Value.absent(), Value dpopPublicKey = const Value.absent(), @@ -353,6 +376,7 @@ class Account extends DataClass implements Insertable { handle: handle ?? this.handle, displayName: displayName.present ? displayName.value : this.displayName, service: service.present ? service.value : this.service, + oauthService: oauthService.present ? oauthService.value : this.oauthService, accessToken: accessToken ?? this.accessToken, refreshToken: refreshToken.present ? refreshToken.value : this.refreshToken, dpopPublicKey: dpopPublicKey.present ? dpopPublicKey.value : this.dpopPublicKey, @@ -368,6 +392,7 @@ class Account extends DataClass implements Insertable { handle: data.handle.present ? data.handle.value : this.handle, displayName: data.displayName.present ? data.displayName.value : this.displayName, service: data.service.present ? data.service.value : this.service, + oauthService: data.oauthService.present ? data.oauthService.value : this.oauthService, accessToken: data.accessToken.present ? data.accessToken.value : this.accessToken, refreshToken: data.refreshToken.present ? data.refreshToken.value : this.refreshToken, dpopPublicKey: data.dpopPublicKey.present ? data.dpopPublicKey.value : this.dpopPublicKey, @@ -386,6 +411,7 @@ class Account extends DataClass implements Insertable { ..write('handle: $handle, ') ..write('displayName: $displayName, ') ..write('service: $service, ') + ..write('oauthService: $oauthService, ') ..write('accessToken: $accessToken, ') ..write('refreshToken: $refreshToken, ') ..write('dpopPublicKey: $dpopPublicKey, ') @@ -404,6 +430,7 @@ class Account extends DataClass implements Insertable { handle, displayName, service, + oauthService, accessToken, refreshToken, dpopPublicKey, @@ -421,6 +448,7 @@ class Account extends DataClass implements Insertable { other.handle == this.handle && other.displayName == this.displayName && other.service == this.service && + other.oauthService == this.oauthService && other.accessToken == this.accessToken && other.refreshToken == this.refreshToken && other.dpopPublicKey == this.dpopPublicKey && @@ -436,6 +464,7 @@ class AccountsCompanion extends UpdateCompanion { final Value handle; final Value displayName; final Value service; + final Value oauthService; final Value accessToken; final Value refreshToken; final Value dpopPublicKey; @@ -450,6 +479,7 @@ class AccountsCompanion extends UpdateCompanion { this.handle = const Value.absent(), this.displayName = const Value.absent(), this.service = const Value.absent(), + this.oauthService = const Value.absent(), this.accessToken = const Value.absent(), this.refreshToken = const Value.absent(), this.dpopPublicKey = const Value.absent(), @@ -465,6 +495,7 @@ class AccountsCompanion extends UpdateCompanion { required String handle, this.displayName = const Value.absent(), this.service = const Value.absent(), + this.oauthService = const Value.absent(), required String accessToken, this.refreshToken = const Value.absent(), this.dpopPublicKey = const Value.absent(), @@ -482,6 +513,7 @@ class AccountsCompanion extends UpdateCompanion { Expression? handle, Expression? displayName, Expression? service, + Expression? oauthService, Expression? accessToken, Expression? refreshToken, Expression? dpopPublicKey, @@ -497,6 +529,7 @@ class AccountsCompanion extends UpdateCompanion { if (handle != null) 'handle': handle, if (displayName != null) 'display_name': displayName, if (service != null) 'service': service, + if (oauthService != null) 'oauth_service': oauthService, if (accessToken != null) 'access_token': accessToken, if (refreshToken != null) 'refresh_token': refreshToken, if (dpopPublicKey != null) 'dpop_public_key': dpopPublicKey, @@ -514,6 +547,7 @@ class AccountsCompanion extends UpdateCompanion { Value? handle, Value? displayName, Value? service, + Value? oauthService, Value? accessToken, Value? refreshToken, Value? dpopPublicKey, @@ -529,6 +563,7 @@ class AccountsCompanion extends UpdateCompanion { handle: handle ?? this.handle, displayName: displayName ?? this.displayName, service: service ?? this.service, + oauthService: oauthService ?? this.oauthService, accessToken: accessToken ?? this.accessToken, refreshToken: refreshToken ?? this.refreshToken, dpopPublicKey: dpopPublicKey ?? this.dpopPublicKey, @@ -556,6 +591,9 @@ class AccountsCompanion extends UpdateCompanion { if (service.present) { map['service'] = Variable(service.value); } + if (oauthService.present) { + map['oauth_service'] = Variable(oauthService.value); + } if (accessToken.present) { map['access_token'] = Variable(accessToken.value); } @@ -593,6 +631,7 @@ class AccountsCompanion extends UpdateCompanion { ..write('handle: $handle, ') ..write('displayName: $displayName, ') ..write('service: $service, ') + ..write('oauthService: $oauthService, ') ..write('accessToken: $accessToken, ') ..write('refreshToken: $refreshToken, ') ..write('dpopPublicKey: $dpopPublicKey, ') @@ -3870,6 +3909,7 @@ typedef $$AccountsTableCreateCompanionBuilder = required String handle, Value displayName, Value service, + Value oauthService, required String accessToken, Value refreshToken, Value dpopPublicKey, @@ -3886,6 +3926,7 @@ typedef $$AccountsTableUpdateCompanionBuilder = Value handle, Value displayName, Value service, + Value oauthService, Value accessToken, Value refreshToken, Value dpopPublicKey, @@ -3916,6 +3957,9 @@ class $$AccountsTableFilterComposer extends Composer<_$AppDatabase, $AccountsTab ColumnFilters get service => $composableBuilder(column: $table.service, builder: (column) => ColumnFilters(column)); + ColumnFilters get oauthService => + $composableBuilder(column: $table.oauthService, builder: (column) => ColumnFilters(column)); + ColumnFilters get accessToken => $composableBuilder(column: $table.accessToken, builder: (column) => ColumnFilters(column)); @@ -3961,6 +4005,9 @@ class $$AccountsTableOrderingComposer extends Composer<_$AppDatabase, $AccountsT ColumnOrderings get service => $composableBuilder(column: $table.service, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get oauthService => + $composableBuilder(column: $table.oauthService, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get accessToken => $composableBuilder(column: $table.accessToken, builder: (column) => ColumnOrderings(column)); @@ -4003,6 +4050,9 @@ class $$AccountsTableAnnotationComposer extends Composer<_$AppDatabase, $Account GeneratedColumn get service => $composableBuilder(column: $table.service, builder: (column) => column); + GeneratedColumn get oauthService => + $composableBuilder(column: $table.oauthService, builder: (column) => column); + GeneratedColumn get accessToken => $composableBuilder(column: $table.accessToken, builder: (column) => column); @@ -4053,6 +4103,7 @@ class $$AccountsTableTableManager Value handle = const Value.absent(), Value displayName = const Value.absent(), Value service = const Value.absent(), + Value oauthService = const Value.absent(), Value accessToken = const Value.absent(), Value refreshToken = const Value.absent(), Value dpopPublicKey = const Value.absent(), @@ -4067,6 +4118,7 @@ class $$AccountsTableTableManager handle: handle, displayName: displayName, service: service, + oauthService: oauthService, accessToken: accessToken, refreshToken: refreshToken, dpopPublicKey: dpopPublicKey, @@ -4083,6 +4135,7 @@ class $$AccountsTableTableManager required String handle, Value displayName = const Value.absent(), Value service = const Value.absent(), + Value oauthService = const Value.absent(), required String accessToken, Value refreshToken = const Value.absent(), Value dpopPublicKey = const Value.absent(), @@ -4097,6 +4150,7 @@ class $$AccountsTableTableManager handle: handle, displayName: displayName, service: service, + oauthService: oauthService, accessToken: accessToken, refreshToken: refreshToken, dpopPublicKey: dpopPublicKey, diff --git a/lib/core/database/tables.dart b/lib/core/database/tables.dart index a8d1b62..0375368 100644 --- a/lib/core/database/tables.dart +++ b/lib/core/database/tables.dart @@ -6,6 +6,7 @@ class Accounts extends Table { TextColumn get handle => text()(); TextColumn get displayName => text().nullable()(); TextColumn get service => text().nullable()(); + TextColumn get oauthService => text().nullable()(); TextColumn get accessToken => text()(); TextColumn get refreshToken => text().nullable()(); TextColumn get dpopPublicKey => text().nullable()(); diff --git a/lib/features/account/cubit/account_switcher_cubit.dart b/lib/features/account/cubit/account_switcher_cubit.dart index 50f155f..373e604 100644 --- a/lib/features/account/cubit/account_switcher_cubit.dart +++ b/lib/features/account/cubit/account_switcher_cubit.dart @@ -50,6 +50,7 @@ class AccountSwitcherCubit extends Cubit { handle: account.handle, displayName: account.displayName, service: account.service, + oauthService: account.oauthService, dpopNonce: account.dpopNonce, dpopPublicKey: account.dpopPublicKey, dpopPrivateKey: account.dpopPrivateKey, @@ -95,6 +96,7 @@ class AccountSwitcherCubit extends Cubit { handle: Value(tokens.handle), displayName: tokens.displayName != null ? Value(tokens.displayName!) : const Value.absent(), service: tokens.service != null ? Value(tokens.service!) : const Value.absent(), + oauthService: tokens.oauthService != null ? Value(tokens.oauthService!) : const Value.absent(), accessToken: Value(tokens.accessToken), refreshToken: tokens.refreshToken != null ? Value(tokens.refreshToken!) : const Value.absent(), dpopPublicKey: tokens.dpopPublicKey != null ? Value(tokens.dpopPublicKey!) : const Value.absent(), diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index ec6c8fb..aca0132 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -68,6 +68,10 @@ class AuthRepository { return null; } + final authMethod = account.dpopPrivateKey != null && account.dpopPublicKey != null + ? AuthMethod.oauth + : AuthMethod.appPassword; + return AuthTokens( accessToken: account.accessToken, refreshToken: account.refreshToken, @@ -76,12 +80,11 @@ class AuthRepository { handle: account.handle, displayName: account.displayName, service: account.service, + oauthService: authMethod == AuthMethod.oauth ? normalizeAtprotoServiceHost(account.oauthService) : null, dpopNonce: account.dpopNonce, dpopPublicKey: account.dpopPublicKey, dpopPrivateKey: account.dpopPrivateKey, - authMethod: account.dpopPrivateKey != null && account.dpopPublicKey != null - ? AuthMethod.oauth - : AuthMethod.appPassword, + authMethod: authMethod, ); } @@ -120,6 +123,7 @@ class AuthRepository { handle: Value(tokens.handle), displayName: tokens.displayName != null ? Value(tokens.displayName) : const Value.absent(), service: tokens.service != null ? Value(tokens.service) : const Value.absent(), + oauthService: tokens.oauthService != null ? Value(tokens.oauthService) : const Value.absent(), accessToken: Value(tokens.accessToken), refreshToken: tokens.refreshToken != null ? Value(tokens.refreshToken) : const Value.absent(), dpopPublicKey: tokens.dpopPublicKey != null ? Value(tokens.dpopPublicKey) : const Value.absent(), @@ -221,12 +225,14 @@ class AuthRepository { privateKey: privateKey, ); final oauthServices = _oauthRefreshServiceCandidates( - storedService: currentSession.service, + storedAuthService: currentSession.oauthService, issuer: restoredSession.accessTokenJwt.iss, ); Object? lastAttemptError; StackTrace? lastAttemptStackTrace; + String? successfulOauthService; + final failedAttemptSummaries = []; OAuthSession? refreshedSession; for (final oauthService in oauthServices) { try { @@ -240,12 +246,15 @@ class AuthRepository { privateKey: privateKey, ), ); + successfulOauthService = oauthService; break; } catch (error, stackTrace) { lastAttemptError = error; lastAttemptStackTrace = stackTrace; + final summary = _summarizeOAuthRefreshError(error); + failedAttemptSummaries.add('$oauthService=$summary'); log.w( - 'AuthRepository: OAuth refresh attempt failed using auth service $oauthService', + 'AuthRepository: OAuth refresh attempt failed using auth service $oauthService ($summary)', error: error, stackTrace: stackTrace, ); @@ -256,7 +265,7 @@ class AuthRepository { Error.throwWithStackTrace( Exception( 'OAuth refresh failed across ${oauthServices.length} auth service candidate(s). ' - 'Last error: $lastAttemptError', + 'Attempts: ${failedAttemptSummaries.join(' | ')}. Last error: $lastAttemptError', ), lastAttemptStackTrace ?? StackTrace.current, ); @@ -266,14 +275,18 @@ class AuthRepository { final refreshedTokens = await _buildOAuthTokens( refreshedSession, fallbackHandle: currentSession.handle, - oauthService: fallbackPdsHost, + fallbackPdsHost: fallbackPdsHost, + oauthService: successfulOauthService ?? currentSession.oauthService ?? _oauthService, ); await saveSession( refreshedTokens, makeActive: await _database.getSetting(AppDatabase.activeAccountDidSettingKey) == currentSession.did, ); - log.i('AuthRepository: OAuth session refresh succeeded for ${refreshedTokens.handle}'); + log.i( + 'AuthRepository: OAuth session refresh succeeded for ${refreshedTokens.handle} ' + 'using auth service ${refreshedTokens.oauthService ?? successfulOauthService ?? 'unknown'}', + ); return refreshedTokens; } catch (error, stackTrace) { log.e('AuthRepository: OAuth session refresh failed', error: error, stackTrace: stackTrace); @@ -426,7 +439,12 @@ class AuthRepository { ); final oauthSession = await oauthClient.callback(callbackUrl, oauthContext); log.i('AuthRepository: OAuth token exchange succeeded for DID ${oauthSession.sub}'); - final tokens = await _buildOAuthTokens(oauthSession, fallbackHandle: fallbackHandle, oauthService: service); + final tokens = await _buildOAuthTokens( + oauthSession, + fallbackHandle: fallbackHandle, + fallbackPdsHost: _fallbackService, + oauthService: service, + ); await saveSession(tokens, makeActive: true); log.i('AuthRepository: OAuth login completed for ${tokens.handle}'); return tokens; @@ -435,6 +453,7 @@ class AuthRepository { Future _buildOAuthTokens( OAuthSession session, { required String fallbackHandle, + required String fallbackPdsHost, required String oauthService, }) async { var resolvedHandle = fallbackHandle; @@ -444,7 +463,11 @@ class AuthRepository { 'AuthRepository: OAuth session will target PDS ' '${session.atprotoPdsEndpoint ?? 'unknown'} via auth service $oauthService', ); - final pdsHost = normalizeAtprotoServiceHost(session.atprotoPdsEndpoint) ?? oauthService; + final pdsHost = normalizeAtprotoServiceHost(session.atprotoPdsEndpoint) ?? fallbackPdsHost; + final normalizedOauthService = + normalizeAtprotoServiceHost(session.accessTokenJwt.iss) ?? + normalizeAtprotoServiceHost(oauthService) ?? + _oauthService; try { final authSession = await createAtProtoForOAuthSession(session).server.getSession(); @@ -472,6 +495,7 @@ class AuthRepository { handle: resolvedHandle, displayName: displayName, service: pdsHost, + oauthService: normalizedOauthService, dpopNonce: session.$dPoPNonce, dpopPublicKey: session.$publicKey, dpopPrivateKey: session.$privateKey, @@ -699,16 +723,32 @@ class AuthRepository { return oauthClient.refresh(session); } - static List _oauthRefreshServiceCandidates({required String? storedService, required String? issuer}) { + String _summarizeOAuthRefreshError(Object error) { + final message = error.toString().replaceAll('\n', ' ').trim(); + + if (message.contains('')) { + return 'non_json_html_response'; + } + if (error is FormatException) { + return 'json_parse_error'; + } + if (message.isEmpty) { + return error.runtimeType.toString(); + } + + return message.length <= 240 ? message : '${message.substring(0, 237)}...'; + } + + static List _oauthRefreshServiceCandidates({required String? storedAuthService, required String? issuer}) { final candidates = {}; final issuerHost = normalizeAtprotoServiceHost(issuer); if (issuerHost != null) { candidates.add(issuerHost); } - final storedHost = normalizeAtprotoServiceHost(storedService); - if (storedHost != null) { - candidates.add(storedHost); + final storedAuthHost = normalizeAtprotoServiceHost(storedAuthService); + if (storedAuthHost != null) { + candidates.add(storedAuthHost); } candidates.add(_oauthService); @@ -717,8 +757,11 @@ class AuthRepository { } @visibleForTesting - static List oauthRefreshServiceCandidatesForTest({required String? storedService, required String? issuer}) { - return _oauthRefreshServiceCandidates(storedService: storedService, issuer: issuer); + static List oauthRefreshServiceCandidatesForTest({ + required String? storedAuthService, + required String? issuer, + }) { + return _oauthRefreshServiceCandidates(storedAuthService: storedAuthService, issuer: issuer); } @visibleForTesting diff --git a/lib/features/auth/data/models/auth_models.dart b/lib/features/auth/data/models/auth_models.dart index 1d27f26..aab4a45 100644 --- a/lib/features/auth/data/models/auth_models.dart +++ b/lib/features/auth/data/models/auth_models.dart @@ -11,6 +11,7 @@ class AuthTokens extends Equatable { required this.handle, this.displayName, this.service, + this.oauthService, this.dpopNonce, this.dpopPublicKey, this.dpopPrivateKey, @@ -23,6 +24,7 @@ class AuthTokens extends Equatable { final String handle; final String? displayName; final String? service; + final String? oauthService; final String? dpopNonce; final String? dpopPublicKey; final String? dpopPrivateKey; @@ -36,6 +38,7 @@ class AuthTokens extends Equatable { String? handle, String? displayName, String? service, + String? oauthService, String? dpopNonce, String? dpopPublicKey, String? dpopPrivateKey, @@ -49,6 +52,7 @@ class AuthTokens extends Equatable { handle: handle ?? this.handle, displayName: displayName ?? this.displayName, service: service ?? this.service, + oauthService: oauthService ?? this.oauthService, dpopNonce: dpopNonce ?? this.dpopNonce, dpopPublicKey: dpopPublicKey ?? this.dpopPublicKey, dpopPrivateKey: dpopPrivateKey ?? this.dpopPrivateKey, @@ -72,6 +76,7 @@ class AuthTokens extends Equatable { handle, displayName, service, + oauthService, dpopNonce, dpopPublicKey, dpopPrivateKey, diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart index c1f4a02..a861946 100644 --- a/lib/features/auth/presentation/login_screen.dart +++ b/lib/features/auth/presentation/login_screen.dart @@ -121,7 +121,7 @@ class _LoginScreenState extends State { child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.language), - label: Text(state.isLoading ? 'Starting sign in...' : 'Continue to BlueSky'), + label: Text(state.isLoading ? 'Starting sign in...' : 'Continue'), style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 18)), ); }, diff --git a/test/core/database/app_database_test.dart b/test/core/database/app_database_test.dart index 9760acc..dc38e2e 100644 --- a/test/core/database/app_database_test.dart +++ b/test/core/database/app_database_test.dart @@ -1,4 +1,5 @@ import 'package:drift/native.dart'; +import 'package:drift/drift.dart' show Value; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/database/app_database.dart'; @@ -165,6 +166,23 @@ void main() { expect(retrieved.refreshToken, equals('new_refresh')); expect(retrieved.dpopNonce, equals('nonce-1')); }); + + test('should persist oauth service separately from pds service', () async { + final account = AccountsCompanion.insert( + did: 'did:plc:oauth123', + handle: 'oauth-user.bsky.social', + accessToken: 'access-token', + service: const Value('porcini.us-east.host.bsky.network'), + oauthService: const Value('bsky.social'), + ); + + await database.insertAccount(account); + final retrieved = await database.getAccount('did:plc:oauth123'); + + expect(retrieved, isNotNull); + expect(retrieved!.service, equals('porcini.us-east.host.bsky.network')); + expect(retrieved.oauthService, equals('bsky.social')); + }); }); group('Cache operations', () { diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart index dab1bb9..3048647 100644 --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -345,7 +345,7 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); - expect(find.text('Continue to BlueSky'), findsOneWidget); + expect(find.text('Continue'), findsOneWidget); expect(tester.takeException(), isNull); router.dispose(); diff --git a/test/features/account/cubit/account_switcher_cubit_test.dart b/test/features/account/cubit/account_switcher_cubit_test.dart index d0f1474..fe2f159 100644 --- a/test/features/account/cubit/account_switcher_cubit_test.dart +++ b/test/features/account/cubit/account_switcher_cubit_test.dart @@ -289,6 +289,7 @@ void main() { accessToken: 'token', did: 'did:plc:newuser', handle: 'new.bsky.social', + oauthService: 'bsky.social', dpopPublicKey: 'public-key', dpopPrivateKey: 'private-key', authMethod: AuthMethod.oauth, @@ -312,6 +313,7 @@ void main() { expect(captured, hasLength(1)); expect(captured.single.dpopPublicKey.value, 'public-key'); expect(captured.single.dpopPrivateKey.value, 'private-key'); + expect(captured.single.oauthService.value, 'bsky.social'); }); blocTest( diff --git a/test/features/auth/data/auth_repository_test.dart b/test/features/auth/data/auth_repository_test.dart index 0863288..dbd6197 100644 --- a/test/features/auth/data/auth_repository_test.dart +++ b/test/features/auth/data/auth_repository_test.dart @@ -42,6 +42,7 @@ void main() { did: 'did:plc:abc123', handle: 'user.bsky.social', service: 'bsky.social', + oauthService: null, accessToken: 'access_token', refreshToken: 'refresh_token', dpopPublicKey: null, @@ -66,6 +67,32 @@ void main() { expect(result.service, equals('bsky.social')); expect(result.authMethod, AuthMethod.appPassword); }); + + test('should read oauthService for oauth-backed account', () async { + final account = Account( + did: 'did:plc:oauth123', + handle: 'oauth-user.bsky.social', + service: 'porcini.us-east.host.bsky.network', + oauthService: 'bsky.social', + accessToken: 'access_token', + refreshToken: 'refresh_token', + dpopPublicKey: 'public-key', + dpopPrivateKey: 'private-key', + dpopNonce: 'nonce', + displayName: 'OAuth User', + expiresAt: null, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + when(() => mockDatabase.getActiveAccount()).thenAnswer((_) async => account); + + final result = await authRepository.getStoredSession(); + + expect(result, isNotNull); + expect(result!.usesOAuth, isTrue); + expect(result.oauthService, equals('bsky.social')); + }); }); group('saveSession', () { @@ -129,13 +156,22 @@ void main() { }); group('oauth refresh', () { - test('orders issuer host before stored host and deduplicates candidates', () { + test('orders issuer host before stored auth host and deduplicates candidates', () { final candidates = AuthRepository.oauthRefreshServiceCandidatesForTest( - storedService: 'https://porcini.us-east.host.bsky.network', + storedAuthService: 'https://bsky.social', issuer: 'https://bsky.social', ); - expect(candidates, equals(['bsky.social', 'porcini.us-east.host.bsky.network'])); + expect(candidates, equals(['bsky.social'])); + }); + + test('uses stored oauth auth host when issuer is unavailable', () { + final candidates = AuthRepository.oauthRefreshServiceCandidatesForTest( + storedAuthService: 'https://oauth.custom.example', + issuer: null, + ); + + expect(candidates, equals(['oauth.custom.example', 'bsky.social'])); }); test('retries OAuth refresh against fallback auth service hosts', () async { @@ -186,6 +222,7 @@ void main() { did: 'did:plc:abc123', handle: 'user.bsky.social', service: 'porcini.us-east.host.bsky.network', + oauthService: 'porcini.us-east.host.bsky.network', dpopNonce: 'nonce', dpopPublicKey: 'public-key', dpopPrivateKey: 'private-key', @@ -206,6 +243,7 @@ void main() { expect(refreshed, isNotNull); expect(refreshed!.did, equals(currentSession.did)); expect(attemptedServices, equals(['porcini.us-east.host.bsky.network', 'bsky.social'])); + expect(refreshed.oauthService, equals('bsky.social')); verifyNever(() => mockDatabase.deleteAccount(any())); verify(() => mockDatabase.insertAccount(any())).called(1); }); @@ -369,8 +407,8 @@ String _buildJwt({ 'sub': sub, 'exp': expEpochSeconds, 'iat': iatEpochSeconds, - if (aud != null) 'aud': aud, - if (iss != null) 'iss': iss, + 'aud': ?aud, + 'iss': ?iss, 'scope': 'atproto', }); diff --git a/test/features/auth/data/models/auth_models_test.dart b/test/features/auth/data/models/auth_models_test.dart index c2afc57..44353e0 100644 --- a/test/features/auth/data/models/auth_models_test.dart +++ b/test/features/auth/data/models/auth_models_test.dart @@ -11,6 +11,7 @@ void main() { handle: 'user.bsky.social', displayName: 'User Name', service: 'bsky.social', + oauthService: 'bsky.social', ); expect(tokens.accessToken, equals('access_token')); @@ -19,6 +20,7 @@ void main() { expect(tokens.handle, equals('user.bsky.social')); expect(tokens.displayName, equals('User Name')); expect(tokens.service, equals('bsky.social')); + expect(tokens.oauthService, equals('bsky.social')); }); test('should create AuthTokens without optional fields', () { @@ -34,11 +36,12 @@ void main() { test('should copy with new values', () { const tokens = AuthTokens(accessToken: 'old_token', did: 'did:plc:abc123', handle: 'user.bsky.social'); - final newTokens = tokens.copyWith(accessToken: 'new_token', displayName: 'New Name'); + final newTokens = tokens.copyWith(accessToken: 'new_token', displayName: 'New Name', oauthService: 'bsky.social'); expect(newTokens.accessToken, equals('new_token')); expect(newTokens.did, equals('did:plc:abc123')); expect(newTokens.displayName, equals('New Name')); + expect(newTokens.oauthService, equals('bsky.social')); }); test('should identify oauth-backed sessions', () { -- 2.51.2