2025-07-03 21:53:07 +08:00
import { Request , Response , NextFunction } from "express" ;
import createHttpError from "http-errors" ;
import HttpCode from "@server/types/HttpCode" ;
import { fromError } from "zod-validation-error" ;
import { z } from "zod" ;
import { db } from "@server/db" ;
import { User , passkeys , users , webauthnChallenge } from "@server/db" ;
import { eq , and , lt } from "drizzle-orm" ;
import { response } from "@server/lib" ;
import logger from "@server/logger" ;
import {
generateRegistrationOptions ,
verifyRegistrationResponse ,
generateAuthenticationOptions ,
verifyAuthenticationResponse
} from "@simplewebauthn/server" ;
import type {
GenerateRegistrationOptionsOpts ,
VerifyRegistrationResponseOpts ,
GenerateAuthenticationOptionsOpts ,
VerifyAuthenticationResponseOpts ,
VerifiedRegistrationResponse ,
VerifiedAuthenticationResponse
} from "@simplewebauthn/server" ;
2025-07-03 22:57:29 +08:00
import type {
AuthenticatorTransport ,
PublicKeyCredentialDescriptorJSON
} from "@simplewebauthn/types" ;
2025-07-03 21:53:07 +08:00
import config from "@server/lib/config" ;
import { UserType } from "@server/types/UserTypes" ;
2025-07-03 22:57:29 +08:00
import { verifyPassword } from "@server/auth/password" ;
import { unauthorized } from "@server/auth/unauthorizedResponse" ;
2025-07-03 21:53:07 +08:00
// The RP ID is the domain name of your application
const rpID = new URL ( config . getRawConfig ( ) . app . dashboard_url ) . hostname ;
const rpName = "Pangolin" ;
const origin = config . getRawConfig ( ) . app . dashboard_url ;
// Database-based challenge storage (replaces in-memory storage)
// Challenges are stored in the webauthnChallenge table with automatic expiration
// This supports clustered deployments and persists across server restarts
// Clean up expired challenges every 5 minutes
setInterval ( async ( ) = > {
try {
const now = Date . now ( ) ;
await db
. delete ( webauthnChallenge )
. where ( lt ( webauthnChallenge . expiresAt , now ) ) ;
logger . debug ( "Cleaned up expired passkey challenges" ) ;
} catch ( error ) {
logger . error ( "Failed to clean up expired passkey challenges" , error ) ;
}
} , 5 * 60 * 1000 ) ;
// Helper functions for challenge management
async function storeChallenge ( sessionId : string , challenge : string , passkeyName? : string , userId? : string ) {
2025-07-05 16:48:37 +08:00
const expiresAt = Date . now ( ) + ( 5 * 60 * 1000 ) ; // 5 minutes
2025-07-03 21:53:07 +08:00
// Delete any existing challenge for this session
await db . delete ( webauthnChallenge ) . where ( eq ( webauthnChallenge . sessionId , sessionId ) ) ;
// Insert new challenge
await db . insert ( webauthnChallenge ) . values ( {
sessionId ,
challenge ,
passkeyName ,
userId ,
expiresAt
} ) ;
}
async function getChallenge ( sessionId : string ) {
const [ challengeData ] = await db
. select ( )
. from ( webauthnChallenge )
. where ( eq ( webauthnChallenge . sessionId , sessionId ) )
. limit ( 1 ) ;
if ( ! challengeData ) {
return null ;
}
// Check if expired
if ( challengeData . expiresAt < Date . now ( ) ) {
await db . delete ( webauthnChallenge ) . where ( eq ( webauthnChallenge . sessionId , sessionId ) ) ;
return null ;
}
return challengeData ;
}
async function clearChallenge ( sessionId : string ) {
await db . delete ( webauthnChallenge ) . where ( eq ( webauthnChallenge . sessionId , sessionId ) ) ;
}
export const registerPasskeyBody = z . object ( {
2025-07-03 22:57:29 +08:00
name : z.string ( ) . min ( 1 ) ,
password : z.string ( ) . min ( 1 )
2025-07-03 21:53:07 +08:00
} ) . strict ( ) ;
export const verifyRegistrationBody = z . object ( {
credential : z.any ( )
} ) . strict ( ) ;
export const startAuthenticationBody = z . object ( {
email : z.string ( ) . email ( ) . optional ( )
} ) . strict ( ) ;
export const verifyAuthenticationBody = z . object ( {
credential : z.any ( )
} ) . strict ( ) ;
2025-07-03 22:57:29 +08:00
export const deletePasskeyBody = z . object ( {
password : z.string ( ) . min ( 1 )
} ) . strict ( ) ;
2025-07-03 21:53:07 +08:00
export async function startRegistration (
req : Request ,
res : Response ,
next : NextFunction
) : Promise < any > {
const parsedBody = registerPasskeyBody . safeParse ( req . body ) ;
if ( ! parsedBody . success ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
fromError ( parsedBody . error ) . toString ( )
)
) ;
}
2025-07-03 22:57:29 +08:00
const { name , password } = parsedBody . data ;
2025-07-03 21:53:07 +08:00
const user = req . user as User ;
// Only allow internal users to use passkeys
if ( user . type !== UserType . Internal ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
"Passkeys are only available for internal users"
)
) ;
}
try {
2025-07-03 22:57:29 +08:00
// Verify password
const validPassword = await verifyPassword ( password , user . passwordHash ! ) ;
if ( ! validPassword ) {
return next ( unauthorized ( ) ) ;
}
// If user has 2FA enabled, require a code
if ( user . twoFactorEnabled ) {
return response < { codeRequested : boolean } > ( res , {
data : { codeRequested : true } ,
success : true ,
error : false ,
message : "Two-factor authentication required" ,
status : HttpCode.ACCEPTED
} ) ;
}
2025-07-03 21:53:07 +08:00
// Get existing passkeys for user
const existingPasskeys = await db
. select ( )
. from ( passkeys )
. where ( eq ( passkeys . userId , user . userId ) ) ;
const excludeCredentials = existingPasskeys . map ( key = > ( {
2025-07-03 22:57:29 +08:00
id : Buffer.from ( key . credentialId , 'base64' ) . toString ( 'base64url' ) ,
2025-07-03 21:53:07 +08:00
type : 'public-key' as const ,
2025-07-03 22:57:29 +08:00
transports : key.transports ? JSON . parse ( key . transports ) as AuthenticatorTransport [ ] : undefined
} satisfies PublicKeyCredentialDescriptorJSON ) ) ;
2025-07-03 21:53:07 +08:00
const options : GenerateRegistrationOptionsOpts = {
rpName ,
rpID ,
userID : user.userId ,
userName : user.email || user . username ,
attestationType : 'none' ,
excludeCredentials ,
authenticatorSelection : {
residentKey : 'preferred' ,
userVerification : 'preferred' ,
}
} ;
const registrationOptions = await generateRegistrationOptions ( options ) ;
// Store challenge in database
await storeChallenge ( req . session . sessionId , registrationOptions . challenge , name , user . userId ) ;
2025-07-03 22:57:29 +08:00
return response < typeof registrationOptions > ( res , {
2025-07-03 21:53:07 +08:00
data : registrationOptions ,
success : true ,
error : false ,
2025-07-03 22:57:29 +08:00
message : "Registration options generated successfully" ,
2025-07-03 21:53:07 +08:00
status : HttpCode.OK
} ) ;
} catch ( error ) {
logger . error ( error ) ;
return next (
createHttpError (
HttpCode . INTERNAL_SERVER_ERROR ,
2025-07-03 22:57:29 +08:00
"Failed to start registration"
2025-07-03 21:53:07 +08:00
)
) ;
}
}
export async function verifyRegistration (
req : Request ,
res : Response ,
next : NextFunction
) : Promise < any > {
const parsedBody = verifyRegistrationBody . safeParse ( req . body ) ;
if ( ! parsedBody . success ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
fromError ( parsedBody . error ) . toString ( )
)
) ;
}
const { credential } = parsedBody . data ;
const user = req . user as User ;
// Only allow internal users to use passkeys
if ( user . type !== UserType . Internal ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
"Passkeys are only available for internal users"
)
) ;
}
try {
// Get challenge from database
const challengeData = await getChallenge ( req . session . sessionId ) ;
if ( ! challengeData ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
"No challenge found in session or challenge expired"
)
) ;
}
const verification = await verifyRegistrationResponse ( {
response : credential ,
expectedChallenge : challengeData.challenge ,
expectedOrigin : origin ,
expectedRPID : rpID ,
requireUserVerification : false
} ) ;
const { verified , registrationInfo } = verification ;
if ( ! verified || ! registrationInfo ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
"Verification failed"
)
) ;
}
// Store the passkey in the database
await db . insert ( passkeys ) . values ( {
credentialId : Buffer.from ( registrationInfo . credentialID ) . toString ( 'base64' ) ,
userId : user.userId ,
publicKey : Buffer.from ( registrationInfo . credentialPublicKey ) . toString ( 'base64' ) ,
signCount : registrationInfo.counter || 0 ,
transports : credential.response.transports ? JSON . stringify ( credential . response . transports ) : null ,
name : challengeData.passkeyName ,
lastUsed : new Date ( ) . toISOString ( ) ,
dateCreated : new Date ( ) . toISOString ( )
} ) ;
// Clear challenge data
await clearChallenge ( req . session . sessionId ) ;
return response < null > ( res , {
data : null ,
success : true ,
error : false ,
message : "Passkey registered successfully" ,
status : HttpCode.OK
} ) ;
} catch ( error ) {
logger . error ( error ) ;
return next (
createHttpError (
HttpCode . INTERNAL_SERVER_ERROR ,
"Failed to verify registration"
)
) ;
}
}
export async function listPasskeys (
req : Request ,
res : Response ,
next : NextFunction
) : Promise < any > {
const user = req . user as User ;
// Only allow internal users to use passkeys
if ( user . type !== UserType . Internal ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
"Passkeys are only available for internal users"
)
) ;
}
try {
const userPasskeys = await db
. select ( )
. from ( passkeys )
. where ( eq ( passkeys . userId , user . userId ) ) ;
return response < typeof userPasskeys > ( res , {
data : userPasskeys ,
success : true ,
error : false ,
message : "Passkeys retrieved successfully" ,
status : HttpCode.OK
} ) ;
} catch ( error ) {
logger . error ( error ) ;
return next (
createHttpError (
HttpCode . INTERNAL_SERVER_ERROR ,
"Failed to retrieve passkeys"
)
) ;
}
}
export async function deletePasskey (
req : Request ,
res : Response ,
next : NextFunction
) : Promise < any > {
const { credentialId : encodedCredentialId } = req . params ;
const credentialId = decodeURIComponent ( encodedCredentialId ) ;
const user = req . user as User ;
2025-07-03 22:57:29 +08:00
const parsedBody = deletePasskeyBody . safeParse ( req . body ) ;
if ( ! parsedBody . success ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
fromError ( parsedBody . error ) . toString ( )
)
) ;
}
const { password } = parsedBody . data ;
2025-07-03 21:53:07 +08:00
// Only allow internal users to use passkeys
if ( user . type !== UserType . Internal ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
"Passkeys are only available for internal users"
)
) ;
}
try {
2025-07-03 22:57:29 +08:00
// Verify password
const validPassword = await verifyPassword ( password , user . passwordHash ! ) ;
if ( ! validPassword ) {
return next ( unauthorized ( ) ) ;
}
// If user has 2FA enabled, require a code
if ( user . twoFactorEnabled ) {
return response < { codeRequested : boolean } > ( res , {
data : { codeRequested : true } ,
success : true ,
error : false ,
message : "Two-factor authentication required" ,
status : HttpCode.ACCEPTED
} ) ;
}
2025-07-03 21:53:07 +08:00
await db
. delete ( passkeys )
. where ( and (
eq ( passkeys . credentialId , credentialId ) ,
eq ( passkeys . userId , user . userId )
) ) ;
return response < null > ( res , {
data : null ,
success : true ,
error : false ,
message : "Passkey deleted successfully" ,
status : HttpCode.OK
} ) ;
} catch ( error ) {
logger . error ( error ) ;
return next (
createHttpError (
HttpCode . INTERNAL_SERVER_ERROR ,
"Failed to delete passkey"
)
) ;
}
}
export async function startAuthentication (
req : Request ,
res : Response ,
next : NextFunction
) : Promise < any > {
const parsedBody = startAuthenticationBody . safeParse ( req . body ) ;
if ( ! parsedBody . success ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
fromError ( parsedBody . error ) . toString ( )
)
) ;
}
const { email } = parsedBody . data ;
try {
let allowCredentials : Array < {
id : Buffer ;
type : 'public-key' ;
transports? : string [ ] ;
} > = [ ] ;
let userId ;
// If email is provided, get passkeys for that specific user
if ( email ) {
const [ user ] = await db
. select ( )
. from ( users )
. where ( eq ( users . email , email ) )
. limit ( 1 ) ;
if ( ! user || user . type !== UserType . Internal ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
2025-07-05 16:48:37 +08:00
"Invalid credentials"
2025-07-03 21:53:07 +08:00
)
) ;
}
userId = user . userId ;
const userPasskeys = await db
. select ( )
. from ( passkeys )
. where ( eq ( passkeys . userId , user . userId ) ) ;
if ( userPasskeys . length === 0 ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
"No passkeys registered for this user"
)
) ;
}
allowCredentials = userPasskeys . map ( key = > ( {
id : Buffer.from ( key . credentialId , 'base64' ) ,
type : 'public-key' as const ,
2025-07-03 22:57:29 +08:00
transports : key.transports ? JSON . parse ( key . transports ) as AuthenticatorTransport [ ] : undefined
2025-07-03 21:53:07 +08:00
} ) ) ;
} else {
// If no email provided, allow any passkey (for resident key authentication)
allowCredentials = [ ] ;
}
const options : GenerateAuthenticationOptionsOpts = {
rpID ,
allowCredentials ,
userVerification : 'preferred' ,
} ;
const authenticationOptions = await generateAuthenticationOptions ( options ) ;
// Generate a temporary session ID for unauthenticated users
const tempSessionId = email ? ` temp_ ${ email } _ ${ Date . now ( ) } ` : ` temp_ ${ Date . now ( ) } ` ;
// Store challenge in database
await storeChallenge ( tempSessionId , authenticationOptions . challenge , undefined , userId ) ;
return response ( res , {
data : { . . . authenticationOptions , tempSessionId } ,
success : true ,
error : false ,
message : "Authentication options generated" ,
status : HttpCode.OK
} ) ;
} catch ( error ) {
logger . error ( error ) ;
return next (
createHttpError (
HttpCode . INTERNAL_SERVER_ERROR ,
"Failed to generate authentication options"
)
) ;
}
}
export async function verifyAuthentication (
req : Request ,
res : Response ,
next : NextFunction
) : Promise < any > {
const parsedBody = verifyAuthenticationBody . safeParse ( req . body ) ;
if ( ! parsedBody . success ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
fromError ( parsedBody . error ) . toString ( )
)
) ;
}
const { credential } = parsedBody . data ;
const tempSessionId = req . headers [ 'x-temp-session-id' ] as string ;
if ( ! tempSessionId ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
2025-07-05 16:52:56 +08:00
"Your session information is missing. This might happen if you've been inactive for too long or if your browser cleared temporary data. Please start the sign-in process again."
2025-07-03 21:53:07 +08:00
)
) ;
}
try {
// Get challenge from database
const challengeData = await getChallenge ( tempSessionId ) ;
if ( ! challengeData ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
2025-07-05 16:52:56 +08:00
"Your sign-in session has expired. For security reasons, you have 5 minutes to complete the authentication process. Please try signing in again."
2025-07-03 21:53:07 +08:00
)
) ;
}
// Find the passkey in database
const credentialId = Buffer . from ( credential . id , 'base64' ) . toString ( 'base64' ) ;
const [ passkey ] = await db
. select ( )
. from ( passkeys )
. where ( eq ( passkeys . credentialId , credentialId ) )
. limit ( 1 ) ;
if ( ! passkey ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
2025-07-05 16:52:56 +08:00
"We couldn't verify your security key. This might happen if your device isn't compatible or if the security key was removed too quickly. Please try again and keep your security key connected until the process completes."
2025-07-03 21:53:07 +08:00
)
) ;
}
// Get the user
const [ user ] = await db
. select ( )
. from ( users )
. where ( eq ( users . userId , passkey . userId ) )
. limit ( 1 ) ;
if ( ! user || user . type !== UserType . Internal ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
"User not found or not authorized for passkey authentication"
)
) ;
}
const verification = await verifyAuthenticationResponse ( {
response : credential ,
expectedChallenge : challengeData.challenge ,
expectedOrigin : origin ,
expectedRPID : rpID ,
authenticator : {
credentialID : Buffer.from ( passkey . credentialId , 'base64' ) ,
credentialPublicKey : Buffer.from ( passkey . publicKey , 'base64' ) ,
counter : passkey.signCount ,
2025-07-03 22:57:29 +08:00
transports : passkey.transports ? JSON . parse ( passkey . transports ) as AuthenticatorTransport [ ] : undefined
2025-07-03 21:53:07 +08:00
} ,
requireUserVerification : false
} ) ;
const { verified , authenticationInfo } = verification ;
if ( ! verified ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
2025-07-05 16:52:56 +08:00
"Authentication failed. This could happen if your security key wasn't recognized or was removed too early. Please ensure your security key is properly connected and try again."
2025-07-03 21:53:07 +08:00
)
) ;
}
// Update sign count
await db
. update ( passkeys )
. set ( {
signCount : authenticationInfo.newCounter ,
lastUsed : new Date ( ) . toISOString ( )
} )
. where ( eq ( passkeys . credentialId , credentialId ) ) ;
// Create session for the user
const { createSession , generateSessionToken , serializeSessionCookie } = await import ( "@server/auth/sessions/app" ) ;
const token = generateSessionToken ( ) ;
const session = await createSession ( token , user . userId ) ;
const isSecure = req . protocol === "https" ;
const cookie = serializeSessionCookie (
token ,
isSecure ,
new Date ( session . expiresAt )
) ;
res . setHeader ( "Set-Cookie" , cookie ) ;
// Clear challenge data
await clearChallenge ( tempSessionId ) ;
return response < null > ( res , {
data : null ,
success : true ,
error : false ,
message : "Authentication successful" ,
status : HttpCode.OK
} ) ;
} catch ( error ) {
logger . error ( error ) ;
return next (
createHttpError (
HttpCode . INTERNAL_SERVER_ERROR ,
"Failed to verify authentication"
)
) ;
}
}