Skip to content

Commit 65fa678

Browse files
committed
feat(@whook/oauth2): add pkce support
fix #58
1 parent f4569eb commit 65fa678

File tree

10 files changed

+292
-26
lines changed

10 files changed

+292
-26
lines changed

packages/whook-oauth2/src/__snapshots__/index.test.ts.snap

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ Object {
122122
},
123123
"a_grant_code",
124124
"http://redirect.example.com/yolo",
125+
undefined,
125126
],
126127
],
127128
"oAuth2CodeCreateCalls": Array [],
@@ -173,7 +174,10 @@ Object {
173174
"scope": "user",
174175
},
175176
"http://redirect.example.com/yolo?a_param=a_value",
176-
Object {},
177+
Object {
178+
"codeChallenge": "",
179+
"codeChallengeMethod": "plain",
180+
},
177181
],
178182
],
179183
"oAuth2PasswordCheckCalls": Array [],

packages/whook-oauth2/src/handlers/__snapshots__/getOAuth2Authorize.test.ts.snap

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ Object {
1111
"redirectURI": "https://www.example.com",
1212
"scope": "user",
1313
},
14-
Object {},
14+
Object {
15+
"codeChallenge": "",
16+
"codeChallengeMethod": "plain",
17+
},
1518
],
1619
],
1720
"logCalls": Array [],

packages/whook-oauth2/src/handlers/getOAuth2Authorize.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
OAuth2GranterService,
1414
} from '../services/oAuth2Granters';
1515
import type { LogService } from 'common-services';
16+
import { CODE_CHALLENGE_METHODS } from '../services/oAuth2CodeGranter';
1617

