import * as plugins from '../plugins.js'; import { LoginSession } from './classes.loginsession.js'; import { MfaChallenge } from './classes.mfachallenge.js'; import { PasskeyCredential } from './classes.passkeycredential.js'; import { TotpCredential } from './classes.totpcredential.js'; import { WebAuthnChallenge } from './classes.webauthnchallenge.js'; import type { Reception } from './classes.reception.js'; import type { User } from './classes.user.js'; type TMfaMethod = 'totp' | 'backupCode' | 'passkey'; export class MfaManager { private readonly mfaChallengeMillis = plugins.smarttime.getMilliSecondsFromUnits({ minutes: 5 }); private readonly webAuthnChallengeMillis = plugins.smarttime.getMilliSecondsFromUnits({ minutes: 5 }); private readonly attemptConfig = { maxAttempts: 5, windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }), blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }), }; public typedRouter = new plugins.typedrequest.TypedRouter(); public CTotpCredential = plugins.smartdata.setDefaultManagerForDoc(this, TotpCredential); public CMfaChallenge = plugins.smartdata.setDefaultManagerForDoc(this, MfaChallenge); public CPasskeyCredential = plugins.smartdata.setDefaultManagerForDoc(this, PasskeyCredential); public CWebAuthnChallenge = plugins.smartdata.setDefaultManagerForDoc(this, WebAuthnChallenge); constructor(public receptionRef: Reception) { this.receptionRef.typedrouter.addTypedRouter(this.typedRouter); this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('getMfaStatus', async (requestArg) => { const user = await this.getUserByJwt(requestArg.jwt); const [totpCredential, passkeys] = await Promise.all([ this.getActiveTotpCredential(user.id), this.getActivePasskeysForUser(user.id), ]); return { totpEnabled: !!totpCredential, backupCodesRemaining: totpCredential ? this.getRemainingBackupCodeCount(totpCredential) : 0, passkeys: passkeys.map((passkeyArg) => this.serializePasskey(passkeyArg)), availableMethods: await this.getAvailableMfaMethodsForUser(user.id), }; })); this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('startTotpEnrollment', async (requestArg) => { const user = await this.getUserByJwt(requestArg.jwt); return this.startTotpEnrollmentForUser(user); })); this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('finishTotpEnrollment', async (requestArg) => { const user = await this.getUserByJwt(requestArg.jwt); return this.finishTotpEnrollmentForUser(user, requestArg.credentialId, requestArg.code); })); this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('disableTotp', async (requestArg) => { const user = await this.getUserByJwt(requestArg.jwt); await this.disableTotpForUser(user.id, requestArg.code); return { success: true }; })); this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('regenerateBackupCodes', async (requestArg) => { const user = await this.getUserByJwt(requestArg.jwt); return { backupCodes: await this.regenerateBackupCodesForUser(user.id, requestArg.code) }; })); this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('verifyMfaChallenge', async (requestArg) => { return this.verifyMfaChallengeWithCode( requestArg.mfaChallengeToken, requestArg.method, requestArg.code, ); })); this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('startPasskeyRegistration', async (requestArg) => { const user = await this.getUserByJwt(requestArg.jwt); return this.startPasskeyRegistrationForUser(user, requestArg.label); })); this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('finishPasskeyRegistration', async (requestArg) => { const user = await this.getUserByJwt(requestArg.jwt); return this.finishPasskeyRegistrationForUser(user, requestArg.challengeId, requestArg.response, requestArg.label); })); this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('revokePasskey', async (requestArg) => { const user = await this.getUserByJwt(requestArg.jwt); await this.revokePasskeyForUser(user.id, requestArg.passkeyId); return { success: true }; })); this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('startPasskeyLogin', async (requestArg) => { return this.startPasskeyLogin(requestArg.username); })); this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('finishPasskeyLogin', async (requestArg) => { return this.finishPasskeyLogin(requestArg.challengeId, requestArg.response); })); this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('startPasskeyMfa', async (requestArg) => { return this.startPasskeyMfa(requestArg.mfaChallengeToken); })); this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler('finishPasskeyMfa', async (requestArg) => { return this.finishPasskeyMfa( requestArg.mfaChallengeToken, requestArg.challengeId, requestArg.response, ); })); } public get db() { return this.receptionRef.db.smartdataDb; } public async getAvailableMfaMethodsForUser(userIdArg: string): Promise { const methods: TMfaMethod[] = []; const [totpCredential, passkeys] = await Promise.all([ this.getActiveTotpCredential(userIdArg), this.getActivePasskeysForUser(userIdArg), ]); if (totpCredential) { methods.push('totp'); if (this.getRemainingBackupCodeCount(totpCredential) > 0) { methods.push('backupCode'); } } if (passkeys.length > 0) { methods.push('passkey'); } return methods; } public async createMfaChallengeForUser(userIdArg: string, primaryAuthMethodArg: 'password' | 'email') { const availableMethods = await this.getAvailableMfaMethodsForUser(userIdArg); if (!availableMethods.length) { return null; } const token = this.createOpaqueToken('mfa_'); const mfaChallenge = new MfaChallenge(); mfaChallenge.id = plugins.smartunique.shortId(); mfaChallenge.data = { userId: userIdArg, tokenHash: MfaChallenge.hashToken(token), status: 'pending', availableMethods, primaryAuthMethod: primaryAuthMethodArg, createdAt: Date.now(), expiresAt: Date.now() + this.mfaChallengeMillis, completedAt: null, }; await mfaChallenge.save(); return { token, availableMethods, }; } public async cleanupExpiredChallenges() { const now = Date.now(); const [mfaChallenges, webAuthnChallenges] = await Promise.all([ this.CMfaChallenge.getInstances({ 'data.status': 'pending' }), this.CWebAuthnChallenge.getInstances({ 'data.status': 'pending' }), ]); for (const challenge of mfaChallenges) { if (challenge.data.expiresAt < now) { await challenge.markExpired(); } } for (const challenge of webAuthnChallenges) { if (challenge.data.expiresAt < now) { await challenge.markExpired(); } } } private async getUserByJwt(jwtArg: string): Promise { const user = await this.receptionRef.userManager.getUserByJwtValidation(jwtArg); if (!user) { throw new plugins.typedrequest.TypedResponseError('Invalid JWT'); } return user; } private async getUserByIdentifier(identifierArg?: string): Promise { if (!identifierArg) { return null; } let user = await this.receptionRef.userManager.CUser.getInstance({ data: { username: identifierArg, }, }); if (!user && identifierArg.includes('@')) { user = await this.receptionRef.userManager.CUser.getInstance({ data: { email: identifierArg, }, }); } return user; } private createOpaqueToken(prefixArg: string) { return `${prefixArg}${plugins.crypto.randomBytes(32).toString('base64url')}`; } private getOrigin() { return new URL(this.receptionRef.options.baseUrl).origin; } private getRpId() { return new URL(this.receptionRef.options.baseUrl).hostname; } private getEncryptionKey() { const keyMaterial = process.env.IDP_TOTP_ENCRYPTION_KEY || process.env.TOTP_ENCRYPTION_KEY || `${this.receptionRef.options.name}:${this.receptionRef.options.baseUrl}`; return plugins.crypto.createHash('sha256').update(keyMaterial).digest(); } private encryptSecret(secretArg: string) { const iv = plugins.crypto.randomBytes(12); const cipher = plugins.crypto.createCipheriv('aes-256-gcm', this.getEncryptionKey(), iv); const ciphertext = Buffer.concat([cipher.update(secretArg, 'utf8'), cipher.final()]); return { secretCiphertext: ciphertext.toString('base64'), secretIv: iv.toString('base64'), secretAuthTag: cipher.getAuthTag().toString('base64'), }; } private decryptSecret(totpCredentialArg: TotpCredential) { const decipher = plugins.crypto.createDecipheriv( 'aes-256-gcm', this.getEncryptionKey(), Buffer.from(totpCredentialArg.data.secretIv, 'base64'), ); decipher.setAuthTag(Buffer.from(totpCredentialArg.data.secretAuthTag, 'base64')); return Buffer.concat([ decipher.update(Buffer.from(totpCredentialArg.data.secretCiphertext, 'base64')), decipher.final(), ]).toString('utf8'); } private normalizeOtpCode(codeArg: string) { return String(codeArg || '').replace(/\s/g, '').trim(); } private normalizeBackupCode(codeArg: string) { return String(codeArg || '').replace(/\s/g, '').toLowerCase(); } private hashBackupCode(codeArg: string) { return plugins.smarthash.sha256FromStringSync(this.normalizeBackupCode(codeArg)); } private createBackupCodes() { return Array.from({ length: 10 }, () => { const raw = plugins.crypto.randomBytes(5).toString('hex'); return `${raw.slice(0, 5)}-${raw.slice(5)}`; }); } private getRemainingBackupCodeCount(totpCredentialArg: TotpCredential) { return (totpCredentialArg.data.backupCodes || []).filter((codeArg) => !codeArg.usedAt).length; } private async getActiveTotpCredential(userIdArg: string) { return this.CTotpCredential.getInstance({ 'data.userId': userIdArg, 'data.status': 'active', }); } private async getActivePasskeysForUser(userIdArg: string) { return this.CPasskeyCredential.getInstances({ 'data.userId': userIdArg, 'data.status': 'active', }); } private serializePasskey(passkeyArg: PasskeyCredential) { return { id: passkeyArg.id, data: passkeyArg.data, }; } private async startTotpEnrollmentForUser(userArg: User) { const activeCredential = await this.getActiveTotpCredential(userArg.id); if (activeCredential) { throw new plugins.typedrequest.TypedResponseError('TOTP is already enabled'); } const existingPending = await this.CTotpCredential.getInstances({ 'data.userId': userArg.id, 'data.status': 'pending', }); for (const pendingCredential of existingPending) { pendingCredential.data.status = 'disabled'; pendingCredential.data.disabledAt = Date.now(); await pendingCredential.save(); } const secret = plugins.otplib.generateSecret(); const encryptedSecret = this.encryptSecret(secret); const totpCredential = new TotpCredential(); totpCredential.id = plugins.smartunique.shortId(); totpCredential.data = { userId: userArg.id, status: 'pending', ...encryptedSecret, algorithm: 'sha1', digits: 6, period: 30, backupCodes: [], createdAt: Date.now(), verifiedAt: null, disabledAt: null, lastUsedAt: null, }; await totpCredential.save(); const otpauthUrl = plugins.otplib.generateURI({ issuer: this.receptionRef.options.name, label: userArg.data.email || userArg.data.username, secret, }); return { credentialId: totpCredential.id, secret, otpauthUrl, }; } private async verifyTotpCodeForCredential(totpCredentialArg: TotpCredential, codeArg: string) { const token = this.normalizeOtpCode(codeArg); if (!/^\d{6,8}$/.test(token)) { return false; } const secret = this.decryptSecret(totpCredentialArg); const result = await plugins.otplib.verify({ secret, token, algorithm: totpCredentialArg.data.algorithm, digits: totpCredentialArg.data.digits, period: totpCredentialArg.data.period, epochTolerance: 30, }); return !!result.valid; } private async finishTotpEnrollmentForUser(userArg: User, credentialIdArg: string, codeArg: string) { await this.receptionRef.abuseProtectionManager.consumeAttempt( 'totpEnrollment', userArg.id, this.attemptConfig, 'Too many TOTP setup attempts. Please wait before trying again.', ); const totpCredential = await this.CTotpCredential.getInstance({ id: credentialIdArg, 'data.userId': userArg.id, 'data.status': 'pending', }); if (!totpCredential) { throw new plugins.typedrequest.TypedResponseError('TOTP enrollment not found'); } const valid = await this.verifyTotpCodeForCredential(totpCredential, codeArg); if (!valid) { throw new plugins.typedrequest.TypedResponseError('Invalid TOTP code'); } const backupCodes = this.createBackupCodes(); totpCredential.data.status = 'active'; totpCredential.data.verifiedAt = Date.now(); totpCredential.data.lastUsedAt = Date.now(); totpCredential.data.backupCodes = backupCodes.map((codeArg) => ({ id: plugins.smartunique.shortId(), codeHash: this.hashBackupCode(codeArg), usedAt: null, createdAt: Date.now(), })); await totpCredential.save(); await this.receptionRef.abuseProtectionManager.clearAttempts('totpEnrollment', userArg.id); await this.receptionRef.activityLogManager.logActivity(userArg.id, 'totp_enabled' as any, 'Enabled TOTP two-factor authentication'); return { success: true, backupCodes, }; } private async verifyTotpForUser(userIdArg: string, codeArg: string) { const totpCredential = await this.getActiveTotpCredential(userIdArg); if (!totpCredential) { return false; } const valid = await this.verifyTotpCodeForCredential(totpCredential, codeArg); if (valid) { totpCredential.data.lastUsedAt = Date.now(); await totpCredential.save(); } return valid; } private async consumeBackupCodeForUser(userIdArg: string, codeArg: string) { const totpCredential = await this.getActiveTotpCredential(userIdArg); if (!totpCredential) { return false; } const codeHash = this.hashBackupCode(codeArg); const backupCode = totpCredential.data.backupCodes.find((codeArg) => { return !codeArg.usedAt && codeArg.codeHash === codeHash; }); if (!backupCode) { return false; } backupCode.usedAt = Date.now(); totpCredential.data.lastUsedAt = Date.now(); await totpCredential.save(); return true; } private async disableTotpForUser(userIdArg: string, codeArg: string) { const totpCredential = await this.getActiveTotpCredential(userIdArg); if (!totpCredential) { throw new plugins.typedrequest.TypedResponseError('TOTP is not enabled'); } const valid = await this.verifyTotpCodeForCredential(totpCredential, codeArg); if (!valid) { throw new plugins.typedrequest.TypedResponseError('Invalid TOTP code'); } totpCredential.data.status = 'disabled'; totpCredential.data.disabledAt = Date.now(); await totpCredential.save(); await this.receptionRef.activityLogManager.logActivity(userIdArg, 'totp_disabled' as any, 'Disabled TOTP two-factor authentication'); } private async regenerateBackupCodesForUser(userIdArg: string, codeArg: string) { const totpCredential = await this.getActiveTotpCredential(userIdArg); if (!totpCredential) { throw new plugins.typedrequest.TypedResponseError('TOTP is not enabled'); } const valid = await this.verifyTotpCodeForCredential(totpCredential, codeArg); if (!valid) { throw new plugins.typedrequest.TypedResponseError('Invalid TOTP code'); } const backupCodes = this.createBackupCodes(); totpCredential.data.backupCodes = backupCodes.map((backupCodeArg) => ({ id: plugins.smartunique.shortId(), codeHash: this.hashBackupCode(backupCodeArg), usedAt: null, createdAt: Date.now(), })); await totpCredential.save(); await this.receptionRef.activityLogManager.logActivity(userIdArg, 'backup_codes_regenerated' as any, 'Regenerated TOTP backup codes'); return backupCodes; } private async getPendingMfaChallengeByToken(tokenArg: string) { const mfaChallenge = await this.CMfaChallenge.getInstance({ 'data.tokenHash': MfaChallenge.hashToken(tokenArg), }); if (!mfaChallenge || mfaChallenge.data.status !== 'pending') { throw new plugins.typedrequest.TypedResponseError('MFA challenge not found'); } if (mfaChallenge.isExpired()) { await mfaChallenge.markExpired(); throw new plugins.typedrequest.TypedResponseError('MFA challenge expired'); } return mfaChallenge; } private async completeMfaChallenge(mfaChallengeArg: MfaChallenge) { const user = await this.receptionRef.userManager.CUser.getInstance({ id: mfaChallengeArg.data.userId }); if (!user) { throw new plugins.typedrequest.TypedResponseError('User not found'); } await mfaChallengeArg.markCompleted(); const loginSession = await LoginSession.createLoginSessionForUser(user); const refreshToken = await loginSession.getRefreshToken(); if (!refreshToken) { throw new plugins.typedrequest.TypedResponseError('Could not create login session'); } await this.receptionRef.activityLogManager.logActivity(user.id, 'mfa_completed' as any, 'Completed multi-factor authentication'); return { refreshToken }; } private async verifyMfaChallengeWithCode(tokenArg: string, methodArg: TMfaMethod, codeArg: string) { const mfaChallenge = await this.getPendingMfaChallengeByToken(tokenArg); await this.receptionRef.abuseProtectionManager.consumeAttempt( 'mfaChallenge', mfaChallenge.id, this.attemptConfig, 'Too many MFA attempts. Please wait before trying again.', ); let valid = false; if (methodArg === 'totp') { valid = await this.verifyTotpForUser(mfaChallenge.data.userId, codeArg); } else if (methodArg === 'backupCode') { valid = await this.consumeBackupCodeForUser(mfaChallenge.data.userId, codeArg); } if (!valid) { throw new plugins.typedrequest.TypedResponseError('Invalid MFA code'); } await this.receptionRef.abuseProtectionManager.clearAttempts('mfaChallenge', mfaChallenge.id); return this.completeMfaChallenge(mfaChallenge); } private async startPasskeyRegistrationForUser(userArg: User, labelArg?: string) { const passkeys = await this.getActivePasskeysForUser(userArg.id); const options = await plugins.simpleWebAuthnServer.generateRegistrationOptions({ rpName: this.receptionRef.options.name, rpID: this.getRpId(), userName: userArg.data.email || userArg.data.username, userDisplayName: userArg.data.name || userArg.data.email || userArg.data.username, userID: Buffer.from(userArg.id, 'utf8'), attestationType: 'none', excludeCredentials: passkeys.map((passkeyArg) => ({ id: passkeyArg.data.credentialId, transports: passkeyArg.data.transports as any, })), authenticatorSelection: { residentKey: 'preferred', userVerification: 'required', }, supportedAlgorithmIDs: [-7, -257], }); const webAuthnChallenge = new WebAuthnChallenge(); webAuthnChallenge.id = plugins.smartunique.shortId(); webAuthnChallenge.data = { userId: userArg.id, username: userArg.data.email || userArg.data.username, mfaChallengeId: null, type: 'registration', challenge: options.challenge, status: 'pending', createdAt: Date.now(), expiresAt: Date.now() + this.webAuthnChallengeMillis, completedAt: null, }; await webAuthnChallenge.save(); return { challengeId: webAuthnChallenge.id, options, }; } private async getPendingWebAuthnChallenge(challengeIdArg: string, typeArg: 'registration' | 'login' | 'mfa') { const webAuthnChallenge = await this.CWebAuthnChallenge.getInstance({ id: challengeIdArg, 'data.type': typeArg, 'data.status': 'pending', }); if (!webAuthnChallenge) { throw new plugins.typedrequest.TypedResponseError('WebAuthn challenge not found'); } if (webAuthnChallenge.isExpired()) { await webAuthnChallenge.markExpired(); throw new plugins.typedrequest.TypedResponseError('WebAuthn challenge expired'); } return webAuthnChallenge; } private async finishPasskeyRegistrationForUser(userArg: User, challengeIdArg: string, responseArg: any, labelArg?: string) { const webAuthnChallenge = await this.getPendingWebAuthnChallenge(challengeIdArg, 'registration'); if (webAuthnChallenge.data.userId !== userArg.id) { throw new plugins.typedrequest.TypedResponseError('WebAuthn challenge does not belong to this user'); } const verification = await plugins.simpleWebAuthnServer.verifyRegistrationResponse({ response: responseArg, expectedChallenge: webAuthnChallenge.data.challenge, expectedOrigin: this.getOrigin(), expectedRPID: this.getRpId(), requireUserVerification: true, supportedAlgorithmIDs: [-7, -257], }); if (!verification.verified) { throw new plugins.typedrequest.TypedResponseError('Passkey registration failed'); } const credential = verification.registrationInfo.credential; const existingCredential = await this.CPasskeyCredential.getInstance({ 'data.credentialId': credential.id, 'data.status': 'active', }); if (existingCredential) { throw new plugins.typedrequest.TypedResponseError('Passkey is already registered'); } const passkeyCredential = new PasskeyCredential(); passkeyCredential.id = plugins.smartunique.shortId(); passkeyCredential.data = { userId: userArg.id, label: labelArg || 'Passkey', credentialId: credential.id, publicKeyBase64: Buffer.from(credential.publicKey).toString('base64'), counter: credential.counter, deviceType: verification.registrationInfo.credentialDeviceType, backedUp: verification.registrationInfo.credentialBackedUp, transports: credential.transports || [], status: 'active', createdAt: Date.now(), lastUsedAt: null, revokedAt: null, }; await passkeyCredential.save(); await webAuthnChallenge.markCompleted(); await this.receptionRef.activityLogManager.logActivity(userArg.id, 'passkey_registered' as any, `Registered passkey ${passkeyCredential.data.label}`); return { success: true, passkey: this.serializePasskey(passkeyCredential), }; } private async revokePasskeyForUser(userIdArg: string, passkeyIdArg: string) { const passkeyCredential = await this.CPasskeyCredential.getInstance({ id: passkeyIdArg, 'data.userId': userIdArg, 'data.status': 'active', }); if (!passkeyCredential) { throw new plugins.typedrequest.TypedResponseError('Passkey not found'); } passkeyCredential.data.status = 'revoked'; passkeyCredential.data.revokedAt = Date.now(); await passkeyCredential.save(); await this.receptionRef.activityLogManager.logActivity(userIdArg, 'passkey_revoked' as any, `Revoked passkey ${passkeyCredential.data.label}`); } private async startPasskeyLogin(usernameArg?: string) { const user = await this.getUserByIdentifier(usernameArg); const passkeys = user ? await this.getActivePasskeysForUser(user.id) : []; if (usernameArg && !passkeys.length) { throw new plugins.typedrequest.TypedResponseError('No passkeys registered for this account'); } const options = await plugins.simpleWebAuthnServer.generateAuthenticationOptions({ rpID: this.getRpId(), allowCredentials: usernameArg ? passkeys.map((passkeyArg) => ({ id: passkeyArg.data.credentialId, transports: passkeyArg.data.transports as any, })) : undefined, userVerification: 'required', }); const webAuthnChallenge = new WebAuthnChallenge(); webAuthnChallenge.id = plugins.smartunique.shortId(); webAuthnChallenge.data = { userId: user?.id || null, username: usernameArg || null, mfaChallengeId: null, type: 'login', challenge: options.challenge, status: 'pending', createdAt: Date.now(), expiresAt: Date.now() + this.webAuthnChallengeMillis, completedAt: null, }; await webAuthnChallenge.save(); return { challengeId: webAuthnChallenge.id, options, }; } private async verifyPasskeyAuthentication(webAuthnChallengeArg: WebAuthnChallenge, responseArg: any) { const credentialId = responseArg?.id; if (!credentialId) { throw new plugins.typedrequest.TypedResponseError('Passkey credential id missing'); } const passkeyCredential = await this.CPasskeyCredential.getInstance({ 'data.credentialId': credentialId, 'data.status': 'active', }); if (!passkeyCredential) { throw new plugins.typedrequest.TypedResponseError('Passkey credential not found'); } if (webAuthnChallengeArg.data.userId && passkeyCredential.data.userId !== webAuthnChallengeArg.data.userId) { throw new plugins.typedrequest.TypedResponseError('Passkey does not belong to this challenge'); } const verification = await plugins.simpleWebAuthnServer.verifyAuthenticationResponse({ response: responseArg, expectedChallenge: webAuthnChallengeArg.data.challenge, expectedOrigin: this.getOrigin(), expectedRPID: this.getRpId(), credential: { id: passkeyCredential.data.credentialId, publicKey: new Uint8Array(Buffer.from(passkeyCredential.data.publicKeyBase64, 'base64')), counter: passkeyCredential.data.counter, transports: passkeyCredential.data.transports as any, }, requireUserVerification: true, }); if (!verification.verified || !verification.authenticationInfo.userVerified) { throw new plugins.typedrequest.TypedResponseError('Passkey authentication failed'); } passkeyCredential.data.counter = verification.authenticationInfo.newCounter; passkeyCredential.data.backedUp = verification.authenticationInfo.credentialBackedUp; passkeyCredential.data.deviceType = verification.authenticationInfo.credentialDeviceType; passkeyCredential.data.lastUsedAt = Date.now(); await passkeyCredential.save(); await webAuthnChallengeArg.markCompleted(); return passkeyCredential; } private async finishPasskeyLogin(challengeIdArg: string, responseArg: any) { const webAuthnChallenge = await this.getPendingWebAuthnChallenge(challengeIdArg, 'login'); const passkeyCredential = await this.verifyPasskeyAuthentication(webAuthnChallenge, responseArg); const user = await this.receptionRef.userManager.CUser.getInstance({ id: passkeyCredential.data.userId }); if (!user) { throw new plugins.typedrequest.TypedResponseError('User not found'); } const loginSession = await LoginSession.createLoginSessionForUser(user); const refreshToken = await loginSession.getRefreshToken(); if (!refreshToken) { throw new plugins.typedrequest.TypedResponseError('Could not create login session'); } await this.receptionRef.activityLogManager.logActivity(user.id, 'passkey_login' as any, `Signed in with passkey ${passkeyCredential.data.label}`); return { refreshToken }; } private async startPasskeyMfa(mfaChallengeTokenArg: string) { const mfaChallenge = await this.getPendingMfaChallengeByToken(mfaChallengeTokenArg); const passkeys = await this.getActivePasskeysForUser(mfaChallenge.data.userId); if (!passkeys.length) { throw new plugins.typedrequest.TypedResponseError('No passkeys registered for this account'); } const options = await plugins.simpleWebAuthnServer.generateAuthenticationOptions({ rpID: this.getRpId(), allowCredentials: passkeys.map((passkeyArg) => ({ id: passkeyArg.data.credentialId, transports: passkeyArg.data.transports as any, })), userVerification: 'required', }); const webAuthnChallenge = new WebAuthnChallenge(); webAuthnChallenge.id = plugins.smartunique.shortId(); webAuthnChallenge.data = { userId: mfaChallenge.data.userId, username: null, mfaChallengeId: mfaChallenge.id, type: 'mfa', challenge: options.challenge, status: 'pending', createdAt: Date.now(), expiresAt: Date.now() + this.webAuthnChallengeMillis, completedAt: null, }; await webAuthnChallenge.save(); return { challengeId: webAuthnChallenge.id, options, }; } private async finishPasskeyMfa(mfaChallengeTokenArg: string, challengeIdArg: string, responseArg: any) { const mfaChallenge = await this.getPendingMfaChallengeByToken(mfaChallengeTokenArg); const webAuthnChallenge = await this.getPendingWebAuthnChallenge(challengeIdArg, 'mfa'); if (webAuthnChallenge.data.mfaChallengeId !== mfaChallenge.id) { throw new plugins.typedrequest.TypedResponseError('Passkey MFA challenge mismatch'); } await this.verifyPasskeyAuthentication(webAuthnChallenge, responseArg); return this.completeMfaChallenge(mfaChallenge); } }