Skip to content

Commit 2f8846b

Browse files
committed
feat: add positive tests for the Authorization Code Grant
1 parent 17f1f93 commit 2f8846b

10 files changed

Lines changed: 675 additions & 17 deletions

src/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,9 @@ program
463463
'Run conformance tests against an authorization server implementation'
464464
)
465465
.requiredOption('--url <url>', 'URL of the authorization server issuer')
466+
.option('--client-id <client>', 'Client ID')
467+
.option('--secret <secret>', 'Client Secret')
468+
.option('-p, --port <port>', 'redirect uri port', (value) => Number(value))
466469
.option('-o, --output-dir <path>', 'Save results to this directory')
467470
.option(
468471
'--spec-version <version>',
@@ -491,14 +494,22 @@ program
491494
);
492495

493496
const allResults: { scenario: string; checks: ConformanceCheck[] }[] = [];
497+
const details: Record<string, unknown> = {};
494498
for (const scenarioName of scenarios) {
495499
console.log(`\n=== Running scenario: ${scenarioName} ===`);
496500
try {
497501
const result = await runAuthorizationServerConformanceTest(
498-
validated.url,
502+
validated,
499503
scenarioName,
504+
details,
500505
outputDir
501506
);
507+
if (
508+
result.checks[0].status === 'SUCCESS' &&
509+
result.checks[0].details
510+
) {
511+
details[scenarioName] = result.checks[0].details;
512+
}
502513
allResults.push({ scenario: scenarioName, checks: result.checks });
503514
} catch (error) {
504515
console.error(`Failed to run scenario ${scenarioName}:`, error);

src/runner/authorization-server.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import { getClientScenarioForAuthorizationServer } from '../scenarios';
55
import { createResultDir } from './utils';
66

77
export async function runAuthorizationServerConformanceTest(
8-
serverUrl: string,
8+
option: any,
99
scenarioName: string,
10+
details: Record<string, unknown>,
1011
outputDir?: string
1112
): Promise<{
1213
checks: ConformanceCheck[];
@@ -28,10 +29,10 @@ export async function runAuthorizationServerConformanceTest(
2829
const scenario = getClientScenarioForAuthorizationServer(scenarioName)!;
2930

3031
console.log(
31-
`Running client scenario for authorization server '${scenarioName}' against server: ${serverUrl}`
32+
`Running client scenario for authorization server '${scenarioName}' against server: ${option.url}`
3233
);
3334

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

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

0 commit comments

Comments
 (0)