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
@@ -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