diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index 0e6f400..591cc3b 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -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,12 @@ 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, +} + func newTestServer(t *testing.T) (*Server, *store.DB) { t.Helper() 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{} 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 +55,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 +754,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) + } +} diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 1d80598..c9b3658 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -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) diff --git a/backend/internal/api/session.go b/backend/internal/api/session.go new file mode 100644 index 0000000..840bf54 --- /dev/null +++ b/backend/internal/api/session.go @@ -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}) +}