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

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

No files matched your search

+1
View File
@@ -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"
+2
View File
@@ -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);
+2 -2
View File
@@ -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');
}
+6 -4
View File
@@ -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]);
}
}
+12 -2
View File
@@ -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<string> {
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)));
}
+3 -3
View File
@@ -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)
+35 -10
View File
@@ -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<AuthUser> {
.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<AuthMethods> => ({
mode: await getSetting(db, 'auth.mode'),
ldap: false, // S4
oidc: null, // S2
}));
app.get('/api/auth/methods', async (): Promise<AuthMethods> => {
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) => ({
+279
View File
@@ -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<OidcSettings> {
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<string, unknown>;
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),
);
}
+66 -5
View File
@@ -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<string>([
]);
async function toDto(db: Db): Promise<SettingsDto> {
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<string, unknown>) {
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<OidcSettings | null> {
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<OidcTestResult> => {
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();
+23 -7
View File
@@ -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<IdentityRow[]> =>
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<UserRow> {
}
/**
* 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<boolean> {
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');
+42
View File
@@ -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();
});
});
+52
View File
@@ -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<string, unknown>;
/**
* 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;
}
+187
View File
@@ -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<SettingValue<'auth.oidc'>>;
/** 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<oidc.Configuration> {
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 = {
/** "<iss> <sub>" (12 §2). */
subject: string;
/** The ID token's claims, then userinfo's on top. */
claims: Record<string, unknown>;
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<SignInResult> {
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<string, unknown> = { ...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<string | null> {
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<OidcTestResult> {
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) };
}
}
+167
View File
@@ -0,0 +1,167 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// A small OpenID provider for tests (12 §6): discovery, JWKS, authorize, token (code + refresh,
// PKCE S256, client_secret_basic), userinfo, end session. `tamper` breaks one thing at a time.
import { createHash, generateKeyPairSync, randomBytes, sign, type KeyObject } from 'node:crypto';
import { createServer, type IncomingMessage } from 'node:http';
import type { AddressInfo } from 'node:net';
export type FakeUser = {
sub: string;
email?: string;
email_verified?: boolean;
/** Only in userinfo, as Keycloak's mappers and Authelia send them. */
groups?: string[];
};
const b64u = (b: Buffer | string) => Buffer.from(b).toString('base64url');
export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
const pair =
alg === 'RS256'
? generateKeyPairSync('rsa', { modulusLength: 2048 })
: generateKeyPairSync('ec', { namedCurve: 'P-256' });
const other = generateKeyPairSync('rsa', { modulusLength: 2048 }); // signs "bad signature" tokens
const clientId = 'selfphoneng';
const clientSecret = 'fake-secret';
const st = {
user: { sub: 'u-1', email: 'ann@example.com', email_verified: true } as FakeUser,
tamper: {} as {
nonce?: boolean;
iss?: boolean;
aud?: boolean;
expired?: boolean;
signature?: boolean;
userinfoSub?: boolean;
},
/** false: refresh tokens are refused, as for a user disabled at the provider. */
refreshOk: true,
refreshes: 0,
};
const codes = new Map<string, { nonce: string; challenge: string; user: FakeUser }>();
const access = new Map<string, FakeUser>();
const refresh = new Map<string, FakeUser>();
const server = createServer(async (req, res) => {
const url = new URL(req.url!, issuer);
const json = (code: number, body: unknown) => {
res.writeHead(code, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
if (url.pathname === '/.well-known/openid-configuration')
return json(200, {
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
userinfo_endpoint: `${issuer}/userinfo`,
jwks_uri: `${issuer}/jwks`,
end_session_endpoint: `${issuer}/logout`,
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: [alg],
code_challenge_methods_supported: ['S256'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['client_secret_basic'],
});
if (url.pathname === '/jwks')
return json(200, {
keys: [{ ...pair.publicKey.export({ format: 'jwk' }), kid: 'k1', alg, use: 'sig' }],
});
if (url.pathname === '/authorize') {
const p = url.searchParams;
if (p.get('client_id') !== clientId || p.get('code_challenge_method') !== 'S256')
return json(400, { error: 'invalid_request' });
const code = b64u(randomBytes(16));
codes.set(code, {
nonce: p.get('nonce')!,
challenge: p.get('code_challenge')!,
user: { ...st.user },
});
const back = new URL(p.get('redirect_uri')!);
back.searchParams.set('code', code);
back.searchParams.set('state', p.get('state')!);
res.writeHead(302, { location: back.href });
return res.end();
}
if (url.pathname === '/token' && req.method === 'POST') {
// client_secret_basic: both parts form-encoded before base64 (RFC 6749 §2.3.1).
const [id, secret] = Buffer.from(
(req.headers.authorization ?? '').replace(/^Basic /, ''),
'base64',
)
.toString()
.split(':')
.map((x) => decodeURIComponent(x.replace(/\+/g, ' ')));
if (id !== clientId || secret !== clientSecret) return json(401, { error: 'invalid_client' });
const form = new URLSearchParams(await body(req));
if (form.get('grant_type') === 'refresh_token') {
const user = refresh.get(form.get('refresh_token')!);
st.refreshes++;
if (!user || !st.refreshOk) return json(400, { error: 'invalid_grant' });
return json(200, tokens(user, null));
}
const c = codes.get(form.get('code') ?? '');
codes.delete(form.get('code') ?? '');
const verifier = form.get('code_verifier') ?? '';
if (!c || b64u(createHash('sha256').update(verifier).digest()) !== c.challenge)
return json(400, { error: 'invalid_grant' });
return json(200, tokens(c.user, c.nonce));
}
if (url.pathname === '/userinfo') {
const user = access.get((req.headers.authorization ?? '').replace(/^Bearer /, ''));
if (!user) return json(401, { error: 'invalid_token' });
return json(200, { ...user, ...(st.tamper.userinfoSub && { sub: 'someone-else' }) });
}
if (url.pathname === '/logout') return json(200, {});
json(404, {});
});
function tokens(user: FakeUser, nonce: string | null) {
const now = Math.floor(Date.now() / 1000);
const t = st.tamper;
const claims = {
iss: t.iss ? 'https://evil.example' : issuer,
sub: user.sub,
aud: t.aud ? 'another-client' : clientId,
iat: now,
exp: t.expired ? now - 3600 : now + 300,
...(nonce && { nonce: t.nonce ? 'wrong-nonce' : nonce }),
...(user.email && { email: user.email, email_verified: user.email_verified }),
};
const at = b64u(randomBytes(16));
const rt = b64u(randomBytes(16));
access.set(at, user);
refresh.set(rt, user);
return {
access_token: at,
token_type: 'Bearer',
expires_in: 300,
refresh_token: rt,
id_token: jwt(claims, t.signature ? other.privateKey : pair.privateKey),
};
}
function jwt(claims: object, key: KeyObject) {
const data = `${b64u(JSON.stringify({ alg, kid: 'k1', typ: 'JWT' }))}.${b64u(JSON.stringify(claims))}`;
const sig =
alg === 'RS256'
? sign('sha256', Buffer.from(data), key)
: sign('sha256', Buffer.from(data), { key, dsaEncoding: 'ieee-p1363' });
return `${data}.${b64u(sig)}`;
}
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
const issuer = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
return {
issuer,
clientId,
clientSecret,
st,
close: () => new Promise<void>((r) => server.close(() => r())),
};
}
async function body(req: IncomingMessage): Promise<string> {
let s = '';
for await (const chunk of req) s += chunk;
return s;
}
+339
View File
@@ -0,0 +1,339 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 S2: OpenID Connect sign-in, connect, disconnect, modes and signing out, against a fake
// provider (test/fake-oidc.ts).
import { and, eq } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { encrypt } from '../src/crypto/secrets.ts';
import { sessions, userIdentities, users } from '../src/db/schema.ts';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { setSetting } from '../src/services/settings.ts';
import type { OidcSettings } from '../src/sso/oidc.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
import { startFakeOidc } from './fake-oidc.ts';
for (const alg of ['RS256', 'ES256'] as const)
describe.skipIf(!hasTestDb)(`OpenID Connect (12 S2, ${alg})`, () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let idp: Awaited<ReturnType<typeof startFakeOidc>>;
let admin: string;
let oidcSettings: OidcSettings;
beforeAll(async () => {
idp = await startFakeOidc(alg);
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
oidcSettings = {
label: 'Keycloak',
issuer: idp.issuer,
clientId: idp.clientId,
clientSecretEnc: encrypt(idp.clientSecret),
scopes: 'openid email profile',
groupsClaim: 'groups',
adminGroups: [],
allowedGroups: [],
trustEmail: false,
};
await setSetting(t.db, 'auth.oidc', oidcSettings);
await setSetting(t.db, 'auth.mode', 'mixed');
});
afterAll(async () => {
await t?.close();
await idp?.close();
});
beforeEach(() => {
resetLoginLimiter();
idp.st.tamper = {};
idp.st.user = { sub: 'u-1', email: 'ann@example.com', email_verified: true };
});
/** The browser's round trip: start → provider → callback. */
async function flow(cookie?: string, query = '') {
const start = await t.app.inject({
url: `/api/auth/oidc/start${query}`,
headers: cookie ? { cookie } : {},
});
expect(start.statusCode).toBe(302);
const pending = start.cookies.find((c) => c.name === 'sp_oidc');
if (!pending) return { start, callback: null };
const atIdp = await fetch(start.headers.location as string, { redirect: 'manual' });
const back = new URL(atIdp.headers.get('location')!);
expect(back.origin).toBe('https://pbx.test');
const callback = await t.app.inject({
url: back.pathname + back.search,
headers: { cookie: [`sp_oidc=${pending.value}`, cookie].filter(Boolean).join('; ') },
});
return { start, callback };
}
const session = (r: { cookies: { name: string; value: string }[] }) => {
const c = r.cookies.find((x) => x.name === 'sp_session');
return c ? `sp_session=${c.value}` : null;
};
const errorOf = (r: { headers: Record<string, unknown> }) =>
new URL(r.headers.location as string, 'https://pbx.test').searchParams.get('sso_error');
it('methods show the button in mixed mode', async () => {
const r = await t.app.inject({ url: '/api/auth/methods' });
expect(r.json()).toEqual({ mode: 'mixed', ldap: false, oidc: { label: 'Keycloak' } });
});
it('starts with PKCE S256, state and nonce, and a short-lived cookie', async () => {
const r = await t.app.inject({ url: '/api/auth/oidc/start?next=/me/calls' });
const to = new URL(r.headers.location as string);
expect(to.origin).toBe(idp.issuer);
for (const p of ['state', 'nonce', 'code_challenge'])
expect(to.searchParams.get(p)).toBeTruthy();
expect(to.searchParams.get('code_challenge_method')).toBe('S256');
expect(to.searchParams.get('redirect_uri')).toBe('https://pbx.test/api/auth/oidc/callback');
expect(r.cookies.find((c) => c.name === 'sp_oidc')).toMatchObject({
httpOnly: true,
secure: true,
sameSite: 'Lax',
path: '/api/auth/oidc',
maxAge: 600,
});
});
it('an account nobody connected is refused, and no user is made', async () => {
idp.st.user = { sub: 'stranger', email: 'stranger@example.com', email_verified: true };
const { callback } = await flow();
expect(errorOf(callback!)).toBe('not_connected');
expect(session(callback!)).toBeNull();
const [u] = await t.db.select().from(users).where(eq(users.email, 'stranger@example.com'));
expect(u).toBeUndefined();
});
it('a user with a password is never matched by email, even verified', async () => {
await t.addUser('ann@example.com', 'ann-password-12');
const { callback } = await flow();
expect(errorOf(callback!)).toBe('not_connected');
});
it('connects from My account, then signs in either way', async () => {
const ann = await t.login('ann@example.com', 'ann-password-12');
const { callback } = await flow(ann, '?connect=1');
expect(callback!.headers.location).toBe('/me/settings?sso=connected');
const me = await t.app.inject({ url: '/api/auth/me', headers: { cookie: ann } });
expect(me.json().user.signIn).toEqual({
password: true,
oidc: { email: 'ann@example.com', groups: [] },
ldap: false,
});
const signIn = await flow(undefined, '?next=/me/calls');
expect(signIn.callback!.headers.location).toBe('/me/calls');
const cookie = session(signIn.callback!);
expect(cookie).toBeTruthy();
const [s] = await t.db
.select()
.from(sessions)
.where(eq(sessions.method, 'oidc'))
.orderBy(sessions.createdAt);
expect(s!.idpRefreshEnc).toBeTruthy();
expect(s!.idpIdTokenEnc).toBeTruthy();
// …and the password still works.
expect(await t.login('ann@example.com', 'ann-password-12')).toBeTruthy();
});
it("refuses to connect an account that's someone else's", async () => {
await t.addUser('bob@example.com', 'bob-password-12');
const bob = await t.login('bob@example.com', 'bob-password-12');
const { callback } = await flow(bob, '?connect=1'); // the fake still signs in as u-1 (Ann)
expect(errorOf(callback!)).toBe('taken');
});
it('matches a user without a password once, by verified email', async () => {
const [u] = await t.db
.insert(users)
.values({ email: 'cara@example.com', displayName: 'Cara', passwordHash: null })
.returning();
idp.st.user = { sub: 'u-cara', email: 'cara@example.com', email_verified: false };
expect(errorOf((await flow()).callback!)).toBe('not_connected');
idp.st.user.email_verified = true;
const { callback } = await flow();
expect(session(callback!)).toBeTruthy();
const ids = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u!.id));
expect(ids).toMatchObject([{ kind: 'oidc', subject: `${idp.issuer} u-cara` }]);
});
it('trustEmail matches an unverified email', async () => {
await t.db
.insert(users)
.values({ email: 'dee@example.com', displayName: 'Dee', passwordHash: null });
idp.st.user = { sub: 'u-dee', email: 'dee@example.com' };
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, trustEmail: true });
try {
expect(session((await flow()).callback!)).toBeTruthy();
} finally {
await setSetting(t.db, 'auth.oidc', oidcSettings);
}
});
for (const [name, tamper] of [
['a wrong nonce', { nonce: true }],
['a wrong issuer', { iss: true }],
['a wrong audience', { aud: true }],
['an expired ID token', { expired: true }],
['a bad signature', { signature: true }],
['a userinfo subject that differs', { userinfoSub: true }],
] as const)
it(`refuses ${name}`, async () => {
idp.st.tamper = tamper;
const { callback } = await flow();
expect(errorOf(callback!)).toBe('provider');
expect(session(callback!)).toBeNull();
});
it('refuses a callback without our cookie, or with another state', async () => {
const r = await t.app.inject({ url: '/api/auth/oidc/callback?code=x&state=y' });
expect(errorOf(r)).toBe('expired');
const start = await t.app.inject({ url: '/api/auth/oidc/start' });
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
const atIdp = await fetch(start.headers.location as string, { redirect: 'manual' });
const back = new URL(atIdp.headers.get('location')!);
back.searchParams.set('state', 'forged');
const forged = await t.app.inject({
url: back.pathname + back.search,
headers: { cookie: `sp_oidc=${pending.value}` },
});
expect(errorOf(forged)).toBe('provider');
});
it('the provider saying access_denied is "denied"', async () => {
const start = await t.app.inject({ url: '/api/auth/oidc/start' });
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
const r = await t.app.inject({
url: '/api/auth/oidc/callback?error=access_denied&state=x',
headers: { cookie: `sp_oidc=${pending.value}` },
});
expect(errorOf(r)).toBe('denied');
});
it('groups: allowedGroups refuses, adminGroups sets the role', async () => {
idp.st.user = { sub: 'u-1', email: 'ann@example.com', groups: ['pbx-admins'] };
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, allowedGroups: ['pbx-users'] });
expect(errorOf((await flow()).callback!)).toBe('not_allowed');
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, adminGroups: ['pbx-admins'] });
try {
const { callback } = await flow();
const me = await t.app.inject({
url: '/api/auth/me',
headers: { cookie: session(callback!)! },
});
expect(me.json().user.role).toBe('admin');
const [id] = await t.db
.select()
.from(userIdentities)
.where(eq(userIdentities.subject, `${idp.issuer} u-1`));
expect(id!.groups).toEqual(['pbx-admins']);
idp.st.user.groups = [];
await flow();
const [ann] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
expect(ann!.role).toBe('user');
} finally {
await setSetting(t.db, 'auth.oidc', oidcSettings);
}
});
it('signing out of an OpenID session sends the browser to the provider', async () => {
const { callback } = await flow();
const out = await t.app.inject({
method: 'POST',
url: '/api/auth/logout',
headers: { ...H, cookie: session(callback!)! },
});
const to = new URL(out.json().redirect);
expect(to.href.startsWith(`${idp.issuer}/logout`)).toBe(true);
expect(to.searchParams.get('id_token_hint')).toBeTruthy();
expect(to.searchParams.get('post_logout_redirect_uri')).toBe('https://pbx.test/login');
// A password session signs out here only.
const pw = await t.login('ann@example.com', 'ann-password-12');
const out2 = await t.app.inject({
method: 'POST',
url: '/api/auth/logout',
headers: { ...H, cookie: pw },
});
expect(out2.json()).toEqual({ ok: true });
});
it('OpenID-only mode: passwords for admins only; disconnect keeps a way in', async () => {
await setSetting(t.db, 'auth.mode', 'oidc');
try {
const r = await t.app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { login: 'ann@example.com', password: 'ann-password-12' },
});
expect(r.json().error.code).toBe('password_sign_in_off');
const { callback } = await flow();
const ann = session(callback!)!;
const d = await t.app.inject({
method: 'DELETE',
url: '/api/me/identities/oidc',
headers: { ...H, cookie: ann },
});
expect(d.statusCode).toBe(409);
expect(d.json().error.code).toBe('last_way_in');
} finally {
await setSetting(t.db, 'auth.mode', 'mixed');
}
// Mixed: Ann still has her password, so she may disconnect; OpenID sessions end.
const { callback } = await flow();
const ann = session(callback!)!;
const [row] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
const d = await t.app.inject({
method: 'DELETE',
url: `/api/users/${row!.id}/identities/oidc`,
headers: { ...H, cookie: admin },
});
expect(d.statusCode).toBe(200);
const left = await t.db
.select()
.from(sessions)
.where(and(eq(sessions.userId, row!.id), eq(sessions.method, 'oidc')));
expect(left).toEqual([]);
expect(
(await t.app.inject({ url: '/api/auth/me', headers: { cookie: ann } })).statusCode,
).toBe(401);
});
it('settings: Test, the secret write-only, and the mode needing a working provider', async () => {
const form = {
label: 'Keycloak',
issuer: idp.issuer,
clientId: idp.clientId,
scopes: 'openid email profile',
groupsClaim: 'groups',
adminGroups: [],
allowedGroups: [],
trustEmail: false,
};
const req = (method: 'GET' | 'PATCH' | 'POST', url: string, payload?: object) =>
t.app.inject({ method, url, payload, headers: { ...H, cookie: admin } });
const ok = await req('POST', '/api/settings/auth/oidc/test', form);
expect(ok.json()).toMatchObject({
ok: true,
pkce: true,
refreshTokens: true,
endSession: true,
});
const bad = await req('POST', '/api/settings/auth/oidc/test', {
...form,
clientSecret: 'wrong',
});
expect(bad.json()).toMatchObject({ ok: false });
expect((await req('PATCH', '/api/settings', { 'auth.oidc': form })).statusCode).toBe(200);
const got = (await req('GET', '/api/settings')).json()['auth.oidc'];
expect(got).toMatchObject({ hasClientSecret: true, label: 'Keycloak' });
expect(JSON.stringify(got)).not.toContain('clientSecretEnc');
const broken = await req('PATCH', '/api/settings', {
'auth.oidc': { ...form, issuer: 'http://127.0.0.1:1' },
});
expect(broken.statusCode).toBe(400);
const remote = await req('PATCH', '/api/settings', {
'auth.oidc': { ...form, issuer: 'http://kc.example.com/realms/x' },
});
expect(remote.json().error.message).toContain('https');
});
});
+5 -1
View File
@@ -57,7 +57,11 @@ describe.skipIf(!hasTestDb)('sign-in (12 S1)', () => {
.insert(userIdentities)
.values({ userId: u.id, kind: 'oidc', subject: 'https://kc/realms/x abc', email: 'l@kc' });
const r = await t.app.inject({ url: `/api/users/${u.id}`, headers: { cookie: admin } });
expect(r.json().signIn).toEqual({ password: true, oidc: { email: 'l@kc' }, ldap: false });
expect(r.json().signIn).toEqual({
password: true,
oidc: { email: 'l@kc', groups: [] },
ldap: false,
});
});
it('auth.mode can only be password until OpenID Connect is set up', async () => {