diff --git a/docs/api/server.md b/docs/api/server.md
index 5286687..fdb4a36 100644
--- a/docs/api/server.md
+++ b/docs/api/server.md
@@ -44,6 +44,7 @@ Instantiates `OAuth2Server` using the supplied model.
| [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. |
+| [options.requirePKCE] | boolean | false | Require PKCE for the `authorization_code` grant: `authorize` rejects requests without a `code_challenge`, and the token exchange rejects authorization codes that were issued without one. Recommended by OAuth 2.1. |
**Example**
```js
diff --git a/docs/guide/pkce.md b/docs/guide/pkce.md
index 3eace68..f216dfd 100644
--- a/docs/guide/pkce.md
+++ b/docs/guide/pkce.md
@@ -29,6 +29,39 @@ Figure 2: Abstract Protocol Flow
See [Section 1 of RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636#section-1.1).
+## Requiring PKCE
+
+By default PKCE is *optional*: the library verifies a `code_challenge` when one is
+present (and enforces the
+[RFC 7636 §4.6](https://datatracker.ietf.org/doc/html/rfc7636#section-4.6)
+downgrade protection — a `code_verifier` with no stored challenge is rejected),
+but a client may also complete the `authorization_code` flow without it.
+
+[OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1) makes
+PKCE **mandatory** for the authorization code grant — for all clients, public and
+confidential — and
+[RFC 9700 (OAuth 2.0 Security BCP) §2.1.1](https://www.rfc-editor.org/rfc/rfc9700#section-2.1.1)
+recommends that authorization servers require it, partly to defend against PKCE
+*downgrade* attacks. To enforce this, enable `requirePKCE`:
+
+```js
+const server = new OAuth2Server({
+ model,
+ requirePKCE: true
+})
+```
+
+When enabled:
+
+- the **authorization** endpoint rejects requests without a `code_challenge`
+ (`invalid_request`), so no PKCE-less codes are ever issued; and
+- the **token** endpoint rejects authorization codes that were issued without a
+ `code_challenge` (`invalid_grant`) — covering codes minted before the option
+ was turned on, or through another path.
+
+`requirePKCE` defaults to `false` to preserve backwards compatibility. It is a
+strong candidate to become the default in a future major release.
+
## 1. Authorization request
diff --git a/index.d.ts b/index.d.ts
index e67540b..920b47a 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -208,6 +208,11 @@ declare namespace OAuth2Server {
* Lifetime of generated authorization codes in seconds (default = 5 minutes).
*/
authorizationCodeLifetime?: number;
+
+ /**
+ * Require PKCE for the authorization code grant: reject authorize requests without a `code_challenge`. Recommended by OAuth 2.1.
+ */
+ requirePKCE?: boolean;
}
interface TokenOptions {
@@ -240,6 +245,11 @@ declare namespace OAuth2Server {
* Additional supported grant types.
*/
extendedGrantTypes?: Record;
+
+ /**
+ * Require PKCE for the authorization code grant: reject token exchanges for codes issued without a `code_challenge`. Recommended by OAuth 2.1.
+ */
+ requirePKCE?: boolean;
}
/**
diff --git a/lib/grant-types/authorization-code-grant-type.js b/lib/grant-types/authorization-code-grant-type.js
index 56df29e..6e93470 100644
--- a/lib/grant-types/authorization-code-grant-type.js
+++ b/lib/grant-types/authorization-code-grant-type.js
@@ -42,6 +42,8 @@ class AuthorizationCodeGrantType extends AbstractGrantType {
// xxx: plain PKCE is only allowed if explicitly enabled
this.enablePlainPKCE = options.enablePlainPKCE === true;
+ // when enabled, the authorization code grant requires PKCE
+ this.requirePKCE = options.requirePKCE === true;
}
/**
@@ -165,6 +167,11 @@ class AuthorizationCodeGrantType extends AbstractGrantType {
}
}
else {
+ if (this.requirePKCE) {
+ // PKCE is required, but the authorization code was not associated with a `code_challenge`.
+ throw new InvalidGrantError('Invalid grant: authorization code was issued without a `code_challenge`');
+ }
+
if (request.body.code_verifier) {
// No code challenge but code_verifier was passed in.
throw new InvalidGrantError('Invalid grant: code verifier is invalid');
diff --git a/lib/handlers/authorize-handler.js b/lib/handlers/authorize-handler.js
index c5f0db1..71c9623 100644
--- a/lib/handlers/authorize-handler.js
+++ b/lib/handlers/authorize-handler.js
@@ -63,6 +63,7 @@ class AuthorizeHandler {
this.authenticateHandler = options.authenticateHandler || new AuthenticateHandler(options);
this.authorizationCodeLifetime = options.authorizationCodeLifetime;
this.enablePlainPKCE = options.enablePlainPKCE === true;
+ this.requirePKCE = options.requirePKCE === true;
this.model = options.model;
}
@@ -101,6 +102,11 @@ class AuthorizeHandler {
const ResponseType = this.getResponseType(request);
const codeChallenge = this.getCodeChallenge(request);
const codeChallengeMethod = this.getCodeChallengeMethod(request);
+
+ if (this.requirePKCE && !codeChallenge) {
+ throw new InvalidRequestError('Missing parameter: `code_challenge`');
+ }
+
const code = await this.saveAuthorizationCode(
authorizationCode,
expiresAt,
diff --git a/lib/handlers/token-handler.js b/lib/handlers/token-handler.js
index 3b98307..650bf48 100644
--- a/lib/handlers/token-handler.js
+++ b/lib/handlers/token-handler.js
@@ -62,6 +62,7 @@ class TokenHandler {
this.requireClientAuthentication = options.requireClientAuthentication || {};
this.alwaysIssueNewRefreshToken = options.alwaysIssueNewRefreshToken !== false;
this.enablePlainPKCE = options.enablePlainPKCE === true;
+ this.requirePKCE = options.requirePKCE === true;
}
/**
@@ -231,7 +232,8 @@ class TokenHandler {
model: this.model,
refreshTokenLifetime: refreshTokenLifetime,
alwaysIssueNewRefreshToken: this.alwaysIssueNewRefreshToken,
- enablePlainPKCE: this.enablePlainPKCE === true
+ enablePlainPKCE: this.enablePlainPKCE === true,
+ requirePKCE: this.requirePKCE === true
};
return new Type(options).handle(request, client);
diff --git a/lib/server.js b/lib/server.js
index 320ddea..f5a5a32 100644
--- a/lib/server.js
+++ b/lib/server.js
@@ -45,6 +45,7 @@ class OAuth2Server {
* @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.
+ * @param [options.requirePKCE=false] {boolean} Require PKCE for the `authorization_code` grant: `authorize` rejects requests without a `code_challenge`, and the token exchange rejects authorization codes that were issued without one. Recommended by OAuth 2.1.
*
* @throws {InvalidArgumentError} if the model is missing
* @return {OAuth2Server} A new `OAuth2Server` instance.
diff --git a/test/compliance/pkce_test.js b/test/compliance/pkce_test.js
index fa46b8f..a1bd94d 100644
--- a/test/compliance/pkce_test.js
+++ b/test/compliance/pkce_test.js
@@ -671,4 +671,99 @@ describe('PKCE Compliance (RFC 7636)', function () {
}
});
});
+
+ // ==================================================================
+ // requirePKCE option (OAuth 2.1 / RFC 9700 §2.1.1)
+ //
+ // When `requirePKCE` is enabled, the authorization_code grant must use
+ // PKCE: the authorize endpoint rejects requests without a
+ // `code_challenge`, and the token endpoint rejects authorization codes
+ // that were issued without one.
+ // ==================================================================
+ describe('requirePKCE option', function () {
+ function pkceModel () {
+ const baseModel = createModel(db);
+ return {
+ ...baseModel,
+ getAuthorizationCode: async (authorizationCode) => db.authorizationCodes.get(authorizationCode) || null,
+ saveAuthorizationCode: async (code, client, user) => {
+ const doc = { ...code, client, user };
+ db.authorizationCodes.set(code.authorizationCode, doc);
+ return doc;
+ },
+ revokeAuthorizationCode: async (code) => db.authorizationCodes.delete(code.authorizationCode),
+ validateScope: async (user, client, scope) => scope
+ };
+ }
+
+ function requirePKCEServer () {
+ return new OAuth2Server({ requirePKCE: true, authorizationCodeLifetime: 300, model: pkceModel() });
+ }
+
+ function authorizeRequest (extraQuery = {}) {
+ return createRequest({
+ method: 'GET',
+ query: {
+ response_type: 'code',
+ client_id: clientDoc.id,
+ redirect_uri: clientDoc.redirectUris[0],
+ state: 'teststate',
+ scope: 'read',
+ ...extraQuery
+ }
+ });
+ }
+
+ const authenticateHandler = { handle: () => userDoc };
+
+ it('rejects an authorize request without a `code_challenge`', async function () {
+ const server = requirePKCEServer();
+ const response = new Response({ headers: {} });
+ let error = null;
+ try {
+ await server.authorize(authorizeRequest(), response, { authenticateHandler });
+ } catch (e) {
+ error = e;
+ }
+ (error !== null).should.equal(true);
+ error.should.be.an.instanceOf(InvalidRequestError);
+ error.message.should.match(/code_challenge/);
+ });
+
+ it('allows an authorize request that includes a `code_challenge`', async function () {
+ const server = requirePKCEServer();
+ const response = new Response({ headers: {} });
+ const challenge = computeS256Challenge('a'.repeat(43));
+ const code = await server.authorize(
+ authorizeRequest({ code_challenge: challenge, code_challenge_method: 'S256' }),
+ response,
+ { authenticateHandler }
+ );
+ code.codeChallenge.should.equal(challenge);
+ });
+
+ it('rejects a token exchange for a code issued without a `code_challenge`', async function () {
+ const server = requirePKCEServer();
+ const codeValue = 'no-pkce-code-' + Math.random().toString(36).slice(2);
+ db.authorizationCodes.set(codeValue, {
+ authorizationCode: codeValue,
+ expiresAt: new Date(Date.now() + 60000),
+ redirectUri: 'https://client.example/callback',
+ client: clientDoc,
+ user: userDoc,
+ scope: ['read']
+ // intentionally no codeChallenge
+ });
+ const response = new Response();
+ let error = null;
+ try {
+ await server.token(tokenRequest(codeValue), response);
+ } catch (e) {
+ error = e;
+ }
+ (error !== null).should.equal(true);
+ error.should.be.an.instanceOf(InvalidGrantError);
+ error.message.should.equal('Invalid grant: authorization code was issued without a `code_challenge`');
+ });
+ });
});