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 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 } )
2025-05-15 14:48:52 +01:00
rememberLoginForUser ( session . user , request . cookies )
2025-05-06 11:00:35 +01:00
}
2025-05-15 14:48:52 +01:00
if ( session . user . id !== DEMO_USER . id ) {
2025-05-06 11:00:35 +01:00
const extendedSession = extendSession ( session )
saveSession ( extendedSession , request . cookies )
}
2025-05-15 14:48:52 +01:00
return handler ( request , session . user )
2025-05-06 11:00:35 +01:00
} )
}
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 ,
} )
}
2025-05-15 14:48:52 +01:00
async function verifyAuthToken ( cookies : Bun.CookieMap ) : Promise < User | null > {
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 < void , { id : string } > ( "DELETE FROM auth_tokens WHERE id = $id" )
query . run ( { id : result.id } )
return null
}
return user
}
2025-05-21 23:27:17 +01:00
function startBackgroundAuthTokenCleanup() {
const query = db . query ( "DELETE FROM auth_tokens WHERE expires_at_unix_ms < $time" )
setInterval ( ( ) = > {
query . run ( { time : new Date ( ) . valueOf ( ) } )
} , 5000 )
}
2025-05-06 11:00:35 +01:00
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" > ) {
2025-05-15 14:48:52 +01:00
// 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 } )
}
}
2025-05-06 11:00:35 +01:00
const body = await request . json ( ) . catch ( ( ) = > {
2025-05-15 14:48:52 +01:00
throw new HttpError ( 401 )
2025-05-06 11:00:35 +01:00
} )
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 )
}
2025-05-15 14:48:52 +01:00
const user = {
2025-05-06 11:00:35 +01:00
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-07 23:40:03 +01:00
forgetAllSessions ( request . cookies , user )
2025-05-06 11:00:35 +01:00
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
}
2025-05-21 23:27:17 +01:00
export { authenticated , signUp , login , logout , startBackgroundAuthTokenCleanup }