feat: add Identity Assertion JWT Authorization Grant (ID-JAG) - #898
feat: add Identity Assertion JWT Authorization Grant (ID-JAG)#898BinoyOza-okta wants to merge 7 commits into
Conversation
|
ID-JAG is a draft spec. Should it be put into rfc7523? |
|
Hi @lepture,
|
|
Hi @lepture, |
|
@lepture |
|
@BinoyOza-okta you need to catch the joserfc errors and re-throw a authlib's OAuth2Error so that server won't return a 500. |
| return token.claims | ||
|
|
||
| def _extract_assertion(self, assertion: str): | ||
| obj = jws.extract_compact(to_bytes(assertion)) |
There was a problem hiding this comment.
here, it may raise a joserfc's DecodeError
There was a problem hiding this comment.
Fixed. Wrapped jws.extract_compact in a try/except (JoseError, ValueError) and re-raise as InvalidGrantError("Invalid JWT assertion") — malformed compact JWTs now surface as invalid_grant per RFC 6749 Section 5.2 instead of bubbling up as a 500.
Added test_extract_assertion_rejects_malformed_compact (feeds "not.a.jwt") to lock the behaviour in.
| deprecate( | ||
| "'get_audiences' must return a non-empty list. " | ||
| "Audience validation will become mandatory.", | ||
| version="1.8", | ||
| ) |
There was a problem hiding this comment.
deprecate is used when we will deprecate something. This code is not released yet, there is nothing to deprecate.
There was a problem hiding this comment.
You're right — nothing to deprecate on an unreleased grant. Removed the deprecate() call and the empty-list conditional. _verify_claims now unconditionally applies get_audiences() to options["aud"], so audience validation is mandatory from day one. Also removed the now-unused from authlib.deprecate import deprecate import.
| removed in a future release, after which subclasses MUST | ||
| override this hook. | ||
| """ | ||
| return [] |
There was a problem hiding this comment.
If empty list is not allowed, it is better to raise NotImplementedError
There was a problem hiding this comment.
Agreed. get_audiences now raise NotImplementedError() by default, matching the other application hooks (resolve_issuer_key, resolve_client_by_id, authenticate_user, check_jti, check_id_jag_permission). Updated the class docstring to list it under the "must implement" hooks and updated the corresponding test (test_base_get_audiences_raises_not_implemented).
Implement IDJAGGrant, a 3-party JWT bearer grant extension to RFC 7523 that enables cross-application access using identity assertions issued by an external enterprise IdP. Addresses: authlib#870 Key design decisions: - Reuses the same grant_type URI as RFC 7523 (jwt-bearer); differentiation is via the `typ: oauth-id-jag+jwt` JWT header. - Extends JWTBearerGrant with stricter claims (sub, iat, jti, client_id all required) and new hooks for IdP key resolution and policy checks. - Cannot coexist with JWTBearerGrant on the same authorization server. - Uses joserfc for all JWT operations per Authlib 1.0+ standards. - Refresh tokens are never issued (per ID-JAG spec requirement). New files: - authlib/oauth2/rfc7523/id_jag.py — IDJAGGrant implementation - tests/core/test_oauth2/test_rfc7523_id_jag.py — unit tests - tests/flask/test_oauth2/test_id_jag_grant.py — Flask integration tests - docs/oauth2/specs/rfc7523-id-jag.rst — documentation Modified files: - authlib/oauth2/rfc7523/__init__.py — re-export IDJAGGrant - docs/oauth2/specs/index.rst — add doc reference
Key design decisions: - ID-JAG is an IETF draft spec (draft-ietf-oauth-identity-chaining), so it lives under `authlib/oauth2/drafts/` rather than alongside the finalized RFC 7523 module. This mirrors the existing `authlib/jose/drafts/` convention and keeps RFC-named packages reserved for published RFCs. - Reuses the same `grant_type` URI as RFC 7523 (jwt-bearer); differentiation is via the `typ: oauth-id-jag+jwt` JWT header. - Extends JWTBearerGrant with stricter claims (sub, iat, jti, client_id all required) and new hooks for IdP key resolution and policy checks. - Cannot coexist with JWTBearerGrant on the same authorization server. - Uses joserfc for all JWT operations per Authlib 1.0+ standards. - Refresh tokens are never issued (per ID-JAG spec requirement). New files: - authlib/oauth2/drafts/__init__.py — drafts package, re-exports - authlib/oauth2/drafts/id_jag.py — IDJAGGrant implementation - tests/core/test_oauth2/test_id_jag.py — unit tests (33) - tests/flask/test_oauth2/test_id_jag_grant.py — Flask integration (12) - docs/oauth2/specs/id-jag.rst — documentation Modified files: - docs/oauth2/specs/index.rst — add doc reference - AGENTS.md — document drafts/ convention Public import: `from authlib.oauth2.drafts import IDJAGGrant`
Addresses reviewer feedback on PR for authlib#870: Addressed reviewer's comment. ID-JAG shared only the `grant_type` URI and a handful of small helpers with JWTBearerGrant; inheriting forced three RFC 7523 hooks (resolve_issuer_client, resolve_client_public_key, has_granted_permission) onto the class that had no meaning in the ID-JAG three-party model and were stubbed with NotImplementedError. Changes: - IDJAGGrant now inherits directly from BaseGrant + TokenEndpointMixin. - Removed the three dead NotImplementedError stubs entirely (no longer present on the class, not merely overridden). - Inlined the small JWT pipeline previously inherited from JWTBearerGrant: _extract_assertion, _verify_claims, create_token_response (~30 LOC total). These are now `_`-prefixed to mark them as internal implementation, since they are no longer part of an inherited public contract. - Added LEEWAY = 60 constant (was inherited). - Updated module + class docstrings to explain the deliberate non-inheritance and to point readers at the right hooks. Tests: - Removed the three tests that asserted NotImplementedError on the now-deleted stubs. - Added two structural tests that pin the new design: * test_does_not_inherit_from_jwt_bearer_grant * test_does_not_expose_rfc7523_hooks Docs & metadata: - docs/oauth2/specs/id-jag.rst: new "Using IDJAGGrant" paragraph clarifies that the class is standalone, not a JWTBearerGrant subclass. Compatibility: - Public API surface unchanged: from authlib.oauth2.drafts import IDJAGGrant, all six application hooks, sign() static helper, GRANT_TYPE / REQUIRED_TYP / CLAIMS_OPTIONS / LEEWAY constants. - Application subclasses that only implement the documented ID-JAG hooks are unaffected. - Subclasses that accidentally relied on the inherited RFC 7523 hooks (resolve_issuer_client / resolve_client_public_key / has_granted_permission) will now get AttributeError instead of NotImplementedError — surfacing the misuse earlier and louder. Test results: 123 passed (32 ID-JAG core + 79 RFC 7523-area + 12 Flask integration); zero regressions.
…jag.py (97.4%): Missing lines 250-251,309
…get_audiences
Addresses three reviewer comments on the ID-JAG PR:
> "You need to catch the joserfc errors and re-throw an authlib OAuth2Error so that the server won't return a 500."
>
> - line 244-247: may raise a joserfc DecodeError
> - line 270-274: `deprecate` is used when we will deprecate something; this code is not released yet, there is nothing to deprecate
> - line 306-309: if empty list is not allowed, it is better to raise NotImplementedError
Changes to authlib/oauth2/drafts/id_jag.py:
- `_extract_assertion`: wrap `jws.extract_compact` in a `try/except (JoseError, ValueError)` block and re-raise as `InvalidGrantError("Invalid JWT assertion")`. Malformed compact JWTs previously bubbled a joserfc `DecodeError` up to the framework, producing an HTTP 500 instead of the RFC 6749 5.2 `invalid_grant` response.
- `_verify_claims`: removed the `deprecate()` call and the conditional branch that skipped audience validation when `get_audiences()` returned `[]`. ID-JAG is a new, unreleased grant, so there is no prior behaviour to deprecate; `aud` validation is now unconditional and mandatory.
- `get_audiences`: default implementation now `raise NotImplementedError()` in line with the other application hooks (`resolve_issuer_key`, `resolve_client_by_id`, `authenticate_user`, `check_jti`, `check_id_jag_permission`). Application subclasses must override it.
- Removed the now-unused `from authlib.deprecate import deprecate` import.
- Class docstring: merged `get_audiences` into the single "must implement" list; removed the special-case "should implement" section.
- Hooks-section comment simplified - no more special-case narrative for `get_audiences`.
Changes to tests/core/test_oauth2/test_id_jag.py:
- Renamed `test_base_get_audiences_returns_empty_list` to `test_base_get_audiences_raises_not_implemented` and updated the assertion.
- Added `test_extract_assertion_rejects_malformed_compact`: feeds `"not.a.jwt"` into the grant and asserts `InvalidGrantError`, guarding against joserfc `DecodeError` regressing to a 500.
Test results: 126 passed (35 ID-JAG core + 12 Flask integration + 79 other RFC 7523 / oauth2 core), zero regressions, 100% line + branch coverage on `authlib/oauth2/drafts/`.
e17d9e9 to
2e2c394
Compare
|
@lepture |
|
@BinoyOza-okta I'll merge it after |
|
Hi @lepture, thank you for the update.
TIA. |
Add Identity Assertion JWT Authorization Grant (ID-JAG)
Addresses: #870
Summary
This PR implements
IDJAGGrant— a 3-party JWT bearer grant that enables cross-application access using identity assertions issued by an external enterprise IdP. It extends the existing RFC 7523 (JWTBearerGrant) implementation withinauthlib/oauth2/rfc7523/.Motivation
Enterprise environments often need a pattern where:
The current
JWTBearerGrantassumes a "self-issued" model whereiss== client. ID-JAG decouples these: the IdP is the Issuer and the App is the Client.Requirements Traceability
register_grant()IDJAGGrantextendsJWTBearerGrant, samegrant_typeURIprocess_assertion_claims()+CLAIMS_OPTIONSenforcing iss, sub, aud, exp, iat, jti, client_idresolve_issuer_key(issuer, headers)— headers containskidfor key selectioncheck_id_jag_permission(client, user, scopes)— application-defined policyJWTBearerGrant.create_token_response()withinclude_refresh_token=FalseInvalidGrantError,InvalidClientError, orUnauthorizedClientErrorDesign
grant_typeURI as RFC 7523 (urn:ietf:params:oauth:grant-type:jwt-bearer); differentiation is via thetyp: oauth-id-jag+jwtJWT header.JWTBearerGrant— inherits token response generation and claim verification, overrides validation flow.sub,iat,jti, andclient_idare all required (in addition toiss,aud,exp).joserfcfor all JWT operations (decode, verify, key import) per Authlib 1.0+ standards.resolve_issuer_key(issuer, headers)— fetch IdP public key(s);headerscontainskidfor key selectionresolve_client_by_id(client_id)— resolve client fromclient_idclaim (decoupled fromiss)authenticate_user(subject)— mapsubclaim to a usercheck_jti(claims, jti)— replay protection (application must persist seen JTIs)check_id_jag_permission(client, user, scopes)— authorization policyget_audiences()— valid audience identifiers for this ASsign()helper for creating test/production assertions with auto-generatedjti.JWTBearerGranton the same server (samegrant_typeURI).Files Changed
authlib/oauth2/rfc7523/id_jag.pyIDJAGGrantimplementationauthlib/oauth2/rfc7523/__init__.pyIDJAGGrantin__all__tests/core/test_oauth2/test_rfc7523_id_jag.pytests/flask/test_oauth2/test_id_jag_grant.pydocs/oauth2/specs/rfc7523-id-jag.rstdocs/oauth2/specs/index.rstTest Coverage
tests/core/): claim validation, typ header enforcement, missing claims, JTI replay, signature verification, hook contracts, disabled-hook errors.tests/flask/): full token endpoint flow covering:invalid_requesttypheader (type confusion attack) →invalid_grantinvalid_grantinvalid_grantinvalid_grantinvalid_clientinvalid_grantunauthorized_clientinvalid_grantinvalid_grantinvalid_grantHow to Test
Design Decisions & Notes
validate_id_jag_policy; the implementation usescheck_id_jag_permission(client, user, scopes)to align with Authlib's existinghas_granted_permissionpattern and include scope awareness.invalid_scope/invalid_targetfor policy failures. We useinvalid_grantbecause the failure is assertion-level (the assertion doesn't grant permission), matching RFC 7523 Section 3.1 error semantics.resolve_issuer_client,resolve_client_public_key, andhas_granted_permissionraiseNotImplementedErrorwith guidance messages to prevent accidental use of the wrong code path.AuthorizationServeridentically to Flask. A Django-specific integration test can be added as follow-up.Does this PR introduce a breaking change? - No
Checklist
prek.pragma: no cover