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:
1 parent
06b3a73edc
commit
73963ee3a3
11 files changed
+595
-26
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user