updated auth routes to use sessions and "httpOnly" cookies

This commit is contained in:
Oliver Bryan
2026-01-09 05:33:36 +00:00
parent 89b38a4aa3
commit f90ddc2e4c
6 changed files with 104 additions and 30 deletions

View File

@@ -1,10 +1,25 @@
import { withAuth, withCors } from "./auth/middleware"; import { withAuth, withCors, withCSRF } from "./auth/middleware";
import { testDB } from "./db/client"; import { testDB } from "./db/client";
import { cleanupExpiredSessions } from "./db/queries";
import { routes } from "./routes"; import { routes } from "./routes";
const DEV = process.argv.find((arg) => ["--dev", "--developer", "-d"].includes(arg.toLowerCase())) != null; const DEV = process.argv.find((arg) => ["--dev", "--developer", "-d"].includes(arg.toLowerCase())) != null;
const PORT = process.argv.find((arg) => arg.toLowerCase().startsWith("--port="))?.split("=")[1] || 0; const PORT = process.argv.find((arg) => arg.toLowerCase().startsWith("--port="))?.split("=")[1] || 0;
const SESSION_CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour in ms
const startSessionCleanup = () => {
const cleanup = async () => {
const count = await cleanupExpiredSessions();
if (count > 0) {
console.log(`cleaned up ${count} expired sessions`);
}
};
cleanup();
setInterval(cleanup, SESSION_CLEANUP_INTERVAL);
};
const main = async () => { const main = async () => {
const server = Bun.serve({ const server = Bun.serve({
port: Number(PORT), port: Number(PORT),
@@ -12,35 +27,39 @@ const main = async () => {
"/": withCors(() => new Response(`title: eussi\ndev-mode: ${DEV}\nport: ${PORT}`)), "/": withCors(() => new Response(`title: eussi\ndev-mode: ${DEV}\nport: ${PORT}`)),
"/health": withCors(() => new Response("OK")), "/health": withCors(() => new Response("OK")),
// routes that modify state require withCSRF middleware
"/auth/register": withCors(routes.authRegister), "/auth/register": withCors(routes.authRegister),
"/auth/login": withCors(routes.authLogin), "/auth/login": withCors(routes.authLogin),
"/auth/logout": withCors(withAuth(withCSRF(routes.authLogout))),
"/auth/me": withCors(withAuth(routes.authMe)), "/auth/me": withCors(withAuth(routes.authMe)),
"/user/by-username": withCors(withAuth(routes.userByUsername)), "/user/by-username": withCors(withAuth(routes.userByUsername)),
"/user/update": withCors(withAuth(routes.userUpdate)), "/user/update": withCors(withAuth(withCSRF(routes.userUpdate))),
"/user/upload-avatar": withCors(routes.userUploadAvatar), "/user/upload-avatar": withCors(withAuth(withCSRF(routes.userUploadAvatar))),
"/issue/create": withCors(withAuth(routes.issueCreate)), "/issue/create": withCors(withAuth(withCSRF(routes.issueCreate))),
"/issue/update": withCors(withAuth(routes.issueUpdate)), "/issue/update": withCors(withAuth(withCSRF(routes.issueUpdate))),
"/issue/delete": withCors(withAuth(routes.issueDelete)), "/issue/delete": withCors(withAuth(withCSRF(routes.issueDelete))),
"/issues/by-project": withCors(withAuth(routes.issuesByProject)), "/issues/by-project": withCors(withAuth(routes.issuesByProject)),
"/issues/all": withCors(withAuth(routes.issues)), "/issues/all": withCors(withAuth(routes.issues)),
"/organisation/create": withCors(withAuth(routes.organisationCreate)), "/organisation/create": withCors(withAuth(withCSRF(routes.organisationCreate))),
"/organisation/by-id": withCors(withAuth(routes.organisationById)), "/organisation/by-id": withCors(withAuth(routes.organisationById)),
"/organisation/update": withCors(withAuth(routes.organisationUpdate)), "/organisation/update": withCors(withAuth(withCSRF(routes.organisationUpdate))),
"/organisation/delete": withCors(withAuth(routes.organisationDelete)), "/organisation/delete": withCors(withAuth(withCSRF(routes.organisationDelete))),
"/organisation/add-member": withCors(withAuth(routes.organisationAddMember)), "/organisation/add-member": withCors(withAuth(withCSRF(routes.organisationAddMember))),
"/organisation/members": withCors(withAuth(routes.organisationMembers)), "/organisation/members": withCors(withAuth(routes.organisationMembers)),
"/organisation/remove-member": withCors(withAuth(routes.organisationRemoveMember)), "/organisation/remove-member": withCors(withAuth(withCSRF(routes.organisationRemoveMember))),
"/organisation/update-member-role": withCors(withAuth(routes.organisationUpdateMemberRole)), "/organisation/update-member-role": withCors(
withAuth(withCSRF(routes.organisationUpdateMemberRole)),
),
"/organisations/by-user": withCors(withAuth(routes.organisationsByUser)), "/organisations/by-user": withCors(withAuth(routes.organisationsByUser)),
"/project/create": withCors(withAuth(routes.projectCreate)), "/project/create": withCors(withAuth(withCSRF(routes.projectCreate))),
"/project/update": withCors(withAuth(routes.projectUpdate)), "/project/update": withCors(withAuth(withCSRF(routes.projectUpdate))),
"/project/delete": withCors(withAuth(routes.projectDelete)), "/project/delete": withCors(withAuth(withCSRF(routes.projectDelete))),
"/project/with-creator": withCors(withAuth(routes.projectWithCreator)), "/project/with-creator": withCors(withAuth(routes.projectWithCreator)),
"/projects/by-creator": withCors(withAuth(routes.projectsByCreator)), "/projects/by-creator": withCors(withAuth(routes.projectsByCreator)),
@@ -52,6 +71,7 @@ const main = async () => {
console.log(`eussi (issue server) listening on ${server.url}`); console.log(`eussi (issue server) listening on ${server.url}`);
await testDB(); await testDB();
startSessionCleanup();
}; };
main(); main();

View File

@@ -1,6 +1,6 @@
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { generateToken, verifyPassword } from "../../auth/utils"; import { buildAuthCookie, generateToken, verifyPassword } from "../../auth/utils";
import { getUserByUsername } from "../../db/queries"; import { createSession, getUserByUsername } from "../../db/queries";
const isNonEmptyString = (value: unknown): value is string => const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.trim().length > 0; typeof value === "string" && value.trim().length > 0;
@@ -37,10 +37,24 @@ export default async function login(req: BunRequest) {
return new Response("invalid credentials", { status: 401 }); return new Response("invalid credentials", { status: 401 });
} }
const token = generateToken(user.id); const session = await createSession(user.id);
if (!session) {
return Response.json({ return new Response("failed to create session", { status: 500 });
user: { id: user.id, name: user.name, username: user.username }, }
token,
}); const token = generateToken(session.id, user.id);
return new Response(
JSON.stringify({
user: { id: user.id, name: user.name, username: user.username, avatarURL: user.avatarURL },
csrfToken: session.csrfToken,
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": buildAuthCookie(token),
},
},
);
} }

View File

@@ -0,0 +1,19 @@
import type { AuthedRequest } from "../../auth/middleware";
import { buildClearAuthCookie } from "../../auth/utils";
import { deleteSession } from "../../db/queries";
export default async function logout(req: AuthedRequest) {
if (req.method !== "POST") {
return new Response("method not allowed", { status: 405 });
}
await deleteSession(req.sessionId);
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": buildClearAuthCookie(),
},
});
}

