mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:51:30 +00:00
fix(oidc/bcl): jti replay-cache + iat freshness check (HIGH-3 closure)
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
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// BCLReplayRepository is the postgres implementation of
|
||||
// repository.BCLReplayRepository. Audit 2026-05-10 HIGH-3.
|
||||
type BCLReplayRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewBCLReplayRepository(db *sql.DB) *BCLReplayRepository {
|
||||
return &BCLReplayRepository{db: db}
|
||||
}
|
||||
|
||||
// ConsumeJTI atomically records that a (jti, issuer_url) pair has been
|
||||
// consumed. INSERT...ON CONFLICT DO NOTHING RETURNING gives us
|
||||
// single-use semantics in one round-trip: if zero rows return, the
|
||||
// jti was already there.
|
||||
func (r *BCLReplayRepository) ConsumeJTI(ctx context.Context, jti, issuerURL string, ttl time.Duration) error {
|
||||
expiresAt := time.Now().UTC().Add(ttl)
|
||||
var inserted bool
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO oidc_bcl_consumed_jtis (jti, issuer_url, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (jti, issuer_url) DO NOTHING
|
||||
RETURNING true`,
|
||||
jti, issuerURL, expiresAt,
|
||||
).Scan(&inserted)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
// ON CONFLICT DO NOTHING returns zero rows = already consumed.
|
||||
return repository.ErrBCLJTIAlreadyConsumed
|
||||
}
|
||||
return fmt.Errorf("bcl consume_jti: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SweepExpired removes rows whose expires_at is in the past.
|
||||
func (r *BCLReplayRepository) SweepExpired(ctx context.Context, now time.Time) (int, error) {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`DELETE FROM oidc_bcl_consumed_jtis WHERE expires_at < $1`,
|
||||
now)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bcl sweep_expired: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return int(n), nil
|
||||
}
|
||||
Reference in New Issue
Block a user