diff options
Diffstat (limited to 'player-android/lib')
| -rw-r--r-- | player-android/lib/api/dio_client.dart | 26 | ||||
| -rw-r--r-- | player-android/lib/providers/auth_state_provider.dart | 41 |
2 files changed, 48 insertions, 19 deletions
diff --git a/player-android/lib/api/dio_client.dart b/player-android/lib/api/dio_client.dart index 35db589..c988cf0 100644 --- a/player-android/lib/api/dio_client.dart +++ b/player-android/lib/api/dio_client.dart @@ -1,6 +1,9 @@ +import 'package:cookie_jar/cookie_jar.dart'; import 'package:dio/dio.dart'; +import 'package:dio_cookie_manager/dio_cookie_manager.dart'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:go_router/go_router.dart'; // Storage key under which the bearer token is persisted across app restarts. const _kTokenKey = 'bearer_token'; @@ -89,9 +92,15 @@ class _UnauthorizedInterceptor extends Interceptor { // Purge the stale token so subsequent requests start unauthenticated. await _storage.deleteToken(); - // Use the navigator key to redirect without needing a BuildContext. - _navigatorKey.currentState - ?.pushNamedAndRemoveUntil(_loginRoute, (_) => false); + // Redirect via go_router (the app's router) rather than the classic + // Navigator. pushNamedAndRemoveUntil would throw "Navigator.onGenerateRoute + // was null" because go_router does not register named routes on the + // underlying Navigator. Using the navigatorKey's currentContext lets us + // resolve the active GoRouter instance without a widget-tree BuildContext. + final ctx = _navigatorKey.currentContext; + if (ctx != null && ctx.mounted) { + GoRouter.of(ctx).go(_loginRoute); + } } handler.next(err); } @@ -138,9 +147,18 @@ class DioClient { responseType: ResponseType.json, ); + // The server's /api/v1/auth/login sets an HttpOnly Set-Cookie (session=...). + // Browsers persist this automatically; on mobile we attach a CookieJar so + // Dio replays the cookie on subsequent requests. Without this, every call + // after login returns 401 because Dio discards cookies by default. + // In-memory is sufficient: logout clears it, and we persist the bearer + // token (for API-token auth) separately via flutter_secure_storage. + final cookieJar = CookieJar(); return Dio(options) ..interceptors.addAll([ - // Auth must run before the 401 handler so the token is attached first. + // Cookie manager runs first so the session cookie is replayed before + // _AuthInterceptor decides whether to add a Bearer fallback. + CookieManager(cookieJar), _AuthInterceptor(storage), _UnauthorizedInterceptor( storage: storage, diff --git a/player-android/lib/providers/auth_state_provider.dart b/player-android/lib/providers/auth_state_provider.dart index 0e0c6fb..96c89d8 100644 --- a/player-android/lib/providers/auth_state_provider.dart +++ b/player-android/lib/providers/auth_state_provider.dart @@ -1,6 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'api_client_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; /// All possible authentication states for the app. /// @@ -56,31 +55,43 @@ class AuthState { /// [AsyncNotifier] is used because the initial state check is async (it reads /// the secure token store). Downstream consumers can call [login] and /// [logout] to drive route redirects via the router's [refreshListenable]. +// SharedPreferences key for the session-presence marker. Used in place of the +// previous bearer-token-in-secure-storage hack: the server authenticates the +// session via an HttpOnly cookie, so the client has no token to persist. We +// only need a tiny boolean to drive the router redirect on cold start. +const _kAuthSessionPresentKey = 'auth_session_present'; + class AuthStateNotifier extends AsyncNotifier<AuthState> { @override Future<AuthState> build() async { - // Determine whether a token already exists on app startup. This drives - // the initial route decision inside the go_router redirect callback. - final storage = ref.read(tokenStorageProvider); - final token = await storage.readToken(); - - return token != null && token.isNotEmpty + // The auth state on cold start is derived from a SharedPreferences marker + // rather than from any stored bearer token. Writing the username into + // SecureTokenStorage (the previous behaviour) caused _AuthInterceptor to + // attach `Authorization: Bearer <username>` to every request, which the + // server checks before falling back to the session cookie — yielding 401 + // on every API call after login despite a valid cookie being sent. + final prefs = await SharedPreferences.getInstance(); + final marked = prefs.getBool(_kAuthSessionPresentKey) ?? false; + return marked ? const AuthState.authenticated() : const AuthState.unauthenticated(); } - /// Called after a successful login; persists [token] and updates state. + /// Called after a successful login. The [token] parameter is accepted for + /// backwards compatibility with the call site but is intentionally unused; + /// the real authentication artefact is the session cookie set by the server + /// and stored by the Dio CookieManager. See [build] for why. Future<void> login(String token) async { - final storage = ref.read(tokenStorageProvider); - await storage.writeToken(token); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kAuthSessionPresentKey, true); state = const AsyncData(AuthState.authenticated()); } - /// Called on explicit logout or after [_UnauthorizedInterceptor] purges the - /// token. Clears the stored token and moves to the unauthenticated state. + /// Called on explicit logout or after the API returns 401. Clears the + /// session marker so the next cold start redirects to /login. Future<void> logout() async { - final storage = ref.read(tokenStorageProvider); - await storage.deleteToken(); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kAuthSessionPresentKey); state = const AsyncData(AuthState.unauthenticated()); } } |
