@cloudflare/workers-oauth-provider 0.0.0-9cd9ab4 → 0.0.0-9d4b595
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 +21 -0
- package/dist/oauth-provider.js +235 -93
- 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
|
|
@@ -116,6 +123,12 @@ interface OAuthProviderOptions {
|
|
|
116
123
|
* Defaults to 1 hour (3600 seconds) if not specified.
|
|
117
124
|
*/
|
|
118
125
|
accessTokenTTL?: number;
|
|
126
|
+
/**
|
|
127
|
+
* Time-to-live for refresh tokens in seconds.
|
|
128
|
+
* If not specified, refresh tokens do not expire.
|
|
129
|
+
* For example: 3600 = 1 hour, 2592000 = 30 days
|
|
130
|
+
*/
|
|
131
|
+
refreshTokenTTL?: number;
|
|
119
132
|
/**
|
|
120
133
|
* List of scopes supported by this OAuth provider.
|
|
121
134
|
* If not provided, the 'scopes_supported' field will be omitted from the OAuth metadata.
|
|
@@ -381,6 +394,10 @@ interface Grant {
|
|
|
381
394
|
* Unix timestamp when the grant was created
|
|
382
395
|
*/
|
|
383
396
|
createdAt: number;
|
|
397
|
+
/**
|
|
398
|
+
* Unix timestamp when the grant expires (if TTL is configured)
|
|
399
|
+
*/
|
|
400
|
+
expiresAt?: number;
|
|
384
401
|
/**
|
|
385
402
|
* The hash of the current refresh token associated with this grant
|
|
386
403
|
*/
|
|
@@ -523,6 +540,10 @@ interface GrantSummary {
|
|
|
523
540
|
* Unix timestamp when the grant was created
|
|
524
541
|
*/
|
|
525
542
|
createdAt: number;
|
|
543
|
+
/**
|
|
544
|
+
* Unix timestamp when the grant expires (if TTL is configured)
|
|
545
|
+
*/
|
|
546
|
+
expiresAt?: number;
|
|
526
547
|
}
|
|
527
548
|
/**
|
|
528
549
|
* OAuth 2.0 Provider implementation for Cloudflare Workers
|
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)
|
|
@@ -859,6 +988,17 @@ var OAuthProviderImpl = class {
|
|
|
859
988
|
createOAuthHelpers(env) {
|
|
860
989
|
return new OAuthHelpersImpl(env, this);
|
|
861
990
|
}
|
|
991
|
+
/**
|
|
992
|
+
* Saves a grant to KV with appropriate TTL based on expiration
|
|
993
|
+
* @param env - The environment bindings
|
|
994
|
+
* @param grantKey - The KV key for the grant
|
|
995
|
+
* @param grantData - The grant data to save
|
|
996
|
+
* @param now - Current timestamp in seconds
|
|
997
|
+
*/
|
|
998
|
+
async saveGrantWithTTL(env, grantKey, grantData, now) {
|
|
999
|
+
const kvOptions = grantData.expiresAt !== void 0 ? { expiration: grantData.expiresAt } : {};
|
|
1000
|
+
await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
|
|
1001
|
+
}
|
|
862
1002
|
/**
|
|
863
1003
|
* Fetches client information from KV storage
|
|
864
1004
|
* This method is not private because `OAuthHelpers` needs to call it. Note that since
|
|
@@ -1071,15 +1211,16 @@ var OAuthHelpersImpl = class {
|
|
|
1071
1211
|
const state = url.searchParams.get("state") || "";
|
|
1072
1212
|
const codeChallenge = url.searchParams.get("code_challenge") || void 0;
|
|
1073
1213
|
const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "plain";
|
|
1214
|
+
if (!redirectUri.startsWith("http://") && !redirectUri.startsWith("https://")) {
|
|
1215
|
+
throw new Error("Invalid redirect URI");
|
|
1216
|
+
}
|
|
1074
1217
|
if (responseType === "token" && !this.provider.options.allowImplicitFlow) {
|
|
1075
1218
|
throw new Error("The implicit grant flow is not enabled for this provider");
|
|
1076
1219
|
}
|
|
1077
1220
|
if (clientId) {
|
|
1078
1221
|
const clientInfo = await this.lookupClient(clientId);
|
|
1079
1222
|
if (!clientInfo) {
|
|
1080
|
-
throw new Error(
|
|
1081
|
-
`Invalid client. The clientId provided does not match to this client.`
|
|
1082
|
-
);
|
|
1223
|
+
throw new Error(`Invalid client. The clientId provided does not match to this client.`);
|
|
1083
1224
|
}
|
|
1084
1225
|
if (clientInfo && redirectUri) {
|
|
1085
1226
|
if (!clientInfo.redirectUris.includes(redirectUri)) {
|
|
@@ -1340,7 +1481,8 @@ var OAuthHelpersImpl = class {
|
|
|
1340
1481
|
userId: grantData.userId,
|
|
1341
1482
|
scope: grantData.scope,
|
|
1342
1483
|
metadata: grantData.metadata,
|
|
1343
|
-
createdAt: grantData.createdAt
|
|
1484
|
+
createdAt: grantData.createdAt,
|
|
1485
|
+
expiresAt: grantData.expiresAt
|
|
1344
1486
|
};
|
|
1345
1487
|
grantSummaries.push(summary);
|
|
1346
1488
|
}
|
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-9d4b595",
|
|
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
|
},
|