diff --git a/docs/superpowers/specs/2026-07-24-oidc-authentication-design.md b/docs/superpowers/specs/2026-07-24-oidc-authentication-design.md new file mode 100644 index 0000000..f4545ea --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-oidc-authentication-design.md @@ -0,0 +1,152 @@ +# OIDC authentication (access gate) — design + +Date: 2026-07-24 + +## Overview + +geniusrun currently has no application-level authentication: `corsMiddleware` in `backend/internal/api/server.go` explicitly reflects any origin because "there's no session/cookie auth to protect against CSRF." Anyone who can reach the HTTP port has full access. + +This adds a login gate in front of the whole app, backed by an existing Keycloak instance via OIDC. Once logged in, the user reaches the same single profile/dataset that exists today — this is **not** multi-tenancy. It does not reverse the "single active profile" decision recorded in `docs/superpowers/specs/2026-07-17-profile-taxonomy-analysis-engine-design.md`; it only gates access to that one profile. + +## Goals + +- No anonymous access to any `/api/*` route except a health check and the login/callback endpoints themselves. +- Login is delegated entirely to Keycloak (OIDC Authorization Code flow). geniusrun never collects or stores a password. +- Access is further restricted to users holding a specific realm role in Keycloak (e.g. `geniusrun-user`) — logging into the realm is not by itself sufficient, in case the realm is shared with other apps/users. +- Frontend gets a simple binary state: logged in (render the app) or not (show a login screen). No per-user data scoping, no user list, no admin UI. + +## Non-goals + +- Multi-profile / multi-tenant data scoping. Every authenticated+authorized user sees the same singleton `profile` row and the same activity data. If real multi-tenancy is ever wanted, it is a separate, much larger project (per-row scoping across `activities`, `workout_kinds`, `sync_state`, Garmin credentials, etc.) — explicitly out of scope here. +- Storing or refreshing Keycloak access/refresh tokens. geniusrun only ever verifies the ID token once, at callback time, then mints its own session cookie. It never calls back into Keycloak-protected APIs afterward. +- Any auth method other than OIDC (no local password DB, no other IdPs). +- Changing the existing Garmin credential login flow (`/api/auth/login`, `/api/auth/mfa`, `/api/auth/status`) — that remains as-is, just now reachable only by an already-authenticated app user. + +## Deployment assumptions + +- Production: frontend and backend served same-origin behind a reverse proxy (frontend static files + `/api` proxied to the backend), over HTTPS. This keeps cookie handling simple: `SameSite=Lax`, `Secure`. +- Local dev: frontend (Vite, port 5173) and backend (port 8080) are different origins. CORS must allow credentialed requests from the dev origin specifically (not `*`). + +## Architecture & flow + +Backend-driven ("BFF") Authorization Code flow. The browser never receives a Keycloak token; the backend is the OIDC client. + +1. Frontend loads, calls `GET /api/session/me`. No valid session cookie → `401` → frontend renders a login screen. +2. User clicks a plain `` (full browser navigation, not a fetch). Backend generates PKCE verifier + `state`, stashes them in a short-lived signed cookie (`geniusrun_oidc_txn`, ~10 min expiry), and `302`s to Keycloak's authorization endpoint. +3. Keycloak authenticates the user (whatever flow Keycloak is configured for — password, MFA, whatever; irrelevant to geniusrun) and redirects to `GET /api/session/callback?code=...&state=...`. +4. Backend: + - Verifies `state` against the stashed transaction cookie; clears that cookie either way. + - Exchanges `code` for tokens via `golang.org/x/oauth2`. + - Verifies the ID token (signature, issuer, audience, expiry) via `coreos/go-oidc/v3`. + - Checks the ID token's `realm_access.roles` array contains the configured required role. + - If the role is missing: redirect to `/?auth_error=forbidden`, no session cookie set. + - If any other step fails: redirect to `/?auth_error=failed`, no session cookie set. + - On success: mint geniusrun's own session cookie (`geniusrun_session`, HttpOnly, Secure in production, SameSite=Lax, signed HMAC JWT via `golang-jwt/jwt/v5`, claims `sub`/`name`/`email`/`exp`) with expiry `now + GENIUSRUN_SESSION_DURATION`. Redirect to `/`. +5. All `/api/*` routes except `/api/health`, `/api/session/login`, and `/api/session/callback` require a valid `geniusrun_session` cookie (verified by chi middleware). Invalid/missing/expired → `401`. +6. `POST /api/session/logout`: clears `geniusrun_session`, redirects (302) to Keycloak's `end_session_endpoint` (for full SSO logout), which Keycloak then redirects back to `/`. + +Route naming: the existing `/api/auth/*` group is Garmin's own credential login and is unrelated to this — left untouched. New endpoints live under `/api/session/*`. + +## Backend design + +### New package `backend/internal/auth` + +Mirrors the existing `internal/garmin` real-client + mock pattern. + +- **`oidc.go`** — real implementation. + - `type Verifier interface` — the seam tests mock out: + ```go + type Verifier interface { + BeginLogin() (authURL string, txn TxnState, err error) + HandleCallback(ctx context.Context, txn TxnState, query url.Values) (Claims, error) + } + ``` + - `Claims{Sub, Name, Email string; Authorized bool}` — `Authorized` is true only if the required role was present. + - `TxnState{State, CodeVerifier string}` — round-tripped via the short-lived transaction cookie (encoded/decoded by the caller in `internal/api`, so this package stays free of `net/http` cookie details... actually cookie encode/decode also lives here since it's auth-specific plumbing, see `session.go`). + - Real implementation wraps `coreos/go-oidc/v3` (`oidc.NewProvider` for discovery, `IDTokenVerifier`) and `golang.org/x/oauth2` (`oauth2.Config`, `Exchange`, PKCE via `oauth2.S256ChallengeOption`/`oauth2.VerifierOption`). + - Constructed once at startup from `config.Config` (`NewOIDCVerifier(ctx, cfg)`); discovery happens once and is cached (`go-oidc` does this internally). + +- **`session.go`** — session cookie mint/parse, independent of OIDC specifics. + - `MintSessionCookie(claims Claims, secret []byte, duration time.Duration) *http.Cookie` + - `ParseSessionCookie(cookie *http.Cookie, secret []byte) (Claims, error)` — returns an error on bad signature, malformed token, or expiry. + - Also owns the transaction-cookie helpers (`MintTxnCookie`/`ParseTxnCookie`) since they're the same signed-cookie mechanism, just shorter-lived and holding different claims. + +- **`middleware.go`** + - `RequireSession(secret []byte) func(http.Handler) http.Handler` — chi middleware; parses `geniusrun_session`, 401s on failure, otherwise stores `Claims` in request context via a package-level context key. + - `ClaimsFromContext(ctx context.Context) (Claims, bool)` — for handlers that want the current user (e.g. `handleSessionMe`). + +- **`mock.go`** (test-only, `_test.go` or a `mock` subpackage matching `garmin/mock`'s style) — a fake `Verifier` that returns canned claims/errors, for `internal/api` tests. + +### `backend/internal/api` changes + +- **New `session.go`**: + - `handleSessionLogin` — calls `Auth.BeginLogin()`, sets the txn cookie, `302`s to the returned URL. + - `handleSessionCallback` — reads the txn cookie, calls `Auth.HandleCallback`, on success mints+sets the session cookie and redirects to `/`; on failure redirects to `/?auth_error=...` per the two cases above. + - `handleSessionLogout` — clears the session cookie, redirects to the issuer's end-session endpoint (built from the OIDC provider's discovery document, with `post_logout_redirect_uri` back to `/`). + - `handleSessionMe` — reads `Claims` from context (populated by middleware, so this route lives inside the protected group even though conceptually it's "am I logged in" — a request with no valid cookie never reaches the handler, it 401s in middleware first, which is exactly the answer the frontend wants). +- **`server.go`**: + - `Server` gains `Auth auth.Verifier` and a `SessionSecret []byte` field (or a small `SessionConfig` struct — secret + duration), set from `config.Config` in `main.go`. + - Router: `/api/health`, `/api/session/login`, `/api/session/callback` stay outside the auth middleware; everything else under `/api` (including the existing `/api/auth/*` Garmin routes) is wrapped in `s.Auth...RequireSession(...)` — mounted as `r.Group(func(r chi.Router) { r.Use(authMW); ... })` around the existing route registrations, minimizing the diff to `Router()`. + - `corsMiddleware`: add `Access-Control-Allow-Credentials: true`; update the stale comment ("no session/cookie auth") since that's no longer true. Reflecting any `Origin` is still fine even with credentials — this remains a single-operator app, just now gated by login rather than by network exposure. + +### `backend/internal/config` changes + +New fields on `Config`, loaded the same way as the existing Garmin path vars (required → `Load()` errors if unset; optional → default applied): + +| Env var | Required | Default | Purpose | +|---|---|---|---| +| `GENIUSRUN_OIDC_ISSUER_URL` | yes | — | Keycloak realm issuer, e.g. `https://keycloak.example.com/realms/myrealm` | +| `GENIUSRUN_OIDC_CLIENT_ID` | yes | — | Confidential client ID registered in Keycloak | +| `GENIUSRUN_OIDC_CLIENT_SECRET` | yes | — | That client's secret | +| `GENIUSRUN_OIDC_REDIRECT_URL` | yes | — | Must exactly match the callback URL, e.g. `https://geniusrun.example.com/api/session/callback` | +| `GENIUSRUN_SESSION_SECRET` | yes | — | Random ≥32-byte secret (base64 or hex) for signing session/txn cookies | +| `GENIUSRUN_OIDC_REQUIRED_ROLE` | no | `geniusrun-user` | Realm role required in `realm_access.roles` | +| `GENIUSRUN_SESSION_DURATION` | no | `720h` (30 days) | Session cookie lifetime | + +### New Go dependencies + +`github.com/coreos/go-oidc/v3`, `golang.org/x/oauth2`, `github.com/golang-jwt/jwt/v5`. + +## Frontend design + +- **`main.tsx`**: wrap `` in a new `` component. +- **`LoginGate.tsx`** (new, `frontend/src/`): + - On mount, calls `api.getSessionInfo()` (→ `GET /api/session/me`). + - Loading → spinner (reuse whatever loading pattern `App.tsx` already uses, if any, else a minimal centered spinner). + - `401` → render a simple centered screen: geniusrun heading, an `Log in` button, and — if the URL has `?auth_error=forbidden` or `?auth_error=failed` — a corresponding one-line message ("Your account isn't authorized for geniusrun." / "Login failed, please try again."). + - `200` → render ``. +- **`App.tsx`**: accepts the session (`{name, email}`) as a prop; adds a small "Log out" control next to the existing profile-name button (a plain link/button that does a full navigation to trigger `POST /api/session/logout` — since logout redirects through Keycloak's end-session endpoint, it must be a real navigation, not a fetch that's then followed by a client-side route change). +- **`api/client.ts`**: + - `request` wrapper: add `credentials: 'include'` to every fetch call. + - New methods: `getSessionInfo(): Promise<{name: string; email: string}>`, and logout is just a link (`/api/session/logout`), no JS method needed. + - On a `401` from any endpoint *other than* `/api/session/me` (i.e., an established session that expired mid-use), redirect the whole page to `/` so `LoginGate` re-checks and shows the login screen, rather than leaving the SPA in a broken half-authenticated state. + +## Security notes + +- PKCE (`S256`) is used even though this is a confidential client — cheap defense-in-depth, and increasingly expected practice regardless of client type. +- `state` is verified on callback to prevent CSRF against the login flow itself. +- Session and transaction cookies are `HttpOnly` always, `Secure` when the app is served over HTTPS (production), `SameSite=Lax`. +- The required-role check happens once, at callback time, and is baked into the session cookie's claims. It is **not** re-checked against Keycloak on every request — if a role is revoked in Keycloak mid-session, that only takes effect the next time the user's session expires and they re-authenticate (bounded by `GENIUSRUN_SESSION_DURATION`). This is an accepted tradeoff for staying stateless (no server-side session store, no periodic re-validation calls to Keycloak). +- `GENIUSRUN_SESSION_SECRET` must be a real random secret, not derived from anything guessable; rotating it invalidates all existing sessions (acceptable — users just log in again). + +## Testing plan + +- `internal/auth`: + - Session cookie round-trip: mint → parse succeeds with correct claims. + - Expired cookie → parse fails. + - Tampered cookie (flipped byte) → parse fails (signature mismatch). + - Role-check logic against fake ID-token claims: role present → `Authorized: true`; absent → `false`; `realm_access` missing entirely → `false` (not a panic). +- `internal/api` (httptest + a fake `auth.Verifier`, same style as `mock.Client` for Garmin): + - Protected route with no cookie → `401`. + - Protected route with a valid cookie → passes through to the handler. + - Protected route with an expired/tampered cookie → `401`. + - `/api/health` reachable with no cookie. + - `handleSessionCallback` with a fake `Verifier` returning `Authorized: false` → redirects, no `Set-Cookie`. + - `handleSessionCallback` with a fake `Verifier` returning `Authorized: true` → redirects, `Set-Cookie` present and parses back to the expected claims. +- Not automated (manual smoke test against real Keycloak, same rationale as the existing Garmin MFA flow): full login → callback → role check → session → logout round trip; and the "role missing" rejection path with a real Keycloak user lacking the role. + +## Rollout notes + +- This requires a Keycloak client to exist before deploy: confidential client, valid redirect URI matching `GENIUSRUN_OIDC_REDIRECT_URL`, and a realm role (default name `geniusrun-user`) assigned to whichever users should have access. +- No database migration needed — nothing here touches `internal/store`. +- Existing local dev workflow (`./start.sh` for both frontend/backend) needs the new env vars added to `backend/.env` (or exported) before `geniusrund` will start, since the required ones cause `config.Load()` to fail fast, matching the existing Garmin-path-required pattern.