Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion docs/api/handlers/token-handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Constructor.
* [.updateSuccessResponse()](#TokenHandler+updateSuccessResponse)
* [.updateErrorResponse()](#TokenHandler+updateErrorResponse)
* [.isClientAuthenticationRequired()](#TokenHandler+isClientAuthenticationRequired)
* [.isClientSecretOptional()](#TokenHandler+isClientSecretOptional)

<a name="TokenHandler+handle"></a>

Expand Down Expand Up @@ -94,7 +95,19 @@ Update response when an error is thrown.
<a name="TokenHandler+isClientAuthenticationRequired"></a>

### 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 [<code>TokenHandler</code>](#TokenHandler)
<a name="TokenHandler+isClientSecretOptional"></a>

### 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 [<code>TokenHandler</code>](#TokenHandler)
<a name="grantTypes"></a>
Expand Down
14 changes: 8 additions & 6 deletions docs/api/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 [<code>Model</code>](#Model)
**Fulfil**: [<code>ClientData</code>](#ClientData) - An `Object` representing the client and associated data, or a falsy value if no such client could be found.
Expand Down Expand Up @@ -687,4 +688,5 @@ An `Object` representing the client and associated data.
| grants | <code>Array.&lt;string&gt;</code> | Grant types allowed for the client. |
| accessTokenLifetime | <code>number</code> | Client-specific lifetime of generated access tokens in seconds. |
| refreshTokenLifetime | <code>number</code> | Client-specific lifetime of generated refresh tokens in seconds. |
| type | <code>string</code> | 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`. |

12 changes: 9 additions & 3 deletions docs/api/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Instantiates `OAuth2Server` using the supplied model.
| [options.accessTokenLifetime] | <code>number</code> | <code>3600</code> | Lifetime of generated access tokens in seconds (default = 1 hour). |
| [options.refreshTokenLifetime] | <code>number</code> | <code>1209600</code> | Lifetime of generated refresh tokens in seconds (default = 2 weeks). |
| [options.allowExtendedTokenAttributes] | <code>boolean</code> | <code>false</code> | Allow extended attributes to be set on the returned token (see remarks section). |
| [options.requireClientAuthentication] | <code>object</code> \| <code>boolean</code> | <code>object</code> | Require a client secret for grant types (names as keys). Defaults to `true` for all grant types. |
| [options.requireClientAuthentication] | <code>object</code> \| <code>boolean</code> | <code>object</code> | 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] | <code>boolean</code> | <code>true</code> | Always revoke the used refresh token and issue a new one for the `refresh_token` grant. |
| [options.extendedGrantTypes] | <code>object</code> | <code>object</code> | Additional supported grant types. |
| [options.enablePlainPKCE] | <code>boolean</code> | <code>false</code> | Allow the use of the `plain` code challenge method for PKCE. This is not recommended for production environments. |
Expand Down Expand Up @@ -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() <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() <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() <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 = {
// ...
Expand Down Expand Up @@ -194,7 +200,7 @@ For information on how to implement a handler for a custom grant type see the ex
| [options.accessTokenLifetime] | <code>number</code> | <code>3600</code> | Lifetime of generated access tokens in seconds (default = 1 hour). |
| [options.refreshTokenLifetime] | <code>number</code> | <code>1209600</code> | Lifetime of generated refresh tokens in seconds (default = 2 weeks). |
| [options.allowExtendedTokenAttributes] | <code>boolean</code> | <code>false</code> | Allow extended attributes to be set on the returned token (see remarks section). |
| [options.requireClientAuthentication] | <code>object</code> \| <code>boolean</code> | <code>object</code> | Require a client secret for grant types (names as keys). Defaults to `true` for all grant types. |
| [options.requireClientAuthentication] | <code>object</code> \| <code>boolean</code> | <code>object</code> | 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] | <code>boolean</code> | <code>true</code> | Always revoke the used refresh token and issue a new one for the `refresh_token` grant. |
| [options.extendedGrantTypes] | <code>object</code> | <code>object</code> | Additional supported grant types. |

Expand Down
71 changes: 48 additions & 23 deletions docs/guide/pkce.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 5 additions & 4 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean>;

Expand Down Expand Up @@ -416,6 +416,7 @@ declare namespace OAuth2Server {
grants: string | string[];
accessTokenLifetime?: number;
refreshTokenLifetime?: number;
type?: 'public' | 'confidential';
[key: string]: any;
}

Expand Down
71 changes: 50 additions & 21 deletions lib/handlers/token-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`');
}

Expand All @@ -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');
Expand All @@ -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
Expand Down Expand Up @@ -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 };
Expand All @@ -194,16 +203,8 @@ class TokenHandler {
};
}

if (pkce.isPKCERequest({ grantType, codeVerifier })) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you please elaborate why this is now fully omitted? The role of PKCE is especially to protect public clients that cannot safely keep a secret.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The !isPkce condition has been relocated. Previously, getClientCredentials had two early-return flows (one for PKCE requests and another for !isClientAuthenticationRequired) that both returned { clientId } without the secret. I consolidated both into a single return, because the decision regarding whether the secret is required or if the PKCE/public-client status excuse it, now take place in getClient after model.getClient() executes, since that's the only place client.type (public vs confidential) is known. Therefore, the check has moved but has not disappeared: getClient still evaluates isClientAuthenticationRequired(grantType, client) && !credentials.clientSecret && !isPkce before accepting a client without a secret, and isClientAuthenticationRequired checks if client is public.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This change in the validation location is also the reason why I unified the error with the !client case: both throw the same InvalidClientError('Invalid client: client is invalid'). Otherwise a client_id that exists but lacks its required secret would be distinguishable from one that doesn't exist.

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');
Expand Down Expand Up @@ -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];
Expand All @@ -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);
Expand Down Expand Up @@ -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';
}
}

Expand Down
15 changes: 9 additions & 6 deletions lib/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*/

/**
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading