Skip to content

Commit d40173d

Browse files
committed
PKCE - implementation
1 parent e1f741f commit d40173d

File tree

11 files changed

+426
-25
lines changed

11 files changed

+426
-25
lines changed

lib/grant-types/abstract-grant-type.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ function AbstractGrantType(options) {
3030
this.model = options.model;
3131
this.refreshTokenLifetime = options.refreshTokenLifetime;
3232
this.alwaysIssueNewRefreshToken = options.alwaysIssueNewRefreshToken;
33+
this.PKCEEnabled = options.PKCEEnabled;
3334
}
3435

3536
/**

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ var InvalidArgumentError = require('../errors/invalid-argument-error');
99
var InvalidGrantError = require('../errors/invalid-grant-error');
1010
var InvalidRequestError = require('../errors/invalid-request-error');
1111
var Promise = require('bluebird');
12+
var crypto = require('crypto');
1213
var promisify = require('promisify-any').use(Promise);
1314
var ServerError = require('../errors/server-error');
1415
var is = require('../validator/is');
1516
var util = require('util');
17+
var stringUtil = require('../utils/string-util');
1618

1719
/**
1820
* Constructor.
@@ -88,7 +90,9 @@ AuthorizationCodeGrantType.prototype.getAuthorizationCode = function(request, cl
8890
if (!is.vschar(request.body.code)) {
8991
throw new InvalidRequestError('Invalid parameter: `code`');
9092
}
93+
9194
return promisify(this.model.getAuthorizationCode, 1).call(this.model, request.body.code)
95+
.bind(this)
9296
.then(function(code) {
9397
if (!code) {
9498
throw new InvalidGrantError('Invalid grant: authorization code is invalid');
@@ -118,6 +122,28 @@ AuthorizationCodeGrantType.prototype.getAuthorizationCode = function(request, cl
118122
throw new InvalidGrantError('Invalid grant: `redirect_uri` is not a valid URI');
119123
}
120124

125+
if (this.PKCEEnabled && client.isPublic) {
126+
if (!code.codeChallenge) {
127+
throw new ServerError('Server error: `getAuthorizationCode()` did not return a `codeChallenge` property');
128+
}
129+
130+
if (!code.codeChallengeMethod) {
131+
throw new ServerError('Server error: `getAuthorizationCode()` did not return a `codeChallengeMethod` property');
132+
}
133+
134+
if (code.codeChallengeMethod === 'plain' && code.codeChallenge !== request.body.code_verifier) {
135+
throw new InvalidGrantError('Invalid grant: `code_verifier` is invalid');
136+
}
137+
138+
if (code.codeChallengeMethod === 'S256') {
139+
var hash = stringUtil.base64URLEncode(crypto.createHash('sha256').update(request.body.code_verifier).digest());
140+
141+
if (code.codeChallenge !== hash) {
142+
throw new InvalidGrantError('Invalid grant: `code_verifier` is invalid');
143+
}
144+
}
145+
}
146+
121147
return code;
122148
});
123149
};

lib/handlers/authorize-handler.js

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

@@ -95,18 +96,25 @@ AuthorizeHandler.prototype.handle = function(request, response) {
9596
var scope;
9697
var state;
9798
var ResponseType;
99+
var codeChallenge;
100+
var codeChallengeMethod;
98101

99102
return Promise.bind(this)
100-
.then(function() {
103+
.then(function() {
101104
scope = this.getScope(request);
105+
codeChallenge = this.getCodeChallenge(request, client);
102106

103-
return this.generateAuthorizationCode(client, user, scope);
104-
})
107+
if (codeChallenge) {
108+
codeChallengeMethod = this.getCodeChallengeMethod(request);
109+
}
110+
111+
return this.generateAuthorizationCode(client, user, scope);
112+
})
105113
.then(function(authorizationCode) {
106114
state = this.getState(request);
107115
ResponseType = this.getResponseType(request);
108116

109-
return this.saveAuthorizationCode(authorizationCode, expiresAt, scope, client, uri, user);
117+
return this.saveAuthorizationCode(authorizationCode, expiresAt, scope, client, uri, user, codeChallenge, codeChallengeMethod);
110118
})
111119
.then(function(code) {
112120
var responseType = new ResponseType(code.authorizationCode);
@@ -135,11 +143,31 @@ AuthorizeHandler.prototype.handle = function(request, response) {
135143

136144
AuthorizeHandler.prototype.generateAuthorizationCode = function(client, user, scope) {
137145
if (this.model.generateAuthorizationCode) {
138-
return promisify(this.model.generateAuthorizationCode).call(this.model, client, user, scope);
146+
return promisify(this.model.generateAuthorizationCode, 3).call(this.model, client, user, scope);
139147
}
140148
return tokenUtil.generateRandomToken();
141149
};
142150

151+
AuthorizeHandler.prototype.getCodeChallenge = function(request, client) {
152+
var codeChallenge = request.body.code_challenge || request.query.code_challenge;
153+
154+
if (this.PKCEEnabled && client.isPublic && _.isEmpty(codeChallenge)) {
155+
throw new InvalidRequestError('Missing parameter: `code_challenge`. Public clients must include a code_challenge');
156+
}
157+
158+
return codeChallenge;
159+
};
160+
161+
AuthorizeHandler.prototype.getCodeChallengeMethod = function(request) {
162+
var codeChallengeMethod = request.body.code_challenge_method || request.query.code_challenge_method || 'plain';
163+
164+
if (!_.includes(['S256', 'plain'], codeChallengeMethod)) {
165+
throw new InvalidRequestError('Invalid parameter: `code_challenge_method`');
166+
}
167+
168+
return codeChallengeMethod;
169+
};
170+
143171
/**
144172
* Get authorization code lifetime.
145173
*/
@@ -172,6 +200,7 @@ AuthorizeHandler.prototype.getClient = function(request) {
172200
throw new InvalidRequestError('Invalid request: `redirect_uri` is not a valid URI');
173201
}
174202
return promisify(this.model.getClient, 2).call(this.model, clientId, null)
203+
.bind(this)
175204
.then(function(client) {
176205
if (!client) {
177206
throw new InvalidClientError('Invalid client: client credentials are invalid');
@@ -192,6 +221,16 @@ AuthorizeHandler.prototype.getClient = function(request) {
192221
if (redirectUri && !_.includes(client.redirectUris, redirectUri)) {
193222
throw new InvalidClientError('Invalid client: `redirect_uri` does not match client value');
194223
}
224+
225+
if (this.PKCEEnabled) {
226+
if (client.isPublic === undefined) {
227+
throw new InvalidClientError('Invalid client: missing client `isPublic`');
228+
}
229+
230+
if (typeof client.isPublic !== 'boolean') {
231+
throw new InvalidClientError('Invalid client: `isPublic` must be a boolean');
232+
}
233+
}
195234
return client;
196235
});
197236
};
@@ -257,12 +296,14 @@ AuthorizeHandler.prototype.getRedirectUri = function(request, client) {
257296
* Save authorization code.
258297
*/
259298

260-
AuthorizeHandler.prototype.saveAuthorizationCode = function(authorizationCode, expiresAt, scope, client, redirectUri, user) {
299+
AuthorizeHandler.prototype.saveAuthorizationCode = function(authorizationCode, expiresAt, scope, client, redirectUri, user, codeChallenge, codeChallengeMethod) {
261300
var code = {
262301
authorizationCode: authorizationCode,
263302
expiresAt: expiresAt,
264303
redirectUri: redirectUri,
265-
scope: scope
304+
scope: scope,
305+
codeChallenge: codeChallenge,
306+
codeChallengeMethod: codeChallengeMethod
266307
};
267308
return promisify(this.model.saveAuthorizationCode, 3).call(this.model, code, client, user);
268309
};

lib/handlers/token-handler.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ function TokenHandler(options) {
6262
this.allowExtendedTokenAttributes = options.allowExtendedTokenAttributes;
6363
this.requireClientAuthentication = options.requireClientAuthentication || {};
6464
this.alwaysIssueNewRefreshToken = options.alwaysIssueNewRefreshToken !== false;
65+
this.PKCEEnabled = options.PKCEEnabled;
6566
}
6667

6768
/**
@@ -133,6 +134,7 @@ TokenHandler.prototype.getClient = function(request, response) {
133134
}
134135

135136
return promisify(this.model.getClient, 2).call(this.model, credentials.clientId, credentials.clientSecret)
137+
.bind(this)
136138
.then(function(client) {
137139
if (!client) {
138140
throw new InvalidClientError('Invalid client: client is invalid');
@@ -146,6 +148,16 @@ TokenHandler.prototype.getClient = function(request, response) {
146148
throw new ServerError('Server error: `grants` must be an array');
147149
}
148150

151+
if (this.PKCEEnabled) {
152+
if (client.isPublic === undefined) {
153+
throw new ServerError('Server error: missing client `isPublic`');
154+
}
155+
156+
if (typeof client.isPublic !== 'boolean') {
157+
throw new ServerError('Server error: invalid client, `isPublic` must be a boolean');
158+
}
159+
}
160+
149161
return client;
150162
})
151163
.catch(function(e) {
@@ -224,7 +236,8 @@ TokenHandler.prototype.handleGrantType = function(request, client) {
224236
accessTokenLifetime: accessTokenLifetime,
225237
model: this.model,
226238
refreshTokenLifetime: refreshTokenLifetime,
227-
alwaysIssueNewRefreshToken: this.alwaysIssueNewRefreshToken
239+
alwaysIssueNewRefreshToken: this.alwaysIssueNewRefreshToken,
240+
PKCEenabled: this.PKCEenabled
228241
};
229242

230243
return new Type(options)

lib/server.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ OAuth2Server.prototype.authenticate = function(request, response, options, callb
5151
OAuth2Server.prototype.authorize = function(request, response, options, callback) {
5252
options = _.assign({
5353
allowEmptyState: false,
54-
authorizationCodeLifetime: 5 * 60 // 5 minutes.
54+
authorizationCodeLifetime: 5 * 60, // 5 minutes.
55+
PKCEEnabled: false
5556
}, this.options, options);
5657

5758
return new AuthorizeHandler(options)
@@ -68,7 +69,8 @@ OAuth2Server.prototype.token = function(request, response, options, callback) {
6869
accessTokenLifetime: 60 * 60, // 1 hour.
6970
refreshTokenLifetime: 60 * 60 * 24 * 14, // 2 weeks.
7071
allowExtendedTokenAttributes: false,
71-
requireClientAuthentication: {} // defaults to true for all grant types
72+
requireClientAuthentication: {}, // defaults to true for all grant types
73+
PKCEEnabled: false
7274
}, this.options, options);
7375

7476
return new TokenHandler(options)

lib/utils/string-util.js

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

package.json

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,32 @@
77
"oauth2"
88
],
99
"contributors": [
10-
{ "name": "Thom Seddon", "email": "thom@seddonmedia.co.uk" },
11-
{ "name": "Lars F. Karlström" , "email": "lars@lfk.io" },
12-
{ "name": "Rui Marinho", "email": "ruipmarinho@gmail.com" },
13-
{ "name" : "Tiago Ribeiro", "email": "tiago.ribeiro@gmail.com" },
14-
{ "name": "Michael Salinger", "email": "mjsalinger@gmail.com" },
15-
{ "name": "Nuno Sousa" },
16-
{ "name": "Max Truxa" }
10+
{
11+
"name": "Thom Seddon",
12+
"email": "thom@seddonmedia.co.uk"
13+
},
14+
{
15+
"name": "Lars F. Karlström",
16+
"email": "lars@lfk.io"
17+
},
18+
{
19+
"name": "Rui Marinho",
20+
"email": "ruipmarinho@gmail.com"
21+
},
22+
{
23+
"name": "Tiago Ribeiro",
24+
"email": "tiago.ribeiro@gmail.com"
25+
},
26+
{
27+
"name": "Michael Salinger",
28+
"email": "mjsalinger@gmail.com"
29+
},
30+
{
31+
"name": "Nuno Sousa"
32+
},
33+
{
34+
"name": "Max Truxa"
35+
}
1736
],
1837
"main": "index.js",
1938
"dependencies": {

0 commit comments

Comments
 (0)