diff --git a/packages/whook-oauth2/src/__snapshots__/index.test.ts.snap b/packages/whook-oauth2/src/__snapshots__/index.test.ts.snap index b11555a8..cd0c662e 100644 --- a/packages/whook-oauth2/src/__snapshots__/index.test.ts.snap +++ b/packages/whook-oauth2/src/__snapshots__/index.test.ts.snap @@ -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": [], @@ -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": [], diff --git a/packages/whook-oauth2/src/handlers/__snapshots__/getOAuth2Authorize.test.ts.snap b/packages/whook-oauth2/src/handlers/__snapshots__/getOAuth2Authorize.test.ts.snap index 2e894809..e53460f0 100644 --- a/packages/whook-oauth2/src/handlers/__snapshots__/getOAuth2Authorize.test.ts.snap +++ b/packages/whook-oauth2/src/handlers/__snapshots__/getOAuth2Authorize.test.ts.snap @@ -11,7 +11,10 @@ exports[`getOAuth2Authorize should redirect 2`] = ` "redirectURI": "https://www.example.com", "scope": "user", }, - {}, + { + "codeChallenge": "", + "codeChallengeMethod": "plain", + }, ], ], "logCalls": [], diff --git a/packages/whook-oauth2/src/handlers/getOAuth2Authorize.ts b/packages/whook-oauth2/src/handlers/getOAuth2Authorize.ts index 08035612..f7ea17de 100644 --- a/packages/whook-oauth2/src/handlers/getOAuth2Authorize.ts +++ b/packages/whook-oauth2/src/handlers/getOAuth2Authorize.ts @@ -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, @@ -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', @@ -95,6 +119,8 @@ export const definition: WhookAPIHandlerDefinition = { refersTo(redirectURIParameter), refersTo(scopeParameter), refersTo(stateParameter), + refersTo(codeChallengeParameter), + refersTo(codeChallengeMethodParameter), ], responses: { '302': { @@ -124,6 +150,8 @@ async function getOAuth2Authorize( redirect_uri: demandedRedirectURI = '', scope: demandedScope = '', state, + code_challenge: codeChallenge = '', + code_challenge_method: codeChallengeMethod = 'plain', ...authorizeParameters }: { response_type: string; @@ -131,6 +159,8 @@ async function getOAuth2Authorize( redirect_uri?: string; scope?: string; state: string; + code_challenge?: string; + code_challenge_method?: string; } & Record, ): Promise> { const url = new URL(OAUTH2.authenticateURL); @@ -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 @@ -153,7 +192,15 @@ async function getOAuth2Authorize( redirectURI: demandedRedirectURI, scope: demandedScope, }, - camelCaseObjectProperties(authorizeParameters), + { + ...authorizeParameters, + ...(responseType === 'code' + ? { + codeChallenge, + codeChallengeMethod, + } + : {}), + }, ); url.searchParams.set('type', responseType); diff --git a/packages/whook-oauth2/src/handlers/postOAuth2Token.ts b/packages/whook-oauth2/src/handlers/postOAuth2Token.ts index cf020507..90bd5c73 100644 --- a/packages/whook-oauth2/src/handlers/postOAuth2Token.ts +++ b/packages/whook-oauth2/src/handlers/postOAuth2Token.ts @@ -44,6 +44,10 @@ export const authorizationCodeTokenRequestBodySchema: WhookAPISchemaDefinition = pattern: '^https?://', format: 'uri', }, + code_verifier: { + type: 'string', + pattern: '^[\\d\\w\\-/\\._~]+$', + }, }, }, }; diff --git a/packages/whook-oauth2/src/index.test.ts b/packages/whook-oauth2/src/index.test.ts index 04e71051..f5a934a6 100644 --- a/packages/whook-oauth2/src/index.test.ts +++ b/packages/whook-oauth2/src/index.test.ts @@ -33,6 +33,8 @@ import { getOAuth2AuthorizeRedirectURIParameter, getOAuth2AuthorizeScopeParameter, getOAuth2AuthorizeStateParameter, + getOAuth2AuthorizeCodeChallengeParameter, + getOAuth2AuthorizeCodeChallengeMethodParameter, initPostOAuth2Acknowledge, postOAuth2AcknowledgeDefinition, initPostOAuth2Token, @@ -131,6 +133,8 @@ describe('OAuth2 server', () => { getOAuth2AuthorizeRedirectURIParameter, getOAuth2AuthorizeScopeParameter, getOAuth2AuthorizeStateParameter, + getOAuth2AuthorizeCodeChallengeParameter, + getOAuth2AuthorizeCodeChallengeMethodParameter, ].reduce( (parametersHash, { name, parameter }) => ({ ...parametersHash, diff --git a/packages/whook-oauth2/src/index.ts b/packages/whook-oauth2/src/index.ts index e87de95c..8a01bc07 100644 --- a/packages/whook-oauth2/src/index.ts +++ b/packages/whook-oauth2/src/index.ts @@ -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, @@ -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, @@ -57,6 +63,7 @@ import type { } from './services/authCookies.js'; export type { + CodeChallengeMethod, OAuth2CodeService, OAuth2PasswordService, OAuth2AccessTokenService, @@ -79,6 +86,10 @@ export { getOAuth2AuthorizeRedirectURIParameter, getOAuth2AuthorizeScopeParameter, getOAuth2AuthorizeStateParameter, + getOAuth2AuthorizeCodeChallengeParameter, + getOAuth2AuthorizeCodeChallengeMethodParameter, + base64UrlEncode, + hashCodeVerifier, initPostOAuth2Acknowledge, postOAuth2AcknowledgeDefinition, initPostOAuth2Token, diff --git a/packages/whook-oauth2/src/services/__snapshots__/oAuth2CodeGranter.test.ts.snap b/packages/whook-oauth2/src/services/__snapshots__/oAuth2CodeGranter.test.ts.snap index 77feace7..72db754d 100644 --- a/packages/whook-oauth2/src/services/__snapshots__/oAuth2CodeGranter.test.ts.snap +++ b/packages/whook-oauth2/src/services/__snapshots__/oAuth2CodeGranter.test.ts.snap @@ -33,6 +33,7 @@ exports[`OAuth2CodeGranter should work with a complete valid flow 2`] = ` }, "yolo", "https://www.example.com/oauth2/code", + "", ], ], "oAuth2CodeCreateCalls": [ @@ -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", + }, ], ], } diff --git a/packages/whook-oauth2/src/services/oAuth2CodeGranter.test.ts b/packages/whook-oauth2/src/services/oAuth2CodeGranter.test.ts index 050a6fd0..19666a7c 100644 --- a/packages/whook-oauth2/src/services/oAuth2CodeGranter.test.ts +++ b/packages/whook-oauth2/src/services/oAuth2CodeGranter.test.ts @@ -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 = { @@ -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, @@ -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( { @@ -69,7 +78,10 @@ describe('OAuth2CodeGranter', () => { redirectURI: 'https://www.example.com/oauth2/code', scope: 'user', }, - {}, + { + codeChallenge: '', + codeChallengeMethod: 'plain', + }, ); const authenticatorResult = await oAuth2CodeGranter.authenticator?.authenticate( @@ -77,6 +89,7 @@ describe('OAuth2CodeGranter', () => { clientId: 'abbacaca-abba-caca-abba-cacaabbacaca', redirectURI: 'https://www.example.com/oauth2/code', code: 'yolo', + codeVerifier: '', }, { applicationId: 'abbacaca-abba-caca-abba-cacaabbacaca', @@ -103,6 +116,8 @@ describe('OAuth2CodeGranter', () => { }, "authorizerResult": { "applicationId": "abbacaca-abba-caca-abba-cacaabbacaca", + "codeChallenge": "", + "codeChallengeMethod": "plain", "redirectURI": "https://www.example.com", "scope": "user", }, @@ -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'); + }); +}); diff --git a/packages/whook-oauth2/src/services/oAuth2CodeGranter.ts b/packages/whook-oauth2/src/services/oAuth2CodeGranter.ts index 9dd10e22..e6508a94 100644 --- a/packages/whook-oauth2/src/services/oAuth2CodeGranter.ts +++ b/packages/whook-oauth2/src/services/oAuth2CodeGranter.ts @@ -1,6 +1,7 @@ import { autoService } from 'knifecycle'; import { noop } from '@whook/whook'; import { YError } from 'yerror'; +import { createHash } from 'crypto'; import type { LogService } from 'common-services'; import type { OAuth2GranterService, @@ -10,23 +11,34 @@ import type { import type { BaseAuthenticationData } from '@whook/authorization'; export const CODE_GRANTER_TYPE = 'code'; +export const CODE_CHALLENGE_METHODS = ['plain', 'S256'] as const; +export type CodeChallengeMethod = typeof CODE_CHALLENGE_METHODS[number]; export type OAuth2CodeGranterDependencies = { oAuth2Code: OAuth2CodeService; checkApplication: CheckApplicationService; log?: LogService; }; -export type OAuth2CodeGranterParameters = { +export type OAuth2CodeGranterAuthorizeParameters = { + codeChallenge: string; + codeChallengeMethod: CodeChallengeMethod; +}; +export type OAuth2CodeGranterAcknowledgeParameters = { + codeChallenge: string; + codeChallengeMethod: CodeChallengeMethod; +}; +export type OAuth2CodeGranterGrantParameters = { code: string; redirectURI: string; clientId: string; + codeVerifier: string; }; export type OAuth2CodeGranterService< AUTHENTICATION_DATA extends BaseAuthenticationData = BaseAuthenticationData, > = OAuth2GranterService< - Record, - Record, - OAuth2CodeGranterParameters, + OAuth2CodeGranterAuthorizeParameters & Record, + OAuth2CodeGranterAcknowledgeParameters & Record, + OAuth2CodeGranterGrantParameters & Record, AUTHENTICATION_DATA >; @@ -41,7 +53,10 @@ async function initOAuth2CodeGranter({ }: OAuth2CodeGranterDependencies): Promise { const authorizeWithCode: NonNullable< OAuth2CodeGranterService['authorizer'] - >['authorize'] = async ({ clientId, redirectURI, scope = '' }) => { + >['authorize'] = async ( + { clientId, redirectURI, scope = '' }, + { codeChallenge, codeChallengeMethod }, + ) => { const { redirectURI: finalRedirectURI } = await checkApplication({ applicationId: clientId, type: CODE_GRANTER_TYPE, @@ -53,6 +68,8 @@ async function initOAuth2CodeGranter({ applicationId: clientId, redirectURI: finalRedirectURI, scope, + codeChallenge, + codeChallengeMethod, }; }; @@ -63,12 +80,20 @@ async function initOAuth2CodeGranter({ >['acknowledge'] = async ( authenticationData, { clientId, redirectURI, scope }, - additionalParameters, + { + codeChallenge = '', + codeChallengeMethod = 'plain', + ...additionalParameters + }, ) => { const code = await oAuth2Code.create( { ...authenticationData, applicationId: clientId, scope }, redirectURI, - additionalParameters, + { + codeChallenge, + codeChallengeMethod, + ...additionalParameters, + }, ); return { @@ -82,7 +107,7 @@ async function initOAuth2CodeGranter({ const authenticateWithCode: NonNullable< OAuth2CodeGranterService['authenticator'] >['authenticate'] = async ( - { code, clientId, redirectURI }, + { code, clientId, redirectURI, codeVerifier }, authenticationData, ) => { // The client must be authenticated (for now, see below) @@ -114,6 +139,7 @@ async function initOAuth2CodeGranter({ authenticationData, code, redirectURI, + codeVerifier, ); return newAuthenticationData; @@ -137,3 +163,21 @@ async function initOAuth2CodeGranter({ }, }; } + +// See https://tools.ietf.org/html/rfc7636#appendix-A +export function base64UrlEncode(buf: Buffer): string { + let s = buf.toString('base64'); + s = s.split('=')[0]; + s = s.replace('+', '-'); + s = s.replace('/', '_'); + return s; +} + +export function hashCodeVerifier( + codeVerifier: Buffer, + method: CodeChallengeMethod, +): Buffer { + return 'plain' === method + ? codeVerifier + : createHash('sha256').update(codeVerifier).digest(); +} diff --git a/packages/whook-oauth2/src/services/oAuth2Granters.ts b/packages/whook-oauth2/src/services/oAuth2Granters.ts index 3ad2854c..055aec58 100644 --- a/packages/whook-oauth2/src/services/oAuth2Granters.ts +++ b/packages/whook-oauth2/src/services/oAuth2Granters.ts @@ -2,6 +2,7 @@ import { initializer } from 'knifecycle'; import { DEFAULT_ERROR_URI, DEFAULT_HELP_URI } from '@whook/whook'; import type { WhookErrorsDescriptors } from '@whook/whook'; import type { BaseAuthenticationData } from '@whook/authorization'; +import type { CodeChallengeMethod } from './oAuth2CodeGranter.js'; export const OAUTH2_ERRORS_DESCRIPTORS: WhookErrorsDescriptors = { E_UNKNOWN_AUTHORIZER_TYPE: { @@ -53,6 +54,20 @@ export const OAUTH2_ERRORS_DESCRIPTORS: WhookErrorsDescriptors = { uri: DEFAULT_ERROR_URI, help: DEFAULT_HELP_URI, }, + E_PKCE_REQUIRED: { + code: 'invalid_request', + status: 400, + description: 'Code challenge required', + uri: DEFAULT_ERROR_URI, + help: DEFAULT_HELP_URI, + }, + E_PKCE_NOT_SUPPORTED: { + code: 'invalid_request', + status: 400, + description: 'Code challenge not supported for that response type ($0)', + uri: DEFAULT_ERROR_URI, + help: DEFAULT_HELP_URI, + }, E_UNAUTHORIZED_CLIENT: { code: 'invalid_client', status: 401, @@ -129,12 +144,17 @@ export type OAuth2CodeService< create: ( authenticationData: AUTHENTICATION_DATA, redirectURI: string, - additionalParameters: { [name: string]: unknown }, + additionalParameters: { + codeChallenge: string; + codeChallengeMethod: CodeChallengeMethod; + [name: string]: unknown; + }, ) => Promise; check: ( authenticationData: AUTHENTICATION_DATA, code: CODE, redirectURI: string, + codeVerifier?: string, ) => Promise< AUTHENTICATION_DATA & { redirectURI: string; @@ -205,7 +225,7 @@ export type OAuth2GranterAuthorize< redirectURI: string; scope: AUTHENTICATION_DATA['scope']; }, - authorizeParameters?: AUTHORIZE_PARAMETERS, + authorizeParameters: AUTHORIZE_PARAMETERS, ) => Promise<{ applicationId: AUTHENTICATION_DATA['applicationId']; redirectURI: string; @@ -280,6 +300,7 @@ export type OAuth2GranterService< export type OAuth2Options = { authenticateURL: string; defaultToClientScope?: boolean; + forcePKCE?: boolean; }; export type OAuth2Config = {