feat(auth): add WebAuthn/Passkey support across all apps

Implements passwordless authentication via passkeys using @simplewebauthn:

Backend (mana-core-auth):
- New passkeys table in auth schema (credentialId, publicKey, counter, etc.)
- PasskeyService with registration/authentication flows and challenge storage
- 7 new API endpoints (register, authenticate, list, delete, rename)
- createSessionAndTokens helper for non-password auth flows
- Security event types for passkey operations

Client (shared-auth):
- signInWithPasskey() and registerPasskey() with dynamic @simplewebauthn/browser imports
- isPasskeyAvailable() browser capability check
- Passkey management methods (list, delete, rename)

UI (shared-auth-ui):
- Passkey button on LoginPage with key icon, shown when browser supports WebAuthn
- Divider between passkey and email/password form

App integration:
- All 19 web app auth stores have isPasskeyAvailable() and signInWithPasskey()
- All 19 web app login pages pass passkeyAvailable and onSignInWithPasskey props
- rpID=mana.how in production enables cross-app passkey usage (SSO-compatible)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-26 10:30:03 +01:00
parent 1095202ad9
commit 3091da914e
52 changed files with 1849 additions and 4 deletions

View file

@ -36,6 +36,7 @@
"@nestjs/schedule": "^4.1.2",
"@nestjs/swagger": "^8.1.0",
"@nestjs/throttler": "^6.2.1",
"@simplewebauthn/server": "^13.3.0",
"@types/multer": "^2.0.0",
"axios": "^1.7.2",
"bcryptjs": "^2.4.3",

View file

@ -19,6 +19,7 @@ import type { Request, Response } from 'express';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
import { BetterAuthService } from './services/better-auth.service';
import { PasskeyService } from './services/passkey.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
@ -76,7 +77,8 @@ export class AuthController {
constructor(
private readonly betterAuthService: BetterAuthService,
private readonly securityEvents: SecurityEventsService,
private readonly accountLockout: AccountLockoutService
private readonly accountLockout: AccountLockoutService,
private readonly passkeyService: PasskeyService
) {}
// =========================================================================
@ -816,6 +818,159 @@ export class AuthController {
}
}
// =========================================================================
// Passkey (WebAuthn) Endpoints
// =========================================================================
/**
* Generate passkey registration options
*
* Returns WebAuthn registration options for the authenticated user.
* The user must be logged in to register a passkey.
*/
@Post('passkeys/register/options')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Generate passkey registration options' })
async passkeyRegisterOptions(@CurrentUser() user: CurrentUserData) {
return this.passkeyService.generateRegistrationOptions(user.userId);
}
/**
* Verify and store passkey registration
*
* Verifies the WebAuthn registration response and stores the passkey.
*/
@Post('passkeys/register/verify')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Verify and store passkey registration' })
async passkeyRegisterVerify(
@CurrentUser() user: CurrentUserData,
@Body() body: { challengeId: string; credential: any; friendlyName?: string },
@Req() req: Request
) {
const result = await this.passkeyService.verifyRegistration(
body.challengeId,
body.credential,
body.friendlyName
);
await this.securityEvents.logEvent({
userId: user.userId,
eventType: SecurityEventType.PASSKEY_REGISTERED,
ipAddress: req.ip,
userAgent: req.headers['user-agent'] as string,
metadata: { passkeyId: result.id },
});
return result;
}
/**
* Generate passkey authentication options
*
* Returns WebAuthn authentication options. No auth required.
*/
@Post('passkeys/authenticate/options')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Generate passkey authentication options' })
async passkeyAuthOptions() {
return this.passkeyService.generateAuthenticationOptions();
}
/**
* Verify passkey authentication and return JWT tokens
*
* Verifies the WebAuthn authentication response and returns
* JWT access and refresh tokens (same format as login).
*/
@Post('passkeys/authenticate/verify')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Verify passkey authentication and return JWT tokens' })
async passkeyAuthVerify(
@Body() body: { challengeId: string; credential: any },
@Req() req: Request
) {
const { user, passkeyId } = await this.passkeyService.verifyAuthentication(
body.challengeId,
body.credential
);
// Generate session + JWT tokens (same pattern as signIn)
const tokenResult = await this.betterAuthService.createSessionAndTokens(user, {
ipAddress: req.ip,
userAgent: req.headers['user-agent'] as string,
});
await this.securityEvents.logEvent({
userId: user.id,
eventType: SecurityEventType.PASSKEY_LOGIN_SUCCESS,
ipAddress: req.ip,
userAgent: req.headers['user-agent'] as string,
metadata: { passkeyId },
});
return tokenResult;
}
/**
* List user's passkeys
*
* Returns all passkeys registered by the authenticated user.
*/
@Get('passkeys')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List user passkeys' })
async listPasskeys(@CurrentUser() user: CurrentUserData) {
return this.passkeyService.listPasskeys(user.userId);
}
/**
* Delete a passkey
*
* Removes a passkey from the user's account.
*/
@Delete('passkeys/:id')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete a passkey' })
async deletePasskey(
@CurrentUser() user: CurrentUserData,
@Param('id') passkeyId: string,
@Req() req: Request
) {
await this.passkeyService.deletePasskey(user.userId, passkeyId);
await this.securityEvents.logEvent({
userId: user.userId,
eventType: SecurityEventType.PASSKEY_DELETED,
ipAddress: req.ip,
userAgent: req.headers['user-agent'] as string,
metadata: { passkeyId },
});
}
/**
* Rename a passkey
*
* Updates the friendly name of a passkey.
*/
@Patch('passkeys/:id')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Rename a passkey' })
async renamePasskey(
@CurrentUser() user: CurrentUserData,
@Param('id') passkeyId: string,
@Body() body: { friendlyName: string }
) {
await this.passkeyService.renamePasskey(user.userId, passkeyId, body.friendlyName);
return { success: true };
}
// =========================================================================
// Helper Methods
// =========================================================================

