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:
2026-07-24 21:36:00 +02:00
parent 48c17bf8ba
commit 26b903721a
3 changed files with 322 additions and 46 deletions

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"
@@ -18,9 +19,11 @@ import (
// Server wires the HTTP handlers to the app's dependencies.
type Server struct {
DB *store.DB
Garmin garmin.Client
Sync *appsync.Service
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,58 +43,72 @@ func (s *Server) Router() http.Handler {
r.Route("/api", func(r chi.Router) {
r.Get("/health", s.handleHealth)
r.Route("/profile", func(r chi.Router) {
r.Get("/", s.handleGetProfile)
r.Put("/", s.handleUpdateProfile)
// Unprotected: these two ARE the login flow, so they can't require
// a session yet.
r.Get("/session/login", s.handleSessionLogin)
r.Get("/session/callback", s.handleSessionCallback)
r.Group(func(r chi.Router) {
r.Use(auth.RequireSession(s.Session.Secret))
r.Get("/session/me", s.handleSessionMe)
r.Post("/session/logout", s.handleSessionLogout)
r.Route("/profile", func(r chi.Router) {
r.Get("/", s.handleGetProfile)
r.Put("/", s.handleUpdateProfile)
})
r.Route("/auth", func(r chi.Router) {
r.Post("/login", s.handleAuthLogin)
r.Post("/mfa", s.handleAuthMFA)
r.Get("/status", s.handleAuthStatus)
})
r.Route("/sync", func(r chi.Router) {
r.Post("/run", s.handleSyncRun)
r.Post("/reset", s.handleSyncReset)
r.Get("/runs", s.handleSyncRuns)
r.Get("/status", s.handleSyncStatus)
})
r.Route("/activities", func(r chi.Router) {
r.Get("/", s.handleListActivities)
r.Get("/{id}", s.handleGetActivity)
})
r.Route("/workout-kinds", func(r chi.Router) {
r.Get("/", s.handleListWorkoutKinds)
r.Get("/{id}", s.handleGetWorkoutKind)
r.Put("/{id}", s.handleUpdateWorkoutKind)
})
r.Post("/reclassify", s.handleReclassifyAll)
r.Route("/review-queue", func(r chi.Router) {
r.Get("/", s.handleReviewQueue)
r.Post("/{activityID}/resolve", s.handleResolveReview)
r.Post("/{activityID}/unlock", s.handleUnlockReview)
r.Post("/{activityID}/unassign", s.handleUnassignReview)
})
r.Get("/progression/{kindID}", s.handleProgression)
})
r.Route("/auth", func(r chi.Router) {
r.Post("/login", s.handleAuthLogin)
r.Post("/mfa", s.handleAuthMFA)
r.Get("/status", s.handleAuthStatus)
})
r.Route("/sync", func(r chi.Router) {
r.Post("/run", s.handleSyncRun)
r.Post("/reset", s.handleSyncReset)
r.Get("/runs", s.handleSyncRuns)
r.Get("/status", s.handleSyncStatus)
})
r.Route("/activities", func(r chi.Router) {
r.Get("/", s.handleListActivities)
r.Get("/{id}", s.handleGetActivity)
})
r.Route("/workout-kinds", func(r chi.Router) {
r.Get("/", s.handleListWorkoutKinds)
r.Get("/{id}", s.handleGetWorkoutKind)
r.Put("/{id}", s.handleUpdateWorkoutKind)
})
r.Post("/reclassify", s.handleReclassifyAll)
r.Route("/review-queue", func(r chi.Router) {
r.Get("/", s.handleReviewQueue)
r.Post("/{activityID}/resolve", s.handleResolveReview)
r.Post("/{activityID}/unlock", s.handleUnlockReview)
r.Post("/{activityID}/unassign", s.handleUnassignReview)
})
r.Get("/progression/{kindID}", s.handleProgression)
})
return r
}
// 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)