From 294777b0ed112e86ff07db6dfc486630b7d08d33 Mon Sep 17 00:00:00 2001 From: Ben Scholzen Date: Tue, 20 Oct 2020 09:58:16 +0200 Subject: [PATCH] Add PKCE support (#2652) * Add PKCE support * Fix failing unit test and add new test for PKCE * Update packages/insomnia-app/app/network/o-auth-2/grant-authorization-code.js Co-authored-by: Opender Singh * Update packages/insomnia-app/app/network/o-auth-2/grant-authorization-code.js Co-authored-by: Opender Singh * Fix prettier report Co-authored-by: Opender Singh --- .../grant-authorization-code.test.js | 59 +++++++++++++++++++ .../app/network/o-auth-2/constants.js | 3 + .../app/network/o-auth-2/get-token.js | 1 + .../o-auth-2/grant-authorization-code.js | 37 ++++++++++++ .../components/editors/auth/o-auth-2-auth.js | 37 ++++++++++++ 5 files changed, 137 insertions(+) diff --git a/packages/insomnia-app/app/network/o-auth-2/__tests__/grant-authorization-code.test.js b/packages/insomnia-app/app/network/o-auth-2/__tests__/grant-authorization-code.test.js index 2e06e3b17..b44ab73ef 100644 --- a/packages/insomnia-app/app/network/o-auth-2/__tests__/grant-authorization-code.test.js +++ b/packages/insomnia-app/app/network/o-auth-2/__tests__/grant-authorization-code.test.js @@ -194,4 +194,63 @@ describe('authorization_code', () => { xResponseId: expect.stringMatching(/^res_/), }); }); + + it('uses PKCE', async () => { + createBWRedirectMock(`${REDIRECT_URI}?code=code_123&state=${STATE}`); + const bodyPath = path.join(getTempDir(), 'foo.response'); + + fs.writeFileSync( + bodyPath, + JSON.stringify({ + access_token: 'token_123', + token_type: 'token_type', + scope: SCOPE, + audience: AUDIENCE, + resource: RESOURCE, + }), + ); + + network.sendWithSettings = jest.fn(() => ({ + bodyPath, + bodyCompression: '', + parentId: 'req_1', + statusCode: 200, + headers: [{ name: 'Content-Type', value: 'application/json' }], + })); + + const result = await getToken( + 'req_1', + AUTHORIZE_URL, + ACCESS_TOKEN_URL, + false, + CLIENT_ID, + CLIENT_SECRET, + REDIRECT_URI, + SCOPE, + STATE, + AUDIENCE, + RESOURCE, + true, + ); + + // Check the request to fetch the token + expect(network.sendWithSettings.mock.calls[0][1].body.params).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'code_verifier' })]), + ); + + // Check the expected value + expect(result).toEqual({ + access_token: 'token_123', + refresh_token: null, + expires_in: null, + token_type: 'token_type', + scope: SCOPE, + audience: AUDIENCE, + resource: RESOURCE, + error: null, + error_uri: null, + error_description: null, + xResponseId: expect.stringMatching(/^res_/), + }); + }); }); diff --git a/packages/insomnia-app/app/network/o-auth-2/constants.js b/packages/insomnia-app/app/network/o-auth-2/constants.js index 741d78a2d..cb134fcb5 100644 --- a/packages/insomnia-app/app/network/o-auth-2/constants.js +++ b/packages/insomnia-app/app/network/o-auth-2/constants.js @@ -15,6 +15,9 @@ export const P_CLIENT_ID = 'client_id'; export const P_CLIENT_SECRET = 'client_secret'; export const P_AUDIENCE = 'audience'; export const P_RESOURCE = 'resource'; +export const P_CODE_CHALLENGE = 'code_challenge'; +export const P_CODE_CHALLENGE_METHOD = 'code_challenge_method'; +export const P_CODE_VERIFIER = 'code_verifier'; export const P_CODE = 'code'; export const P_NONCE = 'nonce'; export const P_ERROR = 'error'; diff --git a/packages/insomnia-app/app/network/o-auth-2/get-token.js b/packages/insomnia-app/app/network/o-auth-2/get-token.js index 4ee5667c0..a4a33d84e 100644 --- a/packages/insomnia-app/app/network/o-auth-2/get-token.js +++ b/packages/insomnia-app/app/network/o-auth-2/get-token.js @@ -65,6 +65,7 @@ async function _getOAuth2AuthorizationCodeHeader( authentication.state, authentication.audience, authentication.resource, + authentication.usePkce, ); return _updateOAuth2Token(requestId, results); diff --git a/packages/insomnia-app/app/network/o-auth-2/grant-authorization-code.js b/packages/insomnia-app/app/network/o-auth-2/grant-authorization-code.js index 76de98c02..f1380c66b 100644 --- a/packages/insomnia-app/app/network/o-auth-2/grant-authorization-code.js +++ b/packages/insomnia-app/app/network/o-auth-2/grant-authorization-code.js @@ -1,4 +1,5 @@ // @flow +import crypto from 'crypto'; import { parse as urlParse } from 'url'; import * as c from './constants'; import { buildQueryStringFromParams, joinUrlAndQueryString } from 'insomnia-url'; @@ -20,6 +21,7 @@ export default async function( state: string = '', audience: string = '', resource: string = '', + usePkce: boolean = false, ): Promise { if (!authorizeUrl) { throw new Error('Invalid authorization URL'); @@ -29,6 +31,19 @@ export default async function( throw new Error('Invalid access token URL'); } + let codeVerifier = ''; + let codeChallenge = ''; + + if (usePkce) { + codeVerifier = _base64UrlEncode(crypto.randomBytes(32)); + codeChallenge = _base64UrlEncode( + crypto + .createHash('sha256') + .update(codeVerifier) + .digest(), + ); + } + const authorizeResults = await _authorize( authorizeUrl, clientId, @@ -37,6 +52,7 @@ export default async function( state, audience, resource, + codeChallenge, ); // Handle the error @@ -58,6 +74,7 @@ export default async function( state, audience, resource, + codeVerifier, ); } @@ -69,6 +86,7 @@ async function _authorize( state = '', audience = '', resource = '', + codeChallenge = '', ) { const params = [ { name: c.P_RESPONSE_TYPE, value: c.RESPONSE_TYPE_CODE }, @@ -82,6 +100,11 @@ async function _authorize( audience && params.push({ name: c.P_AUDIENCE, value: audience }); resource && params.push({ name: c.P_RESOURCE, value: resource }); + if (codeChallenge) { + params.push({ name: c.P_CODE_CHALLENGE, value: codeChallenge }); + params.push({ name: c.P_CODE_CHALLENGE_METHOD, value: 'S256' }); + } + // Add query params to URL const qs = buildQueryStringFromParams(params); const finalUrl = joinUrlAndQueryString(url, qs); @@ -113,6 +136,7 @@ async function _getToken( state: string = '', audience: string = '', resource: string = '', + codeVerifier: string = '', ): Promise { const params = [ { name: c.P_GRANT_TYPE, value: c.GRANT_TYPE_AUTHORIZATION_CODE }, @@ -124,6 +148,7 @@ async function _getToken( state && params.push({ name: c.P_STATE, value: state }); audience && params.push({ name: c.P_AUDIENCE, value: audience }); resource && params.push({ name: c.P_RESOURCE, value: resource }); + codeVerifier && params.push({ name: c.P_CODE_VERIFIER, value: codeVerifier }); const headers = [ { name: 'Content-Type', value: 'application/x-www-form-urlencoded' }, @@ -182,3 +207,15 @@ async function _getToken( return results; } + +function _base64UrlEncode(str: string): string { + return str + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + + // The characters + / = are reserved for PKCE as per the RFC, + // so we replace them with unreserved characters + // Docs: https://tools.ietf.org/html/rfc7636#section-4.2 +} diff --git a/packages/insomnia-app/app/ui/components/editors/auth/o-auth-2-auth.js b/packages/insomnia-app/app/ui/components/editors/auth/o-auth-2-auth.js index adb183986..2a965c884 100644 --- a/packages/insomnia-app/app/ui/components/editors/auth/o-auth-2-auth.js +++ b/packages/insomnia-app/app/ui/components/editors/auth/o-auth-2-auth.js @@ -161,6 +161,10 @@ class OAuth2Auth extends React.PureComponent { this._handleChangeProperty('clientSecret', value); } + _handleChangePkce(value: boolean): void { + this._handleChangeProperty('usePkce', value); + } + _handleChangeAuthorizationUrl(value: string): void { this._handleChangeProperty('authorizationUrl', value); } @@ -235,6 +239,36 @@ class OAuth2Auth extends React.PureComponent { ); } + renderUsePkceRow(onChange: boolean => void): React.Element<*> { + const { request } = this.props; + const { authentication } = request; + return ( + + + + + +
+ +
+ + + ); + } + renderInputRow( label: string, property: string, @@ -336,6 +370,8 @@ class OAuth2Auth extends React.PureComponent { this._handleChangeClientSecret, ); + const usePkce = this.renderUsePkceRow(this._handleChangePkce); + const authorizationUrl = this.renderInputRow( 'Authorization URL', 'authorizationUrl', @@ -422,6 +458,7 @@ class OAuth2Auth extends React.PureComponent { accessTokenUrl, clientId, clientSecret, + usePkce, redirectUri, enabled, ];