diff --git a/.changeset/oauth-discovery-schema-by-document.md b/.changeset/oauth-discovery-schema-by-document.md new file mode 100644 index 0000000000..0c54f351e5 --- /dev/null +++ b/.changeset/oauth-discovery-schema-by-document.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/client': patch +--- + +OAuth discovery now validates authorization server metadata by its document shape instead of by the well-known path it was found under: RFC 8414 metadata served at the `openid-configuration` path (permitted by RFC 8414 §5) is accepted; an invalid value in an optional field drops that field instead of failing the document; a document that fits neither schema skips to the next candidate URL, and if every candidate fails validation, discovery throws an error naming the schema issues instead of silently falling back to guessed endpoints. `OpenIdProviderDiscoveryMetadataSchema` is now a loose object that declares the RFC 8414 revocation/introspection fields with their OAuth validators, so mixed OIDC/OAuth documents keep those fields validated rather than stripped, and `introspection_endpoint` is validated as a safe URL like `revocation_endpoint`. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 802c2ec264..608e913079 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1109,6 +1109,18 @@ OAuth `onUnauthorized` behavior, for composing your own adapter). - **Metadata discovery falls through on 502.** `discoverAuthorizationServerMetadata()` treats `502 Bad Gateway` like 4xx — fall through to the next candidate URL instead of throwing (fixes path-aware discovery behind reverse proxies). Other 5xx still throw. +- **Metadata discovery validates by document shape, not well-known path.** + `discoverAuthorizationServerMetadata()` accepts conforming RFC 8414 metadata served at + the `openid-configuration` path (RFC 8414 §5) instead of rejecting it against the OIDC + schema. An invalid value in an _optional_ metadata field (including a + non-URL or `javascript:`-scheme `introspection_endpoint`/`service_documentation`, which + are now URL-validated like `revocation_endpoint`) drops that field instead of failing + the document; invalid or missing _required_ fields still reject it. A `200` document + that fits neither metadata schema falls through to the next candidate URL, and when + every candidate that returned JSON fails validation, discovery throws an `Error` naming + the schema issues instead of returning `undefined` (returning `undefined` is reserved + for "no metadata found": 404s, CORS failures, non-JSON bodies). The deprecated + `discoverOAuthMetadata()` applies the same drop-invalid-optional-fields policy. - **Scoped credential invalidation on `invalid_client` / `unauthorized_client`.** The `auth()` retry for these errors now issues two scoped calls — `invalidateCredentials('client')` then `invalidateCredentials('tokens')` — instead of diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 7b25c01fe8..95e33759d6 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1732,7 +1732,20 @@ export async function discoverOAuthMetadata( throw new Error(`HTTP ${response.status} trying to load well-known OAuth metadata`); } - return OAuthMetadataSchema.parse(await response.json()); + // Same field policy as discoverAuthorizationServerMetadata: an invalid value in an + // optional field drops the field instead of failing the document; invalid or missing + // required fields still reject; OIDC-declared fields the OAuth schema only passes + // through are sanitized rather than returned unvalidated. + const json: unknown = await response.json(); + const result = parseMetadataDroppingInvalidOptionalFields(OAuthMetadataSchema, json); + if (!result.success) { + throw result.error; + } + return dropPassthroughFieldsRejectedBySibling( + result.data, + OAuthMetadataSchema, + OpenIdProviderDiscoveryMetadataSchema.safeParse(json) + ) as OAuthMetadata; } /** @@ -1803,8 +1816,12 @@ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url: * specifications. * * This function implements a fallback strategy for authorization server discovery: - * 1. Attempts RFC 8414 OAuth metadata discovery first - * 2. If OAuth discovery fails, falls back to OpenID Connect Discovery + * 1. Tries RFC 8414 OAuth metadata discovery URLs first, then OpenID Connect Discovery URLs + * 2. Validates each response by its document shape rather than by the well-known path it was + * found under: the schema implied by the path is tried first, then the other one — + * RFC 8414 §5 explicitly permits plain OAuth 2.0 authorization server metadata to be + * served at the `openid-configuration` path. A document that fits neither schema is + * skipped and the next candidate URL is tried. * * @param authorizationServerUrl - The authorization server URL obtained from the MCP Server's * protected resource metadata, or the MCP server's URL if the @@ -1819,8 +1836,11 @@ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url: * @param options.fetchFn - Optional fetch function for making HTTP requests, defaults to global fetch * @param options.protocolVersion - MCP protocol version to use, defaults to {@linkcode LATEST_PROTOCOL_VERSION} * @param options.skipIssuerValidation - Skip the RFC 8414 §3.3 `issuer` echo check. **Security-weakening.** - * @returns Promise resolving to authorization server metadata, or undefined if discovery fails + * @returns Promise resolving to authorization server metadata, or undefined when no candidate URL + * yielded a metadata document (404s, CORS failures, non-JSON bodies) * @throws {IssuerMismatchError} when the metadata's `issuer` does not match `authorizationServerUrl` + * @throws {Error} when a candidate returns an HTTP 5xx other than 502, or when every candidate that + * returned a JSON document failed schema validation (the error names the schema issues) */ export async function discoverAuthorizationServerMetadata( authorizationServerUrl: string | URL, @@ -1842,6 +1862,11 @@ export async function discoverAuthorizationServerMetadata( // Get the list of URLs to try const urlsToTry = buildDiscoveryUrls(authorizationServerUrl); + // First candidate whose 200 response was valid JSON but fit neither metadata schema — + // surfaced when every candidate fails, so a near-miss published document stays + // diagnosable instead of silently degrading into default-endpoint guessing. + let schemaFailure: { url: URL; detail: string } | undefined; + // Try each URL in order for (const { url: endpointUrl, type } of urlsToTry) { const response = await fetchWithCorsRetry(endpointUrl, headers, fetchFn); @@ -1864,11 +1889,58 @@ export async function discoverAuthorizationServerMetadata( ); } - // Parse and validate based on type - const parsed = + // Validate by document shape, preferring the schema implied by the well-known path. + // RFC 8414 §5 explicitly permits plain OAuth 2.0 authorization server metadata to be + // served at the openid-configuration path, so the path alone cannot decide which + // schema the document must satisfy. A document that fits neither schema is treated + // like the other per-candidate failures above (4xx, 502, CORS): try the next + // candidate URL instead of aborting discovery. + let json: unknown; + try { + json = await response.json(); + } catch (error) { + // A 200 whose body is not JSON (e.g. an SPA catch-all serving HTML at the + // well-known path) is not authorization server metadata: try the next candidate. + // Anything other than a body-parse failure (a network error reading the body, + // an abort from the caller's fetchFn) still propagates. + if (error instanceof SyntaxError) { + continue; + } + throw error; + } + const [primarySchema, siblingSchema] = type === 'oauth' - ? OAuthMetadataSchema.parse(await response.json()) - : OpenIdProviderDiscoveryMetadataSchema.parse(await response.json()); + ? [OAuthMetadataSchema, OpenIdProviderDiscoveryMetadataSchema] + : [OpenIdProviderDiscoveryMetadataSchema, OAuthMetadataSchema]; + // Invalid values in optional fields drop the field rather than the document — a + // relative revocation_endpoint or an unsafe introspection_endpoint must not abort + // an auth flow that never uses those fields. Invalid required fields still fail + // the parse. + const primary = parseMetadataDroppingInvalidOptionalFields(primarySchema, json); + let parsed: AuthorizationServerMetadata; + let acceptingSchema: typeof primarySchema; + if (primary.success) { + parsed = primary.data; + acceptingSchema = primarySchema; + } else { + const fallback = parseMetadataDroppingInvalidOptionalFields(siblingSchema, json); + if (!fallback.success) { + schemaFailure ??= { + url: endpointUrl, + detail: `${summarizeMetadataIssues(primary.error.issues)} (fallback schema: ${summarizeMetadataIssues(fallback.error.issues)})` + }; + continue; + } + parsed = fallback.data; + acceptingSchema = siblingSchema; + } + // A top-level field the accepting schema does NOT declare must not ride through its + // looseObject passthrough unvalidated when the sibling schema declares and rejects + // it (e.g. a document with an unsafe jwks_uri accepted via the OAuth schema, on + // either well-known path). Fields the accepting schema itself declared and + // validated are never removed. + const siblingSanitizer = acceptingSchema === primarySchema ? siblingSchema : primarySchema; + parsed = dropPassthroughFieldsRejectedBySibling(parsed, acceptingSchema, siblingSanitizer.safeParse(json)); if (!skipIssuerValidation) { // RFC 8414 §3.3 / OIDC Discovery §4.3: the `issuer` value in the document MUST be @@ -1890,9 +1962,84 @@ export async function discoverAuthorizationServerMetadata( return parsed; } + if (schemaFailure) { + throw new Error( + `Authorization server metadata from ${schemaFailure.url} matched neither the OAuth 2.0 (RFC 8414) nor the OpenID Connect Discovery metadata schema: ${schemaFailure.detail}` + ); + } + return undefined; } +/** + * Formats schema issues from a failed authorization server metadata parse for error messages. + */ +function summarizeMetadataIssues(issues: ReadonlyArray<{ path: ReadonlyArray; message: string }>): string { + return issues.map(issue => `${issue.path.map(String).join('.') || '(document)'}: ${issue.message}`).join('; '); +} + +type AuthorizationServerMetadataSchema = typeof OAuthMetadataSchema | typeof OpenIdProviderDiscoveryMetadataSchema; + +type MetadataParseResult = + | { success: true; data: AuthorizationServerMetadata } + | { success: false; data?: undefined; error: Error & { issues: ReadonlyArray<{ path: ReadonlyArray; message: string }> } }; + +/** + * Parses authorization server metadata, retrying once with the top-level fields the first + * parse rejected removed. An invalid value in an optional field (e.g. a relative + * `revocation_endpoint`) therefore drops that field instead of failing the whole document, + * while an invalid or missing required field still fails the parse (removing it just turns + * the failure into a missing-field failure). + */ +function parseMetadataDroppingInvalidOptionalFields(schema: AuthorizationServerMetadataSchema, json: unknown): MetadataParseResult { + const first = schema.safeParse(json); + if (first.success || typeof json !== 'object' || json === null) { + return first; + } + const failedPresentKeys = new Set(); + for (const issue of first.error.issues) { + const key = issue.path[0]; + if (typeof key === 'string' && key in json) { + failedPresentKeys.add(key); + } + } + if (failedPresentKeys.size === 0) { + return first; + } + const stripped: Record = { ...(json as Record) }; + for (const key of failedPresentKeys) { + delete stripped[key]; + } + const second = schema.safeParse(stripped); + return second.success ? second : first; +} + +/** + * Drops the top-level fields of a parsed authorization server metadata document that the + * accepting schema does NOT declare (looseObject passthrough keys) when the sibling schema + * declares and rejected them, so a value that failed its declared validator (e.g. + * `SafeUrlSchema` on `jwks_uri`) can never reach callers unvalidated. Fields the accepting + * schema itself declared and validated are never removed. + */ +function dropPassthroughFieldsRejectedBySibling( + data: AuthorizationServerMetadata, + acceptingSchema: AuthorizationServerMetadataSchema, + sibling: { success: true } | { success: false; error: { issues: ReadonlyArray<{ path: ReadonlyArray }> } } +): AuthorizationServerMetadata { + if (sibling.success) { + return data; + } + const declaredByAcceptingSchema = acceptingSchema.shape; + const result: Record = { ...data }; + for (const issue of sibling.error.issues) { + const key = issue.path[0]; + if (typeof key === 'string' && !(key in declaredByAcceptingSchema)) { + delete result[key]; + } + } + return result as AuthorizationServerMetadata; +} + /** * Result of {@linkcode discoverOAuthServerInfo}. */ diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 7329ded3ed..3071c46392 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1022,9 +1022,9 @@ describe('OAuth Authorization', () => { it('preserves authorization_response_iss_parameter_supported through OIDC discovery parse', async () => { // OAuth well-known 404s; OIDC well-known returns metadata advertising RFC 9207 support. - // Regression-guard: OpenIdProviderDiscoveryMetadataSchema is a plain z.object(), so the - // field must be declared on the underlying schemas or it gets stripped — making the - // RFC 9207 §2.4 advertised-but-missing reject inert on the OIDC-only discovery path. + // Regression-guard: the field must be declared on the underlying schemas so its typed + // value is visible to the RFC 9207 §2.4 advertised-but-missing reject on the OIDC-only + // discovery path. mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); mockFetch.mockResolvedValueOnce({ ok: true, @@ -1058,6 +1058,270 @@ describe('OAuth Authorization', () => { expect(mockFetch).toHaveBeenCalledTimes(2); }); + it('accepts RFC 8414 OAuth metadata served at the openid-configuration path', async () => { + // A plain OAuth 2.0 AS (no jwks_uri / subject_types_supported / + // id_token_signing_alg_values_supported) publishing RFC 8414 metadata only at the + // OIDC well-known path — explicitly permitted by RFC 8414 §5. + const rfc8414Metadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/oauth/authorize', + token_endpoint: 'https://auth.example.com/oauth/token', + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + revocation_endpoint: 'https://auth.example.com/oauth/revoke', + introspection_endpoint: 'https://auth.example.com/oauth/introspect' + }; + + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => rfc8414Metadata + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com'); + + expect(metadata).toEqual(rfc8414Metadata); + }); + + it('preserves RFC 8414 fields on OIDC discovery documents', async () => { + // OIDC providers commonly mix in OAuth 2.0 metadata fields; a successful OIDC parse + // must not strip them (the discovery schema is a loose object). + const mixedMetadata = { + ...validOpenIdMetadata, + revocation_endpoint: 'https://auth.example.com/revoke', + introspection_endpoint: 'https://auth.example.com/introspect' + }; + + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mixedMetadata + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com'); + + expect(metadata).toEqual(mixedMetadata); + }); + + it('skips a candidate whose document fits neither schema and tries the next URL', async () => { + const tenantOidcMetadata = { ...validOpenIdMetadata, issuer: 'https://auth.example.com/tenant1' }; + + // First OAuth URL 404s + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); + // Second URL (RFC 8414-style OIDC path) returns a document that is not + // authorization server metadata at all + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ hello: 'world' }) + }); + // Third URL (OIDC Discovery 1.0-style path) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => tenantOidcMetadata + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1'); + + expect(metadata).toEqual(tenantOidcMetadata); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it('skips a candidate whose 200 body is not JSON', async () => { + // e.g. an SPA catch-all serving HTML at the well-known path + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError('Unexpected token < in JSON'); + } + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOpenIdMetadata + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com'); + + expect(metadata).toEqual(validOpenIdMetadata); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('throws a diagnosable error when every candidate document fails validation', async () => { + // The AS publishes a near-miss document (200, valid JSON, fits neither schema): + // discovery must not silently return undefined and degrade to endpoint guessing. + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ issuer: 'https://auth.example.com', token_endpoint: 'https://auth.example.com/token' }) + }); + + await expect(discoverAuthorizationServerMetadata('https://auth.example.com')).rejects.toThrow( + /matched neither the OAuth 2\.0 \(RFC 8414\) nor the OpenID Connect Discovery metadata schema/ + ); + }); + + it('drops fields that failed the path-implied schema instead of passing them through the fallback parse', async () => { + // An OIDC-shaped document whose jwks_uri fails SafeUrlSchema: the OIDC parse + // fails, the OAuth fallback succeeds — but the unsafe value must not ride + // through the OAuth schema's looseObject passthrough unvalidated. + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validOpenIdMetadata, jwks_uri: 'javascript:alert(1)' }) + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com'); + + expect(metadata).toBeDefined(); + expect(metadata).not.toHaveProperty('jwks_uri'); + // Fields that passed validation are kept + expect(metadata?.issuer).toBe('https://auth.example.com'); + expect(metadata).toHaveProperty('subject_types_supported', ['public']); + }); + + it('drops unsafe RFC 8414 endpoint values from OIDC discovery documents without failing the document', async () => { + // revocation_endpoint and introspection_endpoint are declared on the discovery + // schema with their OAuth validators (SafeUrlSchema). An unsafe value in these + // optional, client-unused fields drops the field — it must neither ride through + // the loose object's passthrough nor abort the auth flow. + for (const field of ['revocation_endpoint', 'introspection_endpoint']) { + mockFetch.mockReset(); + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validOpenIdMetadata, [field]: 'javascript:alert(1)' }) + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com'); + + expect(metadata).toBeDefined(); + expect(metadata).not.toHaveProperty(field); + expect(metadata?.issuer).toBe('https://auth.example.com'); + } + }); + + it('legacy discoverOAuthMetadata sanitizes OIDC-declared fields it only passes through', async () => { + // Same sanitization as discoverAuthorizationServerMetadata: jwks_uri is not + // declared by the OAuth schema, so an unsafe value must be dropped, not + // returned via the looseObject passthrough. + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validOpenIdMetadata, jwks_uri: 'javascript:alert(1)' }) + }); + + const metadata = await discoverOAuthMetadata('https://auth.example.com'); + + expect(metadata).toBeDefined(); + expect(metadata).not.toHaveProperty('jwks_uri'); + expect(metadata?.issuer).toBe('https://auth.example.com'); + }); + + it('legacy discoverOAuthMetadata drops an invalid optional field instead of throwing', async () => { + // The deprecated path shares the field policy: base returned this document + // (introspection_endpoint was a plain string then), so the tightened validator + // must drop the field, not reject the document. + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validOAuthMetadata, introspection_endpoint: '/oauth/introspect' }) + }); + + const metadata = await discoverOAuthMetadata('https://auth.example.com'); + + expect(metadata).toBeDefined(); + expect(metadata).not.toHaveProperty('introspection_endpoint'); + expect(metadata?.token_endpoint).toBe('https://auth.example.com/token'); + }); + + it('drops an invalid optional field instead of failing an OAuth metadata document', async () => { + // A document whose only flaw is a relative URL in an optional, client-unused + // field must not abort discovery (on base this hard-failed the OAuth path). + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validOAuthMetadata, revocation_endpoint: '/oauth/revoke' }) + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com'); + + expect(metadata).toBeDefined(); + expect(metadata).not.toHaveProperty('revocation_endpoint'); + expect(metadata?.token_endpoint).toBe('https://auth.example.com/token'); + }); + + it('sanitizes fields the sibling schema rejects even when the path-implied schema accepts the document', async () => { + // A mixed OIDC document served at the oauth-authorization-server path parses + // successfully with the (loose) OAuth schema — the OIDC-declared jwks_uri must + // still be validated, not passed through because of which path served it. + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validOpenIdMetadata, jwks_uri: 'javascript:alert(1)' }) + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com'); + + expect(metadata).toBeDefined(); + expect(metadata).not.toHaveProperty('jwks_uri'); + expect(metadata?.issuer).toBe('https://auth.example.com'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('applies the URL scheme guard to service_documentation on OIDC discovery documents', async () => { + // service_documentation is a URL in both RFC 8414 and OIDC Discovery; the + // discovery schema takes the OAuth validator (SafeUrlSchema) so an unsafe value + // is dropped like any other invalid optional field, while a valid URL survives. + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validOpenIdMetadata, service_documentation: 'javascript:alert(1)' }) + }); + + const dropped = await discoverAuthorizationServerMetadata('https://auth.example.com'); + + expect(dropped).toBeDefined(); + expect(dropped).not.toHaveProperty('service_documentation'); + + mockFetch.mockReset(); + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validOpenIdMetadata, service_documentation: 'https://auth.example.com/docs' }) + }); + + const kept = await discoverAuthorizationServerMetadata('https://auth.example.com'); + + expect(kept).toHaveProperty('service_documentation', 'https://auth.example.com/docs'); + }); + + it('still validates the issuer on RFC 8414 documents found at the openid-configuration path', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://evil.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }) + }); + + await expect(discoverAuthorizationServerMetadata('https://auth.example.com')).rejects.toThrow(IssuerMismatchError); + }); + it('throws on non-502 5xx errors', async () => { mockFetch.mockResolvedValueOnce({ ok: false, diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index e21076d817..e7bef70a28 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -62,7 +62,7 @@ export const OAuthMetadataSchema = z.looseObject({ revocation_endpoint: SafeUrlSchema.optional(), revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(), revocation_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), - introspection_endpoint: z.string().optional(), + introspection_endpoint: SafeUrlSchema.optional(), introspection_endpoint_auth_methods_supported: z.array(z.string()).optional(), introspection_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), code_challenge_methods_supported: z.array(z.string()).optional(), @@ -121,10 +121,25 @@ export const OpenIdProviderMetadataSchema = z.looseObject({ * OpenID Connect Discovery metadata that may include OAuth 2.0 fields * This schema represents the real-world scenario where OIDC providers * return a mix of OpenID Connect and OAuth 2.0 metadata fields + * + * Loose like its component schemas so that fields neither schema declares + * survive a successful parse instead of being stripped. The RFC 8414 fields + * OAuth 2.0 metadata declares and the OIDC shape does not (revocation and + * introspection endpoints) are declared here explicitly so they are + * validated by their OAuth schema validators rather than passed through, + * and `service_documentation` takes the OAuth schema's URL validator so + * every URL-carrying field is scheme-guarded uniformly. */ -export const OpenIdProviderDiscoveryMetadataSchema = z.object({ +export const OpenIdProviderDiscoveryMetadataSchema = z.looseObject({ ...OpenIdProviderMetadataSchema.shape, ...OAuthMetadataSchema.pick({ + revocation_endpoint: true, + revocation_endpoint_auth_methods_supported: true, + revocation_endpoint_auth_signing_alg_values_supported: true, + introspection_endpoint: true, + introspection_endpoint_auth_methods_supported: true, + introspection_endpoint_auth_signing_alg_values_supported: true, + service_documentation: true, code_challenge_methods_supported: true }).shape });