From c807628c39dd1ee7ad9de65c7600ba4d63649daf Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 6 Dec 2024 07:04:03 +0000 Subject: [PATCH 1/3] feat: add JWT string validator - Add JWT validation to string schema - Implement validation in _parse method - Add comprehensive test coverage - Support optional algorithm validation - Maintain cross-runtime compatibility --- deno/lib/__tests__/string.test.ts | 32 +++++++++++++-- deno/lib/types.ts | 66 +++++++++++++++++++++++++++++++ src/__tests__/string.test.ts | 32 +++++++++++++-- src/types.ts | 66 +++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 6 deletions(-) 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..4bed1f04d 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; @@ -943,6 +963,41 @@ export class ZodString extends ZodType { }); status.dirty(); } + } else if (check.kind === "jwt") { + const parts = input.data.split("."); + if (parts.length !== 3) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "jwt", + code: ZodIssueCode.invalid_string, + message: check.message, + }); + status.dirty(); + } else { + try { + // Validate all parts are base64 + for (const part of parts) { + if (!base64Regex.test(part)) throw new Error(); + } + + // Decode and validate header + const header = JSON.parse(atob(parts[0])); + if (!header.typ || !header.alg) throw new Error(); + + // Validate algorithm if specified + if (check.options?.alg && header.alg !== check.options.alg) { + throw new Error(); + } + } catch { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "jwt", + code: ZodIssueCode.invalid_string, + message: check.message, + }); + status.dirty(); + } + } } else { util.assertNever(check); } @@ -1002,6 +1057,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/__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..1abd1426c 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; @@ -943,6 +963,41 @@ export class ZodString extends ZodType { }); status.dirty(); } + } else if (check.kind === "jwt") { + const parts = input.data.split("."); + if (parts.length !== 3) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "jwt", + code: ZodIssueCode.invalid_string, + message: check.message, + }); + status.dirty(); + } else { + try { + // Validate all parts are base64 + for (const part of parts) { + if (!base64Regex.test(part)) throw new Error(); + } + + // Decode and validate header + const header = JSON.parse(atob(parts[0])); + if (!header.typ || !header.alg) throw new Error(); + + // Validate algorithm if specified + if (check.options?.alg && header.alg !== check.options.alg) { + throw new Error(); + } + } catch { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "jwt", + code: ZodIssueCode.invalid_string, + message: check.message, + }); + status.dirty(); + } + } } else { util.assertNever(check); } @@ -1002,6 +1057,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) }); } From bd3291191835272d915b6b05bb5be40c058a15ba Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 6 Dec 2024 07:05:34 +0000 Subject: [PATCH 2/3] fix: add jwt to StringValidation type --- deno/lib/ZodError.ts | 1 + src/ZodError.ts | 1 + 2 files changed, 2 insertions(+) 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/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 }; From 2e189ec68a177f71bae915b356565f7d1afb7846 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 7 Dec 2024 00:21:40 +0000 Subject: [PATCH 3/3] refactor: extract JWT validation into utility function --- deno/lib/types.ts | 51 +++++++++++++++++++++++------------------------ src/types.ts | 51 +++++++++++++++++++++++------------------------ 2 files changed, 50 insertions(+), 52 deletions(-) diff --git a/deno/lib/types.ts b/deno/lib/types.ts index 4bed1f04d..8a2d9a039 100644 --- a/deno/lib/types.ts +++ b/deno/lib/types.ts @@ -691,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) { @@ -964,8 +988,7 @@ export class ZodString extends ZodType { status.dirty(); } } else if (check.kind === "jwt") { - const parts = input.data.split("."); - if (parts.length !== 3) { + if (!isValidJWT(input.data, check.options)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "jwt", @@ -973,30 +996,6 @@ export class ZodString extends ZodType { message: check.message, }); status.dirty(); - } else { - try { - // Validate all parts are base64 - for (const part of parts) { - if (!base64Regex.test(part)) throw new Error(); - } - - // Decode and validate header - const header = JSON.parse(atob(parts[0])); - if (!header.typ || !header.alg) throw new Error(); - - // Validate algorithm if specified - if (check.options?.alg && header.alg !== check.options.alg) { - throw new Error(); - } - } catch { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - validation: "jwt", - code: ZodIssueCode.invalid_string, - message: check.message, - }); - status.dirty(); - } } } else { util.assertNever(check); diff --git a/src/types.ts b/src/types.ts index 1abd1426c..4ead8316b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -691,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) { @@ -964,8 +988,7 @@ export class ZodString extends ZodType { status.dirty(); } } else if (check.kind === "jwt") { - const parts = input.data.split("."); - if (parts.length !== 3) { + if (!isValidJWT(input.data, check.options)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "jwt", @@ -973,30 +996,6 @@ export class ZodString extends ZodType { message: check.message, }); status.dirty(); - } else { - try { - // Validate all parts are base64 - for (const part of parts) { - if (!base64Regex.test(part)) throw new Error(); - } - - // Decode and validate header - const header = JSON.parse(atob(parts[0])); - if (!header.typ || !header.alg) throw new Error(); - - // Validate algorithm if specified - if (check.options?.alg && header.alg !== check.options.alg) { - throw new Error(); - } - } catch { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - validation: "jwt", - code: ZodIssueCode.invalid_string, - message: check.message, - }); - status.dirty(); - } } } else { util.assertNever(check);