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>
110 lines
2.6 KiB
TypeScript
110 lines
2.6 KiB
TypeScript
/**
|
|
* 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`);
|
|
};
|