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

@ -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')