@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.
- package/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- 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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
hdrs
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
s.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
expect(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
expect(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
expect(loc.
|
|
256
|
-
expect(loc.searchParams.get("
|
|
257
|
-
expect(loc.searchParams.get("
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
expect(
|
|
264
|
-
expect(
|
|
265
|
-
expect(loc.searchParams.get("
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
const
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
expect(pending
|
|
286
|
-
expect(pending!.
|
|
287
|
-
expect(
|
|
288
|
-
expect(
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
expect(
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
{ body: {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
expect(startLoc.searchParams.get("
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
{
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
:
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
state: "state-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
{ body: {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
expect(calls
|
|
424
|
-
expect(calls[0].
|
|
425
|
-
expect(calls[0].
|
|
426
|
-
expect(calls[0].
|
|
427
|
-
expect(calls[0].body ?? "").toContain("
|
|
428
|
-
expect(calls[0].body ?? "").toContain("
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
expect(calls[1].
|
|
432
|
-
expect(calls[1].
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
expect(res.
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
{ body: {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
expect(payload.
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
{
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
expect(profile.
|
|
507
|
-
expect(profile.
|
|
508
|
-
expect(profile.
|
|
509
|
-
expect(profile.
|
|
510
|
-
expect(
|
|
511
|
-
expect(provider.
|
|
512
|
-
expect(provider.
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
expect(profile.
|
|
527
|
-
expect(profile.
|
|
528
|
-
expect(profile.
|
|
529
|
-
expect(profile.
|
|
530
|
-
expect(
|
|
531
|
-
expect(provider.
|
|
532
|
-
expect(provider.
|
|
533
|
-
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
const
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
expect(await runWith(
|
|
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
|
+
});
|