Compare commits

..

10 Commits

Author SHA1 Message Date
502e61e7b4 Fix OIDC login-gate cross-file wiring bugs from whole-branch review
Four bugs slipped through per-task review since each task only saw its
own diff:

- Logout was a plain <a href> GET against a POST-only backend route, so
  it 405'd and never cleared the session cookie or hit Keycloak's
  end-session redirect. Now a <form method="post"> with a submit button
  styled to match the old link (still a real full-page navigation, not
  a fetch, so the Keycloak redirect chain still works).
- Login/logout used origin-relative paths, unreachable from the Vite
  dev server (:5173) against the backend (:8080) with no proxy
  configured. Both now build their URL from client.ts's now-exported
  BASE_URL.
- handleSessionCallback's four failure paths redirected to
  /?auth_error=failed with no logging, making a real OIDC failure
  undiagnosable in production. Added log.Printf on each failure site.
- handleSessionLogout passed a bare "/" to EndSessionURL; Keycloak
  requires post_logout_redirect_uri to be an absolute, registered URL.
  Added SessionConfig.PublicBaseURL, wired from cfg.PublicBaseURL in
  main.go, and used to build an absolute redirect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 22:03:02 +02:00
eeff1e9b40 docs: document the OIDC login gate and its required env vars 2026-07-24 21:50:42 +02:00
815db1ec1f frontend: add LoginGate, wire session into App, add logout link 2026-07-24 21:47:41 +02:00
6dd4d9309c frontend: add session API, credentialed fetch, 401-triggered relogin 2026-07-24 21:44:19 +02:00
f9990ddcc2 geniusrund: construct the OIDC verifier and require its config at startup 2026-07-24 21:41:32 +02:00
26b903721a api: gate all routes behind an OIDC session, add session endpoints
Router() now wraps every route except /health, /session/login, and
/session/callback in a chi group requiring a valid session cookie
(auth.RequireSession). Adds internal/api/session.go with the four
session HTTP handlers (login/callback/logout/me) and SessionConfig.
NewServer takes an auth.Verifier and SessionConfig. Test infra
(doJSON, newTestServer) now mints/attaches a signed session cookie
automatically so the 36 pre-existing tests keep exercising the
already-logged-in path unchanged, plus 8 new tests cover the gating
and session endpoints themselves.
2026-07-24 21:36:00 +02:00
48c17bf8ba auth: add RequireSession chi-compatible middleware
Implements HTTP middleware that enforces session authentication by validating
session cookies and making claims available via ClaimsFromContext. Rejects
requests without valid session cookies with 401 Unauthorized.

Tested via four test cases:
- Missing session cookie rejection
- Valid cookie acceptance with claims extraction
- Expired cookie rejection
- Tampered cookie rejection

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-24 21:30:39 +02:00
59df391291 auth: add Keycloak OIDC verifier, role check, and test mock 2026-07-24 21:26:15 +02:00
bf41a34f59 auth: add signed session/transaction cookie mint and parse
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 21:20:04 +02:00
5c0a9ad96c config: add OIDC/session env vars for the login gate
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 21:13:07 +02:00
24 changed files with 1272 additions and 63 deletions

View File

