Auth
This page follows a session from the moment the app process starts to the moment the user signs out. Four pieces of code own it:
| Owner | Kind | File |
|---|---|---|
AuthNotifier |
Riverpod AsyncNotifier<AuthState>, the single owner of the session |
app/lib/features/auth/auth_notifier.dart:18-124 |
authStatusProvider |
derived Provider<AuthStatus>, the router’s view of the session |
app/lib/features/auth/auth_notifier.dart:131-134 |
routerProvider |
the GoRouter and its redirect |
app/lib/app/router.dart:27-128 |
dioProvider |
the shared HTTP client whose error interceptor turns any 401 into a sign-out | app/lib/data/api/api_client.dart:95-105 |
Two small stores hold what survives a restart: TokenStore (the JWT, in the iOS keychain
or Android keystore via flutter_secure_storage, app/lib/data/storage/token_store.dart:12-27)
and UserCache (the last User, JSON in SharedPreferences,
app/lib/data/storage/user_cache.dart:16-43). Both are interfaces with in-memory twins for
tests (InMemoryTokenStore, InMemoryUserCache).
Everything on this page runs in the main isolate.
Cold start
Section titled “Cold start”sequenceDiagram
autonumber
participant M as main()
participant App as FootraceApp
participant R as GoRouter (routerProvider)
participant A as AuthNotifier
participant T as TokenStore
participant C as UserCache
participant D as Dio (dioProvider)
participant B as backend MeController
participant S as socketLifecycleProvider
M->>App: runApp(ProviderScope(FootraceApp))
App->>R: ref.watch(routerProvider)
R->>A: ref.read(authStatusProvider) builds authProvider
Note over R: status unknown, redirect to /splash
A->>T: read()
alt no token
A-->>R: AuthState.signedOut, redirect to /login
else token present
A->>D: GET /api/me (Bearer token)
D->>B: MeController.show
alt 200
B-->>A: user + stats
A->>C: write(user)
A-->>R: signedIn, redirect /splash to /home
A-->>S: signedIn, connect socket
else 401
D->>A: onUnauthorized: sessionExpired()
A->>T: clear(), and C clear()
A-->>R: signedOut, redirect to /login
else network error
A->>C: read()
A-->>R: signedIn(cached, offline: true), or signedOut if no cache
end
end
1. main() and the root widget
Section titled “1. main() and the root widget”app/lib/main.dart:9-21 does three things before any screen appears: binds the Flutter
engine, installs error handlers, and opens the port the foreground-service isolate will use
later (FlutterForegroundTask.initCommunicationPort(), line 14). Then it wraps the app in a
ProviderScope, which is the Riverpod container: every provider in the app lives inside it.
FootraceApp.build (app/lib/app/app.dart:13-21) watches two providers. Watching
socketLifecycleProvider (line 15) is there only to keep that provider alive for the life of
the app; it returns nothing. Watching routerProvider (line 19) builds the router, and
building the router is what starts the session check.
2. The router asks for the auth status
Section titled “2. The router asks for the auth status”routerProvider reads authStatusProvider to seed a ValueNotifier
(app/lib/app/router.dart:30-34) and then listens for changes, copying each new status into
that notifier. The notifier is handed to go_router as refreshListenable (line 38): every
time it changes, go_router re-runs redirect. This is the bridge between Riverpod and
go_router, and it is the closest thing in the app to a Phoenix on_mount hook that re-runs
on every navigation.
authStatusProvider (app/lib/features/auth/auth_notifier.dart:131-134) maps the
AsyncValue<AuthState> from authProvider onto a three-value enum. While AuthNotifier.build
is still running, the value is null, so the status is unknown.
The first navigation goes to initialLocation: AppRoutes.home (app/lib/app/router.dart:37),
and redirect immediately bounces it. The whole gate is eleven lines:
String? _redirect(AuthStatus auth, String location) { final onAuthPage = location == AppRoutes.login || location == AppRoutes.register; switch (auth) { case AuthStatus.unknown: return location == AppRoutes.splash ? null : AppRoutes.splash; case AuthStatus.signedOut: return onAuthPage ? null : AppRoutes.login; case AuthStatus.signedIn: return onAuthPage || location == AppRoutes.splash ? AppRoutes.home : null; }}(app/lib/app/router.dart:136-147.) Returning null means “let it through”. So while the
token is being checked, every route resolves to /splash, which shows a spinner
(app/lib/features/shared/splash_screen.dart:4-30).
Where the plug-pipeline analogy breaks: redirect is a pure function of the status and the
path. It cannot await anything and it does not see the user. All the async work happens in
the notifier; the router only reacts to the result.
3. AuthNotifier.build restores the session
Section titled “3. AuthNotifier.build restores the session”build is an async method that returns the first state (app/lib/features/auth/auth_notifier.dart:24-45).
Riverpod runs it the first time anyone reads authProvider, which here is the router.
- Read the token from the keychain (line 25). None, or empty: return
signedOut. - Call
GET /api/methroughAuthRepository.me()(line 29,app/lib/data/repositories/auth_repository.dart:74-77). The Dio interceptor addsAuthorization: Bearer <token>(app/lib/data/api/api_client.dart:36-44). On the backend this isMeController.show(backend/lib/footrace_web/controllers/api/me_controller.ex:10-15), behind the:authorized_apipipeline and its Guardian plugs (backend/lib/footrace/users/pipeline.ex:14-19). - On a 200, write the user to
UserCacheand returnsignedIn(user, stats: stats)(lines 30-31). The stats are what the Profile tab and the Race tab’s per-distance chips show; they come only from/api/me. - On an
ApiExceptionthat is a 401 (isUnauthorized,app/lib/data/api/api_exception.dart:23), clear both stores and returnsignedOut(lines 33-36). - On any other failure, usually no network, fall back to the cached user and return
signedIn(cached, offline: true)(lines 37-41). Without a cached user, clear and sign out (lines 42-43).
Step 5 is why the app opens straight onto the Race tab in airplane mode. offline: true is
read by the Profile tab, which shows “Offline. Showing the last known profile.”
(app/lib/features/profile/profile_tab.dart:70-89). Nothing else in the app reads it, and
nothing re-runs build when the network comes back: the user gets fresh stats only by
pulling to refresh on the Race or Profile tab, which calls refreshMe()
(app/lib/features/auth/auth_notifier.dart:94-104).
A detail in step 4: the 401 also fires the Dio interceptor’s onUnauthorized callback,
which calls sessionExpired() while build is still pending (see “Any 401 signs you out”
below). Both paths clear the same stores and land on signedOut, so the double call is
harmless.
4. The router reacts, and the socket connects
Section titled “4. The router reacts, and the socket connects”When build completes, authProvider holds data, authStatusProvider recomputes, the
ref.listen in the router copies the new status into the ValueNotifier, and go_router
re-runs redirect. From /splash, signedIn goes to /home and signedOut to /login.
At the same moment socketLifecycleProvider sees the change
(app/lib/data/socket/socket_service.dart:24-38). On signedIn it calls
PhoenixSocketTransport.connect(), which reads the token again and opens the socket with it
as the token connect param (app/lib/data/socket/phoenix_transport.dart:46-85). The URL
is derived from API_BASE_URL by swapping the scheme to ws/wss and the path to
/socket/websocket (app/lib/core/config/app_config.dart:33-39). On the server,
UserSocket.connect/3 verifies the token and assigns current_user
(backend/lib/footrace_web/channels/user_socket.ex:43-52). On signedOut the provider
disconnects.
Sign in and register
Section titled “Sign in and register”sequenceDiagram
autonumber
participant L as LoginScreen state
participant A as AuthNotifier
participant D as Dio
participant B as UserSessionController / MeController
participant T as TokenStore
participant R as GoRouter
L->>L: Form.validate()
L->>A: login(email, password)
A->>D: POST /api/login (noAuth)
D->>B: UserSessionController.create
alt bad credentials
B-->>D: 404 errors: [User not found]
D-->>L: ApiException "Invalid email or password."
L->>L: setState(_error), form stays filled
else 201
B-->>A: token, expiresAt, user {id, email, name}
A->>T: write(token)
A->>D: GET /api/me
D->>B: MeController.show
B-->>A: user + stats
A->>A: state = signedIn(user, stats)
A-->>R: status change, redirect /login to /home
end
The screens
Section titled “The screens”LoginScreen and RegisterScreen are ConsumerStatefulWidgets. The “stateful” part holds
local UI state that does not belong in a provider: the text controllers, a _busy flag for
the button spinner and an _error string for the red banner
(app/lib/features/auth/login_screen.dart:20-25). If you know UIKit, this State object is
closer to a view controller’s instance variables than anything else in Flutter; setState
tells the framework to call build again.
Validation is client-side first. validateEmail, validateNewPassword (12 to 72
characters) and validateName (2 to 32 after trimming) live in
app/lib/features/auth/auth_form.dart:8-33, with comments naming the backend rules they
mirror. Register adds a confirm field that compares against the password controller
(app/lib/features/auth/register_screen.dart:101-110). A form that fails validation sends
nothing.
On submit, _submit (app/lib/features/auth/login_screen.dart:34-51) calls
ref.read(authProvider.notifier).login(...). Note ref.read, not ref.watch: inside a
callback you want the notifier once, not a subscription. The screen never navigates on
success. The comment on line 45 says why: the router’s auth gate moves us to the home tab.
On an ApiException it shows the message in FormErrorBanner
(app/lib/features/auth/auth_form.dart:36-70).
The “Create account” link is a context.push(AppRoutes.register)
(app/lib/features/auth/login_screen.dart:108-112). Push, so the register screen gets a
back button to the login screen.
The repository
Section titled “The repository”ApiAuthRepository.login posts {"user": {"email", "password"}} to /api/login with
Options(extra: {kNoAuth: true}) (app/lib/data/repositories/auth_repository.dart:26-54).
kNoAuth (app/lib/data/api/api_client.dart:11) does two things in the interceptor: it
skips the bearer header, and it stops a 401 on this request from signing anyone out.
The backend answers bad credentials with 404 {"token": null, "errors": ["User not found"]}
so it does not reveal which emails exist
(backend/lib/footrace_web/controllers/api/user_session_controller.ex:25-30). The
repository catches that 404 and rethrows it as “Invalid email or password.”
(app/lib/data/repositories/auth_repository.dart:43-52). Every other failure goes through
toApiException, which reads the backend’s errors: [...] or error: "..." body into
ApiException.messages (app/lib/data/api/api_client.dart:65-84).
Register posts {"user": {"name", "email", "password"}} to /api/register
(app/lib/data/repositories/auth_repository.dart:57-71). The backend replies 422 with
changeset errors flattened to strings like "email has already been taken"
(backend/lib/footrace_web/controllers/api/user_session_controller.ex:50-56, 97-106),
which land in the banner as-is.
Completing the sign-in
Section titled “Completing the sign-in”Both login and register end in _completeSignIn
(app/lib/features/auth/auth_notifier.dart:106-118):
- Write the token to the keychain (line 107).
- Call
/api/me, because the login reply carries onlyid,emailandname, and no stats (line 108’s comment; the backend reply is atbackend/lib/footrace_web/controllers/api/user_session_controller.ex:17-24). - On success, cache the user and set
state = signedIn(user, stats). - If
/api/mefails, use the user from the login reply and setoffline: true.
Setting state is what triggers everything else: authStatusProvider recomputes, the router
redirects from /login (or /register) to /home, and socketLifecycleProvider connects
the socket. Three consumers, one assignment. If you have used Phoenix PubSub to fan a change
out to several LiveViews, this is the same shape, except that Riverpod tracks the
subscribers for you from the ref.watch and ref.listen calls.
The expiresAt in the reply is parsed into AuthResponse.expiresAt
(app/lib/data/models/auth_response.dart:11-19) but nothing reads it. The backend has
POST /api/token/refresh (backend/lib/footrace_web/controllers/api/user_session_controller.ex:65-75);
the app never calls it. A token simply lasts its 30 days and then the next request gets a 401.
Any 401 signs you out
Section titled “Any 401 signs you out”sequenceDiagram
autonumber
participant N as any notifier / repository
participant D as Dio interceptor
participant B as Guardian pipeline
participant A as AuthNotifier
participant R as GoRouter
participant S as socketLifecycleProvider
N->>D: GET /api/... (Bearer expired)
D->>B: request
B-->>D: 401 {"error": "token_expired"}
D->>A: onUnauthorized() = sessionExpired()
A->>A: clear TokenStore + UserCache, state = signedOut
A-->>R: redirect anything to /login
A-->>S: disconnect socket
D-->>N: DioException wrapping ApiException(401)
The interceptor’s onError (app/lib/data/api/api_client.dart:45-58) checks three things:
the status is 401, the request was not marked kNoAuth, and a handler exists. Then it
awaits onUnauthorized before rejecting the request with the normalised ApiException.
The handler is wired in dioProvider (app/lib/data/api/api_client.dart:95-105) as a
closure that calls ref.read(authProvider.notifier).sessionExpired(). The comment on lines
101-102 explains why it is a closure: AuthNotifier depends on the repository, which depends
on dioProvider, so resolving authProvider while dioProvider is being built would be a
cycle. Reading it lazily at 401 time breaks the loop.
sessionExpired() (app/lib/features/auth/auth_notifier.dart:74-78) is idempotent: if the
state is already signedOut it returns, so ten requests failing at once sign out once. It
clears the stores and sets signedOut. The router then sends whatever screen you were on to
/login, because redirect sends every non-auth route there when signed out.
On the backend side, the 401 comes from Footrace.Users.ErrorHandler.auth_error/3, which
writes {"error": "token_expired"} for an expired JWT and "unauthenticated" when there is
no token (backend/lib/footrace/users/error_handler.ex:13-25). The app does not distinguish
the reasons: any 401 is a dead session.
Two places a 401 does not reach this path:
- The socket.
UserSocket.connect/3refuses a bad token outright (backend/lib/footrace_web/channels/user_socket.ex:43-51).PhoenixSocketTransportlogs the error and leaves thephoenix_socketpackage retrying on its backoff (app/lib/data/socket/phoenix_transport.dart:79-84). Nothing tellsAuthNotifier. In practice an HTTP call soon fails with 401 and that signs the user out. - Position uploads. These run in the foreground-service isolate with their own HTTP client and their own copy of the token, so a 401 there cannot call into Riverpod. The uploader stops and the race screen shows “Uploads stopped: unauthorized”; see Failure modes.
Sign out
Section titled “Sign out”The Profile tab’s “Sign out” row opens a confirm dialog and, on yes, calls
ref.read(authProvider.notifier).signOut() (app/lib/features/profile/profile_tab.dart:277-295).
signOut() (app/lib/features/auth/auth_notifier.dart:67-71) posts /api/logout, clears
the stores, and sets signedOut. The logout call is best-effort: the repository swallows
any DioException (app/lib/data/repositories/auth_repository.dart:79-86), because forgetting
the token locally is what matters. On the server, UserSessionController.logout/2 calls
Guardian.revoke/1 and answers 200 either way
(backend/lib/footrace_web/controllers/api/user_session_controller.ex:80-95).
A subtle consequence of the interceptor: if the token is already dead, the logout POST
itself gets a 401, which calls sessionExpired() before signOut() reaches its own
_clearSession(). The end state is the same.
The state change then does the same three things as sign-in, in reverse: the router redirects
to /login, the socket disconnects, and the next cold start finds no token and goes straight
to the sign-in screen. That is step 8 of
footrace-flutter/docs/use-cases/register-and-login.md.
What sign-out does not touch: the active-race id in ActiveRaceStore and a running
foreground service. Signing out mid-race is unusual (the race screen has no tab bar), but a
401 mid-race is not; see Failure modes.
What the use case checks
Section titled “What the use case checks”footrace-flutter/docs/use-cases/register-and-login.md walks this page as a user would. The
mapping:
| Use-case step | Code |
|---|---|
| 1. Signed-out launch shows sign-in | build returns signedOut, redirect to /login |
| 3-4. Short password, mismatched confirm | validateNewPassword, the confirm validator |
| 5. Wrong password, form stays filled | 404 mapped to “Invalid email or password.”, _error banner, no navigation |
| 7. Survive a restart | token in keychain, /api/me in build |
| 8. Sign out, cold start shows sign-in | signOut() clears TokenStore |
| Failure mode “signed in but empty” | /api/me failed after login: offline: true with the login reply’s user and no stats |
For the Riverpod side of this in more depth, see the Riverpod primer and Day 5. For the router, App shell and Day 4.