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 ,
"Missing temp session ID"
)
) ;
}
try {
// Get challenge from database
const challengeData = await getChallenge ( tempSessionId ) ;
if ( ! challengeData ) {
return next (
createHttpError (
HttpCode . BAD_REQUEST ,
"No challenge found or challenge expired"
)
) ;
}
// 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:48:37 +08:00
"We couldn't find this security key. Please make sure you're using a security key that was previously registered with this account. If you're having trouble, try registering a new security key or contact support."
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 ,
"Authentication failed"
)
) ;
}
// 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"
)
) ;
}
}