Feat/sso #3
No files matched your search
@@ -26,3 +26,6 @@ RTP_PORT_END=20000 # firewall and router. Two ports per call;
|
||||
#AI_TTS_URL=http://127.0.0.1:8000/v1
|
||||
#AI_TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX
|
||||
#AI_TTS_VOICE=af_heart
|
||||
# A private CA your identity provider or directory uses (admin guide: Sign-in). Put the PEM file
|
||||
# in data/tls/; the api reads it at start, so restart the api after changing it.
|
||||
#NODE_EXTRA_CA_CERTS=/var/lib/selfphoneng/tls/idp-ca.pem
|
||||
@@ -27,6 +27,7 @@ import { backupDue, runBackup } from './services/backup.ts';
|
||||
import { sendCallReports } from './services/callreport.ts';
|
||||
import { contrackBook, contrackSettings } from './contacts/contrack.ts';
|
||||
import { logCallsToContrack } from './contacts/timeline.ts';
|
||||
import { checkOidcSessions } from './sso/oidcCheck.ts';
|
||||
import { logEvent } from './services/events.ts';
|
||||
import { deliverDue } from './services/webhooks.ts';
|
||||
import type { SendMail } from './services/mail.ts';
|
||||
@@ -261,7 +262,13 @@ export function createJobs(d: JobDeps) {
|
||||
await logCallsToContrack({ db: d.db, log: d.log, now: now() });
|
||||
};
|
||||
|
||||
/** OpenID sessions refreshed at the provider; a leaver's end (12 §3.5). */
|
||||
const oidcCheck = async () => {
|
||||
await checkOidcSessions({ db: d.db, log: d.log, now: now() });
|
||||
};
|
||||
|
||||
return {
|
||||
oidcCheck,
|
||||
daily,
|
||||
backup,
|
||||
publicIpWatch,
|
||||
@@ -295,6 +302,7 @@ export function startJobs(d: JobDeps): () => void {
|
||||
run('callReports', j.callReports);
|
||||
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
|
||||
run('contrackLog', j.contrackLog);
|
||||
if (minute % 5 === 0) run('oidcCheck', j.oidcCheck);
|
||||
run('callTranscripts', async () => {
|
||||
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createSession, setSessionCookie } from '../auth/sessions.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { decide, verifiedEmail } from '../sso/claims.ts';
|
||||
import { applyClaims } from '../sso/oidcCheck.ts';
|
||||
import {
|
||||
finishSignIn,
|
||||
oidcError,
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
type OidcSettings,
|
||||
type PendingSignIn,
|
||||
} from '../sso/oidc.ts';
|
||||
import { isLastAdmin } from './users.ts';
|
||||
|
||||
export const OIDC_COOKIE = 'sp_oidc';
|
||||
const COOKIE_PATH = '/api/auth/oidc';
|
||||
@@ -197,28 +197,10 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user!.isActive) return fail('disabled', `${user!.email} is disabled`, userId);
|
||||
|
||||
const role = d.admin === null ? user!.role : d.admin ? 'admin' : 'user';
|
||||
if (role !== user!.role) {
|
||||
if (role === 'user' && (await isLastAdmin(db, userId)))
|
||||
req.log.warn(
|
||||
{ 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: req.ip,
|
||||
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 applyClaims(db, req.log, s, userId, d, email, req.ip);
|
||||
await db
|
||||
.update(userIdentities)
|
||||
.set({ email, groups: d.groups, lastUsedAt: new Date() })
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'oidc')));
|
||||
const token = await createSession(
|
||||
db,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// What the provider says about a user, applied (12 §3.4), and the leaver check (12 §3.5): every
|
||||
// OpenID session is refreshed at the provider every 15 minutes, and ends when it says no.
|
||||
import { and, eq, gt, isNotNull, isNull, lt, or } from 'drizzle-orm';
|
||||
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 { 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. */
|
||||
export async function applyClaims(
|
||||
db: Db,
|
||||
log: Log,
|
||||
s: OidcSettings,
|
||||
userId: string,
|
||||
d: ClaimsDecision,
|
||||
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 db
|
||||
.update(userIdentities)
|
||||
.set({ email, groups: d.groups })
|
||||
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'oidc')));
|
||||
}
|
||||
|
||||
const CHECK_EVERY_MS = 15 * 60_000;
|
||||
|
||||
/** One run of the oidcCheck job (every 5 minutes): sessions not checked for 15 minutes. */
|
||||
export async function checkOidcSessions(d: { db: Db; log: Log; now?: Date }): Promise<void> {
|
||||
const { db, log } = d;
|
||||
const now = d.now ?? new Date();
|
||||
const s = await getSetting(db, 'auth.oidc');
|
||||
if (!s) return;
|
||||
const due = await db
|
||||
.select({
|
||||
id: sessions.id,
|
||||
userId: sessions.userId,
|
||||
refreshEnc: sessions.idpRefreshEnc,
|
||||
email: users.email,
|
||||
subject: userIdentities.subject,
|
||||
})
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.leftJoin(
|
||||
userIdentities,
|
||||
and(eq(userIdentities.userId, sessions.userId), eq(userIdentities.kind, 'oidc')),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.method, 'oidc'),
|
||||
isNotNull(sessions.idpRefreshEnc),
|
||||
gt(sessions.expiresAt, now),
|
||||
or(
|
||||
isNull(sessions.idpCheckedAt),
|
||||
lt(sessions.idpCheckedAt, new Date(now.getTime() - CHECK_EVERY_MS)),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!due.length) return;
|
||||
let config: oidc.Configuration;
|
||||
try {
|
||||
config = await oidcConfig(s);
|
||||
} catch (err) {
|
||||
log.warn?.({ err: oidcError(err) }, 'OpenID check skipped: the provider is unreachable');
|
||||
return;
|
||||
}
|
||||
|
||||
const end = async (row: (typeof due)[number], why: string) => {
|
||||
await db.delete(sessions).where(eq(sessions.id, row.id));
|
||||
await audit(db, {
|
||||
actorUserId: row.userId,
|
||||
action: 'logout',
|
||||
entityType: 'user',
|
||||
entityId: row.userId,
|
||||
summary: `${row.email} signed out: ${why}`,
|
||||
});
|
||||
};
|
||||
|
||||
for (const row of due) {
|
||||
// Disconnected since (Disconnect also ends these sessions; this is the belt to its braces).
|
||||
if (!row.subject) {
|
||||
await end(row, `no ${s.label} account connected any more`);
|
||||
continue;
|
||||
}
|
||||
let tokens: Awaited<ReturnType<typeof oidc.refreshTokenGrant>>;
|
||||
let claims: Record<string, unknown>;
|
||||
try {
|
||||
tokens = await oidc.refreshTokenGrant(config, decrypt(row.refreshEnc!));
|
||||
const sub = row.subject.slice(row.subject.indexOf(' ') + 1);
|
||||
claims = config.serverMetadata().userinfo_endpoint
|
||||
? { ...(await oidc.fetchUserInfo(config, tokens.access_token, sub)) }
|
||||
: { ...tokens.claims() };
|
||||
} catch (err) {
|
||||
// The provider said no: disabled, signed out there, or the refresh token expired.
|
||||
if (err instanceof oidc.ResponseBodyError && err.error === 'invalid_grant') {
|
||||
await end(row, `${s.label} ended the session (${oidcError(err)})`);
|
||||
continue;
|
||||
}
|
||||
// Down, 5xx, a bad client setup: keep the session and try again next run.
|
||||
log.warn?.({ err: oidcError(err) }, `OpenID check of a session failed`);
|
||||
continue;
|
||||
}
|
||||
const verdict = decide(s, claims);
|
||||
if (!verdict.allowed) {
|
||||
await end(row, `no longer in ${s.label}'s allowed groups`);
|
||||
continue;
|
||||
}
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({
|
||||
idpCheckedAt: now,
|
||||
...(tokens.refresh_token && { idpRefreshEnc: encrypt(tokens.refresh_token) }),
|
||||
...(tokens.id_token && { idpIdTokenEnc: encrypt(tokens.id_token) }),
|
||||
})
|
||||
.where(eq(sessions.id, row.id));
|
||||
const email = typeof claims.email === 'string' ? claims.email : null;
|
||||
await applyClaims(db, log, s, row.userId, verdict, email, null);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
|
||||
},
|
||||
/** false: refresh tokens are refused, as for a user disabled at the provider. */
|
||||
refreshOk: true,
|
||||
/** true: the token endpoint answers 503, as a provider that's down. */
|
||||
down: false,
|
||||
refreshes: 0,
|
||||
};
|
||||
const codes = new Map<string, { nonce: string; challenge: string; user: FakeUser }>();
|
||||
@@ -83,6 +85,7 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
|
||||
return res.end();
|
||||
}
|
||||
if (url.pathname === '/token' && req.method === 'POST') {
|
||||
if (st.down) return json(503, { error: 'temporarily_unavailable' });
|
||||
// client_secret_basic: both parts form-encoded before base64 (RFC 6749 §2.3.1).
|
||||
const [id, secret] = Buffer.from(
|
||||
(req.headers.authorization ?? '').replace(/^Basic /, ''),
|
||||
@@ -97,7 +100,8 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
|
||||
const user = refresh.get(form.get('refresh_token')!);
|
||||
st.refreshes++;
|
||||
if (!user || !st.refreshOk) return json(400, { error: 'invalid_grant' });
|
||||
return json(200, tokens(user, null));
|
||||
// Today's claims, as a real provider gives on refresh.
|
||||
return json(200, tokens(st.user.sub === user.sub ? { ...st.user } : user, null));
|
||||
}
|
||||
const c = codes.get(form.get('code') ?? '');
|
||||
codes.delete(form.get('code') ?? '');
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// 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 start = await t.app.inject({
|
||||
url: `/api/auth/oidc/start${query}`,
|
||||
headers: cookie ? { cookie } : {},
|
||||
});
|
||||
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
|
||||
let res = await browser.get(start.headers.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);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,14 @@
|
||||
// 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, eq } from 'drizzle-orm';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { 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';
|
||||
@@ -46,6 +47,8 @@ for (const alg of ['RS256', 'ES256'] as const)
|
||||
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;
|
||||
});
|
||||
|
||||
/** The browser's round trip: start → provider → callback. */
|
||||
@@ -336,4 +339,68 @@ for (const alg of ['RS256', 'ES256'] as const)
|
||||
});
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ keeping the system safe, legal and up to date.
|
||||
7. [Analog phones (ATAs)](ata.md)
|
||||
8. [Local AI](ai.md): voicemail transcripts
|
||||
9. [Contacts](contacts.md): the builtin phonebook, or Contrack
|
||||
10. [Sign-in](sign-in.md): passwords, and OpenID Connect (Keycloak and others)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# Sign-in: passwords and OpenID Connect
|
||||
|
||||
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
|
||||
changes the web sign-in: desk phones, phone apps and the browser phone's line keep their own SIP
|
||||
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.
|
||||
|
||||
## How it works
|
||||
|
||||
- You still make every user on **Users**. Signing in with the provider never creates an account.
|
||||
- Each person **connects** their provider account once, in **My settings** (users) or **My
|
||||
account** (admins): **Connect Keycloak**, sign in at Keycloak, done. From then on they sign in
|
||||
with **Sign in with Keycloak** or with their password.
|
||||
- **Settings → Sign-in → How people sign in** picks what the login page offers:
|
||||
|
||||
| Choice | Login page | Who can use a password |
|
||||
|---|---|---|
|
||||
| Passwords | the password form | everyone |
|
||||
| Passwords or OpenID | the button and the form | everyone |
|
||||
| OpenID only | the button, and a small **Admin sign-in** link | admins only |
|
||||
|
||||
Admins keep their password in every mode so you can still get in when the provider is down.
|
||||
SelfPhoneNG also won't let you delete, disable or demote the last admin who has a password.
|
||||
- Someone disabled in Keycloak loses their Keycloak sign-in to SelfPhoneNG within about 20
|
||||
minutes: every 15 minutes SelfPhoneNG asks Keycloak whether each Keycloak session is still good.
|
||||
With **Passwords or OpenID**, their SelfPhoneNG password still works until you disable them on
|
||||
Users too. With **OpenID only**, Keycloak alone decides.
|
||||
- **Sign out** in SelfPhoneNG also signs out of Keycloak, when the session came from Keycloak.
|
||||
- Two-step sign-in (the authenticator app code) is asked for password sign-ins only. Keycloak
|
||||
sign-ins use whatever Keycloak asks for (set up OTP or passkeys there).
|
||||
|
||||
## 1. In Keycloak
|
||||
|
||||
1. Pick the realm your people are in (or make one). The **issuer** is
|
||||
`https://<keycloak>/realms/<realm>`.
|
||||
2. **Clients → Create client**:
|
||||
- Client type **OpenID Connect**, Client ID `selfphoneng`.
|
||||
- **Client authentication: On**. Authentication flow: **Standard flow** only (turn off Direct
|
||||
access grants).
|
||||
- **Valid redirect URIs**: `https://pbx.example.com/api/auth/oidc/callback`
|
||||
- **Valid post logout redirect URIs**: `https://pbx.example.com/login`
|
||||
|
||||
(Use your own host name. Settings → Sign-in shows both with Copy buttons.)
|
||||
3. **Credentials** tab of the client: copy the **Client secret**.
|
||||
4. To make admins from a group (optional): **Groups → Create group** `pbx-admins` and put your
|
||||
admins in it. Then the client's **Client scopes** tab → `selfphoneng-dedicated` → **Add mapper →
|
||||
By configuration → Group Membership**: name `groups`, Token Claim Name `groups`, **Full group
|
||||
path off**, and **Add to ID token**, **Add to access token** and **Add to userinfo** on.
|
||||
5. People who keep the browser phone open all week: Keycloak ends a normal session after 10 hours
|
||||
(Realm settings → Sessions → SSO Session Max), and SelfPhoneNG signs them out then too. To keep
|
||||
them signed in, add `offline_access` to the Scopes in step 2 below. Disabling the user in
|
||||
Keycloak still signs them out.
|
||||
|
||||
## 2. In SelfPhoneNG
|
||||
|
||||
**Settings → Sign-in**:
|
||||
|
||||
1. **Issuer** `https://keycloak.example.com/realms/company`, **Client ID** `selfphoneng`,
|
||||
**Client secret** from step 1.3. **Button name** `Keycloak` (shown as "Sign in with Keycloak").
|
||||
2. **Scopes** `openid email profile` (add `offline_access`, see 1.5).
|
||||
3. **Admin groups** `pbx-admins` if you did step 1.4: members become admins at each sign-in, and
|
||||
everyone else becomes a user. Leave it empty to set roles on Users yourself.
|
||||
**Allowed groups**: leave empty, or name the groups whose members may sign in.
|
||||
4. **Test**. It should say "Works", with refresh tokens and "signs out there too".
|
||||
5. Pick **Passwords or OpenID** and **Save**.
|
||||
6. **My account → Connect Keycloak**, then sign in from a private window with **Sign in with
|
||||
Keycloak**. If a group should have made you admin and didn't, Users → your user shows the
|
||||
groups Keycloak sent: type them exactly like that.
|
||||
|
||||
Once everyone has connected (Users shows "Signs in with" for each person), you can switch to
|
||||
**OpenID only**. People you add from then on get no password: they sign in with Keycloak, and
|
||||
SelfPhoneNG matches them by their **verified** email the first time.
|
||||
|
||||
### Keycloak with a self-signed or private certificate
|
||||
|
||||
Put the CA certificate (PEM) in `data/tls/idp-ca.pem`, add this to `.env`, and restart the api
|
||||
(`docker compose up -d api`):
|
||||
|
||||
```dotenv
|
||||
NODE_EXTRA_CA_CERTS=/var/lib/selfphoneng/tls/idp-ca.pem
|
||||
```
|
||||
|
||||
There is no "don't check the certificate" switch.
|
||||
|
||||
## When it doesn't work
|
||||
|
||||
The login page says what went wrong in plain words. The reason Keycloak gave is in the api's log:
|
||||
`docker compose logs api | grep "OpenID sign-in failed"` (the `reason` field).
|
||||
|
||||
| Message | Fix |
|
||||
|---|---|
|
||||
| "…isn't connected to a phone system account" | They sign in with their password and **Connect Keycloak** first. Or: their SelfPhoneNG user has no password and Keycloak's email for them isn't verified (Keycloak → Users → the user → Email verified). |
|
||||
| "…already connected to another user" | Someone else connected that Keycloak account. Disconnect it on Users → that user. |
|
||||
| "…isn't allowed to use the phone system" | Not in **Allowed groups**. |
|
||||
| "…sign-in didn't work" with `invalid_client` | Client ID or secret wrong, or Client authentication off. |
|
||||
| …with `redirect_uri` | The Valid redirect URI in Keycloak doesn't match exactly (https, host, path). |
|
||||
| …with an issuer mismatch | The issuer must be exactly what Keycloak's `/.well-known/openid-configuration` says, including `/realms/<realm>`. |
|
||||
| Test: "fetch failed" / certificate errors | The server can't reach Keycloak, or doesn't trust its certificate (see above). |
|
||||
| Signed out after 10 hours | Add `offline_access` to Scopes (1.5). |
|
||||
|
||||
## Other providers
|
||||
|
||||
| Provider | Issuer | Groups claim | Notes |
|
||||
|---|---|---|---|
|
||||
| Authentik | `https://auth.example.com/application/o/<slug>/` (keep the last `/`) | `groups` | |
|
||||
| Authelia | `https://auth.example.com` | `groups` (add the `groups` scope) | add `offline_access` for the 15-minute check |
|
||||
| Zitadel | `https://<instance>` | `urn:zitadel:iam:org:project:roles` | turn on "Assert roles on authentication" |
|
||||
| Microsoft Entra ID | `https://login.microsoftonline.com/<tenant id>/v2.0` | `roles` (use app roles; group claims are IDs) | Entra doesn't send "email verified": turn on **Trust email addresses** only for your own single tenant |
|
||||
| Google Workspace | `https://accounts.google.com` | none | no sign-out endpoint and no refresh tokens by default: people disabled in Google stay signed in until they sign out or you disable them on Users |
|
||||
| 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.
|
||||
@@ -121,6 +121,7 @@ APP_UID=1000
|
||||
APP_GID=1000
|
||||
LOG_LEVEL=info
|
||||
TRUST_PROXY=true # true when behind Caddy/nginx
|
||||
#NODE_EXTRA_CA_CERTS=/var/lib/selfphoneng/tls/idp-ca.pem # a private CA for the OpenID provider or LDAPS (12 §1)
|
||||
```
|
||||
|
||||
`apps/api/src/config.ts` validates these with zod at startup. If one is
|
||||
|
||||
+21
-3
@@ -451,7 +451,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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -474,8 +474,9 @@ For every active user with a directory account, one connection per run:
|
||||
HS256 (the client secret) is refused. Keycloak, Authentik, Authelia and the others default to
|
||||
RS256 or ES256.
|
||||
- `client_secret_basic` form-encodes the id and secret before base64 (RFC 6749 §2.3.1: `-` →
|
||||
`%2D`); providers decode it (Keycloak does). If one doesn't, a secret with symbols fails with
|
||||
`invalid_client`: `// TODO(spec)` fall back to `client_secret_post` then, if it's ever seen.
|
||||
`%2D`); providers decode it (Keycloak does: the live test's secret has `-+/=`). If one doesn't,
|
||||
a secret with symbols fails with `invalid_client`: fall back to `client_secret_post` then, if
|
||||
it's ever seen.
|
||||
- Callback failures count against the IP only (`isLoginBlocked(ip, null)`); one more code,
|
||||
`too_many`.
|
||||
- `must_change_password` doesn't block OpenID sessions (guards and the live socket both use
|
||||
@@ -489,6 +490,23 @@ For every active user with a directory account, one connection per run:
|
||||
- `apps/api/test/oidc.test.ts` (every case with RS256 and with ES256), `fake-oidc.ts`.
|
||||
- Not yet seen in a browser against a real provider: that's S3 with the owner's Keycloak.
|
||||
|
||||
### 8.3 As built: S3
|
||||
|
||||
- `sso/oidcCheck.ts`: `checkOidcSessions` (job `oidcCheck`, every 5 minutes, sessions last
|
||||
checked over 15 minutes ago) and `applyClaims`, which the callback uses too. Refresh, then
|
||||
userinfo with the new access token (expected subject: the identity's `sub`), then the same
|
||||
allowed/role rules as a sign-in. `invalid_grant` ends the session; anything else (down, 5xx,
|
||||
`invalid_client`) keeps it for the next run. Rotated refresh and ID tokens are stored.
|
||||
- `apps/api/test/oidc-live.test.ts` (`KEYCLOAK_LIVE_URL`, `KEYCLOAK_LIVE_ADMIN`): makes a
|
||||
throwaway realm (client with a Group Membership mapper, group, user), signs in through
|
||||
Keycloak's own login form, and deletes the realm. Passed against Keycloak 26.8.1 on 2026-10-25:
|
||||
Test, connect, sign-in, group → admin, signing out there (302 back to `/login`, and Keycloak asks
|
||||
for the password again), disabling the user ending our session at the next check.
|
||||
- `NODE_EXTRA_CA_CERTS` in `.env.example` and 01 §1.2: the PEM goes in `data/tls/` (already
|
||||
mounted read-only into the api), so no compose change.
|
||||
- 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`).
|
||||
|
||||
## 9. Decided
|
||||
|
||||
- Owner, 2026-10-25: a user connects their OpenID account to their account and signs in with
|
||||
|
||||
Reference in new issue
Block a user