1718
/* Architecture Note #1: OAuth2 authorize
1819
This endpoint simply redirect the user to the authentication
@@ -77,6 +78,29 @@ export const stateParameter: WhookAPIParameterDefinition = {
7778
},
7879
},
7980
};
81+
export const codeChallengeParameter: WhookAPIParameterDefinition = {
82+
name: 'code_challenge',
83+
parameter: {
84+
in: 'query',
85+
name: 'code_challenge',
86+
required: false,
87+
schema: {
88+
type: 'string',
89+
},
90+
},
91+
};
92+
export const codeChallengeMethodParameter: WhookAPIParameterDefinition = {
93+
name: 'code_challenge_method',
94+
parameter: {
95+
in: 'query',
96+
name: 'code_challenge_method',
97+
required: false,
98+
schema: {
99+
type: 'string',
100+
enum: (CODE_CHALLENGE_METHODS as unknown) as string[],
101+
},
102+
},
103+
};
80104

81105
export const definition: WhookAPIHandlerDefinition = {
82106
method: 'get',
@@ -102,6 +126,12 @@ export const definition: WhookAPIHandlerDefinition = {
102126
{
103127
$ref: `#/components/parameters/${stateParameter.name}`,
104128
},
129+
{
130+
$ref: `#/components/parameters/${codeChallengeParameter.name}`,
131+
},
132+
{
133+
$ref: `#/components/parameters/${codeChallengeMethodParameter.name}`,
134+
},
105135
],
106136
responses: {
107137
'302': {
@@ -131,13 +161,17 @@ async function getOAuth2Authorize(
131161
redirect_uri: demandedRedirectURI = '',
132162
scope: demandedScope = '',
133163
state,
164+
code_challenge: codeChallenge = '',
165+
code_challenge_method: codeChallengeMethod = 'plain',
134166
...authorizeParameters
135167
}: {
136168
response_type: string;
137169
client_id: string;
138170
redirect_uri?: string;
139171
scope?: string;
140172
state: string;
173+
code_challenge?: string;
174+
code_challenge_method?: string;
141175
} & Record<string, unknown>,
142176
): Promise<WhookResponse> {
143177
const url = new URL(OAUTH2.authenticateURL);
@@ -151,6 +185,15 @@ async function getOAuth2Authorize(
151185
if (!granter) {
152186
throw new YError('E_UNKNOWN_AUTHORIZER_TYPE', responseType);
153187
}
188+
if (responseType === 'code') {
189+
if (!codeChallenge) {
190+
if (OAUTH2.forcePKCE) {
191+
throw new YError('E_PKCE_REQUIRED', responseType);
192+
}
193+
}
194+
} else if (codeChallenge) {
195+
throw new YError('E_PKCE_NOT_SUPPORTED', responseType);
196+
}
154197

155198
const {
156199
applicationId,
@@ -162,7 +205,15 @@ async function getOAuth2Authorize(
162205
redirectURI: demandedRedirectURI,
163206
scope: demandedScope,
164207
},
165-
camelCaseObjectProperties(authorizeParameters),
208+
camelCaseObjectProperties({
209+
...authorizeParameters,
210+
...(responseType === 'code'
211+
? {
212+
codeChallenge,
213+
codeChallengeMethod,
214+
}
215+
: {}),
216+
}),
166217
);
167218

168219
url.searchParams.set('type', responseType);

packages/whook-oauth2/src/handlers/postOAuth2Token.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ export const authorizationCodeTokenRequestBodySchema: WhookAPISchemaDefinition =
4040
redirect_uri: {
4141
type: 'string',
4242
},
43+
code_verifier: {
44+
type: 'string',
45+
pattern: '^[\\d\\w\\-/\\._~]+$',
46+
},
4347
},
4448
},
4549
};

packages/whook-oauth2/src/index.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
getOAuth2AuthorizeRedirectURIParameter,
2424
getOAuth2AuthorizeScopeParameter,
2525
getOAuth2AuthorizeStateParameter,
26+
getOAuth2AuthorizeCodeChallengeParameter,
27+
getOAuth2AuthorizeCodeChallengeMethodParameter,
2628
initPostOAuth2Acknowledge,
2729
postOAuth2AcknowledgeDefinition,
2830
initPostOAuth2Token,
@@ -108,6 +110,8 @@ describe('OAuth2 server', () => {
108110
getOAuth2AuthorizeRedirectURIParameter,
109111
getOAuth2AuthorizeScopeParameter,
110112
getOAuth2AuthorizeStateParameter,
113+
getOAuth2AuthorizeCodeChallengeParameter,
114+
getOAuth2AuthorizeCodeChallengeMethodParameter,
111115
].reduce(
112116
(parametersHash, { name, parameter }) => ({
113117
...parametersHash,

packages/whook-oauth2/src/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import initGetOAuth2Authorize, {
55
redirectURIParameter as getOAuth2AuthorizeRedirectURIParameter,
66
scopeParameter as getOAuth2AuthorizeScopeParameter,
77
stateParameter as getOAuth2AuthorizeStateParameter,
8+
codeChallengeParameter as getOAuth2AuthorizeCodeChallengeParameter,
9+
codeChallengeMethodParameter as getOAuth2AuthorizeCodeChallengeMethodParameter,
810
} from './handlers/getOAuth2Authorize';
911
import initPostOAuth2Acknowledge, {
1012
definition as postOAuth2AcknowledgeDefinition,
@@ -21,10 +23,14 @@ import initOAuth2Granters, {
2123
OAUTH2_ERRORS_DESCRIPTORS,
2224
} from './services/oAuth2Granters';
2325
import initOAuth2ClientCredentialsGranter from './services/oAuth2ClientCredentialsGranter';
24-
import initOAuth2CodeGranter from './services/oAuth2CodeGranter';
26+
import initOAuth2CodeGranter, {
27+
base64UrlEncode,
28+
hashCodeVerifier,
29+
} from './services/oAuth2CodeGranter';
2530
import initOAuth2PasswordGranter from './services/oAuth2PasswordGranter';
2631
import initOAuth2RefreshTokenGranter from './services/oAuth2RefreshTokenGranter';
2732
import initOAuth2TokenGranter from './services/oAuth2TokenGranter';
33+
import type { CodeChallengeMethod } from './services/oAuth2CodeGranter';
2834
import type {
2935
OAuth2CodeService,
3036
OAuth2PasswordService,
@@ -56,6 +62,7 @@ import type {
5662
} from './services/authCookies';
5763

5864
export type {
65+
CodeChallengeMethod,
5966
OAuth2CodeService,
6067
OAuth2PasswordService,
6168
OAuth2AccessTokenService,
@@ -77,6 +84,10 @@ export {
7784
getOAuth2AuthorizeRedirectURIParameter,
7885
getOAuth2AuthorizeScopeParameter,
7986
getOAuth2AuthorizeStateParameter,
87+
getOAuth2AuthorizeCodeChallengeParameter,
88+
getOAuth2AuthorizeCodeChallengeMethodParameter,
89+
base64UrlEncode,
90+
hashCodeVerifier,
8091
initPostOAuth2Acknowledge,
8192
postOAuth2AcknowledgeDefinition,
8293
initPostOAuth2Token,

packages/whook-oauth2/src/services/__snapshots__/oAuth2CodeGranter.test.ts.snap

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Object {
3333
},
3434
"yolo",
3535
"https://www.example.com/oauth2/code",
36+
"",
3637
],
3738
],
3839
"oAuth2CodeCreateCalls": Array [
@@ -42,7 +43,10 @@ Object {
4243
"scope": "user",
4344
},
4445
"https://www.example.com/oauth2/code",
45-
Object {},
46+
Object {
47+
"codeChallenge": "",
48+
"codeChallengeMethod": "plain",
49+
},
4650
],
4751
],
4852
}

packages/whook-oauth2/src/services/oAuth2CodeGranter.test.ts

Lines changed: 131 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import initOAuth2CodeGranter from './oAuth2CodeGranter';
1+
import initOAuth2CodeGranter, {
2+
base64UrlEncode,
3+
hashCodeVerifier,
4+
} from './oAuth2CodeGranter';
25

36
describe('OAuth2CodeGranter', () => {
47
const oAuth2Code = {
@@ -31,11 +34,17 @@ describe('OAuth2CodeGranter', () => {
3134
scope: 'user',
3235
});
3336

34-
const authorizerResult = await oAuth2CodeGranter.authorizer.authorize({
35-
clientId: 'abbacaca-abba-caca-abba-cacaabbacaca',
36-
redirectURI: 'https://www.example.com/oauth2/code',
37-
scope: 'user',
38-
});
37+
const authorizerResult = await oAuth2CodeGranter.authorizer.authorize(
38+
{
39+
clientId: 'abbacaca-abba-caca-abba-cacaabbacaca',
40+
redirectURI: 'https://www.example.com/oauth2/code',
41+
scope: 'user',
42+
},
43+
{
44+
codeChallenge: '',
45+
codeChallengeMethod: 'plain',
46+
},
47+
);
3948
const acknowledgerResult = await oAuth2CodeGranter.acknowledger.acknowledge(
4049
{
4150
applicationId: 'abbacaca-abba-caca-abba-cacaabbacaca',
@@ -46,13 +55,17 @@ describe('OAuth2CodeGranter', () => {
4655
redirectURI: 'https://www.example.com/oauth2/code',
4756
scope: 'user',
4857
},
49-
{},
58+
{
59+
codeChallenge: '',
60+
codeChallengeMethod: 'plain',
61+
},
5062
);
5163
const authenticatorResult = await oAuth2CodeGranter.authenticator.authenticate(
5264
{
5365
clientId: 'abbacaca-abba-caca-abba-cacaabbacaca',
5466
redirectURI: 'https://www.example.com/oauth2/code',
5567
code: 'yolo',
68+
codeVerifier: '',
5669
},
5770
{
5871
applicationId: 'abbacaca-abba-caca-abba-cacaabbacaca',
@@ -78,6 +91,8 @@ describe('OAuth2CodeGranter', () => {
7891
},
7992
"authorizerResult": Object {
8093
"applicationId": "abbacaca-abba-caca-abba-cacaabbacaca",
94+
"codeChallenge": "",
95+
"codeChallengeMethod": "plain",
8196
"redirectURI": "https://www.example.com",
8297
"scope": "user",
8398
},
@@ -91,3 +106,112 @@ describe('OAuth2CodeGranter', () => {
91106
}).toMatchSnapshot();
92107
});
93108
});
109+
110+
describe('base64UrlEncode()', () => {
111+
test('should work like here https://tools.ietf.org/html/rfc7636#appendix-A', () => {
112+
expect(
113+
base64UrlEncode(
114+
Buffer.from([
115+
116,
116+
24,
117+
223,
118+
180,
119+
151,
120+
153,
121+
224,
122+
37,
123+
79,
124+
250,
125+
96,
126+
125,
127+
216,
128+
173,
129+
187,
130+
186,
131+
22,
132+
212,
133+
37,
134+
77,
135+
105,
136+
214,
137+
191,
138+
240,
139+
91,
140+
88,
141+
5,
142+
88,
143+
83,
144+
132,
145+
141,
146+
121,
147+
]),
148+
),
149+
).toEqual('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk');
150+
});
151+
});
152+
153+
describe('base64UrlEncode()', () => {
154+
test('should work with plain method', () => {
155+
expect(
156+
hashCodeVerifier(
157+
Buffer.from('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'),
158+
'plain',
159+
),
160+
).toEqual(Buffer.from('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'));
161+
});
162+
163+
test('should work with S256 like here https://tools.ietf.org/html/rfc7636#appendix-A', () => {
164+
expect(
165+
hashCodeVerifier(
166+
Buffer.from('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'),
167+
'S256',
168+
),
169+
).toEqual(
170+
Buffer.from([
171+
19,
172+
211,
173+
30,
174+
150,
175+
26,
176+
26,
177+
216,
178+
236,
179+
47,
180+
22,
181+
177,
182+
12,
183+
76,
184+
152,
185+
46,
186+
8,
187+
118,
188+
168,
189+
120,
190+
173,
191+
109,
192+
241,
193+
68,
194+
86,
195+
110,
196+
225,
197+
137,
198+
74,
199+
203,
200+
112,
201+
249,
202+
195,
203+
]),
204+
);
205+
});
206+
207+
test('should work base64 url encode like here https://tools.ietf.org/html/rfc7636#appendix-A', () => {
208+
expect(
209+
base64UrlEncode(
210+
hashCodeVerifier(
211+
Buffer.from('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'),
212+
'S256',
213+
),
214+
),
215+
).toEqual('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
216+
});
217+
});

0 commit comments

Comments
 (0)