@@ -28,6 +28,12 @@ Frontend (from `frontend/`):
- Lint: `npm run lint` (oxlint)
- No frontend test suite exists yet.
## 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.
## Repo layout
```

View File

@@ -12,6 +12,7 @@ import (
"time"
"geniusrun/backend/internal/api"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/config"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
@@ -48,7 +49,23 @@ func main() {
MinConfidence: cfg.MinConfidence,
}, nil)
server := api.NewServer(db, garminClient, syncSvc)
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,
PublicBaseURL: cfg.PublicBaseURL,
})
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

View File

@@ -2,11 +2,18 @@ module geniusrun/backend
go 1.26.4
require github.com/mark3labs/mcp-go v0.56.0
require (
github.com/coreos/go-oidc/v3 v3.20.0
github.com/go-chi/chi/v5 v5.3.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/mark3labs/mcp-go v0.56.0
golang.org/x/oauth2 v0.36.0
modernc.org/sqlite v1.53.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-chi/chi/v5 v5.3.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -20,5 +27,4 @@ require (
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.53.0 // indirect
)

View File

@@ -1,3 +1,5 @@
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
@@ -8,12 +10,20 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -38,18 +48,46 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -13,6 +13,8 @@ import (
"testing"
"time"
"geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
@@ -20,6 +22,13 @@ import (
func newCtx() context.Context { return context.Background() }
var testSessionConfig = SessionConfig{
Secret: []byte("test-session-secret-at-least-32-bytes-long"),
Duration: time.Hour,
Secure: false,
PublicBaseURL: "https://geniusrun.example.com",
}
func newTestServer(t *testing.T) (*Server, *store.DB) {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
@@ -30,7 +39,7 @@ func newTestServer(t *testing.T) (*Server, *store.DB) {
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), db
return NewServer(db, m, svc, &authmock.Verifier{}, testSessionConfig), db
}
func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {
@@ -47,6 +56,11 @@ func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *
}
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
@@ -741,3 +755,163 @@ func TestProfile_RejectsInvalidHRZones(t *testing.T) {
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
}
}
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)
}
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
@@ -21,6 +22,8 @@ type Server struct {
DB *store.DB
Garmin garmin.Client
Sync *appsync.Service
Auth auth.Verifier
Session SessionConfig
mu sync.Mutex
authStatus garmin.AuthStatus
@@ -29,8 +32,8 @@ type Server struct {
}
// NewServer builds a Server.
func NewServer(db *store.DB, g garmin.Client, s *appsync.Service) *Server {
return &Server{DB: db, Garmin: g, Sync: s}
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}
}
// Router builds the HTTP routes.
@@ -40,6 +43,17 @@ func (s *Server) Router() http.Handler {
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)
@@ -80,18 +94,21 @@ func (s *Server) Router() http.Handler {
r.Get("/progression/{kindID}", s.handleProgression)
})
})
return r
}
// corsMiddleware allows the frontend dev server (a different port) to call
// this API. Single-user local app, so reflecting any origin is fine --
// there's no session/cookie auth to protect against CSRF.
// 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)

View File

@@ -0,0 +1,96 @@
package api
import (
"log"
"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
// PublicBaseURL is this app's own externally reachable origin (e.g.
// "https://geniusrun.example.com", no trailing slash), used to build an
// absolute post_logout_redirect_uri for the identity provider -- some
// providers, including Keycloak, require this to be an absolute URL
// matching one registered on the client, not a bare relative path.
PublicBaseURL string
}
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 {
log.Printf("session callback: missing txn cookie: %v", err)
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 {
log.Printf("session callback: failed to parse txn cookie: %v", err)
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound)
return
}
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
if err != nil {
log.Printf("session callback: HandleCallback failed (state mismatch, code exchange, or ID-token verification): %v", err)
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(s.Session.PublicBaseURL+"/"), 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})
}

View File

@@ -0,0 +1,38 @@
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
}

View File

@@ -0,0 +1,74 @@
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)
}
}

View File

@@ -0,0 +1,41 @@
// 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
}

View File

@@ -0,0 +1,159 @@
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()
}

View File

@@ -0,0 +1,31 @@
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)
}
})
}
}

View File

@@ -0,0 +1,150 @@
// 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 }
}

View File

@@ -0,0 +1,84 @@
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)
}
}

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
@@ -29,6 +30,21 @@ type Config struct {
MinConfidence float64
IncrementalSyncEvery time.Duration
// 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
}
// Load reads configuration from environment variables, applying defaults
@@ -42,6 +58,12 @@ func Load() (Config, error) {
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 == "" {
@@ -50,6 +72,26 @@ func Load() (Config, error) {
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
}

View File

@@ -0,0 +1,98 @@
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)
}
}

View File

@@ -6,4 +6,12 @@ export MCP_GARMIN_PYTHON="${MCP_GARMIN_PYTHON:-/Users/cvila/Dev/scm/scm.vilanet.
export MCP_GARMIN_SERVER="${MCP_GARMIN_SERVER:-/Users/cvila/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin/server.py}"
export GENIUSRUN_DB_PATH="${GENIUSRUN_DB_PATH:-geniusrun.db}"
# 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}"
exec go run ./cmd/geniusrund

View File

@@ -57,7 +57,6 @@ body {
alongside Activities/Progression/Training plan, so it reads more like an
account chip (à la Slack/GitHub's corner avatar) than another nav tab. */
.profile-name {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.4rem;
@@ -87,6 +86,28 @@ body {
color: white;
}
.header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.6rem;
}
.logout-link {
background: none;
border: none;
padding: 0;
font: inherit;
color: #9aa0ab;
font-size: 0.85rem;
text-decoration: none;
cursor: pointer;
}
.logout-link:hover {
color: #3b82f6;
}
.garmin-connection {
display: flex;
flex-direction: column;

View File

@@ -1,10 +1,11 @@
import { useEffect, useState } from "react";
import { api } from "./api/client";
import { api, BASE_URL } from "./api/client";
import "./App.css";
import { Dashboard } from "./pages/Dashboard";
import { Plan } from "./pages/Plan";
import { Profile } from "./pages/Profile";
import { ReviewQueue } from "./pages/ReviewQueue";
import type { SessionInfo } from "./types/api";
const TABS = [
{ key: "activities", label: "Activities", icon: "☰", Component: ReviewQueue },
@@ -14,7 +15,7 @@ const TABS = [
type TabKey = (typeof TABS)[number]["key"];
function App() {
function App({ session }: { session: SessionInfo }) {
const [tab, setTab] = useState<TabKey>("activities");
// Profile isn't a tab: it's reached via the profile name in the top-right
// corner instead, since (for now, single-profile) it's account settings,
@@ -47,6 +48,7 @@ function App() {
</button>
))}
</nav>
<div className="header-actions">
<button
type="button"
className={showProfile ? "profile-name active" : "profile-name"}
@@ -54,6 +56,12 @@ function App() {
>
{profileName ?? "Profile"}
</button>
<form method="post" action={`${BASE_URL}/api/session/logout`} title={session.email}>
<button type="submit" className="logout-link">
Log out
</button>
</form>
</div>
</header>
<main>{showProfile ? <Profile onSaved={(p) => setProfileName(p.Name)} /> : <Active />}</main>
</div>

View File

@@ -0,0 +1,27 @@
.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;
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import { api, BASE_URL } 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={`${BASE_URL}/api/session/login`}>
Log in
</a>
</div>
);
}
return <App session={session!} />;
}

View File

@@ -7,18 +7,28 @@ import type {
ProgressionMetric,
ProgressionPoint,
ReviewQueuePage,
SessionInfo,
SyncRun,
SyncStatus,
WorkoutKind,
} from "../types/api";
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080";
export const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080";
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}`);
@@ -28,6 +38,15 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
export const api = {
// Session (app login via OIDC -- distinct from the Garmin credential
// login below). No logout()/login() methods: login is a plain <a href>
// and logout is a <form method="post"> submit button (see App.tsx /
// LoginGate.tsx) -- both are real full-page navigations, not fetches,
// since the OIDC flow and Keycloak's own logout redirect need real
// browser navigation. Logout must POST (see server.go's route), which an
// <a href> can't do, hence the form.
getSessionInfo: () => request<SessionInfo>("/api/session/me"),
// Auth
login: () => request<AuthResponse>("/api/auth/login", { method: "POST" }),
submitMFA: (code: string) =>

View File

@@ -1,9 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import { LoginGate } from './LoginGate.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<LoginGate />
</StrictMode>,
)

View File

@@ -186,6 +186,11 @@ export interface AuthResponse {
message: string;
}
export interface SessionInfo {
name: string;
email: string;
}
export interface DetailFillProgress {
Done: number;
Total: number;