Files
geniusrun/docs/superpowers/specs/2026-07-24-oidc-authentication-design.md
Christophe Vila fd80065e81 Add design doc for OIDC-based app login gate
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>
2026-07-24 20:45:46 +02:00

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 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 302s 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:
      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, 302s 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.