import { DEMO_USER, type User } from "@markone/core/user" import { type } from "arktype" import dayjs from "dayjs" import { ulid } from "ulid" import { db } from "~/database.ts" import { HttpError } from "~/error.ts" import { httpHandler } from "~/http-handler.ts" import { createUser, findUserById, findUserByUsername } from "~/user/user.ts" import { hashPassword, verifyPassword } from "./password.ts" import { createSessionForUser, extendSession, forgetAllSessions, saveSession, verifySession } from "./session.ts" const SignUpRequest = type({ username: "string", password: "string", }) const LoginRequest = type({ username: "string", password: "string", }) function authenticated( handler: (request: Bun.BunRequest, user: User) => Promise, ) { return httpHandler((request) => { const session = verifySession(request.cookies) if (!session) { console.log("session not found!") throw new HttpError(401) } const user = findUserById(session.userId) if (!user) { throw new HttpError(401) } const authTokenCookie = request.cookies.get("auth-token") if (authTokenCookie) { const deleteAuthTokenQuery = db.query("DELETE FROM auth_tokens WHERE id = $id") // biome-ignore lint/style/noNonNullAssertion: the cookie has already been verified by verifySession previously, therefore the cookie must be in the correct format : const tokenId = authTokenCookie.split(":")[0]! deleteAuthTokenQuery.run({ id: tokenId }) rememberLoginForUser(user, request.cookies) } if (user.id !== DEMO_USER.id) { const extendedSession = extendSession(session) saveSession(extendedSession, request.cookies) } return handler(request, user) }) } function rememberLoginForUser(user: User, cookies: Bun.CookieMap) { const tokenId = ulid() const authToken = Buffer.alloc(32) crypto.getRandomValues(authToken) const hasher = new Bun.CryptoHasher("sha256") hasher.update(authToken) const hashedToken = hasher.digest("base64url") const expiryDate = dayjs().add(1, "month") const createAuthTokenQuery = db.query( "INSERT INTO auth_tokens(id, token, user_id, expires_at_unix_ms) VALUES ($id, $token, $userId, $expiresAt)", ) createAuthTokenQuery.run({ id: tokenId, token: hashedToken, userId: user.id, expiresAt: expiryDate.valueOf(), }) cookies.set("auth-token", `${tokenId}:${authToken.toBase64({ alphabet: "base64url" })}`, { maxAge: 30 * 24 * 60 * 60 * 1000, path: "/api", httpOnly: true, }) } async function signUp(request: Bun.BunRequest<"/api/sign-up">) { const body = await request.json().catch(() => { throw new HttpError(500) }) const signUpRequest = SignUpRequest(body) if (signUpRequest instanceof type.errors) { throw new HttpError(400, "BadRequestBody", signUpRequest.summary) } const { username, password } = signUpRequest const hashedPassword = await hashPassword(password) const user = createUser(username, hashedPassword) await createSessionForUser(user, request.cookies) rememberLoginForUser(user, request.cookies) return Response.json(user, { status: 200 }) } async function login(request: Bun.BunRequest<"/api/login">) { const body = await request.json().catch(() => { throw new HttpError(500) }) const loginRequest = LoginRequest(body) if (loginRequest instanceof type.errors) { throw new HttpError(400, "BadRequestBody") } const foundUser = findUserByUsername(loginRequest.username, { password: true, }) if (!foundUser) { throw new HttpError(401) } const ok = await verifyPassword(loginRequest.password, foundUser.password).catch(() => { throw new HttpError(401) }) if (!ok) { throw new HttpError(401) } const user: User = { id: foundUser.id, username: foundUser.username, } await createSessionForUser(user, request.cookies) if (user.id !== DEMO_USER.id) { rememberLoginForUser(user, request.cookies) } return Response.json(user, { status: 200 }) } async function logout(request: Bun.BunRequest<"/api/logout">, user: User): Promise { const deleteAllAuthTokensQuery = db.query("DELETE FROM auth_tokens WHERE user_id = $userId") forgetAllSessions(request.cookies, user) deleteAllAuthTokensQuery.run({ userId: user.id }) return new Response(undefined, { status: 204 }) } export { authenticated, signUp, login, logout }