Files
bewcloud/lib/models/multi-factor-auth/passkey.ts
0xGingi 455a7201e9 Add Optional 2FA Support (#61)
* Add TOTP MFA Support

* Add Passkey MFA Support

It's not impossible I missed some minor cleanup, but most things make sense and there isn't a lot of obvious duplication anymore.

---------

Co-authored-by: Bruno Bernardino <me@brunobernardino.com>
2025-05-29 17:30:28 +01:00

199 lines
5.6 KiB
TypeScript

import {
AuthenticationResponseJSON,
generateAuthenticationOptions,
generateRegistrationOptions,
PublicKeyCredentialCreationOptionsJSON,
RegistrationResponseJSON,
VerifiedAuthenticationResponse,
VerifiedRegistrationResponse,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { MultiFactorAuthMethod, User } from '/lib/types.ts';
export interface PasskeyCredential {
credentialID: string;
credentialPublicKey: string;
counter: number;
credentialDeviceType: string;
credentialBackedUp: boolean;
transports?: AuthenticatorTransport[];
}
export interface PasskeySetupData {
methodId: string;
options: PublicKeyCredentialCreationOptionsJSON;
}
export interface PasskeyAuthenticationData {
options: PublicKeyCredentialCreationOptionsJSON;
}
const RP_NAME = 'bewCloud';
const RP_ID = (baseUrl: string) => {
try {
return new URL(baseUrl).hostname;
} catch {
return 'localhost';
}
};
/**
* Excludes Ed25519 as per https://simplewebauthn.dev/docs/packages/server#domexception-notsupportederror-unrecognized-name
*/
const SUPPORTED_ALGORITHM_IDS = [-7, -257];
export class PasskeyModel {
static async generateRegistrationOptions(
userId: string,
email: string,
baseUrl: string,
existingCredentials: PasskeyCredential[] = [],
): Promise<PublicKeyCredentialCreationOptionsJSON> {
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID(baseUrl),
userID: new TextEncoder().encode(userId),
userName: email,
userDisplayName: email,
attestationType: 'none',
excludeCredentials: existingCredentials.map((credential) => ({
id: credential.credentialID,
type: 'public-key',
transports: credential.transports || [],
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
authenticatorAttachment: 'platform',
},
supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS,
});
return options;
}
static async verifyRegistration(
response: RegistrationResponseJSON,
expectedChallenge: string,
expectedOrigin: string,
expectedRPID: string,
): Promise<VerifiedRegistrationResponse> {
const verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin,
expectedRPID,
supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS,
});
return verification;
}
static async generateAuthenticationOptions(
baseUrl: string,
allowedCredentials?: PasskeyCredential[],
): Promise<PublicKeyCredentialCreationOptionsJSON> {
const options = await generateAuthenticationOptions({
rpID: RP_ID(baseUrl),
allowCredentials: allowedCredentials?.map((credential) => ({
id: credential.credentialID,
type: 'public-key',
transports: credential.transports,
})),
userVerification: 'preferred',
});
return options as PublicKeyCredentialCreationOptionsJSON;
}
static async verifyAuthentication(
response: AuthenticationResponseJSON,
expectedChallenge: string,
expectedOrigin: string,
expectedRPID: string,
credential: PasskeyCredential,
): Promise<VerifiedAuthenticationResponse> {
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin,
expectedRPID,
credential: {
id: credential.credentialID,
publicKey: isoBase64URL.toBuffer(credential.credentialPublicKey),
counter: credential.counter,
transports: credential.transports,
},
});
return verification;
}
static createMethod(
id: string,
name: string,
credentialID: string,
credentialPublicKey: string,
counter: number,
credentialDeviceType: string,
credentialBackedUp: boolean,
transports?: AuthenticatorTransport[],
): MultiFactorAuthMethod {
return {
type: 'passkey',
id,
name,
enabled: false,
created_at: new Date(),
metadata: {
passkey: {
credential_id: credentialID,
public_key: credentialPublicKey,
counter,
device_type: credentialDeviceType,
backed_up: credentialBackedUp,
transports,
},
},
};
}
static getCredentialsFromUser(
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
): PasskeyCredential[] {
if (!user.extra.multi_factor_auth_methods) return [];
return user.extra.multi_factor_auth_methods
.filter((method) => method.type === 'passkey' && method.enabled && method.metadata.passkey)
.map((method) => ({
credentialID: method.metadata.passkey!.credential_id,
credentialPublicKey: method.metadata.passkey!.public_key,
counter: method.metadata.passkey!.counter || 0,
credentialDeviceType: method.metadata.passkey!.device_type || 'unknown',
credentialBackedUp: method.metadata.passkey!.backed_up || false,
transports: method.metadata.passkey!.transports,
}));
}
static updateCounterForUser(
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
credentialID: string,
newCounter: number,
): void {
if (!user.extra.multi_factor_auth_methods) {
return;
}
const method = user.extra.multi_factor_auth_methods.find(
(method) => method.type === 'passkey' && method.metadata.passkey?.credential_id === credentialID,
);
if (method?.metadata.passkey) {
method.metadata.passkey.counter = newCounter;
}
}
}