Files
markone/packages/server/src/auth/auth.ts

152 lines
4.2 KiB
TypeScript
Raw Normal View History

2025-05-07 15:47:08 +01:00
import { DEMO_USER, type User } from "@markone/core/user"
2025-05-06 11:00:35 +01:00
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"
2025-05-07 15:47:08 +01:00
import { hashPassword, verifyPassword } from "./password.ts"
2025-05-06 11:00:35 +01:00
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<Route extends string>(
handler: (request: Bun.BunRequest<Route>, user: User) => Promise<Response>,
) {
return httpHandler<Route>((request) => {
const session = verifySession(request.cookies)
if (!session) {
2025-05-07 15:47:08 +01:00
console.log("session not found!")
2025-05-06 11:00:35 +01:00
throw new HttpError(401)
}
const user = findUserById(session.userId)
if (!user) {
throw new HttpError(401)
}
const authTokenCookie = request.cookies.get("auth-token")
if (authTokenCookie) {
2025-05-07 15:47:08 +01:00
const deleteAuthTokenQuery = db.query("DELETE FROM auth_tokens WHERE id = $id")
2025-05-06 11:00:35 +01:00
// biome-ignore lint/style/noNonNullAssertion: the cookie has already been verified by verifySession previously, therefore the cookie must be in the correct format <token-id>:<token-signature>
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")
2025-05-07 15:47:08 +01:00
const createAuthTokenQuery = db.query(
"INSERT INTO auth_tokens(id, token, user_id, expires_at_unix_ms) VALUES ($id, $token, $userId, $expiresAt)",
)
2025-05-06 11:00:35 +01:00
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,
2025-05-07 15:47:08 +01:00
path: "/api",
2025-05-06 11:00:35 +01:00
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) {
2025-05-07 23:09:14 +01:00
throw new HttpError(400, "BadRequestBody", signUpRequest.summary)
2025-05-06 11:00:35 +01:00
}
const { username, password } = signUpRequest
2025-05-07 15:47:08 +01:00
const hashedPassword = await hashPassword(password)
2025-05-06 11:00:35 +01:00
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) {
2025-05-07 23:09:14 +01:00
throw new HttpError(400, "BadRequestBody")
2025-05-06 11:00:35 +01:00
}
const foundUser = findUserByUsername(loginRequest.username, {
password: true,
})
if (!foundUser) {
2025-05-07 15:47:08 +01:00
throw new HttpError(401)
2025-05-06 11:00:35 +01:00
}
2025-05-07 15:47:08 +01:00
const ok = await verifyPassword(loginRequest.password, foundUser.password).catch(() => {
2025-05-06 11:00:35 +01:00
throw new HttpError(401)
})
if (!ok) {
throw new HttpError(401)
}
const user: User = {
id: foundUser.id,
username: foundUser.username,
}
2025-05-07 15:47:08 +01:00
await createSessionForUser(user, request.cookies)
if (user.id !== DEMO_USER.id) {
2025-05-06 11:00:35 +01:00
rememberLoginForUser(user, request.cookies)
}
return Response.json(user, { status: 200 })
}
async function logout(request: Bun.BunRequest<"/api/logout">, user: User): Promise<Response> {
2025-05-07 15:47:08 +01:00
const deleteAllAuthTokensQuery = db.query("DELETE FROM auth_tokens WHERE user_id = $userId")
2025-05-06 11:00:35 +01:00
forgetAllSessions(user)
deleteAllAuthTokensQuery.run({ userId: user.id })
2025-05-07 23:09:14 +01:00
return new Response(undefined, { status: 204 })
2025-05-06 11:00:35 +01:00
}
export { authenticated, signUp, login, logout }