diff --git a/src/features/add-todo/addToDoPage.test.tsx b/src/features/add-todo/addToDoPage.test.tsx index 3f55213..83fa5bf 100644 --- a/src/features/add-todo/addToDoPage.test.tsx +++ b/src/features/add-todo/addToDoPage.test.tsx @@ -2,11 +2,29 @@ import { describe, expect, test } from "bun:test"; import { createAppConfig } from "@shared/appConfig.ts"; import { createAppConfigMiddleware } from "@shared/appConfigMiddleware.ts"; import type { AppVariables } from "@shared/appVariables.ts"; +import { + createTodoJwtPayload, + type JwtPayload, +} from "@shared/pageJwtMiddleware.ts"; import { pageRoutes } from "@shared/pageRoutes.ts"; import { Hono } from "hono"; import { sign } from "hono/jwt"; import { addToDoPage } from "./addToDoPage.tsx"; +const fixedPayloadDate = new Date("2024-01-01T00:00:00.000Z"); +const fixedIatInSeconds = Math.floor(fixedPayloadDate.getTime() / 1000); +const fixedExpInSeconds = Math.floor( + new Date("2100-01-01T00:00:00.000Z").getTime() / 1000, +); + +const createJwtPayload = (): JwtPayload => { + return { + ...createTodoJwtPayload("1234", "admin", "admin", fixedPayloadDate), + exp: fixedExpInSeconds, + iat: fixedIatInSeconds, + }; +}; + describe("addToDoPage", () => { test("redirects to login when JWT cookie is missing", async () => { const appConfig = createAppConfig({ @@ -28,11 +46,7 @@ describe("addToDoPage", () => { const appConfig = createAppConfig({ JWT_SECRET: "12345678901234567890123456789012", }); - const token = await sign( - { sub: "1234", preferred_username: "admin" }, - appConfig.jwt.secret, - "HS256", - ); + const token = await sign(createJwtPayload(), appConfig.jwt.secret, "HS256"); const app = new Hono<{ Variables: AppVariables }>() .use("*", createAppConfigMiddleware(appConfig)) .route(pageRoutes.ADD_TODO, addToDoPage); diff --git a/src/features/add-todo/addToDoPage.tsx b/src/features/add-todo/addToDoPage.tsx index 5667569..ad3feeb 100644 --- a/src/features/add-todo/addToDoPage.tsx +++ b/src/features/add-todo/addToDoPage.tsx @@ -1,14 +1,12 @@ import type { AppVariables } from "@shared/appVariables.ts"; import { Page } from "@shared/page.tsx"; import { pageJwtMiddleware } from "@shared/pageJwtMiddleware.ts"; -import { getUsernameFromJwtPayload } from "@shared/utility.ts"; import { Hono } from "hono"; export const addToDoPage = new Hono<{ Variables: AppVariables }>() .use("/", pageJwtMiddleware) .get("/", (c) => { - const username = getUsernameFromJwtPayload(c.var.jwtPayload); - + const username = c.var.validJwtPayload.preferred_username; return c.html(

Add ToDo

diff --git a/src/features/home/homePage.test.tsx b/src/features/home/homePage.test.tsx index 8404237..5330a83 100644 --- a/src/features/home/homePage.test.tsx +++ b/src/features/home/homePage.test.tsx @@ -2,11 +2,29 @@ import { describe, expect, test } from "bun:test"; import { createAppConfig } from "@shared/appConfig.ts"; import { createAppConfigMiddleware } from "@shared/appConfigMiddleware.ts"; import type { AppVariables } from "@shared/appVariables.ts"; +import { + createTodoJwtPayload, + type JwtPayload, +} from "@shared/pageJwtMiddleware.ts"; import { pageRoutes } from "@shared/pageRoutes.ts"; import { Hono } from "hono"; import { sign } from "hono/jwt"; import { homePage } from "./homePage.tsx"; +const fixedPayloadDate = new Date("2024-01-01T00:00:00.000Z"); +const fixedIatInSeconds = Math.floor(fixedPayloadDate.getTime() / 1000); +const fixedExpInSeconds = Math.floor( + new Date("2100-01-01T00:00:00.000Z").getTime() / 1000, +); + +const createJwtPayload = (): JwtPayload => { + return { + ...createTodoJwtPayload("1234", "admin", "admin", fixedPayloadDate), + exp: fixedExpInSeconds, + iat: fixedIatInSeconds, + }; +}; + describe("homePage", () => { test("redirects to login when JWT cookie is missing", async () => { const appConfig = createAppConfig({ @@ -26,11 +44,7 @@ describe("homePage", () => { const appConfig = createAppConfig({ JWT_SECRET: "12345678901234567890123456789012", }); - const token = await sign( - { sub: "1234", preferred_username: "admin" }, - appConfig.jwt.secret, - "HS256", - ); + const token = await sign(createJwtPayload(), appConfig.jwt.secret, "HS256"); const app = new Hono<{ Variables: AppVariables }>() .use("*", createAppConfigMiddleware(appConfig)) .route("/", homePage); diff --git a/src/features/home/homePage.tsx b/src/features/home/homePage.tsx index d0f1126..c98d960 100644 --- a/src/features/home/homePage.tsx +++ b/src/features/home/homePage.tsx @@ -1,13 +1,12 @@ import type { AppVariables } from "@shared/appVariables.ts"; import { Page } from "@shared/page.tsx"; import { pageJwtMiddleware } from "@shared/pageJwtMiddleware.ts"; -import { getUsernameFromJwtPayload } from "@shared/utility.ts"; import { Hono } from "hono"; export const homePage = new Hono<{ Variables: AppVariables }>() .use("/", pageJwtMiddleware) .get("/", (c) => { - const username = getUsernameFromJwtPayload(c.var.jwtPayload); + const username = c.var.validJwtPayload.preferred_username; return c.html(

Home

diff --git a/src/features/login/loginApi.tsx b/src/features/login/loginApi.tsx index 412a1d8..7f6f830 100644 --- a/src/features/login/loginApi.tsx +++ b/src/features/login/loginApi.tsx @@ -1,6 +1,7 @@ import { zValidator } from "@hono/zod-validator"; import type { AppVariables } from "@shared/appVariables.ts"; import { sseRedirect } from "@shared/datastar.ts"; +import { createTodoJwtPayload } from "@shared/pageJwtMiddleware.ts"; import { pageRoutes } from "@shared/pageRoutes.ts"; import { getUserByUsername } from "@shared/user.ts"; import { addDays } from "date-fns"; @@ -37,18 +38,13 @@ export const loginApi = new Hono<{ Variables: AppVariables }>().post( if (!isPasswordValid) { throw new HTTPException(401, { message: "Invalid credentials" }); } - const token = await sign( - { - sub: user.id, - preferred_username: user.username, - role: "admin", - iss: "todo-app", - exp: Math.floor(addDays(now, 1).getTime() / 1000), - iat: Math.floor(now.getTime() / 1000), - }, - appConfig.jwt.secret, - "HS256", + const jwtPayload = createTodoJwtPayload( + user.id, + user.username, + "admin", + now, ); + const token = await sign(jwtPayload, appConfig.jwt.secret, "HS256"); setCookie(c, appConfig.jwt.cookieName, token, { httpOnly: true, sameSite: "Strict", diff --git a/src/shared/appVariables.ts b/src/shared/appVariables.ts index e603032..911a460 100644 --- a/src/shared/appVariables.ts +++ b/src/shared/appVariables.ts @@ -1,10 +1,12 @@ -import type { JwtVariables } from "hono/jwt"; import type { Logger } from "winston"; import type { AppConfig } from "./appConfig.ts"; import type { Clock } from "./clock.ts"; +import type { JwtPayload } from "./pageJwtMiddleware.ts"; export type AppVariables = { clock: Clock; appConfig: AppConfig; logger: Logger; -} & JwtVariables; + jwtPayload: unknown; + validJwtPayload: JwtPayload; +}; diff --git a/src/shared/pageJwtMiddleware.test.ts b/src/shared/pageJwtMiddleware.test.ts index 1be2f78..ab4430b 100644 --- a/src/shared/pageJwtMiddleware.test.ts +++ b/src/shared/pageJwtMiddleware.test.ts @@ -1,12 +1,32 @@ import { describe, expect, test } from "bun:test"; -import type { AppVariables } from "@shared/appVariables.ts"; +import { addDays } from "date-fns"; import { Hono } from "hono"; import { sign } from "hono/jwt"; import { createAppConfig } from "./appConfig.ts"; import { createAppConfigMiddleware } from "./appConfigMiddleware.ts"; -import { pageJwtMiddleware } from "./pageJwtMiddleware.ts"; +import type { AppVariables } from "./appVariables.ts"; +import { + createTodoJwtPayload, + type JwtPayload, + pageJwtMiddleware, +} from "./pageJwtMiddleware.ts"; import { pageRoutes } from "./pageRoutes.ts"; +const fixedPayloadDate = new Date("2024-01-01T00:00:00.000Z"); +const fixedIatInSeconds = Math.floor(fixedPayloadDate.getTime() / 1000); +const fixedExpInSeconds = Math.floor( + new Date("2100-01-01T00:00:00.000Z").getTime() / 1000, +); + +const createJwtPayload = (overrides: Partial = {}): JwtPayload => { + return { + ...createTodoJwtPayload("1234", "admin", "admin", fixedPayloadDate), + exp: fixedExpInSeconds, + iat: fixedIatInSeconds, + ...overrides, + }; +}; + describe("pageJwtMiddleware", () => { test("redirects to login when JWT cookie is missing", async () => { const appConfig = createAppConfig({ @@ -48,7 +68,7 @@ describe("pageJwtMiddleware", () => { const appConfig = createAppConfig({ JWT_SECRET: "12345678901234567890123456789012", }); - const token = await sign({ sub: "1234" }, appConfig.jwt.secret, "HS256"); + const token = await sign(createJwtPayload(), appConfig.jwt.secret, "HS256"); let handlerReached = false; const app = new Hono<{ Variables: AppVariables }>() .use("*", createAppConfigMiddleware(appConfig)) @@ -74,7 +94,7 @@ describe("pageJwtMiddleware", () => { const appConfig = createAppConfig({ JWT_SECRET: "12345678901234567890123456789012", }); - const token = await sign({ sub: "1234" }, appConfig.jwt.secret, "HS256"); + const token = await sign(createJwtPayload(), appConfig.jwt.secret, "HS256"); const app = new Hono<{ Variables: AppVariables }>() .use("*", createAppConfigMiddleware(appConfig)) .use("*", pageJwtMiddleware) @@ -93,3 +113,42 @@ describe("pageJwtMiddleware", () => { expect(response.status).toBe(500); }); }); + +describe("createTodoJwtPayload", () => { + test("maps user fields and static issuer", () => { + const now = new Date("2026-05-24T12:00:00.000Z"); + + const payload = createTodoJwtPayload("user-123", "admin", "editor", now); + + expect(payload.sub).toBe("user-123"); + expect(payload.preferred_username).toBe("admin"); + expect(payload.role).toBe("editor"); + expect(payload.iss).toBe("todo-app"); + }); + + test("sets iat to current time in seconds", () => { + const now = new Date("2026-05-24T12:34:56.000Z"); + + const payload = createTodoJwtPayload("user-1", "user", "admin", now); + + expect(payload.iat).toBe(Math.floor(now.getTime() / 1000)); + }); + + test("sets exp to one day after current time in seconds", () => { + const now = new Date("2026-05-24T12:34:56.000Z"); + + const payload = createTodoJwtPayload("user-1", "user", "admin", now); + + expect(payload.exp).toBe(Math.floor(addDays(now, 1).getTime() / 1000)); + expect(payload.exp - payload.iat).toBe(24 * 60 * 60); + }); + + test("floors fractional milliseconds for both iat and exp", () => { + const now = new Date("2026-05-24T12:34:56.789Z"); + + const payload = createTodoJwtPayload("user-1", "user", "admin", now); + + expect(payload.iat).toBe(Math.floor(now.getTime() / 1000)); + expect(payload.exp).toBe(Math.floor(addDays(now, 1).getTime() / 1000)); + }); +}); diff --git a/src/shared/pageJwtMiddleware.ts b/src/shared/pageJwtMiddleware.ts index 3331d97..c87ea48 100644 --- a/src/shared/pageJwtMiddleware.ts +++ b/src/shared/pageJwtMiddleware.ts @@ -1,8 +1,38 @@ +import { addDays } from "date-fns"; import { createMiddleware } from "hono/factory"; import { jwt } from "hono/jwt"; +import z from "zod"; import type { AppVariables } from "./appVariables.ts"; import { pageRoutes } from "./pageRoutes.ts"; +const jwtPayloadSchema = z.object({ + sub: z.string(), + preferred_username: z.string(), + role: z.string(), + iss: z.string(), + exp: z.number(), + iat: z.number(), +}); + +export type JwtPayload = z.infer; + +export function createTodoJwtPayload( + userId: string, + username: string, + role: string, + now: Date, +): JwtPayload { + const iat: number = Math.floor(now.getTime() / 1000); + return { + sub: userId, + preferred_username: username, + role, + iss: "todo-app", + exp: Math.floor(addDays(now, 1).getTime() / 1000), + iat, + }; +} + export const pageJwtMiddleware = createMiddleware<{ Variables: AppVariables }>( async (c, next) => { const appConfig = c.var.appConfig; @@ -14,6 +44,8 @@ export const pageJwtMiddleware = createMiddleware<{ Variables: AppVariables }>( try { await verifyJwt(c, async () => {}); + const jwtPayload = jwtPayloadSchema.parse(c.var.jwtPayload); + c.set("validJwtPayload", jwtPayload); } catch { return c.redirect(pageRoutes.LOGIN); } diff --git a/src/shared/utility.test.ts b/src/shared/utility.test.ts deleted file mode 100644 index c664184..0000000 --- a/src/shared/utility.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { getUsernameFromJwtPayload } from "./utility.ts"; - -describe("getUsernameFromJwtPayload", () => { - test("returns preferred_username when it is a string", () => { - const username = getUsernameFromJwtPayload({ - preferred_username: "admin", - }); - - expect(username).toBe("admin"); - }); - - test("returns an empty string when payload is null", () => { - const username = getUsernameFromJwtPayload(null); - - expect(username).toBe(""); - }); - - test("returns an empty string when payload is not an object", () => { - const username = getUsernameFromJwtPayload("not-an-object"); - - expect(username).toBe(""); - }); - - test("returns an empty string when preferred_username is missing", () => { - const username = getUsernameFromJwtPayload({ sub: "1234" }); - - expect(username).toBe(""); - }); - - test("returns an empty string when preferred_username is not a string", () => { - const username = getUsernameFromJwtPayload({ - preferred_username: 123, - }); - - expect(username).toBe(""); - }); -}); diff --git a/src/shared/utility.ts b/src/shared/utility.ts deleted file mode 100644 index e7349a3..0000000 --- a/src/shared/utility.ts +++ /dev/null @@ -1,14 +0,0 @@ -export function getUsernameFromJwtPayload(payload: unknown): string { - if (typeof payload === "object" && payload !== null) { - const payloadRecord = payload as Record; - - if ( - "preferred_username" in payloadRecord && - typeof payloadRecord["preferred_username"] === "string" - ) { - return payloadRecord["preferred_username"]; - } - } - - return ""; -}