From 26b13f83860048d97425263f6bde080197d89f1d Mon Sep 17 00:00:00 2001 From: Joseph Chen Date: Wed, 7 Oct 2020 22:42:27 -0700 Subject: [PATCH] Implement PKCE (https://tools.ietf.org/html/rfc7636). --- docs/model/spec.rst | 138 ++++++------ .../authorization-code-grant-type.ts | 29 +++ lib/handlers/token-handler.ts | 32 ++- .../authorization-code.interface.ts | 2 + lib/response-types/code-response-type.ts | 58 ++++- lib/utils/string-util.ts | 6 + package-lock.json | 8 +- package.json | 8 +- .../authorization-code-grant-type.spec.ts | 118 +++++++++++ .../handlers/token-handler.spec.ts | 198 ++++++++++++++++++ .../response-types/code-response-type.spec.ts | 57 +++++ test/unit/handlers/token-handler.spec.ts | 3 +- 12 files changed, 586 insertions(+), 71 deletions(-) create mode 100644 lib/utils/string-util.ts diff --git a/docs/model/spec.rst b/docs/model/spec.rst index 674f389a5..bc81cef47 100644 --- a/docs/model/spec.rst +++ b/docs/model/spec.rst @@ -336,25 +336,29 @@ This model function is **required** if the ``authorization_code`` grant is used. An ``Object`` representing the authorization code and associated data. -+--------------------+--------+--------------------------------------------------------------+ -| Name | Type | Description | -+====================+========+==============================================================+ -| code | Object | The return value. | -+--------------------+--------+--------------------------------------------------------------+ -| code.code | String | The authorization code passed to ``getAuthorizationCode()``. | -+--------------------+--------+--------------------------------------------------------------+ -| code.expiresAt | Date | The expiry time of the authorization code. | -+--------------------+--------+--------------------------------------------------------------+ -| [code.redirectUri] | String | The redirect URI of the authorization code. | -+--------------------+--------+--------------------------------------------------------------+ -| [code.scope] | String | The authorized scope of the authorization code. | -+--------------------+--------+--------------------------------------------------------------+ -| code.client | Object | The client associated with the authorization code. | -+--------------------+--------+--------------------------------------------------------------+ -| code.client.id | String | A unique string identifying the client. | -+--------------------+--------+--------------------------------------------------------------+ -| code.user | Object | The user associated with the authorization code. | -+--------------------+--------+--------------------------------------------------------------+ ++----------------------------+--------+---------------------------------------------------------------+ +| Name | Type | Description | ++============================+========+===============================================================+ +| code | Object | The return value. | ++----------------------------+--------+---------------------------------------------------------------+ +| code.code | String | The authorization code passed to ``getAuthorizationCode()``. | ++----------------------------+--------+---------------------------------------------------------------+ +| code.expiresAt | Date | The expiry time of the authorization code. | ++----------------------------+--------+---------------------------------------------------------------+ +| [code.redirectUri] | String | The redirect URI of the authorization code. | ++----------------------------+--------+---------------------------------------------------------------+ +| [code.scope] | String | The authorized scope of the authorization code. | ++----------------------------+--------+---------------------------------------------------------------+ +| code.client | Object | The client associated with the authorization code. | ++----------------------------+--------+---------------------------------------------------------------+ +| code.client.id | String | A unique string identifying the client. | ++----------------------------+--------+---------------------------------------------------------------+ +| code.user | Object | The user associated with the authorization code. | ++----------------------------+--------+---------------------------------------------------------------+ +| [code.codeChallenge] | String | The code challenge string used with PKCE (RFC7636). | ++----------------------------+--------+---------------------------------------------------------------+ +| [code.codeChallengeMethod] | String | The string for the code challenge method (`S256` or `plain`). | ++----------------------------+--------+---------------------------------------------------------------+ ``code.client`` and ``code.user`` can carry additional properties that will be ignored by *oauth2-server*. @@ -379,7 +383,9 @@ An ``Object`` representing the authorization code and associated data. redirectUri: code.redirect_uri, scope: code.scope, client: client, // with 'id' property - user: user + user: user, + codeChallenge: code.code_challenge, + codeChallengeMethod: code.code_challenge_method }; }); } @@ -665,25 +671,29 @@ This model function is **required** if the ``authorization_code`` grant is used. **Arguments:** -+------------------------+----------+---------------------------------------------------------------------+ -| Name | Type | Description | -+========================+==========+=====================================================================+ -| code | Object | The code to be saved. | -+------------------------+----------+---------------------------------------------------------------------+ -| code.authorizationCode | String | The authorization code to be saved. | -+------------------------+----------+---------------------------------------------------------------------+ -| code.expiresAt | Date | The expiry time of the authorization code. | -+------------------------+----------+---------------------------------------------------------------------+ -| code.redirectUri | String | The redirect URI associated with the authorization code. | -+------------------------+----------+---------------------------------------------------------------------+ -| [code.scope] | String | The authorized scope of the authorization code. | -+------------------------+----------+---------------------------------------------------------------------+ -| client | Object | The client associated with the authorization code. | -+------------------------+----------+---------------------------------------------------------------------+ -| user | Object | The user associated with the authorization code. | -+------------------------+----------+---------------------------------------------------------------------+ -| [callback] | Function | Node-style callback to be used instead of the returned ``Promise``. | -+------------------------+----------+---------------------------------------------------------------------+ ++----------------------------+----------+---------------------------------------------------------------------+ +| Name | Type | Description | ++============================+==========+=====================================================================+ +| code | Object | The code to be saved. | ++----------------------------+----------+---------------------------------------------------------------------+ +| code.authorizationCode | String | The authorization code to be saved. | ++----------------------------+----------+---------------------------------------------------------------------+ +| code.expiresAt | Date | The expiry time of the authorization code. | ++----------------------------+----------+---------------------------------------------------------------------+ +| code.redirectUri | String | The redirect URI associated with the authorization code. | ++----------------------------+----------+---------------------------------------------------------------------+ +| [code.scope] | String | The authorized scope of the authorization code. | ++----------------------------+----------+---------------------------------------------------------------------+ +| [code.codeChallenge] | String | The code challenge string used with PKCE (RFC7636). | ++----------------------------+----------+---------------------------------------------------------------------+ +| [code.codeChallengeMethod] | String | The string for the code challenge method (`S256` or `plain`). | ++----------------------------+----------+---------------------------------------------------------------------+ +| client | Object | The client associated with the authorization code. | ++----------------------------+----------+---------------------------------------------------------------------+ +| user | Object | The user associated with the authorization code. | ++----------------------------+----------+---------------------------------------------------------------------+ +| [callback] | Function | Node-style callback to be used instead of the returned ``Promise``. | ++----------------------------+----------+---------------------------------------------------------------------+ .. todo:: Is ``code.scope`` really optional? @@ -691,25 +701,29 @@ This model function is **required** if the ``authorization_code`` grant is used. An ``Object`` representing the authorization code and associated data. -+------------------------+--------+---------------------------------------------------------------+ -| Name | Type | Description | -+========================+========+===============================================================+ -| code | Object | The return value. | -+------------------------+--------+---------------------------------------------------------------+ -| code.authorizationCode | String | The authorization code passed to ``saveAuthorizationCode()``. | -+------------------------+--------+---------------------------------------------------------------+ -| code.expiresAt | Date | The expiry time of the authorization code. | -+------------------------+--------+---------------------------------------------------------------+ -| code.redirectUri | String | The redirect URI associated with the authorization code. | -+------------------------+--------+---------------------------------------------------------------+ -| [code.scope] | String | The authorized scope of the authorization code. | -+------------------------+--------+---------------------------------------------------------------+ -| code.client | Object | The client associated with the authorization code. | -+------------------------+--------+---------------------------------------------------------------+ -| code.client.id | String | A unique string identifying the client. | -+------------------------+--------+---------------------------------------------------------------+ -| code.user | Object | The user associated with the authorization code. | -+------------------------+--------+---------------------------------------------------------------+ ++----------------------------+--------+----------------------------------------------------------------+ +| Name | Type | Description | ++============================+========+================================================================+ +| code | Object | The return value. | ++----------------------------+--------+----------------------------------------------------------------+ +| code.authorizationCode | String | The authorization code passed to ``saveAuthorizationCode()``. | ++----------------------------+--------+----------------------------------------------------------------+ +| code.expiresAt | Date | The expiry time of the authorization code. | ++----------------------------+--------+----------------------------------------------------------------+ +| code.redirectUri | String | The redirect URI associated with the authorization code. | ++----------------------------+--------+----------------------------------------------------------------+ +| [code.scope] | String | The authorized scope of the authorization code. | ++----------------------------+--------+----------------------------------------------------------------+ +| code.client | Object | The client associated with the authorization code. | ++----------------------------+--------+----------------------------------------------------------------+ +| code.client.id | String | A unique string identifying the client. | ++----------------------------+--------+----------------------------------------------------------------+ +| code.user | Object | The user associated with the authorization code. | ++----------------------------+--------+----------------------------------------------------------------+ +| [code.codeChallenge] | String | The code challenge string used with PKCE (RFC7636). | ++----------------------------+--------+----------------------------------------------------------------+ +| [code.codeChallengeMethod] | String | The string for the code challenge method (`S256` or `plain` | ++----------------------------+--------+----------------------------------------------------------------+ ``code.client`` and ``code.user`` can carry additional properties that will be ignored by *oauth2-server*. @@ -725,7 +739,9 @@ An ``Object`` representing the authorization code and associated data. redirect_uri: code.redirectUri, scope: code.scope, client_id: client.id, - user_id: user.id + user_id: user.id, + code_challenge: code.codeChallenge, + code_challenge_method: code.codeChallengeMethod }; return db.saveAuthorizationCode(authCode) .then(function(authorizationCode) { @@ -735,7 +751,9 @@ An ``Object`` representing the authorization code and associated data. redirectUri: authorizationCode.redirect_uri, scope: authorizationCode.scope, client: {id: authorizationCode.client_id}, - user: {id: authorizationCode.user_id} + user: {id: authorizationCode.user_id}, + codeChallenge: authorizationCode.code_challenge, + codeChallengeMethod: authorizationCode.code_challenge_method }; }); } diff --git a/lib/grant-types/authorization-code-grant-type.ts b/lib/grant-types/authorization-code-grant-type.ts index f00c82c71..604f17956 100755 --- a/lib/grant-types/authorization-code-grant-type.ts +++ b/lib/grant-types/authorization-code-grant-type.ts @@ -8,6 +8,8 @@ import { import { AuthorizationCode, Client, Token, User } from '../interfaces'; import { Request } from '../request'; import * as is from '../validator/is'; +import * as crypto from 'crypto'; +import * as stringUtil from '../utils/string-util'; export class AuthorizationCodeGrantType extends AbstractGrantType { constructor(options: any = {}) { @@ -117,6 +119,33 @@ export class AuthorizationCodeGrantType extends AbstractGrantType { ); } + if (code.codeChallenge) { + if (!request.body.code_verifier) { + throw new InvalidGrantError('Missing parameter: `code_verifier`'); + } + + let hash; + switch (code.codeChallengeMethod) { + case 'plain': + hash = request.body.code_verifier; + break; + case 'S256': + hash = stringUtil.base64URLEncode(crypto.createHash('sha256').update(request.body.code_verifier).digest()); + break; + default: + throw new ServerError('Server error: `getAuthorizationCode()` did not return a valid `codeChallengeMethod` property'); + } + + if (code.codeChallenge !== hash) { + throw new InvalidGrantError('Invalid grant: code verifier is invalid'); + } + } else { + if (request.body.code_verifier) { + // No code challenge but code_verifier was passed in. + throw new InvalidGrantError('Invalid grant: code verifier is invalid'); + } + } + return code; } diff --git a/lib/handlers/token-handler.ts b/lib/handlers/token-handler.ts index 66d9ac363..72cf2aa6c 100755 --- a/lib/handlers/token-handler.ts +++ b/lib/handlers/token-handler.ts @@ -22,6 +22,11 @@ import { BearerTokenType } from '../token-types'; import { hasOwnProperty } from '../utils/fn'; import * as is from '../validator/is'; +interface ClientCredentials { + clientId: string; + clientSecret?: string; +} + /** * Grant types. */ @@ -127,9 +132,10 @@ export class TokenHandler { * Get the client from the model. */ - async getClient(request, response) { - const credentials = this.getClientCredentials(request); + async getClient(request: Request, response: Response) { + const credentials: ClientCredentials = this.getClientCredentials(request); const grantType = request.body.grant_type; + const isPkce = this.isPKCERequest(request, grantType); if (!credentials.clientId) { throw new InvalidRequestError('Missing parameter: `client_id`'); @@ -137,7 +143,8 @@ export class TokenHandler { if ( this.isClientAuthenticationRequired(grantType) && - !credentials.clientSecret + !credentials.clientSecret && + !isPkce ) { throw new InvalidRequestError('Missing parameter: `client_secret`'); } @@ -209,6 +216,12 @@ export class TokenHandler { }; } + if (this.isPKCERequest(request, grantType)) { + if (request.body.client_id) { + return { clientId: request.body.client_id }; + } + } + if ( !this.isClientAuthenticationRequired(grantType) && request.body.client_id @@ -328,4 +341,17 @@ export class TokenHandler { return true; } + + /** + * Check if the request is a PCKE request. We assume PKCE if grant type is 'authorization_code' + * and code verifier is present. + */ + isPKCERequest(request: Request, grantType: string): boolean { + if (grantType === 'authorization_code' && request.body.code_verifier) { + return true; + } + + return false; + } + } diff --git a/lib/interfaces/authorization-code.interface.ts b/lib/interfaces/authorization-code.interface.ts index a1f781607..61df9ed9c 100644 --- a/lib/interfaces/authorization-code.interface.ts +++ b/lib/interfaces/authorization-code.interface.ts @@ -10,5 +10,7 @@ export interface AuthorizationCode { scope?: string; client: Client; user: User; + codeChallenge?: string; + codeChallengeMethod?: 'S256' | 'plain' | null; [key: string]: any; } diff --git a/lib/response-types/code-response-type.ts b/lib/response-types/code-response-type.ts index 0d1b93e31..5b4ae04f2 100755 --- a/lib/response-types/code-response-type.ts +++ b/lib/response-types/code-response-type.ts @@ -1,5 +1,5 @@ import { MILLISECONDS_PER_SECOND } from '../constants'; -import { InvalidArgumentError } from '../errors'; +import { InvalidArgumentError, InvalidRequestError } from '../errors'; import { AuthorizationCode, Client, Model, User } from '../interfaces'; import { Request } from '../request'; import * as tokenUtil from '../utils/token-util'; @@ -56,6 +56,13 @@ export class CodeResponseType { throw new InvalidArgumentError('Missing parameter: `uri`'); } + const codeChallenge = this.getCodeChallenge(request); + const codeChallengeMethod = this.getCodeChallengeMethod(request); + + if (!codeChallenge && codeChallengeMethod) { + throw new InvalidArgumentError('Missing parameter: `code_challenge`'); + } + const authorizationCode = await this.generateAuthorizationCode( client, user, @@ -70,6 +77,8 @@ export class CodeResponseType { client, uri, user, + codeChallenge, + codeChallengeMethod, ); this.code = code.authorizationCode; @@ -107,6 +116,8 @@ export class CodeResponseType { client: Client, redirectUri: any, user: User, + codeChallenge?: string, + codeChallengeMethod?: 'S256' | 'plain' | null, ) { const code = { authorizationCode, @@ -115,6 +126,14 @@ export class CodeResponseType { scope, } as AuthorizationCode; + if (codeChallenge) { + code.codeChallenge = codeChallenge; + + // Section 4.3 - https://tools.ietf.org/html/rfc7636#section-4 + // Defaults to "plain" if not present in the request. + code.codeChallengeMethod = codeChallengeMethod || 'plain'; + } + return this.model.saveAuthorizationCode(code, client, user); } @@ -162,4 +181,41 @@ export class CodeResponseType { return redirectUri; } + + /** + * Get Code challenge + */ + getCodeChallenge(request: Request): string | undefined { + const codeChallenge = request.body.code_challenge || request.query.code_challenge; + + if (!codeChallenge) { + return undefined; + } + + // https://tools.ietf.org/html/rfc7636#section-4 + if (!codeChallenge.match(/^([A-Za-z0-9\.\-\_\~]){43,128}$/)) { + throw new InvalidRequestError('Invalid parameter: `code_challenge`'); + } + + return codeChallenge; + } + + /** + * Get Code challenge method + */ + getCodeChallengeMethod(request: Request): 'S256' | 'plain' | undefined { + const codeChallengeMethod = request.body.code_challenge_method || request.query.code_challenge_method; + + // https://tools.ietf.org/html/rfc7636#section-4 + // Section 4.3 - codeChallengeMethod is optional. + if (!codeChallengeMethod) { + return undefined; + } + + if (codeChallengeMethod !== 'S256' && codeChallengeMethod !== 'plain') { + throw new InvalidRequestError('Invalid parameter: `code_challenge_method`'); + } + + return codeChallengeMethod; + } } diff --git a/lib/utils/string-util.ts b/lib/utils/string-util.ts new file mode 100644 index 000000000..3cf58dee1 --- /dev/null +++ b/lib/utils/string-util.ts @@ -0,0 +1,6 @@ +export const base64URLEncode = (buf: Buffer) => { + return buf.toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +}; diff --git a/package-lock.json b/package-lock.json index 9d7156cac..110158500 100755 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@types/basic-auth/-/basic-auth-1.1.2.tgz", "integrity": "sha512-NzkkcC+gkkILWaBi3+/z/3do6Ybk6TWeTqV5zCVXmG2KaBoT5YqlJvfqP44HCyDA+Cu58pp7uKAxy/G58se/TA==", + "dev": true, "requires": { "@types/node": "*" } @@ -77,7 +78,8 @@ "@types/node": { "version": "11.15.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-11.15.3.tgz", - "integrity": "sha512-5RzvXVietaB8S4dwDjxjltAOHtTO87fiksjqjWGZih97j6KSrdCDaRfmYMNrgrLM87odGBrsTHAl6N3fLraQaw==" + "integrity": "sha512-5RzvXVietaB8S4dwDjxjltAOHtTO87fiksjqjWGZih97j6KSrdCDaRfmYMNrgrLM87odGBrsTHAl6N3fLraQaw==", + "dev": true }, "@types/sinon": { "version": "7.5.1", @@ -88,12 +90,14 @@ "@types/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-4zJN5gJH+Km6hA36z8MnOKas6EU0qwxItTXNijYDPuZUsSk4EpIAB56fwnxZIhi3tHx42J7wqNdQTqt49Ar9FQ==" + "integrity": "sha512-4zJN5gJH+Km6hA36z8MnOKas6EU0qwxItTXNijYDPuZUsSk4EpIAB56fwnxZIhi3tHx42J7wqNdQTqt49Ar9FQ==", + "dev": true }, "@types/type-is": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/@types/type-is/-/type-is-1.6.3.tgz", "integrity": "sha512-PNs5wHaNcBgCQG5nAeeZ7OvosrEsI9O4W2jAOO9BCCg4ux9ZZvH2+0iSCOIDBiKuQsiNS8CBlmfX9f5YBQ22cA==", + "dev": true, "requires": { "@types/node": "*" } diff --git a/package.json b/package.json index b26328d1f..df5772c5d 100755 --- a/package.json +++ b/package.json @@ -66,6 +66,10 @@ "devDependencies": { "@types/mocha": "^5.2.7", "@types/sinon": "^7.5.1", + "@types/basic-auth": "^1.1.2", + "@types/node": "^11.15.3", + "@types/statuses": "^1.5.0", + "@types/type-is": "^1.6.3", "mocha": "^6.2.2", "npm-run-all": "^4.1.5", "should": "^13.2.3", @@ -76,10 +80,6 @@ "typescript": "^3.7.2" }, "dependencies": { - "@types/basic-auth": "^1.1.2", - "@types/node": "^11.15.3", - "@types/statuses": "^1.5.0", - "@types/type-is": "^1.6.3", "basic-auth": "^2.0.1", "statuses": "^1.5.0", "tslib": "^1.10.0", diff --git a/test/integration/grant-types/authorization-code-grant-type.spec.ts b/test/integration/grant-types/authorization-code-grant-type.spec.ts index f052a7b34..227b6caae 100755 --- a/test/integration/grant-types/authorization-code-grant-type.spec.ts +++ b/test/integration/grant-types/authorization-code-grant-type.spec.ts @@ -7,6 +7,8 @@ import { } from '../../../lib/errors'; import { AuthorizationCodeGrantType } from '../../../lib/grant-types'; import { Request } from '../../../lib/request'; +import * as stringUtil from '../../../lib/utils/string-util'; +import * as crypto from 'crypto'; /** * Test `AuthorizationCodeGrantType` integration. @@ -612,6 +614,122 @@ describe('AuthorizationCodeGrantType integration', () => { }); }); + describe('with PKCE', function() { + it('should throw an error if the `code_verifier` is invalid with S256 code challenge method', () => { + var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32)); + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar' }, + expiresAt: new Date(new Date().getTime() * 2), + user: {}, + codeChallengeMethod: 'S256', + codeChallenge: stringUtil.base64URLEncode(crypto.createHash('sha256').update(codeVerifier).digest()) + }; + var client: any = { id: 'foobar', isPublic: true }; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + revokeAuthorizationCode: function() {}, + saveToken: function() {} + }; + var grantType = new AuthorizationCodeGrantType({ accessTokenLifetime: 123, model: model }); + var request = new Request({ body: { code: 12345, code_verifier: 'foo' }, headers: {}, method: 'POST', query: {} }); + + return grantType.getAuthorizationCode(request, client) + .then(() => { + should.fail('should.fail', ''); + }) + .catch(e => { + e.should.be.an.instanceOf(InvalidGrantError); + e.message.should.equal('Invalid grant: code verifier is invalid'); + }); + }); + + it('should throw an error if the `code_verifier` is invalid with plain code challenge method', () => { + var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32)); + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar' }, + expiresAt: new Date(new Date().getTime() * 2), + user: {}, + codeChallengeMethod: 'plain', + codeChallenge: codeVerifier + }; + var client: any = { id: 'foobar', isPublic: true }; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + revokeAuthorizationCode: function() {}, + saveToken: function() {} + }; + var grantType = new AuthorizationCodeGrantType({ accessTokenLifetime: 123, model: model }); + var request = new Request({ body: { code: 12345, code_verifier: 'foo' }, headers: {}, method: 'POST', query: {} }); + + return grantType.getAuthorizationCode(request, client) + .then(() => { + should.fail('should.fail', ''); + }) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidGrantError); + e.message.should.equal('Invalid grant: code verifier is invalid'); + }); + }); + + it('should return an auth code when `code_verifier` is valid with S256 code challenge method', () => { + var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32)); + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar', isPublic: true }, + expiresAt: new Date(new Date().getTime() * 2), + user: {}, + codeChallengeMethod: 'S256', + codeChallenge: stringUtil.base64URLEncode(crypto.createHash('sha256').update(codeVerifier).digest()) + }; + var client: any = { id: 'foobar', isPublic: true }; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + revokeAuthorizationCode: function() {}, + saveToken: function() {} + }; + var grantType = new AuthorizationCodeGrantType({ accessTokenLifetime: 123, model: model }); + var request = new Request({ body: { code: 12345, code_verifier: codeVerifier }, headers: {}, method: 'POST', query: {} }); + + return grantType.getAuthorizationCode(request, client) + .then(function(data) { + data.should.equal(authorizationCode); + }) + .catch(() => { + should.fail('should.fail', ''); + }); + }); + + it('should return an auth code when `code_verifier` is valid with plain code challenge method', () => { + var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32)); + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar' }, + expiresAt: new Date(new Date().getTime() * 2), + user: {}, + codeChallengeMethod: 'plain', + codeChallenge: codeVerifier + }; + var client: any = { id: 'foobar', isPublic: true }; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + revokeAuthorizationCode: function() {}, + saveToken: function() {} + }; + var grantType = new AuthorizationCodeGrantType({ accessTokenLifetime: 123, model: model }); + var request = new Request({ body: { code: 12345, code_verifier: codeVerifier }, headers: {}, method: 'POST', query: {} }); + + return grantType.getAuthorizationCode(request, client) + .then(function(data) { + data.should.equal(authorizationCode); + }) + .catch(() => { + should.fail('should.fail', ''); + }); + }); + }); + it('should return an auth code', () => { const authorizationCode = { authorizationCode: 12345, diff --git a/test/integration/handlers/token-handler.spec.ts b/test/integration/handlers/token-handler.spec.ts index 25e861479..8304f93af 100755 --- a/test/integration/handlers/token-handler.spec.ts +++ b/test/integration/handlers/token-handler.spec.ts @@ -4,6 +4,7 @@ import { AccessDeniedError, InvalidArgumentError, InvalidClientError, + InvalidGrantError, InvalidRequestError, ServerError, UnauthorizedClientError, @@ -14,6 +15,8 @@ import { TokenHandler } from '../../../lib/handlers'; import { Request } from '../../../lib/request'; import { Response } from '../../../lib/response'; import { BearerTokenType } from '../../../lib/token-types'; +import * as crypto from 'crypto'; +import * as stringUtil from '../../../lib/utils/string-util'; /** * Test `TokenHandler` integration. @@ -1261,6 +1264,201 @@ describe('TokenHandler integration', () => { // should.fail('should.fail', ''); // }); }); + + describe('with PKCE', function() { + it('should return a token when code verifier is valid using S256 code challenge method', () => { + var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32)); + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar' }, + expiresAt: new Date(new Date().getTime() + 60000), + user: {}, + codeChallengeMethod: 'S256', + codeChallenge: stringUtil.base64URLEncode(crypto.createHash('sha256').update(codeVerifier).digest()) + }; + var client = { id: 'foobar', grants: ['authorization_code'] }; + var token = {}; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + getClient: function() {}, + saveToken: function() { return token; }, + validateScope: function() { return 'foo'; }, + revokeAuthorizationCode: function() { return authorizationCode; } + }; + var handler = new TokenHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 }); + var request = new Request({ + body: { + code: 12345, + grant_type: 'authorization_code', + code_verifier: codeVerifier + }, + headers: {}, + method: 'POST', + query: {} + }); + + return handler.handleGrantType(request, client) + .then(function(data) { + data.should.equal(token); + }); + }); + + it('should return a token when code verifier is valid using plain code challenge method', () => { + var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32)); + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar' }, + expiresAt: new Date(new Date().getTime() + 60000), + user: {}, + codeChallengeMethod: 'plain', + codeChallenge: codeVerifier + }; + var client = { id: 'foobar', grants: ['authorization_code'] }; + var token = {}; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + getClient: function() {}, + saveToken: function() { return token; }, + validateScope: function() { return 'foo'; }, + revokeAuthorizationCode: function() { return authorizationCode; } + }; + var handler = new TokenHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 }); + var request = new Request({ + body: { + code: 12345, + grant_type: 'authorization_code', + code_verifier: codeVerifier + }, + headers: {}, + method: 'POST', + query: {} + }); + + return handler.handleGrantType(request, client) + .then(function(data) { + data.should.equal(token); + }); + }); + + it('should throw an invalid grant error when code verifier is invalid', () => { + var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32)); + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar' }, + expiresAt: new Date(new Date().getTime() + 60000), + user: {}, + codeChallengeMethod: 'S256', + codeChallenge: stringUtil.base64URLEncode(crypto.createHash('sha256').update(codeVerifier).digest()) + }; + var client = { id: 'foobar', grants: ['authorization_code'] }; + var token = {}; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + getClient: function() {}, + saveToken: function() { return token; }, + validateScope: function() { return 'foo'; }, + revokeAuthorizationCode: function() { return authorizationCode; } + }; + var handler = new TokenHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 }); + var request = new Request({ + body: { + code: 12345, + grant_type: 'authorization_code', + code_verifier: '123123123123123123123123123123123123123123123' + }, + headers: {}, + method: 'POST', + query: {} + }); + + return handler.handleGrantType(request, client) + .then(() => { + should.fail('should.fail', ''); + }) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidGrantError); + e.message.should.equal('Invalid grant: code verifier is invalid'); + }); + }); + + it('should throw an invalid grant error when code verifier is missing', function() { + var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32)); + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar' }, + expiresAt: new Date(new Date().getTime() + 60000), + user: {}, + codeChallengeMethod: 'S256', + codeChallenge: stringUtil.base64URLEncode(crypto.createHash('sha256').update(codeVerifier).digest()) + }; + var client = { id: 'foobar', grants: ['authorization_code'] }; + var token = {}; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + getClient: function() {}, + saveToken: function() { return token; }, + validateScope: function() { return 'foo'; }, + revokeAuthorizationCode: function() { return authorizationCode; } + }; + var handler = new TokenHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 }); + var request = new Request({ + body: { + code: 12345, + grant_type: 'authorization_code' + }, + headers: {}, + method: 'POST', + query: {} + }); + + return handler.handleGrantType(request, client) + .then(() => { + should.fail('should.fail', ''); + }) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidGrantError); + e.message.should.equal('Missing parameter: `code_verifier`'); + }); + }); + + it('should throw an invalid grant error when code verifier is present but code challenge is missing', () => { + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar' }, + expiresAt: new Date(new Date().getTime() + 60000), + user: {} + }; + var client = { id: 'foobar', grants: ['authorization_code'] }; + var token = {}; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + getClient: function() {}, + saveToken: function() { return token; }, + validateScope: function() { return 'foo'; }, + revokeAuthorizationCode: function() { return authorizationCode; } + }; + var handler = new TokenHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 }); + var request = new Request({ + body: { + code: 12345, + grant_type: 'authorization_code', + code_verifier: '123123123123123123123123123123123123123123123' + }, + headers: {}, + method: 'POST', + query: {} + }); + + return handler.handleGrantType(request, client) + .then(() => { + should.fail('should.fail', ''); + }) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidGrantError); + e.message.should.equal('Invalid grant: code verifier is invalid'); + }); + }); + }); }); describe('with grant_type `client_credentials`', () => { diff --git a/test/integration/response-types/code-response-type.spec.ts b/test/integration/response-types/code-response-type.spec.ts index 1824a7f51..d60be4a9e 100755 --- a/test/integration/response-types/code-response-type.spec.ts +++ b/test/integration/response-types/code-response-type.spec.ts @@ -1,3 +1,4 @@ +import { Client, User } from 'lib/interfaces'; import * as should from 'should'; import * as sinon from 'sinon'; import * as url from 'url'; @@ -187,6 +188,62 @@ describe('CodeResponseType integration', () => { // .generateAuthorizationCode(undefined, undefined, undefined) // .should.be.an.instanceOf(Promise); // }); + + describe('with PKCE', function() { + it('should save codeChallenge and codeChallengeMethod', function() { + var model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: sinon.stub().returns({}) + }; + var handler = new CodeResponseType({ authorizationCodeLifetime: 120, model: model }); + + return handler.saveAuthorizationCode('foo', new Date(12345), 'qux', { id: 'biz' } as Client, 'baz', { id: 'boz' } as User, 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', 'S256') + .then(function() { + model.saveAuthorizationCode.callCount.should.equal(1); + model.saveAuthorizationCode.firstCall.args.should.have.length(3); + model.saveAuthorizationCode.firstCall.args[0].should.eql({ authorizationCode: 'foo', expiresAt: new Date(12345), redirectUri: 'baz', scope: 'qux', codeChallenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', codeChallengeMethod: 'S256' }); + model.saveAuthorizationCode.firstCall.args[1].id.should.equal('biz'); + model.saveAuthorizationCode.firstCall.args[2].id.should.equal('boz'); + }); + }); + + it('should save codeChallenge and set codeChallengeMethod to `plain` when codeChallengeMethod is not present', function() { + var model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: sinon.stub().returns({}) + }; + var handler = new CodeResponseType({ authorizationCodeLifetime: 120, model: model }); + + return handler.saveAuthorizationCode('foo', new Date(12345), 'qux', { id: 'biz' } as Client, 'baz', { id: 'boz' } as User, 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', null) + .then(function() { + model.saveAuthorizationCode.callCount.should.equal(1); + model.saveAuthorizationCode.firstCall.args.should.have.length(3); + model.saveAuthorizationCode.firstCall.args[0].should.eql({ authorizationCode: 'foo', expiresAt: new Date(12345), redirectUri: 'baz', scope: 'qux', codeChallenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', codeChallengeMethod: 'plain' }); + model.saveAuthorizationCode.firstCall.args[1].id.should.equal('biz'); + model.saveAuthorizationCode.firstCall.args[2].id.should.equal('boz'); + }); + }); + + it('should save code but not save codeChallenge or codeChallengeMethod when codeChallenge is not present and codeChallengeMethod is present', function() { + var model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: sinon.stub().returns({}) + }; + var handler = new CodeResponseType({ authorizationCodeLifetime: 120, model: model }); + + return handler.saveAuthorizationCode('foo', new Date(12345), 'qux', { id: 'biz' } as Client, 'baz', { id: 'boz' } as User, '', 'S256') + .then(function() { + model.saveAuthorizationCode.callCount.should.equal(1); + model.saveAuthorizationCode.firstCall.args.should.have.length(3); + model.saveAuthorizationCode.firstCall.args[0].should.eql({ authorizationCode: 'foo', expiresAt: new Date(12345), redirectUri: 'baz', scope: 'qux' }); + model.saveAuthorizationCode.firstCall.args[1].id.should.equal('biz'); + model.saveAuthorizationCode.firstCall.args[2].id.should.equal('boz'); + }); + }); + }); }); describe('getAuthorizationCodeExpiresAt()', () => { diff --git a/test/unit/handlers/token-handler.spec.ts b/test/unit/handlers/token-handler.spec.ts index 53fcd36cf..070fd28d4 100755 --- a/test/unit/handlers/token-handler.spec.ts +++ b/test/unit/handlers/token-handler.spec.ts @@ -1,3 +1,4 @@ +import { Response } from 'lib/response'; import * as should from 'should'; import * as sinon from 'sinon'; import { TokenHandler } from '../../../lib/handlers'; @@ -29,7 +30,7 @@ describe('TokenHandler', () => { }); return handler - .getClient(request, {}) + .getClient(request, {} as Response) .then(() => { model.getClient.callCount.should.equal(1); model.getClient.firstCall.args.should.have.length(2);