mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 09:43:37 +02:00
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:
parent
1095202ad9
commit
3091da914e
52 changed files with 1849 additions and 4 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
333
services/mana-core-auth/src/auth/services/passkey.service.ts
Normal file
333
services/mana-core-auth/src/auth/services/passkey.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue