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,109 @@
/**
* Cookie configuration for authentication
* All cookies use 'rev.' prefix, HttpOnly, Secure, SameSite=Lax
*/
export const COOKIE_NAMES = {
SESSION_TOKEN: "rev.session_token",
DEVICE_FINGERPRINT: "rev.device_fingerprint",
LOGIN_REQUEST_TOKEN: "rev.login_request_token",
} as const;
export const COOKIE_DURATIONS = {
SESSION: 60 * 60 * 24 * 7, // 7 days in seconds
DEVICE_FINGERPRINT: 60 * 60 * 24 * 365, // 1 year in seconds
LOGIN_REQUEST: 60 * 15, // 15 minutes in seconds
} as const;
export const TOKEN_DURATIONS = {
EMAIL_VERIFICATION: 60 * 60 * 24, // 24 hours in seconds
PASSWORD_RESET: 60 * 60, // 1 hour in seconds
} as const;
// Base cookie options (all cookies share these)
const baseCookieOptions = {
httpOnly: true,
secure: true,
sameSite: "lax" as const,
path: "/",
};
export const COOKIE_OPTIONS = {
session: { ...baseCookieOptions, maxAge: COOKIE_DURATIONS.SESSION },
device: {
...baseCookieOptions,
maxAge: COOKIE_DURATIONS.DEVICE_FINGERPRINT,
},
loginRequest: {
...baseCookieOptions,
maxAge: COOKIE_DURATIONS.LOGIN_REQUEST,
},
} as const;
/**
* Cookie options type for setCookie function
*/
export interface CookieOptions {
httpOnly?: boolean;
secure?: boolean;
sameSite?: "strict" | "lax" | "none";
path?: string;
maxAge?: number;
}
/**
* Parse cookie string and get a specific cookie value
*/
export const getCookie = (
headers: Headers,
name: string,
): string | undefined => {
const cookieHeader = headers.get("Cookie");
if (!cookieHeader) {
return undefined;
}
const cookies = cookieHeader.split(";").map((c) => c.trim());
for (const cookie of cookies) {
const [cookieName, ...valueParts] = cookie.split("=");
if (cookieName === name) {
return valueParts.join("=");
}
}
return undefined;
};
/**
* Set a cookie in the response headers
*/
export const setCookie = (
headers: Headers,
name: string,
value: string,
options: CookieOptions,
): void => {
const parts = [`${name}=${value}`];
if (options.httpOnly) {
parts.push("HttpOnly");
}
if (options.secure) {
parts.push("Secure");
}
if (options.sameSite) {
parts.push(`SameSite=${options.sameSite}`);
}
if (options.path) {
parts.push(`Path=${options.path}`);
}
if (options.maxAge) {
parts.push(`Max-Age=${String(options.maxAge)}`);
}
headers.append("Set-Cookie", parts.join("; "));
};
/**
* Delete a cookie by setting it to expire immediately
*/
export const deleteCookie = (headers: Headers, name: string): void => {
headers.append("Set-Cookie", `${name}=; Path=/; Max-Age=0`);
};

View File

@@ -0,0 +1,38 @@
import { createHash, randomBytes } from "node:crypto";
/**
* Hash a token with SHA-256 for storage in database
* Never store raw tokens - always hash first
*/
export const hashToken = (token: string): string => {
return createHash("sha256").update(token).digest("hex");
};
/**
* Generate a session token (UUID v4)
*/
export const generateSessionToken = (): string => {
return crypto.randomUUID();
};
/**
* Generate a device fingerprint (UUID v4)
*/
export const generateDeviceFingerprint = (): string => {
return crypto.randomUUID();
};
/**
* Generate a secure random token for email verification, password reset, etc.
* Uses 32 bytes (256 bits) of entropy
*/
export const generateSecureToken = (): string => {
return randomBytes(32).toString("hex");
};
/**
* Generate expiration date
*/
export const generateExpiry = (seconds: number): Date => {
return new Date(Date.now() + seconds * 1000);
};

