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 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(session.user, request.cookies) } if (session.user.id !== DEMO_USER.id) { const extendedSession = extendSession(session) saveSession(extendedSession, request.cookies) } return handler(request, session.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 verifyAuthToken(cookies: Bun.CookieMap): Promise { const authTokenCookie = cookies.get("auth-token") if (!authTokenCookie) { return null } const [tokenId, providedTokenEncoded] = authTokenCookie.split(":") if (!tokenId || !providedTokenEncoded) { return null } const findAuthTokenQuery = db.query< { id: string; token: string; user_id: string; expires_at_unix_ms: number }, { id: string } >("SELECT id, token, user_id, expires_at_unix_ms FROM auth_tokens WHERE id = $id") const result = findAuthTokenQuery.get({ id: tokenId }) if (!result) { return null } const providedToken = Buffer.from(providedTokenEncoded, "base64url") const hasher = new Bun.CryptoHasher("sha256") hasher.update(providedToken) const hashedProvidedToken = hasher.digest() const storedToken = Buffer.from(result.token, "base64url") if (!crypto.timingSafeEqual(hashedProvidedToken, storedToken)) { return null } const user = findUserById(result.user_id) if (!user) { const query = db.query("DELETE FROM auth_tokens WHERE id = $id") query.run({ id: result.id }) return null } return user } function startBackgroundAuthTokenCleanup() { const query = db.query("DELETE FROM auth_tokens WHERE expires_at_unix_ms < $time") setInterval(() => { query.run({ time: new Date().valueOf() }) }, 5000) } 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">) { // first, check if there is an existing session const session = verifySession(request.cookies) if (session) { extendSession(session) return Response.json(session, { status: 200 }) } // then, check if there is a valid auth token { const foundUser = await verifyAuthToken(request.cookies) if (foundUser) { await createSessionForUser(foundUser, request.cookies) return Response.json(foundUser, { status: 200 }) } } const body = await request.json().catch(() => { throw new HttpError(401) }) 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 = { 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, startBackgroundAuthTokenCleanup }