blob: 96c89d8b32aed3761a931108b9035c05bec8a209 (
plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// All possible authentication states for the app.
///
/// Using a sealed-like enum keeps the router redirect logic exhaustive and
/// avoids stringly-typed checks throughout the codebase.
enum AuthStatus {
/// Initial state while the app checks whether a stored token exists.
loading,
/// A valid token was found in secure storage; the user is logged in.
authenticated,
/// No token exists or it has been purged (e.g. after a 401 response).
unauthenticated,
}
/// Immutable snapshot of the auth state passed through the provider graph.
///
/// Keeping this as a value object (rather than a mutable notifier field)
/// makes it safe to pass into go_router's redirect callback and to compare
/// with `==` in tests.
class AuthState {
const AuthState({required this.status});
final AuthStatus status;
/// Convenience constructors reduce noise at call sites.
const AuthState.loading() : status = AuthStatus.loading;
const AuthState.authenticated() : status = AuthStatus.authenticated;
const AuthState.unauthenticated() : status = AuthStatus.unauthenticated;
bool get isLoading => status == AuthStatus.loading;
bool get isAuthenticated => status == AuthStatus.authenticated;
bool get isUnauthenticated => status == AuthStatus.unauthenticated;
@override
String toString() => 'AuthState(${status.name})';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AuthState &&
runtimeType == other.runtimeType &&
status == other.status;
@override
int get hashCode => status.hashCode;
}
/// Notifier that owns the mutable [AuthState] and exposes mutation methods
/// for login / logout.
///
/// [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 {
// 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. 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 prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kAuthSessionPresentKey, true);
state = const AsyncData(AuthState.authenticated());
}
/// 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 prefs = await SharedPreferences.getInstance();
await prefs.remove(_kAuthSessionPresentKey);
state = const AsyncData(AuthState.unauthenticated());
}
}
/// The single source of truth for authentication status, consumed by the
/// router's redirect callback and any widget that needs to gate on auth.
final authStateProvider =
AsyncNotifierProvider<AuthStateNotifier, AuthState>(
AuthStateNotifier.new,
);
|