Feat/sso #3

Merged
iamdoubz merged 7 commits from feat/sso into main 2026-10-26 09:52:50 -05:00
19 changed files with 1160 additions and 64 deletions
Showing only changes of commit b06335586b - Show all commits

No files matched your search

+1
View File
@@ -18,6 +18,7 @@
"@selfphoneng/shared": "workspace:*",
"drizzle-orm": "^0.45.4",
"fastify": "^5.12.7",
"ldapts": "^9.2.0",
"nodemailer": "10.0.11",
"openid-client": "^6.8.8",
"pg": "^8.23.1",
+36 -9
View File
@@ -26,6 +26,8 @@ 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 { ldapAuthenticate } from '../sso/ldap.ts';
import { getSetting } from '../services/settings.ts';
import {
unauthorized,
@@ -213,7 +215,7 @@ export async function authRoutes(app: FastifyInstance) {
const oidc = await getSetting(db, 'auth.oidc');
return {
mode,
ldap: false, // S4
ldap: Boolean((await getSetting(db, 'auth.ldap'))?.enabled),
oidc: oidc && mode !== 'password' ? { label: oidc.label } : null,
};
});
@@ -221,13 +223,32 @@ export async function authRoutes(app: FastifyInstance) {
app.post('/api/auth/login', async (req, reply) => {
const { login, ...body } = LoginSchema.parse(req.body);
if (isLoginBlocked(req.ip, login)) throw rateLimited();
const [user] = await db.select().from(users).where(eq(users.email, login));
let [user] = await db.select().from(users).where(eq(users.email, login));
// No password (OpenID only, 12 §3.3) fails like a wrong one, after the same work.
const ok =
user?.isActive && user.passwordHash
? await verifyPassword(user.passwordHash, body.password)
: (await burnVerify(body.password), false);
if (!user || !ok) {
let ok = user?.passwordHash
? await verifyPassword(user.passwordHash, body.password)
: (await burnVerify(body.password), false);
let method: 'password' | 'ldap' = 'password';
// Not this password here: the directory's, when it's on (12 §4.2).
const ldap = ok ? null : await getSetting(db, 'auth.ldap');
if (ldap?.enabled) {
let entry;
try {
entry = await ldapAuthenticate(ldap, login, body.password);
} catch (err) {
req.log.warn({ event: 'auth.ldap', err: (err as Error).message }, 'directory unavailable');
throw new HttpError(
503,
'directory_unavailable',
'The directory can’t be reached. Passwords set in SelfPhoneNG still work.',
);
}
const found = entry && (await directorySignIn(db, req.log, entry, req.ip));
if (entry && !found)
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'directory account has no user here');
if (found) [user, ok, method] = [found, true, 'ldap'];
}
if (!user || !ok || !user.isActive) {
recordLoginFailure(req.ip, login);
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed');
await audit(db, {
@@ -261,7 +282,13 @@ export async function authRoutes(app: FastifyInstance) {
throw new HttpError(401, 'totp_invalid', 'That code is wrong or was used already');
}
}
const token = await createSession(db, user.id, req.ip, req.headers['user-agent'] ?? null);
const token = await createSession(
db,
user.id,
req.ip,
req.headers['user-agent'] ?? null,
method,
);
setSessionCookie(reply, token);
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id));
await audit(db, {
@@ -270,7 +297,7 @@ export async function authRoutes(app: FastifyInstance) {
action: 'login',
entityType: 'user',
entityId: user.id,
summary: `${user.email} logged in`,
summary: `${user.email} logged in${method === 'ldap' ? ' with the directory' : ''}`,
});
return { user: await toAuthUser(db, user) };
});
+26 -13
View File
@@ -224,38 +224,51 @@ export async function oidcRoutes(app: FastifyInstance) {
return reply.redirect(pending.next);
});
/** Disconnect (12 §3.3): refused when it would leave them no way in under the current mode. */
async function disconnect(req: FastifyRequest, reply: FastifyReply, userId: string) {
/**
* Disconnect an OpenID or directory account (12 §3.3, §4.3): refused when it would leave
* them no way in under the current mode. Sessions made with it end.
*/
async function disconnect(
req: FastifyRequest,
reply: FastifyReply,
userId: string,
kind: 'oidc' | 'ldap',
) {
const [u] = await db.select().from(users).where(eq(users.id, userId));
if (!u) throw notFound('User not found');
const ids = await db
.select({ kind: userIdentities.kind })
.from(userIdentities)
.where(eq(userIdentities.userId, userId));
if (!ids.some((i) => i.kind === 'oidc')) throw notFound('No OpenID account is connected');
if (!ids.some((i) => i.kind === kind)) throw notFound('No such account is connected');
const left = ids.filter((i) => i.kind !== kind);
const mode = await getSetting(db, 'auth.mode');
const passwordWay = u.passwordHash !== null || ids.some((i) => i.kind === 'ldap');
if (!passwordWay || (mode === 'oidc' && u.role !== 'admin'))
const password = u.passwordHash !== null || left.some((i) => i.kind === 'ldap');
const openid = mode !== 'password' && left.some((i) => i.kind === 'oidc');
if (!openid && !(password && (mode !== 'oidc' || u.role === 'admin')))
throw new HttpError(409, 'last_way_in', 'That would leave no way to sign in');
await db
.delete(userIdentities)
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'oidc')));
await db.delete(sessions).where(and(eq(sessions.userId, userId), eq(sessions.method, 'oidc')));
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, kind)));
await db.delete(sessions).where(and(eq(sessions.userId, userId), eq(sessions.method, kind)));
const what = kind === 'oidc' ? 'OpenID' : 'directory';
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'sso_disconnected',
entityType: 'user',
entityId: userId,
summary: `Disconnected the OpenID account of ${u.email}`,
summary: `Disconnected the ${what} account of ${u.email}`,
});
return reply.send({ ok: true });
}
app.delete('/api/me/identities/oidc', { preHandler: requireAuth }, (req, reply) =>
disconnect(req, reply, req.user!.id),
);
app.delete('/api/users/:id/identities/oidc', { preHandler: requireAdmin }, (req, reply) =>
disconnect(req, reply, z.object({ id: z.uuid() }).parse(req.params).id),
const Kind = z.object({ kind: z.enum(['oidc', 'ldap']) });
app.delete('/api/me/identities/:kind', { preHandler: requireAuth }, (req, reply) =>
disconnect(req, reply, req.user!.id, Kind.parse(req.params).kind),
);
app.delete('/api/users/:id/identities/:kind', { preHandler: requireAdmin }, (req, reply) => {
const p = Kind.extend({ id: z.uuid() }).parse(req.params);
return disconnect(req, reply, p.id, p.kind);
});
}
+81 -1
View File
@@ -7,12 +7,14 @@ import {
AiEndpointSchema,
AiTtsEndpointSchema,
ContrackInputSchema,
LdapInputSchema,
OidcInputSchema,
SettingsSchemas,
SmtpInputSchema,
type AiTestResult,
type AuthMode,
type Destination,
type LdapTestResult,
type OidcInput,
type OidcTestResult,
type SettingKey,
@@ -34,6 +36,8 @@ import { detectPublicIp } from '../jobs.ts';
import type { SendMail } from '../services/mail.ts';
import { AiError, assertLocalUrl, testEndpoint } from '../ai/client.ts';
import { forgetBook } from '../contacts/contrack.ts';
import { assertLdap, testLdap, type LdapSettings } from '../sso/ldap.ts';
import { directoryUserFor } from '../sso/directory.ts';
import { assertIssuer, oidcConfig, oidcError, testOidc, type OidcSettings } from '../sso/oidc.ts';
declare module 'fastify' {
@@ -52,9 +56,29 @@ const READ_ONLY = new Set<string>([
]);
async function toDto(db: Db): Promise<SettingsDto> {
const { smtp, 'contacts.contrack': ctk, 'auth.oidc': oidc, ...rest } = await getAllSettings(db);
const {
smtp,
'contacts.contrack': ctk,
'auth.oidc': oidc,
'auth.ldap': ldap,
...rest
} = await getAllSettings(db);
return {
...rest,
'auth.ldap': ldap && {
enabled: ldap.enabled,
preset: ldap.preset,
url: ldap.url,
startTls: ldap.startTls,
bindDn: ldap.bindDn,
baseDn: ldap.baseDn,
userFilter: ldap.userFilter,
idAttribute: ldap.idAttribute,
emailAttribute: ldap.emailAttribute,
nameAttribute: ldap.nameAttribute,
adminFilter: ldap.adminFilter,
hasBindPassword: ldap.bindPasswordEnc !== null,
},
'auth.oidc': oidc && {
label: oidc.label,
issuer: oidc.issuer,
@@ -117,6 +141,36 @@ async function checkRefs(db: Db, v: Record<string, unknown>) {
export async function settingsRoutes(app: FastifyInstance) {
const { db } = app;
/** The stored form of `auth.ldap`: the bind password is write-only (empty keeps it). */
async function ldapFromInput(raw: unknown): Promise<LdapSettings | null> {
if (raw === null) return null;
const v = LdapInputSchema.parse(raw);
try {
assertLdap(v);
} catch (err) {
throw badRequest((err as Error).message);
}
const old = await getSetting(db, 'auth.ldap');
return {
enabled: v.enabled,
preset: v.preset,
url: v.url.replace(/\/$/, ''),
startTls: v.startTls,
bindDn: v.bindDn,
bindPasswordEnc: v.bindPassword
? encrypt(v.bindPassword)
: v.clearBindPassword
? null
: (old?.bindPasswordEnc ?? null),
baseDn: v.baseDn,
userFilter: v.userFilter,
idAttribute: v.idAttribute,
emailAttribute: v.emailAttribute,
nameAttribute: v.nameAttribute,
adminFilter: v.adminFilter,
};
}
/** The stored form of `auth.oidc`: the secret is write-only (empty keeps the stored one). */
async function oidcFromInput(input: OidcInput | null): Promise<OidcSettings | null> {
if (!input) return null;
@@ -184,6 +238,8 @@ export async function settingsRoutes(app: FastifyInstance) {
: (old?.companyTokenEnc ?? ''),
};
forgetBook(null);
} else if (key === 'auth.ldap') {
values[key] = await ldapFromInput(raw);
} else if (key === 'auth.oidc') {
values[key] = await oidcFromInput(raw === null ? null : OidcInputSchema.parse(raw));
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
@@ -291,6 +347,30 @@ export async function settingsRoutes(app: FastifyInstance) {
return { notified };
});
/**
* Settings → Sign-in → Directory → Test, from the unsaved form (12 §4.1). The test password
* is used once and never stored or logged.
*/
app.post('/api/settings/auth/ldap/test', async (req): Promise<LdapTestResult> => {
const { testLogin, testPassword, ...form } = LdapInputSchema.extend({
testLogin: z.string().trim().max(254).optional(),
testPassword: z.string().max(200).optional(),
}).parse(req.body);
const s = (await ldapFromInput(form))!;
try {
const r = await testLdap(s, testLogin || undefined, testPassword);
if (!r.user) return { ok: true, matching: r.matching };
const matches = await directoryUserFor(db, r.user);
return {
ok: true,
matching: r.matching,
user: { ...r.user, matches: matches?.email ?? null },
};
} catch (err) {
return { ok: false, error: (err as Error).message };
}
});
/** Settings → Sign-in → Test, from the unsaved form (12 §3.1). */
app.post('/api/settings/auth/oidc/test', async (req): Promise<OidcTestResult> => {
const s = await oidcFromInput(OidcInputSchema.parse(req.body));
+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 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 type { Db } from '../db/client.ts';
import { userIdentities, users } from '../db/schema.ts';
import { audit } from '../services/audit.ts';
import type { DirectoryEntry } from './ldap.ts';
import { applyRole } from './role.ts';
type UserRow = typeof users.$inferSelect;
/** Who this entry is here, without changing anything (Settings → Test says it too). */
export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise<UserRow | null> {
const [byId] = await db
.select({ u: users })
.from(userIdentities)
.innerJoin(users, eq(users.id, userIdentities.userId))
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
if (byId) return byId.u;
if (!e.email) return null;
const [byEmail] = await db.select().from(users).where(eq(users.email, e.email));
return byEmail ?? null;
}
/**
* The user this directory sign-in is for, connected now if it was matched by email (the
* directory is the company's own and the person just proved its password), with the role from
* adminFilter. Null: nobody here has this directory account or email.
*/
export async function directorySignIn(
db: Db,
log: { info(o: object, m: string): void },
e: DirectoryEntry,
ip: string,
): Promise<UserRow | null> {
const u = await directoryUserFor(db, e);
if (!u) return null;
const [mine] = await db
.select({ subject: userIdentities.subject })
.from(userIdentities)
.where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
if (!mine) {
await db.insert(userIdentities).values({
userId: u.id,
kind: 'ldap',
subject: e.id,
email: e.email,
lastUsedAt: new Date(),
});
await audit(db, {
actorUserId: u.id,
actorIp: ip,
action: 'ldap_connected',
entityType: 'user',
entityId: u.id,
summary: `${u.email} connected directory account ${e.dn}`,
});
} else if (mine.subject !== e.id) {
// Their email matches, but another directory account is already theirs: not this one.
log.info({ userId: u.id }, `directory entry ${e.dn} matches by email but isn't theirs`);
return null;
} else
await db
.update(userIdentities)
.set({ email: e.email, lastUsedAt: new Date() })
.where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
await applyRole(db, log, u.id, e.admin, 'the directory', ip);
const [after] = await db.select().from(users).where(eq(users.id, u.id));
return after!;
}
+42
View File
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, expect, it } from 'vitest';
import { anyLogin, assertLdap, ldapAuthenticate, withLogin, type LdapSettings } from './ldap.ts';
describe('LDAP (12 §4)', () => {
const f = '(&(objectClass=person)(|(uid={login})(mail={login})))';
it('escapes the login, so it can only ever be a value (RFC 4515)', () => {
expect(withLogin(f, 'ann')).toBe('(&(objectClass=person)(|(uid=ann)(mail=ann)))');
expect(withLogin(f, '*)(uid=*))(|(uid=*')).toBe(
'(&(objectClass=person)(|(uid=\\2a\\29\\28uid=\\2a\\29\\29\\28|\\28uid=\\2a)(mail=\\2a\\29\\28uid=\\2a\\29\\29\\28|\\28uid=\\2a)))',
);
expect(withLogin('(uid={login})', 'a\\b\u0000')).toBe('(uid=a\\5cb\\00)');
});
it('{login} → * keeps the other conditions, for the leaver check', () => {
expect(anyLogin(f)).toBe('(&(objectClass=person)(|(uid=*)(mail=*)))');
});
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/);
expect(() =>
assertLdap({ ...ok, url: 'ldap://dc1.example.com', startTls: true }),
).not.toThrow();
expect(() => assertLdap({ ...ok, url: 'ldaps://dc1.example.com' })).not.toThrow();
expect(() => assertLdap({ ...ok, url: 'ldap://127.0.0.1:3890' })).not.toThrow();
expect(() => assertLdap({ ...ok, url: 'ldaps://x', startTls: true })).toThrow(/StartTLS/);
});
it('refuses filters that do not parse', () => {
const base = { url: 'ldaps://x', startTls: false, userFilter: f, adminFilter: null };
expect(() => assertLdap({ ...base, userFilter: '(uid={login}' })).toThrow(/User filter/);
expect(() => assertLdap({ ...base, adminFilter: 'memberOf=x)' })).toThrow(/Admin filter/);
});
it('an empty password fails before anything is sent (no "unauthenticated bind")', async () => {
// A directory that doesn't exist: any attempt to connect would throw DirectoryUnavailable.
const s = { url: 'ldaps://192.0.2.1', startTls: false, userFilter: f } as LdapSettings;
expect(await ldapAuthenticate(s, 'ann', '')).toBeNull();
});
});
+216
View File
@@ -0,0 +1,216 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 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 {
Client,
FilterParser,
InvalidCredentialsError,
NoSuchObjectError,
SizeLimitExceededError,
escapeFilter,
type Entry,
} from 'ldapts';
import type { SettingValue } from '@selfphoneng/shared';
import { decrypt } from '../crypto/secrets.ts';
export type LdapSettings = NonNullable<SettingValue<'auth.ldap'>>;
/** The directory can't be asked (down, TLS, a refused service account): not a wrong password. */
export class DirectoryUnavailable extends Error {}
const LOCAL = new Set(['127.0.0.1', '::1', '[::1]', 'localhost']);
/** 12 §4.1: no password in clear text over the network, and filters that parse. */
export function assertLdap(s: {
url: string;
startTls: boolean;
userFilter: string;
adminFilter: string | null;
}): void {
const u = new URL(s.url);
if (u.protocol === 'ldaps:' && s.startTls) throw new Error('StartTLS is for ldap:// addresses');
if (u.protocol === 'ldap:' && !s.startTls && !LOCAL.has(u.hostname))
throw new Error('ldap:// sends passwords in clear text: use ldaps://, or turn on StartTLS');
for (const [name, f] of [
['User filter', s.userFilter],
['Admin filter', s.adminFilter],
] as const)
try {
if (f) FilterParser.parseString(f.replaceAll('{login}', 'x'));
} catch (err) {
throw new Error(`${name}: ${(err as Error).message}`, { cause: err });
}
}
/** Every {login} replaced by the RFC 4515-escaped value (ldapts escapeFilter). */
export function withLogin(filter: string, login: string): string {
const parts = filter.split('{login}');
const strings = Object.assign([...parts], { raw: parts }) as unknown as TemplateStringsArray;
return escapeFilter(strings, ...parts.slice(1).map(() => login));
}
/** {login} → * : the filter's other conditions (group, disabled bit) without the login (§4.5). */
export const anyLogin = (filter: string) => filter.replaceAll('{login}', '*');
const first = (v: Entry[string] | undefined) => (Array.isArray(v) ? v[0] : v);
/** One attribute as text: binary ids (AD's objectGUID) as lowercase hex (12 §4.4). */
const attr = (e: Entry, name: string): string | null => {
const v = first(e[name]);
if (v === undefined) return null;
return (Buffer.isBuffer(v) ? v.toString('hex') : String(v)) || null;
};
const newClient = (s: LdapSettings) =>
new Client({ url: s.url, timeout: 5000, connectTimeout: 5000 });
async function startTls(c: Client, s: LdapSettings) {
if (s.startTls) await c.startTLS({ servername: new URL(s.url).hostname });
}
/** A connection bound as the service account (or anonymous). */
async function connect(s: LdapSettings): Promise<Client> {
const c = newClient(s);
try {
await startTls(c, s);
if (s.bindDn) {
// A DN with an empty password is an anonymous "unauthenticated bind" (RFC 4513 §5.1.2).
if (!s.bindPasswordEnc) throw new Error('The service account has no password');
await c.bind(s.bindDn, decrypt(s.bindPasswordEnc));
}
return c;
} catch (err) {
await c.unbind().catch(() => {});
throw new DirectoryUnavailable(
err instanceof InvalidCredentialsError
? 'The directory refused the service account'
: (err as Error).message,
);
}
}
const attributes = (s: LdapSettings) => [s.idAttribute, s.emailAttribute, s.nameAttribute, 'cn'];
const buffers = (s: LdapSettings) => (s.idAttribute === 'objectGUID' ? ['objectGUID'] : []);
/** The entries `login` matches (at most 2: more than one is as bad as none). */
async function findByLogin(c: Client, s: LdapSettings, login: string): Promise<Entry[]> {
try {
const r = await c.search(s.baseDn, {
scope: 'sub',
filter: withLogin(s.userFilter, login),
attributes: attributes(s),
explicitBufferAttributes: buffers(s),
sizeLimit: 2,
});
return r.searchEntries;
} catch (err) {
if (err instanceof SizeLimitExceededError) return [{ dn: '' }, { dn: '' }];
throw err;
}
}
export type DirectoryEntry = {
dn: string;
/** The stable id attribute's value (12 §4.4). */
id: string;
email: string | null;
name: string | null;
/** adminFilter's answer; null when there is none (the role is managed here). */
admin: boolean | null;
};
async function toEntry(c: Client, s: LdapSettings, e: Entry): Promise<DirectoryEntry> {
const id = attr(e, s.idAttribute);
if (!id) throw new DirectoryUnavailable(`${e.dn} has no ${s.idAttribute}`);
let admin: boolean | null = null;
if (s.adminFilter)
try {
const r = await c.search(e.dn, { scope: 'base', filter: s.adminFilter, attributes: ['1.1'] });
admin = r.searchEntries.length > 0;
} catch (err) {
// lldap answers "no such object" when the entry doesn't match a base search's filter.
if (!(err instanceof NoSuchObjectError)) throw err;
admin = false;
}
return {
dn: e.dn,
id,
email: attr(e, s.emailAttribute),
name: attr(e, s.nameAttribute) ?? attr(e, 'cn'),
admin,
};
}
/** Is this the password of that entry? Its own connection, so the service one stays bound. */
async function passwordWorks(s: LdapSettings, dn: string, password: string): Promise<boolean> {
const u = newClient(s);
try {
await startTls(u, s);
await u.bind(dn, password);
return true;
} catch (err) {
if (err instanceof InvalidCredentialsError) return false;
throw new DirectoryUnavailable((err as Error).message);
} finally {
await u.unbind().catch(() => {});
}
}
const unavailable = (err: unknown) =>
err instanceof DirectoryUnavailable ? err : new DirectoryUnavailable((err as Error).message);
/**
* 12 §4.2 step 3: the directory entry for this login and password, or null when they don't
* match exactly one entry with that password. Throws DirectoryUnavailable otherwise.
*/
export async function ldapAuthenticate(
s: LdapSettings,
login: string,
password: string,
): Promise<DirectoryEntry | null> {
// Never bind with an empty password: many servers take it as anonymous success.
if (!password) return null;
const c = await connect(s);
try {
const found = await findByLogin(c, s, login);
if (found.length !== 1) return null;
if (!(await passwordWorks(s, found[0]!.dn, password))) return null;
return await toEntry(c, s, found[0]!);
} 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,
login?: string,
password?: string,
): Promise<{ matching: number; user?: DirectoryEntry }> {
const c = await connect(s);
try {
let matching: number;
try {
const r = await c.search(s.baseDn, {
scope: 'sub',
filter: anyLogin(s.userFilter),
attributes: ['1.1'],
sizeLimit: 1000,
});
matching = r.searchEntries.length;
} catch (err) {
if (!(err instanceof SizeLimitExceededError)) throw err;
matching = 1000;
}
if (!login) return { matching };
const found = await findByLogin(c, s, login);
if (found.length === 0) throw new Error(`No entry under ${s.baseDn} matches "${login}"`);
if (found.length > 1) throw new Error(`More than one entry matches "${login}"`);
if (!password || !(await passwordWorks(s, found[0]!.dn, password)))
throw new Error(`The directory refused the password for ${found[0]!.dn}`);
return { matching, user: await toEntry(c, s, found[0]!) };
} finally {
await c.unbind().catch(() => {});
}
}
+3 -20
View File
@@ -6,15 +6,15 @@ import * as oidc from 'openid-client';
import type { Db } from '../db/client.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import { sessions, userIdentities, users } from '../db/schema.ts';
import { isLastAdmin } from '../routes/users.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { decide, type ClaimsDecision } from './claims.ts';
import { applyRole } from './role.ts';
import { oidcConfig, oidcError, type OidcSettings } from './oidc.ts';
type Log = { info(o: object, m: string): void; warn?(o: object, m: string): void };
/** The role from adminGroups (never demoting the last admin with a password), groups, email. */
/** The role from adminGroups (12 §3.4), and the groups and email on the identity. */
export async function applyClaims(
db: Db,
log: Log,
@@ -24,24 +24,7 @@ export async function applyClaims(
email: string | null,
actorIp: string | null,
): Promise<void> {
const [user] = await db.select().from(users).where(eq(users.id, userId));
if (!user) return;
const role = d.admin === null ? user.role : d.admin ? 'admin' : 'user';
if (role !== user.role) {
if (role === 'user' && (await isLastAdmin(db, userId)))
log.info({ userId }, `kept the last admin with a password, though ${s.label} says user`);
else {
await db.update(users).set({ role }).where(eq(users.id, userId));
await audit(db, {
actorIp: actorIp ?? undefined,
action: 'update',
entityType: 'user',
entityId: userId,
summary: `${user.email} is now ${role}, from ${s.label}'s groups`,
diff: { before: { role: user.role }, after: { role } },
});
}
}
await applyRole(db, log, userId, d.admin, `${s.label}'s groups`, actorIp);
await db
.update(userIdentities)
.set({ email, groups: d.groups })
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The role a provider says (12 §1): OpenID adminGroups or the LDAP adminFilter.
import { eq } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { users } from '../db/schema.ts';
import { isLastAdmin } from '../routes/users.ts';
import { audit } from '../services/audit.ts';
/** admin null: the role is managed here. Never demotes the last admin with a password. */
export async function applyRole(
db: Db,
log: { info(o: object, m: string): void },
userId: string,
admin: boolean | null,
source: string,
actorIp: string | null,
): Promise<void> {
if (admin === null) return;
const [user] = await db.select().from(users).where(eq(users.id, userId));
const role = admin ? 'admin' : 'user';
if (!user || role === user.role) return;
if (role === 'user' && (await isLastAdmin(db, userId))) {
log.info({ userId }, `kept the last admin with a password, though ${source} says user`);
return;
}
await db.update(users).set({ role }).where(eq(users.id, userId));
await audit(db, {
actorIp: actorIp ?? undefined,
action: 'update',
entityType: 'user',
entityId: userId,
summary: `${user.email} is now ${role}, from ${source}`,
diff: { before: { role: user.role }, after: { role } },
});
}
+205
View File
@@ -0,0 +1,205 @@
// 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.
// 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 \
// -e LLDAP_LDAP_BASE_DN=dc=example,dc=com [-e LLDAP_LDAPS_OPTIONS__ENABLED=true …] \
// lldap/lldap:v0.6.3-alpine
import { randomBytes } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { Client } from 'ldapts';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
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 { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const URL_ = process.env.LDAP_LIVE_URL;
const HTTP = process.env.LDAP_LIVE_HTTP;
const LDAPS = process.env.LDAP_LIVE_LDAPS_URL;
const ADMIN_PASS = process.env.LDAP_LIVE_ADMIN_PASS ?? 'admin-pass-123';
const BASE = 'dc=example,dc=com';
/** RFC 3062 password modify request: SEQUENCE { [0] user DN, [2] new password }. */
function passwordModify(dn: string, password: string): Buffer {
const tlv = (tag: number, v: Buffer) => Buffer.concat([Buffer.from([tag, v.length]), v]);
return tlv(0x30, Buffer.concat([tlv(0x80, Buffer.from(dn)), tlv(0x82, Buffer.from(password))]));
}
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 group = `pbx-admins-${tag}`;
let t: Awaited<ReturnType<typeof createTestApp>>;
let jwt: string;
let admin: string;
const gql = async (query: string) => {
const r = await fetch(`${HTTP}/api/graphql`, {
method: 'POST',
headers: { authorization: `Bearer ${jwt}`, 'content-type': 'application/json' },
body: JSON.stringify({ query }),
});
const j = (await r.json()) as { data: Record<string, unknown>; errors?: unknown };
if (j.errors) throw new Error(JSON.stringify(j.errors));
return j.data;
};
const settings = (url = URL_!) => ({
enabled: true,
preset: 'lldap' as const,
url,
startTls: false,
bindDn: `uid=admin,ou=people,${BASE}`,
bindPasswordEnc: encrypt(ADMIN_PASS),
baseDn: `ou=people,${BASE}`,
userFilter: '(&(objectClass=person)(|(uid={login})(mail={login})))',
idAttribute: 'entryUUID',
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: `(memberOf=cn=${group},ou=groups,${BASE})`,
});
const login = (who: string, password: string) =>
t.app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { login: who, password },
});
beforeAll(async () => {
const r = await fetch(`${HTTP}/auth/simple/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: ADMIN_PASS }),
});
jwt = ((await r.json()) as { token: string }).token;
for (const [id, name] of [
[ann, 'Ann Lee'],
[bob, 'Bob Roe'],
])
await gql(
`mutation { createUser(user: { id: "${id}", email: "${id}@example.com", displayName: "${name}" }) { id } }`,
);
const g = (await gql(`mutation { createGroup(name: "${group}") { id } }`)) as {
createGroup: { id: number };
};
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])
await c.exop(
'1.3.6.1.4.1.4203.1.11.1',
passwordModify(`uid=${id},ou=people,${BASE}`, `${id}-directory-pw`),
);
await c.unbind();
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
await t.addUser(`${ann}@example.com`, 'ann-local-pw-12');
await setSetting(t.db, 'auth.ldap', settings());
}, 60_000);
afterAll(async () => {
await t?.close();
for (const id of [ann, bob])
await gql(`mutation { deleteUser(userId: "${id}") { ok } }`).catch(() => {});
});
it('methods say the directory is on', async () => {
expect((await t.app.inject({ url: '/api/auth/methods' })).json().ldap).toBe(true);
});
it('signs in by uid, connecting the user by email, and memberOf makes an admin', async () => {
resetLoginLimiter();
const r = await login(ann, `${ann}-directory-pw`);
expect(r.statusCode).toBe(200);
expect(r.json().user).toMatchObject({
email: `${ann}@example.com`,
role: 'admin',
signIn: { password: true, ldap: true },
});
const [u] = await t.db
.select()
.from(users)
.where(eq(users.email, `${ann}@example.com`));
const ids = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u!.id));
expect(ids).toMatchObject([{ kind: 'ldap', email: `${ann}@example.com` }]);
expect(ids[0]!.subject).toMatch(/^[0-9a-f-]{36}$/); // entryUUID
});
it('signs in by email too, and the local password still works', async () => {
resetLoginLimiter();
expect((await login(`${ann}@example.com`, `${ann}-directory-pw`)).statusCode).toBe(200);
expect((await login(`${ann}@example.com`, 'ann-local-pw-12')).statusCode).toBe(200);
});
it('a wrong password, an empty one, and a stranger are refused alike', async () => {
resetLoginLimiter();
for (const [who, pw] of [
[ann, 'wrong-password'],
[ann, ''],
[bob, `${bob}-directory-pw`], // in the directory, but nobody here: no user is made
['*', `${ann}-directory-pw`],
]) {
const r = await login(who!, pw!);
expect([400, 401]).toContain(r.statusCode);
}
const [b] = await t.db
.select()
.from(users)
.where(eq(users.email, `${bob}@example.com`));
expect(b).toBeUndefined();
});
it('a directory that is down is a 503, and local passwords still work', async () => {
resetLoginLimiter();
await setSetting(t.db, 'auth.ldap', settings('ldap://127.0.0.1:1'));
try {
const r = await login(ann, `${ann}-directory-pw`);
expect(r.statusCode).toBe(503);
expect(r.json().error.code).toBe('directory_unavailable');
expect((await login(`${ann}@example.com`, 'ann-local-pw-12')).statusCode).toBe(200);
} finally {
await setSetting(t.db, 'auth.ldap', settings());
}
});
it('Test: counts people, and a test login says who it would be', async () => {
const { bindPasswordEnc: _, ...form } = settings();
const r = await t.app.inject({
method: 'POST',
url: '/api/settings/auth/ldap/test',
headers: { ...H, cookie: admin },
payload: { ...form, testLogin: ann, testPassword: `${ann}-directory-pw` },
});
expect(r.json()).toMatchObject({
ok: true,
user: { email: `${ann}@example.com`, admin: true, matches: `${ann}@example.com` },
});
expect(r.json().matching).toBeGreaterThanOrEqual(3);
const bad = await t.app.inject({
method: 'POST',
url: '/api/settings/auth/ldap/test',
headers: { ...H, cookie: admin },
payload: { ...form, testLogin: ann, testPassword: 'nope' },
});
expect(bad.json()).toMatchObject({ ok: false, error: expect.stringContaining('refused') });
});
it.skipIf(!LDAPS)('LDAPS with a private CA (NODE_EXTRA_CA_CERTS)', async () => {
resetLoginLimiter();
await setSetting(t.db, 'auth.ldap', settings(LDAPS));
try {
expect((await login(ann, `${ann}-directory-pw`)).statusCode).toBe(200);
} finally {
await setSetting(t.db, 'auth.ldap', settings());
}
});
});
+7 -1
View File
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Typed fetch wrappers (05 §1). Every request sends X-Requested-With (CSRF, 04 §2.2).
import type {
LdapInput,
LdapTestResult,
AuthMethods,
OidcInput,
OidcTestResult,
@@ -184,7 +186,8 @@ export const usersApi = {
resetPassword: (id: string) =>
api<{ temporaryPassword: string }>('POST', `/api/users/${id}/reset-password`),
resetTwoStep: (id: string) => api<{ ok: true }>('POST', `/api/users/${id}/reset-two-step`, {}),
disconnectOidc: (id: string) => api<{ ok: true }>('DELETE', `/api/users/${id}/identities/oidc`),
disconnect: (id: string, kind: 'oidc' | 'ldap') =>
api<{ ok: true }>('DELETE', `/api/users/${id}/identities/${kind}`),
};
export const extensionsApi = {
@@ -246,6 +249,9 @@ export const settingsApi = {
),
warnings: () => api<{ items: Warning[] }>('GET', '/api/system/warnings').then((r) => r.items),
testOidc: (v: OidcInput) => api<OidcTestResult>('POST', '/api/settings/auth/oidc/test', v),
/** 12 §4.1 */
testLdap: (v: LdapInput & { testLogin?: string; testPassword?: string }) =>
api<LdapTestResult>('POST', '/api/settings/auth/ldap/test', v),
};
/** Sign out, and when the session came from OpenID, at the provider too (12 §3.5). */
+5
View File
@@ -59,6 +59,11 @@ export function SignInCard() {
</p>
)}
</div>
{ways.ldap && (
<p className="text-sm">
Your company directory account is connected: its password works here too.
</p>
)}
{oidc && (
<div className="flex flex-col gap-2 text-sm">
<h3 className="font-medium">{oidc.label}</h3>
+5 -2
View File
@@ -99,12 +99,15 @@ export function LoginPage() {
noValidate
>
{error && <Alert>{error}</Alert>}
<Field label="Email" error={errors.login?.message}>
<Field
label={methods.data?.ldap ? 'Email or username' : 'Email'}
error={errors.login?.message}
>
{(a) => (
<input
{...a}
{...form.register('login')}
type="email"
type={methods.data?.ldap ? 'text' : 'email'}
autoComplete="username"
className={inputClass}
/>
+215 -1
View File
@@ -8,7 +8,9 @@ import {
WEBHOOK_EVENTS,
formatNumber,
type AiTestResult,
LDAP_PRESETS,
type AuthMode,
type LdapInput,
type OidcInput,
type SettingsDto,
type WebhookDto,
@@ -77,7 +79,12 @@ export function SettingsPage() {
{tab === 'tokens' && <TokensTab />}
{settings.data && tab === 'ai' && <AiTab s={settings.data} />}
{settings.data && tab === 'contacts' && <ContactsTab s={settings.data} />}
{settings.data && tab === 'sign-in' && <SignInTab s={settings.data} />}
{settings.data && tab === 'sign-in' && (
<div className="flex flex-col gap-10">
<SignInTab s={settings.data} />
<DirectoryForm s={settings.data} />
</div>
)}
</section>
);
}
@@ -1570,3 +1577,210 @@ function SignInTab({ s }: { s: SettingsDto }) {
</form>
);
}
/** Sign-in → Directory (spec 12 §4.1): LDAP / LDAPS, checked when a password isn't ours. */
function DirectoryForm({ s }: { s: SettingsDto }) {
const save = useSave();
const d = s['auth.ldap'];
const [v, setV] = useState({
enabled: d?.enabled ?? false,
preset: d?.preset ?? ('ad' as LdapInput['preset']),
url: d?.url ?? '',
startTls: d?.startTls ?? false,
bindDn: d?.bindDn ?? '',
bindPassword: '',
baseDn: d?.baseDn ?? '',
userFilter: d?.userFilter ?? LDAP_PRESETS.ad.userFilter,
idAttribute: d?.idAttribute ?? LDAP_PRESETS.ad.idAttribute,
emailAttribute: d?.emailAttribute ?? 'mail',
nameAttribute: d?.nameAttribute ?? 'displayName',
adminFilter: d?.adminFilter ?? '',
});
const [probe, setProbe] = useState({ testLogin: '', testPassword: '' });
const input = (): LdapInput => ({ ...v, bindPassword: v.bindPassword || undefined });
const test = useMutation({ mutationFn: () => settingsApi.testLdap({ ...input(), ...probe }) });
const set = (k: keyof typeof v) => (e: { target: { value: string } }) =>
setV({ ...v, [k]: e.target.value });
const preset = (p: LdapInput['preset']) => {
const x = LDAP_PRESETS[p];
setV({
...v,
preset: p,
userFilter: x.userFilter,
idAttribute: x.idAttribute,
adminFilter: x.adminFilter,
});
};
const text = (k: keyof typeof v, label: string, hint?: string, placeholder?: string) => (
<Field label={label} hint={hint}>
{(a) => (
<input
{...a}
className={inputClass}
placeholder={placeholder}
value={v[k] as string}
onChange={set(k)}
/>
)}
</Field>
);
return (
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
save.mutate({ 'auth.ldap': v.url.trim() ? input() : null });
setV({ ...v, bindPassword: '' });
}}
>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">Directory (LDAP)</legend>
<p className="text-sm text-zinc-500">
When a password isn&apos;t the one set here, SelfPhoneNG asks the directory. People sign
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>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4"
checked={v.enabled}
onChange={(e) => setV({ ...v, enabled: e.target.checked })}
/>
Check passwords against the directory
</label>
<Field label="Directory type" hint="Fills in the filters below; change them as needed">
{(a) => (
<select
{...a}
className={inputClass}
value={v.preset}
onChange={(e) => preset(e.target.value as LdapInput['preset'])}
>
{Object.entries(LDAP_PRESETS).map(([id, p]) => (
<option key={id} value={id}>
{p.label}
</option>
))}
</select>
)}
</Field>
{text(
'url',
'Address',
'ldaps:// (port 636), or ldap:// with StartTLS. Plain ldap:// only to this server itself.',
'ldaps://dc1.example.com',
)}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4"
checked={v.startTls}
onChange={(e) => setV({ ...v, startTls: e.target.checked })}
/>
StartTLS (for ldap:// addresses)
</label>
{text(
'bindDn',
'Service account DN',
'An account that may search people. Empty: search anonymously.',
'CN=pbx-search,OU=Service,DC=example,DC=com',
)}
<Field
label="Service account password"
hint={d?.hasBindPassword ? 'Saved. Type a new one to replace it.' : undefined}
>
{(a) => (
<input
{...a}
type="password"
autoComplete="off"
placeholder={d?.hasBindPassword ? '••••••••' : ''}
className={inputClass}
value={v.bindPassword}
onChange={set('bindPassword')}
/>
)}
</Field>
{text(
'baseDn',
'Where people are',
'Searched with everything under it',
'DC=example,DC=com',
)}
{text(
'userFilter',
'User filter',
'{login} is what they type. To allow one group only, add (memberOf=…) here.',
)}
{text(
'adminFilter',
'Admin filter',
'People it matches are admins, the others users. Empty: you set roles on Users.',
)}
{text(
'idAttribute',
'ID attribute',
'Never changes for a person: objectGUID, entryUUID, ipaUniqueID',
)}
{text('emailAttribute', 'Email attribute')}
{text('nameAttribute', 'Name attribute')}
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Test login (optional)" hint="Username or email">
{(a) => (
<input
{...a}
autoComplete="off"
className={inputClass}
value={probe.testLogin}
onChange={(e) => setProbe({ ...probe, testLogin: e.target.value })}
/>
)}
</Field>
<Field label="Its password" hint="Only used for this test, never stored">
{(a) => (
<input
{...a}
type="password"
autoComplete="off"
className={inputClass}
value={probe.testPassword}
onChange={(e) => setProbe({ ...probe, testPassword: e.target.value })}
/>
)}
</Field>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={() => test.mutate()} disabled={test.isPending || !v.url.trim()}>
Test
</Button>
{test.data && (
<span
role="status"
className={`text-sm ${test.data.ok ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}`}
>
{!test.data.ok
? test.data.error
: `Works: ${test.data.matching >= 1000 ? '1000+' : test.data.matching} people match the user filter.` +
(test.data.user
? ` ${test.data.user.dn}: ${test.data.user.email ?? 'no email'}, ${test.data.user.admin === null ? 'role set here' : test.data.user.admin ? 'admin' : 'user'}, ${test.data.user.matches ? `signs in as ${test.data.user.matches}` : 'no user here has that email'}.`
: '')}
</span>
)}
{test.error && <Alert>{test.error.message}</Alert>}
</div>
</fieldset>
{save.isError && <Alert>{save.error.message}</Alert>}
{save.isSuccess && (
<p role="status" className="text-sm text-green-700 dark:text-green-400">
Saved.
</p>
)}
<div>
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
</div>
</form>
);
}
+24 -13
View File
@@ -240,7 +240,8 @@ function GeneralTab({
Creates a new temporary password and signs them out everywhere.
</p>
{user.twoStep && <ResetTwoStep user={user} />}
{user.signIn.oidc && <ConnectedOidc user={user} />}
{user.signIn.oidc && <ConnectedAccount user={user} kind="oidc" />}
{user.signIn.ldap && <ConnectedAccount user={user} kind="ldap" />}
</div>
)}
</form>
@@ -587,26 +588,36 @@ function EmergencyTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void
);
}
/** Their connected OpenID account (12 §5): the groups it sent, and Disconnect. */
function ConnectedOidc({ user }: { user: UserDto }) {
/** A connected OpenID or directory account (12 §5): what it sent, and Disconnect. */
function ConnectedAccount({ user, kind }: { user: UserDto; kind: 'oidc' | 'ldap' }) {
const qc = useQueryClient();
const label = useAuthMethods().data?.oidc?.label ?? 'OpenID';
const oidcLabel = useAuthMethods().data?.oidc?.label ?? 'OpenID';
const label = kind === 'oidc' ? oidcLabel : 'Directory';
const disconnect = useMutation({
mutationFn: () => usersApi.disconnectOidc(user.id),
mutationFn: () => usersApi.disconnect(user.id, kind),
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
});
const oidc = user.signIn.oidc!;
const oidc = user.signIn.oidc;
return (
<div className="mt-4 flex flex-col gap-1 text-sm">
<p>
<span className="font-medium">{label}</span>: {oidc.email ?? 'connected'}
</p>
<p className="text-xs text-zinc-500">
Groups at the last sign-in: {oidc.groups.length ? oidc.groups.join(', ') : 'none'}
</p>
{kind === 'oidc' && oidc ? (
<>
<p>
<span className="font-medium">{label}</span>: {oidc.email ?? 'connected'}
</p>
<p className="text-xs text-zinc-500">
Groups at the last sign-in: {oidc.groups.length ? oidc.groups.join(', ') : 'none'}
</p>
</>
) : (
<p>
<span className="font-medium">{label}</span>: connected, their directory password works
here
</p>
)}
<div>
<Button onClick={() => disconnect.mutate()} disabled={disconnect.isPending}>
Disconnect {label}
Disconnect {kind === 'oidc' ? label : 'the directory'}
</Button>
</div>
{disconnect.error && <Alert>{disconnect.error.message}</Alert>}
+33 -2
View File
@@ -1,4 +1,4 @@
# Sign-in: passwords and OpenID Connect
# Sign-in: passwords, OpenID Connect and LDAP
People sign in to SelfPhoneNG (the admin pages, their own portal and the browser phone) with a
password, with your company's identity provider through **OpenID Connect**, or either. This only
@@ -6,7 +6,8 @@ changes the web sign-in: desk phones, phone apps and the browser phone's line ke
passwords and never talk to the identity provider.
Any provider that speaks OpenID Connect works. This page uses **Keycloak**; the table at the end
says what is different for the others.
says what is different for the others. A company directory (Active Directory, OpenLDAP, FreeIPA,
lldap …) can check passwords too: see [Directory (LDAP)](#directory-ldap).
## How it works
@@ -113,3 +114,33 @@ The login page says what went wrong in plain words. The reason Keycloak gave is
| Okta, Auth0, Kanidm, Pocket ID, Dex, GitLab, Rauthy | as the provider documents | as the provider documents | expected to work, not tested yet |
Keycloak (26.8.1) is the one SelfPhoneNG is tested against.
## Directory (LDAP)
With a directory, people sign in with their **directory username or email and password**. When
the password isn't the one set in SelfPhoneNG, SelfPhoneNG asks the directory. The first time,
they're matched to their user here by the directory's email; nobody gets a user just for being in
the directory. Their SelfPhoneNG password, if they have one, keeps working too.
**Settings → Sign-in → Directory (LDAP)**:
1. **Directory type**: Active Directory, OpenLDAP, FreeIPA or lldap. It fills in the filters.
2. **Address**: `ldaps://dc1.example.com` (port 636), or `ldap://…` with **StartTLS**. A plain
`ldap://` address is refused unless the directory runs on this same server, because passwords
would cross the network in clear text. For a private CA, see
[the certificate section](#keycloak-with-a-self-signed-or-private-certificate) (same file).
3. **Service account DN** and **password**: an account that may search people, e.g.
`CN=pbx-search,OU=Service,DC=example,DC=com`. Empty searches anonymously.
4. **Where people are**: the base DN, e.g. `DC=example,DC=com` or `ou=people,dc=example,dc=com`.
5. **User filter**: `{login}` is what they type. Only one group may sign in? Add
`(memberOf=CN=Phone users,OU=Groups,DC=example,DC=com)` inside the `(&…)`.
6. **Admin filter**: people it matches are admins at each sign-in, the others users, e.g.
`(memberOf=cn=pbx-admins,ou=groups,dc=example,dc=com)`. Active Directory's preset uses
`memberOf:1.2.840.113556.1.4.1941:=` so nested groups count. Empty: you set roles on Users.
7. **Test**, with a test login and its password: it says how many people the filter matches, and
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.
+25 -1
View File
@@ -452,7 +452,7 @@ For every active user with a directory account, one connection per run:
| S1 | Decision 0004; migration (§2); `auth.mode` (§1.1) with only `password` selectable; break glass; `GET /api/auth/methods`; the `login` field; Users "Signs in with" | `pnpm lint && typecheck && test` pass; sign-in works as before. Done, see §8.1 |
| 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 |
| 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 |
### 8.1 As built: S1
@@ -507,6 +507,30 @@ For every active user with a directory account, one connection per run:
- Admin guide: `docs/admin-guide/sign-in.md`. There is no audit log page yet, so failures are
found in the api log (`OpenID sign-in failed`, `reason`).
### 8.4 As built: S4
- `sso/ldap.ts` (ldapts 9.2: `assertLdap`, `withLogin` through `escapeFilter`, `anyLogin`,
`ldapAuthenticate`, `testLdap`), `sso/directory.ts` (entry → user, connecting by email),
`sso/role.ts` (`applyRole`, shared with OpenID). The user's own bind is on a second connection,
so the admin filter is read with the service account.
- Login (§4.2): the SelfPhoneNG password first; when it isn't that, the directory. The "last
active local admin" exception of the first draft is gone: connecting a directory account no
longer removes a password (§1), so there is nothing to protect. Sessions are `method = 'ldap'`.
- The user's name and email here are **not** changed by the directory (as with OpenID): only the
identity's email and the role (`adminFilter`) follow it. §4.3 step 1's "email follows" is
dropped; an admin edits users on Users.
- lldap answers a base search whose filter doesn't match the entry with `noSuchObject`
(0x20), not an empty result: that counts as "not admin".
- Disconnect is `DELETE /api/me/identities/:kind` and `/api/users/:id/identities/:kind`
(`oidc` or `ldap`), with one "is there still a way in" rule for both.
- `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).
- 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.
## 9. Decided
- Owner, 2026-10-25: a user connects their OpenID account to their account and signs in with
+114 -1
View File
@@ -118,6 +118,24 @@ export const SettingsSchemas = {
.default(null),
/** Sign-in (12 §1.1): passwords only, OpenID only (admins keep passwords), or both. */
'auth.mode': z.enum(['password', 'mixed', 'oidc']).default('password'),
/** LDAP / LDAPS (12 §4.1). The bind password is encrypted; null = anonymous search. */
'auth.ldap': z
.object({
enabled: z.boolean(),
preset: z.enum(['ad', 'openldap', 'freeipa', 'lldap', 'other']),
url: z.string(),
startTls: z.boolean(),
bindDn: z.string().nullable(),
bindPasswordEnc: z.string().nullable(),
baseDn: z.string(),
userFilter: z.string(),
idAttribute: z.string(),
emailAttribute: z.string(),
nameAttribute: z.string(),
adminFilter: z.string().nullable(),
})
.nullable()
.default(null),
/** OpenID Connect (12 §3.1). The client secret is encrypted; null = a public client. */
'auth.oidc': z
.object({
@@ -191,6 +209,93 @@ export const OidcInputSchema = z.object({
});
export type OidcInput = z.input<typeof OidcInputSchema>;
/** The directory presets (12 §4.1): they only fill the form. */
export const LDAP_PRESETS = {
ad: {
label: 'Active Directory / Samba AD',
userFilter:
'(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(|(sAMAccountName={login})(userPrincipalName={login})(mail={login})))',
idAttribute: 'objectGUID',
adminFilter: '(memberOf:1.2.840.113556.1.4.1941:=CN=PBX Admins,OU=Groups,DC=example,DC=com)',
},
openldap: {
label: 'OpenLDAP (memberof overlay)',
userFilter: '(&(objectClass=inetOrgPerson)(|(uid={login})(mail={login})))',
idAttribute: 'entryUUID',
adminFilter: '(memberOf=cn=pbx-admins,ou=groups,dc=example,dc=com)',
},
freeipa: {
label: 'FreeIPA',
userFilter: '(&(objectClass=person)(!(nsAccountLock=TRUE))(|(uid={login})(mail={login})))',
idAttribute: 'ipaUniqueID',
adminFilter: '(memberOf=cn=pbx-admins,cn=groups,cn=accounts,dc=example,dc=com)',
},
lldap: {
label: 'lldap',
userFilter: '(&(objectClass=person)(|(uid={login})(mail={login})))',
idAttribute: 'entryUUID',
adminFilter: '(memberOf=cn=pbx-admins,ou=groups,dc=example,dc=com)',
},
other: {
label: 'Other',
userFilter: '(&(objectClass=person)(|(uid={login})(mail={login})))',
idAttribute: 'entryUUID',
adminFilter: '',
},
} as const;
/** PATCH /api/settings `auth.ldap` and its Test: the bind password is write-only. */
export const LdapInputSchema = z.object({
enabled: z.boolean(),
preset: z.enum(['ad', 'openldap', 'freeipa', 'lldap', 'other']),
url: z
.string()
.trim()
.regex(
/^ldaps?:\/\/[^/\s]+\/?$/,
'Like ldaps://dc1.example.com or ldap://ldap.example.com:389',
),
startTls: z.boolean(),
bindDn: z
.string()
.trim()
.max(500)
.transform((v) => v || null),
bindPassword: z.string().max(500).optional(),
clearBindPassword: z.boolean().optional(),
baseDn: z.string().trim().min(1, 'Required').max(500),
userFilter: z
.string()
.trim()
.max(2000)
.refine((v) => v.includes('{login}'), 'Must contain {login}'),
idAttribute: z.string().trim().min(1).max(100),
emailAttribute: z.string().trim().min(1).max(100),
nameAttribute: z.string().trim().min(1).max(100),
adminFilter: z
.string()
.trim()
.max(2000)
.transform((v) => v || null),
});
export type LdapInput = z.input<typeof LdapInputSchema>;
/** POST /api/settings/auth/ldap/test. `user`: with a test login, who it would sign in as. */
export type LdapTestResult =
| {
ok: true;
matching: number;
user?: {
dn: string;
id: string;
email: string | null;
name: string | null;
admin: boolean | null;
matches: string | null;
};
}
| { ok: false; error: string };
/** POST /api/settings/auth/oidc/test. */
export type OidcTestResult =
| {
@@ -208,13 +313,21 @@ export type OidcTestResult =
/** GET /api/settings: every key, secrets replaced by hasPassword / hasCompanyToken. */
export type SettingsDto = {
[
K in Exclude<SettingKey, 'smtp' | 'sms.webhookToken' | 'contacts.contrack' | 'auth.oidc'>
K in Exclude<
SettingKey,
'smtp' | 'sms.webhookToken' | 'contacts.contrack' | 'auth.oidc' | 'auth.ldap'
>
]: SettingValue<K>;
} & {
smtp: (Omit<SmtpInput, 'password'> & { hasPassword: boolean }) | null;
'contacts.contrack': { url: string; publicUrl: string | null; hasCompanyToken: boolean } | null;
'auth.oidc':
(Omit<OidcInput, 'clientSecret' | 'clearClientSecret'> & { hasClientSecret: boolean }) | null;
'auth.ldap':
| (Omit<z.output<typeof LdapInputSchema>, 'bindPassword' | 'clearBindPassword'> & {
hasBindPassword: boolean;
})
| null;
};
/** POST /api/ai/test: one endpoint tried with a tiny request. `audio`: the TTS test, a data: URL. */
+16
View File
@@ -62,6 +62,9 @@ importers:
fastify:
specifier: ^5.12.7
version: 5.12.7
ldapts:
specifier: ^9.2.0
version: 9.2.0
nodemailer:
specifier: 10.0.11
version: 10.0.11
@@ -1969,6 +1972,10 @@ packages:
keyv@5.6.0:
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
ldapts@9.2.0:
resolution: {integrity: sha512-5mDg3OT5fDP9DUXga9dCLW8cMW24OTOKT4pv/Qj+f2zCV3qxb+gkZLCzMAqzVTOANoycidKkYpPvkclUQU2aXw==}
engines: {node: '>=22'}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -2479,6 +2486,9 @@ packages:
stream-shift@1.0.3:
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
strict-event-emitter-types@2.0.0:
resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -4131,6 +4141,10 @@ snapshots:
dependencies:
'@keyv/serialize': 1.1.1
ldapts@9.2.0:
dependencies:
strict-event-emitter-types: 2.0.0
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -4548,6 +4562,8 @@ snapshots:
stream-shift@1.0.3: {}
strict-event-emitter-types@2.0.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0