Skip to content

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.

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

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.

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.

  1. Read the token from the keychain (line 25). None, or empty: return signedOut.
  2. Call GET /api/me through AuthRepository.me() (line 29, app/lib/data/repositories/auth_repository.dart:74-77). The Dio interceptor adds Authorization: Bearer <token> (app/lib/data/api/api_client.dart:36-44). On the backend this is MeController.show (backend/lib/footrace_web/controllers/api/me_controller.ex:10-15), behind the :authorized_api pipeline and its Guardian plugs (backend/lib/footrace/users/pipeline.ex:14-19).
  3. On a 200, write the user to UserCache and return signedIn(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.
  4. On an ApiException that is a 401 (isUnauthorized, app/lib/data/api/api_exception.dart:23), clear both stores and return signedOut (lines 33-36).
  5. 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.

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

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.

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.

Both login and register end in _completeSignIn (app/lib/features/auth/auth_notifier.dart:106-118):

  1. Write the token to the keychain (line 107).
  2. Call /api/me, because the login reply carries only id, email and name, and no stats (line 108’s comment; the backend reply is at backend/lib/footrace_web/controllers/api/user_session_controller.ex:17-24).
  3. On success, cache the user and set state = signedIn(user, stats).
  4. If /api/me fails, use the user from the login reply and set offline: 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.

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/3 refuses a bad token outright (backend/lib/footrace_web/channels/user_socket.ex:43-51). PhoenixSocketTransport logs the error and leaves the phoenix_socket package retrying on its backoff (app/lib/data/socket/phoenix_transport.dart:79-84). Nothing tells AuthNotifier. 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.

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.

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.