Files
geniusrun/docs/superpowers/plans/2026-07-24-oidc-authentication.md
Christophe Vila e2b2bf9611 refactor: merge internal/sync into internal/garmin, regroup api files and routes
Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes
garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes
internal/log; the test mock moves into the garmin package as MockClient
(breaking the test-only import cycle the merge created); stale test
URLs and type names updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:04:18 +02:00

1837 lines
62 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<SessionInfo>` — 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<T>(path: string, init?: RequestInit): Promise<T> {
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<T>;
}
```
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 <a href>
// full-page navigations (see LoginGate.tsx), not fetches, since the OIDC
// flow and Keycloak's own logout redirect need real browser navigation.
getSessionInfo: () => request<SessionInfo>("/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<string, string> = {
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<Status>("loading");
const [session, setSession] = useState<SessionInfo | null>(null);
useEffect(() => {
api
.getSessionInfo()
.then((s) => {
setSession(s);
setStatus("authenticated");
})
.catch(() => setStatus("unauthenticated"));
}, []);
if (status === "loading") {
return <div className="login-gate-loading">Loading</div>;
}
if (status === "unauthenticated") {
const authError = new URLSearchParams(window.location.search).get("auth_error");
return (
<div className="login-gate">
<h1>🧞 geniusrun</h1>
{authError && <p className="login-gate-error">{AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again."}</p>}
<a className="login-gate-button" href="/api/session/login">
Log in
</a>
</div>
);
}
return <App session={session!} />;
}
```
- [ ] **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<TabKey>("activities");
const [showProfile, setShowProfile] = useState(false);
const [profileName, setProfileName] = useState<string | null>(null);
useEffect(() => {
api.getProfile().then((p) => setProfileName(p.Name)).catch(() => {});
}, []);
const Active = TABS.find((t) => t.key === tab)!.Component;
return (
<div className="app">
<header className="app-header">
<h1>🧞 geniusrun</h1>
<nav className="tabs">
{TABS.map((t) => (
<button
key={t.key}
className={!showProfile && t.key === tab ? "tab active" : "tab"}
onClick={() => {
setShowProfile(false);
setTab(t.key);
}}
>
<span className="tab-icon">{t.icon}</span>
{t.label}
</button>
))}
</nav>
<div className="header-actions">
<button
type="button"
className={showProfile ? "profile-name active" : "profile-name"}
onClick={() => setShowProfile(true)}
>
{profileName ?? "Profile"}
</button>
<a className="logout-link" href="/api/session/logout" title={session.email}>
Log out
</a>
</div>
</header>
<main>{showProfile ? <Profile onSaved={(p) => setProfileName(p.Name)} /> : <Active />}</main>
</div>
);
}
```
(Only the `function App(...)` signature line and the header's JSX between `<nav className="tabs">`'s closing tag and `<main>` change — the `TABS`/`TabKey` declarations above and the `export default App;` below are unchanged.)
- [ ] **Step 4: Update `App.css`**
In `frontend/src/App.css`, remove `margin-left: auto;` from the `.profile-name` rule (that job moves to the new wrapper) and add:
```css
.header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.6rem;
}
.logout-link {
color: #9aa0ab;
font-size: 0.85rem;
text-decoration: none;
}
.logout-link:hover {
color: #3b82f6;
}
```
- [ ] **Step 5: Update `main.tsx`**
Replace `frontend/src/main.tsx` with:
```tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { LoginGate } from './LoginGate.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<LoginGate />
</StrictMode>,
)
```
- [ ] **Step 6: Verify it builds**
Run: `cd frontend && npm run build`
Expected: succeeds with no type errors (in particular, `App`'s new required `session` prop must be satisfied everywhere it's rendered — only `LoginGate.tsx` renders it, which it does).
- [ ] **Step 7: Manual smoke test of what's testable without a live Keycloak**
Run: `cd frontend && npm run dev` (with the backend NOT running, or returning 401 for `/api/session/me`)
Expected: the browser shows the "🧞‍♀️ geniusrun" login screen with a "Log in" button — not the tab UI. This confirms the gate itself renders correctly; the full login round-trip requires a real backend wired to a real Keycloak client (Task 6's rollout note), which is a manual step outside this plan once that client exists.
- [ ] **Step 8: Commit**
```bash
git add frontend/src/LoginGate.tsx frontend/src/LoginGate.css frontend/src/App.tsx frontend/src/App.css frontend/src/main.tsx
git commit -m "frontend: add LoginGate, wire session into App, add logout link"
```
---
### Task 9: Documentation
**Files:**
- Modify: `CLAUDE.md`
- [ ] **Step 1: Add an Authentication section**
In `CLAUDE.md`, insert a new section after `## Commands` and before `## Repo layout`:
```markdown
## Authentication
geniusrun requires login via an existing Keycloak realm (OIDC Authorization Code flow, backend-driven -- the browser never sees a Keycloak token, only geniusrun's own signed session cookie). This is an access gate only: every authenticated+authorized user reaches the same single profile/dataset described above, there is no per-user data scoping. Access additionally requires a specific realm role (`GENIUSRUN_OIDC_REQUIRED_ROLE`, default `geniusrun-user`) -- a successful Keycloak login alone is not sufficient if the realm is shared with other apps/users.
Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_PUBLIC_BASE_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_OIDC_REQUIRED_ROLE`, `GENIUSRUN_SESSION_DURATION` (default `720h`). See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the full design and `internal/auth` for the implementation.
```
- [ ] **Step 2: Commit**
```bash
git add CLAUDE.md
git commit -m "docs: document the OIDC login gate and its required env vars"
```