diff --git a/.ast-grep/rules/orpc-error-required.yml b/.ast-grep/rules/orpc-error-required.yml new file mode 100644 index 0000000..45157ad --- /dev/null +++ b/.ast-grep/rules/orpc-error-required.yml @@ -0,0 +1,21 @@ +id: orpc-error-required +language: typescript +severity: error +message: Use ORPCError instead of Error in API procedures for proper error handling +note: | + In oRPC procedures, always use ORPCError from @orpc/server to ensure + errors are properly serialized with correct HTTP status codes. + + Example: + throw new ORPCError("BAD_REQUEST", { message: "..." }); + + Available error codes: UNAUTHORIZED, FORBIDDEN, BAD_REQUEST, NOT_FOUND, + INTERNAL_SERVER_ERROR, etc. +rule: + pattern: throw new Error($MSG) + inside: + kind: arrow_function + stopBy: end +files: + - "apps/api-server/src/procedures/**/*.ts" + - "apps/api-server/src/router.ts" diff --git a/apps/api-server/package.json b/apps/api-server/package.json index 1e02bb9..95238ec 100644 --- a/apps/api-server/package.json +++ b/apps/api-server/package.json @@ -18,12 +18,14 @@ "@reviq/db-schema": "workspace:*", "@simplewebauthn/server": "^13.2.2", "@simplewebauthn/types": "^12.0.0", - "kysely": "^0.28.2" + "kysely": "^0.28.2", + "zxcvbn": "^4.4.2" }, "devDependencies": { "@macalinao/eslint-config": "catalog:", "@macalinao/tsconfig": "catalog:", "@types/bun": "catalog:", + "@types/zxcvbn": "^4.4.5", "eslint": "catalog:", "typescript": "catalog:" } diff --git a/apps/api-server/src/context.ts b/apps/api-server/src/context.ts index b36541b..aee5056 100644 --- a/apps/api-server/src/context.ts +++ b/apps/api-server/src/context.ts @@ -17,6 +17,10 @@ export interface APIContext { allowedOrigins: string[]; /** Relying party name for WebAuthn */ rpName: string; + /** Request headers (for reading cookies, auth headers) */ + reqHeaders: Headers; + /** Response headers (for setting cookies) */ + resHeaders: Headers; } /** diff --git a/apps/api-server/src/index.ts b/apps/api-server/src/index.ts index 0944a8f..a8c456f 100644 --- a/apps/api-server/src/index.ts +++ b/apps/api-server/src/index.ts @@ -25,17 +25,30 @@ Bun.serve({ const origin = request.headers.get("origin") ?? `http://localhost:${String(port)}`; + // Create response headers for setting cookies + const resHeaders = new Headers(); + const context: APIContext = { db, origin, allowedOrigins, rpName, + reqHeaders: request.headers, + resHeaders, }; const { response } = await handler.handle(request, { prefix: "/api/v1/rpc", context, }); + + // Merge response headers (cookies) into the response + if (response) { + resHeaders.forEach((value, key) => { + response.headers.append(key, value); + }); + } + return response ?? new Response("Not Found", { status: 404 }); } diff --git a/apps/api-server/src/middleware/auth.ts b/apps/api-server/src/middleware/auth.ts index 575214b..71e3647 100644 --- a/apps/api-server/src/middleware/auth.ts +++ b/apps/api-server/src/middleware/auth.ts @@ -1,14 +1,155 @@ /** * Authentication middleware for oRPC server * - * This middleware will be used to: - * - Verify JWT tokens from Authorization header - * - Extract user context from valid tokens - * - Attach user information to request context - * - * TODO: Implement in Phase 2 + * Handles authentication via: + * - Session cookie (rev.session_token) - for browser clients + * - API key header (x-api-key) - for CLI and programmatic access */ -export const authMiddleware = async (): Promise => { - throw new Error("Auth middleware not implemented"); +import type { + APIContext, + AuthenticatedContext, + Session, + SessionUser, +} from "../context.js"; +import { ORPCError } from "@orpc/server"; +import { COOKIE_NAMES, getCookie } from "../utils/cookies.js"; +import { hashToken } from "../utils/crypto.js"; + +/** + * Create the auth middleware function + * This returns a middleware handler that can be used with oRPC procedures + */ +export const createAuthMiddleware = () => { + return async ({ + context, + next, + }: { + context: APIContext; + next: (opts: { + context: Omit; + }) => Promise; + }) => { + const { db, reqHeaders } = context; + + // Try session cookie first + let tokenHash: string | undefined; + const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN); + if (sessionToken) { + tokenHash = hashToken(sessionToken); + } + + // Fall back to API key header (for CLI) + const apiKey = reqHeaders.get("x-api-key"); + if (!tokenHash && apiKey) { + tokenHash = hashToken(apiKey); + } + + if (!tokenHash) { + throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" }); + } + + // Look up session (check not expired and not revoked) + const session = await db + .selectFrom("sessions") + .where("token_hash", "=", tokenHash) + .where("expires_at", ">", new Date()) + .where("revoked_at", "is", null) + .selectAll() + .executeTakeFirst(); + + // Fall back to API token if no session found + const apiToken = !session + ? await db + .selectFrom("api_tokens") + .where("token_hash", "=", tokenHash) + .where("expires_at", ">", new Date()) + .selectAll() + .executeTakeFirst() + : undefined; + + const userId = session?.user_id ?? apiToken?.user_id; + if (!userId) { + throw new ORPCError("UNAUTHORIZED", { + message: "Invalid or expired token", + }); + } + + // Update last_used_at for API tokens + if (apiToken) { + await db + .updateTable("api_tokens") + .set({ last_used_at: new Date() }) + .where("id", "=", apiToken.id) + .execute(); + } + + // Fetch user details + const user = await db + .selectFrom("users") + .where("id", "=", userId) + .select([ + "id", + "email", + "display_name", + "email_verified_at", + "is_superuser", + ]) + .executeTakeFirst(); + + if (!user) { + throw new ORPCError("UNAUTHORIZED", { + message: "User not found", + }); + } + + const sessionUser: SessionUser = { + id: user.id, + email: user.email, + displayName: user.display_name, + emailVerifiedAt: user.email_verified_at, + isSuperuser: user.is_superuser, + }; + + const sessionInfo: Session = session + ? { + id: Number(session.id), + trustedMode: session.trusted_mode, + createdAt: session.created_at, + } + : { + // For API token auth, create a synthetic session object + // We know apiToken exists because userId came from it + id: 0, + trustedMode: true, + createdAt: apiToken?.created_at ?? new Date(), + }; + + return next({ + context: { + user: sessionUser, + session: sessionInfo, + }, + }); + }; +}; + +/** + * Middleware to require superuser access + */ +export const createSuperuserMiddleware = () => { + return async ({ + context, + next, + }: { + context: AuthenticatedContext; + next: () => Promise; + }) => { + if (!context.user.isSuperuser) { + throw new ORPCError("FORBIDDEN", { + message: "Superuser access required", + }); + } + return next(); + }; }; diff --git a/apps/api-server/src/procedures/auth/create-login-request.ts b/apps/api-server/src/procedures/auth/create-login-request.ts new file mode 100644 index 0000000..c71659b --- /dev/null +++ b/apps/api-server/src/procedures/auth/create-login-request.ts @@ -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, + }; + }, +); diff --git a/apps/api-server/src/procedures/auth/forgot-password.ts b/apps/api-server/src/procedures/auth/forgot-password.ts new file mode 100644 index 0000000..a5c87b8 --- /dev/null +++ b/apps/api-server/src/procedures/auth/forgot-password.ts @@ -0,0 +1,62 @@ +import type { APIContext } from "../../context.js"; +import { implement } from "@orpc/server"; +import { contract } from "@reviq/api-contract"; +import { TOKEN_DURATIONS } from "../../utils/cookies.js"; +import { generateExpiry, generateSecureToken } from "../../utils/crypto.js"; +import { sendPasswordResetEmail } from "../../utils/email.js"; + +const os = implement(contract); + +/** + * Forgot password handler + * Public procedure (no authentication required) + * + * Anti-enumeration: Always returns success even if user doesn't exist + * This prevents attackers from determining which emails are registered + */ +export const forgotPassword = os.auth.forgotPassword.handler( + async ({ input, context }) => { + const ctx = context as APIContext; + const { email } = input; + + // Normalize email to lowercase + const normalizedEmail = email.toLowerCase(); + + // Look up user by email + const user = await ctx.db + .selectFrom("users") + .select(["id", "email"]) + .where("email", "=", normalizedEmail) + .executeTakeFirst(); + + // If user exists, create password reset token and send email + if (user) { + // Delete any existing password reset tokens for this user (security measure) + await ctx.db + .deleteFrom("password_resets") + .where("user_id", "=", user.id) + .execute(); + + // Generate secure token (64 hex chars) + const token = generateSecureToken(); + + // Create password reset record with 1 hour expiry + const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET); + + await ctx.db + .insertInto("password_resets") + .values({ + user_id: user.id, + token, + expires_at: expiresAt, + }) + .execute(); + + // Send password reset email (stubbed) + await sendPasswordResetEmail(user.email, token); + } + + // Always return success (anti-enumeration) + // Don't reveal whether the email exists or not + }, +); diff --git a/apps/api-server/src/procedures/auth/login-if-completed.ts b/apps/api-server/src/procedures/auth/login-if-completed.ts new file mode 100644 index 0000000..0d1ee4d --- /dev/null +++ b/apps/api-server/src/procedures/auth/login-if-completed.ts @@ -0,0 +1,170 @@ +/** + * Check if login request is completed and create session if so + * Public procedure - no authentication required + * + * Flow: + * 1. Read rev.login_request_token cookie (could be real ID or fake UUID) + * 2. If fake token (not found in DB): return { status: 'pending' } + * 3. If valid login request ID: + * - Check if expired: return { status: 'expired' } + * - Check if not completed: return { status: 'pending' } + * - If completed: + * a. Create user_device record + * b. Create session (trusted_mode = true) + * c. Delete login_request row + * d. Set session cookie, clear login_request cookie + * e. Return { status: 'completed', redirectTo: '/dashboard' or '/auth/trust-device' } + */ + +import type { APIContext } from "../../context.js"; +import { implement } from "@orpc/server"; +import { contract } from "@reviq/api-contract"; +import { + COOKIE_NAMES, + COOKIE_OPTIONS, + deleteCookie, + getCookie, + setCookie, +} from "../../utils/cookies.js"; +import { getGeoInfo, getUserAgent } from "../../utils/geo.js"; +import { + createSession, + isDeviceTrusted, + upsertUserDevice, +} from "../../utils/session.js"; + +const os = implement(contract); + +/** + * Check if a string looks like a UUID (fake token) + */ +const isUUID = (str: string): boolean => { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return uuidRegex.test(str); +}; + +/** + * Login if request is completed handler + * Polls for login completion and creates session when ready + */ +export const loginIfRequestIsCompleted = + os.auth.loginIfRequestIsCompleted.handler(async ({ context }) => { + const ctx = context as APIContext; + + // Read login request token from cookie + const loginRequestToken = getCookie( + ctx.reqHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + + // No cookie - return pending (shouldn't happen in normal flow) + if (!loginRequestToken) { + return { status: "pending" as const }; + } + + // Check if it's a fake token (UUID) + if (isUUID(loginRequestToken)) { + // Fake token - user doesn't exist + // The cookie will expire naturally after 15 minutes + return { status: "pending" as const }; + } + + // Try to parse as login request ID + const loginRequestId = Number.parseInt(loginRequestToken, 10); + if (Number.isNaN(loginRequestId)) { + // Invalid format - treat as pending + return { status: "pending" as const }; + } + + // Fetch login request from database + const loginRequest = await ctx.db + .selectFrom("login_requests") + .select([ + "id", + "user_id", + "device_fingerprint", + "completed_at", + "expires_at", + ]) + .where("id", "=", String(loginRequestId)) + .executeTakeFirst(); + + // Login request not found - might have been deleted or invalid ID + if (!loginRequest) { + return { status: "pending" as const }; + } + + // Check if expired + if (new Date() > loginRequest.expires_at) { + return { status: "expired" as const }; + } + + // Check if not completed yet + if (loginRequest.completed_at === null) { + return { status: "pending" as const }; + } + + // Login request is completed - create session + const userId = loginRequest.user_id; + const deviceFingerprint = loginRequest.device_fingerprint; + + // Device fingerprint should always be present, but handle null case defensively + if (!deviceFingerprint) { + return { status: "pending" as const }; + } + + // Get current request info + const geo = getGeoInfo(ctx.reqHeaders); + const userAgent = getUserAgent(ctx.reqHeaders); + + // Upsert user device + const deviceId = await upsertUserDevice( + ctx.db, + userId, + deviceFingerprint, + geo, + userAgent, + ); + + // Check if device is already trusted + const deviceTrusted = await isDeviceTrusted( + ctx.db, + userId, + deviceFingerprint, + ); + + // Create session with trusted mode = true (email-confirmed login) + const session = await createSession(ctx.db, { + userId, + deviceId, + trustedMode: true, + geo, + userAgent, + }); + + // Delete the login request (it's been consumed) + await ctx.db + .deleteFrom("login_requests") + .where("id", "=", String(loginRequestId)) + .execute(); + + // Set session cookie + setCookie( + ctx.resHeaders, + COOKIE_NAMES.SESSION_TOKEN, + session.token, + COOKIE_OPTIONS.session, + ); + + // Clear login request cookie + deleteCookie(ctx.resHeaders, COOKIE_NAMES.LOGIN_REQUEST_TOKEN); + + // Determine redirect path based on device trust status + const redirectTo = deviceTrusted ? "/dashboard" : "/auth/trust-device"; + + return { + status: "completed" as const, + redirectTo, + }; + }); diff --git a/apps/api-server/src/procedures/auth/login-password-confirm.ts b/apps/api-server/src/procedures/auth/login-password-confirm.ts new file mode 100644 index 0000000..c0c2740 --- /dev/null +++ b/apps/api-server/src/procedures/auth/login-password-confirm.ts @@ -0,0 +1,57 @@ +import type { APIContext } from "../../context.js"; +import { implement, ORPCError } from "@orpc/server"; +import { contract } from "@reviq/api-contract"; + +const os = implement(contract); + +/** + * Confirm password login via email link token + * Public procedure - no authentication required + * + * Flow: + * 1. Find login_request by token + * 2. Check if token is expired + * 3. Check if already completed (idempotent) + * 4. Mark completed_at = now() + * + * This is called when user clicks the confirmation link in their email + * for untrusted device login attempts. + */ +export const loginPasswordConfirm = os.auth.loginPasswordConfirm.handler( + async ({ input, context }) => { + const ctx = context as APIContext; + const { token } = input; + + // Find the login request by token + const loginRequest = await ctx.db + .selectFrom("login_requests") + .select(["id", "expires_at", "completed_at"]) + .where("token", "=", token) + .executeTakeFirst(); + + if (!loginRequest) { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid or expired confirmation link", + }); + } + + // Check if token is expired + if (new Date() > loginRequest.expires_at) { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid or expired confirmation link", + }); + } + + // If already completed, return success (idempotent) + if (loginRequest.completed_at !== null) { + return; + } + + // Mark as completed + await ctx.db + .updateTable("login_requests") + .set({ completed_at: new Date() }) + .where("id", "=", loginRequest.id) + .execute(); + }, +); diff --git a/apps/api-server/src/procedures/auth/login-password.ts b/apps/api-server/src/procedures/auth/login-password.ts new file mode 100644 index 0000000..f8a5081 --- /dev/null +++ b/apps/api-server/src/procedures/auth/login-password.ts @@ -0,0 +1,147 @@ +/** + * Login with password procedure + * Second step in the login flow - verifies password and completes/confirms login + */ + +import type { APIContext } from "../../context.js"; +import { implement, ORPCError } from "@orpc/server"; +import { contract } from "@reviq/api-contract"; +import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js"; +import { generateSecureToken } from "../../utils/crypto.js"; +import { sendLoginConfirmationEmail } from "../../utils/email.js"; +import { verifyPassword } from "../../utils/password.js"; +import { isDeviceTrusted } from "../../utils/session.js"; + +const os = implement(contract); + +/** + * Check if a string is a valid login request ID (numeric) + */ +const isValidLoginRequestId = (value: string): boolean => { + const num = Number(value); + return !Number.isNaN(num) && Number.isInteger(num) && num > 0; +}; + +/** + * Login with password handler + * - Reads login request token from cookie + * - If fake token (UUID): returns generic error for anti-enumeration + * - If valid login request ID: + * - Validates login request exists and not expired + * - Verifies password against stored hash + * - If device is trusted: marks login request as completed + * - If device is untrusted: generates confirmation token and sends email + */ +export const loginPassword = os.auth.loginPassword.handler( + async ({ input, context }) => { + const ctx = context as APIContext; + const { password } = input; + + // Read login request token from cookie + const loginRequestToken = getCookie( + ctx.reqHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + + // Generic error message for anti-enumeration + const INVALID_CREDENTIALS_ERROR = "Invalid email or password"; + + // No login request token or invalid format + if (!loginRequestToken) { + throw new ORPCError("BAD_REQUEST", { + message: INVALID_CREDENTIALS_ERROR, + }); + } + + // Check if token is a fake token (UUID) or valid login request ID + if (!isValidLoginRequestId(loginRequestToken)) { + // Fake token - return generic error for anti-enumeration + throw new ORPCError("BAD_REQUEST", { + message: INVALID_CREDENTIALS_ERROR, + }); + } + + // Valid login request ID - keep as string (Int8 type) + const loginRequestId = loginRequestToken; + + // Fetch login request with user data in single query (optimized JOIN) + const result = await ctx.db + .selectFrom("login_requests") + .innerJoin("users", "users.id", "login_requests.user_id") + .select([ + "login_requests.id", + "login_requests.user_id", + "login_requests.email", + "login_requests.device_fingerprint", + "login_requests.expires_at", + "login_requests.completed_at", + "users.password_hash", + ]) + .where("login_requests.id", "=", loginRequestId) + .executeTakeFirst(); + + // Login request not found + if (!result) { + throw new ORPCError("BAD_REQUEST", { + message: INVALID_CREDENTIALS_ERROR, + }); + } + + // Check if login request is expired + if (new Date() > new Date(result.expires_at)) { + throw new ORPCError("BAD_REQUEST", { + message: + "Login request has expired. Please start the login process again.", + }); + } + + // User has no password set + if (!result.password_hash) { + throw new ORPCError("BAD_REQUEST", { + message: INVALID_CREDENTIALS_ERROR, + }); + } + + // Verify password + const passwordValid = await verifyPassword(password, result.password_hash); + + if (!passwordValid) { + throw new ORPCError("BAD_REQUEST", { + message: INVALID_CREDENTIALS_ERROR, + }); + } + + // Password is valid - check if device is trusted + // If no device fingerprint, treat as untrusted + const deviceTrusted = result.device_fingerprint + ? await isDeviceTrusted(ctx.db, result.user_id, result.device_fingerprint) + : false; + + if (deviceTrusted) { + // Device is trusted - complete login immediately + await ctx.db + .updateTable("login_requests") + .set({ + completed_at: new Date(), + }) + .where("id", "=", loginRequestId) + .execute(); + } else { + // Device is untrusted - generate confirmation token and send email + const confirmationToken = generateSecureToken(); + + await ctx.db + .updateTable("login_requests") + .set({ + token: confirmationToken, + }) + .where("id", "=", loginRequestId) + .execute(); + + // Send login confirmation email (stubbed for now) + await sendLoginConfirmationEmail(result.email, confirmationToken); + } + + // Return void (success) + }, +); diff --git a/apps/api-server/src/procedures/auth/logout.ts b/apps/api-server/src/procedures/auth/logout.ts new file mode 100644 index 0000000..4461a01 --- /dev/null +++ b/apps/api-server/src/procedures/auth/logout.ts @@ -0,0 +1,32 @@ +/** + * Logout procedure - revokes the current session and clears the session cookie + */ + +import type { AuthenticatedContext } from "../../context.js"; +import { implement } from "@orpc/server"; +import { contract } from "@reviq/api-contract"; +import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js"; + +const os = implement(contract); + +/** + * Logout handler + * - Requires authentication (user must be logged in) + * - Revokes the current session by setting revoked_at to now() + * - Clears the session cookie from the response + */ +export const logout = os.auth.logout.handler( + async ({ context }: { context: unknown }) => { + const ctx = context as AuthenticatedContext; + + // Revoke the current session + await ctx.db + .updateTable("sessions") + .set({ revoked_at: new Date() }) + .where("id", "=", String(ctx.session.id)) + .execute(); + + // Clear the session cookie + deleteCookie(ctx.resHeaders, COOKIE_NAMES.SESSION_TOKEN); + }, +); diff --git a/apps/api-server/src/procedures/auth/resend-verification.ts b/apps/api-server/src/procedures/auth/resend-verification.ts new file mode 100644 index 0000000..c937e65 --- /dev/null +++ b/apps/api-server/src/procedures/auth/resend-verification.ts @@ -0,0 +1,54 @@ +import type { AuthenticatedContext } from "../../context.js"; +import { implement } from "@orpc/server"; +import { contract } from "@reviq/api-contract"; +import { TOKEN_DURATIONS } from "../../utils/cookies.js"; +import { generateExpiry, generateSecureToken } from "../../utils/crypto.js"; +import { sendVerificationEmail } from "../../utils/email.js"; + +const os = implement(contract); + +/** + * Resend email verification to authenticated user + * Requires authentication + * + * Flow: + * 1. Check if email is already verified (return early if so) + * 2. Delete any existing verification tokens for this user + * 3. Generate new secure token (64 hex chars) + * 4. Create new email_verifications record with 24 hour expiry + * 5. Send verification email (stubbed) + */ +export const resendVerificationEmail = os.auth.resendVerificationEmail.handler( + async ({ context }) => { + const ctx = context as AuthenticatedContext; + + // Check if email is already verified + if (ctx.user.emailVerifiedAt !== null) { + // Email already verified, return early + return; + } + + // Delete any existing verification tokens for this user + await ctx.db + .deleteFrom("email_verifications") + .where("user_id", "=", ctx.user.id) + .execute(); + + // Generate new secure token + const token = generateSecureToken(); + const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION); + + // Create new verification record + await ctx.db + .insertInto("email_verifications") + .values({ + user_id: ctx.user.id, + token, + expires_at: expiresAt, + }) + .execute(); + + // Send verification email (stubbed) + await sendVerificationEmail(ctx.user.email, token); + }, +); diff --git a/apps/api-server/src/procedures/auth/reset-password.ts b/apps/api-server/src/procedures/auth/reset-password.ts new file mode 100644 index 0000000..f4d23eb --- /dev/null +++ b/apps/api-server/src/procedures/auth/reset-password.ts @@ -0,0 +1,92 @@ +import type { APIContext } from "../../context.js"; +import { implement, ORPCError } from "@orpc/server"; +import { contract } from "@reviq/api-contract"; +import { hashPassword, validatePassword } from "../../utils/password.js"; + +const os = implement(contract); + +/** + * Reset password handler + * Public procedure (no authentication required) + * + * Validates the reset token, checks password strength, updates password, + * marks token as used, and revokes all existing sessions + */ +export const resetPassword = os.auth.resetPassword.handler( + async ({ input, context }) => { + const ctx = context as APIContext; + const { token, newPassword } = input; + + // Find the password reset token + const passwordReset = await ctx.db + .selectFrom("password_resets") + .select(["id", "user_id", "expires_at", "used_at"]) + .where("token", "=", token) + .executeTakeFirst(); + + if (!passwordReset) { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid or expired reset token", + }); + } + + // Check if token has already been used + if (passwordReset.used_at) { + throw new ORPCError("BAD_REQUEST", { + message: "Reset token has already been used", + }); + } + + // Check if token has expired + const now = new Date(); + if (passwordReset.expires_at < now) { + throw new ORPCError("BAD_REQUEST", { + message: "Reset token has expired", + }); + } + + // Validate password strength with zxcvbn (score must be >= 3) + const validation = validatePassword(newPassword); + if (!validation.valid) { + throw new ORPCError("BAD_REQUEST", { + message: + validation.feedback.join(" ") || + "Password is too weak. Please choose a stronger password.", + }); + } + + // Hash the new password with argon2id + const passwordHash = await hashPassword(newPassword); + + // Update user's password + await ctx.db + .updateTable("users") + .set({ + password_hash: passwordHash, + updated_at: now, + }) + .where("id", "=", passwordReset.user_id) + .execute(); + + // Mark the reset token as used + await ctx.db + .updateTable("password_resets") + .set({ + used_at: now, + }) + .where("id", "=", passwordReset.id) + .execute(); + + // Revoke ALL sessions for this user (security measure) + await ctx.db + .updateTable("sessions") + .set({ + revoked_at: now, + }) + .where("user_id", "=", passwordReset.user_id) + .where("revoked_at", "is", null) + .execute(); + + // Return void on success + }, +); diff --git a/apps/api-server/src/procedures/auth/signup.ts b/apps/api-server/src/procedures/auth/signup.ts new file mode 100644 index 0000000..4cf66aa --- /dev/null +++ b/apps/api-server/src/procedures/auth/signup.ts @@ -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, + email: string, + password: string, +): Promise { + // 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, + email: string, + passkeyInfo: PasskeySignupInfo, + rpInfo: RPInfo, +): Promise { + 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>; + 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); +}); diff --git a/apps/api-server/src/procedures/auth/verify-email.ts b/apps/api-server/src/procedures/auth/verify-email.ts new file mode 100644 index 0000000..1c0d6c3 --- /dev/null +++ b/apps/api-server/src/procedures/auth/verify-email.ts @@ -0,0 +1,61 @@ +import type { APIContext } from "../../context.js"; +import { implement, ORPCError } from "@orpc/server"; +import { contract } from "@reviq/api-contract"; + +const os = implement(contract); + +/** + * Verify user email with token from URL + * Public procedure - no authentication required + * + * Flow: + * 1. Find token in email_verifications table + * 2. Check if token is expired + * 3. Update user's email_verified_at timestamp + * 4. Delete the verification record + */ +export const verifyEmail = os.auth.verifyEmail.handler( + async ({ input, context }) => { + const ctx = context as APIContext; + const { token } = input; + + // Find the verification record + const verification = await ctx.db + .selectFrom("email_verifications") + .select(["id", "user_id", "expires_at"]) + .where("token", "=", token) + .executeTakeFirst(); + + if (!verification) { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid or expired token", + }); + } + + // Check if token is expired + if (new Date() > verification.expires_at) { + // Clean up expired token + await ctx.db + .deleteFrom("email_verifications") + .where("id", "=", verification.id) + .execute(); + + throw new ORPCError("BAD_REQUEST", { + message: "Invalid or expired token", + }); + } + + // Update user's email_verified_at + await ctx.db + .updateTable("users") + .set({ email_verified_at: new Date() }) + .where("id", "=", verification.user_id) + .execute(); + + // Delete the verification record + await ctx.db + .deleteFrom("email_verifications") + .where("id", "=", verification.id) + .execute(); + }, +); diff --git a/apps/api-server/src/router.ts b/apps/api-server/src/router.ts index 5eea8a6..5e91b81 100644 --- a/apps/api-server/src/router.ts +++ b/apps/api-server/src/router.ts @@ -4,8 +4,18 @@ import type { LoginRequestContext, SuperuserContext, } from "./context.js"; -import { implement } from "@orpc/server"; +import { implement, ORPCError } from "@orpc/server"; import { contract } from "@reviq/api-contract"; +import { createLoginRequest as createLoginRequestHandler } from "./procedures/auth/create-login-request.js"; +import { forgotPassword as forgotPasswordHandler } from "./procedures/auth/forgot-password.js"; +import { loginIfRequestIsCompleted as loginIfRequestIsCompletedHandler } from "./procedures/auth/login-if-completed.js"; +import { loginPassword as loginPasswordHandler } from "./procedures/auth/login-password.js"; +import { loginPasswordConfirm as loginPasswordConfirmHandler } from "./procedures/auth/login-password-confirm.js"; +import { logout as logoutHandler } from "./procedures/auth/logout.js"; +import { resendVerificationEmail as resendVerificationHandler } from "./procedures/auth/resend-verification.js"; +import { resetPassword as resetPasswordHandler } from "./procedures/auth/reset-password.js"; +import { signup as signupHandler } from "./procedures/auth/signup.js"; +import { verifyEmail as verifyEmailHandler } from "./procedures/auth/verify-email.js"; import { createAuthenticationOptions as createAuthOptions, createRegistrationOptions as createRegOptions, @@ -30,108 +40,25 @@ const requireSuperuser = (context: unknown): SuperuserContext => { }; // Auth procedures -const signup = os.auth.signup.handler(async () => { - throw new Error("Not implemented"); -}); +const signup = signupHandler; -const verifyEmail = os.auth.verifyEmail.handler(async () => { - throw new Error("Not implemented"); -}); +const verifyEmail = verifyEmailHandler; -const resendVerificationEmail = os.auth.resendVerificationEmail.handler( - async () => { - throw new Error("Not implemented"); - }, -); +const resendVerificationEmail = resendVerificationHandler; -const createLoginRequest = os.auth.createLoginRequest.handler( - async ({ input, context }) => { - const ctx = context as APIContext; - const email = input.email.toLowerCase(); +const createLoginRequest = createLoginRequestHandler; - const user = await ctx.db - .selectFrom("users") - .where("email", "=", email) - .select(["id", "password_hash"]) - .executeTakeFirst(); +const loginPassword = loginPasswordHandler; - // Check for passkeys - const hasPasskey = user - ? (await ctx.db - .selectFrom("passkeys") - .where("user_id", "=", user.id) - .select("id") - .executeTakeFirst()) !== undefined - : false; +const loginPasswordConfirm = loginPasswordConfirmHandler; - const hasPassword = user?.password_hash !== null && user !== undefined; +const loginIfRequestIsCompleted = loginIfRequestIsCompletedHandler; - if (!user) { - // Anti-enumeration: return fake response for non-existent users - return { - hasPasskey: false, - hasPassword: false, - isTrustedDevice: false, - email, - }; - } +const forgotPassword = forgotPasswordHandler; - // Create login request - await ctx.db - .insertInto("login_requests") - .values({ - user_id: user.id, - email, - expires_at: new Date(Date.now() + 15 * 60 * 1000), // 15 min - }) - .execute(); +const resetPassword = resetPasswordHandler; - // TODO: Set login_request cookie - // TODO: Check device fingerprint for trusted device - - return { - hasPasskey, - hasPassword, - isTrustedDevice: false, - email, - }; - }, -); - -const loginPassword = os.auth.loginPassword.handler(async () => { - throw new Error("Not implemented"); -}); - -const loginPasswordConfirm = os.auth.loginPasswordConfirm.handler(async () => { - throw new Error("Not implemented"); -}); - -const loginIfRequestIsCompleted = os.auth.loginIfRequestIsCompleted.handler( - async () => { - throw new Error("Not implemented"); - }, -); - -const forgotPassword = os.auth.forgotPassword.handler(async () => { - throw new Error("Not implemented"); -}); - -const resetPassword = os.auth.resetPassword.handler(async () => { - throw new Error("Not implemented"); -}); - -const logout = os.auth.logout.handler(async ({ context }) => { - const ctx = context as AuthenticatedContext; - - // Revoke the current session by setting revoked_at - await ctx.db - .updateTable("sessions") - .set({ revoked_at: new Date() }) - .where("id", "=", ctx.session.id) - .execute(); - - return undefined; -}); +const logout = logoutHandler; // WebAuthn procedures const createRegistrationOptions = @@ -185,7 +112,9 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication.handler( ); if (!verified) { - throw new Error("Authentication failed"); + throw new ORPCError("BAD_REQUEST", { + message: "Authentication failed", + }); } return undefined; diff --git a/apps/api-server/src/utils/cookies.ts b/apps/api-server/src/utils/cookies.ts new file mode 100644 index 0000000..b2c17ca --- /dev/null +++ b/apps/api-server/src/utils/cookies.ts @@ -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`); +}; diff --git a/apps/api-server/src/utils/crypto.ts b/apps/api-server/src/utils/crypto.ts new file mode 100644 index 0000000..8193901 --- /dev/null +++ b/apps/api-server/src/utils/crypto.ts @@ -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); +}; diff --git a/apps/api-server/src/utils/email.ts b/apps/api-server/src/utils/email.ts new file mode 100644 index 0000000..ae11537 --- /dev/null +++ b/apps/api-server/src/utils/email.ts @@ -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 { + 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 { + 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 { + const url = `${getBaseUrl()}/auth/reset-password?token=${token}`; + console.log(`[EMAIL STUB] Password reset to ${email}`); + console.log(`[EMAIL STUB] Reset link: ${url}`); +} diff --git a/apps/api-server/src/utils/geo.ts b/apps/api-server/src/utils/geo.ts new file mode 100644 index 0000000..7c1d417 --- /dev/null +++ b/apps/api-server/src/utils/geo.ts @@ -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"; +}; diff --git a/apps/api-server/src/utils/password.ts b/apps/api-server/src/utils/password.ts index 5838287..015a0e1 100644 --- a/apps/api-server/src/utils/password.ts +++ b/apps/api-server/src/utils/password.ts @@ -1,58 +1,67 @@ -/** - * Password hashing utilities using scrypt from @noble/hashes - */ +import zxcvbn from "zxcvbn"; -import { scrypt as nobleScrypt } from "@noble/hashes/scrypt.js"; -import { randomBytes } from "@noble/hashes/utils.js"; - -// scrypt parameters: N=2^17, r=8, p=1, dkLen=32 -const N = 131072; -const r = 8; -const p = 1; -const dkLen = 32; +export interface PasswordValidationResult { + valid: boolean; + feedback: string[]; + score: number; +} /** - * Hash a password using scrypt - * Format: scrypt$17$8$1$$ + * 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 hashPassword = (password: string): string => { - const salt = randomBytes(16); - const hash = nobleScrypt(password, salt, { N, r, p, dkLen }); - return `scrypt$17$8$1$${Buffer.from(salt).toString("base64")}$${Buffer.from(hash).toString("base64")}`; +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 => { + return Bun.password.hash(password, { + algorithm: "argon2id", + memoryCost: 65536, // 64 MiB + timeCost: 3, + }); }; /** * Verify a password against a stored hash - * Uses constant-time comparison to prevent timing attacks + * @param password - The plaintext password to verify + * @param hash - The stored password hash + * @returns True if the password matches the hash */ -export const verifyPassword = (password: string, stored: string): boolean => { - const parts = stored.split("$"); - if (parts.length !== 5 || parts[0] !== "scrypt") { - return false; - } - - const saltStr = parts[3]; - const hashStr = parts[4]; - if (!(saltStr && hashStr)) { - return false; - } - - const salt = Buffer.from(saltStr, "base64"); - const storedHash = Buffer.from(hashStr, "base64"); - const computedHash = nobleScrypt(password, salt, { N, r, p, dkLen }); - - // Constant-time comparison - if (storedHash.length !== computedHash.length) { - return false; - } - let diff = 0; - for (let i = 0; i < storedHash.length; i++) { - const storedByte = storedHash[i]; - const computedByte = computedHash[i]; - if (storedByte === undefined || computedByte === undefined) { - return false; - } - diff |= storedByte ^ computedByte; - } - return diff === 0; +export const verifyPassword = async ( + password: string, + hash: string, +): Promise => { + return Bun.password.verify(password, hash); }; diff --git a/apps/api-server/src/utils/session.ts b/apps/api-server/src/utils/session.ts new file mode 100644 index 0000000..4e0549e --- /dev/null +++ b/apps/api-server/src/utils/session.ts @@ -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, + options: CreateSessionOptions, +): Promise { + 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, + userId: number, + deviceFingerprint: string, + geo: GeoInfo, + userAgent: string, +): Promise { + 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, + userId: number, + deviceFingerprint: string, +): Promise { + const device = await db + .selectFrom("user_devices") + .select(["is_trusted"]) + .where("user_id", "=", userId) + .where("device_fingerprint", "=", deviceFingerprint) + .executeTakeFirst(); + + return device?.is_trusted ?? false; +} diff --git a/apps/api-server/src/utils/webauthn.ts b/apps/api-server/src/utils/webauthn.ts index e605b2a..40575b4 100644 --- a/apps/api-server/src/utils/webauthn.ts +++ b/apps/api-server/src/utils/webauthn.ts @@ -23,7 +23,7 @@ import { formatPasskeyDate, parsePasskeyRow } from "./passkey-helpers.js"; /** * Known authenticator AAGUIDs mapped to friendly names */ -const KNOWN_AAGUIDS: Record = { +export const KNOWN_AAGUIDS: Record = { "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4": "Google Password Manager", "adce0002-35bc-c60a-648b-0b25f1f05503": "Chrome on Mac", "08987058-cadc-4b81-b6e1-30de50dcbe96": "Windows Hello", diff --git a/bun.lock b/bun.lock index 5f9d62e..5d8fb47 100644 --- a/bun.lock +++ b/bun.lock @@ -23,11 +23,13 @@ "@simplewebauthn/server": "^13.2.2", "@simplewebauthn/types": "^12.0.0", "kysely": "^0.28.2", + "zxcvbn": "^4.4.2", }, "devDependencies": { "@macalinao/eslint-config": "catalog:", "@macalinao/tsconfig": "catalog:", "@types/bun": "catalog:", + "@types/zxcvbn": "^4.4.5", "eslint": "catalog:", "typescript": "catalog:", }, @@ -455,6 +457,8 @@ "@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="], + "@types/zxcvbn": ["@types/zxcvbn@4.4.5", "", {}, "sha512-FZJgC5Bxuqg7Rhsm/bx6gAruHHhDQ55r+s0JhDh8CQ16fD7NsJJ+p8YMMQDhSQoIrSmjpqqYWA96oQVMNkjRyA=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.52.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/type-utils": "8.52.0", "@typescript-eslint/utils": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.52.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.52.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg=="], @@ -905,6 +909,8 @@ "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "zxcvbn": ["zxcvbn@4.4.2", "", {}, "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],