- Replace gorilla/csrf with net/http CrossOriginProtection - Require valkey-password and add TLS options for session store - End session at /logout and revoke refresh tokens - Re-derive identity and roles from provider every five minutes - Process each Stripe webhook event in its own Temporal workflow - Give each outbox entry its own workflow with Temporal retries - Guard against stale Stripe events with provider timestamps - Derive transport security from base-url scheme
95 lines
3.5 KiB
Go
95 lines
3.5 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"
|
|
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// tokenAfter runs h through the session manager for one request and returns
|
|
// the session token the response sets, or "" when the token was not rotated.
|
|
func tokenAfter(t *testing.T, cfg *Config, h http.HandlerFunc, req *http.Request) string {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
cfg.SessionManager.LoadAndSave(h).ServeHTTP(rec, req)
|
|
for _, c := range rec.Result().Cookies() {
|
|
if c.Name == cfg.SessionManager.Cookie.Name {
|
|
return c.Value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Session fixation, found by the 2026-09 security audit: LoginHandler rotated
|
|
// the session token before starting the OIDC flow but RegistrationHandler did
|
|
// not, so a session id planted in a victim's browser survived their sign-in
|
|
// through /register.
|
|
//
|
|
// The handler is exercised only as far as the rotation, which happens before
|
|
// any OIDC or Viper wiring is touched; the request is expected to fail later
|
|
// (there is no configured issuer here) and that is fine — what is asserted is
|
|
// that a new session token was issued regardless.
|
|
func TestRegistrationHandlerRotatesTheSessionToken(t *testing.T) {
|
|
cfg := newTestConfig()
|
|
|
|
// Establish a session and capture its token, standing in for the id an
|
|
// attacker would have planted.
|
|
seed := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
planted := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
|
|
cfg.SessionManager.Put(r.Context(), "seeded", true)
|
|
}, seed)
|
|
if planted == "" {
|
|
t.Fatal("setup: no session token was issued for the seeding request")
|
|
}
|
|
|
|
// Replay that session id into the registration path.
|
|
req := httptest.NewRequest(http.MethodGet, "/register", nil)
|
|
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: planted})
|
|
rotated := tokenAfter(t, cfg, cfg.RegistrationHandler, req)
|
|
|
|
if rotated == "" {
|
|
t.Fatal("RegistrationHandler issued no new session token: the planted id survives")
|
|
}
|
|
if rotated == planted {
|
|
t.Errorf("session token was not rotated (%q); a fixed session id survives registration", rotated)
|
|
}
|
|
}
|
|
|
|
// The login path carries the same guarantee, so a regression there is caught
|
|
// too. LoginHandler needs OAuthConfig only for AuthCodeURL, which builds a
|
|
// string and talks to nothing, so a dummy config is enough — no live identity
|
|
// provider, no network.
|
|
func TestLoginHandlerRotatesTheSessionToken(t *testing.T) {
|
|
cfg := newTestConfig()
|
|
cfg.OAuthConfig = &oauth2.Config{
|
|
ClientID: "test-client",
|
|
RedirectURL: "http://example.test/callback",
|
|
Endpoint: oauth2.Endpoint{AuthURL: "http://idp.test/auth", TokenURL: "http://idp.test/token"},
|
|
Scopes: []string{"openid"},
|
|
}
|
|
|
|
seed := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
planted := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
|
|
cfg.SessionManager.Put(r.Context(), "seeded", true)
|
|
}, seed)
|
|
if planted == "" {
|
|
t.Fatal("setup: no session token was issued for the seeding request")
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
|
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: planted})
|
|
rotated := tokenAfter(t, cfg, cfg.LoginHandler, req)
|
|
|
|
if rotated == "" {
|
|
t.Fatal("LoginHandler issued no new session token: the planted id survives")
|
|
}
|
|
if rotated == planted {
|
|
t.Errorf("session token was not rotated (%q); a fixed session id survives sign-in", rotated)
|
|
}
|
|
}
|