diff --git a/deno/lib/ZodError.ts b/deno/lib/ZodError.ts index e757cd8ba..5c4bfdb67 100644 --- a/deno/lib/ZodError.ts +++ b/deno/lib/ZodError.ts @@ -104,6 +104,7 @@ export type StringValidation = | "duration" | "ip" | "base64" + | "jwt" | { includes: string; position?: number } | { startsWith: string } | { endsWith: string }; diff --git a/deno/lib/__tests__/string.test.ts b/deno/lib/__tests__/string.test.ts index 64438717a..fb900629d 100644 --- a/deno/lib/__tests__/string.test.ts +++ b/deno/lib/__tests__/string.test.ts @@ -194,12 +194,38 @@ test("base64 validations", () => { ]; for (const str of invalidBase64Strings) { - expect(str + z.string().base64().safeParse(str).success).toBe( - str + "false" - ); + expect(str + z.string().base64().safeParse(str).success).toBe(str + "false"); } }); +test("jwt validations", () => { + const jwt = z.string().jwt(); + const jwtWithAlg = z.string().jwt({ alg: "HS256" }); + + // Valid JWTs + const validHeader = btoa(JSON.stringify({ typ: "JWT", alg: "HS256" })); + const validPayload = btoa(JSON.stringify({ sub: "1234" })); + const validSignature = btoa("signature"); + const validJWT = `${validHeader}.${validPayload}.${validSignature}`; + + expect(jwt.safeParse(validJWT).success).toBe(true); + expect(jwtWithAlg.safeParse(validJWT).success).toBe(true); + + // Different algorithm + const headerWithDiffAlg = btoa(JSON.stringify({ typ: "JWT", alg: "RS256" })); + const jwtWithDiffAlg = `${headerWithDiffAlg}.${validPayload}.${validSignature}`; + expect(jwt.safeParse(jwtWithDiffAlg).success).toBe(true); + expect(jwtWithAlg.safeParse(jwtWithDiffAlg).success).toBe(false); + + // Invalid cases + expect(jwt.safeParse("not.a.jwt").success).toBe(false); + expect(jwt.safeParse("not.enough.parts.here").success).toBe(false); + expect(jwt.safeParse("invalid!base64.parts.here").success).toBe(false); + expect(jwt.safeParse(`${btoa("invalid json")}.${validPayload}.${validSignature}`).success).toBe(false); + expect(jwt.safeParse(`${btoa(JSON.stringify({ alg: "HS256" }))}.${validPayload}.${validSignature}`).success).toBe(false); + expect(jwt.safeParse(`${btoa(JSON.stringify({ typ: "JWT" }))}.${validPayload}.${validSignature}`).success).toBe(false); +}); + test("url validations", () => { const url = z.string().url(); url.parse("http://google.com"); diff --git a/deno/lib/types.ts b/deno/lib/types.ts index 5d020d278..8a2d9a039 100644 --- a/deno/lib/types.ts +++ b/deno/lib/types.ts @@ -527,6 +527,25 @@ export abstract class ZodType< ///////////////////////////////////////// ///////////////////////////////////////// export type IpVersion = "v4" | "v6"; +export type JWTAlgorithm = + | "HS256" + | "HS384" + | "HS512" + | "RS256" + | "RS384" + | "RS512" + | "ES256" + | "ES384" + | "ES512" + | "PS256" + | "PS384" + | "PS512"; + +export interface JWTValidation { + alg?: JWTAlgorithm; + message?: string; +} + export type ZodStringCheck = | { kind: "min"; value: number; message?: string } | { kind: "max"; value: number; message?: string } @@ -546,6 +565,7 @@ export type ZodStringCheck = | { kind: "trim"; message?: string } | { kind: "toLowerCase"; message?: string } | { kind: "toUpperCase"; message?: string } + | { kind: "jwt"; options?: JWTValidation; message?: string } | { kind: "datetime"; offset: boolean; @@ -671,6 +691,30 @@ function isValidIP(ip: string, version?: IpVersion) { return false; } +function isValidJWT(jwt: string, options?: JWTValidation): boolean { + try { + // Check three-part structure + const parts = jwt.split("."); + if (parts.length !== 3) return false; + + // Validate all parts are base64 + for (const part of parts) { + if (!base64Regex.test(part)) return false; + } + + // Decode and validate header + const header = JSON.parse(atob(parts[0])); + if (!header.typ || !header.alg) return false; + + // Validate algorithm if specified + if (options?.alg && header.alg !== options.alg) return false; + + return true; + } catch { + return false; + } +} + export class ZodString extends ZodType { _parse(input: ParseInput): ParseReturnType { if (this._def.coerce) { @@ -943,6 +987,16 @@ export class ZodString extends ZodType { }); status.dirty(); } + } else if (check.kind === "jwt") { + if (!isValidJWT(input.data, check.options)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "jwt", + code: ZodIssueCode.invalid_string, + message: check.message, + }); + status.dirty(); + } } else { util.assertNever(check); } @@ -1002,6 +1056,17 @@ export class ZodString extends ZodType { return this._addCheck({ kind: "base64", ...errorUtil.errToObj(message) }); } + jwt(options?: JWTValidation | string) { + if (typeof options === "string") { + return this._addCheck({ kind: "jwt", message: options }); + } + return this._addCheck({ + kind: "jwt", + options, + ...errorUtil.errToObj(options?.message), + }); + } + ip(options?: string | { version?: IpVersion; message?: string }) { return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) }); } diff --git a/src/ZodError.ts b/src/ZodError.ts index c1f7aa3ee..71a8675e2 100644 --- a/src/ZodError.ts +++ b/src/ZodError.ts @@ -104,6 +104,7 @@ export type StringValidation = | "duration" | "ip" | "base64" + | "jwt" | { includes: string; position?: number } | { startsWith: string } | { endsWith: string }; diff --git a/src/__tests__/string.test.ts b/src/__tests__/string.test.ts index f7037fcc2..74c236c00 100644 --- a/src/__tests__/string.test.ts +++ b/src/__tests__/string.test.ts @@ -193,12 +193,38 @@ test("base64 validations", () => { ]; for (const str of invalidBase64Strings) { - expect(str + z.string().base64().safeParse(str).success).toBe( - str + "false" - ); + expect(str + z.string().base64().safeParse(str).success).toBe(str + "false"); } }); +test("jwt validations", () => { + const jwt = z.string().jwt(); + const jwtWithAlg = z.string().jwt({ alg: "HS256" }); + + // Valid JWTs + const validHeader = btoa(JSON.stringify({ typ: "JWT", alg: "HS256" })); + const validPayload = btoa(JSON.stringify({ sub: "1234" })); + const validSignature = btoa("signature"); + const validJWT = `${validHeader}.${validPayload}.${validSignature}`; + + expect(jwt.safeParse(validJWT).success).toBe(true); + expect(jwtWithAlg.safeParse(validJWT).success).toBe(true); + + // Different algorithm + const headerWithDiffAlg = btoa(JSON.stringify({ typ: "JWT", alg: "RS256" })); + const jwtWithDiffAlg = `${headerWithDiffAlg}.${validPayload}.${validSignature}`; + expect(jwt.safeParse(jwtWithDiffAlg).success).toBe(true); + expect(jwtWithAlg.safeParse(jwtWithDiffAlg).success).toBe(false); + + // Invalid cases + expect(jwt.safeParse("not.a.jwt").success).toBe(false); + expect(jwt.safeParse("not.enough.parts.here").success).toBe(false); + expect(jwt.safeParse("invalid!base64.parts.here").success).toBe(false); + expect(jwt.safeParse(`${btoa("invalid json")}.${validPayload}.${validSignature}`).success).toBe(false); + expect(jwt.safeParse(`${btoa(JSON.stringify({ alg: "HS256" }))}.${validPayload}.${validSignature}`).success).toBe(false); + expect(jwt.safeParse(`${btoa(JSON.stringify({ typ: "JWT" }))}.${validPayload}.${validSignature}`).success).toBe(false); +}); + test("url validations", () => { const url = z.string().url(); url.parse("http://google.com"); diff --git a/src/types.ts b/src/types.ts index f3730ae14..4ead8316b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -527,6 +527,25 @@ export abstract class ZodType< ///////////////////////////////////////// ///////////////////////////////////////// export type IpVersion = "v4" | "v6"; +export type JWTAlgorithm = + | "HS256" + | "HS384" + | "HS512" + | "RS256" + | "RS384" + | "RS512" + | "ES256" + | "ES384" + | "ES512" + | "PS256" + | "PS384" + | "PS512"; + +export interface JWTValidation { + alg?: JWTAlgorithm; + message?: string; +} + export type ZodStringCheck = | { kind: "min"; value: number; message?: string } | { kind: "max"; value: number; message?: string } @@ -546,6 +565,7 @@ export type ZodStringCheck = | { kind: "trim"; message?: string } | { kind: "toLowerCase"; message?: string } | { kind: "toUpperCase"; message?: string } + | { kind: "jwt"; options?: JWTValidation; message?: string } | { kind: "datetime"; offset: boolean; @@ -671,6 +691,30 @@ function isValidIP(ip: string, version?: IpVersion) { return false; } +function isValidJWT(jwt: string, options?: JWTValidation): boolean { + try { + // Check three-part structure + const parts = jwt.split("."); + if (parts.length !== 3) return false; + + // Validate all parts are base64 + for (const part of parts) { + if (!base64Regex.test(part)) return false; + } + + // Decode and validate header + const header = JSON.parse(atob(parts[0])); + if (!header.typ || !header.alg) return false; + + // Validate algorithm if specified + if (options?.alg && header.alg !== options.alg) return false; + + return true; + } catch { + return false; + } +} + export class ZodString extends ZodType { _parse(input: ParseInput): ParseReturnType { if (this._def.coerce) { @@ -943,6 +987,16 @@ export class ZodString extends ZodType { }); status.dirty(); } + } else if (check.kind === "jwt") { + if (!isValidJWT(input.data, check.options)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "jwt", + code: ZodIssueCode.invalid_string, + message: check.message, + }); + status.dirty(); + } } else { util.assertNever(check); } @@ -1002,6 +1056,17 @@ export class ZodString extends ZodType { return this._addCheck({ kind: "base64", ...errorUtil.errToObj(message) }); } + jwt(options?: JWTValidation | string) { + if (typeof options === "string") { + return this._addCheck({ kind: "jwt", message: options }); + } + return this._addCheck({ + kind: "jwt", + options, + ...errorUtil.errToObj(options?.message), + }); + } + ip(options?: string | { version?: IpVersion; message?: string }) { return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) }); }