Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add string jwt #2666

Merged
merged 11 commits into from
Apr 23, 2024
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,8 @@ z.string().length(5);
z.string().email();
z.string().url();
z.string().emoji();
z.string().jwt(); // validates format, NOT signature
z.string().jwt({ alg: "HS256" }); // specify algorithm
z.string().uuid();
z.string().nanoid();
z.string().cuid();
Expand Down
2 changes: 2 additions & 0 deletions deno/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,8 @@ z.string().length(5);
z.string().email();
z.string().url();
z.string().emoji();
z.string().jwt(); // validates format, NOT signature
z.string().jwt({ alg: "HS256" }); // specify algorithm
z.string().uuid();
z.string().nanoid();
z.string().cuid();
Expand Down
1 change: 1 addition & 0 deletions deno/lib/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export interface ZodInvalidDateIssue extends ZodIssueBase {
export type StringValidation =
| "email"
| "url"
| "jwt"
| "emoji"
| "uuid"
| "nanoid"
Expand Down
39 changes: 39 additions & 0 deletions deno/lib/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,45 @@ test("base64 validations", () => {
}
});

test("jwt token", () => {
const ONE_PART = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
const NOT_BASE64 =
"headerIsNotBase64Encoded.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.RRi1X2IlXd5rZa9Mf_0VUOf-RxOzAhbB4tgViUGamWE";
const NO_TYP =
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.GuoUe6tw79bJlbU1HU0ADX0pr0u2kf3r_4OdrDufSfQ";
const TYP_NOT_JWT =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpUVyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.RRi1X2IlXd5rZa9Mf_0VUOf-RxOzAhbB4tgViUGamWE";

const GOOD_JWT_HS256 =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const GOOD_JWT_ES256 =
"eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.tyh-VfuzIxCyGYDlkBA7DfyjrqmSHu6pQ2hoZuFqUSLPNY2N0mpHb3nk5K17HWP_3cYHBw7AhHale5wky6-sVA";

const jwtSchema = z.string().jwt();

expect(() => jwtSchema.parse(ONE_PART)).toThrow();
expect(() => jwtSchema.parse(NOT_BASE64)).toThrow();
expect(() => jwtSchema.parse(NO_TYP)).toThrow();
expect(() => jwtSchema.parse(TYP_NOT_JWT)).toThrow();
expect(() => jwtSchema.parse(TYP_NOT_JWT)).toThrow();
expect(() => jwtSchema.parse(TYP_NOT_JWT)).toThrow();
expect(() =>
z.string().jwt({ alg: "ES256" }).parse(GOOD_JWT_HS256)
).toThrow();
expect(() =>
z.string().jwt({ alg: "HS256" }).parse(GOOD_JWT_ES256)
).toThrow();
//Success
expect(() => jwtSchema.parse(GOOD_JWT_HS256)).not.toThrow();
expect(() => jwtSchema.parse(GOOD_JWT_ES256)).not.toThrow();
expect(() =>
z.string().jwt({ alg: "HS256" }).parse(GOOD_JWT_HS256)
).not.toThrow();
expect(() =>
z.string().jwt({ alg: "ES256" }).parse(GOOD_JWT_ES256)
).not.toThrow();
});

