@cloudflare/workers-oauth-provider 0.0.13 → 0.1.0

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.
@@ -94,6 +94,12 @@ interface ResolveExternalTokenResult {
94
94
  * These properties are set in the execution context (ctx.props) when the external token is validated
95
95
  */
96
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[];
97
103
  }
98
104
  interface OAuthProviderOptions {
99
105
  /**
@@ -304,6 +310,10 @@ interface AuthRequest {
304
310
  * PKCE code challenge method (plain or S256)
305
311
  */
306
312
  codeChallengeMethod?: string;
313
+ /**
314
+ * Resource parameter indicating target resource(s) (RFC 8707)
315
+ */
316
+ resource?: string | string[];
307
317
  }
308
318
  /**
309
319
  * OAuth client registration information
@@ -472,6 +482,11 @@ interface Grant {
472
482
  * Only present during the authorization code exchange process
473
483
  */
474
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[];
475
490
  }
476
491
  /**
477
492
  * Token record stored in KV
@@ -500,6 +515,11 @@ interface Token {
500
515
  * Unix timestamp when the token expires
501
516
  */
502
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[];
503
523
  /**
504
524
  * The encryption key for props, wrapped with this token
505
525
  */
@@ -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
  });
@@ -965,6 +1012,17 @@ var OAuthProviderImpl = class {
965
1012
  "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
966
1013
  });
967
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
+ }
968
1026
  const encryptionKey = await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey);
969
1027
  const decryptedProps = await decryptProps(encryptionKey, tokenData.grant.encryptedProps);
970
1028
  ctx.props = decryptedProps;
@@ -975,6 +1033,17 @@ var OAuthProviderImpl = class {
975
1033
  "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
976
1034
  });
977
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
+ }
978
1047
  ctx.props = ext.props;
979
1048
  }
980
1049
  if (!env.OAUTH_PROVIDER) {
@@ -1051,6 +1120,41 @@ var OAuthProviderImpl = class {
1051
1120
  };
1052
1121
  var DEFAULT_ACCESS_TOKEN_TTL = 60 * 60;
1053
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
+ }
1054
1158
  async function hashSecret(secret) {
1055
1159
  return generateTokenId(secret);
1056
1160
  }
@@ -1244,7 +1348,13 @@ var OAuthHelpersImpl = class {
1244
1348
  const state = url.searchParams.get("state") || "";
1245
1349
  const codeChallenge = url.searchParams.get("code_challenge") || void 0;
1246
1350
  const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "plain";
1351
+ const resourceParams = url.searchParams.getAll("resource");
1352
+ const resourceParam = resourceParams.length > 0 ? resourceParams.length === 1 ? resourceParams[0] : resourceParams : void 0;
1247
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");
1357
+ }
1248
1358
  if (responseType === "token" && !this.provider.options.allowImplicitFlow) {
1249
1359
  throw new Error("The implicit grant flow is not enabled for this provider");
1250
1360
  }
@@ -1268,7 +1378,8 @@ var OAuthHelpersImpl = class {
1268
1378
  scope,
1269
1379
  state,
1270
1380
  codeChallenge,
1271
- codeChallengeMethod
1381
+ codeChallengeMethod,
1382
+ resource
1272
1383
  };
1273
1384
  }
1274
1385
  /**
@@ -1307,6 +1418,10 @@ var OAuthHelpersImpl = class {
1307
1418
  const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
1308
1419
  const accessTokenExpiresAt = now + accessTokenTTL;
1309
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
+ }
1310
1425
  const grant = {
1311
1426
  id: grantId,
1312
1427
  clientId: options.request.clientId,
@@ -1314,7 +1429,8 @@ var OAuthHelpersImpl = class {
1314
1429
  scope: options.scope,
1315
1430
  metadata: options.metadata,
1316
1431
  encryptedProps: encryptedData,
1317
- createdAt: now
1432
+ createdAt: now,
1433
+ resource: options.request.resource
1318
1434
  };
1319
1435
  const grantKey = `grant:${options.userId}:${grantId}`;
1320
1436
  await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
@@ -1324,6 +1440,7 @@ var OAuthHelpersImpl = class {
1324
1440
  userId: options.userId,
1325
1441
  createdAt: now,
1326
1442
  expiresAt: accessTokenExpiresAt,
1443
+ audience,
1327
1444
  wrappedEncryptionKey: accessTokenWrappedKey,
1328
1445
  grant: {
1329
1446
  clientId: options.request.clientId,
@@ -1366,7 +1483,8 @@ var OAuthHelpersImpl = class {
1366
1483
  // Store the wrapped key
1367
1484
  // Store PKCE parameters if provided
1368
1485
  codeChallenge: options.request.codeChallenge,
1369
- codeChallengeMethod: options.request.codeChallengeMethod
1486
+ codeChallengeMethod: options.request.codeChallengeMethod,
1487
+ resource: options.request.resource
1370
1488
  };
1371
1489
  const grantKey = `grant:${options.userId}:${grantId}`;
1372
1490
  const codeExpiresIn = 600;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.0.13",
3
+ "version": "0.1.0",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",