@cloudflare/workers-oauth-provider 0.8.1 → 0.8.3
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 +3 -3
- package/dist/oauth-provider.d.ts +18 -10
- package/dist/oauth-provider.js +34 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -393,11 +393,11 @@ new OAuthProvider({
|
|
|
393
393
|
|
|
394
394
|
Setup:
|
|
395
395
|
|
|
396
|
-
1. Configure your IdP as an ID-JAG issuer with
|
|
396
|
+
1. Configure your IdP as an ID-JAG issuer with a public JWKS endpoint. If it includes the optional `resource` claim, configure it with the MCP endpoint URL.
|
|
397
397
|
2. Set `resourceMetadata.resource` to the MCP endpoint URL (required when EMA is enabled).
|
|
398
398
|
3. Implement `trustedIssuers` as a resolver — for multi-tenant deployments it can read `env` / `clientInfo` to look up per-tenant IdP config without redeploying.
|
|
399
399
|
|
|
400
|
-
The AS enforces `resolved.issuer === iss` (confused-deputy guard) and validates ID-JAG `typ`, signature, audience, client binding, resource, `exp` / `iat` / `nbf`, max lifetime, and `jti` replay. Refresh tokens are not issued for this grant — the ID-JAG itself is the renewable assertion.
|
|
400
|
+
The AS enforces `resolved.issuer === iss` (confused-deputy guard) and validates ID-JAG `typ`, signature, audience, client binding, any supplied resource, `exp` / `iat` / `nbf`, max lifetime, and `jti` replay. When the optional `resource` claim is omitted, the AS uses `resourceMetadata.resource`, so issued tokens remain pinned to the configured MCP resource. Refresh tokens are not issued for this grant — the ID-JAG itself is the renewable assertion.
|
|
401
401
|
|
|
402
402
|
### Public clients
|
|
403
403
|
|
|
@@ -410,7 +410,7 @@ enterpriseManagedAuthorization: {
|
|
|
410
410
|
}
|
|
411
411
|
```
|
|
412
412
|
|
|
413
|
-
This is useful for clients registered via a [Client ID Metadata Document (CIMD)](https://modelcontextprotocol.io/), which are always public and therefore cannot present a client secret. With this enabled, trust rests on the IdP-issued, signature-verified, short-lived, single-use ID-JAG assertion (audience
|
|
413
|
+
This is useful for clients registered via a [Client ID Metadata Document (CIMD)](https://modelcontextprotocol.io/), which are always public and therefore cannot present a client secret. With this enabled, trust rests on the IdP-issued, signature-verified, short-lived, single-use ID-JAG assertion (audience- and client-bound), together with the provider's configured resource pinning, rather than on a separately presented client secret. Leave it unset (default `false`) to keep the spec-default behavior of requiring client authentication.
|
|
414
414
|
|
|
415
415
|
Experimental — the MCP extension is still a draft.
|
|
416
416
|
|
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -102,7 +102,11 @@ interface EmaIdJagClaims {
|
|
|
102
102
|
sub: string;
|
|
103
103
|
/** Authorization server issuer URL or URLs for which this assertion is intended. */
|
|
104
104
|
aud: string | string[];
|
|
105
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Effective RFC 9728 resource identifier of the MCP server. When the ID-JAG
|
|
107
|
+
* omits its optional `resource` claim, the provider supplies its configured
|
|
108
|
+
* `resourceMetadata.resource` value.
|
|
109
|
+
*/
|
|
106
110
|
resource: string;
|
|
107
111
|
/** OAuth client identifier this assertion was issued to. */
|
|
108
112
|
client_id: string;
|
|
@@ -140,7 +144,10 @@ interface EmaClaimsMapperInput<Env = Cloudflare.Env> {
|
|
|
140
144
|
claims: EmaIdJagClaims;
|
|
141
145
|
/** Authenticated OAuth client that presented the assertion. */
|
|
142
146
|
clientInfo: ClientInfo;
|
|
143
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* Effective MCP resource identifier. Taken from the assertion when present,
|
|
149
|
+
* otherwise from the provider's configured `resourceMetadata.resource`.
|
|
150
|
+
*/
|
|
144
151
|
resource: string;
|
|
145
152
|
/** Requested scopes after downscoping to the assertion's scope claim, if present. */
|
|
146
153
|
requestedScope: string[];
|
|
@@ -281,8 +288,9 @@ interface EmaOptions<Env = Cloudflare.Env> {
|
|
|
281
288
|
* always public (`none`) and therefore cannot present a client secret. The
|
|
282
289
|
* security trade-off is documented in the README: the trust then rests on
|
|
283
290
|
* the IdP-issued, signature-verified, short-lived, single-use ID-JAG
|
|
284
|
-
* assertion (audience
|
|
285
|
-
* separately presented client
|
|
291
|
+
* assertion (audience- and client-bound), together with the provider's
|
|
292
|
+
* configured resource pinning, rather than on a separately presented client
|
|
293
|
+
* secret.
|
|
286
294
|
*/
|
|
287
295
|
allowPublicClients?: boolean;
|
|
288
296
|
}
|
|
@@ -350,8 +358,9 @@ interface TokenExchangeCallbackResult {
|
|
|
350
358
|
*/
|
|
351
359
|
refreshTokenTTL?: number;
|
|
352
360
|
/**
|
|
353
|
-
*
|
|
354
|
-
*
|
|
361
|
+
* Optional scopes for the new access token. Values outside the scope ceiling
|
|
362
|
+
* for the current grant flow are ignored. If omitted, the effective requested
|
|
363
|
+
* scopes are used.
|
|
355
364
|
*/
|
|
356
365
|
accessTokenScope?: string[];
|
|
357
366
|
}
|
|
@@ -380,12 +389,11 @@ interface TokenExchangeCallbackOptions {
|
|
|
380
389
|
*/
|
|
381
390
|
grantId: string;
|
|
382
391
|
/**
|
|
383
|
-
* List of scopes
|
|
392
|
+
* List of scopes on the underlying authorization grant.
|
|
384
393
|
*/
|
|
385
394
|
scope: string[];
|
|
386
395
|
/**
|
|
387
|
-
*
|
|
388
|
-
* (Will be the same as granted scopes unless client specifically requested a downscoping)
|
|
396
|
+
* Effective scopes selected for this token before applying the callback result.
|
|
389
397
|
*/
|
|
390
398
|
requestedScope: string[];
|
|
391
399
|
/**
|
|
@@ -782,7 +790,7 @@ interface ExchangeTokenOptions {
|
|
|
782
790
|
*/
|
|
783
791
|
subjectToken: string;
|
|
784
792
|
/**
|
|
785
|
-
* Optional
|
|
793
|
+
* Optional requested scopes for the new token. Issued scopes are limited to the subject token's scopes.
|
|
786
794
|
*/
|
|
787
795
|
scope?: string[];
|
|
788
796
|
/**
|
package/dist/oauth-provider.js
CHANGED
|
@@ -469,13 +469,14 @@ function isWellFormedTrustedIssuer(issuer) {
|
|
|
469
469
|
return true;
|
|
470
470
|
}
|
|
471
471
|
/**
|
|
472
|
-
* Validate
|
|
472
|
+
* Validate the ID-JAG claims and produce a typed `ValidatedIdJag`.
|
|
473
473
|
*
|
|
474
474
|
* Enforces (in order):
|
|
475
|
-
* - presence + type of `iss`, `sub`, `aud`, `
|
|
475
|
+
* - presence + type of `iss`, `sub`, `aud`, `client_id`, `jti`, `exp`, `iat`
|
|
476
476
|
* - `aud` contains the AS's expected audience
|
|
477
477
|
* - `client_id` matches the authenticated client
|
|
478
|
-
* - `resource` is a valid RFC 8707 URI and matches the AS's configured resource
|
|
478
|
+
* - optional `resource` is a valid RFC 8707 URI and matches the AS's configured resource
|
|
479
|
+
* - the configured resource is used when `resource` is omitted
|
|
479
480
|
* - `exp` is in the future
|
|
480
481
|
* - `iat` is not more than `clockSkewSeconds` in the future
|
|
481
482
|
* - `nbf` (if present) is ≤ `now + clockSkewSeconds`
|
|
@@ -495,7 +496,7 @@ function validateIdJagClaims(input) {
|
|
|
495
496
|
if (!sub.ok) return sub;
|
|
496
497
|
const aud = readAudienceClaim(rawClaims);
|
|
497
498
|
if (!aud.ok) return aud;
|
|
498
|
-
const resource = readRequiredString(rawClaims, "resource");
|
|
499
|
+
const resource = rawClaims.resource === void 0 ? ok(configuredResource) : readRequiredString(rawClaims, "resource");
|
|
499
500
|
if (!resource.ok) return resource;
|
|
500
501
|
const claimClientId = readRequiredString(rawClaims, "client_id");
|
|
501
502
|
if (!claimClientId.ok) return claimClientId;
|
|
@@ -989,13 +990,21 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
989
990
|
description: "Method not allowed",
|
|
990
991
|
statusCode: 405
|
|
991
992
|
});
|
|
992
|
-
|
|
993
|
+
const contentType = request.headers.get("Content-Type") || "";
|
|
993
994
|
let body = {};
|
|
994
|
-
if (
|
|
995
|
+
if (contentType.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return this.createErrorResponse("invalid_request", {
|
|
995
996
|
description: "Content-Type must be application/x-www-form-urlencoded",
|
|
996
997
|
statusCode: 400
|
|
997
998
|
});
|
|
998
|
-
|
|
999
|
+
let formData;
|
|
1000
|
+
try {
|
|
1001
|
+
formData = await request.formData();
|
|
1002
|
+
} catch {
|
|
1003
|
+
return this.createErrorResponse("invalid_request", {
|
|
1004
|
+
description: "Request body must be valid application/x-www-form-urlencoded data",
|
|
1005
|
+
statusCode: 400
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
999
1008
|
const processedKeys = /* @__PURE__ */ new Set();
|
|
1000
1009
|
for (const [key, value] of formData.entries()) {
|
|
1001
1010
|
if (processedKeys.has(key)) continue;
|
|
@@ -1263,6 +1272,14 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1263
1272
|
} else calculatedChallenge = codeVerifier;
|
|
1264
1273
|
if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" });
|
|
1265
1274
|
}
|
|
1275
|
+
const originOnly = !!this.options.resourceMatchOriginOnly;
|
|
1276
|
+
if (body.resource && grantData.resource) {
|
|
1277
|
+
const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
|
|
1278
|
+
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
1279
|
+
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", { description: "Requested resource was not included in the authorization request" });
|
|
1280
|
+
}
|
|
1281
|
+
const audience = parseResourceParameter(body.resource || grantData.resource);
|
|
1282
|
+
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
1266
1283
|
let accessTokenTTL = this.options.accessTokenTTL;
|
|
1267
1284
|
let refreshTokenTTL = this.options.refreshTokenTTL;
|
|
1268
1285
|
const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
|
|
@@ -1324,14 +1341,6 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1324
1341
|
grantData.expiresAt = expiresAt;
|
|
1325
1342
|
}
|
|
1326
1343
|
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
1327
|
-
const originOnly = !!this.options.resourceMatchOriginOnly;
|
|
1328
|
-
if (body.resource && grantData.resource) {
|
|
1329
|
-
const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
|
|
1330
|
-
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
1331
|
-
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", { description: "Requested resource was not included in the authorization request" });
|
|
1332
|
-
}
|
|
1333
|
-
const audience = parseResourceParameter(body.resource || grantData.resource);
|
|
1334
|
-
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
1335
1344
|
const tokenResponse = {
|
|
1336
1345
|
access_token: await this.createAccessToken({
|
|
1337
1346
|
userId,
|
|
@@ -1501,7 +1510,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1501
1510
|
* `OAuthProviderImpl` is not exposed outside this module, this is still effectively
|
|
1502
1511
|
* module-private.
|
|
1503
1512
|
* @param subjectToken - The subject token to exchange
|
|
1504
|
-
* @param requestedScopes - Optional
|
|
1513
|
+
* @param requestedScopes - Optional requested scopes, limited to the subject token's scopes
|
|
1505
1514
|
* @param requestedResource - Optional resource/audience (must be subset of original if original had resource)
|
|
1506
1515
|
* @param expiresIn - Optional TTL override in seconds
|
|
1507
1516
|
* @param clientInfo - The client making the exchange request
|
|
@@ -1515,7 +1524,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1515
1524
|
const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
|
|
1516
1525
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
1517
1526
|
if (!grantData) throw new OAuthError("invalid_grant", { description: "Grant not found" });
|
|
1518
|
-
let tokenScopes = this.downscope(requestedScopes,
|
|
1527
|
+
let tokenScopes = this.downscope(requestedScopes, tokenSummary.scope);
|
|
1519
1528
|
const originOnly = !!this.options.resourceMatchOriginOnly;
|
|
1520
1529
|
let newAudience = tokenSummary.audience;
|
|
1521
1530
|
if (requestedResource) {
|
|
@@ -1565,7 +1574,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1565
1574
|
encryptedAccessTokenProps = tokenResult.encryptedData;
|
|
1566
1575
|
accessTokenEncryptionKey = tokenResult.key;
|
|
1567
1576
|
}
|
|
1568
|
-
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope,
|
|
1577
|
+
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, tokenSummary.scope);
|
|
1569
1578
|
}
|
|
1570
1579
|
}
|
|
1571
1580
|
if (accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
|
|
@@ -2209,15 +2218,15 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2209
2218
|
return accessToken;
|
|
2210
2219
|
}
|
|
2211
2220
|
/**
|
|
2212
|
-
*
|
|
2213
|
-
*
|
|
2221
|
+
* Restricts requested scopes to the scopes available for the current flow.
|
|
2222
|
+
* If no scope is requested, all available scopes are returned.
|
|
2214
2223
|
* @param requestedScope - The scope parameter from the request (string or array)
|
|
2215
|
-
* @param
|
|
2216
|
-
* @returns The
|
|
2224
|
+
* @param allowedScopes - The maximum scopes available for the current flow
|
|
2225
|
+
* @returns The requested scopes that are included in the allowed scopes
|
|
2217
2226
|
*/
|
|
2218
|
-
downscope(requestedScope,
|
|
2219
|
-
if (!requestedScope) return
|
|
2220
|
-
return (typeof requestedScope === "string" ? requestedScope.split(" ").filter(Boolean) : requestedScope).filter((scope) =>
|
|
2227
|
+
downscope(requestedScope, allowedScopes) {
|
|
2228
|
+
if (!requestedScope) return allowedScopes;
|
|
2229
|
+
return (typeof requestedScope === "string" ? requestedScope.split(" ").filter(Boolean) : requestedScope).filter((scope) => allowedScopes.includes(scope));
|
|
2221
2230
|
}
|
|
2222
2231
|
/**
|
|
2223
2232
|
* Checks if the global_fetch_strictly_public compatibility flag is enabled.
|