Feat/sso #3
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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). */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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't the one set here, SelfPhoneNG asks the directory. People sign
|
||||
in with their directory username or email; the first time, they'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>
|
||||
);
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Generated
+16
@@ -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
|
||||
|
||||
Reference in new issue
Block a user