diff --git a/lib/screens/auth/login_screen.dart b/lib/screens/auth/login_screen.dart index 10898f7..919a166 100644 --- a/lib/screens/auth/login_screen.dart +++ b/lib/screens/auth/login_screen.dart @@ -7,6 +7,7 @@ import 'package:sentry_flutter/sentry_flutter.dart'; import '../../constants/app_colors.dart'; import '../../providers/auth_provider.dart'; +import '../../services/coves_auth_service.dart'; import '../../widgets/primary_button.dart'; /// Login screen with Coves design language. @@ -151,6 +152,24 @@ class _LoginScreenState extends State { if (mounted) { context.go('/feed'); } + } on SignInCancelledException { + // The user backed out of the OAuth flow (closed the tab, hit Cancel + // on the PDS sign-in page, or denied consent). Not an error: no + // Sentry capture, just a quiet confirmation they're back in the app. + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Sign in cancelled.', + style: GoogleFonts.nunito(fontWeight: FontWeight.w500), + ), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ); + } } on Exception catch (e, stackTrace) { // Log all sign-in errors to Sentry with categorization final errorString = e.toString().toLowerCase(); diff --git a/lib/services/coves_auth_service.dart b/lib/services/coves_auth_service.dart index 47a365a..ff61bcf 100644 --- a/lib/services/coves_auth_service.dart +++ b/lib/services/coves_auth_service.dart @@ -10,6 +10,16 @@ import '../config/oauth_config.dart'; import '../models/coves_session.dart'; import 'retry_interceptor.dart'; +/// Thrown when the user backs out of the OAuth flow without completing it: +/// closing the browser tab, cancelling on the PDS sign-in page, or denying +/// the authorization request. Not an error — no tokens were ever issued. +class SignInCancelledException implements Exception { + const SignInCancelledException(); + + @override + String toString() => 'Sign in cancelled by user'; +} + /// Coves Authentication Service /// /// Simplified OAuth service that uses the Coves backend's mobile OAuth flow. @@ -156,8 +166,7 @@ class CovesAuthService { } // Parse the callback URL to extract session data - final callbackUri = Uri.parse(resultUrl); - final session = CovesSession.fromCallbackUri(callbackUri); + final session = parseCallbackUrl(resultUrl); if (kDebugMode) { print('Session created: $session'); @@ -176,21 +185,56 @@ class CovesAuthService { } return session; + } on SignInCancelledException { + // User backed out of the flow — propagate untouched so callers can + // treat it as a non-error (no scary message, no crash reporting). + rethrow; } on Exception catch (e) { if (kDebugMode) { print('Sign-in failed: $e'); } - // Check for user cancellation + // Check for user cancellation (browser tab closed / system CANCELED) if (e.toString().contains('CANCELED') || e.toString().contains('cancelled')) { - throw Exception('Sign in cancelled by user'); + throw const SignInCancelledException(); } throw Exception('Sign in failed: $e'); } } + /// Parse an OAuth callback URL into a session. + /// + /// The authorization server may redirect back with an error instead of + /// token parameters — e.g. `error=access_denied` when the user cancels + /// on the PDS sign-in page or denies the consent screen. That case is + /// surfaced as [SignInCancelledException]; other server-reported errors + /// and missing/malformed parameters throw a descriptive [Exception] / + /// [FormatException]. + @visibleForTesting + static CovesSession parseCallbackUrl(String resultUrl) { + final callbackUri = Uri.parse(resultUrl); + + final oauthError = callbackUri.queryParameters['error']; + if (oauthError != null && oauthError.isNotEmpty) { + if (kDebugMode) { + // Error codes/descriptions contain no secrets — safe to log. + print('OAuth callback returned error: $oauthError'); + } + if (oauthError == 'access_denied') { + throw const SignInCancelledException(); + } + final description = callbackUri.queryParameters['error_description']; + final detail = (description == null || description.isEmpty) + ? '' + : ' ($description)'; + throw Exception('Authorization server error: $oauthError$detail'); + } + + return CovesSession.fromCallbackUri(callbackUri); + } + /// Restore a previous session from secure storage /// /// Returns the session if found and valid, null otherwise. diff --git a/test/services/coves_auth_service_callback_test.dart b/test/services/coves_auth_service_callback_test.dart new file mode 100644 index 0000000..a61780c --- /dev/null +++ b/test/services/coves_auth_service_callback_test.dart @@ -0,0 +1,56 @@ +import 'package:coves_flutter/services/coves_auth_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('CovesAuthService.parseCallbackUrl()', () { + test('parses a success callback into a session', () { + final session = CovesAuthService.parseCallbackUrl( + 'social.coves:/callback?token=abc123&did=did:plc:test123' + '&session_id=sess456&handle=test.user', + ); + + expect(session.token, 'abc123'); + expect(session.did, 'did:plc:test123'); + expect(session.sessionId, 'sess456'); + expect(session.handle, 'test.user'); + }); + + test( + 'throws SignInCancelledException on access_denied (user cancel/deny)', + () { + // Exact shape the backend redirects with when the user cancels on + // the PDS sign-in page or denies the consent screen (cc98f26). + expect( + () => CovesAuthService.parseCallbackUrl( + 'social.coves:/callback?error=access_denied' + '&error_description=The+user+rejected+the+request', + ), + throwsA(isA()), + ); + }, + ); + + test('throws a descriptive Exception on other server errors', () { + expect( + () => CovesAuthService.parseCallbackUrl( + 'social.coves:/callback?error=server_error', + ), + throwsA( + predicate( + (e) => + e is Exception && + e is! SignInCancelledException && + e.toString().contains('server_error'), + ), + ), + ); + }); + + test('still rejects a callback with no token and no error param', () { + expect( + () => CovesAuthService.parseCallbackUrl('social.coves:/callback'), + throwsA(isA()), + ); + }); + }); +}