Skip to content

feat!: add OAuth 2.0 assertion framework (JWT client auth + JWT bearer grant)#453

Open
dhensby wants to merge 5 commits into
node-oauth:masterfrom
dhensby:feat/client-authentication
Open

feat!: add OAuth 2.0 assertion framework (JWT client auth + JWT bearer grant)#453
dhensby wants to merge 5 commits into
node-oauth:masterfrom
dhensby:feat/client-authentication

Conversation

@dhensby

@dhensby dhensby commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds support for the OAuth 2.0 Assertion Framework (RFC 7521 / RFC 7523): JWT client authentication (private_key_jwt and client_secret_jwt) and the JWT bearer authorization grant.

Rather than special-case assertions in the token handler, this makes client authentication a first-class, pluggable concern — mirroring how extendedGrantTypes already works for grants. The existing HTTP Basic / request-body / public-client behaviour is re-implemented as built-in methods through the same interface, and JWT client assertions are added as an opt-in method on top. The structural change is the point; JWT support falls out of it.

The JWT bearer grant here is the resource-server mechanism requested in discussion #411 (ID-JAG); the IdP-side token exchange (RFC 8693) that mints the assertion is out of scope.

Supersedes #336 (the earlier user-land spike). That draft explored keeping assertions in user-land via a requestProcessor body-rewrite hook + a getClientFromAssertion model method, and surfaced that the library wasn't conducive to this without making client auth pluggable. This PR takes that conclusion: it drops the body-rewrite escape hatch (which conflated client authentication with the grant and lifted unverified claims into the request body) and instead ships spec-correct verification (via jose) behind a clean port. #336 can be closed in favour of this.

Linked issue(s)

Addresses discussion #411 — Support Identity Assertion JWT Authorization Grant (ID-JAG).

