Matching by email skips admins and anyone with a connected account, in both directions. A connected directory account is only swapped by an admin, and an OpenID connect finishes only in the browser whose session asked.
528 lines
22 KiB
TypeScript
528 lines
22 KiB
TypeScript
// 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, desc, eq } from 'drizzle-orm';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
import { decrypt, 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';
|
|
|
|
for (const alg of ['RS256', 'ES256'] as const)
|
|
describe.skipIf(!hasTestDb)(`OpenID Connect (12 S2, ${alg})`, () => {
|
|
let t: Awaited<ReturnType<typeof createTestApp>>;
|
|
let idp: Awaited<ReturnType<typeof startFakeOidc>>;
|
|
let admin: string;
|
|
let oidcSettings: OidcSettings;
|
|
|
|
beforeAll(async () => {
|
|
idp = await startFakeOidc(alg);
|
|
t = await createTestApp();
|
|
await t.clearMustChange(ADMIN.email);
|
|
admin = await t.login(ADMIN.email, ADMIN.password);
|
|
oidcSettings = {
|
|
label: 'Keycloak',
|
|
issuer: idp.issuer,
|
|
clientId: idp.clientId,
|
|
clientSecretEnc: encrypt(idp.clientSecret),
|
|
scopes: 'openid email profile',
|
|
groupsClaim: 'groups',
|
|
adminGroups: [],
|
|
allowedGroups: [],
|
|
trustEmail: false,
|
|
};
|
|
await setSetting(t.db, 'auth.oidc', oidcSettings);
|
|
await setSetting(t.db, 'auth.mode', 'mixed');
|
|
});
|
|
afterAll(async () => {
|
|
await t?.close();
|
|
await idp?.close();
|
|
});
|
|
beforeEach(() => {
|
|
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;
|
|
idp.st.refreshIdToken = true;
|
|
});
|
|
|
|
/** The browser's round trip: start → provider → callback. */
|
|
async function flow(cookie?: string, query = '') {
|
|
const connecting = query === '?connect=1';
|
|
// Connect is a POST from My account (with the CSRF header); sign-in a GET from the button.
|
|
const start = connecting
|
|
? await t.app.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/oidc/connect',
|
|
headers: { ...H, cookie: cookie! },
|
|
payload: {},
|
|
})
|
|
: await t.app.inject({
|
|
url: `/api/auth/oidc/start${query}`,
|
|
headers: cookie ? { cookie } : {},
|
|
});
|
|
const location = connecting ? (start.json() as { url: string }).url : start.headers.location;
|
|
expect(start.statusCode).toBe(connecting ? 200 : 302);
|
|
const pending = start.cookies.find((c) => c.name === 'sp_oidc');
|
|
if (!pending) return { start, callback: null };
|
|
const atIdp = await fetch(location as string, { redirect: 'manual' });
|
|
const back = new URL(atIdp.headers.get('location')!);
|
|
expect(back.origin).toBe('https://pbx.test');
|
|
const callback = await t.app.inject({
|
|
url: back.pathname + back.search,
|
|
headers: { cookie: [`sp_oidc=${pending.value}`, cookie].filter(Boolean).join('; ') },
|
|
});
|
|
return { start, callback };
|
|
}
|
|
const session = (r: { cookies: { name: string; value: string }[] }) => {
|
|
const c = r.cookies.find((x) => x.name === 'sp_session');
|
|
return c ? `sp_session=${c.value}` : null;
|
|
};
|
|
const errorOf = (r: { headers: Record<string, unknown> }) =>
|
|
new URL(r.headers.location as string, 'https://pbx.test').searchParams.get('sso_error');
|
|
|
|
it('methods show the button in mixed mode', async () => {
|
|
const r = await t.app.inject({ url: '/api/auth/methods' });
|
|
expect(r.json()).toEqual({ mode: 'mixed', ldap: false, oidc: { label: 'Keycloak' } });
|
|
});
|
|
|
|
it('never sends the browser to another site after sign-in (a tab, a backslash, //)', async () => {
|
|
await t.addUser('nextcheck@example.com', 'next-check-pw-1');
|
|
idp.st.user = { sub: 'u-next', email: 'nextcheck@example.com', email_verified: true };
|
|
const pw = await t.login('nextcheck@example.com', 'next-check-pw-1');
|
|
await flow(pw, '?connect=1');
|
|
for (const next of [
|
|
'/\t/evil.example',
|
|
'/\n/evil.example',
|
|
'/\\evil.example',
|
|
'//evil.example',
|
|
])
|
|
expect(
|
|
(await flow(undefined, `?next=${encodeURIComponent(next)}`)).callback!.headers.location,
|
|
).toBe('/');
|
|
expect((await flow(undefined, '?next=/me/calls')).callback!.headers.location).toBe(
|
|
'/me/calls',
|
|
);
|
|
});
|
|
|
|
it('connect is a POST with the CSRF header; a GET with connect=1 only signs in', async () => {
|
|
const ann = await t.login(ADMIN.email, ADMIN.password);
|
|
const forged = await t.app.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/oidc/connect',
|
|
headers: { cookie: ann },
|
|
payload: {},
|
|
});
|
|
expect(forged.statusCode).toBe(403);
|
|
expect(forged.json().error.code).toBe('csrf');
|
|
const r = await t.app.inject({
|
|
url: '/api/auth/oidc/start?connect=1',
|
|
headers: { cookie: ann },
|
|
});
|
|
const pending = r.cookies.find((c) => c.name === 'sp_oidc')!;
|
|
expect(JSON.parse(decrypt(pending.value)).connectSessionId).toBeNull();
|
|
});
|
|
|
|
it('starts with PKCE S256, state and nonce, and a short-lived cookie', async () => {
|
|
const r = await t.app.inject({ url: '/api/auth/oidc/start?next=/me/calls' });
|
|
const to = new URL(r.headers.location as string);
|
|
expect(to.origin).toBe(idp.issuer);
|
|
for (const p of ['state', 'nonce', 'code_challenge'])
|
|
expect(to.searchParams.get(p)).toBeTruthy();
|
|
expect(to.searchParams.get('code_challenge_method')).toBe('S256');
|
|
expect(to.searchParams.get('redirect_uri')).toBe('https://pbx.test/api/auth/oidc/callback');
|
|
expect(r.cookies.find((c) => c.name === 'sp_oidc')).toMatchObject({
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'Lax',
|
|
path: '/api/auth/oidc',
|
|
maxAge: 600,
|
|
});
|
|
});
|
|
|
|
it('an account nobody connected is refused, and no user is made', async () => {
|
|
idp.st.user = { sub: 'stranger', email: 'stranger@example.com', email_verified: true };
|
|
const { callback } = await flow();
|
|
expect(errorOf(callback!)).toBe('not_connected');
|
|
expect(session(callback!)).toBeNull();
|
|
const [u] = await t.db.select().from(users).where(eq(users.email, 'stranger@example.com'));
|
|
expect(u).toBeUndefined();
|
|
});
|
|
|
|
it('a user with a password is never matched by email, even verified', async () => {
|
|
await t.addUser('ann@example.com', 'ann-password-12');
|
|
const { callback } = await flow();
|
|
expect(errorOf(callback!)).toBe('not_connected');
|
|
});
|
|
|
|
it('connects from My account, then signs in either way', async () => {
|
|
const ann = await t.login('ann@example.com', 'ann-password-12');
|
|
const { callback } = await flow(ann, '?connect=1');
|
|
expect(callback!.headers.location).toBe('/me/settings?sso=connected');
|
|
const me = await t.app.inject({ url: '/api/auth/me', headers: { cookie: ann } });
|
|
expect(me.json().user.signIn).toEqual({
|
|
password: true,
|
|
oidc: { email: 'ann@example.com', groups: [] },
|
|
ldap: false,
|
|
});
|
|
const signIn = await flow(undefined, '?next=/me/calls');
|
|
expect(signIn.callback!.headers.location).toBe('/me/calls');
|
|
const cookie = session(signIn.callback!);
|
|
expect(cookie).toBeTruthy();
|
|
const [s] = await t.db
|
|
.select()
|
|
.from(sessions)
|
|
.where(eq(sessions.method, 'oidc'))
|
|
.orderBy(sessions.createdAt);
|
|
expect(s!.idpRefreshEnc).toBeTruthy();
|
|
expect(s!.idpIdTokenEnc).toBeTruthy();
|
|
// …and the password still works.
|
|
expect(await t.login('ann@example.com', 'ann-password-12')).toBeTruthy();
|
|
});
|
|
|
|
it("refuses to connect an account that's someone else's", async () => {
|
|
await t.addUser('bob@example.com', 'bob-password-12');
|
|
const bob = await t.login('bob@example.com', 'bob-password-12');
|
|
const { callback } = await flow(bob, '?connect=1'); // the fake still signs in as u-1 (Ann)
|
|
expect(errorOf(callback!)).toBe('taken');
|
|
});
|
|
|
|
it('matches a user without a password once, by verified email', async () => {
|
|
const [u] = await t.db
|
|
.insert(users)
|
|
.values({ email: 'cara@example.com', displayName: 'Cara', passwordHash: null })
|
|
.returning();
|
|
idp.st.user = { sub: 'u-cara', email: 'cara@example.com', email_verified: false };
|
|
expect(errorOf((await flow()).callback!)).toBe('not_connected');
|
|
idp.st.user.email_verified = true;
|
|
const { callback } = await flow();
|
|
expect(session(callback!)).toBeTruthy();
|
|
const ids = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u!.id));
|
|
expect(ids).toMatchObject([{ kind: 'oidc', subject: `${idp.issuer} u-cara` }]);
|
|
});
|
|
|
|
it('never matches by email an admin, or someone with a directory account (08 §7 #2)', async () => {
|
|
const [boss, dir] = await t.db
|
|
.insert(users)
|
|
.values([
|
|
{ email: 'diradmin@example.com', displayName: 'Boss', passwordHash: null, role: 'admin' },
|
|
{ email: 'diruser@example.com', displayName: 'Dir', passwordHash: null },
|
|
])
|
|
.returning();
|
|
await t.db
|
|
.insert(userIdentities)
|
|
.values({ userId: dir!.id, kind: 'ldap', subject: 'dir-user-id' });
|
|
for (const u of [boss!, dir!]) {
|
|
idp.st.user = { sub: `u-${u.email}`, email: u.email, email_verified: true };
|
|
const { callback } = await flow();
|
|
expect(errorOf(callback!)).toBe('not_connected');
|
|
expect(session(callback!)).toBeNull();
|
|
}
|
|
});
|
|
|
|
it('connect finishes only in the browser whose session asked (08 §7 #8)', async () => {
|
|
// Mallory starts Connect, then plants her sp_oidc cookie in Vic's browser and sends him on.
|
|
await t.addUser('mallory@example.com', 'mallory-password-1');
|
|
const [m] = await t.db.select().from(users).where(eq(users.email, 'mallory@example.com'));
|
|
await t.addUser('vic@example.com', 'vic-password-123');
|
|
const mallory = await t.login('mallory@example.com', 'mallory-password-1');
|
|
const vic = await t.login('vic@example.com', 'vic-password-123');
|
|
idp.st.user = { sub: 'u-vic', email: 'vic@example.com', email_verified: true };
|
|
for (const theirs of [vic, '']) {
|
|
const start = await t.app.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/oidc/connect',
|
|
headers: { ...H, cookie: mallory },
|
|
payload: {},
|
|
});
|
|
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
|
|
const atIdp = await fetch((start.json() as { url: string }).url, { redirect: 'manual' });
|
|
const back = new URL(atIdp.headers.get('location')!);
|
|
const callback = await t.app.inject({
|
|
url: back.pathname + back.search,
|
|
headers: { cookie: [`sp_oidc=${pending.value}`, theirs].filter(Boolean).join('; ') },
|
|
});
|
|
expect(errorOf(callback)).toBe('expired');
|
|
const ids = await t.db
|
|
.select()
|
|
.from(userIdentities)
|
|
.where(eq(userIdentities.userId, m!.id));
|
|
expect(ids).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it('trustEmail matches an unverified email', async () => {
|
|
await t.db
|
|
.insert(users)
|
|
.values({ email: 'dee@example.com', displayName: 'Dee', passwordHash: null });
|
|
idp.st.user = { sub: 'u-dee', email: 'dee@example.com' };
|
|
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, trustEmail: true });
|
|
try {
|
|
expect(session((await flow()).callback!)).toBeTruthy();
|
|
} finally {
|
|
await setSetting(t.db, 'auth.oidc', oidcSettings);
|
|
}
|
|
});
|
|
|
|
for (const [name, tamper] of [
|
|
['a wrong nonce', { nonce: true }],
|
|
['a wrong issuer', { iss: true }],
|
|
['a wrong audience', { aud: true }],
|
|
['an expired ID token', { expired: true }],
|
|
['a bad signature', { signature: true }],
|
|
['a userinfo subject that differs', { userinfoSub: true }],
|
|
] as const)
|
|
it(`refuses ${name}`, async () => {
|
|
idp.st.tamper = tamper;
|
|
const { callback } = await flow();
|
|
expect(errorOf(callback!)).toBe('provider');
|
|
expect(session(callback!)).toBeNull();
|
|
});
|
|
|
|
it('refuses a callback without our cookie, or with another state', async () => {
|
|
const r = await t.app.inject({ url: '/api/auth/oidc/callback?code=x&state=y' });
|
|
expect(errorOf(r)).toBe('expired');
|
|
const start = await t.app.inject({ url: '/api/auth/oidc/start' });
|
|
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
|
|
const atIdp = await fetch(start.headers.location as string, { redirect: 'manual' });
|
|
const back = new URL(atIdp.headers.get('location')!);
|
|
back.searchParams.set('state', 'forged');
|
|
const forged = await t.app.inject({
|
|
url: back.pathname + back.search,
|
|
headers: { cookie: `sp_oidc=${pending.value}` },
|
|
});
|
|
expect(errorOf(forged)).toBe('provider');
|
|
});
|
|
|
|
it('the provider saying access_denied is "denied"', async () => {
|
|
const start = await t.app.inject({ url: '/api/auth/oidc/start' });
|
|
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
|
|
const r = await t.app.inject({
|
|
url: '/api/auth/oidc/callback?error=access_denied&state=x',
|
|
headers: { cookie: `sp_oidc=${pending.value}` },
|
|
});
|
|
expect(errorOf(r)).toBe('denied');
|
|
});
|
|
|
|
it('groups: allowedGroups refuses, adminGroups sets the role', async () => {
|
|
idp.st.user = { sub: 'u-1', email: 'ann@example.com', groups: ['pbx-admins'] };
|
|
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, allowedGroups: ['pbx-users'] });
|
|
expect(errorOf((await flow()).callback!)).toBe('not_allowed');
|
|
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, adminGroups: ['pbx-admins'] });
|
|
try {
|
|
const { callback } = await flow();
|
|
const me = await t.app.inject({
|
|
url: '/api/auth/me',
|
|
headers: { cookie: session(callback!)! },
|
|
});
|
|
expect(me.json().user.role).toBe('admin');
|
|
const [id] = await t.db
|
|
.select()
|
|
.from(userIdentities)
|
|
.where(eq(userIdentities.subject, `${idp.issuer} u-1`));
|
|
expect(id!.groups).toEqual(['pbx-admins']);
|
|
idp.st.user.groups = [];
|
|
await flow();
|
|
const [ann] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
|
|
expect(ann!.role).toBe('user');
|
|
} finally {
|
|
await setSetting(t.db, 'auth.oidc', oidcSettings);
|
|
}
|
|
});
|
|
|
|
it('signing out of an OpenID session sends the browser to the provider', async () => {
|
|
const { callback } = await flow();
|
|
const out = await t.app.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/logout',
|
|
headers: { ...H, cookie: session(callback!)! },
|
|
});
|
|
const to = new URL(out.json().redirect);
|
|
expect(to.href.startsWith(`${idp.issuer}/logout`)).toBe(true);
|
|
expect(to.searchParams.get('id_token_hint')).toBeTruthy();
|
|
expect(to.searchParams.get('post_logout_redirect_uri')).toBe('https://pbx.test/login');
|
|
// A password session signs out here only.
|
|
const pw = await t.login('ann@example.com', 'ann-password-12');
|
|
const out2 = await t.app.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/logout',
|
|
headers: { ...H, cookie: pw },
|
|
});
|
|
expect(out2.json()).toEqual({ ok: true });
|
|
});
|
|
|
|
it('OpenID-only mode: passwords for admins only; disconnect keeps a way in', async () => {
|
|
await setSetting(t.db, 'auth.mode', 'oidc');
|
|
try {
|
|
const r = await t.app.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/login',
|
|
headers: H,
|
|
payload: { login: 'ann@example.com', password: 'ann-password-12' },
|
|
});
|
|
expect(r.json().error.code).toBe('password_sign_in_off');
|
|
const { callback } = await flow();
|
|
const ann = session(callback!)!;
|
|
const d = await t.app.inject({
|
|
method: 'DELETE',
|
|
url: '/api/me/identities/oidc',
|
|
headers: { ...H, cookie: ann },
|
|
});
|
|
expect(d.statusCode).toBe(409);
|
|
expect(d.json().error.code).toBe('last_way_in');
|
|
} finally {
|
|
await setSetting(t.db, 'auth.mode', 'mixed');
|
|
}
|
|
// Mixed: Ann still has her password, so she may disconnect; OpenID sessions end.
|
|
const { callback } = await flow();
|
|
const ann = session(callback!)!;
|
|
const [row] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
|
|
const d = await t.app.inject({
|
|
method: 'DELETE',
|
|
url: `/api/users/${row!.id}/identities/oidc`,
|
|
headers: { ...H, cookie: admin },
|
|
});
|
|
expect(d.statusCode).toBe(200);
|
|
const left = await t.db
|
|
.select()
|
|
.from(sessions)
|
|
.where(and(eq(sessions.userId, row!.id), eq(sessions.method, 'oidc')));
|
|
expect(left).toEqual([]);
|
|
expect(
|
|
(await t.app.inject({ url: '/api/auth/me', headers: { cookie: ann } })).statusCode,
|
|
).toBe(401);
|
|
});
|
|
|
|
it('settings: Test, the secret write-only, and the mode needing a working provider', async () => {
|
|
const form = {
|
|
label: 'Keycloak',
|
|
issuer: idp.issuer,
|
|
clientId: idp.clientId,
|
|
scopes: 'openid email profile',
|
|
groupsClaim: 'groups',
|
|
adminGroups: [],
|
|
allowedGroups: [],
|
|
trustEmail: false,
|
|
};
|
|
const req = (method: 'GET' | 'PATCH' | 'POST', url: string, payload?: object) =>
|
|
t.app.inject({ method, url, payload, headers: { ...H, cookie: admin } });
|
|
const ok = await req('POST', '/api/settings/auth/oidc/test', form);
|
|
expect(ok.json()).toMatchObject({
|
|
ok: true,
|
|
pkce: true,
|
|
refreshTokens: true,
|
|
endSession: true,
|
|
});
|
|
const bad = await req('POST', '/api/settings/auth/oidc/test', {
|
|
...form,
|
|
clientSecret: 'wrong',
|
|
});
|
|
expect(bad.json()).toMatchObject({ ok: false });
|
|
expect((await req('PATCH', '/api/settings', { 'auth.oidc': form })).statusCode).toBe(200);
|
|
const got = (await req('GET', '/api/settings')).json()['auth.oidc'];
|
|
expect(got).toMatchObject({ hasClientSecret: true, label: 'Keycloak' });
|
|
expect(JSON.stringify(got)).not.toContain('clientSecretEnc');
|
|
const broken = await req('PATCH', '/api/settings', {
|
|
'auth.oidc': { ...form, issuer: 'http://127.0.0.1:1' },
|
|
});
|
|
expect(broken.statusCode).toBe(400);
|
|
const remote = await req('PATCH', '/api/settings', {
|
|
'auth.oidc': { ...form, issuer: 'http://kc.example.com/realms/x' },
|
|
});
|
|
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);
|
|
}
|
|
});
|
|
|
|
it('the session check reads roles that are only in the ID token, and keeps a session whose refresh brings no groups', async () => {
|
|
idp.st.user = { ...idp.st.user, roles: ['pbx-users'] }; // Entra: app roles, ID token only
|
|
await setSetting(t.db, 'auth.oidc', {
|
|
...oidcSettings,
|
|
groupsClaim: 'roles',
|
|
allowedGroups: ['pbx-users'],
|
|
});
|
|
try {
|
|
const { id } = await annSession();
|
|
await check(20);
|
|
expect(await row(id)).toBeTruthy();
|
|
idp.st.refreshIdToken = false; // no new ID token: no roles claim at all
|
|
await check(40);
|
|
expect(await row(id)).toBeTruthy();
|
|
idp.st.refreshIdToken = true;
|
|
idp.st.user.roles = []; // the claim is there, and empty: ended
|
|
await check(60);
|
|
expect(await row(id)).toBeUndefined();
|
|
} finally {
|
|
await setSetting(t.db, 'auth.oidc', oidcSettings);
|
|
}
|
|
});
|
|
});
|