From 9d1c0937805b46579d491668c4071971257d089f Mon Sep 17 00:00:00 2001 From: Justin Shetty Date: Fri, 11 Sep 2026 14:30:26 -0700 Subject: [PATCH] fix(auth): redirect unauthorized page requests (#628) --- src/helpers/isHtmlPageRequest.ts | 4 ++ src/pages/user.tsx | 53 ++----------------- src/services/user.ts | 63 +++++++++++++++++++++++ tests/pages/auth.test.ts | 87 ++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 50 deletions(-) create mode 100644 src/helpers/isHtmlPageRequest.ts create mode 100644 src/services/user.ts create mode 100644 tests/pages/auth.test.ts diff --git a/src/helpers/isHtmlPageRequest.ts b/src/helpers/isHtmlPageRequest.ts new file mode 100644 index 0000000..67d79f4 --- /dev/null +++ b/src/helpers/isHtmlPageRequest.ts @@ -0,0 +1,4 @@ +export function isHtmlPageRequest(request: Request): boolean { + const acceptsHtml = request.headers.get("accept")?.toLowerCase().includes("text/html"); + return (request.method === "GET" || request.method === "HEAD") && Boolean(acceptsHtml); +} diff --git a/src/pages/user.tsx b/src/pages/user.tsx index d6823eb..5fa9e04 100644 --- a/src/pages/user.tsx +++ b/src/pages/user.tsx @@ -1,5 +1,3 @@ -import { randomUUID } from "node:crypto"; -import { jwt } from "@elysiajs/jwt"; import { Elysia, t } from "elysia"; import { BaseHtml } from "../components/base"; import { Header } from "../components/header"; @@ -12,57 +10,12 @@ import { HTTP_ALLOWED, WEBROOT, } from "../helpers/env"; +import { userService } from "../services/user"; + +export { userService } from "../services/user"; export let FIRST_RUN = db.query("SELECT * FROM users").get() === null || false; -export const userService = new Elysia({ name: "user/service" }) - .use( - jwt({ - name: "jwt", - schema: t.Object({ - id: t.String(), - }), - secret: process.env.JWT_SECRET ?? randomUUID(), - exp: "7d", - }), - ) - .model({ - signIn: t.Object({ - email: t.String(), - password: t.String(), - }), - session: t.Cookie({ - auth: t.String(), - jobId: t.Optional(t.String()), - }), - optionalSession: t.Cookie({ - auth: t.Optional(t.String()), - jobId: t.Optional(t.String()), - }), - }) - .macro("auth", { - cookie: "session", - async resolve({ status, jwt, cookie: { auth } }) { - if (!auth.value) { - return status(401, { - success: false, - message: "Unauthorized", - }); - } - const user = await jwt.verify(auth.value); - if (!user) { - return status(401, { - success: false, - message: "Unauthorized", - }); - } - return { - success: true, - user, - }; - }, - }); - export const user = new Elysia() .use(userService) .get("/setup", ({ redirect }) => { diff --git a/src/services/user.ts b/src/services/user.ts new file mode 100644 index 0000000..d71204d --- /dev/null +++ b/src/services/user.ts @@ -0,0 +1,63 @@ +import { randomUUID } from "node:crypto"; +import { jwt } from "@elysiajs/jwt"; +import { Elysia, t } from "elysia"; +import { WEBROOT } from "../helpers/env"; +import { isHtmlPageRequest } from "../helpers/isHtmlPageRequest"; + +export const userService = new Elysia({ name: "user/service" }) + .use( + jwt({ + name: "jwt", + schema: t.Object({ + id: t.String(), + }), + secret: process.env.JWT_SECRET ?? randomUUID(), + exp: "7d", + }), + ) + .model({ + signIn: t.Object({ + email: t.String(), + password: t.String(), + }), + session: t.Cookie({ + auth: t.String(), + jobId: t.Optional(t.String()), + }), + optionalSession: t.Cookie({ + auth: t.Optional(t.String()), + jobId: t.Optional(t.String()), + }), + }) + .macro("auth", { + cookie: "optionalSession", + async resolve({ request, set, status, jwt, cookie: { auth } }) { + const unauthorized = () => { + if (isHtmlPageRequest(request)) { + set.headers.location = `${WEBROOT}/login`; + return status(302, { + success: false, + message: "Redirecting to login", + }); + } + + return status(401, { + success: false, + message: "Unauthorized", + }); + }; + + if (!auth.value) { + return unauthorized(); + } + const user = await jwt.verify(auth.value); + if (!user) { + auth.remove(); + return unauthorized(); + } + return { + success: true, + user, + }; + }, + }); diff --git a/tests/pages/auth.test.ts b/tests/pages/auth.test.ts new file mode 100644 index 0000000..1e97fae --- /dev/null +++ b/tests/pages/auth.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { Elysia } from "elysia"; +import { isHtmlPageRequest } from "../../src/helpers/isHtmlPageRequest"; +import { userService } from "../../src/services/user"; + +const app = new Elysia() + .use(userService) + .get("/protected", () => "protected", { auth: true }) + .post("/protected", () => "protected", { auth: true }); + +test("identifies HTML page navigation requests", () => { + expect( + isHtmlPageRequest( + new Request("http://localhost/protected", { + headers: { accept: "text/html" }, + }), + ), + ).toBe(true); + + expect( + isHtmlPageRequest( + new Request("http://localhost/protected", { + headers: { accept: "TEXT/HTML" }, + }), + ), + ).toBe(true); + + expect( + isHtmlPageRequest( + new Request("http://localhost/protected", { + method: "HEAD", + headers: { accept: "text/html,application/xhtml+xml" }, + }), + ), + ).toBe(true); +}); + +test("does not identify API requests as HTML page navigation", () => { + expect( + isHtmlPageRequest( + new Request("http://localhost/protected", { + method: "POST", + headers: { accept: "text/html" }, + }), + ), + ).toBe(false); + + expect( + isHtmlPageRequest( + new Request("http://localhost/protected", { + headers: { accept: "application/json" }, + }), + ), + ).toBe(false); + + expect( + isHtmlPageRequest( + new Request("http://localhost/protected", { + headers: { accept: "*/*" }, + }), + ), + ).toBe(false); +}); + +test("redirects unauthorized HTML requests to login", async () => { + const response = await app.handle( + new Request("http://localhost/protected", { + headers: { accept: "text/html" }, + redirect: "manual", + }), + ); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/login"); +}); + +test("returns JSON 401 for unauthorized API requests", async () => { + const response = await app.handle( + new Request("http://localhost/protected", { + method: "POST", + headers: { accept: "application/json" }, + }), + ); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ success: false, message: "Unauthorized" }); +});