* Add OAuthProxy issuer response parameter
* Cover OAuthProxy issuer error redirects
* Relax host origin guard defaults (#4439)
* Use exact issuer in authorize errors
* Restore HTTP host guard compatibility (#4472)
* Hugging Face Auth Integration (#4385)
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
* Docs: add v3.4.4 changelog entries (#4473)
* Explain unnormalized issuer; cover consent-denial path base_url
* Revert "Merge remote-tracking branch 'origin/release/3.x' into codex/oauth-proxy-rfc9207-issuer"
This reverts commit 9e34b1686c, reversing
changes made to 640dc60fe0.
* Preserve callback query bytes when appending iss/code/state params
add_query_params previously decoded the existing query with parse_qsl
and re-encoded it, mutating opaque or signed query strings (a valueless
?flag became ?flag=, non-UTF-8 percent-encoded bytes got replaced).
Append the newly-encoded params to the existing query string instead of
round-tripping it through parse/encode.
Also fixes a stray bare `httpx` reference in a test that should use
httpx2 following the SDK v2 migration.
* Attach RFC 9207 iss to authorize() success redirects too
AuthorizationHandler only added iss to error redirects from the SDK's
base handler, not to code redirects returned directly by authorize()
overrides that bypass consent/upstream (as GitHub's mocked test does).
Since metadata now unconditionally advertises
authorization_response_iss_parameter_supported, any client-facing
redirect missing iss hard-fails RFC 9207-aware clients.
Also fixes HeadlessOAuth, which parsed code/state from the redirect
but silently dropped iss, so the same regression would have masked
itself across every other provider integration test too.
* Carry RFC 9207 iss through the production OAuth callback path
OAuthProxy advertises authorization_response_iss_parameter_supported and
sends iss on every authorization redirect, but the client's production
callback chain (CallbackResponse -> OAuthCallbackResult -> OAuth.callback_handler)
had no iss field, so it was silently dropped and the SDK's
validate_authorization_response_iss rejected the callback. HeadlessOAuth
already carried iss through, which is why CI stayed green while real
clients failed.
Add iss to CallbackResponse and OAuthCallbackResult, thread it through
store_result_once for both success and error branches, and pass it into
AuthorizationCodeResult in OAuth.callback_handler.
* Don't duplicate iss when a provider redirect already carries one
* Consolidate RFC 9207 iss handling into a single redirect helper
Every client-facing authorization redirect must carry exactly one iss.
That invariant was being enforced by hand at five separate call sites,
each building its own params dict -- which is how the success-redirect
path shipped without iss in the first place, and how a registered
redirect_uri that already carries its own iss could end up duplicated.
Route all five sites through build_client_redirect(), which owns the
idempotent replace-or-append behavior so no caller can get it wrong.
---------
Co-authored-by: shaun smith <1936278+evalstate@users.noreply.github.com>
* Add server-side SEP-990 identity assertion (ID-JAG)
* Test SEP-990 identity assertion token endpoint
* Format identity assertion test
* Document SEP-990 identity assertion
* Thread identity_assertion through OIDCProxy
* Harden ID-JAG: authoritative scopes and grant-type enforcement
Scopes for the issued token now derive only from the signed assertion (or server policy when it omits scopes); the client-supplied request scope may narrow but never widen them. Enforce the registered grant-type constraint the SDK check bypassed, and have proxy DCR add the jwt-bearer grant to registered clients when identity assertion is enabled.
* Harden ID-JAG: honor nbf, reject non-object payload, bound jti cache, preserve required_scopes
* Document per-process ID-JAG replay limitation and nbf check
* Harden ID-JAG round 3: resource indicator, non-object header, algorithm config
- Honor RFC 8707 resource on the jwt-bearer grant (invalid_target on
mismatch), mirroring authorize()'s invariant incl. skip-when-unconfigured
- Reject JSON-array JOSE headers with invalid_grant instead of a 500
- Add IdentityAssertion.algorithm so ES256/PS256 issuers can be verified
(JWTVerifier otherwise defaults to RS256)
* Bind ID-JAG exchange to the assertion's signed client_id and resource
SEP-990: the IdP signs which client and which resource the assertion was
minted for. With public proxy clients the presented client_id is
self-asserted, so the signed binding is what stops client B redeeming
client A's leaked assertion — and the signed resource claim stops an
assertion for server A being redeemed at server B behind the same IdP.
* Harden ID-JAG round 4: check bindings before jti consumption; validate temporal claims, algorithm, and discovery body
- Move the client_id/resource binding checks into the validator itself,
before jti is recorded as consumed, so an assertion presented with the
wrong binding is rejected without burning replay protection for whoever
it actually belongs to
- Reject non-numeric exp/iat/nbf with invalid_grant instead of a 500
- Validate IdentityAssertion.algorithm at config time (must be an
asymmetric JWS algorithm verifiable via JWKS)
- Reject a non-object OIDC discovery body with invalid_grant instead of a 500
- Centralize the resource-URL comparison helpers used by both the
validator and OAuthProxy.authorize()
* Rebase onto httpx2/SDK b2 and harden ID-JAG round 5
- Migrate identity assertion + tests to httpx2 and the local httpx2_mock
(legacy httpx is now banned; pytest-httpx no longer intercepts)
- Add is_optional to the shared httpx2_mock, mirroring pytest-httpx
- Tighten the algorithm allowlist to JWTVerifier's exact supported set
(prefix check accepted typos like RS999 -> 500 on first exchange)
- Reject non-string jti before the cache lookup (unhashable -> 500)
- Track revocation for self-contained ID-JAG tokens: revoke_token records
the jti and load_access_token rejects it until natural expiry
- Dedupe resource-URL helpers: proxy now imports the shared
normalize_resource_url/server_url_has_query from identity_assertion
* Advertise 'none' token-endpoint auth method when ID-JAG is enabled without CIMD
DCR clients are public, so metadata consumers must see 'none' to use the
advertised jwt-bearer grant; previously only the CIMD path added it.
* Document 2026-07-28 protocol support as a distinct feature catalog
SEP-990 identity assertion leads: the SDK provides the wire contract and
provider hook; FastMCP provides the complete server-side implementation.
Inventories the full modern-era capability set for v4.
* Harden ID-JAG round 6: lazy re-export, dual-form audience, per-issuer algorithms, discovery backoff
- IdentityAssertion re-exported lazily from server.auth (the eager import
bypassed the package's documented lazy-import boundary)
- Accept the ID-JAG aud both with and without base_url's trailing slash;
metadata advertises the slashed form, so IdPs echoing it verbatim work
- algorithms={issuer: alg} per-issuer override, mirroring jwks_uris
- OIDC discovery serializes per-issuer and backs off 30s after a failure
(discovery runs pre-signature, so garbage could amplify into HTTP floods)
* Transparently refresh upstream token in OAuthProxy.load_access_token()
When upstream token validation fails during load_access_token, attempt
to refresh using the stored refresh token before returning None. This
prevents premature 401s that force clients into expensive full re-auth
flows when the upstream token expires.
Co-authored-by: Claude <noreply@anthropic.com>
* Gate transparent refresh on token expiry, add advisory lock
Only attempt upstream refresh when the token is actually expired, not
on any validation failure (scope mismatch, revocation, etc.). Add
per-token advisory lock to prevent concurrent async tasks from racing
to refresh the same upstream token.
* Re-check expiry inside lock, reload from storage after refresh failure
---------
Co-authored-by: Claude <noreply@anthropic.com>
Keycloak returns refresh_expires_in=0 for offline tokens (offline_access scope),
meaning "no fixed time-based expiry". The truthiness check on this value caused
the proxy to skip issuing a PROXY_RT, forcing browser re-auth every hour.
Closes#3509🤖 Generated with Claude Code
Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com>
* feat: make upstream_client_secret optional in OAuthProxy
Extract _create_upstream_oauth_client() factory method for subclass
override. Cookie signing falls back to JWT key material when no secret.
* fix: include client_id in revocation requests for public clients
* fix: use factory method for revocation auth
Updated all OAuthProxy test instantiations to use MemoryStore instead of defaulting to DiskStore, avoiding SQLite timeout issues on Windows and improving test performance.
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>