import {betterAuth} from 'better-auth'; import {APIError} from 'better-auth/api'; import {passkey, getAuthenticatorName} from '@better-auth/passkey'; import {NachklangAdminDB} from './Admin.db.js'; import {invitationsPlugin} from './invitations/invitations.plugin.js'; import {sendPasswordResetMail} from './admin.mail.js'; import * as UsersService from './users/users.admin.service.js'; import logger from '../../middleware/logger.js'; import { ADMIN_ALLOWED_ORIGINS, API_BASE_URL, BETTER_AUTH_SECRET, CLIENT_IP_HEADERS, PASSKEY_RP_ID, TRUSTED_PROXY_IPS, isProd } from './admin.config.js'; /** * The single better-auth instance for all *.nachklang.art apps. Mounted in * app.ts at /admin/auth/* with better-auth's own node handler, ahead of * express.json() (it needs the raw body stream). * * The session cookie is what every app trusts. Everything else in this module - * permissions, invitations, the admin UI - hangs off it. */ const DAY = 60 * 60 * 24; // Dev runs the apps on plain localhost ports; cookies ignore the port, so // single-sign-on across them works without fake subdomains or mkcert. const localhostOrigins = [ 'http://localhost:3000', 'http://localhost:3001', 'http://localhost:3002', 'http://localhost:3003' ]; const trustedOrigins = isProd ? ADMIN_ALLOWED_ORIGINS : Array.from(new Set([...ADMIN_ALLOWED_ORIGINS, ...localhostOrigins])); export const auth = betterAuth({ appName: 'Nachklang', database: { dialect: NachklangAdminDB.dialect, type: 'mysql' }, basePath: '/admin/auth', // Mandatory once crossSubDomainCookies is on: better-auth derives the // cookie domain and its own absolute URLs from this. baseURL: API_BASE_URL, secret: BETTER_AUTH_SECRET, trustedOrigins, emailAndPassword: { enabled: true, // There is no public sign-up: accounts exist only through an // invitation (see invitations.plugin.ts). This also makes // auth.api.signUpEmail throw, which is intended. disableSignUp: true, sendResetPassword: async ({user, url}) => { await sendPasswordResetMail(user.email, user.name, url); } }, user: { additionalFields: { // Not `returned`, and not settable through the API: disabling is an // admin action on /admin/users/:id/disable, never something a // session owner can flip on themselves. disabled: { type: 'boolean', defaultValue: false, input: false, returned: false } } }, session: { expiresIn: 30 * DAY, updateAge: DAY // Deliberately no cookieCache: requireAppAccess hits the database on // every request anyway, and a cached session would keep a disabled // user or a revoked session alive for the cache's lifetime. }, advanced: { // Fixes the cookie name across releases so the frontends' middleware can // check for it: "nachklang.session_token", or // "__Secure-nachklang.session_token" over https. cookiePrefix: 'nachklang', crossSubDomainCookies: isProd ? {enabled: true, domain: '.nachklang.art'} : {enabled: false}, ipAddress: { // better-auth reads the request itself and does not know about // Express's `trust proxy`, so both the header and the trusted hops // have to be named here. Getting this wrong does not fail loudly - // it collapses every client into one rate-limit bucket. See the // commentary on CLIENT_IP_HEADERS in admin.config.ts. ipAddressHeaders: CLIENT_IP_HEADERS, ...(TRUSTED_PROXY_IPS.length > 0 ? {trustedProxies: TRUSTED_PROXY_IPS} : {}) } }, rateLimit: { enabled: true, // Passenger may run more than one instance; an in-memory limiter would // then give each of them its own budget. storage: 'database' }, plugins: [ passkey({ rpID: PASSKEY_RP_ID, rpName: 'Nachklang', origin: ADMIN_ALLOWED_ORIGINS, registration: { // Without this, every passkey is stored with name = NULL and the // account page can only label them all "Passkey" - useless at the // one moment that list matters, when someone has to remove the // passkey on the device they just lost. // // The AAGUID identifies the authenticator *model* (not a device // and not a person), and better-auth ships the lookup table, so // this yields "1Password", "iCloud Keychain", "Windows Hello". // It only fills a blank: a name the client sent always wins, and // an unknown AAGUID leaves the column NULL as before. afterVerification: async ({verification}) => { const name = getAuthenticatorName(verification.registrationInfo?.aaguid); return name ? {name} : undefined; } } }), invitationsPlugin() ], databaseHooks: { session: { create: { before: async session => { const access = await UsersService.loadAccess(session.userId); if (access?.disabled) { logger.warn('Admin: sign-in attempt by a disabled account', {userId: session.userId}); // Throwing rather than returning false: `false` aborts // the session write silently and the caller sees a // confusing success-shaped response with no cookie. throw new APIError('FORBIDDEN', { code: 'ACCOUNT_DISABLED', message: 'Dieses Konto ist deaktiviert.' }); } } } } } }); export type AdminAuth = typeof auth;