# 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/envconfig.go` - Create: `../../../backend/internal/config/envconfig_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/envconfig_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/envconfig.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/envconfig.go backend/internal/config/envconfig_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 (

🧞‍♀️ geniusrun

{authError &&

{AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again."}

}
Log in
); } 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 `