Skip to content

Commit

Permalink
Add string jwt (#2666)
Browse files Browse the repository at this point in the history
* feat(string): add jwt validator type

Validate string in JWT format. NOT validate signature

* test(string): add tests to validate jwt parse

Validate all possibilities for validation function

* docs(main): update main README

Add z.string().jwt() to String validations list

* fix(types): remove forgotten console.log

* refactor(types-string): replace Buffer.from method for atob into JWT validation

atob method is compatible with Node and modern browsers

* fix(types-string): header property was misspelled

Property is 'typ' and not 'type'

* feat(types-string): add algorithm option to jwt

Can pass a algorithm option to jwt method to check the algorithm of token. If not pass, no check is done for alg.

* test(string): update tests to check jwt method

Fix tests with false positive and add tests to check algorithm validation

* docs(README): update readme.md

Add info that jwt method accepts algorithm as option

* Tweak API and docs

* Fix tests

---------

Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
  • Loading branch information
danilomourelle and colinhacks authored Apr 23, 2024
1 parent 2184a2b commit 12eca8e
Show file tree
Hide file tree
Showing 8 changed files with 206 additions and 2 deletions.
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

0 comments on commit 12eca8e

Please sign in to comment.