@cloudflare/workers-oauth-provider 0.0.0-aa007fc → 0.0.0-be89144

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.
@@ -68,6 +68,39 @@ interface TokenExchangeCallbackOptions {
68
68
  */
69
69
  props: any;
70
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
+ /**
98
+ * Audience claim from the external token (RFC 7519 Section 4.1.3)
99
+ * If provided, will be validated against the resource server identity
100
+ *
101
+ */
102
+ audience?: string | string[];
103
+ }
71
104
  interface OAuthProviderOptions {
72
105
  /**
73
106
  * URL(s) for API routes. Requests with URLs starting with any of these prefixes
@@ -157,6 +190,16 @@ interface OAuthProviderOptions {
157
190
  * If the callback returns nothing or undefined for a props field, the original props will be used.
158
191
  */
159
192
  tokenExchangeCallback?: (options: TokenExchangeCallbackOptions) => Promise<TokenExchangeCallbackResult | void> | TokenExchangeCallbackResult | void;
193
+ /**
194
+ * Optional callback function that is called when a provided token was not found in the internal KV.
195
+ * This allows authentication through external OAuth servers.
196
+ * For example, if a request includes an authenticated token from a different OAuth authentication server,
197
+ * the callback can be used to authenticate it and set the context props through it.
198
+ *
199
+ * The callback can optionally return props values that will passed-through to the apiHandlers.
200
+ * The callback can return `null` to signal resolution failure.
201
+ */
202
+ resolveExternalToken?: (input: ResolveExternalTokenInput) => Promise<ResolveExternalTokenResult | null>;
160
203
  /**
161
204
  * Optional callback function that is called whenever the OAuthProvider returns an error response
162
205
  * This allows the client to emit notifications or perform other actions when an error occurs.
@@ -267,6 +310,10 @@ interface AuthRequest {
267
310
  * PKCE code challenge method (plain or S256)
268
311
  */
269
312
  codeChallengeMethod?: string;
313
+ /**
314
+ * Resource parameter indicating target resource(s) (RFC 8707)
315
+ */
316
+ resource?: string | string[];
270
317
  }
271
318
  /**
272
319
  * OAuth client registration information
@@ -435,6 +482,11 @@ interface Grant {
435
482
  * Only present during the authorization code exchange process
436
483
  */
437
484
  codeChallengeMethod?: string;
485
+ /**
486
+ * Resource parameter from authorization request (RFC 8707 Section 2.1)
487
+ * Indicates the protected resource(s) for which access is requested
488
+ */
489
+ resource?: string | string[];
438
490
  }
439
491
  /**
440
492
  * Token record stored in KV
@@ -463,6 +515,11 @@ interface Token {
463
515
  * Unix timestamp when the token expires
464
516
  */
465
517
  expiresAt: number;
518
+ /**
519
+ * Intended audience for this token (RFC 7519 Section 4.1.3)
520
+ * Can be a single string or array of strings
521
+ */
522
+ audience?: string | string[];
466
523
  /**
467
524
  * The encryption key for props, wrapped with this token
468
525
  */
@@ -568,4 +625,4 @@ declare class OAuthProvider {
568
625
  fetch(request: Request, env: any, ctx: ExecutionContext): Promise<Response>;
569
626
  }
570
627
 
571
- 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 };
628
+ 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 };
@@ -231,7 +231,8 @@ var OAuthProviderImpl = class {
231
231
  }
232
232
  const formData = await request.formData();
233
233
  for (const [key, value] of formData.entries()) {
234
- body[key] = value;
234
+ const allValues = formData.getAll(key);
235
+ body[key] = allValues.length > 1 ? allValues : value;
235
236
  }
236
237
  const authHeader = request.headers.get("Authorization");
237
238
  let clientId = "";
@@ -549,12 +550,32 @@ var OAuthProviderImpl = class {
549
550
  grantData.expiresAt = expiresAt;
550
551
  }
