@cloudflare/workers-oauth-provider 0.8.0 → 0.8.2
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 +12 -4
- package/dist/oauth-provider.js +37 -9
- 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
|
}
|
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;
|
|
@@ -639,9 +640,11 @@ function validateEmaMapperResult(result) {
|
|
|
639
640
|
* TOCTOU window between claim validation and token mint.
|
|
640
641
|
*/
|
|
641
642
|
function computeEmaAccessTokenTTL(input) {
|
|
642
|
-
const { configuredDefaultSeconds, assertionExp, mapperTtl, now } = input;
|
|
643
|
+
const { configuredDefaultSeconds, assertionExp, mapperTtl, now, minTtlSeconds } = input;
|
|
643
644
|
if (assertionExp - now <= 0) return err({ reason: "assertion_expired_after_processing" });
|
|
644
|
-
|
|
645
|
+
const ttl = mapperTtl ?? configuredDefaultSeconds;
|
|
646
|
+
if (ttl < minTtlSeconds) return err({ reason: "invalid_mapped_ttl" });
|
|
647
|
+
return ok(ttl);
|
|
645
648
|
}
|
|
646
649
|
function readRequiredString(claims, claimName) {
|
|
647
650
|
const value = claims[claimName];
|
|
@@ -785,6 +788,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
785
788
|
onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
|
|
786
789
|
...options
|
|
787
790
|
};
|
|
791
|
+
if (!Number.isInteger(this.options.accessTokenTTL) || this.options.accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) throw new TypeError(`accessTokenTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds (Cloudflare KV's minimum expiration window).`);
|
|
788
792
|
this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
|
|
789
793
|
if (this.options.enterpriseManagedAuthorization) {
|
|
790
794
|
this.jwksProvider = createDefaultJwksProvider({ cacheTtlSeconds: this.options.enterpriseManagedAuthorization.jwksCacheTtlSeconds });
|
|
@@ -1375,7 +1379,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1375
1379
|
if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" });
|
|
1376
1380
|
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
|
|
1377
1381
|
if (grantData.expiresAt !== void 0) {
|
|
1378
|
-
|
|
1382
|
+
const now$1 = Math.floor(Date.now() / 1e3);
|
|
1383
|
+
if (grantData.expiresAt - now$1 < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
|
|
1379
1384
|
}
|
|
1380
1385
|
const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
1381
1386
|
const accessTokenId = await generateTokenId(newAccessToken);
|
|
@@ -1432,10 +1437,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1432
1437
|
}
|
|
1433
1438
|
}
|
|
1434
1439
|
const now = Math.floor(Date.now() / 1e3);
|
|
1440
|
+
if (grantData.expiresAt !== void 0 && grantData.expiresAt - now < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
|
|
1435
1441
|
if (grantData.expiresAt !== void 0) {
|
|
1436
1442
|
const remainingRefreshTokenLifetime = grantData.expiresAt - now;
|
|
1437
1443
|
if (remainingRefreshTokenLifetime > 0) accessTokenTTL = Math.min(accessTokenTTL, remainingRefreshTokenLifetime);
|
|
1438
1444
|
}
|
|
1445
|
+
if (accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
|
|
1439
1446
|
const accessTokenExpiresAt = now + accessTokenTTL;
|
|
1440
1447
|
const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
|
|
1441
1448
|
const newRefreshToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
@@ -1524,6 +1531,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1524
1531
|
}
|
|
1525
1532
|
const now = Math.floor(Date.now() / 1e3);
|
|
1526
1533
|
const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
|
|
1534
|
+
if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_grant", { description: "Subject token is too close to expiry to exchange" });
|
|
1527
1535
|
let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
|
|
1528
1536
|
if (expiresIn !== void 0) {
|
|
1529
1537
|
if (expiresIn <= 0) throw new OAuthError("invalid_request", { description: "Invalid expires_in parameter" });
|
|
@@ -1561,6 +1569,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1561
1569
|
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
1562
1570
|
}
|
|
1563
1571
|
}
|
|
1572
|
+
if (accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
|
|
1564
1573
|
const tokenResponse = {
|
|
1565
1574
|
access_token: await this.createAccessToken({
|
|
1566
1575
|
userId: tokenSummary.userId,
|
|
@@ -1737,7 +1746,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1737
1746
|
configuredDefaultSeconds: this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL,
|
|
1738
1747
|
assertionExp: claims.value.claims.exp,
|
|
1739
1748
|
mapperTtl: mapped.value.accessTokenTTL,
|
|
1740
|
-
now: issueNow
|
|
1749
|
+
now: issueNow,
|
|
1750
|
+
minTtlSeconds: KV_MIN_EXPIRATION_TTL_SECONDS
|
|
1741
1751
|
});
|
|
1742
1752
|
if (!ttl.ok) return ttl;
|
|
1743
1753
|
return ok(await this.issueEmaAccessToken({
|
|
@@ -2114,7 +2124,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2114
2124
|
* @param now - Current timestamp in seconds
|
|
2115
2125
|
*/
|
|
2116
2126
|
async saveGrantWithTTL(env, grantKey, grantData, now) {
|
|
2117
|
-
const
|
|
2127
|
+
const minExpiration = now + KV_MIN_EXPIRATION_TTL_SECONDS + KV_EXPIRATION_CLAMP_MARGIN_SECONDS;
|
|
2128
|
+
const kvOptions = grantData.expiresAt !== void 0 ? { expiration: Math.max(grantData.expiresAt, minExpiration) } : {};
|
|
2118
2129
|
try {
|
|
2119
2130
|
await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
|
|
2120
2131
|
} catch (error) {
|
|
@@ -2171,6 +2182,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2171
2182
|
*/
|
|
2172
2183
|
async createAccessToken(params) {
|
|
2173
2184
|
const { userId, grantId, clientId, scope, encryptedProps, encryptionKey, expiresIn, audience, env } = params;
|
|
2185
|
+
if (expiresIn < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
|
|
2174
2186
|
const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
2175
2187
|
const now = Math.floor(Date.now() / 1e3);
|
|
2176
2188
|
const accessTokenId = await generateTokenId(accessToken);
|
|
@@ -2522,6 +2534,22 @@ const DEFAULT_REFRESH_TOKEN_TTL = 720 * 60 * 60;
|
|
|
2522
2534
|
*/
|
|
2523
2535
|
const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
|
|
2524
2536
|
/**
|
|
2537
|
+
* Minimum number of seconds an absolute KV expiration must be in the future.
|
|
2538
|
+
* Cloudflare KV rejects `put` calls whose `expiration` is less than 60 seconds
|
|
2539
|
+
* away with "400 Invalid expiration ... Expiration times must be at least 60
|
|
2540
|
+
* seconds in the future." We use this to treat near-expiry grants as expired and
|
|
2541
|
+
* to clamp absolute expirations when writing grants back to KV.
|
|
2542
|
+
*/
|
|
2543
|
+
const KV_MIN_EXPIRATION_TTL_SECONDS = 60;
|
|
2544
|
+
/**
|
|
2545
|
+
* Safety margin (seconds) added on top of `KV_MIN_EXPIRATION_TTL_SECONDS` when clamping an
|
|
2546
|
+
* absolute KV expiration. Absolute expirations are validated against KV's clock at the
|
|
2547
|
+
* moment the write is processed, so writing exactly `now + 60` can be rejected once
|
|
2548
|
+
* worker→KV latency or minor clock skew is accounted for. The margin keeps clamped writes
|
|
2549
|
+
* comfortably above KV's hard minimum without meaningfully extending a grant's lifetime.
|
|
2550
|
+
*/
|
|
2551
|
+
const KV_EXPIRATION_CLAMP_MARGIN_SECONDS = 5;
|
|
2552
|
+
/**
|
|
2525
2553
|
* Default batch size for purgeExpiredData. Conservative to stay within
|
|
2526
2554
|
* Cloudflare's 1000 subrequest limit per invocation.
|
|
2527
2555
|
*/
|