auth: security audit fixes, rate limits on every route
ci / check (push) Successful in 1m3s
ci / images (push) Successful in 19s
ci / check (pull_request) Successful in 1m5s
ci / images (pull_request) Successful in 5s

Two separate reviews of the SSO branch and all 207 API routes (spec 12 §8.6). Every route was
guarded as meant. Fixed, each with a test:
- login: a directory 503 counts as a failed sign-in; a locked directory account is a refused
  password, not "unavailable"
- open redirect after OpenID sign-in (a tab in next)
- directory email matching only for non-admins without their own password; others connect on
  My settings (POST /api/me/identities/ldap); only admins disconnect a directory account
- OpenID session check reads the refreshed ID token's claims too
- connect is POST /api/auth/oidc/connect (CSRF header)
- stored bind password, client secret and SMTP password tied to their server
- 409 role_managed when a provider sets the role; empty Contrack PATCH is a 400
- @fastify/rate-limit on every route (RATE_LIMIT_PER_MINUTE), 10/min on Test buttons; a
  password asked again shares the login limiter
Live tests pass against lldap 0.6.3, Samba 4 AD and Keycloak 26.8.1.
This commit is contained in:
iamdoubz committed 2026-10-26 09:46:23 -05:00
1 parent 19610777a4
commit 97e67e347d
39 files changed
+1059 -186

No files matched your search

