@cloudflare/workers-oauth-provider 0.0.0-0b064bf

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.
@@ -0,0 +1,1497 @@
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
+ import { WorkerEntrypoint } from "cloudflare:workers";
11
+ var _impl;
12
+ var OAuthProvider = class {
13
+ /**
14
+ * Creates a new OAuth provider instance
15
+ * @param options - Configuration options for the provider
16
+ */
17
+ constructor(options) {
18
+ __privateAdd(this, _impl);
19
+ __privateSet(this, _impl, new OAuthProviderImpl(options));
20
+ }
21
+ /**
22
+ * Main fetch handler for the Worker
23
+ * Routes requests to the appropriate handler based on the URL
24
+ * @param request - The HTTP request
25
+ * @param env - Cloudflare Worker environment variables
26
+ * @param ctx - Cloudflare Worker execution context
27
+ * @returns A Promise resolving to an HTTP Response
28
+ */
29
+ fetch(request, env, ctx) {
30
+ return __privateGet(this, _impl).fetch(request, env, ctx);
31
+ }
32
+ };
33
+ _impl = new WeakMap();
34
+ var OAuthProviderImpl = class {
35
+ /**
36
+ * Creates a new OAuth provider instance
37
+ * @param options - Configuration options for the provider
38
+ */
39
+ constructor(options) {
40
+ this.typedApiHandlers = [];
41
+ const hasSingleHandlerConfig = !!(options.apiRoute && options.apiHandler);
42
+ const hasMultiHandlerConfig = !!options.apiHandlers;
43
+ if (hasSingleHandlerConfig && hasMultiHandlerConfig) {
44
+ throw new TypeError(
45
+ "Cannot use both apiRoute/apiHandler and apiHandlers. Use either apiRoute + apiHandler OR apiHandlers, not both."
46
+ );
47
+ }
48
+ if (!hasSingleHandlerConfig && !hasMultiHandlerConfig) {
49
+ throw new TypeError(
50
+ "Must provide either apiRoute + apiHandler OR apiHandlers. No API route configuration provided."
51
+ );
52
+ }
53
+ this.typedDefaultHandler = this.validateHandler(options.defaultHandler, "defaultHandler");
54
+ if (hasSingleHandlerConfig) {
55
+ const apiHandler = this.validateHandler(options.apiHandler, "apiHandler");
56
+ if (Array.isArray(options.apiRoute)) {
57
+ options.apiRoute.forEach((route, index) => {
58
+ this.validateEndpoint(route, `apiRoute[${index}]`);
59
+ this.typedApiHandlers.push([route, apiHandler]);
60
+ });
61
+ } else {
62
+ this.validateEndpoint(options.apiRoute, "apiRoute");
63
+ this.typedApiHandlers.push([options.apiRoute, apiHandler]);
64
+ }
65
+ } else {
66
+ for (const [route, handler] of Object.entries(options.apiHandlers)) {
67
+ this.validateEndpoint(route, `apiHandlers key: ${route}`);
68
+ this.typedApiHandlers.push([route, this.validateHandler(handler, `apiHandlers[${route}]`)]);
69
+ }
70
+ }
71
+ this.validateEndpoint(options.authorizeEndpoint, "authorizeEndpoint");
72
+ this.validateEndpoint(options.tokenEndpoint, "tokenEndpoint");
73
+ if (options.clientRegistrationEndpoint) {
74
+ this.validateEndpoint(options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
75
+ }
76
+ this.options = {
77
+ accessTokenTTL: DEFAULT_ACCESS_TOKEN_TTL,
78
+ onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
79
+ ...options
80
+ };
81
+ }
82
+ /**
83
+ * Validates that an endpoint is either an absolute path or a full URL
84
+ * @param endpoint - The endpoint to validate
85
+ * @param name - The name of the endpoint property for error messages
86
+ * @throws TypeError if the endpoint is invalid
87
+ */
88
+ validateEndpoint(endpoint, name) {
89
+ if (this.isPath(endpoint)) {
90
+ if (!endpoint.startsWith("/")) {
91
+ throw new TypeError(`${name} path must be an absolute path starting with /`);
92
+ }
93
+ } else {
94
+ try {
95
+ new URL(endpoint);
96
+ } catch (e) {
97
+ throw new TypeError(`${name} must be either an absolute path starting with / or a valid URL`);
98
+ }
99
+ }
100
+ }
101
+ /**
102
+ * Validates that a handler is either an ExportedHandler or a class extending WorkerEntrypoint
103
+ * @param handler - The handler to validate
104
+ * @param name - The name of the handler property for error messages
105
+ * @returns The type of the handler (EXPORTED_HANDLER or WORKER_ENTRYPOINT)
106
+ * @throws TypeError if the handler is invalid
107
+ */
108
+ validateHandler(handler, name) {
109
+ if (typeof handler === "object" && handler !== null && typeof handler.fetch === "function") {
110
+ return { type: 0 /* EXPORTED_HANDLER */, handler };
111
+ }
112
+ if (typeof handler === "function" && handler.prototype instanceof WorkerEntrypoint) {
113
+ return { type: 1 /* WORKER_ENTRYPOINT */, handler };
114
+ }
115
+ throw new TypeError(
116
+ `${name} must be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint`
117
+ );
118
+ }
119
+ /**
120
+ * Main fetch handler for the Worker
121
+ * Routes requests to the appropriate handler based on the URL
122
+ * @param request - The HTTP request
123
+ * @param env - Cloudflare Worker environment variables
124
+ * @param ctx - Cloudflare Worker execution context
125
+ * @returns A Promise resolving to an HTTP Response
126
+ */
127
+ async fetch(request, env, ctx) {
128
+ const url = new URL(request.url);
129
+ if (request.method === "OPTIONS") {
130
+ if (this.isApiRequest(url) || url.pathname === "/.well-known/oauth-authorization-server" || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) {
131
+ return this.addCorsHeaders(
132
+ new Response(null, {
133
+ status: 204,
134
+ headers: { "Content-Length": "0" }
135
+ }),
136
+ request
137
+ );
138
+ }
139
+ }
140
+ if (url.pathname === "/.well-known/oauth-authorization-server") {
141
+ const response = await this.handleMetadataDiscovery(url);
142
+ return this.addCorsHeaders(response, request);
143
+ }
144
+ if (this.isTokenEndpoint(url)) {
145
+ const parsed = await this.parseTokenEndpointRequest(request, env);
146
+ if (parsed instanceof Response) {
147
+ return this.addCorsHeaders(parsed, request);
148
+ }
149
+ let response;
150
+ if (parsed.isRevocationRequest) {
151
+ response = await this.handleRevocationRequest(parsed.body, env);
152
+ } else {
153
+ response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env);
154
+ }
155
+ return this.addCorsHeaders(response, request);
156
+ }
157
+ if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) {
158
+ const response = await this.handleClientRegistration(request, env);
159
+ return this.addCorsHeaders(response, request);
160
+ }
161
+ if (this.isApiRequest(url)) {
162
+ const response = await this.handleApiRequest(request, env, ctx);
163
+ return this.addCorsHeaders(response, request);
164
+ }
165
+ if (!env.OAUTH_PROVIDER) {
166
+ env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
167
+ }
168
+ if (this.typedDefaultHandler.type === 0 /* EXPORTED_HANDLER */) {
169
+ return this.typedDefaultHandler.handler.fetch(
170
+ request,
171
+ env,
172
+ ctx
173
+ );
174
+ } else {
175
+ const handler = new this.typedDefaultHandler.handler(ctx, env);
176
+ return handler.fetch(request);
177
+ }
178
+ }
179
+ /**
180
+ * Determines if an endpoint configuration is a path or a full URL
181
+ * @param endpoint - The endpoint configuration
182
+ * @returns True if the endpoint is a path (starts with /), false if it's a full URL
183
+ */
184
+ isPath(endpoint) {
185
+ return endpoint.startsWith("/");
186
+ }
187
+ /**
188
+ * Matches a URL against an endpoint pattern that can be a full URL or just a path
189
+ * @param url - The URL to check
190
+ * @param endpoint - The endpoint pattern (full URL or path)
191
+ * @returns True if the URL matches the endpoint pattern
192
+ */
193
+ matchEndpoint(url, endpoint) {
194
+ if (this.isPath(endpoint)) {
195
+ return url.pathname === endpoint;
196
+ } else {
197
+ const endpointUrl = new URL(endpoint);
198
+ return url.hostname === endpointUrl.hostname && url.pathname === endpointUrl.pathname;
199
+ }
200
+ }
201
+ /**
202
+ * Checks if a URL matches the configured token endpoint
203
+ * @param url - The URL to check
204
+ * @returns True if the URL matches the token endpoint
205
+ */
206
+ isTokenEndpoint(url) {
207
+ return this.matchEndpoint(url, this.options.tokenEndpoint);
208
+ }
209
+ /**
210
+ * Checks if a URL matches the configured client registration endpoint
211
+ * @param url - The URL to check
212
+ * @returns True if the URL matches the client registration endpoint
213
+ */
214
+ isClientRegistrationEndpoint(url) {
215
+ if (!this.options.clientRegistrationEndpoint) return false;
216
+ return this.matchEndpoint(url, this.options.clientRegistrationEndpoint);
217
+ }
218
+ /**
219
+ * Parses and validates a token endpoint request (used for both token exchange and revocation)
220
+ * @param request - The HTTP request to parse
221
+ * @returns Promise with parsed body and client info, or error response
222
+ */
223
+ async parseTokenEndpointRequest(request, env) {
224
+ if (request.method !== "POST") {
225
+ return this.createErrorResponse("invalid_request", "Method not allowed", 405);
226
+ }
227
+ let contentType = request.headers.get("Content-Type") || "";
228
+ let body = {};
229
+ if (!contentType.includes("application/x-www-form-urlencoded")) {
230
+ return this.createErrorResponse("invalid_request", "Content-Type must be application/x-www-form-urlencoded", 400);
231
+ }
232
+ const formData = await request.formData();
233
+ for (const [key, value] of formData.entries()) {
234
+ body[key] = value;
235
+ }
236
+ const authHeader = request.headers.get("Authorization");
237
+ let clientId = "";
238
+ let clientSecret = "";
239
+ if (authHeader && authHeader.startsWith("Basic ")) {
240
+ const credentials = atob(authHeader.substring(6));
241
+ const [id, secret] = credentials.split(":", 2);
242
+ clientId = decodeURIComponent(id);
243
+ clientSecret = decodeURIComponent(secret || "");
244
+ } else {
245
+ clientId = body.client_id;
246
+ clientSecret = body.client_secret || "";
247
+ }
248
+ if (!clientId) {
249
+ return this.createErrorResponse("invalid_client", "Client ID is required", 401);
250
+ }
251
+ const clientInfo = await this.getClient(env, clientId);
252
+ if (!clientInfo) {
253
+ return this.createErrorResponse("invalid_client", "Client not found", 401);
254
+ }
255
+ const isPublicClient = clientInfo.tokenEndpointAuthMethod === "none";
256
+ if (!isPublicClient) {
257
+ if (!clientSecret) {
258
+ return this.createErrorResponse("invalid_client", "Client authentication failed: missing client_secret", 401);
259
+ }
260
+ if (!clientInfo.clientSecret) {
261
+ return this.createErrorResponse(
262
+ "invalid_client",
263
+ "Client authentication failed: client has no registered secret",
264
+ 401
265
+ );
266
+ }
267
+ const providedSecretHash = await hashSecret(clientSecret);
268
+ if (providedSecretHash !== clientInfo.clientSecret) {
269
+ return this.createErrorResponse("invalid_client", "Client authentication failed: invalid client_secret", 401);
270
+ }
271
+ }
272
+ const isRevocationRequest = !body.grant_type && !!body.token;
273
+ return {
274
+ body,
275
+ clientInfo,
276
+ isRevocationRequest
277
+ };
278
+ }
279
+ /**
280
+ * Checks if a URL matches a specific API route
281
+ * @param url - The URL to check
282
+ * @param route - The API route to check against
283
+ * @returns True if the URL matches the API route
284
+ */
285
+ matchApiRoute(url, route) {
286
+ if (this.isPath(route)) {
287
+ return url.pathname.startsWith(route);
288
+ } else {
289
+ const apiUrl = new URL(route);
290
+ return url.hostname === apiUrl.hostname && url.pathname.startsWith(apiUrl.pathname);
291
+ }
292
+ }
293
+ /**
294
+ * Checks if a URL is an API request based on the configured API route(s)
295
+ * @param url - The URL to check
296
+ * @returns True if the URL matches any of the API routes
297
+ */
298
+ isApiRequest(url) {
299
+ for (const [route, _] of this.typedApiHandlers) {
300
+ if (this.matchApiRoute(url, route)) {
301
+ return true;
302
+ }
303
+ }
304
+ return false;
305
+ }
306
+ /**
307
+ * Finds the appropriate API handler for a URL
308
+ * @param url - The URL to find a handler for
309
+ * @returns The TypedHandler for the URL, or undefined if no handler matches
310
+ */
311
+ findApiHandlerForUrl(url) {
312
+ for (const [route, handler] of this.typedApiHandlers) {
313
+ if (this.matchApiRoute(url, route)) {
314
+ return handler;
315
+ }
316
+ }
317
+ return void 0;
318
+ }
319
+ /**
320
+ * Gets the full URL for an endpoint, using the provided request URL's
321
+ * origin for endpoints specified as just paths
322
+ * @param endpoint - The endpoint configuration (path or full URL)
323
+ * @param requestUrl - The URL of the incoming request
324
+ * @returns The full URL for the endpoint
325
+ */
326
+ getFullEndpointUrl(endpoint, requestUrl) {
327
+ if (this.isPath(endpoint)) {
328
+ return `${requestUrl.origin}${endpoint}`;
329
+ } else {
330
+ return endpoint;
331
+ }
332
+ }
333
+ /**
334
+ * Adds CORS headers to a response
335
+ * @param response - The response to add CORS headers to
336
+ * @param request - The original request
337
+ * @returns A new Response with CORS headers added
338
+ */
339
+ addCorsHeaders(response, request) {
340
+ const origin = request.headers.get("Origin");
341
+ if (!origin) {
342
+ return response;
343
+ }
344
+ const newResponse = new Response(response.body, response);
345
+ newResponse.headers.set("Access-Control-Allow-Origin", origin);
346
+ newResponse.headers.set("Access-Control-Allow-Methods", "*");
347
+ newResponse.headers.set("Access-Control-Allow-Headers", "Authorization, *");
348
+ newResponse.headers.set("Access-Control-Max-Age", "86400");
349
+ return newResponse;
350
+ }
351
+ /**
352
+ * Handles the OAuth metadata discovery endpoint
353
+ * Implements RFC 8414 for OAuth Server Metadata
354
+ * @param requestUrl - The URL of the incoming request
355
+ * @returns Response with OAuth server metadata
356
+ */
357
+ async handleMetadataDiscovery(requestUrl) {
358
+ const tokenEndpoint = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
359
+ const authorizeEndpoint = this.getFullEndpointUrl(this.options.authorizeEndpoint, requestUrl);
360
+ let registrationEndpoint = void 0;
361
+ if (this.options.clientRegistrationEndpoint) {
362
+ registrationEndpoint = this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, requestUrl);
363
+ }
364
+ const responseTypesSupported = ["code"];
365
+ if (this.options.allowImplicitFlow) {
366
+ responseTypesSupported.push("token");
367
+ }
368
+ const metadata = {
369
+ issuer: new URL(tokenEndpoint).origin,
370
+ authorization_endpoint: authorizeEndpoint,
371
+ token_endpoint: tokenEndpoint,
372
+ // not implemented: jwks_uri
373
+ registration_endpoint: registrationEndpoint,
374
+ scopes_supported: this.options.scopesSupported,
375
+ response_types_supported: responseTypesSupported,
376
+ response_modes_supported: ["query"],
377
+ grant_types_supported: ["authorization_code", "refresh_token"],
378
+ // Support "none" auth method for public clients
379
+ token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
380
+ // not implemented: token_endpoint_auth_signing_alg_values_supported
381
+ // not implemented: service_documentation
382
+ // not implemented: ui_locales_supported
383
+ // not implemented: op_policy_uri
384
+ // not implemented: op_tos_uri
385
+ revocation_endpoint: tokenEndpoint,
386
+ // Reusing token endpoint for revocation
387
+ // not implemented: revocation_endpoint_auth_methods_supported
388
+ // not implemented: revocation_endpoint_auth_signing_alg_values_supported
389
+ // not implemented: introspection_endpoint
390
+ // not implemented: introspection_endpoint_auth_methods_supported
391
+ // not implemented: introspection_endpoint_auth_signing_alg_values_supported
392
+ code_challenge_methods_supported: ["plain", "S256"]
393
+ // PKCE support
394
+ };
395
+ return new Response(JSON.stringify(metadata), {
396
+ headers: { "Content-Type": "application/json" }
397
+ });
398
+ }
399
+ /**
400
+ * Handles client authentication and token issuance via the token endpoint
401
+ * Supports authorization_code and refresh_token grant types
402
+ * @param body - The parsed request body
403
+ * @param clientInfo - The authenticated client information
404
+ * @param env - Cloudflare Worker environment variables
405
+ * @returns Response with token data or error
406
+ */
407
+ async handleTokenRequest(body, clientInfo, env) {
408
+ const grantType = body.grant_type;
409
+ if (grantType === "authorization_code") {
410
+ return this.handleAuthorizationCodeGrant(body, clientInfo, env);
411
+ } else if (grantType === "refresh_token") {
412
+ return this.handleRefreshTokenGrant(body, clientInfo, env);
413
+ } else {
414
+ return this.createErrorResponse("unsupported_grant_type", "Grant type not supported");
415
+ }
416
+ }
417
+ /**
418
+ * Handles the authorization code grant type
419
+ * Exchanges an authorization code for access and refresh tokens
420
+ * @param body - The parsed request body
421
+ * @param clientInfo - The authenticated client information
422
+ * @param env - Cloudflare Worker environment variables
423
+ * @returns Response with token data or error
424
+ */
425
+ async handleAuthorizationCodeGrant(body, clientInfo, env) {
426
+ const code = body.code;
427
+ const redirectUri = body.redirect_uri;
428
+ const codeVerifier = body.code_verifier;
429
+ if (!code) {
430
+ return this.createErrorResponse("invalid_request", "Authorization code is required");
431
+ }
432
+ const codeParts = code.split(":");
433
+ if (codeParts.length !== 3) {
434
+ return this.createErrorResponse("invalid_grant", "Invalid authorization code format");
435
+ }
436
+ const [userId, grantId, _] = codeParts;
437
+ const grantKey = `grant:${userId}:${grantId}`;
438
+ const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
439
+ if (!grantData) {
440
+ return this.createErrorResponse("invalid_grant", "Grant not found or authorization code expired");
441
+ }
442
+ if (!grantData.authCodeId) {
443
+ return this.createErrorResponse("invalid_grant", "Authorization code already used");
444
+ }
445
+ const codeHash = await hashSecret(code);
446
+ if (codeHash !== grantData.authCodeId) {
447
+ return this.createErrorResponse("invalid_grant", "Invalid authorization code");
448
+ }
449
+ if (grantData.clientId !== clientInfo.clientId) {
450
+ return this.createErrorResponse("invalid_grant", "Client ID mismatch");
451
+ }
452
+ const isPkceEnabled = !!grantData.codeChallenge;
453
+ if (!redirectUri && !isPkceEnabled) {
454
+ return this.createErrorResponse("invalid_request", "redirect_uri is required when not using PKCE");
455
+ }
456
+ if (redirectUri && !clientInfo.redirectUris.includes(redirectUri)) {
457
+ return this.createErrorResponse("invalid_grant", "Invalid redirect URI");
458
+ }
459
+ if (!isPkceEnabled && codeVerifier) {
460
+ return this.createErrorResponse("invalid_request", "code_verifier provided for a flow that did not use PKCE");
461
+ }
462
+ if (isPkceEnabled) {
463
+ if (!codeVerifier) {
464
+ return this.createErrorResponse("invalid_request", "code_verifier is required for PKCE");
465
+ }
466
+ let calculatedChallenge;
467
+ if (grantData.codeChallengeMethod === "S256") {
468
+ const encoder = new TextEncoder();
469
+ const data = encoder.encode(codeVerifier);
470
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
471
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
472
+ calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
473
+ } else {
474
+ calculatedChallenge = codeVerifier;
475
+ }
476
+ if (calculatedChallenge !== grantData.codeChallenge) {
477
+ return this.createErrorResponse("invalid_grant", "Invalid PKCE code_verifier");
478
+ }
479
+ }
480
+ const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
481
+ const refreshTokenSecret = generateRandomString(TOKEN_LENGTH);
482
+ const accessToken = `${userId}:${grantId}:${accessTokenSecret}`;
483
+ const refreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
484
+ const accessTokenId = await generateTokenId(accessToken);
485
+ const refreshTokenId = await generateTokenId(refreshToken);
486
+ let accessTokenTTL = this.options.accessTokenTTL;
487
+ const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
488
+ let grantEncryptionKey = encryptionKey;
489
+ let accessTokenEncryptionKey = encryptionKey;
490
+ let encryptedAccessTokenProps = grantData.encryptedProps;
491
+ if (this.options.tokenExchangeCallback) {
492
+ const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
493
+ let grantProps = decryptedProps;
494
+ let accessTokenProps = decryptedProps;
495
+ const callbackOptions = {
496
+ grantType: "authorization_code",
497
+ clientId: clientInfo.clientId,
498
+ userId,
499
+ scope: grantData.scope,
500
+ props: decryptedProps
501
+ };
502
+ const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
503
+ if (callbackResult) {
504
+ if (callbackResult.newProps) {
505
+ grantProps = callbackResult.newProps;
506
+ if (!callbackResult.accessTokenProps) {
507
+ accessTokenProps = callbackResult.newProps;
508
+ }
509
+ }
510
+ if (callbackResult.accessTokenProps) {
511
+ accessTokenProps = callbackResult.accessTokenProps;
512
+ }
513
+ if (callbackResult.accessTokenTTL !== void 0) {
514
+ accessTokenTTL = callbackResult.accessTokenTTL;
515
+ }
516
+ }
517
+ const grantResult = await encryptProps(grantProps);
518
+ grantData.encryptedProps = grantResult.encryptedData;
519
+ grantEncryptionKey = grantResult.key;
520
+ if (accessTokenProps !== grantProps) {
521
+ const tokenResult = await encryptProps(accessTokenProps);
522
+ encryptedAccessTokenProps = tokenResult.encryptedData;
523
+ accessTokenEncryptionKey = tokenResult.key;
524
+ } else {
525
+ encryptedAccessTokenProps = grantData.encryptedProps;
526
+ accessTokenEncryptionKey = grantEncryptionKey;
527
+ }
528
+ }
529
+ const now = Math.floor(Date.now() / 1e3);
530
+ const accessTokenExpiresAt = now + accessTokenTTL;
531
+ const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, accessTokenEncryptionKey);
532
+ const refreshTokenWrappedKey = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
533
+ delete grantData.authCodeId;
534
+ delete grantData.codeChallenge;
535
+ delete grantData.codeChallengeMethod;
536
+ delete grantData.authCodeWrappedKey;
537
+ grantData.refreshTokenId = refreshTokenId;
538
+ grantData.refreshTokenWrappedKey = refreshTokenWrappedKey;
539
+ grantData.previousRefreshTokenId = void 0;
540
+ grantData.previousRefreshTokenWrappedKey = void 0;
541
+ await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData));
542
+ const accessTokenData = {
543
+ id: accessTokenId,
544
+ grantId,
545
+ userId,
546
+ createdAt: now,
547
+ expiresAt: accessTokenExpiresAt,
548
+ wrappedEncryptionKey: accessTokenWrappedKey,
549
+ grant: {
550
+ clientId: grantData.clientId,
551
+ scope: grantData.scope,
552
+ encryptedProps: encryptedAccessTokenProps
553
+ }
554
+ };
555
+ await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), {
556
+ expirationTtl: accessTokenTTL
557
+ });
558
+ return new Response(
559
+ JSON.stringify({
560
+ access_token: accessToken,
561
+ token_type: "bearer",
562
+ expires_in: accessTokenTTL,
563
+ refresh_token: refreshToken,
564
+ scope: grantData.scope.join(" ")
565
+ }),
566
+ {
567
+ headers: { "Content-Type": "application/json" }
568
+ }
569
+ );
570
+ }
571
+ /**
572
+ * Handles the refresh token grant type
573
+ * Issues a new access token using a refresh token
574
+ * @param body - The parsed request body
575
+ * @param clientInfo - The authenticated client information
576
+ * @param env - Cloudflare Worker environment variables
577
+ * @returns Response with token data or error
578
+ */
579
+ async handleRefreshTokenGrant(body, clientInfo, env) {
580
+ const refreshToken = body.refresh_token;
581
+ if (!refreshToken) {
582
+ return this.createErrorResponse("invalid_request", "Refresh token is required");
583
+ }
584
+ const tokenParts = refreshToken.split(":");
585
+ if (tokenParts.length !== 3) {
586
+ return this.createErrorResponse("invalid_grant", "Invalid token format");
587
+ }
588
+ const [userId, grantId, _] = tokenParts;
589
+ const providedTokenHash = await generateTokenId(refreshToken);
590
+ const grantKey = `grant:${userId}:${grantId}`;
591
+ const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
592
+ if (!grantData) {
593
+ return this.createErrorResponse("invalid_grant", "Grant not found");
594
+ }
595
+ const isCurrentToken = grantData.refreshTokenId === providedTokenHash;
596
+ const isPreviousToken = grantData.previousRefreshTokenId === providedTokenHash;
597
+ if (!isCurrentToken && !isPreviousToken) {
598
+ return this.createErrorResponse("invalid_grant", "Invalid refresh token");
599
+ }
600
+ if (grantData.clientId !== clientInfo.clientId) {
601
+ return this.createErrorResponse("invalid_grant", "Client ID mismatch");
602
+ }
603
+ const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
604
+ const newAccessToken = `${userId}:${grantId}:${accessTokenSecret}`;
605
+ const accessTokenId = await generateTokenId(newAccessToken);
606
+ const refreshTokenSecret = generateRandomString(TOKEN_LENGTH);
607
+ const newRefreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
608
+ const newRefreshTokenId = await generateTokenId(newRefreshToken);
609
+ let accessTokenTTL = this.options.accessTokenTTL;
610
+ let wrappedKeyToUse;
611
+ if (isCurrentToken) {
612
+ wrappedKeyToUse = grantData.refreshTokenWrappedKey;
613
+ } else {
614
+ wrappedKeyToUse = grantData.previousRefreshTokenWrappedKey;
615
+ }
616
+ const encryptionKey = await unwrapKeyWithToken(refreshToken, wrappedKeyToUse);
617
+ let grantEncryptionKey = encryptionKey;
618
+ let accessTokenEncryptionKey = encryptionKey;
619
+ let encryptedAccessTokenProps = grantData.encryptedProps;
620
+ if (this.options.tokenExchangeCallback) {
621
+ const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
622
+ let grantProps = decryptedProps;
623
+ let accessTokenProps = decryptedProps;
624
+ const callbackOptions = {
625
+ grantType: "refresh_token",
626
+ clientId: clientInfo.clientId,
627
+ userId,
628
+ scope: grantData.scope,
629
+ props: decryptedProps
630
+ };
631
+ const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
632
+ let grantPropsChanged = false;
633
+ if (callbackResult) {
634
+ if (callbackResult.newProps) {
635
+ grantProps = callbackResult.newProps;
636
+ grantPropsChanged = true;
637
+ if (!callbackResult.accessTokenProps) {
638
+ accessTokenProps = callbackResult.newProps;
639
+ }
640
+ }
641
+ if (callbackResult.accessTokenProps) {
642
+ accessTokenProps = callbackResult.accessTokenProps;
643
+ }
644
+ if (callbackResult.accessTokenTTL !== void 0) {
645
+ accessTokenTTL = callbackResult.accessTokenTTL;
646
+ }
647
+ }
648
+ if (grantPropsChanged) {
649
+ const grantResult = await encryptProps(grantProps);
650
+ grantData.encryptedProps = grantResult.encryptedData;
651
+ if (grantResult.key !== encryptionKey) {
652
+ grantEncryptionKey = grantResult.key;
653
+ wrappedKeyToUse = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
654
+ } else {
655
+ grantEncryptionKey = grantResult.key;
656
+ }
657
+ }
658
+ if (accessTokenProps !== grantProps) {
659
+ const tokenResult = await encryptProps(accessTokenProps);
660
+ encryptedAccessTokenProps = tokenResult.encryptedData;
661
+ accessTokenEncryptionKey = tokenResult.key;
662
+ } else {
663
+ encryptedAccessTokenProps = grantData.encryptedProps;
664
+ accessTokenEncryptionKey = grantEncryptionKey;
665
+ }
666
+ }
667
+ const now = Math.floor(Date.now() / 1e3);
668
+ const accessTokenExpiresAt = now + accessTokenTTL;
669
+ const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
670
+ const newRefreshTokenWrappedKey = await wrapKeyWithToken(newRefreshToken, grantEncryptionKey);
671
+ grantData.previousRefreshTokenId = providedTokenHash;
672
+ grantData.previousRefreshTokenWrappedKey = wrappedKeyToUse;
673
+ grantData.refreshTokenId = newRefreshTokenId;
674
+ grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
675
+ await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData));
676
+ const accessTokenData = {
677
+ id: accessTokenId,
678
+ grantId,
679
+ userId,
680
+ createdAt: now,
681
+ expiresAt: accessTokenExpiresAt,
682
+ wrappedEncryptionKey: accessTokenWrappedKey,
683
+ grant: {
684
+ clientId: grantData.clientId,
685
+ scope: grantData.scope,
686
+ encryptedProps: encryptedAccessTokenProps
687
+ }
688
+ };
689
+ await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), {
690
+ expirationTtl: accessTokenTTL
691
+ });
692
+ return new Response(
693
+ JSON.stringify({
694
+ access_token: newAccessToken,
695
+ token_type: "bearer",
696
+ expires_in: accessTokenTTL,
697
+ refresh_token: newRefreshToken,
698
+ scope: grantData.scope.join(" ")
699
+ }),
700
+ {
701
+ headers: { "Content-Type": "application/json" }
702
+ }
703
+ );
704
+ }
705
+ /**
706
+ * Handles OAuth 2.0 token revocation requests (RFC 7009)
707
+ * @param body - The parsed request body containing revocation parameters
708
+ * @param env - Cloudflare Worker environment variables
709
+ * @returns Response confirming revocation or error
710
+ */
711
+ async handleRevocationRequest(body, env) {
712
+ return this.revokeToken(body, env);
713
+ }
714
+ /**
715
+ * - Access tokens: Revokes only the specific token
716
+ * - Refresh tokens: Revokes the entire grant (access + refresh tokens)
717
+ * @param body - The parsed request body containing token parameter
718
+ * @param env - Cloudflare Worker environment variables
719
+ * @returns Response confirming revocation or error
720
+ */
721
+ async revokeToken(body, env) {
722
+ const token = body.token;
723
+ if (!token) {
724
+ return this.createErrorResponse("invalid_request", "Token parameter is required");
725
+ }
726
+ const tokenParts = token.split(":");
727
+ if (tokenParts.length !== 3) {
728
+ return new Response("", { status: 200 });
729
+ }
730
+ const [userId, grantId, _] = tokenParts;
731
+ const tokenId = await generateTokenId(token);
732
+ const isAccessToken = await this.validateAccessToken(tokenId, userId, grantId, env);
733
+ const isRefreshToken = await this.validateRefreshToken(tokenId, userId, grantId, env);
734
+ if (isAccessToken) {
735
+ await this.revokeSpecificAccessToken(tokenId, userId, grantId, env);
736
+ } else if (isRefreshToken) {
737
+ await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
738
+ }
739
+ return new Response("", { status: 200 });
740
+ }
741
+ /**
742
+ * Revokes a specific access token without affecting the refresh token
743
+ * @param tokenId - The hashed token ID
744
+ * @param userId - The user ID extracted from the token
745
+ * @param grantId - The grant ID extracted from the token
746
+ * @param env - Cloudflare Worker environment variables
747
+ */
748
+ async revokeSpecificAccessToken(tokenId, userId, grantId, env) {
749
+ const tokenKey = `token:${userId}:${grantId}:${tokenId}`;
750
+ await env.OAUTH_KV.delete(tokenKey);
751
+ }
752
+ /**
753
+ * Validates if a token is a valid access token
754
+ * @param tokenId - The hashed token ID
755
+ * @param userId - The user ID extracted from the token
756
+ * @param grantId - The grant ID extracted from the token
757
+ * @param env - Cloudflare Worker environment variables
758
+ * @returns Promise<boolean> indicating if the token is valid
759
+ */
760
+ async validateAccessToken(tokenId, userId, grantId, env) {
761
+ const tokenKey = `token:${userId}:${grantId}:${tokenId}`;
762
+ const tokenData = await env.OAUTH_KV.get(tokenKey, { type: "json" });
763
+ if (!tokenData) {
764
+ return false;
765
+ }
766
+ const now = Math.floor(Date.now() / 1e3);
767
+ return tokenData.expiresAt >= now;
768
+ }
769
+ /**
770
+ * Validates if a token is a valid refresh token
771
+ * @param tokenId - The hashed token ID
772
+ * @param userId - The user ID extracted from the token
773
+ * @param grantId - The grant ID extracted from the token
774
+ * @param env - Cloudflare Worker environment variables
775
+ * @returns Promise<boolean> indicating if the token is valid
776
+ */
777
+ async validateRefreshToken(tokenId, userId, grantId, env) {
778
+ const grantKey = `grant:${userId}:${grantId}`;
779
+ const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
780
+ if (!grantData) {
781
+ return false;
782
+ }
783
+ return grantData.refreshTokenId === tokenId || grantData.previousRefreshTokenId === tokenId;
784
+ }
785
+ /**
786
+ * Handles the dynamic client registration endpoint (RFC 7591)
787
+ * @param request - The HTTP request
788
+ * @param env - Cloudflare Worker environment variables
789
+ * @returns Response with client registration data or error
790
+ */
791
+ async handleClientRegistration(request, env) {
792
+ if (!this.options.clientRegistrationEndpoint) {
793
+ return this.createErrorResponse("not_implemented", "Client registration is not enabled", 501);
794
+ }
795
+ if (request.method !== "POST") {
796
+ return this.createErrorResponse("invalid_request", "Method not allowed", 405);
797
+ }
798
+ const contentLength = parseInt(request.headers.get("Content-Length") || "0", 10);
799
+ if (contentLength > 1048576) {
800
+ return this.createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
801
+ }
802
+ let clientMetadata;
803
+ try {
804
+ const text = await request.text();
805
+ if (text.length > 1048576) {
806
+ return this.createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
807
+ }
808
+ clientMetadata = JSON.parse(text);
809
+ } catch (error) {
810
+ return this.createErrorResponse("invalid_request", "Invalid JSON payload", 400);
811
+ }
812
+ const validateStringField = (field) => {
813
+ if (field === void 0) {
814
+ return void 0;
815
+ }
816
+ if (typeof field !== "string") {
817
+ throw new Error("Field must be a string");
818
+ }
819
+ return field;
820
+ };
821
+ const validateStringArray = (arr) => {
822
+ if (arr === void 0) {
823
+ return void 0;
824
+ }
825
+ if (!Array.isArray(arr)) {
826
+ throw new Error("Field must be an array");
827
+ }
828
+ for (const item of arr) {
829
+ if (typeof item !== "string") {
830
+ throw new Error("All array elements must be strings");
831
+ }
832
+ }
833
+ return arr;
834
+ };
835
+ const authMethod = validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic";
836
+ const isPublicClient = authMethod === "none";
837
+ if (isPublicClient && this.options.disallowPublicClientRegistration) {
838
+ return this.createErrorResponse("invalid_client_metadata", "Public client registration is not allowed");
839
+ }
840
+ const clientId = generateRandomString(16);
841
+ let clientSecret;
842
+ let hashedSecret;
843
+ if (!isPublicClient) {
844
+ clientSecret = generateRandomString(32);
845
+ hashedSecret = await hashSecret(clientSecret);
846
+ }
847
+ let clientInfo;
848
+ try {
849
+ const redirectUris = validateStringArray(clientMetadata.redirect_uris);
850
+ if (!redirectUris || redirectUris.length === 0) {
851
+ throw new Error("At least one redirect URI is required");
852
+ }
853
+ clientInfo = {
854
+ clientId,
855
+ redirectUris,
856
+ clientName: validateStringField(clientMetadata.client_name),
857
+ logoUri: validateStringField(clientMetadata.logo_uri),
858
+ clientUri: validateStringField(clientMetadata.client_uri),
859
+ policyUri: validateStringField(clientMetadata.policy_uri),
860
+ tosUri: validateStringField(clientMetadata.tos_uri),
861
+ jwksUri: validateStringField(clientMetadata.jwks_uri),
862
+ contacts: validateStringArray(clientMetadata.contacts),
863
+ grantTypes: validateStringArray(clientMetadata.grant_types) || ["authorization_code", "refresh_token"],
864
+ responseTypes: validateStringArray(clientMetadata.response_types) || ["code"],
865
+ registrationDate: Math.floor(Date.now() / 1e3),
866
+ tokenEndpointAuthMethod: authMethod
867
+ };
868
+ if (!isPublicClient && hashedSecret) {
869
+ clientInfo.clientSecret = hashedSecret;
870
+ }
871
+ } catch (error) {
872
+ return this.createErrorResponse(
873
+ "invalid_client_metadata",
874
+ error instanceof Error ? error.message : "Invalid client metadata"
875
+ );
876
+ }
877
+ await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo));
878
+ const response = {
879
+ client_id: clientInfo.clientId,
880
+ redirect_uris: clientInfo.redirectUris,
881
+ client_name: clientInfo.clientName,
882
+ logo_uri: clientInfo.logoUri,
883
+ client_uri: clientInfo.clientUri,
884
+ policy_uri: clientInfo.policyUri,
885
+ tos_uri: clientInfo.tosUri,
886
+ jwks_uri: clientInfo.jwksUri,
887
+ contacts: clientInfo.contacts,
888
+ grant_types: clientInfo.grantTypes,
889
+ response_types: clientInfo.responseTypes,
890
+ token_endpoint_auth_method: clientInfo.tokenEndpointAuthMethod,
891
+ registration_client_uri: `${this.options.clientRegistrationEndpoint}/${clientId}`,
892
+ client_id_issued_at: clientInfo.registrationDate
893
+ };
894
+ if (clientSecret) {
895
+ response.client_secret = clientSecret;
896
+ }
897
+ return new Response(JSON.stringify(response), {
898
+ status: 201,
899
+ headers: { "Content-Type": "application/json" }
900
+ });
901
+ }
902
+ /**
903
+ * Handles API requests by validating the access token and calling the API handler
904
+ * @param request - The HTTP request
905
+ * @param env - Cloudflare Worker environment variables
906
+ * @param ctx - Cloudflare Worker execution context
907
+ * @returns Response from the API handler or error
908
+ */
909
+ async handleApiRequest(request, env, ctx) {
910
+ const authHeader = request.headers.get("Authorization");
911
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
912
+ return this.createErrorResponse("invalid_token", "Missing or invalid access token", 401, {
913
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token", error_description="Missing or invalid access token"'
914
+ });
915
+ }
916
+ const accessToken = authHeader.substring(7);
917
+ const tokenParts = accessToken.split(":");
918
+ if (tokenParts.length !== 3) {
919
+ return this.createErrorResponse("invalid_token", "Invalid token format", 401, {
920
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
921
+ });
922
+ }
923
+ const [userId, grantId, _] = tokenParts;
924
+ const accessTokenId = await generateTokenId(accessToken);
925
+ const tokenKey = `token:${userId}:${grantId}:${accessTokenId}`;
926
+ const tokenData = await env.OAUTH_KV.get(tokenKey, { type: "json" });
927
+ if (!tokenData) {
928
+ return this.createErrorResponse("invalid_token", "Invalid access token", 401, {
929
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
930
+ });
931
+ }
932
+ const now = Math.floor(Date.now() / 1e3);
933
+ if (tokenData.expiresAt < now) {
934
+ return this.createErrorResponse("invalid_token", "Access token expired", 401, {
935
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
936
+ });
937
+ }
938
+ const encryptionKey = await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey);
939
+ const decryptedProps = await decryptProps(encryptionKey, tokenData.grant.encryptedProps);
940
+ ctx.props = decryptedProps;
941
+ if (!env.OAUTH_PROVIDER) {
942
+ env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
943
+ }
944
+ const url = new URL(request.url);
945
+ const apiHandler = this.findApiHandlerForUrl(url);
946
+ if (!apiHandler) {
947
+ return this.createErrorResponse("invalid_request", "No handler found for API route", 404);
948
+ }
949
+ if (apiHandler.type === 0 /* EXPORTED_HANDLER */) {
950
+ return apiHandler.handler.fetch(request, env, ctx);
951
+ } else {
952
+ const handler = new apiHandler.handler(ctx, env);
953
+ return handler.fetch(request);
954
+ }
955
+ }
956
+ /**
957
+ * Creates the helper methods object for OAuth operations
958
+ * This is passed to the handler functions to allow them to interact with the OAuth system
959
+ * @param env - Cloudflare Worker environment variables
960
+ * @returns An instance of OAuthHelpers
961
+ */
962
+ createOAuthHelpers(env) {
963
+ return new OAuthHelpersImpl(env, this);
964
+ }
965
+ /**
966
+ * Fetches client information from KV storage
967
+ * This method is not private because `OAuthHelpers` needs to call it. Note that since
968
+ * `OAuthProviderImpl` is not exposed outside this module, this is still effectively
969
+ * module-private.
970
+ * @param env - Cloudflare Worker environment variables
971
+ * @param clientId - The client ID to look up
972
+ * @returns The client information, or null if not found
973
+ */
974
+ getClient(env, clientId) {
975
+ const clientKey = `client:${clientId}`;
976
+ return env.OAUTH_KV.get(clientKey, { type: "json" });
977
+ }
978
+ /**
979
+ * Helper function to create OAuth error responses
980
+ * @param code - OAuth error code (e.g., 'invalid_request', 'invalid_token')
981
+ * @param description - Human-readable error description
982
+ * @param status - HTTP status code (default: 400)
983
+ * @param headers - Additional headers to include
984
+ * @returns A Response object with the error
985
+ */
986
+ createErrorResponse(code, description, status = 400, headers = {}) {
987
+ const customErrorResponse = this.options.onError?.({ code, description, status, headers });
988
+ if (customErrorResponse) return customErrorResponse;
989
+ const body = JSON.stringify({
990
+ error: code,
991
+ error_description: description
992
+ });
993
+ return new Response(body, {
994
+ status,
995
+ headers: {
996
+ "Content-Type": "application/json",
997
+ ...headers
998
+ }
999
+ });
1000
+ }
1001
+ };
1002
+ var DEFAULT_ACCESS_TOKEN_TTL = 60 * 60;
1003
+ var TOKEN_LENGTH = 32;
1004
+ async function hashSecret(secret) {
1005
+ return generateTokenId(secret);
1006
+ }
1007
+ function generateRandomString(length) {
1008
+ const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
1009
+ let result = "";
1010
+ const values = new Uint8Array(length);
1011
+ crypto.getRandomValues(values);
1012
+ for (let i = 0; i < length; i++) {
1013
+ result += characters.charAt(values[i] % characters.length);
1014
+ }
1015
+ return result;
1016
+ }
1017
+ async function generateTokenId(token) {
1018
+ const encoder = new TextEncoder();
1019
+ const data = encoder.encode(token);
1020
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1021
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
1022
+ const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
1023
+ return hashHex;
1024
+ }
1025
+ function base64UrlEncode(str) {
1026
+ return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
1027
+ }
1028
+ function arrayBufferToBase64(buffer) {
1029
+ return btoa(String.fromCharCode(...new Uint8Array(buffer)));
1030
+ }
1031
+ function base64ToArrayBuffer(base64) {
1032
+ const binaryString = atob(base64);
1033
+ const bytes = new Uint8Array(binaryString.length);
1034
+ for (let i = 0; i < binaryString.length; i++) {
1035
+ bytes[i] = binaryString.charCodeAt(i);
1036
+ }
1037
+ return bytes.buffer;
1038
+ }
1039
+ async function encryptProps(data) {
1040
+ const key = await crypto.subtle.generateKey(
1041
+ {
1042
+ name: "AES-GCM",
1043
+ length: 256
1044
+ },
1045
+ true,
1046
+ // extractable
1047
+ ["encrypt", "decrypt"]
1048
+ );
1049
+ const iv = new Uint8Array(12);
1050
+ const jsonData = JSON.stringify(data);
1051
+ const encoder = new TextEncoder();
1052
+ const encodedData = encoder.encode(jsonData);
1053
+ const encryptedBuffer = await crypto.subtle.encrypt(
1054
+ {
1055
+ name: "AES-GCM",
1056
+ iv
1057
+ },
1058
+ key,
1059
+ encodedData
1060
+ );
1061
+ return {
1062
+ encryptedData: arrayBufferToBase64(encryptedBuffer),
1063
+ key
1064
+ };
1065
+ }
1066
+ async function decryptProps(key, encryptedData) {
1067
+ const encryptedBuffer = base64ToArrayBuffer(encryptedData);
1068
+ const iv = new Uint8Array(12);
1069
+ const decryptedBuffer = await crypto.subtle.decrypt(
1070
+ {
1071
+ name: "AES-GCM",
1072
+ iv
1073
+ },
1074
+ key,
1075
+ encryptedBuffer
1076
+ );
1077
+ const decoder = new TextDecoder();
1078
+ const jsonData = decoder.decode(decryptedBuffer);
1079
+ return JSON.parse(jsonData);
1080
+ }
1081
+ var WRAPPING_KEY_HMAC_KEY = new Uint8Array([
1082
+ 34,
1083
+ 126,
1084
+ 38,
1085
+ 134,
1086
+ 141,
1087
+ 241,
1088
+ 225,
1089
+ 109,
1090
+ 128,
1091
+ 112,
1092
+ 234,
1093
+ 23,
1094
+ 151,
1095
+ 91,
1096
+ 71,
1097
+ 166,
1098
+ 130,
1099
+ 24,
1100
+ 250,
1101
+ 135,
1102
+ 40,
1103
+ 174,
1104
+ 222,
1105
+ 133,
1106
+ 181,
1107
+ 29,
1108
+ 74,
1109
+ 217,
1110
+ 150,
1111
+ 202,
1112
+ 202,
1113
+ 67
1114
+ ]);
1115
+ async function deriveKeyFromToken(tokenStr) {
1116
+ const encoder = new TextEncoder();
1117
+ const hmacKey = await crypto.subtle.importKey(
1118
+ "raw",
1119
+ WRAPPING_KEY_HMAC_KEY,
1120
+ { name: "HMAC", hash: "SHA-256" },
1121
+ false,
1122
+ ["sign"]
1123
+ );
1124
+ const hmacResult = await crypto.subtle.sign("HMAC", hmacKey, encoder.encode(tokenStr));
1125
+ return await crypto.subtle.importKey(
1126
+ "raw",
1127
+ hmacResult,
1128
+ { name: "AES-KW" },
1129
+ false,
1130
+ // not extractable
1131
+ ["wrapKey", "unwrapKey"]
1132
+ );
1133
+ }
1134
+ async function wrapKeyWithToken(tokenStr, keyToWrap) {
1135
+ const wrappingKey = await deriveKeyFromToken(tokenStr);
1136
+ const wrappedKeyBuffer = await crypto.subtle.wrapKey("raw", keyToWrap, wrappingKey, { name: "AES-KW" });
1137
+ return arrayBufferToBase64(wrappedKeyBuffer);
1138
+ }
1139
+ async function unwrapKeyWithToken(tokenStr, wrappedKeyBase64) {
1140
+ const wrappingKey = await deriveKeyFromToken(tokenStr);
1141
+ const wrappedKeyBuffer = base64ToArrayBuffer(wrappedKeyBase64);
1142
+ return await crypto.subtle.unwrapKey(
1143
+ "raw",
1144
+ wrappedKeyBuffer,
1145
+ wrappingKey,
1146
+ { name: "AES-KW" },
1147
+ { name: "AES-GCM" },
1148
+ true,
1149
+ // extractable
1150
+ ["encrypt", "decrypt"]
1151
+ );
1152
+ }
1153
+ var OAuthHelpersImpl = class {
1154
+ /**
1155
+ * Creates a new OAuthHelpers instance
1156
+ * @param env - Cloudflare Worker environment variables
1157
+ * @param provider - Reference to the parent provider instance
1158
+ */
1159
+ constructor(env, provider) {
1160
+ this.env = env;
1161
+ this.provider = provider;
1162
+ }
1163
+ /**
1164
+ * Parses an OAuth authorization request from the HTTP request
1165
+ * @param request - The HTTP request containing OAuth parameters
1166
+ * @returns The parsed authorization request parameters
1167
+ */
1168
+ async parseAuthRequest(request) {
1169
+ const url = new URL(request.url);
1170
+ const responseType = url.searchParams.get("response_type") || "";
1171
+ const clientId = url.searchParams.get("client_id") || "";
1172
+ const redirectUri = url.searchParams.get("redirect_uri") || "";
1173
+ const scope = (url.searchParams.get("scope") || "").split(" ").filter(Boolean);
1174
+ const state = url.searchParams.get("state") || "";
1175
+ const codeChallenge = url.searchParams.get("code_challenge") || void 0;
1176
+ const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "plain";
1177
+ if (!redirectUri.startsWith("http://") && !redirectUri.startsWith("https://")) {
1178
+ throw new Error("Invalid redirect URI");
1179
+ }
1180
+ if (responseType === "token" && !this.provider.options.allowImplicitFlow) {
1181
+ throw new Error("The implicit grant flow is not enabled for this provider");
1182
+ }
1183
+ if (clientId) {
1184
+ const clientInfo = await this.lookupClient(clientId);
1185
+ if (!clientInfo) {
1186
+ throw new Error(`Invalid client. The clientId provided does not match to this client.`);
1187
+ }
1188
+ if (clientInfo && redirectUri) {
1189
+ if (!clientInfo.redirectUris.includes(redirectUri)) {
1190
+ throw new Error(
1191
+ `Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.`
1192
+ );
1193
+ }
1194
+ }
1195
+ }
1196
+ return {
1197
+ responseType,
1198
+ clientId,
1199
+ redirectUri,
1200
+ scope,
1201
+ state,
1202
+ codeChallenge,
1203
+ codeChallengeMethod
1204
+ };
1205
+ }
1206
+ /**
1207
+ * Looks up a client by its client ID
1208
+ * @param clientId - The client ID to look up
1209
+ * @returns A Promise resolving to the client info, or null if not found
1210
+ */
1211
+ async lookupClient(clientId) {
1212
+ return await this.provider.getClient(this.env, clientId);
1213
+ }
1214
+ /**
1215
+ * Completes an authorization request by creating a grant and either:
1216
+ * - For authorization code flow: generating an authorization code
1217
+ * - For implicit flow: generating an access token directly
1218
+ * @param options - Options specifying the grant details
1219
+ * @returns A Promise resolving to an object containing the redirect URL
1220
+ */
1221
+ async completeAuthorization(options) {
1222
+ const grantId = generateRandomString(16);
1223
+ const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
1224
+ const now = Math.floor(Date.now() / 1e3);
1225
+ if (options.request.responseType === "token") {
1226
+ const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
1227
+ const accessToken = `${options.userId}:${grantId}:${accessTokenSecret}`;
1228
+ const accessTokenId = await generateTokenId(accessToken);
1229
+ const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
1230
+ const accessTokenExpiresAt = now + accessTokenTTL;
1231
+ const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
1232
+ const grant = {
1233
+ id: grantId,
1234
+ clientId: options.request.clientId,
1235
+ userId: options.userId,
1236
+ scope: options.scope,
1237
+ metadata: options.metadata,
1238
+ encryptedProps: encryptedData,
1239
+ createdAt: now
1240
+ };
1241
+ const grantKey = `grant:${options.userId}:${grantId}`;
1242
+ await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
1243
+ const accessTokenData = {
1244
+ id: accessTokenId,
1245
+ grantId,
1246
+ userId: options.userId,
1247
+ createdAt: now,
1248
+ expiresAt: accessTokenExpiresAt,
1249
+ wrappedEncryptionKey: accessTokenWrappedKey,
1250
+ grant: {
1251
+ clientId: options.request.clientId,
1252
+ scope: options.scope,
1253
+ encryptedProps: encryptedData
1254
+ }
1255
+ };
1256
+ await this.env.OAUTH_KV.put(
1257
+ `token:${options.userId}:${grantId}:${accessTokenId}`,
1258
+ JSON.stringify(accessTokenData),
1259
+ { expirationTtl: accessTokenTTL }
1260
+ );
1261
+ const redirectUrl = new URL(options.request.redirectUri);
1262
+ const fragment = new URLSearchParams();
1263
+ fragment.set("access_token", accessToken);
1264
+ fragment.set("token_type", "bearer");
1265
+ fragment.set("expires_in", accessTokenTTL.toString());
1266
+ fragment.set("scope", options.scope.join(" "));
1267
+ if (options.request.state) {
1268
+ fragment.set("state", options.request.state);
1269
+ }
1270
+ redirectUrl.hash = fragment.toString();
1271
+ return { redirectTo: redirectUrl.toString() };
1272
+ } else {
1273
+ const authCodeSecret = generateRandomString(32);
1274
+ const authCode = `${options.userId}:${grantId}:${authCodeSecret}`;
1275
+ const authCodeId = await hashSecret(authCode);
1276
+ const authCodeWrappedKey = await wrapKeyWithToken(authCode, encryptionKey);
1277
+ const grant = {
1278
+ id: grantId,
1279
+ clientId: options.request.clientId,
1280
+ userId: options.userId,
1281
+ scope: options.scope,
1282
+ metadata: options.metadata,
1283
+ encryptedProps: encryptedData,
1284
+ createdAt: now,
1285
+ authCodeId,
1286
+ // Store the auth code hash in the grant
1287
+ authCodeWrappedKey,
1288
+ // Store the wrapped key
1289
+ // Store PKCE parameters if provided
1290
+ codeChallenge: options.request.codeChallenge,
1291
+ codeChallengeMethod: options.request.codeChallengeMethod
1292
+ };
1293
+ const grantKey = `grant:${options.userId}:${grantId}`;
1294
+ const codeExpiresIn = 600;
1295
+ await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), { expirationTtl: codeExpiresIn });
1296
+ const redirectUrl = new URL(options.request.redirectUri);
1297
+ redirectUrl.searchParams.set("code", authCode);
1298
+ if (options.request.state) {
1299
+ redirectUrl.searchParams.set("state", options.request.state);
1300
+ }
1301
+ return { redirectTo: redirectUrl.toString() };
1302
+ }
1303
+ }
1304
+ /**
1305
+ * Creates a new OAuth client
1306
+ * @param clientInfo - Partial client information to create the client with
1307
+ * @returns A Promise resolving to the created client info
1308
+ */
1309
+ async createClient(clientInfo) {
1310
+ const clientId = generateRandomString(16);
1311
+ const tokenEndpointAuthMethod = clientInfo.tokenEndpointAuthMethod || "client_secret_basic";
1312
+ const isPublicClient = tokenEndpointAuthMethod === "none";
1313
+ const newClient = {
1314
+ clientId,
1315
+ redirectUris: clientInfo.redirectUris || [],
1316
+ clientName: clientInfo.clientName,
1317
+ logoUri: clientInfo.logoUri,
1318
+ clientUri: clientInfo.clientUri,
1319
+ policyUri: clientInfo.policyUri,
1320
+ tosUri: clientInfo.tosUri,
1321
+ jwksUri: clientInfo.jwksUri,
1322
+ contacts: clientInfo.contacts,
1323
+ grantTypes: clientInfo.grantTypes || ["authorization_code", "refresh_token"],
1324
+ responseTypes: clientInfo.responseTypes || ["code"],
1325
+ registrationDate: Math.floor(Date.now() / 1e3),
1326
+ tokenEndpointAuthMethod
1327
+ };
1328
+ let clientSecret;
1329
+ if (!isPublicClient) {
1330
+ clientSecret = generateRandomString(32);
1331
+ newClient.clientSecret = await hashSecret(clientSecret);
1332
+ }
1333
+ await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(newClient));
1334
+ const clientResponse = { ...newClient };
1335
+ if (!isPublicClient && clientSecret) {
1336
+ clientResponse.clientSecret = clientSecret;
1337
+ }
1338
+ return clientResponse;
1339
+ }
1340
+ /**
1341
+ * Lists all registered OAuth clients with pagination support
1342
+ * @param options - Optional pagination parameters (limit and cursor)
1343
+ * @returns A Promise resolving to the list result with items and optional cursor
1344
+ */
1345
+ async listClients(options) {
1346
+ const listOptions = {
1347
+ prefix: "client:"
1348
+ };
1349
+ if (options?.limit !== void 0) {
1350
+ listOptions.limit = options.limit;
1351
+ }
1352
+ if (options?.cursor !== void 0) {
1353
+ listOptions.cursor = options.cursor;
1354
+ }
1355
+ const response = await this.env.OAUTH_KV.list(listOptions);
1356
+ const clients = [];
1357
+ const promises = response.keys.map(async (key) => {
1358
+ const clientId = key.name.substring("client:".length);
1359
+ const client = await this.provider.getClient(this.env, clientId);
1360
+ if (client) {
1361
+ clients.push(client);
1362
+ }
1363
+ });
1364
+ await Promise.all(promises);
1365
+ return {
1366
+ items: clients,
1367
+ cursor: response.list_complete ? void 0 : response.cursor
1368
+ };
1369
+ }
1370
+ /**
1371
+ * Updates an existing OAuth client
1372
+ * @param clientId - The ID of the client to update
1373
+ * @param updates - Partial client information with fields to update
1374
+ * @returns A Promise resolving to the updated client info, or null if not found
1375
+ */
1376
+ async updateClient(clientId, updates) {
1377
+ const client = await this.provider.getClient(this.env, clientId);
1378
+ if (!client) {
1379
+ return null;
1380
+ }
1381
+ let authMethod = updates.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod || "client_secret_basic";
1382
+ const isPublicClient = authMethod === "none";
1383
+ let secretToStore = client.clientSecret;
1384
+ let originalSecret = void 0;
1385
+ if (isPublicClient) {
1386
+ secretToStore = void 0;
1387
+ } else if (updates.clientSecret) {
1388
+ originalSecret = updates.clientSecret;
1389
+ secretToStore = await hashSecret(updates.clientSecret);
1390
+ }
1391
+ const updatedClient = {
1392
+ ...client,
1393
+ ...updates,
1394
+ clientId: client.clientId,
1395
+ // Ensure clientId doesn't change
1396
+ tokenEndpointAuthMethod: authMethod
1397
+ // Use determined auth method
1398
+ };
1399
+ if (!isPublicClient && secretToStore) {
1400
+ updatedClient.clientSecret = secretToStore;
1401
+ } else {
1402
+ delete updatedClient.clientSecret;
1403
+ }
1404
+ await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient));
1405
+ const response = { ...updatedClient };
1406
+ if (!isPublicClient && originalSecret) {
1407
+ response.clientSecret = originalSecret;
1408
+ }
1409
+ return response;
1410
+ }
1411
+ /**
1412
+ * Deletes an OAuth client
1413
+ * @param clientId - The ID of the client to delete
1414
+ * @returns A Promise resolving when the deletion is confirmed.
1415
+ */
1416
+ async deleteClient(clientId) {
1417
+ await this.env.OAUTH_KV.delete(`client:${clientId}`);
1418
+ }
1419
+ /**
1420
+ * Lists all authorization grants for a specific user with pagination support
1421
+ * Returns a summary of each grant without sensitive information
1422
+ * @param userId - The ID of the user whose grants to list
1423
+ * @param options - Optional pagination parameters (limit and cursor)
1424
+ * @returns A Promise resolving to the list result with grant summaries and optional cursor
1425
+ */
1426
+ async listUserGrants(userId, options) {
1427
+ const listOptions = {
1428
+ prefix: `grant:${userId}:`
1429
+ };
1430
+ if (options?.limit !== void 0) {
1431
+ listOptions.limit = options.limit;
1432
+ }
1433
+ if (options?.cursor !== void 0) {
1434
+ listOptions.cursor = options.cursor;
1435
+ }
1436
+ const response = await this.env.OAUTH_KV.list(listOptions);
1437
+ const grantSummaries = [];
1438
+ const promises = response.keys.map(async (key) => {
1439
+ const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
1440
+ if (grantData) {
1441
+ const summary = {
1442
+ id: grantData.id,
1443
+ clientId: grantData.clientId,
1444
+ userId: grantData.userId,
1445
+ scope: grantData.scope,
1446
+ metadata: grantData.metadata,
1447
+ createdAt: grantData.createdAt
1448
+ };
1449
+ grantSummaries.push(summary);
1450
+ }
1451
+ });
1452
+ await Promise.all(promises);
1453
+ return {
1454
+ items: grantSummaries,
1455
+ cursor: response.list_complete ? void 0 : response.cursor
1456
+ };
1457
+ }
1458
+ /**
1459
+ * Revokes an authorization grant and all its associated access tokens
1460
+ * @param grantId - The ID of the grant to revoke
1461
+ * @param userId - The ID of the user who owns the grant
1462
+ * @returns A Promise resolving when the revocation is confirmed.
1463
+ */
1464
+ async revokeGrant(grantId, userId) {
1465
+ const grantKey = `grant:${userId}:${grantId}`;
1466
+ const tokenPrefix = `token:${userId}:${grantId}:`;
1467
+ let cursor;
1468
+ let allTokensDeleted = false;
1469
+ while (!allTokensDeleted) {
1470
+ const listOptions = {
1471
+ prefix: tokenPrefix
1472
+ };
1473
+ if (cursor) {
1474
+ listOptions.cursor = cursor;
1475
+ }
1476
+ const result = await this.env.OAUTH_KV.list(listOptions);
1477
+ if (result.keys.length > 0) {
1478
+ await Promise.all(
1479
+ result.keys.map((key) => {
1480
+ return this.env.OAUTH_KV.delete(key.name);
1481
+ })
1482
+ );
1483
+ }
1484
+ if (result.list_complete) {
1485
+ allTokensDeleted = true;
1486
+ } else {
1487
+ cursor = result.cursor;
1488
+ }
1489
+ }
1490
+ await this.env.OAUTH_KV.delete(grantKey);
1491
+ }
1492
+ };
1493
+ var oauth_provider_default = OAuthProvider;
1494
+ export {
1495
+ OAuthProvider,
1496
+ oauth_provider_default as default
1497
+ };