Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(@whook/oauth2): add pkce support #80

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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, location } 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 @@ -56,6 +62,7 @@ import type {
} from './services/authCookies.js';

export type {
CodeChallengeMethod,
OAuth2CodeService,
OAuth2PasswordService,
OAuth2AccessTokenService,
Expand All @@ -77,6 +84,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
Loading