View file

@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { BetterAuthPassthroughController } from './better-auth-passthrough.controller';
import { OidcController } from './oidc.controller';
@ -6,10 +7,11 @@ import { OidcLoginController } from './oidc-login.controller';
import { MatrixSessionController } from './matrix-session.controller';
import { BetterAuthService } from './services/better-auth.service';
import { MatrixSessionService } from './services/matrix-session.service';
import { PasskeyService } from './services/passkey.service';
import { SecurityModule } from '../security';
@Module({
imports: [SecurityModule],
imports: [SecurityModule, ConfigModule],
controllers: [
AuthController,
BetterAuthPassthroughController,
@ -17,7 +19,7 @@ import { SecurityModule } from '../security';
OidcLoginController,
MatrixSessionController,
],
providers: [BetterAuthService, MatrixSessionService],
exports: [BetterAuthService, MatrixSessionService],
providers: [BetterAuthService, MatrixSessionService, PasskeyService],
exports: [BetterAuthService, MatrixSessionService, PasskeyService],
})
export class AuthModule {}

View file

@ -603,6 +603,73 @@ export class BetterAuthService {
}
}
/**
* Create a session and generate JWT tokens for a user
* Used by passkey authentication and other non-password flows
*/
async createSessionAndTokens(
user: { id: string; email: string; name: string; role?: string },
meta?: { ipAddress?: string; userAgent?: string; deviceId?: string; deviceName?: string }
) {
const db = getDb(this.databaseUrl);
const { sessions } = await import('../../db/schema');
const { nanoid } = await import('nanoid');
const sessionId = nanoid();
const sessionToken = nanoid(64);
const refreshToken = nanoid(64);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
const refreshTokenExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
// Create session in DB
await db.insert(sessions).values({
id: sessionId,
token: sessionToken,
userId: user.id,
expiresAt,
refreshToken,
refreshTokenExpiresAt,
ipAddress: meta?.ipAddress || null,
userAgent: meta?.userAgent || null,
deviceId: meta?.deviceId || null,
deviceName: meta?.deviceName || null,
lastActivityAt: new Date(),
});
// Generate JWT access token
let accessToken = '';
try {
const api = this.auth.api as any;
const jwtResult = await api.signJWT({
body: {
payload: {
sub: user.id,
email: user.email,
role: user.role || 'user',
sid: sessionId,
},
},
});
accessToken = jwtResult?.token || '';
if (!accessToken) throw new Error('signJWT returned empty token');
} catch (jwtError) {
this.logger.warn('signJWT failed for passkey auth, using session token as fallback');
accessToken = sessionToken;
}
return {
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role || 'user',
},
accessToken,
refreshToken,
expiresIn: 15 * 60, // 15 minutes
};
}
/**
* Sign out user
*

View file

@ -0,0 +1,333 @@
import {
Injectable,
NotFoundException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type {
RegistrationResponseJSON,
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
} from '@simplewebauthn/server';
import { getDb } from '../../db/connection';
import { passkeys, users } from '../../db/schema';
import { eq, and } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import { LoggerService } from '../../common/logger';
interface ChallengeEntry {
challenge: string;
userId?: string; // Only set for registration
expiresAt: number;
}
@Injectable()
export class PasskeyService {
private readonly logger: LoggerService;
private readonly challenges = new Map<string, ChallengeEntry>();
private readonly rpID: string;
private readonly rpName = 'ManaCore';
private readonly expectedOrigins: string[];
private readonly databaseUrl: string;
constructor(
private readonly configService: ConfigService,
loggerService: LoggerService
) {
this.logger = loggerService.setContext('PasskeyService');
this.databaseUrl = this.configService.get<string>('database.url', '');
this.rpID = this.configService.get<string>('WEBAUTHN_RP_ID', 'localhost');
const originsStr = this.configService.get<string>('WEBAUTHN_ORIGINS', '');
this.expectedOrigins = originsStr
? originsStr.split(',').map((o) => o.trim())
: ['http://localhost:5173', 'http://localhost:5174', 'http://localhost:3001'];
// Clean up expired challenges every 5 minutes
setInterval(() => this.cleanupChallenges(), 5 * 60 * 1000);
}
private getDb() {
return getDb(this.databaseUrl);
}
private cleanupChallenges() {
const now = Date.now();
for (const [key, entry] of this.challenges) {
if (entry.expiresAt < now) {
this.challenges.delete(key);
}
}
}
private storeChallenge(challengeId: string, challenge: string, userId?: string) {
this.challenges.set(challengeId, {
challenge,
userId,
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
});
}
private getAndDeleteChallenge(challengeId: string): ChallengeEntry | null {
const entry = this.challenges.get(challengeId);
if (!entry) return null;
this.challenges.delete(challengeId);
if (entry.expiresAt < Date.now()) return null;
return entry;
}
/**
* Generate registration options for a logged-in user
*/
async generateRegistrationOptions(userId: string) {
const db = this.getDb();
// Get user
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user) throw new NotFoundException('User not found');
// Get existing passkeys to exclude
const existingPasskeys = await db.select().from(passkeys).where(eq(passkeys.userId, userId));
const excludeCredentials = existingPasskeys.map((pk) => ({
id: pk.credentialId,
transports: (pk.transports as AuthenticatorTransportFuture[]) || [],
}));
const options = await generateRegistrationOptions({
rpName: this.rpName,
rpID: this.rpID,
userName: user.email,
userDisplayName: user.name || user.email,
attestationType: 'none',
excludeCredentials,
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
// Store challenge
const challengeId = nanoid();
this.storeChallenge(challengeId, options.challenge, userId);
return { options, challengeId };
}
/**
* Verify registration response and store the new passkey
*/
async verifyRegistration(
challengeId: string,
credential: RegistrationResponseJSON,
friendlyName?: string
) {
const entry = this.getAndDeleteChallenge(challengeId);
if (!entry || !entry.userId) {
throw new BadRequestException('Invalid or expired challenge');
}
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: entry.challenge,
expectedOrigin: this.expectedOrigins,
expectedRPID: this.rpID,
});
if (!verification.verified || !verification.registrationInfo) {
throw new BadRequestException('Passkey verification failed');
}
const {
credential: cred,
credentialDeviceType,
credentialBackedUp,
} = verification.registrationInfo;
const db = this.getDb();
// Check for duplicate
const [existing] = await db
.select()
.from(passkeys)
.where(eq(passkeys.credentialId, cred.id))
.limit(1);
if (existing) {
throw new ConflictException('This passkey is already registered');
}
const id = nanoid();
const [newPasskey] = await db
.insert(passkeys)
.values({
id,
userId: entry.userId,
credentialId: cred.id,
publicKey: Buffer.from(cred.publicKey).toString('base64url'),
counter: cred.counter,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
transports: cred.transports || [],
friendlyName: friendlyName || null,
})
.returning();
this.logger.log(`Passkey registered for user ${entry.userId}: ${id}`);
return {
id: newPasskey.id,
credentialId: newPasskey.credentialId,
deviceType: newPasskey.deviceType,
friendlyName: newPasskey.friendlyName,
createdAt: newPasskey.createdAt,
};
}
/**
* Generate authentication options (public - no auth required)
*/
async generateAuthenticationOptions() {
// Use discoverable credentials (resident keys) - no allowCredentials needed
// The browser will show all available passkeys for this rpID
const options = await generateAuthenticationOptions({
rpID: this.rpID,
userVerification: 'preferred',
});
const challengeId = nanoid();
this.storeChallenge(challengeId, options.challenge);
return { options, challengeId };
}
/**
* Verify authentication response and return the user
*/
async verifyAuthentication(challengeId: string, credential: AuthenticationResponseJSON) {
const entry = this.getAndDeleteChallenge(challengeId);
if (!entry) {
throw new BadRequestException('Invalid or expired challenge');
}
const db = this.getDb();
// Find the passkey by credential ID
const [passkey] = await db
.select()
.from(passkeys)
.where(eq(passkeys.credentialId, credential.id))
.limit(1);
if (!passkey) {
throw new BadRequestException('Passkey not found');
}
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: entry.challenge,
expectedOrigin: this.expectedOrigins,
expectedRPID: this.rpID,
credential: {
id: passkey.credentialId,
publicKey: Buffer.from(passkey.publicKey, 'base64url'),
counter: passkey.counter,
transports: (passkey.transports as AuthenticatorTransportFuture[]) || [],
},
});
if (!verification.verified) {
throw new BadRequestException('Passkey authentication failed');
}
// Update counter and lastUsedAt
await db
.update(passkeys)
.set({
counter: verification.authenticationInfo.newCounter,
lastUsedAt: new Date(),
})
.where(eq(passkeys.id, passkey.id));
// Get user
const [user] = await db.select().from(users).where(eq(users.id, passkey.userId)).limit(1);
if (!user) {
throw new BadRequestException('User not found');
}
if (user.deletedAt) {
throw new BadRequestException('Account has been deleted');
}
return { user, passkeyId: passkey.id };
}
/**
* List all passkeys for a user
*/
async listPasskeys(userId: string) {
const db = this.getDb();
const userPasskeys = await db
.select({
id: passkeys.id,
credentialId: passkeys.credentialId,
deviceType: passkeys.deviceType,
backedUp: passkeys.backedUp,
friendlyName: passkeys.friendlyName,
lastUsedAt: passkeys.lastUsedAt,
createdAt: passkeys.createdAt,
})
.from(passkeys)
.where(eq(passkeys.userId, userId));
return userPasskeys;
}
/**
* Delete a passkey
*/
async deletePasskey(userId: string, passkeyId: string) {
const db = this.getDb();
const [passkey] = await db
.select()
.from(passkeys)
.where(and(eq(passkeys.id, passkeyId), eq(passkeys.userId, userId)))
.limit(1);
if (!passkey) {
throw new NotFoundException('Passkey not found');
}
await db.delete(passkeys).where(eq(passkeys.id, passkeyId));
this.logger.log(`Passkey deleted: ${passkeyId} for user ${userId}`);
}
/**
* Rename a passkey
*/
async renamePasskey(userId: string, passkeyId: string, friendlyName: string) {
const db = this.getDb();
const [passkey] = await db
.select()
.from(passkeys)
.where(and(eq(passkeys.id, passkeyId), eq(passkeys.userId, userId)))
.limit(1);
if (!passkey) {
throw new NotFoundException('Passkey not found');
}
await db.update(passkeys).set({ friendlyName }).where(eq(passkeys.id, passkeyId));
}
}

