Signing in showed the "Welcome back!" toast but never left /login. The session cookie was host-only on the API subdomain, so the Next middleware guard on the site origin saw no cookie and bounced /dashboard straight back to /login?redirect=/dashboard. - Add AUTH_COOKIE_DOMAIN, wiring Better Auth's crossSubDomainCookies so the cookie also reaches the site origin. Unset in dev, where localhost is single-host and must stay host-only. - Navigate after authentication with a full page load, via a shared authRedirect helper: only a top-level request carries the httpOnly cookie. Used by the login, register, magic-link and Google flows. - Show "Redirecting..." on the login and register pages and keep the submit button disabled until the browser replaces the page, instead of re-enabling it mid-navigation. - Guard against a redirect loop with a sessionStorage marker. A React ref cannot do this: the full page load resets component state. If the destination bounces back, explain it rather than navigating again. - Middleware: accept any *.session_token cookie so a cookiePrefix change cannot lock everyone out, and preserve the destination's query string. - Trust any loopback port in dev, so reaching the dev server through a forwarded port does not fail Better Auth's CSRF origin check. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
import { betterAuth } from 'better-auth';
|
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
|
import { magicLink, admin } from 'better-auth/plugins';
|
|
import { APIError, createAuthMiddleware } from 'better-auth/api';
|
|
import { eq, and } from 'drizzle-orm';
|
|
import { db, dbGet, dbAll, isPostgres } from '../db/index.js';
|
|
import {
|
|
authUsers,
|
|
authSessions,
|
|
authAccounts,
|
|
authVerifications,
|
|
authRateLimits,
|
|
} from '../db/auth-schema.js';
|
|
import { generateId } from './utils.js';
|
|
import { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
|
|
import { sendEmail } from './email.js';
|
|
import { getLoginLockout } from './stores/loginLockout.js';
|
|
|
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
|
|
|
|
// Cookie domain shared across subdomains (e.g. ".spanglishcommunity.com") so a session
|
|
// issued by api.* is also sent to the site origin, where the frontend's Next middleware
|
|
// reads it to gate /admin and /dashboard. Leave unset in dev: localhost is single-host
|
|
// and needs a host-only cookie.
|
|
const cookieDomain = process.env.AUTH_COOKIE_DOMAIN?.trim();
|
|
|
|
const DEFAULT_DEV_SECRET = 'spanglish-dev-only-better-auth-secret';
|
|
const rawSecret = process.env.BETTER_AUTH_SECRET;
|
|
|
|
// Never allow a weak/default secret in production: the secret signs session
|
|
// cookies, so a guessable value means forgeable sessions = account takeover.
|
|
if (isProduction && (!rawSecret || rawSecret.length < 32 || rawSecret === DEFAULT_DEV_SECRET)) {
|
|
throw new Error(
|
|
'BETTER_AUTH_SECRET must be set to a strong value (32+ characters) in production. Refusing to start.'
|
|
);
|
|
}
|
|
if (!rawSecret) {
|
|
console.warn('[auth] BETTER_AUTH_SECRET is not set; using an insecure development default.');
|
|
}
|
|
|
|
// The site origin plus its www/non-www alias, mirroring the CORS allowlist in
|
|
// index.ts. Requests whose Origin is not listed here are rejected by Better
|
|
// Auth's CSRF origin check.
|
|
function computeTrustedOrigins(): string[] {
|
|
const origins = new Set<string>([frontendUrl]);
|
|
try {
|
|
const url = new URL(frontendUrl);
|
|
const alias = url.hostname.startsWith('www.')
|
|
? url.hostname.slice(4)
|
|
: `www.${url.hostname}`;
|
|
origins.add(`${url.protocol}//${alias}${url.port ? `:${url.port}` : ''}`);
|
|
} catch {
|
|
/* keep frontendUrl as-is */
|
|
}
|
|
if (process.env.API_URL) origins.add(process.env.API_URL);
|
|
if (!isProduction) {
|
|
// Dev is frequently reached through a forwarded or proxied port (SSH tunnel, editor
|
|
// port forwarding), so the browser's Origin is http://localhost:<random> and every
|
|
// POST would fail the CSRF origin check. Trust any loopback port rather than pinning
|
|
// FRONTEND_URL to a port that changes between sessions. Wildcard patterns are matched
|
|
// per better-auth's trusted-origins helper; production stays on the exact allowlist.
|
|
origins.add('http://localhost:*');
|
|
origins.add('http://127.0.0.1:*');
|
|
}
|
|
return [...origins];
|
|
}
|
|
|
|
// Paths whose request body carries a new password that must satisfy the policy
|
|
// (Better Auth's minPasswordLength alone is weaker than the app's policy).
|
|
const PASSWORD_SETTING_PATHS = new Set([
|
|
'/sign-up/email',
|
|
'/reset-password',
|
|
'/change-password',
|
|
'/set-password',
|
|
]);
|
|
|
|
async function getCredentialAccount(userId: string): Promise<any | null> {
|
|
return dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(authAccounts)
|
|
.where(
|
|
and(
|
|
eq((authAccounts as any).userId, userId),
|
|
eq((authAccounts as any).providerId, 'credential')
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
export const auth = betterAuth({
|
|
appName: 'Spanglish',
|
|
baseURL: process.env.BETTER_AUTH_URL || frontendUrl,
|
|
basePath: '/api/auth',
|
|
secret: rawSecret || DEFAULT_DEV_SECRET,
|
|
trustedOrigins: computeTrustedOrigins(),
|
|
telemetry: { enabled: false },
|
|
|
|
database: drizzleAdapter(db as any, {
|
|
provider: isPostgres() ? 'pg' : 'sqlite',
|
|
schema: {
|
|
user: authUsers,
|
|
session: authSessions,
|
|
account: authAccounts,
|
|
verification: authVerifications,
|
|
rateLimit: authRateLimits,
|
|
},
|
|
// better-sqlite3 cannot run Drizzle's async transactions; operations run
|
|
// sequentially instead (also the adapter default).
|
|
transaction: false,
|
|
}),
|
|
|
|
advanced: {
|
|
cookiePrefix: 'spanglish',
|
|
useSecureCookies: isProduction,
|
|
// Only adds a `Domain=` attribute — name, sameSite, secure and path are unchanged,
|
|
// so the cookie names hardcoded in the frontend middleware and the Go photo-api
|
|
// stay valid.
|
|
...(cookieDomain
|
|
? { crossSubDomainCookies: { enabled: true, domain: cookieDomain } }
|
|
: {}),
|
|
ipAddress: {
|
|
// Set by the /api/auth/* mount in index.ts from getClientIp(), which
|
|
// anchors trust in the TCP peer address (only our own proxies may speak
|
|
// for the client via X-Real-IP / X-Forwarded-For). Never read the raw
|
|
// forwarded headers here: without the socket they are spoofable, and
|
|
// Better Auth would fall back to one shared rate-limit bucket.
|
|
ipAddressHeaders: ['x-client-ip'],
|
|
},
|
|
database: {
|
|
// Match the app's existing ID convention (uuid on pg, nanoid on sqlite)
|
|
// so Better Auth rows are indistinguishable from legacy rows.
|
|
generateId: () => generateId(),
|
|
},
|
|
},
|
|
|
|
user: {
|
|
additionalFields: {
|
|
// `role`, `banned`, `banReason`, `banExpires` come from the admin plugin.
|
|
phone: { type: 'string', required: false, input: true },
|
|
languagePreference: { type: 'string', required: false, input: true },
|
|
rucNumber: { type: 'string', required: false, input: false },
|
|
isClaimed: { type: 'boolean', required: false, input: false, defaultValue: true },
|
|
accountStatus: { type: 'string', required: false, input: false, defaultValue: 'active' },
|
|
},
|
|
},
|
|
|
|
session: {
|
|
expiresIn: 60 * 60 * 24 * 7, // 7 days, rolling
|
|
updateAge: 60 * 60 * 24, // refresh expiry at most once a day
|
|
freshAge: 60 * 60 * 24, // sensitive operations require a session younger than this
|
|
// Disabled deliberately: every request validates against the session table,
|
|
// so ban/suspend/password-reset revocations apply instantly — and the Go
|
|
// photo-api (which reads the same table) can never disagree with us.
|
|
cookieCache: { enabled: false },
|
|
storeSessionInDatabase: true,
|
|
},
|
|
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
minPasswordLength: 10,
|
|
maxPasswordLength: 128,
|
|
autoSignIn: true,
|
|
requireEmailVerification: false,
|
|
revokeSessionsOnPasswordReset: true,
|
|
resetPasswordTokenExpiresIn: 60 * 30, // 30 minutes, matches the legacy flow
|
|
sendResetPassword: async ({ user, url }) => {
|
|
try {
|
|
await sendEmail({
|
|
to: user.email,
|
|
subject: 'Reset Your Spanglish Password',
|
|
html: `
|
|
<h2>Reset Your Password</h2>
|
|
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
|
|
<p><a href="${url}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
|
|
<p>Or copy this link: ${url}</p>
|
|
<p>If you didn't request this, you can safely ignore this email.</p>
|
|
`,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to send password reset email:', error);
|
|
}
|
|
},
|
|
password: {
|
|
// Keep the existing argon2id parameters; legacy bcrypt hashes (migrated
|
|
// into auth_accounts) still verify and are upgraded on login below.
|
|
hash: (password) => hashPassword(password),
|
|
verify: ({ hash, password }) => verifyPassword(password, hash),
|
|
},
|
|
},
|
|
|
|
...(process.env.GOOGLE_CLIENT_ID
|
|
? {
|
|
socialProviders: {
|
|
google: {
|
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
|
// Not required for ID-token (Google Identity Services) sign-in,
|
|
// only for the redirect OAuth flow.
|
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
|
},
|
|
},
|
|
}
|
|
: {}),
|
|
|
|
account: {
|
|
accountLinking: {
|
|
enabled: true,
|
|
// Google verifies email ownership, so linking by email is safe — this
|
|
// matches the legacy /api/auth/google auto-link behavior.
|
|
trustedProviders: ['google'],
|
|
},
|
|
},
|
|
|
|
rateLimit: {
|
|
// Explicitly enabled so dev behaves like production (off in dev by default).
|
|
enabled: true,
|
|
window: 60,
|
|
max: 100,
|
|
// DB-backed rather than Redis: our Redis layer is fail-open by design,
|
|
// which is the wrong default for auth rate limiting. The per-email login
|
|
// lockout below is already Redis-shared across replicas.
|
|
storage: 'database',
|
|
modelName: 'rateLimit',
|
|
customRules: {
|
|
'/sign-in/email': { window: 900, max: 10 },
|
|
'/sign-up/email': { window: 900, max: 10 },
|
|
'/sign-in/magic-link': { window: 900, max: 5 },
|
|
'/magic-link/verify': { window: 900, max: 30 },
|
|
'/request-password-reset': { window: 900, max: 5 },
|
|
'/reset-password': { window: 900, max: 10 },
|
|
'/sign-in/social': { window: 900, max: 20 },
|
|
'/change-password': { window: 900, max: 10 },
|
|
},
|
|
},
|
|
|
|
databaseHooks: {
|
|
user: {
|
|
create: {
|
|
before: async (user) => {
|
|
// First user to register becomes admin (replaces isFirstUser())
|
|
const existing = await dbAll<any>((db as any).select().from(authUsers).limit(1));
|
|
if (!existing || existing.length === 0) {
|
|
return { data: { ...user, role: 'admin' } };
|
|
}
|
|
},
|
|
},
|
|
},
|
|
},
|
|
|
|
hooks: {
|
|
before: createAuthMiddleware(async (ctx) => {
|
|
// Enforce the full password policy (character classes + blocklist) on
|
|
// every password-setting path, for both client and server-side calls.
|
|
if (PASSWORD_SETTING_PATHS.has(ctx.path)) {
|
|
const password = ctx.body?.password ?? ctx.body?.newPassword;
|
|
if (typeof password === 'string') {
|
|
const result = validatePassword(password);
|
|
if (!result.valid) {
|
|
throw new APIError('BAD_REQUEST', { message: result.error });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Per-email lockout: 5 failures / 15 min, Redis-shared when configured.
|
|
// Kept as defense-in-depth on top of Better Auth's per-IP rate limits.
|
|
if (ctx.path === '/sign-in/email' && typeof ctx.body?.email === 'string') {
|
|
const lockout = await getLoginLockout().isLocked(ctx.body.email);
|
|
if (lockout.locked) {
|
|
throw new APIError('TOO_MANY_REQUESTS', {
|
|
message: 'Too many login attempts. Please try again later.',
|
|
});
|
|
}
|
|
}
|
|
}),
|
|
after: createAuthMiddleware(async (ctx) => {
|
|
if (ctx.path !== '/sign-in/email' || typeof ctx.body?.email !== 'string') return;
|
|
const email = ctx.body.email as string;
|
|
|
|
if (ctx.context.returned instanceof APIError) {
|
|
// Failed sign-in attempt counts toward the per-email lockout
|
|
await getLoginLockout().recordFailure(email);
|
|
return;
|
|
}
|
|
|
|
await getLoginLockout().clear(email);
|
|
|
|
// Transparently upgrade legacy bcrypt hashes to argon2 now that we have
|
|
// the verified plaintext. Best-effort: never block the login.
|
|
try {
|
|
const password = ctx.body?.password;
|
|
const userId = (ctx.context.newSession?.user as any)?.id;
|
|
if (typeof password === 'string' && userId) {
|
|
const account = await getCredentialAccount(userId);
|
|
if (account?.password && !String(account.password).startsWith('$argon2')) {
|
|
const upgraded = await hashPassword(password);
|
|
await (db as any)
|
|
.update(authAccounts)
|
|
.set({ password: upgraded, updatedAt: new Date() })
|
|
.where(eq((authAccounts as any).id, account.id));
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
console.error('[auth] Failed to upgrade legacy password hash:', err?.message || err);
|
|
}
|
|
}),
|
|
},
|
|
|
|
plugins: [
|
|
magicLink({
|
|
expiresIn: 60 * 10, // 10 minutes, matches the legacy flow
|
|
// Magic links never create accounts (parity with the legacy behavior;
|
|
// account creation is register / Google / guest booking only).
|
|
disableSignUp: true,
|
|
// Hashed at rest: a leaked verification table cannot be replayed.
|
|
storeToken: 'hashed',
|
|
sendMagicLink: async ({ email, url, token }) => {
|
|
// Email a frontend URL (not the raw API verify URL) so the login
|
|
// completes on the site, preserving the legacy UX. The page calls
|
|
// authClient.magicLink.verify with the token.
|
|
let callbackURL = '/';
|
|
try {
|
|
callbackURL = new URL(url).searchParams.get('callbackURL') || '/';
|
|
} catch {
|
|
/* default */
|
|
}
|
|
const link = `${frontendUrl}/auth/magic-link?token=${encodeURIComponent(token)}&callbackURL=${encodeURIComponent(callbackURL)}`;
|
|
const isClaim = callbackURL.startsWith('/auth/claim-account');
|
|
try {
|
|
await sendEmail({
|
|
to: email,
|
|
subject: isClaim ? 'Claim Your Spanglish Account' : 'Your Spanglish Login Link',
|
|
html: isClaim
|
|
? `
|
|
<h2>Claim Your Account</h2>
|
|
<p>An account was created for you during booking. Click below to set up your login credentials. This link expires in 10 minutes.</p>
|
|
<p><a href="${link}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
|
|
<p>Or copy this link: ${link}</p>
|
|
<p>If you didn't request this, you can safely ignore this email.</p>
|
|
`
|
|
: `
|
|
<h2>Login to Spanglish</h2>
|
|
<p>Click the link below to log in. This link expires in 10 minutes.</p>
|
|
<p><a href="${link}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
|
|
<p>Or copy this link: ${link}</p>
|
|
<p>If you didn't request this, you can safely ignore this email.</p>
|
|
`,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to send magic link email:', error);
|
|
}
|
|
},
|
|
}),
|
|
admin({
|
|
defaultRole: 'user',
|
|
adminRoles: ['admin'],
|
|
bannedUserMessage: 'Account is suspended. Please contact support.',
|
|
}),
|
|
],
|
|
});
|
|
|
|
export type Auth = typeof auth;
|