Files
app/ts/reception/classes.mfamanager.ts
T

781 lines
30 KiB
TypeScript

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<any>('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<any>('startTotpEnrollment', async (requestArg) => {
const user = await this.getUserByJwt(requestArg.jwt);
return this.startTotpEnrollmentForUser(user);
}));
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('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<any>('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<any>('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<any>('verifyMfaChallenge', async (requestArg) => {
return this.verifyMfaChallengeWithCode(
requestArg.mfaChallengeToken,
requestArg.method,
requestArg.code,
);
}));
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('startPasskeyRegistration', async (requestArg) => {
const user = await this.getUserByJwt(requestArg.jwt);
return this.startPasskeyRegistrationForUser(user, requestArg.label);
}));
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('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<any>('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<any>('startPasskeyLogin', async (requestArg) => {
return this.startPasskeyLogin(requestArg.username);
}));
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('finishPasskeyLogin', async (requestArg) => {
return this.finishPasskeyLogin(requestArg.challengeId, requestArg.response);
}));
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('startPasskeyMfa', async (requestArg) => {
return this.startPasskeyMfa(requestArg.mfaChallengeToken);
}));
this.typedRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<any>('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<TMfaMethod[]> {
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<User> {
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<User | null> {
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);
}
}