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:
148
apps/api-server/src/procedures/auth/create-login-request.ts
Normal file
148
apps/api-server/src/procedures/auth/create-login-request.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
);
|
||||
62
apps/api-server/src/procedures/auth/forgot-password.ts
Normal file
62
apps/api-server/src/procedures/auth/forgot-password.ts
Normal file
@@ -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
|
||||
},
|
||||
);
|
||||
170
apps/api-server/src/procedures/auth/login-if-completed.ts
Normal file
170
apps/api-server/src/procedures/auth/login-if-completed.ts
Normal file
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
147
apps/api-server/src/procedures/auth/login-password.ts
Normal file
147
apps/api-server/src/procedures/auth/login-password.ts
Normal file
@@ -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)
|
||||
},
|
||||
);
|
||||
32
apps/api-server/src/procedures/auth/logout.ts
Normal file
32
apps/api-server/src/procedures/auth/logout.ts
Normal file
@@ -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);
|
||||
},
|
||||
);
|
||||
54
apps/api-server/src/procedures/auth/resend-verification.ts
Normal file
54
apps/api-server/src/procedures/auth/resend-verification.ts
Normal file
@@ -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);
|
||||
},
|
||||
);
|
||||
92
apps/api-server/src/procedures/auth/reset-password.ts
Normal file
92
apps/api-server/src/procedures/auth/reset-password.ts
Normal file
@@ -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
|
||||
},
|
||||
);
|
||||
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);
|
||||
});
|
||||
61
apps/api-server/src/procedures/auth/verify-email.ts
Normal file
61
apps/api-server/src/procedures/auth/verify-email.ts
Normal file
@@ -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();
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user