From b46146faa5cdd92df7c32cecf026f96d65ffe433 Mon Sep 17 00:00:00 2001 From: RevIQ Date: Fri, 9 Jan 2026 12:34:26 +0800 Subject: [PATCH] Implement WebAuthn passkey authentication Add complete WebAuthn support for passkey registration and authentication: - Install @simplewebauthn/server for WebAuthn utilities - Create passkey-helpers.ts with base64url/Uint8Array conversion utilities - Create webauthn.ts with registration/authentication option generation and verification - Create context.ts with API context types - Implement all WebAuthn router handlers (createRegistrationOptions, verifyRegistration, createAuthenticationOptions, verifyAuthentication) - Implement passkey management handlers (listPasskeys, createPasskey, renamePasskey, deletePasskey) - Add WebAuthn configuration constants and environment variables Co-Authored-By: Claude Opus 4.5 --- apps/api-server/package.json | 6 +- apps/api-server/src/constants.ts | 27 +- apps/api-server/src/context.ts | 60 ++++ apps/api-server/src/index.ts | 25 +- apps/api-server/src/router.ts | 144 ++++++-- apps/api-server/src/utils/passkey-helpers.ts | 93 ++++++ apps/api-server/src/utils/webauthn.ts | 332 +++++++++++++++++++ bun.lock | 46 +++ 8 files changed, 709 insertions(+), 24 deletions(-) create mode 100644 apps/api-server/src/context.ts create mode 100644 apps/api-server/src/utils/passkey-helpers.ts create mode 100644 apps/api-server/src/utils/webauthn.ts diff --git a/apps/api-server/package.json b/apps/api-server/package.json index c4960e6..7326ef2 100644 --- a/apps/api-server/package.json +++ b/apps/api-server/package.json @@ -13,7 +13,11 @@ "dependencies": { "@orpc/server": "^1.13.2", "@reviq/api-contract": "workspace:*", - "@reviq/db": "workspace:*" + "@reviq/db": "workspace:*", + "@reviq/db-schema": "workspace:*", + "@simplewebauthn/server": "^13.2.2", + "@simplewebauthn/types": "^12.0.0", + "kysely": "^0.28.2" }, "devDependencies": { "@macalinao/eslint-config": "catalog:", diff --git a/apps/api-server/src/constants.ts b/apps/api-server/src/constants.ts index c91f8a0..8db58e4 100644 --- a/apps/api-server/src/constants.ts +++ b/apps/api-server/src/constants.ts @@ -1,4 +1,29 @@ /** - * Default port for the API server + * API Server constants */ + +/** Default port for the API server */ export const DEFAULT_PORT = 9861; + +/** Default Relying Party name for WebAuthn */ +export const DEFAULT_RP_NAME = "Reviq Publisher Dashboard"; + +/** WebAuthn challenge expiry in milliseconds (5 minutes) */ +export const WEBAUTHN_CHALLENGE_EXPIRY_MS = 5 * 60 * 1000; + +/** + * Get allowed WebAuthn origins from environment or defaults + */ +export const getAllowedOrigins = (): string[] => { + const envOrigins = Bun.env.ALLOWED_WEBAUTHN_ORIGINS; + if (envOrigins) { + return envOrigins.split(",").map((o) => o.trim()); + } + + // Default to localhost origins for development + return [ + `http://localhost:${String(DEFAULT_PORT)}`, + "http://localhost:6827", + "http://localhost:6828", + ]; +}; diff --git a/apps/api-server/src/context.ts b/apps/api-server/src/context.ts new file mode 100644 index 0000000..3018ee2 --- /dev/null +++ b/apps/api-server/src/context.ts @@ -0,0 +1,60 @@ +/** + * API context types for oRPC handlers + */ + +import type { Database } from "@reviq/db-schema"; +import type { Kysely } from "kysely"; + +/** + * Base API context available to all handlers + */ +export interface APIContext { + /** Database client */ + db: Kysely; + /** Request origin (e.g., "http://localhost:6827") */ + origin: string; + /** Allowed WebAuthn origins */ + allowedOrigins: string[]; + /** Relying party name for WebAuthn */ + rpName: string; +} + +/** + * User information from the session + */ +export interface SessionUser { + id: number; + email: string; + displayName: string | null; + emailVerifiedAt: Date | null; + isSuperuser: boolean; +} + +/** + * Session information + */ +export interface Session { + id: number; + trustedMode: boolean; + createdAt: Date; +} + +/** + * Authenticated API context for protected handlers + */ +export interface AuthenticatedContext extends APIContext { + /** Current user from session */ + user: SessionUser; + /** Current session */ + session: Session; +} + +/** + * Login request context (used during login flow) + */ +export interface LoginRequestContext extends APIContext { + /** Login request ID from cookie */ + loginRequestId: number; + /** User associated with the login request */ + user: SessionUser; +} diff --git a/apps/api-server/src/index.ts b/apps/api-server/src/index.ts index d2ca2bd..0944a8f 100644 --- a/apps/api-server/src/index.ts +++ b/apps/api-server/src/index.ts @@ -1,10 +1,19 @@ +import type { APIContext } from "./context.js"; import { RPCHandler } from "@orpc/server/fetch"; -import { DEFAULT_PORT } from "./constants.js"; +import { createDb } from "@reviq/db"; +import { + DEFAULT_PORT, + DEFAULT_RP_NAME, + getAllowedOrigins, +} from "./constants.js"; import { router } from "./router.js"; +const db = createDb(); const handler = new RPCHandler(router); -const port = import.meta.env.PORT ?? DEFAULT_PORT; +const port = Bun.env.PORT ?? DEFAULT_PORT; +const allowedOrigins = getAllowedOrigins(); +const rpName = Bun.env.RP_NAME ?? DEFAULT_RP_NAME; Bun.serve({ port, @@ -12,8 +21,20 @@ Bun.serve({ const url = new URL(request.url); if (url.pathname.startsWith("/api/v1/rpc")) { + // Build context for the request + const origin = + request.headers.get("origin") ?? `http://localhost:${String(port)}`; + + const context: APIContext = { + db, + origin, + allowedOrigins, + rpName, + }; + const { response } = await handler.handle(request, { prefix: "/api/v1/rpc", + context, }); return response ?? new Response("Not Found", { status: 404 }); } diff --git a/apps/api-server/src/router.ts b/apps/api-server/src/router.ts index c608226..e76c93a 100644 --- a/apps/api-server/src/router.ts +++ b/apps/api-server/src/router.ts @@ -1,5 +1,18 @@ +import type { + APIContext, + AuthenticatedContext, + LoginRequestContext, +} from "./context.js"; import { implement } from "@orpc/server"; import { contract } from "@reviq/api-contract"; +import { + createAuthenticationOptions as createAuthOptions, + createRegistrationOptions as createRegOptions, + getRPInfo, + getUserPasskeys, + verifyAuthentication as verifyAuth, + verifyRegistration as verifyReg, +} from "./utils/webauthn.js"; const os = implement(contract); @@ -50,24 +63,56 @@ const logout = os.auth.logout.handler(async () => { // WebAuthn procedures const createRegistrationOptions = - os.auth.webauthn.createRegistrationOptions.handler(async () => { - throw new Error("Not implemented"); - }); + os.auth.webauthn.createRegistrationOptions.handler( + async ({ input, context }) => { + const ctx = context as APIContext; + const { email } = input; + + // For signup flow, we don't have a user yet + // The user will be created when signup is called with the passkeyInfo + const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName); + + const result = await createRegOptions(ctx.db, rpInfo, { email }); + return result; + }, + ); const verifyRegistration = os.auth.webauthn.verifyRegistration.handler( - async () => { - throw new Error("Not implemented"); + async ({ input, context }) => { + const ctx = context as AuthenticatedContext; + const { challengeId, response } = input; + + const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName); + await verifyReg(ctx.db, rpInfo, ctx.user.id, challengeId, response); }, ); const createAuthenticationOptions = - os.auth.webauthn.createAuthenticationOptions.handler(async () => { - throw new Error("Not implemented"); + os.auth.webauthn.createAuthenticationOptions.handler(async ({ context }) => { + const ctx = context as LoginRequestContext; + + const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName); + const result = await createAuthOptions(ctx.db, rpInfo, ctx.user.id); + return result; }); const verifyAuthentication = os.auth.webauthn.verifyAuthentication.handler( - async () => { - throw new Error("Not implemented"); + async ({ input, context }) => { + const ctx = context as LoginRequestContext; + const { challengeId, response } = input; + + const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName); + const verified = await verifyAuth( + ctx.db, + rpInfo, + ctx.user.id, + challengeId, + response, + ); + + if (!verified) { + throw new Error("Authentication failed"); + } }, ); @@ -92,21 +137,80 @@ const setPassword = os.me.setPassword.handler(async () => { throw new Error("Not implemented"); }); -const listPasskeys = os.me.listPasskeys.handler(async () => { - throw new Error("Not implemented"); +const listPasskeys = os.me.listPasskeys.handler(async ({ context }) => { + const ctx = context as AuthenticatedContext; + + const passkeys = await getUserPasskeys(ctx.db, ctx.user.id); + + return passkeys.map((p) => ({ + id: p.id, + name: p.name, + createdAt: p.createdAt, + lastUsedAt: p.lastUsedAt, + })); }); -const createPasskey = os.me.createPasskey.handler(async () => { - throw new Error("Not implemented"); -}); +const createPasskey = os.me.createPasskey.handler( + async ({ input, context }) => { + const ctx = context as AuthenticatedContext; + const { name: _name } = input; -const renamePasskey = os.me.renamePasskey.handler(async () => { - throw new Error("Not implemented"); -}); + const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName); + const result = await createRegOptions(ctx.db, rpInfo, { + id: ctx.user.id, + email: ctx.user.email, + displayName: ctx.user.displayName, + }); -const deletePasskey = os.me.deletePasskey.handler(async () => { - throw new Error("Not implemented"); -}); + return result; + }, +); + +const renamePasskey = os.me.renamePasskey.handler( + async ({ input, context }) => { + const ctx = context as AuthenticatedContext; + const { passkeyId, name } = input; + + await ctx.db + .updateTable("passkeys") + .set({ name }) + .where("id", "=", String(passkeyId)) + .where("user_id", "=", ctx.user.id) + .execute(); + }, +); + +const deletePasskey = os.me.deletePasskey.handler( + async ({ input, context }) => { + const ctx = context as AuthenticatedContext; + const { passkeyId } = input; + + // Check if this is the last passkey and user has no password + const user = await ctx.db + .selectFrom("users") + .select(["password_hash"]) + .where("id", "=", ctx.user.id) + .executeTakeFirst(); + + const passkeyCount = await ctx.db + .selectFrom("passkeys") + .select(ctx.db.fn.countAll().as("count")) + .where("user_id", "=", ctx.user.id) + .executeTakeFirst(); + + if (!user?.password_hash && Number(passkeyCount?.count ?? 0) <= 1) { + throw new Error( + "Cannot delete the last passkey when you have no password set", + ); + } + + await ctx.db + .deleteFrom("passkeys") + .where("id", "=", String(passkeyId)) + .where("user_id", "=", ctx.user.id) + .execute(); + }, +); const listSessions = os.me.listSessions.handler(async () => { throw new Error("Not implemented"); diff --git a/apps/api-server/src/utils/passkey-helpers.ts b/apps/api-server/src/utils/passkey-helpers.ts new file mode 100644 index 0000000..b8083b5 --- /dev/null +++ b/apps/api-server/src/utils/passkey-helpers.ts @@ -0,0 +1,93 @@ +/** + * Passkey data helpers for converting between database and WebAuthn formats + */ + +import type { AuthenticatorTransportFuture } from "@simplewebauthn/types"; + +/** + * Convert a base64url string to a Uint8Array for BYTEA storage + */ +export const base64urlToUint8Array = (base64url: string): Uint8Array => { + return Uint8Array.from(Buffer.from(base64url, "base64url")); +}; + +/** + * Convert a Uint8Array (from BYTEA) to a base64url string + */ +export const uint8ArrayToBase64url = (uint8Array: Uint8Array): string => { + return Buffer.from(uint8Array).toString("base64url"); +}; + +/** + * Parsed passkey data for use with simplewebauthn + */ +export interface ParsedPasskey { + id: number; + credentialId: string; + publicKey: Uint8Array; + counter: number; + transports: AuthenticatorTransportFuture[] | null; + deviceType: "singleDevice" | "multiDevice"; + backupEligible: boolean; + backupStatus: boolean; + rpid: string; + name: string; + lastUsedAt: Date | null; + createdAt: Date; +} + +/** + * Raw passkey row from database + */ +export interface PasskeyRow { + id: number; + user_id: number; + credential_id: Uint8Array; + public_key: Uint8Array; + webauthn_user_id: string; + counter: string | number | bigint; + device_type: "singleDevice" | "multiDevice"; + backup_eligible: boolean; + backup_status: boolean; + transports: unknown; + rpid: string; + name: string; + last_used_at: Date | null; + created_at: Date; +} + +/** + * Parse a passkey row from the database into a usable format + */ +export const parsePasskeyRow = (row: PasskeyRow): ParsedPasskey => { + // Create a new Uint8Array to ensure proper ArrayBuffer type + const publicKeyBytes = new Uint8Array(row.public_key); + + return { + id: row.id, + credentialId: uint8ArrayToBase64url(row.credential_id), + publicKey: publicKeyBytes, + counter: Number(row.counter), + transports: row.transports as AuthenticatorTransportFuture[] | null, + deviceType: row.device_type, + backupEligible: row.backup_eligible, + backupStatus: row.backup_status, + rpid: row.rpid, + name: row.name, + lastUsedAt: row.last_used_at, + createdAt: row.created_at, + }; +}; + +/** + * Format a date for passkey name + */ +export const formatPasskeyDate = (date: Date): string => { + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +}; diff --git a/apps/api-server/src/utils/webauthn.ts b/apps/api-server/src/utils/webauthn.ts new file mode 100644 index 0000000..1731fbe --- /dev/null +++ b/apps/api-server/src/utils/webauthn.ts @@ -0,0 +1,332 @@ +/** + * WebAuthn utility functions for passkey registration and authentication + */ + +import type { Database } from "@reviq/db-schema"; +import type { + AuthenticationResponseJSON, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, +} from "@simplewebauthn/types"; +import type { Kysely } from "kysely"; +import type { ParsedPasskey, PasskeyRow } from "./passkey-helpers.js"; +import type { VerifiedRegistrationResponse } from "@simplewebauthn/server"; +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, +} from "@simplewebauthn/server"; +import { formatPasskeyDate, parsePasskeyRow } from "./passkey-helpers.js"; + +/** + * Known authenticator AAGUIDs mapped to friendly names + */ +const KNOWN_AAGUIDS: Record = { + "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4": "Google Password Manager", + "adce0002-35bc-c60a-648b-0b25f1f05503": "Chrome on Mac", + "08987058-cadc-4b81-b6e1-30de50dcbe96": "Windows Hello", + "9ddd1817-af5a-4672-a2b9-3e3dd95000a9": "Windows Hello", + "6028b017-b1d4-4c02-b4b3-afcdafc96bb2": "Windows Hello", + "dd4ec289-e01d-41c9-bb89-70fa845d4bf2": "iCloud Keychain (Managed)", + "531126d6-e717-415c-9320-3d9aa6981239": "Dashlane", + "bada5566-a7aa-401f-bd96-45619a55120d": "1Password", + "b84e4048-15dc-4dd0-8640-f4f60813c8af": "NordPass", + "0ea242b4-43c4-4a1b-8b17-dd6d0b6baec6": "Keeper", + "891494da-2c90-4d31-a9cd-4eab0aed1309": "Sésame", + "f3809540-7f14-49c1-a8b3-8f813b225541": "Enpass", + "b5397666-4885-aa6b-cebf-e52262a439a2": "Chromium Browser", + "771b48fd-d3d4-4f74-9232-fc157ab0507a": "Edge on Mac", + "39a5647e-1853-446c-a1f6-a79bae9f5bc7": "IDmelon", + "d548826e-79b4-db40-a3d8-11116f7e8349": "Bitwarden", + "fbfc3007-154e-4ecc-8c0b-6e020557d7bd": "iCloud Keychain", + "53414d53-554e-4700-0000-000000000000": "Samsung Pass", + "66a0ccb3-bd6a-191f-ee06-e375c50b9846": "Thales Bio iOS SDK", + "8836336a-f590-0921-301d-46427531eee6": "Thales Bio Android SDK", + "cd69adb5-3c7a-deb9-3177-6800ea6cb72a": "Thales PIN Android SDK", + "17290f1e-c212-34d0-1423-365d729f09d9": "Thales PIN iOS SDK", + "50726f74-6f6e-5061-7373-50726f746f6e": "Proton Pass", + "fdb141b2-5d84-443e-8a35-4698c205a502": "KeePassXC", + "cc45f64e-52a2-451b-831a-4edd8022a202": "ToothPic Passkey Provider", + "bfc748bb-3429-4faa-b9f9-7cfa9f3b76d0": "iPasswords", + "b35a26b2-8f6e-4697-ab1d-d44db4da28c6": "Zoho Vault", + "b78a0a55-6ef8-d246-a042-ba0f6d55050c": "LastPass", + "de503f9c-21a4-4f76-b4b7-558eb55c6f89": "Devolutions", +}; + +/** + * Relying Party information for WebAuthn + */ +export interface RPInfo { + rpName: string; + rpID: string; + origins: string[]; +} + +/** + * Get Relying Party information from the origin + */ +export const getRPInfo = ( + origin: string, + allowedOrigins: string[], + rpName: string, +): RPInfo => { + // Extract RP ID (domain only, no port) + const rpID = new URL(origin).hostname; + + // Parse allowed origins to their origin format + const origins = allowedOrigins.map((o) => new URL(o).origin); + + return { + rpName, + rpID, + origins, + }; +}; + +/** + * Get all passkeys for a user + */ +export const getUserPasskeys = async ( + db: Kysely, + userId: number, +): Promise => { + const rows = await db + .selectFrom("passkeys") + .selectAll() + .where("user_id", "=", userId) + .execute(); + + return rows.map((row) => parsePasskeyRow(row as unknown as PasskeyRow)); +}; + +/** + * Create registration options for a new passkey + */ +export const createRegistrationOptions = async ( + db: Kysely, + rpInfo: RPInfo, + user: { id?: number; email: string; displayName?: string | null }, +): Promise<{ + options: PublicKeyCredentialCreationOptionsJSON; + challengeId: number; +}> => { + // Get existing passkeys to exclude (if user exists) + const existingPasskeys = user.id ? await getUserPasskeys(db, user.id) : []; + + const options = await generateRegistrationOptions({ + rpName: rpInfo.rpName, + rpID: rpInfo.rpID, + userName: user.displayName ?? user.email, + // Don't prompt users for additional information about the authenticator + attestationType: "direct", + // Prevent users from re-registering existing authenticators + excludeCredentials: existingPasskeys.map((passkey) => ({ + id: passkey.credentialId, + transports: passkey.transports ?? undefined, + })), + authenticatorSelection: { + residentKey: "preferred", + userVerification: "preferred", + authenticatorAttachment: "platform", + }, + }); + + // Store challenge in database + const { id: challengeId } = await db + .insertInto("webauthn_challenges") + .values({ + options: JSON.stringify(options), + }) + .returning("id") + .executeTakeFirstOrThrow(); + + return { + options, + challengeId: Number(challengeId), + }; +}; + +/** + * Verify a registration response and store the passkey + */ +export const verifyRegistration = async ( + db: Kysely, + rpInfo: RPInfo, + userId: number, + challengeId: number, + response: RegistrationResponseJSON, +): Promise => { + // Fetch the challenge + const challengeRow = await db + .selectFrom("webauthn_challenges") + .select("options") + .where("id", "=", String(challengeId)) + .executeTakeFirst(); + + if (!challengeRow) { + throw new Error("Registration timed out. Please try again."); + } + + const options = + challengeRow.options as unknown as PublicKeyCredentialCreationOptionsJSON; + + let verification: VerifiedRegistrationResponse; + try { + verification = await verifyRegistrationResponse({ + response, + expectedChallenge: options.challenge, + expectedOrigin: rpInfo.origins, + expectedRPID: rpInfo.rpID, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + throw new Error( + `Invalid registration response. Please try again. ${message}`, + ); + } finally { + // Always delete the challenge + await db + .deleteFrom("webauthn_challenges") + .where("id", "=", String(challengeId)) + .execute(); + } + + const { verified, registrationInfo } = verification; + if (!verified) { + throw new Error("Unable to verify your device."); + } + + const { credential, credentialDeviceType, credentialBackedUp } = + registrationInfo; + + // Get friendly name from AAGUID + const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid]; + const passKeyName = + guidName ?? `Key registered at ${formatPasskeyDate(new Date())}`; + + // Store the passkey + await db + .insertInto("passkeys") + .values({ + user_id: userId, + credential_id: Buffer.from(credential.id, "base64url"), + public_key: Buffer.from(credential.publicKey), + webauthn_user_id: options.user.id, + counter: BigInt(credential.counter), + device_type: credentialDeviceType as "singleDevice" | "multiDevice", + backup_eligible: registrationInfo.credentialBackedUp, + backup_status: credentialBackedUp, + transports: JSON.stringify(response.response.transports ?? []), + rpid: rpInfo.rpID, + name: passKeyName, + }) + .execute(); +}; + +/** + * Create authentication options for passkey login + */ +export const createAuthenticationOptions = async ( + db: Kysely, + rpInfo: RPInfo, + userId: number, +): Promise<{ + options: PublicKeyCredentialRequestOptionsJSON; + challengeId: number; +}> => { + const userPasskeys = await getUserPasskeys(db, userId); + + const options = await generateAuthenticationOptions({ + rpID: rpInfo.rpID, + allowCredentials: userPasskeys.map((passkey) => ({ + id: passkey.credentialId, + transports: passkey.transports ?? undefined, + })), + }); + + // Store challenge in database + const { id: challengeId } = await db + .insertInto("webauthn_challenges") + .values({ + options: JSON.stringify(options), + }) + .returning("id") + .executeTakeFirstOrThrow(); + + return { + options, + challengeId: Number(challengeId), + }; +}; + +/** + * Verify an authentication response + */ +export const verifyAuthentication = async ( + db: Kysely, + rpInfo: RPInfo, + userId: number, + challengeId: number, + response: AuthenticationResponseJSON, +): Promise => { + // Fetch the challenge + const challengeRow = await db + .selectFrom("webauthn_challenges") + .select("options") + .where("id", "=", String(challengeId)) + .executeTakeFirst(); + + if (!challengeRow) { + throw new Error("Authentication timed out. Please try again."); + } + + const options = + challengeRow.options as unknown as PublicKeyCredentialRequestOptionsJSON; + + try { + const userPasskeys = await getUserPasskeys(db, userId); + const passkey = userPasskeys.find((p) => p.credentialId === response.id); + + if (!passkey) { + throw new Error("Unknown passkey."); + } + + const verification = await verifyAuthenticationResponse({ + response, + expectedChallenge: options.challenge, + expectedOrigin: rpInfo.origins, + expectedRPID: rpInfo.rpID, + credential: { + id: passkey.credentialId, + // Cast to expected type - the Uint8Array is compatible + publicKey: passkey.publicKey as Uint8Array, + counter: passkey.counter, + transports: passkey.transports ?? undefined, + }, + }); + + if (!verification.verified) { + return false; + } + + // Update passkey counter and last_used_at + await db + .updateTable("passkeys") + .set({ + counter: verification.authenticationInfo.newCounter.toString(), + last_used_at: new Date(), + }) + .where("id", "=", String(passkey.id)) + .execute(); + + return true; + } finally { + // Always delete the challenge + await db + .deleteFrom("webauthn_challenges") + .where("id", "=", String(challengeId)) + .execute(); + } +}; diff --git a/bun.lock b/bun.lock index 8bb0b1d..697f209 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,10 @@ "@orpc/server": "^1.13.2", "@reviq/api-contract": "workspace:*", "@reviq/db": "workspace:*", + "@reviq/db-schema": "workspace:*", + "@simplewebauthn/server": "^13.2.2", + "@simplewebauthn/types": "^12.0.0", + "kysely": "^0.28.2", }, "devDependencies": { "@macalinao/eslint-config": "catalog:", @@ -240,6 +244,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -260,6 +266,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], + "@lucide/svelte": ["@lucide/svelte@0.562.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-wDMULwtTFN2Sc/TFBm6gfuVCNb4Y5P9LDrwxNnUbV52+IEU7NXZmvxwXoz+vrrpad6Xupq+Hw5eUlqIHEGhouw=="], "@macalinao/biome-config": ["@macalinao/biome-config@0.1.7", "", { "peerDependencies": { "@biomejs/biome": "^2.3.10" } }, "sha512-JijaB/REJr6D3fGV36d1XGsf2WFofgnMS1WbOYcNJCQpic2XmFALV7GNL28z7rDCN3/DeSovPuW/1yImce7kPA=="], @@ -292,6 +300,30 @@ "@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.13.2", "", { "dependencies": { "@orpc/shared": "1.13.2", "@orpc/standard-server": "1.13.2" } }, "sha512-LMxaMSmp98jnm0jiHpqPgH5qHXeOvah7pmysrcLaqMfwH+GwRS6S+/ls2hAy9aIba4LrTZyIpTgSohHD/Ed73g=="], + "@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="], + + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "@peculiar/asn1-x509-attr": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA=="], + + "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ=="], + + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw=="], + + "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.6.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-pkcs8": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ=="], + + "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA=="], + + "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.6.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-pfx": "^2.6.0", "@peculiar/asn1-pkcs8": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "@peculiar/asn1-x509-attr": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw=="], + + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w=="], + + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.6.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="], + + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA=="], + + "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA=="], + + "@peculiar/x509": ["@peculiar/x509@1.14.2", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@reviq/api-contract": ["@reviq/api-contract@workspace:packages/api-contract"], @@ -352,6 +384,8 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw=="], + "@simplewebauthn/server": ["@simplewebauthn/server@13.2.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8", "@peculiar/x509": "^1.13.0" } }, "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA=="], + "@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -454,6 +488,8 @@ "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -744,12 +780,18 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], + "radash": ["radash@12.1.1", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "rechoir": ["rechoir@0.6.2", "", { "dependencies": { "resolve": "^1.1.6" } }, "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw=="], + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -814,6 +856,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], + "turbo": ["turbo@2.7.3", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.3", "turbo-darwin-arm64": "2.7.3", "turbo-linux-64": "2.7.3", "turbo-linux-arm64": "2.7.3", "turbo-windows-64": "2.7.3", "turbo-windows-arm64": "2.7.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+HjKlP4OfYk+qzvWNETA3cUO5UuK6b5MSc2UJOKyvBceKucQoQGb2g7HlC2H1GHdkfKrk4YF1VPvROkhVZDDLQ=="], "turbo-darwin-64": ["turbo-darwin-64@2.7.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-aZHhvRiRHXbJw1EcEAq4aws1hsVVUZ9DPuSFaq9VVFAKCup7niIEwc22glxb7240yYEr1vLafdQ2U294Vcwz+w=="], @@ -890,6 +934,8 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "git-diff/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],