From bbf656ac795b19aa5263d77ed2dad77ba49057db Mon Sep 17 00:00:00 2001 From: jucktnich <73582096+jucktnich@users.noreply.github.com> Date: Fri, 25 Apr 2025 23:57:53 +0200 Subject: [PATCH] feat: add validity-check for JWTs --- components/utils/jwt-parser.utils.test.ts | 120 +++++++++++++++++++++- components/utils/jwt-parser.utils.ts | 111 ++++++++++++++++++-- pages/utilities/jwt-parser.tsx | 39 ++++++- tailwind.config.ts | 10 ++ 4 files changed, 264 insertions(+), 16 deletions(-) diff --git a/components/utils/jwt-parser.utils.test.ts b/components/utils/jwt-parser.utils.test.ts index a4de2d2..59c2b0c 100644 --- a/components/utils/jwt-parser.utils.test.ts +++ b/components/utils/jwt-parser.utils.test.ts @@ -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) => { @@ -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"; @@ -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({ @@ -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", () => { diff --git a/components/utils/jwt-parser.utils.ts b/components/utils/jwt-parser.utils.ts index 6091fb3..b7c3ce7 100644 --- a/components/utils/jwt-parser.utils.ts +++ b/components/utils/jwt-parser.utils.ts @@ -1,5 +1,32 @@ import { fromBase64 } from "./base-64.utils"; +enum State { + NotYetValid, + Valid, + Expired, + NeverValid, + Unknown, +} + +type DecodedJWT = { + header: Record; + payload: Record; + 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, "/"); @@ -11,11 +38,63 @@ function base64UrlDecode(str: string): string { } } -function decodeJWT(token: string): { - header: Record; - payload: Record; - 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("."); @@ -23,14 +102,28 @@ function decodeJWT(token: string): { 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, +}; diff --git a/pages/utilities/jwt-parser.tsx b/pages/utilities/jwt-parser.tsx index ad69572..cda2580 100644 --- a/pages/utilities/jwt-parser.tsx +++ b/pages/utilities/jwt-parser.tsx @@ -9,7 +9,7 @@ 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() { @@ -17,6 +17,10 @@ export default function JWTParser() { 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(); @@ -25,22 +29,41 @@ export default function JWTParser() { const { buttonText: signatureText, handleCopy: handleCopySignature } = useCopyToClipboard(); + const stateColors: Record = { + [State.NotYetValid]: "yellow", + [State.Valid]: "green", + [State.Expired]: "red", + [State.NeverValid]: "red", + [State.Unknown]: "gray", + }; + const handleChange = useCallback( (event: ChangeEvent) => { 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 }); } }, [] @@ -76,6 +99,12 @@ export default function JWTParser() { +
+ +
+