mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:21:29 +00:00
15435ca02b
Closes HIGH-3 of the 2026-05-10 audit. Pre-fix the BCL handler
accepted any logout_token whose iat + jti were syntactically present
but never checked (a) that iat fell within a skew window or (b) that
jti hadn't been seen before. A captured logout_token was replayable
indefinitely; once CRIT-2 was fixed, every replay would revoke the
user's current sessions — persistent DoS. RFC 9700 §2.7 + OIDC BCL
1.0 §2.5 require jti replay defense.
- Migration 000040_bcl_replay_cache: oidc_bcl_consumed_jtis table with
composite PK on (jti, issuer_url) — RFC 7519 §4.1.7 per-issuer
uniqueness — and an expires_at index for the GC sweep.
- repository.BCLReplayRepository interface + ErrBCLJTIAlreadyConsumed
sentinel. Postgres impl uses INSERT...ON CONFLICT DO NOTHING
RETURNING true for atomic single-use semantics in one round-trip.
- handler.DefaultBCLVerifier gains WithMaxAge + nowFn clock seam. iat
freshness check rejects tokens whose iat is in the future beyond
max-age OR stale beyond it. Verifier signature extended:
Verify(ctx, jwt) (iss, sub, sid, jti string, iat int64, err error).
- handler.AuthSessionOIDCHandler gains BCLReplayConsumer (interface)
+ WithBCLReplayConsumer(consumer, maxAge) setter. BackChannelLogout
consumes the jti post-verify with TTL = max(24h, 2*maxAge):
- first-receive → 200, sessions revoked, audit outcome=revoked
- replay (ErrBCLJTIAlreadyConsumed) → 200 + Cache-Control: no-store,
audit outcome=jti_replayed, sessions NOT re-revoked
- transient (non-AlreadyConsumed error) → 503 so the IdP retries
- internal/scheduler/scheduler.go: SetBCLReplayGarbageCollector wires
SweepExpired into the existing session-GC tick (no separate ticker
for short-lived replay rows).
- cmd/server/main.go: bclMaxAge from cfg.Auth.OIDCBCLMaxAgeSeconds
(default 60s, env CERTCTL_OIDC_BCL_MAX_AGE_SECONDS); bclReplayRepo
wired into the verifier + handler + scheduler.
- Three regression tests in internal/api/handler/bcl_replay_test.go:
TestBackChannelLogout_FirstReceiveConsumesJTI,
TestBackChannelLogout_ReplayedJTIReturns200WithAudit,
TestBackChannelLogout_TransientConsumeFailureReturns503.
- internal/api/handler/auth_session_oidc_test.go: stubBCLVerifier
gains jti + iat fields; existing TestBackChannelLogout_* tests
rewritten for the new Verify return.
Verification gate green: gofmt clean, go vet clean, go test -short
-count=1 on internal/api/handler / internal/api/router /
internal/scheduler / cmd/server / internal/auth/oidc /
internal/auth/breakglass — all pass.
CRIT-1..CRIT-5 + HIGH-1 + HIGH-2 + HIGH-3 of the 2026-05-10 audit
now closed on this branch. Spec at
cowork/auth-bundles-fixes-2026-05-10/07-high-3-bcl-replay-defense.md.
Refs: cowork/auth-bundles-audit-2026-05-10.md HIGH-3
121 lines
4.1 KiB
Go
121 lines
4.1 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/repository"
|
|
)
|
|
|
|
// Audit 2026-05-10 HIGH-3 closure — regression tests pinning the
|
|
// jti consumed-set replay defense. Pre-fix the handler accepted any
|
|
// logout_token whose iat + jti were syntactically present; captured
|
|
// tokens were replayable indefinitely.
|
|
|
|
// stubBCLReplay tracks ConsumeJTI calls for the replay-cache tests.
|
|
type stubBCLReplay struct {
|
|
consumed map[string]bool // key = jti|iss
|
|
forceErr error // when set, ConsumeJTI returns this (transient path)
|
|
}
|
|
|
|
func (s *stubBCLReplay) ConsumeJTI(_ context.Context, jti, iss string, _ time.Duration) error {
|
|
if s.forceErr != nil {
|
|
return s.forceErr
|
|
}
|
|
if s.consumed == nil {
|
|
s.consumed = map[string]bool{}
|
|
}
|
|
key := jti + "|" + iss
|
|
if s.consumed[key] {
|
|
return repository.ErrBCLJTIAlreadyConsumed
|
|
}
|
|
s.consumed[key] = true
|
|
return nil
|
|
}
|
|
|
|
// TestBackChannelLogout_FirstReceiveConsumesJTI pins the happy path —
|
|
// first BCL with a given (jti, iss) succeeds + records the pair.
|
|
func TestBackChannelLogout_FirstReceiveConsumesJTI(t *testing.T) {
|
|
bcl := &stubBCLVerifier{
|
|
issuer: "https://idp.example.com",
|
|
sub: "alice@example.com",
|
|
jti: "logout-jti-1",
|
|
iat: time.Now().Unix(),
|
|
}
|
|
replay := &stubBCLReplay{}
|
|
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, bcl)
|
|
h.WithBCLReplayConsumer(replay, 60*time.Second)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout",
|
|
strings.NewReader("logout_token=eyJ.payload.sig"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
h.BackChannelLogout(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d; want 200", w.Code)
|
|
}
|
|
if !replay.consumed["logout-jti-1|https://idp.example.com"] {
|
|
t.Errorf("expected (jti, iss) to be recorded; consumed=%v", replay.consumed)
|
|
}
|
|
}
|
|
|
|
// TestBackChannelLogout_ReplayedJTIReturns200WithAudit pins §2.7
|
|
// idempotency: replay returns 200 + audit outcome=jti_replayed.
|
|
func TestBackChannelLogout_ReplayedJTIReturns200WithAudit(t *testing.T) {
|
|
bcl := &stubBCLVerifier{
|
|
issuer: "https://idp.example.com",
|
|
sub: "alice@example.com",
|
|
jti: "logout-jti-1",
|
|
iat: time.Now().Unix(),
|
|
}
|
|
replay := &stubBCLReplay{consumed: map[string]bool{"logout-jti-1|https://idp.example.com": true}}
|
|
h, _, _, _, audit, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, bcl)
|
|
h.WithBCLReplayConsumer(replay, 60*time.Second)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout",
|
|
strings.NewReader("logout_token=eyJ.payload.sig"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
h.BackChannelLogout(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d; want 200 (idempotent on replay)", w.Code)
|
|
}
|
|
if cc := w.Header().Get("Cache-Control"); cc != "no-store" {
|
|
t.Errorf("Cache-Control = %q; want no-store", cc)
|
|
}
|
|
if !contains(audit.events, "auth.oidc_back_channel_logout") {
|
|
t.Errorf("expected audit event with outcome=jti_replayed")
|
|
}
|
|
}
|
|
|
|
// TestBackChannelLogout_TransientConsumeFailureReturns503 pins the
|
|
// transient-error path: ConsumeJTI returns a non-ErrAlreadyConsumed
|
|
// error → 503 so the IdP retries.
|
|
func TestBackChannelLogout_TransientConsumeFailureReturns503(t *testing.T) {
|
|
bcl := &stubBCLVerifier{
|
|
issuer: "https://idp.example.com",
|
|
sub: "alice@example.com",
|
|
jti: "logout-jti-1",
|
|
iat: time.Now().Unix(),
|
|
}
|
|
replay := &stubBCLReplay{forceErr: errors.New("db connection reset")}
|
|
h, _, _, _, _, _ := newPhase5Handler(t, &stubOIDCSvc{}, &stubSession{}, bcl)
|
|
h.WithBCLReplayConsumer(replay, 60*time.Second)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/oidc/back-channel-logout",
|
|
strings.NewReader("logout_token=eyJ.payload.sig"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
h.BackChannelLogout(w, req)
|
|
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("status = %d; want 503 (transient consume failure)", w.Code)
|
|
}
|
|
}
|