From 06b3a73edc0e5a8ad22acc72bfc7c28930c0edd5 Mon Sep 17 00:00:00 2001
From: iamdoubz
Date: Sun, 25 Oct 2026 14:13:27 -0500
Subject: [PATCH] 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.
---
apps/api/package.json | 1 +
apps/api/src/app.ts | 2 +
apps/api/src/auth/guards.ts | 4 +-
apps/api/src/auth/login-limiter.ts | 10 +-
apps/api/src/auth/sessions.ts | 14 +-
apps/api/src/live/hub.ts | 6 +-
apps/api/src/routes/auth.ts | 45 ++-
apps/api/src/routes/oidc.ts | 279 +++++++++++++++++
apps/api/src/routes/settings.ts | 71 ++++-
apps/api/src/routes/users.ts | 30 +-
apps/api/src/sso/claims.test.ts | 42 +++
apps/api/src/sso/claims.ts | 52 ++++
apps/api/src/sso/oidc.ts | 187 ++++++++++++
apps/api/test/fake-oidc.ts | 167 ++++++++++
apps/api/test/oidc.test.ts | 339 +++++++++++++++++++++
apps/api/test/sign-in.test.ts | 6 +-
apps/web/src/api/client.ts | 19 +-
apps/web/src/components/SignInCard.tsx | 99 ++++++
apps/web/src/pages/LoginPage.tsx | 45 ++-
apps/web/src/pages/SetupPage.tsx | 2 +-
apps/web/src/pages/admin/AccountPage.tsx | 16 +-
apps/web/src/pages/admin/AdminLayout.tsx | 4 +-
apps/web/src/pages/admin/SettingsPage.tsx | 209 +++++++++++++
apps/web/src/pages/admin/UserDrawer.tsx | 32 +-
apps/web/src/pages/admin/UsersPage.tsx | 4 +-
apps/web/src/pages/user/MePage.tsx | 4 +-
apps/web/src/pages/user/MeSettingsPage.tsx | 9 +-
docs/spec/02-data-model.md | 1 +
docs/spec/12-sso.md | 27 +-
packages/shared/src/auth.ts | 3 +-
packages/shared/src/settings.ts | 61 +++-
pnpm-lock.yaml | 21 ++
32 files changed, 1747 insertions(+), 64 deletions(-)
create mode 100644 apps/api/src/routes/oidc.ts
create mode 100644 apps/api/src/sso/claims.test.ts
create mode 100644 apps/api/src/sso/claims.ts
create mode 100644 apps/api/src/sso/oidc.ts
create mode 100644 apps/api/test/fake-oidc.ts
create mode 100644 apps/api/test/oidc.test.ts
create mode 100644 apps/web/src/components/SignInCard.tsx
diff --git a/apps/api/package.json b/apps/api/package.json
index f4aa6a9..30fad6c 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -19,6 +19,7 @@
"drizzle-orm": "^0.45.4",
"fastify": "^5.12.7",
"nodemailer": "10.0.11",
+ "openid-client": "^6.8.8",
"pg": "^8.23.1",
"qrcode": "1.5.4",
"zod": "^4.6.7"
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index ee254f6..74fc3b6 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -14,6 +14,7 @@ import { settingsRoutes } from './routes/settings.ts';
import { errorHandler, forbidden, notFound, sendError } from './errors.ts';
import { authRoutes } from './routes/auth.ts';
import { userRoutes } from './routes/users.ts';
+import { oidcRoutes } from './routes/oidc.ts';
import { systemRoutes } from './routes/system.ts';
import { extensionRoutes } from './routes/extensions.ts';
import { trunkRoutes } from './routes/trunks.ts';
@@ -139,6 +140,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(authRoutes);
app.register(userRoutes);
+ app.register(oidcRoutes);
app.register(systemRoutes);
app.register(extensionRoutes);
app.register(trunkRoutes);
diff --git a/apps/api/src/auth/guards.ts b/apps/api/src/auth/guards.ts
index 0cc18c1..12c2b16 100644
--- a/apps/api/src/auth/guards.ts
+++ b/apps/api/src/auth/guards.ts
@@ -3,7 +3,7 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { users } from '../db/schema.ts';
import { forbidden, unauthorized } from '../errors.ts';
-import { COOKIE, getSessionUser, setSessionCookie } from './sessions.ts';
+import { COOKIE, getSessionUser, mustChange, setSessionCookie } from './sessions.ts';
import { getTokenUser } from './tokens.ts';
export type CurrentUser = typeof users.$inferSelect;
@@ -39,7 +39,7 @@ export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Pro
if (found.extended) setSessionCookie(reply, token!);
req.user = found.user;
req.sessionId = found.sessionId;
- if (found.user.mustChangePassword && !req.url.startsWith('/api/auth/'))
+ if (mustChange(found) && !req.url.startsWith('/api/auth/'))
throw forbidden('Change your password first', 'password_change_required');
}
diff --git a/apps/api/src/auth/login-limiter.ts b/apps/api/src/auth/login-limiter.ts
index 696c598..13b5c47 100644
--- a/apps/api/src/auth/login-limiter.ts
+++ b/apps/api/src/auth/login-limiter.ts
@@ -15,21 +15,23 @@ function recent(key: string, windowMs: number, now: number): number[] {
return list;
}
-export function isLoginBlocked(ip: string, email: string, now = Date.now()): boolean {
+/** `email` null: by IP only (an OpenID callback names no account, 12 §3.2). */
+export function isLoginBlocked(ip: string, email: string | null, now = Date.now()): boolean {
return (
recent(`ip:${ip}`, RULES.ip.windowMs, now).length >= RULES.ip.max ||
- recent(`email:${email.toLowerCase()}`, RULES.email.windowMs, now).length >= RULES.email.max
+ (email !== null &&
+ recent(`email:${email.toLowerCase()}`, RULES.email.windowMs, now).length >= RULES.email.max)
);
}
-export function recordLoginFailure(ip: string, email: string, now = Date.now()): void {
+export function recordLoginFailure(ip: string, email: string | null, now = Date.now()): void {
// Forget keys past every window, at most once a minute: emails tried once and never again
// would otherwise stay until restart (08 §6).
if (now - lastSweep > MIN) {
lastSweep = now;
for (const [key, list] of failures) if (now - list.at(-1)! >= LONGEST) failures.delete(key);
}
- for (const key of [`ip:${ip}`, `email:${email.toLowerCase()}`]) {
+ for (const key of [`ip:${ip}`, ...(email === null ? [] : [`email:${email.toLowerCase()}`])]) {
failures.set(key, [...(failures.get(key) ?? []), now]);
}
}
diff --git a/apps/api/src/auth/sessions.ts b/apps/api/src/auth/sessions.ts
index 4c1e5a5..be71fe0 100644
--- a/apps/api/src/auth/sessions.ts
+++ b/apps/api/src/auth/sessions.ts
@@ -4,6 +4,7 @@ import { createHash, randomBytes } from 'node:crypto';
import { and, eq, gt, ne } from 'drizzle-orm';
import type { FastifyReply } from 'fastify';
import type { Db } from '../db/client.ts';
+import { encrypt } from '../crypto/secrets.ts';
import { sessions, users } from '../db/schema.ts';
export const COOKIE = 'sp_session';
@@ -33,6 +34,8 @@ export async function createSession(
ip: string | null,
userAgent: string | null,
method: 'password' | 'ldap' | 'oidc' = 'password',
+ /** OpenID sessions (12 §3.5): kept encrypted for the leaver check and signing out there. */
+ idp?: { refreshToken: string | null; idToken: string },
): Promise {
const token = randomBytes(32).toString('base64url');
await db.insert(sessions).values({
@@ -42,6 +45,9 @@ export async function createSession(
ip,
userAgent: userAgent?.slice(0, 500) ?? null,
method,
+ idpRefreshEnc: idp?.refreshToken ? encrypt(idp.refreshToken) : null,
+ idpIdTokenEnc: idp ? encrypt(idp.idToken) : null,
+ idpCheckedAt: idp ? new Date() : null,
});
return token;
}
@@ -53,7 +59,7 @@ export async function createSession(
export async function getSessionUser(db: Db, token: string, slide = true) {
const id = sha256(token);
const [row] = await db
- .select({ expiresAt: sessions.expiresAt, user: users })
+ .select({ expiresAt: sessions.expiresAt, method: sessions.method, user: users })
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date()), eq(users.isActive, true)));
@@ -66,9 +72,13 @@ export async function getSessionUser(db: Db, token: string, slide = true) {
.where(eq(sessions.id, id));
extended = true;
}
- return { user: row.user, sessionId: id, extended };
+ return { user: row.user, sessionId: id, method: row.method, extended };
}
+/** A password to change only blocks sessions made with it (OpenID sessions skip it, 12). */
+export const mustChange = (f: { user: { mustChangePassword: boolean }; method: string }) =>
+ f.user.mustChangePassword && f.method === 'password';
+
export async function deleteSession(db: Db, token: string) {
await db.delete(sessions).where(eq(sessions.id, sha256(token)));
}
diff --git a/apps/api/src/live/hub.ts b/apps/api/src/live/hub.ts
index e13d8bb..d76dda4 100644
--- a/apps/api/src/live/hub.ts
+++ b/apps/api/src/live/hub.ts
@@ -4,7 +4,7 @@ import type { FastifyInstance } from 'fastify';
import type { WebSocket } from '@fastify/websocket';
import { eq } from 'drizzle-orm';
import type { ExtensionLive, LiveMessage } from '@selfphoneng/shared';
-import { COOKIE, getSessionUser } from '../auth/sessions.ts';
+import { COOKIE, getSessionUser, mustChange } from '../auth/sessions.ts';
import { extensions } from '../db/schema.ts';
import type { LiveState } from './state.ts';
@@ -103,7 +103,7 @@ export async function liveRoutes(app: FastifyInstance) {
for (const c of clients)
void getSessionUser(app.db, c.token, false)
.then((f) => {
- if (!f || f.user.mustChangePassword || (f.user.role === 'admin') !== c.admin)
+ if (!f || mustChange(f) || (f.user.role === 'admin') !== c.admin)
c.ws.close(4401, 'unauthorized');
})
.catch(() => {}); // database down: keep the socket, check again next time
@@ -123,7 +123,7 @@ export async function liveRoutes(app: FastifyInstance) {
const token = req.cookies[COOKIE];
const found = token ? await getSessionUser(app.db, token) : null;
// Browsers can't read an HTTP 401 on an upgrade, so open and close with 4401 (04 §5.2).
- if (!found || found.user.mustChangePassword) return ws.close(4401, 'unauthorized');
+ if (!found || mustChange(found)) return ws.close(4401, 'unauthorized');
const mine = await app.db
.select({ n: extensions.number })
.from(extensions)
diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts
index 1987b85..5ca3cb9 100644
--- a/apps/api/src/routes/auth.ts
+++ b/apps/api/src/routes/auth.ts
@@ -24,7 +24,8 @@ import {
otpauthUrl,
} from '../auth/totp.ts';
import type { Db } from '../db/client.ts';
-import { extensions, userIdentities, users } from '../db/schema.ts';
+import { extensions, sessions, userIdentities, users } from '../db/schema.ts';
+import { endSessionUrl, oidcError } from '../sso/oidc.ts';
import { getSetting } from '../services/settings.ts';
import {
unauthorized,
@@ -55,12 +56,12 @@ import {
/** 12 §2: the user's password and connected accounts, from their user_identities rows. */
export const signInWays = (
u: { passwordHash: string | null },
- ids: { kind: 'oidc' | 'ldap'; email: string | null }[],
+ ids: { kind: 'oidc' | 'ldap'; email: string | null; groups: string[] }[],
): SignInWays => {
const oidc = ids.find((i) => i.kind === 'oidc');
return {
password: u.passwordHash !== null,
- oidc: oidc ? { email: oidc.email } : null,
+ oidc: oidc ? { email: oidc.email, groups: oidc.groups } : null,
ldap: ids.some((i) => i.kind === 'ldap'),
};
};
@@ -71,7 +72,11 @@ export async function toAuthUser(db: Db, u: CurrentUser): Promise {
.from(extensions)
.where(eq(extensions.userId, u.id));
const ids = await db
- .select({ kind: userIdentities.kind, email: userIdentities.email })
+ .select({
+ kind: userIdentities.kind,
+ email: userIdentities.email,
+ groups: userIdentities.groups,
+ })
.from(userIdentities)
.where(eq(userIdentities.userId, u.id));
return {
@@ -203,11 +208,15 @@ export async function authRoutes(app: FastifyInstance) {
});
/** The login page's choices (12 §2). Public: says nothing about any user. */
- app.get('/api/auth/methods', async (): Promise => ({
- mode: await getSetting(db, 'auth.mode'),
- ldap: false, // S4
- oidc: null, // S2
- }));
+ app.get('/api/auth/methods', async (): Promise => {
+ const mode = await getSetting(db, 'auth.mode');
+ const oidc = await getSetting(db, 'auth.oidc');
+ return {
+ mode,
+ ldap: false, // S4
+ oidc: oidc && mode !== 'password' ? { label: oidc.label } : null,
+ };
+ });
app.post('/api/auth/login', async (req, reply) => {
const { login, ...body } = LoginSchema.parse(req.body);
@@ -267,8 +276,24 @@ export async function authRoutes(app: FastifyInstance) {
});
app.post('/api/auth/logout', { preHandler: requireAuth }, async (req, reply) => {
+ const [sess] = req.sessionId
+ ? await db.select().from(sessions).where(eq(sessions.id, req.sessionId))
+ : [];
await deleteSession(db, req.cookies[COOKIE]!);
clearSessionCookie(reply);
+ // Signed in with OpenID: sign out there too (12 §3.5). A provider problem doesn't stop ours.
+ let redirect: string | null = null;
+ const oidc = sess?.idpIdTokenEnc ? await getSetting(db, 'auth.oidc') : null;
+ if (oidc)
+ try {
+ redirect = await endSessionUrl(
+ oidc,
+ app.config.PUBLIC_HOSTNAME,
+ decrypt(sess!.idpIdTokenEnc!),
+ );
+ } catch (err) {
+ req.log.warn({ event: 'auth.oidc', err: oidcError(err) }, 'no sign-out at the provider');
+ }
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
@@ -277,7 +302,7 @@ export async function authRoutes(app: FastifyInstance) {
entityId: req.user!.id,
summary: `${req.user!.email} logged out`,
});
- return { ok: true };
+ return redirect ? { ok: true, redirect } : { ok: true };
});
app.get('/api/auth/me', { preHandler: requireAuth }, async (req) => ({
diff --git a/apps/api/src/routes/oidc.ts b/apps/api/src/routes/oidc.ts
new file mode 100644
index 0000000..473e298
--- /dev/null
+++ b/apps/api/src/routes/oidc.ts
@@ -0,0 +1,279 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Sign in with OpenID Connect, connect and disconnect an OpenID account (12 §3.2, §3.3).
+import { and, eq, gt, isNull, notExists } from 'drizzle-orm';
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { z } from 'zod';
+import { decrypt, encrypt } from '../crypto/secrets.ts';
+import { sessions, userIdentities, users } from '../db/schema.ts';
+import { HttpError, notFound, unauthorized } from '../errors.ts';
+import { requireAdmin, requireAuth } from '../auth/guards.ts';
+import { isLoginBlocked, recordLoginFailure } from '../auth/login-limiter.ts';
+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 {
+ finishSignIn,
+ oidcError,
+ startSignIn,
+ 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';
+
+/** Same-site paths only: never send the browser to another origin. */
+const safeNext = (p: string | undefined) => (p && /^\/(?![/\\])/.test(p) ? p : undefined);
+
+const withParam = (path: string, k: string, v: string) =>
+ `${path}${path.includes('?') ? '&' : '?'}${k}=${v}`;
+
+export async function oidcRoutes(app: FastifyInstance) {
+ const { db } = app;
+ const host = app.config.PUBLIC_HOSTNAME;
+
+ /** The provider's settings, when OpenID is on (12 §1.1: not in `password` mode). */
+ async function enabled(): Promise {
+ const s = await getSetting(db, 'auth.oidc');
+ if (!s || (await getSetting(db, 'auth.mode')) === 'password') throw notFound();
+ return s;
+ }
+
+ app.get('/api/auth/oidc/start', async (req, reply) => {
+ const s = await enabled();
+ const q = z
+ .object({ next: z.string().max(500).optional(), connect: z.literal('1').optional() })
+ .parse(req.query);
+ let connectSessionId: string | null = null;
+ let next = safeNext(q.next);
+ if (q.connect) {
+ await requireAuth(req, reply);
+ if (!req.sessionId) throw unauthorized(); // an API token can't connect an account
+ connectSessionId = req.sessionId;
+ next ??= req.user!.role === 'admin' ? '/admin/account' : '/me/settings';
+ }
+ try {
+ const { url, pending } = await startSignIn(s, host, next ?? '/', connectSessionId);
+ reply.setCookie(OIDC_COOKIE, encrypt(JSON.stringify(pending)), {
+ httpOnly: true,
+ secure: true,
+ sameSite: 'lax',
+ path: COOKIE_PATH,
+ maxAge: 600,
+ });
+ return reply.redirect(url.href);
+ } catch (err) {
+ req.log.warn({ event: 'auth.oidc', err: oidcError(err) }, 'OpenID discovery failed');
+ return reply.redirect(
+ withParam(connectSessionId ? next! : '/login', 'sso_error', 'provider'),
+ );
+ }
+ });
+
+ // Not logged by Fastify: the query holds the authorization code (12 §1).
+ app.get('/api/auth/oidc/callback', { logLevel: 'silent' }, async (req, reply) => {
+ const s = await enabled();
+ let pending: PendingSignIn | null = null;
+ try {
+ pending = JSON.parse(decrypt(req.cookies[OIDC_COOKIE] ?? '')) as PendingSignIn;
+ } catch {
+ // missing or not ours: `expired` below
+ }
+ reply.clearCookie(OIDC_COOKIE, { path: COOKIE_PATH });
+ const connecting = pending?.connectSessionId ?? null;
+
+ const fail = async (code: string, reason: string, userId?: string) => {
+ if (!connecting) recordLoginFailure(req.ip, null);
+ req.log.warn({ event: 'auth.failed', ip: req.ip, reason }, 'OpenID sign-in failed');
+ await audit(db, {
+ actorUserId: userId,
+ actorIp: req.ip,
+ action: 'login_failed',
+ entityType: 'user',
+ entityId: userId,
+ summary: `Failed ${s.label} sign-in (${code}: ${reason.slice(0, 200)})`,
+ });
+ return reply.redirect(withParam(connecting ? pending!.next : '/login', 'sso_error', code));
+ };
+
+ if (!connecting && isLoginBlocked(req.ip, null)) return fail('too_many', 'rate limited');
+ if (!pending || pending.exp < Date.now()) return fail('expired', 'no sign-in in progress');
+ const q = req.query as Record;
+ if (typeof q.error === 'string')
+ return fail(q.error === 'access_denied' ? 'denied' : 'provider', `provider: ${q.error}`);
+
+ let r;
+ try {
+ r = await finishSignIn(s, new URL(`https://${host}${req.url}`), pending);
+ } catch (err) {
+ return fail('provider', oidcError(err));
+ }
+ const d = decide(s, r.claims);
+ const email = typeof r.claims.email === 'string' ? r.claims.email : null;
+ if (!d.allowed) return fail('not_allowed', `${email ?? r.subject} not in the allowed groups`);
+ const [owner] = await db
+ .select({ userId: userIdentities.userId })
+ .from(userIdentities)
+ .where(and(eq(userIdentities.kind, 'oidc'), eq(userIdentities.subject, r.subject)));
+
+ if (connecting) {
+ // Connect (12 §3.3): to the user of the session that asked, if it's still signed in.
+ const [sess] = await db
+ .select({ userId: sessions.userId, email: users.email })
+ .from(sessions)
+ .innerJoin(users, eq(users.id, sessions.userId))
+ .where(
+ and(
+ eq(sessions.id, connecting),
+ gt(sessions.expiresAt, new Date()),
+ eq(users.isActive, true),
+ ),
+ );
+ if (!sess) return fail('expired', 'the session that asked has ended');
+ if (owner && owner.userId !== sess.userId)
+ return fail('taken', `${email ?? r.subject} belongs to another user`, sess.userId);
+ await db.transaction(async (tx) => {
+ await tx
+ .delete(userIdentities)
+ .where(and(eq(userIdentities.userId, sess.userId), eq(userIdentities.kind, 'oidc')));
+ await tx.insert(userIdentities).values({
+ userId: sess.userId,
+ kind: 'oidc',
+ subject: r.subject,
+ email,
+ groups: d.groups,
+ });
+ });
+ await audit(db, {
+ actorUserId: sess.userId,
+ actorIp: req.ip,
+ action: 'sso_connected',
+ entityType: 'user',
+ entityId: sess.userId,
+ summary: `${sess.email} connected ${s.label} account ${email ?? r.subject}`,
+ });
+ return reply.redirect(withParam(pending.next, 'sso', 'connected'));
+ }
+
+ let userId = owner?.userId;
+ if (!userId) {
+ // A user without a password or an OpenID account yet, by verified email (12 §3.3 step 2).
+ const v = verifiedEmail(r.claims, s.trustEmail);
+ const [u] = v
+ ? await db
+ .select({ id: users.id })
+ .from(users)
+ .where(
+ and(
+ eq(users.email, v),
+ isNull(users.passwordHash),
+ notExists(
+ db
+ .select({ id: userIdentities.id })
+ .from(userIdentities)
+ .where(
+ and(eq(userIdentities.userId, users.id), eq(userIdentities.kind, 'oidc')),
+ ),
+ ),
+ ),
+ )
+ : [];
+ if (!u) return fail('not_connected', `${email ?? r.subject} isn't connected to a user`);
+ await db
+ .insert(userIdentities)
+ .values({ userId: u.id, kind: 'oidc', subject: r.subject, email, groups: d.groups });
+ await audit(db, {
+ actorUserId: u.id,
+ actorIp: req.ip,
+ action: 'sso_connected',
+ entityType: 'user',
+ entityId: u.id,
+ summary: `${v} connected ${s.label} by verified email`,
+ });
+ userId = u.id;
+ }
+ 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 db
+ .update(userIdentities)
+ .set({ email, groups: d.groups, lastUsedAt: new Date() })
+ .where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'oidc')));
+ const token = await createSession(
+ db,
+ userId,
+ req.ip,
+ req.headers['user-agent'] ?? null,
+ 'oidc',
+ { refreshToken: r.refreshToken, idToken: r.idToken },
+ );
+ setSessionCookie(reply, token);
+ await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, userId));
+ await audit(db, {
+ actorUserId: userId,
+ actorIp: req.ip,
+ action: 'login',
+ entityType: 'user',
+ entityId: userId,
+ summary: `${user!.email} signed in with ${s.label}`,
+ });
+ req.log.info({ event: 'auth.oidc', userId }, 'OpenID sign-in');
+ return reply.redirect(pending.next);
+ });
+
+ /** Disconnect (12 §3.3): refused when it would leave them no way in under the current mode. */
+ async function disconnect(req: FastifyRequest, reply: FastifyReply, userId: string) {
+ const [u] = await db.select().from(users).where(eq(users.id, userId));
+ if (!u) throw notFound('User not found');
+ const ids = await db
+ .select({ kind: userIdentities.kind })
+ .from(userIdentities)
+ .where(eq(userIdentities.userId, userId));
+ if (!ids.some((i) => i.kind === 'oidc')) throw notFound('No OpenID account is connected');
+ const mode = await getSetting(db, 'auth.mode');
+ const passwordWay = u.passwordHash !== null || ids.some((i) => i.kind === 'ldap');
+ if (!passwordWay || (mode === 'oidc' && u.role !== 'admin'))
+ throw new HttpError(409, 'last_way_in', 'That would leave no way to sign in');
+ await db
+ .delete(userIdentities)
+ .where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'oidc')));
+ await db.delete(sessions).where(and(eq(sessions.userId, userId), eq(sessions.method, 'oidc')));
+ await audit(db, {
+ actorUserId: req.user!.id,
+ actorIp: req.ip,
+ action: 'sso_disconnected',
+ entityType: 'user',
+ entityId: userId,
+ summary: `Disconnected the OpenID account of ${u.email}`,
+ });
+ return reply.send({ ok: true });
+ }
+
+ app.delete('/api/me/identities/oidc', { preHandler: requireAuth }, (req, reply) =>
+ disconnect(req, reply, req.user!.id),
+ );
+ app.delete('/api/users/:id/identities/oidc', { preHandler: requireAdmin }, (req, reply) =>
+ disconnect(req, reply, z.object({ id: z.uuid() }).parse(req.params).id),
+ );
+}
diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts
index e69a9b9..0b254e8 100644
--- a/apps/api/src/routes/settings.ts
+++ b/apps/api/src/routes/settings.ts
@@ -7,10 +7,14 @@ import {
AiEndpointSchema,
AiTtsEndpointSchema,
ContrackInputSchema,
+ OidcInputSchema,
SettingsSchemas,
SmtpInputSchema,
type AiTestResult,
+ type AuthMode,
type Destination,
+ type OidcInput,
+ type OidcTestResult,
type SettingKey,
type SettingsDto,
type Warning,
@@ -30,6 +34,7 @@ import { detectPublicIp } from '../jobs.ts';
import type { SendMail } from '../services/mail.ts';
import { AiError, assertLocalUrl, testEndpoint } from '../ai/client.ts';
import { forgetBook } from '../contacts/contrack.ts';
+import { assertIssuer, oidcConfig, oidcError, testOidc, type OidcSettings } from '../sso/oidc.ts';
declare module 'fastify' {
interface FastifyInstance {
@@ -47,9 +52,20 @@ const READ_ONLY = new Set([
]);
async function toDto(db: Db): Promise {
- const { smtp, 'contacts.contrack': ctk, ...rest } = await getAllSettings(db);
+ const { smtp, 'contacts.contrack': ctk, 'auth.oidc': oidc, ...rest } = await getAllSettings(db);
return {
...rest,
+ 'auth.oidc': oidc && {
+ label: oidc.label,
+ issuer: oidc.issuer,
+ clientId: oidc.clientId,
+ scopes: oidc.scopes,
+ groupsClaim: oidc.groupsClaim,
+ adminGroups: oidc.adminGroups,
+ allowedGroups: oidc.allowedGroups,
+ trustEmail: oidc.trustEmail,
+ hasClientSecret: oidc.clientSecretEnc !== null,
+ },
'contacts.contrack': ctk && {
url: ctk.url,
publicUrl: ctk.publicUrl,
@@ -100,6 +116,32 @@ async function checkRefs(db: Db, v: Record) {
export async function settingsRoutes(app: FastifyInstance) {
const { db } = app;
+
+ /** The stored form of `auth.oidc`: the secret is write-only (empty keeps the stored one). */
+ async function oidcFromInput(input: OidcInput | null): Promise {
+ if (!input) return null;
+ try {
+ assertIssuer(input.issuer);
+ } catch (err) {
+ throw badRequest((err as Error).message);
+ }
+ const old = await getSetting(db, 'auth.oidc');
+ return {
+ label: input.label,
+ issuer: input.issuer,
+ clientId: input.clientId,
+ clientSecretEnc: input.clientSecret
+ ? encrypt(input.clientSecret)
+ : input.clearClientSecret
+ ? null
+ : (old?.clientSecretEnc ?? null),
+ scopes: input.scopes,
+ groupsClaim: input.groupsClaim,
+ adminGroups: input.adminGroups,
+ allowedGroups: input.allowedGroups,
+ trustEmail: input.trustEmail,
+ };
+ }
app.addHook('preHandler', requireAdmin);
app.get('/api/settings', () => toDto(db));
@@ -142,13 +184,11 @@ export async function settingsRoutes(app: FastifyInstance) {
: (old?.companyTokenEnc ?? ''),
};
forgetBook(null);
+ } else if (key === 'auth.oidc') {
+ values[key] = await oidcFromInput(raw === null ? null : OidcInputSchema.parse(raw));
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
}
await checkRefs(db, values);
- // 12 §1.1: mixed and oidc need OpenID Connect set up first.
- // S2 makes this "auth.oidc set and its Test passed".
- if (values['auth.mode'] !== undefined && values['auth.mode'] !== 'password')
- throw badRequest('Set up OpenID Connect first');
for (const key of ['ai.llm', 'ai.stt', 'ai.tts'] as const) {
const ep = values[key] as { url: string } | null | undefined;
try {
@@ -159,6 +199,21 @@ export async function settingsRoutes(app: FastifyInstance) {
}
}
const before = await getAllSettings(db);
+ // 12 §1.1: mixed and oidc need a working OpenID Connect setup.
+ if ('auth.mode' in values || 'auth.oidc' in values) {
+ const mode = (values['auth.mode'] ?? before['auth.mode']) as AuthMode;
+ const oidc = (
+ 'auth.oidc' in values ? values['auth.oidc'] : before['auth.oidc']
+ ) as OidcSettings | null;
+ if (mode !== 'password') {
+ if (!oidc) throw badRequest('Set up OpenID Connect first');
+ try {
+ await oidcConfig(oidc);
+ } catch (err) {
+ throw badRequest(`OpenID Connect doesn't work: ${oidcError(err)}`);
+ }
+ }
+ }
// SIP ports may not land on another listener (Asterisk would fail to start the transport).
const c = app.config;
const [sip, tls] = [
@@ -236,6 +291,12 @@ export async function settingsRoutes(app: FastifyInstance) {
return { notified };
});
+ /** Settings → Sign-in → Test, from the unsaved form (12 §3.1). */
+ app.post('/api/settings/auth/oidc/test', async (req): Promise => {
+ const s = await oidcFromInput(OidcInputSchema.parse(req.body));
+ return testOidc(s!, app.config.PUBLIC_HOSTNAME);
+ });
+
/** The server's public IPv4, as an outside service sees it (only sent when asked). */
app.post('/api/settings/network/detect-public-ip', async () => {
const ip = await detectPublicIp();
diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts
index b90574b..4fe8491 100644
--- a/apps/api/src/routes/users.ts
+++ b/apps/api/src/routes/users.ts
@@ -17,10 +17,16 @@ import { hashPassword } from '../auth/passwords.ts';
import { deleteUserSessions } from '../auth/sessions.ts';
import { randomAlnum } from '../crypto/secrets.ts';
import { audit } from '../services/audit.ts';
+import { getSetting } from '../services/settings.ts';
import { signInWays } from './auth.ts';
type UserRow = typeof users.$inferSelect;
-type IdentityRow = { userId: string; kind: 'oidc' | 'ldap'; email: string | null };
+type IdentityRow = {
+ userId: string;
+ kind: 'oidc' | 'ldap';
+ email: string | null;
+ groups: string[];
+};
export const toUserDto = (
u: UserRow,
@@ -47,6 +53,7 @@ const identitiesOf = (db: Db, userId?: string): Promise =>
userId: userIdentities.userId,
kind: userIdentities.kind,
email: userIdentities.email,
+ groups: userIdentities.groups,
})
.from(userIdentities)
.where(userId ? eq(userIdentities.userId, userId) : undefined);
@@ -75,10 +82,10 @@ async function getUser(db: Db, id: string): Promise {
}
/**
- * Throws if removing `id` from the active admins would leave none with a password: the way in
- * when the identity provider is down (12 §1).
+ * Removing `id` from the active admins would leave none with a password: the way in when the
+ * identity provider is down (12 §1).
*/
-async function assertNotLastAdmin(db: Db, id: string) {
+export async function isLastAdmin(db: Db, id: string): Promise {
const [r] = await db
.select({ n: count() })
.from(users)
@@ -90,7 +97,11 @@ async function assertNotLastAdmin(db: Db, id: string) {
ne(users.id, id),
),
);
- if (!r || r.n === 0) throw conflict('This is the last active admin with a password');
+ return !r || r.n === 0;
+}
+
+async function assertNotLastAdmin(db: Db, id: string) {
+ if (await isLastAdmin(db, id)) throw conflict('This is the last active admin with a password');
}
export async function userRoutes(app: FastifyInstance) {
@@ -124,12 +135,17 @@ export async function userRoutes(app: FastifyInstance) {
app.post('/api/users', async (req, reply) => {
const body = UserCreateSchema.parse(req.body);
- const password = temporaryPassword();
+ // OpenID only (12 §3.3): no password; their first sign-in matches them by verified email.
+ const password = (await getSetting(db, 'auth.mode')) === 'oidc' ? null : temporaryPassword();
let created: UserRow;
try {
[created] = (await db
.insert(users)
- .values({ ...body, passwordHash: await hashPassword(password), mustChangePassword: true })
+ .values({
+ ...body,
+ passwordHash: password && (await hashPassword(password)),
+ mustChangePassword: password !== null,
+ })
.returning()) as [UserRow];
} catch (e) {
if (isUniqueViolation(e)) throw conflict('A user with this email already exists');
diff --git a/apps/api/src/sso/claims.test.ts b/apps/api/src/sso/claims.test.ts
new file mode 100644
index 0000000..37487a7
--- /dev/null
+++ b/apps/api/src/sso/claims.test.ts
@@ -0,0 +1,42 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import { describe, expect, it } from 'vitest';
+import { claimValues, decide, verifiedEmail } from './claims.ts';
+
+describe('OpenID claims (12 §3.4)', () => {
+ const keycloak = {
+ groups: ['/pbx-admins', 'pbx-users'],
+ realm_access: { roles: ['offline_access', 'pbx-admin'] },
+ resource_access: { selfphoneng: { roles: ['operator'] } },
+ 'urn:zitadel:iam:org:project:roles': { 'pbx-admin': { '123': 'example.com' } },
+ hd: 'example.com',
+ };
+
+ it('reads dotted paths, whole keys first', () => {
+ expect(claimValues(keycloak, 'groups')).toEqual(['/pbx-admins', 'pbx-users']);
+ expect(claimValues(keycloak, 'realm_access.roles')).toContain('pbx-admin');
+ expect(claimValues(keycloak, 'resource_access.selfphoneng.roles')).toEqual(['operator']);
+ expect(claimValues(keycloak, 'urn:zitadel:iam:org:project:roles')).toEqual(['pbx-admin']);
+ expect(claimValues(keycloak, 'hd')).toEqual(['example.com']);
+ expect(claimValues(keycloak, 'missing.path')).toEqual([]);
+ expect(claimValues({ 'a.b': ['x'], a: { b: ['y'] } }, 'a.b')).toEqual(['x']);
+ });
+
+ it('decides allowed and role, comparing names exactly', () => {
+ const rules = { groupsClaim: 'groups', adminGroups: [], allowedGroups: [] };
+ expect(decide(rules, keycloak)).toMatchObject({ allowed: true, admin: null });
+ expect(decide({ ...rules, adminGroups: ['pbx-admins'] }, keycloak).admin).toBe(false);
+ expect(decide({ ...rules, adminGroups: ['/pbx-admins'] }, keycloak).admin).toBe(true);
+ expect(decide({ ...rules, allowedGroups: ['sales'] }, keycloak).allowed).toBe(false);
+ expect(decide({ ...rules, allowedGroups: ['sales', 'pbx-users'] }, keycloak).allowed).toBe(
+ true,
+ );
+ });
+
+ it('takes an email only when verified, or trusted', () => {
+ expect(verifiedEmail({ email: 'a@x.com', email_verified: true }, false)).toBe('a@x.com');
+ expect(verifiedEmail({ email: 'a@x.com', email_verified: 'true' }, false)).toBeNull();
+ expect(verifiedEmail({ email: 'a@x.com' }, false)).toBeNull();
+ expect(verifiedEmail({ email: 'a@x.com' }, true)).toBe('a@x.com');
+ expect(verifiedEmail({}, true)).toBeNull();
+ });
+});
diff --git a/apps/api/src/sso/claims.ts b/apps/api/src/sso/claims.ts
new file mode 100644
index 0000000..0266111
--- /dev/null
+++ b/apps/api/src/sso/claims.ts
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// OpenID claims → groups, allowed, role (12 §3.4). Pure: no I/O.
+
+type Claims = Record;
+
+/**
+ * The value at a dotted path. The whole remaining path is tried as one key first, so claim
+ * names with dots or colons (Zitadel's "urn:zitadel:iam:org:project:roles") work, and
+ * "resource_access.selfphoneng.roles" walks down.
+ */
+export function claimAt(claims: unknown, path: string): unknown {
+ if (!claims || typeof claims !== 'object') return undefined;
+ const o = claims as Claims;
+ if (path in o) return o[path];
+ const dot = path.indexOf('.');
+ return dot < 0 ? undefined : claimAt(o[path.slice(0, dot)], path.slice(dot + 1));
+}
+
+/** A string → [it]; an array → its strings; an object → its keys (Zitadel's roles). */
+export function claimValues(claims: unknown, path: string): string[] {
+ const v = claimAt(claims, path);
+ if (typeof v === 'string') return [v];
+ if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string');
+ if (v && typeof v === 'object') return Object.keys(v);
+ return [];
+}
+
+export type OidcRules = { groupsClaim: string; adminGroups: string[]; allowedGroups: string[] };
+
+export type ClaimsDecision = {
+ groups: string[];
+ allowed: boolean;
+ /** null: the role is managed in SelfPhoneNG (no adminGroups). */
+ admin: boolean | null;
+};
+
+export function decide(rules: OidcRules, claims: Claims): ClaimsDecision {
+ const groups = claimValues(claims, rules.groupsClaim);
+ const any = (list: string[]) => list.some((g) => groups.includes(g));
+ return {
+ groups,
+ allowed: rules.allowedGroups.length === 0 || any(rules.allowedGroups),
+ admin: rules.adminGroups.length === 0 ? null : any(rules.adminGroups),
+ };
+}
+
+/** The email, when the provider says it's verified (or the admin trusts its emails, 12 §3.3). */
+export function verifiedEmail(claims: Claims, trustEmail: boolean): string | null {
+ const email = typeof claims.email === 'string' ? claims.email.trim() : '';
+ if (!email) return null;
+ return claims.email_verified === true || trustEmail ? email : null;
+}
diff --git a/apps/api/src/sso/oidc.ts b/apps/api/src/sso/oidc.ts
new file mode 100644
index 0000000..c899028
--- /dev/null
+++ b/apps/api/src/sso/oidc.ts
@@ -0,0 +1,187 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// OpenID Connect relying party (12 §3) on openid-client: discovery, PKCE + state + nonce, the
+// code grant, userinfo, test, signing out at the provider. No protocol code of our own.
+import * as oidc from 'openid-client';
+import type { OidcTestResult, SettingValue } from '@selfphoneng/shared';
+import { decrypt } from '../crypto/secrets.ts';
+
+export type OidcSettings = NonNullable>;
+
+/** http:// only for the fake provider in tests; a real provider is always https (12 §3.1). */
+const insecureOk = (u: URL) =>
+ process.env.NODE_ENV === 'test' && ['127.0.0.1', 'localhost'].includes(u.hostname);
+
+export function assertIssuer(issuer: string): void {
+ const u = new URL(issuer);
+ if (u.protocol !== 'https:' && !insecureOk(u)) throw new Error('The issuer must be https://');
+}
+
+export const redirectUri = (host: string) => `https://${host}/api/auth/oidc/callback`;
+export const postLogoutUri = (host: string) => `https://${host}/login`;
+
+let cached: { key: string; config: oidc.Configuration } | null = null;
+
+/**
+ * Discovers the provider once per settings value (a change or a failure means the next call
+ * discovers again) and picks client_secret_basic, else _post, else none for a public client.
+ */
+export async function oidcConfig(s: OidcSettings): Promise {
+ const key = JSON.stringify(s);
+ if (cached?.key === key) return cached.config;
+ const issuer = new URL(s.issuer);
+ assertIssuer(s.issuer);
+ const insecure = insecureOk(issuer);
+ const found = await oidc.discovery(issuer, s.clientId, undefined, oidc.None(), {
+ timeout: 5,
+ ...(insecure && { execute: [oidc.allowInsecureRequests] }),
+ });
+ const meta = found.serverMetadata();
+ const secret = s.clientSecretEnc ? decrypt(s.clientSecretEnc) : null;
+ const methods = meta.token_endpoint_auth_methods_supported ?? ['client_secret_basic'];
+ const auth = !secret
+ ? oidc.None()
+ : methods.includes('client_secret_basic') || !methods.includes('client_secret_post')
+ ? oidc.ClientSecretBasic(secret)
+ : oidc.ClientSecretPost(secret);
+ const config = new oidc.Configuration(meta, s.clientId, undefined, auth);
+ config.timeout = 5;
+ // Check ID token signatures too (by default TLS to the token endpoint is trusted instead).
+ oidc.enableNonRepudiationChecks(config);
+ if (insecure) oidc.allowInsecureRequests(config);
+ cached = { key, config };
+ return config;
+}
+
+/** What the sp_oidc cookie holds between start and callback (12 §3.2). */
+export type PendingSignIn = {
+ state: string;
+ nonce: string;
+ verifier: string;
+ next: string;
+ /** Connecting from My account: the session that asked (12 §3.3). */
+ connectSessionId: string | null;
+ exp: number;
+};
+
+export async function startSignIn(
+ s: OidcSettings,
+ host: string,
+ next: string,
+ connectSessionId: string | null,
+): Promise<{ url: URL; pending: PendingSignIn }> {
+ const config = await oidcConfig(s);
+ const pending: PendingSignIn = {
+ state: oidc.randomState(),
+ nonce: oidc.randomNonce(),
+ verifier: oidc.randomPKCECodeVerifier(),
+ next,
+ connectSessionId,
+ exp: Date.now() + 600_000,
+ };
+ const url = oidc.buildAuthorizationUrl(config, {
+ redirect_uri: redirectUri(host),
+ scope: s.scopes,
+ state: pending.state,
+ nonce: pending.nonce,
+ code_challenge: await oidc.calculatePKCECodeChallenge(pending.verifier),
+ code_challenge_method: 'S256',
+ });
+ return { url, pending };
+}
+
+export type SignInResult = {
+ /** " " (12 §2). */
+ subject: string;
+ /** The ID token's claims, then userinfo's on top. */
+ claims: Record;
+ idToken: string;
+ refreshToken: string | null;
+};
+
+/** Checks the provider's answer (state, PKCE, nonce, ID token) and reads userinfo. */
+export async function finishSignIn(
+ s: OidcSettings,
+ currentUrl: URL,
+ pending: PendingSignIn,
+): Promise {
+ const config = await oidcConfig(s);
+ const tokens = await oidc.authorizationCodeGrant(config, currentUrl, {
+ pkceCodeVerifier: pending.verifier,
+ expectedState: pending.state,
+ expectedNonce: pending.nonce,
+ idTokenExpected: true,
+ });
+ const id = tokens.claims()!;
+ let claims: Record = { ...id };
+ // Several providers put email or groups only in userinfo (12 §3.2 step 4).
+ if (config.serverMetadata().userinfo_endpoint)
+ claims = { ...claims, ...(await oidc.fetchUserInfo(config, tokens.access_token, id.sub)) };
+ return {
+ subject: `${id.iss} ${id.sub}`,
+ claims,
+ idToken: tokens.id_token!,
+ refreshToken: tokens.refresh_token ?? null,
+ };
+}
+
+/** RP-initiated logout (12 §3.5): where to send the browser, or null when the provider can't. */
+export async function endSessionUrl(
+ s: OidcSettings,
+ host: string,
+ idToken: string,
+): Promise {
+ const config = await oidcConfig(s);
+ if (!config.serverMetadata().end_session_endpoint) return null;
+ return oidc
+ .buildEndSessionUrl(config, {
+ id_token_hint: idToken,
+ post_logout_redirect_uri: postLogoutUri(host),
+ })
+ .toString();
+}
+
+/** A provider error as one line for the log and the admin (never a token). */
+export function oidcError(err: unknown): string {
+ if (err instanceof oidc.ResponseBodyError)
+ return `${err.error}${err.error_description ? `: ${err.error_description}` : ''}`;
+ if (err instanceof oidc.AuthorizationResponseError)
+ return `${err.error}${err.error_description ? `: ${err.error_description}` : ''}`;
+ return (err as Error).message;
+}
+
+/**
+ * Settings → Sign-in → Test (12 §3.1): discovery, then a code grant with a code that can't
+ * exist: `invalid_grant` means the client id and secret were accepted.
+ */
+export async function testOidc(s: OidcSettings, host: string): Promise {
+ try {
+ cached = null;
+ const config = await oidcConfig(s);
+ const meta = config.serverMetadata();
+ try {
+ await oidc.genericGrantRequest(config, 'authorization_code', {
+ code: 'selfphoneng-test-not-a-code',
+ redirect_uri: redirectUri(host),
+ code_verifier: oidc.randomPKCECodeVerifier(),
+ });
+ } catch (err) {
+ const code = err instanceof oidc.ResponseBodyError ? err.error : null;
+ if (code === 'invalid_client' || code === 'unauthorized_client')
+ return { ok: false, error: `The provider refused the client ID or secret (${code})` };
+ if (code !== 'invalid_grant' && code !== 'invalid_request')
+ return { ok: false, error: `The token endpoint answered: ${oidcError(err)}` };
+ }
+ return {
+ ok: true,
+ issuer: meta.issuer,
+ algorithms: meta.id_token_signing_alg_values_supported ?? [],
+ pkce: meta.code_challenge_methods_supported?.includes('S256') ?? false,
+ refreshTokens: meta.grant_types_supported?.includes('refresh_token') ?? false,
+ endSession: Boolean(meta.end_session_endpoint),
+ redirectUri: redirectUri(host),
+ postLogoutRedirectUri: postLogoutUri(host),
+ };
+ } catch (err) {
+ return { ok: false, error: oidcError(err) };
+ }
+}
diff --git a/apps/api/test/fake-oidc.ts b/apps/api/test/fake-oidc.ts
new file mode 100644
index 0000000..855a040
--- /dev/null
+++ b/apps/api/test/fake-oidc.ts
@@ -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();
+ const access = new Map();
+ const refresh = new Map();
+
+ 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((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((r) => server.close(() => r())),
+ };
+}
+
+async function body(req: IncomingMessage): Promise {
+ let s = '';
+ for await (const chunk of req) s += chunk;
+ return s;
+}
diff --git a/apps/api/test/oidc.test.ts b/apps/api/test/oidc.test.ts
new file mode 100644
index 0000000..abe2860
--- /dev/null
+++ b/apps/api/test/oidc.test.ts
@@ -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>;
+ let idp: Awaited>;
+ 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 }) =>
+ 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');
+ });
+ });
diff --git a/apps/api/test/sign-in.test.ts b/apps/api/test/sign-in.test.ts
index fc2b4e3..bc3e0cc 100644
--- a/apps/api/test/sign-in.test.ts
+++ b/apps/api/test/sign-in.test.ts
@@ -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 () => {
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 76dd4e1..213f37b 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Typed fetch wrappers (05 §1). Every request sends X-Requested-With (CSRF, 04 §2.2).
import type {
+ AuthMethods,
+ OidcInput,
+ OidcTestResult,
AboutDto,
SystemEventDto,
SystemStatus,
@@ -163,20 +166,25 @@ export const authApi = {
api<{ ok: true }>('POST', '/api/auth/totp/disable', { password }),
totpNewCodes: (password: string) =>
api<{ recoveryCodes: string[] }>('POST', '/api/auth/totp/recovery-codes', { password }),
- logout: () => api<{ ok: true }>('POST', '/api/auth/logout'),
+ logout: () => api<{ ok: true; redirect?: string }>('POST', '/api/auth/logout'),
+ methods: () => api('GET', '/api/auth/methods'),
+ /** 12 §3.3: disconnect my OpenID account. */
+ disconnectOidc: () => api<{ ok: true }>('DELETE', '/api/me/identities/oidc'),
changePassword: (currentPassword: string, newPassword: string) =>
api<{ ok: true }>('POST', '/api/auth/change-password', { currentPassword, newPassword }),
};
export const usersApi = {
list: () => api>('GET', '/api/users').then((r) => r.items),
+ /** temporaryPassword is null in OpenID-only mode (12 §3.3). */
create: (u: UserCreate) =>
- api<{ user: UserDto; temporaryPassword: string }>('POST', '/api/users', u),
+ api<{ user: UserDto; temporaryPassword: string | null }>('POST', '/api/users', u),
update: (id: string, u: UserUpdate) => api('PATCH', `/api/users/${id}`, u),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/users/${id}`),
resetPassword: (id: string) =>
api<{ temporaryPassword: string }>('POST', `/api/users/${id}/reset-password`),
resetTwoStep: (id: string) => api<{ ok: true }>('POST', `/api/users/${id}/reset-two-step`, {}),
+ disconnectOidc: (id: string) => api<{ ok: true }>('DELETE', `/api/users/${id}/identities/oidc`),
};
export const extensionsApi = {
@@ -237,8 +245,15 @@ export const settingsApi = {
'/api/settings/emergency/test',
),
warnings: () => api<{ items: Warning[] }>('GET', '/api/system/warnings').then((r) => r.items),
+ testOidc: (v: OidcInput) => api('POST', '/api/settings/auth/oidc/test', v),
};
+/** Sign out, and when the session came from OpenID, at the provider too (12 §3.5). */
+export async function signOut(): Promise {
+ const r = await authApi.logout().catch(() => null);
+ if (r?.redirect) window.location.assign(r.redirect);
+}
+
export const aboutApi = { get: () => api('GET', '/api/about') };
export const aiApi = {
diff --git a/apps/web/src/components/SignInCard.tsx b/apps/web/src/components/SignInCard.tsx
new file mode 100644
index 0000000..0c7842c
--- /dev/null
+++ b/apps/web/src/components/SignInCard.tsx
@@ -0,0 +1,99 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// My sign-in ways (12 §5): the password, and connecting or disconnecting an OpenID account.
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Link, useLocation, useSearchParams } from 'react-router';
+import { authApi } from '../api/client.ts';
+import { useMe } from '../auth/RequireAuth.tsx';
+import { Alert, Button } from './ui.tsx';
+
+/** What `?sso_error=` means (12 §3.2), for the login page and here. */
+export function ssoErrorText(code: string, label: string): string {
+ return (
+ {
+ expired: 'Sign-in took too long. Try again.',
+ denied: 'Sign-in was cancelled.',
+ not_allowed: `Your ${label} account isn't allowed to use the phone system. Ask your admin.`,
+ not_connected: `This ${label} account isn't connected to a phone system account. Sign in with your password and connect it in your settings, or ask your admin.`,
+ taken: `This ${label} account is already connected to another user.`,
+ disabled: 'Your account is disabled.',
+ too_many: 'Too many failed sign-ins. Wait 15 minutes and try again.',
+ }[code] ?? `${label} sign-in didn't work. Your admin can see why in the log.`
+ );
+}
+
+export const useAuthMethods = () =>
+ useQuery({ queryKey: ['auth-methods'], queryFn: authApi.methods, staleTime: 60_000 });
+
+export function SignInCard() {
+ const me = useMe();
+ const methods = useAuthMethods();
+ const qc = useQueryClient();
+ const loc = useLocation();
+ const [params] = useSearchParams();
+ const disconnect = useMutation({
+ mutationFn: authApi.disconnectOidc,
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['me'] }),
+ });
+ const ways = me.data?.signIn;
+ const oidc = methods.data?.oidc;
+ if (!ways) return null;
+ const error = params.get('sso_error');
+ return (
+
+ Sign-in
+ {params.get('sso') === 'connected' && (
+
+ Connected. You can now sign in with {oidc?.label ?? 'OpenID'}.
+
+ )}
+ {error && {ssoErrorText(error, oidc?.label ?? 'OpenID')}}
+
+
Password
+ {ways.password ? (
+
+ Change password
+
+ ) : (
+
+ You have no password here. An admin can give you one with Reset password.
+
+ )}
+
+ {oidc && (
+
+
{oidc.label}
+ {ways.oidc ? (
+ <>
+
+ Connected{ways.oidc.email ? ` as ${ways.oidc.email}` : ''}. You can sign in with{' '}
+ {oidc.label}
+ {ways.password ? ' or your password' : ''}.
+
+
+
+
+ {disconnect.error &&
{disconnect.error.message}}
+ >
+ ) : (
+ <>
+
+ Connect your {oidc.label} account to sign in with it as well.
+
+
+ >
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx
index f59005e..0cab7c1 100644
--- a/apps/web/src/pages/LoginPage.tsx
+++ b/apps/web/src/pages/LoginPage.tsx
@@ -7,6 +7,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router';
import { APP_NAME, LoginFields } from '@selfphoneng/shared';
import { ApiError, authApi } from '../api/client.ts';
import { homeFor } from '../auth/RequireAuth.tsx';
+import { ssoErrorText, useAuthMethods } from '../components/SignInCard.tsx';
import { Alert, Button, Field, inputClass } from '../components/ui.tsx';
export function AuthCard({ title, children }: { title: string; children: React.ReactNode }) {
@@ -34,13 +35,19 @@ export function LoginPage() {
const [code, setCode] = useState('');
const form = useForm({ resolver: zodResolver(LoginFields.omit({ totp: true })) });
const { errors, isSubmitting } = form.formState;
+ // 12 §1.1: the OpenID button, the password form, or both. OpenID only: the form is for admins.
+ const methods = useAuthMethods();
+ const oidc = methods.data?.oidc;
+ const [adminForm, setAdminForm] = useState(false);
+ const showForm = methods.data?.mode !== 'oidc' || adminForm;
+ const ssoError = params.get('sso_error');
+ const next = params.get('next');
const onSubmit = form.handleSubmit(async ({ login, password }) => {
setError(undefined);
try {
const user = await authApi.login(login, password, needCode ? code : undefined);
qc.setQueryData(['me'], user);
- const next = params.get('next');
// Only same-site paths: never redirect to another origin.
nav(next?.startsWith('/') && !next.startsWith('//') ? next : homeFor(user), {
replace: true,
@@ -56,7 +63,41 @@ export function LoginPage() {
return (
-
);
}
+
+const lines = (s: string) =>
+ s
+ .split(/[\n,]/)
+ .map((x) => x.trim())
+ .filter(Boolean);
+
+/** Sign-in (spec 12 §5): the mode, and the OpenID Connect provider. */
+function SignInTab({ s }: { s: SettingsDto }) {
+ const save = useSave();
+ const o = s['auth.oidc'];
+ const [mode, setMode] = useState(s['auth.mode']);
+ const [v, setV] = useState({
+ label: o?.label ?? 'Keycloak',
+ issuer: o?.issuer ?? '',
+ clientId: o?.clientId ?? 'selfphoneng',
+ clientSecret: '',
+ scopes: o?.scopes ?? 'openid email profile',
+ groupsClaim: o?.groupsClaim ?? 'groups',
+ adminGroups: o?.adminGroups.join('\n') ?? '',
+ allowedGroups: o?.allowedGroups.join('\n') ?? '',
+ trustEmail: o?.trustEmail ?? false,
+ });
+ const input = (): OidcInput => ({
+ ...v,
+ clientSecret: v.clientSecret || undefined,
+ adminGroups: lines(v.adminGroups),
+ allowedGroups: lines(v.allowedGroups),
+ });
+ const test = useMutation({ mutationFn: () => settingsApi.testOidc(input()) });
+ const set = (k: keyof typeof v) => (e: { target: { value: string } }) =>
+ setV({ ...v, [k]: e.target.value });
+ const host = window.location.host;
+ const uris = [
+ ['Redirect URI', `https://${host}/api/auth/oidc/callback`],
+ ['Post-logout redirect URI', `https://${host}/login`],
+ ] as const;
+ const text = (k: keyof typeof v, label: string, hint?: string, placeholder?: string) => (
+
+ {(a) => (
+
+ )}
+
+ );
+ const area = (k: 'adminGroups' | 'allowedGroups', label: string, hint: string) => (
+
+ {(a) => }
+
+ );
+ const modes = [
+ ['password', 'Passwords', 'Email and password, as set up here.'],
+ [
+ 'mixed',
+ 'Passwords or OpenID',
+ `Both. People connect their ${v.label} account in their settings. Disabling someone in ${v.label} ends their ${v.label} sign-ins, but their password keeps working until you disable them on Users.`,
+ ],
+ [
+ 'oidc',
+ 'OpenID only',
+ `Only ${v.label} decides who gets in. Admins can still sign in with a password (Admin sign-in on the login page), for when ${v.label} is down.`,
+ ],
+ ] as const;
+ return (
+
+ );
+}
diff --git a/apps/web/src/pages/admin/UserDrawer.tsx b/apps/web/src/pages/admin/UserDrawer.tsx
index 0d169aa..2ea255d 100644
--- a/apps/web/src/pages/admin/UserDrawer.tsx
+++ b/apps/web/src/pages/admin/UserDrawer.tsx
@@ -22,6 +22,7 @@ import { Dialog } from '../../components/Dialog.tsx';
import { MohOptions } from '../../components/MohOptions.tsx';
import { PhoneAppSetup } from '../../components/PhoneAppSetup.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
+import { useAuthMethods } from '../../components/SignInCard.tsx';
type Tab = 'general' | 'phone' | 'calling' | 'emergency' | 'credentials';
@@ -164,7 +165,8 @@ function GeneralTab({
role: v.role,
});
userId = r.user.id;
- created = { email: r.user.email, password: r.temporaryPassword };
+ // OpenID-only mode makes no password: they sign in with the provider (12 §3.3).
+ if (r.temporaryPassword) created = { email: r.user.email, password: r.temporaryPassword };
}
// The user's extension: create it, or renumber it.
if (v.number && !ext)
@@ -238,6 +240,7 @@ function GeneralTab({
Creates a new temporary password and signs them out everywhere.
{user.twoStep && }
+ {user.signIn.oidc && }
)}
@@ -584,6 +587,33 @@ function EmergencyTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void
);
}
+/** Their connected OpenID account (12 §5): the groups it sent, and Disconnect. */
+function ConnectedOidc({ user }: { user: UserDto }) {
+ const qc = useQueryClient();
+ const label = useAuthMethods().data?.oidc?.label ?? 'OpenID';
+ const disconnect = useMutation({
+ mutationFn: () => usersApi.disconnectOidc(user.id),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
+ });
+ const oidc = user.signIn.oidc!;
+ return (
+
+
+ {label}: {oidc.email ?? 'connected'}
+
+
+ Groups at the last sign-in: {oidc.groups.length ? oidc.groups.join(', ') : 'none'}
+
+
+
+
+ {disconnect.error &&
{disconnect.error.message}}
+
+ );
+}
+
/** For a user who lost their phone and recovery codes (08 §2.4). */
function ResetTwoStep({ user }: { user: UserDto }) {
const qc = useQueryClient();
diff --git a/apps/web/src/pages/admin/UsersPage.tsx b/apps/web/src/pages/admin/UsersPage.tsx
index 7c8faae..bb2544f 100644
--- a/apps/web/src/pages/admin/UsersPage.tsx
+++ b/apps/web/src/pages/admin/UsersPage.tsx
@@ -9,6 +9,7 @@ import { Alert, Button } from '../../components/ui.tsx';
import { UserDrawer } from './UserDrawer.tsx';
import { StatusDot } from '../../components/StatusDot.tsx';
import { useLive } from '../../live/store.ts';
+import { useAuthMethods } from '../../components/SignInCard.tsx';
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : 'Never');
@@ -129,6 +130,7 @@ function UsersTable({
onDelete: (u: UserDto) => void;
}) {
const live = useLive((s) => s.extensions);
+ const oidcLabel = useAuthMethods().data?.oidc?.label ?? 'OpenID';
if (users.length === 0) return No users yet.
;
const ext = (u: UserDto) =>
u.extension ? (
@@ -153,7 +155,7 @@ function UsersTable({
);
// 12 §5: every way this user can sign in.
const ways = (u: UserDto) =>
- [u.signIn.password && 'Password', u.signIn.oidc && 'OpenID', u.signIn.ldap && 'Directory']
+ [u.signIn.password && 'Password', u.signIn.oidc && oidcLabel, u.signIn.ldap && 'Directory']
.filter(Boolean)
.join(', ') || '–';
const actions = (u: UserDto) => (
diff --git a/apps/web/src/pages/user/MePage.tsx b/apps/web/src/pages/user/MePage.tsx
index 2baee2b..f6a315a 100644
--- a/apps/web/src/pages/user/MePage.tsx
+++ b/apps/web/src/pages/user/MePage.tsx
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { Link, NavLink, Route, Routes, useNavigate } from 'react-router';
import type { AuthUser } from '@selfphoneng/shared';
-import { authApi, meApi } from '../../api/client.ts';
+import { meApi, signOut } from '../../api/client.ts';
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Alert, Brand, Button } from '../../components/ui.tsx';
import { subscribeLive, useLive } from '../../live/store.ts';
@@ -57,7 +57,7 @@ export function MePage({ user }: { user: AuthUser }) {