From 70c6d143e1b07c391cc97c90ab47100f9bfb8ba4 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Fri, 24 Jul 2026 21:02:42 +0200 Subject: [PATCH] Add implementation plan for the OIDC login gate Task-by-task TDD plan covering config, the new internal/auth package (session cookies, Keycloak OIDC verifier, RequireSession middleware), wiring into internal/api and cmd/geniusrund, and the frontend LoginGate. Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-07-24-oidc-authentication.md | 1836 +++++++++++++++++ 1 file changed, 1836 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-24-oidc-authentication.md diff --git a/docs/superpowers/plans/2026-07-24-oidc-authentication.md b/docs/superpowers/plans/2026-07-24-oidc-authentication.md new file mode 100644 index 0000000..7914fd8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-oidc-authentication.md @@ -0,0 +1,1836 @@ +# OIDC Authentication (Access Gate) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Gate the whole geniusrun app behind a login against an existing Keycloak realm (OIDC), so no anonymous access is possible, without changing the underlying single-profile data model. + +**Architecture:** Backend-driven ("BFF") OIDC Authorization Code flow. The Go backend (`geniusrund`) talks to Keycloak directly; the browser only ever sees geniusrun's own signed, HttpOnly session cookie. A chi middleware rejects any `/api/*` request lacking a valid cookie, except health/login/callback. The frontend gets a binary state (logged in / not) from `GET /api/session/me`. + +**Tech Stack:** `github.com/coreos/go-oidc/v3` (OIDC discovery + ID token verification), `golang.org/x/oauth2` (Authorization Code + PKCE exchange), `github.com/golang-jwt/jwt/v5` (signs geniusrun's own session/transaction cookies). No new frontend dependencies. + +## Global Constraints + +- No data model changes. Every authenticated+authorized user reaches the same singleton `profile` row — this is an access gate, not multi-tenancy. +- The existing `/api/auth/*` routes (Garmin credential login) are untouched and unrenamed; new endpoints live under `/api/session/*`. +- Keycloak tokens (access/refresh/ID) are never stored or refreshed. The ID token is verified once, at callback time; afterward only geniusrun's own session cookie is checked. +- The required-role check happens once, at login, and is baked into the session cookie's validity (the cookie either exists, signed, for an authorized user, or it doesn't exist). No per-request call back to Keycloak. +- Cookies: `HttpOnly` always; `Secure` derived from whether `GENIUSRUN_PUBLIC_BASE_URL` is `https://`; `SameSite=Lax`. +- PKCE (`S256`) and `state` are used on every login, even though the client is confidential. +- `gofmt -l .` must report nothing; `go vet ./...` and `go test ./...` must pass before any commit. +- `internal/config`'s new env vars follow the existing required-vs-optional-with-default pattern (`config.Load()` fails fast on missing required vars). + +--- + +## File Structure + +``` +backend/ + internal/config/ + config.go [MODIFY] new OIDC/session fields + Load() validation + config_test.go [CREATE] tests for the above + internal/auth/ [CREATE package] + session.go session cookie + transaction cookie mint/parse (JWT-signed) + session_test.go + oidc.go Verifier interface + real Keycloak-backed implementation, role check + oidc_test.go + middleware.go RequireSession chi middleware + ClaimsFromContext + middleware_test.go + mock/ + mock.go fake Verifier for internal/api tests + internal/api/ + session.go [CREATE] handleSessionLogin/Callback/Logout/Me, SessionConfig + server.go [MODIFY] Server.Auth/Session fields, protected route group, CORS credentials + api_test.go [MODIFY] test infra attaches a valid session cookie by default + new session/gating tests + cmd/geniusrund/ + main.go [MODIFY] construct auth.Verifier from config, pass into api.NewServer + start.sh [MODIFY] fail-fast exports for the new required env vars +frontend/ + src/ + types/api.ts [MODIFY] add SessionInfo + api/client.ts [MODIFY] credentials:'include', 401 redirect, getSessionInfo + LoginGate.tsx [CREATE] login/loading/authenticated gate wrapping App + LoginGate.css [CREATE] + App.tsx [MODIFY] accepts session prop, adds logout link + App.css [MODIFY] header-actions/logout-link styles + main.tsx [MODIFY] renders LoginGate instead of App directly +CLAUDE.md [MODIFY] document the new env vars / auth architecture +``` + +--- + +### Task 1: Config — OIDC/session settings + +**Files:** +- Modify: `backend/internal/config/config.go` +- Create: `backend/internal/config/config_test.go` + +**Interfaces:** +- Produces: `Config.OIDCIssuerURL/OIDCClientID/OIDCClientSecret/OIDCRedirectURL/OIDCRequiredRole string`, `Config.PublicBaseURL string`, `Config.SessionSecret []byte`, `Config.SessionDuration time.Duration`, `Config.SessionSecure bool` — consumed by Task 6 (`main.go`) to build `auth.OIDCConfig` and `api.SessionConfig`. + +- [ ] **Step 1: Write the failing tests** + +Create `backend/internal/config/config_test.go`: + +```go +package config + +import ( + "testing" + "time" +) + +func setRequiredEnv(t *testing.T) { + t.Helper() + t.Setenv("MCP_GARMIN_PYTHON", "/usr/bin/python3") + t.Setenv("MCP_GARMIN_SERVER", "/opt/mcp-garmin/server.py") + t.Setenv("GENIUSRUN_PUBLIC_BASE_URL", "https://geniusrun.example.com") + t.Setenv("GENIUSRUN_OIDC_ISSUER_URL", "https://keycloak.example.com/realms/myrealm") + t.Setenv("GENIUSRUN_OIDC_CLIENT_ID", "geniusrun") + t.Setenv("GENIUSRUN_OIDC_CLIENT_SECRET", "client-secret") + t.Setenv("GENIUSRUN_SESSION_SECRET", "a-session-secret-that-is-at-least-32-bytes-long") +} + +func TestLoad_DerivesOIDCSettingsFromPublicBaseURL(t *testing.T) { + setRequiredEnv(t) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.OIDCRedirectURL != "https://geniusrun.example.com/api/session/callback" { + t.Errorf("OIDCRedirectURL = %q", cfg.OIDCRedirectURL) + } + if !cfg.SessionSecure { + t.Error("SessionSecure = false, want true for an https base URL") + } + if cfg.OIDCRequiredRole != "geniusrun-user" { + t.Errorf("OIDCRequiredRole = %q, want default", cfg.OIDCRequiredRole) + } + if cfg.SessionDuration != 720*time.Hour { + t.Errorf("SessionDuration = %v, want default 720h", cfg.SessionDuration) + } +} + +func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) { + setRequiredEnv(t) + t.Setenv("GENIUSRUN_PUBLIC_BASE_URL", "http://localhost:8080") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.SessionSecure { + t.Error("SessionSecure = true, want false for an http base URL") + } + if cfg.OIDCRedirectURL != "http://localhost:8080/api/session/callback" { + t.Errorf("OIDCRedirectURL = %q", cfg.OIDCRedirectURL) + } +} + +func TestLoad_MissingRequiredOIDCVars(t *testing.T) { + cases := []string{ + "GENIUSRUN_PUBLIC_BASE_URL", + "GENIUSRUN_OIDC_ISSUER_URL", + "GENIUSRUN_OIDC_CLIENT_ID", + "GENIUSRUN_OIDC_CLIENT_SECRET", + } + for _, missing := range cases { + t.Run(missing, func(t *testing.T) { + setRequiredEnv(t) + t.Setenv(missing, "") + if _, err := Load(); err == nil { + t.Fatalf("expected error when %s is unset", missing) + } + }) + } +} + +func TestLoad_SessionSecretTooShort(t *testing.T) { + setRequiredEnv(t) + t.Setenv("GENIUSRUN_SESSION_SECRET", "too-short") + + if _, err := Load(); err == nil { + t.Fatal("expected error for a session secret under 32 characters") + } +} + +func TestLoad_CustomRoleAndDuration(t *testing.T) { + setRequiredEnv(t) + t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin") + t.Setenv("GENIUSRUN_SESSION_DURATION", "24h") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.OIDCRequiredRole != "admin" { + t.Errorf("OIDCRequiredRole = %q", cfg.OIDCRequiredRole) + } + if cfg.SessionDuration != 24*time.Hour { + t.Errorf("SessionDuration = %v", cfg.SessionDuration) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && go test ./internal/config/... -v` +Expected: compile errors (`cfg.OIDCRedirectURL` etc. undefined) — that's the expected "fail" for a new-fields change. + +- [ ] **Step 3: Implement the config fields and validation** + +In `backend/internal/config/config.go`, add `"strings"` to the imports, add these fields to `Config` (after `MinConfidence`/`IncrementalSyncEvery`): + +```go + // OIDC login gate (Keycloak). PublicBaseURL is this app's own externally + // reachable origin (e.g. "https://geniusrun.example.com") -- it derives + // OIDCRedirectURL and whether session cookies can be marked Secure, + // instead of requiring both to be configured separately and risking them + // drifting out of sync. + PublicBaseURL string + OIDCIssuerURL string + OIDCClientID string + OIDCClientSecret string + OIDCRedirectURL string + OIDCRequiredRole string + SessionSecret []byte + SessionDuration time.Duration + SessionSecure bool +``` + +Replace the body of `Load()` with: + +```go +func Load() (Config, error) { + cfg := Config{ + Addr: getEnvDefault("GENIUSRUN_ADDR", ":8080"), + DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"), + GarminPythonPath: os.Getenv("MCP_GARMIN_PYTHON"), + GarminServerPath: os.Getenv("MCP_GARMIN_SERVER"), + GarminTokenStore: os.Getenv("GARMIN_TOKENSTORE"), + MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6), + IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour), + PublicBaseURL: strings.TrimRight(os.Getenv("GENIUSRUN_PUBLIC_BASE_URL"), "/"), + OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"), + OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"), + OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"), + OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"), + SessionDuration: getEnvDuration("GENIUSRUN_SESSION_DURATION", 720*time.Hour), + } + + if cfg.GarminPythonPath == "" { + return cfg, fmt.Errorf("MCP_GARMIN_PYTHON is required (path to mcp-garmin's venv python)") + } + if cfg.GarminServerPath == "" { + return cfg, fmt.Errorf("MCP_GARMIN_SERVER is required (path to mcp-garmin's server.py)") + } + if cfg.PublicBaseURL == "" { + return cfg, fmt.Errorf("GENIUSRUN_PUBLIC_BASE_URL is required (e.g. https://geniusrun.example.com)") + } + if cfg.OIDCIssuerURL == "" { + return cfg, fmt.Errorf("GENIUSRUN_OIDC_ISSUER_URL is required") + } + if cfg.OIDCClientID == "" { + return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_ID is required") + } + if cfg.OIDCClientSecret == "" { + return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_SECRET is required") + } + sessionSecret := os.Getenv("GENIUSRUN_SESSION_SECRET") + if len(sessionSecret) < 32 { + return cfg, fmt.Errorf("GENIUSRUN_SESSION_SECRET is required and must be at least 32 characters") + } + cfg.SessionSecret = []byte(sessionSecret) + cfg.OIDCRedirectURL = cfg.PublicBaseURL + "/api/session/callback" + cfg.SessionSecure = strings.HasPrefix(cfg.PublicBaseURL, "https://") + + return cfg, nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && go test ./internal/config/... -v` +Expected: PASS (all `TestLoad_*` cases). + +- [ ] **Step 5: Commit** + +```bash +git add backend/internal/config/config.go backend/internal/config/config_test.go +git commit -m "config: add OIDC/session env vars for the login gate" +``` + +--- + +### Task 2: `internal/auth` — session and transaction cookies + +**Files:** +- Create: `backend/internal/auth/session.go` +- Create: `backend/internal/auth/session_test.go` + +**Interfaces:** +- Produces: `Claims{Sub, Name, Email string}`, `TxnState{State, CodeVerifier string}`, `MintSessionCookie(claims Claims, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error)`, `ParseSessionCookie(cookie *http.Cookie, secret []byte) (Claims, error)`, `MintTxnCookie(txn TxnState, secret []byte, secure bool) (*http.Cookie, error)`, `ParseTxnCookie(cookie *http.Cookie, secret []byte) (TxnState, error)`, `ClearCookie(name string, secure bool) *http.Cookie`, constants `SessionCookieName = "geniusrun_session"`, `TxnCookieName = "geniusrun_oidc_txn"` — consumed by Task 3 (mock package doesn't need these directly), Task 4 (`middleware.go`), and Task 5 (`internal/api/session.go`). + +- [ ] **Step 1: Add the JWT dependency** + +Run: `cd backend && go get github.com/golang-jwt/jwt/v5` + +- [ ] **Step 2: Write the failing tests** + +Create `backend/internal/auth/session_test.go`: + +```go +package auth + +import ( + "testing" + "time" +) + +const testSecret = "test-secret-at-least-32-bytes-long!" + +func TestMintAndParseSessionCookie(t *testing.T) { + claims := Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"} + cookie, err := MintSessionCookie(claims, []byte(testSecret), time.Hour, true) + if err != nil { + t.Fatalf("mint: %v", err) + } + if cookie.Name != SessionCookieName || !cookie.HttpOnly || !cookie.Secure { + t.Fatalf("cookie = %+v, want name=%s HttpOnly+Secure", cookie, SessionCookieName) + } + + got, err := ParseSessionCookie(cookie, []byte(testSecret)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got != claims { + t.Fatalf("got %+v, want %+v", got, claims) + } +} + +func TestParseSessionCookie_Expired(t *testing.T) { + cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), -time.Hour, false) + if err != nil { + t.Fatalf("mint: %v", err) + } + if _, err := ParseSessionCookie(cookie, []byte(testSecret)); err == nil { + t.Fatal("expected error for expired cookie") + } +} + +func TestParseSessionCookie_Tampered(t *testing.T) { + cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false) + if err != nil { + t.Fatalf("mint: %v", err) + } + cookie.Value = cookie.Value[:len(cookie.Value)-1] + "x" + if _, err := ParseSessionCookie(cookie, []byte(testSecret)); err == nil { + t.Fatal("expected error for tampered cookie") + } +} + +func TestParseSessionCookie_WrongSecret(t *testing.T) { + cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false) + if err != nil { + t.Fatalf("mint: %v", err) + } + if _, err := ParseSessionCookie(cookie, []byte("a-completely-different-secret!!")); err == nil { + t.Fatal("expected error for wrong secret") + } +} + +func TestMintAndParseTxnCookie(t *testing.T) { + txn := TxnState{State: "abc123", CodeVerifier: "verifier-xyz"} + cookie, err := MintTxnCookie(txn, []byte(testSecret), false) + if err != nil { + t.Fatalf("mint: %v", err) + } + if cookie.Name != TxnCookieName { + t.Fatalf("cookie name = %q, want %q", cookie.Name, TxnCookieName) + } + + got, err := ParseTxnCookie(cookie, []byte(testSecret)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got != txn { + t.Fatalf("got %+v, want %+v", got, txn) + } +} + +func TestClearCookie(t *testing.T) { + c := ClearCookie(SessionCookieName, true) + if c.Value != "" || c.MaxAge >= 0 { + t.Fatalf("ClearCookie = %+v, want empty value and negative MaxAge", c) + } +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `cd backend && go test ./internal/auth/... -v` +Expected: FAIL to compile — package `auth` and its functions don't exist yet. + +- [ ] **Step 4: Implement `session.go`** + +Create `backend/internal/auth/session.go`: + +```go +// Package auth implements geniusrun's own login gate: an OIDC Authorization +// Code flow against an existing Keycloak realm (see oidc.go), backed by a +// signed session cookie geniusrun mints itself (this file) and a chi +// middleware that checks it (middleware.go). Keycloak's own tokens are never +// stored or refreshed -- once HandleCallback verifies the ID token and role, +// only this package's own cookie matters for subsequent requests. +package auth + +import ( + "fmt" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + // SessionCookieName holds the signed proof that a request's caller + // completed login and passed the required-role check. + SessionCookieName = "geniusrun_session" + // TxnCookieName holds the short-lived OIDC login transaction (state + + // PKCE verifier) between BeginLogin's redirect and HandleCallback. + TxnCookieName = "geniusrun_oidc_txn" + + txnCookieDuration = 10 * time.Minute +) + +// Claims identifies the authenticated user, carried in the signed session +// cookie. +type Claims struct { + Sub string + Name string + Email string +} + +type sessionClaims struct { + Sub string `json:"sub"` + Name string `json:"name"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +// TxnState is the OIDC login transaction round-tripped via TxnCookieName +// between BeginLogin and HandleCallback. +type TxnState struct { + State string + CodeVerifier string +} + +type txnClaims struct { + State string `json:"state"` + CodeVerifier string `json:"code_verifier"` + jwt.RegisteredClaims +} + +// MintSessionCookie signs claims into a JWT valid for duration and wraps it +// in a cookie. secure should be true whenever the app is served over HTTPS. +func MintSessionCookie(claims Claims, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) { + now := time.Now() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{ + Sub: claims.Sub, + Name: claims.Name, + Email: claims.Email, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(duration)), + }, + }) + signed, err := token.SignedString(secret) + if err != nil { + return nil, fmt.Errorf("sign session token: %w", err) + } + return &http.Cookie{ + Name: SessionCookieName, + Value: signed, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + Expires: now.Add(duration), + }, nil +} + +// ParseSessionCookie verifies the cookie's signature and expiry and decodes +// its Claims. +func ParseSessionCookie(cookie *http.Cookie, secret []byte) (Claims, error) { + if cookie == nil { + return Claims{}, fmt.Errorf("no session cookie") + } + var sc sessionClaims + if _, err := jwt.ParseWithClaims(cookie.Value, &sc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil { + return Claims{}, fmt.Errorf("parse session token: %w", err) + } + return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email}, nil +} + +// MintTxnCookie signs an OIDC login transaction into a short-lived cookie. +func MintTxnCookie(txn TxnState, secret []byte, secure bool) (*http.Cookie, error) { + now := time.Now() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, txnClaims{ + State: txn.State, + CodeVerifier: txn.CodeVerifier, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(txnCookieDuration)), + }, + }) + signed, err := token.SignedString(secret) + if err != nil { + return nil, fmt.Errorf("sign txn token: %w", err) + } + return &http.Cookie{ + Name: TxnCookieName, + Value: signed, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + Expires: now.Add(txnCookieDuration), + }, nil +} + +// ParseTxnCookie verifies and decodes a login transaction cookie. +func ParseTxnCookie(cookie *http.Cookie, secret []byte) (TxnState, error) { + if cookie == nil { + return TxnState{}, fmt.Errorf("no txn cookie") + } + var tc txnClaims + if _, err := jwt.ParseWithClaims(cookie.Value, &tc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil { + return TxnState{}, fmt.Errorf("parse txn token: %w", err) + } + return TxnState{State: tc.State, CodeVerifier: tc.CodeVerifier}, nil +} + +// ClearCookie returns a cookie that immediately expires the named cookie. +func ClearCookie(name string, secure bool) *http.Cookie { + return &http.Cookie{ + Name: name, + Value: "", + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + } +} + +func keyfunc(secret []byte) jwt.Keyfunc { + return func(*jwt.Token) (any, error) { return secret, nil } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd backend && go test ./internal/auth/... -v` +Expected: PASS (all `Test*` in `session_test.go`). + +- [ ] **Step 6: Commit** + +```bash +git add backend/go.mod backend/go.sum backend/internal/auth/session.go backend/internal/auth/session_test.go +git commit -m "auth: add signed session/transaction cookie mint and parse" +``` + +--- + +### Task 3: `internal/auth` — OIDC verifier and role check + +**Files:** +- Create: `backend/internal/auth/oidc.go` +- Create: `backend/internal/auth/oidc_test.go` +- Create: `backend/internal/auth/mock/mock.go` + +**Interfaces:** +- Consumes: `Claims`, `TxnState` from Task 2 (`session.go`, same package). +- Produces: `Verifier` interface (`BeginLogin() (authURL string, txn TxnState, err error)`, `HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error)`, `EndSessionURL(postLogoutRedirectURL string) string`), `LoginResult{Claims Claims; Authorized bool}`, `OIDCConfig{IssuerURL, ClientID, ClientSecret, RedirectURL, RequiredRole string}`, `NewOIDCVerifier(ctx context.Context, cfg OIDCConfig) (Verifier, error)` — consumed by Task 6 (`main.go`). `mock.Verifier` (implements `auth.Verifier`) — consumed by Task 5 (`internal/api` tests). + +- [ ] **Step 1: Add the OIDC/OAuth2 dependencies** + +Run: `cd backend && go get github.com/coreos/go-oidc/v3 golang.org/x/oauth2` + +- [ ] **Step 2: Write the failing test** + +Create `backend/internal/auth/oidc_test.go` (only the pure role-check logic is unit-testable without a real Keycloak — the exchange/verify path is a manual smoke test per the design doc's testing plan): + +```go +package auth + +import ( + "encoding/json" + "testing" +) + +func TestIDTokenClaims_HasRole(t *testing.T) { + cases := []struct { + name string + json string + role string + want bool + }{ + {"role present among others", `{"realm_access":{"roles":["geniusrun-user","other"]}}`, "geniusrun-user", true}, + {"role absent", `{"realm_access":{"roles":["other"]}}`, "geniusrun-user", false}, + {"realm_access missing entirely", `{}`, "geniusrun-user", false}, + {"roles array empty", `{"realm_access":{"roles":[]}}`, "geniusrun-user", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var c idTokenClaims + if err := json.Unmarshal([]byte(tc.json), &c); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got := c.hasRole(tc.role); got != tc.want { + t.Errorf("hasRole(%q) = %v, want %v", tc.role, got, tc.want) + } + }) + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd backend && go test ./internal/auth/... -run TestIDTokenClaims_HasRole -v` +Expected: FAIL to compile — `idTokenClaims` doesn't exist yet. + +- [ ] **Step 4: Implement `oidc.go`** + +Create `backend/internal/auth/oidc.go`: + +```go +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "net/url" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// Verifier is the seam between internal/api and this package's real +// Keycloak-backed OIDC client, so tests can inject internal/auth/mock's fake +// instead of talking to a real identity provider. +type Verifier interface { + // BeginLogin builds a Keycloak authorization URL and the transaction + // state that must be round-tripped (via a TxnCookie) to HandleCallback. + BeginLogin() (authURL string, txn TxnState, err error) + // HandleCallback validates the callback query against txn, exchanges the + // code, verifies the ID token, and reports whether the required role was + // present. + HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error) + // EndSessionURL builds the identity provider's logout URL, redirecting + // back to postLogoutRedirectURL once Keycloak's own session is cleared. + EndSessionURL(postLogoutRedirectURL string) string +} + +// LoginResult is what a completed callback exchange resolves to. +type LoginResult struct { + Claims Claims + Authorized bool +} + +// OIDCConfig configures NewOIDCVerifier. RequiredRole is checked against the +// ID token's realm_access.roles. +type OIDCConfig struct { + IssuerURL string + ClientID string + ClientSecret string + RedirectURL string + RequiredRole string +} + +// idTokenClaims mirrors the subset of a Keycloak ID token's claims geniusrun +// cares about. +type idTokenClaims struct { + Sub string `json:"sub"` + Name string `json:"name"` + Email string `json:"email"` + RealmAccess struct { + Roles []string `json:"roles"` + } `json:"realm_access"` +} + +func (c idTokenClaims) hasRole(required string) bool { + for _, r := range c.RealmAccess.Roles { + if r == required { + return true + } + } + return false +} + +type oidcVerifier struct { + provider *oidc.Provider + idTokenVerif *oidc.IDTokenVerifier + oauth2Config oauth2.Config + requiredRole string +} + +// NewOIDCVerifier performs OIDC discovery against cfg.IssuerURL (once, at +// startup -- go-oidc caches the discovery document internally) and returns a +// Verifier backed by the real Keycloak realm. +func NewOIDCVerifier(ctx context.Context, cfg OIDCConfig) (Verifier, error) { + provider, err := oidc.NewProvider(ctx, cfg.IssuerURL) + if err != nil { + return nil, fmt.Errorf("oidc discovery against %s: %w", cfg.IssuerURL, err) + } + return &oidcVerifier{ + provider: provider, + idTokenVerif: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}), + oauth2Config: oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + RedirectURL: cfg.RedirectURL, + Endpoint: provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + }, + requiredRole: cfg.RequiredRole, + }, nil +} + +func randomString(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate random state: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func (v *oidcVerifier) BeginLogin() (string, TxnState, error) { + state, err := randomString(24) + if err != nil { + return "", TxnState{}, err + } + verifier := oauth2.GenerateVerifier() + authURL := v.oauth2Config.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)) + return authURL, TxnState{State: state, CodeVerifier: verifier}, nil +} + +func (v *oidcVerifier) HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error) { + if got := query.Get("state"); got == "" || got != txn.State { + return LoginResult{}, fmt.Errorf("state mismatch") + } + code := query.Get("code") + if code == "" { + return LoginResult{}, fmt.Errorf("callback missing code") + } + token, err := v.oauth2Config.Exchange(ctx, code, oauth2.VerifierOption(txn.CodeVerifier)) + if err != nil { + return LoginResult{}, fmt.Errorf("exchange code: %w", err) + } + rawIDToken, ok := token.Extra("id_token").(string) + if !ok || rawIDToken == "" { + return LoginResult{}, fmt.Errorf("token response missing id_token") + } + idToken, err := v.idTokenVerif.Verify(ctx, rawIDToken) + if err != nil { + return LoginResult{}, fmt.Errorf("verify id_token: %w", err) + } + var claims idTokenClaims + if err := idToken.Claims(&claims); err != nil { + return LoginResult{}, fmt.Errorf("decode id_token claims: %w", err) + } + return LoginResult{ + Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email}, + Authorized: claims.hasRole(v.requiredRole), + }, nil +} + +func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string { + var discovery struct { + EndSessionEndpoint string `json:"end_session_endpoint"` + } + if err := v.provider.Claims(&discovery); err != nil || discovery.EndSessionEndpoint == "" { + return postLogoutRedirectURL + } + u, err := url.Parse(discovery.EndSessionEndpoint) + if err != nil { + return postLogoutRedirectURL + } + q := u.Query() + q.Set("client_id", v.oauth2Config.ClientID) + q.Set("post_logout_redirect_uri", postLogoutRedirectURL) + u.RawQuery = q.Encode() + return u.String() +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd backend && go test ./internal/auth/... -run TestIDTokenClaims_HasRole -v` +Expected: PASS. + +- [ ] **Step 6: Add the fake Verifier for `internal/api` tests** + +Create `backend/internal/auth/mock/mock.go`: + +```go +// Package mock provides a fake auth.Verifier for tests that need to exercise +// internal/api's session endpoints and RequireSession gating without a real +// Keycloak instance. +package mock + +import ( + "context" + "net/url" + + "geniusrun/backend/internal/auth" +) + +// Verifier is a fake auth.Verifier returning canned results supplied by the +// test/caller. +type Verifier struct { + AuthURL string + Txn auth.TxnState + CallbackResult auth.LoginResult + CallbackErr error + EndSessionResult string // if empty, EndSessionURL returns postLogoutRedirectURL unchanged +} + +var _ auth.Verifier = (*Verifier)(nil) + +func (v *Verifier) BeginLogin() (string, auth.TxnState, error) { + return v.AuthURL, v.Txn, nil +} + +func (v *Verifier) HandleCallback(ctx context.Context, txn auth.TxnState, query url.Values) (auth.LoginResult, error) { + if v.CallbackErr != nil { + return auth.LoginResult{}, v.CallbackErr + } + return v.CallbackResult, nil +} + +func (v *Verifier) EndSessionURL(postLogoutRedirectURL string) string { + if v.EndSessionResult != "" { + return v.EndSessionResult + } + return postLogoutRedirectURL +} +``` + +There's no test file for `mock.go` itself — it's test infrastructure with no independent behavior to verify beyond "it implements the interface," which the `var _ auth.Verifier = (*Verifier)(nil)` line already asserts at compile time. + +- [ ] **Step 7: Verify everything still builds and passes** + +Run: `cd backend && go build ./... && go test ./internal/auth/... -v` +Expected: builds clean, `TestIDTokenClaims_HasRole` PASS. + +- [ ] **Step 8: Commit** + +```bash +git add backend/go.mod backend/go.sum backend/internal/auth/oidc.go backend/internal/auth/oidc_test.go backend/internal/auth/mock/mock.go +git commit -m "auth: add Keycloak OIDC verifier, role check, and test mock" +``` + +--- + +### Task 4: `internal/auth` — `RequireSession` middleware + +**Files:** +- Create: `backend/internal/auth/middleware.go` +- Create: `backend/internal/auth/middleware_test.go` + +**Interfaces:** +- Consumes: `MintSessionCookie`/`ParseSessionCookie`/`Claims`/`SessionCookieName` from Task 2. +- Produces: `RequireSession(secret []byte) func(http.Handler) http.Handler`, `ClaimsFromContext(ctx context.Context) (Claims, bool)` — consumed by Task 5 (`internal/api/server.go`). + +- [ ] **Step 1: Write the failing tests** + +Create `backend/internal/auth/middleware_test.go`: + +```go +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func protectedTestHandler() http.Handler { + return RequireSession([]byte(testSecret))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := ClaimsFromContext(r.Context()) + if !ok { + http.Error(w, "no claims in context", http.StatusInternalServerError) + return + } + w.Write([]byte(claims.Name)) + })) +} + +func TestRequireSession_NoCookie(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + protectedTestHandler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestRequireSession_ValidCookie(t *testing.T) { + cookie, err := MintSessionCookie(Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, []byte(testSecret), time.Hour, false) + if err != nil { + t.Fatalf("mint: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookie) + rec := httptest.NewRecorder() + protectedTestHandler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String()) + } + if rec.Body.String() != "Alice" { + t.Fatalf("body = %q, want Alice", rec.Body.String()) + } +} + +func TestRequireSession_ExpiredCookie(t *testing.T) { + cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), -time.Hour, false) + if err != nil { + t.Fatalf("mint: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookie) + rec := httptest.NewRecorder() + protectedTestHandler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestRequireSession_TamperedCookie(t *testing.T) { + cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false) + if err != nil { + t.Fatalf("mint: %v", err) + } + cookie.Value = cookie.Value[:len(cookie.Value)-1] + "x" + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookie) + rec := httptest.NewRecorder() + protectedTestHandler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && go test ./internal/auth/... -run TestRequireSession -v` +Expected: FAIL to compile — `RequireSession`/`ClaimsFromContext` don't exist yet. + +- [ ] **Step 3: Implement `middleware.go`** + +Create `backend/internal/auth/middleware.go`: + +```go +package auth + +import ( + "context" + "net/http" +) + +type contextKey int + +const claimsContextKey contextKey = iota + +// RequireSession returns middleware that rejects a request with 401 unless +// it carries a valid SessionCookieName cookie signed with secret, and +// otherwise makes the session's Claims available via ClaimsFromContext. +func RequireSession(secret []byte) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(SessionCookieName) + if err != nil { + http.Error(w, "not authenticated", http.StatusUnauthorized) + return + } + claims, err := ParseSessionCookie(cookie, secret) + if err != nil { + http.Error(w, "not authenticated", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsContextKey, claims))) + }) + } +} + +// ClaimsFromContext returns the authenticated user's Claims, as populated by +// RequireSession. +func ClaimsFromContext(ctx context.Context) (Claims, bool) { + c, ok := ctx.Value(claimsContextKey).(Claims) + return c, ok +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && go test ./internal/auth/... -v` +Expected: PASS (every test in the package, Tasks 2-4 combined). + +- [ ] **Step 5: Commit** + +```bash +git add backend/internal/auth/middleware.go backend/internal/auth/middleware_test.go +git commit -m "auth: add RequireSession chi-compatible middleware" +``` + +--- + +### Task 5: `internal/api` — session endpoints, protected router, test infra + +**Files:** +- Create: `backend/internal/api/session.go` +- Modify: `backend/internal/api/server.go` +- Modify: `backend/internal/api/api_test.go` + +**Interfaces:** +- Consumes: `auth.Verifier`, `auth.LoginResult`, `auth.RequireSession`, `auth.ClaimsFromContext`, `auth.MintSessionCookie`, `auth.MintTxnCookie`, `auth.ParseTxnCookie`, `auth.ClearCookie`, `auth.TxnCookieName` (Tasks 2-4); `mock.Verifier` (Task 3) for tests. +- Produces: `SessionConfig{Secret []byte; Duration time.Duration; Secure bool}`, `NewServer(db *store.DB, g garmin.Client, s *appsync.Service, authVerifier auth.Verifier, session SessionConfig) *Server` (signature change) — consumed by Task 6 (`main.go`). + +- [ ] **Step 1: Update `server.go`'s `Server`/`NewServer` and add the protected route group** + +In `backend/internal/api/server.go`, add `"geniusrun/backend/internal/auth"` to the imports (nothing in this file uses `time` directly — `SessionConfig`'s `time.Duration` field is declared in `session.go`, same package, so no `time` import is needed here), then replace the `Server` struct and `NewServer`: + +```go +// Server wires the HTTP handlers to the app's dependencies. +type Server struct { + DB *store.DB + Garmin garmin.Client + Sync *appsync.Service + Auth auth.Verifier + Session SessionConfig + + mu sync.Mutex + authStatus garmin.AuthStatus + authMessage string + syncRunning bool +} + +// NewServer builds a Server. +func NewServer(db *store.DB, g garmin.Client, s *appsync.Service, authVerifier auth.Verifier, session SessionConfig) *Server { + return &Server{DB: db, Garmin: g, Sync: s, Auth: authVerifier, Session: session} +} +``` + +Replace `Router()` with (everything that used to be registered directly under `/api` except `/health` and the new unprotected session routes now lives inside the `r.Group` wrapped in `RequireSession`): + +```go +// Router builds the HTTP routes. +func (s *Server) Router() http.Handler { + r := chi.NewRouter() + r.Use(corsMiddleware) + r.Route("/api", func(r chi.Router) { + r.Get("/health", s.handleHealth) + + // Unprotected: these two ARE the login flow, so they can't require + // a session yet. + r.Get("/session/login", s.handleSessionLogin) + r.Get("/session/callback", s.handleSessionCallback) + + r.Group(func(r chi.Router) { + r.Use(auth.RequireSession(s.Session.Secret)) + + r.Get("/session/me", s.handleSessionMe) + r.Post("/session/logout", s.handleSessionLogout) + + r.Route("/profile", func(r chi.Router) { + r.Get("/", s.handleGetProfile) + r.Put("/", s.handleUpdateProfile) + }) + + r.Route("/auth", func(r chi.Router) { + r.Post("/login", s.handleAuthLogin) + r.Post("/mfa", s.handleAuthMFA) + r.Get("/status", s.handleAuthStatus) + }) + + r.Route("/sync", func(r chi.Router) { + r.Post("/run", s.handleSyncRun) + r.Post("/reset", s.handleSyncReset) + r.Get("/runs", s.handleSyncRuns) + r.Get("/status", s.handleSyncStatus) + }) + + r.Route("/activities", func(r chi.Router) { + r.Get("/", s.handleListActivities) + r.Get("/{id}", s.handleGetActivity) + }) + + r.Route("/workout-kinds", func(r chi.Router) { + r.Get("/", s.handleListWorkoutKinds) + r.Get("/{id}", s.handleGetWorkoutKind) + r.Put("/{id}", s.handleUpdateWorkoutKind) + }) + + r.Post("/reclassify", s.handleReclassifyAll) + + r.Route("/review-queue", func(r chi.Router) { + r.Get("/", s.handleReviewQueue) + r.Post("/{activityID}/resolve", s.handleResolveReview) + r.Post("/{activityID}/unlock", s.handleUnlockReview) + r.Post("/{activityID}/unassign", s.handleUnassignReview) + }) + + r.Get("/progression/{kindID}", s.handleProgression) + }) + }) + return r +} +``` + +Update `corsMiddleware` (now that credentialed cookies are in play, the browser needs `Access-Control-Allow-Credentials`) and its stale comment: + +```go +// corsMiddleware allows the frontend dev server (a different port) to call +// this API. Reflecting any origin back is safe even with credentials +// enabled: this remains a single-operator app whose real access control is +// the OIDC login gate (internal/auth), not origin-based CSRF defense. +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if origin := r.Header.Get("Origin"); origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Credentials", "true") + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} +``` + +- [ ] **Step 2: Implement `internal/api/session.go`** + +Create `backend/internal/api/session.go`: + +```go +package api + +import ( + "net/http" + "time" + + "geniusrun/backend/internal/auth" +) + +// SessionConfig configures how the app-login session cookie is minted and +// validated. Secure should mirror config.Config.SessionSecure (true once +// the app is served over HTTPS). +type SessionConfig struct { + Secret []byte + Duration time.Duration + Secure bool +} + +type sessionMeResponse struct { + Name string `json:"name"` + Email string `json:"email"` +} + +func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) { + authURL, txn, err := s.Auth.BeginLogin() + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + cookie, err := auth.MintTxnCookie(txn, s.Session.Secret, s.Session.Secure) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + http.SetCookie(w, cookie) + http.Redirect(w, r, authURL, http.StatusFound) +} + +func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) { + txnCookie, err := r.Cookie(auth.TxnCookieName) + if err != nil { + http.Redirect(w, r, "/?auth_error=failed", http.StatusFound) + return + } + http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.Session.Secure)) + + txn, err := auth.ParseTxnCookie(txnCookie, s.Session.Secret) + if err != nil { + http.Redirect(w, r, "/?auth_error=failed", http.StatusFound) + return + } + + result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query()) + if err != nil { + http.Redirect(w, r, "/?auth_error=failed", http.StatusFound) + return + } + if !result.Authorized { + http.Redirect(w, r, "/?auth_error=forbidden", http.StatusFound) + return + } + + sessionCookie, err := auth.MintSessionCookie(result.Claims, s.Session.Secret, s.Session.Duration, s.Session.Secure) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + http.SetCookie(w, sessionCookie) + http.Redirect(w, r, "/", http.StatusFound) +} + +func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure)) + http.Redirect(w, r, s.Auth.EndSessionURL("/"), http.StatusFound) +} + +func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + // Unreachable in practice -- RequireSession already 401s before this + // handler runs -- but fail closed rather than panic if that ever changes. + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + writeJSON(w, http.StatusOK, sessionMeResponse{Name: claims.Name, Email: claims.Email}) +} +``` + +- [ ] **Step 3: Update test infra so the existing 36 `doJSON` call sites keep passing** + +Every non-session route now requires a valid session cookie. Rather than touching all 36 existing call sites, make `doJSON` attach one automatically, and give every test server a fixed, shared `SessionConfig` to sign against. In `backend/internal/api/api_test.go`, add imports `"geniusrun/backend/internal/auth"` and `authmock "geniusrun/backend/internal/auth/mock"` (aliased since the existing `mock` import is `geniusrun/backend/internal/garmin/mock`), then add near the top (after `newCtx`): + +```go +var testSessionConfig = SessionConfig{ + Secret: []byte("test-session-secret-at-least-32-bytes-long"), + Duration: time.Hour, + Secure: false, +} +``` + +Update `newTestServer` to pass the new `NewServer` arguments: + +```go +func newTestServer(t *testing.T) (*Server, *store.DB) { + t.Helper() + db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { db.Close() }) + + m := &mock.Client{} + svc := appsync.NewService(m, db, appsync.Config{}, func() time.Time { return time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC) }) + return NewServer(db, m, svc, &authmock.Verifier{}, testSessionConfig), db +} +``` + +Update `doJSON` to attach a valid session cookie to every request (the 36 existing tests are about business-logic endpoints assuming an already-logged-in user; auth gating itself gets its own dedicated tests below): + +```go +func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + var reader *bytes.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request body: %v", err) + } + reader = bytes.NewReader(b) + } else { + reader = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, reader) + req.Header.Set("Content-Type", "application/json") + cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure) + if err != nil { + t.Fatalf("mint test session cookie: %v", err) + } + req.AddCookie(cookie) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec +} +``` + +- [ ] **Step 4: Run the full existing suite to confirm nothing broke** + +Run: `cd backend && go test ./internal/api/... -v` +Expected: every pre-existing test (`TestHealth`, `TestWorkoutKindList_*`, etc.) still PASSes, now implicitly exercising the authenticated path. + +- [ ] **Step 5: Write the new session/gating tests** + +Append to `backend/internal/api/api_test.go`: + +```go +func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) { + t.Helper() + s, db := newTestServer(t) + s.Auth = verifier + return s, db +} + +func TestHealth_NoSessionRequired(t *testing.T) { + s, _ := newTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/api/health", nil) + rec := httptest.NewRecorder() + s.Router().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } +} + +func TestProtectedRoute_RejectsMissingSession(t *testing.T) { + s, _ := newTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/api/profile/", nil) + rec := httptest.NewRecorder() + s.Router().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestSessionLogin_RedirectsToAuthURLAndSetsTxnCookie(t *testing.T) { + s, _ := newTestServerWithAuth(t, &authmock.Verifier{ + AuthURL: "https://keycloak.example.com/auth?client_id=geniusrun", + Txn: auth.TxnState{State: "s1", CodeVerifier: "v1"}, + }) + req := httptest.NewRequest(http.MethodGet, "/api/session/login", nil) + rec := httptest.NewRecorder() + s.Router().ServeHTTP(rec, req) + + if rec.Code != http.StatusFound { + t.Fatalf("status = %d, want 302", rec.Code) + } + if got := rec.Header().Get("Location"); got != "https://keycloak.example.com/auth?client_id=geniusrun" { + t.Fatalf("Location = %q", got) + } + if rec.Result().Cookies()[0].Name != auth.TxnCookieName { + t.Fatalf("expected a %s cookie to be set", auth.TxnCookieName) + } +} + +func TestSessionCallback_AuthorizedSetsSessionCookieAndRedirectsHome(t *testing.T) { + verifier := &authmock.Verifier{ + CallbackResult: auth.LoginResult{ + Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, + Authorized: true, + }, + } + s, _ := newTestServerWithAuth(t, verifier) + + txnCookie, err := auth.MintTxnCookie(auth.TxnState{State: "s1", CodeVerifier: "v1"}, testSessionConfig.Secret, false) + if err != nil { + t.Fatalf("mint txn cookie: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil) + req.AddCookie(txnCookie) + rec := httptest.NewRecorder() + s.Router().ServeHTTP(rec, req) + + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" { + t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location")) + } + var sessionCookie *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == auth.SessionCookieName { + sessionCookie = c + } + } + if sessionCookie == nil { + t.Fatal("expected a session cookie to be set") + } + claims, err := auth.ParseSessionCookie(sessionCookie, testSessionConfig.Secret) + if err != nil { + t.Fatalf("parse session cookie: %v", err) + } + if claims.Name != "Alice" { + t.Fatalf("claims.Name = %q, want Alice", claims.Name) + } +} + +func TestSessionCallback_UnauthorizedRedirectsWithoutSessionCookie(t *testing.T) { + verifier := &authmock.Verifier{ + CallbackResult: auth.LoginResult{Claims: auth.Claims{Sub: "u1"}, Authorized: false}, + } + s, _ := newTestServerWithAuth(t, verifier) + + txnCookie, err := auth.MintTxnCookie(auth.TxnState{State: "s1", CodeVerifier: "v1"}, testSessionConfig.Secret, false) + if err != nil { + t.Fatalf("mint txn cookie: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil) + req.AddCookie(txnCookie) + rec := httptest.NewRecorder() + s.Router().ServeHTTP(rec, req) + + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/?auth_error=forbidden" { + t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location")) + } + for _, c := range rec.Result().Cookies() { + if c.Name == auth.SessionCookieName { + t.Fatal("session cookie must not be set when Authorized is false") + } + } +} + +func TestSessionCallback_MissingTxnCookieRedirectsFailed(t *testing.T) { + s, _ := newTestServerWithAuth(t, &authmock.Verifier{}) + req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil) + rec := httptest.NewRecorder() + s.Router().ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/?auth_error=failed" { + t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location")) + } +} + +func TestSessionMe_ReturnsAuthenticatedUser(t *testing.T) { + rec := doJSON(t, mustServerRouter(t), http.MethodGet, "/api/session/me", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var got sessionMeResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Name != "Test User" || got.Email != "test@example.com" { + t.Fatalf("got %+v, want the doJSON test-cookie identity", got) + } +} + +func mustServerRouter(t *testing.T) http.Handler { + t.Helper() + s, _ := newTestServer(t) + return s.Router() +} + +func TestSessionLogout_ClearsSessionCookieAndRedirectsToEndSession(t *testing.T) { + verifier := &authmock.Verifier{EndSessionResult: "https://keycloak.example.com/logout?post_logout_redirect_uri=%2F"} + s, _ := newTestServerWithAuth(t, verifier) + rec := doJSON(t, s.Router(), http.MethodPost, "/api/session/logout", nil) + + if rec.Code != http.StatusFound || rec.Header().Get("Location") != verifier.EndSessionResult { + t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location")) + } + var cleared *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == auth.SessionCookieName { + cleared = c + } + } + if cleared == nil || cleared.MaxAge >= 0 { + t.Fatalf("expected session cookie to be cleared, got %+v", cleared) + } +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd backend && go test ./internal/api/... -v` +Expected: PASS — every pre-existing test plus all new `TestSession*`/`TestProtectedRoute*`/`TestHealth_NoSessionRequired` tests. + +- [ ] **Step 7: Run `go vet` and `gofmt`** + +Run: `cd backend && gofmt -l . && go vet ./...` +Expected: `gofmt -l .` prints nothing; `go vet` reports no issues. + +- [ ] **Step 8: Commit** + +```bash +git add backend/internal/api/session.go backend/internal/api/server.go backend/internal/api/api_test.go +git commit -m "api: gate all routes behind an OIDC session, add session endpoints" +``` + +--- + +### Task 6: Wire the real OIDC verifier into `cmd/geniusrund` + +**Files:** +- Modify: `backend/cmd/geniusrund/main.go` +- Modify: `backend/start.sh` + +**Interfaces:** +- Consumes: `config.Config` fields from Task 1, `auth.NewOIDCVerifier`/`auth.OIDCConfig` from Task 3, `api.NewServer`/`api.SessionConfig` from Task 5. + +- [ ] **Step 1: Update `main.go`** + +In `backend/cmd/geniusrund/main.go`, add `"geniusrun/backend/internal/auth"` to the imports, and replace the `server := api.NewServer(db, garminClient, syncSvc)` line with: + +```go + authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{ + IssuerURL: cfg.OIDCIssuerURL, + ClientID: cfg.OIDCClientID, + ClientSecret: cfg.OIDCClientSecret, + RedirectURL: cfg.OIDCRedirectURL, + RequiredRole: cfg.OIDCRequiredRole, + }) + if err != nil { + log.Fatalf("oidc: %v", err) + } + + server := api.NewServer(db, garminClient, syncSvc, authVerifier, api.SessionConfig{ + Secret: cfg.SessionSecret, + Duration: cfg.SessionDuration, + Secure: cfg.SessionSecure, + }) +``` + +- [ ] **Step 2: Verify it builds** + +Run: `cd backend && go build ./...` +Expected: builds with no errors. + +- [ ] **Step 3: Update `start.sh` to fail fast on the new required vars** + +`main.go` has no test suite (matching the rest of `cmd/geniusrund`), so verification here is a build + a manual run. In `backend/start.sh`, add after the existing `GENIUSRUN_DB_PATH` export line: + +```bash +# OIDC login gate -- no safe default exists for a client secret or a +# session-signing key, so these must come from your own shell/CI secrets. +export GENIUSRUN_PUBLIC_BASE_URL="${GENIUSRUN_PUBLIC_BASE_URL:?set GENIUSRUN_PUBLIC_BASE_URL, e.g. https://geniusrun.example.com}" +export GENIUSRUN_OIDC_ISSUER_URL="${GENIUSRUN_OIDC_ISSUER_URL:?set GENIUSRUN_OIDC_ISSUER_URL, e.g. https://keycloak.example.com/realms/myrealm}" +export GENIUSRUN_OIDC_CLIENT_ID="${GENIUSRUN_OIDC_CLIENT_ID:?set GENIUSRUN_OIDC_CLIENT_ID}" +export GENIUSRUN_OIDC_CLIENT_SECRET="${GENIUSRUN_OIDC_CLIENT_SECRET:?set GENIUSRUN_OIDC_CLIENT_SECRET}" +export GENIUSRUN_SESSION_SECRET="${GENIUSRUN_SESSION_SECRET:?set GENIUSRUN_SESSION_SECRET to a random string >=32 characters}" +``` + +- [ ] **Step 4: Manually verify the fail-fast behavior** + +Run: `cd backend && (unset GENIUSRUN_PUBLIC_BASE_URL; ./start.sh)` +Expected: the shell itself errors immediately with the `GENIUSRUN_PUBLIC_BASE_URL` message (never reaches `go run`), proving the required var is enforced before the Go process even starts. + +- [ ] **Step 5: Commit** + +```bash +git add backend/cmd/geniusrund/main.go backend/start.sh +git commit -m "geniusrund: construct the OIDC verifier and require its config at startup" +``` + +--- + +### Task 7: Frontend API client — session endpoints and credentialed fetch + +**Files:** +- Modify: `frontend/src/types/api.ts` +- Modify: `frontend/src/api/client.ts` + +**Interfaces:** +- Produces: `SessionInfo{name: string; email: string}`, `api.getSessionInfo(): Promise` — consumed by Task 8 (`LoginGate.tsx`). + +- [ ] **Step 1: Add the `SessionInfo` type** + +In `frontend/src/types/api.ts`, add near the top (order doesn't matter, but grouping it with other simple response types keeps it discoverable): + +```ts +export interface SessionInfo { + name: string; + email: string; +} +``` + +- [ ] **Step 2: Update `client.ts`** + +In `frontend/src/api/client.ts`, add `SessionInfo` to the type import list, then replace `request` with: + +```ts +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE_URL}${path}`, { + headers: { "Content-Type": "application/json" }, + credentials: "include", + ...init, + }); + if (res.status === 401 && path !== "/api/session/me") { + // An established session expired mid-use (not the initial "am I logged + // in" check, which LoginGate itself interprets) -- reload so LoginGate + // re-checks and shows the login screen instead of leaving the SPA in a + // half-authenticated state. + window.location.href = "/"; + throw new Error(`${init?.method ?? "GET"} ${path} failed: 401 (session expired)`); + } + if (!res.ok) { + const body = await res.text(); + throw new Error(`${init?.method ?? "GET"} ${path} failed: ${res.status} ${body}`); + } + if (res.status === 204) return undefined as T; + return res.json() as Promise; +} +``` + +Add to the `api` object (right after the opening brace, before the `// Auth` Garmin section, since this is app-level session, distinct from Garmin's own login): + +```ts + // Session (app login via OIDC -- distinct from the Garmin credential + // login below). No logout()/login() methods: those are plain + // full-page navigations (see LoginGate.tsx), not fetches, since the OIDC + // flow and Keycloak's own logout redirect need real browser navigation. + getSessionInfo: () => request("/api/session/me"), + +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cd frontend && npm run build` +Expected: `tsc -b && vite build` succeeds with no type errors. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/types/api.ts frontend/src/api/client.ts +git commit -m "frontend: add session API, credentialed fetch, 401-triggered relogin" +``` + +--- + +### Task 8: Frontend — `LoginGate` and wiring into `App` + +**Files:** +- Create: `frontend/src/LoginGate.tsx` +- Create: `frontend/src/LoginGate.css` +- Modify: `frontend/src/App.tsx` +- Modify: `frontend/src/App.css` +- Modify: `frontend/src/main.tsx` + +**Interfaces:** +- Consumes: `api.getSessionInfo()`, `SessionInfo` from Task 7. + +- [ ] **Step 1: Create `LoginGate.css`** + +Create `frontend/src/LoginGate.css`: + +```css +.login-gate-loading { + text-align: center; + margin-top: 8rem; + color: #9aa0ab; +} + +.login-gate { + max-width: 420px; + margin: 8rem auto 0; + text-align: center; + color: #e6e6e6; +} + +.login-gate-error { + color: #f87171; +} + +.login-gate-button { + display: inline-block; + margin-top: 1rem; + padding: 0.6rem 1.5rem; + background: #3b82f6; + color: white; + border-radius: 6px; + text-decoration: none; + font-weight: 600; +} +``` + +- [ ] **Step 2: Create `LoginGate.tsx`** + +Create `frontend/src/LoginGate.tsx`: + +```tsx +import { useEffect, useState } from "react"; +import { api } from "./api/client"; +import "./LoginGate.css"; +import App from "./App"; +import type { SessionInfo } from "./types/api"; + +type Status = "loading" | "authenticated" | "unauthenticated"; + +const AUTH_ERROR_MESSAGES: Record = { + forbidden: "Your account isn't authorized for geniusrun.", + failed: "Login failed, please try again.", +}; + +// Wraps App: on mount, asks the backend whether this browser already has a +// valid session (GET /api/session/me). geniusrun has no anonymous view, so +// this is the only fork in the whole frontend between "show the login +// screen" and "show the app." +export function LoginGate() { + const [status, setStatus] = useState("loading"); + const [session, setSession] = useState(null); + + useEffect(() => { + api + .getSessionInfo() + .then((s) => { + setSession(s); + setStatus("authenticated"); + }) + .catch(() => setStatus("unauthenticated")); + }, []); + + if (status === "loading") { + return
Loading…
; + } + + if (status === "unauthenticated") { + const authError = new URLSearchParams(window.location.search).get("auth_error"); + return ( +
+ ); + } + + return ; +} +``` + +- [ ] **Step 3: Update `App.tsx` to accept the session and add a logout link** + +In `frontend/src/App.tsx`, add `import type { SessionInfo } from "./types/api";`, change the function signature and header markup: + +```tsx +function App({ session }: { session: SessionInfo }) { + const [tab, setTab] = useState("activities"); + const [showProfile, setShowProfile] = useState(false); + const [profileName, setProfileName] = useState(null); + + useEffect(() => { + api.getProfile().then((p) => setProfileName(p.Name)).catch(() => {}); + }, []); + + const Active = TABS.find((t) => t.key === tab)!.Component; + + return ( +
+
+

🧞‍♀️ geniusrun

+ +
+ + + Log out + +
+
+
{showProfile ? setProfileName(p.Name)} /> : }
+
+ ); +} +``` + +(Only the `function App(...)` signature line and the header's JSX between `