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 <opender94@gmail.com>

* Update packages/insomnia-app/app/network/o-auth-2/grant-authorization-code.js

Co-authored-by: Opender Singh <opender94@gmail.com>

* Fix prettier report

Co-authored-by: Opender Singh <opender94@gmail.com>
This commit is contained in:
Ben Scholzen 2020-10-20 09:58:16 +02:00 committed by GitHub
parent d201cd1807
commit 294777b0ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 137 additions and 0 deletions

View File

@ -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_/),
});
});
});

View File

@ -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';

View File

@ -65,6 +65,7 @@ async function _getOAuth2AuthorizationCodeHeader(
authentication.state,
authentication.audience,
authentication.resource,
authentication.usePkce,
);
return _updateOAuth2Token(requestId, results);

View File

@ -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<Object> {
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<Object> {
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
}

View File

@ -161,6 +161,10 @@ class OAuth2Auth extends React.PureComponent<Props, State> {
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<Props, State> {
);
}
renderUsePkceRow(onChange: boolean => void): React.Element<*> {
const { request } = this.props;
const { authentication } = request;
return (
<tr key="use-pkce">
<td className="pad-right no-wrap valign-middle">
<label htmlFor="use-pkce" className="label--small no-pad">
Use PKCE
</label>
</td>
<td className="wide">
<div className="form-control form-control--underlined no-margin">
<Button
className="btn btn--super-duper-compact"
id="use-pkce"
onClick={onChange}
value={authentication.usePkce}
title={authentication.usePkce ? 'Disable PKCE' : 'Enable PKCE'}>
{authentication.usePkce ? (
<i className="fa fa-check-square-o" />
) : (
<i className="fa fa-square-o" />
)}
</Button>
</div>
</td>
</tr>
);
}
renderInputRow(
label: string,
property: string,
@ -336,6 +370,8 @@ class OAuth2Auth extends React.PureComponent<Props, State> {
this._handleChangeClientSecret,
);
const usePkce = this.renderUsePkceRow(this._handleChangePkce);
const authorizationUrl = this.renderInputRow(
'Authorization URL',
'authorizationUrl',
@ -422,6 +458,7 @@ class OAuth2Auth extends React.PureComponent<Props, State> {
accessTokenUrl,
clientId,
clientSecret,
usePkce,
redirectUri,
enabled,
];