Skip to content

feat: add Identity Assertion JWT Authorization Grant (ID-JAG) - #898

Open
BinoyOza-okta wants to merge 7 commits into
authlib:mainfrom
BinoyOza-okta:feature/id-jag-grant
Open

feat: add Identity Assertion JWT Authorization Grant (ID-JAG)#898
BinoyOza-okta wants to merge 7 commits into
authlib:mainfrom
BinoyOza-okta:feature/id-jag-grant

Conversation

@BinoyOza-okta

Copy link
Copy Markdown

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 within authlib/oauth2/rfc7523/.

Motivation

Enterprise environments often need a pattern where:

  1. An external IdP authenticates users and issues a signed JWT (the ID-JAG) to a client — typically via OAuth 2.0 Token Exchange (RFC 8693).
  2. The client presents that JWT assertion to a Resource Authorization Server using the JWT Bearer grant (RFC 7523) to obtain an access token.
  3. The authorization server validates the assertion against the IdP's keys (not the client's keys).

The current JWTBearerGrant assumes a "self-issued" model where iss == client. ID-JAG decouples these: the IdP is the Issuer and the App is the Client.

Requirements Traceability

Requirement Implementation
R1 Grant registration via register_grant() IDJAGGrant extends JWTBearerGrant, same grant_type URI
R2 JWT validation (signature, claims, typ, audience, client_id, expiry) process_assertion_claims() + CLAIMS_OPTIONS enforcing iss, sub, aud, exp, iat, jti, client_id
R3 Issuer trust & key configuration hook resolve_issuer_key(issuer, headers) — headers contains kid for key selection
R4 Permission check hook check_id_jag_permission(client, user, scopes) — application-defined policy
R5 No refresh token issued Inherited from JWTBearerGrant.create_token_response() with include_refresh_token=False
R6 RFC 6749-compatible error responses All failures raise InvalidGrantError, InvalidClientError, or UnauthorizedClientError
R7 Framework compatibility Core is framework-neutral; Flask integration test demonstrates full wiring
R8 Tests & documentation Unit tests, Flask integration tests, and Sphinx docs included

Design

  • Same grant_type URI as RFC 7523 (urn:ietf:params:oauth:grant-type:jwt-bearer); differentiation is via the typ: oauth-id-jag+jwt JWT header.
  • Extends JWTBearerGrant — inherits token response generation and claim verification, overrides validation flow.
  • Stricter claims: sub, iat, jti, and client_id are all required (in addition to iss, aud, exp).
  • Uses joserfc for all JWT operations (decode, verify, key import) per Authlib 1.0+ standards.
  • New hooks for subclass implementors:
    • resolve_issuer_key(issuer, headers) — fetch IdP public key(s); headers contains kid for key selection
    • resolve_client_by_id(client_id) — resolve client from client_id claim (decoupled from iss)
    • authenticate_user(subject) — map sub claim to a user
    • check_jti(claims, jti) — replay protection (application must persist seen JTIs)
    • check_id_jag_permission(client, user, scopes) — authorization policy
    • get_audiences() — valid audience identifiers for this AS
  • Static sign() helper for creating test/production assertions with auto-generated jti.
  • Registration constraint: Cannot coexist with JWTBearerGrant on the same server (same grant_type URI).

Files Changed

File Change
authlib/oauth2/rfc7523/id_jag.py New — IDJAGGrant implementation
authlib/oauth2/rfc7523/__init__.py Export IDJAGGrant in __all__
tests/core/test_oauth2/test_rfc7523_id_jag.py New — unit tests
tests/flask/test_oauth2/test_id_jag_grant.py New — Flask integration tests (end-to-end server example)
docs/oauth2/specs/rfc7523-id-jag.rst New — documentation
docs/oauth2/specs/index.rst Add doc reference

Test Coverage

  • Unit tests (tests/core/): claim validation, typ header enforcement, missing claims, JTI replay, signature verification, hook contracts, disabled-hook errors.
  • Flask integration tests (tests/flask/): full token endpoint flow covering:
    • ✅ Valid ID-JAG flow (access token issued, no refresh token)
    • ❌ Missing assertion → invalid_request
    • ❌ Wrong typ header (type confusion attack) → invalid_grant
    • ❌ Untrusted issuer → invalid_grant
    • ❌ Invalid signature → invalid_grant
    • ❌ Audience mismatch → invalid_grant
    • ❌ Unknown client_id → invalid_client
    • ❌ Invalid subject → invalid_grant
    • ❌ Unauthorized grant_type → unauthorized_client
    • ❌ Expired assertion → invalid_grant
    • ❌ JTI replay → invalid_grant
    • ❌ Policy denial → invalid_grant

