Skip to content

Commit 2d0659f

Browse files
committed
fix: multiple PKCE vulnerabilities addressed
1 parent 79b7cf5 commit 2d0659f

9 files changed

Lines changed: 886 additions & 173 deletions

File tree

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

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
/*
44
* Module dependencies.
55
*/
6-
6+
const crypto = require('crypto');
77
const AbstractGrantType = require('./abstract-grant-type');
88
const InvalidArgumentError = require('../errors/invalid-argument-error');
99
const InvalidGrantError = require('../errors/invalid-grant-error');
@@ -39,6 +39,9 @@ class AuthorizationCodeGrantType extends AbstractGrantType {
3939
}
4040

4141
super(options);
42+
43+
// xxx: plain PKCE is only allowed if explicitly enabled
44+
this.enablePlainPKCE = options.enablePlainPKCE === true;
4245
}
4346

4447
/**
@@ -60,6 +63,10 @@ class AuthorizationCodeGrantType extends AbstractGrantType {
6063

6164
const code = await this.getAuthorizationCode(request, client);
6265
await this.revokeAuthorizationCode(code);
66+
// xxx: PKCE verification is done after revoking the code,
67+
// so that a failed verification attempt consumes the code and prevents
68+
// online brute-force guessing.
69+
await this.verifyPKCE(request, code);
6370
await this.validateRedirectUri(request, code);
6471

6572
return this.saveToken(code.user, client, code.authorizationCode, code.scope);
@@ -111,26 +118,51 @@ class AuthorizationCodeGrantType extends AbstractGrantType {
111118
throw new InvalidGrantError('Invalid grant: `redirect_uri` is not a valid URI');
112119
}
113120

114-
// optional: PKCE code challenge
121+
return code;
122+
}
115123

124+
/**
125+
* Verify PKCE code_verifier against the stored code_challenge.
126+
*
127+
* This is called from handle() AFTER the authorization code has been
128+
* revoked, so that a failed verification attempt consumes the code
129+
* and prevents online brute-force guessing.
130+
*
131+
* @param request {Request}
132+
* @param code {AuthorizationCodeData}
133+
* @see https://datatracker.ietf.org/doc/html/rfc7636#section-4.6
134+
*/
135+
136+
verifyPKCE(request, code) {
116137
if (code.codeChallenge) {
138+
const method = this.getCodeChallengeMethod(code.codeChallengeMethod);
139+
140+
if (!this.enablePlainPKCE && method === 'plain') {
141+
throw new InvalidRequestError('Invalid request: `code_challenge_method` "plain" is not allowed; use "S256"');
142+
}
143+
117144
if (!request.body.code_verifier) {
118145
throw new InvalidGrantError('Missing parameter: `code_verifier`');
119146
}
120147

148+
if (!pkce.codeChallengeMatchesABNF(request.body.code_verifier)) {
149+
throw new InvalidRequestError('Invalid parameter: `code_verifier` does not match the ABNF (RFC 7636 §4.1)');
150+
}
151+
121152
const hash = pkce.getHashForCodeChallenge({
122-
method: code.codeChallengeMethod,
123-
verifier: request.body.code_verifier
153+
verifier: request.body.code_verifier,
154+
method
124155
});
125156

126157
if (!hash) {
127-
// notice that we assume that codeChallengeMethod is already
128-
// checked at an earlier stage when being read from
129-
// request.body.code_challenge_method
130-
throw new ServerError('Server error: `getAuthorizationCode()` did not return a valid `codeChallengeMethod` property');
158+
throw new ServerError('Server error: no valid hash algorithm available to verify `code_verifier`');
131159
}
132160

133-
if (code.codeChallenge !== hash) {
161+
// xxx: Use timingSafeEqual to prevent against timing attacks when comparing
162+
// the hash of the code_verifier to the stored code_challenge.
163+
const hashesAreEqual = crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(code.codeChallenge));
164+
165+
if (!hashesAreEqual) {
134166
throw new InvalidGrantError('Invalid grant: code verifier is invalid');
135167
}
136168
}
@@ -140,8 +172,15 @@ class AuthorizationCodeGrantType extends AbstractGrantType {
140172
throw new InvalidGrantError('Invalid grant: code verifier is invalid');
141173
}
142174
}
175+
}
143176

144-
return code;
177+
getCodeChallengeMethod(method) {
178+
if (method) {
179+
return method;
180+
}
181+
// Per RFC 7636 §4.6, the default code challenge method is "plain".
182+
// However, plain PKCE is not secure, so we only allow it if explicitly enabled.
183+
return this.enablePlainPKCE ? 'plain' : 'S256';
145184
}
146185

