auth: sign in with OpenID Connect, connect and disconnect, sign-in modes (S2)

Spec 12 S2: openid-client 6.8 (decision 0004). Discovery, PKCE + state + nonce, ID token
signatures checked, userinfo always read. Users connect their OpenID account from My account
and sign in either way; no accounts are made; a user without a password is matched once by
verified email. auth.mode mixed and oidc (admins keep a password way in), role from adminGroups,
allowedGroups, signing out at the provider. Settings -> Sign-in with Test. Tested against a fake
provider with RS256 and ES256 keys.
This commit is contained in:
iamdoubz committed 2026-10-25 14:13:27 -05:00
1 parent 3a9423af1f
commit 06b3a73edc
32 files changed
+1747 -64

No files matched your search

+167
View File
@@ -0,0 +1,167 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// A small OpenID provider for tests (12 §6): discovery, JWKS, authorize, token (code + refresh,
// PKCE S256, client_secret_basic), userinfo, end session. `tamper` breaks one thing at a time.
import { createHash, generateKeyPairSync, randomBytes, sign, type KeyObject } from 'node:crypto';
import { createServer, type IncomingMessage } from 'node:http';
import type { AddressInfo } from 'node:net';
export type FakeUser = {
sub: string;
email?: string;
email_verified?: boolean;
/** Only in userinfo, as Keycloak's mappers and Authelia send them. */
groups?: string[];
};
const b64u = (b: Buffer | string) => Buffer.from(b).toString('base64url');
export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
const pair =
alg === 'RS256'
? generateKeyPairSync('rsa', { modulusLength: 2048 })
: generateKeyPairSync('ec', { namedCurve: 'P-256' });
const other = generateKeyPairSync('rsa', { modulusLength: 2048 }); // signs "bad signature" tokens
const clientId = 'selfphoneng';
const clientSecret = 'fake-secret';
const st = {
user: { sub: 'u-1', email: 'ann@example.com', email_verified: true } as FakeUser,
tamper: {} as {
nonce?: boolean;
iss?: boolean;
aud?: boolean;
expired?: boolean;
signature?: boolean;
userinfoSub?: boolean;
},
/** false: refresh tokens are refused, as for a user disabled at the provider. */
refreshOk: true,
refreshes: 0,
};
const codes = new Map<string, { nonce: string; challenge: string; user: FakeUser }>();
const access = new Map<string, FakeUser>();
const refresh = new Map<string, FakeUser>();
const server = createServer(async (req, res) => {
const url = new URL(req.url!, issuer);
const json = (code: number, body: unknown) => {
res.writeHead(code, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
if (url.pathname === '/.well-known/openid-configuration')
return json(200, {
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
userinfo_endpoint: `${issuer}/userinfo`,
jwks_uri: `${issuer}/jwks`,
end_session_endpoint: `${issuer}/logout`,
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: [alg],
code_challenge_methods_supported: ['S256'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['client_secret_basic'],
});
if (url.pathname === '/jwks')
return json(200, {
keys: [{ ...pair.publicKey.export({ format: 'jwk' }), kid: 'k1', alg, use: 'sig' }],
});
if (url.pathname === '/authorize') {
const p = url.searchParams;
if (p.get('client_id') !== clientId || p.get('code_challenge_method') !== 'S256')
return json(400, { error: 'invalid_request' });
const code = b64u(randomBytes(16));
codes.set(code, {
nonce: p.get('nonce')!,
challenge: p.get('code_challenge')!,
user: { ...st.user },
});
const back = new URL(p.get('redirect_uri')!);
back.searchParams.set('code', code);
back.searchParams.set('state', p.get('state')!);
res.writeHead(302, { location: back.href });
return res.end();
}
if (url.pathname === '/token' && req.method === 'POST') {
// client_secret_basic: both parts form-encoded before base64 (RFC 6749 §2.3.1).
const [id, secret] = Buffer.from(
(req.headers.authorization ?? '').replace(/^Basic /, ''),
'base64',
)
.toString()
.split(':')
.map((x) => decodeURIComponent(x.replace(/\+/g, ' ')));
if (id !== clientId || secret !== clientSecret) return json(401, { error: 'invalid_client' });
const form = new URLSearchParams(await body(req));
if (form.get('grant_type') === 'refresh_token') {
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));
}
const c = codes.get(form.get('code') ?? '');
codes.delete(form.get('code') ?? '');
const verifier = form.get('code_verifier') ?? '';
if (!c || b64u(createHash('sha256').update(verifier).digest()) !== c.challenge)
return json(400, { error: 'invalid_grant' });
return json(200, tokens(c.user, c.nonce));
}
if (url.pathname === '/userinfo') {
const user = access.get((req.headers.authorization ?? '').replace(/^Bearer /, ''));
if (!user) return json(401, { error: 'invalid_token' });
return json(200, { ...user, ...(st.tamper.userinfoSub && { sub: 'someone-else' }) });
}
if (url.pathname === '/logout') return json(200, {});
json(404, {});
});
function tokens(user: FakeUser, nonce: string | null) {
const now = Math.floor(Date.now() / 1000);
const t = st.tamper;
const claims = {
iss: t.iss ? 'https://evil.example' : issuer,
sub: user.sub,
aud: t.aud ? 'another-client' : clientId,
iat: now,
exp: t.expired ? now - 3600 : now + 300,
...(nonce && { nonce: t.nonce ? 'wrong-nonce' : nonce }),
...(user.email && { email: user.email, email_verified: user.email_verified }),
};
const at = b64u(randomBytes(16));
const rt = b64u(randomBytes(16));
access.set(at, user);
refresh.set(rt, user);
return {
access_token: at,
token_type: 'Bearer',
expires_in: 300,
refresh_token: rt,
id_token: jwt(claims, t.signature ? other.privateKey : pair.privateKey),
};
}
function jwt(claims: object, key: KeyObject) {
const data = `${b64u(JSON.stringify({ alg, kid: 'k1', typ: 'JWT' }))}.${b64u(JSON.stringify(claims))}`;
const sig =
alg === 'RS256'
? sign('sha256', Buffer.from(data), key)
: sign('sha256', Buffer.from(data), { key, dsaEncoding: 'ieee-p1363' });
return `${data}.${b64u(sig)}`;
}
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
const issuer = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
return {
issuer,
clientId,
clientSecret,
st,
close: () => new Promise<void>((r) => server.close(() => r())),
};
}
async function body(req: IncomingMessage): Promise<string> {
let s = '';
for await (const chunk of req) s += chunk;
return s;
}
+339
View File
@@ -0,0 +1,339 @@
// 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 { 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 { 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 };
});
/** The browser's round trip: start → provider → callback. */
async function flow(cookie?: string, query = '') {
const start = await t.app.inject({
url: `/api/auth/oidc/start${query}`,
headers: cookie ? { cookie } : {},
});
expect(start.statusCode).toBe(302);
const pending = start.cookies.find((c) => c.name === 'sp_oidc');
if (!pending) return { start, callback: null };
const atIdp = await fetch(start.headers.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('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('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');
});
});
+5 -1
View File
@@ -57,7 +57,11 @@ describe.skipIf(!hasTestDb)('sign-in (12 S1)', () => {
.insert(userIdentities)
.values({ userId: u.id, kind: 'oidc', subject: 'https://kc/realms/x abc', email: 'l@kc' });
const r = await t.app.inject({ url: `/api/users/${u.id}`, headers: { cookie: admin } });
expect(r.json().signIn).toEqual({ password: true, oidc: { email: 'l@kc' }, ldap: false });
expect(r.json().signIn).toEqual({
password: true,
oidc: { email: 'l@kc', groups: [] },
ldap: false,
});
});
it('auth.mode can only be password until OpenID Connect is set up', async () => {