Compare commits
2 Commits
2fab788208
...
70c6d143e1
| Author | SHA1 | Date | |
|---|---|---|---|
| 70c6d143e1 | |||
| fd80065e81 |
1836
docs/superpowers/plans/2026-07-24-oidc-authentication.md
Normal file
1836
docs/superpowers/plans/2026-07-24-oidc-authentication.md
Normal file
File diff suppressed because it is too large
Load Diff
152
docs/superpowers/specs/2026-07-24-oidc-authentication-design.md
Normal file
152
docs/superpowers/specs/2026-07-24-oidc-authentication-design.md
Normal file
@@ -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 `<a href="/api/session/login">` (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 `<App/>` in a new `<LoginGate>` 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 `<a href="/api/session/login">Log in</a>` 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 user={session} />`.
|
||||
- **`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<T>` 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.
|
||||
Reference in New Issue
Block a user