Skip to content

Commit 920e642

Browse files
committed
PKCE - implementation
1 parent e1f741f commit 920e642

File tree

6 files changed

+253
-10
lines changed

6 files changed

+253
-10
lines changed

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

Lines changed: 20 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.
@@ -118,6 +120,24 @@ AuthorizationCodeGrantType.prototype.getAuthorizationCode = function(request, cl
118120
throw new InvalidGrantError('Invalid grant: `redirect_uri` is not a valid URI');
119121
}
120122

123+
if (code.codeChallenge) {
124+
if (!code.codeChallengeMethod) {
125+
throw new ServerError('Server error: `getAuthorizationCode()` did not return a `codeChallengeMethod` property');
126+
}
127+
128+
if (code.codeChallengeMethod === 'plain' && code.codeChallenge !== request.body.code_verifier) {
129+
throw new InvalidGrantError('Invalid grant: `code_verifier` is invalid');
130+
}
131+
132+
if (code.codeChallengeMethod === 'S256') {
133+
var hash = stringUtil.base64URLEncode(crypto.createHash('sha256').update(request.body.code_verifier).digest());
134+
135+
if (code.codeChallenge !== hash) {
136+
throw new InvalidGrantError('Invalid grant: `code_verifier` is invalid');
137+
}
138+
}
139+
}
140+
121141
return code;
122142
});
123143
};

