33489585a0
Two changes to the admin module, both made now because it is not deployed yet
and neither is free later.
Permissions were "an app", with a `role` column reserved for a future
fine-grained model. Reviewing whether that reservation was enough found three
problems:
- Every row was written with role = 'admin', hardcoded, and the column
defaulted to it. On a `tickets` row that reads as "tickets administrator"
when it only ever meant "has access", and once real roles existed there
would have been no way to tell an old plain grant from a deliberate one.
- The role never left the database. /admin/me, the user list, the user detail
and both write endpoints all spoke apps: AppName[]. Adding roles would have
been a breaking change to /admin/me - and after the cutover that endpoint
has two more consumers, turning a local edit into a coordinated deploy of
three apps.
- The key (user_id, app) allowed one role per app, i.e. a tier rather than a
set of capabilities. Choosing later means an ALTER on a live table.
So: the key is now (user_id, app, role), the role is `access`, and APP_ROLES
in admin.schema.ts is the contract - a role not listed there is rejected with
400 rather than written. permissions: [{app, role}] is on the wire alongside
the derived apps: AppName[], which is kept because the three frontends only
ever ask "may I show this app?". Both write endpoints accept either shape, and
the invitation column (now `permissions`) is parsed leniently: invitations live
seven days, so a deploy that changes the shape has in-flight rows in the old
one. requireAppAccess(app, role?) takes an optional role; nothing passes one
yet.
countActiveAdminsForUpdate now counts DISTINCT users rather than rows. With
several roles per app, counting rows would make a single admin holding two
roles look like two admins and defeat the last-admin guard at exactly the
moment it matters.
Separately, passkey registration now fills `name` from the authenticator's
AAGUID via registration.afterVerification and better-auth's own
getAuthenticatorName, yielding "1Password", "iCloud Keychain", "Windows Hello".
Without it the column stayed NULL and the account page could only label every
passkey "Passkey" - useless when someone has to remove the one on the device
they just lost. A client-supplied name still wins; an unknown AAGUID still
leaves it blank.
148 unit tests (up from 131, including the new admin.schema.test.ts) and 41
integration tests pass. The integration suite applies sql/admin/001_init.sql,
so the new key is exercised rather than trusted.
No production migration is needed - the module is not deployed. An existing dev
database needs three statements: set role = 'access', drop and re-add the
primary key, rename invitations.apps to permissions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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;
|