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', () {