Merge branch 'master' into testing-improvements
This commit is contained in:
@@ -9,24 +9,41 @@ export const adminAuthCompleteLogin = os.admin.auth.completeLogin
|
||||
.use(authMiddleware)
|
||||
.use(superuserMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const loginRequest = await context.db
|
||||
const email = input.email.toLowerCase();
|
||||
|
||||
// First check if any login request exists for this email
|
||||
const anyRequest = await context.db
|
||||
.selectFrom("login_requests")
|
||||
.where("email", "=", input.email.toLowerCase())
|
||||
.where("completed_at", "is", null)
|
||||
.where("expires_at", ">", new Date())
|
||||
.where("email", "=", email)
|
||||
.orderBy("created_at", "desc")
|
||||
.select(["id"])
|
||||
.select(["id", "completed_at", "expires_at"])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!loginRequest) {
|
||||
if (!anyRequest) {
|
||||
throw new ORPCError("NOT_FOUND", {
|
||||
message: "No pending login request found",
|
||||
message: `No login request found for ${email}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already completed
|
||||
if (anyRequest.completed_at) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Login request already completed",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (new Date(anyRequest.expires_at) < new Date()) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message:
|
||||
"Login request expired (15 min limit). Start a new login flow.",
|
||||
});
|
||||
}
|
||||
|
||||
// Complete the login request
|
||||
await context.db
|
||||
.updateTable("login_requests")
|
||||
.set({ completed_at: new Date() })
|
||||
.where("id", "=", loginRequest.id)
|
||||
.where("id", "=", anyRequest.id)
|
||||
.execute();
|
||||
});
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
setCookie,
|
||||
} from "../../utils/cookies.js";
|
||||
import {
|
||||
generateBase58Token,
|
||||
generateDeviceFingerprint,
|
||||
generateExpiry,
|
||||
generateSecureBase58Token,
|
||||
} from "../../utils/crypto.js";
|
||||
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
|
||||
import { isDeviceTrusted } from "../../utils/session.js";
|
||||
@@ -62,7 +62,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
|
||||
if (!user) {
|
||||
// Generate placeholder token (base58) for anti-enumeration
|
||||
// This prevents attackers from knowing if an email exists based on response
|
||||
const placeholderToken = generateBase58Token();
|
||||
const placeholderToken = generateSecureBase58Token("login_");
|
||||
|
||||
// Set placeholder login request token cookie
|
||||
setCookie(
|
||||
@@ -107,7 +107,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
|
||||
|
||||
// Create login request with secure token
|
||||
const expiresAt = generateExpiry(COOKIE_DURATIONS.LOGIN_REQUEST);
|
||||
const token = generateBase58Token();
|
||||
const token = generateSecureBase58Token("login_");
|
||||
|
||||
await context.db
|
||||
.insertInto("login_requests")
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
*/
|
||||
|
||||
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
||||
import { generateExpiry, generateSecureToken } from "../../utils/crypto.js";
|
||||
import {
|
||||
generateExpiry,
|
||||
generateSecureBase58Token,
|
||||
} from "../../utils/crypto.js";
|
||||
import { sendPasswordResetEmail } from "../../utils/email.js";
|
||||
import { os } from "../base.js";
|
||||
|
||||
@@ -33,8 +36,8 @@ export const forgotPassword = os.auth.forgotPassword.handler(
|
||||
.where("user_id", "=", user.id)
|
||||
.execute();
|
||||
|
||||
// Generate secure token (64 hex chars)
|
||||
const token = generateSecureToken();
|
||||
// Generate secure base58 token
|
||||
const token = generateSecureBase58Token();
|
||||
|
||||
// Create password reset record with 1 hour expiry
|
||||
const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET);
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
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";
|
||||
@@ -47,6 +46,7 @@ export const loginPassword = os.auth.loginPassword.handler(
|
||||
"login_requests.id",
|
||||
"login_requests.user_id",
|
||||
"login_requests.email",
|
||||
"login_requests.token",
|
||||
"login_requests.device_fingerprint",
|
||||
"login_requests.expires_at",
|
||||
"login_requests.completed_at",
|
||||
@@ -106,19 +106,9 @@ export const loginPassword = os.auth.loginPassword.handler(
|
||||
.where("id", "=", result.id)
|
||||
.execute();
|
||||
} else {
|
||||
// Device is untrusted - generate confirmation token and send email
|
||||
const confirmationToken = generateSecureToken();
|
||||
|
||||
await context.db
|
||||
.updateTable("login_requests")
|
||||
.set({
|
||||
token: confirmationToken,
|
||||
})
|
||||
.where("id", "=", result.id)
|
||||
.execute();
|
||||
|
||||
// Send login confirmation email
|
||||
await sendLoginConfirmationEmail(result.email, confirmationToken);
|
||||
// Device is untrusted - send confirmation email with existing token
|
||||
// The same base58 token is used for both cookie lookup and email confirmation
|
||||
await sendLoginConfirmationEmail(result.email, result.token);
|
||||
}
|
||||
|
||||
// Return void (success)
|
||||
|
||||
@@ -5,13 +5,16 @@
|
||||
* 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)
|
||||
* 3. Generate new secure base58 token
|
||||
* 4. Create new email_verifications record with 24 hour expiry
|
||||
* 5. Send verification email (stubbed)
|
||||
*/
|
||||
|
||||
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
||||
import { generateExpiry, generateSecureToken } from "../../utils/crypto.js";
|
||||
import {
|
||||
generateExpiry,
|
||||
generateSecureBase58Token,
|
||||
} from "../../utils/crypto.js";
|
||||
import { sendVerificationEmail } from "../../utils/email.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
|
||||
@@ -30,8 +33,8 @@ export const resendVerificationEmail = os.auth.resendVerificationEmail
|
||||
.where("user_id", "=", context.user.id)
|
||||
.execute();
|
||||
|
||||
// Generate new secure token
|
||||
const token = generateSecureToken();
|
||||
// Generate new secure base58 token
|
||||
const token = generateSecureBase58Token();
|
||||
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION);
|
||||
|
||||
// Create new verification record
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
setCookie,
|
||||
TOKEN_DURATIONS,
|
||||
} from "../../utils/cookies.js";
|
||||
import { generateExpiry, generateSecureToken } from "../../utils/crypto.js";
|
||||
import {
|
||||
generateExpiry,
|
||||
generateSecureBase58Token,
|
||||
} from "../../utils/crypto.js";
|
||||
import { sendVerificationEmail } from "../../utils/email.js";
|
||||
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
|
||||
import { hashPassword, validatePassword } from "../../utils/password.js";
|
||||
@@ -262,7 +265,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
||||
);
|
||||
|
||||
// Generate verification token
|
||||
const verificationToken = generateSecureToken();
|
||||
const verificationToken = generateSecureBase58Token();
|
||||
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION);
|
||||
|
||||
// Store verification token (store raw token, not hash - it's already high-entropy)
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js";
|
||||
import { generateExpiry, generateSecureToken } from "../../utils/crypto.js";
|
||||
import {
|
||||
generateExpiry,
|
||||
generateSecureBase58Token,
|
||||
} from "../../utils/crypto.js";
|
||||
import { sendOrgInviteEmail } from "../../utils/email.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
|
||||
@@ -88,7 +91,7 @@ export const invitesCreate = os.orgs.invites.create
|
||||
}
|
||||
|
||||
// Generate invite token and expiry
|
||||
const token = generateSecureToken();
|
||||
const token = generateSecureBase58Token();
|
||||
const expiresAt = generateExpiry(ORG_INVITE_EXPIRY_DAYS * 24 * 60 * 60);
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
/**
|
||||
* Cookie configuration for authentication
|
||||
* All cookies use 'rev.' prefix, HttpOnly, Secure, SameSite=Lax
|
||||
*
|
||||
* Uses oRPC cookie helpers for proper cookie handling
|
||||
*/
|
||||
|
||||
export {
|
||||
deleteCookie,
|
||||
getCookie,
|
||||
setCookie,
|
||||
} from "@orpc/server/helpers";
|
||||
|
||||
export const COOKIE_NAMES = {
|
||||
SESSION_TOKEN: "rev.session_token",
|
||||
DEVICE_FINGERPRINT: "rev.device_fingerprint",
|
||||
@@ -39,71 +47,3 @@ export const COOKIE_OPTIONS = {
|
||||
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`);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { base58 } from "@scure/base";
|
||||
|
||||
// Re-export generateSecureBase58Token from shared utils
|
||||
export { generateSecureBase58Token } from "@reviq/utils";
|
||||
|
||||
/**
|
||||
* Token prefix for all RevIQ API tokens
|
||||
*/
|
||||
@@ -62,58 +65,6 @@ 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
|
||||
* Uses Web Crypto API for Cloudflare Workers compatibility
|
||||
*/
|
||||
export const generateSecureToken = (): string => {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
};
|
||||
|
||||
/**
|
||||
* Base58 alphabet (Bitcoin-style, no 0, O, I, l)
|
||||
*/
|
||||
const BASE58_ALPHABET =
|
||||
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure base58 token
|
||||
* Uses 24 bytes (192 bits) of entropy, producing ~33 character output
|
||||
*/
|
||||
export const generateBase58Token = (byteLength = 24): string => {
|
||||
const bytes = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(bytes);
|
||||
|
||||
// Convert bytes to base58
|
||||
let result = "";
|
||||
let num = BigInt(0);
|
||||
for (const byte of bytes) {
|
||||
num = num * 256n + BigInt(byte);
|
||||
}
|
||||
|
||||
while (num > 0n) {
|
||||
const remainder = Number(num % 58n);
|
||||
result = BASE58_ALPHABET.charAt(remainder) + result;
|
||||
num /= 58n;
|
||||
}
|
||||
|
||||
// Handle leading zeros
|
||||
for (const byte of bytes) {
|
||||
if (byte === 0) {
|
||||
result = BASE58_ALPHABET.charAt(0) + result;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate expiration date
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user