From 48c17bf8ba1bf6dc3ac722a911c47081e74eccdf Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Fri, 24 Jul 2026 21:30:39 +0200 Subject: [PATCH] auth: add RequireSession chi-compatible middleware Implements HTTP middleware that enforces session authentication by validating session cookies and making claims available via ClaimsFromContext. Rejects requests without valid session cookies with 401 Unauthorized. Tested via four test cases: - Missing session cookie rejection - Valid cookie acceptance with claims extraction - Expired cookie rejection - Tampered cookie rejection Co-Authored-By: Claude Haiku 4.5 --- backend/internal/auth/middleware.go | 38 ++++++++++++ backend/internal/auth/middleware_test.go | 74 ++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 backend/internal/auth/middleware.go create mode 100644 backend/internal/auth/middleware_test.go diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go new file mode 100644 index 0000000..7c90d90 --- /dev/null +++ b/backend/internal/auth/middleware.go @@ -0,0 +1,38 @@ +package auth + +import ( + "context" + "net/http" +) + +type contextKey int + +const claimsContextKey contextKey = iota + +// RequireSession returns middleware that rejects a request with 401 unless +// it carries a valid SessionCookieName cookie signed with secret, and +// otherwise makes the session's Claims available via ClaimsFromContext. +func RequireSession(secret []byte) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(SessionCookieName) + if err != nil { + http.Error(w, "not authenticated", http.StatusUnauthorized) + return + } + claims, err := ParseSessionCookie(cookie, secret) + if err != nil { + http.Error(w, "not authenticated", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsContextKey, claims))) + }) + } +} + +// ClaimsFromContext returns the authenticated user's Claims, as populated by +// RequireSession. +func ClaimsFromContext(ctx context.Context) (Claims, bool) { + c, ok := ctx.Value(claimsContextKey).(Claims) + return c, ok +} diff --git a/backend/internal/auth/middleware_test.go b/backend/internal/auth/middleware_test.go new file mode 100644 index 0000000..bac4856 --- /dev/null +++ b/backend/internal/auth/middleware_test.go @@ -0,0 +1,74 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func protectedTestHandler() http.Handler { + return RequireSession([]byte(testSecret))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := ClaimsFromContext(r.Context()) + if !ok { + http.Error(w, "no claims in context", http.StatusInternalServerError) + return + } + w.Write([]byte(claims.Name)) + })) +} + +func TestRequireSession_NoCookie(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + protectedTestHandler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestRequireSession_ValidCookie(t *testing.T) { + cookie, err := MintSessionCookie(Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, []byte(testSecret), time.Hour, false) + if err != nil { + t.Fatalf("mint: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookie) + rec := httptest.NewRecorder() + protectedTestHandler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String()) + } + if rec.Body.String() != "Alice" { + t.Fatalf("body = %q, want Alice", rec.Body.String()) + } +} + +func TestRequireSession_ExpiredCookie(t *testing.T) { + cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), -time.Hour, false) + if err != nil { + t.Fatalf("mint: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookie) + rec := httptest.NewRecorder() + protectedTestHandler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestRequireSession_TamperedCookie(t *testing.T) { + cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false) + if err != nil { + t.Fatalf("mint: %v", err) + } + cookie.Value = cookie.Value[:len(cookie.Value)-1] + "x" + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookie) + rec := httptest.NewRecorder() + protectedTestHandler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +}