diff --git a/docs/api/handlers/token-handler.md b/docs/api/handlers/token-handler.md
index d0817a0d..bde9fd54 100644
--- a/docs/api/handlers/token-handler.md
+++ b/docs/api/handlers/token-handler.md
@@ -32,6 +32,7 @@ Constructor.
* [.updateSuccessResponse()](#TokenHandler+updateSuccessResponse)
* [.updateErrorResponse()](#TokenHandler+updateErrorResponse)
* [.isClientAuthenticationRequired()](#TokenHandler+isClientAuthenticationRequired)
+ * [.isClientSecretOptional()](#TokenHandler+isClientSecretOptional)
@@ -94,7 +95,19 @@ Update response when an error is thrown.
### tokenHandler.isClientAuthenticationRequired()
-Given a grant type, check if client authentication is required
+Given a grant type, client and pkce flag, check if client authentication is required.
+
+An explicit `requireClientAuthentication` entry for the grant type always wins.
+Otherwise the client type decides:
+- `public`: no secret for `refresh_token`; `authorization_code` requires PKCE instead
+- `confidential` or no type: secret always required, PKCE never waives it
+
+**Kind**: instance method of [TokenHandler](#TokenHandler)
+
+
+### tokenHandler.isClientSecretOptional()
+Given a grant type and pkce flag, check if a request without a client secret could be accepted
+for any client type.
**Kind**: instance method of [TokenHandler](#TokenHandler)
diff --git a/docs/api/model.md b/docs/api/model.md
index c5d420d8..7cd41ed6 100644
--- a/docs/api/model.md
+++ b/docs/api/model.md
@@ -85,12 +85,13 @@ This model function is **required** for all grant types.
- `password` grant
**Remarks:**
-`clientSecret` is `null`/absent for public clients, and also for
-`authorization_code` requests that authenticate with PKCE (a `code_verifier`)
-instead of a secret — even if the client *has* a `client_secret`. PKCE is
-**not** a substitute for client authentication: your implementation must
-reject (return a falsy value) a confidential client that should have
-presented its `client_secret` but did not.
+`clientSecret` is `null` when the request does not contain a secret. The
+library decides afterwards whether a secret was required, based on the
+returned client's `type` and the `requireClientAuthentication` option:
+a `confidential` client, or a client without `type`, is rejected without
+a secret, even with PKCE. When a `clientSecret` is provided, your implementation must
+always verify it, also for public clients, and return a falsy value if it
+does not match.
**Kind**: instance method of [Model](#Model)
**Fulfil**: [ClientData](#ClientData) - An `Object` representing the client and associated data, or a falsy value if no such client could be found.
@@ -687,4 +688,5 @@ 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. |
+| type | string | The client type: `public` or `confidential`. Optional. When not set (`undefined` or `null`), the client is treated as confidential. Any other value throws a `ServerError`. |
diff --git a/docs/api/server.md b/docs/api/server.md
index 29f0333e..086c6e38 100644
--- a/docs/api/server.md
+++ b/docs/api/server.md
@@ -40,7 +40,7 @@ Instantiates `OAuth2Server` using the supplied model.
| [options.accessTokenLifetime] | number | 3600 | Lifetime of generated access tokens in seconds (default = 1 hour). |
| [options.refreshTokenLifetime] | number | 1209600 | Lifetime of generated refresh tokens in seconds (default = 2 weeks). |
| [options.allowExtendedTokenAttributes] | boolean | false | Allow extended attributes to be set on the returned token (see remarks section). |
-| [options.requireClientAuthentication] | object \| boolean | object | Require a client secret for grant types (names as keys). Defaults to `true` for all grant types. |
+| [options.requireClientAuthentication] | object \| boolean | object | Require a client secret for grant types (names as keys). An entry takes precedence over the client `type`; grants without an entry follow the client `type` (see remarks section). |
| [options.alwaysIssueNewRefreshToken] | boolean | true | Always revoke the used refresh token and issue a new one for the `refresh_token` grant. |
| [options.extendedGrantTypes] | object | object | Additional supported grant types. |
| [options.enablePlainPKCE] | boolean | false | Allow the use of the `plain` code challenge method for PKCE. This is not recommended for production environments. |
@@ -159,7 +159,13 @@ function authorizeHandler(options) {
Retrieves a new token for an authorized token request.
**Remarks:**
If `options.allowExtendedTokenAttributes` is `true` any additional properties set on the object returned from `Model#saveToken() ` are copied to the token response sent to the client.
-By default, all grant types require the client to send it's `client_secret` with the token request. `options.requireClientAuthentication` can be used to disable this check for selected grants. If used, this server option must be an object containing properties set to `true` or `false`. Possible keys for the object include all supported values for the token request's `grant_type` field (`authorization_code`, `client_credentials`, `password` and `refresh_token`). Grants that are not specified default to `true` which enables verification of the `client_secret`. Note: setting a grant to `false` disables only the *presence* check of the `client_secret` for that grant, and does so for **all** clients (not just public ones); it does not validate a secret that is sent. The same applies to `authorization_code` requests that use PKCE (a `code_verifier`), where the presence check is skipped. Per-client (public vs confidential) authentication must therefore be enforced in your `Model#getClient() ` implementation, which should reject a confidential client that fails to present its `client_secret`.
+By default, all grant types require the client to send its `client_secret` with the token request. `options.requireClientAuthentication` overrides this per grant type.
+It must be an object with `grant_type` values as keys (`authorization_code`, `client_credentials`, `password` and `refresh_token`) set to `true` or `false`.
+An entry takes precedence over the client `type` returned by `Model#getClient() ` and applies to all clients.
+`false` disables only the *presence* check of the `client_secret`; a secret that is sent is still passed to `Model#getClient()`, which must verify it.
+Grants without an entry follow the client `type`: `confidential` clients and clients without `type` always require a secret, even with PKCE. `public` clients may use `refresh_token` without a secret.
+Public clients must use PKCE for `authorization_code`. The `client_credentials` grant type cannot be used with public clients.
+Any other `type` value throws a `ServerError`. See the PKCE guide for details.
```js
let options = {
// ...
@@ -194,7 +200,7 @@ For information on how to implement a handler for a custom grant type see the ex
| [options.accessTokenLifetime] | number | 3600 | Lifetime of generated access tokens in seconds (default = 1 hour). |
| [options.refreshTokenLifetime] | number | 1209600 | Lifetime of generated refresh tokens in seconds (default = 2 weeks). |
| [options.allowExtendedTokenAttributes] | boolean | false | Allow extended attributes to be set on the returned token (see remarks section). |
-| [options.requireClientAuthentication] | object \| boolean | object | Require a client secret for grant types (names as keys). Defaults to `true` for all grant types. |
+| [options.requireClientAuthentication] | object \| boolean | object | Require a client secret for grant types (names as keys). An entry takes precedence over the client `type`; grants without an entry follow the client `type` (see remarks section). |
| [options.alwaysIssueNewRefreshToken] | boolean | true | Always revoke the used refresh token and issue a new one for the `refresh_token` grant. |
| [options.extendedGrantTypes] | object | object | Additional supported grant types. |
diff --git a/docs/guide/pkce.md b/docs/guide/pkce.md
index 206b59c2..6d5702d5 100644
--- a/docs/guide/pkce.md
+++ b/docs/guide/pkce.md
@@ -173,26 +173,51 @@ there. A client that obtained its tokens via PKCE refreshes them like any other
client — by presenting its `refresh_token` (and, if it is a confidential client,
its `client_secret`).
-PKCE is **not** client authentication, and never a substitute for a `client_secret`:
-
-- A **confidential** client (one issued a `client_secret`) must authenticate with
- its secret on **every** token request, including `refresh_token`. PKCE is
- additive. Your `model.getClient(clientId, clientSecret)` is responsible for
- rejecting (returning a falsy value) a confidential client that fails to present
- its secret.
-- A **public** client has no secret. If you choose to issue refresh tokens to
- public clients (weigh the security implications first — see
- [RFC 9700](https://www.rfc-editor.org/rfc/rfc9700)), relax client authentication
- for that grant:
-
- const server = new OAuth2Server({
- model,
- requireClientAuthentication: { refresh_token: false } // allow refresh without a client_secret
- })
-
- `requireClientAuthentication: { refresh_token: false }` disables the
- `client_secret` **presence** check for the `refresh_token` grant for **all**
- clients, not just public ones, so per-client (public vs confidential)
- enforcement must be done in your `model.getClient`. The library does not yet
- model the public/confidential distinction itself (tracked in
- [#81](https://github.com/node-oauth/node-oauth2-server/issues/81)).
+PKCE is **not** client authentication, and never a substitute for a `client_secret`.
+Declare the client type ([RFC 6749 §2.1](https://datatracker.ietf.org/doc/html/rfc6749#section-2.1))
+by returning a `type` property from `model.getClient()`:
+
+```js
+async getClient(clientId, clientSecret) {
+ const client = await db.findClient(clientId);
+ if (!client) return null;
+ // a provided secret must always match, also for public clients
+ if (clientSecret !== null && client.secret !== clientSecret) return null;
+ return { id: client.id, grants: client.grants, type: client.type }; // 'public' or 'confidential'
+}
+```
+
+The token endpoint then decides whether a `client_secret` is required:
+
+- A **confidential** client (`type: 'confidential'`, or no `type` at all) must
+ authenticate with its secret on **every** token request, including
+ `refresh_token`. PKCE is additive and does not waive the secret.
+- A **public** client (`type: 'public'`) has no secret. It must use PKCE for the
+ `authorization_code` grant
+ ([RFC 9700 §2.1.1](https://www.rfc-editor.org/rfc/rfc9700#section-2.1.1)), even
+ if it sends a `client_secret` or `requireClientAuthentication` waives it: a code
+ issued without a `code_challenge` is rejected with `invalid_grant`. Unless
+ `requireClientAuthentication.refresh_token` is set, it may use `refresh_token`
+ without a secret (weigh the security implications first, see
+ [RFC 9700](https://www.rfc-editor.org/rfc/rfc9700)). The `client_credentials`
+ grant type cannot be used with public clients
+ ([RFC 6749 §4.4](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)).
+- Any other `type` value (e.g. `'Confidential'` or `''`) throws a `ServerError`.
+ `undefined` and `null` count as no type.
+
+An explicit `requireClientAuthentication` entry for a grant type always takes
+precedence over the client type, for **all** clients:
+
+ const server = new OAuth2Server({
+ model,
+ requireClientAuthentication: { refresh_token: false } // allow refresh without a client_secret
+ })
+
+An explicit `true` requires a secret for every client of that grant type,
+including public clients.
+
+A request without a `client_secret` is rejected before `model.getClient()` is
+called whenever no client type could accept it: the grant has no explicit
+`false` entry, and the request is neither `authorization_code` with a
+`code_verifier` nor `refresh_token`. In the remaining cases `model.getClient()`
+receives `null` as `clientSecret`, so it must handle a missing secret.
diff --git a/index.d.ts b/index.d.ts
index 34209981..44c5a0f1 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -232,10 +232,10 @@ declare namespace OAuth2Server {
allowExtendedTokenAttributes?: boolean;
/**
- * Require a client secret. Defaults to true for all grant types.
- * Setting a grant to `false` disables the client_secret presence check for
- * that grant for ALL clients (not just public ones); per-client
- * (public vs confidential) enforcement must be done in model.getClient.
+ * Require a client secret per grant type. An entry takes precedence over
+ * the client `type` and applies to ALL clients. `false` disables only the
+ * client_secret presence check; a sent secret must still be verified in
+ * model.getClient. Grants without an entry follow the client `type`.
*/
requireClientAuthentication?: Record;
@@ -416,6 +416,7 @@ declare namespace OAuth2Server {
grants: string | string[];
accessTokenLifetime?: number;
refreshTokenLifetime?: number;
+ type?: 'public' | 'confidential';
[key: string]: any;
}
diff --git a/lib/handlers/token-handler.js b/lib/handlers/token-handler.js
index 895ef9c8..eaa435df 100644
--- a/lib/handlers/token-handler.js
+++ b/lib/handlers/token-handler.js
@@ -127,7 +127,7 @@ class TokenHandler {
throw new InvalidRequestError('Missing parameter: `client_id`');
}
- if (this.isClientAuthenticationRequired(grantType) && !credentials.clientSecret && !isPkce) {
+ if (!credentials.clientSecret && !this.isClientSecretOptional(grantType, isPkce)) {
throw new InvalidRequestError('Missing parameter: `client_secret`');
}
@@ -140,7 +140,7 @@ class TokenHandler {
}
try {
- const client = await this.model.getClient(credentials.clientId, credentials.clientSecret);
+ const client = await this.model.getClient(credentials.clientId, credentials.clientSecret || null);
if (!client) {
throw new InvalidClientError('Invalid client: client is invalid');
@@ -154,6 +154,14 @@ class TokenHandler {
throw new ServerError('Server error: `grants` must be an array');
}
+ if (client.type !== undefined && client.type !== null && !['public', 'confidential'].includes(client.type)) {
+ throw new ServerError('Server error: `type` must be "public" or "confidential"');
+ }
+
+ if (!credentials.clientSecret && this.isClientAuthenticationRequired(grantType, client, isPkce)) {
+ throw new InvalidClientError('Invalid client: client is invalid');
+ }
+
return client;
} catch (e) {
// Include the "WWW-Authenticate" response header field if the client
@@ -182,6 +190,7 @@ class TokenHandler {
const credentials = auth(request);
const grantType = request.body.grant_type;
const codeVerifier = request.body.code_verifier;
+ const isPkce = pkce.isPKCERequest({ grantType, codeVerifier });
if (credentials) {
return { clientId: credentials.name, clientSecret: credentials.pass };
@@ -194,16 +203,8 @@ class TokenHandler {
};
}
- 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 };
- }
+ if (request.body.client_id && this.isClientSecretOptional(grantType, isPkce)) {
+ return { clientId: request.body.client_id };
}
throw new InvalidClientError('Invalid client: cannot retrieve client credentials');
@@ -232,6 +233,10 @@ class TokenHandler {
throw new UnauthorizedClientError('Unauthorized client: `grant_type` is invalid');
}
+ if (client.type === 'public' && grantType === 'client_credentials') {
+ throw new UnauthorizedClientError('Unauthorized client: `grant_type` is invalid');
+ }
+
const accessTokenLifetime = this.getAccessTokenLifetime(client);
const refreshTokenLifetime = this.getRefreshTokenLifetime(client);
const Type = this.grantTypes[grantType];
@@ -242,7 +247,7 @@ class TokenHandler {
refreshTokenLifetime: refreshTokenLifetime,
alwaysIssueNewRefreshToken: this.alwaysIssueNewRefreshToken,
enablePlainPKCE: this.enablePlainPKCE === true,
- requirePKCE: this.requirePKCE === true,
+ requirePKCE: this.requirePKCE === true || client.type === 'public',
};
return new Type(options).handle(request, client);
@@ -309,16 +314,40 @@ class TokenHandler {
}
/**
- * Given a grant type, check if client authentication is required
+ * Given a grant type, client and pkce flag, check if client authentication is required.
+ *
+ * An explicit `requireClientAuthentication` entry for the grant type always wins.
+ * Otherwise the client type decides:
+ * - `public`: no secret for `refresh_token`; `authorization_code` requires PKCE instead
+ * - `confidential` or no type: secret always required, PKCE never waives it
+ */
+ isClientAuthenticationRequired(grantType, client, isPkce) {
+ const explicit = this.requireClientAuthentication[grantType];
+
+ if (typeof explicit !== 'undefined') {
+ return explicit;
+ }
+
+ if (client && client.type === 'public') {
+ if (grantType === 'authorization_code') return !isPkce;
+ if (grantType === 'refresh_token') return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Given a grant type and pkce flag, check if a request without a client secret could be accepted
+ * for any client type.
*/
- isClientAuthenticationRequired(grantType) {
- if (Object.keys(this.requireClientAuthentication).length > 0) {
- return typeof this.requireClientAuthentication[grantType] !== 'undefined'
- ? this.requireClientAuthentication[grantType]
- : true;
- } else {
- return true;
+ isClientSecretOptional(grantType, isPkce) {
+ const explicit = this.requireClientAuthentication[grantType];
+
+ if (typeof explicit !== 'undefined') {
+ return !explicit;
}
+
+ return isPkce || grantType === 'refresh_token';
}
}
diff --git a/lib/model.js b/lib/model.js
index cbf4e843..8fb06205 100644
--- a/lib/model.js
+++ b/lib/model.js
@@ -46,6 +46,8 @@ 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 type {string} The client type: `public` or `confidential`. Optional. When not set (`undefined` or `null`),
+ * the client is treated as confidential. Any other value throws a `ServerError`.
*/
/**
@@ -97,12 +99,13 @@ class Model {
* - `password` grant
*
* **Remarks:**
- * `clientSecret` is `null`/absent for public clients, and also for
- * `authorization_code` requests that authenticate with PKCE (a `code_verifier`)
- * instead of a secret — even if the client *has* a `client_secret`. PKCE is
- * **not** a substitute for client authentication: your implementation must
- * reject (return a falsy value) a confidential client that should have
- * presented its `client_secret` but did not.
+ * `clientSecret` is `null` when the request does not contain a secret. The
+ * library decides afterwards whether a secret was required, based on the
+ * returned client's `type` and the `requireClientAuthentication` option:
+ * a `confidential` client, or a client without `type`, is rejected without
+ * a secret, even with PKCE. When a `clientSecret` is provided, your implementation must
+ * always verify it, also for public clients, and return a falsy value if it
+ * does not match.
*
* @async
* @param clientId {string} The client id of the client to retrieve.
diff --git a/lib/server.js b/lib/server.js
index 5fd70b65..04100be0 100644
--- a/lib/server.js
+++ b/lib/server.js
@@ -40,7 +40,7 @@ class OAuth2Server {
* @param [options.accessTokenLifetime=3600] {number=} Lifetime of generated access tokens in seconds (default = 1 hour).
* @param [options.refreshTokenLifetime=1209600] {number=} Lifetime of generated refresh tokens in seconds (default = 2 weeks).
* @param [options.allowExtendedTokenAttributes=false] {boolean=} Allow extended attributes to be set on the returned token (see remarks section).
- * @param [options.requireClientAuthentication=object] {object|boolean} Require a client secret for grant types (names as keys). Defaults to `true` for all grant types.
+ * @param [options.requireClientAuthentication=object] {object|boolean} Require a client secret for grant types (names as keys). An entry takes precedence over the client `type`; grants without an entry follow the client `type` (see remarks section).
* @param [options.alwaysIssueNewRefreshToken=true] {boolean=} Always revoke the used refresh token and issue a new one for the `refresh_token` grant.
* @param [options.extendedGrantTypes=object] {object} Additional supported grant types.
* @param [options.enablePlainPKCE=false] {boolean} Allow the use of the `plain` code challenge method for PKCE. This is not recommended for production environments.
@@ -184,7 +184,13 @@ class OAuth2Server {
* Retrieves a new token for an authorized token request.
* **Remarks:**
* If `options.allowExtendedTokenAttributes` is `true` any additional properties set on the object returned from `Model#saveToken() ` are copied to the token response sent to the client.
- * By default, all grant types require the client to send it's `client_secret` with the token request. `options.requireClientAuthentication` can be used to disable this check for selected grants. If used, this server option must be an object containing properties set to `true` or `false`. Possible keys for the object include all supported values for the token request's `grant_type` field (`authorization_code`, `client_credentials`, `password` and `refresh_token`). Grants that are not specified default to `true` which enables verification of the `client_secret`. Note: setting a grant to `false` disables only the *presence* check of the `client_secret` for that grant, and does so for **all** clients (not just public ones); it does not validate a secret that is sent. The same applies to `authorization_code` requests that use PKCE (a `code_verifier`), where the presence check is skipped. Per-client (public vs confidential) authentication must therefore be enforced in your `Model#getClient() ` implementation, which should reject a confidential client that fails to present its `client_secret`.
+ * By default, all grant types require the client to send its `client_secret` with the token request. `options.requireClientAuthentication` overrides this per grant type.
+ * It must be an object with `grant_type` values as keys (`authorization_code`, `client_credentials`, `password` and `refresh_token`) set to `true` or `false`.
+ * An entry takes precedence over the client `type` returned by `Model#getClient() ` and applies to all clients.
+ * `false` disables only the *presence* check of the `client_secret`; a secret that is sent is still passed to `Model#getClient()`, which must verify it.
+ * Grants without an entry follow the client `type`: `confidential` clients and clients without `type` always require a secret, even with PKCE. `public` clients may use `refresh_token` without a secret.
+ * Public clients must use PKCE for `authorization_code`. The `client_credentials` grant type cannot be used with public clients.
+ * Any other `type` value throws a `ServerError`. See the PKCE guide for details.
* ```js
* let options = {
* // ...
@@ -209,7 +215,7 @@ class OAuth2Server {
* @param [options.accessTokenLifetime=3600] {number=} Lifetime of generated access tokens in seconds (default = 1 hour).
* @param [options.refreshTokenLifetime=1209600] {number=} Lifetime of generated refresh tokens in seconds (default = 2 weeks).
* @param [options.allowExtendedTokenAttributes=false] {boolean=} Allow extended attributes to be set on the returned token (see remarks section).
- * @param [options.requireClientAuthentication=object] {object|boolean} Require a client secret for grant types (names as keys). Defaults to `true` for all grant types.
+ * @param [options.requireClientAuthentication=object] {object|boolean} Require a client secret for grant types (names as keys). An entry takes precedence over the client `type`; grants without an entry follow the client `type` (see remarks section).
* @param [options.alwaysIssueNewRefreshToken=true] {boolean=} Always revoke the used refresh token and issue a new one for the `refresh_token` grant.
* @param [options.extendedGrantTypes=object] {object} Additional supported grant types.
* @return {Promise.