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

62 KiB

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:

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):

	// 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:

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
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:

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:

// 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
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):

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:

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:

// 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
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/authRequireSession 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:

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:

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
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:

// 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):

// 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:

// 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:

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):

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:

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):

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:

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
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:

	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:

# 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
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):

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:

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):

  // 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
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:

.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:

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:

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:

.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:

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
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:

## 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
git add CLAUDE.md
git commit -m "docs: document the OIDC login gate and its required env vars"