feat(auth): rate limit feedback, audit log UI, and E2E tests

Rate-limiting feedback:
- LoginPage detects 429/account-locked errors and shows countdown timer
- Submit button disabled during cooldown period

Audit log:
- GET /auth/security-events endpoint (JWT-protected) in auth controller
- getSecurityEvents() in BetterAuthService + shared-auth client
- AuditLog component with event type labels, relative dates, UA parsing
- Integrated in ManaCore settings page

E2E tests (passkey-2fa.e2e-spec.ts):
- Passkey registration/authentication flow tests
- Auth guard enforcement (protected vs public endpoints)
- 2FA passthrough route existence tests
- Edge cases (cross-user access, missing fields, token shape)

CSRF note: Already covered by Better Auth (SameSite + HttpOnly +
Trusted Origins). Token refresh already has 4-retry + offline detection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-26 21:58:56 +01:00
parent 11ab265d55
commit 0dfd603892
9 changed files with 1061 additions and 2 deletions

View file

@ -202,6 +202,12 @@ export const authStore = {
return authService.renamePasskey(passkeyId, friendlyName);
},
async getSecurityEvents() {
const authService = getAuthService();
if (!authService) return [];
return authService.getSecurityEvents();
},
/**
* Sign in with email and password
*/

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Button, Input, Card, PageHeader, GlobalSettingsSection } from '@manacore/shared-ui';
import { PasskeyManager, TwoFactorSetup } from '@manacore/shared-auth-ui';
import { PasskeyManager, TwoFactorSetup, AuditLog } from '@manacore/shared-auth-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { creditsService } from '$lib/api/credits';
import type { CreditBalance } from '$lib/api/credits';
@ -23,6 +23,10 @@
// Credits data
let creditBalance = $state<CreditBalance | null>(null);
// Security events
let securityEvents = $state<any[]>([]);
let securityEventsLoading = $state(false);
onMount(async () => {
if (authStore.isAuthenticated) {
try {
@ -30,6 +34,10 @@
passkeys = await authStore.listPasskeys();
// Load user settings from server
await userSettings.load();
// Load security events
securityEventsLoading = true;
securityEvents = await authStore.getSecurityEvents();
securityEventsLoading = false;
} catch (e) {
console.error('Failed to load data:', e);
}
@ -307,6 +315,22 @@
</div>
</Card>
<!-- Security Log Section -->
<Card>
<div class="p-6">
<AuditLog
events={securityEvents}
loading={securityEventsLoading}
onRefresh={async () => {
securityEventsLoading = true;
securityEvents = await authStore.getSecurityEvents();
securityEventsLoading = false;
}}
primaryColor="#6366f1"
/>
</div>
</Card>
<!-- My Data & Danger Zone -->
<Card>
<div class="p-6">

View file

@ -0,0 +1,414 @@
<script lang="ts">
interface SecurityEvent {
id: string;
eventType: string;
ipAddress: string | null;
userAgent: string | null;
metadata: any;
createdAt: string;
}
interface Props {
events: SecurityEvent[];
onRefresh: () => Promise<void>;
loading?: boolean;
primaryColor?: string;
}
let { events, onRefresh, loading = false, primaryColor = '#6366f1' }: Props = $props();
interface EventInfo {
label: string;
badgeClass: string;
badgeText: string;
}
function getEventInfo(eventType: string): EventInfo {
switch (eventType) {
case 'login_success':
return { label: 'Anmeldung erfolgreich', badgeClass: 'badge-success', badgeText: '' };
case 'login_failure':
return { label: 'Anmeldung fehlgeschlagen', badgeClass: 'badge-danger', badgeText: '' };
case 'register':
return { label: 'Konto erstellt', badgeClass: 'badge-info', badgeText: 'Neu' };
case 'logout':
return { label: 'Abgemeldet', badgeClass: 'badge-neutral', badgeText: '' };
case 'password_changed':
return { label: 'Passwort geändert', badgeClass: 'badge-warning', badgeText: '' };
case 'password_reset_requested':
return { label: 'Passwort-Reset angefordert', badgeClass: 'badge-warning', badgeText: '' };
case 'password_reset_completed':
return { label: 'Passwort zurückgesetzt', badgeClass: 'badge-warning', badgeText: '' };
case 'passkey_registered':
return { label: 'Passkey registriert', badgeClass: 'badge-warning', badgeText: '' };
case 'passkey_login_success':
return { label: 'Passkey-Anmeldung', badgeClass: 'badge-success', badgeText: '' };
case 'passkey_deleted':
return { label: 'Passkey gelöscht', badgeClass: 'badge-danger', badgeText: '' };
case 'two_factor_enabled':
return { label: '2FA aktiviert', badgeClass: 'badge-success', badgeText: '' };
case 'two_factor_disabled':
return { label: '2FA deaktiviert', badgeClass: 'badge-warning', badgeText: '' };
case 'account_locked':
return { label: 'Konto gesperrt', badgeClass: 'badge-danger', badgeText: '' };
case 'account_deleted':
return { label: 'Konto gelöscht', badgeClass: 'badge-danger', badgeText: '' };
case 'sso_token_exchange':
return { label: 'SSO-Anmeldung', badgeClass: 'badge-success', badgeText: '' };
default:
return { label: eventType, badgeClass: 'badge-neutral', badgeText: '' };
}
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const timeStr = date.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
if (diffDays === 0) {
return `Heute, ${timeStr}`;
} else if (diffDays === 1) {
return `Gestern, ${timeStr}`;
} else {
const dateFormatted = date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
return `${dateFormatted}, ${timeStr}`;
}
}
function parseUserAgent(ua: string | null): string {
if (!ua) return '';
let browser = '';
let os = '';
// Detect browser
if (ua.includes('Firefox/')) browser = 'Firefox';
else if (ua.includes('Edg/')) browser = 'Edge';
else if (ua.includes('Chrome/') && !ua.includes('Edg/')) browser = 'Chrome';
else if (ua.includes('Safari/') && !ua.includes('Chrome/')) browser = 'Safari';
else if (ua.includes('Opera/') || ua.includes('OPR/')) browser = 'Opera';
// Detect OS
if (ua.includes('Windows')) os = 'Windows';
else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) os = 'macOS';
else if (ua.includes('Linux') && !ua.includes('Android')) os = 'Linux';
else if (ua.includes('Android')) os = 'Android';
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
const parts = [browser, os].filter(Boolean);
return parts.length > 0 ? parts.join(' · ') : '';
}
</script>
<div class="audit-log" style:--primary-color={primaryColor}>
<div class="audit-header">
<div class="audit-header-left">
<div class="audit-icon">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<div>
<h3 class="audit-title">Sicherheitsprotokoll</h3>
<p class="audit-subtitle">Letzte Aktivitäten deines Kontos</p>
</div>
</div>
<button
type="button"
class="refresh-button"
onclick={onRefresh}
disabled={loading}
aria-label="Aktualisieren"
>
<svg
class="refresh-icon"
class:spinning={loading}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
{#if loading && events.length === 0}
<div class="loading-state">
<div class="loading-spinner"></div>
</div>
{:else if events.length === 0}
<p class="empty-state">Keine Sicherheitsereignisse vorhanden.</p>
{:else}
<div class="event-list">
{#each events as event (event.id)}
{@const info = getEventInfo(event.eventType)}
<div class="event-item">
<div class="event-badge {info.badgeClass}"></div>
<div class="event-content">
<div class="event-label">
{info.label}
{#if info.badgeText}
<span class="event-tag">{info.badgeText}</span>
{/if}
</div>
<div class="event-meta">
<span>{formatDate(event.createdAt)}</span>
{#if event.ipAddress}
<span class="meta-separator">·</span>
<span>{event.ipAddress}</span>
{/if}
</div>
{#if parseUserAgent(event.userAgent)}
<div class="event-device">
{parseUserAgent(event.userAgent)}
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.audit-log {
width: 100%;
}
.audit-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.audit-header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.audit-icon {
display: flex;
height: 2.5rem;
width: 2.5rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
background-color: color-mix(in srgb, var(--primary-color) 15%, transparent);
color: var(--primary-color);
}
.audit-icon .icon {
height: 1.25rem;
width: 1.25rem;
}
.audit-title {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.audit-subtitle {
font-size: 0.875rem;
color: var(--text-muted, #9ca3af);
margin: 0;
}
.refresh-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
border: 1px solid var(--border-color, #e5e7eb);
background: transparent;
color: var(--text-muted, #9ca3af);
cursor: pointer;
transition: all 0.2s;
}
.refresh-button:hover:not(:disabled) {
background: var(--hover-bg, #f3f4f6);
color: var(--text-primary, #111827);
}
.refresh-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark) .refresh-button {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .refresh-button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.refresh-icon {
width: 1.125rem;
height: 1.125rem;
}
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state {
display: flex;
justify-content: center;
padding: 2rem;
}
.loading-spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--border-color, #e5e7eb);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-muted, #9ca3af);
font-size: 0.875rem;
}
.event-list {
display: flex;
flex-direction: column;
max-height: 28rem;
overflow-y: auto;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 0.75rem;
}
:global(.dark) .event-list {
border-color: rgba(255, 255, 255, 0.1);
}
.event-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
:global(.dark) .event-item {
border-bottom-color: rgba(255, 255, 255, 0.06);
}
.event-item:last-child {
border-bottom: none;
}
.event-badge {
flex-shrink: 0;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
margin-top: 0.375rem;
}
.badge-success {
background-color: #22c55e;
}
.badge-danger {
background-color: #ef4444;
}
.badge-warning {
background-color: #f59e0b;
}
.badge-info {
background-color: #3b82f6;
}
.badge-neutral {
background-color: #9ca3af;
}
.event-content {
flex: 1;
min-width: 0;
}
.event-label {
font-size: 0.875rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
}
.event-tag {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
background-color: color-mix(in srgb, var(--primary-color) 15%, transparent);
color: var(--primary-color);
}
.event-meta {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
margin-top: 0.125rem;
}
.meta-separator {
margin: 0 0.25rem;
}
.event-device {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
opacity: 0.8;
}
@media (prefers-reduced-motion: reduce) {
.spinning {
animation: none;
}
.loading-spinner {
animation: none;
}
}
</style>

View file

@ -12,6 +12,7 @@ export { default as PasskeyManager } from './components/PasskeyManager.svelte';
export { default as TwoFactorSetup } from './components/TwoFactorSetup.svelte';
export { default as SecurityOnboarding } from './components/SecurityOnboarding.svelte';
export { default as ChangePassword } from './components/ChangePassword.svelte';
export { default as AuditLog } from './components/AuditLog.svelte';
// Utilities
export {

View file

@ -145,6 +145,16 @@
let twoFactorCode = $state('');
let useBackupCode = $state(false);
let trustDevice = $state(false);
let rateLimitCountdown = $state(0);
$effect(() => {
if (rateLimitCountdown > 0) {
const timer = setTimeout(() => {
rateLimitCountdown--;
}, 1000);
return () => clearTimeout(timer);
}
});
// Theme state - can be toggled manually, defaults to system preference
let userThemePreference = $state<'light' | 'dark' | null>(null);
@ -252,6 +262,16 @@
setError(t.emailNotVerified || 'Email not verified.', 'general');
} else {
setError(result.error || t.signInFailed, 'general');
// Detect rate limiting
if (result.error?.includes('Too Many') || result.error?.includes('rate limit')) {
rateLimitCountdown = 60; // 1 minute cooldown
} else if (
result.error?.includes('temporarily locked') ||
result.error === 'ACCOUNT_LOCKED'
) {
rateLimitCountdown = (result as any).retryAfter || 300; // 5 min default
}
}
}
@ -568,6 +588,9 @@
{resendingVerification ? t.resendingVerification : t.resendVerification}
</button>
{/if}
{#if rateLimitCountdown > 0}
<p class="retry-countdown">Erneut versuchen in {rateLimitCountdown}s</p>
{/if}
</div>
</div>
{/if}
@ -652,7 +675,7 @@
<!-- Submit -->
<button
type="submit"
disabled={loading || showSuccess}
disabled={loading || showSuccess || rateLimitCountdown > 0}
class="submit-button"
style:background-color={showSuccess ? '#22c55e' : primaryColor + '60'}
style:border-color={showSuccess ? '#22c55e' : primaryColor}
@ -934,6 +957,11 @@
gap: 0.25rem;
}
.retry-countdown {
font-weight: 600;
margin-top: 0.25rem;
}
.resend-link {
background: none;
border: none;

View file

@ -778,6 +778,25 @@ export function createAuthService(config: AuthServiceConfig) {
}
},
/**
* Get security events (audit log)
*/
async getSecurityEvents(limit = 50): Promise<any[]> {
try {
const appToken = await service.getAppToken();
if (!appToken) return [];
const res = await fetch(`${baseUrl}/api/v1/auth/security-events?limit=${limit}`, {
headers: { Authorization: `Bearer ${appToken}` },
});
if (!res.ok) return [];
return await res.json();
} catch {
return [];
}
},
/**
* Get the current app token
*/

View file

@ -818,6 +818,25 @@ export class AuthController {
}
}
// =========================================================================
// Security Events
// =========================================================================
/**
* Get user security events (audit log)
*
* Returns the authenticated user's security events ordered by most recent first.
*/
@Get('security-events')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Get user security events (audit log)' })
@ApiBearerAuth('JWT-auth')
@ApiResponse({ status: 200, description: 'Returns security events' })
@ApiResponse({ status: 401, description: 'Not authenticated' })
async getSecurityEvents(@CurrentUser() user: CurrentUserData, @Req() req: Request) {
return this.betterAuthService.getSecurityEvents(user.userId);
}
// =========================================================================
// Passkey (WebAuthn) Endpoints
// =========================================================================

View file

@ -2117,4 +2117,29 @@ export class BetterAuthService {
throw new UnauthorizedException('Failed to exchange session for tokens');
}
}
/**
* Get security events for a user (audit log)
*/
async getSecurityEvents(userId: string, limit = 50) {
const db = getDb(this.databaseUrl);
const { securityEvents } = await import('../../db/schema');
const { eq, desc } = await import('drizzle-orm');
const events = await db
.select({
id: securityEvents.id,
eventType: securityEvents.eventType,
ipAddress: securityEvents.ipAddress,
userAgent: securityEvents.userAgent,
metadata: securityEvents.metadata,
createdAt: securityEvents.createdAt,
})
.from(securityEvents)
.where(eq(securityEvents.userId, userId))
.orderBy(desc(securityEvents.createdAt))
.limit(limit);
return events;
}
}

View file

@ -0,0 +1,523 @@
/**
* Passkey & 2FA E2E Tests
*
* Tests the HTTP layer for:
* 1. Passkey registration flow (auth required)
* 2. Passkey authentication flow (public)
* 3. Passkey management (list, rename, delete)
* 4. Auth guard enforcement on passkey endpoints
* 5. 2FA redirect detection during sign-in
* 6. Session-to-token exchange after 2FA verification
*
* WebAuthn crypto is handled by @simplewebauthn/server which is mocked
* at the module level (via jest-e2e.json moduleNameMapper). These tests
* focus on request/response shapes, status codes, and auth guard behavior.
*/
import { Test } from '@nestjs/testing';
import type { TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from '../../src/app.module';
describe('Passkey & 2FA (E2E)', () => {
let app: INestApplication;
let accessToken: string;
let refreshToken: string;
let userId: string;
const testEmail = `passkey-e2e-${Date.now()}@example.com`;
const testPassword = 'SecurePassword123!';
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ transform: true }));
await app.init();
// Register and login a test user for authenticated passkey operations
const registerResponse = await request(app.getHttpServer()).post('/auth/register').send({
email: testEmail,
password: testPassword,
name: 'Passkey E2E User',
});
userId = registerResponse.body.id;
const loginResponse = await request(app.getHttpServer()).post('/auth/login').send({
email: testEmail,
password: testPassword,
});
accessToken = loginResponse.body.accessToken;
refreshToken = loginResponse.body.refreshToken;
});
afterAll(async () => {
await app.close();
});
// =========================================================================
// Passkey Registration Flow
// =========================================================================
describe('Passkey Registration Flow', () => {
it('should generate registration options for authenticated user', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/register/options')
.set('Authorization', `Bearer ${accessToken}`)
.expect((res) => {
expect([200, 201]).toContain(res.status);
});
expect(response.body).toHaveProperty('options');
expect(response.body).toHaveProperty('challengeId');
expect(response.body.options).toHaveProperty('challenge');
expect(typeof response.body.options.challenge).toBe('string');
expect(response.body.options.challenge.length).toBeGreaterThan(0);
expect(typeof response.body.challengeId).toBe('string');
});
it('should include RP info in registration options', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/register/options')
.set('Authorization', `Bearer ${accessToken}`)
.expect((res) => {
expect([200, 201]).toContain(res.status);
});
const { options } = response.body;
expect(options).toHaveProperty('rp');
expect(options.rp).toHaveProperty('name');
expect(options.rp).toHaveProperty('id');
});
it('should include user info in registration options', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/register/options')
.set('Authorization', `Bearer ${accessToken}`)
.expect((res) => {
expect([200, 201]).toContain(res.status);
});
const { options } = response.body;
expect(options).toHaveProperty('user');
expect(options.user).toHaveProperty('name');
expect(options.user).toHaveProperty('displayName');
});
it('should reject registration verify with invalid challenge', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/register/verify')
.set('Authorization', `Bearer ${accessToken}`)
.send({
challengeId: 'invalid-challenge-id',
credential: {
id: 'fake-credential-id',
rawId: 'fake-raw-id',
response: {
clientDataJSON: 'fake-client-data',
attestationObject: 'fake-attestation',
},
type: 'public-key',
},
})
.expect(400);
expect(response.body).toHaveProperty('message');
expect(response.body.message).toMatch(/invalid|expired/i);
});
it('should reject registration verify with expired challenge', async () => {
// Get valid options but use a bogus challengeId
await request(app.getHttpServer())
.post('/auth/passkeys/register/options')
.set('Authorization', `Bearer ${accessToken}`);
const response = await request(app.getHttpServer())
.post('/auth/passkeys/register/verify')
.set('Authorization', `Bearer ${accessToken}`)
.send({
challengeId: 'non-existent-challenge-id',
credential: {
id: 'fake-credential',
rawId: 'fake-raw',
response: {
clientDataJSON: 'fake',
attestationObject: 'fake',
},
type: 'public-key',
},
})
.expect(400);
expect(response.body.message).toMatch(/invalid|expired/i);
});
});
// =========================================================================
// Passkey Authentication Flow (Public Endpoints)
// =========================================================================
describe('Passkey Authentication Flow', () => {
it('should generate authentication options without auth', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/authenticate/options')
.expect(200);
expect(response.body).toHaveProperty('options');
expect(response.body).toHaveProperty('challengeId');
expect(response.body.options).toHaveProperty('challenge');
expect(typeof response.body.options.challenge).toBe('string');
expect(response.body.options.challenge.length).toBeGreaterThan(0);
expect(typeof response.body.challengeId).toBe('string');
});
it('should include rpId in authentication options', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/authenticate/options')
.expect(200);
expect(response.body.options).toHaveProperty('rpId');
});
it('should reject authentication verify with invalid challenge', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/authenticate/verify')
.send({
challengeId: 'invalid-challenge-id',
credential: {
id: 'fake-credential-id',
rawId: 'fake-raw-id',
response: {
clientDataJSON: 'fake-client-data',
authenticatorData: 'fake-auth-data',
signature: 'fake-signature',
},
type: 'public-key',
},
})
.expect(400);
expect(response.body).toHaveProperty('message');
expect(response.body.message).toMatch(/invalid|expired/i);
});
it('should reject authentication verify without challengeId', async () => {
await request(app.getHttpServer())
.post('/auth/passkeys/authenticate/verify')
.send({
credential: {
id: 'fake-credential',
response: {},
type: 'public-key',
},
})
.expect(400);
});
});
// =========================================================================
// Passkey Management (List, Rename, Delete)
// =========================================================================
describe('Passkey Management', () => {
it('should list passkeys for authenticated user (initially empty)', async () => {
const response = await request(app.getHttpServer())
.get('/auth/passkeys')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
// New user should have no passkeys initially
expect(response.body.length).toBe(0);
});
it('should return 404 when deleting non-existent passkey', async () => {
await request(app.getHttpServer())
.delete('/auth/passkeys/non-existent-id')
.set('Authorization', `Bearer ${accessToken}`)
.expect(404);
});
it('should return 404 when renaming non-existent passkey', async () => {
await request(app.getHttpServer())
.patch('/auth/passkeys/non-existent-id')
.set('Authorization', `Bearer ${accessToken}`)
.send({ friendlyName: 'My Key' })
.expect(404);
});
});
// =========================================================================
// Auth Guard Enforcement
// =========================================================================
describe('Auth Guard Enforcement', () => {
describe('Protected endpoints require JWT', () => {
it('POST /auth/passkeys/register/options requires auth', async () => {
await request(app.getHttpServer()).post('/auth/passkeys/register/options').expect(401);
});
it('POST /auth/passkeys/register/verify requires auth', async () => {
await request(app.getHttpServer())
.post('/auth/passkeys/register/verify')
.send({
challengeId: 'test',
credential: { id: 'test', response: {} },
})
.expect(401);
});
it('GET /auth/passkeys requires auth', async () => {
await request(app.getHttpServer()).get('/auth/passkeys').expect(401);
});
it('DELETE /auth/passkeys/:id requires auth', async () => {
await request(app.getHttpServer()).delete('/auth/passkeys/some-id').expect(401);
});
it('PATCH /auth/passkeys/:id requires auth', async () => {
await request(app.getHttpServer())
.patch('/auth/passkeys/some-id')
.send({ friendlyName: 'test' })
.expect(401);
});
});
describe('Public endpoints do not require JWT', () => {
it('POST /auth/passkeys/authenticate/options is public', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/authenticate/options')
.expect(200);
expect(response.body).toHaveProperty('options');
});
it('POST /auth/passkeys/authenticate/verify is public (fails on invalid data, not auth)', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/authenticate/verify')
.send({
challengeId: 'invalid',
credential: { id: 'test', response: {} },
});
// Should get 400 (bad request) not 401 (unauthorized)
expect(response.status).toBe(400);
});
});
describe('Invalid token handling', () => {
it('should reject passkey endpoints with invalid token', async () => {
await request(app.getHttpServer())
.post('/auth/passkeys/register/options')
.set('Authorization', 'Bearer invalid-jwt-token')
.expect(401);
});
it('should reject passkey endpoints with malformed auth header', async () => {
await request(app.getHttpServer())
.get('/auth/passkeys')
.set('Authorization', 'NotBearer token')
.expect(401);
});
});
});
// =========================================================================
// 2FA Flow via Sign-In
// =========================================================================
describe('2FA Flow', () => {
it('should return standard login response when 2FA is not enabled', async () => {
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({
email: testEmail,
password: testPassword,
})
.expect(200);
// Normal user without 2FA should get tokens
expect(response.body).toHaveProperty('accessToken');
expect(response.body).toHaveProperty('refreshToken');
expect(response.body).not.toHaveProperty('twoFactorRedirect');
});
it('session-to-token endpoint should exist', async () => {
// Without a valid session cookie, this should return 401
const response = await request(app.getHttpServer())
.post('/auth/session-to-token')
.expect((res) => {
// Should be 401 (no session cookie) not 404 (endpoint missing)
expect(res.status).not.toBe(404);
expect([200, 401]).toContain(res.status);
});
if (response.status === 401) {
expect(response.body).toHaveProperty('message');
}
});
it('session-to-token should reject request without session cookie', async () => {
await request(app.getHttpServer()).post('/auth/session-to-token').expect(401);
});
});
// =========================================================================
// 2FA Passthrough Endpoints
// =========================================================================
describe('2FA Passthrough Routes', () => {
it('should expose two-factor enable endpoint (requires session)', async () => {
const response = await request(app.getHttpServer())
.post('/api/auth/two-factor/enable')
.send({});
// Should not be 404 - the route exists even if auth fails
expect(response.status).not.toBe(404);
});
it('should expose two-factor verify-totp endpoint', async () => {
const response = await request(app.getHttpServer())
.post('/api/auth/two-factor/verify-totp')
.send({ code: '000000' });
// Should not be 404 - the route exists
expect(response.status).not.toBe(404);
});
it('should expose two-factor disable endpoint', async () => {
const response = await request(app.getHttpServer())
.post('/api/auth/two-factor/disable')
.send({});
expect(response.status).not.toBe(404);
});
it('should expose two-factor get-totp-uri endpoint', async () => {
const response = await request(app.getHttpServer())
.post('/api/auth/two-factor/get-totp-uri')
.send({});
expect(response.status).not.toBe(404);
});
it('should expose two-factor generate-backup-codes endpoint', async () => {
const response = await request(app.getHttpServer())
.post('/api/auth/two-factor/generate-backup-codes')
.send({});
expect(response.status).not.toBe(404);
});
it('should expose two-factor verify-backup-code endpoint', async () => {
const response = await request(app.getHttpServer())
.post('/api/auth/two-factor/verify-backup-code')
.send({ code: 'fake-backup-code' });
expect(response.status).not.toBe(404);
});
});
// =========================================================================
// Passkey + Login Token Shape Consistency
// =========================================================================
describe('Token Response Shape Consistency', () => {
it('login and passkey-auth-verify should share the same token response shape', async () => {
// Login response shape
const loginResponse = await request(app.getHttpServer())
.post('/auth/login')
.send({
email: testEmail,
password: testPassword,
})
.expect(200);
// Verify the login token shape (passkey auth verify returns the same shape)
const tokenKeys = Object.keys(loginResponse.body);
expect(tokenKeys).toContain('user');
expect(tokenKeys).toContain('accessToken');
expect(tokenKeys).toContain('refreshToken');
expect(tokenKeys).toContain('expiresIn');
expect(loginResponse.body.user).toHaveProperty('id');
expect(loginResponse.body.user).toHaveProperty('email');
expect(typeof loginResponse.body.accessToken).toBe('string');
expect(typeof loginResponse.body.refreshToken).toBe('string');
expect(typeof loginResponse.body.expiresIn).toBe('number');
});
});
// =========================================================================
// Edge Cases
// =========================================================================
describe('Edge Cases', () => {
it('should handle empty body on register/options gracefully', async () => {
// The endpoint reads user from JWT, so empty body is fine
const response = await request(app.getHttpServer())
.post('/auth/passkeys/register/options')
.set('Authorization', `Bearer ${accessToken}`)
.send({});
expect([200, 201]).toContain(response.status);
expect(response.body).toHaveProperty('challengeId');
});
it('should handle missing credential field on register/verify', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/register/verify')
.set('Authorization', `Bearer ${accessToken}`)
.send({ challengeId: 'some-challenge' });
expect([400, 500]).toContain(response.status);
});
it('should handle missing body on authenticate/verify', async () => {
const response = await request(app.getHttpServer())
.post('/auth/passkeys/authenticate/verify')
.send({});
expect([400, 500]).toContain(response.status);
});
it('should not allow cross-user passkey deletion', async () => {
// Create a second user
const otherEmail = `passkey-other-${Date.now()}@example.com`;
await request(app.getHttpServer()).post('/auth/register').send({
email: otherEmail,
password: testPassword,
name: 'Other User',
});
const otherLogin = await request(app.getHttpServer()).post('/auth/login').send({
email: otherEmail,
password: testPassword,
});
const otherToken = otherLogin.body.accessToken;
// Try to delete a non-existent passkey with other user's token
// This should return 404 (not found for this user) not 204
await request(app.getHttpServer())
.delete('/auth/passkeys/some-passkey-id')
.set('Authorization', `Bearer ${otherToken}`)
.expect(404);
});
it('should generate unique challenge IDs across requests', async () => {
const [res1, res2] = await Promise.all([
request(app.getHttpServer()).post('/auth/passkeys/authenticate/options').send(),
request(app.getHttpServer()).post('/auth/passkeys/authenticate/options').send(),
]);
expect(res1.body.challengeId).not.toBe(res2.body.challengeId);
expect(res1.body.options.challenge).not.toBe(res2.body.options.challenge);
});
});
});