Skip to content

auth: security-review fixes (jti retry, return_to control chars) - #86

Merged
tibroc merged 5 commits into
mainfrom
auth/security-review-fixes
Aug 28, 2026
Merged

auth: security-review fixes (jti retry, return_to control chars)#86
tibroc merged 5 commits into
mainfrom
auth/security-review-fixes

Conversation

@tibroc

@tibroc tibroc commented Aug 28, 2026

Copy link
Copy Markdown
Member

Part 4 of the M3 series — the "fix what's cheap" outcome of the spec §4 security review. Stacked on #85.

Finding 1 (medium, new code): failed back-channel logout was unretryable. verifyLogoutToken recorded the jti in the replay cache before the session delete ran. A transient DB error answered 504 ("retry later"), but the IdP's retry carries the same token — now rejected as a replayed jti for longer than the token stays fresh, so the logout was silently dropped and the session survived. The cache now has separate contains (checked in validation) and remember (called only after revocation succeeded). Test: 504 under injected DB failure → same token re-POSTed → 200 and sessions revoked; plus an endpoint-level replay test (200 then 400).

Finding 2 (medium, pre-existing, spec §4 checklist "tabs"): open redirect via tab in return_to. sanitizeReturnTo blocked // and /\ but let /\t/evil.com (return_to=%2F%09%2Fevil.com) through; Go writes the interior tab into the Location header verbatim and WHATWG URL preprocessing strips tabs/newlines, so browsers see protocol-relative //evil.com after login. Control bytes (< 0x20, 0x7f) now reject to /. Table test covers tab/CR/LF/VT/DEL and the existing prefixes.

Review summary and the remaining (filed, not fixed) items are in the PR series discussion / issues.

go test -race ./..., golangci-lint: green.

Do not merge — supervisor review.

tibroc added 5 commits August 28, 2026 12:18
…gout

Migration 00015 adds a nullable sessions.oidc_sid (partial index for the
revocation lookup). Callback stores the verified sid claim; IdPs without a
sid keep working — the column is just NULL. SessionStore gains DeleteBySID
and DeleteByOIDCSub (sqlc :execrows) as groundwork for the
/auth/backchannel-logout endpoint (docs/specs/m3-backchannel-logout.md §2.1).

Integration tests cover sid-less logins, per-sid revocation isolation, and
sub-only revocation ending all of a user's sessions.
POST /auth/backchannel-logout accepts the IdP's form-encoded logout token,
validates it per OIDC Back-Channel Logout 1.0 §2.4–2.6 (JWKS signature, iss,
aud, iat freshness, optional exp, events member, nonce absence, sid-or-sub,
jti replay via an in-process TTL cache), then ends the matching sessions:
by oidc_sid when the token carries a sid, else every session of the sub.
200 also when nothing matched — an expired session is a successful logout,
and a distinguishable answer would be an oracle for live sids. Rejections are
400 problem+json, logged with the reason but never the token. Accepted
logouts log issuer, hashed sid/sub, and the session count.

The endpoint is provider-agnostic and always registered; deployments enable
the feature purely by registering the URL at their IdP (golden rule 8).

Tests: the §3 validation table + replay against a new authtest JWKS/discovery
double, handler-level tests over a fake session store (no-store headers,
no-oracle 200, problem+json), and a real-Postgres end-to-end test through the
router: cookie works → logout token lands → old cookie is 401, other sids and
users untouched, sub-only ends all of a user's sessions.
docs/02 §6 gains a 'Logout — both directions' block (validation rules,
revocation semantics, single-instance jti cache note); the Keycloak guide
gains the client Logout settings (Backchannel logout URL + session required),
a verification step, and two troubleshooting rows; README and
config.example.yaml note that registering the URL at the IdP is the only
knob — the endpoint is always on and provider-agnostic.
1. Back-channel logout no longer consumes a token's jti before revocation
   succeeds. The replay cache previously recorded the jti during validation,
   so a transient DB failure (answered 504 so the IdP retries) made every
   retry of the same token fail as a 'replay' — the logout was silently
   dropped and the session survived. The jti is now checked during validation
   and remembered only after the sessions were actually ended.

2. sanitizeReturnTo rejects control characters. Browsers strip ASCII
   tab/newline when parsing a Location URL (WHATWG preprocessing), so
   return_to=%09//evil.com passed the '//' prefix check yet reached the
   browser as protocol-relative //evil.com — an open redirect on the login
   flow. Any byte < 0x20 (and 0x7f) now falls back to '/'.
- Exempt /auth/backchannel-logout from the shared 60/min write limiter: all
  IdP notifications arrive from one IP and Keycloak never retries a 429, so
  an IdP-side mass logout would silently drop sessions' logouts. The route
  carries its own generous per-IP limit (600/min); forged tokens still die at
  signature validation. Router-level test posts a 70-token burst.
- Apply the clock-skew allowance to the stale-iat check (max age + skew), so
  IdP/RP drift can't drop real logouts and the jti-cache TTL comment holds.
- Verify logout-token signatures through a cooldown key set (go-jose over the
  discovered jwks_uri, promoted from indirect dependency): unknown-kid junk at
  the unauthenticated endpoint refetches the JWKS at most once per 30s, while
  cached keys keep verifying. Test counts JWKS hits across a burst.
- Reject a present-but-malformed sid claim (non-string or empty) instead of
  silently escalating to sub-wide revocation.
- Require exp: the final Back-Channel Logout 1.0 spec makes it REQUIRED (the
  optional-exp comment cited a stale draft); spec + docs/02 corrected.
- Drop the manual aud check and Authenticator.clientID — go-oidc's Verify
  already enforces the audience.
- Hoist writeProblem into internal/httpx, shared by the API layer and auth.
@tibroc

tibroc commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

Supervisor review addressed in 7989ddb — all 7 findings, on this branch:

  1. Rate limiter (blocker): /auth/backchannel-logout is exempt from the shared 60/min write limiter and carries its own per-IP limit (600/min). TestBackchannelLogoutNotWriteRateLimited posts a 70-valid-token burst from one IP through the real router — all 200.
  2. iat skew: stale-iat now rejects at logoutTokenMaxAge + logoutClockSkew (7 min); table case "iat older than max age but within skew" (6 min) accepted.
  3. JWKS cooldown: the logout verifier reads keys via a new cooldownKeySet (go-jose v4, already pinned transitively, now a direct dep) — refetch at most once per 30s on cache misses, cached keys keep verifying, failed fetches also cool down. TestLogoutTokenJWKSRefetchCooldown asserts ≤1 fetch across an unknown-kid burst and that a valid token still verifies during cooldown.
  4. Malformed sid: present-but-non-string (or empty) sid → logged 400, never sub-wide fallback; table case with sid: 12345.
  5. exp required: validation rejects a missing exp (final-spec behavior); docs/specs/m3-backchannel-logout.md §2.2 and docs/02 §6 corrected.
  6. Dedupe aud: manual audience check and Authenticator.clientID dropped; go-oidc's Verify enforces it (covered by the wrong-audience table case).
  7. Shared writeProblem: hoisted to internal/httpx.WriteProblem, used by both internal/server (all handler call sites) and internal/auth.

Gates rerun: go test -race ./... (incl. mock-IdP + Postgres integration), golangci-lint 0 issues, tsc, vitest 223, make e2e 115 passed / 6 skipped.

@tibroc
tibroc changed the base branch from auth/backchannel-docs to main August 28, 2026 11:48
@tibroc
tibroc merged commit 207ed32 into main Aug 28, 2026
5 checks passed
@tibroc
tibroc deleted the auth/security-review-fixes branch August 28, 2026 11:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant