@cloudflare/workers-oauth-provider 0.3.3 → 0.4.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.
@@ -253,6 +253,16 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
253
253
  * Defaults to false.
254
254
  */
255
255
  clientIdMetadataDocumentEnabled?: boolean;
256
+ /**
257
+ * When true, resource validation during token exchange compares origins only
258
+ * (scheme + host + port) instead of exact URI matching. This allows grants issued
259
+ * with an origin-only resource (e.g. `https://server.com`) to be used with
260
+ * path-aware resource requests (e.g. `https://server.com/mcp`), enabling seamless
261
+ * migration from pre-0.4.0 versions that stored origin-only resource URIs.
262
+ *
263
+ * Defaults to false (strict exact matching per RFC 8707).
264
+ */
265
+ resourceMatchOriginOnly?: boolean;
256
266
  /**
257
267
  * Optional metadata for RFC 9728 OAuth 2.0 Protected Resource Metadata.
258
268
  * Controls the response served at /.well-known/oauth-protected-resource.
@@ -1,6 +1,7 @@
1
1
  import { WorkerEntrypoint } from "cloudflare:workers";
2
2
 
3
3
  //#region src/oauth-provider.ts
4
+ const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
4
5
  if (!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags?.global_fetch_strictly_public === true)) console.warn("CIMD (Client ID Metadata Document) is disabled: add '\"compatibility_flags\": [\"global_fetch_strictly_public\"]' to your wrangler.jsonc to enable. See: https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public");
5
6
  /**
6
7
  * Enum representing the type of handler (ExportedHandler or WorkerEntrypoint)
@@ -141,7 +142,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
141
142
  async fetch(request, env, ctx) {
142
143
  const url = new URL(request.url);
143
144
  if (request.method === "OPTIONS") {
144
- if (this.isApiRequest(url) || url.pathname === "/.well-known/oauth-authorization-server" || url.pathname === "/.well-known/oauth-protected-resource" || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return this.addCorsHeaders(new Response(null, {
145
+ if (this.isApiRequest(url) || url.pathname === "/.well-known/oauth-authorization-server" || this.isProtectedResourceMetadataRequest(url) || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return this.addCorsHeaders(new Response(null, {
145
146
  status: 204,
146
147
  headers: { "Content-Length": "0" }
147
148
  }), request);
@@ -150,7 +151,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
150
151
  const response = await this.handleMetadataDiscovery(url);
151
152
  return this.addCorsHeaders(response, request);
152
153
  }
153
- if (url.pathname === "/.well-known/oauth-protected-resource") {
154
+ if (this.isProtectedResourceMetadataRequest(url)) {
154
155
  const response = this.handleProtectedResourceMetadata(url);
155
156
  return this.addCorsHeaders(response, request);
156
157
  }
@@ -245,6 +246,27 @@ var OAuthProviderImpl = class OAuthProviderImpl {
245
246
  return this.matchEndpoint(url, this.options.clientRegistrationEndpoint);
246
247
  }
247
248
  /**
249
+ * Checks if a URL is a request for OAuth Protected Resource Metadata (RFC 9728).
250
+ * Matches both the root well-known path and path-suffixed variants per RFC 9728 §3.1.
251
+ */
252
+ isProtectedResourceMetadataRequest(url) {
253
+ return url.pathname === PROTECTED_RESOURCE_WELL_KNOWN_PREFIX || url.pathname.startsWith(PROTECTED_RESOURCE_WELL_KNOWN_PREFIX + "/");
254
+ }
255
+ /**
256
+ * Derives the resource identifier from a protected resource metadata well-known URL.
257
+ * Per RFC 9728 §3.1, the well-known URI is inserted after the authority and before the path,
258
+ * so the resource identifier is reconstructed by removing the well-known prefix.
259
+ *
260
+ * Examples:
261
+ * /.well-known/oauth-protected-resource → origin (e.g. https://example.com)
262
+ * /.well-known/oauth-protected-resource/mcp → origin + /mcp (e.g. https://example.com/mcp)
263
+ */
264
+ deriveResourceIdentifier(requestUrl) {
265
+ const suffix = requestUrl.pathname.slice(37);
266
+ if (!suffix || suffix === "/") return requestUrl.origin;
267
+ return `${requestUrl.origin}${suffix}`;
268
+ }
269
+ /**
248
270
  * Parses and validates a token endpoint request (used for both token exchange and revocation)
249
271
  * @param request - The HTTP request to parse
250
272
  * @returns Promise with parsed body and client info, or error response
@@ -389,7 +411,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
389
411
  const tokenEndpointUrl = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
390
412
  const authServerOrigin = new URL(tokenEndpointUrl).origin;
391
413
  const metadata = {
392
- resource: rm?.resource ?? requestUrl.origin,
414
+ resource: rm?.resource ?? this.deriveResourceIdentifier(requestUrl),
393
415
  authorization_servers: rm?.authorization_servers ?? [authServerOrigin],
394
416
  scopes_supported: rm?.scopes_supported ?? this.options.scopesSupported,
395
417
  bearer_methods_supported: rm?.bearer_methods_supported ?? ["header"]
@@ -515,10 +537,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
515
537
  grantData.expiresAt = expiresAt;
516
538
  }
517
539
  await this.saveGrantWithTTL(env, grantKey, grantData, now);
540
+ const originOnly = !!this.options.resourceMatchOriginOnly;
518
541
  if (body.resource && grantData.resource) {
519
542
  const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
520
543
  const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
521
- for (const requested of requestedResources) if (!grantedResources.includes(requested)) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
544
+ for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
522
545
  }
523
546
  const audience = parseResourceParameter(body.resource || grantData.resource);
524
547
  if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
@@ -635,10 +658,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
635
658
  grantData.refreshTokenId = newRefreshTokenId;
636
659
  grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
637
660
  await this.saveGrantWithTTL(env, grantKey, grantData, now);
661
+ const originOnly = !!this.options.resourceMatchOriginOnly;
638
662
  if (body.resource && grantData.resource) {
639
663
  const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
640
664
  const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
641
- for (const requested of requestedResources) if (!grantedResources.includes(requested)) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
665
+ for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
642
666
  }
643
667
  const audience = parseResourceParameter(body.resource || grantData.resource);
644
668
  if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
@@ -690,12 +714,13 @@ var OAuthProviderImpl = class OAuthProviderImpl {
690
714
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
691
715
  if (!grantData) throw new OAuthError("invalid_grant", "Grant not found");
692
716
  let tokenScopes = this.downscope(requestedScopes, grantData.scope);
717
+ const originOnly = !!this.options.resourceMatchOriginOnly;
693
718
  let newAudience = tokenSummary.audience;
694
719
  if (requestedResource) {
695
720
  if (grantData.resource) {
696
721
  const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
697
722
  const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
698
- for (const requested of requestedResources) if (!grantedResources.includes(requested)) throw new OAuthError("invalid_target", "Requested resource was not included in the authorization request");
723
+ for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) throw new OAuthError("invalid_target", "Requested resource was not included in the authorization request");
699
724
  }
700
725
  const parsedResource = parseResourceParameter(requestedResource);
701
726
  if (!parsedResource) throw new OAuthError("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
@@ -956,7 +981,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
956
981
  */
957
982
  async handleApiRequest(request, env, ctx) {
958
983
  const url = new URL(request.url);
959
- const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
984
+ const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`;
960
985
  const authHeader = request.headers.get("Authorization");
961
986
  if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token", "Missing or invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Missing or invalid access token") });
962
987
  const accessToken = authHeader.substring(7);
@@ -1331,6 +1356,19 @@ function parseResourceParameter(value) {
1331
1356
  return value;
1332
1357
  }
1333
1358
  /**
1359
+ * Checks if a requested resource matches a granted resource.
1360
+ * When originOnly is true, compares only the origin (scheme + host + port),
1361
+ * allowing path-aware resources to match origin-only grants.
1362
+ */
1363
+ function resourceMatches(requested, granted, originOnly) {
1364
+ if (!originOnly) return requested === granted;
1365
+ try {
1366
+ return new URL(requested).origin === new URL(granted).origin;
1367
+ } catch {
1368
+ return requested === granted;
1369
+ }
1370
+ }
1371
+ /**
1334
1372
  * Hashes a secret value using SHA-256
1335
1373
  * @param secret - The secret value to hash
1336
1374
  * @returns A hex string representation of the hash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",