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. +

+
+ {/* A full page load: the browser goes to the provider and comes back. */} + + Connect {oidc.label} + +
+ + )} +
+ )} +
+ ); +} 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 ( -
+ {ssoError && ( +
+ {ssoErrorText(ssoError, oidc?.label ?? 'OpenID')} +
+ )} + {oidc && ( +
+ {/* A full page load: the browser goes to the provider and comes back signed in. */} + + Sign in with {oidc.label} + + {showForm && ( +

+ or with your password +

+ )} +
+ )} + {!showForm && ( + + )} + {error && {error}} {(a) => ( diff --git a/apps/web/src/pages/SetupPage.tsx b/apps/web/src/pages/SetupPage.tsx index 96d5121..3a6cbdf 100644 --- a/apps/web/src/pages/SetupPage.tsx +++ b/apps/web/src/pages/SetupPage.tsx @@ -536,7 +536,7 @@ function UsersStep({ next }: { next: () => void }) { name: r.name, email: r.email, number: number ?? '–', - password: temporaryPassword, + password: temporaryPassword ?? '(signs in with OpenID)', }); setCreated([...out]); } diff --git a/apps/web/src/pages/admin/AccountPage.tsx b/apps/web/src/pages/admin/AccountPage.tsx index 1fc85a8..2343d94 100644 --- a/apps/web/src/pages/admin/AccountPage.tsx +++ b/apps/web/src/pages/admin/AccountPage.tsx @@ -1,22 +1,20 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -// My account (admins): password, and two-step sign-in (TOTP, 08 §2.4) with recovery codes. +// My account (admins): password, OpenID (12 §5), and two-step sign-in (TOTP, 08 §2.4) with recovery codes. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; -import { Link } from 'react-router'; import { authApi } from '../../api/client.ts'; +import { useMe } from '../../auth/RequireAuth.tsx'; +import { SignInCard } from '../../components/SignInCard.tsx'; import { Alert, Button, Field, inputClass } from '../../components/ui.tsx'; export function AccountPage() { + const me = useMe(); return (

My account

-
-

Password

- - Change password - -
- + + {/* Two-step sign-in guards the password, so it needs one (12 §1). */} + {me.data?.signIn.password && }
); } diff --git a/apps/web/src/pages/admin/AdminLayout.tsx b/apps/web/src/pages/admin/AdminLayout.tsx index f60f390..f275275 100644 --- a/apps/web/src/pages/admin/AdminLayout.tsx +++ b/apps/web/src/pages/admin/AdminLayout.tsx @@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { Navigate, NavLink, Outlet, useNavigate } from 'react-router'; import type { AuthUser } from '@selfphoneng/shared'; -import { authApi, systemApi } from '../../api/client.ts'; +import { signOut, systemApi } from '../../api/client.ts'; import { ThemeToggle } from '../../components/ThemeToggle.tsx'; import { Brand, Button } from '../../components/ui.tsx'; import { subscribeLive, useLive } from '../../live/store.ts'; @@ -43,7 +43,7 @@ export function AdminLayout({ user }: { user: AuthUser }) { const setup = useQuery({ queryKey: ['setup'], queryFn: systemApi.setupStatus }); async function logout() { - await authApi.logout().catch(() => undefined); + await signOut(); // may leave for the OpenID provider's sign-out page (12 §3.5) qc.clear(); nav('/login', { replace: true }); } diff --git a/apps/web/src/pages/admin/SettingsPage.tsx b/apps/web/src/pages/admin/SettingsPage.tsx index c39d7f6..65ed37a 100644 --- a/apps/web/src/pages/admin/SettingsPage.tsx +++ b/apps/web/src/pages/admin/SettingsPage.tsx @@ -8,6 +8,8 @@ import { WEBHOOK_EVENTS, formatNumber, type AiTestResult, + type AuthMode, + type OidcInput, type SettingsDto, type WebhookDto, type WebhookEvent, @@ -38,6 +40,7 @@ const TABS = [ { id: 'tokens', label: 'API tokens' }, { id: 'ai', label: 'AI' }, { id: 'contacts', label: 'Contacts' }, + { id: 'sign-in', label: 'Sign-in' }, ] as const; export function SettingsPage() { @@ -74,6 +77,7 @@ export function SettingsPage() { {tab === 'tokens' && } {settings.data && tab === 'ai' && } {settings.data && tab === 'contacts' && } + {settings.data && tab === 'sign-in' && } ); } @@ -1361,3 +1365,208 @@ export function ContactsTab({ s }: { s: SettingsDto }) { ); } + +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) =>