openreader/src/lib/server/auth/auth.ts
Richard R 3da13c2970 chore(logging): remove redundant errorCode fields from server logs and error contracts
Eliminate the errorCode property from server logger calls, error contract types, and API error bodies throughout the codebase. This reduces duplication and streamlines error reporting, relying on structured event names and error classes for identification. All affected API routes, server logic, and logging utilities have been updated for consistency. Documentation directory added for future reference.
2026-05-27 06:33:14 -06:00

360 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
import { anonymous } from "better-auth/plugins";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { db } from "@/db";
import { isAuthEnabled, isAnonymousAuthSessionsEnabled } from "@/lib/server/auth/config";
import { isAdminEmail, syncAdminFlag } from "@/lib/server/admin/email-sync";
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { assertUserSignupAllowed } from '@/lib/server/auth/signup-policy';
import * as authSchemaSqlite from "@/db/schema_auth_sqlite";
import * as authSchemaPostgres from "@/db/schema_auth_postgres";
import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger';
// Heavy modules (S3 SDK, blobstore, rate-limiter, claim-data) are loaded
// lazily via dynamic import() inside the beforeDelete / onLinkAccount
// callbacks to avoid inflating every serverless function that touches auth.
// ...
function tryGetOrigin(url: string | undefined): string | null {
if (!url) return null;
try {
return new URL(url).origin;
} catch {
return null;
}
}
function getTrustedOrigins(): string[] {
const origins = new Set<string>();
const baseOrigin = tryGetOrigin(process.env.BASE_URL);
if (baseOrigin) origins.add(baseOrigin);
// Comma-separated list for local multi-host setups (e.g., localhost + LAN IP).
const extra = (process.env.AUTH_TRUSTED_ORIGINS || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
for (const candidate of extra) {
const origin = tryGetOrigin(candidate);
if (origin) origins.add(origin);
}
return Array.from(origins);
}
function envFlagEnabled(name: string, defaultValue: boolean): boolean {
const raw = process.env[name];
if (!raw || raw.trim() === '') return defaultValue;
const normalized = raw.trim().toLowerCase();
if (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on') return true;
if (normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off') return false;
return defaultValue;
}
const authSchema = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite;
const createAuth = () => betterAuth({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
database: drizzleAdapter(db as any, {
provider: process.env.POSTGRES_URL ? "pg" : "sqlite",
schema: authSchema as Record<string, unknown>,
}),
secret: process.env.AUTH_SECRET!,
baseURL: process.env.BASE_URL!,
trustedOrigins: getTrustedOrigins(),
emailAndPassword: {
enabled: true,
requireEmailVerification: false, // Set to true in production
async sendResetPassword(data) {
// Send an email to the user with a link to reset their password
serverLogger.info({
event: 'auth.password_reset.requested',
userEmailHash: hashForLog(data.user.email),
}, 'Password reset requested');
},
},
user: {
additionalFields: {
isAdmin: {
type: 'boolean',
required: false,
defaultValue: false,
input: false, // never settable from the client; controlled by ADMIN_EMAILS
},
},
deleteUser: {
enabled: true,
beforeDelete: async (user) => {
try {
const { deleteUserStorageData } = await import('@/lib/server/user/data-cleanup');
await deleteUserStorageData(user.id, null);
} catch (error) {
serverLogger.warn({
event: 'auth.user_delete.storage_cleanup_failed',
degraded: true,
step: 'delete_user_storage',
userIdHash: hashForLog(user.id),
error: errorToLog(error),
}, 'Failed to clean up user storage before deletion');
// Don't throw allow the user deletion to proceed even if S3 cleanup fails.
// Orphaned blobs are preferable to a blocked account deletion.
}
},
},
},
databaseHooks: {
user: {
create: {
before: async (user) => {
const runtimeConfig = await getResolvedRuntimeConfig();
assertUserSignupAllowed({
enableUserSignups: runtimeConfig.enableUserSignups,
isAnonymous: Boolean((user as { isAnonymous?: boolean }).isAnonymous),
});
// Stamp newly-created users with the correct isAdmin value if their
// email matches ADMIN_EMAILS. This avoids a follow-up UPDATE on
// first signup. The `input: false` above prevents clients from
// forcing isAdmin=true through signup payloads.
if (isAdminEmail(user.email)) {
return { data: { ...user, isAdmin: true } };
}
return { data: user };
},
},
},
},
rateLimit: {
// Better Auth built-in rate limiting is enabled by default.
// Set DISABLE_AUTH_RATE_LIMIT=true to disable it.
enabled: !envFlagEnabled('DISABLE_AUTH_RATE_LIMIT', false),
},
socialProviders: {
...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && {
github: {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
},
}),
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days (reasonable for user experience)
updateAge: 60 * 60 * 1, // 1 hour (refresh more frequently)
cookieCache: {
maxAge: 60 * 5, // 5 minutes revalidate session against DB regularly
},
},
plugins: [
...(isAnonymousAuthSessionsEnabled()
? [
anonymous({
onLinkAccount: async ({ anonymousUser, newUser }) => {
try {
// Log when anonymous user links to a real account
serverLogger.info({
event: 'auth.link_account.started',
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
newUserEmailHash: hashForLog(newUser.user.email),
}, 'Anonymous user linked to account');
// Lazy-load heavy modules only when account linking actually happens
const [{ rateLimiter }, claimData] = await Promise.all([
import('@/lib/server/rate-limit/rate-limiter'),
import('@/lib/server/user/claim-data'),
]);
// Transfer rate limiting data (TTS char counts) from anonymous user to authenticated user
try {
await rateLimiter.transferAnonymousUsage(anonymousUser.user.id, newUser.user.id);
serverLogger.info({
event: 'auth.link_account.transfer.rate_limit.succeeded',
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
}, 'Transferred rate limit data during account linking');
} catch (error) {
serverLogger.warn({
event: 'auth.link_account.transfer.rate_limit.failed',
degraded: true,
step: 'transfer_rate_limit',
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
error: errorToLog(error),
}, 'Failed transferring rate limit data during account linking');
// Don't throw here to prevent blocking the account linking process
}
// Transfer audiobooks from anonymous user to new authenticated user
try {
const transferred = await claimData.transferUserAudiobooks(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
serverLogger.info({
event: 'auth.link_account.transfer.audiobooks.succeeded',
transferred,
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
}, 'Transferred audiobooks during account linking');
}
} catch (error) {
serverLogger.warn({
event: 'auth.link_account.transfer.audiobooks.failed',
degraded: true,
step: 'transfer_audiobooks',
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
error: errorToLog(error),
}, 'Failed transferring audiobooks during account linking');
// Don't throw here to prevent blocking the account linking process
}
// Transfer documents from anonymous user to new authenticated user
try {
const transferred = await claimData.transferUserDocuments(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
serverLogger.info({
event: 'auth.link_account.transfer.documents.succeeded',
transferred,
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
}, 'Transferred documents during account linking');
}
} catch (error) {
serverLogger.warn({
event: 'auth.link_account.transfer.documents.failed',
degraded: true,
step: 'transfer_documents',
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
error: errorToLog(error),
}, 'Failed transferring documents during account linking');
// Don't throw here to prevent blocking the account linking process
}
// Transfer preferences from anonymous user to new authenticated user
try {
const transferred = await claimData.transferUserPreferences(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
serverLogger.info({
event: 'auth.link_account.transfer.preferences.succeeded',
transferred,
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
}, 'Transferred preferences during account linking');
}
} catch (error) {
serverLogger.warn({
event: 'auth.link_account.transfer.preferences.failed',
degraded: true,
step: 'transfer_preferences',
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
error: errorToLog(error),
}, 'Failed transferring preferences during account linking');
// Don't throw here to prevent blocking the account linking process
}
// Transfer reading progress from anonymous user to new authenticated user
try {
const transferred = await claimData.transferUserProgress(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
serverLogger.info({
event: 'auth.link_account.transfer.progress.succeeded',
transferred,
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
}, 'Transferred reading progress during account linking');
}
} catch (error) {
serverLogger.warn({
event: 'auth.link_account.transfer.progress.failed',
degraded: true,
step: 'transfer_progress',
anonymousUserIdHash: hashForLog(anonymousUser.user.id),
newUserIdHash: hashForLog(newUser.user.id),
error: errorToLog(error),
}, 'Failed transferring reading progress during account linking');
// Don't throw here to prevent blocking the account linking process
}
} catch (error) {
serverLogger.error({
event: 'auth.link_account.failed',
error: errorToLog(error),
}, 'onLinkAccount callback failed');
// Don't throw here to prevent blocking the account linking process
}
// Note: Anonymous user will be automatically deleted after this callback completes
},
}),
]
: []),
// Better Auth requires cookie integration plugins last so post-hooks can
// still append Set-Cookie headers that are forwarded to Next.js.
nextCookies(),
],
});
export const auth = isAuthEnabled() ? createAuth() : null;
type AuthInstance = ReturnType<typeof createAuth>;
export type Session = AuthInstance["$Infer"]["Session"];
type AuthSessionUser = AuthInstance["$Infer"]["Session"]["user"];
export type User = AuthSessionUser & {
isAnonymous?: boolean;
};
export type AuthContext = {
authEnabled: boolean;
session: Session | null;
user: User | null;
userId: string | null;
};
export async function getAuthContext(request: Pick<NextRequest, 'headers'>): Promise<AuthContext> {
const authEnabled = isAuthEnabled();
if (!authEnabled || !auth) {
return { authEnabled, session: null, user: null, userId: null };
}
const session = await auth.api.getSession({ headers: request.headers });
const user = (session?.user ?? null) as User | null;
const userId = user?.id ?? null;
// Keep user.isAdmin in sync with ADMIN_EMAILS on every session resolution.
// Cheap when nothing changes (no-ops at the DB layer); promotes/demotes
// when the env list is edited. Skips anonymous users (no real email).
if (user && userId && user.email && !user.isAnonymous) {
const current = (user as unknown as { isAdmin?: boolean }).isAdmin ?? false;
const resolved = await syncAdminFlag(userId, user.email, current);
if (resolved !== current) {
(user as unknown as { isAdmin: boolean }).isAdmin = resolved;
}
}
return { authEnabled, session, user, userId };
}
export async function requireAuthContext(
request: Pick<NextRequest, 'headers'>,
options?: { requireNonAnonymous?: boolean },
): Promise<AuthContext | Response> {
const ctx = await getAuthContext(request);
if (!ctx.authEnabled) {
return ctx;
}
if (!ctx.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (options?.requireNonAnonymous && ctx.user?.isAnonymous) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return ctx;
}