Skip to content
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
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 @@ -127,6 +129,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 './routes/getOAuth2Authorize.js';
import initPostOAuth2Acknowledge, {
definition as postOAuth2AcknowledgeDefinition,
Expand All @@ -22,7 +24,6 @@ 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';
Expand Down Expand Up @@ -55,6 +56,11 @@ import {
type AuthCookiesData,
type AuthHandlersConfig,
} from './services/authCookies.js';
import initOAuth2CodeGranter, {
base64UrlEncode,
hashCodeVerifier,
type CodeChallengeMethod,
} from './services/oAuth2CodeGranter.js';

declare module 'yerror' {
interface YErrorRegistry extends OAuth2YErrorRegistry {
Expand All @@ -64,6 +70,7 @@ declare module 'yerror' {

export type {
OAuth2YErrorRegistry,
CodeChallengeMethod,
OAuth2CodeService,
OAuth2PasswordService,
OAuth2AccessTokenService,
Expand All @@ -85,6 +92,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 @@ -11,7 +11,10 @@ exports[`getOAuth2Authorize should redirect 2`] = `
"redirectURI": "https://www.example.com",
"scope": "user",
},
{},
{
"codeChallenge": "",
"codeChallengeMethod": "plain",
},
],
],
"logCalls": [],
Expand Down
51 changes: 50 additions & 1 deletion packages/whook-oauth2/src/routes/getOAuth2Authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,38 @@ import {
type OAuth2GranterService,
} from '../services/oAuth2Granters.js';
import { type LogService } from 'common-services';
import { CODE_CHALLENGE_METHODS } from '../services/oAuth2CodeGranter.js';

/* Architecture Note #1: OAuth2 authorize
This endpoint simply redirect the user to the authentication
server page by first checking the application details are
fine.
*/

export const codeChallengeParameter = {
name: 'code_challenge',
parameter: {
in: 'query',
name: 'code_challenge',
required: false,
schema: {
type: 'string',
},
},
} as const satisfies WhookAPIParameterDefinition;
export const codeChallengeMethodParameter = {
name: 'code_challenge_method',
parameter: {
in: 'query',
name: 'code_challenge_method',
required: false,
schema: {
type: 'string',
enum: CODE_CHALLENGE_METHODS as unknown as string[],
},
},
} as const satisfies WhookAPIParameterDefinition;

export const responseTypeParameter = {
name: 'responseType',
parameter: {
Expand Down Expand Up @@ -94,6 +119,8 @@ export const definition = {
refersTo(redirectURIParameter),
refersTo(scopeParameter),
refersTo(stateParameter),
refersTo(codeChallengeParameter),
refersTo(codeChallengeMethodParameter),
],
responses: {
'302': {
Expand Down Expand Up @@ -158,6 +185,8 @@ async function initGetOAuth2Authorize({
redirect_uri: demandedRedirectURI = '',
scope: demandedScope = '',
state,
code_challenge: codeChallenge = '',
code_challenge_method: codeChallengeMethod = 'plain',
...authorizeParameters
},
}: {
Expand All @@ -167,6 +196,8 @@ async function initGetOAuth2Authorize({
redirect_uri?: string;
scope?: string;
state: string;
code_challenge?: string;
code_challenge_method?: string;
} & Record<string, unknown>;
}) => {
const url = new URL(OAUTH2.authenticateURL);
Expand All @@ -182,6 +213,16 @@ async function initGetOAuth2Authorize({
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']>
).authorize(
Expand All @@ -190,7 +231,15 @@ async function initGetOAuth2Authorize({
redirectURI: demandedRedirectURI,
scope: demandedScope,
},
camelCaseObjectProperties(authorizeParameters),
camelCaseObjectProperties({
...authorizeParameters,
...(responseType === 'code'
? {
codeChallenge,
codeChallengeMethod,
}
: {}),
}),
);

url.searchParams.set('type', responseType);
Expand Down
4 changes: 4 additions & 0 deletions packages/whook-oauth2/src/routes/postOAuth2Token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export const authorizationCodeTokenRequestBodySchema = {
pattern: '^https?://',
format: 'uri',
},
code_verifier: {
type: 'string',
pattern: '^[\\d\\w\\-/\\._~]+$',
},
},
},
} as const satisfies WhookAPISchemaDefinition;
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
80 changes: 73 additions & 7 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, test, beforeEach, jest, expect } from '@jest/globals';
import { type WhookAuthenticationData } from '@whook/authorization';
import initOAuth2CodeGranter from './oAuth2CodeGranter.js';
import {
type CheckApplicationService,
type OAuth2CodeService,
} from './oAuth2Granters.js';
import initOAuth2CodeGranter, {
base64UrlEncode,
hashCodeVerifier,
} from './oAuth2CodeGranter.js';

describe('OAuth2CodeGranter', () => {
const oAuth2Code = {
Expand Down Expand Up @@ -44,11 +47,17 @@ describe('OAuth2CodeGranter', () => {
[name: string]: unknown;
});

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 @@ -60,14 +69,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 @@ -94,6 +107,8 @@ describe('OAuth2CodeGranter', () => {
},
"authorizerResult": {
"applicationId": "abbacaca-abba-caca-abba-cacaabbacaca",
"codeChallenge": "",
"codeChallengeMethod": "plain",
"redirectURI": "https://www.example.com",
"scope": "user",
},
Expand All @@ -107,3 +122,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