lib/handlers/authorize-handler.js

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,18 +95,25 @@ AuthorizeHandler.prototype.handle = function(request, response) {
9595
var scope;
9696
var state;
9797
var ResponseType;
98+
var codeChallenge;
99+
var codeChallengeMethod;
98100

99101
return Promise.bind(this)
100-
.then(function() {
102+
.then(function() {
101103
scope = this.getScope(request);
104+
codeChallenge = this.getCodeChallenge(request);
102105

103-
return this.generateAuthorizationCode(client, user, scope);
104-
})
106+
if (codeChallenge) {
107+
codeChallengeMethod = this.getCodeChallengeMethod(request) || 'plain';
108+
}
109+
110+
return this.generateAuthorizationCode(client, user, scope, codeChallenge, codeChallengeMethod);
111+
})
105112
.then(function(authorizationCode) {
106113
state = this.getState(request);
107114
ResponseType = this.getResponseType(request);
108115

109-
return this.saveAuthorizationCode(authorizationCode, expiresAt, scope, client, uri, user);
116+
return this.saveAuthorizationCode(authorizationCode, expiresAt, scope, client, uri, user, codeChallenge, codeChallengeMethod);
110117
})
111118
.then(function(code) {
112119
var responseType = new ResponseType(code.authorizationCode);
@@ -133,13 +140,29 @@ AuthorizeHandler.prototype.handle = function(request, response) {
133140
* Generate authorization code.
134141
*/
135142

136-
AuthorizeHandler.prototype.generateAuthorizationCode = function(client, user, scope) {
143+
AuthorizeHandler.prototype.generateAuthorizationCode = function(client, user, scope, codeChallenge, codeChallengeMethod) {
137144
if (this.model.generateAuthorizationCode) {
138-
return promisify(this.model.generateAuthorizationCode).call(this.model, client, user, scope);
145+
return promisify(this.model.generateAuthorizationCode, 5).call(this.model, client, user, scope, codeChallenge, codeChallengeMethod);
139146
}
140147
return tokenUtil.generateRandomToken();
141148
};
142149

150+
AuthorizeHandler.prototype.getCodeChallenge = function(request) {
151+
var codeChallenge = request.body.code_challenge || request.query.code_challenge;
152+
153+
return codeChallenge;
154+
};
155+
156+
AuthorizeHandler.prototype.getCodeChallengeMethod = function(request) {
157+
var codeChallengeMethod = request.body.code_challenge_method || request.query.code_challenge_method;
158+
159+
if (codeChallengeMethod && !_.includes(['S256', 'plain'], codeChallengeMethod)) {
160+
throw new InvalidRequestError('Invalid parameter: `code_challenge_method`');
161+
}
162+
163+
return codeChallengeMethod;
164+
};
165+
143166
/**
144167
* Get authorization code lifetime.
145168
*/
@@ -257,12 +280,14 @@ AuthorizeHandler.prototype.getRedirectUri = function(request, client) {
257280
* Save authorization code.
258281
*/
259282

260-
AuthorizeHandler.prototype.saveAuthorizationCode = function(authorizationCode, expiresAt, scope, client, redirectUri, user) {
283+
AuthorizeHandler.prototype.saveAuthorizationCode = function(authorizationCode, expiresAt, scope, client, redirectUri, user, codeChallenge, codeChallengeMethod) {
261284
var code = {
262285
authorizationCode: authorizationCode,
263286
expiresAt: expiresAt,
264287
redirectUri: redirectUri,
265-
scope: scope
288+
scope: scope,
289+
codeChallenge: codeChallenge,
290+
codeChallengeMethod: codeChallengeMethod
266291
};
267292
return promisify(this.model.saveAuthorizationCode, 3).call(this.model, code, client, user);
268293
};

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+
};

test/integration/grant-types/authorization-code-grant-type_test.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ var InvalidArgumentError = require('../../../lib/errors/invalid-argument-error')
99
var InvalidGrantError = require('../../../lib/errors/invalid-grant-error');
1010
var InvalidRequestError = require('../../../lib/errors/invalid-request-error');
1111
var Promise = require('bluebird');
12+
var crypto = require('crypto');
1213
var Request = require('../../../lib/request');
1314
var ServerError = require('../../../lib/errors/server-error');
15+
var stringUtil = require('../../../lib/utils/string-util');
1416
var should = require('should');
1517

1618
/**
@@ -361,6 +363,110 @@ describe('AuthorizationCodeGrantType integration', function() {
361363
});
362364
});
363365

366+
it('should throw an error if the `code_verifier` is invalid', function() {
367+
var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32));
368+
var authorizationCode = {
369+
authorizationCode: 12345,
370+
client: { id: 'foobar' },
371+
expiresAt: new Date(new Date() * 2),
372+
user: {},
373+
codeChallengeMethod: 'S256',
374+
codeChallenge: stringUtil.base64URLEncode(crypto.createHash('sha256').update(codeVerifier).digest())
375+
};
376+
var client = { id: 'foobar' };
377+
var model = {
378+
getAuthorizationCode: function() { return authorizationCode; },
379+
revokeAuthorizationCode: function() {},
380+
saveToken: function() {}
381+
};
382+
var grantType = new AuthorizationCodeGrantType({ accessTokenLifetime: 123, model: model });
383+
var request = new Request({ body: { code: 12345, code_verifier: 'foo' }, headers: {}, method: {}, query: {} });
384+
385+
return grantType.getAuthorizationCode(request, client)
386+
.then(should.fail)
387+
.catch(function(e) {
388+
e.should.be.an.instanceOf(InvalidGrantError);
389+
e.message.should.equal('Invalid grant: `code_verifier` is invalid');
390+
});
391+
});
392+
393+
it('should throw an error if the `code_verifier` is invalid', function() {
394+
var authorizationCode = {
395+
authorizationCode: 12345,
396+
client: { id: 'foobar' },
397+
expiresAt: new Date(new Date() * 2),
398+
user: {},
399+
codeChallengeMethod: 'plain',
400+
codeChallenge: 'baz'
401+
};
402+
var client = { id: 'foobar' };
403+
var model = {
404+
getAuthorizationCode: function() { return authorizationCode; },
405+
revokeAuthorizationCode: function() {},
406+
saveToken: function() {}
407+
};
408+
var grantType = new AuthorizationCodeGrantType({ accessTokenLifetime: 123, model: model });
409+
var request = new Request({ body: { code: 12345, code_verifier: 'foo' }, headers: {}, method: {}, query: {} });
410+
411+
return grantType.getAuthorizationCode(request, client)
412+
.then(should.fail)
413+
.catch(function(e) {
414+
e.should.be.an.instanceOf(InvalidGrantError);
415+
e.message.should.equal('Invalid grant: `code_verifier` is invalid');
416+
});
417+
});
418+
419+
it('should return an auth code when `code_verifier` is valid', function() {
420+
var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32));
421+
var authorizationCode = {
422+
authorizationCode: 12345,
423+
client: { id: 'foobar' },
424+
expiresAt: new Date(new Date() * 2),
425+
user: {},
426+
codeChallengeMethod: 'S256',
427+
codeChallenge: stringUtil.base64URLEncode(crypto.createHash('sha256').update(codeVerifier).digest())
428+
};
429+
var client = { id: 'foobar' };
430+
var model = {
431+
getAuthorizationCode: function() { return authorizationCode; },
432+
revokeAuthorizationCode: function() {},
433+
saveToken: function() {}
434+
};
435+
var grantType = new AuthorizationCodeGrantType({ accessTokenLifetime: 123, model: model });
436+
var request = new Request({ body: { code: 12345, code_verifier: codeVerifier }, headers: {}, method: {}, query: {} });
437+
438+
return grantType.getAuthorizationCode(request, client)
439+
.then(function(data) {
440+
data.should.equal(authorizationCode);
441+
})
442+
.catch(should.fail);
443+
});
444+
445+
it('should return an auth code when `code_verifier` is valid', function() {
446+
var authorizationCode = {
447+
authorizationCode: 12345,
448+
client: { id: 'foobar' },
449+
expiresAt: new Date(new Date() * 2),
450+
user: {},
451+
codeChallengeMethod: 'plain',
452+
codeChallenge: 'baz'
453+
};
454+
var client = { id: 'foobar' };
455+
var model = {
456+
getAuthorizationCode: function() { return authorizationCode; },
457+
revokeAuthorizationCode: function() {},
458+
saveToken: function() {}
459+
};
460+
var grantType = new AuthorizationCodeGrantType({ accessTokenLifetime: 123, model: model });
461+
var request = new Request({ body: { code: 12345, code_verifier: 'baz' }, headers: {}, method: {}, query: {} });
462+
463+
return grantType.getAuthorizationCode(request, client)
464+
.then(function(data) {
465+
data.should.equal(authorizationCode);
466+
})
467+
.catch(should.fail);
468+
});
469+
364470
it('should return an auth code', function() {
365471
var authorizationCode = { authorizationCode: 12345, client: { id: 'foobar' }, expiresAt: new Date(new Date() * 2), user: {} };
366472
var client = { id: 'foobar' };

test/integration/handlers/authorize-handler_test.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,84 @@ describe('AuthorizeHandler integration', function() {
824824
});
825825
});
826826

827+
describe('getCodeChallenge()', function() {
828+
describe('with `code_challenge` in the request body', function() {
829+
it('should return the code_challenge', function() {
830+
var model = {
831+
getAccessToken: function() {},
832+
getClient: function() {},
833+
saveAuthorizationCode: function() {}
834+
};
835+
var handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model: model });
836+
var request = new Request({ body: { code_challenge: 'foo' }, headers: {}, method: {}, query: {} });
837+
838+
handler.getCodeChallenge(request).should.equal('foo');
839+
});
840+
});
841+
842+
describe('with `code_challenge` in the request query', function() {
843+
it('should return the code_challenge', function() {
844+
var model = {
845+
getAccessToken: function() {},
846+
getClient: function() {},
847+
saveAuthorizationCode: function() {}
848+
};
849+
var handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model: model });
850+
var request = new Request({ body: {}, headers: {}, method: {}, query: { code_challenge: 'foo' } });
851+
852+
handler.getCodeChallenge(request).should.equal('foo');
853+
});
854+
});
855+
});
856+
857+
describe('getCodeChallengeMethod()', function() {
858+
it('should throw an error if `scope` is invalid', function() {
859+
var model = {
860+
getAccessToken: function() {},
861+
getClient: function() {},
862+
saveAuthorizationCode: function() {}
863+
};
864+
var handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model: model });
865+
var request = new Request({ body: { code_challenge_method: 'foo' }, headers: {}, method: {}, query: {} });
866+
867+
try {
868+
handler.getCodeChallengeMethod(request);
869+
870+
should.fail();
871+
} catch (e) {
872+
e.should.be.an.instanceOf(InvalidRequestError);
873+
e.message.should.equal('Invalid parameter: `code_challenge_method`');
874+
}
875+
});
876+
describe('with `code_challenge_method` in the request body', function() {
877+
it('should return the code_challenge_method', function() {
878+
var model = {
879+
getAccessToken: function() {},
880+
getClient: function() {},
881+
saveAuthorizationCode: function() {}
882+
};
883+
var handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model: model });
884+
var request = new Request({ body: { code_challenge_method: 'plain' }, headers: {}, method: {}, query: {} });
885+
886+
handler.getCodeChallengeMethod(request).should.equal('plain');
887+
});
888+
});
889+
890+
describe('with `code_challenge_method` in the request query', function() {
891+
it('should return the code_challenge_method', function() {
892+
var model = {
893+
getAccessToken: function() {},
894+
getClient: function() {},
895+
saveAuthorizationCode: function() {}
896+
};
897+
var handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model: model });
898+
var request = new Request({ body: {}, headers: {}, method: {}, query: { code_challenge_method: 'S256' } });
899+
900+
handler.getCodeChallengeMethod(request).should.equal('S256');
901+
});
902+
});
903+
});
904+
827905
describe('getState()', function() {
828906
it('should throw an error if `allowEmptyState` is false and `state` is missing', function() {
829907
var model = {

test/unit/handlers/authorize-handler_test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,11 @@ describe('AuthorizeHandler', function() {
8787
};
8888
var handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model: model });
8989

90-
return handler.saveAuthorizationCode('foo', 'bar', 'qux', 'biz', 'baz', 'boz')
90+
return handler.saveAuthorizationCode('foo', 'bar', 'qux', 'biz', 'baz', 'boz', 'buz', 'bez')
9191
.then(function() {
9292
model.saveAuthorizationCode.callCount.should.equal(1);
9393
model.saveAuthorizationCode.firstCall.args.should.have.length(3);
94-
model.saveAuthorizationCode.firstCall.args[0].should.eql({ authorizationCode: 'foo', expiresAt: 'bar', redirectUri: 'baz', scope: 'qux' });
94+
model.saveAuthorizationCode.firstCall.args[0].should.eql({ authorizationCode: 'foo', expiresAt: 'bar', redirectUri: 'baz', scope: 'qux', codeChallenge: 'buz', codeChallengeMethod: 'bez' });
9595
model.saveAuthorizationCode.firstCall.args[1].should.equal('biz');
9696
model.saveAuthorizationCode.firstCall.args[2].should.equal('boz');
9797
model.saveAuthorizationCode.firstCall.thisValue.should.equal(model);

0 commit comments

Comments
 (0)