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,272 @@
|
||||
import {describe, it, expect, beforeAll, beforeEach, afterAll} from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type {Application} from 'express';
|
||||
import {createApp} from '../../src/app.factory.js';
|
||||
import * as InvitationsService from '../../src/models/admin/invitations/invitations.service.js';
|
||||
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
|
||||
import {
|
||||
closeDatabase,
|
||||
createAndAcceptInvitation,
|
||||
resetDatabase,
|
||||
sessionCookieFrom,
|
||||
SESSION_COOKIE
|
||||
} from './helpers.js';
|
||||
|
||||
/**
|
||||
* End-to-end against a real MariaDB (docker-compose.test.yml) and the real
|
||||
* Express wiring from app.factory.ts. Mocks would not catch what this module
|
||||
* can actually get wrong: the Kysely MySQL dialect, cookie attributes, and the
|
||||
* middleware order that lets better-auth read the raw request body.
|
||||
*/
|
||||
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeDatabase();
|
||||
});
|
||||
|
||||
describe('sign-up is closed', () => {
|
||||
it('refuses the public sign-up endpoint', async () => {
|
||||
const res = await request(app)
|
||||
.post('/admin/auth/sign-up/email')
|
||||
.send({email: 'stranger@example.com', password: 'password123', name: 'Stranger'});
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(await UsersService.findUserByEmail('stranger@example.com')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invitation acceptance', () => {
|
||||
it('creates the user, its permissions and a session cookie', async () => {
|
||||
const {agent, userId} = await createAndAcceptInvitation(
|
||||
app,
|
||||
'anna@nachklang.art',
|
||||
'Anna',
|
||||
['feedback', 'tickets']
|
||||
);
|
||||
|
||||
const access = await UsersService.loadAccess(userId);
|
||||
expect(access?.email).toBe('anna@nachklang.art');
|
||||
expect(access?.apps.sort()).toEqual(['feedback', 'tickets']);
|
||||
expect(access?.disabled).toBe(false);
|
||||
|
||||
// The cookie works on a subsequent request.
|
||||
const me = await agent.get('/admin/me');
|
||||
expect(me.status).toBe(200);
|
||||
expect(me.body.email).toBe('anna@nachklang.art');
|
||||
expect(me.body.apps.sort()).toEqual(['feedback', 'tickets']);
|
||||
});
|
||||
|
||||
it('sets the session cookie under the configured prefix', async () => {
|
||||
const invitation = await InvitationsService.createInvitation('b@nachklang.art', 'B', ['feedback'], null);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/admin/auth/invitations/accept')
|
||||
.send({token: invitation.token, password: 'devpassword123'});
|
||||
|
||||
const cookie = sessionCookieFrom(res);
|
||||
expect(cookie).toBeDefined();
|
||||
expect(cookie).toContain('HttpOnly');
|
||||
});
|
||||
|
||||
it('lets the new account sign in with the password it just set', async () => {
|
||||
await createAndAcceptInvitation(app, 'c@nachklang.art', 'C', ['feedback'], 'my-password-1');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/admin/auth/sign-in/email')
|
||||
.send({email: 'c@nachklang.art', password: 'my-password-1'});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(sessionCookieFrom(res)).toBeDefined();
|
||||
});
|
||||
|
||||
it('previews an invitation without revealing the granted apps', async () => {
|
||||
const invitation = await InvitationsService.createInvitation('d@nachklang.art', 'D', ['admin'], null);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/admin/auth/invitations/preview')
|
||||
.send({token: invitation.token});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({email: 'd@nachklang.art', name: 'D'});
|
||||
});
|
||||
|
||||
it('answers an unknown token exactly like an expired one', async () => {
|
||||
const invitation = await InvitationsService.createInvitation('e@nachklang.art', 'E', ['feedback'], null);
|
||||
await InvitationsService.revokeInvitation(invitation.id);
|
||||
|
||||
const unknown = await request(app).post('/admin/auth/invitations/preview').send({token: 'no-such-token'});
|
||||
const revoked = await request(app).post('/admin/auth/invitations/preview').send({token: invitation.token});
|
||||
|
||||
expect(unknown.status).toBe(revoked.status);
|
||||
expect(unknown.body).toEqual(revoked.body);
|
||||
});
|
||||
|
||||
it('cannot be redeemed twice', async () => {
|
||||
const invitation = await InvitationsService.createInvitation('f@nachklang.art', 'F', ['feedback'], null);
|
||||
|
||||
const first = await request(app)
|
||||
.post('/admin/auth/invitations/accept')
|
||||
.send({token: invitation.token, password: 'devpassword123'});
|
||||
const second = await request(app)
|
||||
.post('/admin/auth/invitations/accept')
|
||||
.send({token: invitation.token, password: 'devpassword123'});
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(second.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('rejects an expired invitation', async () => {
|
||||
const invitation = await InvitationsService.createInvitation('g@nachklang.art', 'G', ['feedback'], null);
|
||||
// Reach past the service to age it: there is deliberately no API for this.
|
||||
const {NachklangAdminDB} = await import('../../src/models/admin/Admin.db.js');
|
||||
await NachklangAdminDB.db
|
||||
.updateTable('invitations')
|
||||
.set({expires_at: new Date(Date.now() - 1000)})
|
||||
.where('id', '=', invitation.id)
|
||||
.execute();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/admin/auth/invitations/accept')
|
||||
.send({token: invitation.token, password: 'devpassword123'});
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('rejects a password below the minimum length', async () => {
|
||||
const invitation = await InvitationsService.createInvitation('h@nachklang.art', 'H', ['feedback'], null);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/admin/auth/invitations/accept')
|
||||
.send({token: invitation.token, password: 'short'});
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(await UsersService.findUserByEmail('h@nachklang.art')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessions', () => {
|
||||
it('signs out and stops accepting the cookie', async () => {
|
||||
const {agent} = await createAndAcceptInvitation(app, 'i@nachklang.art', 'I', ['feedback']);
|
||||
|
||||
expect((await agent.get('/admin/me')).status).toBe(200);
|
||||
|
||||
const signOut = await agent.post('/admin/auth/sign-out').send({});
|
||||
expect(signOut.status).toBe(200);
|
||||
|
||||
expect((await agent.get('/admin/me')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a disabled user who still holds a valid cookie', async () => {
|
||||
const {agent, userId} = await createAndAcceptInvitation(app, 'j@nachklang.art', 'J', ['feedback']);
|
||||
|
||||
// Strip the permission check out of the picture: disable without going
|
||||
// through disableUser's session revocation, so the cookie stays live.
|
||||
const {NachklangAdminDB} = await import('../../src/models/admin/Admin.db.js');
|
||||
await NachklangAdminDB.db.updateTable('user').set({disabled: true}).where('id', '=', userId).execute();
|
||||
|
||||
const res = await agent.get('/admin/me');
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('disabling a user revokes their sessions immediately', async () => {
|
||||
const {agent, userId} = await createAndAcceptInvitation(app, 'k@nachklang.art', 'K', ['feedback']);
|
||||
|
||||
await UsersService.disableUser(userId);
|
||||
|
||||
const res = await agent.get('/admin/me');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('refuses to sign a disabled user back in', async () => {
|
||||
const {userId} = await createAndAcceptInvitation(app, 'l@nachklang.art', 'L', ['feedback'], 'my-password-1');
|
||||
await UsersService.disableUser(userId);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/admin/auth/sign-in/email')
|
||||
.send({email: 'l@nachklang.art', password: 'my-password-1'});
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(sessionCookieFrom(res)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireAppAccess', () => {
|
||||
it('401s an anonymous request', async () => {
|
||||
expect((await request(app).get('/admin/me')).status).toBe(401);
|
||||
expect((await request(app).get('/admin/users')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('403s a signed-in user without the admin permission', async () => {
|
||||
const {agent} = await createAndAcceptInvitation(app, 'm@nachklang.art', 'M', ['feedback']);
|
||||
|
||||
const res = await agent.get('/admin/users');
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('lets an admin through', async () => {
|
||||
const {agent} = await createAndAcceptInvitation(app, 'n@nachklang.art', 'N', ['admin']);
|
||||
|
||||
const res = await agent.get('/admin/users');
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
// Step 2 deliberately does NOT swap the feedback and tickets authenticators:
|
||||
// they still authenticate against the legacy calendar sessions, so an admin
|
||||
// cookie means nothing to them yet. This asserts that boundary rather than
|
||||
// the end state - when step 4 lands, these two expectations become 200/403
|
||||
// and this comment goes away.
|
||||
it('leaves the feedback and tickets admin areas on their legacy authenticator', async () => {
|
||||
const user = await createAndAcceptInvitation(app, 'o@nachklang.art', 'O', ['feedback', 'tickets']);
|
||||
|
||||
expect((await user.agent.get('/feedback/admin/me')).status).toBe(401);
|
||||
expect((await user.agent.get('/tickets/admin/me')).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('origin checks', () => {
|
||||
it('rejects a cookie-bearing request from an untrusted origin', async () => {
|
||||
const {agent} = await createAndAcceptInvitation(app, 'p@nachklang.art', 'P', ['admin']);
|
||||
|
||||
const res = await agent
|
||||
.post('/admin/auth/sign-out')
|
||||
.set('Origin', 'https://evil.example')
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('accepts the admin app origin', async () => {
|
||||
const {agent} = await createAndAcceptInvitation(app, 'q@nachklang.art', 'Q', ['admin']);
|
||||
|
||||
const res = await agent
|
||||
.post('/admin/auth/sign-out')
|
||||
.set('Origin', 'http://localhost:3002')
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the session cookie is not readable by scripts', () => {
|
||||
it('is HttpOnly and SameSite=Lax', async () => {
|
||||
const invitation = await InvitationsService.createInvitation('r@nachklang.art', 'R', ['feedback'], null);
|
||||
const res = await request(app)
|
||||
.post('/admin/auth/invitations/accept')
|
||||
.send({token: invitation.token, password: 'devpassword123'});
|
||||
|
||||
const cookie = sessionCookieFrom(res) || '';
|
||||
expect(cookie).toContain(SESSION_COOKIE);
|
||||
expect(cookie).toContain('HttpOnly');
|
||||
expect(cookie.toLowerCase()).toContain('samesite=lax');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user