Skip to content

fix(auth)!: harden token validation - #20

Open
pantierra wants to merge 7 commits into
mainfrom
fix/token-validation
Open

pantierra wants to merge 7 commits into
mainfrom
fix/token-validation

Conversation

@pantierra

Copy link
Copy Markdown
Contributor

Stricter login token checks, and safer loading of the login server's settings.

Token checks

  • Scopes must match exactly. Before, a token with scope ab was accepted where scope a was required.
  • A token without scopes on a protected route now gets 401. Before, the server crashed with an error.
  • Empty tokens are rejected.
  • Tokens are no longer written to the logs.
  • Tokens must have an expiry time (exp) and an issuer (iss), and the issuer must be our login server.

Loading the login server's settings

  • The request now times out after 10 seconds (discovery_timeout). Before, the app could hang at startup forever.
  • Every failure now raises one clear error, OidcFetchError.
  • The loaded settings are kept, so other code can reuse them.

Breaking change
Tokens without exp or iss, or with a different issuer, are rejected now.

  • To get the old behaviour back, set required_claims=().
  • If your app reaches the login server at an internal address, set issuer to the public one.

- Scopes are compared exactly. Before, a substring check of the `scope`
  claim meant a token with scope "ab" passed a required "a".
- A token without a `scope` claim on a scoped route gets 401 instead of
  an internal error.
- Reject an empty bearer token.
- Never log the Authorization header or the token; log only the
  exception class.
- Require the `exp` and `iss` claims by default and check `iss` against
  the issuer from the discovery document. Both can be set with the new
  `issuer` and `required_claims` options (also in OpenIdConnectSettings).
- `create_auth_token_dependency` accepts `issuer`/`required_claims` as
  keyword-only arguments and keeps its old behaviour by default.

BREAKING CHANGE: tokens without `exp` or `iss`, or whose `iss` differs
from the discovery document's `issuer`, are rejected. Set
`required_claims=()` to restore the previous behaviour, or `issuer` when
the service fetches discovery from an internal URL with another issuer.
- Discovery requests get a timeout (`discovery_timeout`, default 10s);
  before, an unresponsive IdP could block startup indefinitely.
- Any failure (HTTP error, unreachable host, timeout, invalid JSON, no
  `jwks_uri`) raises OidcFetchError. The previous non-200 branch was
  unreachable (urlopen raises HTTPError) and would have failed on the
  nonexistent `response.text`.
- Keep the discovery document as `openid_configuration` (e.g. for
  `token_endpoint`), expose `discovered`, and add a thread-safe,
  idempotent `discover()`.
@claude

claude Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Claude finished @pantierra's task in 17s —— View job


✅ No blocking issues — safe to merge.

I read the diff for auth.py, config.py, __init__.py, the README and the conftest changes. I didn't run the test suite.

  • Scope matching: it now compares against scope.split(), so ab no longer satisfies a. A missing or non-string scope gives 401 instead of a KeyError.
  • Header handling: empty tokens are rejected, and the header is no longer logged.
  • Issuer and claims: iss is only checked when it's in required_claims, so required_claims=() restores the old behaviour. A discovery document with no issuer fails fast with OidcFetchError.
  • Discovery errors: OSError covers HTTP errors, unreachable hosts and timeouts. ValueError covers bad JSON. The missing-jwks_uri check is also sound.
  • JWKS outage: it returns 401 and is logged at warning, so operators can tell it from a bad token.

Simplify (ponytail) — advisory only

  • auth.py _validate_header with functools.partial in create_auth_token_dependency: the factory's inner auth_token could call _validate_header directly with the captured arguments. That would drop the partial and one level of indirection.
  • auth.py _unauthorized: the challenge parameter is only used for the scope case. Inlining the one scoped call would let you drop the parameter. This is minor.

💰 Estimated review cost: $0.11 · 0m16s · 5 turns

- Pass the validate function to _validate_header directly.
- Fetch the discovery document in __post_init__; drop discover(), the
  lock and `discovered`, which nothing in this PR needs.
- Check `iss` only when it is required, so `required_claims=()` keeps
  the old behaviour without a manual issuer comparison.
- Catch all discovery errors with one `except`; urlopen already raises
  for non-2xx responses.
- Fold _decode into _validate_header and drop the validate callable.
- Drop the None return for a missing header; Security(auth_scheme)
  already rejects it, and an empty header fails the bearer check.
- create_auth_token_dependency requires `issuer` and `required_claims`
  so a direct caller can't turn the checks off by accident.
- _unauthorized takes the WWW-Authenticate challenge, so the missing
  scope error reuses it; drop BEARER_CHALLENGE.
- create_auth_token_dependency binds its arguments with functools.partial.
An unreachable JWKS still gives 401, but is now logged at warning so
operators see a provider outage. The README notes that only the
space-separated `scope` claim is read.
@pantierra
pantierra requested a review from alukach October 13, 2026 19:01

This branch has not been deployed

No deployments
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