7aac07a013
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>
210 lines
6.3 KiB
TypeScript
210 lines
6.3 KiB
TypeScript
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);
|
|
};
|