@cloudflare/workers-oauth-provider 0.10.1 → 0.10.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 +15 -9
- package/dist/oauth-provider.d.ts +14 -0
- package/dist/oauth-provider.js +479 -279
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -248,6 +248,8 @@ A typical flow has three steps:
|
|
|
248
248
|
|
|
249
249
|
`completeAuthorization()` stores a new grant and, by default, revokes existing grants for the same user and client after the new grant is safely stored. Set `revokeExistingGrants: false` only when the application intentionally allows concurrent grants for the same user and client.
|
|
250
250
|
|
|
251
|
+
For Client ID Metadata Document clients, whose client_id is the metadata URL shared by every installation, default revocation is additionally scoped to grants created from the same redirect URI, so one installation's re-authorization does not revoke another's. Grants created before the redirect URI was recorded are never auto-revoked by CIMD clients.
|
|
252
|
+
|
|
251
253
|
For users with many grants, `revokeExistingGrantsBatchSize` controls the KV page size used during that scan. It defaults to `50` and is capped at KV's maximum page size of `1000`.
|
|
252
254
|
|
|
253
255
|
### Authorization response issuer
|
|
@@ -294,18 +296,21 @@ new OAuthProvider({
|
|
|
294
296
|
}
|
|
295
297
|
```
|
|
296
298
|
|
|
297
|
-
The compatibility flag prevents outbound CIMD fetches from using legacy same-zone origin routing, which is necessary for SSRF protection. The provider advertises `client_id_metadata_document_supported: true` only when both settings are present.
|
|
299
|
+
The compatibility flag prevents outbound CIMD fetches from using legacy same-zone origin routing, which is necessary for SSRF protection. The provider advertises `client_id_metadata_document_supported: true` only when both settings are present. CIMD fetches also use the `cache` option of `fetch`, which requires a compatibility date of `2024-11-11` or later (or the `cache_option_enabled` compatibility flag).
|
|
298
300
|
|
|
299
|
-
CIMD validation includes:
|
|
301
|
+
CIMD validation follows [draft-ietf-oauth-client-id-metadata-document-00](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00) — the revision pinned by the MCP 2026-07-28 authorization spec — and includes:
|
|
300
302
|
|
|
301
|
-
- HTTPS URL with a
|
|
303
|
+
- An HTTPS Client Identifier URL with a path component and no userinfo, fragment, or dot path segments.
|
|
302
304
|
- A document `client_id` exactly matching its URL.
|
|
303
|
-
- Non-empty `client_name` and `redirect_uris` fields.
|
|
305
|
+
- Non-empty `client_name` and `redirect_uris` fields, as MCP requires, with unsafe redirect schemes rejected at ingestion.
|
|
304
306
|
- Exact authorization-request redirect URI validation, with RFC 8252 loopback port handling.
|
|
305
|
-
- A 5 KB response size limit and 10 second
|
|
306
|
-
-
|
|
307
|
+
- A 5 KB response size limit and a 10 second timeout covering both headers and body.
|
|
308
|
+
- Valid UTF-8 JSON object syntax and safe URI schemes for client metadata fields.
|
|
309
|
+
- No embedded client secrets or private JWK material.
|
|
310
|
+
|
|
311
|
+
Validated documents are cached according to their `Cache-Control` headers, capped at 7 days. Error responses and invalid documents are never cached, and a cached document that stops validating is evicted and re-resolved from origin within the same request.
|
|
307
312
|
|
|
308
|
-
CIMD currently
|
|
313
|
+
CIMD token endpoint authentication is negotiated from `token_endpoint_auth_method` and the OpenID RP Metadata Choices field `token_endpoint_auth_methods_supported`. The provider currently implements only `none`: a client may prefer `private_key_jwt` while also offering `none`, in which case the provider selects `none` and applies public-client PKCE requirements. A client that offers only `private_key_jwt` is rejected until assertion validation is implemented.
|
|
309
314
|
|
|
310
315
|
When a CIMD document cannot be fetched or validated, the token endpoint returns a generic `invalid_client` response and reports diagnostics through `onError.internal`. `OAuthHelpers` methods that resolve a CIMD client throw the exported `CimdFetchError`, allowing applications to distinguish an upstream metadata failure from a client that does not exist. See [Advanced configuration](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/advanced-configuration.md#cimd-fetch-errors) for an example.
|
|
311
316
|
|
|
@@ -319,9 +324,9 @@ clientRegistrationEndpoint: '/oauth/register';
|
|
|
319
324
|
|
|
320
325
|
MCP 2026-07-28 deprecates DCR for new implementations in favor of CIMD. The endpoint remains useful for compatibility with clients that do not support CIMD.
|
|
321
326
|
|
|
322
|
-
Registration accepts only authentication methods, grants, and response types implemented by the configured provider, and rejects inconsistent grant/response combinations before storage. Omitted metadata uses the RFC 7591 defaults: `client_secret_basic`, `grant_types: ["authorization_code"]`, and `response_types: ["code"]`.
|
|
327
|
+
Registration accepts only authentication methods, grants, and response types implemented by the configured provider, and rejects inconsistent grant/response combinations before storage. Choice-valued `token_endpoint_auth_methods_supported` input is negotiated to one effective `token_endpoint_auth_method`; grant and response registrations remain strict. Omitted metadata uses the RFC 7591 defaults: `client_secret_basic`, `grant_types: ["authorization_code"]`, and `response_types: ["code"]`.
|
|
323
328
|
|
|
324
|
-
|
|
329
|
+
The effective `token_endpoint_auth_method` returned by registration is enforced exactly. When both authentication metadata fields are omitted, no explicit-method marker is stored and the client may use either `client_secret_basic` or `client_secret_post`, provided the same stored secret validates. Client records written by earlier releases have no marker and receive the same compatibility. This never crosses between `none` and a secret method and does not apply to CIMD clients.
|
|
325
330
|
|
|
326
331
|
Calling `OAuthHelpers.updateClient()` with `tokenEndpointAuthMethod` adds the marker; unrelated updates leave it unchanged.
|
|
327
332
|
|
|
@@ -465,6 +470,7 @@ The package implements or supports the relevant portions of:
|
|
|
465
470
|
- [OAuth 2.0 Authorization Server Issuer Identification, RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207)
|
|
466
471
|
- [OAuth 2.0 Protected Resource Metadata, RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)
|
|
467
472
|
- [OAuth Client ID Metadata Documents](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00)
|
|
473
|
+
- [OpenID Connect RP Metadata Choices 1.0](https://openid.net/specs/openid-connect-rp-metadata-choices-1_0-final.html)
|
|
468
474
|
- [MCP Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization), with experimental package support
|
|
469
475
|
|
|
470
476
|
## Development
|
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -1103,6 +1103,13 @@ interface Grant {
|
|
|
1103
1103
|
* Indicates the protected resource(s) for which access is requested
|
|
1104
1104
|
*/
|
|
1105
1105
|
resource?: string | string[];
|
|
1106
|
+
/**
|
|
1107
|
+
* The exact redirect URI used in the authorization request that created this grant
|
|
1108
|
+
* Recorded so that default grant revocation can be scoped to a single installation
|
|
1109
|
+
* of a CIMD client (whose client_id is shared across all installations). Absent on
|
|
1110
|
+
* grants created before this field was introduced.
|
|
1111
|
+
*/
|
|
1112
|
+
redirectUri?: string;
|
|
1106
1113
|
}
|
|
1107
1114
|
/**
|
|
1108
1115
|
* OAuth 2.0 Token Response
|
|
@@ -1314,6 +1321,13 @@ interface GrantSummary {
|
|
|
1314
1321
|
* Unix timestamp when the grant expires (if TTL is configured)
|
|
1315
1322
|
*/
|
|
1316
1323
|
expiresAt?: number;
|
|
1324
|
+
/**
|
|
1325
|
+
* The exact redirect URI used in the authorization request that created this grant
|
|
1326
|
+
* Recorded so that default grant revocation can be scoped to a single installation
|
|
1327
|
+
* of a CIMD client (whose client_id is shared across all installations). Absent on
|
|
1328
|
+
* grants created before this field was introduced.
|
|
1329
|
+
*/
|
|
1330
|
+
redirectUri?: string;
|
|
1317
1331
|
}
|
|
1318
1332
|
/**
|
|
1319
1333
|
* OAuth 2.0 Provider implementation for Cloudflare Workers
|
package/dist/oauth-provider.js
CHANGED
|
@@ -53,6 +53,49 @@ function validateClientCapabilities(server, client) {
|
|
|
53
53
|
if (client.grantTypes.includes("authorization_code") !== client.responseTypes.includes("code")) throw new Error("grant_types authorization_code and response_types code must be registered together");
|
|
54
54
|
if (client.grantTypes.includes("implicit") !== client.responseTypes.includes("token")) throw new Error("grant_types implicit and response_types token must be registered together");
|
|
55
55
|
}
|
|
56
|
+
const SHARED_SECRET_TOKEN_ENDPOINT_AUTH_METHODS = new Set([
|
|
57
|
+
"client_secret_basic",
|
|
58
|
+
"client_secret_post",
|
|
59
|
+
"client_secret_jwt"
|
|
60
|
+
]);
|
|
61
|
+
function negotiateTokenEndpointAuthMethod(options) {
|
|
62
|
+
const { acceptedMethods, defaultMethod, preferredMethod, supportedMethods, context } = options;
|
|
63
|
+
if (preferredMethod !== void 0 && supportedMethods !== void 0 && !supportedMethods.includes(preferredMethod)) throw new Error("token_endpoint_auth_method must be included in token_endpoint_auth_methods_supported");
|
|
64
|
+
const advertisedMethods = supportedMethods ?? [preferredMethod ?? defaultMethod];
|
|
65
|
+
const effectiveMethod = preferredMethod !== void 0 && acceptedMethods.includes(preferredMethod) ? preferredMethod : acceptedMethods.find((method) => advertisedMethods.includes(method));
|
|
66
|
+
if (effectiveMethod !== void 0) return effectiveMethod;
|
|
67
|
+
const advertised = [...new Set([...preferredMethod === void 0 ? [] : [preferredMethod], ...advertisedMethods])];
|
|
68
|
+
throw new Error(`${context} does not support an accepted token endpoint authentication method. Supported methods: ${acceptedMethods.join(", ")}. Client advertised: ${advertised.length > 0 ? advertised.join(", ") : "(none)"}`);
|
|
69
|
+
}
|
|
70
|
+
function negotiateCimdTokenEndpointAuthMethod(server, preferredMethod, supportedMethods) {
|
|
71
|
+
if (preferredMethod !== void 0 && SHARED_SECRET_TOKEN_ENDPOINT_AUTH_METHODS.has(preferredMethod)) throw new Error(`CIMD clients cannot use symmetric token endpoint authentication method: ${preferredMethod}`);
|
|
72
|
+
return negotiateTokenEndpointAuthMethod({
|
|
73
|
+
acceptedMethods: server.tokenEndpointAuthMethods.filter((method) => !SHARED_SECRET_TOKEN_ENDPOINT_AUTH_METHODS.has(method)),
|
|
74
|
+
defaultMethod: "none",
|
|
75
|
+
preferredMethod,
|
|
76
|
+
supportedMethods,
|
|
77
|
+
context: "CIMD client"
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Negotiates choice-valued authentication metadata while keeping DCR grant and
|
|
82
|
+
* response registration strict.
|
|
83
|
+
*/
|
|
84
|
+
function negotiateDynamicClientRegistrationCapabilities(server, client) {
|
|
85
|
+
const effective = {
|
|
86
|
+
grantTypes: [...client.grantTypes],
|
|
87
|
+
responseTypes: [...client.responseTypes],
|
|
88
|
+
tokenEndpointAuthMethod: negotiateTokenEndpointAuthMethod({
|
|
89
|
+
acceptedMethods: server.tokenEndpointAuthMethods,
|
|
90
|
+
defaultMethod: "client_secret_basic",
|
|
91
|
+
preferredMethod: client.tokenEndpointAuthMethod,
|
|
92
|
+
supportedMethods: client.tokenEndpointAuthMethodsSupported,
|
|
93
|
+
context: "Client"
|
|
94
|
+
})
|
|
95
|
+
};
|
|
96
|
+
validateClientCapabilities(server, effective);
|
|
97
|
+
return effective;
|
|
98
|
+
}
|
|
56
99
|
/**
|
|
57
100
|
* Selects the capabilities from a Client ID Metadata Document that this
|
|
58
101
|
* authorization server supports. CIMD documents may advertise extension
|
|
@@ -67,7 +110,7 @@ function negotiateCimdClientCapabilities(server, client) {
|
|
|
67
110
|
const effective = {
|
|
68
111
|
grantTypes: client.grantTypes.filter((grantType) => server.grantTypes.includes(grantType)),
|
|
69
112
|
responseTypes: client.responseTypes.filter((responseType) => server.responseTypes.includes(responseType)),
|
|
70
|
-
tokenEndpointAuthMethod: client.tokenEndpointAuthMethod
|
|
113
|
+
tokenEndpointAuthMethod: negotiateCimdTokenEndpointAuthMethod(server, client.tokenEndpointAuthMethod, client.tokenEndpointAuthMethodsSupported)
|
|
71
114
|
};
|
|
72
115
|
validateClientCapabilities(server, effective);
|
|
73
116
|
return effective;
|
|
@@ -107,6 +150,403 @@ function isValidOAuthScopeToken(scopeToken) {
|
|
|
107
150
|
return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
|
|
108
151
|
}
|
|
109
152
|
|
|
153
|
+
//#endregion
|
|
154
|
+
//#region src/oauth-client-metadata.ts
|
|
155
|
+
const CIMD_MAX_SIZE_BYTES = 5 * 1024;
|
|
156
|
+
const CIMD_FETCH_TIMEOUT_MS = 1e4;
|
|
157
|
+
const CIMD_CACHE_NAME = "workers-oauth-provider:cimd:v1";
|
|
158
|
+
const CIMD_CACHE_MAX_TTL_SECONDS = 10080 * 60;
|
|
159
|
+
function requireJsonObject(value) {
|
|
160
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Client metadata must be a JSON object");
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
function optionalString(value, fieldName) {
|
|
164
|
+
if (value === void 0) return void 0;
|
|
165
|
+
if (typeof value !== "string") throw new Error(`Invalid ${fieldName}: expected string, got ${typeof value}`);
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
function optionalStringArray(value, fieldName) {
|
|
169
|
+
if (value === void 0) return void 0;
|
|
170
|
+
if (!Array.isArray(value)) throw new Error(`Invalid ${fieldName}: expected array, got ${typeof value}`);
|
|
171
|
+
if (!value.every((item) => typeof item === "string")) throw new Error(`Invalid ${fieldName}: array must contain only strings`);
|
|
172
|
+
return [...value];
|
|
173
|
+
}
|
|
174
|
+
function optionalHttpUri(value, fieldName) {
|
|
175
|
+
const uri = optionalString(value, fieldName);
|
|
176
|
+
if (uri === void 0) return void 0;
|
|
177
|
+
let parsed;
|
|
178
|
+
try {
|
|
179
|
+
parsed = new URL(uri);
|
|
180
|
+
} catch {
|
|
181
|
+
throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
|
|
182
|
+
}
|
|
183
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
|
|
184
|
+
return uri;
|
|
185
|
+
}
|
|
186
|
+
const I18N_FIELDS = {
|
|
187
|
+
client_name: "string",
|
|
188
|
+
client_uri: "uri",
|
|
189
|
+
logo_uri: "uri",
|
|
190
|
+
tos_uri: "uri",
|
|
191
|
+
policy_uri: "uri"
|
|
192
|
+
};
|
|
193
|
+
function extractI18nFields(raw) {
|
|
194
|
+
const result = {};
|
|
195
|
+
for (const key of Object.keys(raw)) {
|
|
196
|
+
const hashIndex = key.indexOf("#");
|
|
197
|
+
if (hashIndex <= 0 || hashIndex === key.length - 1) continue;
|
|
198
|
+
const kind = I18N_FIELDS[key.slice(0, hashIndex)];
|
|
199
|
+
if (!kind) continue;
|
|
200
|
+
const value = kind === "uri" ? optionalHttpUri(raw[key], key) : optionalString(raw[key], key);
|
|
201
|
+
if (value !== void 0) result[key] = value;
|
|
202
|
+
}
|
|
203
|
+
return Object.keys(result).length > 0 ? result : void 0;
|
|
204
|
+
}
|
|
205
|
+
function validateChoiceConsistency(preferredName, preferredValue, choicesName, choices) {
|
|
206
|
+
if (preferredValue !== void 0 && choices !== void 0 && !choices.includes(preferredValue)) throw new Error(`${preferredName} must be included in ${choicesName}`);
|
|
207
|
+
}
|
|
208
|
+
function parseOAuthClientMetadata(raw) {
|
|
209
|
+
const tokenEndpointAuthMethod = optionalString(raw.token_endpoint_auth_method, "token_endpoint_auth_method");
|
|
210
|
+
const tokenEndpointAuthMethodsSupported = optionalStringArray(raw.token_endpoint_auth_methods_supported, "token_endpoint_auth_methods_supported");
|
|
211
|
+
const tokenEndpointAuthSigningAlg = optionalString(raw.token_endpoint_auth_signing_alg, "token_endpoint_auth_signing_alg");
|
|
212
|
+
const tokenEndpointAuthSigningAlgValuesSupported = optionalStringArray(raw.token_endpoint_auth_signing_alg_values_supported, "token_endpoint_auth_signing_alg_values_supported");
|
|
213
|
+
validateChoiceConsistency("token_endpoint_auth_method", tokenEndpointAuthMethod, "token_endpoint_auth_methods_supported", tokenEndpointAuthMethodsSupported);
|
|
214
|
+
validateChoiceConsistency("token_endpoint_auth_signing_alg", tokenEndpointAuthSigningAlg, "token_endpoint_auth_signing_alg_values_supported", tokenEndpointAuthSigningAlgValuesSupported);
|
|
215
|
+
return {
|
|
216
|
+
clientId: optionalString(raw.client_id, "client_id"),
|
|
217
|
+
redirectUris: optionalStringArray(raw.redirect_uris, "redirect_uris"),
|
|
218
|
+
clientName: optionalString(raw.client_name, "client_name"),
|
|
219
|
+
clientUri: optionalHttpUri(raw.client_uri, "client_uri"),
|
|
220
|
+
logoUri: optionalHttpUri(raw.logo_uri, "logo_uri"),
|
|
221
|
+
policyUri: optionalHttpUri(raw.policy_uri, "policy_uri"),
|
|
222
|
+
tosUri: optionalHttpUri(raw.tos_uri, "tos_uri"),
|
|
223
|
+
jwksUri: optionalHttpUri(raw.jwks_uri, "jwks_uri"),
|
|
224
|
+
i18n: extractI18nFields(raw),
|
|
225
|
+
contacts: optionalStringArray(raw.contacts, "contacts"),
|
|
226
|
+
grantTypes: optionalStringArray(raw.grant_types, "grant_types"),
|
|
227
|
+
responseTypes: optionalStringArray(raw.response_types, "response_types"),
|
|
228
|
+
tokenEndpointAuthMethod,
|
|
229
|
+
tokenEndpointAuthMethodsSupported,
|
|
230
|
+
tokenEndpointAuthSigningAlg,
|
|
231
|
+
tokenEndpointAuthSigningAlgValuesSupported
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function pickDisplayMetadata(metadata) {
|
|
235
|
+
const { clientName, clientUri, logoUri, policyUri, tosUri, jwksUri, i18n, contacts } = metadata;
|
|
236
|
+
return {
|
|
237
|
+
clientName,
|
|
238
|
+
clientUri,
|
|
239
|
+
logoUri,
|
|
240
|
+
policyUri,
|
|
241
|
+
tosUri,
|
|
242
|
+
jwksUri,
|
|
243
|
+
i18n,
|
|
244
|
+
contacts
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Validates that a redirect URI has a scheme and does not use a dangerous
|
|
249
|
+
* pseudo-scheme or contain control characters.
|
|
250
|
+
*/
|
|
251
|
+
function validateRedirectUriScheme(redirectUri) {
|
|
252
|
+
const dangerousSchemes = [
|
|
253
|
+
"javascript:",
|
|
254
|
+
"data:",
|
|
255
|
+
"vbscript:",
|
|
256
|
+
"file:",
|
|
257
|
+
"mailto:",
|
|
258
|
+
"blob:"
|
|
259
|
+
];
|
|
260
|
+
const normalized = redirectUri.trim();
|
|
261
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
262
|
+
const code = normalized.charCodeAt(i);
|
|
263
|
+
if (code >= 0 && code <= 31 || code >= 127 && code <= 159) throw new Error("Invalid redirect URI");
|
|
264
|
+
}
|
|
265
|
+
const colonIndex = normalized.indexOf(":");
|
|
266
|
+
if (colonIndex === -1) throw new Error("Invalid redirect URI");
|
|
267
|
+
const scheme = normalized.slice(0, colonIndex + 1).toLowerCase();
|
|
268
|
+
if (dangerousSchemes.includes(scheme)) throw new Error("Invalid redirect URI");
|
|
269
|
+
}
|
|
270
|
+
function requireValidRedirectUris(redirectUris) {
|
|
271
|
+
if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
|
|
272
|
+
for (const redirectUri of redirectUris) validateRedirectUriScheme(redirectUri);
|
|
273
|
+
return redirectUris;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Resolves a Dynamic Client Registration request into the complete metadata
|
|
277
|
+
* shape that can be stored, applying RFC 7591 defaults and server capability
|
|
278
|
+
* validation exactly once.
|
|
279
|
+
*/
|
|
280
|
+
function resolveDynamicClientRegistrationMetadata(raw, server) {
|
|
281
|
+
const metadata = parseOAuthClientMetadata(raw);
|
|
282
|
+
const capabilities = negotiateDynamicClientRegistrationCapabilities(server, {
|
|
283
|
+
tokenEndpointAuthMethod: metadata.tokenEndpointAuthMethod,
|
|
284
|
+
tokenEndpointAuthMethodsSupported: metadata.tokenEndpointAuthMethodsSupported,
|
|
285
|
+
grantTypes: metadata.grantTypes ?? ["authorization_code"],
|
|
286
|
+
responseTypes: metadata.responseTypes ?? ["code"]
|
|
287
|
+
});
|
|
288
|
+
return {
|
|
289
|
+
...pickDisplayMetadata(metadata),
|
|
290
|
+
redirectUris: requireValidRedirectUris(metadata.redirectUris),
|
|
291
|
+
...capabilities,
|
|
292
|
+
authMethodExplicit: metadata.tokenEndpointAuthMethod !== void 0 || metadata.tokenEndpointAuthMethodsSupported !== void 0
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Path component of the raw client ID string. Deliberately not
|
|
297
|
+
* `new URL().pathname`: WHATWG parsing collapses the `.` and `..` segments
|
|
298
|
+
* that CIMD §3 requires rejecting, and normalizes `\` and scheme-relative
|
|
299
|
+
* forms this validation must see verbatim.
|
|
300
|
+
*/
|
|
301
|
+
function rawPath(clientId) {
|
|
302
|
+
const schemeEnd = clientId.indexOf("://");
|
|
303
|
+
if (schemeEnd === -1) return "";
|
|
304
|
+
const authorityStart = schemeEnd + 3;
|
|
305
|
+
const authorityEndOffset = clientId.slice(authorityStart).search(/[/?#]/);
|
|
306
|
+
if (authorityEndOffset === -1) return "";
|
|
307
|
+
const pathStart = authorityStart + authorityEndOffset;
|
|
308
|
+
if (clientId[pathStart] !== "/") return "";
|
|
309
|
+
const pathEndOffset = clientId.slice(pathStart).search(/[?#]/);
|
|
310
|
+
return pathEndOffset === -1 ? clientId.slice(pathStart) : clientId.slice(pathStart, pathStart + pathEndOffset);
|
|
311
|
+
}
|
|
312
|
+
function validateClientIdentifierUrl(clientId) {
|
|
313
|
+
if (clientId !== clientId.trim() || /[\x00-\x20\x7f-\x9f\\]/.test(clientId)) throw new Error("Client Identifier URL contains invalid whitespace or characters");
|
|
314
|
+
if (!/^https:\/\//i.test(clientId)) throw new Error("Client Identifier URL must use an absolute HTTPS URL");
|
|
315
|
+
let parsed;
|
|
316
|
+
try {
|
|
317
|
+
parsed = new URL(clientId);
|
|
318
|
+
} catch {
|
|
319
|
+
throw new Error("Client Identifier URL must be a valid HTTPS URL");
|
|
320
|
+
}
|
|
321
|
+
if (parsed.protocol !== "https:") throw new Error("Client Identifier URL must use HTTPS");
|
|
322
|
+
if (parsed.username || parsed.password) throw new Error("Client Identifier URL must not contain userinfo");
|
|
323
|
+
if (parsed.hash) throw new Error("Client Identifier URL must not contain a fragment");
|
|
324
|
+
const path = rawPath(clientId);
|
|
325
|
+
if (!path) throw new Error("Client Identifier URL must contain a path component");
|
|
326
|
+
for (const segment of path.split("/")) {
|
|
327
|
+
let decodedSegment;
|
|
328
|
+
try {
|
|
329
|
+
decodedSegment = decodeURIComponent(segment);
|
|
330
|
+
} catch {
|
|
331
|
+
throw new Error("Client Identifier URL contains invalid percent encoding");
|
|
332
|
+
}
|
|
333
|
+
if (decodedSegment === "." || decodedSegment === "..") throw new Error("Client Identifier URL must not contain dot path segments");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** Returns whether a client ID has the URL shape this package resolves through CIMD. */
|
|
337
|
+
function isClientIdMetadataDocumentUrl(clientId) {
|
|
338
|
+
try {
|
|
339
|
+
return new URL(clientId).protocol === "https:" && rawPath(clientId) !== "";
|
|
340
|
+
} catch {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function containsPrivateJwkMaterial(value) {
|
|
345
|
+
if (value === void 0) return false;
|
|
346
|
+
const jwks = requireJsonObject(value);
|
|
347
|
+
if (!Array.isArray(jwks.keys)) throw new Error("Invalid jwks: keys must be an array");
|
|
348
|
+
const privateMembers = new Set([
|
|
349
|
+
"d",
|
|
350
|
+
"p",
|
|
351
|
+
"q",
|
|
352
|
+
"dp",
|
|
353
|
+
"dq",
|
|
354
|
+
"qi",
|
|
355
|
+
"oth",
|
|
356
|
+
"k"
|
|
357
|
+
]);
|
|
358
|
+
for (const value$1 of jwks.keys) {
|
|
359
|
+
const key = requireJsonObject(value$1);
|
|
360
|
+
if (Object.keys(key).some((member) => privateMembers.has(member))) return true;
|
|
361
|
+
}
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
function resolveClientIdMetadataDocument(metadataUrl, value, server) {
|
|
365
|
+
const raw = requireJsonObject(value);
|
|
366
|
+
const metadata = parseOAuthClientMetadata(raw);
|
|
367
|
+
if (metadata.clientId !== metadataUrl) throw new Error(`client_id "${metadata.clientId}" does not match metadata URL "${metadataUrl}"`);
|
|
368
|
+
if (!metadata.clientName?.trim()) throw new Error("client_name is required and must not be empty");
|
|
369
|
+
const redirectUris = requireValidRedirectUris(metadata.redirectUris);
|
|
370
|
+
if ("client_secret" in raw || "client_secret_expires_at" in raw) throw new Error("CIMD documents must not contain client secrets");
|
|
371
|
+
if (containsPrivateJwkMaterial(raw.jwks)) throw new Error("CIMD documents must not contain private key material");
|
|
372
|
+
const capabilities = negotiateCimdClientCapabilities(server, {
|
|
373
|
+
tokenEndpointAuthMethod: metadata.tokenEndpointAuthMethod,
|
|
374
|
+
tokenEndpointAuthMethodsSupported: metadata.tokenEndpointAuthMethodsSupported,
|
|
375
|
+
grantTypes: metadata.grantTypes ?? ["authorization_code"],
|
|
376
|
+
responseTypes: metadata.responseTypes ?? ["code"]
|
|
377
|
+
});
|
|
378
|
+
return {
|
|
379
|
+
...pickDisplayMetadata(metadata),
|
|
380
|
+
clientId: metadata.clientId,
|
|
381
|
+
clientName: metadata.clientName,
|
|
382
|
+
redirectUris,
|
|
383
|
+
...capabilities
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
function readStreamChunk(reader, signal) {
|
|
387
|
+
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
|
388
|
+
return new Promise((resolve, reject) => {
|
|
389
|
+
const abort = () => {
|
|
390
|
+
reader.cancel().catch(() => void 0);
|
|
391
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
392
|
+
};
|
|
393
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
394
|
+
reader.read().then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
async function readJsonWithSizeLimit$1(response, maxBytes, signal) {
|
|
398
|
+
const contentLength = response.headers.get("Content-Length");
|
|
399
|
+
if (contentLength !== null) {
|
|
400
|
+
const declaredSize = Number(contentLength);
|
|
401
|
+
if (Number.isFinite(declaredSize) && declaredSize > maxBytes) {
|
|
402
|
+
await response.body?.cancel().catch(() => void 0);
|
|
403
|
+
throw new Error(`Client metadata exceeds size limit: ${contentLength} bytes (max ${maxBytes})`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const reader = response.body?.getReader();
|
|
407
|
+
if (!reader) throw new Error("Client metadata response body is empty");
|
|
408
|
+
const chunks = [];
|
|
409
|
+
let totalSize = 0;
|
|
410
|
+
while (true) {
|
|
411
|
+
const { done, value } = await readStreamChunk(reader, signal);
|
|
412
|
+
if (done) break;
|
|
413
|
+
if (!value) continue;
|
|
414
|
+
totalSize += value.length;
|
|
415
|
+
if (totalSize > maxBytes) {
|
|
416
|
+
await reader.cancel().catch(() => void 0);
|
|
417
|
+
throw new Error(`Response exceeded size limit of ${maxBytes} bytes`);
|
|
418
|
+
}
|
|
419
|
+
chunks.push(value);
|
|
420
|
+
}
|
|
421
|
+
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
422
|
+
const bytes = new Uint8Array(totalSize);
|
|
423
|
+
let offset = 0;
|
|
424
|
+
for (const chunk of chunks) {
|
|
425
|
+
bytes.set(chunk, offset);
|
|
426
|
+
offset += chunk.length;
|
|
427
|
+
}
|
|
428
|
+
let text;
|
|
429
|
+
try {
|
|
430
|
+
text = new TextDecoder("utf-8", {
|
|
431
|
+
fatal: true,
|
|
432
|
+
ignoreBOM: false
|
|
433
|
+
}).decode(bytes);
|
|
434
|
+
} catch {
|
|
435
|
+
throw new Error("Client metadata response is not valid UTF-8");
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
return {
|
|
439
|
+
value: JSON.parse(text),
|
|
440
|
+
bytes
|
|
441
|
+
};
|
|
442
|
+
} catch {
|
|
443
|
+
throw new Error("Client metadata response is not valid JSON");
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function fetchCimdOrigin(metadataUrl, signal) {
|
|
447
|
+
return fetch(metadataUrl, {
|
|
448
|
+
headers: {
|
|
449
|
+
Accept: "application/json",
|
|
450
|
+
"Cache-Control": "no-store"
|
|
451
|
+
},
|
|
452
|
+
signal,
|
|
453
|
+
cache: "no-store"
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
async function openCimdCache() {
|
|
457
|
+
if (typeof caches === "undefined") return void 0;
|
|
458
|
+
try {
|
|
459
|
+
return await caches.open(CIMD_CACHE_NAME);
|
|
460
|
+
} catch {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Shared-cache lifetime derived from the origin's Cache-Control directives,
|
|
466
|
+
* bounded by the server-side cap the CIMD draft permits (§5.2 "MAY define its
|
|
467
|
+
* own upper and/or lower bounds on an acceptable cache lifetime"). Returns
|
|
468
|
+
* undefined when the response must not be stored.
|
|
469
|
+
*/
|
|
470
|
+
function cacheTtlSeconds(response) {
|
|
471
|
+
const cacheControl = response.headers.get("Cache-Control");
|
|
472
|
+
if (cacheControl === null || /(?:^|,)\s*(?:no-cache|no-store|private)\b/i.test(cacheControl)) return void 0;
|
|
473
|
+
const directive = /(?:^|,)\s*s-maxage\s*=\s*"?(\d+)/i.exec(cacheControl) ?? /(?:^|,)\s*max-age\s*=\s*"?(\d+)/i.exec(cacheControl);
|
|
474
|
+
if (!directive) return void 0;
|
|
475
|
+
const ttl = Math.min(Number(directive[1]), CIMD_CACHE_MAX_TTL_SECONDS);
|
|
476
|
+
return ttl > 0 ? ttl : void 0;
|
|
477
|
+
}
|
|
478
|
+
async function cacheValidatedDocument(cache, metadataUrl, response, bytes) {
|
|
479
|
+
if (!cache) return;
|
|
480
|
+
const ttl = cacheTtlSeconds(response);
|
|
481
|
+
if (ttl === void 0) return;
|
|
482
|
+
const headers = new Headers({ "Cache-Control": `public, max-age=${ttl}` });
|
|
483
|
+
for (const name of [
|
|
484
|
+
"Content-Type",
|
|
485
|
+
"ETag",
|
|
486
|
+
"Last-Modified"
|
|
487
|
+
]) {
|
|
488
|
+
const value = response.headers.get(name);
|
|
489
|
+
if (value !== null) headers.set(name, value);
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
await cache.put(metadataUrl, new Response(bytes, {
|
|
493
|
+
status: 200,
|
|
494
|
+
headers
|
|
495
|
+
}));
|
|
496
|
+
} catch {}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Resolves a previously validated cache entry. A stored document that stops
|
|
500
|
+
* validating (changed server capabilities, stricter rules after an upgrade)
|
|
501
|
+
* is evicted so the caller re-resolves from origin in the same request;
|
|
502
|
+
* timeout aborts propagate instead.
|
|
503
|
+
*/
|
|
504
|
+
async function tryResolveFromCache(cache, metadataUrl, server, signal) {
|
|
505
|
+
let cached;
|
|
506
|
+
try {
|
|
507
|
+
cached = await cache?.match(metadataUrl);
|
|
508
|
+
} catch {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (!cached) return void 0;
|
|
512
|
+
try {
|
|
513
|
+
const { value } = await readJsonWithSizeLimit$1(cached, CIMD_MAX_SIZE_BYTES, signal);
|
|
514
|
+
return resolveClientIdMetadataDocument(metadataUrl, value, server);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if (signal.aborted) throw error;
|
|
517
|
+
try {
|
|
518
|
+
await cache?.delete(metadataUrl);
|
|
519
|
+
} catch {}
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Fetches, bounds, parses, validates, and negotiates a Client ID Metadata
|
|
525
|
+
* Document into the single effective client configuration used by OAuth flows.
|
|
526
|
+
*/
|
|
527
|
+
async function fetchClientIdMetadataDocument(metadataUrl, server) {
|
|
528
|
+
validateClientIdentifierUrl(metadataUrl);
|
|
529
|
+
const abortController = new AbortController();
|
|
530
|
+
const timeoutId = setTimeout(() => abortController.abort(), CIMD_FETCH_TIMEOUT_MS);
|
|
531
|
+
try {
|
|
532
|
+
const cache = await openCimdCache();
|
|
533
|
+
const cachedDocument = await tryResolveFromCache(cache, metadataUrl, server, abortController.signal);
|
|
534
|
+
if (cachedDocument) return cachedDocument;
|
|
535
|
+
const response = await fetchCimdOrigin(metadataUrl, abortController.signal);
|
|
536
|
+
if (!response.ok) throw new Error(`Failed to fetch client metadata: HTTP ${response.status}`);
|
|
537
|
+
const { value, bytes } = await readJsonWithSizeLimit$1(response, CIMD_MAX_SIZE_BYTES, abortController.signal);
|
|
538
|
+
const resolved = resolveClientIdMetadataDocument(metadataUrl, value, server);
|
|
539
|
+
clearTimeout(timeoutId);
|
|
540
|
+
await cacheValidatedDocument(cache, metadataUrl, response, bytes);
|
|
541
|
+
return resolved;
|
|
542
|
+
} catch (error) {
|
|
543
|
+
if (abortController.signal.aborted) throw new Error(`Client metadata fetch timed out after ${CIMD_FETCH_TIMEOUT_MS}ms`);
|
|
544
|
+
throw error;
|
|
545
|
+
} finally {
|
|
546
|
+
clearTimeout(timeoutId);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
110
550
|
//#endregion
|
|
111
551
|
//#region src/ema/constants.ts
|
|
112
552
|
/**
|
|
@@ -876,7 +1316,7 @@ function getOAuthApi(options, env) {
|
|
|
876
1316
|
* annotation, and does not actually prevent the method from being called from outside the class,
|
|
877
1317
|
* including over RPC.
|
|
878
1318
|
*/
|
|
879
|
-
var OAuthProviderImpl = class
|
|
1319
|
+
var OAuthProviderImpl = class {
|
|
880
1320
|
/**
|
|
881
1321
|
* Creates a new OAuth provider instance
|
|
882
1322
|
* @param options - Configuration options for the provider
|
|
@@ -2101,37 +2541,29 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2101
2541
|
statusCode: 413
|
|
2102
2542
|
});
|
|
2103
2543
|
const callbackRequest = request.clone();
|
|
2104
|
-
let
|
|
2544
|
+
let parsedJson;
|
|
2105
2545
|
try {
|
|
2106
2546
|
const text = await request.text();
|
|
2107
2547
|
if (text.length > 1048576) return this.createErrorResponse("invalid_request", {
|
|
2108
2548
|
description: "Request payload too large, must be under 1 MiB",
|
|
2109
2549
|
statusCode: 413
|
|
2110
2550
|
});
|
|
2111
|
-
|
|
2112
|
-
} catch
|
|
2551
|
+
parsedJson = JSON.parse(text);
|
|
2552
|
+
} catch {
|
|
2113
2553
|
return this.createErrorResponse("invalid_request", {
|
|
2114
2554
|
description: "Invalid JSON payload",
|
|
2115
2555
|
statusCode: 400
|
|
2116
2556
|
});
|
|
2117
2557
|
}
|
|
2118
|
-
let
|
|
2119
|
-
let
|
|
2120
|
-
let grantTypes;
|
|
2121
|
-
let responseTypes;
|
|
2558
|
+
let clientMetadata;
|
|
2559
|
+
let metadata;
|
|
2122
2560
|
try {
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
grantTypes = OAuthProviderImpl.validateStringArray(clientMetadata.grant_types, "grant_types") || [GrantType.AUTHORIZATION_CODE];
|
|
2126
|
-
responseTypes = OAuthProviderImpl.validateStringArray(clientMetadata.response_types, "response_types") || ["code"];
|
|
2127
|
-
validateClientCapabilities(this.serverCapabilities, {
|
|
2128
|
-
tokenEndpointAuthMethod: authMethod,
|
|
2129
|
-
grantTypes,
|
|
2130
|
-
responseTypes
|
|
2131
|
-
});
|
|
2561
|
+
clientMetadata = requireJsonObject(parsedJson);
|
|
2562
|
+
metadata = resolveDynamicClientRegistrationMetadata(clientMetadata, this.serverCapabilities);
|
|
2132
2563
|
} catch (error) {
|
|
2133
2564
|
return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
|
|
2134
2565
|
}
|
|
2566
|
+
const authMethod = metadata.tokenEndpointAuthMethod;
|
|
2135
2567
|
const isPublicClient = authMethod === "none";
|
|
2136
2568
|
if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" });
|
|
2137
2569
|
const clientId = generateRandomString(16);
|
|
@@ -2141,32 +2573,24 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2141
2573
|
clientSecret = generateRandomString(32);
|
|
2142
2574
|
hashedSecret = await hashSecret(clientSecret);
|
|
2143
2575
|
}
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
registrationDate: Math.floor(Date.now() / 1e3),
|
|
2163
|
-
tokenEndpointAuthMethod: authMethod,
|
|
2164
|
-
...authMethodWasExplicit ? { authMethodExplicit: true } : {}
|
|
2165
|
-
};
|
|
2166
|
-
if (!isPublicClient && hashedSecret) clientInfo.clientSecret = hashedSecret;
|
|
2167
|
-
} catch (error) {
|
|
2168
|
-
return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
|
|
2169
|
-
}
|
|
2576
|
+
const clientInfo = {
|
|
2577
|
+
clientId,
|
|
2578
|
+
redirectUris: metadata.redirectUris,
|
|
2579
|
+
clientName: metadata.clientName,
|
|
2580
|
+
logoUri: metadata.logoUri,
|
|
2581
|
+
clientUri: metadata.clientUri,
|
|
2582
|
+
policyUri: metadata.policyUri,
|
|
2583
|
+
tosUri: metadata.tosUri,
|
|
2584
|
+
jwksUri: metadata.jwksUri,
|
|
2585
|
+
i18n: metadata.i18n,
|
|
2586
|
+
contacts: metadata.contacts,
|
|
2587
|
+
grantTypes: metadata.grantTypes,
|
|
2588
|
+
responseTypes: metadata.responseTypes,
|
|
2589
|
+
registrationDate: Math.floor(Date.now() / 1e3),
|
|
2590
|
+
tokenEndpointAuthMethod: authMethod,
|
|
2591
|
+
...metadata.authMethodExplicit ? { authMethodExplicit: true } : {},
|
|
2592
|
+
...!isPublicClient && hashedSecret ? { clientSecret: hashedSecret } : {}
|
|
2593
|
+
};
|
|
2170
2594
|
if (this.options.clientRegistrationCallback) {
|
|
2171
2595
|
let callbackResult;
|
|
2172
2596
|
try {
|
|
@@ -2187,7 +2611,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2187
2611
|
}
|
|
2188
2612
|
const clientKvOptions = {};
|
|
2189
2613
|
if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
|
|
2190
|
-
await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo), clientKvOptions);
|
|
2614
|
+
await env.OAUTH_KV.put(`client:${clientInfo.clientId}`, JSON.stringify(clientInfo), clientKvOptions);
|
|
2191
2615
|
const response = {
|
|
2192
2616
|
client_id: clientInfo.clientId,
|
|
2193
2617
|
redirect_uris: clientInfo.redirectUris,
|
|
@@ -2382,7 +2806,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2382
2806
|
}
|
|
2383
2807
|
if (!this.hasGlobalFetchStrictlyPublic()) throw new Error(`CIMD is enabled but 'global_fetch_strictly_public' compatibility flag is not set.`);
|
|
2384
2808
|
try {
|
|
2385
|
-
return await this.
|
|
2809
|
+
return await fetchClientIdMetadataDocument(clientId, this.serverCapabilities);
|
|
2386
2810
|
} catch (error) {
|
|
2387
2811
|
console.warn(`CIMD fetch failed for ${clientId}:`, error instanceof Error ? error.message : error);
|
|
2388
2812
|
throw new CimdFetchError(clientId, error);
|
|
@@ -2477,207 +2901,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2477
2901
|
* Not private because OAuthHelpersImpl needs access for purgeExpiredData.
|
|
2478
2902
|
*/
|
|
2479
2903
|
isClientMetadataUrl(clientId) {
|
|
2480
|
-
|
|
2481
|
-
const url = new URL(clientId);
|
|
2482
|
-
return url.protocol === "https:" && url.pathname !== "/";
|
|
2483
|
-
} catch {
|
|
2484
|
-
return false;
|
|
2485
|
-
}
|
|
2486
|
-
}
|
|
2487
|
-
static {
|
|
2488
|
-
this.CIMD_MAX_SIZE_BYTES = 5 * 1024;
|
|
2489
|
-
}
|
|
2490
|
-
static {
|
|
2491
|
-
this.CIMD_FETCH_TIMEOUT_MS = 1e4;
|
|
2492
|
-
}
|
|
2493
|
-
static {
|
|
2494
|
-
this.CIMD_ALLOWED_AUTH_METHODS = ["none"];
|
|
2495
|
-
}
|
|
2496
|
-
/**
|
|
2497
|
-
* Validates that a field is a string or undefined
|
|
2498
|
-
* @param field - The field value to validate
|
|
2499
|
-
* @param fieldName - Name of the field for error messages
|
|
2500
|
-
* @returns The validated string or undefined
|
|
2501
|
-
* @throws Error if field is not a string or undefined
|
|
2502
|
-
*/
|
|
2503
|
-
static validateStringField(field, fieldName) {
|
|
2504
|
-
if (field === void 0) return void 0;
|
|
2505
|
-
if (typeof field !== "string") throw new Error(fieldName ? `Invalid ${fieldName}: expected string, got ${typeof field}` : "Field must be a string");
|
|
2506
|
-
return field;
|
|
2507
|
-
}
|
|
2508
|
-
/**
|
|
2509
|
-
* Validates that a field is an optional URI string using a safe scheme.
|
|
2510
|
-
*
|
|
2511
|
-
* Client metadata URI fields (e.g. logo_uri, client_uri, policy_uri, tos_uri,
|
|
2512
|
-
* jwks_uri) are frequently rendered into HTML attributes such as `<a href>` or
|
|
2513
|
-
* `<img src>` on consent screens. Permitting non-http(s) schemes such as
|
|
2514
|
-
* `javascript:` or `data:` would allow script execution in that context, so we
|
|
2515
|
-
* require an absolute http: or https: URL here, matching how redirect URIs are
|
|
2516
|
-
* already restricted.
|
|
2517
|
-
*
|
|
2518
|
-
* @param field - The field to validate
|
|
2519
|
-
* @param fieldName - Name of the field for error messages
|
|
2520
|
-
* @returns The validated URI string or undefined
|
|
2521
|
-
* @throws Error if the field is not a string or is not an absolute http(s) URL
|
|
2522
|
-
*/
|
|
2523
|
-
static validateOptionalUriField(field, fieldName) {
|
|
2524
|
-
const value = OAuthProviderImpl.validateStringField(field, fieldName);
|
|
2525
|
-
if (value === void 0) return void 0;
|
|
2526
|
-
let parsed;
|
|
2527
|
-
try {
|
|
2528
|
-
parsed = new URL(value);
|
|
2529
|
-
} catch {
|
|
2530
|
-
throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
|
|
2531
|
-
}
|
|
2532
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
|
|
2533
|
-
return value;
|
|
2534
|
-
}
|
|
2535
|
-
static {
|
|
2536
|
-
this.I18N_FIELDS = {
|
|
2537
|
-
client_name: "string",
|
|
2538
|
-
client_uri: "uri",
|
|
2539
|
-
logo_uri: "uri",
|
|
2540
|
-
tos_uri: "uri",
|
|
2541
|
-
policy_uri: "uri"
|
|
2542
|
-
};
|
|
2543
|
-
}
|
|
2544
|
-
/**
|
|
2545
|
-
* Extracts RFC 7591 §2.2 internationalized metadata variants from a raw
|
|
2546
|
-
* registration payload.
|
|
2547
|
-
*
|
|
2548
|
-
* Localized variants are expressed by appending a `#<BCP 47 language tag>`
|
|
2549
|
-
* suffix to a metadata member name (e.g. `client_name#ja`, `tos_uri#fr`).
|
|
2550
|
-
* Only the human-readable fields the RFC names are considered; each value is
|
|
2551
|
-
* validated with the same rules as its canonical field (URI fields must be
|
|
2552
|
-
* absolute http(s) URLs). The raw `field#tag` keys are preserved verbatim so
|
|
2553
|
-
* that consumers can do their own locale matching.
|
|
2554
|
-
*
|
|
2555
|
-
* @param raw - The parsed client metadata object
|
|
2556
|
-
* @returns A map of `field#tag` to validated value, or undefined if none present
|
|
2557
|
-
* @throws Error if a localized value fails its field's validation
|
|
2558
|
-
*/
|
|
2559
|
-
static extractI18nFields(raw) {
|
|
2560
|
-
const result = {};
|
|
2561
|
-
for (const key of Object.keys(raw)) {
|
|
2562
|
-
const hashIndex = key.indexOf("#");
|
|
2563
|
-
if (hashIndex <= 0 || hashIndex === key.length - 1) continue;
|
|
2564
|
-
const baseField = key.slice(0, hashIndex);
|
|
2565
|
-
const kind = OAuthProviderImpl.I18N_FIELDS[baseField];
|
|
2566
|
-
if (!kind) continue;
|
|
2567
|
-
const validated = kind === "uri" ? OAuthProviderImpl.validateOptionalUriField(raw[key], key) : OAuthProviderImpl.validateStringField(raw[key], key);
|
|
2568
|
-
if (validated !== void 0) result[key] = validated;
|
|
2569
|
-
}
|
|
2570
|
-
return Object.keys(result).length > 0 ? result : void 0;
|
|
2571
|
-
}
|
|
2572
|
-
/**
|
|
2573
|
-
* Validates that a field is a string array or undefined
|
|
2574
|
-
* @param arr - The array to validate
|
|
2575
|
-
* @param fieldName - Name of the field for error messages
|
|
2576
|
-
* @returns The validated string array or undefined
|
|
2577
|
-
* @throws Error if field is not a string array or undefined
|
|
2578
|
-
*/
|
|
2579
|
-
static validateStringArray(arr, fieldName) {
|
|
2580
|
-
if (arr === void 0) return void 0;
|
|
2581
|
-
if (!Array.isArray(arr)) throw new Error(fieldName ? `Invalid ${fieldName}: expected array, got ${typeof arr}` : "Field must be an array");
|
|
2582
|
-
if (!arr.every((item) => typeof item === "string")) throw new Error(fieldName ? `Invalid ${fieldName}: array must contain only strings` : "All array elements must be strings");
|
|
2583
|
-
return arr;
|
|
2584
|
-
}
|
|
2585
|
-
/**
|
|
2586
|
-
* Fetches and validates a Client ID Metadata Document from the given URL
|
|
2587
|
-
* Per the MCP spec, the client_id in the document must match the URL exactly
|
|
2588
|
-
*
|
|
2589
|
-
* Uses Cloudflare HTTP cache for caching (via cacheEverything option).
|
|
2590
|
-
* Response size is limited to 5KB per IETF spec.
|
|
2591
|
-
*
|
|
2592
|
-
* @param metadataUrl - The HTTPS URL to fetch metadata from
|
|
2593
|
-
* @returns The client information
|
|
2594
|
-
* @throws Error if fetch fails or validation fails
|
|
2595
|
-
*/
|
|
2596
|
-
async fetchClientMetadataDocument(metadataUrl) {
|
|
2597
|
-
const abortController = new AbortController();
|
|
2598
|
-
const timeoutId = setTimeout(() => abortController.abort(), OAuthProviderImpl.CIMD_FETCH_TIMEOUT_MS);
|
|
2599
|
-
try {
|
|
2600
|
-
const response = await fetch(metadataUrl, {
|
|
2601
|
-
headers: { Accept: "application/json" },
|
|
2602
|
-
signal: abortController.signal,
|
|
2603
|
-
cf: { cacheEverything: true }
|
|
2604
|
-
});
|
|
2605
|
-
clearTimeout(timeoutId);
|
|
2606
|
-
if (!response.ok) throw new Error(`Failed to fetch client metadata: HTTP ${response.status}`);
|
|
2607
|
-
const contentLength = response.headers.get("content-length");
|
|
2608
|
-
if (contentLength && parseInt(contentLength, 10) > OAuthProviderImpl.CIMD_MAX_SIZE_BYTES) throw new Error(`Client metadata exceeds size limit: ${contentLength} bytes (max ${OAuthProviderImpl.CIMD_MAX_SIZE_BYTES})`);
|
|
2609
|
-
const rawMetadata = await this.readJsonWithSizeLimit(response, OAuthProviderImpl.CIMD_MAX_SIZE_BYTES);
|
|
2610
|
-
const clientId = OAuthProviderImpl.validateStringField(rawMetadata.client_id, "client_id");
|
|
2611
|
-
const clientName = OAuthProviderImpl.validateStringField(rawMetadata.client_name, "client_name");
|
|
2612
|
-
const redirectUris = OAuthProviderImpl.validateStringArray(rawMetadata.redirect_uris, "redirect_uris");
|
|
2613
|
-
const declaredAuthMethod = OAuthProviderImpl.validateStringField(rawMetadata.token_endpoint_auth_method, "token_endpoint_auth_method");
|
|
2614
|
-
const authMethodChoices = OAuthProviderImpl.validateStringArray(rawMetadata.token_endpoint_auth_methods_supported, "token_endpoint_auth_methods_supported");
|
|
2615
|
-
const tokenEndpointAuthMethod = declaredAuthMethod ?? (authMethodChoices?.includes("none") ? "none" : void 0);
|
|
2616
|
-
if (clientId !== metadataUrl) throw new Error(`client_id "${clientId}" does not match metadata URL "${metadataUrl}"`);
|
|
2617
|
-
if (!clientName?.trim()) throw new Error("client_name is required and must not be empty");
|
|
2618
|
-
if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
|
|
2619
|
-
if (declaredAuthMethod && !OAuthProviderImpl.CIMD_ALLOWED_AUTH_METHODS.includes(declaredAuthMethod) || authMethodChoices && !authMethodChoices.some((method) => OAuthProviderImpl.CIMD_ALLOWED_AUTH_METHODS.includes(method))) throw new Error(`CIMD client does not support an accepted token endpoint authentication method. Supported methods: ${OAuthProviderImpl.CIMD_ALLOWED_AUTH_METHODS.join(", ")}`);
|
|
2620
|
-
const advertisedGrantTypes = OAuthProviderImpl.validateStringArray(rawMetadata.grant_types, "grant_types") || [GrantType.AUTHORIZATION_CODE];
|
|
2621
|
-
const advertisedResponseTypes = OAuthProviderImpl.validateStringArray(rawMetadata.response_types, "response_types") || ["code"];
|
|
2622
|
-
const effectiveAuthMethod = tokenEndpointAuthMethod || "none";
|
|
2623
|
-
const { grantTypes, responseTypes } = negotiateCimdClientCapabilities(this.serverCapabilities, {
|
|
2624
|
-
tokenEndpointAuthMethod: effectiveAuthMethod,
|
|
2625
|
-
grantTypes: advertisedGrantTypes,
|
|
2626
|
-
responseTypes: advertisedResponseTypes
|
|
2627
|
-
});
|
|
2628
|
-
return {
|
|
2629
|
-
clientId,
|
|
2630
|
-
redirectUris,
|
|
2631
|
-
clientName,
|
|
2632
|
-
clientUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.client_uri, "client_uri"),
|
|
2633
|
-
logoUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.logo_uri, "logo_uri"),
|
|
2634
|
-
policyUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.policy_uri, "policy_uri"),
|
|
2635
|
-
tosUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.tos_uri, "tos_uri"),
|
|
2636
|
-
jwksUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.jwks_uri, "jwks_uri"),
|
|
2637
|
-
i18n: OAuthProviderImpl.extractI18nFields(rawMetadata),
|
|
2638
|
-
contacts: OAuthProviderImpl.validateStringArray(rawMetadata.contacts, "contacts"),
|
|
2639
|
-
grantTypes,
|
|
2640
|
-
responseTypes,
|
|
2641
|
-
tokenEndpointAuthMethod: effectiveAuthMethod
|
|
2642
|
-
};
|
|
2643
|
-
} finally {
|
|
2644
|
-
clearTimeout(timeoutId);
|
|
2645
|
-
}
|
|
2646
|
-
}
|
|
2647
|
-
/**
|
|
2648
|
-
* Reads JSON from a response with a size limit to prevent DoS attacks.
|
|
2649
|
-
* Streams the response body and aborts if it exceeds the limit.
|
|
2650
|
-
*
|
|
2651
|
-
* @param response - The fetch response
|
|
2652
|
-
* @param maxBytes - Maximum allowed size in bytes
|
|
2653
|
-
* @returns Parsed JSON object
|
|
2654
|
-
* @throws Error if response body is null, size exceeded, or JSON parse failed
|
|
2655
|
-
*/
|
|
2656
|
-
async readJsonWithSizeLimit(response, maxBytes) {
|
|
2657
|
-
const reader = response.body?.getReader();
|
|
2658
|
-
if (!reader) throw new Error("Response body is null");
|
|
2659
|
-
const chunks = [];
|
|
2660
|
-
let totalSize = 0;
|
|
2661
|
-
while (true) {
|
|
2662
|
-
const { done, value } = await reader.read();
|
|
2663
|
-
if (done) break;
|
|
2664
|
-
if (value) {
|
|
2665
|
-
totalSize += value.length;
|
|
2666
|
-
if (totalSize > maxBytes) {
|
|
2667
|
-
await reader.cancel();
|
|
2668
|
-
throw new Error(`Response exceeded size limit of ${maxBytes} bytes`);
|
|
2669
|
-
}
|
|
2670
|
-
chunks.push(value);
|
|
2671
|
-
}
|
|
2672
|
-
}
|
|
2673
|
-
const allChunks = new Uint8Array(totalSize);
|
|
2674
|
-
let position = 0;
|
|
2675
|
-
for (const chunk of chunks) {
|
|
2676
|
-
allChunks.set(chunk, position);
|
|
2677
|
-
position += chunk.length;
|
|
2678
|
-
}
|
|
2679
|
-
const text = new TextDecoder().decode(allChunks);
|
|
2680
|
-
return JSON.parse(text);
|
|
2904
|
+
return isClientIdMetadataDocumentUrl(clientId);
|
|
2681
2905
|
}
|
|
2682
2906
|
/**
|
|
2683
2907
|
* Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
|
|
@@ -3016,34 +3240,6 @@ async function generateTokenId(token) {
|
|
|
3016
3240
|
return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
3017
3241
|
}
|
|
3018
3242
|
/**
|
|
3019
|
-
* Validates that a redirect URI does not use a dangerous pseudo-scheme.
|
|
3020
|
-
* Normalizes the URI by trimming whitespace and checking the scheme in a
|
|
3021
|
-
* case-insensitive manner to prevent bypass attacks.
|
|
3022
|
-
* Per RFC 3986, control characters are explicitly disallowed in URIs and
|
|
3023
|
-
* will cause rejection rather than silent removal.
|
|
3024
|
-
* @param redirectUri - The redirect URI to validate
|
|
3025
|
-
* @throws Error if the URI uses a blacklisted scheme or contains control characters
|
|
3026
|
-
*/
|
|
3027
|
-
function validateRedirectUriScheme(redirectUri) {
|
|
3028
|
-
const dangerousSchemes = [
|
|
3029
|
-
"javascript:",
|
|
3030
|
-
"data:",
|
|
3031
|
-
"vbscript:",
|
|
3032
|
-
"file:",
|
|
3033
|
-
"mailto:",
|
|
3034
|
-
"blob:"
|
|
3035
|
-
];
|
|
3036
|
-
const normalized = redirectUri.trim();
|
|
3037
|
-
for (let i = 0; i < normalized.length; i++) {
|
|
3038
|
-
const code = normalized.charCodeAt(i);
|
|
3039
|
-
if (code >= 0 && code <= 31 || code >= 127 && code <= 159) throw new Error("Invalid redirect URI");
|
|
3040
|
-
}
|
|
3041
|
-
const colonIndex = normalized.indexOf(":");
|
|
3042
|
-
if (colonIndex === -1) throw new Error("Invalid redirect URI");
|
|
3043
|
-
const scheme = normalized.substring(0, colonIndex + 1).toLowerCase();
|
|
3044
|
-
for (const dangerousScheme of dangerousSchemes) if (scheme === dangerousScheme) throw new Error("Invalid redirect URI");
|
|
3045
|
-
}
|
|
3046
|
-
/**
|
|
3047
3243
|
* Checks if a URI is a loopback redirect URI (127.0.0.0/8, ::1, or localhost).
|
|
3048
3244
|
* Per RFC 8252 Section 7.3, loopback IPs get special port handling. This library
|
|
3049
3245
|
* applies the same port flexibility to localhost for native apps (e.g., Claude Code).
|
|
@@ -3376,6 +3572,7 @@ var OAuthHelpersImpl = class {
|
|
|
3376
3572
|
validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
|
|
3377
3573
|
let grantsToRevoke = [];
|
|
3378
3574
|
if (options.revokeExistingGrants !== false) {
|
|
3575
|
+
const isCimdClient = this.provider.isClientMetadataUrl(clientId);
|
|
3379
3576
|
const batchSize = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
|
|
3380
3577
|
let cursor;
|
|
3381
3578
|
do {
|
|
@@ -3383,7 +3580,7 @@ var OAuthHelpersImpl = class {
|
|
|
3383
3580
|
cursor,
|
|
3384
3581
|
limit: batchSize
|
|
3385
3582
|
});
|
|
3386
|
-
for (const grant of page.items) if (grant.clientId === clientId) grantsToRevoke.push(grant.id);
|
|
3583
|
+
for (const grant of page.items) if (grant.clientId === clientId && (!isCimdClient || grant.redirectUri === options.request.redirectUri)) grantsToRevoke.push(grant.id);
|
|
3387
3584
|
cursor = page.cursor;
|
|
3388
3585
|
} while (cursor);
|
|
3389
3586
|
}
|
|
@@ -3407,7 +3604,8 @@ var OAuthHelpersImpl = class {
|
|
|
3407
3604
|
metadata: options.metadata,
|
|
3408
3605
|
encryptedProps: encryptedData,
|
|
3409
3606
|
createdAt: now,
|
|
3410
|
-
resource: effectiveResource
|
|
3607
|
+
resource: effectiveResource,
|
|
3608
|
+
redirectUri: options.request.redirectUri
|
|
3411
3609
|
};
|
|
3412
3610
|
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
3413
3611
|
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
|
|
@@ -3457,7 +3655,8 @@ var OAuthHelpersImpl = class {
|
|
|
3457
3655
|
authCodeWrappedKey,
|
|
3458
3656
|
codeChallenge: options.request.codeChallenge,
|
|
3459
3657
|
codeChallengeMethod: options.request.codeChallengeMethod,
|
|
3460
|
-
resource: effectiveResource
|
|
3658
|
+
resource: effectiveResource,
|
|
3659
|
+
redirectUri: options.request.redirectUri
|
|
3461
3660
|
};
|
|
3462
3661
|
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
3463
3662
|
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), { expirationTtl: 600 });
|
|
@@ -3614,7 +3813,8 @@ var OAuthHelpersImpl = class {
|
|
|
3614
3813
|
scope: grantData.scope,
|
|
3615
3814
|
metadata: grantData.metadata,
|
|
3616
3815
|
createdAt: grantData.createdAt,
|
|
3617
|
-
expiresAt: grantData.expiresAt
|
|
3816
|
+
expiresAt: grantData.expiresAt,
|
|
3817
|
+
redirectUri: grantData.redirectUri
|
|
3618
3818
|
};
|
|
3619
3819
|
grantSummaries.push(summary);
|
|
3620
3820
|
}
|