Files
selfphoneng/apps/api/test/oidc.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

478 lines
20 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 S2: OpenID Connect sign-in, connect, disconnect, modes and signing out, against a fake
// provider (test/fake-oidc.ts).
import { and, desc, eq } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
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';
import type { OidcSettings } from '../src/sso/oidc.ts';
import { checkOidcSessions } from '../src/sso/oidcCheck.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
import { startFakeOidc } from './fake-oidc.ts';
for (const alg of ['RS256', 'ES256'] as const)
describe.skipIf(!hasTestDb)(`OpenID Connect (12 S2, ${alg})`, () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let idp: Awaited<ReturnType<typeof startFakeOidc>>;
let admin: string;
let oidcSettings: OidcSettings;
beforeAll(async () => {
idp = await startFakeOidc(alg);
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
oidcSettings = {
label: 'Keycloak',
issuer: idp.issuer,
clientId: idp.clientId,
clientSecretEnc: encrypt(idp.clientSecret),
scopes: 'openid email profile',
groupsClaim: 'groups',
adminGroups: [],
allowedGroups: [],
trustEmail: false,
};
await setSetting(t.db, 'auth.oidc', oidcSettings);
await setSetting(t.db, 'auth.mode', 'mixed');
});
afterAll(async () => {
await t?.close();
await idp?.close();
});
beforeEach(() => {
resetLoginLimiter();
idp.st.tamper = {};
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 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(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({
url: back.pathname + back.search,
headers: { cookie: [`sp_oidc=${pending.value}`, cookie].filter(Boolean).join('; ') },
});
return { start, callback };
}
const session = (r: { cookies: { name: string; value: string }[] }) => {
const c = r.cookies.find((x) => x.name === 'sp_session');
return c ? `sp_session=${c.value}` : null;
};
const errorOf = (r: { headers: Record<string, unknown> }) =>
new URL(r.headers.location as string, 'https://pbx.test').searchParams.get('sso_error');
it('methods show the button in mixed mode', async () => {
const r = await t.app.inject({ url: '/api/auth/methods' });
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);
expect(to.origin).toBe(idp.issuer);
for (const p of ['state', 'nonce', 'code_challenge'])
expect(to.searchParams.get(p)).toBeTruthy();
expect(to.searchParams.get('code_challenge_method')).toBe('S256');
expect(to.searchParams.get('redirect_uri')).toBe('https://pbx.test/api/auth/oidc/callback');
expect(r.cookies.find((c) => c.name === 'sp_oidc')).toMatchObject({
httpOnly: true,
secure: true,
sameSite: 'Lax',
path: '/api/auth/oidc',
maxAge: 600,
});
});
it('an account nobody connected is refused, and no user is made', async () => {
idp.st.user = { sub: 'stranger', email: 'stranger@example.com', email_verified: true };
const { callback } = await flow();
expect(errorOf(callback!)).toBe('not_connected');
expect(session(callback!)).toBeNull();
const [u] = await t.db.select().from(users).where(eq(users.email, 'stranger@example.com'));
expect(u).toBeUndefined();
});
it('a user with a password is never matched by email, even verified', async () => {
await t.addUser('ann@example.com', 'ann-password-12');
const { callback } = await flow();
expect(errorOf(callback!)).toBe('not_connected');
});
it('connects from My account, then signs in either way', async () => {
const ann = await t.login('ann@example.com', 'ann-password-12');
const { callback } = await flow(ann, '?connect=1');
expect(callback!.headers.location).toBe('/me/settings?sso=connected');
const me = await t.app.inject({ url: '/api/auth/me', headers: { cookie: ann } });
expect(me.json().user.signIn).toEqual({
password: true,
oidc: { email: 'ann@example.com', groups: [] },
ldap: false,
});
const signIn = await flow(undefined, '?next=/me/calls');
expect(signIn.callback!.headers.location).toBe('/me/calls');
const cookie = session(signIn.callback!);
expect(cookie).toBeTruthy();
const [s] = await t.db
.select()
.from(sessions)
.where(eq(sessions.method, 'oidc'))
.orderBy(sessions.createdAt);
expect(s!.idpRefreshEnc).toBeTruthy();
expect(s!.idpIdTokenEnc).toBeTruthy();
// …and the password still works.
expect(await t.login('ann@example.com', 'ann-password-12')).toBeTruthy();
});
it("refuses to connect an account that's someone else's", async () => {
await t.addUser('bob@example.com', 'bob-password-12');
const bob = await t.login('bob@example.com', 'bob-password-12');
const { callback } = await flow(bob, '?connect=1'); // the fake still signs in as u-1 (Ann)
expect(errorOf(callback!)).toBe('taken');
});
it('matches a user without a password once, by verified email', async () => {
const [u] = await t.db
.insert(users)
.values({ email: 'cara@example.com', displayName: 'Cara', passwordHash: null })
.returning();
idp.st.user = { sub: 'u-cara', email: 'cara@example.com', email_verified: false };
expect(errorOf((await flow()).callback!)).toBe('not_connected');
idp.st.user.email_verified = true;
const { callback } = await flow();
expect(session(callback!)).toBeTruthy();
const ids = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u!.id));
expect(ids).toMatchObject([{ kind: 'oidc', subject: `${idp.issuer} u-cara` }]);
});
it('trustEmail matches an unverified email', async () => {
await t.db
.insert(users)
.values({ email: 'dee@example.com', displayName: 'Dee', passwordHash: null });
idp.st.user = { sub: 'u-dee', email: 'dee@example.com' };
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, trustEmail: true });
try {
expect(session((await flow()).callback!)).toBeTruthy();
} finally {
await setSetting(t.db, 'auth.oidc', oidcSettings);
}
});
for (const [name, tamper] of [
['a wrong nonce', { nonce: true }],
['a wrong issuer', { iss: true }],
['a wrong audience', { aud: true }],
['an expired ID token', { expired: true }],
['a bad signature', { signature: true }],
['a userinfo subject that differs', { userinfoSub: true }],
] as const)
it(`refuses ${name}`, async () => {
idp.st.tamper = tamper;
const { callback } = await flow();
expect(errorOf(callback!)).toBe('provider');
expect(session(callback!)).toBeNull();
});
it('refuses a callback without our cookie, or with another state', async () => {
const r = await t.app.inject({ url: '/api/auth/oidc/callback?code=x&state=y' });
expect(errorOf(r)).toBe('expired');
const start = await t.app.inject({ url: '/api/auth/oidc/start' });
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
const atIdp = await fetch(start.headers.location as string, { redirect: 'manual' });
const back = new URL(atIdp.headers.get('location')!);
back.searchParams.set('state', 'forged');
const forged = await t.app.inject({
url: back.pathname + back.search,
headers: { cookie: `sp_oidc=${pending.value}` },
});
expect(errorOf(forged)).toBe('provider');
});
it('the provider saying access_denied is "denied"', async () => {
const start = await t.app.inject({ url: '/api/auth/oidc/start' });
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
const r = await t.app.inject({
url: '/api/auth/oidc/callback?error=access_denied&state=x',
headers: { cookie: `sp_oidc=${pending.value}` },
});
expect(errorOf(r)).toBe('denied');
});
it('groups: allowedGroups refuses, adminGroups sets the role', async () => {
idp.st.user = { sub: 'u-1', email: 'ann@example.com', groups: ['pbx-admins'] };
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, allowedGroups: ['pbx-users'] });
expect(errorOf((await flow()).callback!)).toBe('not_allowed');
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, adminGroups: ['pbx-admins'] });
try {
const { callback } = await flow();
const me = await t.app.inject({
url: '/api/auth/me',
headers: { cookie: session(callback!)! },
});
expect(me.json().user.role).toBe('admin');
const [id] = await t.db
.select()
.from(userIdentities)
.where(eq(userIdentities.subject, `${idp.issuer} u-1`));
expect(id!.groups).toEqual(['pbx-admins']);
idp.st.user.groups = [];
await flow();
const [ann] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
expect(ann!.role).toBe('user');
} finally {
await setSetting(t.db, 'auth.oidc', oidcSettings);
}
});
it('signing out of an OpenID session sends the browser to the provider', async () => {
const { callback } = await flow();
const out = await t.app.inject({
method: 'POST',
url: '/api/auth/logout',
headers: { ...H, cookie: session(callback!)! },
});
const to = new URL(out.json().redirect);
expect(to.href.startsWith(`${idp.issuer}/logout`)).toBe(true);
expect(to.searchParams.get('id_token_hint')).toBeTruthy();
expect(to.searchParams.get('post_logout_redirect_uri')).toBe('https://pbx.test/login');
// A password session signs out here only.
const pw = await t.login('ann@example.com', 'ann-password-12');
const out2 = await t.app.inject({
method: 'POST',
url: '/api/auth/logout',
headers: { ...H, cookie: pw },
});
expect(out2.json()).toEqual({ ok: true });
});
it('OpenID-only mode: passwords for admins only; disconnect keeps a way in', async () => {
await setSetting(t.db, 'auth.mode', 'oidc');
try {
const r = await t.app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { login: 'ann@example.com', password: 'ann-password-12' },
});
expect(r.json().error.code).toBe('password_sign_in_off');
const { callback } = await flow();
const ann = session(callback!)!;
const d = await t.app.inject({
method: 'DELETE',
url: '/api/me/identities/oidc',
headers: { ...H, cookie: ann },
});
expect(d.statusCode).toBe(409);
expect(d.json().error.code).toBe('last_way_in');
} finally {
await setSetting(t.db, 'auth.mode', 'mixed');
}
// Mixed: Ann still has her password, so she may disconnect; OpenID sessions end.
const { callback } = await flow();
const ann = session(callback!)!;
const [row] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
const d = await t.app.inject({
method: 'DELETE',
url: `/api/users/${row!.id}/identities/oidc`,
headers: { ...H, cookie: admin },
});
expect(d.statusCode).toBe(200);
const left = await t.db
.select()
.from(sessions)
.where(and(eq(sessions.userId, row!.id), eq(sessions.method, 'oidc')));
expect(left).toEqual([]);
expect(
(await t.app.inject({ url: '/api/auth/me', headers: { cookie: ann } })).statusCode,
).toBe(401);
});
it('settings: Test, the secret write-only, and the mode needing a working provider', async () => {
const form = {
label: 'Keycloak',
issuer: idp.issuer,
clientId: idp.clientId,
scopes: 'openid email profile',
groupsClaim: 'groups',
adminGroups: [],
allowedGroups: [],
trustEmail: false,
};
const req = (method: 'GET' | 'PATCH' | 'POST', url: string, payload?: object) =>
t.app.inject({ method, url, payload, headers: { ...H, cookie: admin } });
const ok = await req('POST', '/api/settings/auth/oidc/test', form);
expect(ok.json()).toMatchObject({
ok: true,
pkce: true,
refreshTokens: true,
endSession: true,
});
const bad = await req('POST', '/api/settings/auth/oidc/test', {
...form,
clientSecret: 'wrong',
});
expect(bad.json()).toMatchObject({ ok: false });
expect((await req('PATCH', '/api/settings', { 'auth.oidc': form })).statusCode).toBe(200);
const got = (await req('GET', '/api/settings')).json()['auth.oidc'];
expect(got).toMatchObject({ hasClientSecret: true, label: 'Keycloak' });
expect(JSON.stringify(got)).not.toContain('clientSecretEnc');
const broken = await req('PATCH', '/api/settings', {
'auth.oidc': { ...form, issuer: 'http://127.0.0.1:1' },
});
expect(broken.statusCode).toBe(400);
const remote = await req('PATCH', '/api/settings', {
'auth.oidc': { ...form, issuer: 'http://kc.example.com/realms/x' },
});
expect(remote.json().error.message).toContain('https');
});
/** Ann's OpenID session, connected again (an earlier test disconnects her). */
async function annSession() {
const pw = await t.login('ann@example.com', 'ann-password-12');
await flow(pw, '?connect=1');
const { callback } = await flow();
const cookie = session(callback!)!;
const [row] = await t.db
.select()
.from(sessions)
.where(eq(sessions.method, 'oidc'))
.orderBy(desc(sessions.idpCheckedAt))
.limit(1);
return { cookie, id: row!.id };
}
const later = (min: number) => new Date(Date.now() + min * 60_000);
const check = (min: number) => checkOidcSessions({ db: t.db, log: t.app.log, now: later(min) });
const row = async (id: string) =>
(await t.db.select().from(sessions).where(eq(sessions.id, id)))[0];
it('the session check: kept while the provider says yes, ended when it says no (§3.5)', async () => {
const { cookie, id } = await annSession();
const signedIn = (await row(id))!.idpCheckedAt!.getTime();
await check(5); // checked at sign-in: not due yet
expect((await row(id))!.idpCheckedAt!.getTime()).toBe(signedIn);
await check(16);
expect((await row(id))!.idpCheckedAt!.getTime()).toBeGreaterThan(signedIn);
idp.st.down = true; // an outage signs no one out
await check(40);
expect(await row(id)).toBeTruthy();
idp.st.down = false;
idp.st.user = { ...idp.st.user, groups: ['pbx-admins'] };
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, adminGroups: ['pbx-admins'] });
try {
await check(56); // groups changed at the provider: the role follows
const [ann] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
expect(ann!.role).toBe('admin');
await t.db.update(users).set({ role: 'user' }).where(eq(users.id, ann!.id));
} finally {
await setSetting(t.db, 'auth.oidc', oidcSettings);
}
idp.st.refreshOk = false; // disabled at the provider
await check(72);
expect(await row(id)).toBeUndefined();
expect((await t.app.inject({ url: '/api/auth/me', headers: { cookie } })).statusCode).toBe(
401,
);
});
it('the session check ends a session no longer in the allowed groups', async () => {
idp.st.user = { ...idp.st.user, groups: ['pbx-users'] };
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, allowedGroups: ['pbx-users'] });
try {
const { id } = await annSession();
idp.st.user.groups = [];
await check(20);
expect(await row(id)).toBeUndefined();
} finally {
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);
}
});
});