Skip to content

Commit 90e0d8c

Browse files
committed
feat: add positive tests for the Authorization Code Grant
1 parent acc70de commit 90e0d8c

11 files changed

Lines changed: 723 additions & 23 deletions

src/index.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,14 @@ program
464464
'Run conformance tests against an authorization server implementation'
465465
)
466466
.requiredOption('--url <url>', 'URL of the authorization server issuer')
467+
.requiredOption('--client-id <client>', 'Client ID')
468+
.requiredOption('--secret <secret>', 'Client Secret')
469+
.option(
470+
'-p, --port <port>',
471+
'redirect uri port',
472+
(value) => Number(value),
473+
3000
474+
)
467475
.option('-o, --output-dir <path>', 'Save results to this directory')
468476
.option(
469477
'--spec-version <version>',
@@ -492,14 +500,22 @@ program
492500
);
493501

494502
const allResults: { scenario: string; checks: ConformanceCheck[] }[] = [];
503+
const details: Record<string, unknown> = {};
495504
for (const scenarioName of scenarios) {
496505
console.log(`\n=== Running scenario: ${scenarioName} ===`);
497506
try {
498507
const result = await runAuthorizationServerConformanceTest(
499-
validated.url,
508+
validated,
500509
scenarioName,
510+
details,
501511
outputDir
502512
);
513+
if (
514+
result.checks[0].status === 'SUCCESS' &&
515+
result.checks[0].details
516+
) {
517+
details[scenarioName] = result.checks[0].details;
518+
}
503519
allResults.push({ scenario: scenarioName, checks: result.checks });
504520
} catch (error) {
505521
console.error(`Failed to run scenario ${scenarioName}:`, error);

src/runner/authorization-server.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import path from 'path';
33
import { ConformanceCheck } from '../types';
44
import { getClientScenarioForAuthorizationServer } from '../scenarios';
55
import { createResultDir } from './utils';
6+
import { AuthorizationServerOptions } from '../schemas';
67

78
export async function runAuthorizationServerConformanceTest(
8-
serverUrl: string,
9+
option: AuthorizationServerOptions,
910
scenarioName: string,
11+
details: Record<string, unknown>,
1012
outputDir?: string
1113
): Promise<{
1214
checks: ConformanceCheck[];
@@ -28,10 +30,10 @@ export async function runAuthorizationServerConformanceTest(
2830
const scenario = getClientScenarioForAuthorizationServer(scenarioName)!;
2931

3032
console.log(
31-
`Running client scenario for authorization server '${scenarioName}' against server: ${serverUrl}`
33+
`Running client scenario for authorization server '${scenarioName}' against server: ${option.url}`
3234
);
3335

34-
const checks = await scenario.run(serverUrl);
36+
const checks = await scenario.run(option, details);
3537

3638
if (resultDir) {
3739
await fs.writeFile(
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { AuthorizationCodeGrantScenario } from './authorization-code-grant.js';
3+
import { request } from 'undici';
4+
import { startCallbackServer } from '../client/auth/helpers/createCallbackServer';
5+
6+
vi.mock('undici', () => ({
7+
request: vi.fn()
8+
}));
9+
10+
vi.mock('../client/auth/helpers/createCallbackServer', () => ({
11+
startCallbackServer: vi.fn()
12+
}));
13+
14+
const mockedRequest = vi.mocked(request);
15+
const mockedStartCallbackServer = vi.mocked(startCallbackServer);
16+
17+
const SERVER_URL = 'https://example.com';
18+
const AUTHORIZATION_ENDPOINT = `${SERVER_URL}/auth`;
19+
const TOKEN_ENDPOINT = `${SERVER_URL}/token`;
20+
21+
const OPTION = {
22+
url: SERVER_URL,
23+
clientId: 'client',
24+
secret: 'secret',
25+
port: 3000
26+
};
27+
28+
const METADATA = {
29+
issuer: SERVER_URL,
30+
authorization_endpoint: AUTHORIZATION_ENDPOINT,
31+
token_endpoint: TOKEN_ENDPOINT,
32+
token_endpoint_auth_methods_supported: ['client_secret_post']
33+
};
34+
35+
const METADATA_PRIVATE_KEY_JWT = {
36+
issuer: SERVER_URL,
37+
authorization_endpoint: AUTHORIZATION_ENDPOINT,
38+
token_endpoint: TOKEN_ENDPOINT,
39+
token_endpoint_auth_methods_supported: ['private_key_jwt']
40+
};
41+
42+
const DETAILS = {
43+
'authorization-server-metadata-endpoint': {
44+
body: METADATA
45+
}
46+
};
47+
48+
const DETAILS_PRIVATE_KEY_JWT = {
49+
'authorization-server-metadata-endpoint': {
50+
body: METADATA_PRIVATE_KEY_JWT
51+
}
52+
};
53+
54+
function mockCallbackServer(
55+
scenario: AuthorizationCodeGrantScenario,
56+
buildUrl: (state: string) => string
57+
) {
58+
mockedStartCallbackServer.mockReturnValue({
59+
waitForCallback: vi.fn().mockImplementation(async () => {
60+
return buildUrl((scenario as any).state);
61+
})
62+
} as any);
63+
}
64+
65+
function mockTokenResponse(body: Record<string, unknown>) {
66+
mockedRequest.mockResolvedValue({
67+
statusCode: 200,
68+
headers: {
69+
'content-type': 'application/json',
70+
'cache-control': 'no-store'
71+
},
72+
body: {
73+
json: async () => body
74+
}
75+
} as any);
76+
}
77+
78+
describe('AuthorizationCodeGrantScenario', () => {
79+
beforeEach(() => {
80+
vi.clearAllMocks();
81+
});
82+
83+
it('returns SUCCESS for valid authorization response and token response', async () => {
84+
const scenario = new AuthorizationCodeGrantScenario();
85+
86+
mockCallbackServer(
87+
scenario,
88+
(state) =>
89+
`http://localhost:3000/callback?code=abc&state=${state}&iss=${SERVER_URL}`
90+
);
91+
92+
mockTokenResponse({
93+
access_token: 'access-token',
94+
token_type: 'Bearer'
95+
});
96+
97+
const checks = await scenario.run(OPTION, DETAILS);
98+
99+
expect(checks).toHaveLength(1);
100+
101+
const check = checks[0];
102+
103+
expect(check.status).toBe('SUCCESS');
104+
expect(check.errorMessage).toBeUndefined();
105+
106+
expect(check.details).toBeDefined();
107+
108+
expect((check.details as any).authorizationRequest).toContain(
109+
AUTHORIZATION_ENDPOINT
110+
);
111+
112+
expect((check.details as any).authorizationResponseUrl).toContain(
113+
'code=abc'
114+
);
115+
116+
expect((check.details as any).body.access_token).toBe('access-token');
117+
expect((check.details as any).body.token_type).toBe('Bearer');
118+
});
119+
120+
it('returns FAILURE when state parameter is invalid', async () => {
121+
const scenario = new AuthorizationCodeGrantScenario();
122+
123+
mockCallbackServer(
124+
scenario,
125+
() => 'http://localhost:3000/callback?code=abc&state=invalid'
126+
);
127+
128+
mockTokenResponse({
129+
access_token: 'access-token',
130+
token_type: 'Bearer'
131+
});
132+
133+
const checks = await scenario.run(OPTION, DETAILS);
134+
135+
expect(checks).toHaveLength(1);
136+
137+
const check = checks[0];
138+
139+
expect(check.status).toBe('FAILURE');
140+
expect(check.errorMessage).toContain('Invalid state parameter');
141+
});
142+
143+
it('returns FAILURE when code parameter is missing', async () => {
144+
const scenario = new AuthorizationCodeGrantScenario();
145+
146+
mockCallbackServer(
147+
scenario,
148+
(state) => `http://localhost:3000/callback?state=${state}`
149+
);
150+
151+
const checks = await scenario.run(OPTION, DETAILS);
152+
153+
expect(checks).toHaveLength(1);
154+
155+
const check = checks[0];
156+
157+
expect(check.status).toBe('FAILURE');
158+
expect(check.errorMessage).toContain('Invalid code parameter');
159+
});
160+
161+
it('returns FAILURE when iss parameter is invalid', async () => {
162+
const scenario = new AuthorizationCodeGrantScenario();
163+
164+
mockCallbackServer(
165+
scenario,
166+
(state) =>
167+
`http://localhost:3000/callback?code=abc&state=${state}&iss=https://evil.example.com`
168+
);
169+
170+
mockTokenResponse({
171+
access_token: 'access-token',
172+
token_type: 'Bearer'
173+
});
174+
175+
const checks = await scenario.run(OPTION, DETAILS);
176+
177+
expect(checks).toHaveLength(1);
178+
179+
const check = checks[0];
180+
181+
expect(check.status).toBe('FAILURE');
182+
expect(check.errorMessage).toContain('Invalid iss parameter');
183+
});
184+
185+
it('returns FAILURE when token response does not include access_token', async () => {
186+
const scenario = new AuthorizationCodeGrantScenario();
187+
188+
mockCallbackServer(
189+
scenario,
190+
(state) => `http://localhost:3000/callback?code=abc&state=${state}`
191+
);
192+
193+
mockTokenResponse({
194+
token_type: 'Bearer'
195+
});
196+
197+
const checks = await scenario.run(OPTION, DETAILS);
198+
199+
expect(checks).toHaveLength(1);
200+
201+
const check = checks[0];
202+
203+
expect(check.status).toBe('FAILURE');
204+
expect(check.errorMessage).toContain('Missing access_token');
205+
});
206+
207+
it('returns FAILURE when token response does not include token_type', async () => {
208+
const scenario = new AuthorizationCodeGrantScenario();
209+
210+
mockCallbackServer(
211+
scenario,
212+
(state) => `http://localhost:3000/callback?code=abc&state=${state}`
213+
);
214+
215+
mockTokenResponse({
216+
access_token: 'access-token'
217+
});
218+
219+
const checks = await scenario.run(OPTION, DETAILS);
220+
221+
expect(checks).toHaveLength(1);
222+
223+
const check = checks[0];
224+
225+
expect(check.status).toBe('FAILURE');
226+
expect(check.errorMessage).toContain('Missing token_type');
227+
});
228+
229+
it('returns FAILURE when token response Content-Type is invalid', async () => {
230+
const scenario = new AuthorizationCodeGrantScenario();
231+
232+
mockCallbackServer(
233+
scenario,
234+
(state) => `http://localhost:3000/callback?code=abc&state=${state}`
235+
);
236+
237+
mockedRequest.mockResolvedValue({
238+
statusCode: 200,
239+
headers: {
240+
'content-type': 'text/plain',
241+
'cache-control': 'no-store'
242+
},
243+
body: {
244+
json: async () => ({
245+
access_token: 'access-token',
246+
token_type: 'Bearer'
247+
})
248+
}
249+
} as any);
250+
251+
const checks = await scenario.run(OPTION, DETAILS);
252+
253+
expect(checks).toHaveLength(1);
254+
255+
const check = checks[0];
256+
257+
expect(check.status).toBe('FAILURE');
258+
expect(check.errorMessage).toContain('Invalid Content-Type');
259+
});
260+
261+
it('returns FAILURE when token response Cache-Control is invalid', async () => {
262+
const scenario = new AuthorizationCodeGrantScenario();
263+
264+
mockCallbackServer(
265+
scenario,
266+
(state) => `http://localhost:3000/callback?code=abc&state=${state}`
267+
);
268+
269+
mockedRequest.mockResolvedValue({
270+
statusCode: 200,
271+
headers: {
272+
'content-type': 'application/json',
273+
'cache-control': 'public'
274+
},
275+
body: {
276+
json: async () => ({
277+
access_token: 'access-token',
278+
token_type: 'Bearer'
279+
})
280+
}
281+
} as any);
282+
283+
const checks = await scenario.run(OPTION, DETAILS);
284+
285+
expect(checks).toHaveLength(1);
286+
287+
const check = checks[0];
288+
289+
expect(check.status).toBe('FAILURE');
290+
expect(check.errorMessage).toContain('Invalid Cache-Control');
291+
});
292+
293+
it('returns SKIPPED when client_secret_post and client_secret_basic are missing', async () => {
294+
const scenario = new AuthorizationCodeGrantScenario();
295+
296+
mockCallbackServer(
297+
scenario,
298+
(state) => `http://localhost:3000/callback?code=abc&state=${state}`
299+
);
300+
301+
const checks = await scenario.run(OPTION, DETAILS_PRIVATE_KEY_JWT);
302+
303+
expect(checks).toHaveLength(1);
304+
305+
const check = checks[0];
306+
307+
expect(check.status).toBe('SKIPPED');
308+
});
309+
});

0 commit comments

Comments
 (0)