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