Skip to content

Commit b3e6483

Browse files
dhensbyclaude
andcommitted
feat: add requirePKCE option to enforce PKCE on the authorization code grant
OAuth 2.1 and RFC 9700 (Security BCP) §2.1.1 require/recommend PKCE for all authorization code flows; previously PKCE was opt-in per request. When `requirePKCE` is enabled (default `false`): - the authorize endpoint rejects requests without a `code_challenge` (InvalidRequestError), so no PKCE-less authorization codes are issued; and - the token endpoint rejects authorization codes issued without a `code_challenge` (InvalidGrantError), closing the gap for pre-existing codes. Plumbed through the server to the authorize and token handlers (mirroring `enablePlainPKCE`), documented in JSDoc/typings, and covered by compliance tests. Existing behaviour is unchanged when the option is off. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7463db7 commit b3e6483

8 files changed

Lines changed: 173 additions & 0 deletions

File tree

docs/api/server.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Instantiates `OAuth2Server` using the supplied model.
4444
| [options.alwaysIssueNewRefreshToken] | <code>boolean</code> | <code>true</code> | Always revoke the used refresh token and issue a new one for the `refresh_token` grant. |
4545
| [options.extendedGrantTypes] | <code>object</code> | <code>object</code> | Additional supported grant types. |
4646
| [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. |
47+
| [options.requirePKCE] | <code>boolean</code> | <code>false</code> | 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. |
4748

4849
**Example**
4950
```js

docs/guide/pkce.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,39 @@ Figure 2: Abstract Protocol Flow
2929

3030
See [Section 1 of RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636#section-1.1).
3131

32+
## Requiring PKCE
33+
34+
By default PKCE is *optional*: the library verifies a `code_challenge` when one is
35+
present (and enforces the
36+
[RFC 7636 §4.6](https://datatracker.ietf.org/doc/html/rfc7636#section-4.6)
37+
downgrade protection — a `code_verifier` with no stored challenge is rejected),
38+
but a client may also complete the `authorization_code` flow without it.
39+
40+
[OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1) makes
41+
PKCE **mandatory** for the authorization code grant — for all clients, public and
42+
confidential — and
43+
[RFC 9700 (OAuth 2.0 Security BCP) §2.1.1](https://www.rfc-editor.org/rfc/rfc9700#section-2.1.1)
44+
recommends that authorization servers require it, partly to defend against PKCE
45+
*downgrade* attacks. To enforce this, enable `requirePKCE`:
46+
47+
```js
48+
const server = new OAuth2Server({
49+
model,
50+
requirePKCE: true
51+
})
52+
```
53+
54+
When enabled:
55+
56+
- the **authorization** endpoint rejects requests without a `code_challenge`
57+
(`invalid_request`), so no PKCE-less codes are ever issued; and
58+
- the **token** endpoint rejects authorization codes that were issued without a
59+
`code_challenge` (`invalid_grant`) — covering codes minted before the option
60+
was turned on, or through another path.
61+
62+
`requirePKCE` defaults to `false` to preserve backwards compatibility. It is a
63+
strong candidate to become the default in a future major release.
64+
3265
## 1. Authorization request
3366

3467
<div id="PKCE#authorizationRequest">

index.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,11 @@ declare namespace OAuth2Server {
208208
* Lifetime of generated authorization codes in seconds (default = 5 minutes).
209209
*/
210210
authorizationCodeLifetime?: number;
211+
212+
/**
213+
* Require PKCE for the authorization code grant: reject authorize requests without a `code_challenge`. Recommended by OAuth 2.1.
214+
*/
215+
requirePKCE?: boolean;
211216
}
212217

213218
interface TokenOptions {
@@ -243,6 +248,11 @@ declare namespace OAuth2Server {
243248
* Additional supported grant types.
244249
*/
245250
extendedGrantTypes?: Record<string, typeof AbstractGrantType>;
251+
252+
/**
253+
* Require PKCE for the authorization code grant: reject token exchanges for codes issued without a `code_challenge`. Recommended by OAuth 2.1.
254+
*/
255+
requirePKCE?: boolean;
246256
}
247257

248258
/**

lib/grant-types/authorization-code-grant-type.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ class AuthorizationCodeGrantType extends AbstractGrantType {
4242

4343
// xxx: plain PKCE is only allowed if explicitly enabled
4444
this.enablePlainPKCE = options.enablePlainPKCE === true;
45+
// when enabled, the authorization code grant requires PKCE
46+
this.requirePKCE = options.requirePKCE === true;
4547
}
4648

4749
/**
@@ -164,6 +166,11 @@ class AuthorizationCodeGrantType extends AbstractGrantType {
164166
throw new InvalidGrantError('Invalid grant: code verifier is invalid');
165167
}
166168
} else {
169+
if (this.requirePKCE) {
170+
// PKCE is required, but the authorization code was not associated with a `code_challenge`.
171+
throw new InvalidGrantError('Invalid grant: authorization code was issued without a `code_challenge`');
172+
}
173+
167174
if (request.body.code_verifier) {
168175
// No code challenge but code_verifier was passed in.
169176
throw new InvalidGrantError('Invalid grant: code verifier is invalid');

lib/handlers/authorize-handler.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class AuthorizeHandler {
6363
this.authenticateHandler = options.authenticateHandler || new AuthenticateHandler(options);
6464
this.authorizationCodeLifetime = options.authorizationCodeLifetime;
6565
this.enablePlainPKCE = options.enablePlainPKCE === true;
66+
this.requirePKCE = options.requirePKCE === true;
6667
this.model = options.model;
6768
}
6869

@@ -100,7 +101,13 @@ class AuthorizeHandler {
100101

101102
const ResponseType = this.getResponseType(request);
102103
const codeChallenge = this.getCodeChallenge(request);
104+
105+
if (this.requirePKCE && !codeChallenge) {
106+
throw new InvalidRequestError('Missing parameter: `code_challenge`');
107+
}
108+
103109
const codeChallengeMethod = this.getCodeChallengeMethod(request);
110+
104111
const code = await this.saveAuthorizationCode(
105112
authorizationCode,
106113
expiresAt,

lib/handlers/token-handler.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class TokenHandler {
6262
this.requireClientAuthentication = options.requireClientAuthentication || {};
6363
this.alwaysIssueNewRefreshToken = options.alwaysIssueNewRefreshToken !== false;
6464
this.enablePlainPKCE = options.enablePlainPKCE === true;
65+
this.requirePKCE = options.requirePKCE === true;
6566
}
6667

6768
/**
@@ -237,6 +238,7 @@ class TokenHandler {
237238
refreshTokenLifetime: refreshTokenLifetime,
238239
alwaysIssueNewRefreshToken: this.alwaysIssueNewRefreshToken,
239240
enablePlainPKCE: this.enablePlainPKCE === true,
241+
requirePKCE: this.requirePKCE === true,
240242
};
241243

242244
return new Type(options).handle(request, client);

lib/server.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class OAuth2Server {
4444
* @param [options.alwaysIssueNewRefreshToken=true] {boolean=} Always revoke the used refresh token and issue a new one for the `refresh_token` grant.
4545
* @param [options.extendedGrantTypes=object] {object} Additional supported grant types.
4646
* @param [options.enablePlainPKCE=false] {boolean} Allow the use of the `plain` code challenge method for PKCE. This is not recommended for production environments.
47+
* @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.
4748
*
4849
* @throws {InvalidArgumentError} if the model is missing
4950
* @return {OAuth2Server} A new `OAuth2Server` instance.

test/compliance/pkce_test.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,4 +675,116 @@ describe('PKCE Compliance (RFC 7636)', function () {
675675
}
676676
});
677677
});
678+
679+
// ==================================================================
680+
// requirePKCE option (OAuth 2.1 / RFC 9700 §2.1.1)
681+
//
682+
// When `requirePKCE` is enabled, the authorization_code grant must use
683+
// PKCE: the authorize endpoint rejects requests without a
684+
// `code_challenge`, and the token endpoint rejects authorization codes
685+
// that were issued without one.
686+
// ==================================================================
687+
describe('requirePKCE option', function () {
688+
function pkceModel() {
689+
const baseModel = createModel(db);
690+
return {
691+
...baseModel,
692+
getAuthorizationCode: async (authorizationCode) => db.authorizationCodes.get(authorizationCode) || null,
693+
saveAuthorizationCode: async (code, client, user) => {
694+
const doc = { ...code, client, user };
695+
db.authorizationCodes.set(code.authorizationCode, doc);
696+
return doc;
697+
},
698+
revokeAuthorizationCode: async (code) => db.authorizationCodes.delete(code.authorizationCode),
699+
validateScope: async (user, client, scope) => scope,
700+
};
701+
}
702+
703+
function requirePKCEServer() {
704+
return new OAuth2Server({ requirePKCE: true, authorizationCodeLifetime: 300, model: pkceModel() });
705+
}
706+
707+
function authorizeRequest(extraQuery = {}) {
708+
return createRequest({
709+
method: 'GET',
710+
query: {
711+
response_type: 'code',
712+
client_id: clientDoc.id,
713+
redirect_uri: clientDoc.redirectUris[0],
714+
state: 'teststate',
715+
scope: 'read',
716+
...extraQuery,
717+
},
718+
});
719+
}
720+
721+
const authenticateHandler = { handle: () => userDoc };
722+
723+
it('rejects an authorize request without a `code_challenge`', async function () {
724+
const server = requirePKCEServer();
725+
const response = new Response({ headers: {} });
726+
let error = null;
727+
try {
728+
await server.authorize(authorizeRequest(), response, { authenticateHandler });
729+
} catch (e) {
730+
error = e;
731+
}
732+
(error !== null).should.equal(true);
733+
error.should.be.an.instanceOf(InvalidRequestError);
734+
error.message.should.match(/code_challenge/);
735+
});
736+
737+
it('rejects a `code_challenge_method` without a `code_challenge` as a missing `code_challenge`', async function () {
738+
// the missing-`code_challenge` error must take precedence over method
739+
// validation, so a request with an (otherwise invalid) method but no
740+
// challenge reports the missing parameter, not a method error.
741+
const server = requirePKCEServer();
742+
const response = new Response({ headers: {} });
743+
let error = null;
744+
try {
745+
await server.authorize(authorizeRequest({ code_challenge_method: 'plain' }), response, { authenticateHandler });
746+
} catch (e) {
747+
error = e;
748+
}
749+
(error !== null).should.equal(true);
750+
error.should.be.an.instanceOf(InvalidRequestError);
751+
error.message.should.equal('Missing parameter: `code_challenge`');
752+
});
753+
754+
it('allows an authorize request that includes a `code_challenge`', async function () {
755+
const server = requirePKCEServer();
756+
const response = new Response({ headers: {} });
757+
const challenge = computeS256Challenge('a'.repeat(43));
758+
const code = await server.authorize(
759+
authorizeRequest({ code_challenge: challenge, code_challenge_method: 'S256' }),
760+
response,
761+
{ authenticateHandler },
762+
);
763+
code.codeChallenge.should.equal(challenge);
764+
});
765+
766+
it('rejects a token exchange for a code issued without a `code_challenge`', async function () {
767+
const server = requirePKCEServer();
768+
const codeValue = 'no-pkce-code-' + Math.random().toString(36).slice(2);
769+
db.authorizationCodes.set(codeValue, {
770+
authorizationCode: codeValue,
771+
expiresAt: new Date(Date.now() + 60000),
772+
redirectUri: 'https://client.example/callback',
773+
client: clientDoc,
774+
user: userDoc,
775+
scope: ['read'],
776+
// intentionally no codeChallenge
777+
});
778+
const response = new Response();
779+
let error = null;
780+
try {
781+
await server.token(tokenRequest(codeValue), response);
782+
} catch (e) {
783+
error = e;
784+
}
785+
(error !== null).should.equal(true);
786+
error.should.be.an.instanceOf(InvalidGrantError);
787+
error.message.should.equal('Invalid grant: authorization code was issued without a `code_challenge`');
788+
});
789+
});
678790
});

0 commit comments

Comments
 (0)