Covers both OIDC methods, distinguished by the JWS alg of the assertion:
+
+
client_secret_jwt — HMAC (HS*), keyed by the client secret;
+
private_key_jwt — asymmetric (RS*/PS*/ES*), verified against
+the client's registered public keys (a JWK Set).
+
+
The library owns the protocol (parse → resolve client → verify → bind);
+key material and replay state come from the model/client. This method is
+opt-in (it requires per-deployment audience configuration); register it
+via the extendedClientAuthentication server option.
The client_assertion_type value identifying a JWT client assertion.
+
+
+
+
+
+## JwtBearerClientAuthentication ⇐ AbstractClientAuthentication
+JWT client assertion authentication — `client_assertion` +
+`client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`.
+
+Covers both OIDC methods, distinguished by the JWS `alg` of the assertion:
+ - `client_secret_jwt` — HMAC (`HS*`), keyed by the client secret;
+ - `private_key_jwt` — asymmetric (`RS*`/`PS*`/`ES*`), verified against
+ the client's registered public keys (a JWK Set).
+
+The library owns the *protocol* (parse → resolve client → verify → bind);
+key material and replay state come from the model/client. This method is
+opt-in (it requires per-deployment `audience` configuration); register it
+via the `extendedClientAuthentication` server option.
+
+**Kind**: global class
+**Extends**: AbstractClientAuthentication
+**See**
+
+- https://datatracker.ietf.org/doc/html/rfc7521#section-4.2
+- https://datatracker.ietf.org/doc/html/rfc7523#section-2.2
+- https://datatracker.ietf.org/doc/html/rfc7523#section-3
+- https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication
+
+
+* [JwtBearerClientAuthentication](#JwtBearerClientAuthentication) ⇐ AbstractClientAuthentication
+ * [new JwtBearerClientAuthentication(options)](#new_JwtBearerClientAuthentication_new)
+ * [.defaultGetKey()](#JwtBearerClientAuthentication+defaultGetKey)
+ * [.assertNotReplayed()](#JwtBearerClientAuthentication+assertNotReplayed)
+
+
+
+### new JwtBearerClientAuthentication(options)
+
+| Param | Type | Description |
+| --- | --- | --- |
+| options | object | |
+| options.audience | string \| Array.<string> | the value(s) the assertion's `aud` claim must contain — typically this authorization server's token endpoint URL and/or its issuer identifier. REQUIRED. |
+| [options.maxTokenAge] | number | maximum assertion age in seconds, measured from `iat` (enabling this requires assertions to carry `iat`). |
+| [options.clockTolerance] | number | clock skew tolerance in seconds. |
+| [options.algorithms] | Array.<string> | override the accepted JWS algorithms. |
+| [options.getKey] | function | `(client, header) => key` override key resolution. By default HMAC keys derive from `client.secret` and asymmetric keys come from `client.jwks` (a JWK Set) or `client.jwksUri`. |
+
+
+
+### jwtBearerClientAuthentication.defaultGetKey()
+Default key resolution: HMAC from the client secret, asymmetric from the
+client's registered JWK Set (inline `jwks` or remote `jwksUri`).
+
+**Kind**: instance method of [JwtBearerClientAuthentication](#JwtBearerClientAuthentication)
+
+
+### jwtBearerClientAuthentication.assertNotReplayed()
+Single-use replay protection. Opt-in: only enforced when the model
+implements the replay hooks. The identifier passed to the hooks is the
+assertion's `jti` when present, otherwise a fingerprint of its signing
+input — so replay protection applies even to assertions without a `jti`
+(OIDC Core §9 requires `jti`; RFC 7523 §3 makes it optional).
+
+**Kind**: instance method of [JwtBearerClientAuthentication](#JwtBearerClientAuthentication)
+
+
+## JwtBearerClientAuthentication
+**Kind**: global class
+
+* [JwtBearerClientAuthentication](#JwtBearerClientAuthentication)
+ * [new JwtBearerClientAuthentication(options)](#new_JwtBearerClientAuthentication_new)
+ * [.defaultGetKey()](#JwtBearerClientAuthentication+defaultGetKey)
+ * [.assertNotReplayed()](#JwtBearerClientAuthentication+assertNotReplayed)
+
+
+
+### new JwtBearerClientAuthentication(options)
+
+| Param | Type | Description |
+| --- | --- | --- |
+| options | object | |
+| options.audience | string \| Array.<string> | the value(s) the assertion's `aud` claim must contain — typically this authorization server's token endpoint URL and/or its issuer identifier. REQUIRED. |
+| [options.maxTokenAge] | number | maximum assertion age in seconds, measured from `iat` (enabling this requires assertions to carry `iat`). |
+| [options.clockTolerance] | number | clock skew tolerance in seconds. |
+| [options.algorithms] | Array.<string> | override the accepted JWS algorithms. |
+| [options.getKey] | function | `(client, header) => key` override key resolution. By default HMAC keys derive from `client.secret` and asymmetric keys come from `client.jwks` (a JWK Set) or `client.jwksUri`. |
+
+
+
+### jwtBearerClientAuthentication.defaultGetKey()
+Default key resolution: HMAC from the client secret, asymmetric from the
+client's registered JWK Set (inline `jwks` or remote `jwksUri`).
+
+**Kind**: instance method of [JwtBearerClientAuthentication](#JwtBearerClientAuthentication)
+
+
+### jwtBearerClientAuthentication.assertNotReplayed()
+Single-use replay protection. Opt-in: only enforced when the model
+implements the replay hooks. The identifier passed to the hooks is the
+assertion's `jti` when present, otherwise a fingerprint of its signing
+input — so replay protection applies even to assertions without a `jti`
+(OIDC Core §9 requires `jti`; RFC 7523 §3 makes it optional).
+
+**Kind**: instance method of [JwtBearerClientAuthentication](#JwtBearerClientAuthentication)
+
+
+## CLIENT\_ASSERTION\_TYPE
+The `client_assertion_type` value identifying a JWT client assertion.
+
+**Kind**: global constant
+**See**: https://datatracker.ietf.org/doc/html/rfc7523#section-2.2
diff --git a/docs/api/client-authentication/none.md b/docs/api/client-authentication/none.md
new file mode 100644
index 00000000..779a3d20
--- /dev/null
+++ b/docs/api/client-authentication/none.md
@@ -0,0 +1,15 @@
+
+
+## None ⇐ AbstractClientAuthentication
+`none`: a public client that identifies itself with `client_id` only and
+presents no secret (e.g. a PKCE flow, or a grant for which
+`requireClientAuthentication` is disabled).
+
+This adapter only resolves the client. Whether a secret-less client is
+*acceptable* for the request is a policy decision owned by the orchestrator
+(it knows the grant type, the `requireClientAuthentication` config and
+whether this is a PKCE request).
+
+**Kind**: global class
+**Extends**: AbstractClientAuthentication
+**See**: https://datatracker.ietf.org/doc/html/rfc6749#section-2.1
diff --git a/docs/api/grant-types/jwt-bearer-grant-type.md b/docs/api/grant-types/jwt-bearer-grant-type.md
new file mode 100644
index 00000000..de8c573e
--- /dev/null
+++ b/docs/api/grant-types/jwt-bearer-grant-type.md
@@ -0,0 +1,128 @@
+## Classes
+
+
The JWT bearer authorization grant (RFC 7521 §4.1, RFC 7523 §2.1/§3): a signed
+JWT is the authorization grant. The assertion's iss identifies a trusted
+issuer (whose key verifies the assertion) and sub identifies the principal
+the access token is issued for. Unlike JWT client authentication, sub is
+the user/principal — not the client — and iss/sub are not bound to the
+client id.
+
This is an extension grant; register it via extendedGrantTypes. The
+requested scope is taken from the body parameter (RFC 7521 §4.1), and no
+refresh token is issued (RFC 7521 §5.2).
+
The model must implement:
+
+
getJWTBearerIssuer(issuer) → { audience, jwks | jwksUri | secret } (or
+falsy for an untrusted issuer) — the verification key material and the
+expected aud.
+
getJWTBearerUser({ issuer, subject, client, scope, jti, assertionId, exp }) → the
+authorized user (or falsy to deny). Replay (jti) can be enforced here.
The grant_type value for the JWT bearer authorization grant.
+
+
+
+
+
+## JwtBearerGrantType ⇐ AbstractGrantType
+The JWT bearer authorization grant (RFC 7521 §4.1, RFC 7523 §2.1/§3): a signed
+JWT *is* the authorization grant. The assertion's `iss` identifies a trusted
+issuer (whose key verifies the assertion) and `sub` identifies the principal
+the access token is issued for. Unlike JWT *client authentication*, `sub` is
+the user/principal — not the client — and `iss`/`sub` are not bound to the
+client id.
+
+This is an extension grant; register it via `extendedGrantTypes`. The
+requested `scope` is taken from the body parameter (RFC 7521 §4.1), and no
+refresh token is issued (RFC 7521 §5.2).
+
+The model must implement:
+ - `getJWTBearerIssuer(issuer)` → `{ audience, jwks | jwksUri | secret }` (or
+ falsy for an untrusted issuer) — the verification key material and the
+ expected `aud`.
+ - `getJWTBearerUser({ issuer, subject, client, scope, jti, assertionId, exp })` → the
+ authorized user (or falsy to deny). Replay (`jti`) can be enforced here.
+
+**Kind**: global class
+**Extends**: AbstractGrantType
+**See**
+
+- https://datatracker.ietf.org/doc/html/rfc7521#section-4.1
+- https://datatracker.ietf.org/doc/html/rfc7523#section-2.1
+- https://datatracker.ietf.org/doc/html/rfc7523#section-3
+
+
+* [JwtBearerGrantType](#JwtBearerGrantType) ⇐ AbstractGrantType
+ * [new JwtBearerGrantType()](#new_JwtBearerGrantType_new)
+ * [.handle(request, client)](#JwtBearerGrantType+handle)
+ * [.verifyAssertion()](#JwtBearerGrantType+verifyAssertion)
+ * [.getKey()](#JwtBearerGrantType+getKey)
+ * [.getUser()](#JwtBearerGrantType+getUser)
+ * [.saveToken()](#JwtBearerGrantType+saveToken)
+
+
+
+### new JwtBearerGrantType()
+**Example**
+```js
+new OAuth2Server({
+ model,
+ extendedGrantTypes: {
+ 'urn:ietf:params:oauth:grant-type:jwt-bearer': JwtBearerGrantType
+ },
+ // typically a public requester; identify it with `client_id`:
+ requireClientAuthentication: { 'urn:ietf:params:oauth:grant-type:jwt-bearer': false }
+});
+```
+
+
+### jwtBearerGrantType.handle(request, client)
+Handle the JWT bearer grant.
+
+**Kind**: instance method of [JwtBearerGrantType](#JwtBearerGrantType)
+**See**: https://datatracker.ietf.org/doc/html/rfc7523#section-2.1
+
+| Param | Type |
+| --- | --- |
+| request | Request |
+| client | ClientData |
+
+
+
+### jwtBearerGrantType.verifyAssertion()
+Verify the `assertion` and return its (trusted) claims.
+
+**Kind**: instance method of [JwtBearerGrantType](#JwtBearerGrantType)
+
+
+### jwtBearerGrantType.getKey()
+Resolve the verification key for the issuer (HMAC secret or asymmetric JWKS).
+
+**Kind**: instance method of [JwtBearerGrantType](#JwtBearerGrantType)
+
+
+### jwtBearerGrantType.getUser()
+Resolve and authorize the principal (`sub`) the token is issued for.
+
+**Kind**: instance method of [JwtBearerGrantType](#JwtBearerGrantType)
+
+
+### jwtBearerGrantType.saveToken()
+Save and return the access token. No refresh token is issued (RFC 7521 §5.2).
+
+**Kind**: instance method of [JwtBearerGrantType](#JwtBearerGrantType)
+
+
+## GRANT\_TYPE
+The `grant_type` value for the JWT bearer authorization grant.
+
+**Kind**: global constant
+**See**: https://datatracker.ietf.org/doc/html/rfc7523#section-2.1
diff --git a/docs/api/handlers/token-handler.md b/docs/api/handlers/token-handler.md
index d0817a0d..f52e4ff0 100644
--- a/docs/api/handlers/token-handler.md
+++ b/docs/api/handlers/token-handler.md
@@ -24,7 +24,6 @@ Constructor.
* [TokenHandler](#TokenHandler)
* [.handle()](#TokenHandler+handle)
* [.getClient()](#TokenHandler+getClient)
- * [.getClientCredentials()](#TokenHandler+getClientCredentials)
* [.handleGrantType()](#TokenHandler+handleGrantType)
* [.getAccessTokenLifetime()](#TokenHandler+getAccessTokenLifetime)
* [.getRefreshTokenLifetime()](#TokenHandler+getRefreshTokenLifetime)
@@ -44,17 +43,15 @@ Token Handler.
### tokenHandler.getClient()
Get the client from the model.
-**Kind**: instance method of [TokenHandler](#TokenHandler)
-
-
-### tokenHandler.getClientCredentials()
-Get client credentials.
-
-The client credentials may be sent using the HTTP Basic authentication scheme or, alternatively,
-the `client_id` and `client_secret` can be embedded in the body.
+Client authentication is delegated to the configured authentication
+methods (see the client authentication guide). The single method
+that matches the request resolves and verifies the client; supported out
+of the box are HTTP Basic, request-body credentials and public clients,
+plus any methods added via `extendedClientAuthentication` (e.g. JWT
+client assertions).
**Kind**: instance method of [TokenHandler](#TokenHandler)
-**See**: https://tools.ietf.org/html/rfc6749#section-2.3.1
+**See**: https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
### tokenHandler.handleGrantType()
diff --git a/docs/api/model.md b/docs/api/model.md
index c5d420d8..81ffc3f6 100644
--- a/docs/api/model.md
+++ b/docs/api/model.md
@@ -60,6 +60,10 @@ as well as generators, are supported.
* [.generateAuthorizationCode(client, user, scope)](#Model+generateAuthorizationCode) ⇒ Promise.<string>
* [.validateScope(user, client, scope)](#Model+validateScope) ⇒ Promise.<boolean>
* [.validateRedirectUri(redirectUri, client)](#Model+validateRedirectUri) ⇒ Promise.<boolean>
+ * [.isClientAssertionJtiUsed(jti)](#Model+isClientAssertionJtiUsed) ⇒ Promise.<boolean>
+ * [.saveClientAssertionJti(jti, exp)](#Model+saveClientAssertionJti) ⇒ Promise.<void>
+ * [.getJWTBearerIssuer(issuer)](#Model+getJWTBearerIssuer) ⇒ Promise.<object>
+ * [.getJWTBearerUser(params)](#Model+getJWTBearerUser) ⇒ Promise.<object>
* _static_
* [.from(impl)](#Model.from) ⇒ [Model](#Model)
@@ -610,6 +614,77 @@ See: https://datatracker.ietf.org/doc/html/rfc6819
| redirectUri | string | The redirect URI to validate |
| client | object | The associated client. |
+
+
+### model.isClientAssertionJtiUsed(jti) ⇒ Promise.<boolean>
+Invoked to check whether a JWT client assertion `jti` has already been used,
+for single-use replay protection of JWT client authentication.
+This model function is **optional**. Replay protection is only enforced when
+both this and `saveClientAssertionJti` are implemented.
+
+**Invoked during:**
+- JWT client authentication (`private_key_jwt` / `client_secret_jwt`)
+
+**Kind**: instance method of [Model](#Model)
+**Returns**: Promise.<boolean> - Resolves `true` if the `jti` has already been used.
+
+| Param | Type | Description |
+| --- | --- | --- |
+| jti | string | The `jti` claim of the client assertion. |
+
+
+
+### model.saveClientAssertionJti(jti, exp) ⇒ Promise.<void>
+Invoked to record a JWT client assertion `jti` (with its expiry) for single-use
+replay protection. Store it with a TTL until `exp` so the record self-expires.
+This model function is **optional**. Replay protection is only enforced when
+both this and `isClientAssertionJtiUsed` are implemented.
+
+**Invoked during:**
+- JWT client authentication (`private_key_jwt` / `client_secret_jwt`)
+
+**Kind**: instance method of [Model](#Model)
+
+| Param | Type | Description |
+| --- | --- | --- |
+| jti | string | The `jti` claim of the client assertion. |
+| exp | number | The assertion's `exp` (epoch seconds), for use as a TTL. |
+
+
+
+### model.getJWTBearerIssuer(issuer) ⇒ Promise.<object>
+Invoked to resolve a trusted issuer for the JWT bearer authorization grant.
+**Required** when the `urn:ietf:params:oauth:grant-type:jwt-bearer` grant is enabled.
+
+**Invoked during:**
+- JWT bearer authorization grant (`JwtBearerGrantType`)
+
+**Kind**: instance method of [Model](#Model)
+**Returns**: Promise.<object> - Resolves to the issuer's verification key material and the
+ expected audience: `{ audience, jwks | jwksUri | secret }`. Resolve to a falsy value
+ to reject the issuer as untrusted (`invalid_grant`).
+
+| Param | Type | Description |
+| --- | --- | --- |
+| issuer | string | The assertion's `iss` claim. |
+
+
+
+### model.getJWTBearerUser(params) ⇒ Promise.<object>
+Invoked to resolve and authorize the principal a JWT bearer assertion is issued for.
+**Required** when the `urn:ietf:params:oauth:grant-type:jwt-bearer` grant is enabled.
+
+**Invoked during:**
+- JWT bearer authorization grant (`JwtBearerGrantType`)
+
+**Kind**: instance method of [Model](#Model)
+**Returns**: Promise.<object> - Resolves to the authorized user, or a falsy value to deny the
+ grant (`invalid_grant`). Single-use (`jti`) replay protection can be enforced here.
+
+| Param | Type | Description |
+| --- | --- | --- |
+| params | object | `{ issuer, subject, client, scope, jti, assertionId, exp }` from the verified assertion. `assertionId` is the `jti`, or a fingerprint of the assertion's signing input when it has no `jti` — use it for single-use replay protection. |
+
### Model.from(impl) ⇒ [Model](#Model)
@@ -687,4 +762,8 @@ An `Object` representing the client and associated data.
| grants | Array.<string> | Grant types allowed for the client. |
| accessTokenLifetime | number | Client-specific lifetime of generated access tokens in seconds. |
| refreshTokenLifetime | number | Client-specific lifetime of generated refresh tokens in seconds. |
+| tokenEndpointAuthMethod | string | Optional. Registered `token_endpoint_auth_method` (RFC 7591). When set, the token endpoint rejects any other client authentication method. |
+| secret | string | Optional. Client secret; also reused as the HMAC key for `client_secret_jwt` authentication. |
+| jwks | object | Optional. JWK Set (RFC 7517) of the client's public keys, used to verify a `private_key_jwt` client assertion. |
+| jwksUri | string | Optional. URL of the client's JWK Set, fetched and cached by the server, used to verify a `private_key_jwt` client assertion. |
diff --git a/docs/api/utils/jws-util.md b/docs/api/utils/jws-util.md
new file mode 100644
index 00000000..5d166626
--- /dev/null
+++ b/docs/api/utils/jws-util.md
@@ -0,0 +1,80 @@
+
+
+## jws-util
+Shared JWS/JWT helpers for the JWT assertion features (client authentication
+and the JWT bearer grant). These encode security-sensitive policy — the
+algorithm-family pinning that prevents algorithm-confusion, and the
+classification of jose errors as client/grant faults vs. operational
+(server) faults — so both features stay aligned by sharing one source.
+
+
+* [jws-util](#module_jws-util)
+ * [~isHmac(alg)](#module_jws-util..isHmac) ⇒ boolean
+ * [~algorithmsForHeader(header)](#module_jws-util..algorithmsForHeader) ⇒ Array.<string>
+ * [~isValidationError(e)](#module_jws-util..isValidationError) ⇒ boolean
+ * [~replayId(assertion, [jti])](#module_jws-util..replayId) ⇒ string
+ * [~getRemoteJwks(uri)](#module_jws-util..getRemoteJwks) ⇒ function
+
+
+
+### jws-util~isHmac(alg) ⇒ boolean
+Whether a JWS algorithm is an HMAC (`HS*`) algorithm.
+
+**Kind**: inner method of [jws-util](#module_jws-util)
+
+| Param | Type |
+| --- | --- |
+| alg | string |
+
+
+
+### jws-util~algorithmsForHeader(header) ⇒ Array.<string>
+The accepted algorithm family for a JWS header (HMAC vs. asymmetric).
+
+**Kind**: inner method of [jws-util](#module_jws-util)
+
+| Param | Type | Description |
+| --- | --- | --- |
+| header | object | the decoded JWS protected header |
+
+
+
+### jws-util~isValidationError(e) ⇒ boolean
+Whether a thrown error is a jose assertion-validation error (vs. operational).
+
+**Kind**: inner method of [jws-util](#module_jws-util)
+
+| Param | Type |
+| --- | --- |
+| e | Error |
+
+
+
+### jws-util~replayId(assertion, [jti]) ⇒ string
+A stable replay identifier for an assertion: its `jti` claim when present,
+otherwise a fingerprint of the JWS *signing input* (`header.payload`).
+
+The signing input — not the full compact JWT — is hashed on purpose: ECDSA
+signatures are malleable (a valid `(r, s)` yields a valid `(r, n-s)`), so a
+fingerprint over the whole token could be evaded by replaying a re-encoded
+signature. The signing input is identical across such variants.
+
+**Kind**: inner method of [jws-util](#module_jws-util)
+
+| Param | Type | Description |
+| --- | --- | --- |
+| assertion | string | the compact JWT (verified before this is called) |
+| [jti] | string | the assertion's `jti` claim, if present |
+
+
+
+### jws-util~getRemoteJwks(uri) ⇒ function
+Resolve a (cached) remote JWK Set for the given URI.
+
+**Kind**: inner method of [jws-util](#module_jws-util)
+**Returns**: function - a jose key-resolution function
+
+| Param | Type |
+| --- | --- |
+| uri | string |
+
diff --git a/docs/guide/client-authentication.md b/docs/guide/client-authentication.md
new file mode 100644
index 00000000..9008cd11
--- /dev/null
+++ b/docs/guide/client-authentication.md
@@ -0,0 +1,245 @@
+# Client Authentication
+
+When a client requests a token from the [token endpoint](../api/handlers/token-handler.md), it
+authenticates itself to the authorization server. The method it uses is its
+`token_endpoint_auth_method`.
+
+Client authentication is **pluggable**: each method is a small adapter, and the token endpoint
+selects the one that matches the incoming request. The following methods are built in and enabled
+by default:
+
+| Method | Credentials | Model lookup |
+| --- | --- | --- |
+| `client_secret_basic` | HTTP `Authorization: Basic` header | `getClient(clientId, clientSecret)` |
+| `client_secret_post` | `client_id` + `client_secret` in the request body | `getClient(clientId, clientSecret)` |
+| `none` | `client_id` only (public client) | `getClient(clientId)` |
+
+A public (`none`) client is only accepted for a [PKCE](./pkce.md) request, or when
+`requireClientAuthentication` is disabled for the grant (see
+[`OAuth2Server#token`](../api/server.md)).
+
+> [!IMPORTANT]
+> A request **MUST NOT** present more than one authentication mechanism
+> ([RFC 6749 §2.3](https://datatracker.ietf.org/doc/html/rfc6749#section-2.3)). A request that
+> carries, for example, both a Basic `Authorization` header and a body `client_secret` is rejected
+> with `invalid_request`.
+
+## The model contract
+
+The secret-based methods verify the client through your model's
+[`getClient`](../api/model.md#modelgetclientclientid-clientsecret--code-promiseclientdata-code):
+
+```js
+const model = {
+ async getClient (clientId, clientSecret) {
+ const client = await db.findClient(clientId)
+ if (!client) {
+ return false
+ }
+ // `clientSecret` is only supplied for the secret-based methods; it is
+ // `undefined` for public (`none`) and JWT clients, so only verify it when present.
+ if (clientSecret != null && client.secret !== clientSecret) {
+ return false
+ }
+ return client // must include `grants`; may include `tokenEndpointAuthMethod`, `jwks`, ...
+ }
+ // ...other model functions
+}
+```
+
+## JWT client authentication
+
+The library can authenticate clients with a signed JWT *client assertion*
+([RFC 7521 §4.2](https://datatracker.ietf.org/doc/html/rfc7521#section-4.2),
+[RFC 7523 §2.2/§3](https://datatracker.ietf.org/doc/html/rfc7523#section-2.2),
+[OpenID Connect Core §9](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication)).
+Two methods are covered, distinguished by the JWS algorithm of the assertion:
+
+- **`private_key_jwt`** — asymmetric (`RS*`/`PS*`/`ES*`/`EdDSA`), verified against the client's
+ registered public keys.
+- **`client_secret_jwt`** — HMAC (`HS*`), verified with the client secret.
+
+JWT authentication is **opt-in** because it needs per-deployment configuration. Enable it by adding
+a `JwtBearerClientAuthentication` instance through the `extendedClientAuthentication` option (the
+key is just a label, the same way [`extendedGrantTypes`](./grant-types.md#extension-grants) works):
+
+```js
+const OAuth2Server = require('@node-oauth/oauth2-server')
+const { JwtBearerClientAuthentication } = OAuth2Server
+
+const server = new OAuth2Server({
+ model,
+ extendedClientAuthentication: {
+ jwt: new JwtBearerClientAuthentication({
+ // REQUIRED: the value(s) the assertion's `aud` claim must contain —
+ // your token endpoint URL and/or this server's issuer identifier.
+ audience: 'https://as.example.com/oauth/token'
+ })
+ }
+})
+```
+
+### Options
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `audience` *(required)* | — | The value(s) the assertion's `aud` claim must contain. Matched exactly. |
+| `maxTokenAge` | _off_ | Maximum assertion age in seconds, measured from `iat`. Enabling it makes `iat` mandatory. |
+| `clockTolerance` | `0` | Clock-skew tolerance in seconds. |
+| `algorithms` | all `HS*`/`RS*`/`PS*`/`ES*`/`EdDSA` | Override the accepted JWS algorithms. Set to an asymmetric-only list to forbid `client_secret_jwt` globally. |
+| `getKey` | reads `client.secret` / `client.jwks` / `client.jwksUri` | `(client, header) => key` — override key resolution. |
+
+### Required claims
+
+The assertion must be a single, signed JWT whose claims satisfy
+[RFC 7523 §3](https://datatracker.ietf.org/doc/html/rfc7523#section-3):
+
+- `iss` — **must** equal the `client_id`
+- `sub` — **must** equal the `client_id`
+- `aud` — **must** contain this server (per the configured `audience`)
+- `exp` — the assertion must not be expired
+
+`iss`/`sub` are bound to the resolved client **after** the signature is verified; `nbf`, `iat` and
+`jti` are optional unless you enable `maxTokenAge` (needs `iat`) or replay protection (needs `jti`).
+
+### Where the keys come from
+
+The default key resolution reads the verification material straight off the client object returned
+by `getClient(clientId)` (called without a secret):
+
+- **`client_secret_jwt`** → `client.secret` (the same secret used for `client_secret_basic`/`post`).
+- **`private_key_jwt`** → `client.jwks` (an inline [JWK Set](https://datatracker.ietf.org/doc/html/rfc7517))
+ **or** `client.jwksUri` (a URL the server fetches and caches).
+
+```js
+const model = {
+ async getClient (clientId, clientSecret) {
+ return {
+ id: clientId,
+ grants: ['client_credentials'],
+ // for private_key_jwt:
+ jwks: { keys: [/* the client's public JWK(s) */] },
+ // or: jwksUri: 'https://client.example.com/jwks.json',
+ // for client_secret_jwt: the existing `secret` is reused as the HMAC key
+ }
+ }
+}
+```
+
+> [!WARNING]
+> `jwksUri` is fetched by the server. Validate it at registration time (HTTPS only, no
+> link-local/internal hosts) to avoid SSRF, exactly as you would any client-supplied URL.
+
+Pass `getKey` if your keys live elsewhere (a database, a KMS, a remote registry).
+
+### Replay protection
+
+[OpenID Connect Core §9](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication)
+requires client assertions to be single-use. This is **opt-in** and only active when your model
+implements **both** of these functions (implementing only one is rejected as a misconfiguration).
+They receive a stable assertion **id** — the `jti` claim when present, otherwise a fingerprint of the
+assertion's signing input — so single-use is enforced even for assertions that omit `jti`:
+
+```js
+const model = {
+ async isClientAssertionJtiUsed (id) {
+ return db.clientAssertionIdExists(id)
+ },
+ async saveClientAssertionJti (id, exp) {
+ // store with a TTL until `exp` so the record self-expires
+ await db.saveClientAssertionId(id, exp)
+ }
+}
+```
+
+### Pinning a client to one method
+
+A client may declare its registered `tokenEndpointAuthMethod`. When set, the token endpoint rejects
+any other method — so a client registered for `private_key_jwt` cannot fall back to
+`client_secret_jwt` (or a shared secret) even if it holds one:
+
+```js
+return { id: clientId, grants: [...], jwks, tokenEndpointAuthMethod: 'private_key_jwt' }
+```
+
+Clients that do not declare a method are unconstrained.
+
+### Example request
+
+The client sends its normal grant request, swapping `client_secret`/Basic for the assertion. Note
+that `scope` (and every other grant parameter) stays a **normal body parameter** — it is not read
+from the assertion:
+
+```
+POST /oauth/token
+Content-Type: application/x-www-form-urlencoded
+
+grant_type=client_credentials
+&scope=read
+&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
+&client_assertion=eyJhbGciOiJSUzI1Ni...
+```
+
+A client can build the assertion with [`jose`](https://github.com/panva/jose):
+
+```js
+const { SignJWT } = require('jose')
+const { randomUUID } = require('crypto')
+
+const clientAssertion = await new SignJWT({})
+ .setProtectedHeader({ alg: 'RS256', kid: 'k1' })
+ .setIssuer(clientId)
+ .setSubject(clientId)
+ .setAudience('https://as.example.com/oauth/token')
+ .setIssuedAt()
+ .setExpirationTime('60s')
+ .setJti(randomUUID())
+ .sign(privateKey)
+```
+
+## Writing a custom method
+
+Implement the [`AbstractClientAuthentication`](../api/client-authentication/abstract-client-authentication) port and register it through
+`extendedClientAuthentication`. For example, a minimal `tls_client_auth` (mTLS) adapter:
+
+```js
+const OAuth2Server = require('@node-oauth/oauth2-server')
+const { AbstractClientAuthentication } = OAuth2Server
+
+class TlsClientAuthentication extends AbstractClientAuthentication {
+ // `true` for a credentialed method, `false` for a public client.
+ get requiresCredentials () { return true }
+
+ // Cheap, side-effect-free: does this request use this method?
+ matches (request) { return !!request.get('x-ssl-client-cert') }
+
+ // The `token_endpoint_auth_method` this request presents (for pinning).
+ presentedMethod (request) { return 'tls_client_auth' }
+
+ // Verify the credentials and resolve the client (throw InvalidClientError on failure).
+ async authenticate (request, { model }) {
+ const client = await model.getClient(request.body.client_id)
+ // ...verify the presented client certificate against the client's registration
+ return client
+ }
+}
+
+const server = new OAuth2Server({
+ model,
+ extendedClientAuthentication: {
+ tls_client_auth: new TlsClientAuthentication()
+ }
+})
+```
+
+The orchestrator owns method selection and exclusivity, so a new method needs no changes elsewhere.
+
+## Security notes
+
+- The token endpoint **must** be served over TLS
+ ([RFC 6749 §3.2](https://datatracker.ietf.org/doc/html/rfc6749#section-3.2)); this is what
+ protects the request body, including `scope`.
+- `alg: none` and algorithm-confusion (verifying an `HS*` token against an RSA public key) are
+ rejected: the HMAC key is always the client secret, and the accepted algorithm family is pinned
+ to the key type.
+- For OpenID Connect conformance, wire up the `jti` replay functions.
diff --git a/docs/guide/grant-types.md b/docs/guide/grant-types.md
index b5827f49..d4a9db69 100644
--- a/docs/guide/grant-types.md
+++ b/docs/guide/grant-types.md
@@ -51,6 +51,95 @@ The client can request an access token using only its client credentials (or oth
when requesting access to the protected resources under its control.
The client credentials grant type **must** only be used by confidential clients.
+## JWT Bearer Grant Type
+
+**Defined in:** [RFC 7523 §2.1](https://datatracker.ietf.org/doc/html/rfc7523#section-2.1) (a profile of the [assertion framework, RFC 7521](https://datatracker.ietf.org/doc/html/rfc7521)).
+
+A signed JWT *is* the authorization grant: the assertion's `iss` identifies a trusted issuer
+and `sub` the principal the access token is issued for. This is the flow used for service-account
+and trusted-IdP token exchange. It ships as an opt-in extension grant, `JwtBearerGrantType`,
+registered through `extendedGrantTypes`:
+
+``` js
+const OAuth2Server = require('@node-oauth/oauth2-server');
+const { JwtBearerGrantType } = OAuth2Server;
+
+const server = new OAuth2Server({
+ model,
+ extendedGrantTypes: {
+ 'urn:ietf:params:oauth:grant-type:jwt-bearer': JwtBearerGrantType
+ },
+ // the requester MUST be identified by a registered `client_id` (see note below):
+ requireClientAuthentication: { 'urn:ietf:params:oauth:grant-type:jwt-bearer': false }
+});
+```
+
+The client sends the assertion as a normal token request; `scope` is a body parameter (it is not
+read from the assertion):
+
+```
+POST /oauth/token
+Content-Type: application/x-www-form-urlencoded
+
+grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer
+&assertion=eyJhbGciOiJSUzI1Ni...
+&client_id=client-1
+&scope=read
+```
+
+**Model requirements:** in addition to `getClient` and `saveToken`, the model must implement:
+
+- `getJWTBearerIssuer(issuer)` — resolve a trusted issuer's verification key material and expected
+ audience (`{ audience, jwks | jwksUri | secret }`); return a falsy value to reject an untrusted issuer.
+- `getJWTBearerUser({ issuer, subject, client, scope, jti, assertionId, exp })` — resolve and
+ authorize the principal the token is for; return a falsy value to deny. Enforce single-use replay
+ protection here if required, keyed on `assertionId` (the `jti`, or a signing-input fingerprint when
+ the assertion has no `jti`).
+
+``` js
+const model = {
+ async getJWTBearerIssuer (issuer) {
+ const trusted = await db.findTrustedIssuer(issuer)
+ if (!trusted) return false
+ return { audience: 'https://as.example.com/oauth/token', jwks: trusted.jwks }
+ },
+ async getJWTBearerUser ({ issuer, subject, assertionId, exp }) {
+ // MUST verify this issuer is permitted to assert this subject, otherwise any
+ // trusted issuer could obtain a token for any user:
+ if (!await db.issuerMayAssert(issuer, subject)) return false
+ // Enforce single-use to prevent replay. `assertionId` is the `jti`, or a
+ // signing-input fingerprint when the assertion carries no `jti`:
+ if (await db.assertionIdUsed(assertionId)) return false
+ await db.saveAssertionId(assertionId, exp)
+ return db.findUser(subject)
+ }
+}
+```
+
+The assertion is verified per [RFC 7523 §3](https://datatracker.ietf.org/doc/html/rfc7523#section-3)
+(signature against the issuer's key, required `iss`/`sub`/`aud`/`exp`, audience and algorithm
+checks); a failed assertion is rejected with `invalid_grant`. No refresh token is issued
+([RFC 7521 §5.2](https://datatracker.ietf.org/doc/html/rfc7521#section-5.2)).
+
+> [!WARNING]
+> **Replay:** the library does not track used assertions itself. Unless `getJWTBearerUser` enforces
+> single-use on `assertionId` (and/or you keep `exp` short), a captured assertion is replayable
+> until it expires — and each replay yields a fresh access token. `assertionId` is stable whether or
+> not the assertion carries a `jti`, so track it and prefer short-lived assertions.
+
+> [!WARNING]
+> **Issuer/subject authorization:** `getJWTBearerUser` MUST verify that the `issuer` is permitted to
+> assert the given `subject`. If it merely resolves the subject to a user, any trusted issuer can
+> obtain a token for any user.
+
+> [!NOTE]
+> This grant requires a registered `client_id` (resolved via `getClient`); it does not support the
+> assertion-only request (no `client_id`) used by some providers, because the client is resolved
+> before the grant runs. It asserts *who is authorized* and is distinct from
+> [JWT client authentication](./client-authentication.md#jwt-client-authentication), where the JWT
+> proves the *client's own* identity (`iss == sub == client_id`). The two are separable and may be
+> combined.
+
## Extension Grants
**Defined in:** [Section 4.5 of RFC 6749](https://www.rfc-editor.org/rfc/rfc6749#section-4.4).
diff --git a/docs/guide/model.md b/docs/guide/model.md
index 2566be25..3b055c1d 100644
--- a/docs/guide/model.md
+++ b/docs/guide/model.md
@@ -25,6 +25,21 @@ Model functions used during request authentication:
- [verifyScope](../api/model.md#modelverifyscopeaccesstoken-scope--codepromisebooleancode)
+## Client Authentication
+
+See the [client authentication guide](./client-authentication.md) for how clients authenticate at the
+token endpoint (`client_secret_basic`, `client_secret_post`, public clients and JWT client assertions).
+
+- [getClient](../api/model.md#modelgetclientclientid-clientsecret--code-promiseclientdata-code) — resolves the client. Called **without** a secret for public (`none`) and JWT clients, so only verify `clientSecret` when it is supplied.
+
+**Optional (single-use replay protection for JWT client assertions):**
+- `isClientAssertionJtiUsed` — checks whether a `jti` has been used
+- `saveClientAssertionJti` — records a used `jti`
+
+The client returned by `getClient` may also carry `tokenEndpointAuthMethod`, `secret`, `jwks` or `jwksUri`
+(see [`ClientData`](../api/model.md)).
+
+
## Grant Types
For each [grant type](./grant-types.md) there are different model required, optional or unused.
@@ -88,6 +103,16 @@ Model functions used by the [password grant](grant-types.md#password-grant-type)
- [saveToken](../api/model.md#modelsavetokentoken-client-user--codepromiseobjectcode)
- [validateScope](../api/model.md#modelvalidatescopeuser-client-scope--codepromisebooleancode)
+### JWT Bearer Grant
+
+Model functions used by the [JWT bearer authorization grant](grant-types.md#jwt-bearer-grant-type)
+(`JwtBearerGrantType`, registered via `extendedGrantTypes`):
+
+- [getClient](../api/model.md#modelgetclientclientid-clientsecret--code-promiseclientdata-code)
+- [saveToken](../api/model.md#modelsavetokentoken-client-user--codepromiseobjectcode)
+- `getJWTBearerIssuer` — resolve a trusted issuer's verification keys and expected audience
+- `getJWTBearerUser` — resolve and authorize the assertion subject (and optionally enforce `jti` single-use)
+
### Extension Grants
The authorization server may also implement custom grant types to issue access (and optionally refresh) tokens.
diff --git a/index.d.ts b/index.d.ts
index 74e554fd..eb5edd67 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -164,6 +164,14 @@ declare namespace OAuth2Server {
abstract handle(request: Request, client: Client): Promise;
}
+ /**
+ * The JWT bearer authorization grant (RFC 7523 §2.1). Register via
+ * `extendedGrantTypes` under `urn:ietf:params:oauth:grant-type:jwt-bearer`.
+ */
+ class JwtBearerGrantType extends AbstractGrantType {
+ handle(request: Request, client: Client): Promise;
+ }
+
interface ServerOptions extends AuthenticateOptions, AuthorizeOptions, TokenOptions {
/**
* Model object
@@ -243,6 +251,64 @@ declare namespace OAuth2Server {
* Additional supported grant types.
*/
extendedGrantTypes?: Record;
+
+ /**
+ * Additional client authentication methods (`token_endpoint_auth_method`),
+ * keyed by name. Merged over the built-in `client_secret_basic`,
+ * `client_secret_post` and `none` methods.
+ */
+ extendedClientAuthentication?: Record;
+ }
+
+ /**
+ * A client authentication method — an OAuth `token_endpoint_auth_method`.
+ */
+ interface ClientAuthentication {
+ /** Whether this method presents client credentials (vs. identifying a public client). */
+ readonly requiresCredentials: boolean;
+
+ /** Does the request present credentials for this method? */
+ matches(request: Request): boolean;
+
+ /** Verify the presented credentials and resolve the authenticated client. */
+ authenticate(request: Request, context: { model: object }): Promise;
+
+ /** The `token_endpoint_auth_method` this request presents, for registered-method enforcement. */
+ presentedMethod(request: Request): string;
+ }
+
+ /**
+ * A trusted issuer for the JWT bearer authorization grant: the verification
+ * key material plus the audience the assertion's `aud` claim must contain.
+ */
+ interface JWTBearerIssuer {
+ /** The value(s) the assertion's `aud` claim must contain (e.g. the token endpoint URL). */
+ audience: string | string[];
+ /** A JWK Set (RFC 7517) of the issuer's public keys (asymmetric assertions). */
+ jwks?: object;
+ /** A URL of the issuer's JWK Set, fetched and cached by the server (asymmetric assertions). */
+ jwksUri?: string;
+ /** A shared secret used as the HMAC key (HS* assertions). */
+ secret?: string;
+ [key: string]: any;
+ }
+
+ /** Parameters passed to `getJWTBearerUser` for the JWT bearer grant. */
+ interface JWTBearerUserParams {
+ /** The verified assertion issuer (`iss`). */
+ issuer: string;
+ /** The verified assertion subject (`sub`) — the principal the token is for. */
+ subject: string;
+ /** The (resolved) client making the request. */
+ client: Client;
+ /** The requested scope (from the `scope` body parameter). */
+ scope?: string[];
+ /** The assertion `jti`, if present. */
+ jti?: string;
+ /** Stable single-use id: the `jti`, or a signing-input fingerprint when the assertion has no `jti`. */
+ assertionId: string;
+ /** The assertion `exp` (epoch seconds). */
+ exp?: number;
}
/**
@@ -268,6 +334,32 @@ declare namespace OAuth2Server {
*
*/
saveToken(token: Omit, client: Client, user: User): Promise;
+
+ /**
+ * Optional. Invoked to check whether a JWT client assertion `jti` has already
+ * been used (single-use replay protection for JWT client authentication).
+ */
+ isClientAssertionJtiUsed?(jti: string): Promise;
+
+ /**
+ * Optional. Invoked to record a JWT client assertion `jti` (with its `exp`)
+ * for single-use replay protection.
+ */
+ saveClientAssertionJti?(jti: string, exp?: number): Promise;
+
+ /**
+ * Required by the JWT bearer authorization grant (`JwtBearerGrantType`). Invoked to
+ * resolve a trusted assertion issuer's verification keys and expected audience.
+ * Return a falsy value to reject the issuer as untrusted.
+ */
+ getJWTBearerIssuer?(issuer: string): Promise;
+
+ /**
+ * Required by the JWT bearer authorization grant (`JwtBearerGrantType`). Invoked to
+ * resolve and authorize the principal (`sub`) the access token is issued for.
+ * Return a falsy value to deny the grant.
+ */
+ getJWTBearerUser?(params: JWTBearerUserParams): Promise;
}
interface RequestAuthenticationModel {
@@ -406,6 +498,15 @@ declare namespace OAuth2Server {
grants: string | string[];
accessTokenLifetime?: number;
refreshTokenLifetime?: number;
+ /**
+ * Optional registered `token_endpoint_auth_method` (RFC 7591). When set, the
+ * token endpoint rejects any client authentication method other than this one.
+ */
+ tokenEndpointAuthMethod?: string;
+ /** Optional JWK Set (RFC 7517) of the client's public keys, for `private_key_jwt`. */
+ jwks?: object;
+ /** Optional URL of the client's JWK Set, fetched and cached by the server, for `private_key_jwt`. */
+ jwksUri?: string;
[key: string]: any;
}
diff --git a/index.js b/index.js
index 070b74a4..000e9281 100644
--- a/index.js
+++ b/index.js
@@ -14,6 +14,19 @@ exports.Response = require('./lib/response');
exports.AbstractGrantType = require('./lib/grant-types/abstract-grant-type');
+/**
+ * Export the JWT bearer authorization grant (register via `extendedGrantTypes`).
+ */
+
+exports.JwtBearerGrantType = require('./lib/grant-types/jwt-bearer-grant-type');
+
+/**
+ * Export client authentication helpers for pluggable `token_endpoint_auth_method`s.
+ */
+
+exports.AbstractClientAuthentication = require('./lib/client-authentication/abstract-client-authentication');
+exports.JwtBearerClientAuthentication = require('./lib/client-authentication/jwt-bearer-client-authentication');
+
/**
* Export error classes.
*/
diff --git a/lib/client-authentication/abstract-client-authentication.js b/lib/client-authentication/abstract-client-authentication.js
new file mode 100644
index 00000000..bbb3e3c1
--- /dev/null
+++ b/lib/client-authentication/abstract-client-authentication.js
@@ -0,0 +1,89 @@
+'use strict';
+
+/*
+ * Module dependencies.
+ */
+
+const InvalidArgumentError = require('../errors/invalid-argument-error');
+
+/**
+ * @class AbstractClientAuthentication
+ * @classdesc
+ * Port for a single client-authentication method — an OAuth
+ * `token_endpoint_auth_method` (e.g. `client_secret_basic`, `private_key_jwt`).
+ *
+ * Concrete adapters are responsible only for (a) recognising their own
+ * credential shape on the incoming request and (b) verifying those
+ * credentials and resolving the client. *Selection* (which method applies,
+ * rejecting requests that present more than one) and *post-validation* (the
+ * client's `grants`) are owned by the orchestrator, not the adapter.
+ *
+ * This is deliberately minimal so that new methods — mTLS
+ * (`tls_client_auth`), attestation, etc. — can be added without touching the
+ * token handler. The built-in methods are themselves implemented against this
+ * same port.
+ *
+ * @abstract
+ */
+class AbstractClientAuthentication {
+ /**
+ * The OAuth `token_endpoint_auth_method` this request presents
+ * (e.g. `client_secret_basic`, `private_key_jwt`). For most methods this is
+ * a constant; for JWT client assertions it is derived from the assertion's
+ * algorithm. The orchestrator uses it to enforce a client's registered
+ * `tokenEndpointAuthMethod`, when the client declares one.
+ *
+ * @param {Request} request
+ * @return {string}
+ */
+ presentedMethod(request) {
+ throw new InvalidArgumentError('Invalid argument: client authentication method must implement `presentedMethod()`');
+ }
+
+ /**
+ * Whether this method presents client *credentials* (`true`) or merely
+ * identifies a public client (`false`, e.g. the `none` method).
+ *
+ * The orchestrator uses this — not the `method` identifier — to enforce
+ * that a request presents at most one credentialed mechanism and to decide
+ * when a public client is acceptable. A new credentialed method (e.g.
+ * `tls_client_auth`) therefore needs no changes elsewhere.
+ *
+ * @return {boolean}
+ */
+ get requiresCredentials() {
+ return true;
+ }
+
+ /**
+ * Does the request present credentials for this method?
+ *
+ * MUST be a cheap, side-effect-free predicate: no model calls, no network,
+ * no throwing. The orchestrator calls this on every registered method to
+ * decide which one applies.
+ *
+ * @param {Request} request the incoming token request
+ * @return {boolean}
+ */
+ matches(request) {
+ throw new InvalidArgumentError('Invalid argument: client authentication method must implement `matches()`');
+ }
+
+ /**
+ * Verify the presented credentials and resolve the authenticated client.
+ *
+ * Implementations MUST throw an `InvalidClientError` when authentication
+ * fails (or an `InvalidRequestError` for malformed input) and MUST NOT
+ * return a falsy client for a credential they accepted.
+ *
+ * @param {Request} request the incoming token request
+ * @param {object} context
+ * @param {Model} context.model the configured model
+ * @return {Promise} the authenticated client
+ */
+ async authenticate(request, context) {
+ throw new InvalidArgumentError('Invalid argument: client authentication method must implement `authenticate()`');
+ }
+}
+
+module.exports = AbstractClientAuthentication;
diff --git a/lib/client-authentication/abstract-client-secret-authentication.js b/lib/client-authentication/abstract-client-secret-authentication.js
new file mode 100644
index 00000000..41d1c7d7
--- /dev/null
+++ b/lib/client-authentication/abstract-client-secret-authentication.js
@@ -0,0 +1,57 @@
+'use strict';
+
+/*
+ * Module dependencies.
+ */
+
+const AbstractClientAuthentication = require('./abstract-client-authentication');
+const InvalidClientError = require('../errors/invalid-client-error');
+const InvalidRequestError = require('../errors/invalid-request-error');
+const isFormat = require('@node-oauth/formats');
+
+/**
+ * @class AbstractClientSecretAuthentication
+ * @classdesc
+ * Shared behaviour for the secret-based methods (`client_secret_basic`,
+ * `client_secret_post`): validate the credential format, then delegate
+ * verification of the secret to `model.getClient(clientId, clientSecret)`.
+ *
+ * Subclasses differ only in how the credentials are carried on the wire
+ * (`getCredentials`).
+ *
+ * @abstract
+ * @extends AbstractClientAuthentication
+ */
+class AbstractClientSecretAuthentication extends AbstractClientAuthentication {
+ /**
+ * Extract `{ clientId, clientSecret }` from the request for this transport.
+ * @param {Request} request
+ * @return {{clientId: *, clientSecret: *}}
+ * @abstract
+ */
+ getCredentials(request) {
+ throw new InvalidClientError('Invalid client: cannot retrieve client credentials');
+ }
+
+ async authenticate(request, { model }) {
+ const { clientId, clientSecret } = this.getCredentials(request);
+
+ if (!isFormat.vschar(clientId)) {
+ throw new InvalidRequestError('Invalid parameter: `client_id`');
+ }
+
+ if (clientSecret && !isFormat.vschar(clientSecret)) {
+ throw new InvalidRequestError('Invalid parameter: `client_secret`');
+ }
+
+ const client = await model.getClient(clientId, clientSecret);
+
+ if (!client) {
+ throw new InvalidClientError('Invalid client: client is invalid');
+ }
+
+ return client;
+ }
+}
+
+module.exports = AbstractClientSecretAuthentication;
diff --git a/lib/client-authentication/client-secret-basic.js b/lib/client-authentication/client-secret-basic.js
new file mode 100644
index 00000000..2f1d35fe
--- /dev/null
+++ b/lib/client-authentication/client-secret-basic.js
@@ -0,0 +1,35 @@
+'use strict';
+
+/*
+ * Module dependencies.
+ */
+
+const AbstractClientSecretAuthentication = require('./abstract-client-secret-authentication');
+const auth = require('basic-auth');
+
+/**
+ * @class ClientSecretBasic
+ * @classdesc
+ * `client_secret_basic`: client credentials supplied via the HTTP Basic
+ * `Authorization` header.
+ *
+ * @see https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
+ * @extends AbstractClientSecretAuthentication
+ */
+class ClientSecretBasic extends AbstractClientSecretAuthentication {
+ presentedMethod() {
+ return 'client_secret_basic';
+ }
+
+ matches(request) {
+ return !!auth(request);
+ }
+
+ getCredentials(request) {
+ const credentials = auth(request);
+
+ return { clientId: credentials.name, clientSecret: credentials.pass };
+ }
+}
+
+module.exports = ClientSecretBasic;
diff --git a/lib/client-authentication/client-secret-post.js b/lib/client-authentication/client-secret-post.js
new file mode 100644
index 00000000..0cb6e727
--- /dev/null
+++ b/lib/client-authentication/client-secret-post.js
@@ -0,0 +1,32 @@
+'use strict';
+
+/*
+ * Module dependencies.
+ */
+
+const AbstractClientSecretAuthentication = require('./abstract-client-secret-authentication');
+
+/**
+ * @class ClientSecretPost
+ * @classdesc
+ * `client_secret_post`: `client_id` and `client_secret` supplied as
+ * `application/x-www-form-urlencoded` request-body parameters.
+ *
+ * @see https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
+ * @extends AbstractClientSecretAuthentication
+ */
+class ClientSecretPost extends AbstractClientSecretAuthentication {
+ presentedMethod() {
+ return 'client_secret_post';
+ }
+
+ matches(request) {
+ return !!(request.body.client_id && request.body.client_secret);
+ }
+
+ getCredentials(request) {
+ return { clientId: request.body.client_id, clientSecret: request.body.client_secret };
+ }
+}
+
+module.exports = ClientSecretPost;
diff --git a/lib/client-authentication/index.js b/lib/client-authentication/index.js
new file mode 100644
index 00000000..641e2517
--- /dev/null
+++ b/lib/client-authentication/index.js
@@ -0,0 +1,142 @@
+'use strict';
+
+/*
+ * Module dependencies.
+ */
+
+const AbstractClientAuthentication = require('./abstract-client-authentication');
+const ClientSecretBasic = require('./client-secret-basic');
+const ClientSecretPost = require('./client-secret-post');
+const JwtBearerClientAuthentication = require('./jwt-bearer-client-authentication');
+const None = require('./none');
+const InvalidClientError = require('../errors/invalid-client-error');
+const InvalidRequestError = require('../errors/invalid-request-error');
+const ServerError = require('../errors/server-error');
+
+/**
+ * @module client-authentication
+ * @description
+ * Pluggable client authentication for the token endpoint. Each
+ * `token_endpoint_auth_method` is an adapter against
+ * {@link AbstractClientAuthentication}; this module owns *selection* (pick the
+ * one method that applies, reject requests presenting more than one) and the
+ * shared *post-validation* of the resolved client.
+ */
+
+/**
+ * The client-authentication methods enabled by default. These reproduce the
+ * library's historical behaviour (HTTP Basic, request-body credentials, and
+ * public clients). JWT client assertions are intentionally NOT enabled by
+ * default — they require per-deployment configuration (the expected
+ * `audience`) — and are added via the `extendedClientAuthentication` option.
+ *
+ * @return {Object}
+ */
+function defaultClientAuthenticationMethods() {
+ return {
+ client_secret_basic: new ClientSecretBasic(),
+ client_secret_post: new ClientSecretPost(),
+ none: new None(),
+ };
+}
+
+/**
+ * Select the single client-authentication method that applies to the request
+ * and use it to resolve and validate the authenticated client.
+ *
+ * @param {Request} request
+ * @param {Response} response
+ * @param {object} options
+ * @param {Model} options.model the configured model
+ * @param {Object} options.methods the enabled methods
+ * @param {boolean} options.clientAuthenticationRequired whether the grant requires client authentication
+ * @param {boolean} options.isPKCE whether this is a PKCE request (public clients are always permitted)
+ * @return {Promise} the authenticated client
+ */
+async function authenticateClient(request, response, options) {
+ const { model, methods, clientAuthenticationRequired, isPKCE } = options;
+
+ const method = selectMethod(request, methods, { clientAuthenticationRequired, isPKCE });
+
+ try {
+ const client = await method.authenticate(request, { model });
+
+ if (!client.grants) {
+ throw new ServerError('Server error: missing client `grants`');
+ }
+
+ if (!(client.grants instanceof Array)) {
+ throw new ServerError('Server error: `grants` must be an array');
+ }
+
+ // Enforce the client's registered authentication method when it declares one
+ // (RFC 7591 `token_endpoint_auth_method`). Clients without a registered
+ // method are unconstrained, preserving backwards compatibility.
+ const presented = method.presentedMethod(request);
+ if (client.tokenEndpointAuthMethod && client.tokenEndpointAuthMethod !== presented) {
+ throw new InvalidClientError(
+ 'Invalid client: `' + presented + '` is not a permitted authentication method for this client',
+ );
+ }
+
+ return client;
+ } catch (e) {
+ // Per RFC 6749 §5.2, include the `WWW-Authenticate` response header when
+ // the client attempted to authenticate via the `Authorization` header.
+ if (e instanceof InvalidClientError && request.get('authorization')) {
+ response.set('WWW-Authenticate', 'Basic realm="Service"');
+ throw new InvalidClientError(e, { code: 401 });
+ }
+
+ throw e;
+ }
+}
+
+/**
+ * Decide which method authenticates this request.
+ *
+ * Rejects requests that present more than one credential-bearing mechanism
+ * (RFC 6749 §2.3). When no credentials are presented, a public (`none`)
+ * client is accepted only for PKCE requests or grants that do not require
+ * client authentication.
+ */
+function selectMethod(request, methods, { clientAuthenticationRequired, isPKCE }) {
+ const credentialed = [];
+ let publicMethod = null;
+
+ for (const method of Object.values(methods)) {
+ if (!method.matches(request)) {
+ continue;
+ }
+
+ if (method.requiresCredentials) {
+ credentialed.push(method);
+ } else {
+ publicMethod = method;
+ }
+ }
+
+ if (credentialed.length > 1) {
+ throw new InvalidRequestError('Invalid request: multiple client authentication mechanisms');
+ }
+
+ if (credentialed.length === 1) {
+ return credentialed[0];
+ }
+
+ if (publicMethod && (isPKCE || !clientAuthenticationRequired)) {
+ return publicMethod;
+ }
+
+ throw new InvalidClientError('Invalid client: cannot retrieve client credentials');
+}
+
+module.exports = {
+ AbstractClientAuthentication,
+ ClientSecretBasic,
+ ClientSecretPost,
+ JwtBearerClientAuthentication,
+ None,
+ defaultClientAuthenticationMethods,
+ authenticateClient,
+};
diff --git a/lib/client-authentication/jwt-bearer-client-authentication.js b/lib/client-authentication/jwt-bearer-client-authentication.js
new file mode 100644
index 00000000..9117ffc8
--- /dev/null
+++ b/lib/client-authentication/jwt-bearer-client-authentication.js
@@ -0,0 +1,200 @@
+'use strict';
+
+/*
+ * Module dependencies.
+ */
+
+const AbstractClientAuthentication = require('./abstract-client-authentication');
+const InvalidClientError = require('../errors/invalid-client-error');
+const ServerError = require('../errors/server-error');
+const { decodeJwt, decodeProtectedHeader, jwtVerify, createLocalJWKSet } = require('jose');
+const { algorithmsForHeader, isHmac, isValidationError, replayId, getRemoteJwks } = require('../utils/jws-util');
+
+/**
+ * The `client_assertion_type` value identifying a JWT client assertion.
+ * @see https://datatracker.ietf.org/doc/html/rfc7523#section-2.2
+ */
+const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
+
+/**
+ * @class JwtBearerClientAuthentication
+ * @classdesc
+ * JWT client assertion authentication — `client_assertion` +
+ * `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`.
+ *
+ * Covers both OIDC methods, distinguished by the JWS `alg` of the assertion:
+ * - `client_secret_jwt` — HMAC (`HS*`), keyed by the client secret;
+ * - `private_key_jwt` — asymmetric (`RS*`/`PS*`/`ES*`), verified against
+ * the client's registered public keys (a JWK Set).
+ *
+ * The library owns the *protocol* (parse → resolve client → verify → bind);
+ * key material and replay state come from the model/client. This method is
+ * opt-in (it requires per-deployment `audience` configuration); register it
+ * via the `extendedClientAuthentication` server option.
+ *
+ * @see https://datatracker.ietf.org/doc/html/rfc7521#section-4.2
+ * @see https://datatracker.ietf.org/doc/html/rfc7523#section-2.2
+ * @see https://datatracker.ietf.org/doc/html/rfc7523#section-3
+ * @see https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication
+ * @extends AbstractClientAuthentication
+ */
+class JwtBearerClientAuthentication extends AbstractClientAuthentication {
+ /**
+ * @param {object} options
+ * @param {string|string[]} options.audience the value(s) the assertion's
+ * `aud` claim must contain — typically this authorization server's token
+ * endpoint URL and/or its issuer identifier. REQUIRED.
+ * @param {number} [options.maxTokenAge] maximum assertion age in seconds,
+ * measured from `iat` (enabling this requires assertions to carry `iat`).
+ * @param {number} [options.clockTolerance] clock skew tolerance in seconds.
+ * @param {string[]} [options.algorithms] override the accepted JWS algorithms.
+ * @param {function} [options.getKey] `(client, header) => key` override key
+ * resolution. By default HMAC keys derive from `client.secret` and
+ * asymmetric keys come from `client.jwks` (a JWK Set) or `client.jwksUri`.
+ */
+ constructor(options = {}) {
+ super();
+
+ if (!options.audience) {
+ throw new ServerError('Server error: `audience` is required for JWT client authentication');
+ }
+
+ this.audience = options.audience;
+ this.maxTokenAge = options.maxTokenAge;
+ this.clockTolerance = options.clockTolerance;
+ this.algorithms = options.algorithms;
+ this.getKey = options.getKey || this.defaultGetKey.bind(this);
+ }
+
+ matches(request) {
+ return request.body.client_assertion_type === CLIENT_ASSERTION_TYPE && !!request.body.client_assertion;
+ }
+
+ // The concrete OIDC method depends on the assertion's signature algorithm:
+ // an HMAC `alg` is `client_secret_jwt`; an asymmetric `alg` is `private_key_jwt`.
+ // Only called after the assertion has been verified, so the header is decodable.
+ presentedMethod(request) {
+ const header = decodeProtectedHeader(request.body.client_assertion);
+ return isHmac(header.alg) ? 'client_secret_jwt' : 'private_key_jwt';
+ }
+
+ async authenticate(request, { model }) {
+ const assertion = request.body.client_assertion;
+
+ // Decode (WITHOUT verifying) only to discover which client we must verify
+ // against. Nothing read here is trusted: the value is used solely to look
+ // up the client and its key, and `iss`/`sub` are re-bound to the resolved
+ // client id *after* the signature is checked, below.
+ let claims;
+ let header;
+ try {
+ claims = decodeJwt(assertion);
+ header = decodeProtectedHeader(assertion);
+ } catch (e) {
+ throw new InvalidClientError('Invalid client: `client_assertion` is malformed');
+ }
+
+ const clientId = claims.sub;
+ if (!clientId) {
+ throw new InvalidClientError('Invalid client: `client_assertion` is missing the `sub` claim');
+ }
+
+ // RFC 7521 §4.2: `client_id` is optional, but if present it MUST match.
+ if (request.body.client_id && request.body.client_id !== clientId) {
+ throw new InvalidClientError('Invalid client: `client_id` does not match the assertion subject');
+ }
+
+ const client = await model.getClient(clientId);
+ if (!client) {
+ throw new InvalidClientError('Invalid client: client is invalid');
+ }
+
+ const algorithms = this.algorithms || algorithmsForHeader(header);
+ const key = await this.getKey(client, header);
+
+ let payload;
+ try {
+ ({ payload } = await jwtVerify(assertion, key, {
+ algorithms,
+ audience: this.audience,
+ issuer: clientId, // RFC 7523 §3: for client auth, `iss` MUST be the client_id
+ subject: clientId, // and `sub` MUST be the client_id
+ requiredClaims: ['iss', 'sub', 'aud', 'exp'],
+ maxTokenAge: this.maxTokenAge,
+ clockTolerance: this.clockTolerance,
+ }));
+ } catch (e) {
+ // An invalid assertion is the client's fault; a JWKS fetch failure or
+ // other operational error is the server's — don't report it as a bad
+ // credential, and don't leak the raw (possibly topology-revealing) message.
+ if (isValidationError(e)) {
+ throw new InvalidClientError('Invalid client: ' + e.message);
+ }
+ throw new ServerError(e);
+ }
+
+ await this.assertNotReplayed(model, payload, assertion);
+
+ return client;
+ }
+
+ /**
+ * Default key resolution: HMAC from the client secret, asymmetric from the
+ * client's registered JWK Set (inline `jwks` or remote `jwksUri`).
+ */
+ async defaultGetKey(client, header) {
+ if (isHmac(header.alg)) {
+ if (typeof client.secret !== 'string' || client.secret.length === 0) {
+ throw new InvalidClientError('Invalid client: client has no usable secret for `client_secret_jwt`');
+ }
+ return new TextEncoder().encode(client.secret);
+ }
+
+ if (client.jwks) {
+ return createLocalJWKSet(client.jwks);
+ }
+
+ if (client.jwksUri) {
+ return getRemoteJwks(client.jwksUri);
+ }
+
+ throw new InvalidClientError('Invalid client: client has no registered keys for `private_key_jwt`');
+ }
+
+ /**
+ * Single-use replay protection. Opt-in: only enforced when the model
+ * implements the replay hooks. The identifier passed to the hooks is the
+ * assertion's `jti` when present, otherwise a fingerprint of its signing
+ * input — so replay protection applies even to assertions without a `jti`
+ * (OIDC Core §9 requires `jti`; RFC 7523 §3 makes it optional).
+ */
+ async assertNotReplayed(model, payload, assertion) {
+ const canCheck = typeof model.isClientAssertionJtiUsed === 'function';
+ const canSave = typeof model.saveClientAssertionJti === 'function';
+
+ // Replay protection is opt-in, but it is only meaningful when the model can
+ // both record AND check an identifier. A half-configured model would
+ // silently provide no protection while appearing enabled, so reject that.
+ if (!canCheck && !canSave) {
+ return;
+ }
+
+ if (canCheck !== canSave) {
+ throw new ServerError(
+ 'Server error: client assertion replay protection requires both `isClientAssertionJtiUsed` and `saveClientAssertionJti`',
+ );
+ }
+
+ const id = replayId(assertion, payload.jti);
+
+ if (await model.isClientAssertionJtiUsed(id)) {
+ throw new InvalidClientError('Invalid client: `client_assertion` has already been used');
+ }
+
+ await model.saveClientAssertionJti(id, payload.exp);
+ }
+}
+
+JwtBearerClientAuthentication.CLIENT_ASSERTION_TYPE = CLIENT_ASSERTION_TYPE;
+
+module.exports = JwtBearerClientAuthentication;
diff --git a/lib/client-authentication/none.js b/lib/client-authentication/none.js
new file mode 100644
index 00000000..4775505f
--- /dev/null
+++ b/lib/client-authentication/none.js
@@ -0,0 +1,60 @@
+'use strict';
+
+/*
+ * Module dependencies.
+ */
+
+const AbstractClientAuthentication = require('./abstract-client-authentication');
+const InvalidClientError = require('../errors/invalid-client-error');
+const InvalidRequestError = require('../errors/invalid-request-error');
+const isFormat = require('@node-oauth/formats');
+
+/**
+ * @class None
+ * @classdesc
+ * `none`: a public client that identifies itself with `client_id` only and
+ * presents no secret (e.g. a PKCE flow, or a grant for which
+ * `requireClientAuthentication` is disabled).
+ *
+ * This adapter only resolves the client. Whether a secret-less client is
+ * *acceptable* for the request is a policy decision owned by the orchestrator
+ * (it knows the grant type, the `requireClientAuthentication` config and
+ * whether this is a PKCE request).
+ *
+ * @see https://datatracker.ietf.org/doc/html/rfc6749#section-2.1
+ * @extends AbstractClientAuthentication
+ */
+class None extends AbstractClientAuthentication {
+ presentedMethod() {
+ return 'none';
+ }
+
+ get requiresCredentials() {
+ return false;
+ }
+
+ // A positive predicate only: the orchestrator owns mutual exclusion, so this
+ // adapter needs no knowledge of other methods' credential shapes. `none` is
+ // selected only as a fallback when no credentialed method matched.
+ matches(request) {
+ return !!request.body.client_id;
+ }
+
+ async authenticate(request, { model }) {
+ const clientId = request.body.client_id;
+
+ if (!isFormat.vschar(clientId)) {
+ throw new InvalidRequestError('Invalid parameter: `client_id`');
+ }
+
+ const client = await model.getClient(clientId);
+
+ if (!client) {
+ throw new InvalidClientError('Invalid client: client is invalid');
+ }
+
+ return client;
+ }
+}
+
+module.exports = None;
diff --git a/lib/grant-types/jwt-bearer-grant-type.js b/lib/grant-types/jwt-bearer-grant-type.js
new file mode 100644
index 00000000..4b105b10
--- /dev/null
+++ b/lib/grant-types/jwt-bearer-grant-type.js
@@ -0,0 +1,235 @@
+'use strict';
+
+/*
+ * Module dependencies.
+ */
+
+const AbstractGrantType = require('./abstract-grant-type');
+const InvalidArgumentError = require('../errors/invalid-argument-error');
+const InvalidGrantError = require('../errors/invalid-grant-error');
+const InvalidRequestError = require('../errors/invalid-request-error');
+const ServerError = require('../errors/server-error');
+const { decodeJwt, decodeProtectedHeader, jwtVerify, createLocalJWKSet } = require('jose');
+const { algorithmsForHeader, isHmac, isValidationError, replayId, getRemoteJwks } = require('../utils/jws-util');
+
+/**
+ * The `grant_type` value for the JWT bearer authorization grant.
+ * @see https://datatracker.ietf.org/doc/html/rfc7523#section-2.1
+ */
+const GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
+
+/**
+ * @class JwtBearerGrantType
+ * @classdesc
+ * The JWT bearer authorization grant (RFC 7521 §4.1, RFC 7523 §2.1/§3): a signed
+ * JWT *is* the authorization grant. The assertion's `iss` identifies a trusted
+ * issuer (whose key verifies the assertion) and `sub` identifies the principal
+ * the access token is issued for. Unlike JWT *client authentication*, `sub` is
+ * the user/principal — not the client — and `iss`/`sub` are not bound to the
+ * client id.
+ *
+ * This is an extension grant; register it via `extendedGrantTypes`. The
+ * requested `scope` is taken from the body parameter (RFC 7521 §4.1), and no
+ * refresh token is issued (RFC 7521 §5.2).
+ *
+ * The model must implement:
+ * - `getJWTBearerIssuer(issuer)` → `{ audience, jwks | jwksUri | secret }` (or
+ * falsy for an untrusted issuer) — the verification key material and the
+ * expected `aud`.
+ * - `getJWTBearerUser({ issuer, subject, client, scope, jti, assertionId, exp })` → the
+ * authorized user (or falsy to deny). Replay (`jti`) can be enforced here.
+ *
+ * @example
+ * new OAuth2Server({
+ * model,
+ * extendedGrantTypes: {
+ * 'urn:ietf:params:oauth:grant-type:jwt-bearer': JwtBearerGrantType
+ * },
+ * // typically a public requester; identify it with `client_id`:
+ * requireClientAuthentication: { 'urn:ietf:params:oauth:grant-type:jwt-bearer': false }
+ * });
+ *
+ * @see https://datatracker.ietf.org/doc/html/rfc7521#section-4.1
+ * @see https://datatracker.ietf.org/doc/html/rfc7523#section-2.1
+ * @see https://datatracker.ietf.org/doc/html/rfc7523#section-3
+ * @extends AbstractGrantType
+ */
+class JwtBearerGrantType extends AbstractGrantType {
+ constructor(options = {}) {
+ if (!options.model) {
+ throw new InvalidArgumentError('Missing parameter: `model`');
+ }
+
+ if (!options.model.getJWTBearerIssuer) {
+ throw new InvalidArgumentError('Invalid argument: model does not implement `getJWTBearerIssuer()`');
+ }
+
+ if (!options.model.getJWTBearerUser) {
+ throw new InvalidArgumentError('Invalid argument: model does not implement `getJWTBearerUser()`');
+ }
+
+ if (!options.model.saveToken) {
+ throw new InvalidArgumentError('Invalid argument: model does not implement `saveToken()`');
+ }
+
+ super(options);
+
+ this.maxTokenAge = options.maxTokenAge;
+ this.clockTolerance = options.clockTolerance;
+ this.algorithms = options.algorithms;
+ }
+
+ /**
+ * Handle the JWT bearer grant.
+ *
+ * @param request {Request}
+ * @param client {ClientData}
+ * @see https://datatracker.ietf.org/doc/html/rfc7523#section-2.1
+ */
+ async handle(request, client) {
+ if (!request) {
+ throw new InvalidArgumentError('Missing parameter: `request`');
+ }
+
+ if (!client) {
+ throw new InvalidArgumentError('Missing parameter: `client`');
+ }
+
+ const payload = await this.verifyAssertion(request);
+ const scope = this.getScope(request);
+ const user = await this.getUser(payload, client, scope, request.body.assertion);
+
+ return this.saveToken(user, client, scope);
+ }
+
+ /**
+ * Verify the `assertion` and return its (trusted) claims.
+ */
+ async verifyAssertion(request) {
+ const assertion = request.body.assertion;
+
+ if (!assertion) {
+ throw new InvalidRequestError('Missing parameter: `assertion`');
+ }
+
+ // Decode (without verifying) only to discover the issuer we must verify
+ // against; nothing here is trusted until the signature is checked.
+ let issuer;
+ let header;
+ try {
+ issuer = decodeJwt(assertion).iss;
+ header = decodeProtectedHeader(assertion);
+ } catch (e) {
+ throw new InvalidGrantError('Invalid grant: `assertion` is malformed');
+ }
+
+ if (!issuer) {
+ throw new InvalidGrantError('Invalid grant: `assertion` is missing the `iss` claim');
+ }
+
+ const issuerData = await this.model.getJWTBearerIssuer(issuer);
+ if (!issuerData) {
+ throw new InvalidGrantError('Invalid grant: `assertion` issuer is not trusted');
+ }
+
+ if (!issuerData.audience || (Array.isArray(issuerData.audience) && issuerData.audience.length === 0)) {
+ throw new ServerError('Server error: `getJWTBearerIssuer()` did not return an `audience`');
+ }
+
+ const algorithms = this.algorithms || algorithmsForHeader(header);
+ const key = await this.getKey(issuerData, header);
+
+ try {
+ const { payload } = await jwtVerify(assertion, key, {
+ algorithms,
+ audience: issuerData.audience,
+ issuer,
+ requiredClaims: ['iss', 'sub', 'aud', 'exp'],
+ maxTokenAge: this.maxTokenAge,
+ clockTolerance: this.clockTolerance,
+ });
+
+ return payload;
+ } catch (e) {
+ if (isValidationError(e)) {
+ throw new InvalidGrantError('Invalid grant: ' + e.message);
+ }
+ throw new ServerError(e);
+ }
+ }
+
+ /**
+ * Resolve the verification key for the issuer (HMAC secret or asymmetric JWKS).
+ */
+ async getKey(issuerData, header) {
+ const hasSecret = typeof issuerData.secret === 'string' && issuerData.secret.length > 0;
+ const hasJwks = !!issuerData.jwks || !!issuerData.jwksUri;
+
+ // No key material at all is a server-side misconfiguration of the issuer.
+ if (!hasSecret && !hasJwks) {
+ throw new ServerError('Server error: issuer has no key material for assertion verification');
+ }
+
+ if (isHmac(header.alg)) {
+ // The issuer has key material, just not for this algorithm family — that
+ // makes the assertion unverifiable against this issuer, i.e. the client's
+ // fault, not the server's.
+ if (!hasSecret) {
+ throw new InvalidGrantError('Invalid grant: assertion algorithm does not match the issuer key material');
+ }
+ return new TextEncoder().encode(issuerData.secret);
+ }
+
+ if (!hasJwks) {
+ throw new InvalidGrantError('Invalid grant: assertion algorithm does not match the issuer key material');
+ }
+
+ if (issuerData.jwks) {
+ return createLocalJWKSet(issuerData.jwks);
+ }
+
+ return getRemoteJwks(issuerData.jwksUri);
+ }
+
+ /**
+ * Resolve and authorize the principal (`sub`) the token is issued for.
+ */
+ async getUser(payload, client, scope, assertion) {
+ const user = await this.model.getJWTBearerUser({
+ issuer: payload.iss,
+ subject: payload.sub,
+ client,
+ scope,
+ jti: payload.jti,
+ assertionId: replayId(assertion, payload.jti),
+ exp: payload.exp,
+ });
+
+ if (!user) {
+ throw new InvalidGrantError('Invalid grant: the assertion subject is not authorized');
+ }
+
+ return user;
+ }
+
+ /**
+ * Save and return the access token. No refresh token is issued (RFC 7521 §5.2).
+ */
+ async saveToken(user, client, requestedScope) {
+ const validatedScope = await this.validateScope(user, client, requestedScope);
+ const accessToken = await this.generateAccessToken(client, user, validatedScope);
+ const accessTokenExpiresAt = this.getAccessTokenExpiresAt(client, user, validatedScope);
+
+ const token = {
+ accessToken,
+ accessTokenExpiresAt,
+ scope: validatedScope,
+ };
+
+ return this.model.saveToken(token, client, user);
+ }
+}
+
+JwtBearerGrantType.GRANT_TYPE = GRANT_TYPE;
+
+module.exports = JwtBearerGrantType;
diff --git a/lib/handlers/token-handler.js b/lib/handlers/token-handler.js
index c6ebebb2..cc691fea 100644
--- a/lib/handlers/token-handler.js
+++ b/lib/handlers/token-handler.js
@@ -6,7 +6,6 @@
const BearerTokenType = require('../token-types/bearer-token-type');
const InvalidArgumentError = require('../errors/invalid-argument-error');
-const InvalidClientError = require('../errors/invalid-client-error');
const InvalidRequestError = require('../errors/invalid-request-error');
const OAuthError = require('../errors/oauth-error');
const Request = require('../request');
@@ -15,9 +14,9 @@ const ServerError = require('../errors/server-error');
const TokenModel = require('../models/token-model');
const UnauthorizedClientError = require('../errors/unauthorized-client-error');
const UnsupportedGrantTypeError = require('../errors/unsupported-grant-type-error');
-const auth = require('basic-auth');
const pkce = require('../pkce/pkce');
const isFormat = require('@node-oauth/formats');
+const { defaultClientAuthenticationMethods, authenticateClient } = require('../client-authentication');
/**
* Grant types.
@@ -62,6 +61,11 @@ class TokenHandler {
this.requireClientAuthentication = options.requireClientAuthentication || {};
this.alwaysIssueNewRefreshToken = options.alwaysIssueNewRefreshToken !== false;
this.enablePlainPKCE = options.enablePlainPKCE === true;
+ this.clientAuthenticationMethods = Object.assign(
+ {},
+ defaultClientAuthenticationMethods(),
+ options.extendedClientAuthentication,
+ );
}
/**
@@ -88,9 +92,7 @@ class TokenHandler {
try {
const client = await this.getClient(request, response);
const data = await this.handleGrantType(request, client);
- const model = new TokenModel(data, {
- allowExtendedTokenAttributes: this.allowExtendedTokenAttributes,
- });
+ const model = new TokenModel(data, { allowExtendedTokenAttributes: this.allowExtendedTokenAttributes });
const tokenType = this.getTokenType(model);
this.updateSuccessResponse(response, tokenType);
@@ -110,98 +112,27 @@ class TokenHandler {
/**
* Get the client from the model.
- */
-
- async getClient(request, response) {
- const credentials = await this.getClientCredentials(request);
- const grantType = request.body.grant_type;
- const codeVerifier = request.body.code_verifier;
- const isPkce = pkce.isPKCERequest({ grantType, codeVerifier });
-
- if (!credentials.clientId) {
- throw new InvalidRequestError('Missing parameter: `client_id`');
- }
-
- if (this.isClientAuthenticationRequired(grantType) && !credentials.clientSecret && !isPkce) {
- throw new InvalidRequestError('Missing parameter: `client_secret`');
- }
-
- if (!isFormat.vschar(credentials.clientId)) {
- throw new InvalidRequestError('Invalid parameter: `client_id`');
- }
-
- if (credentials.clientSecret && !isFormat.vschar(credentials.clientSecret)) {
- throw new InvalidRequestError('Invalid parameter: `client_secret`');
- }
-
- try {
- const client = await this.model.getClient(credentials.clientId, credentials.clientSecret);
-
- if (!client) {
- throw new InvalidClientError('Invalid client: client is invalid');
- }
-
- if (!client.grants) {
- throw new ServerError('Server error: missing client `grants`');
- }
-
- if (!(client.grants instanceof Array)) {
- throw new ServerError('Server error: `grants` must be an array');
- }
-
- return client;
- } catch (e) {
- // Include the "WWW-Authenticate" response header field if the client
- // attempted to authenticate via the "Authorization" request header.
- //
- // @see https://tools.ietf.org/html/rfc6749#section-5.2.
- if (e instanceof InvalidClientError && request.get('authorization')) {
- response.set('WWW-Authenticate', 'Basic realm="Service"');
- throw new InvalidClientError(e, { code: 401 });
- }
-
- throw e;
- }
- }
-
- /**
- * Get client credentials.
*
- * The client credentials may be sent using the HTTP Basic authentication scheme or, alternatively,
- * the `client_id` and `client_secret` can be embedded in the body.
+ * Client authentication is delegated to the configured authentication
+ * methods (see the client authentication guide). The single method
+ * that matches the request resolves and verifies the client; supported out
+ * of the box are HTTP Basic, request-body credentials and public clients,
+ * plus any methods added via `extendedClientAuthentication` (e.g. JWT
+ * client assertions).
*
- * @see https://tools.ietf.org/html/rfc6749#section-2.3.1
+ * @see https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
*/
- getClientCredentials(request) {
- const credentials = auth(request);
+ async getClient(request, response) {
const grantType = request.body.grant_type;
const codeVerifier = request.body.code_verifier;
- if (credentials) {
- return { clientId: credentials.name, clientSecret: credentials.pass };
- }
-
- if (request.body.client_id && request.body.client_secret) {
- return {
- clientId: request.body.client_id,
- clientSecret: request.body.client_secret,
- };
- }
-
- if (pkce.isPKCERequest({ grantType, codeVerifier })) {
- if (request.body.client_id) {
- return { clientId: request.body.client_id };
- }
- }
-
- if (!this.isClientAuthenticationRequired(grantType)) {
- if (request.body.client_id) {
- return { clientId: request.body.client_id };
- }
- }
-
- throw new InvalidClientError('Invalid client: cannot retrieve client credentials');
+ return authenticateClient(request, response, {
+ model: this.model,
+ methods: this.clientAuthenticationMethods,
+ clientAuthenticationRequired: this.isClientAuthenticationRequired(grantType),
+ isPKCE: pkce.isPKCERequest({ grantType, codeVerifier }),
+ });
}
/**
diff --git a/lib/model.js b/lib/model.js
index cbf4e843..a0560349 100644
--- a/lib/model.js
+++ b/lib/model.js
@@ -46,6 +46,10 @@ const ServerError = require('./errors/server-error');
* @property grants {string[]} Grant types allowed for the client.
* @property accessTokenLifetime {number} Client-specific lifetime of generated access tokens in seconds.
* @property refreshTokenLifetime {number} Client-specific lifetime of generated refresh tokens in seconds.
+ * @property tokenEndpointAuthMethod {string} Optional. Registered `token_endpoint_auth_method` (RFC 7591). When set, the token endpoint rejects any other client authentication method.
+ * @property secret {string} Optional. Client secret; also reused as the HMAC key for `client_secret_jwt` authentication.
+ * @property jwks {object} Optional. JWK Set (RFC 7517) of the client's public keys, used to verify a `private_key_jwt` client assertion.
+ * @property jwksUri {string} Optional. URL of the client's JWK Set, fetched and cached by the server, used to verify a `private_key_jwt` client assertion.
*/
/**
@@ -620,6 +624,74 @@ class Model {
async validateRedirectUri(redirectUri, client) {
throw new ServerError('validateRedirectUri not implemented');
}
+
+ /**
+ * Invoked to check whether a JWT client assertion `jti` has already been used,
+ * for single-use replay protection of JWT client authentication.
+ * This model function is **optional**. Replay protection is only enforced when
+ * both this and `saveClientAssertionJti` are implemented.
+ *
+ * **Invoked during:**
+ * - JWT client authentication (`private_key_jwt` / `client_secret_jwt`)
+ *
+ * @async
+ * @param jti {string} The `jti` claim of the client assertion.
+ * @return {Promise} Resolves `true` if the `jti` has already been used.
+ */
+ async isClientAssertionJtiUsed(jti) {
+ throw new ServerError('isClientAssertionJtiUsed not implemented');
+ }
+
+ /**
+ * Invoked to record a JWT client assertion `jti` (with its expiry) for single-use
+ * replay protection. Store it with a TTL until `exp` so the record self-expires.
+ * This model function is **optional**. Replay protection is only enforced when
+ * both this and `isClientAssertionJtiUsed` are implemented.
+ *
+ * **Invoked during:**
+ * - JWT client authentication (`private_key_jwt` / `client_secret_jwt`)
+ *
+ * @async
+ * @param jti {string} The `jti` claim of the client assertion.
+ * @param exp {number} The assertion's `exp` (epoch seconds), for use as a TTL.
+ * @return {Promise}
+ */
+ async saveClientAssertionJti(jti, exp) {
+ throw new ServerError('saveClientAssertionJti not implemented');
+ }
+
+ /**
+ * Invoked to resolve a trusted issuer for the JWT bearer authorization grant.
+ * **Required** when the `urn:ietf:params:oauth:grant-type:jwt-bearer` grant is enabled.
+ *
+ * **Invoked during:**
+ * - JWT bearer authorization grant (`JwtBearerGrantType`)
+ *
+ * @async
+ * @param issuer {string} The assertion's `iss` claim.
+ * @return {Promise