diff --git a/apps/api/package.json b/apps/api/package.json index 331f98f..61572fc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,6 +20,7 @@ "@fastify/sensible": "^6.0.3", "@fastify/static": "^8.2.0", "@node-rs/argon2": "^2.0.2", + "@simplewebauthn/server": "^13.3.2", "fastify": "^5.4.0", "fastify-plugin": "^5.0.1", "jose": "^6.2.8", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 126c571..54ddfb8 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -31,6 +31,7 @@ import { adminRoutes } from './routes/admin.js' import { auditAdminRoutes } from './routes/audit.js' import { auditIngestRoutes } from './routes/ingest-audit.js' import { oidcRoutes } from './routes/oidc.js' +import { webauthnRoutes } from './routes/webauthn.js' import { startAuditRetentionJob } from './services/audit-retention.js' import { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js' @@ -129,6 +130,7 @@ export async function buildApp(opts: { await app.register(auditAdminRoutes) await app.register(auditIngestRoutes) await app.register(oidcRoutes) + await app.register(webauthnRoutes) if (process.env.NODE_ENV !== 'test') { const stopRetention = startAuditRetentionJob(app) diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 5725093..9fd92aa 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -24,6 +24,9 @@ export const configSchema = z.object({ logLevel: z.string().default('info'), isProd: z.boolean(), auditIngestSecret: z.string().min(8).optional(), + webauthnRpID: z.string().min(1), + webauthnRpName: z.string().min(1).default('Auth Portal'), + webauthnOrigins: z.array(z.string().url()).min(1), }) export type AppConfig = z.infer @@ -35,6 +38,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { env.AUDIT_INGEST_SECRET ?? (isProd ? undefined : 'dev-audit-ingest-secret') const issuer = env.ISSUER ?? 'https://auth.shnt.top' + const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd) return configSchema.parse({ databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db', @@ -56,9 +60,43 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) || isProd, auditIngestSecret, + webauthnRpID: rpID, + webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal', + webauthnOrigins: origins, }) } +function webauthnFromIssuer( + issuer: string, + env: NodeJS.ProcessEnv, + isProd: boolean, +): { rpID: string; origins: string[] } { + let issuerUrl: URL + try { + issuerUrl = new URL(issuer) + } catch { + issuerUrl = new URL('https://auth.shnt.top') + } + const rpID = (env.WEBAUTHN_RP_ID || issuerUrl.hostname).trim() + const origins = new Set() + origins.add(issuerUrl.origin) + const extra = env.WEBAUTHN_ORIGINS ?? '' + for (const raw of extra.split(',')) { + const value = raw.trim().replace(/\/$/, '') + if (!value) continue + try { + origins.add(new URL(value).origin) + } catch { + /* skip invalid */ + } + } + if (!isProd) { + origins.add('http://localhost:5173') + origins.add('http://localhost:8080') + } + return { rpID, origins: [...origins] } +} + export function oidcIssuerFromConfig(config: AppConfig): string { return (config.oidcIssuer ?? config.issuer).replace(/\/$/, '') } diff --git a/apps/api/src/lib/complete-login.ts b/apps/api/src/lib/complete-login.ts new file mode 100644 index 0000000..e96f97f --- /dev/null +++ b/apps/api/src/lib/complete-login.ts @@ -0,0 +1,64 @@ +import type { FastifyInstance, FastifyReply } from 'fastify' +import { randomBytes } from 'node:crypto' +import { + createRefreshSession, + touchLastLogin, + type UserRow, +} from '@authportal/db' +import { issueAccessToken } from './issue-access-token.js' +import { safeAudit } from './audit.js' +import { targetAppFromReturnTo } from './target-app.js' + +export const REFRESH_COOKIE = 'refresh_token' + +export function completeLogin( + app: FastifyInstance, + reply: FastifyReply, + user: UserRow, + opts: { + method: 'password' | 'passkey' + returnTo?: string + ip: string | null + userAgent: string | null + }, +) { + const body = issueAccessToken(app, user) + const refreshRaw = randomBytes(32).toString('hex') + const refreshExpires = new Date( + Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000, + ) + createRefreshSession(app.db, user.id, refreshRaw, refreshExpires, { + ip: opts.ip, + userAgent: opts.userAgent, + }) + touchLastLogin(app.db, user.id, opts.ip) + + reply.header( + 'Set-Cookie', + `${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`, + ) + + const targetApp = targetAppFromReturnTo(opts.returnTo) + const isPasskey = opts.method === 'passkey' + safeAudit(app, { + action: isPasskey ? 'auth.passkey_login' : 'auth.login', + severity: 'info', + actorUserId: user.id, + actorEmail: user.email, + actorName: user.name, + targetType: 'session', + targetId: user.id, + summary: isPasskey + ? `Вход (passkey): ${user.email}` + : `Вход: ${user.email}`, + details: { + method: opts.method, + user_agent: opts.userAgent, + return_to: opts.returnTo ?? null, + target_app: targetApp, + }, + ip: opts.ip, + }) + + return body +} diff --git a/apps/api/src/lib/webauthn.ts b/apps/api/src/lib/webauthn.ts new file mode 100644 index 0000000..757fe09 --- /dev/null +++ b/apps/api/src/lib/webauthn.ts @@ -0,0 +1,34 @@ +import type { FastifyInstance } from 'fastify' +import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers' +import type { AuthenticatorTransportFuture } from '@simplewebauthn/server' + +export function webauthnRelyingParty(app: FastifyInstance): { + rpID: string + rpName: string + origins: string[] +} { + return { + rpID: app.config.webauthnRpID, + rpName: app.config.webauthnRpName, + origins: app.config.webauthnOrigins, + } +} + +export function userIdToBytes(userId: string): Uint8Array { + return isoUint8Array.fromUTF8String(userId) +} + +export function encodePublicKey(publicKey: Uint8Array): string { + return isoBase64URL.fromBuffer(publicKey) +} + +export function decodePublicKey(stored: string): Uint8Array { + return isoBase64URL.toBuffer(stored) +} + +export function asTransports( + values: string[], +): AuthenticatorTransportFuture[] | undefined { + if (values.length === 0) return undefined + return values as AuthenticatorTransportFuture[] +} diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 1e01a6a..7ab6318 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -22,6 +22,8 @@ import { listOidcClients, parseJsonStringArray, updateOidcClient, + countWebauthnCredentials, + deleteWebauthnCredentialsForUser, } from '@authportal/db' import { APP_IDS, @@ -62,6 +64,7 @@ function mapUser( permissions, last_login_at: user.lastLoginAt ?? null, last_login_ip: user.lastLoginIp ?? null, + passkey_count: countWebauthnCredentials(db, user.id), created_at: user.createdAt, updated_at: user.updatedAt, } @@ -217,6 +220,30 @@ export async function adminRoutes(app: FastifyInstance): Promise { }, ) + app.delete<{ Params: { id: string } }>( + '/api/v1/admin/users/:id/passkeys', + async (request, reply) => { + const existing = getUserById(app.db, request.params.id) + if (!existing) { + return reply.status(404).send({ + error: { code: 'NOT_FOUND', message: 'Пользователь не найден' }, + }) + } + const removed = deleteWebauthnCredentialsForUser(app.db, existing.id) + safeAudit(app, { + action: 'admin.passkey_reset', + severity: 'warning', + ...actorFromRequest(request), + targetType: 'user', + targetId: existing.id, + summary: `Сброшены passkeys: ${existing.email} (${removed})`, + details: { removed }, + ip: clientIp(request), + }) + return { ok: true, removed, user: mapUser(app.db, existing) } + }, + ) + app.delete<{ Params: { id: string } }>( '/api/v1/admin/users/:id', async (request, reply) => { diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 706fcf0..afbb4fb 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,6 +1,5 @@ import type { FastifyInstance } from 'fastify' import { verify } from '@node-rs/argon2' -import { randomBytes } from 'node:crypto' import { PERMISSION_CATALOG, appsMetaFromSwitcher, @@ -9,26 +8,24 @@ import { ssoAccessRequestSchema, } from '@authportal/shared' import { - createRefreshSession, getAppSwitcherConfig, getUserByEmail, getUserById, listUsers, revokeRefreshSession, - touchLastLogin, } from '@authportal/db' import { requireAuth } from '../plugins/auth-guards.js' import { issueAccessToken } from '../lib/issue-access-token.js' +import { completeLogin, REFRESH_COOKIE } from '../lib/complete-login.js' import { clientIp, safeAudit } from '../lib/audit.js' import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js' -const REFRESH_COOKIE = 'refresh_token' - export async function authRoutes(app: FastifyInstance): Promise { /** Public — SPA reads allowlist at runtime (Docker-friendly). */ app.get('/api/v1/auth/config', async () => ({ return_to_allowlist: app.config.returnToAllowlist, issuer: app.config.issuer, + webauthn: true, })) app.post('/api/v1/auth/login', { @@ -90,41 +87,12 @@ export async function authRoutes(app: FastifyInstance): Promise { }) } - const body = issueAccessToken(app, user) - - const refreshRaw = randomBytes(32).toString('hex') - const refreshExpires = new Date( - Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000, - ) - createRefreshSession(app.db, user.id, refreshRaw, refreshExpires, { + return completeLogin(app, reply, user, { + method: 'password', + returnTo, ip, userAgent, }) - touchLastLogin(app.db, user.id, ip) - - reply.header( - 'Set-Cookie', - `${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`, - ) - - safeAudit(app, { - action: 'auth.login', - severity: 'info', - actorUserId: user.id, - actorEmail: user.email, - actorName: user.name, - targetType: 'session', - targetId: user.id, - summary: `Вход: ${user.email}`, - details: { - user_agent: userAgent, - return_to: returnTo ?? null, - target_app: targetApp, - }, - ip, - }) - - return body }, }) diff --git a/apps/api/src/routes/webauthn.ts b/apps/api/src/routes/webauthn.ts new file mode 100644 index 0000000..a057679 --- /dev/null +++ b/apps/api/src/routes/webauthn.ts @@ -0,0 +1,388 @@ +import type { FastifyInstance } from 'fastify' +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, + type AuthenticationResponseJSON, + type RegistrationResponseJSON, +} from '@simplewebauthn/server' +import { + consumeWebauthnChallenge, + createWebauthnChallenge, + createWebauthnCredential, + deleteWebauthnCredential, + getUserById, + getWebauthnCredentialByCredentialId, + getWebauthnCredentialById, + listWebauthnCredentials, + parseTransportsJson, + updateWebauthnCredentialName, + touchWebauthnCredential, + type WebauthnCredentialRow, +} from '@authportal/db' +import { + patchPasskeyRequestSchema, + webauthnVerifyRequestSchema, + type PasskeyCredential, +} from '@authportal/shared' +import { requireAuth } from '../plugins/auth-guards.js' +import { completeLogin } from '../lib/complete-login.js' +import { clientIp, safeAudit } from '../lib/audit.js' +import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js' +import { + asTransports, + decodePublicKey, + encodePublicKey, + userIdToBytes, + webauthnRelyingParty, +} from '../lib/webauthn.js' + +const LOGIN_RATE = { max: 20, timeWindow: '1 minute' } as const + +function toPasskeyDto(row: WebauthnCredentialRow): PasskeyCredential { + return { + id: row.id, + name: row.name, + created_at: row.createdAt, + last_used_at: row.lastUsedAt ?? null, + device_type: row.deviceType ?? null, + } +} + +function defaultPasskeyName(userAgent: string | null): string { + const date = new Date().toLocaleDateString('ru-RU') + if (!userAgent) return `Passkey ${date}` + if (/iPhone|iPad|Macintosh/i.test(userAgent)) return `Apple ${date}` + if (/Windows/i.test(userAgent)) return `Windows Hello ${date}` + if (/Android/i.test(userAgent)) return `Android ${date}` + return `Passkey ${date}` +} + +function asRegistrationResponse( + raw: unknown, +): RegistrationResponseJSON | null { + if (!raw || typeof raw !== 'object') return null + if (!('id' in raw) || typeof (raw as { id: unknown }).id !== 'string') { + return null + } + return raw as RegistrationResponseJSON +} + +function asAuthenticationResponse( + raw: unknown, +): AuthenticationResponseJSON | null { + if (!raw || typeof raw !== 'object') return null + if (!('id' in raw) || typeof (raw as { id: unknown }).id !== 'string') { + return null + } + return raw as AuthenticationResponseJSON +} + +export async function webauthnRoutes(app: FastifyInstance): Promise { + app.post( + '/api/v1/webauthn/register/options', + { + onRequest: requireAuth, + config: { rateLimit: LOGIN_RATE }, + }, + async (request, reply) => { + const auth = request.authUser! + const user = getUserById(app.db, auth.id) + if (!user || user.disabled) { + return reply.status(401).send({ + error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' }, + }) + } + const rp = webauthnRelyingParty(app) + const existing = listWebauthnCredentials(app.db, user.id) + const options = await generateRegistrationOptions({ + rpName: rp.rpName, + rpID: rp.rpID, + userName: user.email, + userDisplayName: user.name, + userID: userIdToBytes(user.id), + attestationType: 'none', + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred', + }, + excludeCredentials: existing.map((cred) => ({ + id: cred.credentialId, + transports: asTransports(parseTransportsJson(cred.transportsJson)), + })), + }) + const row = createWebauthnChallenge(app.db, { + purpose: 'register', + challenge: options.challenge, + userId: user.id, + }) + return { challenge_id: row.id, options } + }, + ) + + app.post( + '/api/v1/webauthn/register', + { + onRequest: requireAuth, + config: { rateLimit: LOGIN_RATE }, + }, + async (request, reply) => { + const parsed = webauthnVerifyRequestSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ + error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' }, + }) + } + const auth = request.authUser! + const user = getUserById(app.db, auth.id) + if (!user || user.disabled) { + return reply.status(401).send({ + error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' }, + }) + } + const response = asRegistrationResponse(parsed.data.response) + if (!response) { + return reply.status(400).send({ + error: { code: 'VALIDATION_ERROR', message: 'Некорректный ответ passkey' }, + }) + } + const challenge = consumeWebauthnChallenge( + app.db, + parsed.data.challenge_id, + 'register', + user.id, + ) + if (!challenge) { + return reply.status(400).send({ + error: { + code: 'VALIDATION_ERROR', + message: 'Срок действия challenge истёк, повторите регистрацию', + }, + }) + } + const rp = webauthnRelyingParty(app) + let verification + try { + verification = await verifyRegistrationResponse({ + response, + expectedChallenge: challenge.challenge, + expectedOrigin: rp.origins, + expectedRPID: rp.rpID, + requireUserVerification: true, + }) + } catch (err) { + app.log.warn({ err }, 'webauthn register verify failed') + return reply.status(400).send({ + error: { code: 'VALIDATION_ERROR', message: 'Не удалось проверить passkey' }, + }) + } + if (!verification.verified || !verification.registrationInfo) { + return reply.status(400).send({ + error: { code: 'VALIDATION_ERROR', message: 'Passkey не подтверждён' }, + }) + } + const info = verification.registrationInfo + const duplicate = getWebauthnCredentialByCredentialId( + app.db, + info.credential.id, + ) + if (duplicate) { + return reply.status(409).send({ + error: { code: 'CONFLICT', message: 'Этот passkey уже зарегистрирован' }, + }) + } + const name = + parsed.data.name?.trim() || + defaultPasskeyName(clientUserAgent(request.headers)) + const row = createWebauthnCredential(app.db, { + userId: user.id, + credentialId: info.credential.id, + publicKey: encodePublicKey(info.credential.publicKey), + counter: info.credential.counter, + deviceType: info.credentialDeviceType, + backedUp: info.credentialBackedUp, + transports: info.credential.transports, + name, + }) + safeAudit(app, { + action: 'auth.passkey_register', + severity: 'info', + actorUserId: user.id, + actorEmail: user.email, + actorName: user.name, + targetType: 'credential', + targetId: row.id, + summary: `Passkey добавлен: ${user.email}`, + details: { name: row.name, device_type: row.deviceType }, + ip: clientIp(request), + }) + return toPasskeyDto(row) + }, + ) + + app.get( + '/api/v1/webauthn/credentials', + { onRequest: requireAuth }, + async (request) => { + const auth = request.authUser! + return listWebauthnCredentials(app.db, auth.id).map(toPasskeyDto) + }, + ) + + app.patch<{ Params: { id: string } }>( + '/api/v1/webauthn/credentials/:id', + { onRequest: requireAuth }, + async (request, reply) => { + const parsed = patchPasskeyRequestSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ + error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' }, + }) + } + const auth = request.authUser! + const row = updateWebauthnCredentialName( + app.db, + request.params.id, + auth.id, + parsed.data.name.trim(), + ) + if (!row) { + return reply.status(404).send({ + error: { code: 'NOT_FOUND', message: 'Passkey не найден' }, + }) + } + return toPasskeyDto(row) + }, + ) + + app.delete<{ Params: { id: string } }>( + '/api/v1/webauthn/credentials/:id', + { onRequest: requireAuth }, + async (request, reply) => { + const auth = request.authUser! + const existing = getWebauthnCredentialById(app.db, request.params.id) + if (!existing || existing.userId !== auth.id) { + return reply.status(404).send({ + error: { code: 'NOT_FOUND', message: 'Passkey не найден' }, + }) + } + deleteWebauthnCredential(app.db, request.params.id, auth.id) + const user = getUserById(app.db, auth.id) + safeAudit(app, { + action: 'auth.passkey_delete', + severity: 'warning', + actorUserId: auth.id, + actorEmail: auth.email, + actorName: auth.name, + targetType: 'credential', + targetId: request.params.id, + summary: `Passkey удалён: ${user?.email ?? auth.email}`, + details: { name: existing.name }, + ip: clientIp(request), + }) + return { ok: true } + }, + ) + + app.post( + '/api/v1/webauthn/login/options', + { config: { rateLimit: LOGIN_RATE } }, + async () => { + const rp = webauthnRelyingParty(app) + const options = await generateAuthenticationOptions({ + rpID: rp.rpID, + userVerification: 'preferred', + }) + const row = createWebauthnChallenge(app.db, { + purpose: 'authenticate', + challenge: options.challenge, + }) + return { challenge_id: row.id, options } + }, + ) + + app.post( + '/api/v1/webauthn/login', + { config: { rateLimit: LOGIN_RATE } }, + async (request, reply) => { + const parsed = webauthnVerifyRequestSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ + error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' }, + }) + } + const ip = clientIp(request) + const userAgent = clientUserAgent(request.headers) + const returnTo = parsed.data.return_to + const fail = (reason: string) => { + safeAudit(app, { + action: 'auth.passkey_login_failed', + severity: 'warning', + targetType: 'session', + summary: 'Неудачный вход по passkey', + details: { + reason, + user_agent: userAgent, + return_to: returnTo ?? null, + target_app: targetAppFromReturnTo(returnTo), + }, + ip, + }) + return reply.status(401).send({ + error: { code: 'UNAUTHORIZED', message: 'Не удалось войти с passkey' }, + }) + } + + const response = asAuthenticationResponse(parsed.data.response) + if (!response) return fail('bad_response') + + const challenge = consumeWebauthnChallenge( + app.db, + parsed.data.challenge_id, + 'authenticate', + ) + if (!challenge) return fail('expired_challenge') + + const cred = getWebauthnCredentialByCredentialId(app.db, response.id) + if (!cred) return fail('unknown_credential') + + const user = getUserById(app.db, cred.userId) + if (!user || user.disabled) return fail(user ? 'disabled' : 'unknown_user') + + const rp = webauthnRelyingParty(app) + let verification + try { + verification = await verifyAuthenticationResponse({ + response, + expectedChallenge: challenge.challenge, + expectedOrigin: rp.origins, + expectedRPID: rp.rpID, + requireUserVerification: true, + credential: { + id: cred.credentialId, + publicKey: decodePublicKey(cred.publicKey), + counter: cred.counter, + transports: asTransports(parseTransportsJson(cred.transportsJson)), + }, + }) + } catch (err) { + app.log.warn({ err }, 'webauthn login verify failed') + return fail('verify_error') + } + if (!verification.verified) return fail('not_verified') + + touchWebauthnCredential( + app.db, + cred.id, + verification.authenticationInfo.newCounter, + ) + return completeLogin(app, reply, user, { + method: 'passkey', + returnTo, + ip, + userAgent, + }) + }, + ) +} diff --git a/apps/api/test/webauthn.test.ts b/apps/api/test/webauthn.test.ts new file mode 100644 index 0000000..e032bd4 --- /dev/null +++ b/apps/api/test/webauthn.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest' +import { buildApp } from '../src/app.js' +import { loadConfig } from '../src/config.js' +import { createWebauthnCredential } from '@authportal/db' + +async function buildTestApp() { + const config = loadConfig({ + ...process.env, + JWT_SECRET: 'test-secret-at-least-8', + ADMIN_EMAIL: 'admin@test.local', + ADMIN_PASSWORD: 'adminpass', + DATABASE_URL: 'sqlite::memory:', + ISSUER: 'https://auth.test.local', + NODE_ENV: 'test', + }) + return buildApp({ config, databaseUrl: 'sqlite::memory:' }) +} + +async function adminToken(app: Awaited>) { + const login = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { email: 'admin@test.local', password: 'adminpass' }, + }) + expect(login.statusCode).toBe(200) + return (login.json() as { access_token: string }).access_token +} + +describe('webauthn / passkeys', () => { + it('exposes webauthn flag on auth config', async () => { + const app = await buildTestApp() + const res = await app.inject({ method: 'GET', url: '/api/v1/auth/config' }) + expect(res.statusCode).toBe(200) + expect(res.json()).toMatchObject({ webauthn: true }) + expect(app.config.webauthnRpID).toBe('auth.test.local') + expect(app.config.webauthnOrigins).toContain('https://auth.test.local') + expect(app.config.webauthnOrigins).toContain('http://localhost:5173') + await app.close() + }) + + it('requires JWT for register options', async () => { + const app = await buildTestApp() + const denied = await app.inject({ + method: 'POST', + url: '/api/v1/webauthn/register/options', + }) + expect(denied.statusCode).toBe(401) + + const token = await adminToken(app) + const ok = await app.inject({ + method: 'POST', + url: '/api/v1/webauthn/register/options', + headers: { authorization: `Bearer ${token}` }, + }) + expect(ok.statusCode).toBe(200) + const body = ok.json() as { + challenge_id: string + options: { challenge: string; rp: { id: string } } + } + expect(body.challenge_id).toBeTruthy() + expect(body.options.challenge).toBeTruthy() + expect(body.options.rp.id).toBe('auth.test.local') + await app.close() + }) + + it('allows public login options and rejects a bogus assertion', async () => { + const app = await buildTestApp() + const options = await app.inject({ + method: 'POST', + url: '/api/v1/webauthn/login/options', + }) + expect(options.statusCode).toBe(200) + const body = options.json() as { challenge_id: string; options: unknown } + expect(body.challenge_id).toBeTruthy() + + const login = await app.inject({ + method: 'POST', + url: '/api/v1/webauthn/login', + payload: { + challenge_id: body.challenge_id, + response: { id: 'not-a-credential', type: 'public-key' }, + }, + }) + expect(login.statusCode).toBe(401) + await app.close() + }) + + it('lists, deletes own credentials and reports passkey_count', async () => { + const app = await buildTestApp() + const token = await adminToken(app) + const me = await app.inject({ + method: 'GET', + url: '/api/v1/auth/me', + headers: { authorization: `Bearer ${token}` }, + }) + const userId = (me.json() as { id: string }).id + + createWebauthnCredential(app.db, { + userId, + credentialId: 'dGVzdC1jcmVkLWlk', + publicKey: 'dGVzdC1wdWJrZXk', + counter: 0, + name: 'Test key', + }) + + const listed = await app.inject({ + method: 'GET', + url: '/api/v1/webauthn/credentials', + headers: { authorization: `Bearer ${token}` }, + }) + expect(listed.statusCode).toBe(200) + const creds = listed.json() as { id: string; name: string }[] + expect(creds).toHaveLength(1) + expect(creds[0]?.name).toBe('Test key') + + const users = await app.inject({ + method: 'GET', + url: '/api/v1/admin/users', + headers: { authorization: `Bearer ${token}` }, + }) + const admin = ( + users.json() as { email: string; passkey_count: number }[] + ).find((u) => u.email === 'admin@test.local') + expect(admin?.passkey_count).toBe(1) + + const renamed = await app.inject({ + method: 'PATCH', + url: `/api/v1/webauthn/credentials/${creds[0]!.id}`, + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Laptop' }, + }) + expect(renamed.statusCode).toBe(200) + expect((renamed.json() as { name: string }).name).toBe('Laptop') + + const deleted = await app.inject({ + method: 'DELETE', + url: `/api/v1/webauthn/credentials/${creds[0]!.id}`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(deleted.statusCode).toBe(200) + + const empty = await app.inject({ + method: 'GET', + url: '/api/v1/webauthn/credentials', + headers: { authorization: `Bearer ${token}` }, + }) + expect(empty.json()).toEqual([]) + await app.close() + }) + + it('lets admin reset a user passkeys', async () => { + const app = await buildTestApp() + const token = await adminToken(app) + const me = await app.inject({ + method: 'GET', + url: '/api/v1/auth/me', + headers: { authorization: `Bearer ${token}` }, + }) + const userId = (me.json() as { id: string }).id + createWebauthnCredential(app.db, { + userId, + credentialId: 'cmVzZXQta2V5', + publicKey: 'cHVia2V5', + counter: 1, + name: 'To reset', + }) + + const reset = await app.inject({ + method: 'DELETE', + url: `/api/v1/admin/users/${userId}/passkeys`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(reset.statusCode).toBe(200) + expect(reset.json()).toMatchObject({ ok: true, removed: 1 }) + + const listed = await app.inject({ + method: 'GET', + url: '/api/v1/webauthn/credentials', + headers: { authorization: `Bearer ${token}` }, + }) + expect(listed.json()).toEqual([]) + await app.close() + }) +}) diff --git a/apps/web/package.json b/apps/web/package.json index ffa276a..00ab15d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.4.0", + "@simplewebauthn/browser": "^13.3.0", "@tailwindcss/vite": "^4.3.1", "@tanstack/react-query": "^5.101.0", "@tanstack/react-router": "^1.170.15", diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index db8bda6..bc9616b 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -2,6 +2,7 @@ import { Link, useRouterState } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' import { AppWindowIcon, + FingerprintIcon, HistoryIcon, KeyRoundIcon, LayoutGridIcon, @@ -53,6 +54,16 @@ export function AppSidebar() { Приложения + + } + > + + Безопасность + + diff --git a/apps/web/src/components/nav-user.tsx b/apps/web/src/components/nav-user.tsx index 06e51f6..ce865ea 100644 --- a/apps/web/src/components/nav-user.tsx +++ b/apps/web/src/components/nav-user.tsx @@ -1,6 +1,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { ChevronsUpDownIcon, + FingerprintIcon, LogOutIcon, MonitorIcon, MoonIcon, @@ -8,6 +9,7 @@ import { SunIcon, } from 'lucide-react' import { useEffect, useState } from 'react' +import { useNavigate } from '@tanstack/react-router' import { useTheme } from 'next-themes' import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar' @@ -109,6 +111,7 @@ export function NavUser() { const { isMobile } = useSidebar() const { data: me } = useQuery(meQueryOptions) const queryClient = useQueryClient() + const navigate = useNavigate() const name = me?.name?.trim() || 'Пользователь' const email = me?.email?.trim() || '' @@ -175,6 +178,12 @@ export function NavUser() { + void navigate({ to: '/account' })} + > + + Безопасность + Тема diff --git a/apps/web/src/components/portal-login-form.tsx b/apps/web/src/components/portal-login-form.tsx index 4c8e050..5dcf119 100644 --- a/apps/web/src/components/portal-login-form.tsx +++ b/apps/web/src/components/portal-login-form.tsx @@ -1,8 +1,20 @@ -import { useState, type FormEvent } from 'react' +import { useEffect, useState, type FormEvent } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' -import { EyeIcon, EyeOffIcon } from 'lucide-react' -import { buildSsoRedirectUrl, isPortalOidcAuthorizeUrl, isReturnToAllowed } from '@authportal/shared' +import { EyeIcon, EyeOffIcon, FingerprintIcon } from 'lucide-react' +import { + browserSupportsWebAuthn, + browserSupportsWebAuthnAutofill, + startAuthentication, + WebAuthnAbortService, +} from '@simplewebauthn/browser' +import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser' +import { + buildSsoRedirectUrl, + isPortalOidcAuthorizeUrl, + isReturnToAllowed, + type LoginResponse, +} from '@authportal/shared' import { Button } from '@authportal/ui/components/button' import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field' import { Input } from '@authportal/ui/components/input' @@ -12,6 +24,7 @@ import { InputGroupButton, InputGroupInput, } from '@authportal/ui/components/input-group' +import { Separator } from '@authportal/ui/components/separator' import { Alert, AlertDescription, @@ -20,6 +33,7 @@ import { import { ensureAuthConfig, setToken } from '@/lib/auth' import { ApiError } from '@/lib/api-client' import { login, meQueryKey } from '@/queries/auth' +import { webauthnLogin, webauthnLoginOptions } from '@/queries/webauthn' import { AuthLogo } from '@/components/blocks/auth-18/components/auth-logo' export function PortalLoginForm() { @@ -29,42 +43,110 @@ export function PortalLoginForm() { const [showPassword, setShowPassword] = useState(false) const [error, setError] = useState(null) const [pending, setPending] = useState(false) + const [passkeySupported, setPasskeySupported] = useState(false) + + async function applySession(res: LoginResponse) { + setToken(res.access_token) + queryClient.setQueryData(meQueryKey, res.user) + + const returnTo = search.return_to + const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig() + if (returnTo && isReturnToAllowed(returnTo, allowlist)) { + if (isPortalOidcAuthorizeUrl(returnTo, issuer)) { + window.location.href = returnTo + return + } + window.location.href = buildSsoRedirectUrl( + returnTo, + res.access_token, + res.expires_at, + ) + return + } + + if (res.user.is_admin) { + await navigate({ to: '/admin' }) + } else { + await navigate({ to: '/apps' }) + } + } + + async function runPasskeyLogin() { + const { challenge_id, options } = await webauthnLoginOptions() + const assertion = await startAuthentication({ + optionsJSON: options as unknown as PublicKeyCredentialRequestOptionsJSON, + }) + const res = await webauthnLogin(challenge_id, assertion, search.return_to) + await applySession(res) + } + + useEffect(() => { + if (!browserSupportsWebAuthn()) return + setPasskeySupported(true) + let cancelled = false + + async function startConditional() { + if (!(await browserSupportsWebAuthnAutofill())) return + try { + const { challenge_id, options } = await webauthnLoginOptions() + if (cancelled) return + const assertion = await startAuthentication({ + optionsJSON: + options as unknown as PublicKeyCredentialRequestOptionsJSON, + useBrowserAutofill: true, + }) + if (cancelled) return + setPending(true) + setError(null) + const res = await webauthnLogin( + challenge_id, + assertion, + search.return_to, + ) + await applySession(res) + } catch { + /* abort / unsupported / user dismissed */ + } finally { + if (!cancelled) setPending(false) + } + } + + void startConditional() + return () => { + cancelled = true + WebAuthnAbortService.cancelCeremony() + } + // Login page mount only — return_to is stable for the visit. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) async function handleSubmit(event: FormEvent) { event.preventDefault() + WebAuthnAbortService.cancelCeremony() setError(null) setPending(true) const form = new FormData(event.currentTarget) const email = String(form.get('email') ?? '') const password = String(form.get('password') ?? '') - const returnTo = search.return_to try { - const res = await login(email, password, returnTo) - setToken(res.access_token) - queryClient.setQueryData(meQueryKey, res.user) + const res = await login(email, password, search.return_to) + await applySession(res) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Не удалось войти') + } finally { + setPending(false) + } + } - const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig() - if (returnTo && isReturnToAllowed(returnTo, allowlist)) { - if (isPortalOidcAuthorizeUrl(returnTo, issuer)) { - window.location.href = returnTo - return - } - window.location.href = buildSsoRedirectUrl( - returnTo, - res.access_token, - res.expires_at, - ) - return - } - - if (res.user.is_admin) { - await navigate({ to: '/admin' }) - } else { - await navigate({ to: '/apps' }) - } + async function handlePasskeyClick() { + WebAuthnAbortService.cancelCeremony() + setError(null) + setPending(true) + try { + await runPasskeyLogin() } catch (err) { setError( - err instanceof ApiError ? err.message : 'Не удалось войти', + err instanceof ApiError ? err.message : 'Не удалось войти с passkey', ) } finally { setPending(false) @@ -101,7 +183,7 @@ export function PortalLoginForm() { id="email" name="email" type="email" - autoComplete="username" + autoComplete="username webauthn" placeholder="admin@shnt.top" className="bg-background" required @@ -137,6 +219,26 @@ export function PortalLoginForm() { {pending ? 'Вход…' : 'Войти'} + + {passkeySupported ? ( +
+
+ + или + +
+ +
+ ) : null} ) diff --git a/apps/web/src/components/reui-kit/admin-users-grid.tsx b/apps/web/src/components/reui-kit/admin-users-grid.tsx index dcc9bdf..0aabcc4 100644 --- a/apps/web/src/components/reui-kit/admin-users-grid.tsx +++ b/apps/web/src/components/reui-kit/admin-users-grid.tsx @@ -10,6 +10,7 @@ import { CircleDotIcon, FilterIcon, FunnelXIcon, + FingerprintIcon, LockIcon, MailIcon, MoreHorizontalIcon, @@ -115,6 +116,7 @@ export interface AdminUsersGridProps { onOpenAccess: (user: AdminUser) => void onDeactivate: (user: AdminUser) => void onDelete: (user: AdminUser) => void + onResetPasskeys: (user: AdminUser) => void onBulkSetRole: (userIds: string[], isAdmin: boolean) => void onBulkDeactivate: (userIds: string[]) => void } @@ -403,14 +405,17 @@ function ActionsCell({ onOpenAccess, onDeactivate, onDelete, + onResetPasskeys, }: { row: Row onOpenAudit: (user: AdminUser) => void onOpenAccess: (user: AdminUser) => void onDeactivate: (user: AdminUser) => void onDelete: (user: AdminUser) => void + onResetPasskeys: (user: AdminUser) => void }) { const [deleteOpen, setDeleteOpen] = useState(false) + const [resetOpen, setResetOpen] = useState(false) const user = row.original return ( @@ -444,6 +449,12 @@ function ActionsCell({ Отключить
) : null} + {(user.passkey_count ?? 0) > 0 ? ( + setResetOpen(true)}> + + ) : null} + + + + Сбросить passkeys? + + Все ключи пользователя{' '} + {user.email}{' '} + будут удалены. Вход останется по паролю. + + + + Отмена + { + setResetOpen(false) + onResetPasskeys(user) + }} + > + Сбросить + + + + + @@ -489,6 +525,7 @@ function createAdminUserColumns(handlers: { onOpenAccess: (user: AdminUser) => void onDeactivate: (user: AdminUser) => void onDelete: (user: AdminUser) => void + onResetPasskeys: (user: AdminUser) => void }): ColumnDef[] { return [ { @@ -614,15 +651,22 @@ function createAdminUserColumns(handlers: { }, { id: 'twoFactor', + accessorFn: (row) => (row.passkey_count ?? 0) > 0, header: ({ column }) => ( ), - cell: () => ( - - - ), + cell: ({ row }) => + (row.original.passkey_count ?? 0) > 0 ? ( + + + ) : ( + + + ), size: 100, enableSorting: false, enableHiding: true, @@ -686,6 +730,7 @@ function createAdminUserColumns(handlers: { onOpenAccess={handlers.onOpenAccess} onDeactivate={handlers.onDeactivate} onDelete={handlers.onDelete} + onResetPasskeys={handlers.onResetPasskeys} /> ), size: 60, @@ -710,6 +755,7 @@ export function AdminUsersGrid({ onOpenAccess, onDeactivate, onDelete, + onResetPasskeys, onBulkSetRole, onBulkDeactivate, }: AdminUsersGridProps) { @@ -848,8 +894,9 @@ export function AdminUsersGrid({ onOpenAccess, onDeactivate, onDelete, + onResetPasskeys, }), - [onOpenAudit, onOpenAccess, onDeactivate, onDelete], + [onOpenAudit, onOpenAccess, onDeactivate, onDelete, onResetPasskeys], ) const [columnOrder, setColumnOrder] = useState( diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index a4d0cf6..f0b3e4b 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -16,3 +16,4 @@ export { UserAuditSheet } from './user-audit-sheet' export { CreateUserSheet } from './create-user-sheet' export { CreateOidcClientSheet } from './create-oidc-client-sheet' export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid' +export { PasskeySettingsPanel } from './passkey-settings-panel' diff --git a/apps/web/src/components/reui-kit/passkey-settings-panel.tsx b/apps/web/src/components/reui-kit/passkey-settings-panel.tsx new file mode 100644 index 0000000..f09ec3e --- /dev/null +++ b/apps/web/src/components/reui-kit/passkey-settings-panel.tsx @@ -0,0 +1,213 @@ +/** + * Passkey management — DNA settings-16 / settings-2 / settings-10. + * Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-2 · https://reui.io/preview/base/settings-10 + * Docs: https://reui.io/docs/components/base/frame + */ +import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { FingerprintIcon, PlusIcon, Trash2Icon } from 'lucide-react' +import { + startRegistration, + browserSupportsWebAuthn, +} from '@simplewebauthn/browser' +import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser' +import type { PasskeyCredential } from '@authportal/shared' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { Badge } from '@/components/reui/badge' +import { Button } from '@authportal/ui/components/button' +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from '@authportal/ui/components/item' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@authportal/ui/components/alert-dialog' +import { Skeleton } from '@authportal/ui/components/skeleton' +import { toast } from 'sonner' +import { ApiError } from '@/lib/api-client' +import { + deletePasskey, + passkeysQueryKey, + passkeysQueryOptions, + webauthnRegister, + webauthnRegisterOptions, +} from '@/queries/webauthn' + +function formatWhen(iso: string | null) { + if (!iso) return 'ещё не использовался' + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return '—' + return new Intl.DateTimeFormat('ru-RU', { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(date) +} + +export function PasskeySettingsPanel() { + const queryClient = useQueryClient() + const { data: passkeys = [], isLoading } = useQuery(passkeysQueryOptions) + const [pendingDelete, setPendingDelete] = useState( + null, + ) + const supported = browserSupportsWebAuthn() + + const registerMutation = useMutation({ + mutationFn: async () => { + const { challenge_id, options } = await webauthnRegisterOptions() + const attResp = await startRegistration({ + optionsJSON: + options as unknown as PublicKeyCredentialCreationOptionsJSON, + }) + return webauthnRegister(challenge_id, attResp) + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: passkeysQueryKey }) + toast.success('Passkey добавлен') + }, + onError: (err) => { + toast.error( + err instanceof ApiError ? err.message : 'Не удалось добавить passkey', + ) + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => deletePasskey(id), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: passkeysQueryKey }) + toast.success('Passkey удалён') + setPendingDelete(null) + }, + onError: (err) => { + toast.error( + err instanceof ApiError ? err.message : 'Не удалось удалить passkey', + ) + }, + }) + + return ( + <> + + +
+ Passkeys + + Вход без пароля: Windows Hello, Face ID, ключ безопасности + +
+ +
+ + {!supported ? ( +

+ Этот браузер не поддерживает WebAuthn. +

+ ) : null} + {isLoading ? ( +
+ + +
+ ) : passkeys.length === 0 ? ( +

+ Ключи не зарегистрированы. Добавьте passkey, чтобы входить без + пароля. +

+ ) : ( + passkeys.map((item) => ( + + + + + + {item.name} + {item.device_type === 'multiDevice' ? ( + + Синхронизируется + + ) : null} + + + Добавлен {formatWhen(item.created_at)} · вход{' '} + {formatWhen(item.last_used_at)} + + + + + + + )) + )} +
+ + + { + if (!open) setPendingDelete(null) + }} + > + + + Удалить passkey? + + {pendingDelete + ? `«${pendingDelete.name}» больше нельзя будет использовать для входа.` + : null} + + + + Отмена + { + if (pendingDelete) deleteMutation.mutate(pendingDelete.id) + }} + > + Удалить + + + + + + ) +} diff --git a/apps/web/src/queries/webauthn.ts b/apps/web/src/queries/webauthn.ts new file mode 100644 index 0000000..de58639 --- /dev/null +++ b/apps/web/src/queries/webauthn.ts @@ -0,0 +1,63 @@ +import { queryOptions } from '@tanstack/react-query' +import type { + LoginResponse, + PasskeyCredential, + WebauthnOptionsResponse, +} from '@authportal/shared' +import { api } from '@/lib/api-client' + +export const passkeysQueryKey = ['webauthn', 'credentials'] as const + +export const passkeysQueryOptions = queryOptions({ + queryKey: passkeysQueryKey, + queryFn: () => api.get('/api/v1/webauthn/credentials'), +}) + +export function webauthnLoginOptions() { + return api.post('/api/v1/webauthn/login/options') +} + +export function webauthnLogin( + challengeId: string, + response: unknown, + returnTo?: string, +) { + return api.post('/api/v1/webauthn/login', { + challenge_id: challengeId, + response, + ...(returnTo ? { return_to: returnTo } : {}), + }) +} + +export function webauthnRegisterOptions() { + return api.post('/api/v1/webauthn/register/options') +} + +export function webauthnRegister( + challengeId: string, + response: unknown, + name?: string, +) { + return api.post('/api/v1/webauthn/register', { + challenge_id: challengeId, + response, + ...(name ? { name } : {}), + }) +} + +export function renamePasskey(id: string, name: string) { + return api.patch(`/api/v1/webauthn/credentials/${id}`, { + name, + }) +} + +export function deletePasskey(id: string) { + return api.delete<{ ok: boolean }>(`/api/v1/webauthn/credentials/${id}`) +} + +export function adminResetPasskeys(userId: string) { + return api.delete<{ + ok: boolean + removed: number + }>(`/api/v1/admin/users/${userId}/passkeys`) +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 36de51e..0d30d56 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as AuthRouteImport } from './routes/_auth' import { Route as LogoutRouteImport } from './routes/logout' +import { Route as AuthAccountRouteImport } from './routes/_auth.account' import { Route as AuthAdminRouteImport } from './routes/_auth.admin' import { Route as AuthAppsRouteImport } from './routes/_auth.apps' import { Route as AuthAdminIndexRouteImport } from './routes/_auth.admin.index' @@ -35,6 +36,11 @@ const LogoutRoute = LogoutRouteImport.update({ path: '/logout', getParentRoute: () => rootRouteImport, } as any) +const AuthAccountRoute = AuthAccountRouteImport.update({ + id: '/account', + path: '/account', + getParentRoute: () => AuthRoute, +} as any) const AuthAdminRoute = AuthAdminRouteImport.update({ id: '/admin', path: '/admin', @@ -79,6 +85,7 @@ const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/logout': typeof LogoutRoute + '/account': typeof AuthAccountRoute '/admin': typeof AuthAdminRouteWithChildren '/apps': typeof AuthAppsRoute '/admin/apps': typeof AuthAdminAppsRoute @@ -91,6 +98,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/logout': typeof LogoutRoute + '/account': typeof AuthAccountRoute '/apps': typeof AuthAppsRoute '/admin/apps': typeof AuthAdminAppsRoute '/admin/audit': typeof AuthAdminAuditRoute @@ -104,6 +112,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/_auth': typeof AuthRouteWithChildren '/logout': typeof LogoutRoute + '/_auth/account': typeof AuthAccountRoute '/_auth/admin': typeof AuthAdminRouteWithChildren '/_auth/apps': typeof AuthAppsRoute '/_auth/admin/apps': typeof AuthAdminAppsRoute @@ -118,6 +127,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/logout' + | '/account' | '/admin' | '/apps' | '/admin/apps' @@ -130,6 +140,7 @@ export interface FileRouteTypes { to: | '/' | '/logout' + | '/account' | '/apps' | '/admin/apps' | '/admin/audit' @@ -142,6 +153,7 @@ export interface FileRouteTypes { | '/' | '/_auth' | '/logout' + | '/_auth/account' | '/_auth/admin' | '/_auth/apps' | '/_auth/admin/apps' @@ -181,6 +193,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LogoutRouteImport parentRoute: typeof rootRouteImport } + '/_auth/account': { + id: '/_auth/account' + path: '/account' + fullPath: '/account' + preLoaderRoute: typeof AuthAccountRouteImport + parentRoute: typeof AuthRoute + } '/_auth/admin': { id: '/_auth/admin' path: '/admin' @@ -263,11 +282,13 @@ const AuthAdminRouteWithChildren = AuthAdminRoute._addFileChildren( ) interface AuthRouteChildren { + AuthAccountRoute: typeof AuthAccountRoute AuthAdminRoute: typeof AuthAdminRouteWithChildren AuthAppsRoute: typeof AuthAppsRoute } const AuthRouteChildren: AuthRouteChildren = { + AuthAccountRoute: AuthAccountRoute, AuthAdminRoute: AuthAdminRouteWithChildren, AuthAppsRoute: AuthAppsRoute, } diff --git a/apps/web/src/routes/_auth.account.tsx b/apps/web/src/routes/_auth.account.tsx new file mode 100644 index 0000000..f5c4bcf --- /dev/null +++ b/apps/web/src/routes/_auth.account.tsx @@ -0,0 +1,25 @@ +/** + * Account security — passkeys. + * Preview: https://reui.io/preview/base/settings-10 · https://reui.io/preview/base/settings-16 + */ +import { createFileRoute } from '@tanstack/react-router' +import { PageShell } from '@/components/page-shell' +import { PasskeySettingsPanel } from '@/components/reui-kit/passkey-settings-panel' + +export const Route = createFileRoute('/_auth/account')({ + component: AccountPage, +}) + +function AccountPage() { + return ( + +
+

Безопасность

+

+ Passkey как альтернатива паролю. Пароль остаётся запасным входом. +

+
+ +
+ ) +} diff --git a/apps/web/src/routes/_auth.admin.index.tsx b/apps/web/src/routes/_auth.admin.index.tsx index 4b880cb..0b38054 100644 --- a/apps/web/src/routes/_auth.admin.index.tsx +++ b/apps/web/src/routes/_auth.admin.index.tsx @@ -14,6 +14,7 @@ import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet' import { UserAuditSheet } from '@/components/reui-kit/user-audit-sheet' import { api, ApiError } from '@/lib/api-client' import { usersQueryKey, usersQueryOptions } from '@/queries/auth' +import { adminResetPasskeys } from '@/queries/webauthn' import { Button } from '@authportal/ui/components/button' export const Route = createFileRoute('/_auth/admin/')({ @@ -104,6 +105,29 @@ function AdminUsersPage() { [deleteMutation], ) + const resetPasskeysMutation = useMutation({ + mutationFn: (id: string) => adminResetPasskeys(id), + onSuccess: async (_data, id) => { + await invalidateUsers() + const user = users.find((u) => u.id === id) + toast.success('Passkeys сброшены', { + description: user?.email, + }) + }, + onError: (err) => { + toast.error( + err instanceof ApiError ? err.message : 'Не удалось сбросить passkeys', + ) + }, + }) + + const handleResetPasskeys = useCallback( + (user: AdminUser) => { + resetPasskeysMutation.mutate(user.id) + }, + [resetPasskeysMutation], + ) + const handleBulkSetRole = useCallback( (userIds: string[], isAdmin: boolean) => { Promise.all( @@ -170,6 +194,7 @@ function AdminUsersPage() { onOpenAccess={(user) => setAccessUserId(user.id)} onDeactivate={handleDeactivate} onDelete={handleDelete} + onResetPasskeys={handleResetPasskeys} onBulkSetRole={handleBulkSetRole} onBulkDeactivate={handleBulkDeactivate} /> diff --git a/deploy/env.traefik.example b/deploy/env.traefik.example index f0a8907..7b95ebf 100644 --- a/deploy/env.traefik.example +++ b/deploy/env.traefik.example @@ -27,3 +27,7 @@ ADMIN_NAME=Admin RETURN_TO_ALLOWLIST=.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top,https://bgp.shnt.top,https://fw.shnt.top,https://dns.shnt.top LOG_LEVEL=info NODE_ENV=production +# WebAuthn / passkeys (defaults from ISSUER hostname + origin) +# WEBAUTHN_RP_ID=auth.shnt.top +# WEBAUTHN_ORIGINS=https://auth.shnt.top +# WEBAUTHN_RP_NAME=Auth Portal diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 2aa14fd..b1e2903 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -182,6 +182,36 @@ export function migrateSchema(sqlite: Sqlite): void { CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_client ON oidc_auth_codes(client_id); CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_user ON oidc_auth_codes(user_id); `) + + sqlite.exec(` + CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + public_key TEXT NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + device_type TEXT, + backed_up INTEGER NOT NULL DEFAULT 0, + transports_json TEXT, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used_at TEXT + ); + + CREATE TABLE IF NOT EXISTS webauthn_challenges ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + purpose TEXT NOT NULL, + challenge TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user + ON webauthn_credentials(user_id); + CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires + ON webauthn_challenges(expires_at); + `) } export function healthCheck(sqlite: Sqlite): void { @@ -193,3 +223,4 @@ export * from './users.js' export * from './settings.js' export * from './audit-log.js' export * from './oidc.js' +export * from './webauthn.js' diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 40e0020..6c52d4f 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -101,3 +101,28 @@ export const oidcSigningKeys = sqliteTable('oidc_signing_keys', { active: integer('active', { mode: 'boolean' }).notNull().default(true), createdAt: text('created_at').notNull(), }) + +export const webauthnCredentials = sqliteTable('webauthn_credentials', { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + credentialId: text('credential_id').notNull().unique(), + publicKey: text('public_key').notNull(), + counter: integer('counter').notNull().default(0), + deviceType: text('device_type'), + backedUp: integer('backed_up', { mode: 'boolean' }).notNull().default(false), + transportsJson: text('transports_json'), + name: text('name').notNull(), + createdAt: text('created_at').notNull(), + lastUsedAt: text('last_used_at'), +}) + +export const webauthnChallenges = sqliteTable('webauthn_challenges', { + id: text('id').primaryKey(), + userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }), + purpose: text('purpose').notNull(), + challenge: text('challenge').notNull(), + expiresAt: text('expires_at').notNull(), + createdAt: text('created_at').notNull(), +}) diff --git a/packages/db/src/webauthn.ts b/packages/db/src/webauthn.ts new file mode 100644 index 0000000..4ebc99c --- /dev/null +++ b/packages/db/src/webauthn.ts @@ -0,0 +1,216 @@ +import { and, eq, lt } from 'drizzle-orm' +import { randomUUID } from 'node:crypto' +import type { AppDb } from './index.js' +import { webauthnChallenges, webauthnCredentials } from './schema/index.js' + +export type WebauthnCredentialRow = typeof webauthnCredentials.$inferSelect +export type WebauthnChallengeRow = typeof webauthnChallenges.$inferSelect +export type WebauthnChallengePurpose = 'register' | 'authenticate' + +const CHALLENGE_TTL_MS = 5 * 60 * 1000 + +export function purgeExpiredWebauthnChallenges(db: AppDb): void { + const now = new Date().toISOString() + db.delete(webauthnChallenges) + .where(lt(webauthnChallenges.expiresAt, now)) + .run() +} + +export function createWebauthnChallenge( + db: AppDb, + input: { + purpose: WebauthnChallengePurpose + challenge: string + userId?: string | null + }, +): WebauthnChallengeRow { + purgeExpiredWebauthnChallenges(db) + const now = new Date() + const id = randomUUID() + db.insert(webauthnChallenges) + .values({ + id, + userId: input.userId ?? null, + purpose: input.purpose, + challenge: input.challenge, + expiresAt: new Date(now.getTime() + CHALLENGE_TTL_MS).toISOString(), + createdAt: now.toISOString(), + }) + .run() + return getWebauthnChallengeById(db, id)! +} + +export function getWebauthnChallengeById( + db: AppDb, + id: string, +): WebauthnChallengeRow | undefined { + return db + .select() + .from(webauthnChallenges) + .where(eq(webauthnChallenges.id, id)) + .get() +} + +export function consumeWebauthnChallenge( + db: AppDb, + id: string, + purpose: WebauthnChallengePurpose, + userId?: string | null, +): WebauthnChallengeRow | undefined { + const row = getWebauthnChallengeById(db, id) + if (!row) return undefined + if (row.purpose !== purpose) return undefined + if (row.expiresAt < new Date().toISOString()) { + db.delete(webauthnChallenges).where(eq(webauthnChallenges.id, id)).run() + return undefined + } + if (userId != null && row.userId && row.userId !== userId) return undefined + db.delete(webauthnChallenges).where(eq(webauthnChallenges.id, id)).run() + return row +} + +export function listWebauthnCredentials( + db: AppDb, + userId: string, +): WebauthnCredentialRow[] { + return db + .select() + .from(webauthnCredentials) + .where(eq(webauthnCredentials.userId, userId)) + .all() + .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)) +} + +export function countWebauthnCredentials(db: AppDb, userId: string): number { + return listWebauthnCredentials(db, userId).length +} + +export function getWebauthnCredentialById( + db: AppDb, + id: string, +): WebauthnCredentialRow | undefined { + return db + .select() + .from(webauthnCredentials) + .where(eq(webauthnCredentials.id, id)) + .get() +} + +export function getWebauthnCredentialByCredentialId( + db: AppDb, + credentialId: string, +): WebauthnCredentialRow | undefined { + return db + .select() + .from(webauthnCredentials) + .where(eq(webauthnCredentials.credentialId, credentialId)) + .get() +} + +export function createWebauthnCredential( + db: AppDb, + input: { + userId: string + credentialId: string + publicKey: string + counter: number + deviceType?: string | null + backedUp?: boolean + transports?: string[] + name: string + }, +): WebauthnCredentialRow { + const id = randomUUID() + const now = new Date().toISOString() + db.insert(webauthnCredentials) + .values({ + id, + userId: input.userId, + credentialId: input.credentialId, + publicKey: input.publicKey, + counter: input.counter, + deviceType: input.deviceType ?? null, + backedUp: input.backedUp ?? false, + transportsJson: input.transports + ? JSON.stringify(input.transports) + : null, + name: input.name, + createdAt: now, + lastUsedAt: null, + }) + .run() + return getWebauthnCredentialById(db, id)! +} + +export function updateWebauthnCredentialName( + db: AppDb, + id: string, + userId: string, + name: string, +): WebauthnCredentialRow | undefined { + const existing = getWebauthnCredentialById(db, id) + if (!existing || existing.userId !== userId) return undefined + db.update(webauthnCredentials) + .set({ name }) + .where( + and( + eq(webauthnCredentials.id, id), + eq(webauthnCredentials.userId, userId), + ), + ) + .run() + return getWebauthnCredentialById(db, id) +} + +export function touchWebauthnCredential( + db: AppDb, + id: string, + counter: number, +): void { + db.update(webauthnCredentials) + .set({ + counter, + lastUsedAt: new Date().toISOString(), + }) + .where(eq(webauthnCredentials.id, id)) + .run() +} + +export function deleteWebauthnCredential( + db: AppDb, + id: string, + userId: string, +): boolean { + const result = db + .delete(webauthnCredentials) + .where( + and( + eq(webauthnCredentials.id, id), + eq(webauthnCredentials.userId, userId), + ), + ) + .run() + return result.changes > 0 +} + +export function deleteWebauthnCredentialsForUser( + db: AppDb, + userId: string, +): number { + const result = db + .delete(webauthnCredentials) + .where(eq(webauthnCredentials.userId, userId)) + .run() + return result.changes +} + +export function parseTransportsJson(raw: string | null): string[] { + if (!raw) return [] + try { + const v = JSON.parse(raw) as unknown + if (!Array.isArray(v)) return [] + return v.filter((x): x is string => typeof x === 'string') + } catch { + return [] + } +} diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 679f37a..32b556a 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -400,11 +400,42 @@ export const adminUserSchema = z.object({ permissions: z.array(z.string()), last_login_at: z.string().nullable(), last_login_ip: z.string().nullable(), + passkey_count: z.number().int().nonnegative().default(0), created_at: z.string(), updated_at: z.string(), }) export type AdminUser = z.infer +export const passkeyCredentialSchema = z.object({ + id: z.string(), + name: z.string(), + created_at: z.string(), + last_used_at: z.string().nullable(), + device_type: z.string().nullable(), +}) +export type PasskeyCredential = z.infer + +export const webauthnOptionsResponseSchema = z.object({ + challenge_id: z.string(), + options: z.record(z.string(), z.unknown()), +}) +export type WebauthnOptionsResponse = z.infer< + typeof webauthnOptionsResponseSchema +> + +export const webauthnVerifyRequestSchema = z.object({ + challenge_id: z.string().min(1), + response: z.unknown(), + return_to: z.string().url().optional(), + name: z.string().min(1).max(80).optional(), +}) +export type WebauthnVerifyRequest = z.infer + +export const patchPasskeyRequestSchema = z.object({ + name: z.string().min(1).max(80), +}) +export type PatchPasskeyRequest = z.infer + export const adminSessionSchema = z.object({ id: z.string(), user_id: z.string(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a55a5c..61cca65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@node-rs/argon2': specifier: ^2.0.2 version: 2.0.2 + '@simplewebauthn/server': + specifier: ^13.3.2 + version: 13.3.2 fastify: specifier: ^5.4.0 version: 5.10.0 @@ -127,6 +130,9 @@ importers: '@hookform/resolvers': specifier: ^5.4.0 version: 5.4.0(react-hook-form@7.82.0(react@19.2.7)) + '@simplewebauthn/browser': + specifier: ^13.3.0 + version: 13.3.0 '@tailwindcss/vite': specifier: ^4.3.1 version: 4.3.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) @@ -1250,6 +1256,9 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@hookform/resolvers@5.4.0': resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} peerDependencies: @@ -1298,6 +1307,9 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@lukeed/ms@2.0.2': resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} @@ -1471,6 +1483,46 @@ packages: '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@peculiar/asn1-android@2.9.3': + resolution: {integrity: sha512-hiRJvr5ydif9fbTA7czZw1OfgzYDhu5gXNzhDfS3wSXzWYoSS/BHY+Wu2c36CNbn3mI6FoVLf0yHvQy0D4rZWw==} + + '@peculiar/asn1-cms@2.9.3': + resolution: {integrity: sha512-N3POfw5RA7efAliAATiudtmvKQqukVEOzrMQuqQY/us2EPKczMy2WiecLt1SX6s3b0OwcFaPUXGF6uIYlBUTbg==} + + '@peculiar/asn1-csr@2.9.3': + resolution: {integrity: sha512-E9zYmC5mk7eiDKqQAOsZGrJ7mUCIDC0031s4Nsl7dj1Za5EBKcHVqY+1vD/a4xbk480PGqvi455W2b4FeHRJ8Q==} + + '@peculiar/asn1-ecc@2.9.3': + resolution: {integrity: sha512-4xmeZiZ46VI2qGbiZPzdv6p9IhMtjWdbwZf3VCgN8OAVcued60ej5Ki2FF94srVH4Ot/h45Co7T5C/fpQXP4rA==} + + '@peculiar/asn1-pfx@2.9.3': + resolution: {integrity: sha512-Nzwoj+fRr1XB9CQuc4AanUuvQ3OIXAY+ngZIYP+eZUqd+Sonj+ZHTnrg+egQuUJ64/iMi9HFPN04MokGrFTK0w==} + + '@peculiar/asn1-pkcs8@2.9.3': + resolution: {integrity: sha512-ecGZpkY6Lq5bSgTFU+LS74WjawzDgjWHcjFMVNPH/1C0j53Xi9dmLcPMdmZyk8gdsd2RKeV1fP9EbLB6W6zZ1g==} + + '@peculiar/asn1-pkcs9@2.9.3': + resolution: {integrity: sha512-yjWVrQEmPp2y9lWjLLE28BRHbt7wYdwWvwXjFgNuekP9mDD/ofz31muVI8qOg2as7XZs1bZIZGPPnJ/0osTClQ==} + + '@peculiar/asn1-rsa@2.9.3': + resolution: {integrity: sha512-t7m3e9p/Gf9YoMM+hLsHUqB+NhOlifiOkENAIG4RV2BFWVGTATVzbXoR8L0EgNWpiriPaeIjBCS5B9PTtkaxQw==} + + '@peculiar/asn1-schema@2.9.3': + resolution: {integrity: sha512-SOux4+jikCnOwoJvpBp/grOqzFmJPnNSwe3sAg1Bn93YdmCiDtvolZifJIhiRq0UWMTnLRPj9/ZCMAc2W9VsMQ==} + + '@peculiar/asn1-x509-attr@2.9.3': + resolution: {integrity: sha512-v5Oa6p7hCT3ONqYHQyTFQYcycD06eMhHbr0m7evpvPQSpWJIq8GgbdnvA9bVGTUlOx6KOeDrX7Z0W4s/yboNTg==} + + '@peculiar/asn1-x509@2.9.3': + resolution: {integrity: sha512-HE+ejy9dX9JP3yLL6CGYoWym4Cted1lGXH7DxsbNDGiq8KabwVR7mzYidwsyNZ/m0hNWjiS+dzSwrsgMB/OIaQ==} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -1754,6 +1806,13 @@ packages: resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} engines: {node: '>=18'} + '@simplewebauthn/browser@13.3.0': + resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} + + '@simplewebauthn/server@13.3.2': + resolution: {integrity: sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==} + engines: {node: '>=20.0.0'} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -2258,6 +2317,10 @@ packages: asn1.js@5.4.1: resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -4094,6 +4157,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.2.0: + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} + engines: {node: '>=16.0.0'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4171,6 +4241,9 @@ packages: real-require@1.0.0: resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + registry-auth-token@5.1.1: resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==} engines: {node: '>=14'} @@ -4572,6 +4645,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -4599,6 +4675,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -5774,6 +5854,8 @@ snapshots: '@floating-ui/utils@0.2.12': {} + '@hexagon/base64@1.1.28': {} + '@hookform/resolvers@5.4.0(react-hook-form@7.82.0(react@19.2.7))': dependencies: '@standard-schema/utils': 0.3.0 @@ -5818,6 +5900,8 @@ snapshots: '@keyv/serialize@1.1.1': {} + '@levischuck/tiny-cbor@0.2.11': {} + '@lukeed/ms@2.0.2': {} '@markwylde/semantic-release-gitea@2.2.0': @@ -5990,6 +6074,106 @@ snapshots: '@oxc-project/types@0.139.0': {} + '@peculiar/asn1-android@2.9.3': + dependencies: + '@peculiar/asn1-schema': 2.9.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.9.3': + dependencies: + '@peculiar/asn1-schema': 2.9.3 + '@peculiar/asn1-x509': 2.9.3 + '@peculiar/asn1-x509-attr': 2.9.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.9.3': + dependencies: + '@peculiar/asn1-schema': 2.9.3 + '@peculiar/asn1-x509': 2.9.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.9.3': + dependencies: + '@peculiar/asn1-schema': 2.9.3 + '@peculiar/asn1-x509': 2.9.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.9.3': + dependencies: + '@peculiar/asn1-cms': 2.9.3 + '@peculiar/asn1-pkcs8': 2.9.3 + '@peculiar/asn1-rsa': 2.9.3 + '@peculiar/asn1-schema': 2.9.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.9.3': + dependencies: + '@peculiar/asn1-schema': 2.9.3 + '@peculiar/asn1-x509': 2.9.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.9.3': + dependencies: + '@peculiar/asn1-cms': 2.9.3 + '@peculiar/asn1-pfx': 2.9.3 + '@peculiar/asn1-pkcs8': 2.9.3 + '@peculiar/asn1-schema': 2.9.3 + '@peculiar/asn1-x509': 2.9.3 + '@peculiar/asn1-x509-attr': 2.9.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.9.3': + dependencies: + '@peculiar/asn1-schema': 2.9.3 + '@peculiar/asn1-x509': 2.9.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.9.3': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.9.3': + dependencies: + '@peculiar/asn1-schema': 2.9.3 + '@peculiar/asn1-x509': 2.9.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.9.3': + dependencies: + '@peculiar/asn1-schema': 2.9.3 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.9.3 + '@peculiar/asn1-csr': 2.9.3 + '@peculiar/asn1-ecc': 2.9.3 + '@peculiar/asn1-pkcs9': 2.9.3 + '@peculiar/asn1-rsa': 2.9.3 + '@peculiar/asn1-schema': 2.9.3 + '@peculiar/asn1-x509': 2.9.3 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@pinojs/redact@0.4.0': {} '@pnpm/config.env-replace@1.1.0': {} @@ -6229,6 +6413,19 @@ snapshots: '@simple-libs/stream-utils@1.2.0': {} + '@simplewebauthn/browser@13.3.0': {} + + '@simplewebauthn/server@13.3.2': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.9.3 + '@peculiar/asn1-ecc': 2.9.3 + '@peculiar/asn1-rsa': 2.9.3 + '@peculiar/asn1-schema': 2.9.3 + '@peculiar/asn1-x509': 2.9.3 + '@peculiar/x509': 1.14.3 + '@sindresorhus/is@4.6.0': {} '@sindresorhus/is@7.2.0': {} @@ -6786,6 +6983,12 @@ snapshots: minimalistic-assert: 1.0.1 safer-buffer: 2.1.2 + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + assertion-error@2.0.1: {} asynckit@0.4.0: {} @@ -8453,6 +8656,12 @@ snapshots: punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.2.0: {} + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -8537,6 +8746,8 @@ snapshots: real-require@1.0.0: {} + reflect-metadata@0.2.2: {} + registry-auth-token@5.1.1: dependencies: '@pnpm/npm-conf': 3.0.3 @@ -8955,6 +9166,8 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@1.14.1: {} + tslib@2.8.1: {} tsup@8.5.1(jiti@2.7.0)(postcss@8.5.19)(tsx@4.23.1)(typescript@5.9.3): @@ -8991,6 +9204,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1