+7 -1
View File
@@ -94,8 +94,14 @@ describe.skipIf(!hasTestDb || !URL_)('Active Directory with a real Samba AD DC (
});
t = await createTestApp();
for (const sam of [ann, bob, dan, cat])
// New users with the temporary password: the directory email may connect them (12 §4.3).
for (const sam of [ann, bob, dan, cat]) {
await t.addUser(`${sam}@example.com`, 'local-password-1');
await t.db
.update(users)
.set({ mustChangePassword: true })
.where(eq(users.email, `${sam}@example.com`));
}
await setSetting(t.db, 'auth.ldap', {
enabled: true,
preset: 'ad',
+1
View File
@@ -66,6 +66,7 @@ export async function createTestApp(
INITIAL_ADMIN_PASSWORD: ADMIN.password,
AMI_SECRET: 'ami-secret-123',
LOG_LEVEL: 'fatal',
RATE_LIMIT_PER_MINUTE: '0', // the rate limit has its own test
RECORDINGS_DIR: mkdtempSync(join(tmpdir(), 'sp-rec-')),
SOUNDS_DIR: mkdtempSync(join(tmpdir(), 'sp-sounds-')),
MOH_DIR: mkdtempSync(join(tmpdir(), 'sp-moh-')),
+119
View File
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 §4.3: who a directory entry may connect to by email, who may disconnect it (§4.5), and a
// directory that's down counting as a failed sign-in (§4.2).
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { userIdentities, users } from '../src/db/schema.ts';
import { setSetting } from '../src/services/settings.ts';
import { directorySignIn } from '../src/sso/directory.ts';
import type { DirectoryEntry } from '../src/sso/ldap.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const log = { info: () => {} };
describe.skipIf(!hasTestDb)('directory accounts by email (12 §4.3)', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let n = 0;
const entry = (email: string): DirectoryEntry => ({
dn: `uid=e${++n}`,
id: `id-${n}`,
email,
name: email,
admin: null,
});
const connected = async (userId: string) =>
(await t.db.select().from(userIdentities).where(eq(userIdentities.userId, userId))).length;
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
});
afterAll(() => t?.close());
it('never connects an admin, or someone who set their own password', async () => {
const admin = await t.addUser('boss@example.com', 'boss-password-1', 'admin');
const own = await t.addUser('own@example.com', 'own-password-1');
for (const u of [admin, own]) {
expect(await directorySignIn(t.db, log, entry(u.email), '127.0.0.1')).toBeNull();
expect(await connected(u.id)).toBe(0);
}
});
it('connects someone with no password, or only the temporary one', async () => {
const fresh = await t.addUser('fresh@example.com', 'temporary-pw-1');
await t.db.update(users).set({ mustChangePassword: true }).where(eq(users.id, fresh.id));
const none = await t.addUser('none@example.com', 'unused-pw-1');
await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, none.id));
for (const u of [fresh, none]) {
expect((await directorySignIn(t.db, log, entry(u.email), '127.0.0.1'))?.id).toBe(u.id);
expect(await connected(u.id)).toBe(1);
}
});
it('only an admin can disconnect a directory account (a leaver must stay checked)', async () => {
const u = await t.addUser('leaver@example.com', 'leaver-password-1');
await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: 'leaver-id' });
const mine = await t.login(u.email, 'leaver-password-1');
const self = await t.app.inject({
method: 'DELETE',
url: '/api/me/identities/ldap',
headers: { ...H, cookie: mine },
});
expect(self.statusCode).toBe(403);
expect(await connected(u.id)).toBe(1);
const admin = await t.login(ADMIN.email, ADMIN.password);
const byAdmin = await t.app.inject({
method: 'DELETE',
url: `/api/users/${u.id}/identities/ldap`,
headers: { ...H, cookie: admin },
});
expect(byAdmin.statusCode).toBe(200);
expect(await connected(u.id)).toBe(0);
});
it('a directory that is down still counts each failed password: no free guesses', async () => {
resetLoginLimiter();
await setSetting(t.db, 'auth.ldap', {
enabled: true,
preset: 'lldap',
url: 'ldap://127.0.0.1:1', // nothing listens there
startTls: false,
bindDn: null,
bindPasswordEnc: null,
baseDn: 'dc=example,dc=com',
userFilter: '(uid={login})',
idAttribute: 'entryUUID',
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: null,
});
try {
const codes: number[] = [];
for (let i = 0; i < 6; i++)
codes.push(
(
await t.app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { login: ADMIN.email, password: `guess-${i}` },
})
).statusCode,
);
expect(codes).toEqual([503, 503, 503, 503, 503, 429]);
// Even the right password waits now: the guesses were counted.
const right = await t.app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { login: ADMIN.email, password: ADMIN.password },
});
expect(right.statusCode).toBe(429);
} finally {
await setSetting(t.db, 'auth.ldap', null);
resetLoginLimiter();
}
});
});
+9 -2
View File
@@ -11,6 +11,8 @@ export type FakeUser = {
email_verified?: boolean;
/** Only in userinfo, as Keycloak's mappers and Authelia send them. */
groups?: string[];
/** Only in the ID token, as Entra's app roles. */
roles?: string[];
};
const b64u = (b: Buffer | string) => Buffer.from(b).toString('base64url');
@@ -38,6 +40,8 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
/** true: the token endpoint answers 503, as a provider that's down. */
down: false,
refreshes: 0,
/** false: a refresh brings no new ID token (allowed by OIDC Core §12.2). */
refreshIdToken: true,
};
const codes = new Map<string, { nonce: string; challenge: string; user: FakeUser }>();
const access = new Map<string, FakeUser>();
@@ -101,7 +105,8 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
st.refreshes++;
if (!user || !st.refreshOk) return json(400, { error: 'invalid_grant' });
// Today's claims, as a real provider gives on refresh.
return json(200, tokens(st.user.sub === user.sub ? { ...st.user } : user, null));
const t = tokens(st.user.sub === user.sub ? { ...st.user } : user, null);
return json(200, st.refreshIdToken ? t : { ...t, id_token: undefined });
}
const c = codes.get(form.get('code') ?? '');
codes.delete(form.get('code') ?? '');
@@ -113,7 +118,8 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
if (url.pathname === '/userinfo') {
const user = access.get((req.headers.authorization ?? '').replace(/^Bearer /, ''));
if (!user) return json(401, { error: 'invalid_token' });
return json(200, { ...user, ...(st.tamper.userinfoSub && { sub: 'someone-else' }) });
const { roles: _, ...info } = user;
return json(200, { ...info, ...(st.tamper.userinfoSub && { sub: 'someone-else' }) });
}
if (url.pathname === '/logout') return json(200, {});
json(404, {});
@@ -130,6 +136,7 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
exp: t.expired ? now - 3600 : now + 300,
...(nonce && { nonce: t.nonce ? 'wrong-nonce' : nonce }),
...(user.email && { email: user.email, email_verified: user.email_verified }),
...(user.roles && { roles: user.roles }),
};
const at = b64u(randomBytes(16));
const rt = b64u(randomBytes(16));
+32 -6
View File
@@ -38,6 +38,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
const ann = `ann${tag}`;
const bob = `bob${tag}`;
const cat = `cat${tag}`;
const dee = `dee${tag}`;
const group = `pbx-admins-${tag}`;
let t: Awaited<ReturnType<typeof createTestApp>>;
let jwt: string;
@@ -86,6 +87,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
[ann, 'Ann Lee'],
[bob, 'Bob Roe'],
[cat, 'Cat Poe'],
[dee, 'Dee Fox'],
])
await gql(
`mutation { createUser(user: { id: "${id}", email: "${id}@example.com", displayName: "${name}" }) { id } }`,
@@ -96,7 +98,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
await gql(`mutation { addUserToGroup(userId: "${ann}", groupId: ${g.createGroup.id}) { ok } }`);
const c = new Client({ url: URL_! });
await c.bind(`uid=admin,ou=people,${BASE}`, ADMIN_PASS);
for (const id of [ann, bob, cat])
for (const id of [ann, bob, cat, dee])
await c.exop(
'1.3.6.1.4.1.4203.1.11.1',
passwordModify(`uid=${id},ou=people,${BASE}`, `${id}-directory-pw`),
@@ -106,14 +108,21 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
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 t.addUser(`${cat}@example.com`, 'cat-local-pw-12');
// New users with the temporary password from Users: the directory email may connect them.
for (const who of [ann, cat]) {
await t.addUser(`${who}@example.com`, `${who}-local-pw-12`);
await t.db
.update(users)
.set({ mustChangePassword: true })
.where(eq(users.email, `${who}@example.com`));
}
await t.addUser(`${dee}@example.com`, 'dee-own-password-12'); // chose her own password
await setSetting(t.db, 'auth.ldap', settings());
}, 60_000);
afterAll(async () => {
await t?.close();
for (const id of [ann, bob, cat])
for (const id of [ann, bob, cat, dee])
await gql(`mutation { deleteUser(userId: "${id}") { ok } }`).catch(() => {});
});
@@ -142,7 +151,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
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);
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 () => {
@@ -170,7 +179,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
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);
expect((await login(`${ann}@example.com`, `${ann}-local-pw-12`)).statusCode).toBe(200);
} finally {
await setSetting(t.db, 'auth.ldap', settings());
}
@@ -198,6 +207,23 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
expect(bad.json()).toMatchObject({ ok: false, error: expect.stringContaining('refused') });
});
it('someone with their own password connects on My settings, never by email alone', async () => {
resetLoginLimiter();
expect((await login(dee, `${dee}-directory-pw`)).statusCode).toBe(401);
const mine = await t.login(`${dee}@example.com`, 'dee-own-password-12');
const connect = (password: string) =>
t.app.inject({
method: 'POST',
url: '/api/me/identities/ldap',
headers: { ...H, cookie: mine },
payload: { login: dee, password },
});
expect((await connect('wrong-password')).statusCode).toBe(400);
expect((await connect(`${dee}-directory-pw`)).statusCode).toBe(200);
resetLoginLimiter();
expect((await login(dee, `${dee}-directory-pw`)).statusCode).toBe(200);
});
it('directoryCheck disables someone removed from the directory, and only them (§4.5)', async () => {
resetLoginLimiter();
expect((await login(cat, `${cat}-directory-pw`)).statusCode).toBe(200); // connects cat
+15 -5
View File
@@ -128,12 +128,22 @@ describe.skipIf(!hasTestDb || !KC)('OpenID Connect with a real Keycloak (12 S3)'
/** start → Keycloak's login form, filled in like a person would → our callback. */
async function flow(cookie?: string, query = '') {
const start = await t.app.inject({
url: `/api/auth/oidc/start${query}`,
headers: cookie ? { cookie } : {},
});
const connecting = query === '?connect=1';
// Connect is a POST from My account (with the CSRF header); sign-in a GET from the button.
const start = connecting
? await t.app.inject({
method: 'POST',
url: '/api/auth/oidc/connect',
headers: { ...H, cookie: cookie! },
payload: {},
})
: await t.app.inject({
url: `/api/auth/oidc/start${query}`,
headers: cookie ? { cookie } : {},
});
const location = connecting ? (start.json() as { url: string }).url : start.headers.location;
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
let res = await browser.get(start.headers.location as string);
let res = await browser.get(location as string);
if (res.status === 200) {
// Not signed in at Keycloak yet: post its login form.
const html = await res.text();
+78 -7
View File
@@ -3,7 +3,7 @@
// provider (test/fake-oidc.ts).
import { and, desc, eq } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { encrypt } from '../src/crypto/secrets.ts';
import { decrypt, encrypt } from '../src/crypto/secrets.ts';
import { sessions, userIdentities, users } from '../src/db/schema.ts';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { setSetting } from '../src/services/settings.ts';
@@ -49,18 +49,29 @@ for (const alg of ['RS256', 'ES256'] as const)
idp.st.user = { sub: 'u-1', email: 'ann@example.com', email_verified: true };
idp.st.refreshOk = true;
idp.st.down = false;
idp.st.refreshIdToken = true;
});
/** The browser's round trip: start → provider → callback. */
async function flow(cookie?: string, query = '') {
const start = await t.app.inject({
url: `/api/auth/oidc/start${query}`,
headers: cookie ? { cookie } : {},
});
expect(start.statusCode).toBe(302);
const connecting = query === '?connect=1';
// Connect is a POST from My account (with the CSRF header); sign-in a GET from the button.
const start = connecting
? await t.app.inject({
method: 'POST',
url: '/api/auth/oidc/connect',
headers: { ...H, cookie: cookie! },
payload: {},
})
: await t.app.inject({
url: `/api/auth/oidc/start${query}`,
headers: cookie ? { cookie } : {},
});
const location = connecting ? (start.json() as { url: string }).url : start.headers.location;
expect(start.statusCode).toBe(connecting ? 200 : 302);
const pending = start.cookies.find((c) => c.name === 'sp_oidc');
if (!pending) return { start, callback: null };
const atIdp = await fetch(start.headers.location as string, { redirect: 'manual' });
const atIdp = await fetch(location as string, { redirect: 'manual' });
const back = new URL(atIdp.headers.get('location')!);
expect(back.origin).toBe('https://pbx.test');
const callback = await t.app.inject({
@@ -81,6 +92,43 @@ for (const alg of ['RS256', 'ES256'] as const)
expect(r.json()).toEqual({ mode: 'mixed', ldap: false, oidc: { label: 'Keycloak' } });
});
it('never sends the browser to another site after sign-in (a tab, a backslash, //)', async () => {
await t.addUser('nextcheck@example.com', 'next-check-pw-1');
idp.st.user = { sub: 'u-next', email: 'nextcheck@example.com', email_verified: true };
const pw = await t.login('nextcheck@example.com', 'next-check-pw-1');
await flow(pw, '?connect=1');
for (const next of [
'/\t/evil.example',
'/\n/evil.example',
'/\\evil.example',
'//evil.example',
])
expect(
(await flow(undefined, `?next=${encodeURIComponent(next)}`)).callback!.headers.location,
).toBe('/');
expect((await flow(undefined, '?next=/me/calls')).callback!.headers.location).toBe(
'/me/calls',
);
});
it('connect is a POST with the CSRF header; a GET with connect=1 only signs in', async () => {
const ann = await t.login(ADMIN.email, ADMIN.password);
const forged = await t.app.inject({
method: 'POST',
url: '/api/auth/oidc/connect',
headers: { cookie: ann },
payload: {},
});
expect(forged.statusCode).toBe(403);
expect(forged.json().error.code).toBe('csrf');
const r = await t.app.inject({
url: '/api/auth/oidc/start?connect=1',
headers: { cookie: ann },
});
const pending = r.cookies.find((c) => c.name === 'sp_oidc')!;
expect(JSON.parse(decrypt(pending.value)).connectSessionId).toBeNull();
});
it('starts with PKCE S256, state and nonce, and a short-lived cookie', async () => {
const r = await t.app.inject({ url: '/api/auth/oidc/start?next=/me/calls' });
const to = new URL(r.headers.location as string);
@@ -403,4 +451,27 @@ for (const alg of ['RS256', 'ES256'] as const)
await setSetting(t.db, 'auth.oidc', oidcSettings);
}
});
it('the session check reads roles that are only in the ID token, and keeps a session whose refresh brings no groups', async () => {
idp.st.user = { ...idp.st.user, roles: ['pbx-users'] }; // Entra: app roles, ID token only
await setSetting(t.db, 'auth.oidc', {
...oidcSettings,
groupsClaim: 'roles',
allowedGroups: ['pbx-users'],
});
try {
const { id } = await annSession();
await check(20);
expect(await row(id)).toBeTruthy();
idp.st.refreshIdToken = false; // no new ID token: no roles claim at all
await check(40);
expect(await row(id)).toBeTruthy();
idp.st.refreshIdToken = true;
idp.st.user.roles = []; // the claim is there, and empty: ended
await check(60);
expect(await row(id)).toBeUndefined();
} finally {
await setSetting(t.db, 'auth.oidc', oidcSettings);
}
});
});
+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 08 §2.4: every route is rate limited per client IP, Test buttons more, and a signed-in
// person's password asked again counts like a sign-in.
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('rate limits (08 §2.4)', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let admin: string;
beforeAll(async () => {
t = await createTestApp(undefined, { RATE_LIMIT_PER_MINUTE: '30' });
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
});
afterAll(() => t?.close());
it('Test buttons: 10 a minute', async () => {
const codes: number[] = [];
for (let i = 0; i < 11; i++)
codes.push(
(
await t.app.inject({
method: 'POST',
url: '/api/settings/smtp/test',
headers: { ...H, cookie: admin },
payload: {},
})
).statusCode,
);
expect(codes.slice(0, 10)).not.toContain(429);
expect(codes[10]).toBe(429);
});
it('a password asked again counts like a sign-in: guessing it is stopped', async () => {
resetLoginLimiter();
const codes: number[] = [];
for (let i = 0; i < 6; i++)
codes.push(
(
await t.app.inject({
method: 'POST',
url: '/api/auth/change-password',
headers: { ...H, cookie: admin },
payload: { currentPassword: `guess-${i}-wrong`, newPassword: 'another-password-1' },
})
).statusCode,
);
expect(codes.slice(0, 5)).toEqual([400, 400, 400, 400, 400]);
expect(codes[5]).toBe(429);
resetLoginLimiter();
});
it('/prov keeps its own limits (07 §2), outside the global one', async () => {
const prov = await t.app.inject({ url: '/prov/001565000000.cfg' });
expect(prov.headers['x-ratelimit-limit']).toBeUndefined();
const api = await t.app.inject({ url: '/api/auth/methods' });
expect(api.headers['x-ratelimit-limit']).toBe('30');
});
it('every other route: the global limit per IP, as a 429 rate_limited', async () => {
let r;
for (let i = 0; i < 40; i++) {
r = await t.app.inject({ url: '/api/auth/methods' });
if (r.statusCode === 429) break;
}
expect(r!.statusCode).toBe(429);
expect(r!.json().error.code).toBe('rate_limited');
});
});
+119
View File
@@ -111,4 +111,123 @@ describe.skipIf(!hasTestDb)('sign-in (12 S1)', () => {
});
expect(del.statusCode).toBe(200);
});
const patch = (payload: object, cookie = admin) =>
t.app.inject({ method: 'PATCH', url: '/api/settings', payload, headers: { ...H, cookie } });
it('a stored secret never goes to a new server: the directory, OpenID and email', async () => {
const ldap = {
enabled: false,
preset: 'lldap',
url: 'ldaps://dir.example.com',
startTls: false,
bindDn: 'uid=svc,dc=example,dc=com',
bindPassword: 'service-pw-1',
baseDn: 'dc=example,dc=com',
userFilter: '(uid={login})',
idAttribute: 'entryUUID',
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: '',
};
expect((await patch({ 'auth.ldap': ldap })).statusCode).toBe(200);
const { bindPassword: _, ...keep } = ldap;
expect((await patch({ 'auth.ldap': keep })).statusCode).toBe(200); // same server: kept
const moved = await patch({ 'auth.ldap': { ...keep, url: 'ldaps://evil.example' } });
expect(moved.statusCode).toBe(400);
expect(moved.json().error.message).toMatch(/enter its password again/);
const test = await post(
'/api/settings/auth/ldap/test',
{ ...keep, url: 'ldaps://evil.example' },
admin,
);
expect(test.statusCode).toBe(400); // Test too: it would bind there with the stored password
const otherDn = await patch({
'auth.ldap': { ...keep, bindDn: 'uid=other,dc=example,dc=com' },
});
expect(otherDn.statusCode).toBe(400);
const oidc = {
label: 'Keycloak',
issuer: 'https://kc.example.com/realms/x',
clientId: 'selfphoneng',
clientSecret: 'client-secret-1',
scopes: 'openid email profile',
groupsClaim: 'groups',
adminGroups: [],
allowedGroups: [],
trustEmail: false,
};
expect((await patch({ 'auth.oidc': oidc })).statusCode).toBe(200);
const { clientSecret: __, ...noSecret } = oidc;
const newIssuer = await patch({ 'auth.oidc': { ...noSecret, issuer: 'https://evil.example' } });
expect(newIssuer.statusCode).toBe(400);
expect(newIssuer.json().error.message).toMatch(/client secret again/);
expect((await patch({ 'auth.oidc': noSecret })).json()['auth.oidc'].hasClientSecret).toBe(true);
const smtp = {
host: 'mail.example.com',
port: 587,
secure: false,
username: 'pbx',
password: 'smtp-pw-1',
from: 'PBX <pbx@example.com>',
};
expect((await patch({ smtp })).statusCode).toBe(200);
const { password: ___, ...smtpKeep } = smtp;
expect((await patch({ smtp: smtpKeep })).json().smtp.hasPassword).toBe(true);
expect(
(await patch({ smtp: { ...smtpKeep, host: 'evil.example' } })).json().smtp.hasPassword,
).toBe(false);
await patch({ 'auth.ldap': null, 'auth.oidc': null, smtp: null });
});
it('a role set by the directory or the provider is read-only here (409 role_managed)', async () => {
const u = await t.addUser('managed@example.com', 'managed-pw-12');
await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: 'managed-id' });
const ldap = {
enabled: true,
preset: 'lldap' as const,
url: 'ldaps://dir.example.com',
startTls: false,
bindDn: null,
bindPasswordEnc: null,
baseDn: 'dc=example,dc=com',
userFilter: '(uid={login})',
idAttribute: 'entryUUID',
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: null,
};
await setSetting(t.db, 'auth.ldap', ldap);
const setRole = (role: string) =>
t.app.inject({
method: 'PATCH',
url: `/api/users/${u.id}`,
payload: { role },
headers: { ...H, cookie: admin },
});
try {
expect((await setRole('admin')).statusCode).toBe(200); // no admin filter: set here
await setRole('user');
await setSetting(t.db, 'auth.ldap', { ...ldap, adminFilter: '(memberOf=cn=pbx-admins)' });
const r = await setRole('admin');
expect(r.statusCode).toBe(409);
expect(r.json().error.code).toBe('role_managed');
const dto = await t.app.inject({ url: `/api/users/${u.id}`, headers: { cookie: admin } });
expect(dto.json().roleFrom).toBe('the directory');
} finally {
await setSetting(t.db, 'auth.ldap', null);
}
});
it('an empty Contrack update is a 400, not a 500', async () => {
const r = await t.app.inject({
method: 'PATCH',
url: '/api/me/contrack',
payload: {},
headers: { ...H, cookie: admin },
});
expect(r.statusCode).toBe(400);
});
});