How to Test

# Run core ID-JAG tests
coverage run --source=authlib -p -m pytest tests/core/test_oauth2/test_rfc7523_id_jag.py -v

# Run Flask integration tests
coverage run --source=authlib -p -m pytest tests/flask/test_oauth2/test_id_jag_grant.py -v

Design Decisions & Notes

  1. Hook naming: The requirements doc names the policy hook validate_id_jag_policy; the implementation uses check_id_jag_permission(client, user, scopes) to align with Authlib's existing has_granted_permission pattern and include scope awareness.
  2. Error codes for policy failure: R6 suggests invalid_scope/invalid_target for policy failures. We use invalid_grant because the failure is assertion-level (the assertion doesn't grant permission), matching RFC 7523 Section 3.1 error semantics.
  3. Disabled inherited hooks: resolve_issuer_client, resolve_client_public_key, and has_granted_permission raise NotImplementedError with guidance messages to prevent accidental use of the wrong code path.
  4. Django integration: The core grant is framework-neutral and works with Django's AuthorizationServer identically to Flask. A Django-specific integration test can be added as follow-up.

Does this PR introduce a breaking change? - No

Checklist

  • The commits follow the conventional commits specification.
  • You ran the linters with prek.
  • You wrote unit test to demonstrate the bug you are fixing, or to stress the feature you are bringing.
  • You reached 100% of code coverage on the code you edited, without abusive use of pragma: no cover
  • If this PR is about a new feature, or a behavior change, you have updated the documentation accordingly.

  • You consent that the copyright of your pull request source code belongs to Authlib's author.

@BinoyOza-okta
BinoyOza-okta marked this pull request as ready for review May 20, 2026 06:18
@lepture

lepture commented May 22, 2026

Copy link
Copy Markdown
Member

ID-JAG is a draft spec. Should it be put into rfc7523?

@BinoyOza-okta

Copy link
Copy Markdown
Author

Hi @lepture,
Sure, as the ID-JAG is a draft spec. Following is my approach to it, and I will submit the changes. Please let me know if you have any suggestions.

  • Create authlib/oauth2/drafts/init.py and move id_jag.py there (it can still subclass JWTBearerGrant imported from rfc7523).
  • Remove the IDJAGGrant re-export from authlib/oauth2/rfc7523/init.py.
  • Move the tests to tests/core/test_oauth2/test_draft_id_jag.py (the Flask test name can stay descriptive).
  • Update AGENTS.md's ID-JAG bullet to reflect the new path and note the drafts convention.

@BinoyOza-okta

Copy link
Copy Markdown
Author

Hi @lepture,
I have made the changes. Please let me know if you have any suggestions.

Comment thread authlib/oauth2/drafts/id_jag.py Outdated
@BinoyOza-okta

Copy link
Copy Markdown
Author

@lepture
A gentle reminder to review the PR, please.

@lepture

lepture commented Jul 19, 2026

Copy link
Copy Markdown
Member

@BinoyOza-okta you need to catch the joserfc errors and re-throw a authlib's OAuth2Error so that server won't return a 500.

Comment thread authlib/oauth2/drafts/id_jag.py Outdated
return token.claims

def _extract_assertion(self, assertion: str):
obj = jws.extract_compact(to_bytes(assertion))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here, it may raise a joserfc's DecodeError

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread authlib/oauth2/drafts/id_jag.py Outdated
Comment on lines +270 to +274
deprecate(
"'get_audiences' must return a non-empty list. "
"Audience validation will become mandatory.",
version="1.8",
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deprecate is used when we will deprecate something. This code is not released yet, there is nothing to deprecate.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread authlib/oauth2/drafts/id_jag.py Outdated
removed in a future release, after which subclasses MUST
override this hook.
"""
return []

@lepture lepture Jul 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If empty list is not allowed, it is better to raise NotImplementedError

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
…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/`.
@BinoyOza-okta
BinoyOza-okta force-pushed the feature/id-jag-grant branch from e17d9e9 to 2e2c394 Compare July 20, 2026 19:22
@BinoyOza-okta

Copy link
Copy Markdown
Author

@lepture
Thanks for the review — all three fixed in the latest push (commit 2e2c3941).
Please let me know if you have any suggestions.

@lepture

lepture commented Aug 11, 2026

Copy link
Copy Markdown
Member

@BinoyOza-okta I'll merge it after 1.7.3

@BinoyOza-okta

BinoyOza-okta commented Aug 12, 2026

Copy link
Copy Markdown
Author

Hi @lepture, thank you for the update.

  • Could you please suggest a timeline for the release?
  • Also, if the changes look good, could I please get approval for the PR?

TIA.

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.

2 participants