2026-07-24 21:02:42 +02:00
# 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:**
2026-08-04 16:04:18 +02:00
- Modify: `../../../backend/internal/config/envconfig.go`
- Create: `../../../backend/internal/config/envconfig_test.go`
2026-07-24 21:02:42 +02:00
**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 **
2026-08-04 16:04:18 +02:00
Create `../../../backend/internal/config/envconfig_test.go` :
2026-07-24 21:02:42 +02:00
```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 **
2026-08-04 16:04:18 +02:00
In `../../../backend/internal/config/envconfig.go` , add `"strings"` to the imports, add these fields to `Config` (after `MinConfidence` /`IncrementalSyncEvery` ):
2026-07-24 21:02:42 +02:00
```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
2026-08-04 16:04:18 +02:00
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go
2026-07-24 21:02:42 +02:00
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"
```