test("url validations", () => {
const url = z.string().url();
url.parse("http://google.com");
Expand Down
62 changes: 61 additions & 1 deletion deno/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,12 +527,26 @@ 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 type ZodStringCheck =
| { kind: "min"; value: number; message?: string }
| { kind: "max"; value: number; message?: string }
| { kind: "length"; value: number; message?: string }
| { kind: "email"; message?: string }
| { kind: "url"; message?: string }
| { kind: "jwt"; alg: JwtAlgorithm | null; message?: string }
| { kind: "emoji"; message?: string }
| { kind: "uuid"; message?: string }
| { kind: "nanoid"; message?: string }
Expand Down Expand Up @@ -671,6 +685,33 @@ function isValidIP(ip: string, version?: IpVersion) {
return false;
}

function isValidJwt(token: string, algorithm: JwtAlgorithm | null = null) {
try {
const tokensParts = token.split(".");
if (tokensParts.length !== 3) {
return false;
}

const [header] = tokensParts;
const parsedHeader = JSON.parse(atob(header));

if (!("typ" in parsedHeader) || parsedHeader.typ !== "JWT") {
return false;
}

if (
algorithm &&
(!("alg" in parsedHeader) || parsedHeader.alg !== algorithm)
) {
return false;
}

return true;
} catch {
return false;
}
}

export class ZodString extends ZodType<string, ZodStringDef, string> {
_parse(input: ParseInput): ParseReturnType<string> {
if (this._def.coerce) {
Expand Down Expand Up @@ -754,6 +795,16 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
});
status.dirty();
}
} else if (check.kind === "jwt") {
if (!isValidJwt(input.data, check.alg)) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
validation: "jwt",
code: ZodIssueCode.invalid_string,
message: check.message,
});
status.dirty();
}
} else if (check.kind === "emoji") {
if (!emojiRegex) {
emojiRegex = new RegExp(_emojiRegex, "u");
Expand Down Expand Up @@ -988,6 +1039,13 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
return this._addCheck({ kind: "url", ...errorUtil.errToObj(message) });
}

jwt(options?: string | { alg?: JwtAlgorithm; message?: string }) {
return this._addCheck({
kind: "jwt",
alg: typeof options === "object" ? options.alg ?? null : null,
...errorUtil.errToObj(options),
});
}
emoji(message?: errorUtil.ErrMessage) {
return this._addCheck({ kind: "emoji", ...errorUtil.errToObj(message) });
}
Expand Down Expand Up @@ -1188,7 +1246,9 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
get isURL() {
return !!this._def.checks.find((ch) => ch.kind === "url");
}

get isJwt() {
return !!this._def.checks.find((ch) => ch.kind === "jwt");
}
get isEmoji() {
return !!this._def.checks.find((ch) => ch.kind === "emoji");
}
Expand Down
1 change: 1 addition & 0 deletions src/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export interface ZodInvalidDateIssue extends ZodIssueBase {
export type StringValidation =
| "email"
| "url"
| "jwt"
| "emoji"
| "uuid"
| "nanoid"
Expand Down
39 changes: 39 additions & 0 deletions src/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,45 @@ test("base64 validations", () => {
}
});

test("jwt token", () => {
const ONE_PART = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
const NOT_BASE64 =
"headerIsNotBase64Encoded.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.RRi1X2IlXd5rZa9Mf_0VUOf-RxOzAhbB4tgViUGamWE";
const NO_TYP =
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.GuoUe6tw79bJlbU1HU0ADX0pr0u2kf3r_4OdrDufSfQ";
const TYP_NOT_JWT =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpUVyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.RRi1X2IlXd5rZa9Mf_0VUOf-RxOzAhbB4tgViUGamWE";

const GOOD_JWT_HS256 =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const GOOD_JWT_ES256 =
"eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.tyh-VfuzIxCyGYDlkBA7DfyjrqmSHu6pQ2hoZuFqUSLPNY2N0mpHb3nk5K17HWP_3cYHBw7AhHale5wky6-sVA";

const jwtSchema = z.string().jwt();

expect(() => jwtSchema.parse(ONE_PART)).toThrow();
expect(() => jwtSchema.parse(NOT_BASE64)).toThrow();
expect(() => jwtSchema.parse(NO_TYP)).toThrow();
expect(() => jwtSchema.parse(TYP_NOT_JWT)).toThrow();
expect(() => jwtSchema.parse(TYP_NOT_JWT)).toThrow();
expect(() => jwtSchema.parse(TYP_NOT_JWT)).toThrow();
expect(() =>
z.string().jwt({ alg: "ES256" }).parse(GOOD_JWT_HS256)
).toThrow();
expect(() =>
z.string().jwt({ alg: "HS256" }).parse(GOOD_JWT_ES256)
).toThrow();
//Success
expect(() => jwtSchema.parse(GOOD_JWT_HS256)).not.toThrow();
expect(() => jwtSchema.parse(GOOD_JWT_ES256)).not.toThrow();
expect(() =>
z.string().jwt({ alg: "HS256" }).parse(GOOD_JWT_HS256)
).not.toThrow();
expect(() =>
z.string().jwt({ alg: "ES256" }).parse(GOOD_JWT_ES256)
).not.toThrow();
});

test("url validations", () => {
const url = z.string().url();
url.parse("http://google.com");
Expand Down
62 changes: 61 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,12 +527,26 @@ 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 type ZodStringCheck =
| { kind: "min"; value: number; message?: string }
| { kind: "max"; value: number; message?: string }
| { kind: "length"; value: number; message?: string }
| { kind: "email"; message?: string }
| { kind: "url"; message?: string }
| { kind: "jwt"; alg: JwtAlgorithm | null; message?: string }
| { kind: "emoji"; message?: string }
| { kind: "uuid"; message?: string }
| { kind: "nanoid"; message?: string }
Expand Down Expand Up @@ -671,6 +685,33 @@ function isValidIP(ip: string, version?: IpVersion) {
return false;
}

function isValidJwt(token: string, algorithm: JwtAlgorithm | null = null) {
try {
const tokensParts = token.split(".");
if (tokensParts.length !== 3) {
return false;
}

const [header] = tokensParts;
const parsedHeader = JSON.parse(atob(header));

if (!("typ" in parsedHeader) || parsedHeader.typ !== "JWT") {
return false;
}

if (
algorithm &&
(!("alg" in parsedHeader) || parsedHeader.alg !== algorithm)
) {
return false;
}

return true;
} catch {
return false;
}
}

export class ZodString extends ZodType<string, ZodStringDef, string> {
_parse(input: ParseInput): ParseReturnType<string> {
if (this._def.coerce) {
Expand Down Expand Up @@ -754,6 +795,16 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
});
status.dirty();
}
} else if (check.kind === "jwt") {
if (!isValidJwt(input.data, check.alg)) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
validation: "jwt",
code: ZodIssueCode.invalid_string,
message: check.message,
});
status.dirty();
}
} else if (check.kind === "emoji") {
if (!emojiRegex) {
emojiRegex = new RegExp(_emojiRegex, "u");
Expand Down Expand Up @@ -988,6 +1039,13 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
return this._addCheck({ kind: "url", ...errorUtil.errToObj(message) });
}

jwt(options?: string | { alg?: JwtAlgorithm; message?: string }) {
return this._addCheck({
kind: "jwt",
alg: typeof options === "object" ? options.alg ?? null : null,
...errorUtil.errToObj(options),
});
}
emoji(message?: errorUtil.ErrMessage) {
return this._addCheck({ kind: "emoji", ...errorUtil.errToObj(message) });
}
Expand Down Expand Up @@ -1188,7 +1246,9 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
get isURL() {
return !!this._def.checks.find((ch) => ch.kind === "url");
}

get isJwt() {
return !!this._def.checks.find((ch) => ch.kind === "jwt");
}
get isEmoji() {
return !!this._def.checks.find((ch) => ch.kind === "emoji");
}
Expand Down
Loading