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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user