@mandujs/core 0.41.2 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -1,574 +1,575 @@
1
- /**
2
- * OAuth middleware tests
3
- *
4
- * Fixture style mirrors `tests/middleware/session.test.ts`:
5
- * - real `Request` / `Response` / `ManduContext`
6
- * - real `createCookieSessionStorage`
7
- * - fake `fetch` injected via `_oauthWith` — no network
8
- *
9
- * The `_oauthWith` seam is the reason we can unit-test the full callback flow
10
- * without spinning up provider doubles: we record what the middleware
11
- * requested and return canned responses.
12
- */
13
- import { describe, it, expect } from "bun:test";
14
- import {
15
- _oauthWith,
16
- oauth,
17
- github,
18
- google,
19
- type OAuthOptions,
20
- type OAuthProvider,
21
- } from "../index";
22
- import { session, saveSession } from "../../session";
23
- import {
24
- Session,
25
- createCookieSessionStorage,
26
- type SessionStorage,
27
- } from "../../../filling/session";
28
- import { ManduContext } from "../../../filling/context";
29
- import { AuthenticationError } from "../../../filling/auth";
30
-
31
- // ========== Fixtures ==========
32
-
33
- const SECRET = "oauth-mw-test-secret-32bytes!!!!!";
34
-
35
- function makeReq(url: string, init: RequestInit & { cookie?: string } = {}): Request {
36
- const { cookie, headers: rawHeaders, ...rest } = init;
37
- const headers = new Headers(rawHeaders as HeadersInit | undefined);
38
- if (cookie) headers.set("cookie", cookie);
39
- return new Request(url, { ...rest, headers });
40
- }
41
-
42
- function makeCtx(req: Request): ManduContext {
43
- return new ManduContext(req);
44
- }
45
-
46
- function makeStorage(): SessionStorage {
47
- return createCookieSessionStorage({
48
- cookie: { secrets: [SECRET] },
49
- });
50
- }
51
-
52
- /** Install the session middleware on a fresh context. */
53
- async function attachSession(ctx: ManduContext, storage: SessionStorage): Promise<void> {
54
- const mw = session({ storage });
55
- await mw(ctx);
56
- }
57
-
58
- function readSetCookieLines(res: Response): string[] {
59
- return res.headers.getSetCookie?.() ?? [];
60
- }
61
-
62
- function readSetCookieValue(res: Response, name: string): string | null {
63
- const lines = readSetCookieLines(res);
64
- for (const line of lines) {
65
- if (line.startsWith(`${name}=`)) {
66
- const [nv] = line.split(";");
67
- const eq = nv.indexOf("=");
68
- if (eq <= 0) return null;
69
- return decodeURIComponent(nv.slice(eq + 1).trim());
70
- }
71
- }
72
- return null;
73
- }
74
-
75
- /** Fetch recorder — each call pushes a record and returns the next scripted response. */
76
- interface FetchCall {
77
- url: string;
78
- method: string;
79
- headers: Record<string, string>;
80
- body: string | null;
81
- }
82
-
83
- interface ScriptedResponse {
84
- status?: number;
85
- body: unknown;
86
- /** If true, `body` is already a string and is not JSON-stringified. */
87
- raw?: boolean;
88
- }
89
-
90
- function makeFetch(responses: ScriptedResponse[]): {
91
- fetch: typeof globalThis.fetch;
92
- calls: FetchCall[];
93
- } {
94
- const calls: FetchCall[] = [];
95
- const queue = [...responses];
96
-
97
- const fetchImpl = async (
98
- input: URL | RequestInfo,
99
- init?: RequestInit,
100
- ): Promise<Response> => {
101
- const url =
102
- typeof input === "string"
103
- ? input
104
- : input instanceof URL
105
- ? input.toString()
106
- : (input as Request).url;
107
- const method = (init?.method ?? "GET").toUpperCase();
108
- const rawHeaders: Record<string, string> = {};
109
- const hdrs = new Headers(init?.headers as HeadersInit | undefined);
110
- hdrs.forEach((v, k) => {
111
- rawHeaders[k] = v;
112
- });
113
- const bodyText =
114
- typeof init?.body === "string"
115
- ? init.body
116
- : init?.body === undefined || init.body === null
117
- ? null
118
- : String(init.body);
119
- calls.push({ url, method, headers: rawHeaders, body: bodyText });
120
-
121
- const next = queue.shift();
122
- if (!next) {
123
- throw new Error(`[test fetch] no scripted response for ${method} ${url}`);
124
- }
125
- const status = next.status ?? 200;
126
- const payload = next.raw ? String(next.body) : JSON.stringify(next.body);
127
- return new Response(payload, {
128
- status,
129
- headers: { "Content-Type": "application/json" },
130
- });
131
- };
132
-
133
- return { fetch: fetchImpl as typeof globalThis.fetch, calls };
134
- }
135
-
136
- /** Base options factory — tests override only what they care about. */
137
- function baseOptions(overrides: Partial<OAuthOptions> = {}): OAuthOptions {
138
- return {
139
- provider: github(),
140
- clientId: "test-client-id",
141
- clientSecret: "test-client-secret",
142
- redirectUri: "https://app.example.com/auth/github/callback",
143
- resolveUser: async () => "user-42",
144
- ...overrides,
145
- };
146
- }
147
-
148
- /**
149
- * Seed a pending OAuth record into a fresh session's cookie. Returns the
150
- * signed cookie header value suitable for `makeReq({ cookie: ... })`.
151
- *
152
- * We build the signed cookie by taking an empty session, mutating it, and
153
- * committing exactly what the start flow does at runtime.
154
- */
155
- async function seedPendingCookie(
156
- storage: SessionStorage,
157
- pending: {
158
- state: string;
159
- codeVerifier: string | null;
160
- provider: string;
161
- },
162
- ): Promise<string> {
163
- const sentinelReq = makeReq("https://app.example.com/_seed");
164
- const sentinel = makeCtx(sentinelReq);
165
- await attachSession(sentinel, storage);
166
- const s = sentinel.get<Session>("session")!;
167
- s.set("oauth:pending", pending);
168
- await saveSession(sentinel);
169
- const out = sentinel.cookies.getSetCookieHeaders();
170
- if (out.length === 0) {
171
- throw new Error("seed: no Set-Cookie produced");
172
- }
173
- // Convert Set-Cookie into a request-side cookie header.
174
- const [nameValue] = out[0].split(";");
175
- return nameValue;
176
- }
177
-
178
- // ========== Tests ==========
179
-
180
- describe("oauth middleware / path matching", () => {
181
- it("(1) passes through on non-matching path (returns void)", async () => {
182
- const storage = makeStorage();
183
- const req = makeReq("https://app.example.com/api/posts");
184
- const ctx = makeCtx(req);
185
- await attachSession(ctx, storage);
186
-
187
- const mw = oauth(baseOptions());
188
- const result = await mw(ctx);
189
- expect(result).toBeUndefined();
190
- });
191
-
192
- it("(17) passes through on /auth/<unknown-provider> (path param doesn't match configured provider name)", async () => {
193
- const storage = makeStorage();
194
- // Configure GitHub (path: /auth/github) but hit /auth/foo — should not match.
195
- const req = makeReq("https://app.example.com/auth/foo");
196
- const ctx = makeCtx(req);
197
- await attachSession(ctx, storage);
198
-
199
- const mw = oauth(baseOptions());
200
- const result = await mw(ctx);
201
- expect(result).toBeUndefined();
202
- });
203
-
204
- it("(16) custom paths config routes correctly", async () => {
205
- const storage = makeStorage();
206
- const req = makeReq("https://app.example.com/login/github");
207
- const ctx = makeCtx(req);
208
- await attachSession(ctx, storage);
209
-
210
- const { fetch: fakeFetch } = makeFetch([]);
211
- const mw = _oauthWith(
212
- fakeFetch,
213
- baseOptions({
214
- paths: { start: "/login/:provider", callback: "/login/:provider/callback" },
215
- }),
216
- );
217
-
218
- const res = await mw(ctx);
219
- expect(res).toBeInstanceOf(Response);
220
- expect((res as Response).status).toBe(302);
221
- const location = (res as Response).headers.get("location") ?? "";
222
- expect(location.startsWith("https://github.com/login/oauth/authorize")).toBe(true);
223
- });
224
- });
225
-
226
- describe("oauth middleware / start flow", () => {
227
- it("(2) throws AuthenticationError when session middleware is missing", async () => {
228
- const req = makeReq("https://app.example.com/auth/github");
229
- const ctx = makeCtx(req);
230
- // DO NOT install session.
231
-
232
- const mw = oauth(baseOptions());
233
- let captured: unknown = null;
234
- try {
235
- await mw(ctx);
236
- } catch (e) {
237
- captured = e;
238
- }
239
- expect(captured).toBeInstanceOf(AuthenticationError);
240
- expect((captured as Error).message).toContain("session middleware");
241
- });
242
-
243
- it("(3) start redirects to provider authorization endpoint with correct query params", async () => {
244
- const storage = makeStorage();
245
- const req = makeReq("https://app.example.com/auth/github");
246
- const ctx = makeCtx(req);
247
- await attachSession(ctx, storage);
248
-
249
- const mw = oauth(baseOptions());
250
- const res = (await mw(ctx)) as Response;
251
- expect(res.status).toBe(302);
252
-
253
- const loc = new URL(res.headers.get("location") ?? "");
254
- expect(loc.origin + loc.pathname).toBe("https://github.com/login/oauth/authorize");
255
- expect(loc.searchParams.get("response_type")).toBe("code");
256
- expect(loc.searchParams.get("client_id")).toBe("test-client-id");
257
- expect(loc.searchParams.get("redirect_uri")).toBe(
258
- "https://app.example.com/auth/github/callback",
259
- );
260
- expect(loc.searchParams.get("scope")).toBe("read:user user:email");
261
- const state = loc.searchParams.get("state");
262
- expect(state).not.toBeNull();
263
- expect((state ?? "").length).toBeGreaterThan(8);
264
- expect(loc.searchParams.get("code_challenge")).not.toBeNull();
265
- expect(loc.searchParams.get("code_challenge_method")).toBe("S256");
266
- });
267
-
268
- it("(4) start writes oauth:pending into session with state + codeVerifier + provider name", async () => {
269
- const storage = makeStorage();
270
- const req = makeReq("https://app.example.com/auth/github");
271
- const ctx = makeCtx(req);
272
- await attachSession(ctx, storage);
273
-
274
- const mw = oauth(baseOptions());
275
- const res = (await mw(ctx)) as Response;
276
- const loc = new URL(res.headers.get("location") ?? "");
277
- const stateFromUrl = loc.searchParams.get("state");
278
- expect(stateFromUrl).not.toBeNull();
279
-
280
- const s = ctx.get<Session>("session")!;
281
- const pending = s.get<{ state: string; codeVerifier: string | null; provider: string }>(
282
- "oauth:pending",
283
- );
284
- expect(pending).toBeDefined();
285
- expect(pending!.state).toBe(stateFromUrl as string);
286
- expect(pending!.provider).toBe("github");
287
- expect(typeof pending!.codeVerifier).toBe("string");
288
- expect((pending!.codeVerifier ?? "").length).toBeGreaterThan(32);
289
- });
290
-
291
- it("(5) start emits Set-Cookie with the session so it lands on the redirect response", async () => {
292
- const storage = makeStorage();
293
- const req = makeReq("https://app.example.com/auth/github");
294
- const ctx = makeCtx(req);
295
- await attachSession(ctx, storage);
296
-
297
- const mw = oauth(baseOptions());
298
- const res = (await mw(ctx)) as Response;
299
- const cookieVal = readSetCookieValue(res, "__session");
300
- expect(cookieVal).not.toBeNull();
301
- expect((cookieVal ?? "").length).toBeGreaterThan(0);
302
- });
303
-
304
- it("(12) pkce=false provider: start omits code_challenge, callback omits code_verifier", async () => {
305
- const storage = makeStorage();
306
-
307
- const customProvider: OAuthProvider = {
308
- ...github(),
309
- name: "custom-no-pkce",
310
- pkce: false,
311
- };
312
-
313
- // --- Start leg ---
314
- const startReq = makeReq("https://app.example.com/auth/custom-no-pkce");
315
- const startCtx = makeCtx(startReq);
316
- await attachSession(startCtx, storage);
317
-
318
- const { fetch: fakeFetch, calls } = makeFetch([
319
- { body: { access_token: "tok-1", token_type: "bearer" } },
320
- { body: { id: 1, login: "x", email: "x@y.com", name: "X", avatar_url: "u" } },
321
- ]);
322
-
323
- const options = baseOptions({ provider: customProvider });
324
- const mw = _oauthWith(fakeFetch, options);
325
-
326
- const startRes = (await mw(startCtx)) as Response;
327
- const startLoc = new URL(startRes.headers.get("location") ?? "");
328
- expect(startLoc.searchParams.get("code_challenge")).toBeNull();
329
- expect(startLoc.searchParams.get("code_challenge_method")).toBeNull();
330
- const state = startLoc.searchParams.get("state")!;
331
-
332
- // Seed a callback request carrying the pending state cookie that the
333
- // start leg would have set.
334
- const startCookieLine = readSetCookieLines(startRes).find((l) => l.startsWith("__session="))!;
335
- const [nameValue] = startCookieLine.split(";");
336
-
337
- const cbReq = makeReq(
338
- `https://app.example.com/auth/custom-no-pkce/callback?state=${state}&code=abc`,
339
- { cookie: nameValue },
340
- );
341
- const cbCtx = makeCtx(cbReq);
342
- await attachSession(cbCtx, storage);
343
-
344
- await mw(cbCtx);
345
-
346
- // First fetch call is the token exchange. Body should NOT include code_verifier.
347
- expect(calls.length).toBeGreaterThanOrEqual(1);
348
- const tokenCall = calls[0];
349
- expect(tokenCall.body ?? "").not.toContain("code_verifier");
350
- });
351
- });
352
-
353
- describe("oauth middleware / callback flow", () => {
354
- const providerName = "github";
355
-
356
- async function runCallback(
357
- overrides: {
358
- pending?: { state: string; codeVerifier: string | null; provider: string } | null;
359
- state?: string;
360
- code?: string;
361
- responses?: ScriptedResponse[];
362
- options?: Partial<OAuthOptions>;
363
- callbackPath?: string;
364
- } = {},
365
- ): Promise<{ res: Response; ctx: ManduContext; calls: FetchCall[] }> {
366
- const storage = makeStorage();
367
- const pending =
368
- overrides.pending === undefined
369
- ? { state: "state-AAA", codeVerifier: "verifier-XYZ", provider: providerName }
370
- : overrides.pending;
371
-
372
- let cookie: string | undefined;
373
- if (pending) {
374
- cookie = await seedPendingCookie(storage, pending);
375
- }
376
-
377
- const state = overrides.state ?? pending?.state ?? "state-AAA";
378
- const code = overrides.code ?? "auth-code-123";
379
- const url =
380
- `https://app.example.com${overrides.callbackPath ?? "/auth/github/callback"}` +
381
- `?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}`;
382
-
383
- const req = makeReq(url, cookie ? { cookie } : {});
384
- const ctx = makeCtx(req);
385
- await attachSession(ctx, storage);
386
-
387
- const { fetch: fakeFetch, calls } = makeFetch(overrides.responses ?? []);
388
- const mw = _oauthWith(fakeFetch, baseOptions(overrides.options));
389
- const res = (await mw(ctx)) as Response;
390
- return { res, ctx, calls };
391
- }
392
-
393
- it("(6) callback with no pending state in session → 403", async () => {
394
- const { res } = await runCallback({ pending: null });
395
- expect(res.status).toBe(403);
396
- });
397
-
398
- it("(7) callback with state mismatch → 403", async () => {
399
- const { res } = await runCallback({
400
- pending: { state: "state-REAL", codeVerifier: null, provider: providerName },
401
- state: "state-FORGED",
402
- });
403
- expect(res.status).toBe(403);
404
- });
405
-
406
- it("(8) callback happy path: exchanges code, fetches userinfo, logs user in, redirects", async () => {
407
- const resolvedUsers: string[] = [];
408
- const { res, ctx, calls } = await runCallback({
409
- responses: [
410
- { body: { access_token: "ghs_abc", token_type: "bearer" } },
411
- { body: { id: 99, email: "a@b.com", name: "A B", avatar_url: "https://a/b" } },
412
- ],
413
- options: {
414
- resolveUser: async (profile) => {
415
- resolvedUsers.push(profile.id);
416
- return "local-user-7";
417
- },
418
- },
419
- });
420
-
421
- // Two HTTP calls: token + userinfo.
422
- expect(calls.length).toBe(2);
423
- expect(calls[0].url).toBe("https://github.com/login/oauth/access_token");
424
- expect(calls[0].method).toBe("POST");
425
- expect(calls[0].headers["accept"]).toBe("application/json");
426
- expect(calls[0].body ?? "").toContain("grant_type=authorization_code");
427
- expect(calls[0].body ?? "").toContain("code=auth-code-123");
428
- expect(calls[0].body ?? "").toContain("code_verifier=verifier-XYZ");
429
-
430
- expect(calls[1].url).toBe("https://api.github.com/user");
431
- expect(calls[1].method).toBe("GET");
432
- expect(calls[1].headers["authorization"]).toBe("Bearer ghs_abc");
433
-
434
- // resolveUser saw a normalized profile.
435
- expect(resolvedUsers).toEqual(["99"]);
436
-
437
- // Response is a redirect to post-login target (default "/").
438
- expect(res.status).toBe(302);
439
- expect(res.headers.get("location")).toBe("/");
440
-
441
- // Session now carries userId.
442
- const s = ctx.get<Session>("session")!;
443
- expect(s.get<string>("userId")).toBe("local-user-7");
444
- // Pending record cleared.
445
- expect(s.get("oauth:pending")).toBeUndefined();
446
- });
447
-
448
- it("(9) callback: resolveUser returning a Response bubbles up unchanged (loginUser NOT called)", async () => {
449
- const custom = new Response("signup-needed", {
450
- status: 302,
451
- headers: { location: "/signup?provider=github" },
452
- });
453
- const { res, ctx } = await runCallback({
454
- responses: [
455
- { body: { access_token: "ghs_abc" } },
456
- { body: { id: 1 } },
457
- ],
458
- options: {
459
- resolveUser: async () => custom,
460
- },
461
- });
462
-
463
- expect(res).toBe(custom);
464
- const s = ctx.get<Session>("session")!;
465
- expect(s.get("userId")).toBeUndefined();
466
- });
467
-
468
- it("(10) callback: token endpoint 500 → 502, provider details not leaked", async () => {
469
- const { res } = await runCallback({
470
- responses: [{ status: 500, body: { error: "internal_secret_detail" } }],
471
- });
472
- expect(res.status).toBe(502);
473
- const payload = (await res.json()) as { error: string; provider: string };
474
- expect(payload.error).toBe("oauth_failed");
475
- expect(payload.provider).toBe("github");
476
- // The sensitive error string must not appear in the response body.
477
- expect(JSON.stringify(payload)).not.toContain("internal_secret_detail");
478
- });
479
-
480
- it("(11) callback: userinfo endpoint 403 → 502", async () => {
481
- const { res } = await runCallback({
482
- responses: [
483
- { body: { access_token: "ghs_abc" } },
484
- { status: 403, body: { message: "bad scope" } },
485
- ],
486
- });
487
- expect(res.status).toBe(502);
488
- const payload = (await res.json()) as { error: string };
489
- expect(payload.error).toBe("oauth_failed");
490
- });
491
- });
492
-
493
- describe("oauth middleware / provider presets", () => {
494
- it("(13) github preset normalizeProfile shape", () => {
495
- const provider = github();
496
- const raw = {
497
- id: 42,
498
- login: "octocat",
499
- email: "octo@github.com",
500
- name: "Octo Cat",
501
- avatar_url: "https://avatars/octo.png",
502
- extra: "passthrough",
503
- };
504
- const profile = provider.normalizeProfile(raw);
505
- expect(profile.id).toBe("42");
506
- expect(profile.email).toBe("octo@github.com");
507
- expect(profile.name).toBe("Octo Cat");
508
- expect(profile.avatarUrl).toBe("https://avatars/octo.png");
509
- expect(profile.raw).toBe(raw);
510
- expect(provider.name).toBe("github");
511
- expect(provider.pkce).toBe(true);
512
- expect(provider.scopes).toEqual(["read:user", "user:email"]);
513
- });
514
-
515
- it("(14) google preset normalizeProfile shape", () => {
516
- const provider = google();
517
- const raw = {
518
- sub: "1099999999999999",
519
- email: "alice@gmail.com",
520
- name: "Alice",
521
- picture: "https://lh3.googleusercontent.com/a/x",
522
- email_verified: true,
523
- };
524
- const profile = provider.normalizeProfile(raw);
525
- expect(profile.id).toBe("1099999999999999");
526
- expect(profile.email).toBe("alice@gmail.com");
527
- expect(profile.name).toBe("Alice");
528
- expect(profile.avatarUrl).toBe("https://lh3.googleusercontent.com/a/x");
529
- expect(profile.raw).toBe(raw);
530
- expect(provider.name).toBe("google");
531
- expect(provider.pkce).toBe(true);
532
- expect(provider.scopes).toEqual(["openid", "email", "profile"]);
533
- });
534
- });
535
-
536
- describe("oauth middleware / constant-time state comparison", () => {
537
- /**
538
- * Validates the behavior of the safeEqual helper as expressed through the
539
- * public API: a callback whose returned state differs from the stored one
540
- * at ANY byte position (not just the first) must still be rejected.
541
- *
542
- * If the implementation short-circuited on the first mismatched index it
543
- * would be a timing oracle; correctness is asserted here by checking that
544
- * TWO distinct mismatch shapes (early-byte-diff vs late-byte-diff) both
545
- * produce 403 i.e. the comparison looked at the whole string either way.
546
- */
547
- it("(15) equal-length states differing at any index are rejected (no short-circuit)", async () => {
548
- const storage = makeStorage();
549
-
550
- async function runWith(returnedState: string): Promise<number> {
551
- const stored = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; // 43 chars
552
- const cookie = await seedPendingCookie(storage, {
553
- state: stored,
554
- codeVerifier: null,
555
- provider: "github",
556
- });
557
- const url = `https://app.example.com/auth/github/callback?state=${encodeURIComponent(returnedState)}&code=x`;
558
- const ctx = makeCtx(makeReq(url, { cookie }));
559
- await attachSession(ctx, storage);
560
- const { fetch: fakeFetch } = makeFetch([]);
561
- const mw = _oauthWith(fakeFetch, baseOptions());
562
- const res = (await mw(ctx)) as Response;
563
- return res.status;
564
- }
565
-
566
- // Early mismatch: first byte differs.
567
- const earlyDiff = "Xbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
568
- // Late mismatch: last byte differs.
569
- const lateDiff = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFX";
570
-
571
- expect(await runWith(earlyDiff)).toBe(403);
572
- expect(await runWith(lateDiff)).toBe(403);
573
- });
574
- });
1
+ /**
2
+ * OAuth middleware tests
3
+ *
4
+ * Fixture style mirrors `tests/middleware/session.test.ts`:
5
+ * - real `Request` / `Response` / `ManduContext`
6
+ * - real `createCookieSessionStorage`
7
+ * - fake `fetch` injected via `_oauthWith` — no network
8
+ *
9
+ * The `_oauthWith` seam is the reason we can unit-test the full callback flow
10
+ * without spinning up provider doubles: we record what the middleware
11
+ * requested and return canned responses.
12
+ */
13
+ import { describe, it, expect } from "bun:test";
14
+ import {
15
+ _oauthWith,
16
+ oauth,
17
+ github,
18
+ google,
19
+ type OAuthOptions,
20
+ type OAuthProvider,
21
+ } from "../index";
22
+ import { session, saveSession } from "../../session";
23
+ import type {
24
+ Session} from "../../../filling/session";
25
+ import {
26
+ createCookieSessionStorage,
27
+ type SessionStorage,
28
+ } from "../../../filling/session";
29
+ import { ManduContext } from "../../../filling/context";
30
+ import { AuthenticationError } from "../../../filling/auth";
31
+
32
+ // ========== Fixtures ==========
33
+
34
+ const SECRET = "oauth-mw-test-secret-32bytes!!!!!";
35
+
36
+ function makeReq(url: string, init: RequestInit & { cookie?: string } = {}): Request {
37
+ const { cookie, headers: rawHeaders, ...rest } = init;
38
+ const headers = new Headers(rawHeaders as HeadersInit | undefined);
39
+ if (cookie) headers.set("cookie", cookie);
40
+ return new Request(url, { ...rest, headers });
41
+ }
42
+
43
+ function makeCtx(req: Request): ManduContext {
44
+ return new ManduContext(req);
45
+ }
46
+
47
+ function makeStorage(): SessionStorage {
48
+ return createCookieSessionStorage({
49
+ cookie: { secrets: [SECRET] },
50
+ });
51
+ }
52
+
53
+ /** Install the session middleware on a fresh context. */
54
+ async function attachSession(ctx: ManduContext, storage: SessionStorage): Promise<void> {
55
+ const mw = session({ storage });
56
+ await mw(ctx);
57
+ }
58
+
59
+ function readSetCookieLines(res: Response): string[] {
60
+ return res.headers.getSetCookie?.() ?? [];
61
+ }
62
+
63
+ function readSetCookieValue(res: Response, name: string): string | null {
64
+ const lines = readSetCookieLines(res);
65
+ for (const line of lines) {
66
+ if (line.startsWith(`${name}=`)) {
67
+ const [nv] = line.split(";");
68
+ const eq = nv.indexOf("=");
69
+ if (eq <= 0) return null;
70
+ return decodeURIComponent(nv.slice(eq + 1).trim());
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /** Fetch recorder — each call pushes a record and returns the next scripted response. */
77
+ interface FetchCall {
78
+ url: string;
79
+ method: string;
80
+ headers: Record<string, string>;
81
+ body: string | null;
82
+ }
83
+
84
+ interface ScriptedResponse {
85
+ status?: number;
86
+ body: unknown;
87
+ /** If true, `body` is already a string and is not JSON-stringified. */
88
+ raw?: boolean;
89
+ }
90
+
91
+ function makeFetch(responses: ScriptedResponse[]): {
92
+ fetch: typeof globalThis.fetch;
93
+ calls: FetchCall[];
94
+ } {
95
+ const calls: FetchCall[] = [];
96
+ const queue = [...responses];
97
+
98
+ const fetchImpl = async (
99
+ input: URL | RequestInfo,
100
+ init?: RequestInit,
101
+ ): Promise<Response> => {
102
+ const url =
103
+ typeof input === "string"
104
+ ? input
105
+ : input instanceof URL
106
+ ? input.toString()
107
+ : (input as Request).url;
108
+ const method = (init?.method ?? "GET").toUpperCase();
109
+ const rawHeaders: Record<string, string> = {};
110
+ const hdrs = new Headers(init?.headers as HeadersInit | undefined);
111
+ hdrs.forEach((v, k) => {
112
+ rawHeaders[k] = v;
113
+ });
114
+ const bodyText =
115
+ typeof init?.body === "string"
116
+ ? init.body
117
+ : init?.body === undefined || init.body === null
118
+ ? null
119
+ : String(init.body);
120
+ calls.push({ url, method, headers: rawHeaders, body: bodyText });
121
+
122
+ const next = queue.shift();
123
+ if (!next) {
124
+ throw new Error(`[test fetch] no scripted response for ${method} ${url}`);
125
+ }
126
+ const status = next.status ?? 200;
127
+ const payload = next.raw ? String(next.body) : JSON.stringify(next.body);
128
+ return new Response(payload, {
129
+ status,
130
+ headers: { "Content-Type": "application/json" },
131
+ });
132
+ };
133
+
134
+ return { fetch: fetchImpl as typeof globalThis.fetch, calls };
135
+ }
136
+
137
+ /** Base options factory tests override only what they care about. */
138
+ function baseOptions(overrides: Partial<OAuthOptions> = {}): OAuthOptions {
139
+ return {
140
+ provider: github(),
141
+ clientId: "test-client-id",
142
+ clientSecret: "test-client-secret",
143
+ redirectUri: "https://app.example.com/auth/github/callback",
144
+ resolveUser: async () => "user-42",
145
+ ...overrides,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Seed a pending OAuth record into a fresh session's cookie. Returns the
151
+ * signed cookie header value suitable for `makeReq({ cookie: ... })`.
152
+ *
153
+ * We build the signed cookie by taking an empty session, mutating it, and
154
+ * committing — exactly what the start flow does at runtime.
155
+ */
156
+ async function seedPendingCookie(
157
+ storage: SessionStorage,
158
+ pending: {
159
+ state: string;
160
+ codeVerifier: string | null;
161
+ provider: string;
162
+ },
163
+ ): Promise<string> {
164
+ const sentinelReq = makeReq("https://app.example.com/_seed");
165
+ const sentinel = makeCtx(sentinelReq);
166
+ await attachSession(sentinel, storage);
167
+ const s = sentinel.get<Session>("session")!;
168
+ s.set("oauth:pending", pending);
169
+ await saveSession(sentinel);
170
+ const out = sentinel.cookies.getSetCookieHeaders();
171
+ if (out.length === 0) {
172
+ throw new Error("seed: no Set-Cookie produced");
173
+ }
174
+ // Convert Set-Cookie into a request-side cookie header.
175
+ const [nameValue] = out[0].split(";");
176
+ return nameValue;
177
+ }
178
+
179
+ // ========== Tests ==========
180
+
181
+ describe("oauth middleware / path matching", () => {
182
+ it("(1) passes through on non-matching path (returns void)", async () => {
183
+ const storage = makeStorage();
184
+ const req = makeReq("https://app.example.com/api/posts");
185
+ const ctx = makeCtx(req);
186
+ await attachSession(ctx, storage);
187
+
188
+ const mw = oauth(baseOptions());
189
+ const result = await mw(ctx);
190
+ expect(result).toBeUndefined();
191
+ });
192
+
193
+ it("(17) passes through on /auth/<unknown-provider> (path param doesn't match configured provider name)", async () => {
194
+ const storage = makeStorage();
195
+ // Configure GitHub (path: /auth/github) but hit /auth/foo — should not match.
196
+ const req = makeReq("https://app.example.com/auth/foo");
197
+ const ctx = makeCtx(req);
198
+ await attachSession(ctx, storage);
199
+
200
+ const mw = oauth(baseOptions());
201
+ const result = await mw(ctx);
202
+ expect(result).toBeUndefined();
203
+ });
204
+
205
+ it("(16) custom paths config routes correctly", async () => {
206
+ const storage = makeStorage();
207
+ const req = makeReq("https://app.example.com/login/github");
208
+ const ctx = makeCtx(req);
209
+ await attachSession(ctx, storage);
210
+
211
+ const { fetch: fakeFetch } = makeFetch([]);
212
+ const mw = _oauthWith(
213
+ fakeFetch,
214
+ baseOptions({
215
+ paths: { start: "/login/:provider", callback: "/login/:provider/callback" },
216
+ }),
217
+ );
218
+
219
+ const res = await mw(ctx);
220
+ expect(res).toBeInstanceOf(Response);
221
+ expect((res as Response).status).toBe(302);
222
+ const location = (res as Response).headers.get("location") ?? "";
223
+ expect(location.startsWith("https://github.com/login/oauth/authorize")).toBe(true);
224
+ });
225
+ });
226
+
227
+ describe("oauth middleware / start flow", () => {
228
+ it("(2) throws AuthenticationError when session middleware is missing", async () => {
229
+ const req = makeReq("https://app.example.com/auth/github");
230
+ const ctx = makeCtx(req);
231
+ // DO NOT install session.
232
+
233
+ const mw = oauth(baseOptions());
234
+ let captured: unknown = null;
235
+ try {
236
+ await mw(ctx);
237
+ } catch (e) {
238
+ captured = e;
239
+ }
240
+ expect(captured).toBeInstanceOf(AuthenticationError);
241
+ expect((captured as Error).message).toContain("session middleware");
242
+ });
243
+
244
+ it("(3) start redirects to provider authorization endpoint with correct query params", async () => {
245
+ const storage = makeStorage();
246
+ const req = makeReq("https://app.example.com/auth/github");
247
+ const ctx = makeCtx(req);
248
+ await attachSession(ctx, storage);
249
+
250
+ const mw = oauth(baseOptions());
251
+ const res = (await mw(ctx)) as Response;
252
+ expect(res.status).toBe(302);
253
+
254
+ const loc = new URL(res.headers.get("location") ?? "");
255
+ expect(loc.origin + loc.pathname).toBe("https://github.com/login/oauth/authorize");
256
+ expect(loc.searchParams.get("response_type")).toBe("code");
257
+ expect(loc.searchParams.get("client_id")).toBe("test-client-id");
258
+ expect(loc.searchParams.get("redirect_uri")).toBe(
259
+ "https://app.example.com/auth/github/callback",
260
+ );
261
+ expect(loc.searchParams.get("scope")).toBe("read:user user:email");
262
+ const state = loc.searchParams.get("state");
263
+ expect(state).not.toBeNull();
264
+ expect((state ?? "").length).toBeGreaterThan(8);
265
+ expect(loc.searchParams.get("code_challenge")).not.toBeNull();
266
+ expect(loc.searchParams.get("code_challenge_method")).toBe("S256");
267
+ });
268
+
269
+ it("(4) start writes oauth:pending into session with state + codeVerifier + provider name", async () => {
270
+ const storage = makeStorage();
271
+ const req = makeReq("https://app.example.com/auth/github");
272
+ const ctx = makeCtx(req);
273
+ await attachSession(ctx, storage);
274
+
275
+ const mw = oauth(baseOptions());
276
+ const res = (await mw(ctx)) as Response;
277
+ const loc = new URL(res.headers.get("location") ?? "");
278
+ const stateFromUrl = loc.searchParams.get("state");
279
+ expect(stateFromUrl).not.toBeNull();
280
+
281
+ const s = ctx.get<Session>("session")!;
282
+ const pending = s.get<{ state: string; codeVerifier: string | null; provider: string }>(
283
+ "oauth:pending",
284
+ );
285
+ expect(pending).toBeDefined();
286
+ expect(pending!.state).toBe(stateFromUrl as string);
287
+ expect(pending!.provider).toBe("github");
288
+ expect(typeof pending!.codeVerifier).toBe("string");
289
+ expect((pending!.codeVerifier ?? "").length).toBeGreaterThan(32);
290
+ });
291
+
292
+ it("(5) start emits Set-Cookie with the session so it lands on the redirect response", async () => {
293
+ const storage = makeStorage();
294
+ const req = makeReq("https://app.example.com/auth/github");
295
+ const ctx = makeCtx(req);
296
+ await attachSession(ctx, storage);
297
+
298
+ const mw = oauth(baseOptions());
299
+ const res = (await mw(ctx)) as Response;
300
+ const cookieVal = readSetCookieValue(res, "__session");
301
+ expect(cookieVal).not.toBeNull();
302
+ expect((cookieVal ?? "").length).toBeGreaterThan(0);
303
+ });
304
+
305
+ it("(12) pkce=false provider: start omits code_challenge, callback omits code_verifier", async () => {
306
+ const storage = makeStorage();
307
+
308
+ const customProvider: OAuthProvider = {
309
+ ...github(),
310
+ name: "custom-no-pkce",
311
+ pkce: false,
312
+ };
313
+
314
+ // --- Start leg ---
315
+ const startReq = makeReq("https://app.example.com/auth/custom-no-pkce");
316
+ const startCtx = makeCtx(startReq);
317
+ await attachSession(startCtx, storage);
318
+
319
+ const { fetch: fakeFetch, calls } = makeFetch([
320
+ { body: { access_token: "tok-1", token_type: "bearer" } },
321
+ { body: { id: 1, login: "x", email: "x@y.com", name: "X", avatar_url: "u" } },
322
+ ]);
323
+
324
+ const options = baseOptions({ provider: customProvider });
325
+ const mw = _oauthWith(fakeFetch, options);
326
+
327
+ const startRes = (await mw(startCtx)) as Response;
328
+ const startLoc = new URL(startRes.headers.get("location") ?? "");
329
+ expect(startLoc.searchParams.get("code_challenge")).toBeNull();
330
+ expect(startLoc.searchParams.get("code_challenge_method")).toBeNull();
331
+ const state = startLoc.searchParams.get("state")!;
332
+
333
+ // Seed a callback request carrying the pending state cookie that the
334
+ // start leg would have set.
335
+ const startCookieLine = readSetCookieLines(startRes).find((l) => l.startsWith("__session="))!;
336
+ const [nameValue] = startCookieLine.split(";");
337
+
338
+ const cbReq = makeReq(
339
+ `https://app.example.com/auth/custom-no-pkce/callback?state=${state}&code=abc`,
340
+ { cookie: nameValue },
341
+ );
342
+ const cbCtx = makeCtx(cbReq);
343
+ await attachSession(cbCtx, storage);
344
+
345
+ await mw(cbCtx);
346
+
347
+ // First fetch call is the token exchange. Body should NOT include code_verifier.
348
+ expect(calls.length).toBeGreaterThanOrEqual(1);
349
+ const tokenCall = calls[0];
350
+ expect(tokenCall.body ?? "").not.toContain("code_verifier");
351
+ });
352
+ });
353
+
354
+ describe("oauth middleware / callback flow", () => {
355
+ const providerName = "github";
356
+
357
+ async function runCallback(
358
+ overrides: {
359
+ pending?: { state: string; codeVerifier: string | null; provider: string } | null;
360
+ state?: string;
361
+ code?: string;
362
+ responses?: ScriptedResponse[];
363
+ options?: Partial<OAuthOptions>;
364
+ callbackPath?: string;
365
+ } = {},
366
+ ): Promise<{ res: Response; ctx: ManduContext; calls: FetchCall[] }> {
367
+ const storage = makeStorage();
368
+ const pending =
369
+ overrides.pending === undefined
370
+ ? { state: "state-AAA", codeVerifier: "verifier-XYZ", provider: providerName }
371
+ : overrides.pending;
372
+
373
+ let cookie: string | undefined;
374
+ if (pending) {
375
+ cookie = await seedPendingCookie(storage, pending);
376
+ }
377
+
378
+ const state = overrides.state ?? pending?.state ?? "state-AAA";
379
+ const code = overrides.code ?? "auth-code-123";
380
+ const url =
381
+ `https://app.example.com${overrides.callbackPath ?? "/auth/github/callback"}` +
382
+ `?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}`;
383
+
384
+ const req = makeReq(url, cookie ? { cookie } : {});
385
+ const ctx = makeCtx(req);
386
+ await attachSession(ctx, storage);
387
+
388
+ const { fetch: fakeFetch, calls } = makeFetch(overrides.responses ?? []);
389
+ const mw = _oauthWith(fakeFetch, baseOptions(overrides.options));
390
+ const res = (await mw(ctx)) as Response;
391
+ return { res, ctx, calls };
392
+ }
393
+
394
+ it("(6) callback with no pending state in session 403", async () => {
395
+ const { res } = await runCallback({ pending: null });
396
+ expect(res.status).toBe(403);
397
+ });
398
+
399
+ it("(7) callback with state mismatch 403", async () => {
400
+ const { res } = await runCallback({
401
+ pending: { state: "state-REAL", codeVerifier: null, provider: providerName },
402
+ state: "state-FORGED",
403
+ });
404
+ expect(res.status).toBe(403);
405
+ });
406
+
407
+ it("(8) callback happy path: exchanges code, fetches userinfo, logs user in, redirects", async () => {
408
+ const resolvedUsers: string[] = [];
409
+ const { res, ctx, calls } = await runCallback({
410
+ responses: [
411
+ { body: { access_token: "ghs_abc", token_type: "bearer" } },
412
+ { body: { id: 99, email: "a@b.com", name: "A B", avatar_url: "https://a/b" } },
413
+ ],
414
+ options: {
415
+ resolveUser: async (profile) => {
416
+ resolvedUsers.push(profile.id);
417
+ return "local-user-7";
418
+ },
419
+ },
420
+ });
421
+
422
+ // Two HTTP calls: token + userinfo.
423
+ expect(calls.length).toBe(2);
424
+ expect(calls[0].url).toBe("https://github.com/login/oauth/access_token");
425
+ expect(calls[0].method).toBe("POST");
426
+ expect(calls[0].headers["accept"]).toBe("application/json");
427
+ expect(calls[0].body ?? "").toContain("grant_type=authorization_code");
428
+ expect(calls[0].body ?? "").toContain("code=auth-code-123");
429
+ expect(calls[0].body ?? "").toContain("code_verifier=verifier-XYZ");
430
+
431
+ expect(calls[1].url).toBe("https://api.github.com/user");
432
+ expect(calls[1].method).toBe("GET");
433
+ expect(calls[1].headers["authorization"]).toBe("Bearer ghs_abc");
434
+
435
+ // resolveUser saw a normalized profile.
436
+ expect(resolvedUsers).toEqual(["99"]);
437
+
438
+ // Response is a redirect to post-login target (default "/").
439
+ expect(res.status).toBe(302);
440
+ expect(res.headers.get("location")).toBe("/");
441
+
442
+ // Session now carries userId.
443
+ const s = ctx.get<Session>("session")!;
444
+ expect(s.get<string>("userId")).toBe("local-user-7");
445
+ // Pending record cleared.
446
+ expect(s.get("oauth:pending")).toBeUndefined();
447
+ });
448
+
449
+ it("(9) callback: resolveUser returning a Response bubbles up unchanged (loginUser NOT called)", async () => {
450
+ const custom = new Response("signup-needed", {
451
+ status: 302,
452
+ headers: { location: "/signup?provider=github" },
453
+ });
454
+ const { res, ctx } = await runCallback({
455
+ responses: [
456
+ { body: { access_token: "ghs_abc" } },
457
+ { body: { id: 1 } },
458
+ ],
459
+ options: {
460
+ resolveUser: async () => custom,
461
+ },
462
+ });
463
+
464
+ expect(res).toBe(custom);
465
+ const s = ctx.get<Session>("session")!;
466
+ expect(s.get("userId")).toBeUndefined();
467
+ });
468
+
469
+ it("(10) callback: token endpoint 500 502, provider details not leaked", async () => {
470
+ const { res } = await runCallback({
471
+ responses: [{ status: 500, body: { error: "internal_secret_detail" } }],
472
+ });
473
+ expect(res.status).toBe(502);
474
+ const payload = (await res.json()) as { error: string; provider: string };
475
+ expect(payload.error).toBe("oauth_failed");
476
+ expect(payload.provider).toBe("github");
477
+ // The sensitive error string must not appear in the response body.
478
+ expect(JSON.stringify(payload)).not.toContain("internal_secret_detail");
479
+ });
480
+
481
+ it("(11) callback: userinfo endpoint 403 502", async () => {
482
+ const { res } = await runCallback({
483
+ responses: [
484
+ { body: { access_token: "ghs_abc" } },
485
+ { status: 403, body: { message: "bad scope" } },
486
+ ],
487
+ });
488
+ expect(res.status).toBe(502);
489
+ const payload = (await res.json()) as { error: string };
490
+ expect(payload.error).toBe("oauth_failed");
491
+ });
492
+ });
493
+
494
+ describe("oauth middleware / provider presets", () => {
495
+ it("(13) github preset normalizeProfile shape", () => {
496
+ const provider = github();
497
+ const raw = {
498
+ id: 42,
499
+ login: "octocat",
500
+ email: "octo@github.com",
501
+ name: "Octo Cat",
502
+ avatar_url: "https://avatars/octo.png",
503
+ extra: "passthrough",
504
+ };
505
+ const profile = provider.normalizeProfile(raw);
506
+ expect(profile.id).toBe("42");
507
+ expect(profile.email).toBe("octo@github.com");
508
+ expect(profile.name).toBe("Octo Cat");
509
+ expect(profile.avatarUrl).toBe("https://avatars/octo.png");
510
+ expect(profile.raw).toBe(raw);
511
+ expect(provider.name).toBe("github");
512
+ expect(provider.pkce).toBe(true);
513
+ expect(provider.scopes).toEqual(["read:user", "user:email"]);
514
+ });
515
+
516
+ it("(14) google preset normalizeProfile shape", () => {
517
+ const provider = google();
518
+ const raw = {
519
+ sub: "1099999999999999",
520
+ email: "alice@gmail.com",
521
+ name: "Alice",
522
+ picture: "https://lh3.googleusercontent.com/a/x",
523
+ email_verified: true,
524
+ };
525
+ const profile = provider.normalizeProfile(raw);
526
+ expect(profile.id).toBe("1099999999999999");
527
+ expect(profile.email).toBe("alice@gmail.com");
528
+ expect(profile.name).toBe("Alice");
529
+ expect(profile.avatarUrl).toBe("https://lh3.googleusercontent.com/a/x");
530
+ expect(profile.raw).toBe(raw);
531
+ expect(provider.name).toBe("google");
532
+ expect(provider.pkce).toBe(true);
533
+ expect(provider.scopes).toEqual(["openid", "email", "profile"]);
534
+ });
535
+ });
536
+
537
+ describe("oauth middleware / constant-time state comparison", () => {
538
+ /**
539
+ * Validates the behavior of the safeEqual helper as expressed through the
540
+ * public API: a callback whose returned state differs from the stored one
541
+ * at ANY byte position (not just the first) must still be rejected.
542
+ *
543
+ * If the implementation short-circuited on the first mismatched index it
544
+ * would be a timing oracle; correctness is asserted here by checking that
545
+ * TWO distinct mismatch shapes (early-byte-diff vs late-byte-diff) both
546
+ * produce 403 — i.e. the comparison looked at the whole string either way.
547
+ */
548
+ it("(15) equal-length states differing at any index are rejected (no short-circuit)", async () => {
549
+ const storage = makeStorage();
550
+
551
+ async function runWith(returnedState: string): Promise<number> {
552
+ const stored = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; // 43 chars
553
+ const cookie = await seedPendingCookie(storage, {
554
+ state: stored,
555
+ codeVerifier: null,
556
+ provider: "github",
557
+ });
558
+ const url = `https://app.example.com/auth/github/callback?state=${encodeURIComponent(returnedState)}&code=x`;
559
+ const ctx = makeCtx(makeReq(url, { cookie }));
560
+ await attachSession(ctx, storage);
561
+ const { fetch: fakeFetch } = makeFetch([]);
562
+ const mw = _oauthWith(fakeFetch, baseOptions());
563
+ const res = (await mw(ctx)) as Response;
564
+ return res.status;
565
+ }
566
+
567
+ // Early mismatch: first byte differs.
568
+ const earlyDiff = "Xbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
569
+ // Late mismatch: last byte differs.
570
+ const lateDiff = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFX";
571
+
572
+ expect(await runWith(earlyDiff)).toBe(403);
573
+ expect(await runWith(lateDiff)).toBe(403);
574
+ });
575
+ });