Compare commits
10 Commits
f9d85e16ef
...
502e61e7b4
| Author | SHA1 | Date | |
|---|---|---|---|
| 502e61e7b4 | |||
| eeff1e9b40 | |||
| 815db1ec1f | |||
| 6dd4d9309c | |||
| f9990ddcc2 | |||
| 26b903721a | |||
| 48c17bf8ba | |||
| 59df391291 | |||
| bf41a34f59 | |||
| 5c0a9ad96c |
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
96
backend/internal/api/session.go
Normal file
96
backend/internal/api/session.go
Normal 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})
|
||||
}
|
||||
38
backend/internal/auth/middleware.go
Normal file
38
backend/internal/auth/middleware.go
Normal 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
|
||||
}
|
||||
74
backend/internal/auth/middleware_test.go
Normal file
74
backend/internal/auth/middleware_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
41
backend/internal/auth/mock/mock.go
Normal file
41
backend/internal/auth/mock/mock.go
Normal 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
|
||||
}
|
||||
159
backend/internal/auth/oidc.go
Normal file
159
backend/internal/auth/oidc.go
Normal 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()
|
||||
}
|
||||
31
backend/internal/auth/oidc_test.go
Normal file
31
backend/internal/auth/oidc_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
150
backend/internal/auth/session.go
Normal file
150
backend/internal/auth/session.go
Normal 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 }
|
||||
}
|
||||
84
backend/internal/auth/session_test.go
Normal file
84
backend/internal/auth/session_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
98
backend/internal/config/config_test.go
Normal file
98
backend/internal/config/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
27
frontend/src/LoginGate.css
Normal file
27
frontend/src/LoginGate.css
Normal 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;
|
||||
}
|
||||
50
frontend/src/LoginGate.tsx
Normal file
50
frontend/src/LoginGate.tsx
Normal 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!} />;
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -186,6 +186,11 @@ export interface AuthResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface DetailFillProgress {
|
||||
Done: number;
|
||||
Total: number;
|
||||
|
||||
Reference in New Issue
Block a user