View File

@@ -0,0 +1,46 @@
/**
* Email sending utilities (stubbed for now)
* Will be implemented in Workstream G with actual Postmark integration
*/
/**
* Get the base URL for email links
* Read at function call time to allow environment variable changes
*/
const getBaseUrl = (): string => Bun.env.APP_URL ?? "http://localhost:6827";
/**
* Send verification email to user
*/
export async function sendVerificationEmail(
email: string,
token: string,
): Promise<void> {
const url = `${getBaseUrl()}/auth/verify?token=${token}`;
console.log(`[EMAIL STUB] Verification email to ${email}`);
console.log(`[EMAIL STUB] Verify link: ${url}`);
}
/**
* Send login confirmation email (for untrusted device flow)
*/
export async function sendLoginConfirmationEmail(
email: string,
token: string,
): Promise<void> {
const url = `${getBaseUrl()}/auth/confirm?token=${token}`;
console.log(`[EMAIL STUB] Login confirmation to ${email}`);
console.log(`[EMAIL STUB] Confirm link: ${url}`);
}
/**
* Send password reset email
*/
export async function sendPasswordResetEmail(
email: string,
token: string,
): Promise<void> {
const url = `${getBaseUrl()}/auth/reset-password?token=${token}`;
console.log(`[EMAIL STUB] Password reset to ${email}`);
console.log(`[EMAIL STUB] Reset link: ${url}`);
}

View File

@@ -0,0 +1,42 @@
export interface GeoInfo {
ip: string | null;
city: string | null;
region: string | null;
country: string | null;
}
/**
* Extract geolocation info from request headers
* Supports Cloudflare headers in production, falls back to standard headers
* @param headers - Request headers
* @returns Geolocation information extracted from headers
*/
export const getGeoInfo = (headers: Headers): GeoInfo => {
// Try Cloudflare headers first (production)
const cfIP = headers.get("CF-Connecting-IP");
const cfCountry = headers.get("CF-IPCountry");
const cfCity = headers.get("CF-IPCity");
const cfRegion = headers.get("CF-Region");
// Fallback to X-Forwarded-For or X-Real-IP
const forwardedFor = headers.get("X-Forwarded-For");
const realIP = headers.get("X-Real-IP");
const ip = cfIP ?? realIP ?? forwardedFor?.split(",")[0]?.trim() ?? null;
return {
ip,
city: cfCity ?? null,
region: cfRegion ?? null,
country: cfCountry ?? null,
};
};
/**
* Extract User-Agent from request headers
* @param headers - Request headers
* @returns User-Agent string or "Unknown" if not present
*/
export const getUserAgent = (headers: Headers): string => {
return headers.get("User-Agent") ?? "Unknown";
};

View File

@@ -0,0 +1,67 @@
import zxcvbn from "zxcvbn";
export interface PasswordValidationResult {
valid: boolean;
feedback: string[];
score: number;
}
/**
* Validate password strength using zxcvbn
* @param password - The password to validate
* @param userInputs - User-specific inputs to penalize (email, display name)
* @returns Validation result with feedback if invalid
*/
export const validatePassword = (
password: string,
userInputs: string[] = [],
): PasswordValidationResult => {
const result = zxcvbn(password, userInputs);
if (result.score < 3) {
const feedback =
result.feedback.suggestions.length > 0
? result.feedback.suggestions
: [
"Password is too weak. Try a longer phrase or add numbers and symbols.",
];
return {
valid: false,
feedback,
score: result.score,
};
}
return {
valid: true,
feedback: [],
score: result.score,
};
};
/**
* Hash a password using Bun's built-in argon2id
* @param password - The plaintext password to hash
* @returns The hashed password
*/
export const hashPassword = async (password: string): Promise<string> => {
return Bun.password.hash(password, {
algorithm: "argon2id",
memoryCost: 65536, // 64 MiB
timeCost: 3,
});
};
/**
* Verify a password against a stored hash
* @param password - The plaintext password to verify
* @param hash - The stored password hash
* @returns True if the password matches the hash
*/
export const verifyPassword = async (
password: string,
hash: string,
): Promise<boolean> => {
return Bun.password.verify(password, hash);
};

