Files
selfphoneng/apps/api/test/directory-signin.test.ts
T
iamdoubz 97e67e347d
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
auth: security audit fixes, rate limits on every route
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.
2026-10-26 09:46:23 -05:00

120 lines
4.5 KiB
TypeScript

// 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();
}
});
});