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.
237 lines
9.3 KiB
TypeScript
237 lines
9.3 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// 12 S3 against a real Keycloak: connect, sign in through Keycloak's own login form, a group
|
|
// makes an admin, signing out there too, and disabling the user there ending our session.
|
|
// Needs KEYCLOAK_LIVE_URL (e.g. http://127.0.0.1:18080) and KEYCLOAK_LIVE_ADMIN ("user:password"
|
|
// of the master realm); makes a throwaway realm and deletes it. Passed against 26.8.1:
|
|
// docker run -d -p 127.0.0.1:18080:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
|
|
// -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:26.8.1 start-dev
|
|
import { randomBytes } from 'node:crypto';
|
|
import { eq } from 'drizzle-orm';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { encrypt } from '../src/crypto/secrets.ts';
|
|
import { sessions, users } from '../src/db/schema.ts';
|
|
import { setSetting } from '../src/services/settings.ts';
|
|
import { checkOidcSessions } from '../src/sso/oidcCheck.ts';
|
|
import { hasTestDb } from './db.ts';
|
|
import { ADMIN, H, createTestApp } from './app.ts';
|
|
|
|
const KC = process.env.KEYCLOAK_LIVE_URL;
|
|
const [KC_USER, KC_PASS] = (process.env.KEYCLOAK_LIVE_ADMIN ?? 'admin:admin').split(':');
|
|
|
|
/** A cookie jar for the browser's side of Keycloak. */
|
|
class Browser {
|
|
jar = new Map<string, string>();
|
|
async get(url: string, init: RequestInit = {}) {
|
|
const res = await fetch(url, {
|
|
...init,
|
|
redirect: 'manual',
|
|
headers: { ...(init.headers as object), cookie: this.cookie() },
|
|
});
|
|
for (const c of res.headers.getSetCookie()) {
|
|
const [kv] = c.split(';');
|
|
const i = kv!.indexOf('=');
|
|
this.jar.set(kv!.slice(0, i), kv!.slice(i + 1));
|
|
}
|
|
return res;
|
|
}
|
|
cookie = () => [...this.jar].map(([k, v]) => `${k}=${v}`).join('; ');
|
|
}
|
|
|
|
describe.skipIf(!hasTestDb || !KC)('OpenID Connect with a real Keycloak (12 S3)', () => {
|
|
const realm = `sp-test-${randomBytes(4).toString('hex')}`;
|
|
const issuer = `${KC}/realms/${realm}`;
|
|
let t: Awaited<ReturnType<typeof createTestApp>>;
|
|
let token: string;
|
|
let kcUserId: string;
|
|
const browser = new Browser();
|
|
|
|
const kcAdmin = async (method: string, path: string, body?: unknown) => {
|
|
const res = await fetch(`${KC}/admin/realms${path}`, {
|
|
method,
|
|
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${await res.text()}`);
|
|
return res;
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
const tok = await fetch(`${KC}/realms/master/protocol/openid-connect/token`, {
|
|
method: 'POST',
|
|
body: new URLSearchParams({
|
|
grant_type: 'password',
|
|
client_id: 'admin-cli',
|
|
username: KC_USER!,
|
|
password: KC_PASS!,
|
|
}),
|
|
});
|
|
token = ((await tok.json()) as { access_token: string }).access_token;
|
|
await kcAdmin('POST', '', { realm, enabled: true });
|
|
await kcAdmin('POST', `/${realm}/clients`, {
|
|
clientId: 'selfphoneng',
|
|
protocol: 'openid-connect',
|
|
publicClient: false,
|
|
secret: 'live-secret-with-symbols-+/=',
|
|
standardFlowEnabled: true,
|
|
directAccessGrantsEnabled: false,
|
|
redirectUris: ['https://pbx.test/api/auth/oidc/callback'],
|
|
attributes: { 'post.logout.redirect.uris': 'https://pbx.test/login' },
|
|
protocolMappers: [
|
|
{
|
|
name: 'groups',
|
|
protocol: 'openid-connect',
|
|
protocolMapper: 'oidc-group-membership-mapper',
|
|
config: {
|
|
'full.path': 'false',
|
|
'id.token.claim': 'true',
|
|
'access.token.claim': 'true',
|
|
'userinfo.token.claim': 'true',
|
|
'claim.name': 'groups',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
await kcAdmin('POST', `/${realm}/groups`, { name: 'pbx-admins' });
|
|
const u = await kcAdmin('POST', `/${realm}/users`, {
|
|
username: 'ann',
|
|
email: 'ann@example.com',
|
|
emailVerified: true,
|
|
firstName: 'Ann',
|
|
lastName: 'Lee',
|
|
enabled: true,
|
|
groups: ['/pbx-admins'],
|
|
credentials: [{ type: 'password', value: 'ann-keycloak-pw', temporary: false }],
|
|
});
|
|
kcUserId = u.headers.get('location')!.split('/').at(-1)!;
|
|
|
|
t = await createTestApp();
|
|
await t.clearMustChange(ADMIN.email);
|
|
await t.addUser('ann@example.com', 'ann-password-12');
|
|
await setSetting(t.db, 'auth.oidc', {
|
|
label: 'Keycloak',
|
|
issuer,
|
|
clientId: 'selfphoneng',
|
|
clientSecretEnc: encrypt('live-secret-with-symbols-+/='),
|
|
scopes: 'openid email profile',
|
|
groupsClaim: 'groups',
|
|
adminGroups: ['pbx-admins'],
|
|
allowedGroups: [],
|
|
trustEmail: false,
|
|
});
|
|
await setSetting(t.db, 'auth.mode', 'mixed');
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await t?.close();
|
|
if (token) await kcAdmin('DELETE', `/${realm}`).catch(() => {});
|
|
});
|
|
|
|
/** start → Keycloak's login form, filled in like a person would → our 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;
|
|
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
|
|
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();
|
|
const action = /<form[^>]*id="kc-form-login"[^>]*action="([^"]+)"/.exec(html)![1]!;
|
|
res = await browser.get(action.replaceAll('&', '&'), {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({ username: 'ann', password: 'ann-keycloak-pw' }).toString(),
|
|
});
|
|
}
|
|
expect(res.status).toBe(302);
|
|
const back = new URL(res.headers.get('location')!);
|
|
expect(back.origin).toBe('https://pbx.test');
|
|
return t.app.inject({
|
|
url: back.pathname + back.search,
|
|
headers: { cookie: [`sp_oidc=${pending.value}`, cookie].filter(Boolean).join('; ') },
|
|
});
|
|
}
|
|
const session = (r: Awaited<ReturnType<typeof flow>>) => {
|
|
const c = r.cookies.find((x) => x.name === 'sp_session');
|
|
return c ? `sp_session=${c.value}` : null;
|
|
};
|
|
|
|
it('Test sees the realm and accepts the client', async () => {
|
|
const admin = await t.login(ADMIN.email, ADMIN.password);
|
|
const r = await t.app.inject({
|
|
method: 'POST',
|
|
url: '/api/settings/auth/oidc/test',
|
|
headers: { ...H, cookie: admin },
|
|
payload: {
|
|
label: 'Keycloak',
|
|
issuer,
|
|
clientId: 'selfphoneng',
|
|
scopes: 'openid email profile',
|
|
groupsClaim: 'groups',
|
|
adminGroups: [],
|
|
allowedGroups: [],
|
|
trustEmail: false,
|
|
},
|
|
});
|
|
expect(r.json()).toMatchObject({ ok: true, pkce: true, refreshTokens: true, endSession: true });
|
|
});
|
|
|
|
it('connects, signs in through the login form, and the group makes an admin', async () => {
|
|
const pw = await t.login('ann@example.com', 'ann-password-12');
|
|
const c = await flow(pw, '?connect=1');
|
|
expect(c.headers.location).toBe('/me/settings?sso=connected');
|
|
const r = await flow();
|
|
const cookie = session(r);
|
|
expect(cookie).toBeTruthy();
|
|
const me = (await t.app.inject({ url: '/api/auth/me', headers: { cookie: cookie! } })).json();
|
|
expect(me.user).toMatchObject({ role: 'admin' });
|
|
expect(me.user.signIn.oidc).toEqual({ email: 'ann@example.com', groups: ['pbx-admins'] });
|
|
});
|
|
|
|
it('signing out signs out of Keycloak too', async () => {
|
|
const r = await flow(); // already signed in at Keycloak: straight back
|
|
const out = await t.app.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/logout',
|
|
headers: { ...H, cookie: session(r)! },
|
|
});
|
|
const to = out.json().redirect as string;
|
|
expect(to.startsWith(`${issuer}/protocol/openid-connect/logout`)).toBe(true);
|
|
const done = await browser.get(to);
|
|
expect(done.status).toBe(302);
|
|
expect(done.headers.get('location')).toBe('https://pbx.test/login');
|
|
// Keycloak forgot us: the next sign-in shows its login form again (flow() fills it).
|
|
expect(session(await flow())).toBeTruthy();
|
|
});
|
|
|
|
it('disabling the user in Keycloak ends their session at the next check', async () => {
|
|
const r = await flow();
|
|
const cookie = session(r)!;
|
|
const [ann] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
|
|
const check = (min: number) =>
|
|
checkOidcSessions({
|
|
db: t.db,
|
|
log: t.app.log,
|
|
now: new Date(Date.now() + min * 60_000),
|
|
});
|
|
await check(16);
|
|
expect((await t.app.inject({ url: '/api/auth/me', headers: { cookie } })).statusCode).toBe(200);
|
|
await kcAdmin('PUT', `/${realm}/users/${kcUserId}`, { enabled: false });
|
|
await check(32);
|
|
const left = await t.db.select().from(sessions).where(eq(sessions.userId, ann!.id));
|
|
expect(left.filter((s) => s.method === 'oidc')).toEqual([]);
|
|
expect((await t.app.inject({ url: '/api/auth/me', headers: { cookie } })).statusCode).toBe(401);
|
|
});
|
|
});
|