๐ŸฅฆNdiro
Chapter 9 of 13

Identity: OAuth, sessions, guards

Ndiro never sees a password. Sign-in is delegated to Google via OAuth 2.0, identity is carried in a signed cookie, and authorization is re-checked from the database on every request. This chapter walks the whole chain, because it's the part of web development with the least room for improvisation.

The OAuth dance

browser                          the app                        Google
   โ”‚ GET /login โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถ โ”‚
   โ”‚                                  โ”‚ session['oauth_state'] = random
   โ”‚ โ—€โ”€ 302 to accounts.google.com โ”€โ”€ โ”‚   (and login_next = where to land after)
   โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถ โ”‚
   โ”‚            user picks their Google account, consents            โ”‚
   โ”‚ โ—€โ”€ 302 back to /callback?code=โ€ฆ&state=โ€ฆ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€  โ”‚
   โ”‚ GET /callback?code=โ€ฆ&state=โ€ฆ โ”€โ”€โ–ถ โ”‚
   โ”‚                                  โ”‚ 1. state == session.pop('oauth_state')?
   โ”‚                                  โ”‚ 2. POST the code + client_secret โ”€โ”€โ”€โ”€โ–ถ โ”‚
   โ”‚                                  โ”‚ โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ access token โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚
   โ”‚                                  โ”‚ 3. GET userinfo (sub, email, name) โ”€โ”€โ–ถ โ”‚
   โ”‚                                  โ”‚ 4. upsert users row; session['user_id']=sub
   โ”‚ โ—€โ”€ 302 to /log, Set-Cookie โ”€โ”€โ”€โ”€โ”€ โ”‚

Three details carry the security weight. The code exchange happens server-to-server (step 2, in auth.fetch_userinfo) โ€” the browser only ever sees the one-time code, never the client secret or the token. The state parameter is a random nonce stored in the session and compared with session.pop on return; a forged or replayed callback fails the comparison. And the account key is Google's sub (a stable ID, treated as an opaque string โ€” never parsed), not the email โ€” emails change; primary keys shouldn't.

One more guard hides in plain sight: _safe_next. The ?next= destination that rides through login is validated to be a same-site relative path โ€” starts with /, not //, no backslashes โ€” otherwise /login?next=https://evil.example turns your trusted domain into an open redirect for phishing.

The session cookie: signed, not encrypted

Flask's default session is a cookie containing your data as base64 JSON plus an HMAC signature keyed by SECRET_KEY. Anyone can read it (decode yours โ€” it's just {"user_id": "โ€ฆ"}); nobody can modify it without the signature check failing. Forge-proof, not secret. Which is exactly why the rule exists:

auth.py โ†—
# Session stores ONLY user_id (the Google sub) plus transient oauth_state /
# login_next. Status is NEVER cached in the cookie: every guarded request does
# a fresh users-table read (~$0.25/million) so a rejected user's live session
# dies immediately.

(One more transient rides the session during an invite sign-up: invite_token, stored by /login and popped unconditionally in /callback so it never survives into the signed-in session โ€” invariant #4 lists all three transients.)

A cookie is a cache you cannot invalidate โ€” it lives on someone else's machine for 30 days. Cache status: approved in it and a banned user stays approved until expiry. So the cookie holds only the identity pointer, and authorization data is read fresh per request. Revocation latency: zero. Cost: one cheap key lookup.

The cookie's attributes are set once in app.py and each earns its place:

FlagProtects against
SecureTransmission over plain HTTP (only sent on HTTPS)
HttpOnlyJavaScript reading it โ€” even injected script can't exfiltrate the session
SameSite=LaxOther sites triggering authenticated writes (the CSRF defence โ€” the security chapter)

Guards and the status machine

A users row has status โˆˆ pending | approved | rejected | admin. New sign-ups land pending (the waiting page) until an admin approves them โ€” or they arrived through an invite link, which auto-approves. The two decorators from the backend chapter enforce it all; the branchy middle of approved_required is the actual policy: approved/admin โ†’ proceed with g.user set; pending โ†’ the waiting page (or 403 for API calls); rejected or deleted โ†’ session.clear() and goodbye. API paths get JSON errors, page paths get redirects โ€” _wants_json() switches on the /api/ prefix so both callers get an answer they can handle.

ADMIN_EMAILS is only a bootstrap: it decides the status written at first sign-in. After that the table is the truth โ€” an admin is whoever's row says admin, and the env var is never consulted at request time.

Invites: an auth flow with CAS semantics

Invite links (/i/<token>) are single-use, expiring, auto-approving. The interesting part is the redemption: marking an invite used must be race-safe (two people clicking the same link at once), so db.claim_invite is a DynamoDB conditional write โ€” "set used_by, if used_by doesn't exist and it isn't revoked or expired" โ€” an atomic compare-and-set at the storage layer, not a read-check-write in Python. The MAX_USERS capacity check runs before invite logic, so a full instance never burns someone's invite.

๐Ÿ˜ From your world

The cookie is a Kerberos-ish ticket: self-contained proof of identity, honored without a round trip. The fresh status read is the deliberate opposite โ€” a per-request ACL check against the authority โ€” because for a 100-user app, "revocation is instant" is worth a millisecond per request. Recognize the trade: tickets scale reads, authority lookups scale trust.

๐Ÿ”ฌ Try it

Devtools โ†’ Application โ†’ Cookies. Find the session cookie, copy its value, and decode the first segment (it's URL-safe base64 of JSON โ€” in a terminal: echo 'PASTE' | cut -d. -f1 | base64 -d). There's your user_id, readable but untamperable. Note HttpOnly is checked โ€” now try document.cookie in the Console and see the session cookie refuse to appear.

๐Ÿ