Add admin identity module: better-auth, per-app permissions, invitations (#12)
Jenkins Production Deployment
Jenkins Production Deployment
Reviewed-on: #12 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
This commit was merged in pull request #12.
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user