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.
This commit is contained in:
@@ -13,6 +13,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"geniusrun/backend/internal/auth"
|
||||||
|
authmock "geniusrun/backend/internal/auth/mock"
|
||||||
"geniusrun/backend/internal/garmin/mock"
|
"geniusrun/backend/internal/garmin/mock"
|
||||||
"geniusrun/backend/internal/store"
|
"geniusrun/backend/internal/store"
|
||||||
appsync "geniusrun/backend/internal/sync"
|
appsync "geniusrun/backend/internal/sync"
|
||||||
@@ -20,6 +22,12 @@ import (
|
|||||||
|
|
||||||
func newCtx() context.Context { return context.Background() }
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
func newTestServer(t *testing.T) (*Server, *store.DB) {
|
func newTestServer(t *testing.T) (*Server, *store.DB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||||||
@@ -30,7 +38,7 @@ func newTestServer(t *testing.T) (*Server, *store.DB) {
|
|||||||
|
|
||||||
m := &mock.Client{}
|
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) })
|
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 {
|
func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {
|
||||||
@@ -47,6 +55,11 @@ func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *
|
|||||||
}
|
}
|
||||||
req := httptest.NewRequest(method, path, reader)
|
req := httptest.NewRequest(method, path, reader)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
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()
|
rec := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
return rec
|
return rec
|
||||||
@@ -741,3 +754,163 @@ func TestProfile_RejectsInvalidHRZones(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
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"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"geniusrun/backend/internal/auth"
|
||||||
"geniusrun/backend/internal/garmin"
|
"geniusrun/backend/internal/garmin"
|
||||||
"geniusrun/backend/internal/store"
|
"geniusrun/backend/internal/store"
|
||||||
appsync "geniusrun/backend/internal/sync"
|
appsync "geniusrun/backend/internal/sync"
|
||||||
@@ -21,6 +22,8 @@ type Server struct {
|
|||||||
DB *store.DB
|
DB *store.DB
|
||||||
Garmin garmin.Client
|
Garmin garmin.Client
|
||||||
Sync *appsync.Service
|
Sync *appsync.Service
|
||||||
|
Auth auth.Verifier
|
||||||
|
Session SessionConfig
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
authStatus garmin.AuthStatus
|
authStatus garmin.AuthStatus
|
||||||
@@ -29,8 +32,8 @@ type Server struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewServer builds a Server.
|
// NewServer builds a Server.
|
||||||
func NewServer(db *store.DB, g garmin.Client, s *appsync.Service) *Server {
|
func NewServer(db *store.DB, g garmin.Client, s *appsync.Service, authVerifier auth.Verifier, session SessionConfig) *Server {
|
||||||
return &Server{DB: db, Garmin: g, Sync: s}
|
return &Server{DB: db, Garmin: g, Sync: s, Auth: authVerifier, Session: session}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Router builds the HTTP routes.
|
// Router builds the HTTP routes.
|
||||||
@@ -40,6 +43,17 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Route("/api", func(r chi.Router) {
|
r.Route("/api", func(r chi.Router) {
|
||||||
r.Get("/health", s.handleHealth)
|
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.Route("/profile", func(r chi.Router) {
|
||||||
r.Get("/", s.handleGetProfile)
|
r.Get("/", s.handleGetProfile)
|
||||||
r.Put("/", s.handleUpdateProfile)
|
r.Put("/", s.handleUpdateProfile)
|
||||||
@@ -80,18 +94,21 @@ func (s *Server) Router() http.Handler {
|
|||||||
|
|
||||||
r.Get("/progression/{kindID}", s.handleProgression)
|
r.Get("/progression/{kindID}", s.handleProgression)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// corsMiddleware allows the frontend dev server (a different port) to call
|
// corsMiddleware allows the frontend dev server (a different port) to call
|
||||||
// this API. Single-user local app, so reflecting any origin is fine --
|
// this API. Reflecting any origin back is safe even with credentials
|
||||||
// there's no session/cookie auth to protect against CSRF.
|
// 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 {
|
func corsMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if origin := r.Header.Get("Origin"); origin != "" {
|
if origin := r.Header.Get("Origin"); origin != "" {
|
||||||
w.Header().Set("Access-Control-Allow-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-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
}
|
}
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
86
backend/internal/api/session.go
Normal file
86
backend/internal/api/session.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"geniusrun/backend/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionConfig configures how the app-login session cookie is minted and
|
||||||
|
// validated. Secure should mirror config.Config.SessionSecure (true once
|
||||||
|
// the app is served over HTTPS).
|
||||||
|
type SessionConfig struct {
|
||||||
|
Secret []byte
|
||||||
|
Duration time.Duration
|
||||||
|
Secure bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionMeResponse struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
authURL, txn, err := s.Auth.BeginLogin()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cookie, err := auth.MintTxnCookie(txn, s.Session.Secret, s.Session.Secure)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
http.Redirect(w, r, authURL, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
txnCookie, err := r.Cookie(auth.TxnCookieName)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.Session.Secure))
|
||||||
|
|
||||||
|
txn, err := auth.ParseTxnCookie(txnCookie, s.Session.Secret)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !result.Authorized {
|
||||||
|
http.Redirect(w, r, "/?auth_error=forbidden", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionCookie, err := auth.MintSessionCookie(result.Claims, s.Session.Secret, s.Session.Duration, s.Session.Secure)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, sessionCookie)
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure))
|
||||||
|
http.Redirect(w, r, s.Auth.EndSessionURL("/"), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
// Unreachable in practice -- RequireSession already 401s before this
|
||||||
|
// handler runs -- but fail closed rather than panic if that ever changes.
|
||||||
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, sessionMeResponse{Name: claims.Name, Email: claims.Email})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user