Feat/sso #3

Merged
iamdoubz merged 7 commits from feat/sso into main 2026-10-26 09:52:50 -05:00
11 changed files with 595 additions and 26 deletions
Showing only changes of commit 73963ee3a3 - Show all commits

No files matched your search

+3
View File
@@ -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
+8
View File
@@ -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 });
});
+3 -21
View File
@@ -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,
+144
View File
@@ -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);
}
}
+5 -1
View File
@@ -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') ?? '');
+226
View File
@@ -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('&amp;', '&'), {
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);
});
});
+68 -1
View File
@@ -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);
}
});
});
+1
View File
@@ -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)
---
+115
View File
@@ -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.
+1
View File
@@ -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
View File
@@ -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