bf7be65b03
Jenkins Production Deployment
Reviewed-on: #12 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
163 lines
5.1 KiB
TypeScript
163 lines
5.1 KiB
TypeScript
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;
|