Implement auth procedures with code review fixes
Add complete auth backend (Workstream D): - Auth middleware for session/API key authentication - Signup with password or passkey (WebAuthn) - Login flow with device trust and email confirmation - Password reset and email verification - Session management and logout Utilities created: - cookies.ts: Cookie helpers and configuration - crypto.ts: Token generation and hashing - password.ts: zxcvbn validation, argon2id hashing - geo.ts: IP/location extraction from headers - email.ts: Stubbed email sending - session.ts: Session creation and device trust Code review improvements applied: - Use ORPCError instead of Error in procedures - Add ast-grep rule to enforce ORPCError usage - Remove error info leakage (generic messages) - Optimize N+1 query with JOIN in login-password - Extract signupWithPassword/signupWithPasskey for testability - Add 15-minute WebAuthn challenge expiry check - Strengthen CookieOptions type definitions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
280
apps/api-server/src/procedures/auth/signup.ts
Normal file
280
apps/api-server/src/procedures/auth/signup.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Signup procedure - creates a new user account with email + password or passkey
|
||||
*/
|
||||
|
||||
import type {
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from "@simplewebauthn/types";
|
||||
import type { Kysely } from "kysely";
|
||||
import type { DB } from "@reviq/db-schema";
|
||||
import type { APIContext } from "../../context.js";
|
||||
import type { RPInfo } from "../../utils/webauthn.js";
|
||||
import { implement, ORPCError } from "@orpc/server";
|
||||
import { contract } from "@reviq/api-contract";
|
||||
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";
|
||||
|
||||
const os = implement(contract);
|
||||
|
||||
/**
|
||||
* 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
|
||||
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<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", "=", 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<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", "=", 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 ctx = context as APIContext;
|
||||
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 ctx.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(ctx.reqHeaders);
|
||||
const userAgent = getUserAgent(ctx.reqHeaders);
|
||||
|
||||
let userId: number;
|
||||
|
||||
// Delegate to appropriate signup function
|
||||
if (password) {
|
||||
userId = await signupWithPassword(ctx.db, email, password);
|
||||
} else if (passkeyInfo) {
|
||||
const rpInfo = getRPInfo(ctx.origin, ctx.allowedOrigins, ctx.rpName);
|
||||
userId = await signupWithPasskey(ctx.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(ctx.db, {
|
||||
userId,
|
||||
deviceId: null,
|
||||
trustedMode: false,
|
||||
geo,
|
||||
userAgent,
|
||||
});
|
||||
|
||||
// Set session cookie
|
||||
setCookie(
|
||||
ctx.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 ctx.db
|
||||
.insertInto("email_verifications")
|
||||
.values({
|
||||
user_id: userId,
|
||||
token: verificationToken,
|
||||
expires_at: expiresAt,
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Send verification email (stubbed)
|
||||
await sendVerificationEmail(email, verificationToken);
|
||||
});
|
||||
Reference in New Issue
Block a user