Feat/sso #3

Merged
iamdoubz merged 7 commits from feat/sso into main 2026-10-26 09:52:50 -05:00
14 changed files with 540 additions and 17 deletions
Showing only changes of commit 19610777a4 - Show all commits

No files matched your search

+8
View File
@@ -27,6 +27,7 @@ import { backupDue, runBackup } from './services/backup.ts';
import { sendCallReports } from './services/callreport.ts';
import { contrackBook, contrackSettings } from './contacts/contrack.ts';
import { logCallsToContrack } from './contacts/timeline.ts';
import { checkDirectory } from './sso/directory.ts';
import { checkOidcSessions } from './sso/oidcCheck.ts';
import { logEvent } from './services/events.ts';
import { deliverDue } from './services/webhooks.ts';
@@ -267,8 +268,14 @@ export function createJobs(d: JobDeps) {
await checkOidcSessions({ db: d.db, log: d.log, now: now() });
};
/** Directory accounts re-read; a leaver disabled (12 §4.5). */
const directoryCheck = async () => {
await checkDirectory({ db: d.db, log: d.log });
};
return {
oidcCheck,
directoryCheck,
daily,
backup,
publicIpWatch,
@@ -303,6 +310,7 @@ export function startJobs(d: JobDeps): () => void {
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
run('contrackLog', j.contrackLog);
if (minute % 5 === 0) run('oidcCheck', j.oidcCheck);
if (minute % 60 === 0) run('directoryCheck', j.directoryCheck);
run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
});
+5 -1
View File
@@ -26,7 +26,7 @@ import {
import type { Db } from '../db/client.ts';
import { extensions, sessions, userIdentities, users } from '../db/schema.ts';
import { endSessionUrl, oidcError } from '../sso/oidc.ts';
import { directorySignIn } from '../sso/directory.ts';
import { directorySignIn, directoryWarning } from '../sso/directory.ts';
import { ldapAuthenticate } from '../sso/ldap.ts';
import { getSetting } from '../services/settings.ts';
import {
@@ -237,6 +237,10 @@ export async function authRoutes(app: FastifyInstance) {
entry = await ldapAuthenticate(ldap, login, body.password);
} catch (err) {
req.log.warn({ event: 'auth.ldap', err: (err as Error).message }, 'directory unavailable');
await directoryWarning(
db,
`A directory sign-in failed: the directory couldn't be reached (${(err as Error).message}).`,
);
throw new HttpError(
503,
'directory_unavailable',
+7
View File
@@ -51,6 +51,7 @@ declare module 'fastify' {
const READ_ONLY = new Set<string>([
'system.restartRequired',
'system.transportHash',
'system.directoryWarning',
'sms.webhookToken',
'setup.completed',
]);
@@ -456,6 +457,12 @@ export async function settingsRoutes(app: FastifyInstance) {
message: "Today's backup hasn't been made. Check the api log, or press Back up now.",
link: '/admin/settings/backups',
});
if (s['system.directoryWarning'])
items.push({
code: 'directory_check',
message: s['system.directoryWarning'],
link: '/admin/settings/sign-in',
});
if (!s.smtp)
items.push({
code: 'smtp_missing',
+84 -1
View File
@@ -2,10 +2,13 @@
// A directory entry → its SelfPhoneNG user (12 §4.3): connected before, or by the directory's
// email. Never a new user (owner, 2026-10-25).
import { and, eq } from 'drizzle-orm';
import { deleteUserSessions } from '../auth/sessions.ts';
import type { Db } from '../db/client.ts';
import { userIdentities, users } from '../db/schema.ts';
import { isLastAdmin } from '../routes/users.ts';
import { audit } from '../services/audit.ts';
import type { DirectoryEntry } from './ldap.ts';
import { getSetting, setSetting } from '../services/settings.ts';
import { lookupIds, type DirectoryEntry, type LdapSettings } from './ldap.ts';
import { applyRole } from './role.ts';
type UserRow = typeof users.$inferSelect;
@@ -69,3 +72,83 @@ export async function directorySignIn(
const [after] = await db.select().from(users).where(eq(users.id, u.id));
return after!;
}
/** The dashboard's directory warning (12 §4.2, §4.5); null clears it. */
export const directoryWarning = (db: Db, message: string | null) =>
setSetting(db, 'system.directoryWarning', message);
type Log = { info(o: object, m: string): void; warn?(o: object, m: string): void };
/**
* One run of the directoryCheck job (hourly, 12 §4.5): every active user with a directory
* account still in it gets its email and role; one who isn't is disabled. Never re-enables.
* `lookup` is for tests.
*/
export async function checkDirectory(d: {
db: Db;
log: Log;
lookup?: (s: LdapSettings, ids: string[]) => Promise<Map<string, DirectoryEntry>>;
}): Promise<void> {
const { db, log } = d;
const s = await getSetting(db, 'auth.ldap');
const rows = s?.enabled
? await db
.select({ u: users, subject: userIdentities.subject })
.from(userIdentities)
.innerJoin(users, eq(users.id, userIdentities.userId))
.where(and(eq(userIdentities.kind, 'ldap'), eq(users.isActive, true)))
: [];
if (!s || !rows.length) return directoryWarning(db, null);
let found: Map<string, DirectoryEntry>;
try {
found = await (d.lookup ?? lookupIds)(
s,
rows.map((r) => r.subject),
);
} catch (err) {
log.warn?.({ err: (err as Error).message }, 'directory check skipped');
return directoryWarning(
db,
`The directory check couldn't reach the directory (${(err as Error).message}), so leavers aren't being disabled.`,
);
}
const gone = rows.filter((r) => !found.has(r.subject));
// A broken filter or base DN is likelier than a mass layoff.
if (gone.length >= 3 && gone.length * 2 > rows.length) {
log.warn?.({ gone: gone.length, of: rows.length }, 'directory check would disable most users');
return directoryWarning(
db,
`The directory check would disable ${gone.length} of ${rows.length} people, so it disabled nobody. Check the base DN and user filter.`,
);
}
for (const { u, subject } of rows) {
const e = found.get(subject);
if (e) {
await db
.update(userIdentities)
.set({ email: e.email })
.where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
await applyRole(db, log, u.id, e.admin, 'the directory', null);
continue;
}
if (u.role === 'admin' && u.passwordHash && (await isLastAdmin(db, u.id))) {
log.warn?.(
{ userId: u.id },
'kept the last admin with a password, though the directory lost them',
);
continue;
}
await db.update(users).set({ isActive: false }).where(eq(users.id, u.id));
await deleteUserSessions(db, u.id);
await audit(db, {
action: 'directory_disabled',
entityType: 'user',
entityId: u.id,
summary: `${u.email} is no longer in the directory`,
diff: { before: { isActive: true }, after: { isActive: false } },
});
}
await directoryWarning(db, null);
}
+22 -1
View File
@@ -1,6 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { BerWriter } from 'ldapts';
import { describe, expect, it } from 'vitest';
import { anyLogin, assertLdap, ldapAuthenticate, withLogin, type LdapSettings } from './ldap.ts';
import {
anyLogin,
assertLdap,
byIdFilter,
ldapAuthenticate,
withLogin,
type LdapSettings,
} from './ldap.ts';
describe('LDAP (12 §4)', () => {
const f = '(&(objectClass=person)(|(uid={login})(mail={login})))';
@@ -17,6 +25,19 @@ describe('LDAP (12 §4)', () => {
expect(anyLogin(f)).toBe('(&(objectClass=person)(|(uid=*)(mail=*)))');
});
it('finds a leaver by id, keeping the other conditions of the filter (§4.5)', () => {
expect(byIdFilter({ idAttribute: 'entryUUID', userFilter: f }, 'a*b').toString()).toBe(
'(&(entryUUID=a\\2ab)(&(objectClass=person)(|(uid=*)(mail=*))))',
);
// AD: objectGUID is stored as hex and searched as its bytes (§4.4).
const guid = byIdFilter({ idAttribute: 'objectGUID', userFilter: '(cn={login})' }, '00ff2a');
expect(guid.toString()).toBe('(&(objectGUID=\\00\\ff\\2a)(cn=*))');
// …and goes out as those three bytes, not U+00FF in UTF-8.
const ber = new BerWriter();
guid.write(ber);
expect(ber.buffer.includes(Buffer.from([0x04, 0x03, 0x00, 0xff, 0x2a]))).toBe(true);
});
it('never sends a password in clear text to another host', () => {
const ok = { startTls: false, userFilter: f, adminFilter: null };
expect(() => assertLdap({ ...ok, url: 'ldap://dc1.example.com' })).toThrow(/clear text/);
+45
View File
@@ -2,7 +2,9 @@
// LDAP / LDAPS password check (12 §4) on ldapts: search the person with the service account,
// bind as them with their password, read whether the admin filter matches them.
import {
AndFilter,
Client,
EqualityFilter,
FilterParser,
InvalidCredentialsError,
NoSuchObjectError,
@@ -182,6 +184,49 @@ export async function ldapAuthenticate(
}
}
/** `(&(<id attribute>=<id>)<userFilter with {login} → *>)`: still there, and still let in (§4.5). */
export function byIdFilter(s: Pick<LdapSettings, 'idAttribute' | 'userFilter'>, id: string) {
// An object, not a string: ldapts parses "\ff" in a string filter to U+00FF and sends it as
// UTF-8 (two bytes), so a binary objectGUID would never match. A Buffer goes out as is.
const value = s.idAttribute === 'objectGUID' ? Buffer.from(id, 'hex') : id;
return new AndFilter({
filters: [
new EqualityFilter({ attribute: s.idAttribute, value }),
FilterParser.parseString(anyLogin(s.userFilter)),
],
});
}
/**
* The leaver check (12 §4.5): each id → its entry, when the directory still has it and the user
* filter still lets it in. One connection for all. Throws DirectoryUnavailable on any failure,
* so a half-done run never reads as "gone".
*/
export async function lookupIds(
s: LdapSettings,
ids: string[],
): Promise<Map<string, DirectoryEntry>> {
const c = await connect(s);
try {
const found = new Map<string, DirectoryEntry>();
for (const id of ids) {
const r = await c.search(s.baseDn, {
scope: 'sub',
filter: byIdFilter(s, id),
attributes: attributes(s),
explicitBufferAttributes: buffers(s),
sizeLimit: 2,
});
if (r.searchEntries.length === 1) found.set(id, await toEntry(c, s, r.searchEntries[0]!));
}
return found;
} catch (err) {
throw unavailable(err);
} finally {
await c.unbind().catch(() => {});
}
}
/** Settings → Sign-in → Directory → Test (12 §4.1), step by step so it can say which failed. */
export async function testLdap(
s: LdapSettings,
+157
View File
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 S5: the Active Directory preset against a real Samba AD DC: sign-in by sAMAccountName, UPN
// and mail, admin through a nested group (LDAP_MATCHING_RULE_IN_CHAIN), the disabled bit,
// objectGUID as hex, and directoryCheck disabling a deleted user. Needs AD_LIVE_URL (ldaps://…,
// with NODE_EXTRA_CA_CERTS naming its CA) and AD_LIVE_ADMIN_PASS (the Administrator's).
// Passed against Samba 4 (diegogslomp/samba-ad-dc), its smb.conf given a throwaway certificate
// for 127.0.0.1 (`tls keyfile`, `tls certfile`, `tls cafile`):
// docker run -d --privileged -p 127.0.0.1:11636:636 -e REALM=EXAMPLE.COM -e DOMAIN=EXAMPLE \
// -e ADMIN_PASS=… -e DNS_FORWARDER=1.1.1.1 -e BIND_NETWORK_INTERFACES=false \
// diegogslomp/samba-ad-dc
import { randomBytes } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { Attribute, Client } from 'ldapts';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { LDAP_PRESETS } from '@selfphoneng/shared';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { encrypt } from '../src/crypto/secrets.ts';
import { userIdentities, users } from '../src/db/schema.ts';
import { setSetting } from '../src/services/settings.ts';
import { checkDirectory } from '../src/sso/directory.ts';
import { hasTestDb } from './db.ts';
import { H, createTestApp } from './app.ts';
const URL_ = process.env.AD_LIVE_URL;
const ADMIN_PASS = process.env.AD_LIVE_ADMIN_PASS ?? '';
const DC = 'DC=example,DC=com';
const BASE = `CN=Users,${DC}`;
const ADMIN_DN = `CN=Administrator,${BASE}`;
describe.skipIf(!hasTestDb || !URL_)('Active Directory with a real Samba AD DC (12 S5)', () => {
const tag = randomBytes(3).toString('hex');
const [ann, bob, dan, cat] = ['ann', 'bob', 'dan', 'cat'].map((n) => `${n}${tag}`) as [
string,
string,
string,
string,
];
const team = `CN=team-${tag},${BASE}`;
const admins = `CN=pbx-admins-${tag},${BASE}`;
const dn = (sam: string) => `CN=${sam},${BASE}`;
let t: Awaited<ReturnType<typeof createTestApp>>;
let ad: Client;
const login = (who: string, password: string) =>
t.app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { login: who, password },
});
const row = async (sam: string) =>
(
await t.db
.select()
.from(users)
.where(eq(users.email, `${sam}@example.com`))
)[0]!;
beforeAll(async () => {
ad = new Client({ url: URL_! });
await ad.bind(ADMIN_DN, ADMIN_PASS);
// userAccountControl 512: a normal account; 514: the same, disabled.
for (const [sam, uac] of [
[ann, '512'],
[bob, '512'],
[dan, '514'],
[cat, '512'],
] as const)
await ad.add(dn(sam), [
...Object.entries({
objectClass: ['top', 'person', 'organizationalPerson', 'user'],
sAMAccountName: [sam],
userPrincipalName: [`${sam}@example.com`],
mail: [`${sam}@example.com`],
displayName: [sam],
userAccountControl: [uac],
}).map(([type, values]) => new Attribute({ type, values })),
// AD's password: the quoted string in UTF-16LE, only over an encrypted connection.
new Attribute({
type: 'unicodePwd',
values: [Buffer.from(`"${sam}-Directory-Pw1"`, 'utf16le')],
}),
]);
// ann is in team, team is in pbx-admins: only the nested-group rule finds her admin.
await ad.add(team, {
objectClass: ['top', 'group'],
sAMAccountName: `team-${tag}`,
member: dn(ann),
});
await ad.add(admins, {
objectClass: ['top', 'group'],
sAMAccountName: `pbx-admins-${tag}`,
member: team,
});
t = await createTestApp();
for (const sam of [ann, bob, dan, cat])
await t.addUser(`${sam}@example.com`, 'local-password-1');
await setSetting(t.db, 'auth.ldap', {
enabled: true,
preset: 'ad',
url: URL_!,
startTls: false,
bindDn: ADMIN_DN,
bindPasswordEnc: encrypt(ADMIN_PASS),
baseDn: BASE,
userFilter: LDAP_PRESETS.ad.userFilter,
idAttribute: LDAP_PRESETS.ad.idAttribute,
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: `(memberOf:1.2.840.113556.1.4.1941:=${admins})`,
});
}, 60_000);
afterAll(async () => {
await t?.close();
for (const d of [admins, team, ...[ann, bob, dan, cat].map(dn)])
await ad?.del(d).catch(() => {});
await ad?.unbind();
});
it('signs in by sAMAccountName; a nested group makes an admin; objectGUID is stored as hex', async () => {
resetLoginLimiter();
const r = await login(ann, `${ann}-Directory-Pw1`);
expect(r.statusCode).toBe(200);
expect(r.json().user).toMatchObject({ role: 'admin', signIn: { ldap: true } });
const [ident] = await t.db
.select()
.from(userIdentities)
.where(eq(userIdentities.userId, (await row(ann)).id));
expect(ident!.subject).toMatch(/^[0-9a-f]{32}$/);
});
it('signs in by UPN and by mail; outside the group is a user', async () => {
resetLoginLimiter();
expect((await login(`${bob}@example.com`, `${bob}-Directory-Pw1`)).json().user.role).toBe(
'user',
);
resetLoginLimiter();
expect((await login(`${ann}@example.com`, `${ann}-Directory-Pw1`)).statusCode).toBe(200);
});
it('a disabled account (userAccountControl bit 2) is refused', async () => {
resetLoginLimiter();
expect((await login(dan, `${dan}-Directory-Pw1`)).statusCode).toBe(401);
});
it('directoryCheck disables a deleted user and keeps the rest', async () => {
resetLoginLimiter();
expect((await login(cat, `${cat}-Directory-Pw1`)).statusCode).toBe(200);
await ad.del(dn(cat));
await checkDirectory({ db: t.db, log: { info: () => {}, warn: () => {} } });
expect((await row(cat)).isActive).toBe(false);
expect(await row(ann)).toMatchObject({ isActive: true, role: 'admin' });
expect((await row(bob)).isActive).toBe(true);
});
});
+128
View File
@@ -0,0 +1,128 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 §4.5: the hourly directoryCheck, against a fake directory lookup.
import { eq, ne } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { auditLog, sessions, userIdentities, users } from '../src/db/schema.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { checkDirectory } from '../src/sso/directory.ts';
import type { DirectoryEntry, LdapSettings } from '../src/sso/ldap.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const log = { info: () => {}, warn: () => {} };
const LDAP: LdapSettings = {
enabled: true,
preset: 'lldap',
url: 'ldaps://dir.example.com',
startTls: false,
bindDn: null,
bindPasswordEnc: null,
baseDn: 'ou=people,dc=example,dc=com',
userFilter: '(uid={login})',
idAttribute: 'entryUUID',
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: '(memberOf=cn=pbx-admins)',
};
describe.skipIf(!hasTestDb)('directoryCheck (12 §4.5)', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let n = 0;
/** A user here with a directory account `id`. */
const member = async (role: 'admin' | 'user' = 'user') => {
const u = await t.addUser(`dir${++n}@example.com`, 'local-password-1', role);
const dirId = `uuid-${n}`;
await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: dirId });
return { ...u, dirId };
};
const entry = (id: string, admin: boolean | null, email = `${id}@dir.example.com`) =>
[id, { dn: `uid=${id}`, id, email, name: id, admin }] as [string, DirectoryEntry];
const run = (found: [string, DirectoryEntry][] | Error) =>
checkDirectory({
db: t.db,
log,
lookup: () =>
found instanceof Error ? Promise.reject(found) : Promise.resolve(new Map(found)),
});
const user = async (id: string) => (await t.db.select().from(users).where(eq(users.id, id)))[0]!;
const warning = () => getSetting(t.db, 'system.directoryWarning');
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
});
afterAll(() => t?.close());
beforeEach(async () => {
await t.db.delete(userIdentities);
await t.db.update(users).set({ role: 'user' }).where(ne(users.email, ADMIN.email));
await setSetting(t.db, 'auth.ldap', LDAP);
});
it('updates who is still there, and disables who is gone', async () => {
const [stays, promoted, leaves] = [await member(), await member(), await member()];
await t.login(leaves.email, 'local-password-1');
await setSetting(t.db, 'system.directoryWarning', 'old');
await run([entry(stays.dirId, false, 'new@dir.example.com'), entry(promoted.dirId, true)]);
expect(await user(stays.id)).toMatchObject({ isActive: true, role: 'user' });
const [ident] = await t.db
.select()
.from(userIdentities)
.where(eq(userIdentities.userId, stays.id));
expect(ident!.email).toBe('new@dir.example.com');
expect((await user(promoted.id)).role).toBe('admin');
expect((await user(leaves.id)).isActive).toBe(false);
expect(await t.db.select().from(sessions).where(eq(sessions.userId, leaves.id))).toEqual([]);
const audits = await t.db.select().from(auditLog).where(eq(auditLog.entityId, leaves.id));
expect(audits.map((a) => a.action)).toContain('directory_disabled');
expect(await warning()).toBeNull();
});
it('changes nothing and warns on the dashboard when the directory is down', async () => {
const a = await member();
await run(new Error('connect ECONNREFUSED'));
expect((await user(a.id)).isActive).toBe(true);
expect(await warning()).toMatch(/couldn't reach the directory \(connect ECONNREFUSED\)/);
const admin = await t.login(ADMIN.email, ADMIN.password);
const r = await t.app.inject({
url: '/api/system/warnings',
headers: { ...H, cookie: admin },
});
expect(r.json().items).toContainEqual(
expect.objectContaining({ code: 'directory_check', link: '/admin/settings/sign-in' }),
);
});
it('disables nobody when most people would go (a broken filter, not a layoff)', async () => {
const all = [await member(), await member(), await member(), await member()];
await run([entry(all[0]!.dirId, null)]);
for (const u of all) expect((await user(u.id)).isActive).toBe(true);
expect(await warning()).toMatch(/would disable 3 of 4 people/);
});
it('two of four gone is not "most": they are disabled', async () => {
const all = [await member(), await member(), await member(), await member()];
await run([entry(all[0]!.dirId, null), entry(all[1]!.dirId, null)]);
expect((await user(all[2]!.id)).isActive).toBe(false);
expect((await user(all[3]!.id)).isActive).toBe(false);
});
it('keeps the last active admin with a password', async () => {
const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
await t.db.insert(userIdentities).values({ userId: me!.id, kind: 'ldap', subject: 'admin-id' });
await run([]);
expect((await user(me!.id)).isActive).toBe(true);
await run([entry('admin-id', false)]);
expect((await user(me!.id)).role).toBe('admin');
});
it('does nothing with the directory off', async () => {
const a = await member();
await setSetting(t.db, 'auth.ldap', { ...LDAP, enabled: false });
await setSetting(t.db, 'system.directoryWarning', 'old');
await run([]);
expect((await user(a.id)).isActive).toBe(true);
expect(await warning()).toBeNull();
});
});
+27 -6
View File
@@ -1,8 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 S4 against a real lldap: sign in by uid and by email, connect by email, no user made for a
// stranger, admin by memberOf, Test, LDAPS. Needs LDAP_LIVE_URL (ldap://127.0.0.1:<port>) and
// LDAP_LIVE_HTTP (its web port, to make the users); LDAP_LIVE_LDAPS_URL to try LDAPS too, with
// NODE_EXTRA_CA_CERTS naming the CA of its certificate. Admin password: LDAP_LIVE_ADMIN_PASS.
// 12 S4-S5 against a real lldap: sign in by uid and by email, connect by email, no user made for
// a stranger, admin by memberOf, Test, LDAPS, directoryCheck disabling a removed user. Needs
// LDAP_LIVE_URL (ldap://127.0.0.1:<port>) and LDAP_LIVE_HTTP (its web port, to make the users);
// LDAP_LIVE_LDAPS_URL to try LDAPS too, with NODE_EXTRA_CA_CERTS naming the CA of its
// certificate. Admin password: LDAP_LIVE_ADMIN_PASS.
// Passed against lldap 0.6.3:
// docker run -d -p 127.0.0.1:13890:3890 -p 127.0.0.1:16360:6360 -p 127.0.0.1:17170:17170 \
// -e LLDAP_JWT_SECRET=… -e LLDAP_KEY_SEED=… -e LLDAP_LDAP_USER_PASS=admin-pass-123 \
@@ -16,6 +17,7 @@ import { encrypt } from '../src/crypto/secrets.ts';
import { userIdentities, users } from '../src/db/schema.ts';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { setSetting } from '../src/services/settings.ts';
import { checkDirectory } from '../src/sso/directory.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
@@ -35,6 +37,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
const tag = randomBytes(3).toString('hex');
const ann = `ann${tag}`;
const bob = `bob${tag}`;
const cat = `cat${tag}`;
const group = `pbx-admins-${tag}`;
let t: Awaited<ReturnType<typeof createTestApp>>;
let jwt: string;
@@ -82,6 +85,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
for (const [id, name] of [
[ann, 'Ann Lee'],
[bob, 'Bob Roe'],
[cat, 'Cat Poe'],
])
await gql(
`mutation { createUser(user: { id: "${id}", email: "${id}@example.com", displayName: "${name}" }) { id } }`,
@@ -92,7 +96,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
await gql(`mutation { addUserToGroup(userId: "${ann}", groupId: ${g.createGroup.id}) { ok } }`);
const c = new Client({ url: URL_! });
await c.bind(`uid=admin,ou=people,${BASE}`, ADMIN_PASS);
for (const id of [ann, bob])
for (const id of [ann, bob, cat])
await c.exop(
'1.3.6.1.4.1.4203.1.11.1',
passwordModify(`uid=${id},ou=people,${BASE}`, `${id}-directory-pw`),
@@ -103,12 +107,13 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
await t.addUser(`${ann}@example.com`, 'ann-local-pw-12');
await t.addUser(`${cat}@example.com`, 'cat-local-pw-12');
await setSetting(t.db, 'auth.ldap', settings());
}, 60_000);
afterAll(async () => {
await t?.close();
for (const id of [ann, bob])
for (const id of [ann, bob, cat])
await gql(`mutation { deleteUser(userId: "${id}") { ok } }`).catch(() => {});
});
@@ -193,6 +198,22 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
expect(bad.json()).toMatchObject({ ok: false, error: expect.stringContaining('refused') });
});
it('directoryCheck disables someone removed from the directory, and only them (§4.5)', async () => {
resetLoginLimiter();
expect((await login(cat, `${cat}-directory-pw`)).statusCode).toBe(200); // connects cat
await gql(`mutation { deleteUser(userId: "${cat}") { ok } }`);
await checkDirectory({ db: t.db, log: { info: () => {}, warn: () => {} } });
const active = async (who: string) =>
(
await t.db
.select()
.from(users)
.where(eq(users.email, `${who}@example.com`))
)[0]!.isActive;
expect(await active(cat)).toBe(false);
expect(await active(ann)).toBe(true);
});
it.skipIf(!LDAPS)('LDAPS with a private CA (NODE_EXTRA_CA_CERTS)', async () => {
resetLoginLimiter();
await setSetting(t.db, 'auth.ldap', settings(LDAPS));
+10
View File
@@ -1640,6 +1640,16 @@ function DirectoryForm({ s }: { s: SettingsDto }) {
in with their directory username or email; the first time, they&apos;re matched to their
user here by email. Nobody gets a user just for being in the directory.
</p>
<p className="text-sm text-zinc-500">
Every hour, someone no longer in the directory (or no longer matched by the user filter)
is disabled here, password and OpenID sign-in included. It never enables anyone again: do
that on Users.
</p>
{s['system.directoryWarning'] && (
<p role="alert" className="text-sm text-red-700 dark:text-red-400">
{s['system.directoryWarning']}
</p>
)}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
+16 -3
View File
@@ -141,6 +141,19 @@ the directory. Their SelfPhoneNG password, if they have one, keeps working too.
who that login would sign in as here.
8. Tick **Check passwords against the directory** and **Save**.
If the directory is down, people get "The directory can't be reached" and passwords set in
SelfPhoneNG still work. Directories without `memberOf` on people (OpenLDAP without the memberof
overlay) can't map groups yet. Tested against lldap 0.6.3.
### Leavers
Every hour, SelfPhoneNG looks up each user who has signed in with the directory. Someone deleted
from it, disabled there (Active Directory's disabled bit, FreeIPA's lock) or no longer matched by
the user filter (removed from the group you added to it) is **disabled here**: their password and
OpenID sign-in stop working too, and they're signed out. The role follows the admin filter in the
same run. Nobody is enabled again automatically: do that on **Users**.
If a run would disable more than half of these people (and at least 3), it disables nobody and
the dashboard says so: a wrong base DN or filter is likelier than everyone leaving. The last admin
who has a password is never disabled.
If the directory is down, people get "The directory can't be reached", passwords set in
SelfPhoneNG still work, and the dashboard shows a warning until the next hourly check reaches it.
Directories without `memberOf` on people (OpenLDAP without the memberof overlay) can't map groups
yet. Tested against lldap 0.6.3 and Samba 4 Active Directory (nested groups, the disabled bit).
+1 -1
View File
@@ -425,7 +425,7 @@ As built (M11-T6): `routes/reports.ts`. `from`/`to` are company-time-zone days,
| GET/PATCH | `/api/settings` | whole settings object; PATCH merges known keys only. `smtp` comes back as `{host, port, secure, username, from, hasPassword}` and is written with an optional `password` (empty keeps the stored one). `system.*`, `sms.webhookToken` and `setup.completed` are read-only. References (DIDs, extensions, destinations) are checked, and a transport a trunk uses can't be turned off |
| POST | `/api/settings/smtp/test` | `{to}` sends a test email |
| POST | `/api/settings/emergency/test` | runs 04 §5.3 steps 2–4 with `TEST` in front (email, ring-out, banner); records nothing and dials nothing (06 §6.5). Returns `{notified}` |
| GET | `/api/system/warnings` | `{items: [{code, message, link}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3). "Trunk down" comes from live state in the browser |
| GET | `/api/system/warnings` | `{items: [{code, message, link}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3), `directory_check` (12 §4.2, §4.5: the directory unreachable at a sign-in or by `directoryCheck`, or that job refusing to disable most people). "Trunk down" comes from live state in the browser |
| POST | `/api/settings/network/detect-public-ip` | |
| GET | `/api/system/status` | `{ db: ok, ami: connected, asteriskVersion, uptime, trunks: [...], disk: {free, total}, restartRequired, lastGeneratorRun }` |
| GET | `/api/system/tls` | the SIP TLS certificate in `TLS_DIR`: `{ok: true, subject, names, notAfter}` or `{ok: false, error}` (M11-T2) |
+28 -4
View File
@@ -385,7 +385,8 @@ For every active user with a directory account, one connection per run:
- Search `baseDn` for `(&(<idAttribute>=<id>)<userFilter with {login} → *>)`. Replacing `{login}`
with `*` keeps every other condition of the filter (the group, AD's disabled bit, FreeIPA's lock)
and makes the login parts presence tests.
- Found → update email, name, and role (`adminFilter`). Gone → the user is disabled
- Found → update the identity's email and the role (`adminFilter`); the user's own name and email
stay as an admin set them (§8.4). Gone → the user is disabled
(`is_active = false`, sessions deleted, as an admin's disable), audit `directory_disabled`
"… is no longer in the directory". The directory account is the company's record of the person,
so it disables the whole user, password and OpenID account included.
@@ -453,7 +454,7 @@ For every active user with a directory account, one connection per run:
| S2 | OIDC: settings + Test, start/callback, claims (§3.4), §3.3 (connect, disconnect, email match), `mixed` and `oidc` modes, sign out at the provider, login page and My account, fake provider tests | every §6 OIDC case passes against the fake provider. Done, see §8.2 |
| S3 | OIDC session check (§3.5); Keycloak live test; admin guide page `docs/admin-guide/sign-in.md` with the §3.6 table | the owner connects their Keycloak account, signs in with it or their password, a Keycloak group makes them admin, signing out also signs out of Keycloak, and disabling them in Keycloak ends their Keycloak session here within 20 min. Done (against a throwaway Keycloak 26.8.1), see §8.3; the owner's own Keycloak still to try |
| S4 | LDAP: settings + Test, presets, sign-in flow (§4.2), §4.3, unit + lldap live tests | sign-in by uid and email against lldap over LDAPS; admin by group; no user made for a stranger. Done, see §8.4 |
| S5 | `directoryCheck` (§4.5), dashboard warnings; Samba AD check | a user removed from the directory is disabled within an hour; AD nested-group admin works |
| S5 | `directoryCheck` (§4.5), dashboard warnings; Samba AD check | a user removed from the directory is disabled within an hour; AD nested-group admin works. Done, see §8.5 |
### 8.1 As built: S1
@@ -526,11 +527,34 @@ For every active user with a directory account, one connection per run:
- `apps/api/src/sso/ldap.test.ts`, `apps/api/test/ldap-live.test.ts` (`LDAP_LIVE_URL`,
`LDAP_LIVE_HTTP`, `LDAP_LIVE_LDAPS_URL`; users made through lldap's GraphQL, passwords with an
RFC 3062 password modify). Passed against lldap 0.6.3 on 2026-10-25, LDAPS with a throwaway CA
through `NODE_EXTRA_CA_CERTS`. The AD, FreeIPA and OpenLDAP presets are not checked yet (S5: a
Samba AD DC).
through `NODE_EXTRA_CA_CERTS`. The AD, FreeIPA and OpenLDAP presets were not checked in S4 (AD:
§8.5).
- Web: Settings → Sign-in → Directory (presets, Test with an optional test login), "Email or
username" on the login page, the directory account on My settings and the Users drawer.
### 8.5 As built: S5
- `checkDirectory` in `sso/directory.ts` (job `directoryCheck`, hourly, `jobs.ts`), `lookupIds` in
`sso/ldap.ts`: one connection, one search per id. Any failure in the run (connect, bind, a
wrong base DN's `noSuchObject`) changes nothing, so a half-done run never reads as "gone".
- **The by-id filter is a filter object, not a string.** ldapts parses ` f` in a string filter
to U+00FF and sends it as UTF-8 (two bytes), so AD's binary `objectGUID` never matched: every
connected AD user looked gone, and the "more than half" guard is what stopped it disabling them
all (the Samba check caught it). `EqualityFilter` with a Buffer value goes out as is; the rest is
`FilterParser.parseString(userFilter with {login} → *)`. Sign-in never searches by id, so S4 was
unaffected.
- Dashboard warning `directory_check` (04 §7.3): setting `system.directoryWarning` (read-only, set
by the system), written by a sign-in's `directory_unavailable` and by the job, cleared by a clean
run. Settings → Sign-in → Directory shows it too, with the hourly check's note.
- The last active admin with a password is kept (logged) when gone, as `applyRole` keeps them.
- `test/directory-check.test.ts` (a fake lookup: update, disable, down, the half guard, last
admin, directory off), `test/ad-live.test.ts` (`AD_LIVE_URL`, `AD_LIVE_ADMIN_PASS`; users and a
nested group made over LDAPS): passed against Samba 4 (`diegogslomp/samba-ad-dc`) on 2026-10-25,
LDAPS with a throwaway CA: sign-in by sAMAccountName, UPN and mail, admin through a nested group,
the disabled bit refused, `objectGUID` as hex, a deleted user disabled. `ldap-live.test.ts`
gained a removed user disabled by the check. The OpenLDAP and FreeIPA presets are still
unchecked against a real server.
## 9. Decided
- Owner, 2026-10-25: a user connects their OpenID account to their account and signs in with
+2
View File
@@ -151,6 +151,8 @@ export const SettingsSchemas = {
})
.nullable()
.default(null),
/** Why the directory couldn't be used (12 §4.2, §4.5), for the dashboard; null = fine. */
'system.directoryWarning': z.string().nullable().default(null),
// Not in 02 §3's table but used by the generator (03 §3).
'system.transportHash': z.string().nullable().default(null),
};