import { Injectable, Logger, BadRequestException, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DataSource } from 'typeorm'; import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } from '@simplewebauthn/server'; // Use inline type aliases to avoid ESM-only @simplewebauthn/types import issue type RegistrationResponseJSON = any; type AuthenticationResponseJSON = any; type AuthenticatorTransportFuture = any; @Injectable() export class PasskeyService { private readonly logger = new Logger(PasskeyService.name); private rpID: string; private rpName: string; private origin: string; constructor( private configService: ConfigService, private dataSource: DataSource, ) { this.rpID = this.configService.get('WEBAUTHN_RP_ID') || 'localhost'; this.rpName = 'HOA LedgerIQ'; this.origin = this.configService.get('WEBAUTHN_RP_ORIGIN') || 'http://localhost'; } /** * Generate registration options for navigator.credentials.create(). */ async generateRegistrationOptions(userId: string) { const userRows = await this.dataSource.query( `SELECT id, email, first_name, last_name FROM shared.users WHERE id = $1`, [userId], ); if (userRows.length === 0) throw new BadRequestException('User not found'); const user = userRows[0]; // Get existing passkeys for exclusion const existingKeys = await this.dataSource.query( `SELECT credential_id, transports FROM shared.user_passkeys WHERE user_id = $1`, [userId], ); const options = await generateRegistrationOptions({ rpName: this.rpName, rpID: this.rpID, userID: new TextEncoder().encode(userId), userName: user.email, userDisplayName: `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email, attestationType: 'none', excludeCredentials: existingKeys.map((k: any) => ({ id: k.credential_id, type: 'public-key' as const, transports: k.transports || [], })), authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred', }, }); // Store challenge temporarily await this.dataSource.query( `UPDATE shared.users SET webauthn_challenge = $1, updated_at = NOW() WHERE id = $2`, [options.challenge, userId], ); return options; } /** * Verify and store a passkey registration. */ async verifyRegistration(userId: string, response: RegistrationResponseJSON, deviceName?: string) { const userRows = await this.dataSource.query( `SELECT webauthn_challenge FROM shared.users WHERE id = $1`, [userId], ); if (userRows.length === 0) throw new BadRequestException('User not found'); const expectedChallenge = userRows[0].webauthn_challenge; if (!expectedChallenge) throw new BadRequestException('No registration challenge found'); const verification = await verifyRegistrationResponse({ response, expectedChallenge, expectedOrigin: this.origin, expectedRPID: this.rpID, }); if (!verification.verified || !verification.registrationInfo) { throw new BadRequestException('Passkey registration verification failed'); } const { credential } = verification.registrationInfo; // Store the passkey await this.dataSource.query( `INSERT INTO shared.user_passkeys (user_id, credential_id, public_key, counter, device_name, transports) VALUES ($1, $2, $3, $4, $5, $6)`, [ userId, Buffer.from(credential.id).toString('base64url'), Buffer.from(credential.publicKey).toString('base64url'), credential.counter, deviceName || 'Passkey', credential.transports || [], ], ); // Clear challenge await this.dataSource.query( `UPDATE shared.users SET webauthn_challenge = NULL WHERE id = $1`, [userId], ); this.logger.log(`Passkey registered for user ${userId}`); return { verified: true }; } /** * Generate authentication options for navigator.credentials.get(). */ async generateAuthenticationOptions(email?: string) { let allowCredentials: any[] | undefined; if (email) { const userRows = await this.dataSource.query( `SELECT u.id FROM shared.users u WHERE u.email = $1`, [email], ); if (userRows.length > 0) { const passkeys = await this.dataSource.query( `SELECT credential_id, transports FROM shared.user_passkeys WHERE user_id = $1`, [userRows[0].id], ); allowCredentials = passkeys.map((k: any) => ({ id: k.credential_id, type: 'public-key' as const, transports: k.transports || [], })); } } const options = await generateAuthenticationOptions({ rpID: this.rpID, allowCredentials, userVerification: 'preferred', }); // Store challenge — for passkey login we need a temporary storage // Since we don't know the user yet, store in a shared way // In production, use Redis/session. For now, we'll pass it back and verify client-side. return { ...options, challenge: options.challenge }; } /** * Verify authentication and return the user. */ async verifyAuthentication(response: AuthenticationResponseJSON, expectedChallenge: string) { // Find the credential const credId = response.id; const passkeys = await this.dataSource.query( `SELECT p.*, u.id as user_id, u.email FROM shared.user_passkeys p JOIN shared.users u ON u.id = p.user_id WHERE p.credential_id = $1`, [credId], ); if (passkeys.length === 0) { throw new UnauthorizedException('Passkey not recognized'); } const passkey = passkeys[0]; const verification = await verifyAuthenticationResponse({ response, expectedChallenge, expectedOrigin: this.origin, expectedRPID: this.rpID, credential: { id: passkey.credential_id, publicKey: Buffer.from(passkey.public_key, 'base64url'), counter: Number(passkey.counter), transports: (passkey.transports || []) as AuthenticatorTransportFuture[], }, }); if (!verification.verified) { throw new UnauthorizedException('Passkey authentication failed'); } // Update counter and last_used_at await this.dataSource.query( `UPDATE shared.user_passkeys SET counter = $1, last_used_at = NOW() WHERE id = $2`, [verification.authenticationInfo.newCounter, passkey.id], ); return { userId: passkey.user_id }; } /** * List user's registered passkeys. */ async listPasskeys(userId: string) { const rows = await this.dataSource.query( `SELECT id, device_name, created_at, last_used_at FROM shared.user_passkeys WHERE user_id = $1 ORDER BY created_at DESC`, [userId], ); return rows; } /** * Remove a passkey. */ async removePasskey(userId: string, passkeyId: string): Promise { // Check that user has password or other passkeys const [userRows, passkeyCount] = await Promise.all([ this.dataSource.query(`SELECT password_hash FROM shared.users WHERE id = $1`, [userId]), this.dataSource.query( `SELECT COUNT(*) as cnt FROM shared.user_passkeys WHERE user_id = $1`, [userId], ), ]); const hasPassword = !!userRows[0]?.password_hash; const count = parseInt(passkeyCount[0]?.cnt || '0', 10); if (!hasPassword && count <= 1) { throw new BadRequestException('Cannot remove your only passkey without a password set'); } await this.dataSource.query( `DELETE FROM shared.user_passkeys WHERE id = $1 AND user_id = $2`, [passkeyId, userId], ); } }