Files
member-console/internal/auth/identity_refresh_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

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)
}
}