Generated from: “How the login flow works across the SPA, the auth service, and Google.”
View the Markdown
```meta
title: Login flow
subtitle: How a user signs in across the SPA, the auth service, and Google.
tag: DRAFT
```
Login uses OpenID Connect Authorization Code with PKCE. The SPA never sees Google credentials and never holds a Google token. The auth service is the only party that talks to Google's token endpoint, and it is the only party that issues our own session.
```callout
tone: note
title: Assumptions
body: "The request left the stack open, so this document fixes it. The SPA is a React app at app.example.com. The auth service is a Node service at auth.example.com. Google is the only identity provider. The session is a rotating refresh token in an HttpOnly cookie plus a 15-minute access JWT held in SPA memory. Change the nouns here and the blocks below stay valid."
```
## Who talks to whom
Three parties take part. The browser is the SPA's runtime, so the SPA appears as one system. The auth service owns the session store and is the only system with Google client credentials.
```c4
id: login-context
title: Login system context
level: context
nodes:
- { id: user, col: 1, row: 1, kind: person, name: User, desc: "A person with a Google account." }
- { id: spa, col: 2, row: 1, kind: system, name: SPA, desc: "React app at app.example.com. Starts login and holds the access token in memory." }
- { id: auth, col: 3, row: 1, kind: system, name: Auth service, desc: "Node service at auth.example.com. Exchanges the Google code and issues our session." }
- { id: sessions, col: 3, row: 2, kind: store, name: Session store, desc: "Redis. Refresh token families and revocations." }
- { id: google, col: 4, row: 1, kind: external, name: Google, desc: "OIDC provider. Authenticates the user and issues the ID token." }
edges:
- { from: user, to: spa, label: "clicks Sign in with Google" }
- { from: spa, to: auth, label: "start, callback, refresh, logout", tech: "HTTPS + JSON" }
- { from: auth, to: google, label: "redirect, token exchange, JWKS", tech: "OIDC" }
- { from: auth, to: sessions, label: "reads and writes sessions" }
```
## Message order on a successful login
The state parameter and the PKCE verifier both live server-side in a short-lived login record keyed by the state cookie. This keeps the SPA free of secrets and lets the auth service reject a callback that it did not start. The redirect from Google lands on the auth service, not the SPA, so the authorization code never enters SPA JavaScript.
```sequence
id: login-happy-path
title: Sign in with Google — happy path
actors:
- { id: SPA, name: SPA, sub: app.example.com }
- { id: Auth, name: Auth service, sub: auth.example.com }
- { id: Google, name: Google, external: true }
messages:
- SPA -> +Auth: "GET /auth/google/start?return_to=/dashboard"
- Auth -> Auth: "create login record: state, PKCE verifier, return_to (TTL 5 min)"
- Auth --> -SPA: "302 to Google authorize URL; Set-Cookie login_state (HttpOnly, 5 min)"
- SPA -> +Google: "GET /o/oauth2/v2/auth?client_id&redirect_uri&scope=openid email profile&state&code_challenge"
- Google --> -SPA: "302 to https://auth.example.com/auth/google/callback?code&state"
- SPA -> +Auth: "GET /auth/google/callback?code&state (Cookie: login_state)"
- Auth -> Auth: "match state to login record; load PKCE verifier"
- Auth -> +Google: "POST /token (code, code_verifier, client_id, client_secret)"
- Google --> -Auth: "200 { id_token, access_token, expires_in }"
- Auth -> Google: "GET /oauth2/v3/certs (cached JWKS)"
- Auth -> Auth: "verify id_token signature, iss, aud, exp, nonce, email_verified"
- Auth -> Auth: "upsert user by Google sub; create session family in Redis"
- Auth --> -SPA: "302 to /dashboard; Set-Cookie refresh_token (HttpOnly, Secure, SameSite=Lax, 30 d)"
- SPA -> +Auth: "POST /auth/refresh (Cookie: refresh_token)"
- Auth --> -SPA: "200 { access_token (JWT, 15 min), user }; rotated refresh_token cookie"
foot:
- { label: Redirect URI, value: "https://auth.example.com/auth/google/callback" }
- { label: Scopes, value: "openid email profile" }
- { label: Access token lifetime, value: "15 minutes" }
```
## Where a login stops
Every rejection ends at the SPA's `/login?error=<code>` page so the user gets one place to retry. The auth service deletes the login record on the first callback, so a replayed code or state fails at the first check.
```flow
id: login-failures
title: Callback checks and their exits
dir: LR
nodes:
- { id: cb, col: 1, row: 1, kind: start, label: "Callback arrives" }
- { id: state, col: 2, row: 1, kind: decision, label: "state matches login_state cookie?" }
- { id: exch, col: 3, row: 1, kind: decision, label: "Google accepts code + verifier?" }
- { id: idt, col: 4, row: 1, kind: decision, label: "id_token valid and email verified?" }
- { id: allow, col: 5, row: 1, kind: decision, label: "user active and not blocked?" }
- { id: ok, col: 6, row: 1, kind: end, label: "Issue session, redirect to return_to" }
- { id: e_state, col: 2, row: 2, kind: end, label: "/login?error=state_mismatch" }
- { id: e_exch, col: 3, row: 2, kind: end, label: "/login?error=exchange_failed" }
- { id: e_idt, col: 4, row: 2, kind: end, label: "/login?error=identity_rejected" }
- { id: e_allow, col: 5, row: 2, kind: end, label: "/login?error=account_blocked" }
- { id: denied, col: 1, row: 2, kind: end, label: "/login?error=access_denied" }
edges:
- cb -> state
- cb -x-> denied: "Google returned error=access_denied"
- state -> exch: "yes"
- state -x-> e_state: "no, expired, or reused"
- exch -> idt: "200"
- exch -x-> e_exch: "4xx or timeout after 3 retries"
- idt -> allow: "yes"
- idt -x-> e_idt: "bad signature, aud, nonce, or email_verified=false"
- allow -> ok: "yes"
- allow -x-> e_allow: "no"
```
## Session lifecycle
A session is a refresh token family. Each refresh rotates the token; presenting an already-rotated token revokes the whole family, because it means the cookie was copied. The access JWT is not stored anywhere and is not revocable; its 15-minute lifetime bounds the damage of a leak.
```state
id: session-lifecycle
title: Session family lifecycle
dir: LR
states:
- { id: s0, col: 1, row: 1, kind: start }
- { id: pending, col: 2, row: 1, kind: wait, name: LOGIN_PENDING }
- { id: active, col: 3, row: 1, kind: active, name: ACTIVE }
- { id: expired, col: 4, row: 1, kind: terminal, name: EXPIRED }
- { id: revoked, col: 4, row: 2, kind: terminal, name: REVOKED }
- { id: abandoned, col: 2, row: 2, kind: terminal, name: ABANDONED }
transitions:
- { from: s0, to: pending, event: "GET /auth/google/start" }
- { from: pending, to: active, event: "callback verified", guard: "state matches, id_token valid" }
- { from: pending, to: abandoned, event: "login record TTL 5 min elapses" }
- { from: active, to: active, event: "POST /auth/refresh rotates token" }
- { from: active, to: expired, event: "30 days without refresh" }
- { from: active, to: revoked, event: "POST /auth/logout" }
- { from: active, to: revoked, event: "rotated token presented again" }
- { from: active, to: revoked, event: "admin blocks user" }
```
## What each party holds
The split below is the security boundary of the design. If a value moves to another column, the threat model changes and this document needs a new revision.
```table
id: token-inventory
title: Credentials and where they live
columns: [Credential, Holder, Storage, Lifetime, Purpose]
rows:
- [Google client_secret, Auth service, "Secret manager, env at boot", Rotated yearly, "Authenticates the auth service to Google's token endpoint"]
- [PKCE verifier, Auth service, "Login record in Redis", 5 minutes, "Proves the callback came from the party that started the login"]
- [state, "Auth service + browser cookie", "login_state cookie (HttpOnly)", 5 minutes, "Binds the callback to the browser that started it"]
- [Google id_token, Auth service, "Never stored; verified then discarded", "1 hour (Google)", "Proves who the user is; source of sub, email, name"]
- [Google access_token, Auth service, "Never stored", "1 hour (Google)", "Unused; we request no Google APIs beyond identity"]
- [refresh_token, Browser, "refresh_token cookie (HttpOnly, Secure, SameSite=Lax)", "30 days, rotates on use", "Obtains new access tokens; identifies the session family"]
- [access_token, SPA, "JavaScript memory only", 15 minutes, "Bearer token on API calls; carries user id and roles"]
note: "Nothing sensitive is written to localStorage or sessionStorage. A full page reload costs one POST /auth/refresh."
```