Add admin identity module: better-auth, per-app permissions, invitations
Introduces src/models/admin/, a dedicated identity and permissions module on its own nachklang_admin database, and the shared authenticator that feedback and tickets will move onto in the cutover step. Nothing swaps over yet: feedback.auth.ts and tickets.auth.ts still authenticate against the legacy calendar sessions, so production behaviour is unchanged. - better-auth 1.7 mounted at /admin/auth/*, sessions as httpOnly cookies scoped to .nachklang.art so one sign-in covers every *.nachklang.art app. - Accounts are invite-only: public sign-up is disabled, and the invitations plugin is the only code that creates users. Tokens are stored as SHA-256 hashes and travel in the request body, never in a URL. - Per-app permissions in user_app_permissions; requireAppAccess(app) queries the database on every request (no cookie cache) so disabling a user or revoking a session takes effect immediately. - ADMIN_BOOTSTRAP_EMAIL guarantees a way in on an empty database, idempotently and without crashing the API if the database is unreachable at boot. - Guards prevent an admin from removing their own admin permission, disabling themselves, or stripping the last active admin. The admin pool uses the callback-style mysql2, not mysql2/promise: Kysely's MysqlDialect drives the pool with callbacks, and the promise wrapper ignores them, so every query hangs silently. Only the integration tests caught this. Schema in sql/admin/001_init.sql, derived from getAuthTables() on the installed better-auth rather than the published CLI, which lags the library and omits account.issuer. app.ts is split into src/app.factory.ts so the integration tests drive the real middleware order rather than a copy of it. Tests: 131 unit, plus 41 integration tests against a throwaway MariaDB started by test/integration/setup.ts (docker or podman). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import * as z from 'zod';
|
||||
import {APIError, createAuthEndpoint} from 'better-auth/api';
|
||||
import {setSessionCookie} from 'better-auth/cookies';
|
||||
import {createLocalAccountIssuer} from 'better-auth/db';
|
||||
import type {BetterAuthPlugin} from 'better-auth';
|
||||
import * as InvitationsService from './invitations.service.js';
|
||||
import * as UsersService from '../users/users.admin.service.js';
|
||||
import logger from '../../../middleware/logger.js';
|
||||
|
||||
/**
|
||||
* The two public endpoints of the invitation flow, implemented as a better-auth
|
||||
* plugin rather than as plain Express routes on the admin router.
|
||||
*
|
||||
* Why a plugin: `emailAndPassword.disableSignUp` is on, which makes
|
||||
* `auth.api.signUpEmail` refuse - deliberately, there is no public sign-up.
|
||||
* Accepting an invitation still has to create a user, hash a password, write a
|
||||
* credential account and sign the person in. All four are better-auth
|
||||
* internals reachable only from inside an endpoint's context, so this is where
|
||||
* account creation lives. Nothing outside this file may create users.
|
||||
*
|
||||
* Because they are plugin endpoints they sit under better-auth's basePath:
|
||||
* POST /admin/auth/invitations/preview
|
||||
* POST /admin/auth/invitations/accept
|
||||
*
|
||||
* The token travels in the request *body*, never in the path or query, so it
|
||||
* cannot end up in an access log or a Referer header.
|
||||
*/
|
||||
|
||||
// Unknown, expired, revoked and already-accepted tokens must be
|
||||
// indistinguishable to the caller: one shared error, one shared message.
|
||||
const invalidToken = (): APIError => {
|
||||
return new APIError('BAD_REQUEST', {
|
||||
code: 'INVALID_INVITATION',
|
||||
message: 'Diese Einladung ist nicht mehr gültig.'
|
||||
});
|
||||
};
|
||||
|
||||
export const invitationsPlugin = () => {
|
||||
return {
|
||||
id: 'nachklang-invitations',
|
||||
endpoints: {
|
||||
/**
|
||||
* Lets the accept-invite page show who the invitation is for before
|
||||
* asking for a password. Returns only name and email - never the
|
||||
* granted apps, which is information the invitee has no need for
|
||||
* and an attacker with a stolen link should not get either.
|
||||
*/
|
||||
previewInvitation: createAuthEndpoint(
|
||||
'/invitations/preview',
|
||||
{
|
||||
method: 'POST',
|
||||
body: z.object({
|
||||
token: z.string().min(1)
|
||||
})
|
||||
},
|
||||
async ctx => {
|
||||
const invitation = await InvitationsService.findByToken(ctx.body.token);
|
||||
if (!invitation) {
|
||||
throw invalidToken();
|
||||
}
|
||||
|
||||
return ctx.json({email: invitation.email, name: invitation.name});
|
||||
}
|
||||
),
|
||||
|
||||
/**
|
||||
* Redeems the invitation: creates the user, its credential account
|
||||
* and its permissions, then signs the person straight in so they
|
||||
* land in the app instead of on a login form.
|
||||
*/
|
||||
acceptInvitation: createAuthEndpoint(
|
||||
'/invitations/accept',
|
||||
{
|
||||
method: 'POST',
|
||||
body: z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(8).max(128)
|
||||
})
|
||||
},
|
||||
async ctx => {
|
||||
const invitation = await InvitationsService.findByToken(ctx.body.token);
|
||||
if (!invitation) {
|
||||
throw invalidToken();
|
||||
}
|
||||
|
||||
// An account for this address already exists: the right fix
|
||||
// is for an admin to grant permissions on the existing user,
|
||||
// not to create a second one. Reported distinctly because
|
||||
// the person holds a valid token - this leaks nothing they
|
||||
// do not already know about their own mailbox.
|
||||
const existing = await UsersService.findUserByEmail(invitation.email);
|
||||
if (existing) {
|
||||
throw new APIError('CONFLICT', {
|
||||
code: 'USER_ALREADY_EXISTS',
|
||||
message: 'Für diese E-Mail-Adresse gibt es bereits ein Konto. Melde dich stattdessen an.'
|
||||
});
|
||||
}
|
||||
|
||||
// Claim the invitation before creating anything. The update
|
||||
// is conditional on it still being open, so two concurrent
|
||||
// submissions of the same link cannot both end up creating a
|
||||
// user.
|
||||
const claimed = await InvitationsService.markAccepted(invitation.id);
|
||||
if (!claimed) {
|
||||
throw invalidToken();
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await ctx.context.internalAdapter.createUser(
|
||||
{
|
||||
email: invitation.email,
|
||||
name: invitation.name,
|
||||
// Accepting a link sent to that mailbox *is* the
|
||||
// proof of address ownership, so there is no
|
||||
// separate verification mail (plan decision 15).
|
||||
emailVerified: true,
|
||||
disabled: false
|
||||
},
|
||||
{method: 'email-password'}
|
||||
);
|
||||
|
||||
// Same call better-auth's own sign-up route makes, down to
|
||||
// the synthetic issuer - a credential account written any
|
||||
// other way would not be found on sign-in.
|
||||
await ctx.context.internalAdapter.linkAccount({
|
||||
userId: user.id,
|
||||
providerId: 'credential',
|
||||
issuer: createLocalAccountIssuer('credential'),
|
||||
accountId: user.id,
|
||||
password: await ctx.context.password.hash(ctx.body.password)
|
||||
});
|
||||
|
||||
await UsersService.setPermissions(user.id, invitation.apps, null);
|
||||
|
||||
const session = await ctx.context.internalAdapter.createSession(user.id);
|
||||
await setSessionCookie(ctx, {session, user});
|
||||
|
||||
return ctx.json({
|
||||
user: {id: user.id, email: user.email, name: user.name}
|
||||
});
|
||||
} catch (e: any) {
|
||||
// The invitation is already marked accepted at this
|
||||
// point. Leaving it that way is deliberate: a token that
|
||||
// has been through a half-completed account creation
|
||||
// should not stay usable. The admin can send a new
|
||||
// invitation, and this log says why one is needed.
|
||||
logger.error('Admin: invitation accepted but account creation failed', {
|
||||
invitationId: invitation.id,
|
||||
detail: e?.message
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} satisfies BetterAuthPlugin;
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import express, {Request, Response} from 'express';
|
||||
import * as InvitationsService from './invitations.service.js';
|
||||
import * as UsersService from '../users/users.admin.service.js';
|
||||
import {isAppName, AppName} from '../admin.schema.js';
|
||||
import {sendInvitationMail} from '../admin.mail.js';
|
||||
import {ADMIN_APP_URL} from '../admin.config.js';
|
||||
import {sendServerError} from '../admin.errors.js';
|
||||
import logger from '../../../middleware/logger.js';
|
||||
import {isProd} from '../admin.config.js';
|
||||
|
||||
export const invitationsRouter = express.Router();
|
||||
|
||||
/**
|
||||
* The admin-facing half of invitations (create, resend, revoke). The public
|
||||
* half - preview and accept - lives in invitations.plugin.ts, because
|
||||
* redeeming an invitation has to create a user through better-auth internals.
|
||||
*
|
||||
* Mounted behind requireAppAccess('admin').
|
||||
*/
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/**
|
||||
* Outside production the Salesforce relay is normally off, so the invitation
|
||||
* mail never arrives and only the token's hash is stored - there would be no
|
||||
* way to walk through the accept flow locally. Logging the link closes that,
|
||||
* and mirrors what the bootstrap already does. Never in production: the log
|
||||
* would then hold a live credential.
|
||||
*/
|
||||
const logInviteLinkInDev = (token: string): void => {
|
||||
if (!isProd) {
|
||||
logger.info(`Admin: invitation link ${ADMIN_APP_URL}/accept-invite?token=${encodeURIComponent(token)}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/invitations:
|
||||
* get:
|
||||
* summary: List open (unaccepted, unrevoked, unexpired) invitations
|
||||
* tags: [admin]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
*/
|
||||
invitationsRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
res.status(200).send(await InvitationsService.listOpenInvitations());
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/invitations:
|
||||
* post:
|
||||
* summary: Invite someone and mail them an acceptance link
|
||||
* tags: [admin]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [email, name, apps]
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* apps:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Invitation created and mailed
|
||||
* 400:
|
||||
* description: Invalid input
|
||||
* 409:
|
||||
* description: A user with this address already exists
|
||||
*/
|
||||
invitationsRouter.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const email = String(req.body?.email || '').trim().toLowerCase();
|
||||
const name = String(req.body?.name || '').trim();
|
||||
const apps: unknown = req.body?.apps;
|
||||
|
||||
if (!EMAIL_PATTERN.test(email) || name.length === 0 || !Array.isArray(apps) || !apps.every(isAppName)) {
|
||||
res.status(400).send({status: 'BAD_REQUEST', message: 'E-Mail, Name und App-Liste sind erforderlich.'});
|
||||
return;
|
||||
}
|
||||
|
||||
// Inviting someone who already has an account would strand them on an
|
||||
// accept page that can only fail. Granting permissions on the existing
|
||||
// user is the operation they actually want.
|
||||
if (await UsersService.findUserByEmail(email)) {
|
||||
res.status(409).send({
|
||||
status: 'CONFLICT',
|
||||
message: 'Für diese E-Mail-Adresse gibt es bereits ein Konto. Vergib dort die Berechtigungen.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const invitation = await InvitationsService.createInvitation(
|
||||
email,
|
||||
name,
|
||||
apps as AppName[],
|
||||
res.locals.admin.id
|
||||
);
|
||||
|
||||
const mailed = await sendInvitationMail(email, name, invitation.token, invitation.expiresAt);
|
||||
if (!mailed) {
|
||||
logger.warn('Admin: invitation created but the mail was not accepted', {email});
|
||||
}
|
||||
logInviteLinkInDev(invitation.token);
|
||||
|
||||
res.status(201).send({id: invitation.id, email, name, expiresAt: invitation.expiresAt, mailed});
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/invitations/{invitationId}/resend:
|
||||
* post:
|
||||
* summary: Issue a new token for an open invitation and mail it again
|
||||
* description: The previous link stops working.
|
||||
* tags: [admin]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Resent
|
||||
* 404:
|
||||
* description: No open invitation with this id
|
||||
*/
|
||||
invitationsRouter.post('/:invitationId/resend', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const invitationId = parseInt(req.params.invitationId, 10);
|
||||
if (Number.isNaN(invitationId)) {
|
||||
res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige Einladungs-ID.'});
|
||||
return;
|
||||
}
|
||||
|
||||
const resent = await InvitationsService.resendInvitation(invitationId);
|
||||
if (!resent) {
|
||||
res.status(404).send({status: 'NOT_FOUND', message: 'Einladung nicht gefunden.'});
|
||||
return;
|
||||
}
|
||||
|
||||
const mailed = await sendInvitationMail(resent.email, resent.name, resent.token, resent.expiresAt);
|
||||
if (!mailed) {
|
||||
logger.warn('Admin: invitation resent but the mail was not accepted', {email: resent.email});
|
||||
}
|
||||
logInviteLinkInDev(resent.token);
|
||||
|
||||
res.status(200).send({id: invitationId, expiresAt: resent.expiresAt, mailed});
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/invitations/{invitationId}:
|
||||
* delete:
|
||||
* summary: Revoke an open invitation
|
||||
* tags: [admin]
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Revoked
|
||||
* 404:
|
||||
* description: No open invitation with this id
|
||||
*/
|
||||
invitationsRouter.delete('/:invitationId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const invitationId = parseInt(req.params.invitationId, 10);
|
||||
if (Number.isNaN(invitationId)) {
|
||||
res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige Einladungs-ID.'});
|
||||
return;
|
||||
}
|
||||
|
||||
const revoked = await InvitationsService.revokeInvitation(invitationId);
|
||||
if (!revoked) {
|
||||
res.status(404).send({status: 'NOT_FOUND', message: 'Einladung nicht gefunden.'});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import * as crypto from 'crypto';
|
||||
import {NachklangAdminDB} from '../Admin.db.js';
|
||||
import {AppName, APP_NAMES, isAppName} from '../admin.schema.js';
|
||||
|
||||
const db = NachklangAdminDB.db;
|
||||
|
||||
/**
|
||||
* Invitations are this API's only path to a new account (there is no public
|
||||
* sign-up). The raw token exists exactly twice: in the mail we send and in the
|
||||
* request body when it comes back. What we store is its SHA-256 hash, so a
|
||||
* database dump does not hand out account access - the same reasoning as the
|
||||
* calendar module's session key hashing, and the reason lookups go through
|
||||
* `findByToken` rather than any query on a plaintext column.
|
||||
*/
|
||||
|
||||
export const INVITATION_TTL_DAYS = 7;
|
||||
|
||||
export interface OpenInvitation {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
apps: AppName[];
|
||||
invitedBy: string | null;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export interface AcceptableInvitation {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
apps: AppName[];
|
||||
}
|
||||
|
||||
const hashToken = (token: string): string => {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
};
|
||||
|
||||
const generateToken = (): string => {
|
||||
// 32 bytes, url-safe: it travels in a mail link's query string.
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
};
|
||||
|
||||
const parseApps = (value: unknown): AppName[] => {
|
||||
// mysql2 hands back a JSON column already parsed; a driver or column-type
|
||||
// change that turns it into a string must not break the read path.
|
||||
const raw = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
return Array.isArray(raw) ? raw.filter(isAppName) : [];
|
||||
};
|
||||
|
||||
const expiryFromNow = (): Date => {
|
||||
return new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an invitation and returns the raw token for the mail. Any earlier
|
||||
* open invitation for the same address is revoked first: two valid links for
|
||||
* one mailbox is a needless second live credential, and "resend" would
|
||||
* otherwise quietly accumulate them.
|
||||
*/
|
||||
export const createInvitation = async (
|
||||
email: string,
|
||||
name: string,
|
||||
apps: AppName[],
|
||||
invitedBy: string | null
|
||||
): Promise<{id: number; token: string; expiresAt: Date}> => {
|
||||
const token = generateToken();
|
||||
const expiresAt = expiryFromNow();
|
||||
const validApps = Array.from(new Set(apps)).filter(app => APP_NAMES.includes(app));
|
||||
|
||||
const id = await db.transaction().execute(async trx => {
|
||||
await trx
|
||||
.updateTable('invitations')
|
||||
.set({revoked_at: new Date()})
|
||||
.where('email', '=', email)
|
||||
.where('accepted_at', 'is', null)
|
||||
.where('revoked_at', 'is', null)
|
||||
.execute();
|
||||
|
||||
const result = await trx
|
||||
.insertInto('invitations')
|
||||
.values({
|
||||
email,
|
||||
name,
|
||||
token_hash: hashToken(token),
|
||||
apps: JSON.stringify(validApps),
|
||||
invited_by: invitedBy,
|
||||
created_at: new Date(),
|
||||
expires_at: expiresAt
|
||||
})
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result.insertId);
|
||||
});
|
||||
|
||||
return {id, token, expiresAt};
|
||||
};
|
||||
|
||||
/**
|
||||
* Looks up a still-usable invitation by raw token. Callers must not
|
||||
* distinguish "unknown", "expired", "revoked" and "already accepted" to the
|
||||
* client: all four answer with the same shape, so a stranger cannot probe which
|
||||
* tokens ever existed.
|
||||
*/
|
||||
export const findByToken = async (token: string): Promise<AcceptableInvitation | null> => {
|
||||
const row = await db
|
||||
.selectFrom('invitations')
|
||||
.select(['id', 'email', 'name', 'apps'])
|
||||
.where('token_hash', '=', hashToken(token))
|
||||
.where('accepted_at', 'is', null)
|
||||
.where('revoked_at', 'is', null)
|
||||
.where('expires_at', '>', new Date())
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {id: row.id, email: row.email, name: row.name, apps: parseApps(row.apps)};
|
||||
};
|
||||
|
||||
/** Marks the invitation accepted. Conditional on it still being open so two
|
||||
* concurrent accepts of the same link cannot both create an account. */
|
||||
export const markAccepted = async (invitationId: number): Promise<boolean> => {
|
||||
const result = await db
|
||||
.updateTable('invitations')
|
||||
.set({accepted_at: new Date()})
|
||||
.where('id', '=', invitationId)
|
||||
.where('accepted_at', 'is', null)
|
||||
.where('revoked_at', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result.numUpdatedRows) > 0;
|
||||
};
|
||||
|
||||
export const listOpenInvitations = async (): Promise<OpenInvitation[]> => {
|
||||
const rows = await db
|
||||
.selectFrom('invitations')
|
||||
.select(['id', 'email', 'name', 'apps', 'invited_by', 'created_at', 'expires_at'])
|
||||
.where('accepted_at', 'is', null)
|
||||
.where('revoked_at', 'is', null)
|
||||
.where('expires_at', '>', new Date())
|
||||
.orderBy('created_at', 'desc')
|
||||
.execute();
|
||||
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
apps: parseApps(row.apps),
|
||||
invitedBy: row.invited_by,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at
|
||||
}));
|
||||
};
|
||||
|
||||
export const getOpenInvitation = async (invitationId: number): Promise<OpenInvitation | null> => {
|
||||
const all = await listOpenInvitations();
|
||||
return all.find(invitation => invitation.id === invitationId) ?? null;
|
||||
};
|
||||
|
||||
/** Resend issues a *new* token and expiry and invalidates the old one, rather
|
||||
* than re-mailing the existing link: if the first mail leaked, resending it
|
||||
* would extend the leak's lifetime. */
|
||||
export const resendInvitation = async (
|
||||
invitationId: number
|
||||
): Promise<{token: string; email: string; name: string; expiresAt: Date} | null> => {
|
||||
const invitation = await getOpenInvitation(invitationId);
|
||||
if (!invitation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const expiresAt = expiryFromNow();
|
||||
|
||||
await db
|
||||
.updateTable('invitations')
|
||||
.set({token_hash: hashToken(token), expires_at: expiresAt, created_at: new Date()})
|
||||
.where('id', '=', invitationId)
|
||||
.execute();
|
||||
|
||||
return {token, email: invitation.email, name: invitation.name, expiresAt};
|
||||
};
|
||||
|
||||
export const revokeInvitation = async (invitationId: number): Promise<boolean> => {
|
||||
const result = await db
|
||||
.updateTable('invitations')
|
||||
.set({revoked_at: new Date()})
|
||||
.where('id', '=', invitationId)
|
||||
.where('accepted_at', 'is', null)
|
||||
.where('revoked_at', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result.numUpdatedRows) > 0;
|
||||
};
|
||||
|
||||
/** Used by the bootstrap to stay idempotent across restarts. */
|
||||
export const hasOpenInvitationFor = async (email: string): Promise<boolean> => {
|
||||
const row = await db
|
||||
.selectFrom('invitations')
|
||||
.select('id')
|
||||
.where('email', '=', email)
|
||||
.where('accepted_at', 'is', null)
|
||||
.where('revoked_at', 'is', null)
|
||||
.where('expires_at', '>', new Date())
|
||||
.executeTakeFirst();
|
||||
|
||||
return Boolean(row);
|
||||
};
|
||||
Reference in New Issue
Block a user