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

Configure Feed

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

fix: account switching and typeahead (#5)

* fix: account switch/auth redirect

* persist multiple accounts

* replace account switcher input with typeahead

* fix: better error handling for OAuth login and refine identifier resolution exceptions

* feat: add explicit validation feedback for Bluesky handles and DIDs in account switcher

* refactor: shared identifier validator and error

* pull typeahead repo from state

* feat: account removal functionality and reauthentication

* feat: better DID validation

* add session clearing

* feat: remove "auto" account switching

* more resilient validation for DIDs and handles

authored by

Owais and committed by
GitHub
5e78d92a 455685bd

+945 -83
+3 -7
lib/core/router/app_router.dart
··· 104 104 final path = state.uri.path; 105 105 final publicPaths = {'/login', '/terms', '/privacy', OAuthCallbackScreen.routePath}; 106 106 final isLoggingIn = path == '/login'; 107 - final isOAuthCallback = path == OAuthCallbackScreen.routePath; 107 + final isReauthLogin = state.uri.queryParameters['reauth'] == '1'; 108 108 final isPublicPath = publicPaths.contains(path); 109 109 110 110 if (!isAuthenticated && !isPublicPath) { 111 111 return '/login'; 112 112 } 113 113 114 - if (isAuthenticated && (isLoggingIn || isOAuthCallback)) { 114 + if (isAuthenticated && isLoggingIn && !isReauthLogin) { 115 115 return '/'; 116 116 } 117 117 ··· 122 122 GoRoute( 123 123 path: OAuthCallbackScreen.routePath, 124 124 parentNavigatorKey: _rootNavigatorKey, 125 - pageBuilder: (context, state) => _page( 126 - context, 127 - state, 128 - OAuthCallbackScreen(callbackUri: state.uri), 129 - ), 125 + pageBuilder: (context, state) => _page(context, state, OAuthCallbackScreen(callbackUri: state.uri)), 130 126 ), 131 127 GoRoute(path: '/terms', pageBuilder: (context, state) => _page(context, state, const TermsOfServiceScreen())), 132 128 GoRoute(path: '/privacy', pageBuilder: (context, state) => _page(context, state, const PrivacyPolicyScreen())),
+107 -18
lib/features/account/cubit/account_switcher_cubit.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:drift/drift.dart'; 2 4 import 'package:equatable/equatable.dart'; 3 5 import 'package:flutter_bloc/flutter_bloc.dart'; 4 6 import 'package:lazurite/core/database/app_database.dart'; 7 + import 'package:lazurite/core/logging/app_logger.dart'; 5 8 import 'package:lazurite/features/auth/data/auth_repository.dart'; 6 9 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 7 10 8 11 part 'account_switcher_state.dart'; 9 12 13 + class AccountRemovalResult { 14 + const AccountRemovalResult._({required this.removed, required this.requiresSignIn, this.switchedTokens}); 15 + 16 + const AccountRemovalResult.removed() : this._(removed: true, requiresSignIn: false); 17 + 18 + const AccountRemovalResult.switched(AuthTokens tokens) 19 + : this._(removed: true, requiresSignIn: false, switchedTokens: tokens); 20 + 21 + const AccountRemovalResult.requiresSignIn() : this._(removed: true, requiresSignIn: true); 22 + 23 + const AccountRemovalResult.failed() : this._(removed: false, requiresSignIn: false); 24 + 25 + final bool removed; 26 + final bool requiresSignIn; 27 + final AuthTokens? switchedTokens; 28 + } 29 + 10 30 class AccountSwitcherCubit extends Cubit<AccountSwitcherState> { 11 31 AccountSwitcherCubit({required AppDatabase database, required AuthRepository authRepository}) 12 32 : _database = database, ··· 15 35 16 36 final AppDatabase _database; 17 37 final AuthRepository _authRepository; 38 + String? _lastAddAccountErrorMessage; 39 + 40 + String? get lastAddAccountErrorMessage => _lastAddAccountErrorMessage; 18 41 19 42 Future<void> loadAccounts() async { 20 43 emit(const AccountSwitcherState.loading()); ··· 42 65 final account = await _database.getAccount(did); 43 66 if (account == null) return null; 44 67 68 + return _switchToAccount(account, allowRefresh: true); 69 + } 70 + 71 + Future<AuthTokens?> _switchToAccount(Account account, {required bool allowRefresh}) async { 72 + final did = account.did; 73 + final tokens = _tokensFromAccount(account); 74 + 75 + try { 76 + final nextTokens = tokens.isExpired 77 + ? !allowRefresh || tokens.refreshToken == null 78 + ? null 79 + : await _authRepository.refreshSession(tokens) 80 + : tokens; 81 + 82 + if (nextTokens == null) { 83 + return null; 84 + } 85 + 86 + await _database.setSetting(AppDatabase.activeAccountDidSettingKey, did); 87 + emit(state.copyWith(activeDid: did)); 88 + return nextTokens; 89 + } catch (_) { 90 + return null; 91 + } 92 + } 93 + 94 + AuthTokens _tokensFromAccount(Account account) { 45 95 final tokens = AuthTokens( 46 96 accessToken: account.accessToken, 47 97 refreshToken: account.refreshToken, ··· 59 109 : AuthMethod.appPassword, 60 110 ); 61 111 62 - try { 63 - final nextTokens = tokens.isExpired 64 - ? tokens.refreshToken == null 65 - ? null 66 - : await _authRepository.refreshSession(tokens) 67 - : tokens; 68 - 69 - if (nextTokens == null) { 70 - return null; 71 - } 72 - 73 - await _database.setSetting(AppDatabase.activeAccountDidSettingKey, did); 74 - emit(state.copyWith(activeDid: did)); 75 - return nextTokens; 76 - } catch (_) { 77 - return null; 78 - } 112 + return tokens; 79 113 } 80 114 81 115 Future<AuthTokens?> addAccountWithOAuth(String handle) async { 116 + _lastAddAccountErrorMessage = null; 82 117 try { 83 118 final tokens = await _authRepository.loginWithOAuth(handle); 84 119 if (tokens == null) return null; 85 120 await addAccountCompleted(tokens); 86 121 return tokens; 87 - } catch (_) { 122 + } on AuthIdentifierResolutionException catch (error) { 123 + _lastAddAccountErrorMessage = error.message; 124 + return null; 125 + } catch (error, _) { 126 + _lastAddAccountErrorMessage = _userFacingAddAccountErrorMessage(error); 88 127 return null; 89 128 } 90 129 } 91 130 131 + String _userFacingAddAccountErrorMessage(Object error) { 132 + if (error is TimeoutException) { 133 + return 'Sign-in timed out before completion. Please try again.'; 134 + } 135 + return 'Unable to add account right now. Please try again.'; 136 + } 137 + 92 138 Future<void> addAccountCompleted(AuthTokens tokens) async { 93 139 await _database.insertAccount( 94 140 AccountsCompanion( ··· 108 154 109 155 await loadAccounts(); 110 156 await switchAccount(tokens.did); 157 + } 158 + 159 + Future<AccountRemovalResult> removeAccount(String did) async { 160 + try { 161 + if (state.status != AccountSwitcherStatus.ready) { 162 + return const AccountRemovalResult.failed(); 163 + } 164 + 165 + final account = await _database.getAccount(did); 166 + if (account == null) { 167 + return const AccountRemovalResult.failed(); 168 + } 169 + 170 + final wasActive = state.activeDid == did; 171 + await _database.deleteAccount(did); 172 + 173 + if (!wasActive) { 174 + await loadAccounts(); 175 + return const AccountRemovalResult.removed(); 176 + } 177 + 178 + final remainingAccounts = await _database.getAllAccounts(); 179 + if (remainingAccounts.isEmpty) { 180 + await _database.deleteSetting(AppDatabase.activeAccountDidSettingKey); 181 + await loadAccounts(); 182 + return const AccountRemovalResult.requiresSignIn(); 183 + } 184 + 185 + for (final remaining in remainingAccounts) { 186 + final switchedTokens = await _switchToAccount(remaining, allowRefresh: false); 187 + if (switchedTokens != null) { 188 + await loadAccounts(); 189 + return AccountRemovalResult.switched(switchedTokens); 190 + } 191 + } 192 + 193 + await _database.deleteSetting(AppDatabase.activeAccountDidSettingKey); 194 + await loadAccounts(); 195 + return const AccountRemovalResult.requiresSignIn(); 196 + } catch (error, stackTrace) { 197 + log.w('AccountSwitcherCubit: Failed to remove account $did', error: error, stackTrace: stackTrace); 198 + return const AccountRemovalResult.failed(); 199 + } 111 200 } 112 201 }
+152 -34
lib/features/account/presentation/account_switcher_sheet.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:flutter/material.dart'; 2 4 import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:go_router/go_router.dart'; 3 6 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 4 7 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 8 + import 'package:lazurite/features/auth/data/atproto_identifier.dart'; 9 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 10 + import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 11 + import 'package:lazurite/features/typeahead/presentation/typeahead_text_field.dart'; 5 12 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 6 - import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 7 13 import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 8 14 import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 15 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 16 + 17 + String? validateAtProtoIdentifierInput(String? value) { 18 + final normalized = normalizeAtProtoIdentifierForAuth(value ?? ''); 19 + final validationError = validateAtProtoIdentifierForAuth(normalized); 20 + if (validationError == null) { 21 + return null; 22 + } 23 + 24 + return switch (validationError.code) { 25 + AtProtoIdentifierValidationErrorCode.empty => 'Enter a Bluesky handle or DID', 26 + AtProtoIdentifierValidationErrorCode.unsupportedDid => 'Use a did:plc:... or did:web:... identifier', 27 + AtProtoIdentifierValidationErrorCode.invalidDid => 'Enter a complete DID like did:plc:... or did:web:...', 28 + AtProtoIdentifierValidationErrorCode.invalidHandle => 'Enter a full handle like username.bsky.social', 29 + }; 30 + } 9 31 10 32 void showAccountSwitcherSheet(BuildContext context) { 11 33 final cubit = context.read<AccountSwitcherCubit>(); 12 34 final authBloc = context.read<AuthBloc>(); 35 + final typeaheadRepository = context.read<TypeaheadRepository>(); 36 + final parentContext = context; 37 + unawaited(cubit.loadAccounts()); 13 38 14 39 showAppBottomSheet<void>( 15 40 context: context, 16 41 builder: (sheetContext) => BlocProvider.value( 17 42 value: cubit, 18 - child: _AccountSwitcherSheet(authBloc: authBloc), 43 + child: _AccountSwitcherSheet( 44 + authBloc: authBloc, 45 + parentContext: parentContext, 46 + typeaheadRepository: typeaheadRepository, 47 + ), 19 48 ), 20 49 ); 21 50 } 22 51 23 52 class _AccountSwitcherSheet extends StatelessWidget { 24 - const _AccountSwitcherSheet({required this.authBloc}); 53 + const _AccountSwitcherSheet({required this.authBloc, required this.parentContext, required this.typeaheadRepository}); 25 54 26 55 final AuthBloc authBloc; 56 + final BuildContext parentContext; 57 + final TypeaheadRepository typeaheadRepository; 58 + 59 + static bool _isIdentifierInputValid(String value) => validateAtProtoIdentifierInput(value) == null; 27 60 28 61 @override 29 62 Widget build(BuildContext context) { ··· 64 97 leading: ProfileAvatar(size: 40, fallbackText: label), 65 98 title: Text(label), 66 99 subtitle: Text('@${account.handle}'), 67 - trailing: isActive ? const Icon(Icons.check) : null, 100 + trailing: Row( 101 + mainAxisSize: MainAxisSize.min, 102 + children: [ 103 + if (isActive) const Icon(Icons.check), 104 + IconButton( 105 + tooltip: 'Remove account', 106 + icon: const Icon(Icons.delete_outline), 107 + onPressed: () => _onRemoveAccount(context, account.did, account.handle), 108 + ), 109 + ], 110 + ), 68 111 onTap: isActive ? null : () => _onSwitchAccount(context, account.did), 69 112 ); 70 113 }, ··· 82 125 ); 83 126 } 84 127 85 - Widget _buildEmptyState(ColorScheme colorScheme, TextTheme textTheme) { 86 - return Padding( 87 - padding: const EdgeInsets.fromLTRB(24, 20, 24, 24), 88 - child: Row( 89 - children: [ 90 - Icon(Icons.swap_horiz_outlined, color: colorScheme.onSurfaceVariant), 91 - const SizedBox(width: 12), 92 - Expanded( 93 - child: Text( 94 - 'No other signed-in accounts yet. Add an account to switch between profiles.', 95 - style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), 96 - ), 128 + Widget _buildEmptyState(ColorScheme colorScheme, TextTheme textTheme) => Padding( 129 + padding: const EdgeInsets.fromLTRB(24, 20, 24, 24), 130 + child: Row( 131 + children: [ 132 + Icon(Icons.swap_horiz_outlined, color: colorScheme.onSurfaceVariant), 133 + const SizedBox(width: 12), 134 + Expanded( 135 + child: Text( 136 + 'No other signed-in accounts yet. Add an account to switch between profiles.', 137 + style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), 97 138 ), 98 - ], 99 - ), 100 - ); 101 - } 139 + ), 140 + ], 141 + ), 142 + ); 102 143 103 144 Future<void> _onSwitchAccount(BuildContext context, String did) async { 104 145 final cubit = context.read<AccountSwitcherCubit>(); ··· 109 150 return; 110 151 } 111 152 112 - if (context.mounted) { 113 - showAppSnackBar(context, 'Unable to switch accounts. Sign in again for that account.'); 153 + if (parentContext.mounted) { 154 + showAppSnackBar(parentContext, 'Please sign in again for that account.'); 155 + final router = GoRouter.maybeOf(parentContext); 156 + if (router != null) { 157 + unawaited(Future<void>.delayed(Duration.zero, () => router.go('/login?reauth=1'))); 158 + } 114 159 } 115 160 } 116 161 ··· 119 164 Navigator.pop(context); 120 165 121 166 final controller = TextEditingController(); 167 + final formKey = GlobalKey<FormState>(); 168 + final focusNode = FocusNode(); 122 169 final handle = await showDialog<String>( 123 - context: context, 124 - builder: (dialogContext) => ConfirmationDialog( 125 - title: const Text('Add Account'), 126 - content: TextField( 127 - controller: controller, 128 - decoration: const InputDecoration(labelText: 'Handle or DID'), 129 - autofocus: true, 170 + context: parentContext, 171 + builder: (dialogContext) => StatefulBuilder( 172 + builder: (dialogContext, setDialogState) => ConfirmationDialog( 173 + title: const Text('Add Account'), 174 + content: SizedBox( 175 + width: 420, 176 + child: Form( 177 + key: formKey, 178 + child: TypeaheadTextField( 179 + controller: controller, 180 + focusNode: focusNode, 181 + repository: typeaheadRepository, 182 + onSelected: (TypeaheadResult result) { 183 + controller.text = result.handle; 184 + setDialogState(() {}); 185 + }, 186 + minChars: 2, 187 + debounceMs: 300, 188 + limit: 8, 189 + decoration: const InputDecoration(labelText: 'Handle or DID', hintText: 'username.bsky.social'), 190 + textInputAction: TextInputAction.done, 191 + validator: validateAtProtoIdentifierInput, 192 + onChanged: (_) => setDialogState(() {}), 193 + onFieldSubmitted: (_) { 194 + if ((formKey.currentState?.validate() ?? false)) { 195 + Navigator.pop(dialogContext, controller.text.trim()); 196 + } 197 + }, 198 + ), 199 + ), 200 + ), 201 + confirmEnabled: _isIdentifierInputValid(controller.text.trim()), 202 + confirmLabel: 'Continue', 203 + onCancel: () => Navigator.pop(dialogContext), 204 + onConfirm: () { 205 + if (!(formKey.currentState?.validate() ?? false)) { 206 + return; 207 + } 208 + Navigator.pop(dialogContext, controller.text.trim()); 209 + }, 130 210 ), 131 - confirmLabel: 'Continue', 132 - onCancel: () => Navigator.pop(dialogContext), 133 - onConfirm: () => Navigator.pop(dialogContext, controller.text.trim()), 134 211 ), 135 212 ); 136 213 controller.dispose(); 214 + focusNode.dispose(); 137 215 138 216 if (handle == null || handle.isEmpty) return; 139 217 140 218 final tokens = await cubit.addAccountWithOAuth(handle); 141 219 if (tokens != null) { 142 220 authBloc.add(SessionRestored(tokens: tokens)); 143 - } else if (context.mounted) { 144 - showAppSnackBar(context, 'Failed to add account', isError: true); 221 + } else if (parentContext.mounted) { 222 + showAppSnackBar(parentContext, cubit.lastAddAccountErrorMessage ?? 'Failed to add account', isError: true); 223 + } 224 + } 225 + 226 + Future<void> _onRemoveAccount(BuildContext context, String did, String handle) async { 227 + final cubit = context.read<AccountSwitcherCubit>(); 228 + final remove = await showDialog<bool>( 229 + context: parentContext, 230 + builder: (dialogContext) => ConfirmationDialog( 231 + title: const Text('Remove Account'), 232 + content: Text('Remove @$handle from this device?'), 233 + confirmLabel: 'Remove', 234 + onCancel: () => Navigator.pop(dialogContext, false), 235 + onConfirm: () => Navigator.pop(dialogContext, true), 236 + ), 237 + ); 238 + 239 + if (remove != true) { 240 + return; 241 + } 242 + 243 + final result = await cubit.removeAccount(did); 244 + if (!result.removed) { 245 + if (parentContext.mounted) { 246 + showAppSnackBar(parentContext, 'Unable to remove account right now.', isError: true); 247 + } 248 + return; 249 + } 250 + 251 + final switchedTokens = result.switchedTokens; 252 + if (switchedTokens != null) { 253 + authBloc.add(SessionRestored(tokens: switchedTokens)); 254 + } 255 + 256 + if (result.requiresSignIn && parentContext.mounted) { 257 + authBloc.add(const SessionCleared()); 258 + Navigator.pop(context); 259 + final router = GoRouter.maybeOf(parentContext); 260 + if (router != null) { 261 + router.go('/login?reauth=1'); 262 + } 145 263 } 146 264 } 147 265 }
+5
lib/features/auth/bloc/auth_bloc.dart
··· 15 15 on<LogoutRequested>(_onLogoutRequested); 16 16 on<SessionRestored>(_onSessionRestored); 17 17 on<CheckSessionRequested>(_onCheckSessionRequested); 18 + on<SessionCleared>(_onSessionCleared); 18 19 } 19 20 20 21 final AuthRepository _authRepository; ··· 82 83 } catch (_) { 83 84 emit(const AuthState.unauthenticated()); 84 85 } 86 + } 87 + 88 + Future<void> _onSessionCleared(SessionCleared event, Emitter<AuthState> emit) async { 89 + emit(const AuthState.unauthenticated()); 85 90 } 86 91 }
+4
lib/features/auth/bloc/auth_event.dart
··· 39 39 class CheckSessionRequested extends AuthEvent { 40 40 const CheckSessionRequested(); 41 41 } 42 + 43 + class SessionCleared extends AuthEvent { 44 + const SessionCleared(); 45 + }
+125
lib/features/auth/data/atproto_identifier.dart
··· 1 + import 'package:flutter/foundation.dart'; 2 + 3 + enum AtProtoIdentifierValidationErrorCode { empty, unsupportedDid, invalidDid, invalidHandle } 4 + 5 + class AtProtoIdentifierValidationError { 6 + const AtProtoIdentifierValidationError(this.code); 7 + 8 + final AtProtoIdentifierValidationErrorCode code; 9 + } 10 + 11 + final RegExp _atprotoHandlePattern = RegExp( 12 + r'^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$', 13 + ); 14 + final RegExp _didPlcSuffixPattern = RegExp(r'^[a-z2-7]{24}$'); 15 + final RegExp _didWebLocalhostPortPattern = RegExp(r'^localhost%3A[0-9]+$', caseSensitive: false); 16 + final RegExp _didWebHostLabelPattern = RegExp(r'^[A-Za-z0-9-]+$'); 17 + const Set<String> _disallowedDidWebTopLevelDomains = { 18 + 'alt', 19 + 'arpa', 20 + 'example', 21 + 'internal', 22 + 'invalid', 23 + 'local', 24 + 'localhost', 25 + 'onion', 26 + }; 27 + 28 + String normalizeAtProtoIdentifierForAuth(String identifier) { 29 + final trimmed = identifier.trim(); 30 + final lower = trimmed.toLowerCase(); 31 + if (lower.startsWith('did:plc:')) { 32 + return 'did:plc:${trimmed.substring('did:plc:'.length).toLowerCase()}'; 33 + } 34 + if (lower.startsWith('did:web:')) { 35 + return 'did:web:${trimmed.substring('did:web:'.length).toLowerCase()}'; 36 + } 37 + if (lower.startsWith('did:')) { 38 + return 'did:${trimmed.substring('did:'.length)}'; 39 + } 40 + 41 + final withoutAt = trimmed.replaceFirst(RegExp(r'^@+'), ''); 42 + return withoutAt.toLowerCase(); 43 + } 44 + 45 + AtProtoIdentifierValidationError? validateAtProtoIdentifierForAuth( 46 + String identifier, { 47 + bool allowDevelopmentDidWebHosts = !kReleaseMode, 48 + }) { 49 + if (identifier.isEmpty) { 50 + return const AtProtoIdentifierValidationError(AtProtoIdentifierValidationErrorCode.empty); 51 + } 52 + 53 + final normalizedLower = identifier.toLowerCase(); 54 + if (normalizedLower.startsWith('did:')) { 55 + if (normalizedLower.startsWith('did:plc:')) { 56 + final suffix = identifier.substring('did:plc:'.length).trim(); 57 + if (!_didPlcSuffixPattern.hasMatch(suffix)) { 58 + return const AtProtoIdentifierValidationError(AtProtoIdentifierValidationErrorCode.invalidDid); 59 + } 60 + return null; 61 + } 62 + 63 + if (normalizedLower.startsWith('did:web:')) { 64 + final suffix = identifier.substring('did:web:'.length).trim(); 65 + if (suffix.isEmpty || 66 + suffix.contains(RegExp(r'\s')) || 67 + suffix.contains(':') || 68 + suffix.contains('/') || 69 + suffix.contains('?') || 70 + suffix.contains('#')) { 71 + return const AtProtoIdentifierValidationError(AtProtoIdentifierValidationErrorCode.invalidDid); 72 + } 73 + 74 + if (!_isValidDidWebHostSuffix(suffix, allowDevelopmentDidWebHosts: allowDevelopmentDidWebHosts)) { 75 + return const AtProtoIdentifierValidationError(AtProtoIdentifierValidationErrorCode.invalidDid); 76 + } 77 + return null; 78 + } 79 + 80 + return const AtProtoIdentifierValidationError(AtProtoIdentifierValidationErrorCode.unsupportedDid); 81 + } 82 + 83 + if (!_atprotoHandlePattern.hasMatch(identifier)) { 84 + return const AtProtoIdentifierValidationError(AtProtoIdentifierValidationErrorCode.invalidHandle); 85 + } 86 + 87 + return null; 88 + } 89 + 90 + bool _isValidDidWebHostSuffix(String suffix, {required bool allowDevelopmentDidWebHosts}) { 91 + if (allowDevelopmentDidWebHosts && _didWebLocalhostPortPattern.hasMatch(suffix)) { 92 + return true; 93 + } 94 + 95 + final lower = suffix.toLowerCase(); 96 + if (allowDevelopmentDidWebHosts && lower == 'localhost') { 97 + return true; 98 + } 99 + 100 + if (suffix.length > 253 || suffix.startsWith('.') || suffix.endsWith('.') || suffix.contains('..')) { 101 + return false; 102 + } 103 + 104 + final labels = suffix.split('.'); 105 + if (labels.length < 2) { 106 + return false; 107 + } 108 + 109 + for (final label in labels) { 110 + if (label.isEmpty || 111 + label.length > 63 || 112 + !_didWebHostLabelPattern.hasMatch(label) || 113 + label.startsWith('-') || 114 + label.endsWith('-')) { 115 + return false; 116 + } 117 + } 118 + 119 + final tld = labels.last.toLowerCase(); 120 + if (_disallowedDidWebTopLevelDomains.contains(tld)) { 121 + return false; 122 + } 123 + 124 + return true; 125 + }
+69 -15
lib/features/auth/data/auth_repository.dart
··· 10 10 import 'package:http/http.dart' as http; 11 11 import 'package:lazurite/core/database/app_database.dart'; 12 12 import 'package:lazurite/core/logging/app_logger.dart'; 13 - import 'package:lazurite/core/network/atproto_host_resolver.dart'; 14 13 import 'package:lazurite/core/network/app_view_provider.dart'; 14 + import 'package:lazurite/core/network/atproto_host_resolver.dart'; 15 15 import 'package:lazurite/core/network/slingshot_client.dart'; 16 16 import 'package:lazurite/core/network/xrpc_client_factory.dart'; 17 17 import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; 18 + import 'package:lazurite/features/auth/data/atproto_identifier.dart'; 18 19 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 19 20 import 'package:url_launcher/url_launcher.dart'; 20 21 ··· 27 28 required String service, 28 29 required OAuthSession session, 29 30 }); 31 + 32 + final class AuthIdentifierResolutionException implements Exception { 33 + const AuthIdentifierResolutionException(this.message); 34 + 35 + final String message; 36 + 37 + @override 38 + String toString() => message; 39 + } 30 40 31 41 class AuthRepository { 32 42 AuthRepository({ ··· 164 174 Future<AuthTokens?> loginWithOAuth(String handle) async { 165 175 try { 166 176 _oauthCompleter = Completer<AuthTokens?>(); 167 - _pendingHandle = handle.trim(); 177 + _pendingHandle = normalizeAtProtoIdentifierForAuth(handle); 178 + final validationError = validateAtProtoIdentifierForAuth(_pendingHandle!); 179 + if (validationError != null) { 180 + throw AuthIdentifierResolutionException(_identifierValidationMessage(validationError)); 181 + } 168 182 final preferredOauthService = normalizeAtprotoServiceHost(_oauthServiceResolver()) ?? _oauthService; 169 - String? resolvedPdsHost; 183 + late final String resolvedPdsHost; 170 184 String? resolvedAuthService; 171 185 try { 172 186 resolvedPdsHost = await _resolveServiceForIdentifier(_pendingHandle!); 187 + } on atcore.InvalidRequestException catch (error, stackTrace) { 188 + final failure = _handleResolutionFailureForIdentifier(_pendingHandle!, error); 189 + log.w( 190 + 'AuthRepository: Identifier resolution failed for ${_pendingHandle!}', 191 + error: failure, 192 + stackTrace: stackTrace, 193 + ); 194 + throw failure; 195 + } 196 + 197 + try { 173 198 resolvedAuthService = await _resolveAuthorizationServiceForPdsHost(resolvedPdsHost); 174 199 } catch (error, stackTrace) { 175 200 log.w( 176 - 'AuthRepository: Failed to pre-resolve OAuth account authority for ${_pendingHandle!}; ' 201 + 'AuthRepository: Failed to resolve OAuth authorization server metadata for ${_pendingHandle!}; ' 177 202 'continuing with fallback auth service chain.', 178 203 error: error, 179 204 stackTrace: stackTrace, ··· 212 237 213 238 return await _oauthCompleter!.future.timeout( 214 239 const Duration(minutes: 3), 215 - onTimeout: () => throw TimeoutException( 216 - 'Timed out waiting for OAuth callback on custom scheme redirect', 217 - ), 240 + onTimeout: () => throw TimeoutException('Timed out waiting for OAuth callback on custom scheme redirect'), 218 241 ); 219 242 } catch (error, stackTrace) { 220 243 lastAttemptError = error; ··· 236 259 ), 237 260 lastAttemptStackTrace ?? StackTrace.current, 238 261 ); 262 + } on AuthIdentifierResolutionException { 263 + rethrow; 239 264 } catch (error, stackTrace) { 240 265 log.e('AuthRepository: OAuth login failed', error: error, stackTrace: stackTrace); 241 266 _resetPendingOAuthState(); ··· 438 463 439 464 Future<bool> completeOAuthCallbackFromUri(Uri callbackUri) async { 440 465 final pendingOAuthFlow = 441 - _pendingOAuthClient != null && _pendingOAuthContext != null && _pendingHandle != null && _pendingService != null; 466 + _pendingOAuthClient != null && 467 + _pendingOAuthContext != null && 468 + _pendingHandle != null && 469 + _pendingService != null; 442 470 if (!pendingOAuthFlow) { 443 471 log.w( 444 472 'AuthRepository: Ignoring OAuth callback without active flow ' ··· 467 495 } 468 496 return false; 469 497 } finally { 470 - _resetPendingOAuthState(); 498 + _resetPendingOAuthState(clearLaunchMode: false); 471 499 } 472 500 } 473 501 ··· 543 571 } 544 572 545 573 Future<({String did, String? pdsHost})> _resolveIdentityForIdentifier(String identifier) async { 546 - final normalizedIdentifier = identifier.trim(); 547 - if (normalizedIdentifier.startsWith('did:')) { 574 + final normalizedIdentifier = normalizeAtProtoIdentifierForAuth(identifier); 575 + if (normalizedIdentifier.toLowerCase().startsWith('did:')) { 548 576 return (did: normalizedIdentifier, pdsHost: null); 549 577 } 550 578 ··· 595 623 return (await client.identity.resolveHandle(handle: handle)).data.did; 596 624 } 597 625 626 + String _identifierValidationMessage(AtProtoIdentifierValidationError validationError) { 627 + return switch (validationError.code) { 628 + AtProtoIdentifierValidationErrorCode.empty => 'Enter a Bluesky handle or DID.', 629 + AtProtoIdentifierValidationErrorCode.unsupportedDid => 630 + 'Unsupported DID format. Use a did:plc:... or did:web:... identifier.', 631 + AtProtoIdentifierValidationErrorCode.invalidDid => 632 + 'Invalid DID format. Enter a complete did:plc:... or did:web:... identifier.', 633 + AtProtoIdentifierValidationErrorCode.invalidHandle => 634 + 'Invalid handle format. Enter a full handle like username.bsky.social.', 635 + }; 636 + } 637 + 638 + AuthIdentifierResolutionException _handleResolutionFailureForIdentifier( 639 + String identifier, 640 + atcore.InvalidRequestException error, 641 + ) { 642 + final primaryMessage = error.response.data.message?.trim() ?? ''; 643 + final fallbackMessage = error.response.data.error.trim(); 644 + final responseMessage = primaryMessage.isNotEmpty ? primaryMessage : fallbackMessage; 645 + final sanitizedMessage = responseMessage.trim().isEmpty ? 'Unable to resolve identifier.' : responseMessage.trim(); 646 + return AuthIdentifierResolutionException('Unable to resolve "$identifier". $sanitizedMessage'); 647 + } 648 + 598 649 Future<String?> _resolveAuthorizationServiceForPdsHost(String pdsHost) async { 599 650 final normalizedPdsHost = normalizeAtprotoServiceHost(pdsHost); 600 651 if (normalizedPdsHost == null) { ··· 657 708 } 658 709 659 710 Uri _didDocumentUri(String did) { 660 - if (did.startsWith('did:plc:')) { 711 + final didLower = did.toLowerCase(); 712 + if (didLower.startsWith('did:plc:')) { 661 713 return Uri.https('plc.directory', '/$did'); 662 714 } 663 715 664 - if (did.startsWith('did:web:')) { 716 + if (didLower.startsWith('did:web:')) { 665 717 final encodedSegments = did.substring('did:web:'.length).split(':'); 666 718 if (encodedSegments.isEmpty || encodedSegments.first.isEmpty) { 667 719 throw Exception('Invalid did:web identifier: $did'); ··· 811 863 } 812 864 } 813 865 814 - void _resetPendingOAuthState() { 866 + void _resetPendingOAuthState({bool clearLaunchMode = true}) { 815 867 _oauthCompleter = null; 816 868 _pendingOAuthClient = null; 817 869 _pendingOAuthContext = null; 818 870 _pendingHandle = null; 819 871 _pendingService = null; 820 - _oauthLaunchMode = null; 872 + if (clearLaunchMode) { 873 + _oauthLaunchMode = null; 874 + } 821 875 } 822 876 823 877 OAuthSession _restoreOAuthSession({
+4
lib/features/auth/presentation/login_screen.dart
··· 128 128 child: SafeArea( 129 129 child: BlocListener<AuthBloc, AuthState>( 130 130 listener: (context, state) { 131 + if (state.isAuthenticated) { 132 + context.go('/'); 133 + return; 134 + } 131 135 if (state.hasError && state.errorMessage != null) { 132 136 ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.errorMessage!))); 133 137 }
+52 -2
test/core/router/app_router_test.dart
··· 5 5 import 'package:flutter/material.dart'; 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 7 import 'package:flutter_test/flutter_test.dart'; 8 + import 'package:go_router/go_router.dart'; 8 9 import 'package:lazurite/core/database/app_database.dart'; 9 10 import 'package:lazurite/core/router/app_router.dart'; 10 11 import 'package:lazurite/core/theme/app_theme.dart'; 11 12 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 12 13 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 13 14 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 15 + import 'package:lazurite/features/auth/presentation/oauth_callback_screen.dart'; 14 16 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 15 17 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 16 18 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; ··· 85 87 createdAt: DateTime.utc(2024, 3, 1), 86 88 ); 87 89 90 + setUpAll(() { 91 + registerFallbackValue(Uri.parse('https://example.com/oauth/callback')); 92 + }); 93 + 88 94 setUp(() { 89 95 authBloc = MockAuthBloc(); 90 96 feedPreferencesCubit = MockFeedPreferencesCubit(); ··· 103 109 currentAuthState = const AuthState.authenticated(tokens); 104 110 105 111 when(() => authBloc.state).thenAnswer((_) => currentAuthState); 112 + when(() => authBloc.handleOAuthRedirectUri(any())).thenAnswer((_) async => false); 106 113 when(() => feedPreferencesCubit.state).thenReturn(const FeedPreferencesState.loaded(feeds: [])); 107 114 when(() => profileBloc.state).thenReturn(ProfileState.loaded(profile: profile)); 108 115 when(() => feedBloc.state).thenReturn( ··· 117 124 ); 118 125 when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 119 126 when(() => accountSwitcherCubit.state).thenReturn(const AccountSwitcherState.ready(accounts: [])); 127 + when(() => accountSwitcherCubit.loadAccounts()).thenAnswer((_) async {}); 120 128 when(() => unreadCountCubit.state).thenReturn(const UnreadCountState(0)); 121 129 when(() => convoListBloc.state).thenReturn(const ConvoListState.loaded(convos: [], cursor: null, hasMore: false)); 122 130 when(() => notificationRepository.getUnreadCount()).thenAnswer((_) async => 0); ··· 169 177 await authController.close(); 170 178 }); 171 179 172 - Widget buildSubject() => MultiBlocProvider( 180 + Widget buildSubjectWithRouter(GoRouter router) => MultiBlocProvider( 173 181 providers: [ 174 182 BlocProvider<AuthBloc>.value(value: authBloc), 175 183 BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), ··· 190 198 RepositoryProvider<AppDatabase>.value(value: database), 191 199 RepositoryProvider<String>.value(value: tokens.did), 192 200 ], 193 - child: MaterialApp.router(routerConfig: AppRouter(authBloc: authBloc).router), 201 + child: MaterialApp.router(routerConfig: router), 194 202 ), 195 203 ), 196 204 ); 205 + 206 + Widget buildSubject() => buildSubjectWithRouter(AppRouter(authBloc: authBloc).router); 197 207 198 208 testWidgets('opens the side menu and switches authenticated branches', (tester) async { 199 209 await tester.binding.setSurfaceSize(const Size(430, 932)); ··· 475 485 router.go('/terms'); 476 486 await tester.pumpAndSettle(); 477 487 expect(find.text('Terms of Service'), findsWidgets); 488 + 489 + router.dispose(); 490 + }); 491 + 492 + testWidgets('allows authenticated access to login route when reauth query is present', (tester) async { 493 + final router = AppRouter(authBloc: authBloc).router; 494 + 495 + await tester.pumpWidget(buildSubjectWithRouter(router)); 496 + await tester.pumpAndSettle(); 497 + 498 + router.go('/login?reauth=1'); 499 + await tester.pumpAndSettle(); 500 + 501 + expect(find.text('Continue'), findsOneWidget); 502 + 503 + router.dispose(); 504 + }); 505 + 506 + testWidgets('processes oauth callback route while authenticated', (tester) async { 507 + final router = AppRouter(authBloc: authBloc).router; 508 + final pendingCallback = Completer<bool>(); 509 + when(() => authBloc.handleOAuthRedirectUri(any())).thenAnswer((_) => pendingCallback.future); 510 + 511 + await tester.pumpWidget(buildSubjectWithRouter(router)); 512 + router.go('/oauth/callback?code=abc&state=xyz'); 513 + await tester.pump(); 514 + await tester.pump(const Duration(milliseconds: 100)); 515 + 516 + verify( 517 + () => authBloc.handleOAuthRedirectUri( 518 + any(that: predicate<Uri>((uri) => uri.path == OAuthCallbackScreen.routePath)), 519 + ), 520 + ).called(1); 521 + expect(router.routeInformationProvider.value.uri.path, equals(OAuthCallbackScreen.routePath)); 522 + 523 + pendingCallback.complete(true); 524 + await tester.pumpAndSettle(); 525 + 526 + expect(router.routeInformationProvider.value.uri.path, isNot(equals(OAuthCallbackScreen.routePath))); 527 + expect(find.text('No feeds pinned'), findsOneWidget); 478 528 479 529 router.dispose(); 480 530 });
+138 -1
test/features/account/cubit/account_switcher_cubit_test.dart
··· 377 377 }); 378 378 379 379 test('returns null when loginWithOAuth throws', () async { 380 - when(() => mockAuthRepository.loginWithOAuth(any())).thenThrow(Exception('OAuth failed')); 380 + when( 381 + () => mockAuthRepository.loginWithOAuth(any()), 382 + ).thenThrow(const AuthIdentifierResolutionException('Unable to resolve "bad.handle".')); 381 383 382 384 final cubit = buildCubit(); 383 385 final result = await cubit.addAccountWithOAuth('bad.handle'); 384 386 385 387 expect(result, isNull); 388 + expect(cubit.lastAddAccountErrorMessage, equals('Unable to resolve "bad.handle".')); 386 389 verifyNever(() => mockDatabase.insertAccount(any())); 390 + }); 391 + 392 + test('returns curated message when loginWithOAuth fails unexpectedly', () async { 393 + when(() => mockAuthRepository.loginWithOAuth(any())).thenThrow(Exception('network stack exploded')); 394 + 395 + final cubit = buildCubit(); 396 + final result = await cubit.addAccountWithOAuth('new.bsky.social'); 397 + 398 + expect(result, isNull); 399 + expect(cubit.lastAddAccountErrorMessage, equals('Unable to add account right now. Please try again.')); 400 + verifyNever(() => mockDatabase.insertAccount(any())); 401 + }); 402 + }); 403 + 404 + group('removeAccount', () { 405 + test('removes inactive account and reloads accounts', () async { 406 + when(() => mockDatabase.getAccount('did:plc:user2')).thenAnswer((_) async => makeAccount(did: 'did:plc:user2')); 407 + when(() => mockDatabase.deleteAccount('did:plc:user2')).thenAnswer((_) async => 1); 408 + when( 409 + () => mockDatabase.getAllAccounts(), 410 + ).thenAnswer((_) async => [makeAccount(did: 'did:plc:user1'), makeAccount(did: 'did:plc:user3')]); 411 + when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => 'did:plc:user1'); 412 + 413 + final cubit = buildCubit(); 414 + cubit.emit( 415 + AccountSwitcherState.ready( 416 + accounts: [ 417 + makeAccount(did: 'did:plc:user1'), 418 + makeAccount(did: 'did:plc:user2'), 419 + ], 420 + activeDid: 'did:plc:user1', 421 + ), 422 + ); 423 + 424 + final result = await cubit.removeAccount('did:plc:user2'); 425 + 426 + expect(result.removed, isTrue); 427 + expect(result.requiresSignIn, isFalse); 428 + expect(result.switchedTokens, isNull); 429 + verify(() => mockDatabase.deleteAccount('did:plc:user2')).called(1); 430 + }); 431 + 432 + test('removing active account switches to remaining account', () async { 433 + when(() => mockDatabase.getAccount('did:plc:user1')).thenAnswer((_) async => makeAccount(did: 'did:plc:user1')); 434 + when(() => mockDatabase.deleteAccount('did:plc:user1')).thenAnswer((_) async => 1); 435 + when(() => mockDatabase.getAllAccounts()).thenAnswer((_) async => [makeAccount(did: 'did:plc:user2')]); 436 + when(() => mockDatabase.getAccount('did:plc:user2')).thenAnswer((_) async => makeAccount(did: 'did:plc:user2')); 437 + when(() => mockDatabase.setSetting(any(), any())).thenAnswer((_) async => 1); 438 + when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => 'did:plc:user2'); 439 + 440 + final cubit = buildCubit(); 441 + cubit.emit( 442 + AccountSwitcherState.ready( 443 + accounts: [ 444 + makeAccount(did: 'did:plc:user1'), 445 + makeAccount(did: 'did:plc:user2'), 446 + ], 447 + activeDid: 'did:plc:user1', 448 + ), 449 + ); 450 + 451 + final result = await cubit.removeAccount('did:plc:user1'); 452 + 453 + expect(result.removed, isTrue); 454 + expect(result.requiresSignIn, isFalse); 455 + expect(result.switchedTokens?.did, equals('did:plc:user2')); 456 + }); 457 + 458 + test('removing active account does not refresh/invalidate remaining expired accounts', () async { 459 + final expiredAt = DateTime.now().subtract(const Duration(hours: 1)); 460 + when(() => mockDatabase.getAccount('did:plc:user1')).thenAnswer((_) async => makeAccount(did: 'did:plc:user1')); 461 + when(() => mockDatabase.deleteAccount('did:plc:user1')).thenAnswer((_) async => 1); 462 + when( 463 + () => mockDatabase.getAllAccounts(), 464 + ).thenAnswer((_) async => [makeAccount(did: 'did:plc:user2', expiresAt: expiredAt, refreshToken: 'refresh')]); 465 + when(() => mockDatabase.deleteSetting(any())).thenAnswer((_) async => 1); 466 + when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => null); 467 + 468 + final cubit = buildCubit(); 469 + cubit.emit( 470 + AccountSwitcherState.ready( 471 + accounts: [ 472 + makeAccount(did: 'did:plc:user1'), 473 + makeAccount(did: 'did:plc:user2'), 474 + ], 475 + activeDid: 'did:plc:user1', 476 + ), 477 + ); 478 + 479 + final result = await cubit.removeAccount('did:plc:user1'); 480 + 481 + expect(result.removed, isTrue); 482 + expect(result.requiresSignIn, isTrue); 483 + verifyNever(() => mockAuthRepository.refreshSession(any())); 484 + }); 485 + 486 + test('removing last account requires sign-in', () async { 487 + when(() => mockDatabase.getAccount('did:plc:user1')).thenAnswer((_) async => makeAccount(did: 'did:plc:user1')); 488 + when(() => mockDatabase.deleteAccount('did:plc:user1')).thenAnswer((_) async => 1); 489 + when(() => mockDatabase.getAllAccounts()).thenAnswer((_) async => []); 490 + when(() => mockDatabase.deleteSetting(any())).thenAnswer((_) async => 1); 491 + when(() => mockDatabase.getSetting(any())).thenAnswer((_) async => null); 492 + 493 + final cubit = buildCubit(); 494 + cubit.emit( 495 + AccountSwitcherState.ready( 496 + accounts: [makeAccount(did: 'did:plc:user1')], 497 + activeDid: 'did:plc:user1', 498 + ), 499 + ); 500 + 501 + final result = await cubit.removeAccount('did:plc:user1'); 502 + 503 + expect(result.removed, isTrue); 504 + expect(result.requiresSignIn, isTrue); 505 + expect(result.switchedTokens, isNull); 506 + }); 507 + 508 + test('returns failed when removeAccount hits database exception', () async { 509 + when(() => mockDatabase.getAccount('did:plc:user1')).thenAnswer((_) async => makeAccount(did: 'did:plc:user1')); 510 + when(() => mockDatabase.deleteAccount('did:plc:user1')).thenThrow(Exception('db write failed')); 511 + 512 + final cubit = buildCubit(); 513 + cubit.emit( 514 + AccountSwitcherState.ready( 515 + accounts: [makeAccount(did: 'did:plc:user1')], 516 + activeDid: 'did:plc:user1', 517 + ), 518 + ); 519 + 520 + final result = await cubit.removeAccount('did:plc:user1'); 521 + 522 + expect(result.removed, isFalse); 523 + expect(result.requiresSignIn, isFalse); 387 524 }); 388 525 }); 389 526 });
+92 -6
test/features/account/presentation/account_switcher_sheet_test.dart
··· 7 7 import 'package:lazurite/features/account/presentation/account_switcher_sheet.dart'; 8 8 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 9 9 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 10 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 11 + import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 10 12 import 'package:mocktail/mocktail.dart'; 11 13 12 14 class MockAccountSwitcherCubit extends MockCubit<AccountSwitcherState> implements AccountSwitcherCubit {} 13 15 14 16 class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 17 + 18 + class MockTypeaheadRepository extends Mock implements TypeaheadRepository {} 15 19 16 20 void main() { 17 21 late MockAccountSwitcherCubit cubit; 18 22 late MockAuthBloc authBloc; 23 + late MockTypeaheadRepository typeaheadRepository; 19 24 20 25 const tokens = AuthTokens(accessToken: 'token', did: 'did:plc:me', handle: 'me.bsky.social'); 21 26 ··· 27 32 setUp(() { 28 33 cubit = MockAccountSwitcherCubit(); 29 34 authBloc = MockAuthBloc(); 35 + typeaheadRepository = MockTypeaheadRepository(); 30 36 when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 31 37 whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); 38 + when(() => cubit.loadAccounts()).thenAnswer((_) async {}); 39 + when( 40 + () => typeaheadRepository.search( 41 + query: any(named: 'query'), 42 + limit: any(named: 'limit'), 43 + ), 44 + ).thenAnswer((_) async => const <TypeaheadResult>[]); 32 45 }); 33 46 34 47 Account makeAccount({required String did, String handle = 'user.bsky.social', String? displayName}) { ··· 54 67 BlocProvider<AuthBloc>.value(value: authBloc), 55 68 BlocProvider<AccountSwitcherCubit>.value(value: cubit), 56 69 ], 57 - child: MaterialApp( 58 - home: Scaffold( 59 - body: Builder( 60 - builder: (context) => 61 - TextButton(onPressed: () => showAccountSwitcherSheet(context), child: const Text('Open')), 70 + child: RepositoryProvider<TypeaheadRepository>.value( 71 + value: typeaheadRepository, 72 + child: MaterialApp( 73 + home: Scaffold( 74 + body: Builder( 75 + builder: (context) => 76 + TextButton(onPressed: () => showAccountSwitcherSheet(context), child: const Text('Open')), 77 + ), 62 78 ), 63 79 ), 64 80 ), ··· 73 89 } 74 90 75 91 group('AccountSwitcherSheet', () { 92 + group('identifier validation', () { 93 + test('accepts valid handle', () { 94 + expect(validateAtProtoIdentifierInput('alice.bsky.social'), isNull); 95 + }); 96 + 97 + test('rejects malformed handle', () { 98 + expect(validateAtProtoIdentifierInput('not-a-handle'), equals('Enter a full handle like username.bsky.social')); 99 + }); 100 + 101 + test('accepts supported did methods', () { 102 + expect(validateAtProtoIdentifierInput('did:plc:ewvi7nxzyoun6zhxrhs64oiz'), isNull); 103 + expect(validateAtProtoIdentifierInput('did:web:example.com'), isNull); 104 + }); 105 + 106 + test('rejects unsupported did methods', () { 107 + expect(validateAtProtoIdentifierInput('did:key:z6Mk'), equals('Use a did:plc:... or did:web:... identifier')); 108 + }); 109 + }); 110 + 76 111 testWidgets('shows CircularProgressIndicator during loading state', (tester) async { 77 112 when(() => cubit.state).thenReturn(const AccountSwitcherState.loading()); 78 113 79 114 await openSheet(tester); 80 115 116 + verify(() => cubit.loadAccounts()).called(1); 81 117 expect(find.byType(CircularProgressIndicator), findsOneWidget); 82 118 }); 83 119 ··· 168 204 await tester.pump(const Duration(milliseconds: 300)); 169 205 170 206 verifyNever(() => authBloc.add(any(that: isA<LogoutRequested>()))); 171 - expect(find.text('Unable to switch accounts. Sign in again for that account.'), findsOneWidget); 207 + verify(() => cubit.switchAccount('did:plc:user2')).called(1); 172 208 }); 173 209 174 210 testWidgets('tapping active account does nothing', (tester) async { ··· 184 220 await tester.pump(); 185 221 186 222 verifyNever(() => cubit.switchAccount(any())); 223 + }); 224 + 225 + testWidgets('invalid add-account handle is blocked with inline validation', (tester) async { 226 + when(() => cubit.state).thenReturn(const AccountSwitcherState.ready(accounts: [])); 227 + 228 + await openSheet(tester); 229 + await tester.tap(find.text('Add Account')); 230 + await tester.pump(); 231 + await tester.pump(const Duration(milliseconds: 300)); 232 + 233 + await tester.enterText(find.byType(TextFormField), 'not-a-handle'); 234 + await tester.tap(find.text('Continue')); 235 + await tester.pump(); 236 + verifyNever(() => cubit.addAccountWithOAuth(any())); 237 + }); 238 + 239 + testWidgets('add-account Continue button stays disabled for invalid identifier', (tester) async { 240 + when(() => cubit.state).thenReturn(const AccountSwitcherState.ready(accounts: [])); 241 + 242 + await openSheet(tester); 243 + await tester.tap(find.text('Add Account')); 244 + await tester.pumpAndSettle(); 245 + 246 + await tester.enterText(find.byType(TextFormField), 'not-a-handle'); 247 + await tester.pump(); 248 + 249 + final continueButton = tester.widget<FilledButton>(find.widgetWithText(FilledButton, 'Continue')); 250 + expect(continueButton.onPressed, isNull); 251 + verifyNever(() => cubit.addAccountWithOAuth(any())); 252 + }); 253 + 254 + testWidgets('remove account action removes account from sheet flow', (tester) async { 255 + when(() => cubit.state).thenReturn( 256 + AccountSwitcherState.ready( 257 + accounts: [ 258 + makeAccount(did: 'did:plc:user1', handle: 'alice.bsky.social'), 259 + makeAccount(did: 'did:plc:user2', handle: 'bob.bsky.social'), 260 + ], 261 + activeDid: 'did:plc:user1', 262 + ), 263 + ); 264 + when(() => cubit.removeAccount('did:plc:user2')).thenAnswer((_) async => const AccountRemovalResult.removed()); 265 + 266 + await openSheet(tester); 267 + await tester.tap(find.byTooltip('Remove account').last); 268 + await tester.pumpAndSettle(); 269 + await tester.tap(find.text('Remove')); 270 + await tester.pumpAndSettle(); 271 + 272 + verify(() => cubit.removeAccount('did:plc:user2')).called(1); 187 273 }); 188 274 }); 189 275 }
+8
test/features/auth/bloc/auth_bloc_test.dart
··· 102 102 act: (bloc) => bloc.add(const CheckSessionRequested()), 103 103 expect: () => [const AuthState.authenticating(), const AuthState.authenticated(tokens)], 104 104 ); 105 + 106 + blocTest<AuthBloc, AuthState>( 107 + 'emits [unauthenticated] when SessionCleared is added', 108 + build: () => AuthBloc(authRepository: mockAuthRepository), 109 + seed: () => const AuthState.authenticated(tokens), 110 + act: (bloc) => bloc.add(const SessionCleared()), 111 + expect: () => [const AuthState.unauthenticated()], 112 + ); 105 113 }); 106 114 }
+106
test/features/auth/data/atproto_identifier_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:lazurite/features/auth/data/atproto_identifier.dart'; 3 + 4 + void main() { 5 + group('normalizeAtProtoIdentifierForAuth', () { 6 + test('normalizes did:web casing', () { 7 + final normalized = normalizeAtProtoIdentifierForAuth('DID:WEB:Example.com'); 8 + expect(normalized, equals('did:web:example.com')); 9 + }); 10 + 11 + test('normalizes did:plc casing', () { 12 + final normalized = normalizeAtProtoIdentifierForAuth('DID:PLC:EWVI7NXZYOUN6ZHXRHS64OIZ'); 13 + expect(normalized, equals('did:plc:ewvi7nxzyoun6zhxrhs64oiz')); 14 + }); 15 + }); 16 + 17 + group('validateAtProtoIdentifierForAuth', () { 18 + test('accepts valid did:plc identifier', () { 19 + final error = validateAtProtoIdentifierForAuth('did:plc:ewvi7nxzyoun6zhxrhs64oiz'); 20 + expect(error, isNull); 21 + }); 22 + 23 + test('accepts known valid did:plc identifiers', () { 24 + const identifiers = <String>[ 25 + 'did:plc:xg2vq45muivyy3xwatcehspu', 26 + 'did:plc:bmw5siutico6v4tmtbj5377q', 27 + 'did:plc:526rjityvizz4ism2ihd77mm', 28 + ]; 29 + 30 + for (final identifier in identifiers) { 31 + final error = validateAtProtoIdentifierForAuth(identifier); 32 + expect(error, isNull, reason: '$identifier should be valid'); 33 + } 34 + }); 35 + 36 + test('rejects did:plc identifier with non-base32 digits', () { 37 + final error = validateAtProtoIdentifierForAuth('did:plc:ewvi7nxzyoun6zhxrhs64oi9'); 38 + expect(error?.code, equals(AtProtoIdentifierValidationErrorCode.invalidDid)); 39 + }); 40 + 41 + test('accepts valid did:web host', () { 42 + final error = validateAtProtoIdentifierForAuth('did:web:example.com'); 43 + expect(error, isNull); 44 + }); 45 + 46 + test('accepts did:web localhost with encoded port', () { 47 + final error = validateAtProtoIdentifierForAuth('did:web:LOCALHOST%3A3000'); 48 + expect(error, isNull); 49 + }); 50 + 51 + test('rejects did:web localhost with encoded port outside development mode', () { 52 + final error = validateAtProtoIdentifierForAuth('did:web:localhost%3A3000', allowDevelopmentDidWebHosts: false); 53 + expect(error?.code, equals(AtProtoIdentifierValidationErrorCode.invalidDid)); 54 + }); 55 + 56 + test('rejects did:web with single-label non-localhost host', () { 57 + final error = validateAtProtoIdentifierForAuth('did:web:example'); 58 + expect(error?.code, equals(AtProtoIdentifierValidationErrorCode.invalidDid)); 59 + }); 60 + 61 + test('rejects did:web values with fragment', () { 62 + final error = validateAtProtoIdentifierForAuth('did:web:example.com#frag'); 63 + expect(error?.code, equals(AtProtoIdentifierValidationErrorCode.invalidDid)); 64 + }); 65 + 66 + test('rejects did:web values with whitespace', () { 67 + final error = validateAtProtoIdentifierForAuth('did:web:example .com'); 68 + expect(error?.code, equals(AtProtoIdentifierValidationErrorCode.invalidDid)); 69 + }); 70 + 71 + test('rejects path-based did:web in atproto context', () { 72 + final error = validateAtProtoIdentifierForAuth('did:web:example.com:user:alice'); 73 + expect(error?.code, equals(AtProtoIdentifierValidationErrorCode.invalidDid)); 74 + }); 75 + 76 + test('rejects did:web values with empty suffix', () { 77 + final error = validateAtProtoIdentifierForAuth('did:web:'); 78 + expect(error?.code, equals(AtProtoIdentifierValidationErrorCode.invalidDid)); 79 + }); 80 + 81 + test('rejects did:web with consecutive dots', () { 82 + final error = validateAtProtoIdentifierForAuth('did:web:example..com'); 83 + expect(error?.code, equals(AtProtoIdentifierValidationErrorCode.invalidDid)); 84 + }); 85 + 86 + test('rejects did:web with trailing dot', () { 87 + final error = validateAtProtoIdentifierForAuth('did:web:example.com.'); 88 + expect(error?.code, equals(AtProtoIdentifierValidationErrorCode.invalidDid)); 89 + }); 90 + 91 + test('rejects did:web label starting with hyphen', () { 92 + final error = validateAtProtoIdentifierForAuth('did:web:-bad.example.com'); 93 + expect(error?.code, equals(AtProtoIdentifierValidationErrorCode.invalidDid)); 94 + }); 95 + 96 + test('rejects did:web label ending with hyphen', () { 97 + final error = validateAtProtoIdentifierForAuth('did:web:bad-.example.com'); 98 + expect(error?.code, equals(AtProtoIdentifierValidationErrorCode.invalidDid)); 99 + }); 100 + 101 + test('rejects did:web with disallowed top-level domain', () { 102 + final error = validateAtProtoIdentifierForAuth('did:web:name.arpa'); 103 + expect(error?.code, equals(AtProtoIdentifierValidationErrorCode.invalidDid)); 104 + }); 105 + }); 106 + }
+80
test/features/auth/data/auth_repository_test.dart
··· 1 1 import 'dart:convert'; 2 2 3 + import 'package:atproto_core/atproto_core.dart' as atcore; 3 4 import 'package:atproto_oauth/atproto_oauth.dart'; 4 5 import 'package:flutter/foundation.dart'; 5 6 import 'package:flutter_test/flutter_test.dart'; ··· 315 316 }); 316 317 }); 317 318 319 + group('oauth identifier validation', () { 320 + test('fails fast for malformed handle input', () async { 321 + await expectLater( 322 + authRepository.loginWithOAuth('not-a-handle'), 323 + throwsA( 324 + isA<AuthIdentifierResolutionException>().having( 325 + (error) => error.toString(), 326 + 'message', 327 + contains('Invalid handle format'), 328 + ), 329 + ), 330 + ); 331 + }); 332 + 333 + test('throws identifier resolution error for unresolvable handle', () async { 334 + authRepository = AuthRepository( 335 + database: mockDatabase, 336 + resolveHandleDid: (_) async => throw _invalidResolveHandleRequestException(), 337 + ); 338 + 339 + await expectLater( 340 + authRepository.loginWithOAuth('nobody.bsky.social'), 341 + throwsA( 342 + isA<AuthIdentifierResolutionException>().having( 343 + (error) => error.toString(), 344 + 'message', 345 + allOf(contains('Unable to resolve'), contains('nobody.bsky.social')), 346 + ), 347 + ), 348 + ); 349 + }); 350 + 351 + test('normalizes uppercase did input before identity handling', () async { 352 + authRepository = AuthRepository( 353 + database: mockDatabase, 354 + resolveDidDocument: (_) async => { 355 + 'service': [ 356 + { 357 + 'id': '#atproto_pds', 358 + 'type': 'AtprotoPersonalDataServer', 359 + 'serviceEndpoint': 'https://pds.example', 360 + }, 361 + ], 362 + }, 363 + ); 364 + 365 + final service = await authRepository.resolveServiceForIdentifierForTest('DID:PLC:ABC123'); 366 + expect(service, equals('pds.example')); 367 + }); 368 + 369 + test('fails fast for incomplete did identifiers', () async { 370 + await expectLater( 371 + authRepository.loginWithOAuth('did:web:'), 372 + throwsA( 373 + isA<AuthIdentifierResolutionException>().having( 374 + (error) => error.toString(), 375 + 'message', 376 + contains('Invalid DID format'), 377 + ), 378 + ), 379 + ); 380 + }); 381 + }); 382 + 318 383 group('clearSession', () { 319 384 test('should delete all accounts', () async { 320 385 when(() => mockDatabase.deleteAllAccounts()).thenAnswer((_) async => 1); ··· 419 484 }); 420 485 }); 421 486 }); 487 + } 488 + 489 + atcore.InvalidRequestException _invalidResolveHandleRequestException() { 490 + return atcore.InvalidRequestException( 491 + atcore.XRPCResponse( 492 + headers: const {}, 493 + status: atcore.HttpStatus.badRequest, 494 + request: atcore.XRPCRequest( 495 + method: atcore.HttpMethod.get, 496 + url: Uri.https('bsky.social', '/xrpc/com.atproto.identity.resolveHandle'), 497 + ), 498 + rateLimit: atcore.RateLimit.unlimited(), 499 + data: const atcore.XRPCError(error: 'InvalidRequest', message: 'Could not resolve handle'), 500 + ), 501 + ); 422 502 } 423 503 424 504 OAuthClientMetadata _testClientMetadata() {