@cloudflare/workers-oauth-provider 0.1.0 → 0.2.3
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/README.md +65 -46
- package/dist/oauth-provider.d.ts +641 -527
- package/dist/oauth-provider.js +1771 -1659
- package/package.json +10 -11
package/dist/oauth-provider.js
CHANGED
|
@@ -1,1697 +1,1809 @@
|
|
|
1
|
-
var __typeError = (msg) => {
|
|
2
|
-
throw TypeError(msg);
|
|
3
|
-
};
|
|
4
|
-
var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
|
|
5
|
-
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
|
|
6
|
-
var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
|
|
7
|
-
var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
|
|
8
|
-
|
|
9
|
-
// src/oauth-provider.ts
|
|
10
1
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
11
|
-
|
|
2
|
+
|
|
3
|
+
//#region src/oauth-provider.ts
|
|
4
|
+
if (!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags?.global_fetch_strictly_public === true)) console.warn("CIMD (Client ID Metadata Document) is disabled: add '\"compatibility_flags\": [\"global_fetch_strictly_public\"]' to your wrangler.jsonc to enable. See: https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public");
|
|
5
|
+
/**
|
|
6
|
+
* Enum representing the type of handler (ExportedHandler or WorkerEntrypoint)
|
|
7
|
+
*/
|
|
8
|
+
var HandlerType = /* @__PURE__ */ function(HandlerType$1) {
|
|
9
|
+
HandlerType$1[HandlerType$1["EXPORTED_HANDLER"] = 0] = "EXPORTED_HANDLER";
|
|
10
|
+
HandlerType$1[HandlerType$1["WORKER_ENTRYPOINT"] = 1] = "WORKER_ENTRYPOINT";
|
|
11
|
+
return HandlerType$1;
|
|
12
|
+
}(HandlerType || {});
|
|
13
|
+
/**
|
|
14
|
+
* Enum representing OAuth grant types
|
|
15
|
+
*/
|
|
16
|
+
let GrantType = /* @__PURE__ */ function(GrantType$1) {
|
|
17
|
+
GrantType$1["AUTHORIZATION_CODE"] = "authorization_code";
|
|
18
|
+
GrantType$1["REFRESH_TOKEN"] = "refresh_token";
|
|
19
|
+
GrantType$1["TOKEN_EXCHANGE"] = "urn:ietf:params:oauth:grant-type:token-exchange";
|
|
20
|
+
return GrantType$1;
|
|
21
|
+
}({});
|
|
22
|
+
/**
|
|
23
|
+
* OAuth 2.0 Provider implementation for Cloudflare Workers
|
|
24
|
+
* Implements authorization code flow with support for refresh tokens
|
|
25
|
+
* and dynamic client registration.
|
|
26
|
+
*/
|
|
12
27
|
var OAuthProvider = class {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
#impl;
|
|
29
|
+
/**
|
|
30
|
+
* Creates a new OAuth provider instance
|
|
31
|
+
* @param options - Configuration options for the provider
|
|
32
|
+
*/
|
|
33
|
+
constructor(options) {
|
|
34
|
+
this.#impl = new OAuthProviderImpl(options);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Main fetch handler for the Worker
|
|
38
|
+
* Routes requests to the appropriate handler based on the URL
|
|
39
|
+
* @param request - The HTTP request
|
|
40
|
+
* @param env - Cloudflare Worker environment variables
|
|
41
|
+
* @param ctx - Cloudflare Worker execution context
|
|
42
|
+
* @returns A Promise resolving to an HTTP Response
|
|
43
|
+
*/
|
|
44
|
+
fetch(request, env, ctx) {
|
|
45
|
+
return this.#impl.fetch(request, env, ctx);
|
|
46
|
+
}
|
|
32
47
|
};
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Gets OAuthHelpers for the given environment
|
|
50
|
+
* @param options - Configuration options for the OAuth provider
|
|
51
|
+
* @param env - Cloudflare Worker environment variables
|
|
52
|
+
* @returns An instance of OAuthHelpers
|
|
53
|
+
*/
|
|
54
|
+
function getOAuthApi(options, env) {
|
|
55
|
+
return new OAuthProviderImpl(options).createOAuthHelpers(env);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Implementation class backing OAuthProvider.
|
|
59
|
+
*
|
|
60
|
+
* We use a PImpl pattern in `OAuthProvider` to make sure we don't inadvertently export any private
|
|
61
|
+
* methods over RPC. Unfortunately, declaring a method "private" in TypeScript is merely a type
|
|
62
|
+
* annotation, and does not actually prevent the method from being called from outside the class,
|
|
63
|
+
* including over RPC.
|
|
64
|
+
*/
|
|
65
|
+
var OAuthProviderImpl = class OAuthProviderImpl {
|
|
66
|
+
/**
|
|
67
|
+
* Creates a new OAuth provider instance
|
|
68
|
+
* @param options - Configuration options for the provider
|
|
69
|
+
*/
|
|
70
|
+
constructor(options) {
|
|
71
|
+
this.typedApiHandlers = [];
|
|
72
|
+
const hasSingleHandlerConfig = !!(options.apiRoute && options.apiHandler);
|
|
73
|
+
const hasMultiHandlerConfig = !!options.apiHandlers;
|
|
74
|
+
if (hasSingleHandlerConfig && hasMultiHandlerConfig) throw new TypeError("Cannot use both apiRoute/apiHandler and apiHandlers. Use either apiRoute + apiHandler OR apiHandlers, not both.");
|
|
75
|
+
if (!hasSingleHandlerConfig && !hasMultiHandlerConfig) throw new TypeError("Must provide either apiRoute + apiHandler OR apiHandlers. No API route configuration provided.");
|
|
76
|
+
this.typedDefaultHandler = this.validateHandler(options.defaultHandler, "defaultHandler");
|
|
77
|
+
if (hasSingleHandlerConfig) {
|
|
78
|
+
const apiHandler = this.validateHandler(options.apiHandler, "apiHandler");
|
|
79
|
+
if (Array.isArray(options.apiRoute)) options.apiRoute.forEach((route, index) => {
|
|
80
|
+
this.validateEndpoint(route, `apiRoute[${index}]`);
|
|
81
|
+
this.typedApiHandlers.push([route, apiHandler]);
|
|
82
|
+
});
|
|
83
|
+
else {
|
|
84
|
+
this.validateEndpoint(options.apiRoute, "apiRoute");
|
|
85
|
+
this.typedApiHandlers.push([options.apiRoute, apiHandler]);
|
|
86
|
+
}
|
|
87
|
+
} else for (const [route, handler] of Object.entries(options.apiHandlers)) {
|
|
88
|
+
this.validateEndpoint(route, `apiHandlers key: ${route}`);
|
|
89
|
+
this.typedApiHandlers.push([route, this.validateHandler(handler, `apiHandlers[${route}]`)]);
|
|
90
|
+
}
|
|
91
|
+
this.validateEndpoint(options.authorizeEndpoint, "authorizeEndpoint");
|
|
92
|
+
this.validateEndpoint(options.tokenEndpoint, "tokenEndpoint");
|
|
93
|
+
if (options.clientRegistrationEndpoint) this.validateEndpoint(options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
|
|
94
|
+
this.options = {
|
|
95
|
+
accessTokenTTL: DEFAULT_ACCESS_TOKEN_TTL,
|
|
96
|
+
onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
|
|
97
|
+
...options
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Validates that an endpoint is either an absolute path or a full URL
|
|
102
|
+
* @param endpoint - The endpoint to validate
|
|
103
|
+
* @param name - The name of the endpoint property for error messages
|
|
104
|
+
* @throws TypeError if the endpoint is invalid
|
|
105
|
+
*/
|
|
106
|
+
validateEndpoint(endpoint, name) {
|
|
107
|
+
if (this.isPath(endpoint)) {
|
|
108
|
+
if (!endpoint.startsWith("/")) throw new TypeError(`${name} path must be an absolute path starting with /`);
|
|
109
|
+
} else try {
|
|
110
|
+
new URL(endpoint);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
throw new TypeError(`${name} must be either an absolute path starting with / or a valid URL`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Validates that a handler is either an ExportedHandler or a class extending WorkerEntrypoint
|
|
117
|
+
* @param handler - The handler to validate
|
|
118
|
+
* @param name - The name of the handler property for error messages
|
|
119
|
+
* @returns The type of the handler (EXPORTED_HANDLER or WORKER_ENTRYPOINT)
|
|
120
|
+
* @throws TypeError if the handler is invalid
|
|
121
|
+
*/
|
|
122
|
+
validateHandler(handler, name) {
|
|
123
|
+
if (typeof handler === "object" && handler !== null && typeof handler.fetch === "function") return {
|
|
124
|
+
type: HandlerType.EXPORTED_HANDLER,
|
|
125
|
+
handler
|
|
126
|
+
};
|
|
127
|
+
if (typeof handler === "function" && handler.prototype instanceof WorkerEntrypoint) return {
|
|
128
|
+
type: HandlerType.WORKER_ENTRYPOINT,
|
|
129
|
+
handler
|
|
130
|
+
};
|
|
131
|
+
throw new TypeError(`${name} must be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint`);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Main fetch handler for the Worker
|
|
135
|
+
* Routes requests to the appropriate handler based on the URL
|
|
136
|
+
* @param request - The HTTP request
|
|
137
|
+
* @param env - Cloudflare Worker environment variables
|
|
138
|
+
* @param ctx - Cloudflare Worker execution context
|
|
139
|
+
* @returns A Promise resolving to an HTTP Response
|
|
140
|
+
*/
|
|
141
|
+
async fetch(request, env, ctx) {
|
|
142
|
+
const url = new URL(request.url);
|
|
143
|
+
if (request.method === "OPTIONS") {
|
|
144
|
+
if (this.isApiRequest(url) || url.pathname === "/.well-known/oauth-authorization-server" || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return this.addCorsHeaders(new Response(null, {
|
|
145
|
+
status: 204,
|
|
146
|
+
headers: { "Content-Length": "0" }
|
|
147
|
+
}), request);
|
|
148
|
+
}
|
|
149
|
+
if (url.pathname === "/.well-known/oauth-authorization-server") {
|
|
150
|
+
const response = await this.handleMetadataDiscovery(url);
|
|
151
|
+
return this.addCorsHeaders(response, request);
|
|
152
|
+
}
|
|
153
|
+
if (this.isTokenEndpoint(url)) {
|
|
154
|
+
const parsed = await this.parseTokenEndpointRequest(request, env);
|
|
155
|
+
if (parsed instanceof Response) return this.addCorsHeaders(parsed, request);
|
|
156
|
+
let response;
|
|
157
|
+
if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, env);
|
|
158
|
+
else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env);
|
|
159
|
+
return this.addCorsHeaders(response, request);
|
|
160
|
+
}
|
|
161
|
+
if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) {
|
|
162
|
+
const response = await this.handleClientRegistration(request, env);
|
|
163
|
+
return this.addCorsHeaders(response, request);
|
|
164
|
+
}
|
|
165
|
+
if (this.isApiRequest(url)) {
|
|
166
|
+
const response = await this.handleApiRequest(request, env, ctx);
|
|
167
|
+
return this.addCorsHeaders(response, request);
|
|
168
|
+
}
|
|
169
|
+
if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
|
|
170
|
+
if (this.typedDefaultHandler.type === HandlerType.EXPORTED_HANDLER) return this.typedDefaultHandler.handler.fetch(request, env, ctx);
|
|
171
|
+
else return new this.typedDefaultHandler.handler(ctx, env).fetch(request);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Decodes a token and returns token data with decrypted props
|
|
175
|
+
* @param token - The granted token
|
|
176
|
+
* @param env - Cloudflare Worker environment variables
|
|
177
|
+
* @returns Promise resolving to token data with decrypted props, or null if token is invalid
|
|
178
|
+
*/
|
|
179
|
+
async unwrapToken(token, env) {
|
|
180
|
+
const parts = token.split(":");
|
|
181
|
+
if (!(parts.length === 3)) return null;
|
|
182
|
+
const [userId, grantId] = parts;
|
|
183
|
+
const id = await generateTokenId(token);
|
|
184
|
+
const tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
|
|
185
|
+
if (!tokenData) return null;
|
|
186
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
187
|
+
if (tokenData.expiresAt < now) return null;
|
|
188
|
+
const decryptedProps = await decryptProps(await unwrapKeyWithToken(token, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
|
|
189
|
+
const { grant } = tokenData;
|
|
190
|
+
return {
|
|
191
|
+
id: tokenData.id,
|
|
192
|
+
grantId: tokenData.grantId,
|
|
193
|
+
userId: tokenData.userId,
|
|
194
|
+
createdAt: tokenData.createdAt,
|
|
195
|
+
expiresAt: tokenData.expiresAt,
|
|
196
|
+
audience: tokenData.audience,
|
|
197
|
+
scope: tokenData.scope || grant.scope,
|
|
198
|
+
grant: {
|
|
199
|
+
clientId: grant.clientId,
|
|
200
|
+
scope: grant.scope,
|
|
201
|
+
props: decryptedProps
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Determines if an endpoint configuration is a path or a full URL
|
|
207
|
+
* @param endpoint - The endpoint configuration
|
|
208
|
+
* @returns True if the endpoint is a path (starts with /), false if it's a full URL
|
|
209
|
+
*/
|
|
210
|
+
isPath(endpoint) {
|
|
211
|
+
return endpoint.startsWith("/");
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Matches a URL against an endpoint pattern that can be a full URL or just a path
|
|
215
|
+
* @param url - The URL to check
|
|
216
|
+
* @param endpoint - The endpoint pattern (full URL or path)
|
|
217
|
+
* @returns True if the URL matches the endpoint pattern
|
|
218
|
+
*/
|
|
219
|
+
matchEndpoint(url, endpoint) {
|
|
220
|
+
if (this.isPath(endpoint)) return url.pathname === endpoint;
|
|
221
|
+
else {
|
|
222
|
+
const endpointUrl = new URL(endpoint);
|
|
223
|
+
return url.hostname === endpointUrl.hostname && url.pathname === endpointUrl.pathname;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Checks if a URL matches the configured token endpoint
|
|
228
|
+
* @param url - The URL to check
|
|
229
|
+
* @returns True if the URL matches the token endpoint
|
|
230
|
+
*/
|
|
231
|
+
isTokenEndpoint(url) {
|
|
232
|
+
return this.matchEndpoint(url, this.options.tokenEndpoint);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Checks if a URL matches the configured client registration endpoint
|
|
236
|
+
* @param url - The URL to check
|
|
237
|
+
* @returns True if the URL matches the client registration endpoint
|
|
238
|
+
*/
|
|
239
|
+
isClientRegistrationEndpoint(url) {
|
|
240
|
+
if (!this.options.clientRegistrationEndpoint) return false;
|
|
241
|
+
return this.matchEndpoint(url, this.options.clientRegistrationEndpoint);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Parses and validates a token endpoint request (used for both token exchange and revocation)
|
|
245
|
+
* @param request - The HTTP request to parse
|
|
246
|
+
* @returns Promise with parsed body and client info, or error response
|
|
247
|
+
*/
|
|
248
|
+
async parseTokenEndpointRequest(request, env) {
|
|
249
|
+
if (request.method !== "POST") return this.createErrorResponse("invalid_request", "Method not allowed", 405);
|
|
250
|
+
let contentType = request.headers.get("Content-Type") || "";
|
|
251
|
+
let body = {};
|
|
252
|
+
if (!contentType.includes("application/x-www-form-urlencoded")) return this.createErrorResponse("invalid_request", "Content-Type must be application/x-www-form-urlencoded", 400);
|
|
253
|
+
const formData = await request.formData();
|
|
254
|
+
for (const [key, value] of formData.entries()) {
|
|
255
|
+
const allValues = formData.getAll(key);
|
|
256
|
+
body[key] = allValues.length > 1 ? allValues : value;
|
|
257
|
+
}
|
|
258
|
+
const authHeader = request.headers.get("Authorization");
|
|
259
|
+
let clientId = "";
|
|
260
|
+
let clientSecret = "";
|
|
261
|
+
if (authHeader && authHeader.startsWith("Basic ")) {
|
|
262
|
+
const [id, secret] = atob(authHeader.substring(6)).split(":", 2);
|
|
263
|
+
clientId = decodeURIComponent(id);
|
|
264
|
+
clientSecret = decodeURIComponent(secret || "");
|
|
265
|
+
} else {
|
|
266
|
+
clientId = body.client_id;
|
|
267
|
+
clientSecret = body.client_secret || "";
|
|
268
|
+
}
|
|
269
|
+
if (!clientId) return this.createErrorResponse("invalid_client", "Client ID is required", 401);
|
|
270
|
+
const clientInfo = await this.getClient(env, clientId);
|
|
271
|
+
if (!clientInfo) return this.createErrorResponse("invalid_client", "Client not found", 401);
|
|
272
|
+
if (!(clientInfo.tokenEndpointAuthMethod === "none")) {
|
|
273
|
+
if (!clientSecret) return this.createErrorResponse("invalid_client", "Client authentication failed: missing client_secret", 401);
|
|
274
|
+
if (!clientInfo.clientSecret) return this.createErrorResponse("invalid_client", "Client authentication failed: client has no registered secret", 401);
|
|
275
|
+
if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createErrorResponse("invalid_client", "Client authentication failed: invalid client_secret", 401);
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
body,
|
|
279
|
+
clientInfo,
|
|
280
|
+
isRevocationRequest: !body.grant_type && !!body.token
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Checks if a URL matches a specific API route
|
|
285
|
+
* @param url - The URL to check
|
|
286
|
+
* @param route - The API route to check against
|
|
287
|
+
* @returns True if the URL matches the API route
|
|
288
|
+
*/
|
|
289
|
+
matchApiRoute(url, route) {
|
|
290
|
+
if (this.isPath(route)) return url.pathname.startsWith(route);
|
|
291
|
+
else {
|
|
292
|
+
const apiUrl = new URL(route);
|
|
293
|
+
return url.hostname === apiUrl.hostname && url.pathname.startsWith(apiUrl.pathname);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Checks if a URL is an API request based on the configured API route(s)
|
|
298
|
+
* @param url - The URL to check
|
|
299
|
+
* @returns True if the URL matches any of the API routes
|
|
300
|
+
*/
|
|
301
|
+
isApiRequest(url) {
|
|
302
|
+
for (const [route, _] of this.typedApiHandlers) if (this.matchApiRoute(url, route)) return true;
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Finds the appropriate API handler for a URL
|
|
307
|
+
* @param url - The URL to find a handler for
|
|
308
|
+
* @returns The TypedHandler for the URL, or undefined if no handler matches
|
|
309
|
+
*/
|
|
310
|
+
findApiHandlerForUrl(url) {
|
|
311
|
+
for (const [route, handler] of this.typedApiHandlers) if (this.matchApiRoute(url, route)) return handler;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Gets the full URL for an endpoint, using the provided request URL's
|
|
315
|
+
* origin for endpoints specified as just paths
|
|
316
|
+
* @param endpoint - The endpoint configuration (path or full URL)
|
|
317
|
+
* @param requestUrl - The URL of the incoming request
|
|
318
|
+
* @returns The full URL for the endpoint
|
|
319
|
+
*/
|
|
320
|
+
getFullEndpointUrl(endpoint, requestUrl) {
|
|
321
|
+
if (this.isPath(endpoint)) return `${requestUrl.origin}${endpoint}`;
|
|
322
|
+
else return endpoint;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Adds CORS headers to a response
|
|
326
|
+
* @param response - The response to add CORS headers to
|
|
327
|
+
* @param request - The original request
|
|
328
|
+
* @returns A new Response with CORS headers added
|
|
329
|
+
*/
|
|
330
|
+
addCorsHeaders(response, request) {
|
|
331
|
+
const origin = request.headers.get("Origin");
|
|
332
|
+
if (!origin) return response;
|
|
333
|
+
const newResponse = new Response(response.body, response);
|
|
334
|
+
newResponse.headers.set("Access-Control-Allow-Origin", origin);
|
|
335
|
+
newResponse.headers.set("Access-Control-Allow-Methods", "*");
|
|
336
|
+
newResponse.headers.set("Access-Control-Allow-Headers", "Authorization, *");
|
|
337
|
+
newResponse.headers.set("Access-Control-Max-Age", "86400");
|
|
338
|
+
return newResponse;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Handles the OAuth metadata discovery endpoint
|
|
342
|
+
* Implements RFC 8414 for OAuth Server Metadata
|
|
343
|
+
* @param requestUrl - The URL of the incoming request
|
|
344
|
+
* @returns Response with OAuth server metadata
|
|
345
|
+
*/
|
|
346
|
+
async handleMetadataDiscovery(requestUrl) {
|
|
347
|
+
const tokenEndpoint = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
|
|
348
|
+
const authorizeEndpoint = this.getFullEndpointUrl(this.options.authorizeEndpoint, requestUrl);
|
|
349
|
+
let registrationEndpoint = void 0;
|
|
350
|
+
if (this.options.clientRegistrationEndpoint) registrationEndpoint = this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, requestUrl);
|
|
351
|
+
const responseTypesSupported = ["code"];
|
|
352
|
+
if (this.options.allowImplicitFlow) responseTypesSupported.push("token");
|
|
353
|
+
const grantTypesSupported = [GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN];
|
|
354
|
+
if (this.options.allowTokenExchangeGrant) grantTypesSupported.push(GrantType.TOKEN_EXCHANGE);
|
|
355
|
+
const metadata = {
|
|
356
|
+
issuer: new URL(tokenEndpoint).origin,
|
|
357
|
+
authorization_endpoint: authorizeEndpoint,
|
|
358
|
+
token_endpoint: tokenEndpoint,
|
|
359
|
+
registration_endpoint: registrationEndpoint,
|
|
360
|
+
scopes_supported: this.options.scopesSupported,
|
|
361
|
+
response_types_supported: responseTypesSupported,
|
|
362
|
+
response_modes_supported: ["query"],
|
|
363
|
+
grant_types_supported: grantTypesSupported,
|
|
364
|
+
token_endpoint_auth_methods_supported: [
|
|
365
|
+
"client_secret_basic",
|
|
366
|
+
"client_secret_post",
|
|
367
|
+
"none"
|
|
368
|
+
],
|
|
369
|
+
revocation_endpoint: tokenEndpoint,
|
|
370
|
+
code_challenge_methods_supported: ["plain", "S256"],
|
|
371
|
+
client_id_metadata_document_supported: this.hasGlobalFetchStrictlyPublic()
|
|
372
|
+
};
|
|
373
|
+
return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Handles client authentication and token issuance via the token endpoint
|
|
377
|
+
* Supports authorization_code and refresh_token grant types
|
|
378
|
+
* @param body - The parsed request body
|
|
379
|
+
* @param clientInfo - The authenticated client information
|
|
380
|
+
* @param env - Cloudflare Worker environment variables
|
|
381
|
+
* @returns Response with token data or error
|
|
382
|
+
*/
|
|
383
|
+
async handleTokenRequest(body, clientInfo, env) {
|
|
384
|
+
const grantType = body.grant_type;
|
|
385
|
+
if (grantType === GrantType.AUTHORIZATION_CODE) return this.handleAuthorizationCodeGrant(body, clientInfo, env);
|
|
386
|
+
else if (grantType === GrantType.REFRESH_TOKEN) return this.handleRefreshTokenGrant(body, clientInfo, env);
|
|
387
|
+
else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return this.handleTokenExchangeGrant(body, clientInfo, env);
|
|
388
|
+
else return this.createErrorResponse("unsupported_grant_type", "Grant type not supported");
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Handles the authorization code grant type
|
|
392
|
+
* Exchanges an authorization code for access and refresh tokens
|
|
393
|
+
* @param body - The parsed request body
|
|
394
|
+
* @param clientInfo - The authenticated client information
|
|
395
|
+
* @param env - Cloudflare Worker environment variables
|
|
396
|
+
* @returns Response with token data or error
|
|
397
|
+
*/
|
|
398
|
+
async handleAuthorizationCodeGrant(body, clientInfo, env) {
|
|
399
|
+
const code = body.code;
|
|
400
|
+
const redirectUri = body.redirect_uri;
|
|
401
|
+
const codeVerifier = body.code_verifier;
|
|
402
|
+
if (!code) return this.createErrorResponse("invalid_request", "Authorization code is required");
|
|
403
|
+
const codeParts = code.split(":");
|
|
404
|
+
if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", "Invalid authorization code format");
|
|
405
|
+
const [userId, grantId, _] = codeParts;
|
|
406
|
+
const grantKey = `grant:${userId}:${grantId}`;
|
|
407
|
+
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
408
|
+
if (!grantData) return this.createErrorResponse("invalid_grant", "Grant not found or authorization code expired");
|
|
409
|
+
if (!grantData.authCodeId) return this.createErrorResponse("invalid_grant", "Authorization code already used");
|
|
410
|
+
if (await hashSecret(code) !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", "Invalid authorization code");
|
|
411
|
+
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
|
|
412
|
+
const isPkceEnabled = !!grantData.codeChallenge;
|
|
413
|
+
if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", "redirect_uri is required when not using PKCE");
|
|
414
|
+
if (redirectUri && !clientInfo.redirectUris.includes(redirectUri)) return this.createErrorResponse("invalid_grant", "Invalid redirect URI");
|
|
415
|
+
if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier provided for a flow that did not use PKCE");
|
|
416
|
+
if (isPkceEnabled) {
|
|
417
|
+
if (!codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier is required for PKCE");
|
|
418
|
+
let calculatedChallenge;
|
|
419
|
+
if (grantData.codeChallengeMethod === "S256") {
|
|
420
|
+
const data = new TextEncoder().encode(codeVerifier);
|
|
421
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
422
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
423
|
+
calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
|
|
424
|
+
} else calculatedChallenge = codeVerifier;
|
|
425
|
+
if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", "Invalid PKCE code_verifier");
|
|
426
|
+
}
|
|
427
|
+
let accessTokenTTL = this.options.accessTokenTTL;
|
|
428
|
+
let refreshTokenTTL = this.options.refreshTokenTTL;
|
|
429
|
+
const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
|
|
430
|
+
let grantEncryptionKey = encryptionKey;
|
|
431
|
+
let accessTokenEncryptionKey = encryptionKey;
|
|
432
|
+
let encryptedAccessTokenProps = grantData.encryptedProps;
|
|
433
|
+
let tokenScopes = this.downscope(body.scope, grantData.scope);
|
|
434
|
+
if (this.options.tokenExchangeCallback) {
|
|
435
|
+
const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
|
|
436
|
+
let grantProps = decryptedProps;
|
|
437
|
+
let accessTokenProps = decryptedProps;
|
|
438
|
+
const callbackOptions = {
|
|
439
|
+
grantType: GrantType.AUTHORIZATION_CODE,
|
|
440
|
+
clientId: clientInfo.clientId,
|
|
441
|
+
userId,
|
|
442
|
+
scope: grantData.scope,
|
|
443
|
+
requestedScope: tokenScopes,
|
|
444
|
+
props: decryptedProps
|
|
445
|
+
};
|
|
446
|
+
const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
|
|
447
|
+
if (callbackResult) {
|
|
448
|
+
if (callbackResult.newProps) {
|
|
449
|
+
grantProps = callbackResult.newProps;
|
|
450
|
+
if (!callbackResult.accessTokenProps) accessTokenProps = callbackResult.newProps;
|
|
451
|
+
}
|
|
452
|
+
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
453
|
+
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
454
|
+
if ("refreshTokenTTL" in callbackResult) refreshTokenTTL = callbackResult.refreshTokenTTL;
|
|
455
|
+
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
456
|
+
}
|
|
457
|
+
const grantResult = await encryptProps(grantProps);
|
|
458
|
+
grantData.encryptedProps = grantResult.encryptedData;
|
|
459
|
+
grantEncryptionKey = grantResult.key;
|
|
460
|
+
if (accessTokenProps !== grantProps) {
|
|
461
|
+
const tokenResult = await encryptProps(accessTokenProps);
|
|
462
|
+
encryptedAccessTokenProps = tokenResult.encryptedData;
|
|
463
|
+
accessTokenEncryptionKey = tokenResult.key;
|
|
464
|
+
} else {
|
|
465
|
+
encryptedAccessTokenProps = grantData.encryptedProps;
|
|
466
|
+
accessTokenEncryptionKey = grantEncryptionKey;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
470
|
+
const useRefreshToken = refreshTokenTTL !== 0;
|
|
471
|
+
delete grantData.authCodeId;
|
|
472
|
+
delete grantData.codeChallenge;
|
|
473
|
+
delete grantData.codeChallengeMethod;
|
|
474
|
+
delete grantData.authCodeWrappedKey;
|
|
475
|
+
let refreshToken;
|
|
476
|
+
if (useRefreshToken) {
|
|
477
|
+
refreshToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
478
|
+
const refreshTokenId = await generateTokenId(refreshToken);
|
|
479
|
+
const refreshTokenWrappedKey = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
|
|
480
|
+
const expiresAt = refreshTokenTTL !== void 0 ? now + refreshTokenTTL : void 0;
|
|
481
|
+
grantData.refreshTokenId = refreshTokenId;
|
|
482
|
+
grantData.refreshTokenWrappedKey = refreshTokenWrappedKey;
|
|
483
|
+
grantData.previousRefreshTokenId = void 0;
|
|
484
|
+
grantData.previousRefreshTokenWrappedKey = void 0;
|
|
485
|
+
grantData.expiresAt = expiresAt;
|
|
486
|
+
}
|
|
487
|
+
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
488
|
+
if (body.resource && grantData.resource) {
|
|
489
|
+
const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
|
|
490
|
+
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
491
|
+
for (const requested of requestedResources) if (!grantedResources.includes(requested)) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
|
|
492
|
+
}
|
|
493
|
+
const audience = parseResourceParameter(body.resource || grantData.resource);
|
|
494
|
+
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
|
|
495
|
+
const tokenResponse = {
|
|
496
|
+
access_token: await this.createAccessToken({
|
|
497
|
+
userId,
|
|
498
|
+
grantId,
|
|
499
|
+
clientId: grantData.clientId,
|
|
500
|
+
scope: tokenScopes,
|
|
501
|
+
encryptedProps: encryptedAccessTokenProps,
|
|
502
|
+
encryptionKey: accessTokenEncryptionKey,
|
|
503
|
+
expiresIn: accessTokenTTL,
|
|
504
|
+
audience,
|
|
505
|
+
env
|
|
506
|
+
}),
|
|
507
|
+
token_type: "bearer",
|
|
508
|
+
expires_in: accessTokenTTL,
|
|
509
|
+
scope: tokenScopes.join(" ")
|
|
510
|
+
};
|
|
511
|
+
if (refreshToken) tokenResponse.refresh_token = refreshToken;
|
|
512
|
+
if (audience) tokenResponse.resource = audience;
|
|
513
|
+
return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Handles the refresh token grant type
|
|
517
|
+
* Issues a new access token using a refresh token
|
|
518
|
+
* @param body - The parsed request body
|
|
519
|
+
* @param clientInfo - The authenticated client information
|
|
520
|
+
* @param env - Cloudflare Worker environment variables
|
|
521
|
+
* @returns Response with token data or error
|
|
522
|
+
*/
|
|
523
|
+
async handleRefreshTokenGrant(body, clientInfo, env) {
|
|
524
|
+
const refreshToken = body.refresh_token;
|
|
525
|
+
if (!refreshToken) return this.createErrorResponse("invalid_request", "Refresh token is required");
|
|
526
|
+
const tokenParts = refreshToken.split(":");
|
|
527
|
+
if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", "Invalid token format");
|
|
528
|
+
const [userId, grantId, _] = tokenParts;
|
|
529
|
+
const providedTokenHash = await generateTokenId(refreshToken);
|
|
530
|
+
const grantKey = `grant:${userId}:${grantId}`;
|
|
531
|
+
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
532
|
+
if (!grantData) return this.createErrorResponse("invalid_grant", "Grant not found");
|
|
533
|
+
const isCurrentToken = grantData.refreshTokenId === providedTokenHash;
|
|
534
|
+
const isPreviousToken = grantData.previousRefreshTokenId === providedTokenHash;
|
|
535
|
+
if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", "Invalid refresh token");
|
|
536
|
+
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
|
|
537
|
+
if (grantData.expiresAt !== void 0) {
|
|
538
|
+
if (Math.floor(Date.now() / 1e3) >= grantData.expiresAt) return this.createErrorResponse("invalid_grant", "Refresh token has expired");
|
|
539
|
+
}
|
|
540
|
+
const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
541
|
+
const accessTokenId = await generateTokenId(newAccessToken);
|
|
542
|
+
let accessTokenTTL = this.options.accessTokenTTL;
|
|
543
|
+
let wrappedKeyToUse;
|
|
544
|
+
if (isCurrentToken) wrappedKeyToUse = grantData.refreshTokenWrappedKey;
|
|
545
|
+
else wrappedKeyToUse = grantData.previousRefreshTokenWrappedKey;
|
|
546
|
+
const encryptionKey = await unwrapKeyWithToken(refreshToken, wrappedKeyToUse);
|
|
547
|
+
let grantEncryptionKey = encryptionKey;
|
|
548
|
+
let accessTokenEncryptionKey = encryptionKey;
|
|
549
|
+
let encryptedAccessTokenProps = grantData.encryptedProps;
|
|
550
|
+
let tokenScopes = this.downscope(body.scope, grantData.scope);
|
|
551
|
+
let grantPropsChanged = false;
|
|
552
|
+
if (this.options.tokenExchangeCallback) {
|
|
553
|
+
const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
|
|
554
|
+
let grantProps = decryptedProps;
|
|
555
|
+
let accessTokenProps = decryptedProps;
|
|
556
|
+
const callbackOptions = {
|
|
557
|
+
grantType: GrantType.REFRESH_TOKEN,
|
|
558
|
+
clientId: clientInfo.clientId,
|
|
559
|
+
userId,
|
|
560
|
+
scope: grantData.scope,
|
|
561
|
+
requestedScope: tokenScopes,
|
|
562
|
+
props: decryptedProps
|
|
563
|
+
};
|
|
564
|
+
const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
|
|
565
|
+
if (callbackResult) {
|
|
566
|
+
if (callbackResult.newProps) {
|
|
567
|
+
grantProps = callbackResult.newProps;
|
|
568
|
+
grantPropsChanged = true;
|
|
569
|
+
if (!callbackResult.accessTokenProps) accessTokenProps = callbackResult.newProps;
|
|
570
|
+
}
|
|
571
|
+
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
572
|
+
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
573
|
+
if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", "refreshTokenTTL cannot be changed during refresh token exchange");
|
|
574
|
+
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
575
|
+
}
|
|
576
|
+
if (grantPropsChanged) {
|
|
577
|
+
const grantResult = await encryptProps(grantProps);
|
|
578
|
+
grantData.encryptedProps = grantResult.encryptedData;
|
|
579
|
+
if (grantResult.key !== encryptionKey) {
|
|
580
|
+
grantEncryptionKey = grantResult.key;
|
|
581
|
+
wrappedKeyToUse = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
|
|
582
|
+
} else grantEncryptionKey = grantResult.key;
|
|
583
|
+
}
|
|
584
|
+
if (accessTokenProps !== grantProps) {
|
|
585
|
+
const tokenResult = await encryptProps(accessTokenProps);
|
|
586
|
+
encryptedAccessTokenProps = tokenResult.encryptedData;
|
|
587
|
+
accessTokenEncryptionKey = tokenResult.key;
|
|
588
|
+
} else {
|
|
589
|
+
encryptedAccessTokenProps = grantData.encryptedProps;
|
|
590
|
+
accessTokenEncryptionKey = grantEncryptionKey;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
594
|
+
if (grantData.expiresAt !== void 0) {
|
|
595
|
+
const remainingRefreshTokenLifetime = grantData.expiresAt - now;
|
|
596
|
+
if (remainingRefreshTokenLifetime > 0) accessTokenTTL = Math.min(accessTokenTTL, remainingRefreshTokenLifetime);
|
|
597
|
+
}
|
|
598
|
+
const accessTokenExpiresAt = now + accessTokenTTL;
|
|
599
|
+
const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
|
|
600
|
+
const newRefreshToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
601
|
+
const newRefreshTokenId = await generateTokenId(newRefreshToken);
|
|
602
|
+
const newRefreshTokenWrappedKey = await wrapKeyWithToken(newRefreshToken, grantEncryptionKey);
|
|
603
|
+
grantData.previousRefreshTokenId = providedTokenHash;
|
|
604
|
+
grantData.previousRefreshTokenWrappedKey = wrappedKeyToUse;
|
|
605
|
+
grantData.refreshTokenId = newRefreshTokenId;
|
|
606
|
+
grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
|
|
607
|
+
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
608
|
+
if (body.resource && grantData.resource) {
|
|
609
|
+
const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
|
|
610
|
+
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
611
|
+
for (const requested of requestedResources) if (!grantedResources.includes(requested)) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
|
|
612
|
+
}
|
|
613
|
+
const audience = parseResourceParameter(body.resource || grantData.resource);
|
|
614
|
+
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
|
|
615
|
+
const accessTokenData = {
|
|
616
|
+
id: accessTokenId,
|
|
617
|
+
grantId,
|
|
618
|
+
userId,
|
|
619
|
+
createdAt: now,
|
|
620
|
+
expiresAt: accessTokenExpiresAt,
|
|
621
|
+
audience,
|
|
622
|
+
scope: tokenScopes,
|
|
623
|
+
wrappedEncryptionKey: accessTokenWrappedKey,
|
|
624
|
+
grant: {
|
|
625
|
+
clientId: grantData.clientId,
|
|
626
|
+
scope: grantData.scope,
|
|
627
|
+
encryptedProps: encryptedAccessTokenProps
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: accessTokenTTL });
|
|
631
|
+
const tokenResponse = {
|
|
632
|
+
access_token: newAccessToken,
|
|
633
|
+
token_type: "bearer",
|
|
634
|
+
expires_in: accessTokenTTL,
|
|
635
|
+
refresh_token: newRefreshToken,
|
|
636
|
+
scope: tokenScopes.join(" ")
|
|
637
|
+
};
|
|
638
|
+
if (audience) tokenResponse.resource = audience;
|
|
639
|
+
return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Core token exchange logic (RFC 8693)
|
|
643
|
+
* Performs the actual token exchange operation
|
|
644
|
+
* This method is not private because `OAuthHelpers` needs to call it. Note that since
|
|
645
|
+
* `OAuthProviderImpl` is not exposed outside this module, this is still effectively
|
|
646
|
+
* module-private.
|
|
647
|
+
* @param subjectToken - The subject token to exchange
|
|
648
|
+
* @param requestedScopes - Optional narrowed scopes (must be subset of original)
|
|
649
|
+
* @param requestedResource - Optional resource/audience (must be subset of original if original had resource)
|
|
650
|
+
* @param expiresIn - Optional TTL override in seconds
|
|
651
|
+
* @param clientInfo - The client making the exchange request
|
|
652
|
+
* @param env - Cloudflare Worker environment variables
|
|
653
|
+
* @returns Promise resolving to token response
|
|
654
|
+
* @throws OAuthError with OAuth error code and description
|
|
655
|
+
*/
|
|
656
|
+
async exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env) {
|
|
657
|
+
const tokenSummary = await this.unwrapToken(subjectToken, env);
|
|
658
|
+
if (!tokenSummary) throw new OAuthError("invalid_grant", "Invalid or expired subject token");
|
|
659
|
+
const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
|
|
660
|
+
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
661
|
+
if (!grantData) throw new OAuthError("invalid_grant", "Grant not found");
|
|
662
|
+
let tokenScopes = this.downscope(requestedScopes, grantData.scope);
|
|
663
|
+
let newAudience = tokenSummary.audience;
|
|
664
|
+
if (requestedResource) {
|
|
665
|
+
if (grantData.resource) {
|
|
666
|
+
const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
|
|
667
|
+
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
668
|
+
for (const requested of requestedResources) if (!grantedResources.includes(requested)) throw new OAuthError("invalid_target", "Requested resource was not included in the authorization request");
|
|
669
|
+
}
|
|
670
|
+
const parsedResource = parseResourceParameter(requestedResource);
|
|
671
|
+
if (!parsedResource) throw new OAuthError("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
|
|
672
|
+
newAudience = parsedResource;
|
|
673
|
+
}
|
|
674
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
675
|
+
const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
|
|
676
|
+
let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
|
|
677
|
+
if (expiresIn !== void 0) {
|
|
678
|
+
if (expiresIn <= 0) throw new OAuthError("invalid_request", "Invalid expires_in parameter");
|
|
679
|
+
accessTokenTTL = Math.min(expiresIn, subjectTokenRemainingLifetime);
|
|
680
|
+
} else accessTokenTTL = Math.min(accessTokenTTL, subjectTokenRemainingLifetime);
|
|
681
|
+
const subjectTokenData = await env.OAUTH_KV.get(`token:${tokenSummary.userId}:${tokenSummary.grantId}:${tokenSummary.id}`, { type: "json" });
|
|
682
|
+
if (!subjectTokenData) throw new OAuthError("invalid_grant", "Subject token data not found");
|
|
683
|
+
const encryptionKey = await unwrapKeyWithToken(subjectToken, subjectTokenData.wrappedEncryptionKey);
|
|
684
|
+
let accessTokenEncryptionKey = encryptionKey;
|
|
685
|
+
let encryptedAccessTokenProps = subjectTokenData.grant.encryptedProps;
|
|
686
|
+
if (this.options.tokenExchangeCallback) {
|
|
687
|
+
const decryptedProps = await decryptProps(encryptionKey, subjectTokenData.grant.encryptedProps);
|
|
688
|
+
const callbackOptions = {
|
|
689
|
+
grantType: GrantType.TOKEN_EXCHANGE,
|
|
690
|
+
clientId: clientInfo.clientId,
|
|
691
|
+
userId: tokenSummary.userId,
|
|
692
|
+
scope: tokenSummary.grant.scope,
|
|
693
|
+
requestedScope: tokenScopes,
|
|
694
|
+
props: decryptedProps
|
|
695
|
+
};
|
|
696
|
+
const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
|
|
697
|
+
if (callbackResult) {
|
|
698
|
+
let accessTokenProps = decryptedProps;
|
|
699
|
+
if (callbackResult.newProps) {
|
|
700
|
+
if (!callbackResult.accessTokenProps) accessTokenProps = callbackResult.newProps;
|
|
701
|
+
}
|
|
702
|
+
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
703
|
+
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = Math.min(callbackResult.accessTokenTTL, subjectTokenRemainingLifetime);
|
|
704
|
+
if (accessTokenProps !== decryptedProps) {
|
|
705
|
+
const tokenResult = await encryptProps(accessTokenProps);
|
|
706
|
+
encryptedAccessTokenProps = tokenResult.encryptedData;
|
|
707
|
+
accessTokenEncryptionKey = tokenResult.key;
|
|
708
|
+
}
|
|
709
|
+
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
const tokenResponse = {
|
|
713
|
+
access_token: await this.createAccessToken({
|
|
714
|
+
userId: tokenSummary.userId,
|
|
715
|
+
grantId: tokenSummary.grantId,
|
|
716
|
+
clientId: tokenSummary.grant.clientId,
|
|
717
|
+
scope: tokenScopes,
|
|
718
|
+
encryptedProps: encryptedAccessTokenProps,
|
|
719
|
+
encryptionKey: accessTokenEncryptionKey,
|
|
720
|
+
expiresIn: accessTokenTTL,
|
|
721
|
+
audience: newAudience,
|
|
722
|
+
env
|
|
723
|
+
}),
|
|
724
|
+
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
|
|
725
|
+
token_type: "bearer",
|
|
726
|
+
expires_in: accessTokenTTL,
|
|
727
|
+
scope: tokenScopes.join(" ")
|
|
728
|
+
};
|
|
729
|
+
if (newAudience) tokenResponse.resource = newAudience;
|
|
730
|
+
return tokenResponse;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Handles OAuth 2.0 token exchange requests (RFC 8693)
|
|
734
|
+
* Exchanges an existing access token for a new one with modified characteristics
|
|
735
|
+
* @param body - The parsed request body
|
|
736
|
+
* @param clientInfo - The authenticated client information
|
|
737
|
+
* @param env - Cloudflare Worker environment variables
|
|
738
|
+
* @returns Response with new token data or error
|
|
739
|
+
*/
|
|
740
|
+
async handleTokenExchangeGrant(body, clientInfo, env) {
|
|
741
|
+
const subjectToken = body.subject_token;
|
|
742
|
+
const subjectTokenType = body.subject_token_type;
|
|
743
|
+
const requestedTokenType = body.requested_token_type || "urn:ietf:params:oauth:token-type:access_token";
|
|
744
|
+
const requestedScope = body.scope;
|
|
745
|
+
const requestedResource = body.resource;
|
|
746
|
+
if (!subjectToken) return this.createErrorResponse("invalid_request", "subject_token is required");
|
|
747
|
+
if (!subjectTokenType) return this.createErrorResponse("invalid_request", "subject_token_type is required");
|
|
748
|
+
if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token subject_token_type is supported");
|
|
749
|
+
if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token requested_token_type is supported");
|
|
750
|
+
let requestedScopes;
|
|
751
|
+
if (requestedScope) if (typeof requestedScope === "string") requestedScopes = requestedScope.split(" ").filter(Boolean);
|
|
752
|
+
else if (Array.isArray(requestedScope)) requestedScopes = requestedScope;
|
|
753
|
+
else return this.createErrorResponse("invalid_request", "Invalid scope parameter format");
|
|
754
|
+
let expiresIn;
|
|
755
|
+
if (body.expires_in !== void 0) {
|
|
756
|
+
const requestedTTL = parseInt(body.expires_in, 10);
|
|
757
|
+
if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", "Invalid expires_in parameter");
|
|
758
|
+
expiresIn = requestedTTL;
|
|
759
|
+
}
|
|
760
|
+
try {
|
|
761
|
+
const tokenResponse = await this.exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env);
|
|
762
|
+
return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
|
|
763
|
+
} catch (error) {
|
|
764
|
+
if (error instanceof OAuthError) return this.createErrorResponse(error.code, error.message);
|
|
765
|
+
throw error;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Handles OAuth 2.0 token revocation requests (RFC 7009)
|
|
770
|
+
* @param body - The parsed request body containing revocation parameters
|
|
771
|
+
* @param env - Cloudflare Worker environment variables
|
|
772
|
+
* @returns Response confirming revocation or error
|
|
773
|
+
*/
|
|
774
|
+
async handleRevocationRequest(body, env) {
|
|
775
|
+
return this.revokeToken(body, env);
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* - Access tokens: Revokes only the specific token
|
|
779
|
+
* - Refresh tokens: Revokes the entire grant (access + refresh tokens)
|
|
780
|
+
* @param body - The parsed request body containing token parameter
|
|
781
|
+
* @param env - Cloudflare Worker environment variables
|
|
782
|
+
* @returns Response confirming revocation or error
|
|
783
|
+
*/
|
|
784
|
+
async revokeToken(body, env) {
|
|
785
|
+
const token = body.token;
|
|
786
|
+
if (!token) return this.createErrorResponse("invalid_request", "Token parameter is required");
|
|
787
|
+
const tokenParts = token.split(":");
|
|
788
|
+
if (tokenParts.length !== 3) return new Response("", { status: 200 });
|
|
789
|
+
const [userId, grantId, _] = tokenParts;
|
|
790
|
+
const tokenId = await generateTokenId(token);
|
|
791
|
+
const isAccessToken = await this.validateAccessToken(tokenId, userId, grantId, env);
|
|
792
|
+
const isRefreshToken = await this.validateRefreshToken(tokenId, userId, grantId, env);
|
|
793
|
+
if (isAccessToken) await this.revokeSpecificAccessToken(tokenId, userId, grantId, env);
|
|
794
|
+
else if (isRefreshToken) await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
|
|
795
|
+
return new Response("", { status: 200 });
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Revokes a specific access token without affecting the refresh token
|
|
799
|
+
* @param tokenId - The hashed token ID
|
|
800
|
+
* @param userId - The user ID extracted from the token
|
|
801
|
+
* @param grantId - The grant ID extracted from the token
|
|
802
|
+
* @param env - Cloudflare Worker environment variables
|
|
803
|
+
*/
|
|
804
|
+
async revokeSpecificAccessToken(tokenId, userId, grantId, env) {
|
|
805
|
+
const tokenKey = `token:${userId}:${grantId}:${tokenId}`;
|
|
806
|
+
await env.OAUTH_KV.delete(tokenKey);
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Validates if a token is a valid access token
|
|
810
|
+
* @param tokenId - The hashed token ID
|
|
811
|
+
* @param userId - The user ID extracted from the token
|
|
812
|
+
* @param grantId - The grant ID extracted from the token
|
|
813
|
+
* @param env - Cloudflare Worker environment variables
|
|
814
|
+
* @returns Promise<boolean> indicating if the token is valid
|
|
815
|
+
*/
|
|
816
|
+
async validateAccessToken(tokenId, userId, grantId, env) {
|
|
817
|
+
const tokenKey = `token:${userId}:${grantId}:${tokenId}`;
|
|
818
|
+
const tokenData = await env.OAUTH_KV.get(tokenKey, { type: "json" });
|
|
819
|
+
if (!tokenData) return false;
|
|
820
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
821
|
+
return tokenData.expiresAt >= now;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Validates if a token is a valid refresh token
|
|
825
|
+
* @param tokenId - The hashed token ID
|
|
826
|
+
* @param userId - The user ID extracted from the token
|
|
827
|
+
* @param grantId - The grant ID extracted from the token
|
|
828
|
+
* @param env - Cloudflare Worker environment variables
|
|
829
|
+
* @returns Promise<boolean> indicating if the token is valid
|
|
830
|
+
*/
|
|
831
|
+
async validateRefreshToken(tokenId, userId, grantId, env) {
|
|
832
|
+
const grantKey = `grant:${userId}:${grantId}`;
|
|
833
|
+
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
834
|
+
if (!grantData) return false;
|
|
835
|
+
return grantData.refreshTokenId === tokenId || grantData.previousRefreshTokenId === tokenId;
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Handles the dynamic client registration endpoint (RFC 7591)
|
|
839
|
+
* @param request - The HTTP request
|
|
840
|
+
* @param env - Cloudflare Worker environment variables
|
|
841
|
+
* @returns Response with client registration data or error
|
|
842
|
+
*/
|
|
843
|
+
async handleClientRegistration(request, env) {
|
|
844
|
+
if (!this.options.clientRegistrationEndpoint) return this.createErrorResponse("not_implemented", "Client registration is not enabled", 501);
|
|
845
|
+
if (request.method !== "POST") return this.createErrorResponse("invalid_request", "Method not allowed", 405);
|
|
846
|
+
if (parseInt(request.headers.get("Content-Length") || "0", 10) > 1048576) return this.createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
|
|
847
|
+
let clientMetadata;
|
|
848
|
+
try {
|
|
849
|
+
const text = await request.text();
|
|
850
|
+
if (text.length > 1048576) return this.createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
|
|
851
|
+
clientMetadata = JSON.parse(text);
|
|
852
|
+
} catch (error) {
|
|
853
|
+
return this.createErrorResponse("invalid_request", "Invalid JSON payload", 400);
|
|
854
|
+
}
|
|
855
|
+
const authMethod = OAuthProviderImpl.validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic";
|
|
856
|
+
const isPublicClient = authMethod === "none";
|
|
857
|
+
if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", "Public client registration is not allowed");
|
|
858
|
+
const clientId = generateRandomString(16);
|
|
859
|
+
let clientSecret;
|
|
860
|
+
let hashedSecret;
|
|
861
|
+
if (!isPublicClient) {
|
|
862
|
+
clientSecret = generateRandomString(32);
|
|
863
|
+
hashedSecret = await hashSecret(clientSecret);
|
|
864
|
+
}
|
|
865
|
+
let clientInfo;
|
|
866
|
+
try {
|
|
867
|
+
const redirectUris = OAuthProviderImpl.validateStringArray(clientMetadata.redirect_uris);
|
|
868
|
+
if (!redirectUris || redirectUris.length === 0) throw new Error("At least one redirect URI is required");
|
|
869
|
+
for (const uri of redirectUris) validateRedirectUriScheme(uri);
|
|
870
|
+
clientInfo = {
|
|
871
|
+
clientId,
|
|
872
|
+
redirectUris,
|
|
873
|
+
clientName: OAuthProviderImpl.validateStringField(clientMetadata.client_name),
|
|
874
|
+
logoUri: OAuthProviderImpl.validateStringField(clientMetadata.logo_uri),
|
|
875
|
+
clientUri: OAuthProviderImpl.validateStringField(clientMetadata.client_uri),
|
|
876
|
+
policyUri: OAuthProviderImpl.validateStringField(clientMetadata.policy_uri),
|
|
877
|
+
tosUri: OAuthProviderImpl.validateStringField(clientMetadata.tos_uri),
|
|
878
|
+
jwksUri: OAuthProviderImpl.validateStringField(clientMetadata.jwks_uri),
|
|
879
|
+
contacts: OAuthProviderImpl.validateStringArray(clientMetadata.contacts),
|
|
880
|
+
grantTypes: OAuthProviderImpl.validateStringArray(clientMetadata.grant_types) || [
|
|
881
|
+
GrantType.AUTHORIZATION_CODE,
|
|
882
|
+
GrantType.REFRESH_TOKEN,
|
|
883
|
+
...this.options.allowTokenExchangeGrant ? [GrantType.TOKEN_EXCHANGE] : []
|
|
884
|
+
],
|
|
885
|
+
responseTypes: OAuthProviderImpl.validateStringArray(clientMetadata.response_types) || ["code"],
|
|
886
|
+
registrationDate: Math.floor(Date.now() / 1e3),
|
|
887
|
+
tokenEndpointAuthMethod: authMethod
|
|
888
|
+
};
|
|
889
|
+
if (!isPublicClient && hashedSecret) clientInfo.clientSecret = hashedSecret;
|
|
890
|
+
} catch (error) {
|
|
891
|
+
return this.createErrorResponse("invalid_client_metadata", error instanceof Error ? error.message : "Invalid client metadata");
|
|
892
|
+
}
|
|
893
|
+
await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo));
|
|
894
|
+
const response = {
|
|
895
|
+
client_id: clientInfo.clientId,
|
|
896
|
+
redirect_uris: clientInfo.redirectUris,
|
|
897
|
+
client_name: clientInfo.clientName,
|
|
898
|
+
logo_uri: clientInfo.logoUri,
|
|
899
|
+
client_uri: clientInfo.clientUri,
|
|
900
|
+
policy_uri: clientInfo.policyUri,
|
|
901
|
+
tos_uri: clientInfo.tosUri,
|
|
902
|
+
jwks_uri: clientInfo.jwksUri,
|
|
903
|
+
contacts: clientInfo.contacts,
|
|
904
|
+
grant_types: clientInfo.grantTypes,
|
|
905
|
+
response_types: clientInfo.responseTypes,
|
|
906
|
+
token_endpoint_auth_method: clientInfo.tokenEndpointAuthMethod,
|
|
907
|
+
registration_client_uri: `${this.options.clientRegistrationEndpoint}/${clientId}`,
|
|
908
|
+
client_id_issued_at: clientInfo.registrationDate
|
|
909
|
+
};
|
|
910
|
+
if (clientSecret) response.client_secret = clientSecret;
|
|
911
|
+
return new Response(JSON.stringify(response), {
|
|
912
|
+
status: 201,
|
|
913
|
+
headers: { "Content-Type": "application/json" }
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Handles API requests by validating the access token and calling the API handler
|
|
918
|
+
* @param request - The HTTP request
|
|
919
|
+
* @param env - Cloudflare Worker environment variables
|
|
920
|
+
* @param ctx - Cloudflare Worker execution context
|
|
921
|
+
* @returns Response from the API handler or error
|
|
922
|
+
*/
|
|
923
|
+
async handleApiRequest(request, env, ctx) {
|
|
924
|
+
const authHeader = request.headers.get("Authorization");
|
|
925
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token", "Missing or invalid access token", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\", error_description=\"Missing or invalid access token\"" });
|
|
926
|
+
const accessToken = authHeader.substring(7);
|
|
927
|
+
const parts = accessToken.split(":");
|
|
928
|
+
const isPossiblyInternalFormat = parts.length === 3;
|
|
929
|
+
let tokenData = null;
|
|
930
|
+
let userId = "";
|
|
931
|
+
let grantId = "";
|
|
932
|
+
if (isPossiblyInternalFormat) {
|
|
933
|
+
[userId, grantId] = parts;
|
|
934
|
+
const id = await generateTokenId(accessToken);
|
|
935
|
+
tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
|
|
936
|
+
}
|
|
937
|
+
if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\"" });
|
|
938
|
+
if (tokenData) {
|
|
939
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
940
|
+
if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", "Access token expired", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\"" });
|
|
941
|
+
if (tokenData.audience) {
|
|
942
|
+
const requestUrl = new URL(request.url);
|
|
943
|
+
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
944
|
+
if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\", error_description=\"Invalid audience\"" });
|
|
945
|
+
}
|
|
946
|
+
ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
|
|
947
|
+
} else if (this.options.resolveExternalToken) {
|
|
948
|
+
const ext = await this.options.resolveExternalToken({
|
|
949
|
+
token: accessToken,
|
|
950
|
+
request,
|
|
951
|
+
env
|
|
952
|
+
});
|
|
953
|
+
if (!ext) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\"" });
|
|
954
|
+
if (ext.audience) {
|
|
955
|
+
const requestUrl = new URL(request.url);
|
|
956
|
+
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
957
|
+
if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\", error_description=\"Invalid audience\"" });
|
|
958
|
+
}
|
|
959
|
+
ctx.props = ext.props;
|
|
960
|
+
}
|
|
961
|
+
if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
|
|
962
|
+
const url = new URL(request.url);
|
|
963
|
+
const apiHandler = this.findApiHandlerForUrl(url);
|
|
964
|
+
if (!apiHandler) return this.createErrorResponse("invalid_request", "No handler found for API route", 404);
|
|
965
|
+
if (apiHandler.type === HandlerType.EXPORTED_HANDLER) return apiHandler.handler.fetch(request, env, ctx);
|
|
966
|
+
else return new apiHandler.handler(ctx, env).fetch(request);
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Creates the helper methods object for OAuth operations
|
|
970
|
+
* This is passed to the handler functions to allow them to interact with the OAuth system
|
|
971
|
+
* @param env - Cloudflare Worker environment variables
|
|
972
|
+
* @returns An instance of OAuthHelpers
|
|
973
|
+
*/
|
|
974
|
+
createOAuthHelpers(env) {
|
|
975
|
+
return new OAuthHelpersImpl(env, this);
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Saves a grant to KV with appropriate TTL based on expiration
|
|
979
|
+
* @param env - The environment bindings
|
|
980
|
+
* @param grantKey - The KV key for the grant
|
|
981
|
+
* @param grantData - The grant data to save
|
|
982
|
+
* @param now - Current timestamp in seconds
|
|
983
|
+
*/
|
|
984
|
+
async saveGrantWithTTL(env, grantKey, grantData, now) {
|
|
985
|
+
const kvOptions = grantData.expiresAt !== void 0 ? { expiration: grantData.expiresAt } : {};
|
|
986
|
+
await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Fetches client information from KV storage or via CIMD (Client ID Metadata Document)
|
|
990
|
+
* This method is not private because `OAuthHelpers` needs to call it. Note that since
|
|
991
|
+
* `OAuthProviderImpl` is not exposed outside this module, this is still effectively
|
|
992
|
+
* module-private.
|
|
993
|
+
*
|
|
994
|
+
* Supports CIMD: If clientId is an HTTPS URL with a non-root path, the metadata
|
|
995
|
+
* document will be fetched from that URL instead of looking up in KV storage.
|
|
996
|
+
*
|
|
997
|
+
* @param env - Cloudflare Worker environment variables
|
|
998
|
+
* @param clientId - The client ID to look up (can be a regular ID or an HTTPS URL for CIMD)
|
|
999
|
+
* @returns The client information, or null if not found
|
|
1000
|
+
*/
|
|
1001
|
+
async getClient(env, clientId) {
|
|
1002
|
+
if (this.isClientMetadataUrl(clientId)) {
|
|
1003
|
+
if (!this.hasGlobalFetchStrictlyPublic()) throw new Error(`Client ID "${clientId}" appears to be a CIMD URL, but the 'global_fetch_strictly_public' compatibility flag is not enabled. Add this flag to your wrangler.jsonc to enable CIMD support.`);
|
|
1004
|
+
return this.fetchClientMetadataDocument(clientId);
|
|
1005
|
+
}
|
|
1006
|
+
const clientKey = `client:${clientId}`;
|
|
1007
|
+
return env.OAUTH_KV.get(clientKey, { type: "json" });
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Creates and stores an access token
|
|
1011
|
+
* @param params - Options for creating the access token
|
|
1012
|
+
* @returns The access token string
|
|
1013
|
+
*/
|
|
1014
|
+
async createAccessToken(params) {
|
|
1015
|
+
const { userId, grantId, clientId, scope, encryptedProps, encryptionKey, expiresIn, audience, env } = params;
|
|
1016
|
+
const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
1017
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1018
|
+
const accessTokenId = await generateTokenId(accessToken);
|
|
1019
|
+
const accessTokenData = {
|
|
1020
|
+
id: accessTokenId,
|
|
1021
|
+
grantId,
|
|
1022
|
+
userId,
|
|
1023
|
+
createdAt: now,
|
|
1024
|
+
expiresAt: now + expiresIn,
|
|
1025
|
+
audience,
|
|
1026
|
+
scope,
|
|
1027
|
+
wrappedEncryptionKey: await wrapKeyWithToken(accessToken, encryptionKey),
|
|
1028
|
+
grant: {
|
|
1029
|
+
clientId,
|
|
1030
|
+
scope,
|
|
1031
|
+
encryptedProps
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: expiresIn });
|
|
1035
|
+
return accessToken;
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Downscopes requested scopes to only include those that are in the grant
|
|
1039
|
+
* Filters out any requested scopes that are not in the granted scopes
|
|
1040
|
+
* @param requestedScope - The scope parameter from the request (string or array)
|
|
1041
|
+
* @param grantedScopes - The scopes that were granted in the authorization
|
|
1042
|
+
* @returns The filtered scopes that are a subset of the granted scopes
|
|
1043
|
+
*/
|
|
1044
|
+
downscope(requestedScope, grantedScopes) {
|
|
1045
|
+
if (!requestedScope) return grantedScopes;
|
|
1046
|
+
return (typeof requestedScope === "string" ? requestedScope.split(" ").filter(Boolean) : requestedScope).filter((scope) => grantedScopes.includes(scope));
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Checks if the global_fetch_strictly_public compatibility flag is enabled.
|
|
1050
|
+
* This flag is required for CIMD to prevent SSRF attacks.
|
|
1051
|
+
* See: https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public
|
|
1052
|
+
*/
|
|
1053
|
+
hasGlobalFetchStrictlyPublic() {
|
|
1054
|
+
return !!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags ? Cloudflare.compatibilityFlags : null)?.global_fetch_strictly_public;
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Checks if a client_id is a CIMD URL (HTTPS with non-root path)
|
|
1058
|
+
*/
|
|
1059
|
+
isClientMetadataUrl(clientId) {
|
|
1060
|
+
try {
|
|
1061
|
+
const url = new URL(clientId);
|
|
1062
|
+
return url.protocol === "https:" && url.pathname !== "/";
|
|
1063
|
+
} catch {
|
|
1064
|
+
return false;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
static {
|
|
1068
|
+
this.CIMD_MAX_SIZE_BYTES = 5 * 1024;
|
|
1069
|
+
}
|
|
1070
|
+
static {
|
|
1071
|
+
this.CIMD_FETCH_TIMEOUT_MS = 1e4;
|
|
1072
|
+
}
|
|
1073
|
+
static {
|
|
1074
|
+
this.CIMD_ALLOWED_AUTH_METHODS = ["none", "private_key_jwt"];
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Validates that a field is a string or undefined
|
|
1078
|
+
* @param field - The field value to validate
|
|
1079
|
+
* @param fieldName - Name of the field for error messages
|
|
1080
|
+
* @returns The validated string or undefined
|
|
1081
|
+
* @throws Error if field is not a string or undefined
|
|
1082
|
+
*/
|
|
1083
|
+
static validateStringField(field, fieldName) {
|
|
1084
|
+
if (field === void 0) return void 0;
|
|
1085
|
+
if (typeof field !== "string") throw new Error(fieldName ? `Invalid ${fieldName}: expected string, got ${typeof field}` : "Field must be a string");
|
|
1086
|
+
return field;
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Validates that a field is a string array or undefined
|
|
1090
|
+
* @param arr - The array to validate
|
|
1091
|
+
* @param fieldName - Name of the field for error messages
|
|
1092
|
+
* @returns The validated string array or undefined
|
|
1093
|
+
* @throws Error if field is not a string array or undefined
|
|
1094
|
+
*/
|
|
1095
|
+
static validateStringArray(arr, fieldName) {
|
|
1096
|
+
if (arr === void 0) return void 0;
|
|
1097
|
+
if (!Array.isArray(arr)) throw new Error(fieldName ? `Invalid ${fieldName}: expected array, got ${typeof arr}` : "Field must be an array");
|
|
1098
|
+
if (!arr.every((item) => typeof item === "string")) throw new Error(fieldName ? `Invalid ${fieldName}: array must contain only strings` : "All array elements must be strings");
|
|
1099
|
+
return arr;
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Fetches and validates a Client ID Metadata Document from the given URL
|
|
1103
|
+
* Per the MCP spec, the client_id in the document must match the URL exactly
|
|
1104
|
+
*
|
|
1105
|
+
* Uses Cloudflare HTTP cache for caching (via cacheEverything option).
|
|
1106
|
+
* Response size is limited to 5KB per IETF spec.
|
|
1107
|
+
*
|
|
1108
|
+
* @param metadataUrl - The HTTPS URL to fetch metadata from
|
|
1109
|
+
* @returns The client information
|
|
1110
|
+
* @throws Error if fetch fails or validation fails
|
|
1111
|
+
*/
|
|
1112
|
+
async fetchClientMetadataDocument(metadataUrl) {
|
|
1113
|
+
const abortController = new AbortController();
|
|
1114
|
+
const timeoutId = setTimeout(() => abortController.abort(), OAuthProviderImpl.CIMD_FETCH_TIMEOUT_MS);
|
|
1115
|
+
try {
|
|
1116
|
+
const response = await fetch(metadataUrl, {
|
|
1117
|
+
headers: { Accept: "application/json" },
|
|
1118
|
+
signal: abortController.signal,
|
|
1119
|
+
cf: { cacheEverything: true }
|
|
1120
|
+
});
|
|
1121
|
+
clearTimeout(timeoutId);
|
|
1122
|
+
if (!response.ok) throw new Error(`Failed to fetch client metadata: HTTP ${response.status}`);
|
|
1123
|
+
const contentLength = response.headers.get("content-length");
|
|
1124
|
+
if (contentLength && parseInt(contentLength, 10) > OAuthProviderImpl.CIMD_MAX_SIZE_BYTES) throw new Error(`Client metadata exceeds size limit: ${contentLength} bytes (max ${OAuthProviderImpl.CIMD_MAX_SIZE_BYTES})`);
|
|
1125
|
+
const rawMetadata = await this.readJsonWithSizeLimit(response, OAuthProviderImpl.CIMD_MAX_SIZE_BYTES);
|
|
1126
|
+
const clientId = OAuthProviderImpl.validateStringField(rawMetadata.client_id, "client_id");
|
|
1127
|
+
const redirectUris = OAuthProviderImpl.validateStringArray(rawMetadata.redirect_uris, "redirect_uris");
|
|
1128
|
+
const tokenEndpointAuthMethod = OAuthProviderImpl.validateStringField(rawMetadata.token_endpoint_auth_method, "token_endpoint_auth_method");
|
|
1129
|
+
if (clientId !== metadataUrl) throw new Error(`client_id "${clientId}" does not match metadata URL "${metadataUrl}"`);
|
|
1130
|
+
if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
|
|
1131
|
+
if (tokenEndpointAuthMethod && !OAuthProviderImpl.CIMD_ALLOWED_AUTH_METHODS.includes(tokenEndpointAuthMethod)) throw new Error(`token_endpoint_auth_method "${tokenEndpointAuthMethod}" is not allowed for CIMD clients. Allowed methods: ${OAuthProviderImpl.CIMD_ALLOWED_AUTH_METHODS.join(", ")}`);
|
|
1132
|
+
return {
|
|
1133
|
+
clientId,
|
|
1134
|
+
redirectUris,
|
|
1135
|
+
clientName: OAuthProviderImpl.validateStringField(rawMetadata.client_name, "client_name"),
|
|
1136
|
+
clientUri: OAuthProviderImpl.validateStringField(rawMetadata.client_uri, "client_uri"),
|
|
1137
|
+
logoUri: OAuthProviderImpl.validateStringField(rawMetadata.logo_uri, "logo_uri"),
|
|
1138
|
+
policyUri: OAuthProviderImpl.validateStringField(rawMetadata.policy_uri, "policy_uri"),
|
|
1139
|
+
tosUri: OAuthProviderImpl.validateStringField(rawMetadata.tos_uri, "tos_uri"),
|
|
1140
|
+
jwksUri: OAuthProviderImpl.validateStringField(rawMetadata.jwks_uri, "jwks_uri"),
|
|
1141
|
+
contacts: OAuthProviderImpl.validateStringArray(rawMetadata.contacts, "contacts"),
|
|
1142
|
+
grantTypes: OAuthProviderImpl.validateStringArray(rawMetadata.grant_types, "grant_types") || ["authorization_code"],
|
|
1143
|
+
responseTypes: OAuthProviderImpl.validateStringArray(rawMetadata.response_types, "response_types") || ["code"],
|
|
1144
|
+
tokenEndpointAuthMethod: tokenEndpointAuthMethod || "none"
|
|
1145
|
+
};
|
|
1146
|
+
} finally {
|
|
1147
|
+
clearTimeout(timeoutId);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Reads JSON from a response with a size limit to prevent DoS attacks.
|
|
1152
|
+
* Streams the response body and aborts if it exceeds the limit.
|
|
1153
|
+
*
|
|
1154
|
+
* @param response - The fetch response
|
|
1155
|
+
* @param maxBytes - Maximum allowed size in bytes
|
|
1156
|
+
* @returns Parsed JSON object
|
|
1157
|
+
* @throws Error if response body is null, size exceeded, or JSON parse failed
|
|
1158
|
+
*/
|
|
1159
|
+
async readJsonWithSizeLimit(response, maxBytes) {
|
|
1160
|
+
const reader = response.body?.getReader();
|
|
1161
|
+
if (!reader) throw new Error("Response body is null");
|
|
1162
|
+
const chunks = [];
|
|
1163
|
+
let totalSize = 0;
|
|
1164
|
+
while (true) {
|
|
1165
|
+
const { done, value } = await reader.read();
|
|
1166
|
+
if (done) break;
|
|
1167
|
+
if (value) {
|
|
1168
|
+
totalSize += value.length;
|
|
1169
|
+
if (totalSize > maxBytes) {
|
|
1170
|
+
await reader.cancel();
|
|
1171
|
+
throw new Error(`Response exceeded size limit of ${maxBytes} bytes`);
|
|
1172
|
+
}
|
|
1173
|
+
chunks.push(value);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
const allChunks = new Uint8Array(totalSize);
|
|
1177
|
+
let position = 0;
|
|
1178
|
+
for (const chunk of chunks) {
|
|
1179
|
+
allChunks.set(chunk, position);
|
|
1180
|
+
position += chunk.length;
|
|
1181
|
+
}
|
|
1182
|
+
const text = new TextDecoder().decode(allChunks);
|
|
1183
|
+
return JSON.parse(text);
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Helper function to create OAuth error responses
|
|
1187
|
+
* @param code - OAuth error code (e.g., 'invalid_request', 'invalid_token')
|
|
1188
|
+
* @param description - Human-readable error description
|
|
1189
|
+
* @param status - HTTP status code (default: 400)
|
|
1190
|
+
* @param headers - Additional headers to include
|
|
1191
|
+
* @returns A Response object with the error
|
|
1192
|
+
*/
|
|
1193
|
+
createErrorResponse(code, description, status = 400, headers = {}) {
|
|
1194
|
+
const customErrorResponse = this.options.onError?.({
|
|
1195
|
+
code,
|
|
1196
|
+
description,
|
|
1197
|
+
status,
|
|
1198
|
+
headers
|
|
1199
|
+
});
|
|
1200
|
+
if (customErrorResponse) return customErrorResponse;
|
|
1201
|
+
const body = JSON.stringify({
|
|
1202
|
+
error: code,
|
|
1203
|
+
error_description: description
|
|
1204
|
+
});
|
|
1205
|
+
return new Response(body, {
|
|
1206
|
+
status,
|
|
1207
|
+
headers: {
|
|
1208
|
+
"Content-Type": "application/json",
|
|
1209
|
+
...headers
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1120
1213
|
};
|
|
1121
|
-
|
|
1122
|
-
|
|
1214
|
+
/**
|
|
1215
|
+
* Error class for OAuth operations
|
|
1216
|
+
* Carries OAuth error code and description for proper error responses
|
|
1217
|
+
*/
|
|
1218
|
+
var OAuthError = class extends Error {
|
|
1219
|
+
constructor(code, message) {
|
|
1220
|
+
super(message);
|
|
1221
|
+
this.code = code;
|
|
1222
|
+
this.name = "OAuthError";
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
/**
|
|
1226
|
+
* Default expiration time for access tokens (1 hour in seconds)
|
|
1227
|
+
*/
|
|
1228
|
+
const DEFAULT_ACCESS_TOKEN_TTL = 3600;
|
|
1229
|
+
/**
|
|
1230
|
+
* Length of generated token strings
|
|
1231
|
+
*/
|
|
1232
|
+
const TOKEN_LENGTH = 32;
|
|
1233
|
+
/**
|
|
1234
|
+
* Validates a resource URI per RFC 8707 Section 2
|
|
1235
|
+
* @param uri - The URI string to validate
|
|
1236
|
+
* @returns true if valid, false otherwise
|
|
1237
|
+
*/
|
|
1123
1238
|
function validateResourceUri(uri) {
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
}
|
|
1135
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1136
|
-
return false;
|
|
1137
|
-
}
|
|
1138
|
-
return true;
|
|
1139
|
-
} catch {
|
|
1140
|
-
return false;
|
|
1141
|
-
}
|
|
1239
|
+
if (!uri || typeof uri !== "string") return false;
|
|
1240
|
+
try {
|
|
1241
|
+
const parsed = new URL(uri);
|
|
1242
|
+
if (!parsed.protocol) return false;
|
|
1243
|
+
if (parsed.hash) return false;
|
|
1244
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
1245
|
+
return true;
|
|
1246
|
+
} catch {
|
|
1247
|
+
return false;
|
|
1248
|
+
}
|
|
1142
1249
|
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Checks if a resource server matches an audience claim.
|
|
1252
|
+
* Uses origin comparison (case-insensitive hostname via URL normalization)
|
|
1253
|
+
* and path-prefix matching on path boundaries for RFC 8707 resource indicators.
|
|
1254
|
+
* @param resourceServerUrl - The resource server URL (from request)
|
|
1255
|
+
* @param audienceValue - The audience value from token
|
|
1256
|
+
* @returns true if they match, false otherwise
|
|
1257
|
+
*/
|
|
1143
1258
|
function audienceMatches(resourceServerUrl, audienceValue) {
|
|
1144
|
-
|
|
1259
|
+
try {
|
|
1260
|
+
const resource = new URL(resourceServerUrl);
|
|
1261
|
+
const audience = new URL(audienceValue);
|
|
1262
|
+
if (resource.origin !== audience.origin) return false;
|
|
1263
|
+
if (audience.pathname === "/" || audience.pathname === "") return true;
|
|
1264
|
+
return resource.pathname === audience.pathname || resource.pathname.startsWith(audience.pathname + "/");
|
|
1265
|
+
} catch {
|
|
1266
|
+
return false;
|
|
1267
|
+
}
|
|
1145
1268
|
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Parses and validates the resource parameter from a token request (RFC 8707)
|
|
1271
|
+
* Handles single string or array of strings (from multiple form parameters)
|
|
1272
|
+
* @param value - The resource parameter value from the request body
|
|
1273
|
+
* @returns The validated value as string, string array, or undefined if validation fails
|
|
1274
|
+
*/
|
|
1146
1275
|
function parseResourceParameter(value) {
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
for (const uri of uris) {
|
|
1152
|
-
if (typeof uri !== "string" || !validateResourceUri(uri)) {
|
|
1153
|
-
return void 0;
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
return value;
|
|
1276
|
+
if (!value) return;
|
|
1277
|
+
const uris = Array.isArray(value) ? value : [value];
|
|
1278
|
+
for (const uri of uris) if (typeof uri !== "string" || !validateResourceUri(uri)) return;
|
|
1279
|
+
return value;
|
|
1157
1280
|
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Hashes a secret value using SHA-256
|
|
1283
|
+
* @param secret - The secret value to hash
|
|
1284
|
+
* @returns A hex string representation of the hash
|
|
1285
|
+
*/
|
|
1158
1286
|
async function hashSecret(secret) {
|
|
1159
|
-
|
|
1287
|
+
return generateTokenId(secret);
|
|
1160
1288
|
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Generates a cryptographically secure random string
|
|
1291
|
+
* @param length - The length of the string to generate
|
|
1292
|
+
* @returns A random string of the specified length
|
|
1293
|
+
*/
|
|
1161
1294
|
function generateRandomString(length) {
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
}
|
|
1169
|
-
return result;
|
|
1295
|
+
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
1296
|
+
let result = "";
|
|
1297
|
+
const values = new Uint8Array(length);
|
|
1298
|
+
crypto.getRandomValues(values);
|
|
1299
|
+
for (let i = 0; i < length; i++) result += characters.charAt(values[i] % 64);
|
|
1300
|
+
return result;
|
|
1170
1301
|
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Generates a token ID by hashing the token value using SHA-256
|
|
1304
|
+
* @param token - The token to hash
|
|
1305
|
+
* @returns A hex string representation of the hash
|
|
1306
|
+
*/
|
|
1171
1307
|
async function generateTokenId(token) {
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
1176
|
-
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1177
|
-
return hashHex;
|
|
1308
|
+
const data = new TextEncoder().encode(token);
|
|
1309
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
1310
|
+
return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1178
1311
|
}
|
|
1312
|
+
/**
|
|
1313
|
+
* Validates that a redirect URI does not use a dangerous pseudo-scheme.
|
|
1314
|
+
* Normalizes the URI by trimming whitespace and checking the scheme in a
|
|
1315
|
+
* case-insensitive manner to prevent bypass attacks.
|
|
1316
|
+
* Per RFC 3986, control characters are explicitly disallowed in URIs and
|
|
1317
|
+
* will cause rejection rather than silent removal.
|
|
1318
|
+
* @param redirectUri - The redirect URI to validate
|
|
1319
|
+
* @throws Error if the URI uses a blacklisted scheme or contains control characters
|
|
1320
|
+
*/
|
|
1179
1321
|
function validateRedirectUriScheme(redirectUri) {
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
}
|
|
1322
|
+
const dangerousSchemes = [
|
|
1323
|
+
"javascript:",
|
|
1324
|
+
"data:",
|
|
1325
|
+
"vbscript:",
|
|
1326
|
+
"file:",
|
|
1327
|
+
"mailto:",
|
|
1328
|
+
"blob:"
|
|
1329
|
+
];
|
|
1330
|
+
const normalized = redirectUri.trim();
|
|
1331
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
1332
|
+
const code = normalized.charCodeAt(i);
|
|
1333
|
+
if (code >= 0 && code <= 31 || code >= 127 && code <= 159) throw new Error("Invalid redirect URI");
|
|
1334
|
+
}
|
|
1335
|
+
const colonIndex = normalized.indexOf(":");
|
|
1336
|
+
if (colonIndex === -1) throw new Error("Invalid redirect URI");
|
|
1337
|
+
const scheme = normalized.substring(0, colonIndex + 1).toLowerCase();
|
|
1338
|
+
for (const dangerousScheme of dangerousSchemes) if (scheme === dangerousScheme) throw new Error("Invalid redirect URI");
|
|
1198
1339
|
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Encodes a string as base64url (URL-safe base64)
|
|
1342
|
+
* @param str - The string to encode
|
|
1343
|
+
* @returns The base64url encoded string
|
|
1344
|
+
*/
|
|
1199
1345
|
function base64UrlEncode(str) {
|
|
1200
|
-
|
|
1346
|
+
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
1201
1347
|
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Encodes an ArrayBuffer as base64 string
|
|
1350
|
+
* @param buffer - The ArrayBuffer to encode
|
|
1351
|
+
* @returns The base64 encoded string
|
|
1352
|
+
*/
|
|
1202
1353
|
function arrayBufferToBase64(buffer) {
|
|
1203
|
-
|
|
1354
|
+
return btoa(String.fromCharCode(...new Uint8Array(buffer)));
|
|
1204
1355
|
}
|
|
1356
|
+
/**
|
|
1357
|
+
* Decodes a base64 string to an ArrayBuffer
|
|
1358
|
+
* @param base64 - The base64 string to decode
|
|
1359
|
+
* @returns The decoded ArrayBuffer
|
|
1360
|
+
*/
|
|
1205
1361
|
function base64ToArrayBuffer(base64) {
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
}
|
|
1211
|
-
return bytes.buffer;
|
|
1362
|
+
const binaryString = atob(base64);
|
|
1363
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
1364
|
+
for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
|
|
1365
|
+
return bytes.buffer;
|
|
1212
1366
|
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Encrypts props data with a newly generated key
|
|
1369
|
+
* @param data - The data to encrypt
|
|
1370
|
+
* @returns An object containing the encrypted data and the generated key
|
|
1371
|
+
*/
|
|
1213
1372
|
async function encryptProps(data) {
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
{
|
|
1229
|
-
name: "AES-GCM",
|
|
1230
|
-
iv
|
|
1231
|
-
},
|
|
1232
|
-
key,
|
|
1233
|
-
encodedData
|
|
1234
|
-
);
|
|
1235
|
-
return {
|
|
1236
|
-
encryptedData: arrayBufferToBase64(encryptedBuffer),
|
|
1237
|
-
key
|
|
1238
|
-
};
|
|
1373
|
+
const key = await crypto.subtle.generateKey({
|
|
1374
|
+
name: "AES-GCM",
|
|
1375
|
+
length: 256
|
|
1376
|
+
}, true, ["encrypt", "decrypt"]);
|
|
1377
|
+
const iv = new Uint8Array(12);
|
|
1378
|
+
const jsonData = JSON.stringify(data);
|
|
1379
|
+
const encodedData = new TextEncoder().encode(jsonData);
|
|
1380
|
+
return {
|
|
1381
|
+
encryptedData: arrayBufferToBase64(await crypto.subtle.encrypt({
|
|
1382
|
+
name: "AES-GCM",
|
|
1383
|
+
iv
|
|
1384
|
+
}, key, encodedData)),
|
|
1385
|
+
key
|
|
1386
|
+
};
|
|
1239
1387
|
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Decrypts encrypted props data using the provided key
|
|
1390
|
+
* @param key - The CryptoKey to use for decryption
|
|
1391
|
+
* @param encryptedData - The encrypted data as a base64 string
|
|
1392
|
+
* @returns The decrypted data object
|
|
1393
|
+
*/
|
|
1240
1394
|
async function decryptProps(key, encryptedData) {
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
encryptedBuffer
|
|
1250
|
-
);
|
|
1251
|
-
const decoder = new TextDecoder();
|
|
1252
|
-
const jsonData = decoder.decode(decryptedBuffer);
|
|
1253
|
-
return JSON.parse(jsonData);
|
|
1395
|
+
const encryptedBuffer = base64ToArrayBuffer(encryptedData);
|
|
1396
|
+
const iv = new Uint8Array(12);
|
|
1397
|
+
const decryptedBuffer = await crypto.subtle.decrypt({
|
|
1398
|
+
name: "AES-GCM",
|
|
1399
|
+
iv
|
|
1400
|
+
}, key, encryptedBuffer);
|
|
1401
|
+
const jsonData = new TextDecoder().decode(decryptedBuffer);
|
|
1402
|
+
return JSON.parse(jsonData);
|
|
1254
1403
|
}
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1404
|
+
const WRAPPING_KEY_HMAC_KEY = new Uint8Array([
|
|
1405
|
+
34,
|
|
1406
|
+
126,
|
|
1407
|
+
38,
|
|
1408
|
+
134,
|
|
1409
|
+
141,
|
|
1410
|
+
241,
|
|
1411
|
+
225,
|
|
1412
|
+
109,
|
|
1413
|
+
128,
|
|
1414
|
+
112,
|
|
1415
|
+
234,
|
|
1416
|
+
23,
|
|
1417
|
+
151,
|
|
1418
|
+
91,
|
|
1419
|
+
71,
|
|
1420
|
+
166,
|
|
1421
|
+
130,
|
|
1422
|
+
24,
|
|
1423
|
+
250,
|
|
1424
|
+
135,
|
|
1425
|
+
40,
|
|
1426
|
+
174,
|
|
1427
|
+
222,
|
|
1428
|
+
133,
|
|
1429
|
+
181,
|
|
1430
|
+
29,
|
|
1431
|
+
74,
|
|
1432
|
+
217,
|
|
1433
|
+
150,
|
|
1434
|
+
202,
|
|
1435
|
+
202,
|
|
1436
|
+
67
|
|
1288
1437
|
]);
|
|
1438
|
+
/**
|
|
1439
|
+
* Derives a wrapping key from a token string
|
|
1440
|
+
* This intentionally uses a different method than token ID generation
|
|
1441
|
+
* to ensure the token ID cannot be used to derive the wrapping key
|
|
1442
|
+
* @param tokenStr - The token string to use as key material
|
|
1443
|
+
* @returns A Promise resolving to the derived CryptoKey
|
|
1444
|
+
*/
|
|
1289
1445
|
async function deriveKeyFromToken(tokenStr) {
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
);
|
|
1298
|
-
const hmacResult = await crypto.subtle.sign("HMAC", hmacKey, encoder.encode(tokenStr));
|
|
1299
|
-
return await crypto.subtle.importKey(
|
|
1300
|
-
"raw",
|
|
1301
|
-
hmacResult,
|
|
1302
|
-
{ name: "AES-KW" },
|
|
1303
|
-
false,
|
|
1304
|
-
// not extractable
|
|
1305
|
-
["wrapKey", "unwrapKey"]
|
|
1306
|
-
);
|
|
1446
|
+
const encoder = new TextEncoder();
|
|
1447
|
+
const hmacKey = await crypto.subtle.importKey("raw", WRAPPING_KEY_HMAC_KEY, {
|
|
1448
|
+
name: "HMAC",
|
|
1449
|
+
hash: "SHA-256"
|
|
1450
|
+
}, false, ["sign"]);
|
|
1451
|
+
const hmacResult = await crypto.subtle.sign("HMAC", hmacKey, encoder.encode(tokenStr));
|
|
1452
|
+
return await crypto.subtle.importKey("raw", hmacResult, { name: "AES-KW" }, false, ["wrapKey", "unwrapKey"]);
|
|
1307
1453
|
}
|
|
1454
|
+
/**
|
|
1455
|
+
* Wraps an encryption key using a token-derived key
|
|
1456
|
+
* @param tokenStr - The token string to use for key wrapping
|
|
1457
|
+
* @param keyToWrap - The encryption key to wrap
|
|
1458
|
+
* @returns A Promise resolving to the wrapped key as a base64 string
|
|
1459
|
+
*/
|
|
1308
1460
|
async function wrapKeyWithToken(tokenStr, keyToWrap) {
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
return arrayBufferToBase64(wrappedKeyBuffer);
|
|
1461
|
+
const wrappingKey = await deriveKeyFromToken(tokenStr);
|
|
1462
|
+
return arrayBufferToBase64(await crypto.subtle.wrapKey("raw", keyToWrap, wrappingKey, { name: "AES-KW" }));
|
|
1312
1463
|
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Unwraps an encryption key using a token-derived key
|
|
1466
|
+
* @param tokenStr - The token string used for key wrapping
|
|
1467
|
+
* @param wrappedKeyBase64 - The wrapped key as a base64 string
|
|
1468
|
+
* @returns A Promise resolving to the unwrapped CryptoKey
|
|
1469
|
+
*/
|
|
1313
1470
|
async function unwrapKeyWithToken(tokenStr, wrappedKeyBase64) {
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
"raw",
|
|
1318
|
-
wrappedKeyBuffer,
|
|
1319
|
-
wrappingKey,
|
|
1320
|
-
{ name: "AES-KW" },
|
|
1321
|
-
{ name: "AES-GCM" },
|
|
1322
|
-
true,
|
|
1323
|
-
// extractable
|
|
1324
|
-
["encrypt", "decrypt"]
|
|
1325
|
-
);
|
|
1471
|
+
const wrappingKey = await deriveKeyFromToken(tokenStr);
|
|
1472
|
+
const wrappedKeyBuffer = base64ToArrayBuffer(wrappedKeyBase64);
|
|
1473
|
+
return await crypto.subtle.unwrapKey("raw", wrappedKeyBuffer, wrappingKey, { name: "AES-KW" }, { name: "AES-GCM" }, true, ["encrypt", "decrypt"]);
|
|
1326
1474
|
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Class that implements the OAuth helper methods
|
|
1477
|
+
* Provides methods for OAuth operations needed by handlers
|
|
1478
|
+
*/
|
|
1327
1479
|
var OAuthHelpersImpl = class {
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
grantSummaries.push(summary);
|
|
1650
|
-
}
|
|
1651
|
-
});
|
|
1652
|
-
await Promise.all(promises);
|
|
1653
|
-
return {
|
|
1654
|
-
items: grantSummaries,
|
|
1655
|
-
cursor: response.list_complete ? void 0 : response.cursor
|
|
1656
|
-
};
|
|
1657
|
-
}
|
|
1658
|
-
/**
|
|
1659
|
-
* Revokes an authorization grant and all its associated access tokens
|
|
1660
|
-
* @param grantId - The ID of the grant to revoke
|
|
1661
|
-
* @param userId - The ID of the user who owns the grant
|
|
1662
|
-
* @returns A Promise resolving when the revocation is confirmed.
|
|
1663
|
-
*/
|
|
1664
|
-
async revokeGrant(grantId, userId) {
|
|
1665
|
-
const grantKey = `grant:${userId}:${grantId}`;
|
|
1666
|
-
const tokenPrefix = `token:${userId}:${grantId}:`;
|
|
1667
|
-
let cursor;
|
|
1668
|
-
let allTokensDeleted = false;
|
|
1669
|
-
while (!allTokensDeleted) {
|
|
1670
|
-
const listOptions = {
|
|
1671
|
-
prefix: tokenPrefix
|
|
1672
|
-
};
|
|
1673
|
-
if (cursor) {
|
|
1674
|
-
listOptions.cursor = cursor;
|
|
1675
|
-
}
|
|
1676
|
-
const result = await this.env.OAUTH_KV.list(listOptions);
|
|
1677
|
-
if (result.keys.length > 0) {
|
|
1678
|
-
await Promise.all(
|
|
1679
|
-
result.keys.map((key) => {
|
|
1680
|
-
return this.env.OAUTH_KV.delete(key.name);
|
|
1681
|
-
})
|
|
1682
|
-
);
|
|
1683
|
-
}
|
|
1684
|
-
if (result.list_complete) {
|
|
1685
|
-
allTokensDeleted = true;
|
|
1686
|
-
} else {
|
|
1687
|
-
cursor = result.cursor;
|
|
1688
|
-
}
|
|
1689
|
-
}
|
|
1690
|
-
await this.env.OAUTH_KV.delete(grantKey);
|
|
1691
|
-
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Creates a new OAuthHelpers instance
|
|
1482
|
+
* @param env - Cloudflare Worker environment variables
|
|
1483
|
+
* @param provider - Reference to the parent provider instance
|
|
1484
|
+
*/
|
|
1485
|
+
constructor(env, provider) {
|
|
1486
|
+
this.env = env;
|
|
1487
|
+
this.provider = provider;
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Parses an OAuth authorization request from the HTTP request
|
|
1491
|
+
* @param request - The HTTP request containing OAuth parameters
|
|
1492
|
+
* @returns The parsed authorization request parameters
|
|
1493
|
+
*/
|
|
1494
|
+
async parseAuthRequest(request) {
|
|
1495
|
+
const url = new URL(request.url);
|
|
1496
|
+
const responseType = url.searchParams.get("response_type") || "";
|
|
1497
|
+
const clientId = url.searchParams.get("client_id") || "";
|
|
1498
|
+
const redirectUri = url.searchParams.get("redirect_uri") || "";
|
|
1499
|
+
const scope = (url.searchParams.get("scope") || "").split(" ").filter(Boolean);
|
|
1500
|
+
const state = url.searchParams.get("state") || "";
|
|
1501
|
+
const codeChallenge = url.searchParams.get("code_challenge") || void 0;
|
|
1502
|
+
const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "plain";
|
|
1503
|
+
const resourceParams = url.searchParams.getAll("resource");
|
|
1504
|
+
const resourceParam = resourceParams.length > 0 ? resourceParams.length === 1 ? resourceParams[0] : resourceParams : void 0;
|
|
1505
|
+
validateRedirectUriScheme(redirectUri);
|
|
1506
|
+
const resource = parseResourceParameter(resourceParam);
|
|
1507
|
+
if (resourceParam && !resource) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
|
|
1508
|
+
if (responseType === "token" && !this.provider.options.allowImplicitFlow) throw new Error("The implicit grant flow is not enabled for this provider");
|
|
1509
|
+
if (clientId) {
|
|
1510
|
+
const clientInfo = await this.lookupClient(clientId);
|
|
1511
|
+
if (!clientInfo) throw new Error(`Invalid client. The clientId provided does not match to this client.`);
|
|
1512
|
+
if (clientInfo && redirectUri) {
|
|
1513
|
+
if (!clientInfo.redirectUris.includes(redirectUri)) throw new Error(`Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.`);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
return {
|
|
1517
|
+
responseType,
|
|
1518
|
+
clientId,
|
|
1519
|
+
redirectUri,
|
|
1520
|
+
scope,
|
|
1521
|
+
state,
|
|
1522
|
+
codeChallenge,
|
|
1523
|
+
codeChallengeMethod,
|
|
1524
|
+
resource
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* Looks up a client by its client ID
|
|
1529
|
+
* @param clientId - The client ID to look up
|
|
1530
|
+
* @returns A Promise resolving to the client info, or null if not found
|
|
1531
|
+
*/
|
|
1532
|
+
async lookupClient(clientId) {
|
|
1533
|
+
return await this.provider.getClient(this.env, clientId);
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Completes an authorization request by creating a grant and either:
|
|
1537
|
+
* - For authorization code flow: generating an authorization code
|
|
1538
|
+
* - For implicit flow: generating an access token directly
|
|
1539
|
+
* @param options - Options specifying the grant details
|
|
1540
|
+
* @returns A Promise resolving to an object containing the redirect URL
|
|
1541
|
+
*/
|
|
1542
|
+
async completeAuthorization(options) {
|
|
1543
|
+
const { clientId, redirectUri } = options.request;
|
|
1544
|
+
if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
|
|
1545
|
+
const clientInfo = await this.lookupClient(clientId);
|
|
1546
|
+
if (!clientInfo || !clientInfo.redirectUris.includes(redirectUri)) throw new Error("Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.");
|
|
1547
|
+
const grantId = generateRandomString(16);
|
|
1548
|
+
const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
|
|
1549
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1550
|
+
if (options.request.responseType === "token") {
|
|
1551
|
+
const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
|
|
1552
|
+
const accessToken = `${options.userId}:${grantId}:${accessTokenSecret}`;
|
|
1553
|
+
const accessTokenId = await generateTokenId(accessToken);
|
|
1554
|
+
const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
|
|
1555
|
+
const accessTokenExpiresAt = now + accessTokenTTL;
|
|
1556
|
+
const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
|
|
1557
|
+
const audience = parseResourceParameter(options.request.resource);
|
|
1558
|
+
if (options.request.resource && !audience) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
|
|
1559
|
+
const grant = {
|
|
1560
|
+
id: grantId,
|
|
1561
|
+
clientId: options.request.clientId,
|
|
1562
|
+
userId: options.userId,
|
|
1563
|
+
scope: options.scope,
|
|
1564
|
+
metadata: options.metadata,
|
|
1565
|
+
encryptedProps: encryptedData,
|
|
1566
|
+
createdAt: now,
|
|
1567
|
+
resource: options.request.resource
|
|
1568
|
+
};
|
|
1569
|
+
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
1570
|
+
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
|
|
1571
|
+
const accessTokenData = {
|
|
1572
|
+
id: accessTokenId,
|
|
1573
|
+
grantId,
|
|
1574
|
+
userId: options.userId,
|
|
1575
|
+
createdAt: now,
|
|
1576
|
+
expiresAt: accessTokenExpiresAt,
|
|
1577
|
+
audience,
|
|
1578
|
+
scope: options.scope,
|
|
1579
|
+
wrappedEncryptionKey: accessTokenWrappedKey,
|
|
1580
|
+
grant: {
|
|
1581
|
+
clientId: options.request.clientId,
|
|
1582
|
+
scope: options.scope,
|
|
1583
|
+
encryptedProps: encryptedData
|
|
1584
|
+
}
|
|
1585
|
+
};
|
|
1586
|
+
await this.env.OAUTH_KV.put(`token:${options.userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: accessTokenTTL });
|
|
1587
|
+
const redirectUrl = new URL(options.request.redirectUri);
|
|
1588
|
+
const fragment = new URLSearchParams();
|
|
1589
|
+
fragment.set("access_token", accessToken);
|
|
1590
|
+
fragment.set("token_type", "bearer");
|
|
1591
|
+
fragment.set("expires_in", accessTokenTTL.toString());
|
|
1592
|
+
fragment.set("scope", options.scope.join(" "));
|
|
1593
|
+
if (options.request.state) fragment.set("state", options.request.state);
|
|
1594
|
+
redirectUrl.hash = fragment.toString();
|
|
1595
|
+
return { redirectTo: redirectUrl.toString() };
|
|
1596
|
+
} else {
|
|
1597
|
+
const authCodeSecret = generateRandomString(32);
|
|
1598
|
+
const authCode = `${options.userId}:${grantId}:${authCodeSecret}`;
|
|
1599
|
+
const authCodeId = await hashSecret(authCode);
|
|
1600
|
+
const authCodeWrappedKey = await wrapKeyWithToken(authCode, encryptionKey);
|
|
1601
|
+
const grant = {
|
|
1602
|
+
id: grantId,
|
|
1603
|
+
clientId: options.request.clientId,
|
|
1604
|
+
userId: options.userId,
|
|
1605
|
+
scope: options.scope,
|
|
1606
|
+
metadata: options.metadata,
|
|
1607
|
+
encryptedProps: encryptedData,
|
|
1608
|
+
createdAt: now,
|
|
1609
|
+
authCodeId,
|
|
1610
|
+
authCodeWrappedKey,
|
|
1611
|
+
codeChallenge: options.request.codeChallenge,
|
|
1612
|
+
codeChallengeMethod: options.request.codeChallengeMethod,
|
|
1613
|
+
resource: options.request.resource
|
|
1614
|
+
};
|
|
1615
|
+
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
1616
|
+
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), { expirationTtl: 600 });
|
|
1617
|
+
const redirectUrl = new URL(options.request.redirectUri);
|
|
1618
|
+
redirectUrl.searchParams.set("code", authCode);
|
|
1619
|
+
if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
|
|
1620
|
+
return { redirectTo: redirectUrl.toString() };
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* Creates a new OAuth client
|
|
1625
|
+
* @param clientInfo - Partial client information to create the client with
|
|
1626
|
+
* @returns A Promise resolving to the created client info
|
|
1627
|
+
*/
|
|
1628
|
+
async createClient(clientInfo) {
|
|
1629
|
+
const clientId = generateRandomString(16);
|
|
1630
|
+
const tokenEndpointAuthMethod = clientInfo.tokenEndpointAuthMethod || "client_secret_basic";
|
|
1631
|
+
const isPublicClient = tokenEndpointAuthMethod === "none";
|
|
1632
|
+
const newClient = {
|
|
1633
|
+
clientId,
|
|
1634
|
+
redirectUris: clientInfo.redirectUris || [],
|
|
1635
|
+
clientName: clientInfo.clientName,
|
|
1636
|
+
logoUri: clientInfo.logoUri,
|
|
1637
|
+
clientUri: clientInfo.clientUri,
|
|
1638
|
+
policyUri: clientInfo.policyUri,
|
|
1639
|
+
tosUri: clientInfo.tosUri,
|
|
1640
|
+
jwksUri: clientInfo.jwksUri,
|
|
1641
|
+
contacts: clientInfo.contacts,
|
|
1642
|
+
grantTypes: clientInfo.grantTypes || [
|
|
1643
|
+
GrantType.AUTHORIZATION_CODE,
|
|
1644
|
+
GrantType.REFRESH_TOKEN,
|
|
1645
|
+
...this.provider.options.allowTokenExchangeGrant ? [GrantType.TOKEN_EXCHANGE] : []
|
|
1646
|
+
],
|
|
1647
|
+
responseTypes: clientInfo.responseTypes || ["code"],
|
|
1648
|
+
registrationDate: Math.floor(Date.now() / 1e3),
|
|
1649
|
+
tokenEndpointAuthMethod
|
|
1650
|
+
};
|
|
1651
|
+
for (const uri of newClient.redirectUris) validateRedirectUriScheme(uri);
|
|
1652
|
+
let clientSecret;
|
|
1653
|
+
if (!isPublicClient) {
|
|
1654
|
+
clientSecret = generateRandomString(32);
|
|
1655
|
+
newClient.clientSecret = await hashSecret(clientSecret);
|
|
1656
|
+
}
|
|
1657
|
+
await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(newClient));
|
|
1658
|
+
const clientResponse = { ...newClient };
|
|
1659
|
+
if (!isPublicClient && clientSecret) clientResponse.clientSecret = clientSecret;
|
|
1660
|
+
return clientResponse;
|
|
1661
|
+
}
|
|
1662
|
+
/**
|
|
1663
|
+
* Lists all registered OAuth clients with pagination support
|
|
1664
|
+
* @param options - Optional pagination parameters (limit and cursor)
|
|
1665
|
+
* @returns A Promise resolving to the list result with items and optional cursor
|
|
1666
|
+
*/
|
|
1667
|
+
async listClients(options) {
|
|
1668
|
+
const listOptions = { prefix: "client:" };
|
|
1669
|
+
if (options?.limit !== void 0) listOptions.limit = options.limit;
|
|
1670
|
+
if (options?.cursor !== void 0) listOptions.cursor = options.cursor;
|
|
1671
|
+
const response = await this.env.OAUTH_KV.list(listOptions);
|
|
1672
|
+
const clients = [];
|
|
1673
|
+
const promises = response.keys.map(async (key) => {
|
|
1674
|
+
const clientId = key.name.substring(7);
|
|
1675
|
+
const client = await this.provider.getClient(this.env, clientId);
|
|
1676
|
+
if (client) clients.push(client);
|
|
1677
|
+
});
|
|
1678
|
+
await Promise.all(promises);
|
|
1679
|
+
return {
|
|
1680
|
+
items: clients,
|
|
1681
|
+
cursor: response.list_complete ? void 0 : response.cursor
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Updates an existing OAuth client
|
|
1686
|
+
* @param clientId - The ID of the client to update
|
|
1687
|
+
* @param updates - Partial client information with fields to update
|
|
1688
|
+
* @returns A Promise resolving to the updated client info, or null if not found
|
|
1689
|
+
*/
|
|
1690
|
+
async updateClient(clientId, updates) {
|
|
1691
|
+
const client = await this.provider.getClient(this.env, clientId);
|
|
1692
|
+
if (!client) return null;
|
|
1693
|
+
let authMethod = updates.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod || "client_secret_basic";
|
|
1694
|
+
const isPublicClient = authMethod === "none";
|
|
1695
|
+
let secretToStore = client.clientSecret;
|
|
1696
|
+
let originalSecret = void 0;
|
|
1697
|
+
if (isPublicClient) secretToStore = void 0;
|
|
1698
|
+
else if (updates.clientSecret) {
|
|
1699
|
+
originalSecret = updates.clientSecret;
|
|
1700
|
+
secretToStore = await hashSecret(updates.clientSecret);
|
|
1701
|
+
}
|
|
1702
|
+
const updatedClient = {
|
|
1703
|
+
...client,
|
|
1704
|
+
...updates,
|
|
1705
|
+
clientId: client.clientId,
|
|
1706
|
+
tokenEndpointAuthMethod: authMethod
|
|
1707
|
+
};
|
|
1708
|
+
if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
|
|
1709
|
+
else delete updatedClient.clientSecret;
|
|
1710
|
+
await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient));
|
|
1711
|
+
const response = { ...updatedClient };
|
|
1712
|
+
if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
|
|
1713
|
+
return response;
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* Deletes an OAuth client
|
|
1717
|
+
* @param clientId - The ID of the client to delete
|
|
1718
|
+
* @returns A Promise resolving when the deletion is confirmed.
|
|
1719
|
+
*/
|
|
1720
|
+
async deleteClient(clientId) {
|
|
1721
|
+
await this.env.OAUTH_KV.delete(`client:${clientId}`);
|
|
1722
|
+
}
|
|
1723
|
+
/**
|
|
1724
|
+
* Lists all authorization grants for a specific user with pagination support
|
|
1725
|
+
* Returns a summary of each grant without sensitive information
|
|
1726
|
+
* @param userId - The ID of the user whose grants to list
|
|
1727
|
+
* @param options - Optional pagination parameters (limit and cursor)
|
|
1728
|
+
* @returns A Promise resolving to the list result with grant summaries and optional cursor
|
|
1729
|
+
*/
|
|
1730
|
+
async listUserGrants(userId, options) {
|
|
1731
|
+
const listOptions = { prefix: `grant:${userId}:` };
|
|
1732
|
+
if (options?.limit !== void 0) listOptions.limit = options.limit;
|
|
1733
|
+
if (options?.cursor !== void 0) listOptions.cursor = options.cursor;
|
|
1734
|
+
const response = await this.env.OAUTH_KV.list(listOptions);
|
|
1735
|
+
const grantSummaries = [];
|
|
1736
|
+
const promises = response.keys.map(async (key) => {
|
|
1737
|
+
const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
|
|
1738
|
+
if (grantData) {
|
|
1739
|
+
const summary = {
|
|
1740
|
+
id: grantData.id,
|
|
1741
|
+
clientId: grantData.clientId,
|
|
1742
|
+
userId: grantData.userId,
|
|
1743
|
+
scope: grantData.scope,
|
|
1744
|
+
metadata: grantData.metadata,
|
|
1745
|
+
createdAt: grantData.createdAt,
|
|
1746
|
+
expiresAt: grantData.expiresAt
|
|
1747
|
+
};
|
|
1748
|
+
grantSummaries.push(summary);
|
|
1749
|
+
}
|
|
1750
|
+
});
|
|
1751
|
+
await Promise.all(promises);
|
|
1752
|
+
return {
|
|
1753
|
+
items: grantSummaries,
|
|
1754
|
+
cursor: response.list_complete ? void 0 : response.cursor
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
/**
|
|
1758
|
+
* Revokes an authorization grant and all its associated access tokens
|
|
1759
|
+
* @param grantId - The ID of the grant to revoke
|
|
1760
|
+
* @param userId - The ID of the user who owns the grant
|
|
1761
|
+
* @returns A Promise resolving when the revocation is confirmed.
|
|
1762
|
+
*/
|
|
1763
|
+
async revokeGrant(grantId, userId) {
|
|
1764
|
+
const grantKey = `grant:${userId}:${grantId}`;
|
|
1765
|
+
const tokenPrefix = `token:${userId}:${grantId}:`;
|
|
1766
|
+
let cursor;
|
|
1767
|
+
let allTokensDeleted = false;
|
|
1768
|
+
while (!allTokensDeleted) {
|
|
1769
|
+
const listOptions = { prefix: tokenPrefix };
|
|
1770
|
+
if (cursor) listOptions.cursor = cursor;
|
|
1771
|
+
const result = await this.env.OAUTH_KV.list(listOptions);
|
|
1772
|
+
if (result.keys.length > 0) await Promise.all(result.keys.map((key) => {
|
|
1773
|
+
return this.env.OAUTH_KV.delete(key.name);
|
|
1774
|
+
}));
|
|
1775
|
+
if (result.list_complete) allTokensDeleted = true;
|
|
1776
|
+
else cursor = result.cursor;
|
|
1777
|
+
}
|
|
1778
|
+
await this.env.OAUTH_KV.delete(grantKey);
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1781
|
+
* Decodes a token and returns token data with decrypted props
|
|
1782
|
+
* @param token - The token
|
|
1783
|
+
* @returns Promise resolving to token data with decrypted props, or null if token is invalid
|
|
1784
|
+
*/
|
|
1785
|
+
async unwrapToken(token) {
|
|
1786
|
+
return await this.provider.unwrapToken(token, this.env);
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Exchanges an existing access token for a new one with modified characteristics
|
|
1790
|
+
* Implements OAuth 2.0 Token Exchange (RFC 8693)
|
|
1791
|
+
* @param options - Options for token exchange including subject token and optional modifications
|
|
1792
|
+
* @returns Promise resolving to token response with new access token
|
|
1793
|
+
*/
|
|
1794
|
+
async exchangeToken(options) {
|
|
1795
|
+
const tokenSummary = await this.unwrapToken(options.subjectToken);
|
|
1796
|
+
if (!tokenSummary) throw new Error("Invalid or expired subject token");
|
|
1797
|
+
const clientInfo = await this.lookupClient(tokenSummary.grant.clientId);
|
|
1798
|
+
if (!clientInfo) throw new Error("Client not found");
|
|
1799
|
+
return await this.provider.exchangeToken(options.subjectToken, options.scope, options.aud, options.expiresIn, clientInfo, this.env);
|
|
1800
|
+
}
|
|
1692
1801
|
};
|
|
1802
|
+
/**
|
|
1803
|
+
* Default export of the OAuth provider
|
|
1804
|
+
* This allows users to import the library and use it directly as in the example
|
|
1805
|
+
*/
|
|
1693
1806
|
var oauth_provider_default = OAuthProvider;
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
};
|
|
1807
|
+
|
|
1808
|
+
//#endregion
|
|
1809
|
+
export { GrantType, OAuthProvider, oauth_provider_default as default, getOAuthApi };
|