View File

@@ -8,5 +8,10 @@ export default async function me(req: AuthedRequest) {
return new Response("user not found", { status: 404 }); return new Response("user not found", { status: 404 });
} }
return Response.json(user as UserRecord); const { passwordHash: _, ...safeUser } = user;
return Response.json({
user: safeUser as Omit<UserRecord, "passwordHash">,
csrfToken: req.csrfToken,
});
} }

View File

@@ -1,6 +1,6 @@
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { generateToken, hashPassword } from "../../auth/utils"; import { buildAuthCookie, generateToken, hashPassword } from "../../auth/utils";
import { createUser, getUserByUsername } from "../../db/queries"; import { createSession, createUser, getUserByUsername } from "../../db/queries";
const isNonEmptyString = (value: unknown): value is string => const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.trim().length > 0; typeof value === "string" && value.trim().length > 0;
@@ -54,10 +54,24 @@ export default async function register(req: BunRequest) {
return new Response("failed to create user", { status: 500 }); return new Response("failed to create user", { status: 500 });
} }
const token = generateToken(user.id); const session = await createSession(user.id);
if (!session) {
return Response.json({ return new Response("failed to create session", { status: 500 });
token, }
user,
}); const token = generateToken(session.id, user.id);
return new Response(
JSON.stringify({
user: { id: user.id, name: user.name, username: user.username, avatarURL: user.avatarURL },
csrfToken: session.csrfToken,
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": buildAuthCookie(token),
},
},
);
} }

View File

@@ -1,4 +1,5 @@
import authLogin from "./auth/login"; import authLogin from "./auth/login";
import authLogout from "./auth/logout";
import authMe from "./auth/me"; import authMe from "./auth/me";
import authRegister from "./auth/register"; import authRegister from "./auth/register";
import issueCreate from "./issue/create"; import issueCreate from "./issue/create";
@@ -30,6 +31,7 @@ import userUploadAvatar from "./user/upload-avatar";
export const routes = { export const routes = {
authRegister, authRegister,
authLogin, authLogin,
authLogout,
authMe, authMe,
userByUsername, userByUsername,