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

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