diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index fce00187..b4fcfd94 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -23,6 +23,7 @@ export default defineConfig({ {text: 'Model', link: '/guide/model'}, {text: 'Token types', link: '/guide/token-types'}, {text: 'PKCE', link: '/guide/pkce'}, + {text: 'Client authentication', link: '/guide/client-authentication'}, {text: 'Adapters', link: '/guide/adapters'}, {text: 'Compliance', link: '/guide/compliance'}, {text: 'Migrating to v5', link: '/guide/migrating-to-v5'}, @@ -61,6 +62,7 @@ export default defineConfig({ { text: 'Client Credentials', link: '/api/grant-types/client-credentials-grant-type' }, { text: 'Password', link: '/api/grant-types/password-grant-type' }, { text: 'Refresh Token', link: '/api/grant-types/refresh-token-grant-type' }, + { text: 'JWT Bearer', link: '/api/grant-types/jwt-bearer-grant-type' }, ] }, { @@ -70,6 +72,16 @@ export default defineConfig({ { text: 'Token Handler', link: '/api/handlers/token-handler' }, ] }, + { + text: 'Client Authentication', items: [ + { text: 'Abstract', link: '/api/client-authentication/abstract-client-authentication' }, + { text: 'Abstract Client Secret', link: '/api/client-authentication/abstract-client-secret-authentication' }, + { text: 'Client Secret Basic', link: '/api/client-authentication/client-secret-basic' }, + { text: 'Client Secret Post', link: '/api/client-authentication/client-secret-post' }, + { text: 'None (public client)', link: '/api/client-authentication/none' }, + { text: 'JWT Bearer', link: '/api/client-authentication/jwt-bearer-client-authentication' }, + ] + }, { text: 'Models', items: [ { text: 'Token Model', link: '/api/models/token-model' }, @@ -96,6 +108,7 @@ export default defineConfig({ text: 'Utils', items: [ { text: 'Crypto', link: '/api/utils/crypto-util' }, { text: 'Date', link: '/api/utils/date-util' }, + { text: 'JWS', link: '/api/utils/jws-util' }, { text: 'Scope', link: '/api/utils/scope-util' }, { text: 'String', link: '/api/utils/string-util' }, { text: 'Token', link: '/api/utils/token-util' }, diff --git a/docs/api/client-authentication/abstract-client-authentication.md b/docs/api/client-authentication/abstract-client-authentication.md new file mode 100644 index 00000000..b0fd77b2 --- /dev/null +++ b/docs/api/client-authentication/abstract-client-authentication.md @@ -0,0 +1,85 @@ + + +## *AbstractClientAuthentication* +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. + +**Kind**: global abstract class + +* *[AbstractClientAuthentication](#AbstractClientAuthentication)* + * *[.requiresCredentials](#AbstractClientAuthentication+requiresCredentials) ⇒ boolean* + * *[.presentedMethod(request)](#AbstractClientAuthentication+presentedMethod) ⇒ string* + * *[.matches(request)](#AbstractClientAuthentication+matches) ⇒ boolean* + * *[.authenticate(request, context)](#AbstractClientAuthentication+authenticate) ⇒ Promise.<Client>* + + + +### *abstractClientAuthentication.requiresCredentials ⇒ boolean* +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. + +**Kind**: instance property of [AbstractClientAuthentication](#AbstractClientAuthentication) + + +### *abstractClientAuthentication.presentedMethod(request) ⇒ string* +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. + +**Kind**: instance method of [AbstractClientAuthentication](#AbstractClientAuthentication) + +| Param | Type | +| --- | --- | +| request | Request | + + + +### *abstractClientAuthentication.matches(request) ⇒ boolean* +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. + +**Kind**: instance method of [AbstractClientAuthentication](#AbstractClientAuthentication) + +| Param | Type | Description | +| --- | --- | --- | +| request | Request | the incoming token request | + + + +### *abstractClientAuthentication.authenticate(request, context) ⇒ Promise.<Client>* +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. + +**Kind**: instance method of [AbstractClientAuthentication](#AbstractClientAuthentication) +**Returns**: Promise.<Client> - the authenticated client + +| Param | Type | Description | +| --- | --- | --- | +| request | Request | the incoming token request | +| context | object | | +| context.model | Model | the configured model | + diff --git a/docs/api/client-authentication/abstract-client-secret-authentication.md b/docs/api/client-authentication/abstract-client-secret-authentication.md new file mode 100644 index 00000000..c8664b28 --- /dev/null +++ b/docs/api/client-authentication/abstract-client-secret-authentication.md @@ -0,0 +1,23 @@ + + +## *AbstractClientSecretAuthentication ⇐ AbstractClientAuthentication* +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`). + +**Kind**: global abstract class +**Extends**: AbstractClientAuthentication + + +### **abstractClientSecretAuthentication.getCredentials(request) ⇒ Object** +Extract `{ clientId, clientSecret }` from the request for this transport. + +**Kind**: instance abstract method of [AbstractClientSecretAuthentication](#AbstractClientSecretAuthentication) + +| Param | Type | +| --- | --- | +| request | Request | + diff --git a/docs/api/client-authentication/client-secret-basic.md b/docs/api/client-authentication/client-secret-basic.md new file mode 100644 index 00000000..ec67b40f --- /dev/null +++ b/docs/api/client-authentication/client-secret-basic.md @@ -0,0 +1,9 @@ + + +## ClientSecretBasic ⇐ AbstractClientSecretAuthentication +`client_secret_basic`: client credentials supplied via the HTTP Basic +`Authorization` header. + +**Kind**: global class +**Extends**: AbstractClientSecretAuthentication +**See**: https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 diff --git a/docs/api/client-authentication/client-secret-post.md b/docs/api/client-authentication/client-secret-post.md new file mode 100644 index 00000000..f4eec886 --- /dev/null +++ b/docs/api/client-authentication/client-secret-post.md @@ -0,0 +1,9 @@ + + +## ClientSecretPost ⇐ AbstractClientSecretAuthentication +`client_secret_post`: `client_id` and `client_secret` supplied as +`application/x-www-form-urlencoded` request-body parameters. + +**Kind**: global class +**Extends**: AbstractClientSecretAuthentication +**See**: https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 diff --git a/docs/api/client-authentication/index.md b/docs/api/client-authentication/index.md new file mode 100644 index 00000000..16a28e1e --- /dev/null +++ b/docs/api/client-authentication/index.md @@ -0,0 +1,55 @@ + + +## client-authentication +Pluggable client authentication for the token endpoint. Each +`token_endpoint_auth_method` is an adapter against +[AbstractClientAuthentication](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. + + +* [client-authentication](#module_client-authentication) + * [~defaultClientAuthenticationMethods()](#module_client-authentication..defaultClientAuthenticationMethods) ⇒ Object.<string, AbstractClientAuthentication> + * [~authenticateClient(request, response, options)](#module_client-authentication..authenticateClient) ⇒ Promise.<Client> + * [~selectMethod()](#module_client-authentication..selectMethod) + + + +### client-authentication~defaultClientAuthenticationMethods() ⇒ Object.<string, AbstractClientAuthentication> +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. + +**Kind**: inner method of [client-authentication](#module_client-authentication) + + +### client-authentication~authenticateClient(request, response, options) ⇒ Promise.<Client> +Select the single client-authentication method that applies to the request +and use it to resolve and validate the authenticated client. + +**Kind**: inner method of [client-authentication](#module_client-authentication) +**Returns**: Promise.<Client> - the authenticated client + +| Param | Type | Description | +| --- | --- | --- | +| request | Request | | +| response | Response | | +| options | object | | +| options.model | Model | the configured model | +| options.methods | Object.<string, AbstractClientAuthentication> | the enabled methods | +| options.clientAuthenticationRequired | boolean | whether the grant requires client authentication | +| options.isPKCE | boolean | whether this is a PKCE request (public clients are always permitted) | + + + +### client-authentication~selectMethod() +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. + +**Kind**: inner method of [client-authentication](#module_client-authentication) diff --git a/docs/api/client-authentication/jwt-bearer-client-authentication.md b/docs/api/client-authentication/jwt-bearer-client-authentication.md new file mode 100644 index 00000000..b5396264 --- /dev/null +++ b/docs/api/client-authentication/jwt-bearer-client-authentication.md @@ -0,0 +1,137 @@ +## Classes + +
+
JwtBearerClientAuthenticationAbstractClientAuthentication
+

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:

+ +

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.

+
+
JwtBearerClientAuthentication
+
+
+ +## Constants + +
+
CLIENT_ASSERTION_TYPE
+

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 + +
+
JwtBearerGrantTypeAbstractGrantType
+

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:

+ +
+
+ +## Constants + +
+
GRANT_TYPE
+

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} 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`). + */ + async getJWTBearerIssuer(issuer) { + throw new ServerError('getJWTBearerIssuer not implemented'); + } + + /** + * 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`) + * + * @async + * @param 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. + * @return {Promise} Resolves to the authorized user, or a falsy value to deny the + * grant (`invalid_grant`). Single-use (`jti`) replay protection can be enforced here. + */ + async getJWTBearerUser(params) { + throw new ServerError('getJWTBearerUser not implemented'); + } } module.exports = Model; diff --git a/lib/utils/jws-util.js b/lib/utils/jws-util.js new file mode 100644 index 00000000..e293176d --- /dev/null +++ b/lib/utils/jws-util.js @@ -0,0 +1,132 @@ +'use strict'; + +/* + * Module dependencies. + */ + +const { createRemoteJWKSet } = require('jose'); +const { createHash } = require('crypto'); + +/** + * @module jws-util + * @description + * 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. + */ + +/* + * Accepted JWS algorithms per key type. Pinning the algorithm *family* to the + * key type prevents algorithm-confusion: an asymmetric ("private key") client + * must use an asymmetric algorithm, and an attacker cannot submit an `HS256` + * token to be verified with an RSA public key as the HMAC secret. + */ +const HMAC_ALGS = ['HS256', 'HS384', 'HS512']; +const ASYMMETRIC_ALGS = ['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512', 'EdDSA']; + +/* + * jose error codes that represent an *invalid assertion* (the caller's fault → + * `invalid_client` / `invalid_grant`) as opposed to an operational fault such + * as a JWKS fetch failure (the server's fault → `server_error`). Anything not + * listed here is treated as a server error rather than mis-reported as a bad + * credential, and its (possibly topology-revealing) message is not surfaced. + */ +const JOSE_VALIDATION_CODES = new Set([ + 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED', + 'ERR_JWS_INVALID', + 'ERR_JWT_INVALID', + 'ERR_JWT_EXPIRED', + 'ERR_JWT_CLAIM_VALIDATION_FAILED', + 'ERR_JOSE_ALG_NOT_ALLOWED', + 'ERR_JOSE_NOT_SUPPORTED', + 'ERR_JWKS_NO_MATCHING_KEY', + 'ERR_JWKS_MULTIPLE_MATCHING_KEYS', +]); + +/* + * Module-level cache of remote JWK sets, keyed by URI, so jose's built-in + * caching, cooldown and rate-limiting apply across requests. This must live at + * module scope: per-request consumers (e.g. grant types, which are + * instantiated per request) would otherwise build a fresh set every time and + * defeat those protections, amplifying load against the JWKS endpoint. + */ +const remoteJwksCache = new Map(); + +/** + * Whether a JWS algorithm is an HMAC (`HS*`) algorithm. + * @param {string} alg + * @return {boolean} + */ +function isHmac(alg) { + return typeof alg === 'string' && alg.startsWith('HS'); +} + +/** + * The accepted algorithm family for a JWS header (HMAC vs. asymmetric). + * @param {object} header the decoded JWS protected header + * @return {string[]} + */ +function algorithmsForHeader(header) { + return isHmac(header.alg) ? HMAC_ALGS : ASYMMETRIC_ALGS; +} + +/** + * Whether a thrown error is a jose assertion-validation error (vs. operational). + * @param {Error} e + * @return {boolean} + */ +function isValidationError(e) { + return !!e && JOSE_VALIDATION_CODES.has(e.code); +} + +/** + * 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. + * + * @param {string} assertion the compact JWT (verified before this is called) + * @param {string} [jti] the assertion's `jti` claim, if present + * @return {string} + */ +function replayId(assertion, jti) { + if (jti) { + return jti; + } + + const signingInput = assertion.slice(0, assertion.lastIndexOf('.')); + + return createHash('sha256').update(signingInput).digest('base64url'); +} + +/** + * Resolve a (cached) remote JWK Set for the given URI. + * @param {string} uri + * @return {function} a jose key-resolution function + */ +function getRemoteJwks(uri) { + let jwks = remoteJwksCache.get(uri); + + if (!jwks) { + jwks = createRemoteJWKSet(new URL(uri)); + remoteJwksCache.set(uri, jwks); + } + + return jwks; +} + +module.exports = { + HMAC_ALGS, + ASYMMETRIC_ALGS, + JOSE_VALIDATION_CODES, + isHmac, + algorithmsForHeader, + isValidationError, + replayId, + getRemoteJwks, +}; diff --git a/package-lock.json b/package-lock.json index 993dbc89..21c7b6b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@node-oauth/formats": "1.0.0", "basic-auth": "2.0.1", + "jose": "6.2.3", "type-is": "2.1.0" }, "devDependencies": { @@ -23,7 +24,7 @@ "vitepress": "^2.0.0-alpha.17" }, "engines": { - "node": ">=16.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@babel/code-frame": { @@ -3503,6 +3504,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index a08ed1f6..84a773e7 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dependencies": { "@node-oauth/formats": "1.0.0", "basic-auth": "2.0.1", + "jose": "6.2.3", "type-is": "2.1.0" }, "devDependencies": { @@ -41,7 +42,7 @@ }, "license": "MIT", "engines": { - "node": ">=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "scripts": { "pretest": "npx @biomejs/biome check .", diff --git a/test/integration/client-authentication/jwt-bearer-client-authentication_test.js b/test/integration/client-authentication/jwt-bearer-client-authentication_test.js new file mode 100644 index 00000000..4c63cf18 --- /dev/null +++ b/test/integration/client-authentication/jwt-bearer-client-authentication_test.js @@ -0,0 +1,378 @@ +'use strict'; + +/** + * JWT client authentication (RFC 7521 §4.2 / RFC 7523 §2.2 / §3), covering + * both `private_key_jwt` (asymmetric) and `client_secret_jwt` (HMAC), driven + * end-to-end through `OAuth2Server#token`. + */ + +const OAuth2Server = require('../../..'); +const Response = require('../../../lib/response'); +const JwtBearerClientAuthentication = require('../../../lib/client-authentication/jwt-bearer-client-authentication'); +const createRequest = require('../../helpers/request'); +const { SignJWT, generateKeyPair, exportJWK } = require('jose'); + +require('chai').should(); + +const CLIENT_ASSERTION_TYPE = JwtBearerClientAuthentication.CLIENT_ASSERTION_TYPE; +const TOKEN_URL = 'https://as.example.com/oauth/token'; + +describe('JWT client authentication integration', function () { + const rsaClient = { id: 'rsa-client', grants: ['client_credentials'] }; + const hmacClient = { id: 'hmac-client', grants: ['client_credentials'], secret: 'super-secret-shared-value' }; + const pinnedClient = { + id: 'pinned-client', + grants: ['client_credentials'], + secret: 'pinned-hmac-secret', + tokenEndpointAuthMethod: 'private_key_jwt', + }; + const keylessClient = { id: 'keyless-client', grants: ['client_credentials'] }; + const user = { id: 'service-user' }; + + let rsaPrivateKey; + let server; + + before(async function () { + const { publicKey, privateKey } = await generateKeyPair('RS256'); + rsaPrivateKey = privateKey; + + const jwk = await exportJWK(publicKey); + rsaClient.jwks = { keys: [{ ...jwk, kid: 'k1', alg: 'RS256', use: 'sig' }] }; + pinnedClient.jwks = rsaClient.jwks; + + const model = { + getClient: async (id) => { + if (id === rsaClient.id) return rsaClient; + if (id === hmacClient.id) return hmacClient; + if (id === pinnedClient.id) return pinnedClient; + if (id === keylessClient.id) return keylessClient; + return undefined; + }, + getUserFromClient: async () => user, + saveToken: async (token, client, tokenUser) => ({ ...token, client, user: tokenUser }), + validateScope: async (u, c, scope) => scope, + }; + + server = new OAuth2Server({ + model, + extendedClientAuthentication: { + jwt_bearer: new JwtBearerClientAuthentication({ audience: TOKEN_URL }), + }, + }); + }); + + function tokenRequest(clientAssertion, extra) { + return createRequest({ + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: Object.assign( + { + grant_type: 'client_credentials', + scope: 'read', + client_assertion_type: CLIENT_ASSERTION_TYPE, + client_assertion: clientAssertion, + }, + extra, + ), + }); + } + + function assertionFor(clientId) { + return new SignJWT({}) + .setIssuer(clientId) + .setSubject(clientId) + .setAudience(TOKEN_URL) + .setIssuedAt() + .setExpirationTime('2m') + .setJti('jti-' + clientId + '-' + process.hrtime.bigint().toString()); + } + + it('authenticates a client using private_key_jwt (RS256)', async function () { + const assertion = await assertionFor(rsaClient.id) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .sign(rsaPrivateKey); + + const token = await server.token(tokenRequest(assertion), new Response({})); + + token.accessToken.should.be.a('string'); + token.client.id.should.equal(rsaClient.id); + }); + + it('authenticates a client using client_secret_jwt (HS256)', async function () { + const key = new TextEncoder().encode(hmacClient.secret); + const assertion = await assertionFor(hmacClient.id).setProtectedHeader({ alg: 'HS256' }).sign(key); + + const token = await server.token(tokenRequest(assertion), new Response({})); + + token.client.id.should.equal(hmacClient.id); + }); + + it('rejects an assertion with the wrong audience', async function () { + const assertion = await new SignJWT({}) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .setIssuer(rsaClient.id) + .setSubject(rsaClient.id) + .setAudience('https://attacker.example.com') + .setIssuedAt() + .setExpirationTime('2m') + .sign(rsaPrivateKey); + + await server + .token(tokenRequest(assertion), new Response({})) + .then(() => { + throw new Error('should not authenticate'); + }) + .catch((e) => e.name.should.equal('invalid_client')); + }); + + it('rejects an expired assertion', async function () { + const now = Math.floor(Date.now() / 1000); + const assertion = await new SignJWT({}) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .setIssuer(rsaClient.id) + .setSubject(rsaClient.id) + .setAudience(TOKEN_URL) + .setIssuedAt(now - 600) + .setExpirationTime(now - 300) + .sign(rsaPrivateKey); + + await server + .token(tokenRequest(assertion), new Response({})) + .then(() => { + throw new Error('should not authenticate'); + }) + .catch((e) => e.name.should.equal('invalid_client')); + }); + + it('rejects an assertion signed with the wrong key', async function () { + const { privateKey: otherKey } = await generateKeyPair('RS256'); + const assertion = await assertionFor(rsaClient.id).setProtectedHeader({ alg: 'RS256', kid: 'k1' }).sign(otherKey); + + await server + .token(tokenRequest(assertion), new Response({})) + .then(() => { + throw new Error('should not authenticate'); + }) + .catch((e) => e.name.should.equal('invalid_client')); + }); + + it('rejects a request presenting more than one client authentication mechanism', async function () { + const assertion = await assertionFor(rsaClient.id) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .sign(rsaPrivateKey); + + const request = tokenRequest(assertion, { client_id: rsaClient.id, client_secret: 'whatever' }); + + await server + .token(request, new Response({})) + .then(() => { + throw new Error('should not authenticate'); + }) + .catch((e) => e.name.should.equal('invalid_request')); + }); + + it('accepts a client pinned to private_key_jwt signing with its key', async function () { + const assertion = await assertionFor(pinnedClient.id) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .sign(rsaPrivateKey); + + const token = await server.token(tokenRequest(assertion), new Response({})); + + token.client.id.should.equal(pinnedClient.id); + }); + + it('rejects a client pinned to private_key_jwt that downgrades to client_secret_jwt', async function () { + // A validly-signed HMAC assertion (client_secret_jwt) — rejected purely + // because the client registered private_key_jwt. + const key = new TextEncoder().encode(pinnedClient.secret); + const assertion = await assertionFor(pinnedClient.id).setProtectedHeader({ alg: 'HS256' }).sign(key); + + await server + .token(tokenRequest(assertion), new Response({})) + .then(() => { + throw new Error('should not authenticate'); + }) + .catch((e) => e.name.should.equal('invalid_client')); + }); + + describe('assertion error cases', function () { + it('rejects a malformed client_assertion', async function () { + await server + .token(tokenRequest('not-a-jwt'), new Response({})) + .then(() => { + throw new Error('should not authenticate'); + }) + .catch((e) => e.name.should.equal('invalid_client')); + }); + + it('rejects an assertion missing the `sub` claim', async function () { + const assertion = await new SignJWT({}) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .setIssuer(rsaClient.id) + .setAudience(TOKEN_URL) + .setIssuedAt() + .setExpirationTime('2m') + .sign(rsaPrivateKey); + + await server + .token(tokenRequest(assertion), new Response({})) + .then(() => { + throw new Error('should not authenticate'); + }) + .catch((e) => e.name.should.equal('invalid_client')); + }); + + it('rejects when body `client_id` does not match the assertion subject', async function () { + const assertion = await assertionFor(rsaClient.id) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .sign(rsaPrivateKey); + + await server + .token(tokenRequest(assertion, { client_id: 'someone-else' }), new Response({})) + .then(() => { + throw new Error('should not authenticate'); + }) + .catch((e) => e.name.should.equal('invalid_client')); + }); + + it('rejects a private_key_jwt assertion when the client has no registered keys', async function () { + const assertion = await assertionFor(keylessClient.id) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .sign(rsaPrivateKey); + + await server + .token(tokenRequest(assertion), new Response({})) + .then(() => { + throw new Error('should not authenticate'); + }) + .catch((e) => e.name.should.equal('invalid_client')); + }); + + it('rejects a client_secret_jwt assertion when the client has no secret', async function () { + const assertion = await assertionFor(keylessClient.id) + .setProtectedHeader({ alg: 'HS256' }) + .sign(new TextEncoder().encode('whatever')); + + await server + .token(tokenRequest(assertion), new Response({})) + .then(() => { + throw new Error('should not authenticate'); + }) + .catch((e) => e.name.should.equal('invalid_client')); + }); + }); + + describe('operational failures map to server_error', function () { + it('returns server_error when key resolution fails operationally', async function () { + const opServer = new OAuth2Server({ + model: { + getClient: async () => rsaClient, + getUserFromClient: async () => user, + saveToken: async (token, c, u) => ({ ...token, client: c, user: u }), + validateScope: async (u, c, scope) => scope, + }, + extendedClientAuthentication: { + jwt_bearer: new JwtBearerClientAuthentication({ + audience: TOKEN_URL, + getKey: () => () => { + throw new Error('jwks endpoint unreachable'); + }, + }), + }, + }); + + const assertion = await assertionFor(rsaClient.id) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .sign(rsaPrivateKey); + + await opServer + .token(tokenRequest(assertion), new Response({})) + .then(() => { + throw new Error('should not authenticate'); + }) + .catch((e) => e.name.should.equal('server_error')); + }); + }); + + describe('replay protection (jti)', function () { + function jtiServer(jtiHooks) { + return new OAuth2Server({ + model: Object.assign( + { + getClient: async () => rsaClient, + getUserFromClient: async () => user, + saveToken: async (token, c, u) => ({ ...token, client: c, user: u }), + validateScope: async (u, c, scope) => scope, + }, + jtiHooks, + ), + extendedClientAuthentication: { + jwt_bearer: new JwtBearerClientAuthentication({ audience: TOKEN_URL }), + }, + }); + } + + it('accepts an assertion once and rejects its replay', async function () { + const used = new Set(); + const replayServer = jtiServer({ + isClientAssertionJtiUsed: async (jti) => used.has(jti), + saveClientAssertionJti: async (jti) => { + used.add(jti); + }, + }); + const assertion = await assertionFor(rsaClient.id) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .sign(rsaPrivateKey); + + await replayServer.token(tokenRequest(assertion), new Response({})); + + await replayServer + .token(tokenRequest(assertion), new Response({})) + .then(() => { + throw new Error('replay should be rejected'); + }) + .catch((e) => e.name.should.equal('invalid_client')); + }); + + it('protects assertions without a `jti` via a signing-input fingerprint', async function () { + const used = new Set(); + const replayServer = jtiServer({ + isClientAssertionJtiUsed: async (id) => used.has(id), + saveClientAssertionJti: async (id) => { + used.add(id); + }, + }); + const assertion = await new SignJWT({}) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .setIssuer(rsaClient.id) + .setSubject(rsaClient.id) + .setAudience(TOKEN_URL) + .setIssuedAt() + .setExpirationTime('2m') + .sign(rsaPrivateKey); // deliberately no jti + + await replayServer.token(tokenRequest(assertion), new Response({})); + + await replayServer + .token(tokenRequest(assertion), new Response({})) + .then(() => { + throw new Error('replay should be rejected'); + }) + .catch((e) => e.name.should.equal('invalid_client')); + }); + + it('returns server_error when only one jti hook is implemented', async function () { + const replayServer = jtiServer({ isClientAssertionJtiUsed: async () => false }); + const assertion = await assertionFor(rsaClient.id) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .sign(rsaPrivateKey); + + await replayServer + .token(tokenRequest(assertion), new Response({})) + .then(() => { + throw new Error('should not authenticate'); + }) + .catch((e) => e.name.should.equal('server_error')); + }); + }); +}); diff --git a/test/integration/grant-types/jwt-bearer-grant-type_test.js b/test/integration/grant-types/jwt-bearer-grant-type_test.js new file mode 100644 index 00000000..8921a3fd --- /dev/null +++ b/test/integration/grant-types/jwt-bearer-grant-type_test.js @@ -0,0 +1,424 @@ +'use strict'; + +/** + * JWT bearer authorization grant (RFC 7521 §4.1 / RFC 7523 §2.1/§3), driven + * end-to-end through `OAuth2Server#token`. + */ + +const OAuth2Server = require('../../..'); +const Response = require('../../../lib/response'); +const JwtBearerGrantType = require('../../../lib/grant-types/jwt-bearer-grant-type'); +const createRequest = require('../../helpers/request'); +const { SignJWT, generateKeyPair, exportJWK } = require('jose'); + +require('chai').should(); + +const GRANT_TYPE = JwtBearerGrantType.GRANT_TYPE; +const TOKEN_URL = 'https://as.example.com/oauth/token'; +const ISSUER = 'https://idp.example.com'; + +describe('JWT bearer authorization grant integration', function () { + const client = { id: 'client-1', grants: [GRANT_TYPE] }; + const clientNoGrant = { id: 'client-no-grant', grants: ['password'] }; + const user = { id: 'user-42' }; + const HMAC_ISSUER = 'https://hmac-idp.example.com'; + const HMAC_SECRET = 'issuer-shared-secret-value'; + + let issuerPrivateKey; + let jwks; + let server; + + before(async function () { + const { publicKey, privateKey } = await generateKeyPair('RS256'); + issuerPrivateKey = privateKey; + const jwk = await exportJWK(publicKey); + jwks = { keys: [{ ...jwk, kid: 'k1', alg: 'RS256', use: 'sig' }] }; + + const model = { + getClient: async (id) => { + if (id === client.id) return client; + if (id === clientNoGrant.id) return clientNoGrant; + return undefined; + }, + getJWTBearerIssuer: async (issuer) => { + if (issuer === ISSUER) return { jwks, audience: TOKEN_URL }; + if (issuer === HMAC_ISSUER) return { secret: HMAC_SECRET, audience: TOKEN_URL }; + if (issuer === 'https://no-audience.example.com') return { jwks }; + if (issuer === 'https://empty-audience.example.com') return { jwks, audience: [] }; + if (issuer === 'https://no-keys.example.com') return { audience: TOKEN_URL }; + return undefined; + }, + getJWTBearerUser: async ({ subject }) => (subject === user.id ? user : undefined), + saveToken: async (token, tokenClient, tokenUser) => ({ ...token, client: tokenClient, user: tokenUser }), + validateScope: async (u, c, scope) => scope, + }; + + server = new OAuth2Server({ + model, + extendedGrantTypes: { [GRANT_TYPE]: JwtBearerGrantType }, + requireClientAuthentication: { [GRANT_TYPE]: false }, + }); + }); + + function tokenRequest(assertion, extra) { + return createRequest({ + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: Object.assign( + { + grant_type: GRANT_TYPE, + client_id: client.id, + scope: 'read', + assertion, + }, + extra, + ), + }); + } + + function assertion(overrides = {}) { + return new SignJWT({}) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .setIssuer(overrides.iss || ISSUER) + .setSubject(overrides.sub || user.id) + .setAudience(overrides.aud || TOKEN_URL) + .setIssuedAt() + .setExpirationTime(overrides.exp || '2m'); + } + + it('issues an access token (and no refresh token) for a valid assertion', async function () { + const jwt = await assertion().sign(issuerPrivateKey); + + const token = await server.token(tokenRequest(jwt), new Response({})); + + token.accessToken.should.be.a('string'); + token.user.id.should.equal(user.id); + token.client.id.should.equal(client.id); + (token.scope || []).should.eql(['read']); + (token.refreshToken === undefined).should.equal(true); + }); + + it('rejects an assertion from an untrusted issuer', async function () { + const jwt = await assertion({ iss: 'https://evil.example.com' }).sign(issuerPrivateKey); + + await server + .token(tokenRequest(jwt), new Response({})) + .then(() => { + throw new Error('should not issue a token'); + }) + .catch((e) => e.name.should.equal('invalid_grant')); + }); + + it('rejects an assertion with the wrong audience', async function () { + const jwt = await assertion({ aud: 'https://attacker.example.com' }).sign(issuerPrivateKey); + + await server + .token(tokenRequest(jwt), new Response({})) + .then(() => { + throw new Error('should not issue a token'); + }) + .catch((e) => e.name.should.equal('invalid_grant')); + }); + + it('rejects an expired assertion', async function () { + const now = Math.floor(Date.now() / 1000); + const jwt = await assertion({ exp: now - 300 }).sign(issuerPrivateKey); + + await server + .token(tokenRequest(jwt), new Response({})) + .then(() => { + throw new Error('should not issue a token'); + }) + .catch((e) => e.name.should.equal('invalid_grant')); + }); + + it('rejects an assertion signed with the wrong key', async function () { + const { privateKey: otherKey } = await generateKeyPair('RS256'); + const jwt = await assertion().sign(otherKey); + + await server + .token(tokenRequest(jwt), new Response({})) + .then(() => { + throw new Error('should not issue a token'); + }) + .catch((e) => e.name.should.equal('invalid_grant')); + }); + + it('rejects an unauthorized subject', async function () { + const jwt = await assertion({ sub: 'unknown-user' }).sign(issuerPrivateKey); + + await server + .token(tokenRequest(jwt), new Response({})) + .then(() => { + throw new Error('should not issue a token'); + }) + .catch((e) => e.name.should.equal('invalid_grant')); + }); + + it('rejects a request without an `assertion`', async function () { + await server + .token(tokenRequest(undefined), new Response({})) + .then(() => { + throw new Error('should not issue a token'); + }) + .catch((e) => e.name.should.equal('invalid_request')); + }); + + it('issues a token for a client_secret_jwt (HMAC) issuer', async function () { + const jwt = await new SignJWT({}) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuer(HMAC_ISSUER) + .setSubject(user.id) + .setAudience(TOKEN_URL) + .setIssuedAt() + .setExpirationTime('2m') + .sign(new TextEncoder().encode(HMAC_SECRET)); + + const token = await server.token(tokenRequest(jwt), new Response({})); + + token.accessToken.should.be.a('string'); + token.user.id.should.equal(user.id); + }); + + it('returns server_error when the issuer has no configured audience', async function () { + const jwt = await new SignJWT({}) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .setIssuer('https://no-audience.example.com') + .setSubject(user.id) + .setAudience(TOKEN_URL) + .setIssuedAt() + .setExpirationTime('2m') + .sign(issuerPrivateKey); + + await server + .token(tokenRequest(jwt), new Response({})) + .then(() => { + throw new Error('should not issue a token'); + }) + .catch((e) => e.name.should.equal('server_error')); + }); + + it('returns server_error when the issuer audience is an empty array', async function () { + const jwt = await new SignJWT({}) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .setIssuer('https://empty-audience.example.com') + .setSubject(user.id) + .setAudience(TOKEN_URL) + .setIssuedAt() + .setExpirationTime('2m') + .sign(issuerPrivateKey); + + await server + .token(tokenRequest(jwt), new Response({})) + .then(() => { + throw new Error('should not issue a token'); + }) + .catch((e) => e.name.should.equal('server_error')); + }); + + it('returns server_error when the issuer has no keys', async function () { + const jwt = await new SignJWT({}) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .setIssuer('https://no-keys.example.com') + .setSubject(user.id) + .setAudience(TOKEN_URL) + .setIssuedAt() + .setExpirationTime('2m') + .sign(issuerPrivateKey); + + await server + .token(tokenRequest(jwt), new Response({})) + .then(() => { + throw new Error('should not issue a token'); + }) + .catch((e) => e.name.should.equal('server_error')); + }); + + it('rejects a request without a client_id', async function () { + const jwt = await assertion().sign(issuerPrivateKey); + const request = createRequest({ + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: { grant_type: GRANT_TYPE, scope: 'read', assertion: jwt }, + }); + + await server + .token(request, new Response({})) + .then(() => { + throw new Error('should not issue a token'); + }) + .catch((e) => e.name.should.equal('invalid_client')); + }); + + it('rejects a client not authorized for the grant', async function () { + const jwt = await assertion().sign(issuerPrivateKey); + + await server + .token(tokenRequest(jwt, { client_id: clientNoGrant.id }), new Response({})) + .then(() => { + throw new Error('should not issue a token'); + }) + .catch((e) => e.name.should.equal('unauthorized_client')); + }); + + it('rejects a malformed assertion', async function () { + await server + .token(tokenRequest('not-a-jwt'), new Response({})) + .then(() => { + throw new Error('should not issue a token'); + }) + .catch((e) => e.name.should.equal('invalid_grant')); + }); + + it('rejects an assertion missing the `iss` claim', async function () { + const jwt = await new SignJWT({}) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .setSubject(user.id) + .setAudience(TOKEN_URL) + .setIssuedAt() + .setExpirationTime('2m') + .sign(issuerPrivateKey); + + await server + .token(tokenRequest(jwt), new Response({})) + .then(() => { + throw new Error('should not issue a token'); + }) + .catch((e) => e.name.should.equal('invalid_grant')); + }); + + it('lets the model enforce replay via assertionId for assertions without a jti', async function () { + const used = new Set(); + const replayServer = new OAuth2Server({ + model: { + getClient: async () => client, + getJWTBearerIssuer: async () => ({ jwks, audience: TOKEN_URL }), + getJWTBearerUser: async ({ subject, assertionId }) => { + if (used.has(assertionId)) return false; + used.add(assertionId); + return subject === user.id ? user : undefined; + }, + saveToken: async (token, c, u) => ({ ...token, client: c, user: u }), + validateScope: async (u, c, scope) => scope, + }, + extendedGrantTypes: { [GRANT_TYPE]: JwtBearerGrantType }, + requireClientAuthentication: { [GRANT_TYPE]: false }, + }); + + const jwt = await assertion().sign(issuerPrivateKey); // no jti + + await replayServer.token(tokenRequest(jwt), new Response({})); + + await replayServer + .token(tokenRequest(jwt), new Response({})) + .then(() => { + throw new Error('replay should be rejected'); + }) + .catch((e) => e.name.should.equal('invalid_grant')); + }); + + describe('getKey()', function () { + const grant = new JwtBearerGrantType({ + accessTokenLifetime: 120, + model: { getJWTBearerIssuer() {}, getJWTBearerUser() {}, saveToken() {} }, + }); + + it('derives an HMAC key from the issuer secret', async function () { + (await grant.getKey({ secret: 'shhh' }, { alg: 'HS256' })).should.be.an.instanceOf(Uint8Array); + }); + + it('returns a lazy remote key resolver for a jwksUri issuer', async function () { + (await grant.getKey({ jwksUri: 'https://issuer.example.com/jwks.json' }, { alg: 'RS256' })).should.be.a( + 'function', + ); + }); + + it('throws server_error when the issuer has no key material (HMAC alg)', async function () { + await grant + .getKey({}, { alg: 'HS256' }) + .then(() => { + throw new Error('should throw'); + }) + .catch((e) => e.name.should.equal('server_error')); + }); + + it('throws server_error when the issuer has no key material (asymmetric alg)', async function () { + await grant + .getKey({}, { alg: 'RS256' }) + .then(() => { + throw new Error('should throw'); + }) + .catch((e) => e.name.should.equal('server_error')); + }); + + it('rejects (invalid_grant) an HS256 assertion when the issuer is asymmetric-only', async function () { + await grant + .getKey({ jwks: { keys: [] } }, { alg: 'HS256' }) + .then(() => { + throw new Error('should throw'); + }) + .catch((e) => e.name.should.equal('invalid_grant')); + }); + + it('rejects (invalid_grant) an RS256 assertion when the issuer is HMAC-only', async function () { + await grant + .getKey({ secret: 'shhh' }, { alg: 'RS256' }) + .then(() => { + throw new Error('should throw'); + }) + .catch((e) => e.name.should.equal('invalid_grant')); + }); + }); + + describe('constructor and argument guards', function () { + const okModel = { getJWTBearerIssuer() {}, getJWTBearerUser() {}, saveToken() {} }; + + it('throws without a model', function () { + (() => new JwtBearerGrantType({ accessTokenLifetime: 120 })).should.throw(/model/); + }); + + it('throws when the model lacks getJWTBearerIssuer', function () { + (() => + new JwtBearerGrantType({ + accessTokenLifetime: 120, + model: { getJWTBearerUser() {}, saveToken() {} }, + })).should.throw(/getJWTBearerIssuer/); + }); + + it('throws when the model lacks getJWTBearerUser', function () { + (() => + new JwtBearerGrantType({ + accessTokenLifetime: 120, + model: { getJWTBearerIssuer() {}, saveToken() {} }, + })).should.throw(/getJWTBearerUser/); + }); + + it('throws when the model lacks saveToken', function () { + (() => + new JwtBearerGrantType({ + accessTokenLifetime: 120, + model: { getJWTBearerIssuer() {}, getJWTBearerUser() {} }, + })).should.throw(/saveToken/); + }); + + it('rejects handle() without a request', async function () { + const grant = new JwtBearerGrantType({ accessTokenLifetime: 120, model: okModel }); + await grant + .handle(undefined, client) + .then(() => { + throw new Error('should reject'); + }) + .catch((e) => e.message.should.match(/request/)); + }); + + it('rejects handle() without a client', async function () { + const grant = new JwtBearerGrantType({ accessTokenLifetime: 120, model: okModel }); + await grant + .handle({ body: {} }, undefined) + .then(() => { + throw new Error('should reject'); + }) + .catch((e) => e.message.should.match(/client/)); + }); + }); +}); diff --git a/test/integration/handlers/token-handler_test.js b/test/integration/handlers/token-handler_test.js index 010a9292..ab4f6cad 100644 --- a/test/integration/handlers/token-handler_test.js +++ b/test/integration/handlers/token-handler_test.js @@ -881,134 +881,6 @@ describe('TokenHandler integration', function () { }); }); - describe('getClientCredentials()', function () { - it('should throw an error if `client_id` is missing', async function () { - const model = Model.from({ - getClient: function () {}, - saveToken: function () {}, - }); - const handler = new TokenHandler({ - accessTokenLifetime: 120, - model: model, - refreshTokenLifetime: 120, - }); - const request = new Request({ - body: { client_secret: 'foo' }, - headers: {}, - method: {}, - query: {}, - }); - - try { - await handler.getClientCredentials(request); - - should.fail(); - } catch (e) { - e.should.be.an.instanceOf(InvalidClientError); - e.message.should.equal('Invalid client: cannot retrieve client credentials'); - } - }); - - it('should throw an error if `client_secret` is missing', async function () { - const model = Model.from({ - getClient: function () {}, - saveToken: function () {}, - }); - const handler = new TokenHandler({ - accessTokenLifetime: 120, - model: model, - refreshTokenLifetime: 120, - }); - const request = new Request({ - body: { client_id: 'foo' }, - headers: {}, - method: {}, - query: {}, - }); - - try { - await handler.getClientCredentials(request); - - should.fail(); - } catch (e) { - e.should.be.an.instanceOf(InvalidClientError); - e.message.should.equal('Invalid client: cannot retrieve client credentials'); - } - }); - - describe('with `client_id` and grant type is `password` and `requireClientAuthentication` is false', function () { - it('should return a client', function () { - const model = Model.from({ - getClient: function () {}, - saveToken: function () {}, - }); - const handler = new TokenHandler({ - accessTokenLifetime: 120, - model: model, - refreshTokenLifetime: 120, - requireClientAuthentication: { password: false }, - }); - const request = new Request({ - body: { client_id: 'foo', grant_type: 'password' }, - headers: {}, - method: {}, - query: {}, - }); - const credentials = handler.getClientCredentials(request); - - credentials.should.eql({ clientId: 'foo' }); - }); - }); - - describe('with `client_id` and `client_secret` in the request header as basic auth', function () { - it('should return a client', function () { - const model = Model.from({ - getClient: function () {}, - saveToken: function () {}, - }); - const handler = new TokenHandler({ - accessTokenLifetime: 120, - model: model, - refreshTokenLifetime: 120, - }); - const request = new Request({ - body: {}, - headers: { - authorization: util.format('Basic %s', Buffer.from('foo:bar').toString('base64')), - }, - method: {}, - query: {}, - }); - const credentials = handler.getClientCredentials(request); - - credentials.should.eql({ clientId: 'foo', clientSecret: 'bar' }); - }); - }); - - describe('with `client_id` and `client_secret` in the request body', function () { - it('should return a client', function () { - const model = Model.from({ - getClient: function () {}, - saveToken: function () {}, - }); - const handler = new TokenHandler({ - accessTokenLifetime: 120, - model: model, - refreshTokenLifetime: 120, - }); - const request = new Request({ - body: { client_id: 'foo', client_secret: 'bar' }, - headers: {}, - method: {}, - query: {}, - }); - const credentials = handler.getClientCredentials(request); - - credentials.should.eql({ clientId: 'foo', clientSecret: 'bar' }); - }); - }); - }); - describe('handleGrantType()', function () { it('should throw an error if `grant_type` is missing', async function () { const model = Model.from({ diff --git a/test/unit/client-authentication/client-authentication_test.js b/test/unit/client-authentication/client-authentication_test.js new file mode 100644 index 00000000..8debc9af --- /dev/null +++ b/test/unit/client-authentication/client-authentication_test.js @@ -0,0 +1,282 @@ +'use strict'; + +/** + * Unit tests for the pluggable client-authentication layer: the built-in + * adapters and the orchestrator's method selection. These replace the former + * `TokenHandler#getClientCredentials` unit coverage and lock in the + * selection/behaviour deltas introduced by the refactor. + */ + +const { + AbstractClientAuthentication, + authenticateClient, + defaultClientAuthenticationMethods, + ClientSecretBasic, + ClientSecretPost, + None, + JwtBearerClientAuthentication, +} = require('../../../lib/client-authentication'); +const Response = require('../../../lib/response'); +const createRequest = require('../../helpers/request'); + +require('chai').should(); + +function basicHeader(id, secret) { + return 'Basic ' + Buffer.from(id + ':' + secret).toString('base64'); +} + +function clientWith(grants) { + return { id: 'a', grants: grants || [] }; +} + +function authenticate(request, { model, methods, clientAuthenticationRequired = true, isPKCE = false } = {}) { + return authenticateClient(request, new Response({}), { + model, + methods: methods || defaultClientAuthenticationMethods(), + clientAuthenticationRequired, + isPKCE, + }); +} + +describe('Client authentication', function () { + describe('built-in adapters', function () { + it('client_secret_basic matches a Basic header and is credentialed', function () { + const adapter = new ClientSecretBasic(); + adapter.requiresCredentials.should.equal(true); + adapter.presentedMethod(createRequest({})).should.equal('client_secret_basic'); + adapter.matches(createRequest({ headers: { authorization: basicHeader('a', 'b') } })).should.equal(true); + adapter.matches(createRequest({ headers: {} })).should.equal(false); + }); + + it('client_secret_post matches client_id + client_secret in the body', function () { + const adapter = new ClientSecretPost(); + adapter.matches(createRequest({ body: { client_id: 'a', client_secret: 'b' } })).should.equal(true); + adapter.matches(createRequest({ body: { client_id: 'a' } })).should.equal(false); + }); + + it('none is a public (non-credentialed) positive predicate on client_id', function () { + const adapter = new None(); + adapter.requiresCredentials.should.equal(false); + adapter.matches(createRequest({ body: { client_id: 'a' } })).should.equal(true); + adapter.matches(createRequest({ body: {} })).should.equal(false); + }); + }); + + describe('selection', function () { + it('authenticates via Basic auth', async function () { + const client = clientWith(); + const model = { getClient: (id, secret) => (id === 'a' && secret === 'b' ? client : undefined) }; + const request = createRequest({ headers: { authorization: basicHeader('a', 'b') } }); + + (await authenticate(request, { model })).should.equal(client); + }); + + it('authenticates via request-body credentials', async function () { + const client = clientWith(); + const model = { getClient: (id, secret) => (id === 'a' && secret === 'b' ? client : undefined) }; + const request = createRequest({ body: { client_id: 'a', client_secret: 'b' } }); + + (await authenticate(request, { model })).should.equal(client); + }); + + it('authenticates a public client for a PKCE request (no secret)', async function () { + const client = clientWith(); + const model = { getClient: (id) => (id === 'a' ? client : undefined) }; + const request = createRequest({ body: { client_id: 'a', grant_type: 'authorization_code', code_verifier: 'x' } }); + + (await authenticate(request, { model, isPKCE: true })).should.equal(client); + }); + + it('authenticates a public client when client authentication is not required', async function () { + const client = clientWith(); + const model = { getClient: (id) => (id === 'a' ? client : undefined) }; + const request = createRequest({ body: { client_id: 'a', grant_type: 'password' } }); + + (await authenticate(request, { model, clientAuthenticationRequired: false })).should.equal(client); + }); + + it('rejects more than one credentialed mechanism (Basic + body secret)', async function () { + const model = { getClient: () => clientWith() }; + const request = createRequest({ + headers: { authorization: basicHeader('a', 'b') }, + body: { client_id: 'a', client_secret: 'b' }, + }); + + await authenticate(request, { model }) + .then(() => { + throw new Error('should not authenticate'); + }) + .catch((e) => e.name.should.equal('invalid_request')); + }); + + it('throws invalid_client when no credentials can be retrieved', async function () { + const model = { getClient: () => clientWith() }; + const request = createRequest({ body: { grant_type: 'password' } }); + + await authenticate(request, { model }) + .then(() => { + throw new Error('should not authenticate'); + }) + .catch((e) => e.name.should.equal('invalid_client')); + }); + + it('sets WWW-Authenticate and a 401 when a Basic credential is invalid', async function () { + const model = { getClient: () => undefined }; + const request = createRequest({ headers: { authorization: basicHeader('a', 'bad') } }); + const response = new Response({}); + + await authenticateClient(request, response, { + model, + methods: defaultClientAuthenticationMethods(), + clientAuthenticationRequired: true, + isPKCE: false, + }) + .then(() => { + throw new Error('should not authenticate'); + }) + .catch((e) => { + e.name.should.equal('invalid_client'); + e.code.should.equal(401); + response.get('WWW-Authenticate').should.equal('Basic realm="Service"'); + }); + }); + + it('validates the client `grants` shape', async function () { + const model = { getClient: () => ({ id: 'a' }) }; + const request = createRequest({ body: { client_id: 'a', client_secret: 'b' } }); + + await authenticate(request, { model }) + .then(() => { + throw new Error('should not authenticate'); + }) + .catch((e) => { + e.name.should.equal('server_error'); + e.message.should.equal('Server error: missing client `grants`'); + }); + }); + + it('rejects an authentication method the client has not registered', async function () { + const client = { id: 'a', grants: [], tokenEndpointAuthMethod: 'client_secret_basic' }; + const model = { getClient: () => client }; + const request = createRequest({ body: { client_id: 'a', client_secret: 'b' } }); + + await authenticate(request, { model }) + .then(() => { + throw new Error('should not authenticate'); + }) + .catch((e) => { + e.name.should.equal('invalid_client'); + e.message.should.match(/not a permitted authentication method/); + }); + }); + + it('accepts the authentication method the client registered', async function () { + const client = { id: 'a', grants: [], tokenEndpointAuthMethod: 'client_secret_basic' }; + const model = { getClient: () => client }; + const request = createRequest({ headers: { authorization: basicHeader('a', 'b') } }); + + (await authenticate(request, { model })).should.equal(client); + }); + }); + + describe('JwtBearerClientAuthentication', function () { + it('requires an `audience` to be configured', function () { + (function () { + new JwtBearerClientAuthentication(); + }).should.throw(/audience/); + }); + + it('matches a JWT client assertion and is credentialed', function () { + const adapter = new JwtBearerClientAuthentication({ audience: 'https://as.example.com/token' }); + adapter.requiresCredentials.should.equal(true); + adapter + .matches( + createRequest({ + body: { + client_assertion: 'x.y.z', + client_assertion_type: JwtBearerClientAuthentication.CLIENT_ASSERTION_TYPE, + }, + }), + ) + .should.equal(true); + adapter.matches(createRequest({ body: { client_assertion: 'x.y.z' } })).should.equal(false); + }); + }); + + describe('AbstractClientAuthentication (port)', function () { + it('defaults requiresCredentials to true and throws for unimplemented members', async function () { + const port = new AbstractClientAuthentication(); + + port.requiresCredentials.should.equal(true); + (() => port.matches(createRequest({}))).should.throw(/must implement/); + (() => port.presentedMethod(createRequest({}))).should.throw(/must implement/); + + await port + .authenticate(createRequest({}), {}) + .then(() => { + throw new Error('should reject'); + }) + .catch((e) => e.message.should.match(/must implement/)); + }); + }); + + describe('None adapter resolution', function () { + it('rejects an invalid `client_id` format', async function () { + await new None() + .authenticate(createRequest({ body: { client_id: 'øå€£‰' } }), { + model: { getClient: async () => ({ grants: [] }) }, + }) + .then(() => { + throw new Error('should not resolve'); + }) + .catch((e) => { + e.name.should.equal('invalid_request'); + e.message.should.equal('Invalid parameter: `client_id`'); + }); + }); + + it('rejects when the client cannot be found', async function () { + await new None() + .authenticate(createRequest({ body: { client_id: 'a' } }), { model: { getClient: async () => undefined } }) + .then(() => { + throw new Error('should not resolve'); + }) + .catch((e) => e.name.should.equal('invalid_client')); + }); + }); + + describe('JwtBearerClientAuthentication.defaultGetKey()', function () { + const adapter = new JwtBearerClientAuthentication({ audience: 'https://as.example.com/token' }); + + it('derives an HMAC key from the client secret', async function () { + const key = await adapter.defaultGetKey({ secret: 'shhh' }, { alg: 'HS256' }); + key.should.be.an.instanceOf(Uint8Array); + }); + + it('rejects an HMAC assertion when the client has no usable secret', async function () { + await adapter + .defaultGetKey({ secret: 123 }, { alg: 'HS256' }) + .then(() => { + throw new Error('should throw'); + }) + .catch((e) => e.name.should.equal('invalid_client')); + }); + + it('returns a lazy remote key resolver for a jwksUri client', async function () { + const resolver = await adapter.defaultGetKey( + { jwksUri: 'https://client.example.com/jwks.json' }, + { alg: 'RS256' }, + ); + resolver.should.be.a('function'); + }); + + it('rejects an asymmetric assertion when the client has no keys', async function () { + await adapter + .defaultGetKey({}, { alg: 'RS256' }) + .then(() => { + throw new Error('should throw'); + }) + .catch((e) => e.name.should.equal('invalid_client')); + }); + }); +}); diff --git a/test/unit/utils/jws-util_test.js b/test/unit/utils/jws-util_test.js new file mode 100644 index 00000000..0a2b8809 --- /dev/null +++ b/test/unit/utils/jws-util_test.js @@ -0,0 +1,69 @@ +'use strict'; + +const jws = require('../../../lib/utils/jws-util'); + +require('chai').should(); + +describe('jws-util', function () { + describe('isHmac()', function () { + it('is true for HS* algorithms and false otherwise', function () { + jws.isHmac('HS256').should.equal(true); + jws.isHmac('HS512').should.equal(true); + jws.isHmac('RS256').should.equal(false); + jws.isHmac('EdDSA').should.equal(false); + jws.isHmac(undefined).should.equal(false); + }); + }); + + describe('algorithmsForHeader()', function () { + it('returns the HMAC family for an HMAC header', function () { + jws.algorithmsForHeader({ alg: 'HS256' }).should.equal(jws.HMAC_ALGS); + }); + + it('returns the asymmetric family otherwise', function () { + jws.algorithmsForHeader({ alg: 'RS256' }).should.equal(jws.ASYMMETRIC_ALGS); + jws.algorithmsForHeader({ alg: 'EdDSA' }).should.equal(jws.ASYMMETRIC_ALGS); + }); + }); + + describe('isValidationError()', function () { + it('is true for a jose assertion-validation error code', function () { + jws.isValidationError({ code: 'ERR_JWT_EXPIRED' }).should.equal(true); + jws.isValidationError({ code: 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED' }).should.equal(true); + }); + + it('is false for operational/unknown errors', function () { + jws.isValidationError({ code: 'ECONNREFUSED' }).should.equal(false); + jws.isValidationError(new Error('boom')).should.equal(false); + jws.isValidationError(null).should.equal(false); + }); + }); + + describe('getRemoteJwks()', function () { + it('memoizes the remote JWK set per URI (no fetch on creation)', function () { + const a1 = jws.getRemoteJwks('https://issuer.example.com/jwks.json'); + const a2 = jws.getRemoteJwks('https://issuer.example.com/jwks.json'); + const b = jws.getRemoteJwks('https://other.example.com/jwks.json'); + + a1.should.be.a('function'); + a1.should.equal(a2); + a1.should.not.equal(b); + }); + }); + + describe('replayId()', function () { + it('returns the jti when present', function () { + jws.replayId('aaa.bbb.ccc', 'the-jti').should.equal('the-jti'); + }); + + it('fingerprints the signing input (ignoring the signature) when jti is absent', function () { + const a = jws.replayId('aaa.bbb.ccc'); + const sameInput = jws.replayId('aaa.bbb.a-different-signature'); + const otherInput = jws.replayId('aaa.xxx.ccc'); + + a.should.be.a('string'); + a.should.equal(sameInput); // signature-independent — defeats ECDSA malleability replay + a.should.not.equal(otherInput); // a different signing input yields a different id + }); + }); +});