Skip to content

Add jwt-bearer grant for ID-JAG (Identity Assertion Authorization Grant) support - #462

Open
manmohan-shaw-okta wants to merge 1 commit into
node-oauth:masterfrom
manmohan-shaw-okta:feat/id-jag-jwt-bearer-grant
Open

Add jwt-bearer grant for ID-JAG (Identity Assertion Authorization Grant) support#462
manmohan-shaw-okta wants to merge 1 commit into
node-oauth:masterfrom
manmohan-shaw-okta:feat/id-jag-jwt-bearer-grant

Conversation

@manmohan-shaw-okta

Copy link
Copy Markdown

Summary

Adds a built-in jwt-bearer grant (urn:ietf:params:oauth:grant-type:jwt-bearer, RFC 7523) implementing the Identity Assertion Authorization Grant (ID-JAG) draft, so this library can act as the Resource Authorization Server side of a Cross App Access exchange: it verifies an ID-JAG assertion minted by an external Identity Provider and, once verified, issues a locally-scoped access token. Minting the ID-JAG itself (the IdP side, RFC 8693 Token Exchange) is out of scope for this grant.

The library currently has zero JWT/crypto dependencies, and per CONTRIBUTING.md new tight dependencies shouldn't be introduced without discussion, so signature verification (RS256/ES256/PS256) is implemented using only Node's built-in crypto module — no new npm dependency.

Linked issue(s)

This continues the discussion in #411 (ecosystem-wide ID-JAG tracking + the requirements spec for this library specifically). I have not yet opened a formal tracking Issue for this PR — happy to do so if a maintainer confirms the existing Discussion isn't sufficient per the "no PR without an issue" contribution guideline. Opening this as a Draft for that reason.

