@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.
- package/dist/oauth-provider.d.ts +10 -0
- package/dist/oauth-provider.js +45 -7
- package/package.json +1 -1
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -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.
|
package/dist/oauth-provider.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|