Skip to content

Commit

Permalink
feat(@whook/oauth2): add pkce support
Browse files Browse the repository at this point in the history
fix #58
  • Loading branch information
nfroidure committed Aug 20, 2023
1 parent 7a07983 commit 24f21ed
Show file tree
Hide file tree
Showing 10 changed files with 232 additions and 24 deletions.
6 changes: 5 additions & 1 deletion packages/whook-oauth2/src/__snapshots__/index.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ exports[`OAuth2 server with the code flow should produce new tokens 2`] = `
},
"a_grant_code",
"http://redirect.example.com/yolo",
undefined,
],
],
"oAuth2CodeCreateCalls": [],
Expand Down Expand Up @@ -179,7 +180,10 @@ exports[`OAuth2 server with the code flow should redirect with a code 2`] = `
"userId": "2",
},
"http://redirect.example.com/yolo?a_param=a_value",
{},
{
"codeChallenge": "",
"codeChallengeMethod": "plain",
},
],
],
"oAuth2PasswordCheckCalls": [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ exports[`getOAuth2Authorize should redirect 2`] = `
"redirectURI": "https://www.example.com",
"scope": "user",
},
{},
{
"codeChallenge": "",
"codeChallengeMethod": "plain",
},
],
],
"logCalls": [],
Expand Down
49 changes: 48 additions & 1 deletion packages/whook-oauth2/src/handlers/getOAuth2Authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { autoHandler } from 'knifecycle';
import camelCase from 'camelcase';
import { YError, printStackTrace } from 'yerror';
import { refersTo } from '@whook/whook';
import { CODE_CHALLENGE_METHODS } from '../services/oAuth2CodeGranter.js';
import type {
WhookAPIHandlerDefinition,
WhookAPIParameterDefinition,
Expand Down Expand Up @@ -80,6 +81,29 @@ export const stateParameter: WhookAPIParameterDefinition = {
},
},
};
export const codeChallengeParameter: WhookAPIParameterDefinition = {
name: 'code_challenge',
parameter: {
in: 'query',
name: 'code_challenge',
required: false,
schema: {
type: 'string',
},
},
};
export const codeChallengeMethodParameter: WhookAPIParameterDefinition = {
name: 'code_challenge_method',
parameter: {
in: 'query',
name: 'code_challenge_method',
required: false,
schema: {
type: 'string',
enum: CODE_CHALLENGE_METHODS as unknown as string[],
},
},
};

export const definition: WhookAPIHandlerDefinition = {
method: 'get',
Expand All @@ -95,6 +119,8 @@ export const definition: WhookAPIHandlerDefinition = {
refersTo(redirectURIParameter),
refersTo(scopeParameter),
refersTo(stateParameter),
refersTo(codeChallengeParameter),
refersTo(codeChallengeMethodParameter),
],
responses: {
'302': {
Expand Down Expand Up @@ -124,13 +150,17 @@ async function getOAuth2Authorize(
redirect_uri: demandedRedirectURI = '',
scope: demandedScope = '',
state,
code_challenge: codeChallenge = '',
code_challenge_method: codeChallengeMethod = 'plain',
...authorizeParameters
}: {
response_type: string;
client_id: string;
redirect_uri?: string;
scope?: string;
state: string;
code_challenge?: string;
code_challenge_method?: string;
} & Record<string, unknown>,
): Promise<WhookResponse<302, { location: string }>> {
const url = new URL(OAUTH2.authenticateURL);
Expand All @@ -144,6 +174,15 @@ async function getOAuth2Authorize(
if (!granter) {
throw new YError('E_UNKNOWN_AUTHORIZER_TYPE', responseType);
}
if (responseType === 'code') {
if (!codeChallenge) {
if (OAUTH2.forcePKCE) {
throw new YError('E_PKCE_REQUIRED', responseType);
}
}
} else if (codeChallenge) {
throw new YError('E_PKCE_NOT_SUPPORTED', responseType);
}

const { applicationId, redirectURI, scope } = await (
granter.authorizer as NonNullable<OAuth2GranterService['authorizer']>
Expand All @@ -153,7 +192,15 @@ async function getOAuth2Authorize(
redirectURI: demandedRedirectURI,
scope: demandedScope,
},
camelCaseObjectProperties(authorizeParameters),
{
...authorizeParameters,
...(responseType === 'code'
? {
codeChallenge,
codeChallengeMethod,
}
: {}),
},
);

url.searchParams.set('type', responseType);
Expand Down
4 changes: 4 additions & 0 deletions packages/whook-oauth2/src/handlers/postOAuth2Token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export const authorizationCodeTokenRequestBodySchema: WhookAPISchemaDefinition =
pattern: '^https?://',
format: 'uri',
},
code_verifier: {
type: 'string',
pattern: '^[\\d\\w\\-/\\._~]+$',
},
},
},
};
Expand Down
4 changes: 4 additions & 0 deletions packages/whook-oauth2/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import {
getOAuth2AuthorizeRedirectURIParameter,
getOAuth2AuthorizeScopeParameter,
getOAuth2AuthorizeStateParameter,
getOAuth2AuthorizeCodeChallengeParameter,
getOAuth2AuthorizeCodeChallengeMethodParameter,
initPostOAuth2Acknowledge,
postOAuth2AcknowledgeDefinition,
initPostOAuth2Token,
Expand Down Expand Up @@ -136,6 +138,8 @@ describe('OAuth2 server', () => {
getOAuth2AuthorizeRedirectURIParameter,
getOAuth2AuthorizeScopeParameter,
getOAuth2AuthorizeStateParameter,
getOAuth2AuthorizeCodeChallengeParameter,
getOAuth2AuthorizeCodeChallengeMethodParameter,
].reduce(
(parametersHash, { name, parameter }) => ({
...parametersHash,
Expand Down
13 changes: 12 additions & 1 deletion packages/whook-oauth2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import initGetOAuth2Authorize, {
redirectURIParameter as getOAuth2AuthorizeRedirectURIParameter,
scopeParameter as getOAuth2AuthorizeScopeParameter,
stateParameter as getOAuth2AuthorizeStateParameter,
codeChallengeParameter as getOAuth2AuthorizeCodeChallengeParameter,
codeChallengeMethodParameter as getOAuth2AuthorizeCodeChallengeMethodParameter,
} from './handlers/getOAuth2Authorize.js';
import initPostOAuth2Acknowledge, {
definition as postOAuth2AcknowledgeDefinition,
Expand All @@ -21,10 +23,14 @@ import initOAuth2Granters, {
OAUTH2_ERRORS_DESCRIPTORS,
} from './services/oAuth2Granters.js';
import initOAuth2ClientCredentialsGranter from './services/oAuth2ClientCredentialsGranter.js';
import initOAuth2CodeGranter from './services/oAuth2CodeGranter.js';
import initOAuth2PasswordGranter from './services/oAuth2PasswordGranter.js';
import initOAuth2RefreshTokenGranter from './services/oAuth2RefreshTokenGranter.js';
import initOAuth2TokenGranter from './services/oAuth2TokenGranter.js';
import initOAuth2CodeGranter, {
base64UrlEncode,
hashCodeVerifier,
} from './services/oAuth2CodeGranter.js';
import type { CodeChallengeMethod } from './services/oAuth2CodeGranter.js';
import type {
OAuth2CodeService,
OAuth2PasswordService,
Expand Down Expand Up @@ -57,6 +63,7 @@ import type {
} from './services/authCookies.js';

export type {
CodeChallengeMethod,
OAuth2CodeService,
OAuth2PasswordService,
OAuth2AccessTokenService,
Expand All @@ -79,6 +86,10 @@ export {
getOAuth2AuthorizeRedirectURIParameter,
getOAuth2AuthorizeScopeParameter,
getOAuth2AuthorizeStateParameter,
getOAuth2AuthorizeCodeChallengeParameter,
getOAuth2AuthorizeCodeChallengeMethodParameter,
base64UrlEncode,
hashCodeVerifier,
initPostOAuth2Acknowledge,
postOAuth2AcknowledgeDefinition,
initPostOAuth2Token,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ exports[`OAuth2CodeGranter should work with a complete valid flow 2`] = `
},
"yolo",
"https://www.example.com/oauth2/code",
"",
],
],
"oAuth2CodeCreateCalls": [
Expand All @@ -42,7 +43,10 @@ exports[`OAuth2CodeGranter should work with a complete valid flow 2`] = `
"scope": "user",
},
"https://www.example.com/oauth2/code",
{},
{
"codeChallenge": "",
"codeChallengeMethod": "plain",
},
],
],
}
Expand Down
84 changes: 75 additions & 9 deletions packages/whook-oauth2/src/services/oAuth2CodeGranter.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { describe, it, beforeEach, jest, expect } from '@jest/globals';
import { describe, test, beforeEach, jest, expect } from '@jest/globals';
import { BaseAuthenticationData } from '@whook/authorization';
import initOAuth2CodeGranter from './oAuth2CodeGranter.js';
import {
CheckApplicationService,
OAuth2CodeService,
} from './oAuth2Granters.js';
import initOAuth2CodeGranter, {
base64UrlEncode,
hashCodeVerifier,
} from './oAuth2CodeGranter.js';

describe('OAuth2CodeGranter', () => {
const oAuth2Code = {
Expand Down Expand Up @@ -33,7 +36,7 @@ describe('OAuth2CodeGranter', () => {
log.mockReset();
});

it('should work with a complete valid flow', async () => {
test('should work with a complete valid flow', async () => {
const oAuth2CodeGranter = await initOAuth2CodeGranter({
checkApplication,
oAuth2Code,
Expand All @@ -53,11 +56,17 @@ describe('OAuth2CodeGranter', () => {
scope: 'user',
});

const authorizerResult = await oAuth2CodeGranter.authorizer?.authorize({
clientId: 'abbacaca-abba-caca-abba-cacaabbacaca',
redirectURI: 'https://www.example.com/oauth2/code',
scope: 'user',
});
const authorizerResult = await oAuth2CodeGranter.authorizer?.authorize(
{
clientId: 'abbacaca-abba-caca-abba-cacaabbacaca',
redirectURI: 'https://www.example.com/oauth2/code',
scope: 'user',
},
{
codeChallenge: '',
codeChallengeMethod: 'plain',
},
);
const acknowledgerResult =
await oAuth2CodeGranter.acknowledger?.acknowledge(
{
Expand All @@ -69,14 +78,18 @@ describe('OAuth2CodeGranter', () => {
redirectURI: 'https://www.example.com/oauth2/code',
scope: 'user',
},
{},
{
codeChallenge: '',
codeChallengeMethod: 'plain',
},
);
const authenticatorResult =
await oAuth2CodeGranter.authenticator?.authenticate(
{
clientId: 'abbacaca-abba-caca-abba-cacaabbacaca',
redirectURI: 'https://www.example.com/oauth2/code',
code: 'yolo',
codeVerifier: '',
},
{
applicationId: 'abbacaca-abba-caca-abba-cacaabbacaca',
Expand All @@ -103,6 +116,8 @@ describe('OAuth2CodeGranter', () => {
},
"authorizerResult": {
"applicationId": "abbacaca-abba-caca-abba-cacaabbacaca",
"codeChallenge": "",
"codeChallengeMethod": "plain",
"redirectURI": "https://www.example.com",
"scope": "user",
},
Expand All @@ -116,3 +131,54 @@ describe('OAuth2CodeGranter', () => {
}).toMatchSnapshot();
});
});

describe('base64UrlEncode()', () => {
test('should work like here https://tools.ietf.org/html/rfc7636#appendix-A', () => {
expect(
base64UrlEncode(
Buffer.from([
116, 24, 223, 180, 151, 153, 224, 37, 79, 250, 96, 125, 216, 173, 187,
186, 22, 212, 37, 77, 105, 214, 191, 240, 91, 88, 5, 88, 83, 132, 141,
121,
]),
),
).toEqual('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk');
});
});

describe('base64UrlEncode()', () => {
test('should work with plain method', () => {
expect(
hashCodeVerifier(
Buffer.from('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'),
'plain',
),
).toEqual(Buffer.from('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'));
});

test('should work with S256 like here https://tools.ietf.org/html/rfc7636#appendix-A', () => {
expect(
hashCodeVerifier(
Buffer.from('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'),
'S256',
),
).toEqual(
Buffer.from([
19, 211, 30, 150, 26, 26, 216, 236, 47, 22, 177, 12, 76, 152, 46, 8,
118, 168, 120, 173, 109, 241, 68, 86, 110, 225, 137, 74, 203, 112, 249,
195,
]),
);
});

test('should work base64 url encode like here https://tools.ietf.org/html/rfc7636#appendix-A', () => {
expect(
base64UrlEncode(
hashCodeVerifier(
Buffer.from('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'),
'S256',
),
),
).toEqual('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
});
});
Loading

0 comments on commit 24f21ed

Please sign in to comment.