View File

@@ -0,0 +1,112 @@
import type { Database } from "@reviq/db-schema";
import type { Kysely } from "kysely";
import type { GeoInfo } from "./geo.js";
import { COOKIE_DURATIONS } from "./cookies.js";
import { generateExpiry, generateSessionToken, hashToken } from "./crypto.js";
export interface CreateSessionOptions {
userId: number;
deviceId: number | null;
trustedMode: boolean;
geo: GeoInfo;
userAgent: string;
}
export interface SessionResult {
token: string;
sessionId: number;
expiresAt: Date;
}
/**
* Create a new session for a user
* Returns the raw token (to be sent in cookie) and session details
*/
export async function createSession(
db: Kysely<Database>,
options: CreateSessionOptions,
): Promise<SessionResult> {
const token = generateSessionToken();
const tokenHash = hashToken(token);
const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION);
const result = await db
.insertInto("sessions")
.values({
user_id: options.userId,
device_id: options.deviceId,
token_hash: tokenHash,
trusted_mode: options.trustedMode,
ip_address: options.geo.ip,
city: options.geo.city,
region: options.geo.region,
country: options.geo.country,
user_agent: options.userAgent,
expires_at: expiresAt,
})
.returning(["id"])
.executeTakeFirstOrThrow();
return {
token,
sessionId: Number(result.id),
expiresAt,
};
}
/**
* Upsert a user device record
* Creates new device if not exists, updates last_used_at if exists
* Returns the device ID
*/
export async function upsertUserDevice(
db: Kysely<Database>,
userId: number,
deviceFingerprint: string,
geo: GeoInfo,
userAgent: string,
): Promise<number> {
const result = await db
.insertInto("user_devices")
.values({
user_id: userId,
device_fingerprint: deviceFingerprint,
user_agent: userAgent,
ip_address: geo.ip,
city: geo.city,
region: geo.region,
country: geo.country,
})
.onConflict((oc) =>
oc.columns(["user_id", "device_fingerprint"]).doUpdateSet({
ip_address: geo.ip,
city: geo.city,
region: geo.region,
country: geo.country,
user_agent: userAgent,
last_used_at: new Date(),
}),
)
.returning(["id"])
.executeTakeFirstOrThrow();
return Number(result.id);
}
/**
* Check if a device is trusted for a user
*/
export async function isDeviceTrusted(
db: Kysely<Database>,
userId: number,
deviceFingerprint: string,
): Promise<boolean> {
const device = await db
.selectFrom("user_devices")
.select(["is_trusted"])
.where("user_id", "=", userId)
.where("device_fingerprint", "=", deviceFingerprint)
.executeTakeFirst();
return device?.is_trusted ?? false;
}

View File

@@ -3,6 +3,7 @@
*/
import type { Database } from "@reviq/db-schema";
import type { VerifiedRegistrationResponse } from "@simplewebauthn/server";
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
@@ -11,7 +12,6 @@ import type {
} from "@simplewebauthn/types";
import type { Kysely } from "kysely";
import type { ParsedPasskey, PasskeyRow } from "./passkey-helpers.js";
import type { VerifiedRegistrationResponse } from "@simplewebauthn/server";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
@@ -23,7 +23,7 @@ import { formatPasskeyDate, parsePasskeyRow } from "./passkey-helpers.js";
/**
* Known authenticator AAGUIDs mapped to friendly names
*/
const KNOWN_AAGUIDS: Record<string, string> = {
export const KNOWN_AAGUIDS: Record<string, string> = {
"ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4": "Google Password Manager",
"adce0002-35bc-c60a-648b-0b25f1f05503": "Chrome on Mac",
"08987058-cadc-4b81-b6e1-30de50dcbe96": "Windows Hello",