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