- Rotate the session token at the OIDC callback and restore the full lifetime; cap pre-auth sessions at 15 minutes and write no session for bare anonymous requests - Treat db-dsn as a secret: accept db-dsn-file, log only host, port, database and user, and never echo a malformed DSN in an error - Guard the logout callback with a state cookie so a forged visit cannot end a live session - Collapse FedWiki site actions on a foreign tenant's domain to the not-found answer, as for a domain that does not exist
133 lines
4.6 KiB
Go
133 lines
4.6 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package auth
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/alexedwards/scs/v2"
|
|
)
|
|
|
|
// newTestConfig builds a minimal Config whose Middleware only touches
|
|
// SessionManager, so no Redis/OIDC wiring is needed. scs.New() defaults to
|
|
// an in-memory store when Store is left nil.
|
|
func newTestConfig() *Config {
|
|
sm := scs.New()
|
|
return &Config{SessionManager: sm}
|
|
}
|
|
|
|
// TestMiddleware_ExpiredSession_HTMXRequest_RespondsWithHXRedirect covers
|
|
// finding #15: an HTMX request against an expired/unauthenticated session
|
|
// used to get a 302 to the (cross-origin) Keycloak authorize URL, which the
|
|
// XHR can't follow — error-handler.js showed a lying "Network error" toast.
|
|
// The middleware must instead respond 401 with HX-Redirect: /login so htmx
|
|
// performs a same-origin client-side redirect.
|
|
func TestMiddleware_ExpiredSession_HTMXRequest_RespondsWithHXRedirect(t *testing.T) {
|
|
cfg := newTestConfig()
|
|
mw := cfg.Middleware()
|
|
|
|
nextCalled := false
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
nextCalled = true
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/partials/operator/organizations/abc/enrollment", nil)
|
|
req.Header.Set("HX-Request", "true")
|
|
rec := httptest.NewRecorder()
|
|
|
|
cfg.SessionManager.LoadAndSave(mw(next)).ServeHTTP(rec, req)
|
|
|
|
if nextCalled {
|
|
t.Fatal("expected middleware to short-circuit an unauthenticated HTMX request, but next handler ran")
|
|
}
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("HX-Redirect"); got != "/login" {
|
|
t.Fatalf("expected HX-Redirect header %q, got %q", "/login", got)
|
|
}
|
|
}
|
|
|
|
// TestMiddleware_ExpiredSession_NonHTMXRequest_RedirectsToLogin verifies a
|
|
// plain browser navigation still gets the original 302-to-/login behavior
|
|
// (unchanged by the finding #15 fix).
|
|
func TestMiddleware_ExpiredSession_NonHTMXRequest_RedirectsToLogin(t *testing.T) {
|
|
cfg := newTestConfig()
|
|
mw := cfg.Middleware()
|
|
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("expected middleware to short-circuit an unauthenticated request, but next handler ran")
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
cfg.SessionManager.LoadAndSave(mw(next)).ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("expected 302, got %d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Location"); got != "/login" {
|
|
t.Fatalf("expected Location header %q, got %q", "/login", got)
|
|
}
|
|
if got := rec.Header().Get("HX-Redirect"); got != "" {
|
|
t.Fatalf("did not expect HX-Redirect on a non-HTMX request, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestMiddleware_BareRoot_SetsNoSessionCookie covers design D3's anonymous
|
|
// sessions requirement: a bare GET / would only ever store the default
|
|
// return path, which resolveReturnTo already falls back to, so bounceToLogin
|
|
// stores nothing for it and the session manager has nothing to commit — no
|
|
// Set-Cookie at all, not even an empty session.
|
|
func TestMiddleware_BareRoot_SetsNoSessionCookie(t *testing.T) {
|
|
cfg := newTestConfig()
|
|
mw := cfg.Middleware()
|
|
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("expected the middleware to redirect an unauthenticated request, not call next")
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
cfg.SessionManager.LoadAndSave(mw(next)).ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login" {
|
|
t.Fatalf("expected 302 to /login, got %d %q", rec.Code, rec.Header().Get("Location"))
|
|
}
|
|
for _, c := range rec.Result().Cookies() {
|
|
if c.Name == cfg.SessionManager.Cookie.Name {
|
|
t.Fatalf("a bare GET / set a session cookie (%q); the store should not have been written", c.Value)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMiddleware_PublicPath_SkipsAuth guards against a regression where the
|
|
// HX-Request branch swallows the public-path bypass.
|
|
func TestMiddleware_PublicPath_SkipsAuth(t *testing.T) {
|
|
cfg := newTestConfig()
|
|
mw := cfg.Middleware()
|
|
|
|
nextCalled := false
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
nextCalled = true
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
|
req.Header.Set("HX-Request", "true")
|
|
rec := httptest.NewRecorder()
|
|
|
|
cfg.SessionManager.LoadAndSave(mw(next)).ServeHTTP(rec, req)
|
|
|
|
if !nextCalled {
|
|
t.Fatal("expected /login to bypass auth and reach the next handler")
|
|
}
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
}
|