551
552
  await this.saveGrantWithTTL(env, grantKey, grantData, now);
553
+ if (body.resource && grantData.resource) {
554
+ const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
555
+ const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
556
+ for (const requested of requestedResources) {
557
+ if (!grantedResources.includes(requested)) {
558
+ return this.createErrorResponse(
559
+ "invalid_target",
560
+ "Requested resource was not included in the authorization request"
561
+ );
562
+ }
563
+ }
564
+ }
565
+ const audience = parseResourceParameter(body.resource || grantData.resource);
566
+ if ((body.resource || grantData.resource) && !audience) {
567
+ return this.createErrorResponse(
568
+ "invalid_target",
569
+ "The resource parameter must be a valid absolute URI without a fragment"
570
+ );
571
+ }
552
572
  const accessTokenData = {
553
573
  id: accessTokenId,
554
574
  grantId,
555
575
  userId,
556
576
  createdAt: now,
557
577
  expiresAt: accessTokenExpiresAt,
578
+ audience,
558
579
  wrappedEncryptionKey: accessTokenWrappedKey,
559
580
  grant: {
560
581
  clientId: grantData.clientId,
@@ -574,6 +595,9 @@ var OAuthProviderImpl = class {
574
595
  if (refreshToken) {
575
596
  tokenResponse.refresh_token = refreshToken;
576
597
  }
598
+ if (audience) {
599
+ tokenResponse.resource = audience;
600
+ }
577
601
  return new Response(JSON.stringify(tokenResponse), {
578
602
  headers: { "Content-Type": "application/json" }
579
603
  });
@@ -701,12 +725,32 @@ var OAuthProviderImpl = class {
701
725
  grantData.refreshTokenId = newRefreshTokenId;
702
726
  grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
703
727
  await this.saveGrantWithTTL(env, grantKey, grantData, now);
728
+ if (body.resource && grantData.resource) {
729
+ const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
730
+ const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
731
+ for (const requested of requestedResources) {
732
+ if (!grantedResources.includes(requested)) {
733
+ return this.createErrorResponse(
734
+ "invalid_target",
735
+ "Requested resource was not included in the authorization request"
736
+ );
737
+ }
738
+ }
739
+ }
740
+ const audience = parseResourceParameter(body.resource || grantData.resource);
741
+ if ((body.resource || grantData.resource) && !audience) {
742
+ return this.createErrorResponse(
743
+ "invalid_target",
744
+ "The resource parameter must be a valid absolute URI without a fragment"
745
+ );
746
+ }
704
747
  const accessTokenData = {
705
748
  id: accessTokenId,
706
749
  grantId,
707
750
  userId,
708
751
  createdAt: now,
709
752
  expiresAt: accessTokenExpiresAt,
753
+ audience,
710
754
  wrappedEncryptionKey: accessTokenWrappedKey,
711
755
  grant: {
712
756
  clientId: grantData.clientId,
@@ -724,6 +768,9 @@ var OAuthProviderImpl = class {
724
768
  refresh_token: newRefreshToken,
725
769
  scope: grantData.scope.join(" ")
726
770
  };
771
+ if (audience) {
772
+ tokenResponse.resource = audience;
773
+ }
727
774
  return new Response(JSON.stringify(tokenResponse), {
728
775
  headers: { "Content-Type": "application/json" }
729
776
  });
@@ -876,6 +923,9 @@ var OAuthProviderImpl = class {
876
923
  if (!redirectUris || redirectUris.length === 0) {
877
924
  throw new Error("At least one redirect URI is required");
878
925
  }
926
+ for (const uri of redirectUris) {
927
+ validateRedirectUriScheme(uri);
928
+ }
879
929
  clientInfo = {
880
930
  clientId,
881
931
  redirectUris,
@@ -940,30 +990,62 @@ var OAuthProviderImpl = class {
940
990
  });
941
991
  }
942
992
  const accessToken = authHeader.substring(7);
943
- const tokenParts = accessToken.split(":");
944
- if (tokenParts.length !== 3) {
945
- return this.createErrorResponse("invalid_token", "Invalid token format", 401, {
946
- "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
947
- });
948
- }
949
- const [userId, grantId, _] = tokenParts;
950
- const accessTokenId = await generateTokenId(accessToken);
951
- const tokenKey = `token:${userId}:${grantId}:${accessTokenId}`;
952
- const tokenData = await env.OAUTH_KV.get(tokenKey, { type: "json" });
953
- if (!tokenData) {
993
+ const parts = accessToken.split(":");
994
+ const isPossiblyInternalFormat = parts.length === 3;
995
+ let tokenData = null;
996
+ let userId = "";
997
+ let grantId = "";
998
+ if (isPossiblyInternalFormat) {
999
+ [userId, grantId] = parts;
1000
+ const id = await generateTokenId(accessToken);
1001
+ tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
1002
+ }
1003
+ if (!tokenData && !this.options.resolveExternalToken) {
954
1004
  return this.createErrorResponse("invalid_token", "Invalid access token", 401, {
955
1005
  "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
956
1006
  });
957
1007
  }
958
- const now = Math.floor(Date.now() / 1e3);
959
- if (tokenData.expiresAt < now) {
960
- return this.createErrorResponse("invalid_token", "Access token expired", 401, {
961
- "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
962
- });
1008
+ if (tokenData) {
1009
+ const now = Math.floor(Date.now() / 1e3);
1010
+ if (tokenData.expiresAt < now) {
1011
+ return this.createErrorResponse("invalid_token", "Access token expired", 401, {
1012
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
1013
+ });
1014
+ }
1015
+ if (tokenData.audience) {
1016
+ const requestUrl = new URL(request.url);
1017
+ const resourceServer = `${requestUrl.protocol}//${requestUrl.host}`;
1018
+ const audiences = Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience];
1019
+ const matches = audiences.some((aud) => audienceMatches(resourceServer, aud));
1020
+ if (!matches) {
1021
+ return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, {
1022
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token", error_description="Invalid audience"'
1023
+ });
1024
+ }
1025
+ }
1026
+ const encryptionKey = await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey);
1027
+ const decryptedProps = await decryptProps(encryptionKey, tokenData.grant.encryptedProps);
1028
+ ctx.props = decryptedProps;
1029
+ } else if (this.options.resolveExternalToken) {
1030
+ const ext = await this.options.resolveExternalToken({ token: accessToken, request, env });
1031
+ if (!ext) {
1032
+ return this.createErrorResponse("invalid_token", "Invalid access token", 401, {
1033
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
1034
+ });
1035
+ }
1036
+ if (ext.audience) {
1037
+ const requestUrl = new URL(request.url);
1038
+ const resourceServer = `${requestUrl.protocol}//${requestUrl.host}`;
1039
+ const audiences = Array.isArray(ext.audience) ? ext.audience : [ext.audience];
1040
+ const matches = audiences.some((aud) => audienceMatches(resourceServer, aud));
1041
+ if (!matches) {
1042
+ return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, {
1043
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token", error_description="Invalid audience"'
1044
+ });
1045
+ }
1046
+ }
1047
+ ctx.props = ext.props;
963
1048
  }
964
- const encryptionKey = await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey);
965
- const decryptedProps = await decryptProps(encryptionKey, tokenData.grant.encryptedProps);
966
- ctx.props = decryptedProps;
967
1049
  if (!env.OAUTH_PROVIDER) {
968
1050
  env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
969
1051
  }
@@ -1038,11 +1120,46 @@ var OAuthProviderImpl = class {
1038
1120
  };
1039
1121
  var DEFAULT_ACCESS_TOKEN_TTL = 60 * 60;
1040
1122
  var TOKEN_LENGTH = 32;
1123
+ function validateResourceUri(uri) {
1124
+ if (!uri || typeof uri !== "string") {
1125
+ return false;
1126
+ }
1127
+ try {
1128
+ const parsed = new URL(uri);
1129
+ if (!parsed.protocol) {
1130
+ return false;
1131
+ }
1132
+ if (parsed.hash) {
1133
+ return false;
1134
+ }
1135
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1136
+ return false;
1137
+ }
1138
+ return true;
1139
+ } catch {
1140
+ return false;
1141
+ }
1142
+ }
1143
+ function audienceMatches(resourceServerUrl, audienceValue) {
1144
+ return resourceServerUrl === audienceValue;
1145
+ }
1146
+ function parseResourceParameter(value) {
1147
+ if (!value) {
1148
+ return void 0;
1149
+ }
1150
+ const uris = Array.isArray(value) ? value : [value];
1151
+ for (const uri of uris) {
1152
+ if (typeof uri !== "string" || !validateResourceUri(uri)) {
1153
+ return void 0;
1154
+ }
1155
+ }
1156
+ return value;
1157
+ }
1041
1158
  async function hashSecret(secret) {
1042
1159
  return generateTokenId(secret);
1043
1160
  }
1044
1161
  function generateRandomString(length) {
1045
- const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
1162
+ const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1046
1163
  let result = "";
1047
1164
  const values = new Uint8Array(length);
1048
1165
  crypto.getRandomValues(values);
@@ -1059,6 +1176,26 @@ async function generateTokenId(token) {
1059
1176
  const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
1060
1177
  return hashHex;
1061
1178
  }
1179
+ function validateRedirectUriScheme(redirectUri) {
1180
+ const dangerousSchemes = ["javascript:", "data:", "vbscript:", "file:", "mailto:", "blob:"];
1181
+ const normalized = redirectUri.trim();
1182
+ for (let i = 0; i < normalized.length; i++) {
1183
+ const code = normalized.charCodeAt(i);
1184
+ if (code >= 0 && code <= 31 || code >= 127 && code <= 159) {
1185
+ throw new Error("Invalid redirect URI");
1186
+ }
1187
+ }
1188
+ const colonIndex = normalized.indexOf(":");
1189
+ if (colonIndex === -1) {
1190
+ throw new Error("Invalid redirect URI");
1191
+ }
1192
+ const scheme = normalized.substring(0, colonIndex + 1).toLowerCase();
1193
+ for (const dangerousScheme of dangerousSchemes) {
1194
+ if (scheme === dangerousScheme) {
1195
+ throw new Error("Invalid redirect URI");
1196
+ }
1197
+ }
1198
+ }
1062
1199
  function base64UrlEncode(str) {
1063
1200
  return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
1064
1201
  }
@@ -1211,8 +1348,12 @@ var OAuthHelpersImpl = class {
1211
1348
  const state = url.searchParams.get("state") || "";
1212
1349
  const codeChallenge = url.searchParams.get("code_challenge") || void 0;
1213
1350
  const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "plain";
1214
- if (!redirectUri.startsWith("http://") && !redirectUri.startsWith("https://")) {
1215
- throw new Error("Invalid redirect URI");
1351
+ const resourceParams = url.searchParams.getAll("resource");
1352
+ const resourceParam = resourceParams.length > 0 ? resourceParams.length === 1 ? resourceParams[0] : resourceParams : void 0;
1353
+ validateRedirectUriScheme(redirectUri);
1354
+ const resource = parseResourceParameter(resourceParam);
1355
+ if (resourceParam && !resource) {
1356
+ throw new Error("The resource parameter must be a valid absolute URI without a fragment");
1216
1357
  }
1217
1358
  if (responseType === "token" && !this.provider.options.allowImplicitFlow) {
1218
1359
  throw new Error("The implicit grant flow is not enabled for this provider");
@@ -1237,7 +1378,8 @@ var OAuthHelpersImpl = class {
1237
1378
  scope,
1238
1379
  state,
1239
1380
  codeChallenge,
1240
- codeChallengeMethod
1381
+ codeChallengeMethod,
1382
+ resource
1241
1383
  };
1242
1384
  }
1243
1385
  /**
@@ -1256,6 +1398,16 @@ var OAuthHelpersImpl = class {
1256
1398
  * @returns A Promise resolving to an object containing the redirect URL
1257
1399
  */
1258
1400
  async completeAuthorization(options) {
1401
+ const { clientId, redirectUri } = options.request;
1402
+ if (!clientId || !redirectUri) {
1403
+ throw new Error("Client ID and Redirect URI are required in the authorization request.");
1404
+ }
1405
+ const clientInfo = await this.lookupClient(clientId);
1406
+ if (!clientInfo || !clientInfo.redirectUris.includes(redirectUri)) {
1407
+ throw new Error(
1408
+ "Invalid redirect URI. The redirect URI provided does not match any registered URI for this client."
1409
+ );
1410
+ }
1259
1411
  const grantId = generateRandomString(16);
1260
1412
  const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
1261
1413
  const now = Math.floor(Date.now() / 1e3);
@@ -1266,6 +1418,10 @@ var OAuthHelpersImpl = class {
1266
1418
  const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
1267
1419
  const accessTokenExpiresAt = now + accessTokenTTL;
1268
1420
  const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
1421
+ const audience = parseResourceParameter(options.request.resource);
1422
+ if (options.request.resource && !audience) {
1423
+ throw new Error("The resource parameter must be a valid absolute URI without a fragment");
1424
+ }
1269
1425
  const grant = {
1270
1426
  id: grantId,
1271
1427
  clientId: options.request.clientId,
@@ -1273,7 +1429,8 @@ var OAuthHelpersImpl = class {
1273
1429
  scope: options.scope,
1274
1430
  metadata: options.metadata,
1275
1431
  encryptedProps: encryptedData,
1276
- createdAt: now
1432
+ createdAt: now,
1433
+ resource: options.request.resource
1277
1434
  };
1278
1435
  const grantKey = `grant:${options.userId}:${grantId}`;
1279
1436
  await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
@@ -1283,6 +1440,7 @@ var OAuthHelpersImpl = class {
1283
1440
  userId: options.userId,
1284
1441
  createdAt: now,
1285
1442
  expiresAt: accessTokenExpiresAt,
1443
+ audience,
1286
1444
  wrappedEncryptionKey: accessTokenWrappedKey,
1287
1445
  grant: {
1288
1446
  clientId: options.request.clientId,
@@ -1325,7 +1483,8 @@ var OAuthHelpersImpl = class {
1325
1483
  // Store the wrapped key
1326
1484
  // Store PKCE parameters if provided
1327
1485
  codeChallenge: options.request.codeChallenge,
1328
- codeChallengeMethod: options.request.codeChallengeMethod
1486
+ codeChallengeMethod: options.request.codeChallengeMethod,
1487
+ resource: options.request.resource
1329
1488
  };
1330
1489
  const grantKey = `grant:${options.userId}:${grantId}`;
1331
1490
  const codeExpiresIn = 600;
@@ -1362,6 +1521,9 @@ var OAuthHelpersImpl = class {
1362
1521
  registrationDate: Math.floor(Date.now() / 1e3),
1363
1522
  tokenEndpointAuthMethod
1364
1523
  };
1524
+ for (const uri of newClient.redirectUris) {
1525
+ validateRedirectUriScheme(uri);
1526
+ }
1365
1527
  let clientSecret;
1366
1528
  if (!isPublicClient) {
1367
1529
  clientSecret = generateRandomString(32);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.0.0-aa007fc",
3
+ "version": "0.0.0-be89144",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",