@cloudflare/workers-oauth-provider 0.6.0 → 0.7.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/README.md CHANGED
@@ -117,6 +117,10 @@ export default new OAuthProvider({
117
117
  // Defaults to false.
118
118
  allowTokenExchangeGrant: false,
119
119
 
120
+ // Optional: Experimental MCP Enterprise-Managed Authorization support.
121
+ // When enabled, the token endpoint accepts ID-JAG JWTs with the JWT bearer grant.
122
+ enterpriseManagedAuthorization: undefined,
123
+
120
124
  // Optional: Explicitly enable Client ID Metadata Document (CIMD) support.
121
125
  // When true, URL-formatted client_ids will be fetched as metadata documents.
122
126
  // Requires the 'global_fetch_strictly_public' compatibility flag.
@@ -352,6 +356,42 @@ async function refreshUpstream(props) {
352
356
 
353
357
  Only `OAuthError` from this package is converted into a structured `/token` response. Plain errors, plain objects with a `code` field, and app-local error classes continue to surface as 500s so unexpected failures stay visible. Import `OAuthError` from `@cloudflare/workers-oauth-provider` rather than copying or re-implementing it.
354
358
 
359
+ ## Enterprise-Managed Authorization (Experimental)
360
+
361
+ Accepts ID-JAG assertions at `/token` per the [MCP Enterprise-Managed Authorization extension](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization). The enterprise IdP issues an ID-JAG JWT and the MCP client exchanges it here for an opaque access token.
362
+
363
+ ```ts
364
+ new OAuthProvider({
365
+ // ... other options ...
366
+ resourceMetadata: { resource: 'https://mcp.example.com/mcp' },
367
+ enterpriseManagedAuthorization: {
368
+ trustedIssuers: async ({ iss }) =>
369
+ iss === 'https://idp.example.com'
370
+ ? { issuer: iss, jwksUri: 'https://idp.example.com/.well-known/jwks.json', algorithms: ['RS256'] }
371
+ : null,
372
+ async mapClaims({ claims, requestedScope }) {
373
+ return {
374
+ // Opaque tokens use ':' as a separator — encode subjects that may contain it.
375
+ userId: `enterprise-${claims.sub}`,
376
+ scope: requestedScope,
377
+ metadata: { enterpriseIssuer: claims.iss, enterpriseSubject: claims.sub },
378
+ props: { enterprise: true, subject: claims.sub, email: claims.email },
379
+ };
380
+ },
381
+ },
382
+ });
383
+ ```
384
+
385
+ Setup:
386
+
387
+ 1. Configure your IdP as an ID-JAG issuer with this worker's origin as the resource and a public JWKS endpoint.
388
+ 2. Set `resourceMetadata.resource` to the MCP endpoint URL (required when EMA is enabled).
389
+ 3. Implement `trustedIssuers` as a resolver — for multi-tenant deployments it can read `env` / `clientInfo` to look up per-tenant IdP config without redeploying.
390
+
391
+ 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.
392
+
393
+ Experimental — the MCP extension is still a draft.
394
+
355
395
  ## Custom Error Responses
356
396
 
357
397
  By using the `onError` option, you can emit notifications or take other actions when an error response was to be emitted:
@@ -434,6 +474,7 @@ This library implements the following OAuth and MCP specifications:
434
474
  - [OAuth 2.0 Protected Resource Metadata (RFC 9728)](https://datatracker.ietf.org/doc/html/rfc9728) — `/.well-known/oauth-protected-resource` discovery endpoint
435
475
  - [OAuth 2.0 Dynamic Client Registration (RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591) — Dynamic client registration endpoint
436
476
  - [OAuth Client ID Metadata Documents (draft-ietf-oauth-client-id-metadata-document)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document) — HTTPS URLs as client IDs
477
+ - [MCP Enterprise-Managed Authorization extension](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) — experimental ID-JAG JWT bearer grant support
437
478
 
438
479
  These are the specifications required by the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
439
480
 
@@ -1,7 +1,277 @@
1
1
  import { WorkerEntrypoint } from "cloudflare:workers";
2
2
 
3
- //#region src/oauth-provider.d.ts
3
+ //#region src/ema/result.d.ts
4
4
 
5
+ /**
6
+ * Tagged union of every validation failure that can occur on the EMA token
7
+ * endpoint path. Extend by adding a new arm; the exhaustive `switch` in
8
+ * `emaErrorToWire` will surface unhandled cases at compile time.
9
+ */
10
+ type EmaValidationError = {
11
+ reason: 'assertion_missing';
12
+ } | {
13
+ reason: 'assertion_too_large';
14
+ size: number;
15
+ max: number;
16
+ } | {
17
+ reason: 'assertion_malformed';
18
+ } | {
19
+ reason: 'invalid_typ';
20
+ got?: unknown;
21
+ } | {
22
+ reason: 'invalid_alg';
23
+ got?: unknown;
24
+ } | {
25
+ reason: 'issuer_not_trusted';
26
+ iss: string;
27
+ } | {
28
+ reason: 'no_matching_key';
29
+ kid?: string;
30
+ } | {
31
+ reason: 'signature_failed';
32
+ } | {
33
+ reason: 'jwks_fetch_failed';
34
+ status?: number;
35
+ } | {
36
+ reason: 'invalid_claim';
37
+ claim: string;
38
+ } | {
39
+ reason: 'aud_mismatch';
40
+ expected: string;
41
+ got: string | string[];
42
+ } | {
43
+ reason: 'expired';
44
+ exp: number;
45
+ now: number;
46
+ } | {
47
+ reason: 'iat_in_future';
48
+ iat: number;
49
+ now: number;
50
+ skew: number;
51
+ } | {
52
+ reason: 'nbf_in_future';
53
+ nbf: number;
54
+ now: number;
55
+ skew: number;
56
+ } | {
57
+ reason: 'lifetime_too_long';
58
+ lifetime: number;
59
+ max: number;
60
+ } | {
61
+ reason: 'replayed';
62
+ jti: string;
63
+ } | {
64
+ reason: 'client_id_mismatch';
65
+ expected: string;
66
+ got: string;
67
+ } | {
68
+ reason: 'resource_invalid';
69
+ resource: string;
70
+ } | {
71
+ reason: 'resource_mismatch';
72
+ expected: string;
73
+ got: string;
74
+ } | {
75
+ reason: 'invalid_scope_param';
76
+ } | {
77
+ reason: 'invalid_mapped_user';
78
+ } | {
79
+ reason: 'invalid_mapped_scope';
80
+ } | {
81
+ reason: 'invalid_mapped_props';
82
+ } | {
83
+ reason: 'invalid_mapped_ttl';
84
+ } | {
85
+ reason: 'mapper_denied';
86
+ } | {
87
+ reason: 'mapper_threw';
88
+ } | {
89
+ reason: 'assertion_expired_after_processing';
90
+ };
91
+ //#endregion
92
+ //#region src/ema/types.d.ts
93
+ /**
94
+ * Claims expected in an MCP Enterprise-Managed Authorization ID-JAG assertion.
95
+ * Additional issuer-specific claims (e.g. `email`) are preserved verbatim
96
+ * under the index signature.
97
+ */
98
+ interface EmaIdJagClaims {
99
+ /** Identity provider issuer URL. */
100
+ iss: string;
101
+ /** Enterprise subject identifier for the resource owner. */
102
+ sub: string;
103
+ /** Authorization server issuer URL or URLs for which this assertion is intended. */
104
+ aud: string | string[];
105
+ /** RFC 9728 resource identifier of the MCP server. */
106
+ resource: string;
107
+ /** OAuth client identifier this assertion was issued to. */
108
+ client_id: string;
109
+ /** Unique assertion identifier used for replay protection. */
110
+ jti: string;
111
+ /** Assertion expiration time as a Unix timestamp in seconds. */
112
+ exp: number;
113
+ /** Assertion issued-at time as a Unix timestamp in seconds. */
114
+ iat: number;
115
+ /** Optional space-separated OAuth scope string. */
116
+ scope?: string;
117
+ /** Optional email claim supplied by the enterprise IdP. */
118
+ email?: string;
119
+ /** Additional enterprise IdP claims. */
120
+ [claim: string]: unknown;
121
+ }
122
+ /**
123
+ * Trusted enterprise IdP configuration for ID-JAG validation.
124
+ */
125
+ interface EmaTrustedIssuer {
126
+ /** Issuer URL that must exactly match the assertion `iss` claim. */
127
+ issuer: string;
128
+ /** HTTPS JWKS endpoint used to validate assertion signatures. */
129
+ jwksUri: string;
130
+ /** Allowed JWT signing algorithms for this issuer. Defaults to `['RS256']`. */
131
+ algorithms?: string[];
132
+ /** Expected authorization server audience. Defaults to this provider's issuer URL. */
133
+ audience?: string;
134
+ }
135
+ /**
136
+ * Input passed to `enterpriseManagedAuthorization.mapClaims` after ID-JAG validation.
137
+ */
138
+ interface EmaClaimsMapperInput<Env = Cloudflare.Env> {
139
+ /** Validated ID-JAG claims. */
140
+ claims: EmaIdJagClaims;
141
+ /** Authenticated OAuth client that presented the assertion. */
142
+ clientInfo: ClientInfo;
143
+ /** Validated MCP resource identifier from the assertion. */
144
+ resource: string;
145
+ /** Requested scopes after downscoping to the assertion's scope claim, if present. */
146
+ requestedScope: string[];
147
+ /** The original HTTP token request, e.g. for inspecting Host header in multi-tenant routing. */
148
+ request: Request;
149
+ /** Cloudflare Worker environment variables. */
150
+ env: Env;
151
+ }
152
+ /**
153
+ * Result returned by `enterpriseManagedAuthorization.mapClaims`.
154
+ */
155
+ interface EmaClaimsMapperResult {
156
+ /**
157
+ * User ID to associate with the issued grant and access token.
158
+ *
159
+ * Must not contain `:` — the opaque access token format issued by this
160
+ * provider uses `:` as an internal separator, so a `userId` containing
161
+ * it will produce tokens that fail to parse on validation. If the IdP
162
+ * subject may contain `:` (e.g. an email), encode or hash it first
163
+ * (e.g. ``userId: `enterprise-${encodeURIComponent(claims.sub)}` ``).
164
+ */
165
+ userId: string;
166
+ /** Scopes to grant to the issued access token. */
167
+ scope: string[];
168
+ /**
169
+ * Optional grant metadata used for audit and grant listing. This is not
170
+ * encrypted. Stored on the grant in KV alongside the user ID — visible to
171
+ * server-side code via `OAuthHelpers` but never exposed to the MCP client.
172
+ * Use for audit logs, admin UIs, or "list active sessions" features.
173
+ */
174
+ metadata?: unknown;
175
+ /**
176
+ * Application props encrypted into the issued access token and exposed to
177
+ * API handlers on every authenticated request (via `getMcpAuthContext()`
178
+ * for MCP servers, or `ctx.props` in the underlying OAuth helpers). Use
179
+ * for per-request data the protected resource needs without hitting the
180
+ * IdP again — e.g. `enterprise: true`, subject, email, role claims.
181
+ */
182
+ props: unknown;
183
+ /**
184
+ * Optional access token TTL override in seconds. Overrides the AS's
185
+ * configured default. Not clamped to the assertion lifetime — the ID-JAG
186
+ * `exp` governs how long the assertion remains a usable grant, not the
187
+ * lifetime of the access token it mints (RFC 7523 §3).
188
+ */
189
+ accessTokenTTL?: number;
190
+ }
191
+ /**
192
+ * Maps validated enterprise ID-JAG claims to this provider's local user, scopes,
193
+ * metadata, and props. Return `null` to deny token issuance.
194
+ *
195
+ * Always async — mirrors `EmaTrustedIssuerResolver` so the two enterprise
196
+ * callbacks have the same shape and downstream lookups (KV, D1, IdP federation)
197
+ * don't require a later API change to add `Promise<…>` return support.
198
+ */
199
+ type EmaClaimsMapper<Env = Cloudflare.Env> = (input: EmaClaimsMapperInput<Env>) => Promise<EmaClaimsMapperResult | null>;
200
+ /**
201
+ * Input passed to a dynamic `EmaTrustedIssuerResolver`.
202
+ *
203
+ * The `iss` value comes from the assertion's unverified payload — it is
204
+ * used here only as a routing key to choose which IdP's JWKS to load.
205
+ * The cryptographic trust comes from signature verification later, so
206
+ * the resolver may safely treat `iss` as untrusted input.
207
+ */
208
+ interface EmaTrustedIssuerResolverInput<Env = Cloudflare.Env> {
209
+ /** Issuer URL from the assertion's `iss` claim (unverified). */
210
+ iss: string;
211
+ /** Cloudflare Worker environment variables (bindings, secrets, KV, D1). */
212
+ env: Env;
213
+ /** The original HTTP request, e.g. for inspecting the Host header in multi-tenant routing. */
214
+ request: Request;
215
+ /** Authenticated OAuth client that presented the assertion. */
216
+ clientInfo: ClientInfo;
217
+ }
218
+ /**
219
+ * Dynamic resolver for `EmaOptions.trustedIssuers`.
220
+ *
221
+ * Returns the trusted issuer configuration for an incoming `iss` claim,
222
+ * or `null` if the issuer is not trusted for this client / tenant. The
223
+ * returned `issuer` field must equal the input `iss` — the AS enforces
224
+ * this to prevent a resolver from being tricked into returning a config
225
+ * for a different IdP than the one the assertion claims to be from.
226
+ *
227
+ * Useful for B2B platforms where new tenants onboard their own IdPs
228
+ * dynamically and the AS cannot ship a static list at deploy time.
229
+ *
230
+ * **SSRF warning**: `jwksUri` is fetched outbound by the AS. If your
231
+ * resolver reads `jwksUri` from tenant-controlled storage (self-service
232
+ * tenant onboarding, etc.), an attacker who controls a tenant config
233
+ * can point the AS at arbitrary HTTPS endpoints — internal services,
234
+ * cloud metadata endpoints, victim hosts for DoS amplification. The
235
+ * library only enforces `https:`; deployers must validate `jwksUri`
236
+ * against their own allowlist (e.g. registered IdP vendor domains)
237
+ * before storing or returning it.
238
+ */
239
+ type EmaTrustedIssuerResolver<Env = Cloudflare.Env> = (input: EmaTrustedIssuerResolverInput<Env>) => Promise<EmaTrustedIssuer | null>;
240
+ /**
241
+ * MCP Enterprise-Managed Authorization configuration.
242
+ *
243
+ * Presence of this option on `OAuthProviderOptions` enables the EMA grant.
244
+ * There is intentionally no `enabled` flag — forgetting to set it would
245
+ * silently disable the feature despite full configuration.
246
+ */
247
+ interface EmaOptions<Env = Cloudflare.Env> {
248
+ /**
249
+ * Resolver that returns the trusted issuer configuration for an
250
+ * incoming `iss` claim, or `null` to reject the assertion.
251
+ *
252
+ * Always a function — for a fixed list of IdPs, write a one-line closure:
253
+ *
254
+ * ```ts
255
+ * const issuers = [{ issuer: 'https://idp.example.com', jwksUri: '...' }];
256
+ * trustedIssuers: async ({ iss }) => issuers.find((i) => i.issuer === iss) ?? null,
257
+ * ```
258
+ *
259
+ * For B2B / multi-tenant deployments, the resolver can consult `env`,
260
+ * `request`, or `clientInfo` to look up the issuer dynamically (e.g.
261
+ * a per-tenant config in KV / D1) without redeploying.
262
+ */
263
+ trustedIssuers: EmaTrustedIssuerResolver<Env>;
264
+ /** Maps validated enterprise claims to local token data. */
265
+ mapClaims: EmaClaimsMapper<Env>;
266
+ /** JWKS cache TTL in seconds. Defaults to 300 seconds. */
267
+ jwksCacheTtlSeconds?: number;
268
+ /** Allowed clock skew for `exp` and `iat` checks in seconds. Defaults to 60 seconds. */
269
+ clockSkewSeconds?: number;
270
+ /** Maximum accepted assertion lifetime in seconds. Defaults to 300 seconds. */
271
+ maxAssertionLifetimeSeconds?: number;
272
+ }
273
+ //#endregion
274
+ //#region src/oauth-provider.d.ts
5
275
  /**
6
276
  * Enum representing OAuth grant types
7
277
  */
@@ -9,6 +279,7 @@ declare enum GrantType {
9
279
  AUTHORIZATION_CODE = "authorization_code",
10
280
  REFRESH_TOKEN = "refresh_token",
11
281
  TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange",
282
+ JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer",
12
283
  }
13
284
  /**
14
285
  * Aliases for either type of Handler that makes .fetch required
@@ -84,6 +355,14 @@ interface TokenExchangeCallbackOptions {
84
355
  * User who authorized this grant
85
356
  */
86
357
  userId: string;
358
+ /**
359
+ * Identifier of the grant record this callback is operating on. Stable across
360
+ * refreshes for the lifetime of the grant. Pass this together with `userId`
361
+ * to {@link OAuthHelpers.revokeGrant} when the callback decides the grant
362
+ * should be torn down (for example, after an upstream refresh fails with a
363
+ * terminal error code).
364
+ */
365
+ grantId: string;
87
366
  /**
88
367
  * List of scopes that were granted
89
368
  */
@@ -227,6 +506,15 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
227
506
  * Defaults to false.
228
507
  */
229
508
  allowTokenExchangeGrant?: boolean;
509
+ /**
510
+ * Experimental support for the MCP Enterprise-Managed Authorization extension.
511
+ * When enabled, the token endpoint accepts ID-JAG assertions using the JWT bearer
512
+ * grant type (`urn:ietf:params:oauth:grant-type:jwt-bearer`).
513
+ *
514
+ * This feature is opt-in because the MCP extension and underlying OAuth drafts are
515
+ * still evolving. Trusted issuers and a claim mapper are required.
516
+ */
517
+ enterpriseManagedAuthorization?: EmaOptions<Env>;
230
518
  /**
231
519
  * Controls whether public clients (clients without a secret, like SPAs) can register via the
232
520
  * dynamic client registration endpoint. When true, only confidential clients can register.
@@ -255,16 +543,26 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
255
543
  */
256
544
  resolveExternalToken?: (input: ResolveExternalTokenInput) => Promise<ResolveExternalTokenResult | null>;
257
545
  /**
258
- * Optional callback function that is called whenever the OAuthProvider returns an error response
546
+ * Optional callback function that is called whenever the OAuthProvider returns an error response.
259
547
  * This allows the client to emit notifications or perform other actions when an error occurs.
260
548
  *
261
549
  * If the function returns a Response, that will be used in place of the OAuthProvider's default one.
550
+ *
551
+ * `internal` (when present) carries a tagged, server-side-only reason that the library
552
+ * deliberately did NOT put on the wire — used for richer diagnostics where the public
553
+ * response must stay generic (e.g. JWT validation failures on the EMA path). Backwards
554
+ * compatible: existing callbacks ignoring this field continue to work unchanged.
262
555
  */
263
556
  onError?: (error: {
264
557
  code: string;
265
558
  description: string;
266
559
  status: number;
267
560
  headers: Record<string, string>;
561
+ internal?: {
562
+ category: string;
563
+ reason: string;
564
+ detail?: unknown;
565
+ };
268
566
  }) => Response | void;
269
567
  /**
270
568
  * Explicitly enable Client ID Metadata Document (CIMD) support.
@@ -980,5 +1278,33 @@ declare class OAuthError extends Error {
980
1278
  readonly headers?: Record<string, string>;
981
1279
  constructor(code: string, options: OAuthErrorOptions);
982
1280
  }
1281
+ /**
1282
+ * Validates a resource URI per RFC 8707 Section 2
1283
+ * @param uri - The URI string to validate
1284
+ * @returns true if valid, false otherwise
1285
+ */
1286
+ declare function validateResourceUri(uri: string): boolean;
1287
+ /**
1288
+ * Checks if a requested resource matches a granted resource.
1289
+ * When originOnly is true, compares only the origin (scheme + host + port),
1290
+ * allowing path-aware resources to match origin-only grants.
1291
+ */
1292
+ declare function resourceMatches(requested: string, granted: string, originOnly: boolean): boolean;
1293
+ /**
1294
+ * Decodes a base64url-encoded string to bytes.
1295
+ */
1296
+ declare function base64UrlToBytes(base64Url: string): Uint8Array;
1297
+ /**
1298
+ * Parses a base64url-encoded JWT JSON part into an object.
1299
+ */
1300
+ declare function parseJwtJsonPart(encoded: string): Record<string, unknown>;
1301
+ declare function isValidOAuthScopeToken(scopeToken: string): boolean;
1302
+ /**
1303
+ * Gets WebCrypto import and verify parameters for supported JOSE algorithms.
1304
+ */
1305
+ declare function getJwtCryptoAlgorithms(alg: string): {
1306
+ importAlgorithm: Parameters<SubtleCrypto['importKey']>[2];
1307
+ verifyAlgorithm: Parameters<SubtleCrypto['verify']>[0];
1308
+ };
983
1309
  //#endregion
984
- export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, ExchangeTokenOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthError, OAuthErrorOptions, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, OAuthTokenErrorCode, PurgeOptions, PurgeResult, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, getOAuthApi };
1310
+ export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, type EmaClaimsMapper, type EmaClaimsMapperInput, type EmaClaimsMapperResult, type EmaIdJagClaims, type EmaOptions, type EmaTrustedIssuer, type EmaTrustedIssuerResolver, type EmaTrustedIssuerResolverInput, type EmaValidationError, ExchangeTokenOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthError, OAuthErrorOptions, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, OAuthTokenErrorCode, PurgeOptions, PurgeResult, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, base64UrlToBytes, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };