-
Notifications
You must be signed in to change notification settings - Fork 61
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) => { | ||||||
|
|
@@ -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({ | ||||||
|
||||||
| expect(result.payload).toEqual({ | |
| expect(result.validity).toEqual({ |
| 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, "/"); | ||||||
|
|
@@ -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", | ||||||
|
||||||
| message: "Token doesn`t contain a validity period", | |
| message: "Token doesn't contain a validity period", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The expected payload is missing the 'iat' field that was added to the test token on line 141. The assertion should include
iat: "10000"to match the actual payload structure being tested.