/** * Signup procedure - creates a new user account with email + password or passkey */ import type { DB } from "@reviq/db-schema"; import type { PublicKeyCredentialCreationOptionsJSON, RegistrationResponseJSON, } from "@simplewebauthn/types"; import type { Kysely } from "kysely"; import type { RPInfo } from "../../utils/webauthn.js"; import { ORPCError } from "@orpc/server"; import { withTransaction } from "@reviq/db"; import { sendVerificationEmail } from "@reviq/emails"; import { verifyRegistrationResponse } from "@simplewebauthn/server"; import { COOKIE_NAMES, COOKIE_OPTIONS, setCookie, TOKEN_DURATIONS, } from "../../utils/cookies.js"; import { generateExpiry, generateSecureBase58Token, } from "../../utils/crypto.js"; import { getGeoInfo, getUserAgent } from "../../utils/geo.js"; import { hashPassword, validatePassword } from "../../utils/password.js"; import { createSession } from "../../utils/session.js"; import { getRPInfo, KNOWN_AAGUIDS } from "../../utils/webauthn.js"; import { os } from "../base.js"; /** * Create user with password authentication * Validates password strength and creates user record * * @param db - Database connection * @param email - Normalized email address * @param password - Plain text password to hash * @returns User ID of created user * @throws ORPCError if password is too weak */ export async function signupWithPassword( db: Kysely, email: string, password: string, ): Promise { // Validate password strength const validation = validatePassword(password, [email]); if (!validation.valid) { throw new ORPCError("BAD_REQUEST", { message: validation.feedback[0] }); } // Hash password const passwordHash = await hashPassword(password); // Create user (handle race condition if concurrent signup with same email) try { const user = await db .insertInto("users") .values({ email, password_hash: passwordHash, }) .returning(["id"]) .executeTakeFirstOrThrow(); return user.id; } catch (error) { // Handle duplicate email (unique constraint violation) // Use generic error to prevent email enumeration if (error instanceof Error && error.message.includes("users_email_key")) { throw new ORPCError("BAD_REQUEST", { message: "Unable to create account", }); } throw error; } } /** * Passkey info input shape - matches contract.auth.signup input */ interface PasskeySignupInfo { challengeId: number; response: RegistrationResponseJSON; } /** * Create user with passkey authentication * Verifies WebAuthn registration and creates user + passkey records * * @param db - Database connection * @param email - Normalized email address * @param passkeyInfo - WebAuthn registration response * @param rpInfo - Relying Party info for verification * @returns User ID of created user * @throws ORPCError if verification fails or challenge expired */ export async function signupWithPasskey( db: Kysely, email: string, passkeyInfo: PasskeySignupInfo, rpInfo: RPInfo, ): Promise { const { challengeId, response } = passkeyInfo; // Fetch the challenge (with expiry check - challenges expire after 15 minutes) const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000); const challengeRow = await db .selectFrom("webauthn_challenges") .select("options") .where("id", "=", challengeId.toString()) .where("created_at", ">", fifteenMinutesAgo) .executeTakeFirst(); if (!challengeRow) { throw new ORPCError("BAD_REQUEST", { message: "Registration timed out. Please try again.", }); } const options = challengeRow.options as unknown as PublicKeyCredentialCreationOptionsJSON; // Verify the registration response let verification: Awaited>; try { verification = await verifyRegistrationResponse({ response, expectedChallenge: options.challenge, expectedOrigin: rpInfo.origins, expectedRPID: rpInfo.rpID, }); } catch (error) { // Delete the challenge await db .deleteFrom("webauthn_challenges") .where("id", "=", challengeId.toString()) .execute(); // Log error for debugging but don't expose to client console.error("WebAuthn registration error:", error); throw new ORPCError("BAD_REQUEST", { message: "Failed to register your device. Please try again.", }); } const { verified, registrationInfo } = verification; if (!verified) { // Delete the challenge await db .deleteFrom("webauthn_challenges") .where("id", "=", challengeId.toString()) .execute(); throw new ORPCError("BAD_REQUEST", { message: "Unable to verify your device.", }); } // Create user and passkey in a transaction (handle race condition if concurrent signup) try { const result = await withTransaction(db, async (trx) => { // Create user const user = await trx .insertInto("users") .values({ email, password_hash: null, }) .returning(["id"]) .executeTakeFirstOrThrow(); const newUserId = user.id; // Get friendly name from AAGUID const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid]; const passkeyName = guidName ?? "Default"; // Store the passkey const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo; await trx .insertInto("passkeys") .values({ user_id: newUserId, 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(); // Delete the challenge await trx .deleteFrom("webauthn_challenges") .where("id", "=", challengeId.toString()) .execute(); return { userId: newUserId }; }); return result.userId; } catch (error) { // Handle duplicate email (unique constraint violation) // Use generic error to prevent email enumeration if (error instanceof Error && error.message.includes("users_email_key")) { throw new ORPCError("BAD_REQUEST", { message: "Unable to create account", }); } throw error; } } /** * Signup handler * - Accepts email + (password OR passkeyInfo) * - Normalizes email to lowercase * - Checks if email already exists (returns generic error for anti-enumeration) * - Delegates to signupWithPassword or signupWithPasskey * - Creates session immediately (7 days) * - Sets rev.session_token cookie * - Sends verification email (stubbed) */ export const signup = os.auth.signup.handler(async ({ input, context }) => { const { email: rawEmail, password, passkeyInfo } = input; // Normalize email to lowercase const email = rawEmail.toLowerCase(); // Check if email already exists (anti-enumeration: return generic error) const existingUser = await context.db .selectFrom("users") .select(["id"]) .where("email", "=", email) .executeTakeFirst(); if (existingUser) { throw new ORPCError("BAD_REQUEST", { message: "Unable to create account" }); } // Get geo info and user agent for session creation const geo = getGeoInfo(context.reqHeaders, context.clientIP); const userAgent = getUserAgent(context.reqHeaders); let userId: number; // Delegate to appropriate signup function if (password) { userId = await signupWithPassword(context.db, email, password); } else if (passkeyInfo) { const rpInfo = getRPInfo( context.origin, context.allowedOrigins, context.rpName, ); userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo); } else { // Unreachable - schema validation requires password or passkeyInfo throw new ORPCError("BAD_REQUEST", { message: "Either password or passkeyInfo is required", }); } // Generate verification token const verificationToken = generateSecureBase58Token(); const verificationExpiresAt = generateExpiry( TOKEN_DURATIONS.EMAIL_VERIFICATION, ); // Create session and email verification in transaction const session = await withTransaction(context.db, async (trx) => { // Create session (7 days, trusted mode false initially, no device) const newSession = await createSession(trx, { userId, deviceId: null, trustedMode: false, geo, userAgent, }); // Store verification token (store raw token, not hash - it's already high-entropy) await trx .insertInto("email_verifications") .values({ user_id: userId, token: verificationToken, expires_at: verificationExpiresAt, }) .execute(); return newSession; }); // Set session cookie setCookie( context.resHeaders, COOKIE_NAMES.SESSION_TOKEN, session.token, COOKIE_OPTIONS.session, ); // Send verification email await sendVerificationEmail({ client: context.email.client, fromAddress: context.email.fromAddress, baseUrl: context.email.baseUrl, email, token: verificationToken, expiryHours: 24, }); return { success: true }; });