Files
member-console/internal/auth/auth_failure_page_test.go
T
cgalo5758 0b28a9dc29 Remediate security audit findings
- 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
2026-09-09 13:25:43 -05:00

202 lines
7.5 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package auth
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeFailurePage records what the handler asked to be rendered, standing in
// for internal/server's template set (which this package cannot import).
type fakeFailurePage struct {
called bool
status int
heading string
actionLabel string
actionHref string
}
func (f *fakeFailurePage) RenderAuthFailure(w http.ResponseWriter, r *http.Request, status int, heading, actionLabel, actionHref string) {
f.called = true
f.status = status
f.heading = heading
f.actionLabel = actionLabel
f.actionHref = actionHref
w.WriteHeader(status)
fmt.Fprintf(w, `<h1>%s</h1><a href="%s">%s</a>`, heading, actionHref, actionLabel)
}
// callbackWithStaleState drives /callback with a session holding savedState
// and a query carrying queryState, which is the shape a sign-in tab left open
// too long comes back in.
func callbackWithStaleState(t *testing.T, cfg *Config, savedState, queryState string) *httptest.ResponseRecorder {
t.Helper()
seed := httptest.NewRequest(http.MethodGet, "/login", nil)
token := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
cfg.SessionManager.Put(r.Context(), sessionKeyState, savedState)
}, seed)
if token == "" {
t.Fatal("setup: no session token was issued for the seeding request")
}
req := httptest.NewRequest(http.MethodGet, "/callback?state="+queryState+"&code=abc", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.CallbackHandler)).ServeHTTP(rec, req)
return rec
}
// The defect this closes: a Keycloak authorize page left open for 45 minutes
// and then submitted answered with `400 Invalid state` as unstyled http.Error
// text, on a page with no way back to sign-in. The refusal is correct and
// stays; what changes is that the person is told what happened and given the
// link that fixes it.
func TestStaleCallbackStateRendersTheStyledPageWithAWayBack(t *testing.T) {
cfg := newTestConfig()
page := &fakeFailurePage{}
cfg.Failures = page
rec := callbackWithStaleState(t, cfg, "the-state-login-stored", "a-different-state")
if !page.called {
t.Fatal("a stale callback state did not reach the failure page")
}
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d: the refusal must stay a refusal", rec.Code, http.StatusBadRequest)
}
if page.heading != "Sign-in expired" {
t.Errorf("heading = %q, want %q", page.heading, "Sign-in expired")
}
if page.actionHref != "/login" {
t.Errorf("action href = %q, want /login: the page must lead back into sign-in", page.actionHref)
}
if page.actionLabel == "" {
t.Error("the action has no label")
}
if body := rec.Body.String(); !strings.Contains(body, `href="/login"`) {
t.Errorf("rendered body carries no sign-in link: %q", body)
}
if body := rec.Body.String(); strings.Contains(body, "Invalid state") {
t.Errorf("the person is still shown the raw refusal: %q", body)
}
}
// A Config with no renderer (every other test here, and any embedder that has
// not wired one) must still refuse, in plain text, rather than dereference nil.
func TestRefusalWithoutARendererFallsBackToPlainText(t *testing.T) {
cfg := newTestConfig()
rec := callbackWithStaleState(t, cfg, "the-state-login-stored", "a-different-state")
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if body := rec.Body.String(); !strings.Contains(body, "Sign-in expired") {
t.Errorf("plain-text fallback = %q, want the heading", body)
}
}
// callbackWithProviderError drives /callback with a session whose state
// matches, and a query carrying the identity provider's error response
// instead of an authorization code.
func callbackWithProviderError(t *testing.T, cfg *Config, code, description string) *httptest.ResponseRecorder {
t.Helper()
const state = "the-state-login-stored"
seed := httptest.NewRequest(http.MethodGet, "/login", nil)
token := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
cfg.SessionManager.Put(r.Context(), sessionKeyState, state)
}, seed)
if token == "" {
t.Fatal("setup: no session token was issued for the seeding request")
}
req := httptest.NewRequest(http.MethodGet, "/callback", nil)
q := req.URL.Query()
q.Set("state", state)
q.Set("error", code)
q.Set("error_description", description)
req.URL.RawQuery = q.Encode()
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.CallbackHandler)).ServeHTTP(rec, req)
return rec
}
// The live failure this closes: the identity provider answered the authorize
// request with `error=temporarily_unavailable&error_description=
// authentication_expired`, the callback ignored the parameter, and the token
// exchange then failed with an empty code -- an internal error for something
// that was neither internal nor an error on this side.
func TestAProviderErrorResponseIsNamedAsAnExpiredSignIn(t *testing.T) {
cfg := newTestConfig()
page := &fakeFailurePage{}
cfg.Failures = page
rec := callbackWithProviderError(t, cfg, "temporarily_unavailable", "authentication_expired")
if !page.called {
t.Fatal("the provider's error response did not reach the failure page")
}
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if page.heading != "Sign-in expired" {
t.Errorf("heading = %q, want %q: a transient provider error clears on a retry", page.heading, "Sign-in expired")
}
if page.actionHref != "/login" {
t.Errorf("action href = %q, want /login", page.actionHref)
}
}
// A provider error a retry will not clear says so, so the person is not sent
// round the same loop.
func TestANonRetryableProviderErrorIsNamedAsAFailure(t *testing.T) {
cfg := newTestConfig()
page := &fakeFailurePage{}
cfg.Failures = page
rec := callbackWithProviderError(t, cfg, "access_denied", "user denied the request")
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if page.heading != "Sign-in failed" {
t.Errorf("heading = %q, want %q", page.heading, "Sign-in failed")
}
}
// The state is checked before the error parameter is read. An error response
// carries a state too, so an unsolicited one must not be able to end a flow
// the person is still in.
func TestAProviderErrorWithTheWrongStateIsRefusedOnTheState(t *testing.T) {
cfg := newTestConfig()
page := &fakeFailurePage{}
cfg.Failures = page
const state = "the-state-login-stored"
seed := httptest.NewRequest(http.MethodGet, "/login", nil)
token := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
cfg.SessionManager.Put(r.Context(), sessionKeyState, state)
}, seed)
req := httptest.NewRequest(http.MethodGet, "/callback?state=someone-elses&error=access_denied", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.CallbackHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if page.heading != "Sign-in expired" {
t.Errorf("heading = %q: the state mismatch must be what refuses, before the error parameter is read", page.heading)
}
}