@cloudflare/workers-oauth-provider 0.10.0 → 0.10.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 +20 -17
- package/dist/oauth-provider.d.ts +14 -11
- package/dist/oauth-provider.js +549 -294
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -294,18 +294,21 @@ new OAuthProvider({
|
|
|
294
294
|
}
|
|
295
295
|
```
|
|
296
296
|
|
|
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.
|
|
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. 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
298
|
|
|
299
|
-
CIMD validation includes:
|
|
299
|
+
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
300
|
|
|
301
|
-
- HTTPS URL with a
|
|
301
|
+
- An HTTPS Client Identifier URL with a path component and no userinfo, fragment, or dot path segments.
|
|
302
302
|
- A document `client_id` exactly matching its URL.
|
|
303
|
-
- Non-empty `client_name` and `redirect_uris` fields.
|
|
303
|
+
- Non-empty `client_name` and `redirect_uris` fields, as MCP requires, with unsafe redirect schemes rejected at ingestion.
|
|
304
304
|
- Exact authorization-request redirect URI validation, with RFC 8252 loopback port handling.
|
|
305
|
-
- A 5 KB response size limit and 10 second
|
|
306
|
-
-
|
|
305
|
+
- A 5 KB response size limit and a 10 second timeout covering both headers and body.
|
|
306
|
+
- Valid UTF-8 JSON object syntax and safe URI schemes for client metadata fields.
|
|
307
|
+
- No embedded client secrets or private JWK material.
|
|
307
308
|
|
|
308
|
-
|
|
309
|
+
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.
|
|
310
|
+
|
|
311
|
+
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
312
|
|
|
310
313
|
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
314
|
|
|
@@ -319,7 +322,11 @@ clientRegistrationEndpoint: '/oauth/register';
|
|
|
319
322
|
|
|
320
323
|
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
324
|
|
|
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"]`.
|
|
325
|
+
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"]`.
|
|
326
|
+
|
|
327
|
+
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.
|
|
328
|
+
|
|
329
|
+
Calling `OAuthHelpers.updateClient()` with `tokenEndpointAuthMethod` adds the marker; unrelated updates leave it unchanged.
|
|
323
330
|
|
|
324
331
|
Related options:
|
|
325
332
|
|
|
@@ -345,16 +352,11 @@ The provider owns `tokenEndpoint`. It exchanges authorization codes for tokens,
|
|
|
345
352
|
|
|
346
353
|
## Resources and token audiences
|
|
347
354
|
|
|
348
|
-
MCP clients
|
|
349
|
-
|
|
350
|
-
Resource policy follows `resourceMetadata.resource`:
|
|
351
|
-
|
|
352
|
-
- When configured, authorization requests, token requests, and externally resolved tokens must use that one exact resource. `resourceMatchOriginOnly` cannot weaken this policy.
|
|
353
|
-
- When omitted, valid resources are accepted. Token requests may inherit the authorization resource. If the authorization request also omits it, the provider uses the request origin as the default and issues an origin-bound token.
|
|
355
|
+
MCP clients are required to send the canonical MCP server URI as `resource` in authorization and token requests. The provider tolerates omission for compatibility: when `resourceMetadata.resource` is configured, it is used as the canonical default and inherited by later token requests; otherwise a token request inherits any resource already stored on the grant. An explicit resource that does not match a bound grant is rejected with `invalid_target`.
|
|
354
356
|
|
|
355
|
-
Path-aware audiences use path-boundary prefix matching
|
|
357
|
+
Legacy grants may have no stored resource. With no configured canonical resource, omitting `resource` preserves that unbound state. If a client supplies a resource during code exchange or refresh, it applies to that issued token but is not persisted as a new grant binding. Path-aware audiences use path-boundary prefix matching, so a token for `https://example.com/mcp` can be used at `/mcp/tools`, but not at `/mcp-other`.
|
|
356
358
|
|
|
357
|
-
`resourceMatchOriginOnly`
|
|
359
|
+
`resourceMatchOriginOnly` is deprecated; its existing behavior is unchanged. Prefer `resourceMetadata.resource` for new deployments.
|
|
358
360
|
|
|
359
361
|
## Scopes and step-up authorization
|
|
360
362
|
|
|
@@ -431,7 +433,7 @@ Deleting a client through `OAuthHelpers.deleteClient()` also revokes its grants
|
|
|
431
433
|
| `allowTokenExchangeGrant` | Enable RFC 8693 | `false` |
|
|
432
434
|
| `tokenExchangeCallback` | Update props, scopes, or lifetimes during token exchange | None |
|
|
433
435
|
| `resolveExternalToken` | Validate external bearer credentials (advanced) | None |
|
|
434
|
-
| `resourceMatchOriginOnly` |
|
|
436
|
+
| `resourceMatchOriginOnly` | Deprecated origin-only resource comparison | `false` |
|
|
435
437
|
| `enterpriseManagedAuthorization` | Enable experimental ID-JAG grant support | Disabled |
|
|
436
438
|
| `onError` | Observe or replace OAuth error responses | Logs a warning |
|
|
437
439
|
|
|
@@ -466,6 +468,7 @@ The package implements or supports the relevant portions of:
|
|
|
466
468
|
- [OAuth 2.0 Authorization Server Issuer Identification, RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207)
|
|
467
469
|
- [OAuth 2.0 Protected Resource Metadata, RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)
|
|
468
470
|
- [OAuth Client ID Metadata Documents](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00)
|
|
471
|
+
- [OpenID Connect RP Metadata Choices 1.0](https://openid.net/specs/openid-connect-rp-metadata-choices-1_0-final.html)
|
|
469
472
|
- [MCP Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization), with experimental package support
|
|
470
473
|
|
|
471
474
|
## Development
|
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -299,7 +299,7 @@ interface EmaOptions<Env = Cloudflare.Env> {
|
|
|
299
299
|
}
|
|
300
300
|
//#endregion
|
|
301
301
|
//#region src/oauth-capabilities.d.ts
|
|
302
|
-
type AuthorizationErrorCode = 'invalid_request' | 'unauthorized_client' | 'access_denied' | 'unsupported_response_type' | 'invalid_scope' | 'server_error' | 'temporarily_unavailable';
|
|
302
|
+
type AuthorizationErrorCode = 'invalid_request' | 'invalid_target' | 'unauthorized_client' | 'access_denied' | 'unsupported_response_type' | 'invalid_scope' | 'server_error' | 'temporarily_unavailable';
|
|
303
303
|
interface AuthorizationErrorOptions {
|
|
304
304
|
/** Wire-safe OAuth authorization error description. */
|
|
305
305
|
description: string;
|
|
@@ -685,14 +685,16 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
|
|
|
685
685
|
*/
|
|
686
686
|
clientIdMetadataDocumentEnabled?: boolean;
|
|
687
687
|
/**
|
|
688
|
-
* When true, resource validation
|
|
689
|
-
* (scheme + host + port) instead of exact
|
|
690
|
-
*
|
|
691
|
-
* path
|
|
692
|
-
*
|
|
693
|
-
* Explicit `resourceMetadata.resource` configuration always uses exact matching.
|
|
688
|
+
* When true, requested-vs-granted resource validation compares origins only
|
|
689
|
+
* (scheme + host + port) instead of exact URIs. This allows an origin-only
|
|
690
|
+
* grant such as `https://server.com` to accept `https://server.com/mcp`, but
|
|
691
|
+
* also ignores path and query differences. Configured canonical resources
|
|
692
|
+
* always use exact matching.
|
|
694
693
|
*
|
|
695
694
|
* Defaults to false (strict exact matching per RFC 8707).
|
|
695
|
+
*
|
|
696
|
+
* @deprecated This comparison is unsafe for shared-origin multi-path or
|
|
697
|
+
* multi-tenant deployments. Prefer configuring `resourceMetadata.resource`.
|
|
696
698
|
*/
|
|
697
699
|
resourceMatchOriginOnly?: boolean;
|
|
698
700
|
/**
|
|
@@ -706,10 +708,11 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
|
|
|
706
708
|
/**
|
|
707
709
|
* The protected resource identifier URL (RFC 9728 `resource` field).
|
|
708
710
|
*
|
|
709
|
-
* Configuring this value pins
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
711
|
+
* Configuring this value pins grants and access-token audiences to this
|
|
712
|
+
* exact resource. An omitted authorization resource defaults to this value,
|
|
713
|
+
* and an omitted token-request resource inherits it from the grant. Without
|
|
714
|
+
* configuration, explicit RFC 8707 resource indicators are accepted and
|
|
715
|
+
* omission remains unbound for backwards compatibility.
|
|
713
716
|
*/
|
|
714
717
|
resource?: string;
|
|
715
718
|
/**
|
package/dist/oauth-provider.js
CHANGED
|
@@ -53,6 +53,68 @@ 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
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Selects the capabilities from a Client ID Metadata Document that this
|
|
101
|
+
* authorization server supports. CIMD documents may advertise extension
|
|
102
|
+
* capabilities alongside the flow used with this server, so unsupported grant
|
|
103
|
+
* and response types are omitted from the effective client metadata instead of
|
|
104
|
+
* invalidating an otherwise usable client.
|
|
105
|
+
*
|
|
106
|
+
* The effective subset is still checked for grant/response consistency, and
|
|
107
|
+
* token endpoint authentication must have a mutually supported method.
|
|
108
|
+
*/
|
|
109
|
+
function negotiateCimdClientCapabilities(server, client) {
|
|
110
|
+
const effective = {
|
|
111
|
+
grantTypes: client.grantTypes.filter((grantType) => server.grantTypes.includes(grantType)),
|
|
112
|
+
responseTypes: client.responseTypes.filter((responseType) => server.responseTypes.includes(responseType)),
|
|
113
|
+
tokenEndpointAuthMethod: negotiateCimdTokenEndpointAuthMethod(server, client.tokenEndpointAuthMethod, client.tokenEndpointAuthMethodsSupported)
|
|
114
|
+
};
|
|
115
|
+
validateClientCapabilities(server, effective);
|
|
116
|
+
return effective;
|
|
117
|
+
}
|
|
56
118
|
function validateAuthorizationResponseType(server, responseType, clientResponseTypes) {
|
|
57
119
|
if (!responseType) throw new AuthorizationError("invalid_request", { description: "response_type is required" });
|
|
58
120
|
if (!server.responseTypes.includes(responseType)) throw new AuthorizationError("unsupported_response_type", { description: `The authorization server does not support response_type ${responseType}` });
|
|
@@ -88,6 +150,403 @@ function isValidOAuthScopeToken(scopeToken) {
|
|
|
88
150
|
return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
|
|
89
151
|
}
|
|
90
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
|
+
|
|
91
550
|
//#endregion
|
|
92
551
|
//#region src/ema/constants.ts
|
|
93
552
|
/**
|
|
@@ -794,6 +1253,15 @@ let GrantType = /* @__PURE__ */ function(GrantType$1) {
|
|
|
794
1253
|
GrantType$1["JWT_BEARER"] = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
|
795
1254
|
return GrantType$1;
|
|
796
1255
|
}({});
|
|
1256
|
+
function toPublicClientInfo(client) {
|
|
1257
|
+
const { authMethodExplicit: _explicit, ...publicClient } = client;
|
|
1258
|
+
return publicClient;
|
|
1259
|
+
}
|
|
1260
|
+
function isClientAuthMethodAllowed(client, presentedMethod, isClientMetadataDocument) {
|
|
1261
|
+
if (presentedMethod === client.tokenEndpointAuthMethod) return true;
|
|
1262
|
+
const isSecretMethod = (method) => method === "client_secret_basic" || method === "client_secret_post";
|
|
1263
|
+
return !isClientMetadataDocument && client.authMethodExplicit === void 0 && isSecretMethod(client.tokenEndpointAuthMethod) && isSecretMethod(presentedMethod);
|
|
1264
|
+
}
|
|
797
1265
|
/**
|
|
798
1266
|
* OAuth 2.0 Provider implementation for Cloudflare Workers
|
|
799
1267
|
* Implements authorization code flow with support for refresh tokens
|
|
@@ -848,7 +1316,7 @@ function getOAuthApi(options, env) {
|
|
|
848
1316
|
* annotation, and does not actually prevent the method from being called from outside the class,
|
|
849
1317
|
* including over RPC.
|
|
850
1318
|
*/
|
|
851
|
-
var OAuthProviderImpl = class
|
|
1319
|
+
var OAuthProviderImpl = class {
|
|
852
1320
|
/**
|
|
853
1321
|
* Creates a new OAuth provider instance
|
|
854
1322
|
* @param options - Configuration options for the provider
|
|
@@ -935,7 +1403,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
935
1403
|
/** Validate configured RFC 9728 protected resource metadata. */
|
|
936
1404
|
validateResourceMetadataOptions(options) {
|
|
937
1405
|
if (!options) return;
|
|
938
|
-
if (options.resource && !validateResourceUri(options.resource)) throw new TypeError("resourceMetadata.resource must be an absolute HTTP(S) URI without a fragment");
|
|
1406
|
+
if (options.resource !== void 0 && !validateResourceUri(options.resource)) throw new TypeError("resourceMetadata.resource must be an absolute HTTP(S) URI without a fragment");
|
|
939
1407
|
if (options.authorization_servers !== void 0) {
|
|
940
1408
|
if (options.authorization_servers.length === 0) throw new TypeError("resourceMetadata.authorization_servers must contain at least one issuer");
|
|
941
1409
|
for (const issuer of options.authorization_servers) {
|
|
@@ -1179,7 +1647,16 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1179
1647
|
}
|
|
1180
1648
|
if (!clientInfo) return this.createInvalidClientResponse("Client not found", basicAuthenticationAttempted);
|
|
1181
1649
|
const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : formData.has("client_secret") ? "client_secret_post" : "none";
|
|
1182
|
-
|
|
1650
|
+
const registeredAuthMethod = clientInfo.tokenEndpointAuthMethod;
|
|
1651
|
+
if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && this.isClientMetadataUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
|
|
1652
|
+
category: "client-authentication",
|
|
1653
|
+
reason: "token_endpoint_auth_method_mismatch",
|
|
1654
|
+
detail: {
|
|
1655
|
+
clientId: clientInfo.clientId,
|
|
1656
|
+
registeredMethod: registeredAuthMethod,
|
|
1657
|
+
presentedMethod: presentedAuthMethod
|
|
1658
|
+
}
|
|
1659
|
+
});
|
|
1183
1660
|
if (presentedAuthMethod !== "none") {
|
|
1184
1661
|
if (!clientSecret) return this.createInvalidClientResponse("Client authentication failed: missing client_secret", basicAuthenticationAttempted);
|
|
1185
1662
|
if (!clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: client has no registered secret", basicAuthenticationAttempted);
|
|
@@ -1671,7 +2148,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1671
2148
|
let tokenScopes = this.downscope(requestedScopes, tokenSummary.scope);
|
|
1672
2149
|
const configuredResource = this.options.resourceMetadata?.resource;
|
|
1673
2150
|
if (configuredResource && !isExactResource(tokenSummary.audience, configuredResource)) throw new OAuthError("invalid_target", { description: "Subject token is not bound to the configured resource" });
|
|
1674
|
-
const newAudience = this.resolveTokenResource(requestedResource, grantData.resource);
|
|
2151
|
+
const newAudience = requestedResource === void 0 ? tokenSummary.audience : this.resolveTokenResource(requestedResource, grantData.resource);
|
|
1675
2152
|
const now = Math.floor(Date.now() / 1e3);
|
|
1676
2153
|
const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
|
|
1677
2154
|
if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_grant", { description: "Subject token is too close to expiry to exchange" });
|
|
@@ -2064,35 +2541,29 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2064
2541
|
statusCode: 413
|
|
2065
2542
|
});
|
|
2066
2543
|
const callbackRequest = request.clone();
|
|
2067
|
-
let
|
|
2544
|
+
let parsedJson;
|
|
2068
2545
|
try {
|
|
2069
2546
|
const text = await request.text();
|
|
2070
2547
|
if (text.length > 1048576) return this.createErrorResponse("invalid_request", {
|
|
2071
2548
|
description: "Request payload too large, must be under 1 MiB",
|
|
2072
2549
|
statusCode: 413
|
|
2073
2550
|
});
|
|
2074
|
-
|
|
2075
|
-
} catch
|
|
2551
|
+
parsedJson = JSON.parse(text);
|
|
2552
|
+
} catch {
|
|
2076
2553
|
return this.createErrorResponse("invalid_request", {
|
|
2077
2554
|
description: "Invalid JSON payload",
|
|
2078
2555
|
statusCode: 400
|
|
2079
2556
|
});
|
|
2080
2557
|
}
|
|
2081
|
-
let
|
|
2082
|
-
let
|
|
2083
|
-
let responseTypes;
|
|
2558
|
+
let clientMetadata;
|
|
2559
|
+
let metadata;
|
|
2084
2560
|
try {
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
responseTypes = OAuthProviderImpl.validateStringArray(clientMetadata.response_types, "response_types") || ["code"];
|
|
2088
|
-
validateClientCapabilities(this.serverCapabilities, {
|
|
2089
|
-
tokenEndpointAuthMethod: authMethod,
|
|
2090
|
-
grantTypes,
|
|
2091
|
-
responseTypes
|
|
2092
|
-
});
|
|
2561
|
+
clientMetadata = requireJsonObject(parsedJson);
|
|
2562
|
+
metadata = resolveDynamicClientRegistrationMetadata(clientMetadata, this.serverCapabilities);
|
|
2093
2563
|
} catch (error) {
|
|
2094
2564
|
return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
|
|
2095
2565
|
}
|
|
2566
|
+
const authMethod = metadata.tokenEndpointAuthMethod;
|
|
2096
2567
|
const isPublicClient = authMethod === "none";
|
|
2097
2568
|
if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" });
|
|
2098
2569
|
const clientId = generateRandomString(16);
|
|
@@ -2102,31 +2573,24 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2102
2573
|
clientSecret = generateRandomString(32);
|
|
2103
2574
|
hashedSecret = await hashSecret(clientSecret);
|
|
2104
2575
|
}
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
registrationDate: Math.floor(Date.now() / 1e3),
|
|
2124
|
-
tokenEndpointAuthMethod: authMethod
|
|
2125
|
-
};
|
|
2126
|
-
if (!isPublicClient && hashedSecret) clientInfo.clientSecret = hashedSecret;
|
|
2127
|
-
} catch (error) {
|
|
2128
|
-
return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
|
|
2129
|
-
}
|
|
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
|
+
};
|
|
2130
2594
|
if (this.options.clientRegistrationCallback) {
|
|
2131
2595
|
let callbackResult;
|
|
2132
2596
|
try {
|
|
@@ -2147,7 +2611,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2147
2611
|
}
|
|
2148
2612
|
const clientKvOptions = {};
|
|
2149
2613
|
if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
|
|
2150
|
-
await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo), clientKvOptions);
|
|
2614
|
+
await env.OAUTH_KV.put(`client:${clientInfo.clientId}`, JSON.stringify(clientInfo), clientKvOptions);
|
|
2151
2615
|
const response = {
|
|
2152
2616
|
client_id: clientInfo.clientId,
|
|
2153
2617
|
redirect_uris: clientInfo.redirectUris,
|
|
@@ -2342,7 +2806,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2342
2806
|
}
|
|
2343
2807
|
if (!this.hasGlobalFetchStrictlyPublic()) throw new Error(`CIMD is enabled but 'global_fetch_strictly_public' compatibility flag is not set.`);
|
|
2344
2808
|
try {
|
|
2345
|
-
return await this.
|
|
2809
|
+
return await fetchClientIdMetadataDocument(clientId, this.serverCapabilities);
|
|
2346
2810
|
} catch (error) {
|
|
2347
2811
|
console.warn(`CIMD fetch failed for ${clientId}:`, error instanceof Error ? error.message : error);
|
|
2348
2812
|
throw new CimdFetchError(clientId, error);
|
|
@@ -2353,19 +2817,26 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2353
2817
|
}
|
|
2354
2818
|
/**
|
|
2355
2819
|
* Resolves an access-token audience from a token request and its authorization grant.
|
|
2356
|
-
*
|
|
2357
|
-
* configuration, RFC 8707 downscoping is allowed
|
|
2820
|
+
* A configured canonical resource is inherited when omitted but cannot be overridden.
|
|
2821
|
+
* Without configuration, RFC 8707 downscoping is allowed, omission inherits a
|
|
2822
|
+
* bound grant, and a legacy unbound grant retains the v0.8.2 behavior.
|
|
2358
2823
|
*/
|
|
2359
2824
|
resolveTokenResource(requestedResource, grantedResource) {
|
|
2825
|
+
const resourceWasProvided = requestedResource !== void 0;
|
|
2360
2826
|
const requestedAudience = parseResourceParameter(requestedResource);
|
|
2361
|
-
if (
|
|
2827
|
+
if (resourceWasProvided && !requestedAudience) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
2828
|
+
const grantResourceWasStored = grantedResource !== void 0;
|
|
2362
2829
|
const grantedAudience = parseResourceParameter(grantedResource);
|
|
2363
|
-
if (
|
|
2830
|
+
if (grantResourceWasStored && !grantedAudience) throw new OAuthError("invalid_target", { description: "The authorization grant contains an invalid resource" });
|
|
2364
2831
|
const configuredResource = this.options.resourceMetadata?.resource;
|
|
2365
|
-
if (configuredResource
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2832
|
+
if (configuredResource) {
|
|
2833
|
+
if (resourceWasProvided && !isExactResource(requestedResource, configuredResource)) throw new OAuthError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
|
|
2834
|
+
if (isExactResource(grantedResource, configuredResource)) return configuredResource;
|
|
2835
|
+
if (!grantResourceWasStored) return configuredResource;
|
|
2836
|
+
throw new OAuthError("invalid_target", { description: "The authorization grant is not bound to the configured resource" });
|
|
2837
|
+
}
|
|
2838
|
+
const originOnly = !!this.options.resourceMatchOriginOnly;
|
|
2839
|
+
if (resourceWasProvided && grantResourceWasStored) {
|
|
2369
2840
|
const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
|
|
2370
2841
|
const grantedResources = Array.isArray(grantedResource) ? grantedResource : [grantedResource];
|
|
2371
2842
|
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) throw new OAuthError("invalid_target", { description: "Requested resource was not included in the authorization request" });
|
|
@@ -2430,207 +2901,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2430
2901
|
* Not private because OAuthHelpersImpl needs access for purgeExpiredData.
|
|
2431
2902
|
*/
|
|
2432
2903
|
isClientMetadataUrl(clientId) {
|
|
2433
|
-
|
|
2434
|
-
const url = new URL(clientId);
|
|
2435
|
-
return url.protocol === "https:" && url.pathname !== "/";
|
|
2436
|
-
} catch {
|
|
2437
|
-
return false;
|
|
2438
|
-
}
|
|
2439
|
-
}
|
|
2440
|
-
static {
|
|
2441
|
-
this.CIMD_MAX_SIZE_BYTES = 5 * 1024;
|
|
2442
|
-
}
|
|
2443
|
-
static {
|
|
2444
|
-
this.CIMD_FETCH_TIMEOUT_MS = 1e4;
|
|
2445
|
-
}
|
|
2446
|
-
static {
|
|
2447
|
-
this.CIMD_ALLOWED_AUTH_METHODS = ["none"];
|
|
2448
|
-
}
|
|
2449
|
-
/**
|
|
2450
|
-
* Validates that a field is a string or undefined
|
|
2451
|
-
* @param field - The field value to validate
|
|
2452
|
-
* @param fieldName - Name of the field for error messages
|
|
2453
|
-
* @returns The validated string or undefined
|
|
2454
|
-
* @throws Error if field is not a string or undefined
|
|
2455
|
-
*/
|
|
2456
|
-
static validateStringField(field, fieldName) {
|
|
2457
|
-
if (field === void 0) return void 0;
|
|
2458
|
-
if (typeof field !== "string") throw new Error(fieldName ? `Invalid ${fieldName}: expected string, got ${typeof field}` : "Field must be a string");
|
|
2459
|
-
return field;
|
|
2460
|
-
}
|
|
2461
|
-
/**
|
|
2462
|
-
* Validates that a field is an optional URI string using a safe scheme.
|
|
2463
|
-
*
|
|
2464
|
-
* Client metadata URI fields (e.g. logo_uri, client_uri, policy_uri, tos_uri,
|
|
2465
|
-
* jwks_uri) are frequently rendered into HTML attributes such as `<a href>` or
|
|
2466
|
-
* `<img src>` on consent screens. Permitting non-http(s) schemes such as
|
|
2467
|
-
* `javascript:` or `data:` would allow script execution in that context, so we
|
|
2468
|
-
* require an absolute http: or https: URL here, matching how redirect URIs are
|
|
2469
|
-
* already restricted.
|
|
2470
|
-
*
|
|
2471
|
-
* @param field - The field to validate
|
|
2472
|
-
* @param fieldName - Name of the field for error messages
|
|
2473
|
-
* @returns The validated URI string or undefined
|
|
2474
|
-
* @throws Error if the field is not a string or is not an absolute http(s) URL
|
|
2475
|
-
*/
|
|
2476
|
-
static validateOptionalUriField(field, fieldName) {
|
|
2477
|
-
const value = OAuthProviderImpl.validateStringField(field, fieldName);
|
|
2478
|
-
if (value === void 0) return void 0;
|
|
2479
|
-
let parsed;
|
|
2480
|
-
try {
|
|
2481
|
-
parsed = new URL(value);
|
|
2482
|
-
} catch {
|
|
2483
|
-
throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
|
|
2484
|
-
}
|
|
2485
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
|
|
2486
|
-
return value;
|
|
2487
|
-
}
|
|
2488
|
-
static {
|
|
2489
|
-
this.I18N_FIELDS = {
|
|
2490
|
-
client_name: "string",
|
|
2491
|
-
client_uri: "uri",
|
|
2492
|
-
logo_uri: "uri",
|
|
2493
|
-
tos_uri: "uri",
|
|
2494
|
-
policy_uri: "uri"
|
|
2495
|
-
};
|
|
2496
|
-
}
|
|
2497
|
-
/**
|
|
2498
|
-
* Extracts RFC 7591 §2.2 internationalized metadata variants from a raw
|
|
2499
|
-
* registration payload.
|
|
2500
|
-
*
|
|
2501
|
-
* Localized variants are expressed by appending a `#<BCP 47 language tag>`
|
|
2502
|
-
* suffix to a metadata member name (e.g. `client_name#ja`, `tos_uri#fr`).
|
|
2503
|
-
* Only the human-readable fields the RFC names are considered; each value is
|
|
2504
|
-
* validated with the same rules as its canonical field (URI fields must be
|
|
2505
|
-
* absolute http(s) URLs). The raw `field#tag` keys are preserved verbatim so
|
|
2506
|
-
* that consumers can do their own locale matching.
|
|
2507
|
-
*
|
|
2508
|
-
* @param raw - The parsed client metadata object
|
|
2509
|
-
* @returns A map of `field#tag` to validated value, or undefined if none present
|
|
2510
|
-
* @throws Error if a localized value fails its field's validation
|
|
2511
|
-
*/
|
|
2512
|
-
static extractI18nFields(raw) {
|
|
2513
|
-
const result = {};
|
|
2514
|
-
for (const key of Object.keys(raw)) {
|
|
2515
|
-
const hashIndex = key.indexOf("#");
|
|
2516
|
-
if (hashIndex <= 0 || hashIndex === key.length - 1) continue;
|
|
2517
|
-
const baseField = key.slice(0, hashIndex);
|
|
2518
|
-
const kind = OAuthProviderImpl.I18N_FIELDS[baseField];
|
|
2519
|
-
if (!kind) continue;
|
|
2520
|
-
const validated = kind === "uri" ? OAuthProviderImpl.validateOptionalUriField(raw[key], key) : OAuthProviderImpl.validateStringField(raw[key], key);
|
|
2521
|
-
if (validated !== void 0) result[key] = validated;
|
|
2522
|
-
}
|
|
2523
|
-
return Object.keys(result).length > 0 ? result : void 0;
|
|
2524
|
-
}
|
|
2525
|
-
/**
|
|
2526
|
-
* Validates that a field is a string array or undefined
|
|
2527
|
-
* @param arr - The array to validate
|
|
2528
|
-
* @param fieldName - Name of the field for error messages
|
|
2529
|
-
* @returns The validated string array or undefined
|
|
2530
|
-
* @throws Error if field is not a string array or undefined
|
|
2531
|
-
*/
|
|
2532
|
-
static validateStringArray(arr, fieldName) {
|
|
2533
|
-
if (arr === void 0) return void 0;
|
|
2534
|
-
if (!Array.isArray(arr)) throw new Error(fieldName ? `Invalid ${fieldName}: expected array, got ${typeof arr}` : "Field must be an array");
|
|
2535
|
-
if (!arr.every((item) => typeof item === "string")) throw new Error(fieldName ? `Invalid ${fieldName}: array must contain only strings` : "All array elements must be strings");
|
|
2536
|
-
return arr;
|
|
2537
|
-
}
|
|
2538
|
-
/**
|
|
2539
|
-
* Fetches and validates a Client ID Metadata Document from the given URL
|
|
2540
|
-
* Per the MCP spec, the client_id in the document must match the URL exactly
|
|
2541
|
-
*
|
|
2542
|
-
* Uses Cloudflare HTTP cache for caching (via cacheEverything option).
|
|
2543
|
-
* Response size is limited to 5KB per IETF spec.
|
|
2544
|
-
*
|
|
2545
|
-
* @param metadataUrl - The HTTPS URL to fetch metadata from
|
|
2546
|
-
* @returns The client information
|
|
2547
|
-
* @throws Error if fetch fails or validation fails
|
|
2548
|
-
*/
|
|
2549
|
-
async fetchClientMetadataDocument(metadataUrl) {
|
|
2550
|
-
const abortController = new AbortController();
|
|
2551
|
-
const timeoutId = setTimeout(() => abortController.abort(), OAuthProviderImpl.CIMD_FETCH_TIMEOUT_MS);
|
|
2552
|
-
try {
|
|
2553
|
-
const response = await fetch(metadataUrl, {
|
|
2554
|
-
headers: { Accept: "application/json" },
|
|
2555
|
-
signal: abortController.signal,
|
|
2556
|
-
cf: { cacheEverything: true }
|
|
2557
|
-
});
|
|
2558
|
-
clearTimeout(timeoutId);
|
|
2559
|
-
if (!response.ok) throw new Error(`Failed to fetch client metadata: HTTP ${response.status}`);
|
|
2560
|
-
const contentLength = response.headers.get("content-length");
|
|
2561
|
-
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})`);
|
|
2562
|
-
const rawMetadata = await this.readJsonWithSizeLimit(response, OAuthProviderImpl.CIMD_MAX_SIZE_BYTES);
|
|
2563
|
-
const clientId = OAuthProviderImpl.validateStringField(rawMetadata.client_id, "client_id");
|
|
2564
|
-
const clientName = OAuthProviderImpl.validateStringField(rawMetadata.client_name, "client_name");
|
|
2565
|
-
const redirectUris = OAuthProviderImpl.validateStringArray(rawMetadata.redirect_uris, "redirect_uris");
|
|
2566
|
-
const declaredAuthMethod = OAuthProviderImpl.validateStringField(rawMetadata.token_endpoint_auth_method, "token_endpoint_auth_method");
|
|
2567
|
-
const authMethodChoices = OAuthProviderImpl.validateStringArray(rawMetadata.token_endpoint_auth_methods_supported, "token_endpoint_auth_methods_supported");
|
|
2568
|
-
const tokenEndpointAuthMethod = declaredAuthMethod ?? (authMethodChoices?.includes("none") ? "none" : void 0);
|
|
2569
|
-
if (clientId !== metadataUrl) throw new Error(`client_id "${clientId}" does not match metadata URL "${metadataUrl}"`);
|
|
2570
|
-
if (!clientName?.trim()) throw new Error("client_name is required and must not be empty");
|
|
2571
|
-
if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
|
|
2572
|
-
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(", ")}`);
|
|
2573
|
-
const grantTypes = OAuthProviderImpl.validateStringArray(rawMetadata.grant_types, "grant_types") || [GrantType.AUTHORIZATION_CODE];
|
|
2574
|
-
const responseTypes = OAuthProviderImpl.validateStringArray(rawMetadata.response_types, "response_types") || ["code"];
|
|
2575
|
-
const effectiveAuthMethod = tokenEndpointAuthMethod || "none";
|
|
2576
|
-
validateClientCapabilities(this.serverCapabilities, {
|
|
2577
|
-
tokenEndpointAuthMethod: effectiveAuthMethod,
|
|
2578
|
-
grantTypes,
|
|
2579
|
-
responseTypes
|
|
2580
|
-
});
|
|
2581
|
-
return {
|
|
2582
|
-
clientId,
|
|
2583
|
-
redirectUris,
|
|
2584
|
-
clientName,
|
|
2585
|
-
clientUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.client_uri, "client_uri"),
|
|
2586
|
-
logoUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.logo_uri, "logo_uri"),
|
|
2587
|
-
policyUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.policy_uri, "policy_uri"),
|
|
2588
|
-
tosUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.tos_uri, "tos_uri"),
|
|
2589
|
-
jwksUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.jwks_uri, "jwks_uri"),
|
|
2590
|
-
i18n: OAuthProviderImpl.extractI18nFields(rawMetadata),
|
|
2591
|
-
contacts: OAuthProviderImpl.validateStringArray(rawMetadata.contacts, "contacts"),
|
|
2592
|
-
grantTypes,
|
|
2593
|
-
responseTypes,
|
|
2594
|
-
tokenEndpointAuthMethod: effectiveAuthMethod
|
|
2595
|
-
};
|
|
2596
|
-
} finally {
|
|
2597
|
-
clearTimeout(timeoutId);
|
|
2598
|
-
}
|
|
2599
|
-
}
|
|
2600
|
-
/**
|
|
2601
|
-
* Reads JSON from a response with a size limit to prevent DoS attacks.
|
|
2602
|
-
* Streams the response body and aborts if it exceeds the limit.
|
|
2603
|
-
*
|
|
2604
|
-
* @param response - The fetch response
|
|
2605
|
-
* @param maxBytes - Maximum allowed size in bytes
|
|
2606
|
-
* @returns Parsed JSON object
|
|
2607
|
-
* @throws Error if response body is null, size exceeded, or JSON parse failed
|
|
2608
|
-
*/
|
|
2609
|
-
async readJsonWithSizeLimit(response, maxBytes) {
|
|
2610
|
-
const reader = response.body?.getReader();
|
|
2611
|
-
if (!reader) throw new Error("Response body is null");
|
|
2612
|
-
const chunks = [];
|
|
2613
|
-
let totalSize = 0;
|
|
2614
|
-
while (true) {
|
|
2615
|
-
const { done, value } = await reader.read();
|
|
2616
|
-
if (done) break;
|
|
2617
|
-
if (value) {
|
|
2618
|
-
totalSize += value.length;
|
|
2619
|
-
if (totalSize > maxBytes) {
|
|
2620
|
-
await reader.cancel();
|
|
2621
|
-
throw new Error(`Response exceeded size limit of ${maxBytes} bytes`);
|
|
2622
|
-
}
|
|
2623
|
-
chunks.push(value);
|
|
2624
|
-
}
|
|
2625
|
-
}
|
|
2626
|
-
const allChunks = new Uint8Array(totalSize);
|
|
2627
|
-
let position = 0;
|
|
2628
|
-
for (const chunk of chunks) {
|
|
2629
|
-
allChunks.set(chunk, position);
|
|
2630
|
-
position += chunk.length;
|
|
2631
|
-
}
|
|
2632
|
-
const text = new TextDecoder().decode(allChunks);
|
|
2633
|
-
return JSON.parse(text);
|
|
2904
|
+
return isClientIdMetadataDocumentUrl(clientId);
|
|
2634
2905
|
}
|
|
2635
2906
|
/**
|
|
2636
2907
|
* Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
|
|
@@ -2885,6 +3156,7 @@ function audienceMatches(resourceServerUrl, audienceValue) {
|
|
|
2885
3156
|
function parseResourceParameter(value) {
|
|
2886
3157
|
if (!value) return;
|
|
2887
3158
|
const uris = Array.isArray(value) ? value : [value];
|
|
3159
|
+
if (uris.length === 0) return;
|
|
2888
3160
|
for (const uri of uris) if (typeof uri !== "string" || !validateResourceUri(uri)) return;
|
|
2889
3161
|
return value;
|
|
2890
3162
|
}
|
|
@@ -2968,34 +3240,6 @@ async function generateTokenId(token) {
|
|
|
2968
3240
|
return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2969
3241
|
}
|
|
2970
3242
|
/**
|
|
2971
|
-
* Validates that a redirect URI does not use a dangerous pseudo-scheme.
|
|
2972
|
-
* Normalizes the URI by trimming whitespace and checking the scheme in a
|
|
2973
|
-
* case-insensitive manner to prevent bypass attacks.
|
|
2974
|
-
* Per RFC 3986, control characters are explicitly disallowed in URIs and
|
|
2975
|
-
* will cause rejection rather than silent removal.
|
|
2976
|
-
* @param redirectUri - The redirect URI to validate
|
|
2977
|
-
* @throws Error if the URI uses a blacklisted scheme or contains control characters
|
|
2978
|
-
*/
|
|
2979
|
-
function validateRedirectUriScheme(redirectUri) {
|
|
2980
|
-
const dangerousSchemes = [
|
|
2981
|
-
"javascript:",
|
|
2982
|
-
"data:",
|
|
2983
|
-
"vbscript:",
|
|
2984
|
-
"file:",
|
|
2985
|
-
"mailto:",
|
|
2986
|
-
"blob:"
|
|
2987
|
-
];
|
|
2988
|
-
const normalized = redirectUri.trim();
|
|
2989
|
-
for (let i = 0; i < normalized.length; i++) {
|
|
2990
|
-
const code = normalized.charCodeAt(i);
|
|
2991
|
-
if (code >= 0 && code <= 31 || code >= 127 && code <= 159) throw new Error("Invalid redirect URI");
|
|
2992
|
-
}
|
|
2993
|
-
const colonIndex = normalized.indexOf(":");
|
|
2994
|
-
if (colonIndex === -1) throw new Error("Invalid redirect URI");
|
|
2995
|
-
const scheme = normalized.substring(0, colonIndex + 1).toLowerCase();
|
|
2996
|
-
for (const dangerousScheme of dangerousSchemes) if (scheme === dangerousScheme) throw new Error("Invalid redirect URI");
|
|
2997
|
-
}
|
|
2998
|
-
/**
|
|
2999
3243
|
* Checks if a URI is a loopback redirect URI (127.0.0.0/8, ::1, or localhost).
|
|
3000
3244
|
* Per RFC 8252 Section 7.3, loopback IPs get special port handling. This library
|
|
3001
3245
|
* applies the same port flexibility to localhost for native apps (e.g., Claude Code).
|
|
@@ -3258,11 +3502,14 @@ var OAuthHelpersImpl = class {
|
|
|
3258
3502
|
const withRedirect = (error) => {
|
|
3259
3503
|
throw withAuthorizationRedirect(error, redirectUri, state || void 0, issuer);
|
|
3260
3504
|
};
|
|
3505
|
+
const resourceWasProvided = resourceParam !== void 0;
|
|
3261
3506
|
let resource = parseResourceParameter(resourceParam);
|
|
3262
|
-
if (
|
|
3507
|
+
if (resourceWasProvided && !resource) withRedirect(new AuthorizationError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" }));
|
|
3263
3508
|
const configuredResource = this.provider.options.resourceMetadata?.resource;
|
|
3264
|
-
if (configuredResource
|
|
3265
|
-
|
|
3509
|
+
if (configuredResource) {
|
|
3510
|
+
if (resourceWasProvided && !isExactResource(resource, configuredResource)) withRedirect(new AuthorizationError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` }));
|
|
3511
|
+
resource = configuredResource;
|
|
3512
|
+
}
|
|
3266
3513
|
try {
|
|
3267
3514
|
validateAuthorizationResponseType(this.provider.serverCapabilities, responseType, clientInfo.responseTypes);
|
|
3268
3515
|
validateAuthorizationPkce(this.provider.serverCapabilities, {
|
|
@@ -3298,7 +3545,8 @@ var OAuthHelpersImpl = class {
|
|
|
3298
3545
|
* validating the metadata document fails.
|
|
3299
3546
|
*/
|
|
3300
3547
|
async lookupClient(clientId) {
|
|
3301
|
-
|
|
3548
|
+
const client = await this.provider.getClient(this.env, clientId);
|
|
3549
|
+
return client ? toPublicClientInfo(client) : null;
|
|
3302
3550
|
}
|
|
3303
3551
|
/**
|
|
3304
3552
|
* Completes an authorization request by creating a grant and either:
|
|
@@ -3316,8 +3564,11 @@ var OAuthHelpersImpl = class {
|
|
|
3316
3564
|
if (!clientInfo || !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new Error("Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.");
|
|
3317
3565
|
validateAuthorizationResponseType(this.provider.serverCapabilities, options.request.responseType, clientInfo.responseTypes);
|
|
3318
3566
|
const configuredResource = this.provider.options.resourceMetadata?.resource;
|
|
3319
|
-
|
|
3320
|
-
const
|
|
3567
|
+
const resourceWasProvided = options.request.resource !== void 0;
|
|
3568
|
+
const parsedResource = parseResourceParameter(options.request.resource);
|
|
3569
|
+
if (resourceWasProvided && !parsedResource) throw new AuthorizationError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
3570
|
+
if (configuredResource && resourceWasProvided && !isExactResource(parsedResource, configuredResource)) throw new AuthorizationError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
|
|
3571
|
+
const effectiveResource = configuredResource ?? parsedResource;
|
|
3321
3572
|
validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
|
|
3322
3573
|
let grantsToRevoke = [];
|
|
3323
3574
|
if (options.revokeExistingGrants !== false) {
|
|
@@ -3423,6 +3674,7 @@ var OAuthHelpersImpl = class {
|
|
|
3423
3674
|
*/
|
|
3424
3675
|
async createClient(clientInfo) {
|
|
3425
3676
|
const clientId = generateRandomString(16);
|
|
3677
|
+
const authMethodWasExplicit = clientInfo.tokenEndpointAuthMethod !== void 0;
|
|
3426
3678
|
const tokenEndpointAuthMethod = clientInfo.tokenEndpointAuthMethod || "client_secret_basic";
|
|
3427
3679
|
const isPublicClient = tokenEndpointAuthMethod === "none";
|
|
3428
3680
|
const newClient = {
|
|
@@ -3443,7 +3695,8 @@ var OAuthHelpersImpl = class {
|
|
|
3443
3695
|
],
|
|
3444
3696
|
responseTypes: clientInfo.responseTypes || ["code"],
|
|
3445
3697
|
registrationDate: Math.floor(Date.now() / 1e3),
|
|
3446
|
-
tokenEndpointAuthMethod
|
|
3698
|
+
tokenEndpointAuthMethod,
|
|
3699
|
+
...authMethodWasExplicit ? { authMethodExplicit: true } : {}
|
|
3447
3700
|
};
|
|
3448
3701
|
for (const uri of newClient.redirectUris) validateRedirectUriScheme(uri);
|
|
3449
3702
|
let clientSecret;
|
|
@@ -3452,7 +3705,7 @@ var OAuthHelpersImpl = class {
|
|
|
3452
3705
|
newClient.clientSecret = await hashSecret(clientSecret);
|
|
3453
3706
|
}
|
|
3454
3707
|
await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(newClient));
|
|
3455
|
-
const clientResponse =
|
|
3708
|
+
const clientResponse = toPublicClientInfo(newClient);
|
|
3456
3709
|
if (!isPublicClient && clientSecret) clientResponse.clientSecret = clientSecret;
|
|
3457
3710
|
return clientResponse;
|
|
3458
3711
|
}
|
|
@@ -3470,7 +3723,7 @@ var OAuthHelpersImpl = class {
|
|
|
3470
3723
|
const promises = response.keys.map(async (key) => {
|
|
3471
3724
|
const clientId = key.name.substring(7);
|
|
3472
3725
|
const client = await this.provider.getClient(this.env, clientId);
|
|
3473
|
-
if (client) clients.push(client);
|
|
3726
|
+
if (client) clients.push(toPublicClientInfo(client));
|
|
3474
3727
|
});
|
|
3475
3728
|
await Promise.all(promises);
|
|
3476
3729
|
return {
|
|
@@ -3487,7 +3740,8 @@ var OAuthHelpersImpl = class {
|
|
|
3487
3740
|
async updateClient(clientId, updates) {
|
|
3488
3741
|
const client = await this.provider.getClient(this.env, clientId);
|
|
3489
3742
|
if (!client) return null;
|
|
3490
|
-
|
|
3743
|
+
const authMethodWasExplicit = updates.tokenEndpointAuthMethod !== void 0;
|
|
3744
|
+
const authMethod = updates.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod || "client_secret_basic";
|
|
3491
3745
|
const isPublicClient = authMethod === "none";
|
|
3492
3746
|
let secretToStore = client.clientSecret;
|
|
3493
3747
|
let originalSecret = void 0;
|
|
@@ -3500,14 +3754,15 @@ var OAuthHelpersImpl = class {
|
|
|
3500
3754
|
...client,
|
|
3501
3755
|
...updates,
|
|
3502
3756
|
clientId: client.clientId,
|
|
3503
|
-
tokenEndpointAuthMethod: authMethod
|
|
3757
|
+
tokenEndpointAuthMethod: authMethod,
|
|
3758
|
+
authMethodExplicit: authMethodWasExplicit ? true : client.authMethodExplicit
|
|
3504
3759
|
};
|
|
3505
3760
|
if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
|
|
3506
3761
|
else delete updatedClient.clientSecret;
|
|
3507
3762
|
const clientKvOptions = {};
|
|
3508
3763
|
if (this.provider.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.provider.options.clientRegistrationTTL;
|
|
3509
3764
|
await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient), clientKvOptions);
|
|
3510
|
-
const response =
|
|
3765
|
+
const response = toPublicClientInfo(updatedClient);
|
|
3511
3766
|
if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
|
|
3512
3767
|
return response;
|
|
3513
3768
|
}
|