147186
/**

lib/handlers/authorize-handler.js

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class AuthorizeHandler {
6262
this.allowEmptyState = options.allowEmptyState;
6363
this.authenticateHandler = options.authenticateHandler || new AuthenticateHandler(options);
6464
this.authorizationCodeLifetime = options.authorizationCodeLifetime;
65+
this.enablePlainPKCE = options.enablePlainPKCE === true;
6566
this.model = options.model;
6667
}
6768

@@ -371,9 +372,14 @@ class AuthorizeHandler {
371372
}
372373

373374
/**
374-
* Get code challenge method from request or defaults to plain.
375-
* https://www.rfc-editor.org/rfc/rfc7636#section-4.3
375+
* Get code challenge method from request.
376376
*
377+
* When `enablePlainPKCE` is false (the default), the "plain" method is
378+
* rejected and the default (when no method is provided) is "S256".
379+
* When `enablePlainPKCE` is true, "plain" is accepted and used as the
380+
* default per RFC 7636 §4.3.
381+
*
382+
* @see https://www.rfc-editor.org/rfc/rfc7636#section-4.3
377383
* @throws {InvalidRequestError} if request contains unsupported code_challenge_method
378384
* (see https://www.rfc-editor.org/rfc/rfc7636#section-4.4)
379385
*/
@@ -384,7 +390,19 @@ class AuthorizeHandler {
384390
throw new InvalidRequestError(`Invalid request: transform algorithm '${algorithm}' not supported`);
385391
}
386392

387-
return algorithm || 'plain';
393+
if (!this.enablePlainPKCE && algorithm === 'plain') {
394+
throw new InvalidRequestError('Invalid request: `code_challenge_method` "plain" is not allowed; use "S256"');
395+
}
396+
397+
// return the verified algorithm, if provided
398+
if (algorithm) {
399+
return algorithm;
400+
}
401+
402+
// otherwise, return the default algorithm based on the value of `enablePlainPKCE`
403+
// which enables extended hardening by default, while
404+
// optionally enable legacy support for the "plain" method per RFC 7636 §4.3
405+
return this.enablePlainPKCE ? 'plain' : 'S256';
388406
}
389407
}
390408

lib/handlers/token-handler.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ class TokenHandler {
6161
this.allowExtendedTokenAttributes = options.allowExtendedTokenAttributes;
6262
this.requireClientAuthentication = options.requireClientAuthentication || {};
6363
this.alwaysIssueNewRefreshToken = options.alwaysIssueNewRefreshToken !== false;
64+
this.enablePlainPKCE = options.enablePlainPKCE === true;
6465
}
6566

6667
/**
@@ -229,7 +230,8 @@ class TokenHandler {
229230
accessTokenLifetime: accessTokenLifetime,
230231
model: this.model,
231232
refreshTokenLifetime: refreshTokenLifetime,
232-
alwaysIssueNewRefreshToken: this.alwaysIssueNewRefreshToken
233+
alwaysIssueNewRefreshToken: this.alwaysIssueNewRefreshToken,
234+
enablePlainPKCE: this.enablePlainPKCE === true
233235
};
234236

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

lib/pkce/pkce.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,17 @@
55
*/
66
const { base64URLEncode } = require('../utils/string-util');
77
const { createHash } = require('../utils/crypto-util');
8-
const codeChallengeRegexp = /^([a-zA-Z0-9.\-_~]){43,128}$/;
8+
9+
/**
10+
* ABNF for "code_verifier" and "code_challenge" is as follows.
11+
*
12+
* code-verifier = 43*128unreserved
13+
* unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
14+
* ALPHA = %x41-5A / %x61-7A
15+
* DIGIT = %x30-39
16+
* @type {RegExp}
17+
*/
18+
const codeChallengeAndVerifierRegexp = /^([\u0041-\u005a\u0061-\u007A0-9.\-_~]){43,128}$/;
919

1020
/**
1121
* @module pkce
@@ -48,8 +58,7 @@ function getHashForCodeChallenge({ method, verifier }) {
4858
* @return {Boolean}
4959
*/
5060
function codeChallengeMatchesABNF (codeChallenge) {
51-
return typeof codeChallenge === 'string' &&
52-
!!codeChallenge.match(codeChallengeRegexp);
61+
return typeof codeChallenge === 'string' && codeChallengeAndVerifierRegexp.test(codeChallenge);
5362
}
5463

5564

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.requireClientAuthentication=object] {object|boolean} Require a client secret for grant types (names as keys). Defaults to `true` for all grant types.
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.
47+
* @param [options.enablePlainPKCE=false] {boolean} Allow the use of the `plain` code challenge method for PKCE. This is not recommended for production environments.
4748
*
4849
* @throws {InvalidArgumentError} if the model is missing
4950
* @return {OAuth2Server} A new `OAuth2Server` instance.

0 commit comments

Comments
 (0)