Skip to content

feat: add validity-check for JWTs #88

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 118 additions & 2 deletions components/utils/jwt-parser.utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { base64UrlDecode, decodeJWT } from "./jwt-parser.utils";
import {
base64UrlDecode,
parseDate,
dateToString,
checkValidity,
decodeJWT,
State,
} from "./jwt-parser.utils";

jest.mock("./base-64.utils", () => ({
fromBase64: (value: string) => {
Expand All @@ -22,13 +29,117 @@ describe("base64UrlDecode", () => {
});
});

describe("parseDate", () => {
it("should return undefined if input is undefined", () => {
expect(parseDate(undefined)).toBeUndefined();
});

it("should parse number timestamp correctly", () => {
const timestamp = 1714048653;
const result = parseDate(timestamp);
expect(result).toBeInstanceOf(Date);
expect(result?.getTime()).toBe(timestamp * 1000);
});

it("should parse string timestamp correctly", () => {
const timestamp = "1714048653";
const result = parseDate(timestamp);
expect(result).toBeInstanceOf(Date);
expect(result?.getTime()).toBe(Number(timestamp) * 1000);
});

it("should return undefined for invalid string", () => {
expect(parseDate("invalid")).toBeUndefined();
});
});

describe("dateToString", () => {
it("should return only time if date is today", () => {
const now = new Date();
const result = dateToString(now);
const expected = now.toISOString().split("T")[1].split(".")[0];
expect(result).toBe(expected);
});

it("should return date string if not today", () => {
const pastDate = new Date(Date.now() - 86400000);
const result = dateToString(pastDate);
const expected = pastDate.toISOString().split("T")[0];
expect(result).toBe(expected);
});
});

describe("checkValidity", () => {
const now = Math.floor(Date.now() / 1000);

it("should return NeverValid if exp is before iat/nbf", () => {
const payload = {
iat: now,
nbf: now,
exp: now - 100,
};
const result = checkValidity(payload);
expect(result.state).toBe(State.NeverValid);
expect(result.message).toMatch(/Token expires before being valid/);
});

it("should return NotYetValid if validFrom is in the future", () => {
const payload = {
iat: now + 1000,
};
const result = checkValidity(payload);
expect(result.state).toBe(State.NotYetValid);
expect(result.message).toMatch(/Token will be valid starting/);
});

it("should return Valid if currently valid", () => {
const payload = {
iat: now - 1000,
exp: now + 1000,
};
const result = checkValidity(payload);
expect(result.state).toBe(State.Valid);
expect(result.message).toMatch(/Token valid until/);
});

it("should return Expired if exp is in the past", () => {
const payload = {
iat: now - 2000,
exp: now - 1000,
};
const result = checkValidity(payload);
expect(result.state).toBe(State.Expired);
expect(result.message).toMatch(/Token expired since/);
});

it("should return Valid forever if no exp but has iat or nbf", () => {
const payload = {
iat: now - 1000,
};
const result = checkValidity(payload);
expect(result.state).toBe(State.Valid);
expect(result.message).toMatch(/Token forever valid since/);
});

it("should return Valid with generic message if no dates given", () => {
const result = checkValidity({});
expect(result.state).toBe(State.Valid);
expect(result.message).toMatch(/Token doesn't contain a validity period/);
});
});

describe("decodeJWT", () => {
it("should decode a valid JWT", () => {
const header = Buffer.from(
JSON.stringify({ alg: "HS256", typ: "JWT" })
).toString("base64url");
const payload = Buffer.from(
JSON.stringify({ sub: "1234567890", name: "John Doe", admin: true })
JSON.stringify({
sub: "1234567890",
name: "John Doe",
admin: true,
iat: "10000",
})
).toString("base64url");
const signature = "abc123";

Expand All @@ -38,6 +149,7 @@ describe("decodeJWT", () => {
expect(result).toHaveProperty("header");
expect(result).toHaveProperty("payload");
expect(result).toHaveProperty("signature");
expect(result).toHaveProperty("validity");

expect(result.header).toEqual({ alg: "HS256", typ: "JWT" });
expect(result.payload).toEqual({
Expand All @@ -46,6 +158,10 @@ describe("decodeJWT", () => {
admin: true,
});
expect(result.signature).toBe("abc123");
expect(result.payload).toEqual({
message: "Token forever valid since 1970-01-01",
state: State.Valid,
});
});

it("should throw an error for an invalid JWT format", () => {
Expand Down
111 changes: 102 additions & 9 deletions components/utils/jwt-parser.utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
import { fromBase64 } from "./base-64.utils";

enum State {
NotYetValid,
Valid,
Expired,
NeverValid,
Unknown,
}

type DecodedJWT = {
header: Record<string, unknown>;
payload: Record<string, unknown>;
signature: string;
validity: Validity;
};

type Payload = {
iat?: string | number;
nbf?: string | number;
exp?: string | number;
[key: string]: unknown;
};

type Validity = {
message: string;
state: State;
};

function base64UrlDecode(str: string): string {
try {
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
Expand All @@ -11,26 +38,92 @@ function base64UrlDecode(str: string): string {
}
}

function decodeJWT(token: string): {
header: Record<string, unknown>;
payload: Record<string, unknown>;
signature: string;
} {
function parseDate(date: string | number | undefined): Date | undefined {
if (date === undefined) return undefined;
if (typeof date === "string") date = Number(date);
if (isNaN(date)) return undefined;
date *= 1000;
return new Date(date);
}

function dateToString(input: Date): string {
const inputArr = input.toISOString().split("T");
const inputDate: string = inputArr[0];
const inputTime: string = inputArr[1].split(".")[0];
const today: string = new Date().toISOString().split("T")[0];
if (inputDate === today) return inputTime;
return inputDate;
}

function checkValidity(payload: Payload): Validity {
const currentDate = new Date();
const iat = parseDate(payload.iat);
const nbf = parseDate(payload.nbf);
const exp = parseDate(payload.exp);
const validFrom = iat && nbf ? (iat > nbf ? iat : nbf) : (iat ?? nbf);
if (validFrom && exp && validFrom >= exp)
return {
message: "Token expires before being valid",
state: State.NeverValid,
};
else if (validFrom && validFrom >= currentDate)
return {
message: `Token will be valid starting ${dateToString(validFrom)}`,
state: State.NotYetValid,
};
else if (exp) {
if (exp >= currentDate)
return {
message: `Token valid until ${dateToString(exp)}`,
state: State.Valid,
};
else
return {
message: `Token expired since ${dateToString(exp)}`,
state: State.Expired,
};
} else if (validFrom)
return {
message: `Token forever valid since ${dateToString(validFrom)}`,
state: State.Valid,
};
else
return {
message: "Token doesn`t contain a validity period",
state: State.Valid,
};
}

function decodeJWT(token: string): DecodedJWT {
try {
const [header, payload, signature] = token.split(".");

if (!header || !payload || !signature) {
throw new Error("Invalid token");
}

return {
header: JSON.parse(base64UrlDecode(header)),
payload: JSON.parse(base64UrlDecode(payload)),
const decodedHeader = JSON.parse(base64UrlDecode(header));
const decodedPayload = JSON.parse(base64UrlDecode(payload));
const validity = checkValidity(decodedPayload);

const decodedJWT: DecodedJWT = {
header: decodedHeader,
payload: decodedPayload,
signature,
validity,
};

return decodedJWT;
} catch (error) {
throw new Error("Invalid token");
}
}

export { decodeJWT, base64UrlDecode };
export {
decodeJWT,
checkValidity,
dateToString,
parseDate,
base64UrlDecode,
State,
};
39 changes: 34 additions & 5 deletions pages/utilities/jwt-parser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ import { CMDK } from "@/components/CMDK";
import { useCopyToClipboard } from "@/components/hooks/useCopyToClipboard";
import CallToActionGrid from "@/components/CallToActionGrid";
import Meta from "@/components/Meta";
import { decodeJWT } from "@/components/utils/jwt-parser.utils";
import { decodeJWT, State } from "@/components/utils/jwt-parser.utils";
import GitHubContribution from "@/components/GitHubContribution";

export default function JWTParser() {
const [input, setInput] = useState("");
const [header, setHeader] = useState("");
const [payload, setPayload] = useState("");
const [signature, setSignature] = useState("");
const [validity, setValidity] = useState({
message: "Validity check",
state: State.Unknown,
});

const { buttonText: headerText, handleCopy: handleCopyHeader } =
useCopyToClipboard();
Expand All @@ -25,22 +29,41 @@ export default function JWTParser() {
const { buttonText: signatureText, handleCopy: handleCopySignature } =
useCopyToClipboard();

const stateColors: Record<State, string> = {
[State.NotYetValid]: "yellow",
[State.Valid]: "green",
[State.Expired]: "red",
[State.NeverValid]: "red",
[State.Unknown]: "gray",
};

const handleChange = useCallback(
(event: ChangeEvent<HTMLTextAreaElement>) => {
const value = event.currentTarget.value;
setInput(value);

try {
const { header, payload, signature } = decodeJWT(value.trim());
setHeader(JSON.stringify(header, null, 2));
setPayload(JSON.stringify(payload, null, 2));
setSignature(signature || "");
if (!value) {
setHeader("");
setPayload("");
setSignature("");
setValidity({ message: "Validity check", state: State.Unknown });
} else {
const { header, payload, signature, validity } = decodeJWT(
value.trim()
);
setHeader(JSON.stringify(header, null, 2));
setPayload(JSON.stringify(payload, null, 2));
setSignature(signature || "");
setValidity(validity || { message: "", state: State.Unknown });
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Invalid Input.";
setHeader(errorMessage);
setPayload(errorMessage);
setSignature(errorMessage);
setValidity({ message: errorMessage, state: State.Unknown });
}
},
[]
Expand Down Expand Up @@ -76,6 +99,12 @@ export default function JWTParser() {

<Divider />

<div
className={`p-4 bg-${stateColors[validity.state]}-200 dark:bg-${stateColors[validity.state]}-800 rounded-xl mb-6`}
>
<Label className="m-0">{validity.message}</Label>
</div>

<div>
<Label>Decoded Header</Label>
<Textarea value={header} rows={6} readOnly className="mb-4" />
Expand Down
10 changes: 10 additions & 0 deletions tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ const config = {
},
},
plugins: [require("tailwindcss-animate")],
safelist: [
"bg-yellow-200",
"dark:bg-yellow-800",
"bg-green-200",
"dark:bg-green-800",
"bg-red-200",
"dark:bg-red-800",
"bg-gray-200",
"dark:bg-gray-800",
],
} satisfies Config;

export default config;