Skip to content

Commit ef467c9

Browse files
authored
Merge commit from fork
fix: multiple PKCE vulnerabilities addressed
2 parents 79b7cf5 + 8a35509 commit ef467c9

File tree

10 files changed

+910
-174
lines changed

10 files changed

+910
-174
lines changed

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

Lines changed: 56 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,49 @@ 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`');
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+
if (!this.hashesAreEqual(hash, code.codeChallenge)) {
134164
throw new InvalidGrantError('Invalid grant: code verifier is invalid');
135165
}
136166
}
@@ -140,8 +170,24 @@ class AuthorizationCodeGrantType extends AbstractGrantType {
140170
throw new InvalidGrantError('Invalid grant: code verifier is invalid');
141171
}
142172
}
173+
}
143174

144-
return code;
175+
hashesAreEqual(trusted, untrusted) {
176+
const trustedBuf = Buffer.isBuffer(trusted) ? trusted : Buffer.from(trusted);
177+
const untrustedBuf = Buffer.isBuffer(untrusted) ? untrusted : Buffer.from(untrusted);
178+
const equalLength = trustedBuf.byteLength === untrustedBuf.byteLength;
179+
// if the buffers are the same length, compare them,
180+
// otherwise only compare with the trusted buffer but return false anyway
181+
return crypto.timingSafeEqual(trustedBuf, equalLength ? untrustedBuf : trustedBuf) && equalLength;
182+
}
183+
184+
getCodeChallengeMethod(method) {
185+
if (method) {
186+
return method;
187+
}
188+
// Per RFC 7636 §4.6, the default code challenge method is "plain".
189+
// However, plain PKCE is not secure, so we only allow it if explicitly enabled.
190+
return this.enablePlainPKCE ? 'plain' : 'S256';
145191
}
146192

147193
/**

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.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@node-oauth/oauth2-server",
33
"description": "Complete, framework-agnostic, compliant and well tested module for implementing an OAuth2 Server in node.js",
4-
"version": "5.2.0",
4+
"version": "5.3.0",
55
"keywords": [
66
"oauth",
77
"oauth2"

0 commit comments

Comments
 (0)