@cloudflare/workers-oauth-provider 0.0.0-4d02346 → 0.0.0-5a59d78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/dist/oauth-provider.d.ts +59 -1
- package/dist/oauth-provider.js +275 -113
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ export default new OAuthProvider({
|
|
|
38
38
|
// You can provide either an object with a fetch method (ExportedHandler)
|
|
39
39
|
// or a class extending WorkerEntrypoint.
|
|
40
40
|
apiHandler: ApiHandler, // Using a WorkerEntrypoint class
|
|
41
|
-
|
|
41
|
+
|
|
42
42
|
// For multi-handler setups, you can use apiHandlers instead of apiRoute+apiHandler.
|
|
43
43
|
// This allows you to use different handlers for different API routes.
|
|
44
44
|
// Note: You must use either apiRoute+apiHandler (single-handler) OR apiHandlers (multi-handler), not both.
|
|
@@ -87,7 +87,13 @@ export default new OAuthProvider({
|
|
|
87
87
|
// Note: Creating public clients via the OAuthHelpers.createClient() method
|
|
88
88
|
// is always allowed regardless of this setting.
|
|
89
89
|
// Defaults to false.
|
|
90
|
-
disallowPublicClientRegistration: false
|
|
90
|
+
disallowPublicClientRegistration: false,
|
|
91
|
+
|
|
92
|
+
// Optional: Time-to-live for refresh tokens in seconds.
|
|
93
|
+
// If not specified, refresh tokens do not expire.
|
|
94
|
+
// Set to 0 to disable refresh tokens (only access tokens will be issued).
|
|
95
|
+
// For example: 3600 = 1 hour, 86400 = 1 day, 2592000 = 30 days
|
|
96
|
+
refreshTokenTTL: 2592000 // 30 days
|
|
91
97
|
});
|
|
92
98
|
|
|
93
99
|
// The default handler object - the OAuthProvider will pass through HTTP requests to this object's fetch method
|
|
@@ -261,6 +267,7 @@ The callback can:
|
|
|
261
267
|
- Return only `accessTokenProps` to update just the current access token
|
|
262
268
|
- Return only `newProps` to update both the grant and access token (the access token inherits these props)
|
|
263
269
|
- Return `accessTokenTTL` to override the default TTL for this specific access token
|
|
270
|
+
- Return `refreshTokenTTL` to override the default TTL for this specific refresh token
|
|
264
271
|
- Return nothing to keep the original props unchanged
|
|
265
272
|
|
|
266
273
|
The `accessTokenTTL` override is particularly useful when the application is also an OAuth client to another service and wants to match its access token TTL to the upstream access token TTL. This helps prevent situations where the downstream token is still valid but the upstream token has expired.
|
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -33,6 +33,13 @@ interface TokenExchangeCallbackResult {
|
|
|
33
33
|
* Value should be in seconds.
|
|
34
34
|
*/
|
|
35
35
|
accessTokenTTL?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Override the default refresh token TTL (time-to-live) for this specific grant.
|
|
38
|
+
* Value should be in seconds.
|
|
39
|
+
* Note: This is only honored during authorization code exchange. If returned during
|
|
40
|
+
* refresh token exchange, it will be ignored.
|
|
41
|
+
*/
|
|
42
|
+
refreshTokenTTL?: number;
|
|
36
43
|
}
|
|
37
44
|
/**
|
|
38
45
|
* Options for token exchange callback functions
|
|
@@ -61,6 +68,33 @@ interface TokenExchangeCallbackOptions {
|
|
|
61
68
|
*/
|
|
62
69
|
props: any;
|
|
63
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Input parameters for the resolveExternalToken callback function
|
|
73
|
+
*/
|
|
74
|
+
interface ResolveExternalTokenInput {
|
|
75
|
+
/**
|
|
76
|
+
* The token string that was provided in the Authorization header
|
|
77
|
+
*/
|
|
78
|
+
token: string;
|
|
79
|
+
/**
|
|
80
|
+
* The original HTTP request
|
|
81
|
+
*/
|
|
82
|
+
request: Request;
|
|
83
|
+
/**
|
|
84
|
+
* Cloudflare Worker environment variables
|
|
85
|
+
*/
|
|
86
|
+
env: any;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Result returned from the resolveExternalToken callback function
|
|
90
|
+
*/
|
|
91
|
+
interface ResolveExternalTokenResult {
|
|
92
|
+
/**
|
|
93
|
+
* Application-specific properties that will be passed to the API handlers
|
|
94
|
+
* These properties are set in the execution context (ctx.props) when the external token is validated
|
|
95
|
+
*/
|
|
96
|
+
props: any;
|
|
97
|
+
}
|
|
64
98
|
interface OAuthProviderOptions {
|
|
65
99
|
/**
|
|
66
100
|
* URL(s) for API routes. Requests with URLs starting with any of these prefixes
|
|
@@ -116,6 +150,12 @@ interface OAuthProviderOptions {
|
|
|
116
150
|
* Defaults to 1 hour (3600 seconds) if not specified.
|
|
117
151
|
*/
|
|
118
152
|
accessTokenTTL?: number;
|
|
153
|
+
/**
|
|
154
|
+
* Time-to-live for refresh tokens in seconds.
|
|
155
|
+
* If not specified, refresh tokens do not expire.
|
|
156
|
+
* For example: 3600 = 1 hour, 2592000 = 30 days
|
|
157
|
+
*/
|
|
158
|
+
refreshTokenTTL?: number;
|
|
119
159
|
/**
|
|
120
160
|
* List of scopes supported by this OAuth provider.
|
|
121
161
|
* If not provided, the 'scopes_supported' field will be omitted from the OAuth metadata.
|
|
@@ -144,6 +184,16 @@ interface OAuthProviderOptions {
|
|
|
144
184
|
* If the callback returns nothing or undefined for a props field, the original props will be used.
|
|
145
185
|
*/
|
|
146
186
|
tokenExchangeCallback?: (options: TokenExchangeCallbackOptions) => Promise<TokenExchangeCallbackResult | void> | TokenExchangeCallbackResult | void;
|
|
187
|
+
/**
|
|
188
|
+
* Optional callback function that is called when a provided token was not found in the internal KV.
|
|
189
|
+
* This allows authentication through external OAuth servers.
|
|
190
|
+
* For example, if a request includes an authenticated token from a different OAuth authentication server,
|
|
191
|
+
* the callback can be used to authenticate it and set the context props through it.
|
|
192
|
+
*
|
|
193
|
+
* The callback can optionally return props values that will passed-through to the apiHandlers.
|
|
194
|
+
* The callback can return `null` to signal resolution failure.
|
|
195
|
+
*/
|
|
196
|
+
resolveExternalToken?: (input: ResolveExternalTokenInput) => Promise<ResolveExternalTokenResult | null>;
|
|
147
197
|
/**
|
|
148
198
|
* Optional callback function that is called whenever the OAuthProvider returns an error response
|
|
149
199
|
* This allows the client to emit notifications or perform other actions when an error occurs.
|
|
@@ -381,6 +431,10 @@ interface Grant {
|
|
|
381
431
|
* Unix timestamp when the grant was created
|
|
382
432
|
*/
|
|
383
433
|
createdAt: number;
|
|
434
|
+
/**
|
|
435
|
+
* Unix timestamp when the grant expires (if TTL is configured)
|
|
436
|
+
*/
|
|
437
|
+
expiresAt?: number;
|
|
384
438
|
/**
|
|
385
439
|
* The hash of the current refresh token associated with this grant
|
|
386
440
|
*/
|
|
@@ -523,6 +577,10 @@ interface GrantSummary {
|
|
|
523
577
|
* Unix timestamp when the grant was created
|
|
524
578
|
*/
|
|
525
579
|
createdAt: number;
|
|
580
|
+
/**
|
|
581
|
+
* Unix timestamp when the grant expires (if TTL is configured)
|
|
582
|
+
*/
|
|
583
|
+
expiresAt?: number;
|
|
526
584
|
}
|
|
527
585
|
/**
|
|
528
586
|
* OAuth 2.0 Provider implementation for Cloudflare Workers
|
|
@@ -547,4 +605,4 @@ declare class OAuthProvider {
|
|
|
547
605
|
fetch(request: Request, env: any, ctx: ExecutionContext): Promise<Response>;
|
|
548
606
|
}
|
|
549
607
|
|
|
550
|
-
export { type AuthRequest, type ClientInfo, type CompleteAuthorizationOptions, type Grant, type GrantSummary, type ListOptions, type ListResult, type OAuthHelpers, OAuthProvider, type OAuthProviderOptions, type Token, type TokenExchangeCallbackOptions, type TokenExchangeCallbackResult, OAuthProvider as default };
|
|
608
|
+
export { type AuthRequest, type ClientInfo, type CompleteAuthorizationOptions, type Grant, type GrantSummary, type ListOptions, type ListResult, type OAuthHelpers, OAuthProvider, type OAuthProviderOptions, type ResolveExternalTokenInput, type ResolveExternalTokenResult, type Token, type TokenExchangeCallbackOptions, type TokenExchangeCallbackResult, OAuthProvider as default };
|
package/dist/oauth-provider.js
CHANGED
|
@@ -142,7 +142,16 @@ var OAuthProviderImpl = class {
|
|
|
142
142
|
return this.addCorsHeaders(response, request);
|
|
143
143
|
}
|
|
144
144
|
if (this.isTokenEndpoint(url)) {
|
|
145
|
-
const
|
|
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
|
+
}
|
|
146
155
|
return this.addCorsHeaders(response, request);
|
|
147
156
|
}
|
|
148
157
|
if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) {
|
|
@@ -206,6 +215,67 @@ var OAuthProviderImpl = class {
|
|
|
206
215
|
if (!this.options.clientRegistrationEndpoint) return false;
|
|
207
216
|
return this.matchEndpoint(url, this.options.clientRegistrationEndpoint);
|
|
208
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
|
+
}
|
|
209
279
|
/**
|
|
210
280
|
* Checks if a URL matches a specific API route
|
|
211
281
|
* @param url - The URL to check
|
|
@@ -329,59 +399,12 @@ var OAuthProviderImpl = class {
|
|
|
329
399
|
/**
|
|
330
400
|
* Handles client authentication and token issuance via the token endpoint
|
|
331
401
|
* Supports authorization_code and refresh_token grant types
|
|
332
|
-
* @param
|
|
402
|
+
* @param body - The parsed request body
|
|
403
|
+
* @param clientInfo - The authenticated client information
|
|
333
404
|
* @param env - Cloudflare Worker environment variables
|
|
334
405
|
* @returns Response with token data or error
|
|
335
406
|
*/
|
|
336
|
-
async handleTokenRequest(
|
|
337
|
-
if (request.method !== "POST") {
|
|
338
|
-
return this.createErrorResponse("invalid_request", "Method not allowed", 405);
|
|
339
|
-
}
|
|
340
|
-
let contentType = request.headers.get("Content-Type") || "";
|
|
341
|
-
let body = {};
|
|
342
|
-
if (!contentType.includes("application/x-www-form-urlencoded")) {
|
|
343
|
-
return this.createErrorResponse("invalid_request", "Content-Type must be application/x-www-form-urlencoded", 400);
|
|
344
|
-
}
|
|
345
|
-
const formData = await request.formData();
|
|
346
|
-
for (const [key, value] of formData.entries()) {
|
|
347
|
-
body[key] = value;
|
|
348
|
-
}
|
|
349
|
-
const authHeader = request.headers.get("Authorization");
|
|
350
|
-
let clientId = "";
|
|
351
|
-
let clientSecret = "";
|
|
352
|
-
if (authHeader && authHeader.startsWith("Basic ")) {
|
|
353
|
-
const credentials = atob(authHeader.substring(6));
|
|
354
|
-
const [id, secret] = credentials.split(":", 2);
|
|
355
|
-
clientId = decodeURIComponent(id);
|
|
356
|
-
clientSecret = decodeURIComponent(secret || "");
|
|
357
|
-
} else {
|
|
358
|
-
clientId = body.client_id;
|
|
359
|
-
clientSecret = body.client_secret || "";
|
|
360
|
-
}
|
|
361
|
-
if (!clientId) {
|
|
362
|
-
return this.createErrorResponse("invalid_client", "Client ID is required", 401);
|
|
363
|
-
}
|
|
364
|
-
const clientInfo = await this.getClient(env, clientId);
|
|
365
|
-
if (!clientInfo) {
|
|
366
|
-
return this.createErrorResponse("invalid_client", "Client not found", 401);
|
|
367
|
-
}
|
|
368
|
-
const isPublicClient = clientInfo.tokenEndpointAuthMethod === "none";
|
|
369
|
-
if (!isPublicClient) {
|
|
370
|
-
if (!clientSecret) {
|
|
371
|
-
return this.createErrorResponse("invalid_client", "Client authentication failed: missing client_secret", 401);
|
|
372
|
-
}
|
|
373
|
-
if (!clientInfo.clientSecret) {
|
|
374
|
-
return this.createErrorResponse(
|
|
375
|
-
"invalid_client",
|
|
376
|
-
"Client authentication failed: client has no registered secret",
|
|
377
|
-
401
|
|
378
|
-
);
|
|
379
|
-
}
|
|
380
|
-
const providedSecretHash = await hashSecret(clientSecret);
|
|
381
|
-
if (providedSecretHash !== clientInfo.clientSecret) {
|
|
382
|
-
return this.createErrorResponse("invalid_client", "Client authentication failed: invalid client_secret", 401);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
407
|
+
async handleTokenRequest(body, clientInfo, env) {
|
|
385
408
|
const grantType = body.grant_type;
|
|
386
409
|
if (grantType === "authorization_code") {
|
|
387
410
|
return this.handleAuthorizationCodeGrant(body, clientInfo, env);
|
|
@@ -455,12 +478,10 @@ var OAuthProviderImpl = class {
|
|
|
455
478
|
}
|
|
456
479
|
}
|
|
457
480
|
const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
|
|
458
|
-
const refreshTokenSecret = generateRandomString(TOKEN_LENGTH);
|
|
459
481
|
const accessToken = `${userId}:${grantId}:${accessTokenSecret}`;
|
|
460
|
-
const refreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
|
|
461
482
|
const accessTokenId = await generateTokenId(accessToken);
|
|
462
|
-
const refreshTokenId = await generateTokenId(refreshToken);
|
|
463
483
|
let accessTokenTTL = this.options.accessTokenTTL;
|
|
484
|
+
let refreshTokenTTL = this.options.refreshTokenTTL;
|
|
464
485
|
const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
|
|
465
486
|
let grantEncryptionKey = encryptionKey;
|
|
466
487
|
let accessTokenEncryptionKey = encryptionKey;
|
|
@@ -490,6 +511,9 @@ var OAuthProviderImpl = class {
|
|
|
490
511
|
if (callbackResult.accessTokenTTL !== void 0) {
|
|
491
512
|
accessTokenTTL = callbackResult.accessTokenTTL;
|
|
492
513
|
}
|
|
514
|
+
if ("refreshTokenTTL" in callbackResult) {
|
|
515
|
+
refreshTokenTTL = callbackResult.refreshTokenTTL;
|
|
516
|
+
}
|
|
493
517
|
}
|
|
494
518
|
const grantResult = await encryptProps(grantProps);
|
|
495
519
|
grantData.encryptedProps = grantResult.encryptedData;
|
|
@@ -505,17 +529,26 @@ var OAuthProviderImpl = class {
|
|
|
505
529
|
}
|
|
506
530
|
const now = Math.floor(Date.now() / 1e3);
|
|
507
531
|
const accessTokenExpiresAt = now + accessTokenTTL;
|
|
532
|
+
const useRefreshToken = refreshTokenTTL !== 0;
|
|
508
533
|
const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, accessTokenEncryptionKey);
|
|
509
|
-
const refreshTokenWrappedKey = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
|
|
510
534
|
delete grantData.authCodeId;
|
|
511
535
|
delete grantData.codeChallenge;
|
|
512
536
|
delete grantData.codeChallengeMethod;
|
|
513
537
|
delete grantData.authCodeWrappedKey;
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
538
|
+
let refreshToken;
|
|
539
|
+
if (useRefreshToken) {
|
|
540
|
+
const refreshTokenSecret = generateRandomString(TOKEN_LENGTH);
|
|
541
|
+
refreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
|
|
542
|
+
const refreshTokenId = await generateTokenId(refreshToken);
|
|
543
|
+
const refreshTokenWrappedKey = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
|
|
544
|
+
const expiresAt = refreshTokenTTL !== void 0 ? now + refreshTokenTTL : void 0;
|
|
545
|
+
grantData.refreshTokenId = refreshTokenId;
|
|
546
|
+
grantData.refreshTokenWrappedKey = refreshTokenWrappedKey;
|
|
547
|
+
grantData.previousRefreshTokenId = void 0;
|
|
548
|
+
grantData.previousRefreshTokenWrappedKey = void 0;
|
|
549
|
+
grantData.expiresAt = expiresAt;
|
|
550
|
+
}
|
|
551
|
+
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
519
552
|
const accessTokenData = {
|
|
520
553
|
id: accessTokenId,
|
|
521
554
|
grantId,
|
|
@@ -532,18 +565,18 @@ var OAuthProviderImpl = class {
|
|
|
532
565
|
await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), {
|
|
533
566
|
expirationTtl: accessTokenTTL
|
|
534
567
|
});
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
}
|
|
546
|
-
);
|
|
568
|
+
const tokenResponse = {
|
|
569
|
+
access_token: accessToken,
|
|
570
|
+
token_type: "bearer",
|
|
571
|
+
expires_in: accessTokenTTL,
|
|
572
|
+
scope: grantData.scope.join(" ")
|
|
573
|
+
};
|
|
574
|
+
if (refreshToken) {
|
|
575
|
+
tokenResponse.refresh_token = refreshToken;
|
|
576
|
+
}
|
|
577
|
+
return new Response(JSON.stringify(tokenResponse), {
|
|
578
|
+
headers: { "Content-Type": "application/json" }
|
|
579
|
+
});
|
|
547
580
|
}
|
|
548
581
|
/**
|
|
549
582
|
* Handles the refresh token grant type
|
|
@@ -577,12 +610,15 @@ var OAuthProviderImpl = class {
|
|
|
577
610
|
if (grantData.clientId !== clientInfo.clientId) {
|
|
578
611
|
return this.createErrorResponse("invalid_grant", "Client ID mismatch");
|
|
579
612
|
}
|
|
613
|
+
if (grantData.expiresAt !== void 0) {
|
|
614
|
+
const now2 = Math.floor(Date.now() / 1e3);
|
|
615
|
+
if (now2 >= grantData.expiresAt) {
|
|
616
|
+
return this.createErrorResponse("invalid_grant", "Refresh token has expired");
|
|
617
|
+
}
|
|
618
|
+
}
|
|
580
619
|
const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
|
|
581
620
|
const newAccessToken = `${userId}:${grantId}:${accessTokenSecret}`;
|
|
582
621
|
const accessTokenId = await generateTokenId(newAccessToken);
|
|
583
|
-
const refreshTokenSecret = generateRandomString(TOKEN_LENGTH);
|
|
584
|
-
const newRefreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
|
|
585
|
-
const newRefreshTokenId = await generateTokenId(newRefreshToken);
|
|
586
622
|
let accessTokenTTL = this.options.accessTokenTTL;
|
|
587
623
|
let wrappedKeyToUse;
|
|
588
624
|
if (isCurrentToken) {
|
|
@@ -594,6 +630,7 @@ var OAuthProviderImpl = class {
|
|
|
594
630
|
let grantEncryptionKey = encryptionKey;
|
|
595
631
|
let accessTokenEncryptionKey = encryptionKey;
|
|
596
632
|
let encryptedAccessTokenProps = grantData.encryptedProps;
|
|
633
|
+
let grantPropsChanged = false;
|
|
597
634
|
if (this.options.tokenExchangeCallback) {
|
|
598
635
|
const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
|
|
599
636
|
let grantProps = decryptedProps;
|
|
@@ -606,7 +643,6 @@ var OAuthProviderImpl = class {
|
|
|
606
643
|
props: decryptedProps
|
|
607
644
|
};
|
|
608
645
|
const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
|
|
609
|
-
let grantPropsChanged = false;
|
|
610
646
|
if (callbackResult) {
|
|
611
647
|
if (callbackResult.newProps) {
|
|
612
648
|
grantProps = callbackResult.newProps;
|
|
@@ -621,6 +657,12 @@ var OAuthProviderImpl = class {
|
|
|
621
657
|
if (callbackResult.accessTokenTTL !== void 0) {
|
|
622
658
|
accessTokenTTL = callbackResult.accessTokenTTL;
|
|
623
659
|
}
|
|
660
|
+
if ("refreshTokenTTL" in callbackResult) {
|
|
661
|
+
return this.createErrorResponse(
|
|
662
|
+
"invalid_request",
|
|
663
|
+
"refreshTokenTTL cannot be changed during refresh token exchange"
|
|
664
|
+
);
|
|
665
|
+
}
|
|
624
666
|
}
|
|
625
667
|
if (grantPropsChanged) {
|
|
626
668
|
const grantResult = await encryptProps(grantProps);
|
|
@@ -642,14 +684,23 @@ var OAuthProviderImpl = class {
|
|
|
642
684
|
}
|
|
643
685
|
}
|
|
644
686
|
const now = Math.floor(Date.now() / 1e3);
|
|
687
|
+
if (grantData.expiresAt !== void 0) {
|
|
688
|
+
const remainingRefreshTokenLifetime = grantData.expiresAt - now;
|
|
689
|
+
if (remainingRefreshTokenLifetime > 0) {
|
|
690
|
+
accessTokenTTL = Math.min(accessTokenTTL, remainingRefreshTokenLifetime);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
645
693
|
const accessTokenExpiresAt = now + accessTokenTTL;
|
|
646
694
|
const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
|
|
695
|
+
const refreshTokenSecret = generateRandomString(TOKEN_LENGTH);
|
|
696
|
+
const newRefreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
|
|
697
|
+
const newRefreshTokenId = await generateTokenId(newRefreshToken);
|
|
647
698
|
const newRefreshTokenWrappedKey = await wrapKeyWithToken(newRefreshToken, grantEncryptionKey);
|
|
648
699
|
grantData.previousRefreshTokenId = providedTokenHash;
|
|
649
700
|
grantData.previousRefreshTokenWrappedKey = wrappedKeyToUse;
|
|
650
701
|
grantData.refreshTokenId = newRefreshTokenId;
|
|
651
702
|
grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
|
|
652
|
-
await
|
|
703
|
+
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
653
704
|
const accessTokenData = {
|
|
654
705
|
id: accessTokenId,
|
|
655
706
|
grantId,
|
|
@@ -666,18 +717,96 @@ var OAuthProviderImpl = class {
|
|
|
666
717
|
await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), {
|
|
667
718
|
expirationTtl: accessTokenTTL
|
|
668
719
|
});
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
{
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
720
|
+
const tokenResponse = {
|
|
721
|
+
access_token: newAccessToken,
|
|
722
|
+
token_type: "bearer",
|
|
723
|
+
expires_in: accessTokenTTL,
|
|
724
|
+
refresh_token: newRefreshToken,
|
|
725
|
+
scope: grantData.scope.join(" ")
|
|
726
|
+
};
|
|
727
|
+
return new Response(JSON.stringify(tokenResponse), {
|
|
728
|
+
headers: { "Content-Type": "application/json" }
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Handles OAuth 2.0 token revocation requests (RFC 7009)
|
|
733
|
+
* @param body - The parsed request body containing revocation parameters
|
|
734
|
+
* @param env - Cloudflare Worker environment variables
|
|
735
|
+
* @returns Response confirming revocation or error
|
|
736
|
+
*/
|
|
737
|
+
async handleRevocationRequest(body, env) {
|
|
738
|
+
return this.revokeToken(body, env);
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* - Access tokens: Revokes only the specific token
|
|
742
|
+
* - Refresh tokens: Revokes the entire grant (access + refresh tokens)
|
|
743
|
+
* @param body - The parsed request body containing token parameter
|
|
744
|
+
* @param env - Cloudflare Worker environment variables
|
|
745
|
+
* @returns Response confirming revocation or error
|
|
746
|
+
*/
|
|
747
|
+
async revokeToken(body, env) {
|
|
748
|
+
const token = body.token;
|
|
749
|
+
if (!token) {
|
|
750
|
+
return this.createErrorResponse("invalid_request", "Token parameter is required");
|
|
751
|
+
}
|
|
752
|
+
const tokenParts = token.split(":");
|
|
753
|
+
if (tokenParts.length !== 3) {
|
|
754
|
+
return new Response("", { status: 200 });
|
|
755
|
+
}
|
|
756
|
+
const [userId, grantId, _] = tokenParts;
|
|
757
|
+
const tokenId = await generateTokenId(token);
|
|
758
|
+
const isAccessToken = await this.validateAccessToken(tokenId, userId, grantId, env);
|
|
759
|
+
const isRefreshToken = await this.validateRefreshToken(tokenId, userId, grantId, env);
|
|
760
|
+
if (isAccessToken) {
|
|
761
|
+
await this.revokeSpecificAccessToken(tokenId, userId, grantId, env);
|
|
762
|
+
} else if (isRefreshToken) {
|
|
763
|
+
await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
|
|
764
|
+
}
|
|
765
|
+
return new Response("", { status: 200 });
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Revokes a specific access token without affecting the refresh token
|
|
769
|
+
* @param tokenId - The hashed token ID
|
|
770
|
+
* @param userId - The user ID extracted from the token
|
|
771
|
+
* @param grantId - The grant ID extracted from the token
|
|
772
|
+
* @param env - Cloudflare Worker environment variables
|
|
773
|
+
*/
|
|
774
|
+
async revokeSpecificAccessToken(tokenId, userId, grantId, env) {
|
|
775
|
+
const tokenKey = `token:${userId}:${grantId}:${tokenId}`;
|
|
776
|
+
await env.OAUTH_KV.delete(tokenKey);
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Validates if a token is a valid access token
|
|
780
|
+
* @param tokenId - The hashed token ID
|
|
781
|
+
* @param userId - The user ID extracted from the token
|
|
782
|
+
* @param grantId - The grant ID extracted from the token
|
|
783
|
+
* @param env - Cloudflare Worker environment variables
|
|
784
|
+
* @returns Promise<boolean> indicating if the token is valid
|
|
785
|
+
*/
|
|
786
|
+
async validateAccessToken(tokenId, userId, grantId, env) {
|
|
787
|
+
const tokenKey = `token:${userId}:${grantId}:${tokenId}`;
|
|
788
|
+
const tokenData = await env.OAUTH_KV.get(tokenKey, { type: "json" });
|
|
789
|
+
if (!tokenData) {
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
793
|
+
return tokenData.expiresAt >= now;
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Validates if a token is a valid refresh token
|
|
797
|
+
* @param tokenId - The hashed token ID
|
|
798
|
+
* @param userId - The user ID extracted from the token
|
|
799
|
+
* @param grantId - The grant ID extracted from the token
|
|
800
|
+
* @param env - Cloudflare Worker environment variables
|
|
801
|
+
* @returns Promise<boolean> indicating if the token is valid
|
|
802
|
+
*/
|
|
803
|
+
async validateRefreshToken(tokenId, userId, grantId, env) {
|
|
804
|
+
const grantKey = `grant:${userId}:${grantId}`;
|
|
805
|
+
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
806
|
+
if (!grantData) {
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
return grantData.refreshTokenId === tokenId || grantData.previousRefreshTokenId === tokenId;
|
|
681
810
|
}
|
|
682
811
|
/**
|
|
683
812
|
* Handles the dynamic client registration endpoint (RFC 7591)
|
|
@@ -811,30 +940,40 @@ var OAuthProviderImpl = class {
|
|
|
811
940
|
});
|
|
812
941
|
}
|
|
813
942
|
const accessToken = authHeader.substring(7);
|
|
814
|
-
const
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
if (!tokenData) {
|
|
943
|
+
const parts = accessToken.split(":");
|
|
944
|
+
const isPossiblyInternalFormat = parts.length === 3;
|
|
945
|
+
let tokenData = null;
|
|
946
|
+
let userId = "";
|
|
947
|
+
let grantId = "";
|
|
948
|
+
if (isPossiblyInternalFormat) {
|
|
949
|
+
[userId, grantId] = parts;
|
|
950
|
+
const id = await generateTokenId(accessToken);
|
|
951
|
+
tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
|
|
952
|
+
}
|
|
953
|
+
if (!tokenData && !this.options.resolveExternalToken) {
|
|
825
954
|
return this.createErrorResponse("invalid_token", "Invalid access token", 401, {
|
|
826
955
|
"WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
|
|
827
956
|
});
|
|
828
957
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
"
|
|
833
|
-
|
|
958
|
+
if (tokenData) {
|
|
959
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
960
|
+
if (tokenData.expiresAt < now) {
|
|
961
|
+
return this.createErrorResponse("invalid_token", "Access token expired", 401, {
|
|
962
|
+
"WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
const encryptionKey = await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey);
|
|
966
|
+
const decryptedProps = await decryptProps(encryptionKey, tokenData.grant.encryptedProps);
|
|
967
|
+
ctx.props = decryptedProps;
|
|
968
|
+
} else if (this.options.resolveExternalToken) {
|
|
969
|
+
const ext = await this.options.resolveExternalToken({ token: accessToken, request, env });
|
|
970
|
+
if (!ext) {
|
|
971
|
+
return this.createErrorResponse("invalid_token", "Invalid access token", 401, {
|
|
972
|
+
"WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
ctx.props = ext.props;
|
|
834
976
|
}
|
|
835
|
-
const encryptionKey = await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey);
|
|
836
|
-
const decryptedProps = await decryptProps(encryptionKey, tokenData.grant.encryptedProps);
|
|
837
|
-
ctx.props = decryptedProps;
|
|
838
977
|
if (!env.OAUTH_PROVIDER) {
|
|
839
978
|
env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
|
|
840
979
|
}
|
|
@@ -859,6 +998,17 @@ var OAuthProviderImpl = class {
|
|
|
859
998
|
createOAuthHelpers(env) {
|
|
860
999
|
return new OAuthHelpersImpl(env, this);
|
|
861
1000
|
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Saves a grant to KV with appropriate TTL based on expiration
|
|
1003
|
+
* @param env - The environment bindings
|
|
1004
|
+
* @param grantKey - The KV key for the grant
|
|
1005
|
+
* @param grantData - The grant data to save
|
|
1006
|
+
* @param now - Current timestamp in seconds
|
|
1007
|
+
*/
|
|
1008
|
+
async saveGrantWithTTL(env, grantKey, grantData, now) {
|
|
1009
|
+
const kvOptions = grantData.expiresAt !== void 0 ? { expiration: grantData.expiresAt } : {};
|
|
1010
|
+
await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
|
|
1011
|
+
}
|
|
862
1012
|
/**
|
|
863
1013
|
* Fetches client information from KV storage
|
|
864
1014
|
* This method is not private because `OAuthHelpers` needs to call it. Note that since
|
|
@@ -902,7 +1052,7 @@ async function hashSecret(secret) {
|
|
|
902
1052
|
return generateTokenId(secret);
|
|
903
1053
|
}
|
|
904
1054
|
function generateRandomString(length) {
|
|
905
|
-
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
1055
|
+
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
906
1056
|
let result = "";
|
|
907
1057
|
const values = new Uint8Array(length);
|
|
908
1058
|
crypto.getRandomValues(values);
|
|
@@ -1071,15 +1221,16 @@ var OAuthHelpersImpl = class {
|
|
|
1071
1221
|
const state = url.searchParams.get("state") || "";
|
|
1072
1222
|
const codeChallenge = url.searchParams.get("code_challenge") || void 0;
|
|
1073
1223
|
const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "plain";
|
|
1224
|
+
if (redirectUri.startsWith("javascript:") || redirectUri.startsWith("data:") || redirectUri.startsWith("vbscript:") || redirectUri.startsWith("file:") || redirectUri.startsWith("mailto:") || redirectUri.startsWith("blob:")) {
|
|
1225
|
+
throw new Error("Invalid redirect URI");
|
|
1226
|
+
}
|
|
1074
1227
|
if (responseType === "token" && !this.provider.options.allowImplicitFlow) {
|
|
1075
1228
|
throw new Error("The implicit grant flow is not enabled for this provider");
|
|
1076
1229
|
}
|
|
1077
1230
|
if (clientId) {
|
|
1078
1231
|
const clientInfo = await this.lookupClient(clientId);
|
|
1079
1232
|
if (!clientInfo) {
|
|
1080
|
-
throw new Error(
|
|
1081
|
-
`Invalid client. The clientId provided does not match to this client.`
|
|
1082
|
-
);
|
|
1233
|
+
throw new Error(`Invalid client. The clientId provided does not match to this client.`);
|
|
1083
1234
|
}
|
|
1084
1235
|
if (clientInfo && redirectUri) {
|
|
1085
1236
|
if (!clientInfo.redirectUris.includes(redirectUri)) {
|
|
@@ -1115,6 +1266,16 @@ var OAuthHelpersImpl = class {
|
|
|
1115
1266
|
* @returns A Promise resolving to an object containing the redirect URL
|
|
1116
1267
|
*/
|
|
1117
1268
|
async completeAuthorization(options) {
|
|
1269
|
+
const { clientId, redirectUri } = options.request;
|
|
1270
|
+
if (!clientId || !redirectUri) {
|
|
1271
|
+
throw new Error("Client ID and Redirect URI are required in the authorization request.");
|
|
1272
|
+
}
|
|
1273
|
+
const clientInfo = await this.lookupClient(clientId);
|
|
1274
|
+
if (!clientInfo || !clientInfo.redirectUris.includes(redirectUri)) {
|
|
1275
|
+
throw new Error(
|
|
1276
|
+
"Invalid redirect URI. The redirect URI provided does not match any registered URI for this client."
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1118
1279
|
const grantId = generateRandomString(16);
|
|
1119
1280
|
const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
|
|
1120
1281
|
const now = Math.floor(Date.now() / 1e3);
|
|
@@ -1340,7 +1501,8 @@ var OAuthHelpersImpl = class {
|
|
|
1340
1501
|
userId: grantData.userId,
|
|
1341
1502
|
scope: grantData.scope,
|
|
1342
1503
|
metadata: grantData.metadata,
|
|
1343
|
-
createdAt: grantData.createdAt
|
|
1504
|
+
createdAt: grantData.createdAt,
|
|
1505
|
+
expiresAt: grantData.expiresAt
|
|
1344
1506
|
};
|
|
1345
1507
|
grantSummaries.push(summary);
|
|
1346
1508
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudflare/workers-oauth-provider",
|
|
3
|
-
"version": "0.0.0-
|
|
3
|
+
"version": "0.0.0-5a59d78",
|
|
4
4
|
"description": "OAuth provider for Cloudflare Workers",
|
|
5
5
|
"main": "dist/oauth-provider.js",
|
|
6
6
|
"types": "dist/oauth-provider.d.ts",
|
|
@@ -26,11 +26,12 @@
|
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@changesets/changelog-github": "^0.5.1",
|
|
29
|
-
"@changesets/cli": "^2.29.
|
|
29
|
+
"@changesets/cli": "^2.29.7",
|
|
30
30
|
"@cloudflare/workers-types": "^4.20250807.0",
|
|
31
|
+
"pkg-pr-new": "^0.0.59",
|
|
31
32
|
"prettier": "^3.6.2",
|
|
32
33
|
"tsup": "^8.5.0",
|
|
33
|
-
"tsx": "^4.20.
|
|
34
|
+
"tsx": "^4.20.5",
|
|
34
35
|
"typescript": "^5.9.2",
|
|
35
36
|
"vitest": "^3.2.4"
|
|
36
37
|
},
|