@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.
- package/LICENSE.txt +21 -0
- package/README.md +330 -0
- package/dist/oauth-provider.d.ts +550 -0
- package/dist/oauth-provider.js +1497 -0
- package/package.json +45 -0
|
@@ -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
|
+
};
|