ID-JAG (draft-ietf-oauth-identity-assertion-authz-grant) is a two-step flow: an IdP first mints an assertion (RFC 8693 token exchange — the IdP's job), then the client presents it to the resource authorization server via the JWT bearer grant (RFC 7523 §2.1) to obtain an access token. This library is that resource-AS, and this PR implements that grant (plus JWT client authentication alongside it). A deployment wires up the ID-JAG step-2 use case through the new getJWTBearerIssuer (trust the IdP + its keys/audience) and getJWTBearerUser (map the assertion subject to a user) model hooks. The IdP-side issuance is not implemented here.

Involved parts of the project

  • Token-endpoint client authentication — new pluggable layer under lib/client-authentication/ (an AbstractClientAuthentication port + a registry/orchestrator). Built-ins client_secret_basic, client_secret_post, none re-implemented through it; TokenHandler delegates client resolution to it.
  • JWT client assertions (opt-in)JwtBearerClientAuthentication (covers private_key_jwt + client_secret_jwt), registered via the new extendedClientAuthentication option.
  • JWT bearer grantJwtBearerGrantType (urn:ietf:params:oauth:grant-type:jwt-bearer), registered via extendedGrantTypes.
  • Per-client auth method — optional client.tokenEndpointAuthMethod; when set, the presented method must match it.
  • Model — additive, optional hooks only (no existing signature changed): isClientAssertionJtiUsed/saveClientAssertionJti (replay), getJWTBearerIssuer/getJWTBearerUser (grant); optional client fields tokenEndpointAuthMethod, jwks, jwksUri.
  • Dependency — adds jose 6; requires Node ^20.19.0 || >=22.12.0 (see breaking changes).

Added tests?

Yes — new unit and integration suites covering each method, the orchestrator's selection logic, the grant, and the shared JWS helpers, plus negative paths (bad signature/audience/expiry, malformed assertions, replay, algorithm-confusion, method mismatch, server_error vs invalid_* mapping). 526 tests; 99.26% statements / 98.08% branches. biome check clean.

OAuth2 standard

  • RFC 7521 §4.2 / RFC 7523 §2.2 / OIDC Core §9 — JWT client authentication. Enforces signature/MAC, aud, exp, the required iss/sub/aud/exp claims, and iss == sub == client_id (bound after verification). Algorithm family pinned per key type to prevent RS↔HS confusion; alg: none rejected.
  • RFC 7521 §4.1 / RFC 7523 §2.1 — JWT bearer authorization grant; the resource-AS grant ID-JAG (Support Identity Assertion JWT Authorization Grant (ID-JAG) #411) builds on. iss resolves a trusted issuer, sub is the principal, scope is a body parameter, no refresh token (§5.2). The ID-JAG profile itself (assertion issuance / RFC 8693 exchange) is the IdP's role and not implemented here.
  • Replayjti is optional in RFC 7523 but required + single-use in OIDC §9. Replay keys on jti when present, else a fingerprint of the JWS signing input (not the whole token — that would be bypassable via ECDSA signature malleability).
  • Out of scope: SAML profile (RFC 7522); the public/confidential client type model.

Reproduction

private_key_jwt / client_secret_jwt:

const { JwtBearerClientAuthentication } = require('@node-oauth/oauth2-server');
new OAuth2Server({
  model,
  extendedClientAuthentication: {
    jwt: new JwtBearerClientAuthentication({ audience: 'https://as.example.com/oauth/token' }),
  },
});

JWT bearer grant:

const { JwtBearerGrantType } = require('@node-oauth/oauth2-server');
new OAuth2Server({
  model,
  extendedGrantTypes: { 'urn:ietf:params:oauth:grant-type:jwt-bearer': JwtBearerGrantType },
  requireClientAuthentication: { 'urn:ietf:params:oauth:grant-type:jwt-bearer': false },
});

Full model contract and examples are in docs/guide/client-authentication.md and docs/guide/grant-types.md.

⚠️ Breaking changes & migration

Default Basic / request-body / public-client behaviour is preserved and no existing model method changed. Breaking items:

Change Before After Migration
Node.js >= 16 ^20.19.0 || >=22.12.0 jose 6 is ESM-only, loaded via Node's require(esm) (unflagged in 20.19+ / 22.12+). Node ≤ 18 is EOL.
Multiple auth mechanisms in one request HTTP Basic silently won if a body client_secret was also present rejected with invalid_request (RFC 6749 §2.3) Send credentials one way only.
Empty-password Basic for a confidential client 400 invalid_request "Missing parameter: `client_secret`" 401 invalid_client Valid-credential behaviour unchanged; only this degenerate case differs.
Removed internal TokenHandler#getClientCredentials + the Missing parameter: client_id/client_secret messages present removed Only affects code reaching into internals or asserting on those exact strings.

This warrants a major release (feat! / chore! commits carry BREAKING CHANGE: footers).

Out of scope (follow-ups)

  • Public vs confidential client typetokenEndpointAuthMethod lays the groundwork; moving the auth-required decision from the global requireClientAuthentication to a per-client type is a separate change, to coordinate with the require-PKCE work.
  • SAML 2.0 profile (RFC 7522) — the JWT profile covers the modern use cases.
  • ID-JAG profile specifics / RFC 8693 token exchange — the IdP side of Support Identity Assertion JWT Authorization Grant (ID-JAG) #411.

JWT client authentication and the JWT bearer authorization grant are built on
jose. We depend on jose 6, which is ESM-only, and load it from this CommonJS
package via Node's synchronous require(esm) support — enabled by default in
Node ^20.19.0 || >=22.12.0, which the engines range reflects. Node 18 and
earlier are end-of-life.

BREAKING CHANGE: the minimum supported Node.js version is now ^20.19.0 || >=22.12.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces OAuth 2.0 assertion support by making token-endpoint client authentication pluggable (built-in adapters + opt-in JWT client assertions) and adding the JWT bearer authorization grant, along with shared JWS/JWT utilities, typings, docs, and extensive tests.

Changes:

  • Added pluggable client authentication layer (lib/client-authentication/*) and wired TokenHandler to use it (supporting built-ins + opt-in JWT client auth).
  • Added JWT bearer grant type (JwtBearerGrantType) plus shared JWS helpers (lib/utils/jws-util.js).
  • Updated docs/typings and added unit/integration tests; updated Node engine + added jose.

Reviewed changes

Copilot reviewed 33 out of 35 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
test/unit/utils/jws-util_test.js Unit coverage for shared JWS/JWT helper utilities.
test/unit/client-authentication/client-authentication_test.js Unit coverage for adapters + orchestrator selection logic.
test/integration/handlers/token-handler_test.js Removes old getClientCredentials() integration coverage (now covered via new layer).
test/integration/grant-types/jwt-bearer-grant-type_test.js End-to-end JWT bearer grant integration tests.
test/integration/client-authentication/jwt-bearer-client-authentication_test.js End-to-end JWT client authentication integration tests (HMAC + RSA + replay).
package.json Adds jose and bumps Node engine requirement.
package-lock.json Lockfile updates for jose and engine change.
lib/utils/jws-util.js New shared logic for alg pinning, jose error classification, JWKS caching, replay id.
lib/model.js Documents new optional model hooks + client fields for JWT features.
lib/handlers/token-handler.js Delegates client resolution/auth to pluggable authentication layer.
lib/grant-types/jwt-bearer-grant-type.js Adds JWT bearer authorization grant implementation.
lib/client-authentication/none.js Adds public-client (none) client-auth adapter.
lib/client-authentication/jwt-bearer-client-authentication.js Adds JWT client assertion auth adapter (private_key_jwt / client_secret_jwt).
lib/client-authentication/index.js Adds orchestrator + default built-in authentication methods.
lib/client-authentication/client-secret-post.js Adds client_secret_post adapter.
lib/client-authentication/client-secret-basic.js Adds client_secret_basic adapter.
lib/client-authentication/abstract-client-secret-authentication.js Shared behavior for secret-based methods.
lib/client-authentication/abstract-client-authentication.js Port/contract for pluggable client auth methods.
index.js Exports new grant + client-auth classes.
index.d.ts Adds typings for new grant + extended client authentication option (needs fixes).
docs/guide/model.md Documents client auth + JWT bearer model hooks.
docs/guide/grant-types.md Adds JWT bearer grant guide.
docs/guide/client-authentication.md New guide for pluggable client auth + JWT client assertions.
docs/api/utils/jws-util.md Generated API docs for new JWS util.
docs/api/model.md Generated API docs for new model hooks + client fields.
docs/api/handlers/token-handler.md Updates token handler docs to describe new auth delegation (link needs fix).
docs/api/grant-types/jwt-bearer-grant-type.md Generated API docs for new grant type (param list needs fix).
docs/api/client-authentication/none.md Generated API docs for none adapter.
docs/api/client-authentication/jwt-bearer-client-authentication.md Generated API docs for JWT client auth adapter.
docs/api/client-authentication/index.md Generated API docs for client-authentication module.
docs/api/client-authentication/client-secret-post.md Generated API docs for client_secret_post.
docs/api/client-authentication/client-secret-basic.md Generated API docs for client_secret_basic.
docs/api/client-authentication/abstract-client-secret-authentication.md Generated API docs for abstract secret adapter.
docs/api/client-authentication/abstract-client-authentication.md Generated API docs for auth port.
docs/.vitepress/config.mts Adds nav/sidebars for new guides and API pages.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/grant-types/jwt-bearer-grant-type.js
Comment thread lib/grant-types/jwt-bearer-grant-type.js Outdated
Comment thread docs/api/grant-types/jwt-bearer-grant-type.md Outdated
Comment thread docs/api/grant-types/jwt-bearer-grant-type.md Outdated
Comment thread docs/guide/grant-types.md Outdated
Comment thread docs/guide/client-authentication.md Outdated
Comment thread docs/guide/client-authentication.md Outdated
Comment thread docs/guide/client-authentication.md Outdated
Comment thread docs/api/handlers/token-handler.md
@dhensby

dhensby commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review — all nine points were valid and are addressed in fixup commits (they'll autosquash into the relevant commits at merge time):

  • getKey() error mapping (real fix): an assertion whose algorithm family doesn't match the issuer's configured key material (e.g. HS* against a JWKS-only issuer) is now rejected with invalid_grant, not server_error. server_error is reserved for an issuer configured with no key material at all. Added unit tests for both mismatch directions.
  • getJWTBearerUser / assertionId: added assertionId to the JSDoc param list and regenerated the API docs (both the summary and the repeated signature) so the contract matches the call site.
  • Non-ASCII İ in the example JWTs: fixed the copy/paste typo in both the grant-types and client-authentication guides.
  • Broken AbstractClientAuthentication link: now points to ../api/client-authentication/abstract-client-authentication instead of server.md.
  • presentedMethod example: now includes the request parameter to match the port signature.
  • {@link module:client-authentication}: replaced with a plain reference (it didn't resolve in VitePress); regenerated the token-handler API doc.

All green: biome check clean, 528 tests passing.

dhensby and others added 4 commits June 24, 2026 14:47
Client authentication at the token endpoint is now pluggable: each
`token_endpoint_auth_method` is an adapter implementing a small port
(`requiresCredentials`, `matches`, `authenticate`, `presentedMethod`), and a
registry/orchestrator selects the single method that applies. The existing HTTP
Basic, request-body and public-client behaviours are reimplemented as the
built-in `client_secret_basic`, `client_secret_post` and `none` methods.

Adds JWT client assertion authentication (RFC 7521 / RFC 7523 / OIDC Core §9),
covering both `private_key_jwt` (asymmetric) and `client_secret_jwt` (HMAC) via
jose, enabled through the new `extendedClientAuthentication` option. Supports
per-client method pinning (`tokenEndpointAuthMethod`), optional single-use `jti`
replay protection (`isClientAssertionJtiUsed`/`saveClientAssertionJti`), audience
and algorithm validation (with algorithm-family pinning), and remote JWKS
(`jwksUri`).

BREAKING CHANGE: a request presenting more than one client authentication
mechanism is now rejected with `invalid_request` (RFC 6749 §2.3); previously the
HTTP Basic credentials silently took precedence. The internal
`TokenHandler#getClientCredentials` method and the `Missing parameter:
client_id` / `Missing parameter: client_secret` error messages have been removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a client authentication guide (built-in methods, JWT client assertions,
per-client pinning, replay protection and writing a custom method), document
the new optional model functions and `ClientData` fields, add guide and API
sidebar entries, and regenerate the API reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add `JwtBearerGrantType`, an opt-in extension grant for the JWT bearer
authorization grant (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`,
RFC 7521 §4.1 / RFC 7523 §2.1). A signed JWT is the grant: its `iss` names a
trusted issuer (whose key verifies the assertion) and `sub` the principal the
access token is issued for. The assertion is verified with jose (signature,
audience, `exp`, required claims, algorithm-family pinning); `scope` comes from
the request body and no refresh token is issued (RFC 7521 §5.2).

Register it via `extendedGrantTypes`. The model implements
`getJWTBearerIssuer(issuer)` (trusted issuer keys + expected audience) and
`getJWTBearerUser({ issuer, subject, client, scope, jti, exp })` (resolve and
authorize the subject; may enforce `jti` single-use).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a grant-types guide section and model-function notes for the JWT bearer
authorization grant, and regenerate the API reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dhensby dhensby force-pushed the feat/client-authentication branch from af8c70e to 1e506f2 Compare June 24, 2026 13:48
@dhensby dhensby requested a review from jankapunkt June 24, 2026 13:48
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