Adds an access-gate authentication design: Keycloak OIDC via a backend-driven Authorization Code flow, restricted by realm role, with geniusrun minting its own session cookie. No data model or multi-profile changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
14 KiB
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
profilerow and the same activity data. If real multi-tenancy is ever wanted, it is a separate, much larger project (per-row scoping acrossactivities,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 +
/apiproxied 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.
- Frontend loads, calls
GET /api/session/me. No valid session cookie →401→ frontend renders a login screen. - 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), and302s to Keycloak's authorization endpoint. - 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=.... - Backend:
- Verifies
stateagainst the stashed transaction cookie; clears that cookie either way. - Exchanges
codefor tokens viagolang.org/x/oauth2. - Verifies the ID token (signature, issuer, audience, expiry) via
coreos/go-oidc/v3. - Checks the ID token's
realm_access.rolesarray 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 viagolang-jwt/jwt/v5, claimssub/name/email/exp) with expirynow + GENIUSRUN_SESSION_DURATION. Redirect to/.
- Verifies
- All
/api/*routes except/api/health,/api/session/login, and/api/session/callbackrequire a validgeniusrun_sessioncookie (verified by chi middleware). Invalid/missing/expired →401. POST /api/session/logout: clearsgeniusrun_session, redirects (302) to Keycloak'send_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: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}—Authorizedis 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 ininternal/api, so this package stays free ofnet/httpcookie details... actually cookie encode/decode also lives here since it's auth-specific plumbing, seesession.go).- Real implementation wraps
coreos/go-oidc/v3(oidc.NewProviderfor discovery,IDTokenVerifier) andgolang.org/x/oauth2(oauth2.Config,Exchange, PKCE viaoauth2.S256ChallengeOption/oauth2.VerifierOption). - Constructed once at startup from
config.Config(NewOIDCVerifier(ctx, cfg)); discovery happens once and is cached (go-oidcdoes this internally).
-
session.go— session cookie mint/parse, independent of OIDC specifics.MintSessionCookie(claims Claims, secret []byte, duration time.Duration) *http.CookieParseSessionCookie(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.goRequireSession(secret []byte) func(http.Handler) http.Handler— chi middleware; parsesgeniusrun_session, 401s on failure, otherwise storesClaimsin 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.goor amocksubpackage matchinggarmin/mock's style) — a fakeVerifierthat returns canned claims/errors, forinternal/apitests.
backend/internal/api changes
- New
session.go:handleSessionLogin— callsAuth.BeginLogin(), sets the txn cookie,302s to the returned URL.handleSessionCallback— reads the txn cookie, callsAuth.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, withpost_logout_redirect_uriback to/).handleSessionMe— readsClaimsfrom 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:ServergainsAuth auth.Verifierand aSessionSecret []bytefield (or a smallSessionConfigstruct — secret + duration), set fromconfig.Configinmain.go.- Router:
/api/health,/api/session/login,/api/session/callbackstay outside the auth middleware; everything else under/api(including the existing/api/auth/*Garmin routes) is wrapped ins.Auth...RequireSession(...)— mounted asr.Group(func(r chi.Router) { r.Use(authMW); ... })around the existing route registrations, minimizing the diff toRouter(). corsMiddleware: addAccess-Control-Allow-Credentials: true; update the stale comment ("no session/cookie auth") since that's no longer true. Reflecting anyOriginis 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.tsxalready 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=forbiddenor?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} />.
- On mount, calls
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 triggerPOST /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: addcredentials: '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
401from any endpoint other than/api/session/me(i.e., an established session that expired mid-use), redirect the whole page to/soLoginGatere-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. stateis verified on callback to prevent CSRF against the login flow itself.- Session and transaction cookies are
HttpOnlyalways,Securewhen 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_SECRETmust 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_accessmissing entirely →false(not a panic).
internal/api(httptest + a fakeauth.Verifier, same style asmock.Clientfor 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/healthreachable with no cookie.handleSessionCallbackwith a fakeVerifierreturningAuthorized: false→ redirects, noSet-Cookie.handleSessionCallbackwith a fakeVerifierreturningAuthorized: true→ redirects,Set-Cookiepresent and parses back to the expected claims.
- Protected route with no cookie →
- 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 namegeniusrun-user) assigned to whichever users should have access. - No database migration needed — nothing here touches
internal/store. - Existing local dev workflow (
./start.shfor both frontend/backend) needs the new env vars added tobackend/.env(or exported) beforegeniusrundwill start, since the required ones causeconfig.Load()to fail fast, matching the existing Garmin-path-required pattern.