Skip to content

Commit d17a382

Browse files
committed
Auth tests and fixes
1 parent 2b9a581 commit d17a382

File tree

2 files changed

+396
-5
lines changed

2 files changed

+396
-5
lines changed

src/client/auth.test.ts

Lines changed: 391 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
1+
import {
2+
discoverOAuthMetadata,
3+
startAuthorization,
4+
exchangeAuthorization,
5+
refreshAuthorization,
6+
registerClient,
7+
} from "./auth";
8+
9+
// Mock pkce-challenge
10+
jest.mock("pkce-challenge", () => ({
11+
__esModule: true,
12+
default: () => ({
13+
code_verifier: "test_verifier",
14+
code_challenge: "test_challenge",
15+
}),
16+
}));
17+
18+
// Mock fetch globally
19+
const mockFetch = jest.fn();
20+
global.fetch = mockFetch;
21+
22+
describe("OAuth Authorization", () => {
23+
beforeEach(() => {
24+
mockFetch.mockReset();
25+
});
26+
27+
describe("discoverOAuthMetadata", () => {
28+
const validMetadata = {
29+
issuer: "https://auth.example.com",
30+
authorization_endpoint: "https://auth.example.com/authorize",
31+
token_endpoint: "https://auth.example.com/token",
32+
registration_endpoint: "https://auth.example.com/register",
33+
response_types_supported: ["code"],
34+
code_challenge_methods_supported: ["S256"],
35+
};
36+
37+
it("returns metadata when discovery succeeds", async () => {
38+
mockFetch.mockResolvedValueOnce({
39+
ok: true,
40+
status: 200,
41+
json: async () => validMetadata,
42+
});
43+
44+
const metadata = await discoverOAuthMetadata("https://auth.example.com");
45+
expect(metadata).toEqual(validMetadata);
46+
expect(mockFetch).toHaveBeenCalledWith(
47+
expect.objectContaining({
48+
href: "https://auth.example.com/.well-known/oauth-authorization-server",
49+
})
50+
);
51+
});
52+
53+
it("returns undefined when discovery endpoint returns 404", async () => {
54+
mockFetch.mockResolvedValueOnce({
55+
ok: false,
56+
status: 404,
57+
});
58+
59+
const metadata = await discoverOAuthMetadata("https://auth.example.com");
60+
expect(metadata).toBeUndefined();
61+
});
62+
63+
it("throws on non-404 errors", async () => {
64+
mockFetch.mockResolvedValueOnce({
65+
ok: false,
66+
status: 500,
67+
});
68+
69+
await expect(
70+
discoverOAuthMetadata("https://auth.example.com")
71+
).rejects.toThrow("HTTP 500");
72+
});
73+
74+
it("validates metadata schema", async () => {
75+
mockFetch.mockResolvedValueOnce({
76+
ok: true,
77+
status: 200,
78+
json: async () => ({
79+
// Missing required fields
80+
issuer: "https://auth.example.com",
81+
}),
82+
});
83+
84+
await expect(
85+
discoverOAuthMetadata("https://auth.example.com")
86+
).rejects.toThrow();
87+
});
88+
});
89+
90+
describe("startAuthorization", () => {
91+
const validMetadata = {
92+
issuer: "https://auth.example.com",
93+
authorization_endpoint: "https://auth.example.com/authorize",
94+
token_endpoint: "https://auth.example.com/token",
95+
response_types_supported: ["code"],
96+
code_challenge_methods_supported: ["S256"],
97+
};
98+
99+
it("generates authorization URL with PKCE challenge", async () => {
100+
const { authorizationUrl, codeVerifier } = await startAuthorization(
101+
"https://auth.example.com",
102+
{
103+
redirectUrl: "http://localhost:3000/callback",
104+
}
105+
);
106+
107+
expect(authorizationUrl.toString()).toMatch(
108+
/^https:\/\/auth\.example\.com\/authorize\?/
109+
);
110+
expect(authorizationUrl.searchParams.get("response_type")).toBe("code");
111+
expect(authorizationUrl.searchParams.get("code_challenge")).toBe("test_challenge");
112+
expect(authorizationUrl.searchParams.get("code_challenge_method")).toBe(
113+
"S256"
114+
);
115+
expect(authorizationUrl.searchParams.get("redirect_uri")).toBe(
116+
"http://localhost:3000/callback"
117+
);
118+
expect(codeVerifier).toBe("test_verifier");
119+
});
120+
121+
it("uses metadata authorization_endpoint when provided", async () => {
122+
const { authorizationUrl } = await startAuthorization(
123+
"https://auth.example.com",
124+
{
125+
metadata: validMetadata,
126+
redirectUrl: "http://localhost:3000/callback",
127+
}
128+
);
129+
130+
expect(authorizationUrl.toString()).toMatch(
131+
/^https:\/\/auth\.example\.com\/authorize\?/
132+
);
133+
});
134+
135+
it("validates response type support", async () => {
136+
const metadata = {
137+
...validMetadata,
138+
response_types_supported: ["token"], // Does not support 'code'
139+
};
140+
141+
await expect(
142+
startAuthorization("https://auth.example.com", {
143+
metadata,
144+
redirectUrl: "http://localhost:3000/callback",
145+
})
146+
).rejects.toThrow(/does not support response type/);
147+
});
148+
149+
it("validates PKCE support", async () => {
150+
const metadata = {
151+
...validMetadata,
152+
response_types_supported: ["code"],
153+
code_challenge_methods_supported: ["plain"], // Does not support 'S256'
154+
};
155+
156+
await expect(
157+
startAuthorization("https://auth.example.com", {
158+
metadata,
159+
redirectUrl: "http://localhost:3000/callback",
160+
})
161+
).rejects.toThrow(/does not support code challenge method/);
162+
});
163+
});
164+
165+
describe("exchangeAuthorization", () => {
166+
const validTokens = {
167+
access_token: "access123",
168+
token_type: "Bearer",
169+
expires_in: 3600,
170+
refresh_token: "refresh123",
171+
};
172+
173+
it("exchanges code for tokens", async () => {
174+
mockFetch.mockResolvedValueOnce({
175+
ok: true,
176+
status: 200,
177+
json: async () => validTokens,
178+
});
179+
180+
const tokens = await exchangeAuthorization("https://auth.example.com", {
181+
authorizationCode: "code123",
182+
codeVerifier: "verifier123",
183+
});
184+
185+
expect(tokens).toEqual(validTokens);
186+
expect(mockFetch).toHaveBeenCalledWith(
187+
expect.objectContaining({
188+
href: "https://auth.example.com/token",
189+
}),
190+
expect.objectContaining({
191+
method: "POST",
192+
headers: {
193+
"Content-Type": "application/x-www-form-urlencoded",
194+
},
195+
})
196+
);
197+
198+
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
199+
expect(body.get("grant_type")).toBe("authorization_code");
200+
expect(body.get("code")).toBe("code123");
201+
expect(body.get("code_verifier")).toBe("verifier123");
202+
});
203+
204+
it("validates token response schema", async () => {
205+
mockFetch.mockResolvedValueOnce({
206+
ok: true,
207+
status: 200,
208+
json: async () => ({
209+
// Missing required fields
210+
access_token: "access123",
211+
}),
212+
});
213+
214+
await expect(
215+
exchangeAuthorization("https://auth.example.com", {
216+
authorizationCode: "code123",
217+
codeVerifier: "verifier123",
218+
})
219+
).rejects.toThrow();
220+
});
221+
222+
it("throws on error response", async () => {
223+
mockFetch.mockResolvedValueOnce({
224+
ok: false,
225+
status: 400,
226+
});
227+
228+
await expect(
229+
exchangeAuthorization("https://auth.example.com", {
230+
authorizationCode: "code123",
231+
codeVerifier: "verifier123",
232+
})
233+
).rejects.toThrow("Token exchange failed");
234+
});
235+
});
236+
237+
describe("refreshAuthorization", () => {
238+
const validTokens = {
239+
access_token: "newaccess123",
240+
token_type: "Bearer",
241+
expires_in: 3600,
242+
refresh_token: "newrefresh123",
243+
};
244+
245+
it("exchanges refresh token for new tokens", async () => {
246+
mockFetch.mockResolvedValueOnce({
247+
ok: true,
248+
status: 200,
249+
json: async () => validTokens,
250+
});
251+
252+
const tokens = await refreshAuthorization("https://auth.example.com", {
253+
refreshToken: "refresh123",
254+
});
255+
256+
expect(tokens).toEqual(validTokens);
257+
expect(mockFetch).toHaveBeenCalledWith(
258+
expect.objectContaining({
259+
href: "https://auth.example.com/token",
260+
}),
261+
expect.objectContaining({
262+
method: "POST",
263+
headers: {
264+
"Content-Type": "application/x-www-form-urlencoded",
265+
},
266+
})
267+
);
268+
269+
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
270+
expect(body.get("grant_type")).toBe("refresh_token");
271+
expect(body.get("refresh_token")).toBe("refresh123");
272+
});
273+
274+
it("validates token response schema", async () => {
275+
mockFetch.mockResolvedValueOnce({
276+
ok: true,
277+
status: 200,
278+
json: async () => ({
279+
// Missing required fields
280+
access_token: "newaccess123",
281+
}),
282+
});
283+
284+
await expect(
285+
refreshAuthorization("https://auth.example.com", {
286+
refreshToken: "refresh123",
287+
})
288+
).rejects.toThrow();
289+
});
290+
291+
it("throws on error response", async () => {
292+
mockFetch.mockResolvedValueOnce({
293+
ok: false,
294+
status: 400,
295+
});
296+
297+
await expect(
298+
refreshAuthorization("https://auth.example.com", {
299+
refreshToken: "refresh123",
300+
})
301+
).rejects.toThrow("Token exchange failed");
302+
});
303+
});
304+
305+
describe("registerClient", () => {
306+
const validClientMetadata = {
307+
redirect_uris: ["http://localhost:3000/callback"],
308+
client_name: "Test Client",
309+
};
310+
311+
const validClientInfo = {
312+
client_id: "client123",
313+
client_secret: "secret123",
314+
client_id_issued_at: 1612137600,
315+
client_secret_expires_at: 1612224000,
316+
...validClientMetadata,
317+
};
318+
319+
it("registers client and returns client information", async () => {
320+
mockFetch.mockResolvedValueOnce({
321+
ok: true,
322+
status: 200,
323+
json: async () => validClientInfo,
324+
});
325+
326+
const clientInfo = await registerClient("https://auth.example.com", {
327+
clientMetadata: validClientMetadata,
328+
});
329+
330+
expect(clientInfo).toEqual(validClientInfo);
331+
expect(mockFetch).toHaveBeenCalledWith(
332+
expect.objectContaining({
333+
href: "https://auth.example.com/register",
334+
}),
335+
expect.objectContaining({
336+
method: "POST",
337+
headers: {
338+
"Content-Type": "application/json",
339+
},
340+
body: JSON.stringify(validClientMetadata),
341+
})
342+
);
343+
});
344+
345+
it("validates client information response schema", async () => {
346+
mockFetch.mockResolvedValueOnce({
347+
ok: true,
348+
status: 200,
349+
json: async () => ({
350+
// Missing required fields
351+
client_secret: "secret123",
352+
}),
353+
});
354+
355+
await expect(
356+
registerClient("https://auth.example.com", {
357+
clientMetadata: validClientMetadata,
358+
})
359+
).rejects.toThrow();
360+
});
361+
362+
it("throws when registration endpoint not available in metadata", async () => {
363+
const metadata = {
364+
issuer: "https://auth.example.com",
365+
authorization_endpoint: "https://auth.example.com/authorize",
366+
token_endpoint: "https://auth.example.com/token",
367+
response_types_supported: ["code"],
368+
};
369+
370+
await expect(
371+
registerClient("https://auth.example.com", {
372+
metadata,
373+
clientMetadata: validClientMetadata,
374+
})
375+
).rejects.toThrow(/does not support dynamic client registration/);
376+
});
377+
378+
it("throws on error response", async () => {
379+
mockFetch.mockResolvedValueOnce({
380+
ok: false,
381+
status: 400,
382+
});
383+
384+
await expect(
385+
registerClient("https://auth.example.com", {
386+
clientMetadata: validClientMetadata,
387+
})
388+
).rejects.toThrow("Dynamic client registration failed");
389+
});
390+
});
391+
});

0 commit comments

Comments
 (0)