diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 4cfa012..64425fc 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -35,12 +35,16 @@ export const authMiddleware: MiddlewareHandler = async( const payload = verifyToken(token); context.set("discordId", payload.discordId); } catch (error) { - void logger.error( - "auth_middleware", - error instanceof Error - ? error - : new Error(String(error)), - ); + const isExpiredToken + = error instanceof Error && error.message === "Token has expired"; + if (!isExpiredToken) { + void logger.error( + "auth_middleware", + error instanceof Error + ? error + : new Error(String(error)), + ); + } return context.json({ error: "Invalid or expired token" }, 401); } diff --git a/apps/api/test/middleware/auth.spec.ts b/apps/api/test/middleware/auth.spec.ts index 09167db..425f161 100644 --- a/apps/api/test/middleware/auth.spec.ts +++ b/apps/api/test/middleware/auth.spec.ts @@ -6,18 +6,26 @@ vi.mock("../../src/services/jwt.js", () => ({ verifyToken: vi.fn(), })); +vi.mock("../../src/services/logger.js", () => ({ + logger: { + error: vi.fn().mockResolvedValue(undefined), + }, +})); + describe("authMiddleware", () => { beforeEach(() => { vi.resetModules(); + vi.clearAllMocks(); }); const makeApp = async () => { const { authMiddleware } = await import("../../src/middleware/auth.js"); const { verifyToken } = await import("../../src/services/jwt.js"); + const { logger } = await import("../../src/services/logger.js"); const app = new Hono<{ Variables: { discordId: string } }>(); app.use("*", authMiddleware); app.get("/test", (c) => c.json({ discordId: c.get("discordId") })); - return { app, verifyToken }; + return { app, logger, verifyToken }; }; it("returns 401 when Authorization header is missing", async () => { @@ -45,8 +53,8 @@ describe("authMiddleware", () => { expect(body.discordId).toBe("user_123"); }); - it("returns 401 when verifyToken throws", async () => { - const { app, verifyToken } = await makeApp(); + it("returns 401 and logs when verifyToken throws a non-expiry error", async () => { + const { app, logger, verifyToken } = await makeApp(); vi.mocked(verifyToken).mockImplementationOnce(() => { throw new Error("Invalid token"); }); @@ -54,10 +62,15 @@ describe("authMiddleware", () => { headers: { Authorization: "Bearer bad_token" }, })); expect(res.status).toBe(401); + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */ + expect((logger.error as ReturnType)).toHaveBeenCalledWith( + "auth_middleware", + expect.any(Error), + ); }); - it("returns 401 when verifyToken throws a non-Error value", async () => { - const { app, verifyToken } = await makeApp(); + it("returns 401 and logs when verifyToken throws a non-Error value", async () => { + const { app, logger, verifyToken } = await makeApp(); vi.mocked(verifyToken).mockImplementationOnce(() => { throw "raw string error"; }); @@ -65,5 +78,23 @@ describe("authMiddleware", () => { headers: { Authorization: "Bearer bad_token" }, })); expect(res.status).toBe(401); + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */ + expect((logger.error as ReturnType)).toHaveBeenCalledWith( + "auth_middleware", + expect.any(Error), + ); + }); + + it("returns 401 without logging when token has expired", async () => { + const { app, logger, verifyToken } = await makeApp(); + vi.mocked(verifyToken).mockImplementationOnce(() => { + throw new Error("Token has expired"); + }); + const res = await app.fetch(new Request("http://localhost/test", { + headers: { Authorization: "Bearer expired_token" }, + })); + expect(res.status).toBe(401); + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */ + expect((logger.error as ReturnType)).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index f26b210..151aa85 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -92,6 +92,11 @@ const fetchJson = async ( = typeof errorBody.error === "string" ? errorBody.error : "Unknown error"; + if (response.status === 401) { + globalThis.localStorage.removeItem("elysium_token"); + globalThis.localStorage.removeItem("elysium_save_signature"); + globalThis.location.href = "/"; + } if (response.status >= 400 && response.status < 500) { throw new ValidationError(message, response.status); }