You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: docs/explanation/authentication-design.md
+61Lines changed: 61 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -74,3 +74,64 @@ SSO via OIDC redirects out to the IdP and back. The browser's first request afte
74
74
Self-signup implies a user database, an email-verification flow (or an open-by-default surface), and an admin UI for moderation. That's an entire product surface that doesn't belong in an OSS tool deployed by small operators who want a *locked-down* viewer. Operator-provisioned users via env keep the threat model the same as API keys: the people with access are the people the operator deliberately gave access to.
75
75
76
76
For deployments that need self-signup, OIDC is the right answer — federate to an IdP that already handles signup, password reset, and account hygiene.
77
+
78
+
## How the OIDC redirect flow holds together
79
+
80
+
The browser drives a chain of redirects with state preserved across hops in two short-lived cookies. The high-level picture:
81
+
82
+
```
83
+
User → SPA GET /login (anonymous; AuthProvider knows mode=oidc)
84
+
SPA → Backend GET /api/v1/auth/login/entra (kickoff route)
Several decisions are worth understanding because they're not obvious from reading the code alone.
100
+
101
+
### Why two cookies, not one
102
+
103
+
The OAuth `state` parameter is the canonical CSRF defence for the redirect-back. To validate it, we need to know what state we minted on kickoff — that's the kind of thing many implementations stash in a server-side session. Our session store doesn't exist yet at that point in the flow (the user is still anonymous), so a separate, short-lived `reflow_oauth_tx` cookie carries it instead. Same cookie also carries the PKCE verifier and the original `next` path so the callback finishes the round-trip without needing any other state.
104
+
105
+
The `reflow_oauth_tx` cookie is signed with a different `itsdangerous` salt from the session cookie, so a session value can never be replayed as a tx value or vice versa. TTL is 10 minutes — long enough that a user pausing on an MFA prompt or a password reset still completes the flow, short enough that a captured tx cookie can't be replayed against a future kickoff.
106
+
107
+
### Why PKCE even with a confidential client
108
+
109
+
Entra (and most enterprise IdPs) treat us as a "confidential client" because we have a `client_secret`. The OAuth 2.0 spec says PKCE is optional for confidential clients. We do it anyway because:
110
+
111
+
1. Defence in depth — if the `client_secret` ever leaks (CI logs, accidental commit, env-dump page), an attacker who also intercepts a single authorisation code can't redeem it without the verifier we never put on the wire.
112
+
2. Future-proofing — if we ever break the deployment into a public client (e.g. a native desktop variant), nothing about the OIDC integration changes.
113
+
114
+
The `code_challenge` (S256 hash of a 64-char verifier) goes in the auth URL; the verifier rides home in the signed tx cookie and is sent in the token-exchange POST body.
115
+
116
+
### Why we validate every claim ourselves
117
+
118
+
`joserfc.jwt.decode` validates the JWT signature against the JWKS we fetched from the discovery doc. It does **not** validate `iss`, `aud`, `exp`, or `nonce` — those are application-level checks. We do them in `OIDCAuthProvider._validate_id_token`:
119
+
120
+
-`iss` must match the discovery doc's issuer. Catches "wrong tenant" misconfigurations.
121
+
-`aud` must include our `client_id`. ID tokens issued for *another* client of the same IdP should not be redeemable here.
122
+
-`exp` must not be in the past (with 60s leeway for clock skew).
123
+
-`nonce` must match the value we minted on kickoff. Prevents replay of an ID token captured from a different login session — even from the same IdP, even within the token's `exp` window.
124
+
125
+
### Why JWKS rotation gets a force-refresh retry
126
+
127
+
IdPs rotate signing keys silently. Entra ≈ daily; some providers do it on demand. Our JWKS cache TTL (one hour) is an optimisation, not a correctness boundary — on a signature-validation failure we force-refresh JWKS once and retry. If validation still fails, the token really is bad. This avoids a class of "everything was fine yesterday and now nobody can log in" outages that would otherwise need a container restart to recover.
128
+
129
+
### Why the open-redirect sanitiser
130
+
131
+
A malicious link `https://reflow.example/login?next=https://evil.example/steal` would, without sanitisation, surface `https://evil.example/steal` as the post-login destination — turning our login flow into an unintentional open redirect. `_safe_next_path` accepts only values starting with a single `/` (no scheme, no `//`); anything else falls back to `AUTH_POST_LOGIN_REDIRECT`. Cheap defence, real value.
132
+
133
+
## Why no group/role gating in the first cut
134
+
135
+
The `Identity` model deliberately has no `groups` or `roles` field. Reading the IdP's group claim is straightforward (Entra emits group object IDs in the `groups` claim or via Graph API for large groups), but **policy** — what to do with the membership — is the messy part. "Members of group X may use the viewer" is a different policy from "members of group Y are admins" or "members of any of these N groups", and each of those wants a different config shape.
136
+
137
+
Shipping a half-baked policy mechanism in PR2 would be worse than shipping none. The clean extension point is there: add `groups: list[str]` to `Identity`, plus a `RequireGroups` FastAPI dependency, plus an env-driven allowlist. That's a Phase 4 ticket once the OIDC plumbing has lived in production for a while and we know which policy patterns operators actually need.
0 commit comments