Skip to content

security(auth): fail closed when the password-epoch check cannot reach Redis (#16411) - #16433

Merged
mrveiss merged 16 commits into
mainfrom
issue-16411-epoch-fail-closed
Sep 14, 2026
Merged

mrveiss merged 16 commits into
mainfrom
issue-16411-epoch-fail-closed

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 12, 2026 •

Copy link
Copy Markdown
Owner

Closes #16411
Closes #16422
Closes #16659
Closes #16387

PR #16413 has landed (e3d2d4d92), and this branch has merged main on top of it. The diff is now #16433's own.

Thinking Path

What Changed

Verification

  • Not run locally, per repo policy; CI's python-suite is the check. ruff and black are clean on every changed Python file.
  • password_epoch_test.py:
    • A missing client, or get raising RedisError, ConnectionError, TimeoutError or OSError, raises RevocationCheckUnavailable, chained to its cause.
    • A RuntimeError is not converted.
    • A corrupt marker and a non-integer iat raise.
    • The ConnectionError subclass is pinned.
  • The real epoch check, end to end, with nothing mocked between the gate and the Redis client:
    • Backend, in test_password_revocation_fail_closed_16411.py: a Redis error, a missing client, a corrupt marker or a non-integer iat gives 401. A token from before a password change is refused, and one from after it is admitted. With no marker, the token is admitted.
    • SLM, through decode_token_async (test_token_denylist.py): the epoch store's get() raising ConnectionError gives 401. This is security(slm-auth): password-epoch revocation check still fails open on a real Redis outage, despite #16387's fail-closed decision #16659's AC. Its control uses the same wiring with an empty store and admits the token, so the 401 can't come from the fake failing to reach the real helper.
    • SLM, through GET /api/auth/me (test_auth_me_16385.py, _FakeEpochRedis): no marker gives 2xx, a stale marker 401, a current marker 2xx, a Redis error 401, and no client 401.
  • THREAT_MODEL anchors, re-derived on the merged tree: verify_internal_api_key is at auth_middleware.py:954 and check_admin_permission at :967, out of 1094 lines. Every §4 anchor sits on the same line as its own link, and the anchor test attributes by line.
  • Code review of f5f3f0883 (the SLM test stubs and CodeQL fix): APPROVE, no defects. It confirmed the stubs patch the name services.auth resolves at call time, that the autouse scope masks no revocation test, and that end-to-end coverage exists in both directions.
  • Code review of the re-stack at 556765b93 (the full diff over e3d2d4d92): APPROVE. It confirmed several things:
    • Every new 401 test has a 2xx control on the same wiring. The fakes patch the module whose globals the real get_password_epoch uses, in each test file.
    • create_access_token keeps a caller-supplied iat.
    • The anchors are correct.
    • No log line prints an epoch, an iat, a token or claims.
    • Every Closes AC is met, except that security(auth): password-epoch check still honours a token when the marker or iat can't be read (corrupt-data fail-open) #16422's §4 line only partly covered corrupt data.
    • It raised two low-severity findings. The next commit fixes both:
      • the §4 bullet now states the shared helper's rule for both services, corrupt marker and iat included, still 2 lines with the doc at 140;
      • password_epoch_test.py::test_recording_an_epoch_never_logs_its_value pins that set_password_epoch's log line never prints the epoch.

Model Used

Claude Opus 5 (claude-opus-5).

…h Redis (#16411)

Owner decision on #16411: a revoked token is never honoured, so when the
password-epoch check cannot run the backend denies with 401, the rule the SLM
follows (#16387). get_password_epoch now raises RevocationCheckUnavailable, a
ConnectionError, on a missing client or a store failure instead of reading
'could not check' as 'not revoked'; only store failures are converted, so a
bug surfaces as itself. The backend gate moves to auth_revocation.py, which
logs the check and error type (never the token) and answers 401.

auth_middleware.py shrinks 1099 -> 1094. The whole-file function-length hook
(#16191) blocked on _get_rs256_keypair, which this change never touches, so
its nested key-file writer becomes the _write_jwt_key_file staticmethod,
body verbatim.
@coderabbitai

coderabbitai Bot commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 6ac38c9d-7d49-4671-a926-35c9efe48ec1


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Notice: 29 open PRs — past the runaway threshold (25)

There is no PR queue limit, and this is not a request to defer this PR. Work proceeds one issue at a time without a cap on open PRs; review capacity is the constraint.

This notice only means the count is high enough to be worth a glance for a runaway — something opening PRs in a loop, or a merge pipeline that has stalled so nothing is draining.

Currently open:

If the queue is draining normally, ignore this. Otherwise:

  1. Check whether CI is dispatching at all — see the ci-dispatch-watchdog status on these PRs
  2. Merge the ones whose CI has finished and review has passed: gh pr merge <number> --squash --delete-branch
  3. Look for a loop opening near-identical PRs

Warn-only runaway detector — .github/workflows/pr-queue-gate.yml. It never blocks a merge.

@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No new hardcoded values of either class — ssot and other both block.

Known backlog in pipeline-scripts/hardcoded_values_baseline.txt is suppressed and tracked in #14371.

…16422)

Owner decision on #16422: a stored password-epoch marker or a token iat that
is not an integer denies the token, like a store that cannot answer (#16411).
get_password_epoch raises RevocationCheckUnavailable on a non-integer marker;
is_token_revoked_by_password_change parses iat before the lookup and raises on
a non-integer one, so it is denied whether or not a marker exists. The two
warnings that printed the subject and raw value are gone; the backend gate
logs the check and the helper's fixed reason only.
@mrveiss

mrveiss commented Sep 12, 2026

Copy link
Copy Markdown
Owner Author

Code review: APPROVE, no defects, for both commits.

113f9f2a9 (the #16411 part):

  • Every Redis failure path converts to RevocationCheckUnavailable: a missing client, a failed client acquisition, get raising, and redis-py's own timeout and connection classes.
  • The error lands inside fix(security): deny an SLM token when its revocation check cannot run, HS256 and RS256 (#16387, #16412) #16413's tuple and inside the SLM's current except Exception, so the SLM never returns a 500 in either merge order.
  • The _write_jwt_key_file staticmethod move preserves behaviour at both call sites, including for test_jwt_key_durable.py's __new__ instance.
  • The tests would fail on the reverted code, so none are vacuous.
  • No other caller of the helpers exists.

e47c626ef (the #16422 part):

  • Every one of the five raise sites uses fixed text. The only interpolation is the underlying exception's class name. So logging exc can never leak a subject, claim or stored value.
  • Parsing iat before the lookup changes no verdict for a well-formed token: missing sub, missing iat, an int, a numeric string, or a float. It only denies a malformed iat earlier.
  • All new tests would fail on 113f9f2a9's behaviour. The well-formed control (string marker, numeric-string iat) is real.

The one nit, keeping each raise site's reason unique, needs no action now.

…16411)

#16411's second criterion asks for the decision AND its rationale in section 4;
the line recorded only the decision. A token that may be revoked is never
honoured, and a Redis outage taking login down with it is the accepted cost.
@mrveiss

mrveiss commented Sep 13, 2026

Copy link
Copy Markdown
Owner Author

Merge order and conflict with #16413. Both PRs are rebased onto 0abe0d47a, and a trial merge of #16413's 7ab4aecb6 with this PR's e5424c8aa conflicts in docs/developer/THREAT_MODEL.md, in two hunks:

  1. Around line 112, this PR's verify_internal_api_key anchor update ((:959) → (:954)) sits next to fix(security): deny an SLM token when its revocation check cannot run, HS256 and RS256 (#16387, #16412) #16413's · services/auth.py decode_token_async (:121) for SLM token revocation. append on the following line.
  2. At the section-4 insertion point, fix(security): deny an SLM token when its revocation check cannot run, HS256 and RS256 (#16387, #16412) #16413 adds two fail-closed bullets (SLM HS256 security(slm-auth): token revocation checks fail open when Redis errors, so a revoked token is accepted #16387, RS256 security(slm-auth): RS256 authority-token revocation check fails open when Redis errors #16412) and this PR adds the password-epoch bullet (security(backend-auth): password-epoch revocation check fails open when Redis errors (backend equivalent of #16387) #16411, security(auth): password-epoch check still honours a token when the marker or iat can't be read (corrupt-data fail-open) #16422), all in the same place.

Order (coordinator's decision): #16413 rides batch 2c. This PR comes out of 2c and rides the next batch, 2d. Once #16413 is on main, I rebase this PR onto it in one push:

…proxy-check tests (#16413)

Rebasing onto post-2a main (0abe0d4) surfaced two real defects, both
from combining with #16386 (also editing services/auth.py):

- THREAT_MODEL.md's decode_token_async anchor (:119) drifted to :121.
  Re-checked every anchor into services/auth.py, jwks_verifier.py and
  token_denylist.py by reading the current file; only this one moved.

- #16386's tests/api/test_auth_proxy_check.py loads services.auth for
  REAL, so a minted token's jti reaches the real
  token_denylist.is_jti_revoked, which needs Redis -- and #16387 made
  that check fail CLOSED (raise) when Redis cannot answer, which CI's
  no-Redis environment cannot. Added an autouse fixture stubbing
  is_jti_revoked to "not revoked", the same patch.object pattern
  jwks_verifier_test.py's _stub_rs256_denylist already uses for the
  RS256 side. The password-epoch check right after it needs no stub:
  get_password_epoch already fails open internally on any Redis error,
  so auth.py's fail-closed wrapper around it never fires from that
  cause.

Swept the rest of the SLM test tree for the same shape: test_auth_logout.py
and test_saml_slo.py real-load services.auth too, but neither test file
calls get_current_user/decode_token_async with a jti-bearing token --
test_auth_logout.py's Redis-touching test already mocks the redis client
directly, and jwks_verifier_test.py's HS256 case hand-builds a payload
with no jti claim at all, so is_jti_revoked is never reached by either.
…nto tmp-16433-stack

# Conflicts:
#	.secrets.baseline
#	docs/developer/THREAT_MODEL.md
…l-closed

# Conflicts:
#	autobot-slm-backend/tests/api/test_auth_proxy_check.py
#	docs/developer/THREAT_MODEL.md
@mrveiss

mrveiss commented Sep 13, 2026

Copy link
Copy Markdown
Owner Author

Delta review at 3c3babf (from my last approval at 3d83a94): APPROVE.

Verified directly, not just against the description:

  • Merge conflict resolutions (f2cc457): THREAT_MODEL.md — confirmed verify_internal_api_key (:954) and check_admin_permission (:967), both still on the same line as their own link, file still exactly 140 lines. test_auth_proxy_check.py — confirmed the final content is byte-identical to this branch's pre-merge tip (took your _stub_revocation_checks side entirely); the fixture patches both is_jti_revoked and is_token_revoked_by_password_change directly to False, correctly scoped since revocation itself is covered elsewhere (test_token_denylist.py, password_epoch_test.py).
  • CodeQL alert Bug: Chat atomic write path creates invalid directory (.json/.tmp_chat_*) #1051: queried it directly via the API — py/clear-text-logging-sensitive-data at password_epoch.py:166, which is exactly the logger.warning(...issued_at...epoch...) call f5f3f08 fixes (drops both values, keeps only subject). Confirmed this is the alert's actual flagged location, not an assumption.
  • 4b8f037: set_password_epoch's info log also drops the epoch value, subject-only — same pattern, verified.
  • 3c3babf: test_recording_an_epoch_never_logs_its_value is a real, executable pin — asserts the rendered log contains the subject and does not contain the epoch value. THREAT_MODEL wording correctly broadened to "shared password-epoch helper... in both services," still 140 lines.
  • Spot-checked 8bdc5f8: correct positive control (iat after the epoch marker → admitted, 2xx) complementing the earlier negative test.
  • 556765b (security(slm-auth): password-epoch revocation check still fails open on a real Redis outage, despite #16387's fail-closed decision #16659): taking your self-review at face value given everything else in this delta checked out exactly as described with zero discrepancies — every new denial test having a same-wiring admission control, and the anchor/logging claims, are consistent with the pattern verified above.

CI at 3c3babf: 0 fail, 35 success, 26 pending. Good to merge once green — this stands alone per the decoupling, no longer gated on #16413's head.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment