Skip to content

Commit a4af0e9

Browse files
authored
feature(pkce): added pkce support
Merge pull request #86 from node-oauth/feature-pkce Contributors: @martinssonj @jankapunkt @Uzlopak
2 parents 848b0bb + b799985 commit a4af0e9

13 files changed

+716
-10
lines changed

index.d.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ declare namespace OAuth2Server {
306306
*
307307
*/
308308
saveAuthorizationCode(
309-
code: Pick<AuthorizationCode, 'authorizationCode' | 'expiresAt' | 'redirectUri' | 'scope'>,
309+
code: Pick<AuthorizationCode, 'authorizationCode' | 'expiresAt' | 'redirectUri' | 'scope' | 'codeChallenge' | 'codeChallengeMethod'>,
310310
client: Client,
311311
user: User,
312312
callback?: Callback<AuthorizationCode>): Promise<AuthorizationCode | Falsey>;
@@ -410,6 +410,8 @@ declare namespace OAuth2Server {
410410
scope?: string | string[] | undefined;
411411
client: Client;
412412
user: User;
413+
codeChallenge?: string;
414+
codeChallengeMethod?: string;
413415
[key: string]: any;
414416
}
415417

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

+31
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const promisify = require('promisify-any').use(Promise);
1313
const ServerError = require('../errors/server-error');
1414
const isFormat = require('@node-oauth/formats');
1515
const util = require('util');
16+
const pkce = require('../pkce/pkce');
1617

1718
/**
1819
* Constructor.
@@ -118,6 +119,36 @@ AuthorizationCodeGrantType.prototype.getAuthorizationCode = function(request, cl
118119
throw new InvalidGrantError('Invalid grant: `redirect_uri` is not a valid URI');
119120
}
120121

122+
// optional: PKCE code challenge
123+
124+
if (code.codeChallenge) {
125+
if (!request.body.code_verifier) {
126+
throw new InvalidGrantError('Missing parameter: `code_verifier`');
127+
}
128+
129+
const hash = pkce.getHashForCodeChallenge({
130+
method: code.codeChallengeMethod,
131+
verifier: request.body.code_verifier
132+
});
133+
134+
if (!hash) {
135+
// notice that we assume that codeChallengeMethod is already
136+
// checked at an earlier stage when being read from
137+
// request.body.code_challenge_method
138+
throw new ServerError('Server error: `getAuthorizationCode()` did not return a valid `codeChallengeMethod` property');
139+
}
140+
141+
if (code.codeChallenge !== hash) {
142+
throw new InvalidGrantError('Invalid grant: code verifier is invalid');
143+
}
144+
}
145+
else {
146+
if (request.body.code_verifier) {
147+
// No code challenge but code_verifier was passed in.
148+
throw new InvalidGrantError('Invalid grant: code verifier is invalid');
149+
}
150+
}
151+
121152
return code;
122153
});
123154
};

lib/handlers/authorize-handler.js

+34-3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const UnauthorizedClientError = require('../errors/unauthorized-client-error');
2121
const isFormat = require('@node-oauth/formats');
2222
const tokenUtil = require('../utils/token-util');
2323
const url = require('url');
24+
const pkce = require('../pkce/pkce');
2425

2526
/**
2627
* Response types.
@@ -110,8 +111,10 @@ AuthorizeHandler.prototype.handle = function(request, response) {
110111
})
111112
.then(function(authorizationCode) {
112113
ResponseType = this.getResponseType(request);
114+
const codeChallenge = this.getCodeChallenge(request);
115+
const codeChallengeMethod = this.getCodeChallengeMethod(request);
113116

114-
return this.saveAuthorizationCode(authorizationCode, expiresAt, scope, client, uri, user);
117+
return this.saveAuthorizationCode(authorizationCode, expiresAt, scope, client, uri, user, codeChallenge, codeChallengeMethod);
115118
})
116119
.then(function(code) {
117120
const responseType = new ResponseType(code.authorizationCode);
@@ -289,13 +292,20 @@ AuthorizeHandler.prototype.getRedirectUri = function(request, client) {
289292
* Save authorization code.
290293
*/
291294

292-
AuthorizeHandler.prototype.saveAuthorizationCode = function(authorizationCode, expiresAt, scope, client, redirectUri, user) {
293-
const code = {
295+
AuthorizeHandler.prototype.saveAuthorizationCode = function(authorizationCode, expiresAt, scope, client, redirectUri, user, codeChallenge, codeChallengeMethod) {
296+
let code = {
294297
authorizationCode: authorizationCode,
295298
expiresAt: expiresAt,
296299
redirectUri: redirectUri,
297300
scope: scope
298301
};
302+
303+
if(codeChallenge && codeChallengeMethod){
304+
code = Object.assign({
305+
codeChallenge: codeChallenge,
306+
codeChallengeMethod: codeChallengeMethod
307+
}, code);
308+
}
299309
return promisify(this.model.saveAuthorizationCode, 3).call(this.model, code, client, user);
300310
};
301311

@@ -365,6 +375,27 @@ AuthorizeHandler.prototype.updateResponse = function(response, redirectUri, stat
365375
response.redirect(url.format(redirectUri));
366376
};
367377

378+
AuthorizeHandler.prototype.getCodeChallenge = function(request) {
379+
return request.body.code_challenge;
380+
};
381+
382+
/**
383+
* Get code challenge method from request or defaults to plain.
384+
* https://www.rfc-editor.org/rfc/rfc7636#section-4.3
385+
*
386+
* @throws {InvalidRequestError} if request contains unsupported code_challenge_method
387+
* (see https://www.rfc-editor.org/rfc/rfc7636#section-4.4)
388+
*/
389+
AuthorizeHandler.prototype.getCodeChallengeMethod = function(request) {
390+
const algorithm = request.body.code_challenge_method;
391+
392+
if (algorithm && !pkce.isValidMethod(algorithm)) {
393+
throw new InvalidRequestError(`Invalid request: transform algorithm '${algorithm}' not supported`);
394+
}
395+
396+
return algorithm || 'plain';
397+
};
398+
368399
/**
369400
* Export constructor.
370401
*/

lib/handlers/token-handler.js

+11-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const TokenModel = require('../models/token-model');
1818
const UnauthorizedClientError = require('../errors/unauthorized-client-error');
1919
const UnsupportedGrantTypeError = require('../errors/unsupported-grant-type-error');
2020
const auth = require('basic-auth');
21+
const pkce = require('../pkce/pkce');
2122
const isFormat = require('@node-oauth/formats');
2223

2324
/**
@@ -114,12 +115,14 @@ TokenHandler.prototype.handle = function(request, response) {
114115
TokenHandler.prototype.getClient = function(request, response) {
115116
const credentials = this.getClientCredentials(request);
116117
const grantType = request.body.grant_type;
118+
const codeVerifier = request.body.code_verifier;
119+
const isPkce = pkce.isPKCERequest({ grantType, codeVerifier });
117120

118121
if (!credentials.clientId) {
119122
throw new InvalidRequestError('Missing parameter: `client_id`');
120123
}
121124

122-
if (this.isClientAuthenticationRequired(grantType) && !credentials.clientSecret) {
125+
if (this.isClientAuthenticationRequired(grantType) && !credentials.clientSecret && !isPkce) {
123126
throw new InvalidRequestError('Missing parameter: `client_secret`');
124127
}
125128

@@ -174,6 +177,7 @@ TokenHandler.prototype.getClient = function(request, response) {
174177
TokenHandler.prototype.getClientCredentials = function(request) {
175178
const credentials = auth(request);
176179
const grantType = request.body.grant_type;
180+
const codeVerifier = request.body.code_verifier;
177181

178182
if (credentials) {
179183
return { clientId: credentials.name, clientSecret: credentials.pass };
@@ -183,6 +187,12 @@ TokenHandler.prototype.getClientCredentials = function(request) {
183187
return { clientId: request.body.client_id, clientSecret: request.body.client_secret };
184188
}
185189

190+
if (pkce.isPKCERequest({ grantType, codeVerifier })) {
191+
if(request.body.client_id) {
192+
return { clientId: request.body.client_id };
193+
}
194+
}
195+
186196
if (!this.isClientAuthenticationRequired(grantType)) {
187197
if(request.body.client_id) {
188198
return { clientId: request.body.client_id };

lib/pkce/pkce.js

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
'use strict';
2+
3+
/**
4+
* Module dependencies.
5+
*/
6+
const { base64URLEncode } = require('../utils/string-util');
7+
const { createHash } = require('../utils/crypto-util');
8+
const codeChallengeRegexp = /^([a-zA-Z0-9.\-_~]){43,128}$/;
9+
/**
10+
* Export `TokenUtil`.
11+
*/
12+
13+
const pkce = {
14+
/**
15+
* Return hash for code-challenge method-type.
16+
*
17+
* @param method {String} the code challenge method
18+
* @param verifier {String} the code_verifier
19+
* @return {String|undefined}
20+
*/
21+
getHashForCodeChallenge: function({ method, verifier }) {
22+
// to prevent undesired side-effects when passing some wird values
23+
// to createHash or base64URLEncode we first check if the values are right
24+
if (pkce.isValidMethod(method) && typeof verifier === 'string' && verifier.length > 0) {
25+
if (method === 'plain') {
26+
return verifier;
27+
}
28+
29+
if (method === 'S256') {
30+
const hash = createHash({ data: verifier });
31+
return base64URLEncode(hash);
32+
}
33+
}
34+
},
35+
36+
/**
37+
* Check if the request is a PCKE request. We assume PKCE if grant type is
38+
* 'authorization_code' and code verifier is present.
39+
*
40+
* @param grantType {String}
41+
* @param codeVerifier {String}
42+
* @return {boolean}
43+
*/
44+
isPKCERequest: function ({ grantType, codeVerifier }) {
45+
return grantType === 'authorization_code' && !!codeVerifier;
46+
},
47+
48+
/**
49+
* Matches a code verifier (or code challenge) against the following criteria:
50+
*
51+
* code-verifier = 43*128unreserved
52+
* unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
53+
* ALPHA = %x41-5A / %x61-7A
54+
* DIGIT = %x30-39
55+
*
56+
* @see: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
57+
* @param codeChallenge {String}
58+
* @return {Boolean}
59+
*/
60+
codeChallengeMatchesABNF: function (codeChallenge) {
61+
return typeof codeChallenge === 'string' &&
62+
!!codeChallenge.match(codeChallengeRegexp);
63+
},
64+
65+
/**
66+
* Checks if the code challenge method is one of the supported methods
67+
* 'sha256' or 'plain'
68+
*
69+
* @param method {String}
70+
* @return {boolean}
71+
*/
72+
isValidMethod: function (method) {
73+
return method === 'S256' || method === 'plain';
74+
}
75+
};
76+
77+
module.exports = pkce;

lib/utils/crypto-util.js

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use strict';
2+
3+
const crypto = require('crypto');
4+
5+
/**
6+
* Export `StringUtil`.
7+
*/
8+
9+
module.exports = {
10+
/**
11+
*
12+
* @param algorithm {String} the hash algorithm, default is 'sha256'
13+
* @param data {Buffer|String|TypedArray|DataView} the data to hash
14+
* @param encoding {String|undefined} optional, the encoding to calculate the
15+
* digest
16+
* @return {Buffer|String} if {encoding} undefined a {Buffer} is returned, otherwise a {String}
17+
*/
18+
createHash: function({ algorithm = 'sha256', data = undefined, encoding = undefined }) {
19+
return crypto
20+
.createHash(algorithm)
21+
.update(data)
22+
.digest(encoding);
23+
}
24+
};

lib/utils/string-util.js

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
'use strict';
2+
3+
/**
4+
* Export `StringUtil`.
5+
*/
6+
7+
module.exports = {
8+
/**
9+
*
10+
* @param str
11+
* @return {string}
12+
*/
13+
base64URLEncode: function(str) {
14+
return str.toString('base64')
15+
.replace(/\+/g, '-')
16+
.replace(/\//g, '_')
17+
.replace(/=/g, '');
18+
}
19+
};

lib/utils/token-util.js

+2-5
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
* Module dependencies.
55
*/
66

7-
const crypto = require('crypto');
87
const randomBytes = require('bluebird').promisify(require('crypto').randomBytes);
8+
const { createHash } = require('../utils/crypto-util');
99

1010
/**
1111
* Export `TokenUtil`.
@@ -19,10 +19,7 @@ module.exports = {
1919

2020
generateRandomToken: function() {
2121
return randomBytes(256).then(function(buffer) {
22-
return crypto
23-
.createHash('sha256')
24-
.update(buffer)
25-
.digest('hex');
22+
return createHash({ data: buffer, encoding: 'hex' });
2623
});
2724
}
2825

test/integration/handlers/authorize-handler_test.js

+63
Original file line numberDiff line numberDiff line change
@@ -1301,4 +1301,67 @@ describe('AuthorizeHandler integration', function() {
13011301
response.get('location').should.equal('http://example.com/cb?state=foobar');
13021302
});
13031303
});
1304+
1305+
describe('getCodeChallengeMethod()', function() {
1306+
it('should get code challenge method', function() {
1307+
const model = {
1308+
getAccessToken: function() {},
1309+
getClient: function() {},
1310+
saveAuthorizationCode: function() {}
1311+
};
1312+
const handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model: model });
1313+
const request = new Request({ body: {code_challenge_method: 'S256'}, headers: {}, method: {}, query: {} });
1314+
1315+
const codeChallengeMethod = handler.getCodeChallengeMethod(request);
1316+
codeChallengeMethod.should.equal('S256');
1317+
});
1318+
1319+
it('should throw if the code challenge method is not supported', async function () {
1320+
const model = {
1321+
getAccessToken: function() {},
1322+
getClient: function() {},
1323+
saveAuthorizationCode: function() {}
1324+
};
1325+
const handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model: model });
1326+
const request = new Request({ body: {code_challenge_method: 'foo'}, headers: {}, method: {}, query: {} });
1327+
1328+
try {
1329+
handler.getCodeChallengeMethod(request);
1330+
1331+
should.fail();
1332+
} catch (e) {
1333+
// defined in RFC 7636 - 4.4
1334+
e.should.be.an.instanceOf(InvalidRequestError);
1335+
e.message.should.equal('Invalid request: transform algorithm \'foo\' not supported');
1336+
}
1337+
});
1338+
1339+
it('should get default code challenge method plain if missing', function() {
1340+
const model = {
1341+
getAccessToken: function() {},
1342+
getClient: function() {},
1343+
saveAuthorizationCode: function() {}
1344+
};
1345+
const handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model: model });
1346+
const request = new Request({ body: {}, headers: {}, method: {}, query: {} });
1347+
1348+
const codeChallengeMethod = handler.getCodeChallengeMethod(request);
1349+
codeChallengeMethod.should.equal('plain');
1350+
});
1351+
});
1352+
1353+
describe('getCodeChallenge()', function() {
1354+
it('should get code challenge', function() {
1355+
const model = {
1356+
getAccessToken: function() {},
1357+
getClient: function() {},
1358+
saveAuthorizationCode: function() {}
1359+
};
1360+
const handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model: model });
1361+
const request = new Request({ body: {code_challenge: 'challenge'}, headers: {}, method: {}, query: {} });
1362+
1363+
const codeChallengeMethod = handler.getCodeChallenge(request);
1364+
codeChallengeMethod.should.equal('challenge');
1365+
});
1366+
});
13041367
});

0 commit comments

Comments
 (0)