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:
RevIQ
2026-01-09 15:19:15 +08:00
parent 8de88472b1
commit 829d365e80
24 changed files with 1739 additions and 47 deletions

View File

@@ -0,0 +1,148 @@
/**
* Create login request procedure
* First step in the login flow - validates email and returns available auth methods
*/
import type { APIContext } from "../../context.js";
import { implement } from "@orpc/server";
import { contract } from "@reviq/api-contract";
import {
COOKIE_DURATIONS,
COOKIE_NAMES,
COOKIE_OPTIONS,
getCookie,
setCookie,
} from "../../utils/cookies.js";
import {
generateDeviceFingerprint,
generateExpiry,
} from "../../utils/crypto.js";
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
import { isDeviceTrusted } from "../../utils/session.js";
const os = implement(contract);
/**
* Create login request handler
* - Normalizes email to lowercase
* - Reads/generates device fingerprint cookie
* - Looks up user by email
* - If user exists: checks device trust, passkey, password; creates login request
* - If user doesn't exist: generates fake token for anti-enumeration
* - Returns auth method availability and device trust status
*/
export const createLoginRequest = os.auth.createLoginRequest.handler(
async ({ input, context }) => {
const ctx = context as APIContext;
const { email: rawEmail } = input;
// Normalize email to lowercase
const email = rawEmail.toLowerCase();
// Read or generate device fingerprint
let deviceFingerprint = getCookie(
ctx.reqHeaders,
COOKIE_NAMES.DEVICE_FINGERPRINT,
);
if (!deviceFingerprint) {
deviceFingerprint = generateDeviceFingerprint();
setCookie(
ctx.resHeaders,
COOKIE_NAMES.DEVICE_FINGERPRINT,
deviceFingerprint,
COOKIE_OPTIONS.device,
);
}
// Look up user by email
const user = await ctx.db
.selectFrom("users")
.select(["id", "password_hash"])
.where("email", "=", email)
.executeTakeFirst();
// User doesn't exist - return fake response for anti-enumeration
if (!user) {
// Generate placeholder token (UUID) for anti-enumeration
// This prevents attackers from knowing if an email exists based on response
const placeholderToken = crypto.randomUUID();
// Set placeholder login request token cookie
setCookie(
ctx.resHeaders,
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
placeholderToken,
COOKIE_OPTIONS.loginRequest,
);
return {
hasPasskey: false,
hasPassword: false,
isTrustedDevice: false,
email,
};
}
// User exists - gather real auth information
const userId = user.id;
// Check if device is trusted
const isTrustedDevice = await isDeviceTrusted(
ctx.db,
userId,
deviceFingerprint,
);
// Check if user has passkey
const passkey = await ctx.db
.selectFrom("passkeys")
.select(["id"])
.where("user_id", "=", userId)
.executeTakeFirst();
const hasPasskey = !!passkey;
// Check if user has password
const hasPassword = user.password_hash !== null;
// Get geo info and user agent
const geo = getGeoInfo(ctx.reqHeaders);
const userAgent = getUserAgent(ctx.reqHeaders);
// Create login request
const expiresAt = generateExpiry(COOKIE_DURATIONS.LOGIN_REQUEST);
const loginRequest = await ctx.db
.insertInto("login_requests")
.values({
user_id: userId,
email,
device_fingerprint: deviceFingerprint,
ip_address: geo.ip,
city: geo.city,
region: geo.region,
country: geo.country,
user_agent: userAgent,
expires_at: expiresAt,
})
.returning(["id"])
.executeTakeFirstOrThrow();
const loginRequestId = loginRequest.id;
// Set login request token cookie with the real login request ID
setCookie(
ctx.resHeaders,
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
loginRequestId,
COOKIE_OPTIONS.loginRequest,
);
return {
hasPasskey,
hasPassword,
isTrustedDevice,
email,
};
},
);