View file

@ -7,6 +7,7 @@ import {
jsonb,
pgEnum,
index,
integer,
} from 'drizzle-orm/pg-core';
export const authSchema = pgSchema('auth');
@ -207,6 +208,29 @@ export const matrixUserLinks = authSchema.table(
})
);
// Passkeys table (WebAuthn credentials)
export const passkeys = authSchema.table(
'passkeys',
{
id: text('id').primaryKey(), // nanoid
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
credentialId: text('credential_id').unique().notNull(), // base64url-encoded
publicKey: text('public_key').notNull(), // base64url-encoded COSE public key
counter: integer('counter').default(0).notNull(), // signature counter
deviceType: text('device_type').notNull(), // 'singleDevice' | 'multiDevice'
backedUp: boolean('backed_up').default(false).notNull(),
transports: jsonb('transports').$type<string[]>(), // ['internal', 'hybrid', etc.]
friendlyName: text('friendly_name'),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdIdx: index('passkeys_user_id_idx').on(table.userId),
})
);
// User settings table (synced across all apps)
export const userSettings = authSchema.table('user_settings', {
userId: text('user_id')

View file

@ -43,6 +43,12 @@ export const SecurityEventType = {
API_KEY_VALIDATED: 'api_key_validated',
API_KEY_VALIDATION_FAILED: 'api_key_validation_failed',
// Passkeys
PASSKEY_REGISTERED: 'passkey_registered',
PASSKEY_LOGIN_SUCCESS: 'passkey_login_success',
PASSKEY_LOGIN_FAILURE: 'passkey_login_failure',
PASSKEY_DELETED: 'passkey_deleted',
// Organizations
ORG_CREATED: 'org_created',
ORG_DELETED: 'org_deleted',