- Add withTransaction helper that gracefully handles nested transactions (reuses existing transaction in tests, starts new one otherwise) - Update auth procedures to use withTransaction instead of direct .transaction() - Add email config to all e2e test contexts (required by merged code) - Remove duplicate verification token code from signup procedure Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
323 lines
9.4 KiB
TypeScript
323 lines
9.4 KiB
TypeScript
/**
|
|
* 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<DB>,
|
|
email: string,
|
|
password: string,
|
|
): Promise<number> {
|
|
// 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<DB>,
|
|
email: string,
|
|
passkeyInfo: PasskeySignupInfo,
|
|
rpInfo: RPInfo,
|
|
): Promise<number> {
|
|
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<ReturnType<typeof verifyRegistrationResponse>>;
|
|
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 };
|
|
});
|