Skip to content

Commit b08d51c

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 4cd878b commit b08d51c

8 files changed

Lines changed: 156 additions & 1 deletion

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 {
@@ -240,6 +245,11 @@ declare namespace OAuth2Server {
240245
* Additional supported grant types.
241246
*/
242247
extendedGrantTypes?: Record<string, typeof AbstractGrantType>;
248+
249+
/**
250+
* Require PKCE for the authorization code grant: reject token exchanges for codes issued without a `code_challenge`. Recommended by OAuth 2.1.
251+
*/
252+
requirePKCE?: boolean;
243253
}
244254

245255
/**

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
/**
@@ -165,6 +167,11 @@ class AuthorizationCodeGrantType extends AbstractGrantType {
165167
}
166168
}
167169
else {
170+
if (this.requirePKCE) {
171+
// PKCE is required, but the authorization code was not associated with a `code_challenge`.
172+
throw new InvalidGrantError('Invalid grant: authorization code was issued without a `code_challenge`');
173+
}
174+
168175
if (request.body.code_verifier) {
169176
// No code challenge but code_verifier was passed in.
170177
throw new InvalidGrantError('Invalid grant: code verifier is invalid');

lib/handlers/authorize-handler.js

Lines changed: 6 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

@@ -101,6 +102,11 @@ class AuthorizeHandler {
101102
const ResponseType = this.getResponseType(request);
102103
const codeChallenge = this.getCodeChallenge(request);
103104
const codeChallengeMethod = this.getCodeChallengeMethod(request);
105+
106+
if (this.requirePKCE && !codeChallenge) {
107+
throw new InvalidRequestError('Missing parameter: `code_challenge`');
108+
}
109+
104110
const code = await this.saveAuthorizationCode(
105111
authorizationCode,
106112
expiresAt,

lib/handlers/token-handler.js

Lines changed: 3 additions & 1 deletion
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
/**
@@ -231,7 +232,8 @@ class TokenHandler {
231232
model: this.model,
232233
refreshTokenLifetime: refreshTokenLifetime,
233234
alwaysIssueNewRefreshToken: this.alwaysIssueNewRefreshToken,
234-
enablePlainPKCE: this.enablePlainPKCE === true
235+
enablePlainPKCE: this.enablePlainPKCE === true,
236+
requirePKCE: this.requirePKCE === true
235237
};
236238

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

lib/server.js

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

test/compliance/pkce_test.js

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

0 commit comments

Comments
 (0)