- 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
282 lines
10 KiB
Go
282 lines
10 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package auth
|
|
|
|
import (
|
|
"crypto"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
)
|
|
|
|
const (
|
|
refreshIssuer = "https://idp.test/realms/main"
|
|
refreshSubject = "subject-1"
|
|
)
|
|
|
|
// refreshConfig is logoutConfig plus a verifier over the test signing key, so
|
|
// the ID token the fake token endpoint hands back verifies as a provider's
|
|
// would.
|
|
func refreshConfig(t *testing.T, endpoint *fakeTokenEndpoint) *Config {
|
|
t.Helper()
|
|
cfg := logoutConfig(t, endpoint)
|
|
cfg.Verifier = oidc.NewVerifier(refreshIssuer,
|
|
&oidc.StaticKeySet{PublicKeys: []crypto.PublicKey{&signingKey.PublicKey}},
|
|
&oidc.Config{ClientID: "test-client"})
|
|
return cfg
|
|
}
|
|
|
|
// currentIDToken is a signed ID token for refreshSubject carrying roles, as
|
|
// the provider's refresh response would carry it.
|
|
func currentIDToken(t *testing.T, subject string, roles ...string) string {
|
|
t.Helper()
|
|
if roles == nil {
|
|
roles = []string{}
|
|
}
|
|
return signIDToken(t, map[string]any{
|
|
"iss": refreshIssuer,
|
|
"sub": subject,
|
|
"aud": "test-client",
|
|
"exp": time.Now().Add(5 * time.Minute).Unix(),
|
|
"iat": time.Now().Unix(),
|
|
"email": "bob@example.test",
|
|
"roles": roles,
|
|
})
|
|
}
|
|
|
|
// sessionAged seeds a signed-in session for refreshSubject whose identity was
|
|
// last confirmed refreshedAgo ago, holding roles, and returns its cookie
|
|
// token.
|
|
func sessionAged(t *testing.T, cfg *Config, refreshedAgo time.Duration, refreshToken string, roles ...string) string {
|
|
t.Helper()
|
|
if roles == nil {
|
|
roles = []string{}
|
|
}
|
|
seed := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
token := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
cfg.SessionManager.Put(ctx, sessionKeyAuthenticated, true)
|
|
cfg.SessionManager.Put(ctx, sessionKeyPersonID, "person-1")
|
|
cfg.SessionManager.Put(ctx, sessionKeyOIDCSubject, refreshSubject)
|
|
cfg.SessionManager.Put(ctx, sessionKeyIDToken, "stale-id-token")
|
|
cfg.SessionManager.Put(ctx, sessionKeyRoles, roles)
|
|
cfg.SessionManager.Put(ctx, sessionKeyIdentityRefreshedAt, time.Now().Add(-refreshedAgo).Unix())
|
|
if refreshToken != "" {
|
|
cfg.SessionManager.Put(ctx, sessionKeyRefreshToken, refreshToken)
|
|
}
|
|
}, seed)
|
|
if token == "" {
|
|
t.Fatal("setup: no session token was issued")
|
|
}
|
|
return token
|
|
}
|
|
|
|
// through sends one authenticated request through the middleware and reports
|
|
// whether it reached the handler, plus the roles the session held when it did.
|
|
func through(t *testing.T, cfg *Config, token string) (reached bool, roles []string, rec *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
reached = true
|
|
roles = cfg.getRoles(r.Context())
|
|
})
|
|
req := httptest.NewRequest(http.MethodGet, "/operator", nil)
|
|
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
|
|
rec = httptest.NewRecorder()
|
|
cfg.SessionManager.LoadAndSave(cfg.Middleware()(next)).ServeHTTP(rec, req)
|
|
return reached, roles, rec
|
|
}
|
|
|
|
// Inside the interval the provider is not consulted at all.
|
|
func TestIdentityIsNotReDerivedInsideTheInterval(t *testing.T) {
|
|
endpoint := newFakeTokenEndpoint(t, currentIDToken(t, refreshSubject, "operator-member"))
|
|
cfg := refreshConfig(t, endpoint)
|
|
token := sessionAged(t, cfg, time.Minute, "refresh-1", "operator-member")
|
|
|
|
reached, roles, _ := through(t, cfg, token)
|
|
|
|
if !reached || !slices.Equal(roles, []string{"operator-member"}) {
|
|
t.Errorf("reached=%v roles=%v", reached, roles)
|
|
}
|
|
if n := endpoint.calls.Load(); n != 0 {
|
|
t.Errorf("token endpoint called %d times inside the interval", n)
|
|
}
|
|
}
|
|
|
|
// Finding 3 of the 2026-09 security audit: a role removed at the identity
|
|
// provider stayed in the session for a week. Past the interval the session's
|
|
// roles are re-read from a fresh ID token, so the removal takes effect on the
|
|
// next request after it.
|
|
func TestARoleRemovedAtTheProviderIsGoneAfterTheInterval(t *testing.T) {
|
|
endpoint := newFakeTokenEndpoint(t, currentIDToken(t, refreshSubject))
|
|
cfg := refreshConfig(t, endpoint)
|
|
token := sessionAged(t, cfg, 6*time.Minute, "refresh-1", "operator-member")
|
|
|
|
reached, roles, _ := through(t, cfg, token)
|
|
|
|
if !reached {
|
|
t.Fatal("the request did not reach the handler")
|
|
}
|
|
if len(roles) != 0 {
|
|
t.Errorf("roles after re-derivation = %v, want none: the provider no longer grants operator-member", roles)
|
|
}
|
|
if endpoint.lastGrant != "refresh_token" || endpoint.lastRefresh != "refresh-1" {
|
|
t.Errorf("token endpoint saw grant %q with token %q", endpoint.lastGrant, endpoint.lastRefresh)
|
|
}
|
|
}
|
|
|
|
// The inverse: a role granted at the provider arrives the same way, and the
|
|
// session's tokens and clock are replaced with it.
|
|
func TestARoleGrantedAtTheProviderArrivesAfterTheInterval(t *testing.T) {
|
|
endpoint := newFakeTokenEndpoint(t, currentIDToken(t, refreshSubject, "operator-member"))
|
|
cfg := refreshConfig(t, endpoint)
|
|
token := sessionAged(t, cfg, 6*time.Minute, "refresh-1")
|
|
|
|
reached, roles, _ := through(t, cfg, token)
|
|
if !reached || !slices.Equal(roles, []string{"operator-member"}) {
|
|
t.Fatalf("reached=%v roles=%v, want the granted role", reached, roles)
|
|
}
|
|
|
|
// The next request is inside the new interval: no second call, and the
|
|
// rotated refresh token and fresh ID token are what the session holds.
|
|
var idToken, refreshToken string
|
|
var refreshedAt int64
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
idToken = cfg.SessionManager.GetString(r.Context(), sessionKeyIDToken)
|
|
refreshToken = cfg.SessionManager.GetString(r.Context(), sessionKeyRefreshToken)
|
|
refreshedAt = cfg.SessionManager.GetInt64(r.Context(), sessionKeyIdentityRefreshedAt)
|
|
})
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
|
|
cfg.SessionManager.LoadAndSave(cfg.Middleware()(next)).ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
if n := endpoint.calls.Load(); n != 1 {
|
|
t.Errorf("token endpoint called %d times, want 1: the clock restarted", n)
|
|
}
|
|
if idToken == "stale-id-token" || idToken == "" {
|
|
t.Error("the session still holds the stale ID token")
|
|
}
|
|
if refreshToken != "rotated-refresh" {
|
|
t.Errorf("refresh token = %q, want the rotated one", refreshToken)
|
|
}
|
|
if time.Since(time.Unix(refreshedAt, 0)) > time.Minute {
|
|
t.Errorf("identity_refreshed_at was not restarted: %v", time.Unix(refreshedAt, 0))
|
|
}
|
|
}
|
|
|
|
// A definitive refusal -- the session ended at the provider, the account
|
|
// disabled, the token revoked -- ends the console session on the spot.
|
|
func TestARefusedRefreshEndsTheSession(t *testing.T) {
|
|
endpoint := newFakeTokenEndpoint(t, "")
|
|
endpoint.fail = true
|
|
cfg := refreshConfig(t, endpoint)
|
|
token := sessionAged(t, cfg, 6*time.Minute, "refresh-1", "operator-member")
|
|
|
|
reached, _, rec := through(t, cfg, token)
|
|
|
|
if reached {
|
|
t.Fatal("the request reached the handler after the provider refused the session")
|
|
}
|
|
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login" {
|
|
t.Errorf("status %d Location %q, want a bounce to /login", rec.Code, rec.Header().Get("Location"))
|
|
}
|
|
if stillAuthenticated(t, cfg, token) {
|
|
t.Error("the session survived the provider's refusal")
|
|
}
|
|
}
|
|
|
|
// No answer at all keeps the session on its last known state and asks again
|
|
// only after the backoff, so a provider outage signs nobody out and does not
|
|
// get hammered.
|
|
func TestNoAnswerKeepsTheSessionAndBacksOff(t *testing.T) {
|
|
endpoint := newFakeTokenEndpoint(t, "")
|
|
endpoint.unavailable = true
|
|
cfg := refreshConfig(t, endpoint)
|
|
token := sessionAged(t, cfg, 6*time.Minute, "refresh-1", "operator-member")
|
|
|
|
reached, roles, _ := through(t, cfg, token)
|
|
if !reached || !slices.Equal(roles, []string{"operator-member"}) {
|
|
t.Fatalf("reached=%v roles=%v, want the last known state kept", reached, roles)
|
|
}
|
|
reached, _, _ = through(t, cfg, token)
|
|
if !reached {
|
|
t.Fatal("the second request did not reach the handler")
|
|
}
|
|
// The oauth2 client may retry a failed call once with the other
|
|
// client-auth style, so one attempt is one or two requests.
|
|
if n := endpoint.calls.Load(); n < 1 || n > 2 {
|
|
t.Errorf("token endpoint called %d times across two requests, want one attempt: the second is inside the backoff", n)
|
|
}
|
|
}
|
|
|
|
// A refreshed ID token that names someone else is not this session's, whatever
|
|
// the provider says; the session ends rather than take on another identity.
|
|
func TestARefreshForADifferentSubjectEndsTheSession(t *testing.T) {
|
|
endpoint := newFakeTokenEndpoint(t, currentIDToken(t, "someone-else", "operator-member"))
|
|
cfg := refreshConfig(t, endpoint)
|
|
token := sessionAged(t, cfg, 6*time.Minute, "refresh-1")
|
|
|
|
reached, _, _ := through(t, cfg, token)
|
|
|
|
if reached {
|
|
t.Fatal("the request reached the handler with a token for a different subject")
|
|
}
|
|
if stillAuthenticated(t, cfg, token) {
|
|
t.Error("the session survived a subject mismatch")
|
|
}
|
|
}
|
|
|
|
// A provider that issued no refresh token leaves the session on its sign-in
|
|
// snapshot, which is what every session did before this existed.
|
|
func TestNoRefreshTokenKeepsTheSnapshot(t *testing.T) {
|
|
endpoint := newFakeTokenEndpoint(t, currentIDToken(t, refreshSubject))
|
|
cfg := refreshConfig(t, endpoint)
|
|
token := sessionAged(t, cfg, 6*time.Minute, "", "operator-member")
|
|
|
|
reached, roles, _ := through(t, cfg, token)
|
|
|
|
if !reached || !slices.Equal(roles, []string{"operator-member"}) {
|
|
t.Errorf("reached=%v roles=%v", reached, roles)
|
|
}
|
|
if n := endpoint.calls.Load(); n != 0 {
|
|
t.Errorf("token endpoint called %d times with no refresh token to present", n)
|
|
}
|
|
}
|
|
|
|
// Requests that cross the interval together share one refresh. A provider that
|
|
// retires a refresh token on use would otherwise refuse the second as a
|
|
// replay and end a live session.
|
|
func TestConcurrentRequestsShareOneRefresh(t *testing.T) {
|
|
endpoint := newFakeTokenEndpoint(t, currentIDToken(t, refreshSubject, "operator-member"))
|
|
endpoint.delay = 150 * time.Millisecond
|
|
cfg := refreshConfig(t, endpoint)
|
|
token := sessionAged(t, cfg, 6*time.Minute, "refresh-1")
|
|
|
|
const parallel = 6
|
|
var wg sync.WaitGroup
|
|
reachedCount := make(chan bool, parallel)
|
|
for i := 0; i < parallel; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
reached, _, _ := through(t, cfg, token)
|
|
reachedCount <- reached
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(reachedCount)
|
|
for reached := range reachedCount {
|
|
if !reached {
|
|
t.Error("a concurrent request did not reach the handler")
|
|
}
|
|
}
|
|
if n := endpoint.calls.Load(); n != 1 {
|
|
t.Errorf("token endpoint called %d times for %d concurrent requests, want 1", n, parallel)
|
|
}
|
|
}
|