Involved parts of the project

  • lib/utils/jwt-util.js (new): dependency-free JWS decode/verify (RS256/ES256/PS256) using Node's built-in crypto.
  • lib/grant-types/jwt-bearer-grant-type.js (new): the grant itself.
  • lib/handlers/token-handler.js, lib/server.js: register the grant as built-in (not extendedGrantTypes, since this is a registered IETF grant type) and thread through new options: tokenEndpointUri (required — this AS's own RFC 8414 issuer identifier), idJagClockSkew, jwtBearerAllowedAlgorithms, jwtBearerAllowPublicClients.
  • lib/model.js, index.d.ts: 5 new model hooks — getTrustedIssuer, getRequestingIssuerKey, getUserFromIdJagAssertion, validateIdJagPermission, validateJti (or isJtiUsed+recordJti) — only required if this grant is used, following the same conditionally-required pattern as e.g. getRefreshToken for the refresh_token grant.
  • docs/guide/grant-types.md, docs/guide/model.md, examples/express-id-jag-server.js: usage docs and a runnable example.

OAuth2 workflow involved: token endpoint (grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer) only. No changes to authorize/authenticate.

Added tests?

Yes — test/unit/grant-types/jwt-bearer-grant-type_test.js, test/integration/grant-types/jwt-bearer-grant-type_test.js, test/unit/utils/jwt-util_test.js. The integration suite mints real signed JWTs (RS256/ES256/PS256) and covers the full security matrix: typ type-confusion, alg allow-list violations (none, HS256), audience mismatch, expired/future/clock-skew-boundary timestamps, tampered signatures, untrusted issuer, unresolvable key, client_id claim mismatch, replay (both validateJti() and isJtiUsed()/recordJti() model shapes, plus fail-closed on a throwing replay store), refresh-token suppression, and scope intersection/narrowing. npm run lint and npm test (528 passing) are clean.

OAuth2 standard

  • RFC 7523 — JWT Bearer grant.
  • ID-JAG draft (draft-ietf-oauth-identity-assertion-authz-grant-03) — the profile this grant implements claim-by-claim (Sections 3.1, 4.4.1, 4.4.3, 8.1, 9 referenced directly in code comments/JSDoc).
  • RFC 8414aud is validated against this AS's issuer identifier.
  • RFC 6749 §5.2 — all failures map to existing OAuthError subclasses (InvalidGrantError, InvalidRequestError, InvalidClientError, InvalidScopeError); no new error classes were needed. Per the draft's error-handling guidance, assertion-validation failures all share one non-specific message so a response can't be used as an oracle for which check failed.

Reproduction

const OAuth2Server = require('@node-oauth/oauth2-server');
const oauth = new OAuth2Server({ model, tokenEndpointUri: 'https://rs.example.com' });
// POST /token with grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<ID-JAG JWT>

See examples/express-id-jag-server.js for a full runnable example (mints a self-signed test assertion and exchanges it end-to-end with no external IdP required — npm install express && node examples/express-id-jag-server.js).

Additional note for maintainers

CONTRIBUTING.md says to branch from development, but that branch is stale (last commit Jan 2026, 5.2.2-rc.0) relative to master (actively merged into, most recently Jul 2026). This PR targets master; happy to retarget if that's wrong.

Adds a built-in `jwt-bearer` grant (urn:ietf:params:oauth:grant-type:jwt-bearer,
RFC 7523) implementing the Identity Assertion Authorization Grant (ID-JAG)
draft, letting this library act as the Resource Authorization Server side of
a Cross App Access exchange.

- lib/utils/jwt-util.js: dependency-free JWS decode/verify (RS256/ES256/PS256)
  using only Node's built-in crypto module.
- lib/grant-types/jwt-bearer-grant-type.js: the grant itself - assertion
  parsing, typ/alg checks, issuer trust + key resolution, claim validation,
  replay protection, user resolution, permission hook, scope narrowing, and
  token issuance without ever setting a refresh token.
- lib/handlers/token-handler.js, lib/server.js: wire the grant into the
  built-in grantTypes map and thread through the new tokenEndpointUri /
  idJagClockSkew / jwtBearerAllowedAlgorithms / jwtBearerAllowPublicClients
  options.
- lib/model.js, index.d.ts: new required model hooks (getTrustedIssuer,
  getRequestingIssuerKey, getUserFromIdJagAssertion, validateIdJagPermission,
  validateJti / isJtiUsed+recordJti) and their TypeScript types.
- docs/guide/{grant-types,model}.md, examples/express-id-jag-server.js: usage
  docs and a runnable example.
- test/{unit,integration}/...: unit + integration coverage, including the
  full security matrix (type confusion, algorithm confusion, audience/claim
  validation, clock skew, replay, scope narrowing, refresh-token suppression).
@jankapunkt

Copy link
Copy Markdown
Member

@dhensby how should we proceed with PRs that are geared toward non standard RFCs? The RFC 7523 is a draft and likely become standard but it remains unclear when this will happen.

@jankapunkt

Copy link
Copy Markdown
Member

also, how far is this related to #453 ?

@manmohan-shaw-okta
manmohan-shaw-okta marked this pull request as ready for review July 20, 2026 02:53
@BinoyOza-okta

Copy link
Copy Markdown

@jankapunkt Thanks — both fair questions, and they have a shared answer.

Draft-spec status

Quick clarification on scope: RFC 7523 itself is a published standard, so the grant type URN and bearer-assertion mechanics are settled ground. What's at draft is the ID-JAG profile (draft-ietf-oauth-identity-assertion-authz-grant-03), which layers a specific claim set on top. So the question is how to isolate the profile, not whether the underlying grant is stable.

I hit the same question implementing ID-JAG for Python Authlib, and the approach we settled on there worked cleanly — authlib/authlib#898, where the ask was exactly this ("ID-JAG is a draft spec. Should it be put into rfc7523?"). The resolution, now merged and released:

  • the grant lives in a purpose-built authlib/oauth2/drafts/ package, not in the stable RFC module
  • it's absent from the stable module's public exports
  • it inherits BaseGrant directly rather than the RFC 7523 grant class, so draft churn can't reach stable code
  • the docs page is named after the draft, not the RFC

The equivalent here would be:

  • grant moves to lib/grant-types/drafts/id-jag-grant-type.js
  • registered via extendedGrantTypes only — not in the built-in grantTypes map — so no existing deployment picks up draft behaviour implicitly
  • the 5 model hooks stay outside the required set (already conditionally required, same pattern as getRefreshToken for refresh_token)
  • docs page states the draft revision it implements and flags the profile as unstable

Important to highlight: this also removes the need for the new server options. Because extendedGrantTypes takes a class rather than an instance, config can be injected by the consumer:

extendedGrantTypes: {
  'urn:ietf:params:oauth:grant-type:jwt-bearer':
    IdJagGrantType.configure({ tokenEndpointUri: 'https://rs.example.com' }),
}

I've verified this end-to-end against master: the URN routes correctly (it clears the format gate via isFormat.uri), config reaches the grant, and core defaults still apply. So the 9 lines currently added to token-handler.js and the 4 in server.js can all come out — the PR becomes purely additive, with no existing source file modified.

Relationship to #453

With the above, the overlap largely resolves itself.

#453 is the broader and better-positioned change — it makes client authentication pluggable instead of special-casing assertions in the token handler, covers private_key_jwt / client_secret_jwt alongside the grant, and has already been through a review round. It should own the generic RFC 7523 grant.

What this PR adds on top is the ID-JAG profile specifically:

  • typ pinned to exactly oauth-id-jag+jwt (§3.1); the generic JWT value rejected as type confusion
  • aud validated against the AS's own RFC 8414 issuer identifier rather than the token endpoint URL
  • scope narrowed to the intersection of the assertion's scope claim and the request (§4.4.1)
  • refresh token never issued (§4.4.3 — stricter than the draft's SHOULD NOT)
  • confidential clients only (§8.1)
  • replay keyed on (iss, jti), since jti uniqueness is per-issuer

So: #453 as the stable generic grant, this as an opt-in draft-scoped profile. Either can land first and neither blocks the other. The one constraint to document is that a deployment binds only one implementation to the URN at a time — extendedGrantTypes overrides built-ins via Object.assign, so precedence is already well-defined. We documented the same constraint in authlib.

The authlib experience is also why I'd suggest keeping this decoupled from #453's grant class rather than subclassing it: we tried inheritance first and it meant disabling three inherited hooks with NotImplementedError, which is what prompted the decoupling commit. Keeping them independent also means the JWT plumbing here stays self-contained — signature verification is ~155 lines on Node's built-in crypto, no new dependency and no engine change, per the CONTRIBUTING.md note on new tight dependencies.

If the drafts-namespace approach looks right to you, I'll restructure along those lines in one push. Let me know if you'd rather it were shaped differently before I move it.

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.

3 participants