/** * 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 { verifyRegistrationResponse } from "@simplewebauthn/server"; import { COOKIE_NAMES, COOKIE_OPTIONS, setCookie, TOKEN_DURATIONS, } from "../../utils/cookies.js"; import { generateExpiry, generateSecureToken } from "../../utils/crypto.js"; import { sendVerificationEmail } from "../../utils/email.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 const user = await db .insertInto("users") .values({ email, password_hash: passwordHash, }) .returning(["id"]) .executeTakeFirstOrThrow(); return user.id; } /** * 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", "=", String(challengeId)) .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", "=", String(challengeId)) .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", "=", String(challengeId)) .execute(); throw new ORPCError("BAD_REQUEST", { message: "Unable to verify your device.", }); } // Create user and passkey in a transaction const result = await db.transaction().execute(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", "=", String(challengeId)) .execute(); return { userId: newUserId }; }); return result.userId; } /** * 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); 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 { // Should never reach here due to schema validation throw new ORPCError("BAD_REQUEST", { message: "Either password or passkeyInfo is required", }); } // Create session (7 days, trusted mode false initially, no device) const session = await createSession(context.db, { userId, deviceId: null, trustedMode: false, geo, userAgent, }); // Set session cookie setCookie( context.resHeaders, COOKIE_NAMES.SESSION_TOKEN, session.token, COOKIE_OPTIONS.session, ); // Generate verification token const verificationToken = generateSecureToken(); const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION); // Store verification token (store raw token, not hash - it's already high-entropy) await context.db .insertInto("email_verifications") .values({ user_id: userId, token: verificationToken, expires_at: expiresAt, }) .execute(); // Send verification email (stubbed) await sendVerificationEmail(email, verificationToken); });