auth: OpenID session check, live Keycloak test, sign-in guide (S3)

Spec 12 S3: job oidcCheck refreshes every OpenID session at the provider every 15 minutes and
applies groups and role again; invalid_grant ends the session, an outage doesn't. A live test
makes a throwaway Keycloak realm and signs in through its login form (passed against 26.8.1).
NODE_EXTRA_CA_CERTS for a private CA. Admin guide: docs/admin-guide/sign-in.md.
This commit is contained in:
iamdoubz committed 2026-10-25 15:14:59 -05:00
1 parent 06b3a73edc
commit 73963ee3a3
11 files changed
+595 -26

No files matched your search

+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);
}
});
});