- 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
202 lines
7.5 KiB
Go
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)
|
|
}
|
|
}
|