Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,12 @@ jobs:
exit 1

- name: go test real Keycloak e2e
run: go test -tags=keycloak_e2e -run TestKeycloakE2EFullOAuthFlow -count=1 .
# Run every TestKeycloakE2E* test — happy path + the
# negative-path tests (consent denied, replayed code,
# refresh family revoke, resource mismatch). Adding new
# negative-path tests does not require a CI workflow change
# as long as they keep the TestKeycloakE2E_ prefix.
run: go test -tags=keycloak_e2e -run "^TestKeycloakE2E" -count=1 -v .

- name: dump compose logs on failure
if: failure()
Expand Down
6 changes: 3 additions & 3 deletions docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ privilege.
| # | STRIDE | Threat | Mitigation | Code | Test | Runbook |
|---|---|---|---|---|---|---|
| 1 | T / I | **DCR abuse** — mass client registration to exhaust quota or phish via attacker-controlled `client_name` | Per-IP rate limit on `/register` (10/min default); control-byte strip on `client_name` at registration; `mcp_auth_clients_registered_total` for trend monitoring | `handlers/register.go`, `main.go` (`registerLimit`) | `handlers/handlers_test.go` (`TestRegister_*`) | — |
| 2 | S | **Active-IdP-session phishing** — malicious DCR client + an active IdP session = silent token issuance without user interaction | Proxy-rendered consent page (default `RENDER_CONSENT_PAGE=true`); per-render `jti` makes the consent token single-use (T1.2); `client_name` rendered via `html/template` contextual escaping; CSP locks the page (no JS, no remote subresources) | `handlers/authorize.go` (consent fork), `handlers/consent.go` | `handlers/consent_test.go`, `handlers/single_use_replay_test.go` | `docs/runbooks/consent-denials.md` |
| 2 | S | **Active-IdP-session phishing** — malicious DCR client + an active IdP session = silent token issuance without user interaction | Proxy-rendered consent page (default `RENDER_CONSENT_PAGE=true`); per-render `jti` makes the consent token single-use (T1.2); `client_name` rendered via `html/template` contextual escaping; CSP locks the page (no JS, no remote subresources) | `handlers/authorize.go` (consent fork), `handlers/consent.go` | `handlers/consent_test.go`, `handlers/single_use_replay_test.go`, `keycloak_e2e_test.go` (`TestKeycloakE2E_ConsentDenied` against real Keycloak) | `docs/runbooks/consent-denials.md` |
| 3 | T | **Redirect-URI / open-redirect abuse** — crafted `redirect_uri` matches loosely or smuggles attacker host | Exact-match against registered URIs with RFC 8252 loopback-port relaxation; fragment-bearing URIs rejected at DCR; fragment scrubbed again on the success redirect (defense in depth) | `handlers/helpers.go` (`redirectURIMatches`), `handlers/register.go`, `handlers/callback.go` (fragment scrub) | `handlers/handlers_test.go` (redirect tests) | — |
| 4 | S | **Authorization-code replay** — captured authorization code submitted twice within its TTL | `replay.Store.ClaimOnce` on `TokenID` at `/token`, run after parameter validation but before token issuance so a malformed legitimate retry doesn't burn the code; on detected replay the family of refresh tokens seeded by that code is revoked (RFC 6749 §4.1.2 MUST) | `handlers/token.go` (`handleAuthorizationCode`), `replay/redis.go` | `handlers/handlers_test.go` (replay tests) | `docs/runbooks/redis-outage.md` |
| 5 | S | **Refresh-token replay / family compromise** — captured refresh submitted after legit rotation | Atomic Lua `ClaimOrCheckFamily` on Redis: claim + family revoke happen as one EVAL so the invariant "alreadyClaimed ⇒ family revoked" cannot be violated by a client disconnect or Redis blip. The script also classifies sub-grace-window collisions as `racing` (returns 429 `refresh_concurrent_submit`, family NOT revoked) so benign parallel-tab refreshes don't kill the lineage. `REFRESH_RACE_GRACE_SEC` (default 2s, max 10s, set 0 to disable) tunes the window. **Rolling-deploy transient:** claims minted by a pre-T2.3 binary stored a placeholder value instead of an epoch ms; collisions on those entries fall through to the strict revoke path (matches the pre-T2.3 contract) — the grace window starts applying only to claims minted by the new binary | `handlers/token.go` (`handleRefreshToken`), `replay/redis.go`, `replay/memory.go` | `handlers/handlers_test.go` (refresh tests), `handlers/single_use_replay_test.go` (`TestTokenRefresh_RaceGrace_*`), `replay/redis_test.go` | `docs/runbooks/revoke.md` |
| 4 | S | **Authorization-code replay** — captured authorization code submitted twice within its TTL | `replay.Store.ClaimOnce` on `TokenID` at `/token`, run after parameter validation but before token issuance so a malformed legitimate retry doesn't burn the code; on detected replay the family of refresh tokens seeded by that code is revoked (RFC 6749 §4.1.2 MUST) | `handlers/token.go` (`handleAuthorizationCode`), `replay/redis.go` | `handlers/handlers_test.go` (replay tests), `keycloak_e2e_test.go` (`TestKeycloakE2E_ReplayedCode` against real Keycloak) | `docs/runbooks/redis-outage.md` |
| 5 | S | **Refresh-token replay / family compromise** — captured refresh submitted after legit rotation | Atomic Lua `ClaimOrCheckFamily` on Redis: claim + family revoke happen as one EVAL so the invariant "alreadyClaimed ⇒ family revoked" cannot be violated by a client disconnect or Redis blip. The script also classifies sub-grace-window collisions as `racing` (returns 429 `refresh_concurrent_submit`, family NOT revoked) so benign parallel-tab refreshes don't kill the lineage. `REFRESH_RACE_GRACE_SEC` (default 2s, max 10s, set 0 to disable) tunes the window. **Rolling-deploy transient:** claims minted by a pre-T2.3 binary stored a placeholder value instead of an epoch ms; collisions on those entries fall through to the strict revoke path (matches the pre-T2.3 contract) — the grace window starts applying only to claims minted by the new binary | `handlers/token.go` (`handleRefreshToken`), `replay/redis.go`, `replay/memory.go` | `handlers/handlers_test.go` (refresh tests), `handlers/single_use_replay_test.go` (`TestTokenRefresh_RaceGrace_*`), `replay/redis_test.go`, `keycloak_e2e_test.go` (`TestKeycloakE2E_RefreshReuseRevokesFamily` against real Keycloak) | `docs/runbooks/revoke.md` |
| 6 | S | **Consent-token replay** — captured `consent_token` POSTed twice within its 5-min TTL | `JTI` `ClaimOnce` at POST `/consent` before either Approve or Deny; per-render `JTI` so back-button = re-consent (a new claim slot) | `handlers/consent.go` | `handlers/single_use_replay_test.go` (`TestConsent_SingleUse_*`) | — |
| 7 | S | **Callback-state replay** — captured `/callback` URL replayed (e.g. attacker-observable redirect) | `SessionID` `ClaimOnce` BEFORE the upstream OIDC token-endpoint exchange — replay never fans out to the IdP and never produces audit-log noise | `handlers/callback.go` | `handlers/single_use_replay_test.go` (`TestCallback_SingleUse_*`) | — |
| 8 | D | **Redis outage** — replay store unreachable | Fail-closed at every claim site: 503 `server_error` + `error_code=replay_store_unavailable`. `mcp_auth_access_denied_total{reason="replay_store_unavailable"}` is the single alerting source. `PROD_MODE=true` validates `REDIS_REQUIRED=true` + `REDIS_URL` set at startup | `replay/redis.go`, `config/config.go` (`Validate`), every claim site | `config/config_test.go` (production posture) | `docs/runbooks/redis-outage.md` |
Expand Down
Loading