diff --git a/docs/api/server.md b/docs/api/server.md
index 5d8f3a6..29f0333 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 7e89658..206b59c 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 74e554f..3420998 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 {
@@ -243,6 +248,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 ff33620..66c3cc1 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;
}
/**
@@ -164,6 +166,11 @@ class AuthorizationCodeGrantType extends AbstractGrantType {
throw new InvalidGrantError('Invalid grant: code verifier is invalid');
}
} 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 a6bcd84..35a5c22 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;
}
@@ -100,7 +101,13 @@ class AuthorizeHandler {
const ResponseType = this.getResponseType(request);
const codeChallenge = this.getCodeChallenge(request);
+
+ if (this.requirePKCE && !codeChallenge) {
+ throw new InvalidRequestError('Missing parameter: `code_challenge`');
+ }
+
const codeChallengeMethod = this.getCodeChallengeMethod(request);
+
const code = await this.saveAuthorizationCode(
authorizationCode,
expiresAt,
diff --git a/lib/handlers/token-handler.js b/lib/handlers/token-handler.js
index c6ebebb..0fd81e7 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;
}
/**
@@ -237,6 +238,7 @@ class TokenHandler {
refreshTokenLifetime: refreshTokenLifetime,
alwaysIssueNewRefreshToken: this.alwaysIssueNewRefreshToken,
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 2a152f3..5fd70b6 100644
--- a/lib/server.js
+++ b/lib/server.js
@@ -44,6 +44,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 8cabb6b..0db2b5d 100644
--- a/test/compliance/pkce_test.js
+++ b/test/compliance/pkce_test.js
@@ -675,4 +675,116 @@ 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('rejects a `code_challenge_method` without a `code_challenge` as a missing `code_challenge`', async function () {
+ // the missing-`code_challenge` error must take precedence over method
+ // validation, so a request with an (otherwise invalid) method but no
+ // challenge reports the missing parameter, not a method error.
+ const server = requirePKCEServer();
+ const response = new Response({ headers: {} });
+ let error = null;
+ try {
+ await server.authorize(authorizeRequest({ code_challenge_method: 'plain' }), response, { authenticateHandler });
+ } catch (e) {
+ error = e;
+ }
+ (error !== null).should.equal(true);
+ error.should.be.an.instanceOf(InvalidRequestError);
+ error.message.should.equal('Missing parameter: `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`');
+ });
+ });
});