feat: zod v4 with backwards compatibility for v3.25+ (#1040)

This commit is contained in:
Devin Clark 2025-11-19 12:24:53 -08:00 • committed by GitHub
commit 418b7f9b85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 14723 additions and 1002 deletions

View file

@ -1,10 +1,9 @@
import { z } from 'zod';
import * as z from 'zod/v4';
/**
* Reusable URL validation that disallows javascript: scheme
*/
export const SafeUrlSchema = z
.string()
.url()
.superRefine((val, ctx) => {
if (!URL.canParse(val)) {
@ -28,107 +27,102 @@ export const SafeUrlSchema = z
/**
* RFC 9728 OAuth Protected Resource Metadata
*/
export const OAuthProtectedResourceMetadataSchema = z
.object({
resource: z.string().url(),
authorization_servers: z.array(SafeUrlSchema).optional(),
jwks_uri: z.string().url().optional(),
scopes_supported: z.array(z.string()).optional(),
bearer_methods_supported: z.array(z.string()).optional(),
resource_signing_alg_values_supported: z.array(z.string()).optional(),
resource_name: z.string().optional(),
resource_documentation: z.string().optional(),
resource_policy_uri: z.string().url().optional(),
resource_tos_uri: z.string().url().optional(),
tls_client_certificate_bound_access_tokens: z.boolean().optional(),
authorization_details_types_supported: z.array(z.string()).optional(),
dpop_signing_alg_values_supported: z.array(z.string()).optional(),
dpop_bound_access_tokens_required: z.boolean().optional()
})
.passthrough();
export const OAuthProtectedResourceMetadataSchema = z.looseObject({
resource: z.string().url(),
authorization_servers: z.array(SafeUrlSchema).optional(),
jwks_uri: z.string().url().optional(),
scopes_supported: z.array(z.string()).optional(),
bearer_methods_supported: z.array(z.string()).optional(),
resource_signing_alg_values_supported: z.array(z.string()).optional(),
resource_name: z.string().optional(),
resource_documentation: z.string().optional(),
resource_policy_uri: z.string().url().optional(),
resource_tos_uri: z.string().url().optional(),
tls_client_certificate_bound_access_tokens: z.boolean().optional(),
authorization_details_types_supported: z.array(z.string()).optional(),
dpop_signing_alg_values_supported: z.array(z.string()).optional(),
dpop_bound_access_tokens_required: z.boolean().optional()
});
/**
* RFC 8414 OAuth 2.0 Authorization Server Metadata
*/
export const OAuthMetadataSchema = z
.object({
issuer: z.string(),
authorization_endpoint: SafeUrlSchema,
token_endpoint: SafeUrlSchema,
registration_endpoint: SafeUrlSchema.optional(),
scopes_supported: z.array(z.string()).optional(),
response_types_supported: z.array(z.string()),
response_modes_supported: z.array(z.string()).optional(),
grant_types_supported: z.array(z.string()).optional(),
token_endpoint_auth_methods_supported: z.array(z.string()).optional(),
token_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(),
service_documentation: SafeUrlSchema.optional(),
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_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(),
client_id_metadata_document_supported: z.boolean().optional()
})
.passthrough();
export const OAuthMetadataSchema = z.looseObject({
issuer: z.string(),
authorization_endpoint: SafeUrlSchema,
token_endpoint: SafeUrlSchema,
registration_endpoint: SafeUrlSchema.optional(),
scopes_supported: z.array(z.string()).optional(),
response_types_supported: z.array(z.string()),
response_modes_supported: z.array(z.string()).optional(),
grant_types_supported: z.array(z.string()).optional(),
token_endpoint_auth_methods_supported: z.array(z.string()).optional(),
token_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(),
service_documentation: SafeUrlSchema.optional(),
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_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(),
client_id_metadata_document_supported: z.boolean().optional()
});
/**
* OpenID Connect Discovery 1.0 Provider Metadata
* see: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
*/
export const OpenIdProviderMetadataSchema = z
.object({
issuer: z.string(),
authorization_endpoint: SafeUrlSchema,
token_endpoint: SafeUrlSchema,
userinfo_endpoint: SafeUrlSchema.optional(),
jwks_uri: SafeUrlSchema,
registration_endpoint: SafeUrlSchema.optional(),
scopes_supported: z.array(z.string()).optional(),
response_types_supported: z.array(z.string()),
response_modes_supported: z.array(z.string()).optional(),
grant_types_supported: z.array(z.string()).optional(),
acr_values_supported: z.array(z.string()).optional(),
subject_types_supported: z.array(z.string()),
id_token_signing_alg_values_supported: z.array(z.string()),
id_token_encryption_alg_values_supported: z.array(z.string()).optional(),
id_token_encryption_enc_values_supported: z.array(z.string()).optional(),
userinfo_signing_alg_values_supported: z.array(z.string()).optional(),
userinfo_encryption_alg_values_supported: z.array(z.string()).optional(),
userinfo_encryption_enc_values_supported: z.array(z.string()).optional(),
request_object_signing_alg_values_supported: z.array(z.string()).optional(),
request_object_encryption_alg_values_supported: z.array(z.string()).optional(),
request_object_encryption_enc_values_supported: z.array(z.string()).optional(),
token_endpoint_auth_methods_supported: z.array(z.string()).optional(),
token_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(),
display_values_supported: z.array(z.string()).optional(),
claim_types_supported: z.array(z.string()).optional(),
claims_supported: z.array(z.string()).optional(),
service_documentation: z.string().optional(),
claims_locales_supported: z.array(z.string()).optional(),
ui_locales_supported: z.array(z.string()).optional(),
claims_parameter_supported: z.boolean().optional(),
request_parameter_supported: z.boolean().optional(),
request_uri_parameter_supported: z.boolean().optional(),
require_request_uri_registration: z.boolean().optional(),
op_policy_uri: SafeUrlSchema.optional(),
op_tos_uri: SafeUrlSchema.optional(),
client_id_metadata_document_supported: z.boolean().optional()
})
.passthrough();
export const OpenIdProviderMetadataSchema = z.looseObject({
issuer: z.string(),
authorization_endpoint: SafeUrlSchema,
token_endpoint: SafeUrlSchema,
userinfo_endpoint: SafeUrlSchema.optional(),
jwks_uri: SafeUrlSchema,
registration_endpoint: SafeUrlSchema.optional(),
scopes_supported: z.array(z.string()).optional(),
response_types_supported: z.array(z.string()),
response_modes_supported: z.array(z.string()).optional(),
grant_types_supported: z.array(z.string()).optional(),
acr_values_supported: z.array(z.string()).optional(),
subject_types_supported: z.array(z.string()),
id_token_signing_alg_values_supported: z.array(z.string()),
id_token_encryption_alg_values_supported: z.array(z.string()).optional(),
id_token_encryption_enc_values_supported: z.array(z.string()).optional(),
userinfo_signing_alg_values_supported: z.array(z.string()).optional(),
userinfo_encryption_alg_values_supported: z.array(z.string()).optional(),
userinfo_encryption_enc_values_supported: z.array(z.string()).optional(),
request_object_signing_alg_values_supported: z.array(z.string()).optional(),
request_object_encryption_alg_values_supported: z.array(z.string()).optional(),
request_object_encryption_enc_values_supported: z.array(z.string()).optional(),
token_endpoint_auth_methods_supported: z.array(z.string()).optional(),
token_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(),
display_values_supported: z.array(z.string()).optional(),
claim_types_supported: z.array(z.string()).optional(),
claims_supported: z.array(z.string()).optional(),
service_documentation: z.string().optional(),
claims_locales_supported: z.array(z.string()).optional(),
ui_locales_supported: z.array(z.string()).optional(),
claims_parameter_supported: z.boolean().optional(),
request_parameter_supported: z.boolean().optional(),
request_uri_parameter_supported: z.boolean().optional(),
require_request_uri_registration: z.boolean().optional(),
op_policy_uri: SafeUrlSchema.optional(),
op_tos_uri: SafeUrlSchema.optional(),
client_id_metadata_document_supported: z.boolean().optional()
});
/**
* 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
*/
export const OpenIdProviderDiscoveryMetadataSchema = OpenIdProviderMetadataSchema.merge(
OAuthMetadataSchema.pick({
export const OpenIdProviderDiscoveryMetadataSchema = z.object({
...OpenIdProviderMetadataSchema.shape,
...OAuthMetadataSchema.pick({
code_challenge_methods_supported: true
})
);
}).shape
});
/**
* OAuth 2.1 token response

View file

@ -2,7 +2,7 @@ import { describe, expect, test, beforeEach } from 'vitest';
import { Protocol } from './protocol.js';
import { Transport } from './transport.js';
import { Request, Notification, Result, JSONRPCMessage } from '../types.js';
import { z } from 'zod';
import * as z from 'zod/v4';
// Mock Transport class
class MockTransport implements Transport {

View file

@ -1,4 +1,4 @@
import { ZodLiteral, ZodObject, ZodType, z } from 'zod';
import { AnySchema, AnyObjectSchema, SchemaOutput, safeParse } from '../server/zod-compat.js';
import {
CancelledNotificationSchema,
ClientCapabilities,
@ -27,6 +27,7 @@ import {
} from '../types.js';
import { Transport, TransportSendOptions } from './transport.js';
import { AuthInfo } from '../server/auth/types.js';
import { getMethodLiteral, parseWithCompat } from '../server/zod-json-schema-compat.js';
/**
* Callback for progress notifications.
@ -152,7 +153,7 @@ export type RequestHandlerExtra<SendRequestT extends Request, SendNotificationT
*
* This is used by certain transports to correctly associate related messages.
*/
sendRequest: <U extends ZodType<object>>(request: SendRequestT, resultSchema: U, options?: RequestOptions) => Promise<z.infer<U>>;
sendRequest: <U extends AnySchema>(request: SendRequestT, resultSchema: U, options?: RequestOptions) => Promise<SchemaOutput<U>>;
};
/**
@ -490,7 +491,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
*
* Do not use this method to emit notifications! Use notification() instead.
*/
request<T extends ZodType<object>>(request: SendRequestT, resultSchema: T, options?: RequestOptions): Promise<z.infer<T>> {
request<T extends AnySchema>(request: SendRequestT, resultSchema: T, options?: RequestOptions): Promise<SchemaOutput<T>> {
const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {};
return new Promise((resolve, reject) => {
@ -555,8 +556,13 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
}
try {
const result = resultSchema.parse(response.result);
resolve(result);
const parseResult = safeParse(resultSchema, response.result);
if (!parseResult.success) {
// Type guard: if success is false, error is guaranteed to exist
reject(parseResult.error);
} else {
resolve(parseResult.data as SchemaOutput<T>);
}
} catch (error) {
reject(error);
}
@ -639,19 +645,19 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
*
* Note that this will replace any previous request handler for the same method.
*/
setRequestHandler<
T extends ZodObject<{
method: ZodLiteral<string>;
}>
>(
setRequestHandler<T extends AnyObjectSchema>(
requestSchema: T,
handler: (request: z.infer<T>, extra: RequestHandlerExtra<SendRequestT, SendNotificationT>) => SendResultT | Promise<SendResultT>
handler: (
request: SchemaOutput<T>,
extra: RequestHandlerExtra<SendRequestT, SendNotificationT>
) => SendResultT | Promise<SendResultT>
): void {
const method = requestSchema.shape.method.value;
const method = getMethodLiteral(requestSchema);
this.assertRequestHandlerCapability(method);
this._requestHandlers.set(method, (request, extra) => {
return Promise.resolve(handler(requestSchema.parse(request), extra));
const parsed = parseWithCompat(requestSchema, request) as SchemaOutput<T>;
return Promise.resolve(handler(parsed, extra));
});
}
@ -676,14 +682,15 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
*
* Note that this will replace any previous notification handler for the same method.
*/
setNotificationHandler<
T extends ZodObject<{
method: ZodLiteral<string>;
}>
>(notificationSchema: T, handler: (notification: z.infer<T>) => void | Promise<void>): void {
this._notificationHandlers.set(notificationSchema.shape.method.value, notification =>
Promise.resolve(handler(notificationSchema.parse(notification)))
);
setNotificationHandler<T extends AnyObjectSchema>(
notificationSchema: T,
handler: (notification: SchemaOutput<T>) => void | Promise<void>
): void {
const method = getMethodLiteral(notificationSchema);
this._notificationHandlers.set(method, notification => {
const parsed = parseWithCompat(notificationSchema, notification) as SchemaOutput<T>;
return Promise.resolve(handler(parsed));
});
}
/**