Feat/sso #3

Merged
iamdoubz merged 7 commits from feat/sso into main 2026-10-26 09:52:50 -05:00
69 changed files with 10193 additions and 162 deletions

No files matched your search

+4
View File
@@ -15,6 +15,7 @@ APP_UID=1000
APP_GID=1000
LOG_LEVEL=info
TRUST_PROXY=true # true when behind Caddy/nginx
RATE_LIMIT_PER_MINUTE=600 # requests per minute per client IP, every route; 0 = off
RTP_PORT_START=10000 # call audio (UDP), both ends included: open this range on the
RTP_PORT_END=20000 # firewall and router. Two ports per call; 200 is plenty for a small office
# Local AI (admin guide: AI). Starting values for Settings → AI; that page wins once saved.
@@ -26,3 +27,6 @@ RTP_PORT_END=20000 # firewall and router. Two ports per call;
#AI_TTS_URL=http://127.0.0.1:8000/v1
#AI_TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX
#AI_TTS_VOICE=af_heart
# A private CA your identity provider or directory uses (admin guide: Sign-in). Put the PEM file
# in data/tls/; the api reads it at start, so restart the api after changing it.
#NODE_EXTRA_CA_CERTS=/var/lib/selfphoneng/tls/idp-ca.pem
+1
View File
@@ -28,6 +28,7 @@ write the software around it: database, config generator, API, web UI.
| `docs/spec/08-security-ops.md` | Security, backups, monitoring, legal requirements | Before any release, and when touching auth or networking |
| `docs/spec/09-roadmap.md` | Milestones, ordered tasks, acceptance criteria | Picking your next task |
| `docs/spec/11-contacts.md` | Contacts: the builtin phonebook or Contrack, lookup, the Contrack timeline | Working on contacts or caller names |
| `docs/spec/12-sso.md` | Single sign-on: OpenID Connect (Keycloak and others) and LDAP/LDAPS | Working on sign-in, users or roles |
## Hard rules
+3
View File
@@ -12,13 +12,16 @@
},
"dependencies": {
"@fastify/cookie": "^11.1.3",
"@fastify/rate-limit": "^11.2.1",
"@fastify/static": "^10.1.6",
"@fastify/websocket": "^11.3.4",
"@node-rs/argon2": "^2.2.2",
"@selfphoneng/shared": "workspace:*",
"drizzle-orm": "^0.45.4",
"fastify": "^5.12.7",
"ldapts": "^9.2.0",
"nodemailer": "10.0.11",
"openid-client": "^6.8.8",
"pg": "^8.23.1",
"qrcode": "1.5.4",
"zod": "^4.6.7"
+13 -1
View File
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import Fastify from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyRateLimit from '@fastify/rate-limit';
import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket';
import { sql } from 'drizzle-orm';
@@ -11,9 +12,10 @@ import type { Db } from './db/client.ts';
import type { Generator } from './generator/index.ts';
import type { AmiClient } from './ami/client.ts';
import { settingsRoutes } from './routes/settings.ts';
import { errorHandler, forbidden, notFound, sendError } from './errors.ts';
import { HttpError, 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';
@@ -91,6 +93,15 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.decorateRequest('sessionId', null);
app.register(fastifyCookie);
app.register(fastifyWebsocket);
// Every route, per client IP (08 §2.4). Before the routes, so each one gets it; routes with
// their own limits (login, /prov) or a stricter one say so in `config.rateLimit`.
if (config.RATE_LIMIT_PER_MINUTE > 0)
app.register(fastifyRateLimit, {
max: config.RATE_LIMIT_PER_MINUTE,
timeWindow: 60_000,
errorResponseBuilder: (_req, ctx) =>
new HttpError(429, 'rate_limited', `Too many requests, try again in ${ctx.after}`),
});
app.setErrorHandler(errorHandler);
// CSRF (04 §2.2): browsers can't add this header cross-site without CORS, and CORS is off.
@@ -139,6 +150,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);
+9 -3
View File
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// preHandler guards (04 §2.4). Routes list them in `preHandler`.
// preHandler guards (04 §2.4). Routes list them in `preHandler`; the rate limit presets go in
// `config`.
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 +40,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');
}
@@ -47,3 +48,8 @@ export async function requireAdmin(req: FastifyRequest, reply: FastifyReply): Pr
await requireAuth(req, reply);
if (req.user!.role !== 'admin') throw forbidden('Admins only');
}
/** Route `config` for what calls out or does real work (Test buttons, downloads): 10 a minute. */
export const SLOW = { rateLimit: { max: 10, timeWindow: 60_000 } };
/** Route `config` for routes with their own limit (/prov) or only Asterisk on this machine. */
export const NO_LIMIT = { rateLimit: false as const };
+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]);
}
}
+14 -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';
@@ -32,6 +33,9 @@ export async function createSession(
userId: string,
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({
@@ -40,6 +44,10 @@ export async function createSession(
expiresAt: new Date(Date.now() + LIFETIME),
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;
}
@@ -51,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)));
@@ -64,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)));
}
+2
View File
@@ -35,6 +35,8 @@ const EnvSchema = z
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
TRUST_PROXY: z.stringbool().default(true),
/** Requests per minute per client IP, every route (08 §2.4); stricter ones per route. 0: off. */
RATE_LIMIT_PER_MINUTE: z.coerce.number().int().min(0).default(600),
/** Call audio (RTP) ports, both ends included: the firewall opens this UDP range (01 §3.6). */
RTP_PORT_START: z.coerce.number().int().min(1024).max(65534).default(10000),
RTP_PORT_END: z.coerce.number().int().min(1025).max(65535).default(20000),
+21
View File
@@ -0,0 +1,21 @@
CREATE TYPE "public"."identity_kind" AS ENUM('oidc', 'ldap');--> statement-breakpoint
CREATE TABLE "user_identities" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"kind" "identity_kind" NOT NULL,
"subject" text NOT NULL,
"email" text,
"groups" text[] DEFAULT '{}' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_used_at" timestamp with time zone,
CONSTRAINT "user_identities_kind_subject_unique" UNIQUE("kind","subject"),
CONSTRAINT "user_identities_userId_kind_unique" UNIQUE("user_id","kind")
);
--> statement-breakpoint
ALTER TABLE "users" ALTER COLUMN "password_hash" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "method" text DEFAULT 'password' NOT NULL;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "idp_refresh_enc" text;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "idp_id_token_enc" text;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "idp_checked_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_method_check" CHECK ("sessions"."method" in ('password', 'ldap', 'oidc'));
File diff suppressed because it is too large. Load diff
@@ -85,6 +85,13 @@
"when": 1792788393381,
"tag": "0011_contrack_call_log",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1792951905226,
"tag": "0012_sso",
"breakpoints": true
}
]
}
+35 -2
View File
@@ -19,6 +19,7 @@ import {
smallint,
text,
timestamp,
unique,
uuid,
} from 'drizzle-orm/pg-core';
@@ -53,7 +54,8 @@ export const users = pgTable('users', {
id: id(),
email: citext().notNull().unique(),
displayName: text().notNull(),
passwordHash: text().notNull(),
/** argon2id; null: no password, signs in with OpenID only (12 §3.3). */
passwordHash: text(),
role: userRole().notNull().default('user'),
isActive: boolean().notNull().default(true),
totpSecretEnc: text(),
@@ -83,9 +85,40 @@ export const sessions = pgTable(
expiresAt: tstz().notNull(),
ip: inet(),
userAgent: text(),
/** How it was made (12 §2): password, ldap or oidc. */
method: text().notNull().default('password'),
/** OpenID sessions (12 §3.5): refresh token for the leaver check, ID token for signing out. */
idpRefreshEnc: text(),
idpIdTokenEnc: text(),
idpCheckedAt: tstz(),
...timestamps,
},
(t) => [index().on(t.userId)],
(t) => [
index().on(t.userId),
check('sessions_method_check', sql`${t.method} in ('password', 'ldap', 'oidc')`),
],
);
// One more way to sign in to a user (12 §2): a connected OpenID account or directory entry.
export const identityKind = pgEnum('identity_kind', ['oidc', 'ldap']);
export const userIdentities = pgTable(
'user_identities',
{
id: id(),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
kind: identityKind().notNull(),
/** oidc: "<iss> <sub>"; ldap: the entry's UUID attribute. */
subject: text().notNull(),
/** As the provider last said. */
email: text(),
/** oidc: groupsClaim values at the last sign-in (12 §3.4). */
groups: emptyTextArray(),
createdAt: createdAt(),
lastUsedAt: tstz(),
},
(t) => [unique().on(t.kind, t.subject), unique().on(t.userId, t.kind)],
);
export const apiTokens = pgTable('api_tokens', {
+16
View File
@@ -27,6 +27,8 @@ import { backupDue, runBackup } from './services/backup.ts';
import { sendCallReports } from './services/callreport.ts';
import { contrackBook, contrackSettings } from './contacts/contrack.ts';
import { logCallsToContrack } from './contacts/timeline.ts';
import { checkDirectory } from './sso/directory.ts';
import { checkOidcSessions } from './sso/oidcCheck.ts';
import { logEvent } from './services/events.ts';
import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.ts';
@@ -261,7 +263,19 @@ export function createJobs(d: JobDeps) {
await logCallsToContrack({ db: d.db, log: d.log, now: now() });
};
/** OpenID sessions refreshed at the provider; a leaver's end (12 §3.5). */
const oidcCheck = async () => {
await checkOidcSessions({ db: d.db, log: d.log, now: now() });
};
/** Directory accounts re-read; a leaver disabled (12 §4.5). */
const directoryCheck = async () => {
await checkDirectory({ db: d.db, log: d.log });
};
return {
oidcCheck,
directoryCheck,
daily,
backup,
publicIpWatch,
@@ -295,6 +309,8 @@ export function startJobs(d: JobDeps): () => void {
run('callReports', j.callReports);
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
run('contrackLog', j.contrackLog);
if (minute % 5 === 0) run('oidcCheck', j.oidcCheck);
if (minute % 60 === 0) run('directoryCheck', j.directoryCheck);
run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
});
+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)
+2 -1
View File
@@ -7,6 +7,7 @@ import { readFile, stat } from 'node:fs/promises';
import type { FastifyInstance } from 'fastify';
import type { AboutDto } from '@selfphoneng/shared';
import { notFound } from '../errors.ts';
import { SLOW } from '../auth/guards.ts';
export const REPO_URL = 'https://git.dou.bet/spng/selfphoneng';
/** In the api's working directory (/app in the image), like ./public. */
@@ -35,7 +36,7 @@ export async function aboutRoutes(app: FastifyInstance) {
};
});
app.get('/api/about/source', async (_req, reply) => {
app.get('/api/about/source', { config: SLOW }, async (_req, reply) => {
if (!(await stat(SOURCE_ARCHIVE).catch(() => null)))
throw notFound('The source archive is only in builds made with Docker');
return reply
+134 -22
View File
@@ -1,14 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/auth/* (04 §2, §7.3).
import { and, eq, isNull, lt, or, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import {
ChangePasswordSchema,
LoginSchema,
PasswordConfirmSchema,
TotpEnableSchema,
APP_NAME,
type AuthMethods,
type AuthUser,
type SignInWays,
type TotpSetup,
type TotpStatus,
} from '@selfphoneng/shared';
@@ -22,7 +24,11 @@ import {
otpauthUrl,
} from '../auth/totp.ts';
import type { Db } from '../db/client.ts';
import { extensions, users } from '../db/schema.ts';
import { extensions, sessions, userIdentities, users } from '../db/schema.ts';
import { endSessionUrl, oidcError } from '../sso/oidc.ts';
import { directorySignIn, directoryWarning } from '../sso/directory.ts';
import { ldapAuthenticate } from '../sso/ldap.ts';
import { getSetting } from '../services/settings.ts';
import {
unauthorized,
rateLimited,
@@ -49,11 +55,32 @@ import {
setSessionCookie,
} from '../auth/sessions.ts';
/** 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; groups: string[] }[],
): SignInWays => {
const oidc = ids.find((i) => i.kind === 'oidc');
return {
password: u.passwordHash !== null,
oidc: oidc ? { email: oidc.email, groups: oidc.groups } : null,
ldap: ids.some((i) => i.kind === 'ldap'),
};
};
export async function toAuthUser(db: Db, u: CurrentUser): Promise<AuthUser> {
const [ext] = await db
.select({ id: extensions.id, number: extensions.number, name: extensions.name })
.from(extensions)
.where(eq(extensions.userId, u.id));
const ids = await db
.select({
kind: userIdentities.kind,
email: userIdentities.email,
groups: userIdentities.groups,
})
.from(userIdentities)
.where(eq(userIdentities.userId, u.id));
return {
id: u.id,
email: u.email,
@@ -61,6 +88,7 @@ export async function toAuthUser(db: Db, u: CurrentUser): Promise<AuthUser> {
role: u.role,
mustChangePassword: u.mustChangePassword,
extension: ext ?? null,
signIn: signInWays(u, ids),
};
}
@@ -112,13 +140,24 @@ export async function authRoutes(app: FastifyInstance) {
return { secret, otpauthUrl: url, qr: await QRCode.toDataURL(url, { margin: 2, width: 240 }) };
});
/**
* A signed-in person's own password, asked again (two-step on/off, recovery codes, a new
* password). Wrong ones count in the login limiter, so a stolen session can't guess it.
*/
async function myPasswordOk(req: FastifyRequest, hash: string | null, password: string) {
if (isLoginBlocked(req.ip, req.user!.email)) throw rateLimited();
if (hash && (await verifyPassword(hash, password))) return true;
recordLoginFailure(req.ip, req.user!.email);
return false;
}
app.post('/api/auth/totp/enable', { preHandler: requireAuth }, async (req) => {
adminOnly(req.user!);
const { secret, code, password } = TotpEnableSchema.parse(req.body);
const u = await myRow(req.user!.id);
if (u.totpSecretEnc) throw conflict('Two-step sign-in is already on');
// Like turning it off: a stolen session alone can't lock the owner out with its own secret.
if (!(await verifyPassword(u.passwordHash, password))) throw badRequest('Wrong password');
if (!(await myPasswordOk(req, u.passwordHash, password))) throw badRequest('Wrong password');
const step = checkTotp(secret, code, null);
if (step === null)
throw badRequest('That code doesn’t match. Check the phone’s clock and try again.');
@@ -139,15 +178,15 @@ export async function authRoutes(app: FastifyInstance) {
});
/** Turning it off or making new recovery codes needs the password again (on does too). */
async function confirmPassword(id: string, body: unknown) {
const { password } = PasswordConfirmSchema.parse(body);
const u = await myRow(id);
if (!(await verifyPassword(u.passwordHash, password))) throw badRequest('Wrong password');
async function confirmPassword(req: FastifyRequest) {
const { password } = PasswordConfirmSchema.parse(req.body);
const u = await myRow(req.user!.id);
if (!(await myPasswordOk(req, u.passwordHash, password))) throw badRequest('Wrong password');
return u;
}
app.post('/api/auth/totp/disable', { preHandler: requireAuth }, async (req) => {
await confirmPassword(req.user!.id, req.body);
await confirmPassword(req);
await db
.update(users)
.set({ totpSecretEnc: null, totpLastStep: null, totpRecoveryHashes: [] })
@@ -164,7 +203,7 @@ export async function authRoutes(app: FastifyInstance) {
});
app.post('/api/auth/totp/recovery-codes', { preHandler: requireAuth }, async (req) => {
const u = await confirmPassword(req.user!.id, req.body);
const u = await confirmPassword(req);
if (!u.totpSecretEnc) throw badRequest('Two-step sign-in is off');
const { codes, hashes } = newRecoveryCodes();
await db.update(users).set({ totpRecoveryHashes: hashes }).where(eq(users.id, u.id));
@@ -179,15 +218,61 @@ export async function authRoutes(app: FastifyInstance) {
return { recoveryCodes: codes };
});
/** The login page's choices (12 §2). Public: says nothing about any user. */
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: Boolean((await getSetting(db, 'auth.ldap'))?.enabled),
oidc: oidc && mode !== 'password' ? { label: oidc.label } : null,
};
});
app.post('/api/auth/login', async (req, reply) => {
const body = LoginSchema.parse(req.body);
if (isLoginBlocked(req.ip, body.email)) throw rateLimited();
const [user] = await db.select().from(users).where(eq(users.email, body.email));
const ok = user?.isActive
const { login, ...body } = LoginSchema.parse(req.body);
if (isLoginBlocked(req.ip, login)) throw rateLimited();
let [user] = await db.select().from(users).where(eq(users.email, login));
// No password (OpenID only, 12 §3.3) fails like a wrong one, after the same work.
let ok = user?.passwordHash
? await verifyPassword(user.passwordHash, body.password)
: (await burnVerify(body.password), false);
if (!user || !ok) {
recordLoginFailure(req.ip, body.email);
let method: 'password' | 'ldap' = 'password';
// Not this password here: the directory's, when it's on (12 §4.2).
const ldap = ok ? null : await getSetting(db, 'auth.ldap');
if (ldap?.enabled) {
let entry;
try {
entry = await ldapAuthenticate(ldap, login, body.password);
} catch (err) {
// Counted like a wrong password: a 503 must not be a free guess at the local one.
recordLoginFailure(req.ip, login);
await audit(db, {
actorUserId: user?.id,
actorIp: req.ip,
action: 'login_failed',
entityType: 'user',
entityId: user?.id,
summary: `Failed login for ${login} (the directory can't be reached)`,
});
req.log.warn({ event: 'auth.ldap', err: (err as Error).message }, 'directory unavailable');
await directoryWarning(
db,
`A directory sign-in failed: the directory couldn't be reached (${(err as Error).message}).`,
);
throw new HttpError(
503,
'directory_unavailable',
'The directory can’t be reached. Passwords set in SelfPhoneNG still work.',
);
}
const found = entry && (await directorySignIn(db, req.log, entry, req.ip));
if (entry && !found)
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'directory account has no user here');
if (found) [user, ok, method] = [found, true, 'ldap'];
}
if (!user || !ok || !user.isActive) {
recordLoginFailure(req.ip, login);
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed');
await audit(db, {
actorUserId: user?.id,
@@ -195,16 +280,19 @@ export async function authRoutes(app: FastifyInstance) {
action: 'login_failed',
entityType: 'user',
entityId: user?.id,
summary: `Failed login for ${body.email}`,
summary: `Failed login for ${login}`,
});
throw unauthorized('Wrong email or password');
}
// OpenID only (12 §1.1): passwords are for admins, the way in when the provider is down.
if (user.role !== 'admin' && (await getSetting(db, 'auth.mode')) === 'oidc')
throw forbidden('Use the sign-in button instead', 'password_sign_in_off');
// Two-step sign-in (08 §2.4): the password was right; now the code.
if (user.totpSecretEnc) {
if (!body.totp)
throw new HttpError(401, 'totp_required', 'Enter the code from your authenticator app');
if (!(await useSecondFactor(user, body.totp))) {
recordLoginFailure(req.ip, body.email);
recordLoginFailure(req.ip, login);
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed: code');
await audit(db, {
actorUserId: user.id,
@@ -212,12 +300,18 @@ export async function authRoutes(app: FastifyInstance) {
action: 'login_failed',
entityType: 'user',
entityId: user.id,
summary: `Failed login for ${body.email} (wrong authenticator or recovery code)`,
summary: `Failed login for ${login} (wrong authenticator or recovery code)`,
});
throw new HttpError(401, 'totp_invalid', 'That code is wrong or was used already');
}
}
const token = await createSession(db, user.id, req.ip, req.headers['user-agent'] ?? null);
const token = await createSession(
db,
user.id,
req.ip,
req.headers['user-agent'] ?? null,
method,
);
setSessionCookie(reply, token);
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id));
await audit(db, {
@@ -226,14 +320,30 @@ export async function authRoutes(app: FastifyInstance) {
action: 'login',
entityType: 'user',
entityId: user.id,
summary: `${user.email} logged in`,
summary: `${user.email} logged in${method === 'ldap' ? ' with the directory' : ''}`,
});
return { user: await toAuthUser(db, user) };
});
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,
@@ -242,7 +352,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) => ({
@@ -252,7 +362,9 @@ export async function authRoutes(app: FastifyInstance) {
app.post('/api/auth/change-password', { preHandler: requireAuth }, async (req) => {
const body = ChangePasswordSchema.parse(req.body);
const user = req.user!;
if (!(await verifyPassword(user.passwordHash, body.currentPassword)))
if (!user.passwordHash)
throw badRequest('You have no password here. Ask an admin to reset it to get one.');
if (!(await myPasswordOk(req, user.passwordHash, body.currentPassword)))
throw badRequest('Current password is wrong');
if (body.newPassword === body.currentPassword)
throw badRequest('Choose a password different from the current one');
+51 -41
View File
@@ -16,7 +16,7 @@ import {
type ContactShareDto,
type ListResponse,
} from '@selfphoneng/shared';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import { NO_LIMIT, SLOW, requireAdmin, requireAuth } from '../auth/guards.ts';
import {
addToBook,
assertBuiltin,
@@ -162,33 +162,39 @@ export async function contactRoutes(app: FastifyInstance) {
* The dialplan's caller-name lookup (11 §6 C4): only Asterisk on this machine (no proxy in
* between) with the token. Never waits on Contrack. Not logged: the token is in the query.
*/
app.get('/api/internal/caller-name', { logLevel: 'silent' }, async (req, reply) => {
const q = z
.object({
ext: z.string().regex(/^\d{1,10}$/),
number: z.string().regex(/^\d{3,20}$/),
token: z.string().max(100),
})
.safeParse(req.query);
const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress ?? '');
if (
!local ||
req.headers['x-forwarded-for'] ||
!q.success ||
!sameToken(q.data.token, callerNameToken(app.config.APP_SECRET))
)
return reply.code(404).send('');
const [e] = await db
.select({ userId: extensions.userId })
.from(extensions)
.where(eq(extensions.number, q.data.ext));
const names = await contactNames(db, e?.userId ?? null, [q.data.number], { wait: false }).catch(
() => new Map<string, string>(),
);
return reply
.type('text/plain; charset=utf-8')
.send(phoneSafeName(names.get(q.data.number) ?? ''));
});
app.get(
'/api/internal/caller-name',
{ logLevel: 'silent', config: NO_LIMIT },
async (req, reply) => {
const q = z
.object({
ext: z.string().regex(/^\d{1,10}$/),
number: z.string().regex(/^\d{3,20}$/),
token: z.string().max(100),
})
.safeParse(req.query);
const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(
req.socket.remoteAddress ?? '',
);
if (
!local ||
req.headers['x-forwarded-for'] ||
!q.success ||
!sameToken(q.data.token, callerNameToken(app.config.APP_SECRET))
)
return reply.code(404).send('');
const [e] = await db
.select({ userId: extensions.userId })
.from(extensions)
.where(eq(extensions.number, q.data.ext));
const names = await contactNames(db, e?.userId ?? null, [q.data.number], {
wait: false,
}).catch(() => new Map<string, string>());
return reply
.type('text/plain; charset=utf-8')
.send(phoneSafeName(names.get(q.data.number) ?? ''));
},
);
/** Copy the builtin book into Contrack (11 §5, C6): mine, or (admin) the company's. */
app.post(
@@ -214,19 +220,23 @@ export async function contactRoutes(app: FastifyInstance) {
);
/** Settings → Contacts → Test (11 §4.3): the form's address, and its token or the saved one. */
app.post('/api/contacts/contrack/test', { preHandler: requireAdmin }, async (req) => {
const b = z
.object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() })
.parse(req.body);
const saved = await getSetting(db, 'contacts.contrack');
const token =
b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
try {
return { ok: true, detail: await contrackCheck(b.url, token) };
} catch (err) {
return { ok: false, error: (err as Error).message };
}
});
app.post(
'/api/contacts/contrack/test',
{ preHandler: requireAdmin, config: SLOW },
async (req) => {
const b = z
.object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() })
.parse(req.body);
const saved = await getSetting(db, 'contacts.contrack');
const token =
b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
try {
return { ok: true, detail: await contrackCheck(b.url, token) };
} catch (err) {
return { ok: false, error: (err as Error).message };
}
},
);
// ───── sharing: an offer each, accepted or denied by the recipient (11 §2.1) ─────
app.post('/api/me/contacts/share', { preHandler: requireAuth }, async (req) => {
+2 -2
View File
@@ -13,7 +13,7 @@ import {
type WebhookDto,
type WebhookEvent,
} from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import { SLOW, requireAdmin } from '../auth/guards.ts';
import { generateToken, hashToken } from '../auth/tokens.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import { apiTokens, users, webhookDeliveries, webhooks } from '../db/schema.ts';
@@ -103,7 +103,7 @@ export async function integrationRoutes(app: FastifyInstance) {
});
/** Sends a `webhook.test` event now, not queued: the answer shows right away. */
app.post('/api/webhooks/:id/test', async (req) => {
app.post('/api/webhooks/:id/test', { config: SLOW }, async (req) => {
const h = await getHook(IdParams.parse(req.params).id);
const body = JSON.stringify({
id: crypto.randomUUID(),
+3
View File
@@ -180,6 +180,9 @@ export async function meRoutes(app: FastifyInstance) {
/** Log my calls in Contrack, with or without their summaries (11 §4.4). */
app.patch('/api/me/contrack', async (req) => {
const o = ContrackOptionsSchema.parse(req.body);
// An empty update would be `UPDATE … SET` with nothing to set: a 500, not a 400.
if (o.logCalls === undefined && o.logSummaries === undefined)
throw badRequest('Nothing to update');
const [u] = await db
.select({ logCalls: users.contrackLogCalls })
.from(users)
+333
View File
@@ -0,0 +1,333 @@
// 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,
badRequest,
forbidden,
notFound,
rateLimited,
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 { connectDirectory } from '../sso/directory.ts';
import { ldapAuthenticate } from '../sso/ldap.ts';
import { applyClaims } from '../sso/oidcCheck.ts';
import {
finishSignIn,
oidcError,
startSignIn,
type OidcSettings,
type PendingSignIn,
} from '../sso/oidc.ts';
export const OIDC_COOKIE = 'sp_oidc';
const COOKIE_PATH = '/api/auth/oidc';
/**
* Same-site paths only: never send the browser to another origin. No backslash or control
* character anywhere: browsers drop tabs and newlines from URLs, so "/<tab>/evil.example"
* would become "//evil.example".
*/
export const safeNext = (p: string | undefined) =>
// eslint-disable-next-line no-control-regex -- control characters are what it refuses
p && /^\/(?!\/)[^\\\x00-\x1f\x7f]*$/.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;
}
/** The sp_oidc cookie: what the callback checks the provider's answer against. */
async function begin(reply: FastifyReply, s: OidcSettings, next: string, connect: string | null) {
const { url, pending } = await startSignIn(s, host, next, connect);
reply.setCookie(OIDC_COOKIE, encrypt(JSON.stringify(pending)), {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: COOKIE_PATH,
maxAge: 600,
});
return url.href;
}
app.get('/api/auth/oidc/start', async (req, reply) => {
const s = await enabled();
const q = z.object({ next: z.string().max(500).optional() }).parse(req.query);
try {
return reply.redirect(await begin(reply, s, safeNext(q.next) ?? '/', null));
} catch (err) {
req.log.warn({ event: 'auth.oidc', err: oidcError(err) }, 'OpenID discovery failed');
return reply.redirect(withParam('/login', 'sso_error', 'provider'));
}
});
/**
* Connect from My account (12 §3.3). A POST, so it needs the CSRF header: another site can't
* start it and connect whatever provider account the browser is signed in to. The page then
* sends the browser to `url`.
*/
app.post('/api/auth/oidc/connect', { preHandler: requireAuth }, async (req, reply) => {
const s = await enabled();
if (!req.sessionId) throw unauthorized(); // an API token can't connect an account
const q = z.object({ next: z.string().max(500).optional() }).parse(req.body ?? {});
const next =
safeNext(q.next) ?? (req.user!.role === 'admin' ? '/admin/account' : '/me/settings');
try {
return { url: await begin(reply, s, next, req.sessionId) };
} catch (err) {
req.log.warn({ event: 'auth.oidc', err: oidcError(err) }, 'OpenID discovery failed');
throw new HttpError(502, 'provider', `${s.label} can't be reached`);
}
});
// 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);
await applyClaims(db, req.log, s, userId, d, email, req.ip);
await db
.update(userIdentities)
.set({ 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 an OpenID or directory account (12 §3.3, §4.3): refused when it would leave
* them no way in under the current mode. Sessions made with it end.
*/
async function disconnect(
req: FastifyRequest,
reply: FastifyReply,
userId: string,
kind: 'oidc' | 'ldap',
) {
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 === kind)) throw notFound('No such account is connected');
const left = ids.filter((i) => i.kind !== kind);
const mode = await getSetting(db, 'auth.mode');
const password = u.passwordHash !== null || left.some((i) => i.kind === 'ldap');
const openid = mode !== 'password' && left.some((i) => i.kind === 'oidc');
if (!openid && !(password && (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, kind)));
await db.delete(sessions).where(and(eq(sessions.userId, userId), eq(sessions.method, kind)));
const what = kind === 'oidc' ? 'OpenID' : 'directory';
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'sso_disconnected',
entityType: 'user',
entityId: userId,
summary: `Disconnected the ${what} account of ${u.email}`,
});
return reply.send({ ok: true });
}
const Kind = z.object({ kind: z.enum(['oidc', 'ldap']) });
app.delete('/api/me/identities/:kind', { preHandler: requireAuth }, (req, reply) => {
const { kind } = Kind.parse(req.params);
// The directory account is how a leaver gets disabled (12 §4.5): only an admin drops it.
if (kind === 'ldap' && req.user!.role !== 'admin')
throw forbidden('Ask an admin to disconnect your directory account', 'admin_only');
return disconnect(req, reply, req.user!.id, kind);
});
/**
* Connect my directory account (12 §4.3): its username and password, checked by the directory,
* while signed in here. Counted like a sign-in by the login limiter.
*/
app.post('/api/me/identities/ldap', { preHandler: requireAuth }, async (req) => {
if (!req.sessionId) throw unauthorized(); // an API token can't connect an account
const { login, password } = z
.object({ login: z.string().trim().min(1).max(254), password: z.string().min(1).max(200) })
.parse(req.body);
const ldap = await getSetting(db, 'auth.ldap');
if (!ldap?.enabled) throw notFound();
if (isLoginBlocked(req.ip, login)) throw rateLimited();
let entry;
try {
entry = await ldapAuthenticate(ldap, login, password);
} catch {
recordLoginFailure(req.ip, login);
throw new HttpError(503, 'directory_unavailable', 'The directory can’t be reached');
}
if (!entry) {
recordLoginFailure(req.ip, login);
throw badRequest('The directory refused that username or password');
}
if ((await connectDirectory(db, req.log, req.user!.id, entry, req.ip)) === 'taken')
throw new HttpError(409, 'taken', 'That directory account belongs to another user');
return { ok: true };
});
app.delete('/api/users/:id/identities/:kind', { preHandler: requireAdmin }, (req, reply) => {
const p = Kind.extend({ id: z.uuid() }).parse(req.params);
return disconnect(req, reply, p.id, p.kind);
});
}
+2 -1
View File
@@ -14,6 +14,7 @@ import { allowProvRequest, recordProvFailure } from '../provisioning/limiter.ts'
import { grandstream, grandstreamPhonebook } from '../provisioning/templates/grandstream.ts';
import { yealink, yealinkCommon, yealinkPhonebook } from '../provisioning/templates/yealink.ts';
import { getSetting } from '../services/settings.ts';
import { NO_LIMIT } from '../auth/guards.ts';
/** File name → what it is. Anything else is a plain 404 (phones ask for files we don't serve). */
const FILES: {
@@ -73,7 +74,7 @@ export async function provisioningRoutes(app: FastifyInstance) {
const deny = (reply: FastifyReply, code: number, text: string) =>
reply.code(code).header('Cache-Control', 'no-store').type('text/plain').send(text);
app.get('/prov/*', async (req, reply) => {
app.get('/prov/*', { config: NO_LIMIT }, async (req, reply) => {
if (!(await getSetting(db, 'provisioning.enabled'))) return deny(reply, 404, 'Not found');
const local = await isLocal(req.ip);
if ((await getSetting(db, 'provisioning.lanOnly')) && !local)
+184 -7
View File
@@ -7,10 +7,16 @@ import {
AiEndpointSchema,
AiTtsEndpointSchema,
ContrackInputSchema,
LdapInputSchema,
OidcInputSchema,
SettingsSchemas,
SmtpInputSchema,
type AiTestResult,
type AuthMode,
type Destination,
type LdapTestResult,
type OidcInput,
type OidcTestResult,
type SettingKey,
type SettingsDto,
type Warning,
@@ -18,7 +24,7 @@ import {
import type { Db } from '../db/client.ts';
import { dids, extensions, trunks } from '../db/schema.ts';
import { HttpError, badRequest } from '../errors.ts';
import { requireAdmin } from '../auth/guards.ts';
import { SLOW, requireAdmin } from '../auth/guards.ts';
import { encrypt } from '../crypto/secrets.ts';
import { audit } from '../services/audit.ts';
import { assertDestinationExists } from '../services/destinations.ts';
@@ -30,6 +36,9 @@ 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 { assertLdap, testLdap, type LdapSettings } from '../sso/ldap.ts';
import { directoryUserFor } from '../sso/directory.ts';
import { assertIssuer, oidcConfig, oidcError, testOidc, type OidcSettings } from '../sso/oidc.ts';
declare module 'fastify' {
interface FastifyInstance {
@@ -42,14 +51,46 @@ declare module 'fastify' {
const READ_ONLY = new Set<string>([
'system.restartRequired',
'system.transportHash',
'system.directoryWarning',
'sms.webhookToken',
'setup.completed',
]);
async function toDto(db: Db): Promise<SettingsDto> {
const { smtp, 'contacts.contrack': ctk, ...rest } = await getAllSettings(db);
const {
smtp,
'contacts.contrack': ctk,
'auth.oidc': oidc,
'auth.ldap': ldap,
...rest
} = await getAllSettings(db);
return {
...rest,
'auth.ldap': ldap && {
enabled: ldap.enabled,
preset: ldap.preset,
url: ldap.url,
startTls: ldap.startTls,
bindDn: ldap.bindDn,
baseDn: ldap.baseDn,
userFilter: ldap.userFilter,
idAttribute: ldap.idAttribute,
emailAttribute: ldap.emailAttribute,
nameAttribute: ldap.nameAttribute,
adminFilter: ldap.adminFilter,
hasBindPassword: ldap.bindPasswordEnc !== null,
},
'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 +141,74 @@ async function checkRefs(db: Db, v: Record<string, unknown>) {
export async function settingsRoutes(app: FastifyInstance) {
const { db } = app;
/** The stored form of `auth.ldap`: the bind password is write-only (empty keeps it). */
async function ldapFromInput(raw: unknown): Promise<LdapSettings | null> {
if (raw === null) return null;
const v = LdapInputSchema.parse(raw);
try {
assertLdap(v);
} catch (err) {
throw badRequest((err as Error).message);
}
const old = await getSetting(db, 'auth.ldap');
const url = v.url.replace(/\/$/, '');
// The stored password only goes back to the server and account it was typed for: else a
// changed address (or Test) would send it to whoever answers there.
const same = old?.url === url && old.bindDn === v.bindDn;
if (!v.bindPassword && !v.clearBindPassword && !same && v.bindDn && old?.bindPasswordEnc)
throw badRequest('The address or service account changed: enter its password again');
return {
enabled: v.enabled,
preset: v.preset,
url,
startTls: v.startTls,
bindDn: v.bindDn,
bindPasswordEnc: v.bindPassword
? encrypt(v.bindPassword)
: v.clearBindPassword || !same
? null
: (old?.bindPasswordEnc ?? null),
baseDn: v.baseDn,
userFilter: v.userFilter,
idAttribute: v.idAttribute,
emailAttribute: v.emailAttribute,
nameAttribute: v.nameAttribute,
adminFilter: v.adminFilter,
};
}
/** 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');
// As for the directory: the stored secret only goes to the provider it was typed for.
const same = old?.issuer === input.issuer && old.clientId === input.clientId;
if (!input.clientSecret && !input.clearClientSecret && !same && old?.clientSecretEnc)
throw badRequest('The issuer or client ID changed: enter the client secret again');
return {
label: input.label,
issuer: input.issuer,
clientId: input.clientId,
clientSecretEnc: input.clientSecret
? encrypt(input.clientSecret)
: input.clearClientSecret
? null
: same
? (old.clientSecretEnc ?? null)
: 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));
@@ -120,7 +229,12 @@ export async function settingsRoutes(app: FastifyInstance) {
secure: input.secure,
username: input.username,
from: input.from,
passwordEnc: input.password ? encrypt(input.password) : (old?.passwordEnc ?? ''),
// Kept only for the same server and account: a new host doesn't get the old password.
passwordEnc: input.password
? encrypt(input.password)
: old?.host === input.host && old.username === input.username
? old.passwordEnc
: '',
};
} else if (key === 'contacts.contrack') {
// The company token is write-only too (11 §4.3).
@@ -142,6 +256,10 @@ export async function settingsRoutes(app: FastifyInstance) {
: (old?.companyTokenEnc ?? ''),
};
forgetBook(null);
} else if (key === 'auth.ldap') {
values[key] = await ldapFromInput(raw);
} 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);
@@ -155,6 +273,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] = [
@@ -195,7 +328,7 @@ export async function settingsRoutes(app: FastifyInstance) {
return toDto(db);
});
app.post('/api/settings/smtp/test', async (req) => {
app.post('/api/settings/smtp/test', { config: SLOW }, async (req) => {
const { to } = z.object({ to: z.email() }).parse(req.body);
try {
await app.sendMail({
@@ -210,7 +343,7 @@ export async function settingsRoutes(app: FastifyInstance) {
});
/** Settings → Emergency → Test notifications (06 §6.5): email + ring-out, never dials 911. */
app.post('/api/settings/emergency/test', async (req) => {
app.post('/api/settings/emergency/test', { config: SLOW }, async (req) => {
const [ext] = await db.select().from(extensions).where(eq(extensions.userId, req.user!.id));
const notified = await notifyEmergency(
{ db, ami: app.ami, live: app.live, send: app.sendMail, log: req.log },
@@ -232,8 +365,46 @@ export async function settingsRoutes(app: FastifyInstance) {
return { notified };
});
/**
* Settings → Sign-in → Directory → Test, from the unsaved form (12 §4.1). The test password
* is used once and never stored or logged.
*/
app.post(
'/api/settings/auth/ldap/test',
{ config: SLOW },
async (req): Promise<LdapTestResult> => {
const { testLogin, testPassword, ...form } = LdapInputSchema.extend({
testLogin: z.string().trim().max(254).optional(),
testPassword: z.string().max(200).optional(),
}).parse(req.body);
const s = (await ldapFromInput(form))!;
try {
const r = await testLdap(s, testLogin || undefined, testPassword);
if (!r.user) return { ok: true, matching: r.matching };
const matches = await directoryUserFor(db, r.user);
return {
ok: true,
matching: r.matching,
user: { ...r.user, matches: matches?.email ?? null },
};
} catch (err) {
return { ok: false, error: (err as Error).message };
}
},
);
/** Settings → Sign-in → Test, from the unsaved form (12 §3.1). */
app.post(
'/api/settings/auth/oidc/test',
{ config: SLOW },
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 () => {
app.post('/api/settings/network/detect-public-ip', { config: SLOW }, async () => {
const ip = await detectPublicIp();
if (ip) return { ip };
throw new HttpError(502, 'unavailable', "Couldn't detect the public IP. Enter it by hand.");
@@ -255,7 +426,7 @@ export async function settingsRoutes(app: FastifyInstance) {
});
/** Settings → AI → Test (spec 10): the values in the form, saved or not. */
app.post('/api/ai/test', async (req): Promise<AiTestResult> => {
app.post('/api/ai/test', { config: SLOW }, async (req): Promise<AiTestResult> => {
const { kind } = z.object({ kind: z.enum(['llm', 'stt', 'tts']) }).parse(req.body);
const ep = (kind === 'tts' ? AiTtsEndpointSchema : AiEndpointSchema).parse(
(req.body as { endpoint?: unknown }).endpoint,
@@ -311,6 +482,12 @@ export async function settingsRoutes(app: FastifyInstance) {
message: "Today's backup hasn't been made. Check the api log, or press Back up now.",
link: '/admin/settings/backups',
});
if (s['system.directoryWarning'])
items.push({
code: 'directory_check',
message: s['system.directoryWarning'],
link: '/admin/settings/sign-in',
});
if (!s.smtp)
items.push({
code: 'smtp_missing',
+13 -9
View File
@@ -13,7 +13,7 @@ import type {
SystemStatus,
} from '@selfphoneng/shared';
import type { FastifyInstance } from 'fastify';
import { requireAdmin } from '../auth/guards.ts';
import { SLOW, requireAdmin } from '../auth/guards.ts';
import { audit } from '../services/audit.ts';
import { getAllSettings } from '../services/settings.ts';
import { systemEvents } from '../db/schema.ts';
@@ -175,14 +175,18 @@ export async function systemRoutes(app: FastifyInstance) {
app.get('/api/system/diagnostics', async (): Promise<DiagnosticsDto> => ({
checks: await insideChecks(diag()),
}));
app.post('/api/system/diagnostics/network', async (req): Promise<NetworkTestDto> => {
const { mode } = z
.object({ mode: z.enum(['quick', 'complete']).default('quick') })
.parse(req.body ?? {});
if (!(await getAllSettings(app.db))['network.probeUrl'])
throw badRequest('No network test probe set (Settings → Network)');
return networkTest(diag(), mode);
});
app.post(
'/api/system/diagnostics/network',
{ config: SLOW },
async (req): Promise<NetworkTestDto> => {
const { mode } = z
.object({ mode: z.enum(['quick', 'complete']).default('quick') })
.parse(req.body ?? {});
if (!(await getAllSettings(app.db))['network.probeUrl'])
throw badRequest('No network test probe set (Settings → Network)');
return networkTest(diag(), mode);
},
);
app.get('/api/system/generated-files', async () => ({
files: await Promise.all(
+2 -2
View File
@@ -18,7 +18,7 @@ import {
import type { Db } from '../db/client.ts';
import { dids, outboundRoutes, trunks } from '../db/schema.ts';
import { badRequest, conflict, HttpError, notFound } from '../errors.ts';
import { requireAdmin } from '../auth/guards.ts';
import { SLOW, requireAdmin } from '../auth/guards.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import {
getDids,
@@ -202,7 +202,7 @@ export async function trunkRoutes(app: FastifyInstance) {
return { ok: true };
});
app.post('/api/trunks/:id/test', async (req): Promise<TrunkTestResult> => {
app.post('/api/trunks/:id/test', { config: SLOW }, async (req): Promise<TrunkTestResult> => {
const { id } = IdParams.parse(req.params);
const t = await getTrunk(db, id);
if (!t.enabled) throw badRequest('Turn the trunk on first');
+94 -13
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/users (04 §7.3). Admin only.
import { and, asc, count, eq, ne } from 'drizzle-orm';
import { and, asc, count, eq, isNotNull, ne } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
@@ -10,17 +10,30 @@ import {
type UserDto,
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { extensions, users } from '../db/schema.ts';
import { badRequest, conflict, notFound } from '../errors.ts';
import { extensions, userIdentities, users } from '../db/schema.ts';
import { HttpError, badRequest, conflict, notFound } from '../errors.ts';
import { requireAdmin } from '../auth/guards.ts';
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;
groups: string[];
};
export const toUserDto = (u: UserRow, extension: UserDto['extension'] = null): UserDto => ({
export const toUserDto = (
u: UserRow,
extension: UserDto['extension'] = null,
ids: IdentityRow[] = [],
from: RoleSources = { oidc: null, ldap: false },
): UserDto => ({
id: u.id,
email: u.email,
displayName: u.displayName,
@@ -31,8 +44,39 @@ export const toUserDto = (u: UserRow, extension: UserDto['extension'] = null): U
lastLoginAt: u.lastLoginAt?.toISOString() ?? null,
createdAt: u.createdAt.toISOString(),
extension,
signIn: signInWays(u, ids),
roleFrom: roleFrom(ids, from),
});
/** The providers that set roles now (12 §1): adminGroups / adminFilter set and in use. */
type RoleSources = { oidc: string | null; ldap: boolean };
async function roleSources(db: Db): Promise<RoleSources> {
const [oidc, ldap, mode] = await Promise.all([
getSetting(db, 'auth.oidc'),
getSetting(db, 'auth.ldap'),
getSetting(db, 'auth.mode'),
]);
return {
oidc: oidc && mode !== 'password' && oidc.adminGroups.length > 0 ? oidc.label : null,
ldap: Boolean(ldap?.enabled && ldap.adminFilter),
};
}
const roleFrom = (ids: { kind: string }[], from: RoleSources) =>
(from.oidc && ids.some((i) => i.kind === 'oidc') && from.oidc) ||
(from.ldap && ids.some((i) => i.kind === 'ldap') ? 'the directory' : null);
/** Connected accounts (12 §2), for one user or all of them. */
const identitiesOf = (db: Db, userId?: string): Promise<IdentityRow[]> =>
db
.select({
userId: userIdentities.userId,
kind: userIdentities.kind,
email: userIdentities.email,
groups: userIdentities.groups,
})
.from(userIdentities)
.where(userId ? eq(userIdentities.userId, userId) : undefined);
const extOf = async (db: Db, userId: string) => {
const [e] = await db
.select({ id: extensions.id, number: extensions.number })
@@ -56,13 +100,27 @@ async function getUser(db: Db, id: string): Promise<UserRow> {
return u;
}
/** Throws if removing `id` from the active admins would leave none. */
async function assertNotLastAdmin(db: Db, id: string) {
/**
* Removing `id` from the active admins would leave none with a password: the way in when the
* identity provider is down (12 §1).
*/
export async function isLastAdmin(db: Db, id: string): Promise<boolean> {
const [r] = await db
.select({ n: count() })
.from(users)
.where(and(eq(users.role, 'admin'), eq(users.isActive, true), ne(users.id, id)));
if (!r || r.n === 0) throw conflict('This is the last active admin');
.where(
and(
eq(users.role, 'admin'),
eq(users.isActive, true),
isNotNull(users.passwordHash),
ne(users.id, id),
),
);
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) {
@@ -76,9 +134,16 @@ export async function userRoutes(app: FastifyInstance) {
.from(users)
.leftJoin(extensions, eq(extensions.userId, users.id))
.orderBy(asc(users.displayName), asc(users.id));
const ids = await identitiesOf(db);
const from = await roleSources(db);
return {
items: rows.map((r) =>
toUserDto(r.u, r.extId && r.extNumber ? { id: r.extId, number: r.extNumber } : null),
toUserDto(
r.u,
r.extId && r.extNumber ? { id: r.extId, number: r.extNumber } : null,
ids.filter((i) => i.userId === r.u.id),
from,
),
),
nextCursor: null,
};
@@ -86,17 +151,27 @@ export async function userRoutes(app: FastifyInstance) {
app.get('/api/users/:id', async (req) => {
const { id } = IdParams.parse(req.params);
return toUserDto(await getUser(db, id), await extOf(db, id));
return toUserDto(
await getUser(db, id),
await extOf(db, id),
await identitiesOf(db, id),
await roleSources(db),
);
});
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');
@@ -120,6 +195,12 @@ export async function userRoutes(app: FastifyInstance) {
const body = UserUpdateSchema.parse(req.body);
if (Object.keys(body).length === 0) throw badRequest('Nothing to update');
const before = await getUser(db, id);
const ids = await identitiesOf(db, id);
const from = await roleSources(db);
// 12 §1: a provider that sets the role would undo this at the next sign-in or check.
const managed = roleFrom(ids, from);
if (body.role !== undefined && body.role !== before.role && managed)
throw new HttpError(409, 'role_managed', `The role comes from ${managed}`);
const losesAdmin =
before.role === 'admin' &&
before.isActive &&
@@ -150,7 +231,7 @@ export async function userRoutes(app: FastifyInstance) {
after: Object.fromEntries(changed.map((k) => [k, after[k]])),
},
});
return toUserDto(after, await extOf(db, id));
return toUserDto(after, await extOf(db, id), ids, from);
});
app.delete('/api/users/:id', async (req) => {
+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;
}
+196
View File
@@ -0,0 +1,196 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// A directory entry → its SelfPhoneNG user (12 §4.3): connected before, or by the directory's
// email. Never a new user (owner, 2026-10-25).
import { and, eq } from 'drizzle-orm';
import { deleteUserSessions } from '../auth/sessions.ts';
import type { Db } from '../db/client.ts';
import { userIdentities, users } from '../db/schema.ts';
import { isLastAdmin } from '../routes/users.ts';
import { audit } from '../services/audit.ts';
import { getSetting, setSetting } from '../services/settings.ts';
import { lookupIds, type DirectoryEntry, type LdapSettings } from './ldap.ts';
import { applyRole } from './role.ts';
type UserRow = typeof users.$inferSelect;
/** Who this entry is here, without changing anything (Settings → Test says it too). */
export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise<UserRow | null> {
const [byId] = await db
.select({ u: users })
.from(userIdentities)
.innerJoin(users, eq(users.id, userIdentities.userId))
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
if (byId) return byId.u;
if (!e.email) return null;
const [byEmail] = await db.select().from(users).where(eq(users.email, e.email));
return byEmail && byEmailOk(byEmail) ? byEmail : null;
}
/**
* Who a directory email may connect to on its own (12 §4.3 step 2): not an admin, and nobody
* who chose their own password (none, or only the temporary one from Users). Anyone else
* connects on My settings with both passwords: a directory that lets people edit their own
* `mail` must not hand out the admin's account.
*/
const byEmailOk = (u: UserRow) =>
u.role !== 'admin' && (u.passwordHash === null || u.mustChangePassword);
/** Connect a directory account the person proved (My settings, 12 §4.3). */
export async function connectDirectory(
db: Db,
log: { info(o: object, m: string): void },
userId: string,
e: DirectoryEntry,
ip: string,
): Promise<'connected' | 'taken'> {
const [owner] = await db
.select({ userId: userIdentities.userId })
.from(userIdentities)
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
if (owner && owner.userId !== userId) return 'taken';
await db.transaction(async (tx) => {
await tx
.delete(userIdentities)
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'ldap')));
await tx
.insert(userIdentities)
.values({ userId, kind: 'ldap', subject: e.id, email: e.email, lastUsedAt: new Date() });
});
await audit(db, {
actorUserId: userId,
actorIp: ip,
action: 'ldap_connected',
entityType: 'user',
entityId: userId,
summary: `Connected directory account ${e.dn}`,
});
await applyRole(db, log, userId, e.admin, 'the directory', ip);
return 'connected';
}
/**
* The user this directory sign-in is for, connected now if it was matched by email (the
* directory is the company's own and the person just proved its password), with the role from
* adminFilter. Null: nobody here has this directory account or email.
*/
export async function directorySignIn(
db: Db,
log: { info(o: object, m: string): void },
e: DirectoryEntry,
ip: string,
): Promise<UserRow | null> {
const u = await directoryUserFor(db, e);
if (!u) return null;
const [mine] = await db
.select({ subject: userIdentities.subject })
.from(userIdentities)
.where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
if (!mine) {
await db.insert(userIdentities).values({
userId: u.id,
kind: 'ldap',
subject: e.id,
email: e.email,
lastUsedAt: new Date(),
});
await audit(db, {
actorUserId: u.id,
actorIp: ip,
action: 'ldap_connected',
entityType: 'user',
entityId: u.id,
summary: `${u.email} connected directory account ${e.dn}`,
});
} else if (mine.subject !== e.id) {
// Their email matches, but another directory account is already theirs: not this one.
log.info({ userId: u.id }, `directory entry ${e.dn} matches by email but isn't theirs`);
return null;
} else
await db
.update(userIdentities)
.set({ email: e.email, lastUsedAt: new Date() })
.where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
await applyRole(db, log, u.id, e.admin, 'the directory', ip);
const [after] = await db.select().from(users).where(eq(users.id, u.id));
return after!;
}
/** The dashboard's directory warning (12 §4.2, §4.5); null clears it. */
export const directoryWarning = (db: Db, message: string | null) =>
setSetting(db, 'system.directoryWarning', message);
type Log = { info(o: object, m: string): void; warn?(o: object, m: string): void };
/**
* One run of the directoryCheck job (hourly, 12 §4.5): every active user with a directory
* account still in it gets its email and role; one who isn't is disabled. Never re-enables.
* `lookup` is for tests.
*/
export async function checkDirectory(d: {
db: Db;
log: Log;
lookup?: (s: LdapSettings, ids: string[]) => Promise<Map<string, DirectoryEntry>>;
}): Promise<void> {
const { db, log } = d;
const s = await getSetting(db, 'auth.ldap');
const rows = s?.enabled
? await db
.select({ u: users, subject: userIdentities.subject })
.from(userIdentities)
.innerJoin(users, eq(users.id, userIdentities.userId))
.where(and(eq(userIdentities.kind, 'ldap'), eq(users.isActive, true)))
: [];
if (!s || !rows.length) return directoryWarning(db, null);
let found: Map<string, DirectoryEntry>;
try {
found = await (d.lookup ?? lookupIds)(
s,
rows.map((r) => r.subject),
);
} catch (err) {
log.warn?.({ err: (err as Error).message }, 'directory check skipped');
return directoryWarning(
db,
`The directory check couldn't reach the directory (${(err as Error).message}), so leavers aren't being disabled.`,
);
}
const gone = rows.filter((r) => !found.has(r.subject));
// A broken filter or base DN is likelier than a mass layoff.
if (gone.length >= 3 && gone.length * 2 > rows.length) {
log.warn?.({ gone: gone.length, of: rows.length }, 'directory check would disable most users');
return directoryWarning(
db,
`The directory check would disable ${gone.length} of ${rows.length} people, so it disabled nobody. Check the base DN and user filter.`,
);
}
for (const { u, subject } of rows) {
const e = found.get(subject);
if (e) {
await db
.update(userIdentities)
.set({ email: e.email })
.where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
await applyRole(db, log, u.id, e.admin, 'the directory', null);
continue;
}
if (u.role === 'admin' && u.passwordHash && (await isLastAdmin(db, u.id))) {
log.warn?.(
{ userId: u.id },
'kept the last admin with a password, though the directory lost them',
);
continue;
}
await db.update(users).set({ isActive: false }).where(eq(users.id, u.id));
await deleteUserSessions(db, u.id);
await audit(db, {
action: 'directory_disabled',
entityType: 'user',
entityId: u.id,
summary: `${u.email} is no longer in the directory`,
diff: { before: { isActive: true }, after: { isActive: false } },
});
}
await directoryWarning(db, null);
}
+101
View File
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { BerWriter, Client, UnwillingToPerformError } from 'ldapts';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
anyLogin,
assertLdap,
byIdFilter,
DirectoryUnavailable,
ldapAuthenticate,
withLogin,
type LdapSettings,
} from './ldap.ts';
describe('LDAP (12 §4)', () => {
const f = '(&(objectClass=person)(|(uid={login})(mail={login})))';
it('escapes the login, so it can only ever be a value (RFC 4515)', () => {
expect(withLogin(f, 'ann')).toBe('(&(objectClass=person)(|(uid=ann)(mail=ann)))');
expect(withLogin(f, '*)(uid=*))(|(uid=*')).toBe(
'(&(objectClass=person)(|(uid=\\2a\\29\\28uid=\\2a\\29\\29\\28|\\28uid=\\2a)(mail=\\2a\\29\\28uid=\\2a\\29\\29\\28|\\28uid=\\2a)))',
);
expect(withLogin('(uid={login})', 'a\\b\u0000')).toBe('(uid=a\\5cb\\00)');
});
it('{login} → * keeps the other conditions, for the leaver check', () => {
expect(anyLogin(f)).toBe('(&(objectClass=person)(|(uid=*)(mail=*)))');
});
it('finds a leaver by id, keeping the other conditions of the filter (§4.5)', () => {
expect(byIdFilter({ idAttribute: 'entryUUID', userFilter: f }, 'a*b').toString()).toBe(
'(&(entryUUID=a\\2ab)(&(objectClass=person)(|(uid=*)(mail=*))))',
);
// AD: objectGUID is stored as hex and searched as its bytes (§4.4).
const guid = byIdFilter({ idAttribute: 'objectGUID', userFilter: '(cn={login})' }, '00ff2a');
expect(guid.toString()).toBe('(&(objectGUID=\\00\\ff\\2a)(cn=*))');
// …and goes out as those three bytes, not U+00FF in UTF-8.
const ber = new BerWriter();
guid.write(ber);
expect(ber.buffer.includes(Buffer.from([0x04, 0x03, 0x00, 0xff, 0x2a]))).toBe(true);
});
it('never sends a password in clear text to another host', () => {
const ok = { startTls: false, userFilter: f, adminFilter: null };
expect(() => assertLdap({ ...ok, url: 'ldap://dc1.example.com' })).toThrow(/clear text/);
expect(() =>
assertLdap({ ...ok, url: 'ldap://dc1.example.com', startTls: true }),
).not.toThrow();
expect(() => assertLdap({ ...ok, url: 'ldaps://dc1.example.com' })).not.toThrow();
expect(() => assertLdap({ ...ok, url: 'ldap://127.0.0.1:3890' })).not.toThrow();
expect(() => assertLdap({ ...ok, url: 'ldaps://x', startTls: true })).toThrow(/StartTLS/);
});
it('refuses filters that do not parse', () => {
const base = { url: 'ldaps://x', startTls: false, userFilter: f, adminFilter: null };
expect(() => assertLdap({ ...base, userFilter: '(uid={login}' })).toThrow(/User filter/);
expect(() => assertLdap({ ...base, adminFilter: 'memberOf=x)' })).toThrow(/Admin filter/);
});
it('an empty password fails before anything is sent (no "unauthenticated bind")', async () => {
// A directory that doesn't exist: any attempt to connect would throw DirectoryUnavailable.
const s = { url: 'ldaps://192.0.2.1', startTls: false, userFilter: f } as LdapSettings;
expect(await ldapAuthenticate(s, 'ann', '')).toBeNull();
});
});
describe('a directory that answers no (12 §4.2)', () => {
afterEach(() => vi.restoreAllMocks());
const s = {
url: 'ldaps://dir.example.com',
startTls: false,
bindDn: null,
baseDn: 'dc=example,dc=com',
userFilter: '(uid={login})',
idAttribute: 'entryUUID',
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: null,
} as LdapSettings;
it('a locked account (389 DS, FreeIPA: not code 49) is a refused password, not "unavailable"', async () => {
vi.spyOn(Client.prototype, 'search').mockResolvedValue({
searchEntries: [{ dn: 'uid=ann,dc=example,dc=com', entryUUID: 'id-1' }],
searchReferences: [],
});
vi.spyOn(Client.prototype, 'unbind').mockResolvedValue();
vi.spyOn(Client.prototype, 'bind').mockRejectedValue(
new UnwillingToPerformError('Account locked'),
);
expect(await ldapAuthenticate(s, 'ann', 'right-but-locked')).toBeNull();
});
it('no answer at all is "unavailable"', async () => {
vi.spyOn(Client.prototype, 'search').mockResolvedValue({
searchEntries: [{ dn: 'uid=ann,dc=example,dc=com', entryUUID: 'id-1' }],
searchReferences: [],
});
vi.spyOn(Client.prototype, 'unbind').mockResolvedValue();
vi.spyOn(Client.prototype, 'bind').mockRejectedValue(new Error('connect ECONNREFUSED'));
await expect(ldapAuthenticate(s, 'ann', 'pw')).rejects.toThrow(DirectoryUnavailable);
});
});
+265
View File
@@ -0,0 +1,265 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// LDAP / LDAPS password check (12 §4) on ldapts: search the person with the service account,
// bind as them with their password, read whether the admin filter matches them.
import {
AndFilter,
Client,
EqualityFilter,
FilterParser,
InvalidCredentialsError,
NoSuchObjectError,
ResultCodeError,
SizeLimitExceededError,
escapeFilter,
type Entry,
} from 'ldapts';
import type { SettingValue } from '@selfphoneng/shared';
import { decrypt } from '../crypto/secrets.ts';
export type LdapSettings = NonNullable<SettingValue<'auth.ldap'>>;
/** The directory can't be asked (down, TLS, a refused service account): not a wrong password. */
export class DirectoryUnavailable extends Error {}
const LOCAL = new Set(['127.0.0.1', '::1', '[::1]', 'localhost']);
/** 12 §4.1: no password in clear text over the network, and filters that parse. */
export function assertLdap(s: {
url: string;
startTls: boolean;
userFilter: string;
adminFilter: string | null;
}): void {
const u = new URL(s.url);
if (u.protocol === 'ldaps:' && s.startTls) throw new Error('StartTLS is for ldap:// addresses');
if (u.protocol === 'ldap:' && !s.startTls && !LOCAL.has(u.hostname))
throw new Error('ldap:// sends passwords in clear text: use ldaps://, or turn on StartTLS');
for (const [name, f] of [
['User filter', s.userFilter],
['Admin filter', s.adminFilter],
] as const)
try {
if (f) FilterParser.parseString(f.replaceAll('{login}', 'x'));
} catch (err) {
throw new Error(`${name}: ${(err as Error).message}`, { cause: err });
}
}
/** Every {login} replaced by the RFC 4515-escaped value (ldapts escapeFilter). */
export function withLogin(filter: string, login: string): string {
const parts = filter.split('{login}');
const strings = Object.assign([...parts], { raw: parts }) as unknown as TemplateStringsArray;
return escapeFilter(strings, ...parts.slice(1).map(() => login));
}
/** {login} → * : the filter's other conditions (group, disabled bit) without the login (§4.5). */
export const anyLogin = (filter: string) => filter.replaceAll('{login}', '*');
const first = (v: Entry[string] | undefined) => (Array.isArray(v) ? v[0] : v);
/** One attribute as text: binary ids (AD's objectGUID) as lowercase hex (12 §4.4). */
const attr = (e: Entry, name: string): string | null => {
const v = first(e[name]);
if (v === undefined) return null;
return (Buffer.isBuffer(v) ? v.toString('hex') : String(v)) || null;
};
const newClient = (s: LdapSettings) =>
new Client({ url: s.url, timeout: 5000, connectTimeout: 5000 });
async function startTls(c: Client, s: LdapSettings) {
if (s.startTls) await c.startTLS({ servername: new URL(s.url).hostname });
}
/** A connection bound as the service account (or anonymous). */
async function connect(s: LdapSettings): Promise<Client> {
const c = newClient(s);
try {
await startTls(c, s);
if (s.bindDn) {
// A DN with an empty password is an anonymous "unauthenticated bind" (RFC 4513 §5.1.2).
if (!s.bindPasswordEnc) throw new Error('The service account has no password');
await c.bind(s.bindDn, decrypt(s.bindPasswordEnc));
}
return c;
} catch (err) {
await c.unbind().catch(() => {});
throw new DirectoryUnavailable(
err instanceof InvalidCredentialsError
? 'The directory refused the service account'
: (err as Error).message,
);
}
}
const attributes = (s: LdapSettings) => [s.idAttribute, s.emailAttribute, s.nameAttribute, 'cn'];
const buffers = (s: LdapSettings) => (s.idAttribute === 'objectGUID' ? ['objectGUID'] : []);
/** The entries `login` matches (at most 2: more than one is as bad as none). */
async function findByLogin(c: Client, s: LdapSettings, login: string): Promise<Entry[]> {
try {
const r = await c.search(s.baseDn, {
scope: 'sub',
filter: withLogin(s.userFilter, login),
attributes: attributes(s),
explicitBufferAttributes: buffers(s),
sizeLimit: 2,
});
return r.searchEntries;
} catch (err) {
if (err instanceof SizeLimitExceededError) return [{ dn: '' }, { dn: '' }];
throw err;
}
}
export type DirectoryEntry = {
dn: string;
/** The stable id attribute's value (12 §4.4). */
id: string;
email: string | null;
name: string | null;
/** adminFilter's answer; null when there is none (the role is managed here). */
admin: boolean | null;
};
async function toEntry(c: Client, s: LdapSettings, e: Entry): Promise<DirectoryEntry> {
const id = attr(e, s.idAttribute);
if (!id) throw new DirectoryUnavailable(`${e.dn} has no ${s.idAttribute}`);
let admin: boolean | null = null;
if (s.adminFilter)
try {
const r = await c.search(e.dn, { scope: 'base', filter: s.adminFilter, attributes: ['1.1'] });
admin = r.searchEntries.length > 0;
} catch (err) {
// lldap answers "no such object" when the entry doesn't match a base search's filter.
if (!(err instanceof NoSuchObjectError)) throw err;
admin = false;
}
return {
dn: e.dn,
id,
email: attr(e, s.emailAttribute),
name: attr(e, s.nameAttribute) ?? attr(e, 'cn'),
admin,
};
}
/** Is this the password of that entry? Its own connection, so the service one stays bound. */
async function passwordWorks(s: LdapSettings, dn: string, password: string): Promise<boolean> {
const u = newClient(s);
try {
await startTls(u, s);
await u.bind(dn, password);
return true;
} catch (err) {
// The server answered (wrong password, locked, expired: 389 DS and FreeIPA say 19 or 53
// for a locked account): a refused password. Only no answer at all is "unavailable", so
// locking someone's account can't turn their sign-in into a 503.
if (err instanceof ResultCodeError) return false;
throw new DirectoryUnavailable((err as Error).message);
} finally {
await u.unbind().catch(() => {});
}
}
const unavailable = (err: unknown) =>
err instanceof DirectoryUnavailable ? err : new DirectoryUnavailable((err as Error).message);
/**
* 12 §4.2 step 3: the directory entry for this login and password, or null when they don't
* match exactly one entry with that password. Throws DirectoryUnavailable otherwise.
*/
export async function ldapAuthenticate(
s: LdapSettings,
login: string,
password: string,
): Promise<DirectoryEntry | null> {
// Never bind with an empty password: many servers take it as anonymous success.
if (!password) return null;
const c = await connect(s);
try {
const found = await findByLogin(c, s, login);
if (found.length !== 1) return null;
if (!(await passwordWorks(s, found[0]!.dn, password))) return null;
return await toEntry(c, s, found[0]!);
} catch (err) {
throw unavailable(err);
} finally {
await c.unbind().catch(() => {});
}
}
/** `(&(<id attribute>=<id>)<userFilter with {login} → *>)`: still there, and still let in (§4.5). */
export function byIdFilter(s: Pick<LdapSettings, 'idAttribute' | 'userFilter'>, id: string) {
// An object, not a string: ldapts parses "\ff" in a string filter to U+00FF and sends it as
// UTF-8 (two bytes), so a binary objectGUID would never match. A Buffer goes out as is.
const value = s.idAttribute === 'objectGUID' ? Buffer.from(id, 'hex') : id;
return new AndFilter({
filters: [
new EqualityFilter({ attribute: s.idAttribute, value }),
FilterParser.parseString(anyLogin(s.userFilter)),
],
});
}
/**
* The leaver check (12 §4.5): each id → its entry, when the directory still has it and the user
* filter still lets it in. One connection for all. Throws DirectoryUnavailable on any failure,
* so a half-done run never reads as "gone".
*/
export async function lookupIds(
s: LdapSettings,
ids: string[],
): Promise<Map<string, DirectoryEntry>> {
const c = await connect(s);
try {
const found = new Map<string, DirectoryEntry>();
for (const id of ids) {
const r = await c.search(s.baseDn, {
scope: 'sub',
filter: byIdFilter(s, id),
attributes: attributes(s),
explicitBufferAttributes: buffers(s),
sizeLimit: 2,
});
if (r.searchEntries.length === 1) found.set(id, await toEntry(c, s, r.searchEntries[0]!));
}
return found;
} catch (err) {
throw unavailable(err);
} finally {
await c.unbind().catch(() => {});
}
}
/** Settings → Sign-in → Directory → Test (12 §4.1), step by step so it can say which failed. */
export async function testLdap(
s: LdapSettings,
login?: string,
password?: string,
): Promise<{ matching: number; user?: DirectoryEntry }> {
const c = await connect(s);
try {
let matching: number;
try {
const r = await c.search(s.baseDn, {
scope: 'sub',
filter: anyLogin(s.userFilter),
attributes: ['1.1'],
sizeLimit: 1000,
});
matching = r.searchEntries.length;
} catch (err) {
if (!(err instanceof SizeLimitExceededError)) throw err;
matching = 1000;
}
if (!login) return { matching };
const found = await findByLogin(c, s, login);
if (found.length === 0) throw new Error(`No entry under ${s.baseDn} matches "${login}"`);
if (found.length > 1) throw new Error(`More than one entry matches "${login}"`);
if (!password || !(await passwordWorks(s, found[0]!.dn, password)))
throw new Error(`The directory refused the password for ${found[0]!.dn}`);
return { matching, user: await toEntry(c, s, found[0]!) };
} finally {
await c.unbind().catch(() => {});
}
}
+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) };
}
}
+146
View File
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// What the provider says about a user, applied (12 §3.4), and the leaver check (12 §3.5): every
// OpenID session is refreshed at the provider every 15 minutes, and ends when it says no.
import { and, eq, gt, isNotNull, isNull, lt, or } from 'drizzle-orm';
import * as oidc from 'openid-client';
import type { Db } from '../db/client.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import { sessions, userIdentities, users } from '../db/schema.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { claimAt, decide, type ClaimsDecision } from './claims.ts';
import { applyRole } from './role.ts';
import { oidcConfig, oidcError, type OidcSettings } from './oidc.ts';
type Log = { info(o: object, m: string): void; warn?(o: object, m: string): void };
/** The role from adminGroups (12 §3.4), and the groups and email on the identity. */
export async function applyClaims(
db: Db,
log: Log,
s: OidcSettings,
userId: string,
d: ClaimsDecision,
email: string | null,
actorIp: string | null,
): Promise<void> {
await applyRole(db, log, userId, d.admin, `${s.label}'s groups`, actorIp);
await db
.update(userIdentities)
.set({ email, groups: d.groups })
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'oidc')));
}
const CHECK_EVERY_MS = 15 * 60_000;
/** One run of the oidcCheck job (every 5 minutes): sessions not checked for 15 minutes. */
export async function checkOidcSessions(d: { db: Db; log: Log; now?: Date }): Promise<void> {
const { db, log } = d;
const now = d.now ?? new Date();
const s = await getSetting(db, 'auth.oidc');
if (!s) return;
const due = await db
.select({
id: sessions.id,
userId: sessions.userId,
refreshEnc: sessions.idpRefreshEnc,
email: users.email,
subject: userIdentities.subject,
})
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.leftJoin(
userIdentities,
and(eq(userIdentities.userId, sessions.userId), eq(userIdentities.kind, 'oidc')),
)
.where(
and(
eq(sessions.method, 'oidc'),
isNotNull(sessions.idpRefreshEnc),
gt(sessions.expiresAt, now),
or(
isNull(sessions.idpCheckedAt),
lt(sessions.idpCheckedAt, new Date(now.getTime() - CHECK_EVERY_MS)),
),
),
);
if (!due.length) return;
let config: oidc.Configuration;
try {
config = await oidcConfig(s);
} catch (err) {
log.warn?.({ err: oidcError(err) }, 'OpenID check skipped: the provider is unreachable');
return;
}
const end = async (row: (typeof due)[number], why: string) => {
await db.delete(sessions).where(eq(sessions.id, row.id));
await audit(db, {
actorUserId: row.userId,
action: 'logout',
entityType: 'user',
entityId: row.userId,
summary: `${row.email} signed out: ${why}`,
});
};
for (const row of due) {
// Disconnected since (Disconnect also ends these sessions; this is the belt to its braces).
if (!row.subject) {
await end(row, `no ${s.label} account connected any more`);
continue;
}
let tokens: Awaited<ReturnType<typeof oidc.refreshTokenGrant>>;
let claims: Record<string, unknown>;
try {
tokens = await oidc.refreshTokenGrant(config, decrypt(row.refreshEnc!));
const sub = row.subject.slice(row.subject.indexOf(' ') + 1);
// As at sign-in: the ID token's claims, userinfo's on top. Entra's app roles and
// "ID token only" mappers are in the ID token alone.
claims = {
...tokens.claims(),
...(config.serverMetadata().userinfo_endpoint &&
(await oidc.fetchUserInfo(config, tokens.access_token, sub))),
};
} catch (err) {
// The provider said no: disabled, signed out there, or the refresh token expired.
if (err instanceof oidc.ResponseBodyError && err.error === 'invalid_grant') {
await end(row, `${s.label} ended the session (${oidcError(err)})`);
continue;
}
// Down, 5xx, a bad client setup: keep the session and try again next run.
log.warn?.({ err: oidcError(err) }, `OpenID check of a session failed`);
continue;
}
// No groups claim at all (the refresh brought no ID token, and userinfo doesn't carry it):
// nothing to decide groups on, so not "in no groups". Checked again next run.
const groupsKnown = claimAt(claims, s.groupsClaim) !== undefined;
const ruled = s.allowedGroups.length > 0 || s.adminGroups.length > 0;
if (ruled && !groupsKnown) {
log.info({ userId: row.userId }, 'OpenID check: no groups claim, role and access kept');
await db
.update(sessions)
.set({
idpCheckedAt: now,
...(tokens.refresh_token && { idpRefreshEnc: encrypt(tokens.refresh_token) }),
})
.where(eq(sessions.id, row.id));
continue;
}
const verdict = decide(s, claims);
if (!verdict.allowed) {
await end(row, `no longer in ${s.label}'s allowed groups`);
continue;
}
await db
.update(sessions)
.set({
idpCheckedAt: now,
...(tokens.refresh_token && { idpRefreshEnc: encrypt(tokens.refresh_token) }),
...(tokens.id_token && { idpIdTokenEnc: encrypt(tokens.id_token) }),
})
.where(eq(sessions.id, row.id));
const email = typeof claims.email === 'string' ? claims.email : null;
await applyClaims(db, log, s, row.userId, verdict, email, null);
}
}
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The role a provider says (12 §1): OpenID adminGroups or the LDAP adminFilter.
import { eq } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { users } from '../db/schema.ts';
import { isLastAdmin } from '../routes/users.ts';
import { audit } from '../services/audit.ts';
/** admin null: the role is managed here. Never demotes the last admin with a password. */
export async function applyRole(
db: Db,
log: { info(o: object, m: string): void },
userId: string,
admin: boolean | null,
source: string,
actorIp: string | null,
): Promise<void> {
if (admin === null) return;
const [user] = await db.select().from(users).where(eq(users.id, userId));
const role = admin ? 'admin' : 'user';
if (!user || role === user.role) return;
if (role === 'user' && (await isLastAdmin(db, userId))) {
log.info({ userId }, `kept the last admin with a password, though ${source} says user`);
return;
}
await db.update(users).set({ role }).where(eq(users.id, userId));
await audit(db, {
actorIp: actorIp ?? undefined,
action: 'update',
entityType: 'user',
entityId: userId,
summary: `${user.email} is now ${role}, from ${source}`,
diff: { before: { role: user.role }, after: { role } },
});
}
+163
View File
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 S5: the Active Directory preset against a real Samba AD DC: sign-in by sAMAccountName, UPN
// and mail, admin through a nested group (LDAP_MATCHING_RULE_IN_CHAIN), the disabled bit,
// objectGUID as hex, and directoryCheck disabling a deleted user. Needs AD_LIVE_URL (ldaps://…,
// with NODE_EXTRA_CA_CERTS naming its CA) and AD_LIVE_ADMIN_PASS (the Administrator's).
// Passed against Samba 4 (diegogslomp/samba-ad-dc), its smb.conf given a throwaway certificate
// for 127.0.0.1 (`tls keyfile`, `tls certfile`, `tls cafile`):
// docker run -d --privileged -p 127.0.0.1:11636:636 -e REALM=EXAMPLE.COM -e DOMAIN=EXAMPLE \
// -e ADMIN_PASS=… -e DNS_FORWARDER=1.1.1.1 -e BIND_NETWORK_INTERFACES=false \
// diegogslomp/samba-ad-dc
import { randomBytes } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { Attribute, Client } from 'ldapts';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { LDAP_PRESETS } from '@selfphoneng/shared';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { encrypt } from '../src/crypto/secrets.ts';
import { userIdentities, users } from '../src/db/schema.ts';
import { setSetting } from '../src/services/settings.ts';
import { checkDirectory } from '../src/sso/directory.ts';
import { hasTestDb } from './db.ts';
import { H, createTestApp } from './app.ts';
const URL_ = process.env.AD_LIVE_URL;
const ADMIN_PASS = process.env.AD_LIVE_ADMIN_PASS ?? '';
const DC = 'DC=example,DC=com';
const BASE = `CN=Users,${DC}`;
const ADMIN_DN = `CN=Administrator,${BASE}`;
describe.skipIf(!hasTestDb || !URL_)('Active Directory with a real Samba AD DC (12 S5)', () => {
const tag = randomBytes(3).toString('hex');
const [ann, bob, dan, cat] = ['ann', 'bob', 'dan', 'cat'].map((n) => `${n}${tag}`) as [
string,
string,
string,
string,
];
const team = `CN=team-${tag},${BASE}`;
const admins = `CN=pbx-admins-${tag},${BASE}`;
const dn = (sam: string) => `CN=${sam},${BASE}`;
let t: Awaited<ReturnType<typeof createTestApp>>;
let ad: Client;
const login = (who: string, password: string) =>
t.app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { login: who, password },
});
const row = async (sam: string) =>
(
await t.db
.select()
.from(users)
.where(eq(users.email, `${sam}@example.com`))
)[0]!;
beforeAll(async () => {
ad = new Client({ url: URL_! });
await ad.bind(ADMIN_DN, ADMIN_PASS);
// userAccountControl 512: a normal account; 514: the same, disabled.
for (const [sam, uac] of [
[ann, '512'],
[bob, '512'],
[dan, '514'],
[cat, '512'],
] as const)
await ad.add(dn(sam), [
...Object.entries({
objectClass: ['top', 'person', 'organizationalPerson', 'user'],
sAMAccountName: [sam],
userPrincipalName: [`${sam}@example.com`],
mail: [`${sam}@example.com`],
displayName: [sam],
userAccountControl: [uac],
}).map(([type, values]) => new Attribute({ type, values })),
// AD's password: the quoted string in UTF-16LE, only over an encrypted connection.
new Attribute({
type: 'unicodePwd',
values: [Buffer.from(`"${sam}-Directory-Pw1"`, 'utf16le')],
}),
]);
// ann is in team, team is in pbx-admins: only the nested-group rule finds her admin.
await ad.add(team, {
objectClass: ['top', 'group'],
sAMAccountName: `team-${tag}`,
member: dn(ann),
});
await ad.add(admins, {
objectClass: ['top', 'group'],
sAMAccountName: `pbx-admins-${tag}`,
member: team,
});
t = await createTestApp();
// New users with the temporary password: the directory email may connect them (12 §4.3).
for (const sam of [ann, bob, dan, cat]) {
await t.addUser(`${sam}@example.com`, 'local-password-1');
await t.db
.update(users)
.set({ mustChangePassword: true })
.where(eq(users.email, `${sam}@example.com`));
}
await setSetting(t.db, 'auth.ldap', {
enabled: true,
preset: 'ad',
url: URL_!,
startTls: false,
bindDn: ADMIN_DN,
bindPasswordEnc: encrypt(ADMIN_PASS),
baseDn: BASE,
userFilter: LDAP_PRESETS.ad.userFilter,
idAttribute: LDAP_PRESETS.ad.idAttribute,
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: `(memberOf:1.2.840.113556.1.4.1941:=${admins})`,
});
}, 60_000);
afterAll(async () => {
await t?.close();
for (const d of [admins, team, ...[ann, bob, dan, cat].map(dn)])
await ad?.del(d).catch(() => {});
await ad?.unbind();
});
it('signs in by sAMAccountName; a nested group makes an admin; objectGUID is stored as hex', async () => {
resetLoginLimiter();
const r = await login(ann, `${ann}-Directory-Pw1`);
expect(r.statusCode).toBe(200);
expect(r.json().user).toMatchObject({ role: 'admin', signIn: { ldap: true } });
const [ident] = await t.db
.select()
.from(userIdentities)
.where(eq(userIdentities.userId, (await row(ann)).id));
expect(ident!.subject).toMatch(/^[0-9a-f]{32}$/);
});
it('signs in by UPN and by mail; outside the group is a user', async () => {
resetLoginLimiter();
expect((await login(`${bob}@example.com`, `${bob}-Directory-Pw1`)).json().user.role).toBe(
'user',
);
resetLoginLimiter();
expect((await login(`${ann}@example.com`, `${ann}-Directory-Pw1`)).statusCode).toBe(200);
});
it('a disabled account (userAccountControl bit 2) is refused', async () => {
resetLoginLimiter();
expect((await login(dan, `${dan}-Directory-Pw1`)).statusCode).toBe(401);
});
it('directoryCheck disables a deleted user and keeps the rest', async () => {
resetLoginLimiter();
expect((await login(cat, `${cat}-Directory-Pw1`)).statusCode).toBe(200);
await ad.del(dn(cat));
await checkDirectory({ db: t.db, log: { info: () => {}, warn: () => {} } });
expect((await row(cat)).isActive).toBe(false);
expect(await row(ann)).toMatchObject({ isActive: true, role: 'admin' });
expect((await row(bob)).isActive).toBe(true);
});
});
+1
View File
@@ -66,6 +66,7 @@ export async function createTestApp(
INITIAL_ADMIN_PASSWORD: ADMIN.password,
AMI_SECRET: 'ami-secret-123',
LOG_LEVEL: 'fatal',
RATE_LIMIT_PER_MINUTE: '0', // the rate limit has its own test
RECORDINGS_DIR: mkdtempSync(join(tmpdir(), 'sp-rec-')),
SOUNDS_DIR: mkdtempSync(join(tmpdir(), 'sp-sounds-')),
MOH_DIR: mkdtempSync(join(tmpdir(), 'sp-moh-')),
+128
View File
@@ -0,0 +1,128 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 §4.5: the hourly directoryCheck, against a fake directory lookup.
import { eq, ne } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { auditLog, sessions, userIdentities, users } from '../src/db/schema.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { checkDirectory } from '../src/sso/directory.ts';
import type { DirectoryEntry, LdapSettings } from '../src/sso/ldap.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const log = { info: () => {}, warn: () => {} };
const LDAP: LdapSettings = {
enabled: true,
preset: 'lldap',
url: 'ldaps://dir.example.com',
startTls: false,
bindDn: null,
bindPasswordEnc: null,
baseDn: 'ou=people,dc=example,dc=com',
userFilter: '(uid={login})',
idAttribute: 'entryUUID',
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: '(memberOf=cn=pbx-admins)',
};
describe.skipIf(!hasTestDb)('directoryCheck (12 §4.5)', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let n = 0;
/** A user here with a directory account `id`. */
const member = async (role: 'admin' | 'user' = 'user') => {
const u = await t.addUser(`dir${++n}@example.com`, 'local-password-1', role);
const dirId = `uuid-${n}`;
await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: dirId });
return { ...u, dirId };
};
const entry = (id: string, admin: boolean | null, email = `${id}@dir.example.com`) =>
[id, { dn: `uid=${id}`, id, email, name: id, admin }] as [string, DirectoryEntry];
const run = (found: [string, DirectoryEntry][] | Error) =>
checkDirectory({
db: t.db,
log,
lookup: () =>
found instanceof Error ? Promise.reject(found) : Promise.resolve(new Map(found)),
});
const user = async (id: string) => (await t.db.select().from(users).where(eq(users.id, id)))[0]!;
const warning = () => getSetting(t.db, 'system.directoryWarning');
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
});
afterAll(() => t?.close());
beforeEach(async () => {
await t.db.delete(userIdentities);
await t.db.update(users).set({ role: 'user' }).where(ne(users.email, ADMIN.email));
await setSetting(t.db, 'auth.ldap', LDAP);
});
it('updates who is still there, and disables who is gone', async () => {
const [stays, promoted, leaves] = [await member(), await member(), await member()];
await t.login(leaves.email, 'local-password-1');
await setSetting(t.db, 'system.directoryWarning', 'old');
await run([entry(stays.dirId, false, 'new@dir.example.com'), entry(promoted.dirId, true)]);
expect(await user(stays.id)).toMatchObject({ isActive: true, role: 'user' });
const [ident] = await t.db
.select()
.from(userIdentities)
.where(eq(userIdentities.userId, stays.id));
expect(ident!.email).toBe('new@dir.example.com');
expect((await user(promoted.id)).role).toBe('admin');
expect((await user(leaves.id)).isActive).toBe(false);
expect(await t.db.select().from(sessions).where(eq(sessions.userId, leaves.id))).toEqual([]);
const audits = await t.db.select().from(auditLog).where(eq(auditLog.entityId, leaves.id));
expect(audits.map((a) => a.action)).toContain('directory_disabled');
expect(await warning()).toBeNull();
});
it('changes nothing and warns on the dashboard when the directory is down', async () => {
const a = await member();
await run(new Error('connect ECONNREFUSED'));
expect((await user(a.id)).isActive).toBe(true);
expect(await warning()).toMatch(/couldn't reach the directory \(connect ECONNREFUSED\)/);
const admin = await t.login(ADMIN.email, ADMIN.password);
const r = await t.app.inject({
url: '/api/system/warnings',
headers: { ...H, cookie: admin },
});
expect(r.json().items).toContainEqual(
expect.objectContaining({ code: 'directory_check', link: '/admin/settings/sign-in' }),
);
});
it('disables nobody when most people would go (a broken filter, not a layoff)', async () => {
const all = [await member(), await member(), await member(), await member()];
await run([entry(all[0]!.dirId, null)]);
for (const u of all) expect((await user(u.id)).isActive).toBe(true);
expect(await warning()).toMatch(/would disable 3 of 4 people/);
});
it('two of four gone is not "most": they are disabled', async () => {
const all = [await member(), await member(), await member(), await member()];
await run([entry(all[0]!.dirId, null), entry(all[1]!.dirId, null)]);
expect((await user(all[2]!.id)).isActive).toBe(false);
expect((await user(all[3]!.id)).isActive).toBe(false);
});
it('keeps the last active admin with a password', async () => {
const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
await t.db.insert(userIdentities).values({ userId: me!.id, kind: 'ldap', subject: 'admin-id' });
await run([]);
expect((await user(me!.id)).isActive).toBe(true);
await run([entry('admin-id', false)]);
expect((await user(me!.id)).role).toBe('admin');
});
it('does nothing with the directory off', async () => {
const a = await member();
await setSetting(t.db, 'auth.ldap', { ...LDAP, enabled: false });
await setSetting(t.db, 'system.directoryWarning', 'old');
await run([]);
expect((await user(a.id)).isActive).toBe(true);
expect(await warning()).toBeNull();
});
});
+119
View File
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 §4.3: who a directory entry may connect to by email, who may disconnect it (§4.5), and a
// directory that's down counting as a failed sign-in (§4.2).
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { userIdentities, users } from '../src/db/schema.ts';
import { setSetting } from '../src/services/settings.ts';
import { directorySignIn } from '../src/sso/directory.ts';
import type { DirectoryEntry } from '../src/sso/ldap.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const log = { info: () => {} };
describe.skipIf(!hasTestDb)('directory accounts by email (12 §4.3)', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let n = 0;
const entry = (email: string): DirectoryEntry => ({
dn: `uid=e${++n}`,
id: `id-${n}`,
email,
name: email,
admin: null,
});
const connected = async (userId: string) =>
(await t.db.select().from(userIdentities).where(eq(userIdentities.userId, userId))).length;
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
});
afterAll(() => t?.close());
it('never connects an admin, or someone who set their own password', async () => {
const admin = await t.addUser('boss@example.com', 'boss-password-1', 'admin');
const own = await t.addUser('own@example.com', 'own-password-1');
for (const u of [admin, own]) {
expect(await directorySignIn(t.db, log, entry(u.email), '127.0.0.1')).toBeNull();
expect(await connected(u.id)).toBe(0);
}
});
it('connects someone with no password, or only the temporary one', async () => {
const fresh = await t.addUser('fresh@example.com', 'temporary-pw-1');
await t.db.update(users).set({ mustChangePassword: true }).where(eq(users.id, fresh.id));
const none = await t.addUser('none@example.com', 'unused-pw-1');
await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, none.id));
for (const u of [fresh, none]) {
expect((await directorySignIn(t.db, log, entry(u.email), '127.0.0.1'))?.id).toBe(u.id);
expect(await connected(u.id)).toBe(1);
}
});
it('only an admin can disconnect a directory account (a leaver must stay checked)', async () => {
const u = await t.addUser('leaver@example.com', 'leaver-password-1');
await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: 'leaver-id' });
const mine = await t.login(u.email, 'leaver-password-1');
const self = await t.app.inject({
method: 'DELETE',
url: '/api/me/identities/ldap',
headers: { ...H, cookie: mine },
});
expect(self.statusCode).toBe(403);
expect(await connected(u.id)).toBe(1);
const admin = await t.login(ADMIN.email, ADMIN.password);
const byAdmin = await t.app.inject({
method: 'DELETE',
url: `/api/users/${u.id}/identities/ldap`,
headers: { ...H, cookie: admin },
});
expect(byAdmin.statusCode).toBe(200);
expect(await connected(u.id)).toBe(0);
});
it('a directory that is down still counts each failed password: no free guesses', async () => {
resetLoginLimiter();
await setSetting(t.db, 'auth.ldap', {
enabled: true,
preset: 'lldap',
url: 'ldap://127.0.0.1:1', // nothing listens there
startTls: false,
bindDn: null,
bindPasswordEnc: null,
baseDn: 'dc=example,dc=com',
userFilter: '(uid={login})',
idAttribute: 'entryUUID',
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: null,
});
try {
const codes: number[] = [];
for (let i = 0; i < 6; i++)
codes.push(
(
await t.app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { login: ADMIN.email, password: `guess-${i}` },
})
).statusCode,
);
expect(codes).toEqual([503, 503, 503, 503, 503, 429]);
// Even the right password waits now: the guesses were counted.
const right = await t.app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { login: ADMIN.email, password: ADMIN.password },
});
expect(right.statusCode).toBe(429);
} finally {
await setSetting(t.db, 'auth.ldap', null);
resetLoginLimiter();
}
});
});
+178
View File
@@ -0,0 +1,178 @@
// 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[];
/** Only in the ID token, as Entra's app roles. */
roles?: 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,
/** true: the token endpoint answers 503, as a provider that's down. */
down: false,
refreshes: 0,
/** false: a refresh brings no new ID token (allowed by OIDC Core §12.2). */
refreshIdToken: true,
};
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') {
if (st.down) return json(503, { error: 'temporarily_unavailable' });
// client_secret_basic: both parts form-encoded before base64 (RFC 6749 §2.3.1).
const [id, secret] = Buffer.from(
(req.headers.authorization ?? '').replace(/^Basic /, ''),
'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' });
// Today's claims, as a real provider gives on refresh.
const t = tokens(st.user.sub === user.sub ? { ...st.user } : user, null);
return json(200, st.refreshIdToken ? t : { ...t, id_token: undefined });
}
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' });
const { roles: _, ...info } = user;
return json(200, { ...info, ...(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 }),
...(user.roles && { roles: user.roles }),
};
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;
}
+252
View File
@@ -0,0 +1,252 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 S4-S5 against a real lldap: sign in by uid and by email, connect by email, no user made for
// a stranger, admin by memberOf, Test, LDAPS, directoryCheck disabling a removed user. Needs
// LDAP_LIVE_URL (ldap://127.0.0.1:<port>) and LDAP_LIVE_HTTP (its web port, to make the users);
// LDAP_LIVE_LDAPS_URL to try LDAPS too, with NODE_EXTRA_CA_CERTS naming the CA of its
// certificate. Admin password: LDAP_LIVE_ADMIN_PASS.
// Passed against lldap 0.6.3:
// docker run -d -p 127.0.0.1:13890:3890 -p 127.0.0.1:16360:6360 -p 127.0.0.1:17170:17170 \
// -e LLDAP_JWT_SECRET=… -e LLDAP_KEY_SEED=… -e LLDAP_LDAP_USER_PASS=admin-pass-123 \
// -e LLDAP_LDAP_BASE_DN=dc=example,dc=com [-e LLDAP_LDAPS_OPTIONS__ENABLED=true …] \
// lldap/lldap:v0.6.3-alpine
import { randomBytes } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { Client } from 'ldapts';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { encrypt } from '../src/crypto/secrets.ts';
import { userIdentities, users } from '../src/db/schema.ts';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { setSetting } from '../src/services/settings.ts';
import { checkDirectory } from '../src/sso/directory.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const URL_ = process.env.LDAP_LIVE_URL;
const HTTP = process.env.LDAP_LIVE_HTTP;
const LDAPS = process.env.LDAP_LIVE_LDAPS_URL;
const ADMIN_PASS = process.env.LDAP_LIVE_ADMIN_PASS ?? 'admin-pass-123';
const BASE = 'dc=example,dc=com';
/** RFC 3062 password modify request: SEQUENCE { [0] user DN, [2] new password }. */
function passwordModify(dn: string, password: string): Buffer {
const tlv = (tag: number, v: Buffer) => Buffer.concat([Buffer.from([tag, v.length]), v]);
return tlv(0x30, Buffer.concat([tlv(0x80, Buffer.from(dn)), tlv(0x82, Buffer.from(password))]));
}
describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)', () => {
const tag = randomBytes(3).toString('hex');
const ann = `ann${tag}`;
const bob = `bob${tag}`;
const cat = `cat${tag}`;
const dee = `dee${tag}`;
const group = `pbx-admins-${tag}`;
let t: Awaited<ReturnType<typeof createTestApp>>;
let jwt: string;
let admin: string;
const gql = async (query: string) => {
const r = await fetch(`${HTTP}/api/graphql`, {
method: 'POST',
headers: { authorization: `Bearer ${jwt}`, 'content-type': 'application/json' },
body: JSON.stringify({ query }),
});
const j = (await r.json()) as { data: Record<string, unknown>; errors?: unknown };
if (j.errors) throw new Error(JSON.stringify(j.errors));
return j.data;
};
const settings = (url = URL_!) => ({
enabled: true,
preset: 'lldap' as const,
url,
startTls: false,
bindDn: `uid=admin,ou=people,${BASE}`,
bindPasswordEnc: encrypt(ADMIN_PASS),
baseDn: `ou=people,${BASE}`,
userFilter: '(&(objectClass=person)(|(uid={login})(mail={login})))',
idAttribute: 'entryUUID',
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: `(memberOf=cn=${group},ou=groups,${BASE})`,
});
const login = (who: string, password: string) =>
t.app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { login: who, password },
});
beforeAll(async () => {
const r = await fetch(`${HTTP}/auth/simple/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: ADMIN_PASS }),
});
jwt = ((await r.json()) as { token: string }).token;
for (const [id, name] of [
[ann, 'Ann Lee'],
[bob, 'Bob Roe'],
[cat, 'Cat Poe'],
[dee, 'Dee Fox'],
])
await gql(
`mutation { createUser(user: { id: "${id}", email: "${id}@example.com", displayName: "${name}" }) { id } }`,
);
const g = (await gql(`mutation { createGroup(name: "${group}") { id } }`)) as {
createGroup: { id: number };
};
await gql(`mutation { addUserToGroup(userId: "${ann}", groupId: ${g.createGroup.id}) { ok } }`);
const c = new Client({ url: URL_! });
await c.bind(`uid=admin,ou=people,${BASE}`, ADMIN_PASS);
for (const id of [ann, bob, cat, dee])
await c.exop(
'1.3.6.1.4.1.4203.1.11.1',
passwordModify(`uid=${id},ou=people,${BASE}`, `${id}-directory-pw`),
);
await c.unbind();
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
// New users with the temporary password from Users: the directory email may connect them.
for (const who of [ann, cat]) {
await t.addUser(`${who}@example.com`, `${who}-local-pw-12`);
await t.db
.update(users)
.set({ mustChangePassword: true })
.where(eq(users.email, `${who}@example.com`));
}
await t.addUser(`${dee}@example.com`, 'dee-own-password-12'); // chose her own password
await setSetting(t.db, 'auth.ldap', settings());
}, 60_000);
afterAll(async () => {
await t?.close();
for (const id of [ann, bob, cat, dee])
await gql(`mutation { deleteUser(userId: "${id}") { ok } }`).catch(() => {});
});
it('methods say the directory is on', async () => {
expect((await t.app.inject({ url: '/api/auth/methods' })).json().ldap).toBe(true);
});
it('signs in by uid, connecting the user by email, and memberOf makes an admin', async () => {
resetLoginLimiter();
const r = await login(ann, `${ann}-directory-pw`);
expect(r.statusCode).toBe(200);
expect(r.json().user).toMatchObject({
email: `${ann}@example.com`,
role: 'admin',
signIn: { password: true, ldap: true },
});
const [u] = await t.db
.select()
.from(users)
.where(eq(users.email, `${ann}@example.com`));
const ids = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u!.id));
expect(ids).toMatchObject([{ kind: 'ldap', email: `${ann}@example.com` }]);
expect(ids[0]!.subject).toMatch(/^[0-9a-f-]{36}$/); // entryUUID
});
it('signs in by email too, and the local password still works', async () => {
resetLoginLimiter();
expect((await login(`${ann}@example.com`, `${ann}-directory-pw`)).statusCode).toBe(200);
expect((await login(`${ann}@example.com`, `${ann}-local-pw-12`)).statusCode).toBe(200);
});
it('a wrong password, an empty one, and a stranger are refused alike', async () => {
resetLoginLimiter();
for (const [who, pw] of [
[ann, 'wrong-password'],
[ann, ''],
[bob, `${bob}-directory-pw`], // in the directory, but nobody here: no user is made
['*', `${ann}-directory-pw`],
]) {
const r = await login(who!, pw!);
expect([400, 401]).toContain(r.statusCode);
}
const [b] = await t.db
.select()
.from(users)
.where(eq(users.email, `${bob}@example.com`));
expect(b).toBeUndefined();
});
it('a directory that is down is a 503, and local passwords still work', async () => {
resetLoginLimiter();
await setSetting(t.db, 'auth.ldap', settings('ldap://127.0.0.1:1'));
try {
const r = await login(ann, `${ann}-directory-pw`);
expect(r.statusCode).toBe(503);
expect(r.json().error.code).toBe('directory_unavailable');
expect((await login(`${ann}@example.com`, `${ann}-local-pw-12`)).statusCode).toBe(200);
} finally {
await setSetting(t.db, 'auth.ldap', settings());
}
});
it('Test: counts people, and a test login says who it would be', async () => {
const { bindPasswordEnc: _, ...form } = settings();
const r = await t.app.inject({
method: 'POST',
url: '/api/settings/auth/ldap/test',
headers: { ...H, cookie: admin },
payload: { ...form, testLogin: ann, testPassword: `${ann}-directory-pw` },
});
expect(r.json()).toMatchObject({
ok: true,
user: { email: `${ann}@example.com`, admin: true, matches: `${ann}@example.com` },
});
expect(r.json().matching).toBeGreaterThanOrEqual(3);
const bad = await t.app.inject({
method: 'POST',
url: '/api/settings/auth/ldap/test',
headers: { ...H, cookie: admin },
payload: { ...form, testLogin: ann, testPassword: 'nope' },
});
expect(bad.json()).toMatchObject({ ok: false, error: expect.stringContaining('refused') });
});
it('someone with their own password connects on My settings, never by email alone', async () => {
resetLoginLimiter();
expect((await login(dee, `${dee}-directory-pw`)).statusCode).toBe(401);
const mine = await t.login(`${dee}@example.com`, 'dee-own-password-12');
const connect = (password: string) =>
t.app.inject({
method: 'POST',
url: '/api/me/identities/ldap',
headers: { ...H, cookie: mine },
payload: { login: dee, password },
});
expect((await connect('wrong-password')).statusCode).toBe(400);
expect((await connect(`${dee}-directory-pw`)).statusCode).toBe(200);
resetLoginLimiter();
expect((await login(dee, `${dee}-directory-pw`)).statusCode).toBe(200);
});
it('directoryCheck disables someone removed from the directory, and only them (§4.5)', async () => {
resetLoginLimiter();
expect((await login(cat, `${cat}-directory-pw`)).statusCode).toBe(200); // connects cat
await gql(`mutation { deleteUser(userId: "${cat}") { ok } }`);
await checkDirectory({ db: t.db, log: { info: () => {}, warn: () => {} } });
const active = async (who: string) =>
(
await t.db
.select()
.from(users)
.where(eq(users.email, `${who}@example.com`))
)[0]!.isActive;
expect(await active(cat)).toBe(false);
expect(await active(ann)).toBe(true);
});
it.skipIf(!LDAPS)('LDAPS with a private CA (NODE_EXTRA_CA_CERTS)', async () => {
resetLoginLimiter();
await setSetting(t.db, 'auth.ldap', settings(LDAPS));
try {
expect((await login(ann, `${ann}-directory-pw`)).statusCode).toBe(200);
} finally {
await setSetting(t.db, 'auth.ldap', settings());
}
});
});
+236
View File
@@ -0,0 +1,236 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 S3 against a real Keycloak: connect, sign in through Keycloak's own login form, a group
// makes an admin, signing out there too, and disabling the user there ending our session.
// Needs KEYCLOAK_LIVE_URL (e.g. http://127.0.0.1:18080) and KEYCLOAK_LIVE_ADMIN ("user:password"
// of the master realm); makes a throwaway realm and deletes it. Passed against 26.8.1:
// docker run -d -p 127.0.0.1:18080:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
// -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:26.8.1 start-dev
import { randomBytes } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { encrypt } from '../src/crypto/secrets.ts';
import { sessions, users } from '../src/db/schema.ts';
import { setSetting } from '../src/services/settings.ts';
import { checkOidcSessions } from '../src/sso/oidcCheck.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const KC = process.env.KEYCLOAK_LIVE_URL;
const [KC_USER, KC_PASS] = (process.env.KEYCLOAK_LIVE_ADMIN ?? 'admin:admin').split(':');
/** A cookie jar for the browser's side of Keycloak. */
class Browser {
jar = new Map<string, string>();
async get(url: string, init: RequestInit = {}) {
const res = await fetch(url, {
...init,
redirect: 'manual',
headers: { ...(init.headers as object), cookie: this.cookie() },
});
for (const c of res.headers.getSetCookie()) {
const [kv] = c.split(';');
const i = kv!.indexOf('=');
this.jar.set(kv!.slice(0, i), kv!.slice(i + 1));
}
return res;
}
cookie = () => [...this.jar].map(([k, v]) => `${k}=${v}`).join('; ');
}
describe.skipIf(!hasTestDb || !KC)('OpenID Connect with a real Keycloak (12 S3)', () => {
const realm = `sp-test-${randomBytes(4).toString('hex')}`;
const issuer = `${KC}/realms/${realm}`;
let t: Awaited<ReturnType<typeof createTestApp>>;
let token: string;
let kcUserId: string;
const browser = new Browser();
const kcAdmin = async (method: string, path: string, body?: unknown) => {
const res = await fetch(`${KC}/admin/realms${path}`, {
method,
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${await res.text()}`);
return res;
};
beforeAll(async () => {
const tok = await fetch(`${KC}/realms/master/protocol/openid-connect/token`, {
method: 'POST',
body: new URLSearchParams({
grant_type: 'password',
client_id: 'admin-cli',
username: KC_USER!,
password: KC_PASS!,
}),
});
token = ((await tok.json()) as { access_token: string }).access_token;
await kcAdmin('POST', '', { realm, enabled: true });
await kcAdmin('POST', `/${realm}/clients`, {
clientId: 'selfphoneng',
protocol: 'openid-connect',
publicClient: false,
secret: 'live-secret-with-symbols-+/=',
standardFlowEnabled: true,
directAccessGrantsEnabled: false,
redirectUris: ['https://pbx.test/api/auth/oidc/callback'],
attributes: { 'post.logout.redirect.uris': 'https://pbx.test/login' },
protocolMappers: [
{
name: 'groups',
protocol: 'openid-connect',
protocolMapper: 'oidc-group-membership-mapper',
config: {
'full.path': 'false',
'id.token.claim': 'true',
'access.token.claim': 'true',
'userinfo.token.claim': 'true',
'claim.name': 'groups',
},
},
],
});
await kcAdmin('POST', `/${realm}/groups`, { name: 'pbx-admins' });
const u = await kcAdmin('POST', `/${realm}/users`, {
username: 'ann',
email: 'ann@example.com',
emailVerified: true,
firstName: 'Ann',
lastName: 'Lee',
enabled: true,
groups: ['/pbx-admins'],
credentials: [{ type: 'password', value: 'ann-keycloak-pw', temporary: false }],
});
kcUserId = u.headers.get('location')!.split('/').at(-1)!;
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
await t.addUser('ann@example.com', 'ann-password-12');
await setSetting(t.db, 'auth.oidc', {
label: 'Keycloak',
issuer,
clientId: 'selfphoneng',
clientSecretEnc: encrypt('live-secret-with-symbols-+/='),
scopes: 'openid email profile',
groupsClaim: 'groups',
adminGroups: ['pbx-admins'],
allowedGroups: [],
trustEmail: false,
});
await setSetting(t.db, 'auth.mode', 'mixed');
}, 60_000);
afterAll(async () => {
await t?.close();
if (token) await kcAdmin('DELETE', `/${realm}`).catch(() => {});
});
/** start → Keycloak's login form, filled in like a person would → our callback. */
async function flow(cookie?: string, query = '') {
const connecting = query === '?connect=1';
// Connect is a POST from My account (with the CSRF header); sign-in a GET from the button.
const start = connecting
? await t.app.inject({
method: 'POST',
url: '/api/auth/oidc/connect',
headers: { ...H, cookie: cookie! },
payload: {},
})
: await t.app.inject({
url: `/api/auth/oidc/start${query}`,
headers: cookie ? { cookie } : {},
});
const location = connecting ? (start.json() as { url: string }).url : start.headers.location;
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
let res = await browser.get(location as string);
if (res.status === 200) {
// Not signed in at Keycloak yet: post its login form.
const html = await res.text();
const action = /<form[^>]*id="kc-form-login"[^>]*action="([^"]+)"/.exec(html)![1]!;
res = await browser.get(action.replaceAll('&amp;', '&'), {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ username: 'ann', password: 'ann-keycloak-pw' }).toString(),
});
}
expect(res.status).toBe(302);
const back = new URL(res.headers.get('location')!);
expect(back.origin).toBe('https://pbx.test');
return t.app.inject({
url: back.pathname + back.search,
headers: { cookie: [`sp_oidc=${pending.value}`, cookie].filter(Boolean).join('; ') },
});
}
const session = (r: Awaited<ReturnType<typeof flow>>) => {
const c = r.cookies.find((x) => x.name === 'sp_session');
return c ? `sp_session=${c.value}` : null;
};
it('Test sees the realm and accepts the client', async () => {
const admin = await t.login(ADMIN.email, ADMIN.password);
const r = await t.app.inject({
method: 'POST',
url: '/api/settings/auth/oidc/test',
headers: { ...H, cookie: admin },
payload: {
label: 'Keycloak',
issuer,
clientId: 'selfphoneng',
scopes: 'openid email profile',
groupsClaim: 'groups',
adminGroups: [],
allowedGroups: [],
trustEmail: false,
},
});
expect(r.json()).toMatchObject({ ok: true, pkce: true, refreshTokens: true, endSession: true });
});
it('connects, signs in through the login form, and the group makes an admin', async () => {
const pw = await t.login('ann@example.com', 'ann-password-12');
const c = await flow(pw, '?connect=1');
expect(c.headers.location).toBe('/me/settings?sso=connected');
const r = await flow();
const cookie = session(r);
expect(cookie).toBeTruthy();
const me = (await t.app.inject({ url: '/api/auth/me', headers: { cookie: cookie! } })).json();
expect(me.user).toMatchObject({ role: 'admin' });
expect(me.user.signIn.oidc).toEqual({ email: 'ann@example.com', groups: ['pbx-admins'] });
});
it('signing out signs out of Keycloak too', async () => {
const r = await flow(); // already signed in at Keycloak: straight back
const out = await t.app.inject({
method: 'POST',
url: '/api/auth/logout',
headers: { ...H, cookie: session(r)! },
});
const to = out.json().redirect as string;
expect(to.startsWith(`${issuer}/protocol/openid-connect/logout`)).toBe(true);
const done = await browser.get(to);
expect(done.status).toBe(302);
expect(done.headers.get('location')).toBe('https://pbx.test/login');
// Keycloak forgot us: the next sign-in shows its login form again (flow() fills it).
expect(session(await flow())).toBeTruthy();
});
it('disabling the user in Keycloak ends their session at the next check', async () => {
const r = await flow();
const cookie = session(r)!;
const [ann] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
const check = (min: number) =>
checkOidcSessions({
db: t.db,
log: t.app.log,
now: new Date(Date.now() + min * 60_000),
});
await check(16);
expect((await t.app.inject({ url: '/api/auth/me', headers: { cookie } })).statusCode).toBe(200);
await kcAdmin('PUT', `/${realm}/users/${kcUserId}`, { enabled: false });
await check(32);
const left = await t.db.select().from(sessions).where(eq(sessions.userId, ann!.id));
expect(left.filter((s) => s.method === 'oidc')).toEqual([]);
expect((await t.app.inject({ url: '/api/auth/me', headers: { cookie } })).statusCode).toBe(401);
});
});
+477
View File
@@ -0,0 +1,477 @@
// 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, desc, eq } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { decrypt, encrypt } from '../src/crypto/secrets.ts';
import { sessions, userIdentities, users } from '../src/db/schema.ts';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { setSetting } from '../src/services/settings.ts';
import type { OidcSettings } from '../src/sso/oidc.ts';
import { checkOidcSessions } from '../src/sso/oidcCheck.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
import { startFakeOidc } from './fake-oidc.ts';
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 };
idp.st.refreshOk = true;
idp.st.down = false;
idp.st.refreshIdToken = true;
});
/** The browser's round trip: start → provider → callback. */
async function flow(cookie?: string, query = '') {
const connecting = query === '?connect=1';
// Connect is a POST from My account (with the CSRF header); sign-in a GET from the button.
const start = connecting
? await t.app.inject({
method: 'POST',
url: '/api/auth/oidc/connect',
headers: { ...H, cookie: cookie! },
payload: {},
})
: await t.app.inject({
url: `/api/auth/oidc/start${query}`,
headers: cookie ? { cookie } : {},
});
const location = connecting ? (start.json() as { url: string }).url : start.headers.location;
expect(start.statusCode).toBe(connecting ? 200 : 302);
const pending = start.cookies.find((c) => c.name === 'sp_oidc');
if (!pending) return { start, callback: null };
const atIdp = await fetch(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('never sends the browser to another site after sign-in (a tab, a backslash, //)', async () => {
await t.addUser('nextcheck@example.com', 'next-check-pw-1');
idp.st.user = { sub: 'u-next', email: 'nextcheck@example.com', email_verified: true };
const pw = await t.login('nextcheck@example.com', 'next-check-pw-1');
await flow(pw, '?connect=1');
for (const next of [
'/\t/evil.example',
'/\n/evil.example',
'/\\evil.example',
'//evil.example',
])
expect(
(await flow(undefined, `?next=${encodeURIComponent(next)}`)).callback!.headers.location,
).toBe('/');
expect((await flow(undefined, '?next=/me/calls')).callback!.headers.location).toBe(
'/me/calls',
);
});
it('connect is a POST with the CSRF header; a GET with connect=1 only signs in', async () => {
const ann = await t.login(ADMIN.email, ADMIN.password);
const forged = await t.app.inject({
method: 'POST',
url: '/api/auth/oidc/connect',
headers: { cookie: ann },
payload: {},
});
expect(forged.statusCode).toBe(403);
expect(forged.json().error.code).toBe('csrf');
const r = await t.app.inject({
url: '/api/auth/oidc/start?connect=1',
headers: { cookie: ann },
});
const pending = r.cookies.find((c) => c.name === 'sp_oidc')!;
expect(JSON.parse(decrypt(pending.value)).connectSessionId).toBeNull();
});
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');
});
/** Ann's OpenID session, connected again (an earlier test disconnects her). */
async function annSession() {
const pw = await t.login('ann@example.com', 'ann-password-12');
await flow(pw, '?connect=1');
const { callback } = await flow();
const cookie = session(callback!)!;
const [row] = await t.db
.select()
.from(sessions)
.where(eq(sessions.method, 'oidc'))
.orderBy(desc(sessions.idpCheckedAt))
.limit(1);
return { cookie, id: row!.id };
}
const later = (min: number) => new Date(Date.now() + min * 60_000);
const check = (min: number) => checkOidcSessions({ db: t.db, log: t.app.log, now: later(min) });
const row = async (id: string) =>
(await t.db.select().from(sessions).where(eq(sessions.id, id)))[0];
it('the session check: kept while the provider says yes, ended when it says no (§3.5)', async () => {
const { cookie, id } = await annSession();
const signedIn = (await row(id))!.idpCheckedAt!.getTime();
await check(5); // checked at sign-in: not due yet
expect((await row(id))!.idpCheckedAt!.getTime()).toBe(signedIn);
await check(16);
expect((await row(id))!.idpCheckedAt!.getTime()).toBeGreaterThan(signedIn);
idp.st.down = true; // an outage signs no one out
await check(40);
expect(await row(id)).toBeTruthy();
idp.st.down = false;
idp.st.user = { ...idp.st.user, groups: ['pbx-admins'] };
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, adminGroups: ['pbx-admins'] });
try {
await check(56); // groups changed at the provider: the role follows
const [ann] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
expect(ann!.role).toBe('admin');
await t.db.update(users).set({ role: 'user' }).where(eq(users.id, ann!.id));
} finally {
await setSetting(t.db, 'auth.oidc', oidcSettings);
}
idp.st.refreshOk = false; // disabled at the provider
await check(72);
expect(await row(id)).toBeUndefined();
expect((await t.app.inject({ url: '/api/auth/me', headers: { cookie } })).statusCode).toBe(
401,
);
});
it('the session check ends a session no longer in the allowed groups', async () => {
idp.st.user = { ...idp.st.user, groups: ['pbx-users'] };
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, allowedGroups: ['pbx-users'] });
try {
const { id } = await annSession();
idp.st.user.groups = [];
await check(20);
expect(await row(id)).toBeUndefined();
} finally {
await setSetting(t.db, 'auth.oidc', oidcSettings);
}
});
it('the session check reads roles that are only in the ID token, and keeps a session whose refresh brings no groups', async () => {
idp.st.user = { ...idp.st.user, roles: ['pbx-users'] }; // Entra: app roles, ID token only
await setSetting(t.db, 'auth.oidc', {
...oidcSettings,
groupsClaim: 'roles',
allowedGroups: ['pbx-users'],
});
try {
const { id } = await annSession();
await check(20);
expect(await row(id)).toBeTruthy();
idp.st.refreshIdToken = false; // no new ID token: no roles claim at all
await check(40);
expect(await row(id)).toBeTruthy();
idp.st.refreshIdToken = true;
idp.st.user.roles = []; // the claim is there, and empty: ended
await check(60);
expect(await row(id)).toBeUndefined();
} finally {
await setSetting(t.db, 'auth.oidc', oidcSettings);
}
});
});
+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 08 §2.4: every route is rate limited per client IP, Test buttons more, and a signed-in
// person's password asked again counts like a sign-in.
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('rate limits (08 §2.4)', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let admin: string;
beforeAll(async () => {
t = await createTestApp(undefined, { RATE_LIMIT_PER_MINUTE: '30' });
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
});
afterAll(() => t?.close());
it('Test buttons: 10 a minute', async () => {
const codes: number[] = [];
for (let i = 0; i < 11; i++)
codes.push(
(
await t.app.inject({
method: 'POST',
url: '/api/settings/smtp/test',
headers: { ...H, cookie: admin },
payload: {},
})
).statusCode,
);
expect(codes.slice(0, 10)).not.toContain(429);
expect(codes[10]).toBe(429);
});
it('a password asked again counts like a sign-in: guessing it is stopped', async () => {
resetLoginLimiter();
const codes: number[] = [];
for (let i = 0; i < 6; i++)
codes.push(
(
await t.app.inject({
method: 'POST',
url: '/api/auth/change-password',
headers: { ...H, cookie: admin },
payload: { currentPassword: `guess-${i}-wrong`, newPassword: 'another-password-1' },
})
).statusCode,
);
expect(codes.slice(0, 5)).toEqual([400, 400, 400, 400, 400]);
expect(codes[5]).toBe(429);
resetLoginLimiter();
});
it('/prov keeps its own limits (07 §2), outside the global one', async () => {
const prov = await t.app.inject({ url: '/prov/001565000000.cfg' });
expect(prov.headers['x-ratelimit-limit']).toBeUndefined();
const api = await t.app.inject({ url: '/api/auth/methods' });
expect(api.headers['x-ratelimit-limit']).toBe('30');
});
it('every other route: the global limit per IP, as a 429 rate_limited', async () => {
let r;
for (let i = 0; i < 40; i++) {
r = await t.app.inject({ url: '/api/auth/methods' });
if (r.statusCode === 429) break;
}
expect(r!.statusCode).toBe(429);
expect(r!.json().error.code).toBe('rate_limited');
});
});
+3 -3
View File
@@ -15,7 +15,7 @@ const EXPECTED: Record<string, Record<string, string>> = {
id: 'uuid|NO|gen_random_uuid()',
email: 'USER-DEFINED:citext|NO|',
display_name: 'text|NO|',
password_hash: 'text|NO|',
password_hash: 'text|YES|',
role: "USER-DEFINED:user_role|NO|'user'::user_role",
is_active: 'boolean|NO|true',
totp_secret_enc: 'text|YES|',
@@ -115,11 +115,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.());
it('creates all 43 tables', async () => {
it('creates all 44 tables', async () => {
const r = await db.execute(
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
);
expect(r.rows[0]).toEqual({ n: 43 });
expect(r.rows[0]).toEqual({ n: 44 });
});
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
+233
View File
@@ -0,0 +1,233 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 S1: ways to sign in, the sign-in mode, and the admin with a password kept as the way in.
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
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 { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('sign-in (12 S1)', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let admin: string;
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
});
afterAll(() => t?.close());
beforeEach(() => resetLoginLimiter());
const post = (url: string, payload: object, cookie?: string) =>
t.app.inject({ method: 'POST', url, payload, headers: { ...H, ...(cookie && { cookie }) } });
it('GET /api/auth/methods is public and offers passwords only', async () => {
const r = await t.app.inject({ url: '/api/auth/methods' });
expect(r.statusCode).toBe(200);
expect(r.json()).toEqual({ mode: 'password', ldap: false, oidc: null });
});
it('signs in with `login`, and still with `email`; the session says how', async () => {
const r = await post('/api/auth/login', { login: ADMIN.email, password: ADMIN.password });
expect(r.statusCode).toBe(200);
expect(r.json().user.signIn).toEqual({ password: true, oidc: null, ldap: false });
const rows = await t.db.select({ method: sessions.method }).from(sessions);
expect(rows.every((s) => s.method === 'password')).toBe(true);
});
it('a user without a password fails like a wrong password, and has none to change', async () => {
const u = await t.addUser('nopw@example.com', 'temporary-pass-1');
const cookie = await t.login('nopw@example.com', 'temporary-pass-1');
await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, u.id));
const r = await post('/api/auth/login', { login: 'nopw@example.com', password: 'anything' });
expect(r.statusCode).toBe(401);
expect(r.json().error.message).toBe('Wrong email or password');
const c = await post(
'/api/auth/change-password',
{ currentPassword: 'x', newPassword: 'a-new-password-1' },
cookie,
);
expect(c.statusCode).toBe(400);
});
it('users show their connected accounts', async () => {
const u = await t.addUser('linked@example.com', 'linked-password-1');
await t.db
.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', groups: [] },
ldap: false,
});
});
it('auth.mode can only be password until OpenID Connect is set up', async () => {
const r = t.app.inject({
method: 'PATCH',
url: '/api/settings',
payload: { 'auth.mode': 'mixed' },
headers: { ...H, cookie: admin },
});
expect((await r).statusCode).toBe(400);
});
it('OpenID-only mode refuses passwords, except for admins', async () => {
await t.addUser('plain@example.com', 'plain-password-1');
await setSetting(t.db, 'auth.mode', 'oidc');
try {
const r = await post('/api/auth/login', {
login: 'plain@example.com',
password: 'plain-password-1',
});
expect(r.statusCode).toBe(403);
expect(r.json().error.code).toBe('password_sign_in_off');
const a = await post('/api/auth/login', { login: ADMIN.email, password: ADMIN.password });
expect(a.statusCode).toBe(200);
} finally {
await setSetting(t.db, 'auth.mode', 'password');
}
});
it('the last active admin with a password stays, even with other admins', async () => {
const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
const other = await t.addUser('sso-admin@example.com', 'sso-admin-pass-1', 'admin');
await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, other.id));
const demote = await t.app.inject({
method: 'PATCH',
url: `/api/users/${me!.id}`,
payload: { role: 'user' },
headers: { ...H, cookie: admin },
});
expect(demote.statusCode).toBe(409);
expect(demote.json().error.message).toBe('This is the last active admin with a password');
// An admin without a password can go: the one with a password is still there.
const del = await t.app.inject({
method: 'DELETE',
url: `/api/users/${other.id}`,
headers: { ...H, cookie: admin },
});
expect(del.statusCode).toBe(200);
});
const patch = (payload: object, cookie = admin) =>
t.app.inject({ method: 'PATCH', url: '/api/settings', payload, headers: { ...H, cookie } });
it('a stored secret never goes to a new server: the directory, OpenID and email', async () => {
const ldap = {
enabled: false,
preset: 'lldap',
url: 'ldaps://dir.example.com',
startTls: false,
bindDn: 'uid=svc,dc=example,dc=com',
bindPassword: 'service-pw-1',
baseDn: 'dc=example,dc=com',
userFilter: '(uid={login})',
idAttribute: 'entryUUID',
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: '',
};
expect((await patch({ 'auth.ldap': ldap })).statusCode).toBe(200);
const { bindPassword: _, ...keep } = ldap;
expect((await patch({ 'auth.ldap': keep })).statusCode).toBe(200); // same server: kept
const moved = await patch({ 'auth.ldap': { ...keep, url: 'ldaps://evil.example' } });
expect(moved.statusCode).toBe(400);
expect(moved.json().error.message).toMatch(/enter its password again/);
const test = await post(
'/api/settings/auth/ldap/test',
{ ...keep, url: 'ldaps://evil.example' },
admin,
);
expect(test.statusCode).toBe(400); // Test too: it would bind there with the stored password
const otherDn = await patch({
'auth.ldap': { ...keep, bindDn: 'uid=other,dc=example,dc=com' },
});
expect(otherDn.statusCode).toBe(400);
const oidc = {
label: 'Keycloak',
issuer: 'https://kc.example.com/realms/x',
clientId: 'selfphoneng',
clientSecret: 'client-secret-1',
scopes: 'openid email profile',
groupsClaim: 'groups',
adminGroups: [],
allowedGroups: [],
trustEmail: false,
};
expect((await patch({ 'auth.oidc': oidc })).statusCode).toBe(200);
const { clientSecret: __, ...noSecret } = oidc;
const newIssuer = await patch({ 'auth.oidc': { ...noSecret, issuer: 'https://evil.example' } });
expect(newIssuer.statusCode).toBe(400);
expect(newIssuer.json().error.message).toMatch(/client secret again/);
expect((await patch({ 'auth.oidc': noSecret })).json()['auth.oidc'].hasClientSecret).toBe(true);
const smtp = {
host: 'mail.example.com',
port: 587,
secure: false,
username: 'pbx',
password: 'smtp-pw-1',
from: 'PBX <pbx@example.com>',
};
expect((await patch({ smtp })).statusCode).toBe(200);
const { password: ___, ...smtpKeep } = smtp;
expect((await patch({ smtp: smtpKeep })).json().smtp.hasPassword).toBe(true);
expect(
(await patch({ smtp: { ...smtpKeep, host: 'evil.example' } })).json().smtp.hasPassword,
).toBe(false);
await patch({ 'auth.ldap': null, 'auth.oidc': null, smtp: null });
});
it('a role set by the directory or the provider is read-only here (409 role_managed)', async () => {
const u = await t.addUser('managed@example.com', 'managed-pw-12');
await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: 'managed-id' });
const ldap = {
enabled: true,
preset: 'lldap' as const,
url: 'ldaps://dir.example.com',
startTls: false,
bindDn: null,
bindPasswordEnc: null,
baseDn: 'dc=example,dc=com',
userFilter: '(uid={login})',
idAttribute: 'entryUUID',
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: null,
};
await setSetting(t.db, 'auth.ldap', ldap);
const setRole = (role: string) =>
t.app.inject({
method: 'PATCH',
url: `/api/users/${u.id}`,
payload: { role },
headers: { ...H, cookie: admin },
});
try {
expect((await setRole('admin')).statusCode).toBe(200); // no admin filter: set here
await setRole('user');
await setSetting(t.db, 'auth.ldap', { ...ldap, adminFilter: '(memberOf=cn=pbx-admins)' });
const r = await setRole('admin');
expect(r.statusCode).toBe(409);
expect(r.json().error.code).toBe('role_managed');
const dto = await t.app.inject({ url: `/api/users/${u.id}`, headers: { cookie: admin } });
expect(dto.json().roleFrom).toBe('the directory');
} finally {
await setSetting(t.db, 'auth.ldap', null);
}
});
it('an empty Contrack update is a 400, not a 500', async () => {
const r = await t.app.inject({
method: 'PATCH',
url: '/api/me/contrack',
payload: {},
headers: { ...H, cookie: admin },
});
expect(r.statusCode).toBe(400);
});
});
+30 -4
View File
@@ -1,6 +1,11 @@
// 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 {
LdapInput,
LdapTestResult,
AuthMethods,
OidcInput,
OidcTestResult,
AboutDto,
SystemEventDto,
SystemStatus,
@@ -149,9 +154,9 @@ async function upload<T>(method: string, url: string, body: Blob): Promise<T> {
export const authApi = {
me: () => api<{ user: AuthUser }>('GET', '/api/auth/me').then((r) => r.user),
login: (email: string, password: string, totp?: string) =>
login: (login: string, password: string, totp?: string) =>
api<{ user: AuthUser }>('POST', '/api/auth/login', {
email,
login,
password,
...(totp ? { totp } : {}),
}).then((r) => r.user),
@@ -163,20 +168,31 @@ 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<AuthMethods>('GET', '/api/auth/methods'),
/** 12 §3.3: disconnect my OpenID account. */
disconnectOidc: () => api<{ ok: true }>('DELETE', '/api/me/identities/oidc'),
/** 12 §3.3: where to send the browser to connect my OpenID account. */
connectOidc: (next: string) => api<{ url: string }>('POST', '/api/auth/oidc/connect', { next }),
/** 12 §4.3: connect my directory account with its username and password. */
connectLdap: (login: string, password: string) =>
api<{ ok: true }>('POST', '/api/me/identities/ldap', { login, password }),
changePassword: (currentPassword: string, newPassword: string) =>
api<{ ok: true }>('POST', '/api/auth/change-password', { currentPassword, newPassword }),
};
export const usersApi = {
list: () => api<ListResponse<UserDto>>('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<UserDto>('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`, {}),
disconnect: (id: string, kind: 'oidc' | 'ldap') =>
api<{ ok: true }>('DELETE', `/api/users/${id}/identities/${kind}`),
};
export const extensionsApi = {
@@ -237,8 +253,18 @@ export const settingsApi = {
'/api/settings/emergency/test',
),
warnings: () => api<{ items: Warning[] }>('GET', '/api/system/warnings').then((r) => r.items),
testOidc: (v: OidcInput) => api<OidcTestResult>('POST', '/api/settings/auth/oidc/test', v),
/** 12 §4.1 */
testLdap: (v: LdapInput & { testLogin?: string; testPassword?: string }) =>
api<LdapTestResult>('POST', '/api/settings/auth/ldap/test', v),
};
/** Sign out, and when the session came from OpenID, at the provider too (12 §3.5). */
export async function signOut(): Promise<void> {
const r = await authApi.logout().catch(() => null);
if (r?.redirect) window.location.assign(r.redirect);
}
export const aboutApi = { get: () => api<AboutDto>('GET', '/api/about') };
export const aiApi = {
+170
View File
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My sign-in ways (12 §5): the password, connecting or disconnecting an OpenID account, and
// connecting a directory account.
import { useState } from 'react';
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, Field, inputClass } 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 connect = useMutation({
mutationFn: () => authApi.connectOidc(loc.pathname),
onSuccess: (r) => window.location.assign(r.url),
});
const ways = me.data?.signIn;
const oidc = methods.data?.oidc;
if (!ways) return null;
const error = params.get('sso_error');
return (
<section className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">Sign-in</h2>
{params.get('sso') === 'connected' && (
<p role="status" className="text-sm text-green-700 dark:text-green-400">
Connected. You can now sign in with {oidc?.label ?? 'OpenID'}.
</p>
)}
{error && <Alert>{ssoErrorText(error, oidc?.label ?? 'OpenID')}</Alert>}
<div className="flex flex-col gap-1 text-sm">
<h3 className="font-medium">Password</h3>
{ways.password ? (
<Link className="text-sky-700 underline dark:text-sky-400" to="/change-password">
Change password
</Link>
) : (
<p className="text-zinc-500">
You have no password here. An admin can give you one with Reset password.
</p>
)}
</div>
{ways.ldap ? (
<p className="text-sm">
Your company directory account is connected: its password works here too. Only an admin
can disconnect it.
</p>
) : (
methods.data?.ldap && <DirectoryConnect />
)}
{oidc && (
<div className="flex flex-col gap-2 text-sm">
<h3 className="font-medium">{oidc.label}</h3>
{ways.oidc ? (
<>
<p>
Connected{ways.oidc.email ? ` as ${ways.oidc.email}` : ''}. You can sign in with{' '}
{oidc.label}
{ways.password ? ' or your password' : ''}.
</p>
<div>
<Button onClick={() => disconnect.mutate()} disabled={disconnect.isPending}>
Disconnect {oidc.label}
</Button>
</div>
{disconnect.error && <Alert>{disconnect.error.message}</Alert>}
</>
) : (
<>
<p className="text-zinc-500">
Connect your {oidc.label} account to sign in with it as well.
</p>
<div>
{/* The browser goes to the provider and comes back (a POST first: 12 §3.3). */}
<Button
onClick={() => connect.mutate()}
disabled={connect.isPending || connect.isSuccess}
>
Connect {oidc.label}
</Button>
</div>
{connect.error && <Alert>{connect.error.message}</Alert>}
</>
)}
</div>
)}
</section>
);
}
/** Connect my directory account (12 §4.3): its username and password, checked by the directory. */
function DirectoryConnect() {
const qc = useQueryClient();
const [v, setV] = useState({ login: '', password: '' });
const connect = useMutation({
mutationFn: () => authApi.connectLdap(v.login, v.password),
onSuccess: () => {
setV({ login: '', password: '' });
return qc.invalidateQueries({ queryKey: ['me'] });
},
});
return (
<form
className="flex flex-col gap-2 text-sm"
onSubmit={(e) => {
e.preventDefault();
connect.mutate();
}}
>
<h3 className="font-medium">Company directory</h3>
<p className="text-zinc-500">
Connect your directory account to sign in with its password as well.
</p>
<Field label="Directory username or email">
{(a) => (
<input
{...a}
className={inputClass}
autoComplete="username"
value={v.login}
onChange={(e) => setV({ ...v, login: e.target.value })}
/>
)}
</Field>
<Field label="Directory password">
{(a) => (
<input
{...a}
type="password"
className={inputClass}
autoComplete="current-password"
value={v.password}
onChange={(e) => setV({ ...v, password: e.target.value })}
/>
)}
</Field>
<div>
<Button type="submit" disabled={connect.isPending || !v.login || !v.password}>
Connect directory account
</Button>
</div>
{connect.error && <Alert>{connect.error.message}</Alert>}
</form>
);
}
+53 -9
View File
@@ -4,9 +4,10 @@ import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { APP_NAME, LoginSchema } from '@selfphoneng/shared';
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 }) {
@@ -32,15 +33,21 @@ export function LoginPage() {
// Two-step sign-in: shown once the server says the password was right and a code is needed.
const [needCode, setNeedCode] = useState(false);
const [code, setCode] = useState('');
const form = useForm({ resolver: zodResolver(LoginSchema.omit({ totp: true })) });
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 ({ email, password }) => {
const onSubmit = form.handleSubmit(async ({ login, password }) => {
setError(undefined);
try {
const user = await authApi.login(email, password, needCode ? code : undefined);
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,14 +63,51 @@ export function LoginPage() {
return (
<AuthCard title="Sign in">
<form onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
{ssoError && (
<div className="mb-4">
<Alert>{ssoErrorText(ssoError, oidc?.label ?? 'OpenID')}</Alert>
</div>
)}
{oidc && (
<div className="mb-4 flex flex-col gap-3">
{/* A full page load: the browser goes to the provider and comes back signed in. */}
<a
href={`/api/auth/oidc/start${next ? `?next=${encodeURIComponent(next)}` : ''}`}
className="rounded-md bg-sky-700 px-4 py-2 text-center text-sm font-medium text-white hover:bg-sky-800"
>
Sign in with {oidc.label}
</a>
{showForm && (
<p className="text-center text-xs text-zinc-500" aria-hidden>
or with your password
</p>
)}
</div>
)}
{!showForm && (
<button
type="button"
className="w-full text-center text-xs text-zinc-500 hover:underline"
onClick={() => setAdminForm(true)}
>
Admin sign-in
</button>
)}
<form
onSubmit={onSubmit}
className={`flex flex-col gap-4 ${showForm ? '' : 'hidden'}`}
noValidate
>
{error && <Alert>{error}</Alert>}
<Field label="Email" error={errors.email?.message}>
<Field
label={methods.data?.ldap ? 'Email or username' : 'Email'}
error={errors.login?.message}
>
{(a) => (
<input
{...a}
{...form.register('email')}
type="email"
{...form.register('login')}
type={methods.data?.ldap ? 'text' : 'email'}
autoComplete="username"
className={inputClass}
/>
+1 -1
View File
@@ -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]);
}
+7 -9
View File
@@ -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 (
<section className="flex max-w-xl flex-col gap-6">
<h1 className="text-xl font-semibold">My account</h1>
<section className="flex flex-col gap-2">
<h2 className="font-medium">Password</h2>
<Link className="text-sm text-sky-700 underline dark:text-sky-400" to="/change-password">
Change password
</Link>
</section>
<TwoStep />
<SignInCard />
{/* Two-step sign-in guards the password, so it needs one (12 §1). */}
{me.data?.signIn.password && <TwoStep />}
</section>
);
}
+2 -2
View File
@@ -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 });
}
+433
View File
@@ -8,6 +8,10 @@ import {
WEBHOOK_EVENTS,
formatNumber,
type AiTestResult,
LDAP_PRESETS,
type AuthMode,
type LdapInput,
type OidcInput,
type SettingsDto,
type WebhookDto,
type WebhookEvent,
@@ -38,6 +42,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 +79,12 @@ export function SettingsPage() {
{tab === 'tokens' && <TokensTab />}
{settings.data && tab === 'ai' && <AiTab s={settings.data} />}
{settings.data && tab === 'contacts' && <ContactsTab s={settings.data} />}
{settings.data && tab === 'sign-in' && (
<div className="flex flex-col gap-10">
<SignInTab s={settings.data} />
<DirectoryForm s={settings.data} />
</div>
)}
</section>
);
}
@@ -1361,3 +1372,425 @@ export function ContactsTab({ s }: { s: SettingsDto }) {
</form>
);
}
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<AuthMode>(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) => (
<Field label={label} hint={hint}>
{(a) => (
<input
{...a}
className={inputClass}
placeholder={placeholder}
value={v[k] as string}
onChange={set(k)}
/>
)}
</Field>
);
const area = (k: 'adminGroups' | 'allowedGroups', label: string, hint: string) => (
<Field label={label} hint={hint}>
{(a) => <textarea {...a} rows={2} className={inputClass} value={v[k]} onChange={set(k)} />}
</Field>
);
const modes = [
['password', 'Passwords', 'Email and password, as set up here.'],
[
'mixed',
'Passwords or OpenID',
`Both. People connect their ${v.label} account in their settings. Disabling someone in ${v.label} ends their ${v.label} sign-ins, but their password keeps working until you disable them on Users.`,
],
[
'oidc',
'OpenID only',
`Only ${v.label} decides who gets in. Admins can still sign in with a password (Admin sign-in on the login page), for when ${v.label} is down.`,
],
] as const;
return (
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
save.mutate({ 'auth.oidc': v.issuer.trim() ? input() : null, 'auth.mode': mode });
setV({ ...v, clientSecret: '' });
}}
>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">How people sign in</legend>
{modes.map(([id, title, about]) => (
<label key={id} className="flex items-start gap-2 text-sm">
<input
type="radio"
name="auth-mode"
checked={mode === id}
onChange={() => setMode(id)}
/>
<span>
<span className="font-medium">{title}</span>: {about}
</span>
</label>
))}
</fieldset>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">OpenID Connect</legend>
<p className="text-sm text-zinc-500">
Keycloak, Authentik, Authelia, Zitadel, Entra ID, Google and others. At the provider, make
a confidential client using the standard (authorization code) flow, and add these:
</p>
<dl className="flex flex-col gap-2 text-sm">
{uris.map(([label, uri]) => (
<div key={label} className="flex flex-wrap items-center gap-2">
<dt className="w-48 text-zinc-500">{label}</dt>
<dd className="flex items-center gap-2">
<code className="rounded bg-zinc-100 px-2 py-1 dark:bg-zinc-800">{uri}</code>
<Button onClick={() => void navigator.clipboard?.writeText(uri)}>Copy</Button>
</dd>
</div>
))}
</dl>
{text('label', 'Button name', 'Shown as "Sign in with …"')}
{text(
'issuer',
'Issuer',
'Exactly as the provider writes it. Leave empty to turn OpenID Connect off.',
'https://kc.example.com/realms/company',
)}
{text('clientId', 'Client ID')}
<Field
label="Client secret"
hint={
o?.hasClientSecret
? 'Saved. Type a new one to replace it.'
: 'Empty for a public client (PKCE only)'
}
>
{(a) => (
<input
{...a}
type="password"
autoComplete="off"
placeholder={o?.hasClientSecret ? '••••••••' : ''}
className={inputClass}
value={v.clientSecret}
onChange={set('clientSecret')}
/>
)}
</Field>
{text(
'scopes',
'Scopes',
'Add offline_access so people who keep the softphone open stay signed in past the provider’s session limit.',
)}
{text(
'groupsClaim',
'Groups claim',
'Where the provider puts groups or roles: groups, roles, realm_access.roles, resource_access.<client>.roles …',
)}
{area(
'adminGroups',
'Admin groups',
'One per line, exactly as sent (Keycloak with full group path: /pbx-admins). Members are admins, others users. Empty: you set roles on Users.',
)}
{area(
'allowedGroups',
'Allowed groups',
'One per line. Empty: anyone with a connected account.',
)}
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
className="mt-0.5 size-4"
checked={v.trustEmail}
onChange={(e) => setV({ ...v, trustEmail: e.target.checked })}
/>
<span>
Trust email addresses without “email verified”. Turn on only if people can’t change
their own email at {v.label} (a single company’s Keycloak with email editing off, a
single-tenant Entra ID). Only used to match people who have no password here.
</span>
</label>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={() => test.mutate()} disabled={test.isPending || !v.issuer.trim()}>
Test
</Button>
{test.data && (
<span
role="status"
className={`text-sm ${test.data.ok ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}`}
>
{test.data.ok
? `Works: ${test.data.issuer}. Signing: ${test.data.algorithms.join(', ') || '?'}. ${test.data.refreshTokens ? 'Refresh tokens' : 'No refresh tokens'}, ${test.data.endSession ? 'signs out there too' : 'no sign-out endpoint'}.`
: test.data.error}
</span>
)}
{test.error && <Alert>{test.error.message}</Alert>}
</div>
<p className="text-xs text-zinc-500">
Then connect your own account in My account, and sign in from a private window.
</p>
</fieldset>
{save.isError && <Alert>{save.error.message}</Alert>}
{save.isSuccess && (
<p role="status" className="text-sm text-green-700 dark:text-green-400">
Saved.
</p>
)}
<div>
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
</div>
</form>
);
}
/** Sign-in → Directory (spec 12 §4.1): LDAP / LDAPS, checked when a password isn't ours. */
function DirectoryForm({ s }: { s: SettingsDto }) {
const save = useSave();
const d = s['auth.ldap'];
const [v, setV] = useState({
enabled: d?.enabled ?? false,
preset: d?.preset ?? ('ad' as LdapInput['preset']),
url: d?.url ?? '',
startTls: d?.startTls ?? false,
bindDn: d?.bindDn ?? '',
bindPassword: '',
baseDn: d?.baseDn ?? '',
userFilter: d?.userFilter ?? LDAP_PRESETS.ad.userFilter,
idAttribute: d?.idAttribute ?? LDAP_PRESETS.ad.idAttribute,
emailAttribute: d?.emailAttribute ?? 'mail',
nameAttribute: d?.nameAttribute ?? 'displayName',
adminFilter: d?.adminFilter ?? '',
});
const [probe, setProbe] = useState({ testLogin: '', testPassword: '' });
const input = (): LdapInput => ({ ...v, bindPassword: v.bindPassword || undefined });
const test = useMutation({ mutationFn: () => settingsApi.testLdap({ ...input(), ...probe }) });
const set = (k: keyof typeof v) => (e: { target: { value: string } }) =>
setV({ ...v, [k]: e.target.value });
const preset = (p: LdapInput['preset']) => {
const x = LDAP_PRESETS[p];
setV({
...v,
preset: p,
userFilter: x.userFilter,
idAttribute: x.idAttribute,
adminFilter: x.adminFilter,
});
};
const text = (k: keyof typeof v, label: string, hint?: string, placeholder?: string) => (
<Field label={label} hint={hint}>
{(a) => (
<input
{...a}
className={inputClass}
placeholder={placeholder}
value={v[k] as string}
onChange={set(k)}
/>
)}
</Field>
);
return (
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
save.mutate({ 'auth.ldap': v.url.trim() ? input() : null });
setV({ ...v, bindPassword: '' });
}}
>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">Directory (LDAP)</legend>
<p className="text-sm text-zinc-500">
When a password isn&apos;t the one set here, SelfPhoneNG asks the directory. People sign
in with their directory username or email; the first time, they&apos;re matched to their
user here by email. Nobody gets a user just for being in the directory.
</p>
<p className="text-sm text-zinc-500">
Every hour, someone no longer in the directory (or no longer matched by the user filter)
is disabled here, password and OpenID sign-in included. It never enables anyone again: do
that on Users.
</p>
{s['system.directoryWarning'] && (
<p role="alert" className="text-sm text-red-700 dark:text-red-400">
{s['system.directoryWarning']}
</p>
)}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4"
checked={v.enabled}
onChange={(e) => setV({ ...v, enabled: e.target.checked })}
/>
Check passwords against the directory
</label>
<Field label="Directory type" hint="Fills in the filters below; change them as needed">
{(a) => (
<select
{...a}
className={inputClass}
value={v.preset}
onChange={(e) => preset(e.target.value as LdapInput['preset'])}
>
{Object.entries(LDAP_PRESETS).map(([id, p]) => (
<option key={id} value={id}>
{p.label}
</option>
))}
</select>
)}
</Field>
{text(
'url',
'Address',
'ldaps:// (port 636), or ldap:// with StartTLS. Plain ldap:// only to this server itself.',
'ldaps://dc1.example.com',
)}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4"
checked={v.startTls}
onChange={(e) => setV({ ...v, startTls: e.target.checked })}
/>
StartTLS (for ldap:// addresses)
</label>
{text(
'bindDn',
'Service account DN',
'An account that may search people. Empty: search anonymously.',
'CN=pbx-search,OU=Service,DC=example,DC=com',
)}
<Field
label="Service account password"
hint={d?.hasBindPassword ? 'Saved. Type a new one to replace it.' : undefined}
>
{(a) => (
<input
{...a}
type="password"
autoComplete="off"
placeholder={d?.hasBindPassword ? '••••••••' : ''}
className={inputClass}
value={v.bindPassword}
onChange={set('bindPassword')}
/>
)}
</Field>
{text(
'baseDn',
'Where people are',
'Searched with everything under it',
'DC=example,DC=com',
)}
{text(
'userFilter',
'User filter',
'{login} is what they type. To allow one group only, add (memberOf=…) here.',
)}
{text(
'adminFilter',
'Admin filter',
'People it matches are admins, the others users. Empty: you set roles on Users.',
)}
{text(
'idAttribute',
'ID attribute',
'Never changes for a person: objectGUID, entryUUID, ipaUniqueID',
)}
{text('emailAttribute', 'Email attribute')}
{text('nameAttribute', 'Name attribute')}
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Test login (optional)" hint="Username or email">
{(a) => (
<input
{...a}
autoComplete="off"
className={inputClass}
value={probe.testLogin}
onChange={(e) => setProbe({ ...probe, testLogin: e.target.value })}
/>
)}
</Field>
<Field label="Its password" hint="Only used for this test, never stored">
{(a) => (
<input
{...a}
type="password"
autoComplete="off"
className={inputClass}
value={probe.testPassword}
onChange={(e) => setProbe({ ...probe, testPassword: e.target.value })}
/>
)}
</Field>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={() => test.mutate()} disabled={test.isPending || !v.url.trim()}>
Test
</Button>
{test.data && (
<span
role="status"
className={`text-sm ${test.data.ok ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}`}
>
{!test.data.ok
? test.data.error
: `Works: ${test.data.matching >= 1000 ? '1000+' : test.data.matching} people match the user filter.` +
(test.data.user
? ` ${test.data.user.dn}: ${test.data.user.email ?? 'no email'}, ${test.data.user.admin === null ? 'role set here' : test.data.user.admin ? 'admin' : 'user'}, ${test.data.user.matches ? `signs in as ${test.data.user.matches}` : 'no user here has that email'}.`
: '')}
</span>
)}
{test.error && <Alert>{test.error.message}</Alert>}
</div>
</fieldset>
{save.isError && <Alert>{save.error.message}</Alert>}
{save.isSuccess && (
<p role="status" className="text-sm text-green-700 dark:text-green-400">
Saved.
</p>
)}
<div>
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
</div>
</form>
);
}
+53 -3
View File
@@ -22,6 +22,7 @@ import { Dialog } from '../../components/Dialog.tsx';
import { MohOptions } from '../../components/MohOptions.tsx';
import { PhoneAppSetup } from '../../components/PhoneAppSetup.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { useAuthMethods } from '../../components/SignInCard.tsx';
type Tab = 'general' | 'phone' | 'calling' | 'emergency' | 'credentials';
@@ -164,7 +165,8 @@ function GeneralTab({
role: v.role,
});
userId = r.user.id;
created = { email: r.user.email, password: r.temporaryPassword };
// OpenID-only mode makes no password: they sign in with the provider (12 §3.3).
if (r.temporaryPassword) created = { email: r.user.email, password: r.temporaryPassword };
}
// The user's extension: create it, or renumber it.
if (v.number && !ext)
@@ -190,11 +192,20 @@ function GeneralTab({
label="Role"
error={errors.role?.message}
hint={
isSelf ? 'You cannot remove your own admin role if you are the last admin.' : undefined
user?.roleFrom
? `From ${user.roleFrom}: change it there.`
: isSelf
? 'You cannot remove your own admin role if you are the last admin.'
: undefined
}
>
{(a) => (
<select {...a} {...form.register('role')} className={inputClass}>
<select
{...a}
{...form.register('role')}
disabled={Boolean(user?.roleFrom)}
className={inputClass}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
@@ -238,6 +249,8 @@ function GeneralTab({
Creates a new temporary password and signs them out everywhere.
</p>
{user.twoStep && <ResetTwoStep user={user} />}
{user.signIn.oidc && <ConnectedAccount user={user} kind="oidc" />}
{user.signIn.ldap && <ConnectedAccount user={user} kind="ldap" />}
</div>
)}
</form>
@@ -584,6 +597,43 @@ function EmergencyTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void
);
}
/** A connected OpenID or directory account (12 §5): what it sent, and Disconnect. */
function ConnectedAccount({ user, kind }: { user: UserDto; kind: 'oidc' | 'ldap' }) {
const qc = useQueryClient();
const oidcLabel = useAuthMethods().data?.oidc?.label ?? 'OpenID';
const label = kind === 'oidc' ? oidcLabel : 'Directory';
const disconnect = useMutation({
mutationFn: () => usersApi.disconnect(user.id, kind),
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
});
const oidc = user.signIn.oidc;
return (
<div className="mt-4 flex flex-col gap-1 text-sm">
{kind === 'oidc' && oidc ? (
<>
<p>
<span className="font-medium">{label}</span>: {oidc.email ?? 'connected'}
</p>
<p className="text-xs text-zinc-500">
Groups at the last sign-in: {oidc.groups.length ? oidc.groups.join(', ') : 'none'}
</p>
</>
) : (
<p>
<span className="font-medium">{label}</span>: connected, their directory password works
here
</p>
)}
<div>
<Button onClick={() => disconnect.mutate()} disabled={disconnect.isPending}>
Disconnect {kind === 'oidc' ? label : 'the directory'}
</Button>
</div>
{disconnect.error && <Alert>{disconnect.error.message}</Alert>}
</div>
);
}
/** For a user who lost their phone and recovery codes (08 §2.4). */
function ResetTwoStep({ user }: { user: UserDto }) {
const qc = useQueryClient();
+12 -1
View File
@@ -9,6 +9,7 @@ import { Alert, Button } from '../../components/ui.tsx';
import { UserDrawer } from './UserDrawer.tsx';
import { StatusDot } from '../../components/StatusDot.tsx';
import { useLive } from '../../live/store.ts';
import { useAuthMethods } from '../../components/SignInCard.tsx';
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : 'Never');
@@ -129,6 +130,7 @@ function UsersTable({
onDelete: (u: UserDto) => void;
}) {
const live = useLive((s) => s.extensions);
const oidcLabel = useAuthMethods().data?.oidc?.label ?? 'OpenID';
if (users.length === 0) return <p className="text-sm text-zinc-500">No users yet.</p>;
const ext = (u: UserDto) =>
u.extension ? (
@@ -151,6 +153,11 @@ function UsersTable({
)}
</span>
);
// 12 §5: every way this user can sign in.
const ways = (u: UserDto) =>
[u.signIn.password && 'Password', u.signIn.oidc && oidcLabel, u.signIn.ldap && 'Directory']
.filter(Boolean)
.join(', ') || '–';
const actions = (u: UserDto) => (
<span className="flex gap-1">
<Button variant="ghost" onClick={() => onEdit(u)} aria-label={`Edit ${u.displayName}`}>
@@ -178,7 +185,9 @@ function UsersTable({
{badge(u)}
</div>
<div className="mt-2 flex items-center justify-between text-xs text-zinc-500">
<span>Last login: {fmtDate(u.lastLoginAt)}</span>
<span>
Last login: {fmtDate(u.lastLoginAt)} · {ways(u)}
</span>
{actions(u)}
</div>
</li>
@@ -191,6 +200,7 @@ function UsersTable({
<th className="py-2 font-medium">Name</th>
<th className="py-2 font-medium">Email</th>
<th className="py-2 font-medium">Role</th>
<th className="py-2 font-medium">Signs in with</th>
<th className="py-2 font-medium">Last login</th>
<th className="py-2 font-medium">
<span className="sr-only">Actions</span>
@@ -204,6 +214,7 @@ function UsersTable({
<td className="py-2 font-medium">{u.displayName}</td>
<td className="py-2">{u.email}</td>
<td className="py-2">{badge(u)}</td>
<td className="py-2">{ways(u)}</td>
<td className="py-2 text-zinc-500">{fmtDate(u.lastLoginAt)}</td>
<td className="py-2 text-right">{actions(u)}</td>
</tr>
+2 -2
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { Link, NavLink, Route, Routes, useNavigate } from 'react-router';
import type { AuthUser } from '@selfphoneng/shared';
import { authApi, meApi } from '../../api/client.ts';
import { meApi, signOut } from '../../api/client.ts';
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Alert, Brand, Button } from '../../components/ui.tsx';
import { subscribeLive, useLive } from '../../live/store.ts';
@@ -57,7 +57,7 @@ export function MePage({ user }: { user: AuthUser }) {
<Button
variant="ghost"
onClick={async () => {
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 });
}}
+2 -7
View File
@@ -4,10 +4,10 @@
// Softphone devices and notifications live in the softphone panel. "Set up a phone app" (07 §5).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Link } from 'react-router';
import { formatNumber, type CallReport, type MeDto, type MeUpdate } from '@selfphoneng/shared';
import { meApi } from '../../api/client.ts';
import { PhoneAppSetup } from '../../components/PhoneAppSetup.tsx';
import { SignInCard } from '../../components/SignInCard.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
/** "102" stays as is; outside numbers get their usual format. */
@@ -70,12 +70,7 @@ function SettingsForms({ me }: { me: MeDto }) {
</h2>
<PhoneAppSetup load={meApi.softphoneQr} />
</section>
<section className="flex flex-col gap-2">
<h2 className="text-lg font-semibold">Password</h2>
<Link className="text-sm text-sky-700 underline dark:text-sky-400" to="/change-password">
Change password
</Link>
</section>
<SignInCard />
</div>
);
}
+1
View File
@@ -12,6 +12,7 @@ keeping the system safe, legal and up to date.
7. [Analog phones (ATAs)](ata.md)
8. [Local AI](ai.md): voicemail transcripts
9. [Contacts](contacts.md): the builtin phonebook, or Contrack
10. [Sign-in](sign-in.md): passwords, and OpenID Connect (Keycloak and others)
---
+172
View File
@@ -0,0 +1,172 @@
# Sign-in: passwords, OpenID Connect and LDAP
People sign in to SelfPhoneNG (the admin pages, their own portal and the browser phone) with a
password, with your company's identity provider through **OpenID Connect**, or either. This only
changes the web sign-in: desk phones, phone apps and the browser phone's line keep their own SIP
passwords and never talk to the identity provider.
Any provider that speaks OpenID Connect works. This page uses **Keycloak**; the table at the end
says what is different for the others. A company directory (Active Directory, OpenLDAP, FreeIPA,
lldap …) can check passwords too: see [Directory (LDAP)](#directory-ldap).
## How it works
- You still make every user on **Users**. Signing in with the provider never creates an account.
- Each person **connects** their provider account once, in **My settings** (users) or **My
account** (admins): **Connect Keycloak**, sign in at Keycloak, done. From then on they sign in
with **Sign in with Keycloak** or with their password.
- **Settings → Sign-in → How people sign in** picks what the login page offers:
| Choice | Login page | Who can use a password |
|---|---|---|
| Passwords | the password form | everyone |
| Passwords or OpenID | the button and the form | everyone |
| OpenID only | the button, and a small **Admin sign-in** link | admins only |
Admins keep their password in every mode so you can still get in when the provider is down.
SelfPhoneNG also won't let you delete, disable or demote the last admin who has a password.
- Someone disabled in Keycloak loses their Keycloak sign-in to SelfPhoneNG within about 20
minutes: every 15 minutes SelfPhoneNG asks Keycloak whether each Keycloak session is still good.
With **Passwords or OpenID**, their SelfPhoneNG password still works until you disable them on
Users too. With **OpenID only**, Keycloak alone decides.
- **Sign out** in SelfPhoneNG also signs out of Keycloak, when the session came from Keycloak.
- Two-step sign-in (the authenticator app code) is asked for password sign-ins only. Keycloak
sign-ins use whatever Keycloak asks for (set up OTP or passkeys there).
## 1. In Keycloak
1. Pick the realm your people are in (or make one). The **issuer** is
`https://<keycloak>/realms/<realm>`.
2. **Clients → Create client**:
- Client type **OpenID Connect**, Client ID `selfphoneng`.
- **Client authentication: On**. Authentication flow: **Standard flow** only (turn off Direct
access grants).
- **Valid redirect URIs**: `https://pbx.example.com/api/auth/oidc/callback`
- **Valid post logout redirect URIs**: `https://pbx.example.com/login`
(Use your own host name. Settings → Sign-in shows both with Copy buttons.)
3. **Credentials** tab of the client: copy the **Client secret**.
4. To make admins from a group (optional): **Groups → Create group** `pbx-admins` and put your
admins in it. Then the client's **Client scopes** tab → `selfphoneng-dedicated` → **Add mapper →
By configuration → Group Membership**: name `groups`, Token Claim Name `groups`, **Full group
path off**, and **Add to ID token**, **Add to access token** and **Add to userinfo** on.
5. People who keep the browser phone open all week: Keycloak ends a normal session after 10 hours
(Realm settings → Sessions → SSO Session Max), and SelfPhoneNG signs them out then too. To keep
them signed in, add `offline_access` to the Scopes in step 2 below. Disabling the user in
Keycloak still signs them out.
## 2. In SelfPhoneNG
**Settings → Sign-in**:
1. **Issuer** `https://keycloak.example.com/realms/company`, **Client ID** `selfphoneng`,
**Client secret** from step 1.3. **Button name** `Keycloak` (shown as "Sign in with Keycloak").
2. **Scopes** `openid email profile` (add `offline_access`, see 1.5).
3. **Admin groups** `pbx-admins` if you did step 1.4: members become admins at each sign-in, and
everyone else becomes a user. Leave it empty to set roles on Users yourself.
**Allowed groups**: leave empty, or name the groups whose members may sign in.
4. **Test**. It should say "Works", with refresh tokens and "signs out there too".
5. Pick **Passwords or OpenID** and **Save**.
6. **My account → Connect Keycloak**, then sign in from a private window with **Sign in with
Keycloak**. If a group should have made you admin and didn't, Users → your user shows the
groups Keycloak sent: type them exactly like that.
Once everyone has connected (Users shows "Signs in with" for each person), you can switch to
**OpenID only**. People you add from then on get no password: they sign in with Keycloak, and
SelfPhoneNG matches them by their **verified** email the first time.
### Keycloak with a self-signed or private certificate
Put the CA certificate (PEM) in `data/tls/idp-ca.pem`, add this to `.env`, and restart the api
(`docker compose up -d api`):
```dotenv
NODE_EXTRA_CA_CERTS=/var/lib/selfphoneng/tls/idp-ca.pem
```
There is no "don't check the certificate" switch.
## When it doesn't work
The login page says what went wrong in plain words. The reason Keycloak gave is in the api's log:
`docker compose logs api | grep "OpenID sign-in failed"` (the `reason` field).
| Message | Fix |
|---|---|
| "…isn't connected to a phone system account" | They sign in with their password and **Connect Keycloak** first. Or: their SelfPhoneNG user has no password and Keycloak's email for them isn't verified (Keycloak → Users → the user → Email verified). |
| "…already connected to another user" | Someone else connected that Keycloak account. Disconnect it on Users → that user. |
| "…isn't allowed to use the phone system" | Not in **Allowed groups**. |
| "…sign-in didn't work" with `invalid_client` | Client ID or secret wrong, or Client authentication off. |
| …with `redirect_uri` | The Valid redirect URI in Keycloak doesn't match exactly (https, host, path). |
| …with an issuer mismatch | The issuer must be exactly what Keycloak's `/.well-known/openid-configuration` says, including `/realms/<realm>`. |
| Test: "fetch failed" / certificate errors | The server can't reach Keycloak, or doesn't trust its certificate (see above). |
| Signed out after 10 hours | Add `offline_access` to Scopes (1.5). |
## Other providers
| Provider | Issuer | Groups claim | Notes |
|---|---|---|---|
| Authentik | `https://auth.example.com/application/o/<slug>/` (keep the last `/`) | `groups` | |
| Authelia | `https://auth.example.com` | `groups` (add the `groups` scope) | add `offline_access` for the 15-minute check |
| Zitadel | `https://<instance>` | `urn:zitadel:iam:org:project:roles` | turn on "Assert roles on authentication" |
| Microsoft Entra ID | `https://login.microsoftonline.com/<tenant id>/v2.0` | `roles` (use app roles; group claims are IDs) | Entra doesn't send "email verified": turn on **Trust email addresses** only for your own single tenant |
| Google Workspace | `https://accounts.google.com` | none | no sign-out endpoint and no refresh tokens by default: people disabled in Google stay signed in until they sign out or you disable them on Users |
| Okta, Auth0, Kanidm, Pocket ID, Dex, GitLab, Rauthy | as the provider documents | as the provider documents | expected to work, not tested yet |
Keycloak (26.8.1) is the one SelfPhoneNG is tested against.
## Directory (LDAP)
With a directory, people sign in with their **directory username or email and password**. When
the password isn't the one set in SelfPhoneNG, SelfPhoneNG asks the directory. Nobody gets a user
just for being in the directory, and their SelfPhoneNG password, if they have one, keeps working
too.
The first time, the directory account is connected to their user here:
- **New users** (still on the temporary password from Users, or with none): automatically, by the
directory's email.
- **Everyone else, and every admin**: they sign in with their SelfPhoneNG password once, then
**My settings** (admins: **My account**) → **Sign-in** → **Connect directory account** with their
directory username and password. An email alone never connects an admin or someone who chose
their own password, so a directory that lets people edit their own email can't hand out an
account.
Only an admin can disconnect a directory account (Users → the user → Sign-in): it's what the
hourly leaver check follows.
**Settings → Sign-in → Directory (LDAP)**:
1. **Directory type**: Active Directory, OpenLDAP, FreeIPA or lldap. It fills in the filters.
2. **Address**: `ldaps://dc1.example.com` (port 636), or `ldap://…` with **StartTLS**. A plain
`ldap://` address is refused unless the directory runs on this same server, because passwords
would cross the network in clear text. For a private CA, see
[the certificate section](#keycloak-with-a-self-signed-or-private-certificate) (same file).
3. **Service account DN** and **password**: an account that may search people, e.g.
`CN=pbx-search,OU=Service,DC=example,DC=com`. Empty searches anonymously.
4. **Where people are**: the base DN, e.g. `DC=example,DC=com` or `ou=people,dc=example,dc=com`.
5. **User filter**: `{login}` is what they type. Only one group may sign in? Add
`(memberOf=CN=Phone users,OU=Groups,DC=example,DC=com)` inside the `(&…)`.
6. **Admin filter**: people it matches are admins at each sign-in, the others users, e.g.
`(memberOf=cn=pbx-admins,ou=groups,dc=example,dc=com)`. Active Directory's preset uses
`memberOf:1.2.840.113556.1.4.1941:=` so nested groups count. Empty: you set roles on Users.
7. **Test**, with a test login and its password: it says how many people the filter matches, and
who that login would sign in as here.
8. Tick **Check passwords against the directory** and **Save**.
### Leavers
Every hour, SelfPhoneNG looks up each user who has signed in with the directory. Someone deleted
from it, disabled there (Active Directory's disabled bit, FreeIPA's lock) or no longer matched by
the user filter (removed from the group you added to it) is **disabled here**: their password and
OpenID sign-in stop working too, and they're signed out. The role follows the admin filter in the
same run. Nobody is enabled again automatically: do that on **Users**.
If a run would disable more than half of these people (and at least 3), it disables nobody and
the dashboard says so: a wrong base DN or filter is likelier than everyone leaving. The last admin
who has a password is never disabled.
If the directory is down, people get "The directory can't be reached", passwords set in
SelfPhoneNG still work, and the dashboard shows a warning until the next hourly check reaches it.
Directories without `memberOf` on people (OpenLDAP without the memberof overlay) can't map groups
yet. Tested against lldap 0.6.3 and Samba 4 Active Directory (nested groups, the disabled bit).
+11
View File
@@ -0,0 +1,11 @@
# 0004 — openid-client and ldapts for single sign-on
Owner, 2026-10-25: sign-in with an OpenID Connect provider and with an LDAP directory (spec 12).
Both protocols are security code we must not write ourselves: OIDC needs PKCE, state/nonce, ID
token signature and claim checks, `iss` response checks and JWKS rotation; LDAP needs BER encoding,
StartTLS and RFC 4515 filter escaping. **openid-client** 6.x (panva, MIT) is the reference
certified OpenID relying party library for Node; its only dependencies, `oauth4webapi` and `jose`,
are the same author's and MIT. **ldapts** (MIT, TypeScript, promise API) is the maintained LDAP
client (`ldapjs` was decommissioned in 2024) and has `escapeFilter`. Both are open source with no
native code. No OIDC test server is added: the tests use a small fake provider on `node:http`
and `node:crypto`, and live tests run against a real Keycloak and lldap.
+2
View File
@@ -161,6 +161,8 @@ AstDB keys (family/key). This list is complete; do not add keys without updating
| Multipart uploads | @fastify/multipart | latest |
| Rate limiting | @fastify/rate-limit | latest |
| Cookies | @fastify/cookie | latest (decisions/0002) |
| OpenID Connect client | openid-client | 6.x (decisions/0004) |
| LDAP client | ldapts | latest (decisions/0004) |
| PostgreSQL driver | pg | latest (decisions/0002) |
| QR codes | qrcode | latest |
| Logging | pino | latest |
+2
View File
@@ -121,6 +121,8 @@ APP_UID=1000
APP_GID=1000
LOG_LEVEL=info
TRUST_PROXY=true # true when behind Caddy/nginx
RATE_LIMIT_PER_MINUTE=600 # requests per minute per client IP, every route; 0 = off
#NODE_EXTRA_CA_CERTS=/var/lib/selfphoneng/tls/idp-ca.pem # a private CA for the OpenID provider or LDAPS (12 §1)
```
`apps/api/src/config.ts` validates these with zod at startup. If one is
+24 -2
View File
@@ -69,7 +69,7 @@ create table users (
id uuid primary key default gen_random_uuid(),
email citext not null unique,
display_name text not null,
password_hash text not null, -- argon2id
password_hash text, -- argon2id; null: OpenID only (12 §3.3)
role user_role not null default 'user',
is_active boolean not null default true,
totp_secret_enc text, -- null = 2FA off (M11)
@@ -88,10 +88,30 @@ create table sessions (
user_id uuid not null references users(id) on delete cascade,
expires_at timestamptz not null,
ip inet,
user_agent text
user_agent text,
method text not null default 'password' -- password | ldap | oidc (12 §2)
check (method in ('password', 'ldap', 'oidc')),
idp_refresh_enc text, -- oidc: refresh token, encrypted (12 §3.5)
idp_id_token_enc text, -- oidc: ID token, encrypted, to sign out there
idp_checked_at timestamptz
);
create index on sessions (user_id);
-- One more way to sign in to a user (12 §2): a connected OpenID account or directory entry.
create type identity_kind as enum ('oidc', 'ldap');
create table user_identities (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade,
kind identity_kind not null,
subject text not null, -- oidc: "<iss> <sub>"; ldap: the UUID attribute
email text, -- as the provider last said
groups text[] not null default '{}', -- oidc: groupsClaim values at the last sign-in
created_at timestamptz not null default now(),
last_used_at timestamptz,
unique (kind, subject),
unique (user_id, kind)
);
create table api_tokens (
id uuid primary key default gen_random_uuid(),
name text not null,
@@ -608,6 +628,8 @@ with a zod schema per key and a default. Missing rows return the default.
| `backup.includeRecordings` | boolean | false | |
| `webhooks.allowPrivate` | boolean | false | allow webhook URLs on private/loopback IPs |
| `setup.completed` | boolean | false | first-run wizard done |
| `auth.mode` | `'password'\|'mixed'\|'oidc'` | `'password'` | sign-in ways offered (12 §1.1) |
| `auth.oidc` | object \| null | null | the OpenID Connect provider, client secret encrypted (12 §3.1) |
| `sms.webhookToken` | string | random 32 chars, made on first read | secret part of the voip.ms SMS callback URL |
| `sip.restrictPhonesToLocalNets` | boolean | false | only accept phone registrations from `network.localNets` (+ `sip.extraAllowedNets`) |
| `sip.extraAllowedNets` | string[] | `[]` | e.g. a VPN subnet |
+12 -2
View File
@@ -33,9 +33,19 @@
- **CSRF:** every non-GET `/api/*` request with cookie auth must carry the header
`X-Requested-With: selfphoneng`. The web client always sends it. Browsers can't send
it cross-site without CORS, and CORS is off.
- Login rate limit: 5 failures per IP per 15 min and 10 per email per hour → HTTP 429.
- Login rate limit: 5 failures per IP per 15 min and 10 per email per hour → HTTP 429. A signed-in
person's password asked again (change password, two-step on/off, recovery codes) counts the same.
- Every route: `@fastify/rate-limit`, `RATE_LIMIT_PER_MINUTE` (default 600) per client IP → 429
`rate_limited`. Test buttons, diagnostics and the source download: 10 a minute (`SLOW` in
`auth/guards.ts`). `/prov` (its own limits, 07 §2) and the caller-name route (Asterisk on this
machine) are outside it (`NO_LIMIT`).
Write `login_failed` to the audit log with the IP.
- If `must_change_password`, every route except `/api/auth/*` returns 403 `{code:'password_change_required'}`.
- Single sign-on (spec 12, as built in S1): the body is `{login, password, totp?}` (`login`: email, or a
directory username from S4; `email` is still accepted). A user without a password fails like a wrong
one. `auth.mode = 'oidc'` refuses passwords for non-admins (403 `password_sign_in_off`).
`GET /api/auth/methods` (public) → `{mode, ldap, oidc}` for the login page. The last active admin
**with a password** can't be deleted, disabled or demoted.
### 2.3 API tokens (integrations)
- Header `Authorization: Bearer sp_<40 random base62 chars>`. Stored as sha256.
@@ -420,7 +430,7 @@ As built (M11-T6): `routes/reports.ts`. `from`/`to` are company-time-zone days,
| GET/PATCH | `/api/settings` | whole settings object; PATCH merges known keys only. `smtp` comes back as `{host, port, secure, username, from, hasPassword}` and is written with an optional `password` (empty keeps the stored one). `system.*`, `sms.webhookToken` and `setup.completed` are read-only. References (DIDs, extensions, destinations) are checked, and a transport a trunk uses can't be turned off |
| POST | `/api/settings/smtp/test` | `{to}` sends a test email |
| POST | `/api/settings/emergency/test` | runs 04 §5.3 steps 2–4 with `TEST` in front (email, ring-out, banner); records nothing and dials nothing (06 §6.5). Returns `{notified}` |
| GET | `/api/system/warnings` | `{items: [{code, message, link}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3). "Trunk down" comes from live state in the browser |
| GET | `/api/system/warnings` | `{items: [{code, message, link}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3), `directory_check` (12 §4.2, §4.5: the directory unreachable at a sign-in or by `directoryCheck`, or that job refusing to disable most people). "Trunk down" comes from live state in the browser |
| POST | `/api/settings/network/detect-public-ip` | |
| GET | `/api/system/status` | `{ db: ok, ami: connected, asteriskVersion, uptime, trunks: [...], disk: {free, total}, restartRequired, lastGeneratorRun }` |
| GET | `/api/system/tls` | the SIP TLS certificate in `TLS_DIR`: `{ok: true, subject, names, notAfter}` or `{ok: false, error}` (M11-T2) |
+2
View File
@@ -77,6 +77,8 @@ get the proxy's address only (below). The setting is on Settings → Security, w
`Permissions-Policy: microphone=(self), camera=()`.
- HSTS is set by the reverse proxy (Caddy does it by default; nginx example adds `add_header Strict-Transport-Security "max-age=31536000" always;`).
- `TRUST_PROXY=true` → Fastify `trustProxy: '127.0.0.1'` only.
- Rate limits on every route (04 §2.2): `RATE_LIMIT_PER_MINUTE` per client IP, stricter on Test
buttons; login and a password asked again share the login limiter.
- Admin TOTP (M11): RFC 6238, 30 s, 6 digits, ±1 window, 10 single-use recovery codes (stored hashed).
- As built (M11-T1), TOTP (`auth/totp.ts`, node:crypto only, RFC 6238 test vectors in its test):
opt-in for admins on **My account** (header → Account): scan the QR or type the key, confirm one
+3 -1
View File
@@ -9,7 +9,9 @@ Legend: 📖 read · 🔨 build · ✅ done when
---
## Backlog (owner's order, 2026-10-21)
0. **Contacts** (owner, 2026-10-23: needed next), tasks C1–C6 in [11 §6](11-contacts.md#6-order-of-work):
0. **Single sign-on** (owner, 2026-10-25: next), tasks S1–S5 in [12 §8](12-sso.md#8-order-of-work):
OpenID Connect (any provider; the owner uses Keycloak), then LDAP/LDAPS.
0. Done: **Contacts** (owner, 2026-10-23), tasks C1–C6 in [11 §6](11-contacts.md#6-order-of-work):
Simple (builtin phonebook) or Robust (Contrack), picked in the setup wizard. The owner uses
Contrack.
1. **Grandstream HT802 V2 set up automatically** (step 3 of
+611
View File
@@ -0,0 +1,611 @@
# 12 — Single sign-on: OpenID Connect and LDAP
Owner, 2026-10-25: people can sign in to the web app (admin panel, user portal, browser softphone)
with the company's identity provider as well as, or instead of, a SelfPhoneNG password:
- **OpenID Connect** (S1–S3): "Sign in with Keycloak" on the login page. The owner's install uses
Keycloak, but nothing here is Keycloak-only: any provider that follows OpenID Connect Core and
Discovery works (§3.6 lists the ones checked).
- **LDAP / LDAPS** (S4–S5): the normal password form, checked against the company directory
(Active Directory, OpenLDAP, FreeIPA, lldap, Samba AD, 389 DS …) when the SelfPhoneNG password
doesn't match.
A user **connects** their OpenID account to their SelfPhoneNG account and can then sign in either
way: with their password or with the provider. The admin picks which ways are on (§1.1).
Only **web sign-in** changes. SIP passwords, voicemail PINs, desk phone provisioning, API tokens and
the softphone's SIP login are untouched: a phone never talks to the identity provider.
## 1. Rules that hold for both
- **Identities**: a user has a SelfPhoneNG password (or none), and at most one connected OpenID
account and one directory account (`user_identities`, §2). Each is one more way to sign in to the
same account; none replaces the others.
- **Who someone is at the provider** is its stable id, never the email or name: OIDC `iss` + `sub`,
LDAP the entry's UUID attribute (`entryUUID`, `objectGUID`, `ipaUniqueID`, `nsUniqueId`).
- **No automatic accounts** (owner, 2026-10-25): signing in with a provider never creates a user.
An admin creates every user on Users, as today; the provider only signs in users who exist.
- **Break glass**: the last active admin with a password can't be deleted, disabled or demoted
(extends `assertNotLastAdmin`), and admins can always sign in with a password, even in
OpenID-only mode (§1.1). The identity provider being down never locks the owner out.
- **One OIDC provider and one directory** per install. More than one OIDC provider: later (§7).
- **Role**: when the provider's admin mapping is set (`adminGroups`, `adminFilter`), the role
follows it on every sign-in and check through that provider (§3.4, §4.4), and Users shows it
read-only (`PATCH role` → 409 `role_managed`). Unset: the role is managed in SelfPhoneNG as now.
A sync never demotes or disables the last active admin with a password (it keeps them and logs a
warning).
- **Extensions** stay manual: an admin assigns one on Users.
- **Our own session**: every sign-in makes the same `sp_session` cookie as today (04 §2.2, 14 days,
sliding). `sessions.method` says how it was made. Provider tokens never reach the browser, except
the ID token as `id_token_hint` in the sign-out redirect to the provider (§3.5; RP-initiated
logout works that way).
- **Two-step sign-in** (08 §2.4, owner 2026-10-25): password sign-ins ask for our TOTP code when
it's on, whether the password was checked here or by the directory. OpenID sign-ins don't: MFA
there is the provider's job.
- **Self-signed or private CA** (a home-lab Keycloak or a domain controller): `NODE_EXTRA_CA_CERTS`
(01 env list) names a PEM file mounted into the api container; Node then trusts it for both
HTTPS and LDAPS. No "skip certificate check" switch anywhere.
- The api runs with host networking (01), so a Keycloak or directory on the LAN is reachable as is.
Both settings are admin-only, so the webhook private-address block (04 §9) doesn't apply.
- **Never logged**: client secret, bind password, user passwords, authorization codes, ID/access/
refresh tokens. The OIDC callback route is `logLevel: 'silent'` (its query holds the code, like
the caller-name route, 11 §6.1) and writes its own line without the query.
### 1.1 Sign-in mode (`auth.mode`)
Settings → Sign-in, admin:
| Mode | Login page | `POST /api/auth/login` (password, local or directory) | OpenID |
|---|---|---|---|
| `password` (default) | the password form | everyone | off (start/callback answer 404) |
| `mixed` | the **Sign in with <label>** button and the password form | everyone | everyone with a connected account |
| `oidc` | the button only; a small **Admin sign-in** link shows the password form | **admins only**, everyone else 403 `password_sign_in_off` | everyone with a connected account |
- `mixed` and `oidc` need `auth.oidc` set up and its Test passed (PATCH → 400 otherwise).
- In `oidc` mode a user without a connected account can still get in once, by verified email
(§3.3 step 2), so pre-made users work without a password. Settings shows "N people can't sign in
yet" with their names: no connected account and an email the provider can't match.
- Switching modes signs no one out. Passwords are kept, only refused, so switching back works.
- In `mixed` mode the password stays a separate way in: disabling someone in Keycloak ends their
Keycloak sign-ins (§3.5), but their password still works until they're disabled on Users. Settings
says so next to the mode; `oidc` mode is the one where Keycloak alone decides.
## 2. Data
Migration (02 §users gets the table and these notes):
```sql
alter table users alter column password_hash drop not null; -- null: no password (made in oidc mode)
create type identity_kind as enum ('oidc', 'ldap');
create table user_identities (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade,
kind identity_kind not null,
subject text not null, -- oidc: "<iss> <sub>"; ldap: the UUID attribute's value
email text, -- as the provider last said, shown in My account and Users
groups text[] not null default '{}', -- oidc: groupsClaim values at the last sign-in (§3.4)
created_at timestamptz not null default now(),
last_used_at timestamptz,
unique (kind, subject),
unique (user_id, kind)
);
alter table sessions
add column method text not null default 'password' check (method in ('password', 'ldap', 'oidc')),
add column idp_refresh_enc text, -- oidc: refresh token, encrypt() (§3.5)
add column idp_id_token_enc text, -- oidc: ID token, encrypt(), for signing out there (§3.5)
add column idp_checked_at timestamptz;
```
Settings (02 §3), all admin-only, written by Settings → Sign-in, secrets write-only (`has…` in GET,
an empty value keeps the stored one), like `smtp`:
| Key | Type | Default | Notes |
|---|---|---|---|
| `auth.mode` | `'password' \| 'mixed' \| 'oidc'` | `'password'` | §1.1 |
| `auth.oidc` | object \| null | null | §3.1 |
| `auth.ldap` | object \| null | null | §4.1 |
`UserDto` and `AuthUser` (`/api/auth/me`) get
`signIn: { password: boolean, oidc: { email } | null, ldap: boolean }`.
Public route for the login page: `GET /api/auth/methods` →
`{ mode, ldap: boolean, oidc: { label } | null }`. Nothing else about the setup is public.
## 3. OpenID Connect
Library: **openid-client** 6.x (panva, MIT; its two dependencies `oauth4webapi` and `jose` are
the same author's, MIT). Decision 0004. It does discovery, PKCE, state and nonce checks, ID token
signature and claim checks (RS/PS/ES/EdDSA), RFC 9207 `iss` checks, JWKS caching and key
rotation, and builds the end-session URL. We don't hand-roll any of that.
### 3.1 Settings (`auth.oidc`)
```ts
{
label: string, // button text: "Sign in with <label>", e.g. "Keycloak"
issuer: string, // https URL; discovery reads <issuer>/.well-known/openid-configuration
clientId: string,
clientSecretEnc: string | null, // null: public client (PKCE only), for providers that allow it
scopes: string, // default "openid email profile"; add "groups", "offline_access" … per provider
groupsClaim: string, // dotted path, default "groups" (e.g. "realm_access.roles", "roles")
adminGroups: string[], // in groupsClaim → admin; others → user. [] = role managed here
allowedGroups: string[], // [] = anyone the provider signs in (who has an account here)
trustEmail: boolean, // default false: treat email as verified without email_verified (§3.3)
}
```
- On or off is `auth.mode` (§1.1), not a flag here.
- `issuer` must be `https:` (`http://localhost…` allowed for the fake provider only, `NODE_ENV=test`).
- Discovery runs on first use and is cached in memory; a settings change or a failed discovery
forgets it. Client authentication: `client_secret_basic` when the provider lists it, else
`client_secret_post`, else none for a public client (openid-client `ClientSecretBasic`,
`ClientSecretPost`, `None`). `private_key_jwt`: later, if asked.
- Two fixed URIs, shown in Settings with Copy buttons, to register at the provider:
- redirect URI `https://${PUBLIC_HOSTNAME}/api/auth/oidc/callback`;
- post-logout redirect URI `https://${PUBLIC_HOSTNAME}/login` (§3.5).
- Response mode is the default `query` (a `form_post` POST wouldn't carry our SameSite=Lax cookie).
- **Test** (`POST /api/settings/auth/oidc/test`, from the unsaved form): discovery works, the issuer
in the document equals `issuer` exactly, the token endpoint accepts the client (a deliberately bad
`authorization_code` grant: `invalid_grant` means the client was accepted, `invalid_client` means
it wasn't), and says which signing algorithms and whether PKCE, refresh tokens and an end-session
endpoint are offered. The real check is the one Settings suggests next: connect your own account
in My account.
### 3.2 Flow
1. Login page (`mixed`, `oidc`): **Sign in with <label>**, a plain link to
`/api/auth/oidc/start?next=<path>` (same-site paths only, as `next` is today).
My account: **Connect <label>** posts `POST /api/auth/oidc/connect` (`{ next? }`, a session, the
CSRF header) and sends the browser to the `url` it answers. A POST, so another site can't start
a connect and attach whatever provider account the browser is signed in to (security audit,
2026-10-26).
2. `GET /api/auth/oidc/start`: 404 in `password` mode. Makes `state`, `nonce` and a PKCE verifier
(always all three, even when the provider advertises PKCE), stores them with `next` (and, to
connect, the session id) in the cookie `sp_oidc = encrypt(JSON)`;
`HttpOnly; Secure; SameSite=Lax; Path=/api/auth/oidc; Max-Age=600`, and answers 302 to
`buildAuthorizationUrl` (`code_challenge_method=S256`).
3. `GET /api/auth/oidc/callback` (`logLevel: 'silent'`): reads and clears `sp_oidc`, builds the
current URL from `PUBLIC_HOSTNAME` and the path (never from the Host header), and calls
`authorizationCodeGrant` with the verifier, `expectedState` and `expectedNonce`.
4. Claims: the ID token's, then **always** the userinfo endpoint's when the provider has one
(`fetchUserInfo` with the ID token's `sub` as the expected subject), merged, userinfo winning.
Several providers (Authelia, and Keycloak's mappers by default) put email or groups only in
userinfo, so one rule covers them all.
5. `applyClaims` (§3.4): allowed, role, groups.
6. Connecting (§3.3 step 3) → back to My account with "Connected". Signing in → find the user
(§3.3), make the session (`method = 'oidc'`, refresh and ID tokens kept, §3.5), set
`last_login_at` and `last_used_at`, audit `login` "… signed in with Keycloak", 302 to `next` or
the user's home.
Any failure: 302 to `/login?sso_error=<code>` (to `/me/account?sso_error=` when connecting), audit
`login_failed` with the reason, the provider's `error_description` in the log only:
| Code | When | Says |
|---|---|---|
| `expired` | no or bad `sp_oidc` (took over 10 min, or started in another tab) | "Sign-in took too long. Try again." |
| `denied` | the provider answered `error=access_denied` | "Sign-in was cancelled." |
| `not_allowed` | not in `allowedGroups` | "Your <label> account isn't allowed to use the phone system. Ask your admin." |
| `not_connected` | no user has this account, and none can be matched by email (§3.3) | "This <label> account isn't connected to a phone system account. Sign in with your password and connect it in My account, or ask your admin." |
| `taken` | connecting an account that's already someone else's | "This <label> account is already connected to another user." |
| `disabled` | the user is disabled here | "Your account is disabled." |
| `provider` | anything else (discovery, token endpoint, signature, clock) | "<label> sign-in didn't work. Your admin can see why in the log." |
Failed callbacks count in the IP login limiter (04 §2.2).
### 3.3 Finding the user, and connecting
Signing in, in this order:
1. A `user_identities` row (`oidc`, `"<iss> <sub>"`) → its user.
2. Else, a user with that email (case-insensitive) who has **no password and no OpenID account
yet** (made by an admin in `oidc` mode, or whose password an admin removed), **and the email is
verified** (`email_verified === true`, or `trustEmail`) → connect it now (step 3's row) and sign
in. Users who have a password are never matched by email: they connect from My account, so a
provider where people can set their own email can't hand over an account.
3. Else `not_connected`.
Connecting (from My account, already signed in, any mode but `password`):
- After the callback, the session's user gets the `user_identities` row (email and groups as
sent). Refused (`taken`) when that `iss sub` belongs to another user; a user who already has one
replaces it (confirm in the UI). Other sessions stay. Audit `sso_connected`.
- **Disconnect** (`DELETE /api/me/identities/oidc`; admins also on Users,
`DELETE /api/users/:id/identities/oidc`): ends that user's `oidc` sessions. Refused (409
`last_way_in`) when it would leave them no way to sign in in the current mode (no password, or
`oidc` mode and not an admin). Audit `sso_disconnected`.
- A user without a password in `mixed` mode can get one from an admin (Users → Reset password, as
today). Setting a password yourself without one: later, if asked.
`trustEmail` help text: "Turn on only if people can't change their own email address at <label>
(true for a single company's Keycloak with email editing off, or a single-tenant Entra ID)". The
issuer is pinned to one realm or tenant, so emails come from one admin's directory. It only
matters for step 2.
Users → Create: in `oidc` mode no temporary password is made (the person signs in by email, step
2); in the other modes as today.
### 3.4 Claims → allowed and role (`sso/claims.ts`, pure, unit-tested)
- `groupsClaim` is a dotted path into the merged claims. Its value becomes a string list: a string
→ `[s]`; an array → its strings; an object → its keys (Zitadel's
`urn:zitadel:iam:org:project:roles` is `{ role: { orgId: domain } }`). Path lookup: the whole
remaining path as one key first, else split on the first `.`, so a claim name with dots or
colons and `resource_access.selfphoneng.roles` both work.
- Group names compare exactly (case-sensitive): Keycloak with "Full group path" on sends
`/pbx-admins`, off sends `pbx-admins`; the admin types what their provider sends. They are stored
on the identity (`groups`), and Users → a user shows them, to copy from.
- Allowed: `allowedGroups` empty or any match.
- Role: `adminGroups` empty → unchanged; else admin when any matches.
### 3.5 Leavers and signing out
**Session check** (`sso/oidcCheck.ts`, job every 5 min, owner 2026-10-25: keep it). Our session
lasts 14 days, so a person disabled in Keycloak must lose it sooner:
- Sessions with `method = 'oidc'`, a refresh token and `idp_checked_at` older than 15 min →
`refreshTokenGrant`, store the new refresh and ID tokens when rotated, fetch userinfo with the new
access token, run `applyClaims` on the new ID token's claims with userinfo's on top (as at
sign-in: Entra's app roles are in the ID token only). No groups claim at all (the refresh brought
no ID token and userinfo lacks it) → role and access unchanged this run; an empty one counts. The provider refuses (`invalid_grant`: user disabled, session
revoked, refresh token expired) or `applyClaims` says not allowed → delete that session (the live
socket closes within 30 s, 04 §6). Role, groups and email changes apply. The provider unreachable
or 5xx → keep the session, try next run (an outage doesn't sign everyone out).
- Only `oidc` sessions: password sessions of the same user are untouched (§1.1, `mixed`).
- No refresh token offered (Google without `access_type=offline`, some setups) → no check:
Settings → Sign-in says "<label> gives no refresh token: someone disabled there stays signed in
here until they sign out, their session ends, or you disable them on Users."
- Keycloak note (admin guide): a normal refresh token dies with Keycloak's SSO session (Session
Idle 30 min, Max 10 h by default). The 15-minute check keeps it from going idle, but at
Session Max the user is signed out of SelfPhoneNG too. For people who keep the softphone open all
week, add `offline_access` to `scopes` (Keycloak then issues an offline token, Offline Session
Idle 30 days); disabling the user in Keycloak still revokes it.
**Signing out at the provider too** (owner, 2026-10-25: yes). `POST /api/auth/logout` on an `oidc`
session, when the provider has an `end_session_endpoint`: delete our session as today, then answer
`{ ok: true, redirect }` with `buildEndSessionUrl` (`id_token_hint` from `idp_id_token_enc`,
`client_id`, `post_logout_redirect_uri = https://${PUBLIC_HOSTNAME}/login`). The web client goes
there; the provider signs out and sends the browser back to our login page. Password sessions and
providers without the endpoint: no `redirect`, as today. The session check removing a session
doesn't sign out at the provider (the provider is what said no). OIDC Back-Channel Logout: later.
### 3.6 Providers
Each row is "checked" only after a real sign-in, role mapping, the session check and signing out
pass against it (06's verify rule). The others are expected to work; the admin guide gives the same
table.
| Provider | Issuer | Groups / roles | Notes | Checked |
|---|---|---|---|---|
| Keycloak (owner) | `https://kc.example.com/realms/<realm>` | client scope with a **Group Membership** mapper → `groups` (add to ID token and userinfo), or realm roles → `realm_access.roles` (turn on "Add to userinfo": off by default) | client: OpenID Connect, Client authentication on, Standard flow only; Valid redirect URIs and Valid post logout redirect URIs from Settings; `offline_access` (§3.5) | S3 |
| Authentik | `https://auth.example.com/application/o/<slug>/` | `groups` (default profile scope) | | |
| Authelia | `https://auth.example.com` | `groups` scope → `groups` | claims mostly in userinfo (handled, §3.2 step 4); needs `offline_access` for refresh tokens | |
| Zitadel | `https://<instance>` | `urn:zitadel:iam:org:project:roles` (object → keys) | turn on "Assert roles on authentication" | |
| Microsoft Entra ID | `https://login.microsoftonline.com/<tenant>/v2.0` | app roles → `roles` (group claims are GUIDs and stop at 200) | single tenant only (never `common`); no `email_verified`: see `trustEmail` | |
| Google Workspace | `https://accounts.google.com` | none | refresh tokens need `access_type=offline`: §3.5; no end-session endpoint | |
| Okta, Auth0, Kanidm, Pocket ID, Dex, GitLab, Rauthy, LemonLDAP::NG | per provider | per provider | Kanidm requires PKCE S256 and ES256 (both supported) | |
## 4. LDAP / LDAPS
Library: **ldapts** (MIT, TypeScript, maintained). Decision 0004. Its `escapeFilter` does RFC 4515
filter escaping.
The directory is one more way to check a **password**: it works in `password` and `mixed` mode for
everyone, and in `oidc` mode for admins only (§1.1).
### 4.1 Settings (`auth.ldap`)
```ts
{
enabled: boolean,
preset: 'ad' | 'openldap' | 'freeipa' | 'lldap' | 'other', // fills the defaults below in the form only
url: string, // ldaps://dc1.example.com (636) or ldap://… with startTls
startTls: boolean,
bindDn: string | null, // service account; null = anonymous search
bindPasswordEnc: string | null,
baseDn: string, // where people are searched (subtree)
userFilter: string, // must contain {login}; who may sign in
idAttribute: string, // stable UUID attribute
emailAttribute: string, // default "mail"
nameAttribute: string, // default "displayName", falls back to "cn"
adminFilter: string | null, // matched against the user's own entry → admin. null = role managed here
}
```
Presets:
| Preset | `userFilter` | `idAttribute` | `adminFilter` example |
|---|---|---|---|
| Active Directory / Samba AD | `(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(\|(sAMAccountName={login})(userPrincipalName={login})(mail={login})))` | `objectGUID` (binary, read as hex) | `(memberOf:1.2.840.113556.1.4.1941:=CN=PBX Admins,OU=Groups,DC=example,DC=com)` (nested groups) |
| OpenLDAP (memberof overlay) | `(&(objectClass=inetOrgPerson)(\|(uid={login})(mail={login})))` | `entryUUID` | `(memberOf=cn=pbx-admins,ou=groups,dc=example,dc=com)` |
| FreeIPA | `(&(objectClass=person)(!(nsAccountLock=TRUE))(\|(uid={login})(mail={login})))` | `ipaUniqueID` | `(memberOf=cn=pbx-admins,cn=groups,cn=accounts,dc=example,dc=com)` |
| lldap | `(&(objectClass=person)(\|(uid={login})(mail={login})))` | `entryUUID` | `(memberOf=cn=pbx-admins,ou=groups,dc=example,dc=com)` |
To limit sign-in to one group, the admin adds `(memberOf=…)` to `userFilter`. Directories without
`memberOf` on the user (plain OpenLDAP without the overlay, `posixGroup`) can't map groups yet:
`// TODO(spec)` group search, if someone needs it (§7).
Rules, checked by `PATCH /api/settings` (400 with the reason):
- **No password in clear text over the network**: `ldap://` needs `startTls`, unless the host is
`127.0.0.1`/`::1`/`localhost` (a directory on the same machine).
- `userFilter` contains `{login}`; `userFilter` and `adminFilter` parse as LDAP filters (ldapts).
- Timeouts are fixed: connect 5 s, each operation 5 s. Not settings.
**Test** (`POST /api/settings/auth/ldap/test`, from the unsaved form): connect, StartTLS, bind the
service account, count entries matching `userFilter` with `{login}` → `*` (at most 1,000), and with
an optional **test login** (username + password, never stored or logged): the whole §4.2 flow
without signing in, answering the DN, email, name, id, the SelfPhoneNG user it matches (or none)
and the role it would get, or the step that failed.
### 4.2 Flow (`POST /api/auth/login`)
The form field becomes **Email or username** when LDAP is on; the body is
`{ login, password, totp? }` (`email` renamed to `login`, a string of 1–254; `email` is still
accepted, so older scripts keep working).
1. Rate limit as today, keyed by IP and `login`.
2. A user with email = `login` who has a password, and it's right → that user (as today).
3. Else, LDAP on → `ldapAuthenticate`:
1. Empty password → fail **before** any bind (an LDAP simple bind with a DN and no password is
an "unauthenticated bind" that many servers accept as anonymous success, RFC 4513 §5.1.2).
2. Connect, StartTLS if set, bind the service account (or none).
3. Search `baseDn`, subtree, `userFilter` with every `{login}` replaced by the escaped `login`
(`escapeFilter`), attributes: id, email, name. Not exactly one entry → fail.
4. Bind as that entry's DN with the password. Refused → fail. Any answer from the server is a
refusal (wrong password, locked, expired: 389 DS and FreeIPA answer a locked account with
19 or 53, not 49); only no answer at all is "unavailable". Else locking someone's directory
account would turn their sign-in into a 503.
5. `adminFilter` set → search `base` = that DN, scope `base`, filter `adminFilter`, with the
**service** connection: one entry → admin, none → user.
6. Unbind and close (one connection per sign-in, no pool: a few sign-ins a day).
Success → find the user (§4.3), copy name and role.
4. Mode check (§1.1): `oidc` mode and not an admin → 403 `password_sign_in_off`.
5. TOTP if they turned it on, then the session (`method` = `password` or `ldap`), `login` audited
("… signed in with the directory" for LDAP).
6. Anything else → 401 "Wrong email or password", as today (no hint whether the account exists).
The directory unreachable → 503 `directory_unavailable` "The directory can't be reached.
Passwords set in SelfPhoneNG still work." and a dashboard warning. The 503 is counted and
audited as a failed sign-in first: else, while the directory is down, a wrong local password
(503) and a right one (200) would be free unlimited guesses.
A user can have both a SelfPhoneNG password and a directory account: either password works.
### 4.3 Finding the user
1. A `user_identities` row (`ldap`, id) → its user. Their email here follows the directory's when
it changes (unless another user has it: kept, logged).
2. Else, a user with the directory email who is **not an admin** and has **no password or only the
temporary one** from Users (`must_change_password`) → connect it (add the row) and sign in,
audit `ldap_connected`. Anyone else connects on My settings → Sign-in → **Connect directory
account** (`POST /api/me/identities/ldap`, `{ login, password }`, checked by the directory and
counted by the login limiter; 409 `taken` when another user has it). Security audit,
2026-10-26: a directory where people can edit their own `mail` (common OpenLDAP access rules)
must not hand out the admin's account.
3. Else → 401 "Wrong email or password" (no account is made, §1), with `login_failed` audited as
"directory account … has no phone system account". The admin's Test shows the same answer.
4. No email on the entry → same 401, logged.
Users → a user shows the directory account and **Disconnect** (same rules as §3.3). Only an admin
disconnects a directory account (`DELETE /api/me/identities/ldap` → 403 `admin_only` for a user):
it's what the leaver check (§4.5) follows, so a leaver mustn't be able to drop it on the way out.
### 4.4 Ids
`objectGUID` is binary: ldapts `explicitBufferAttributes: ['objectGUID']`, stored as lowercase hex,
searched back with the Buffer through `escapeFilter` (it escapes bytes). Text ids are stored as is.
### 4.5 Leavers: `directoryCheck` (job, hourly)
For every active user with a directory account, one connection per run:
- Search `baseDn` for `(&(<idAttribute>=<id>)<userFilter with {login} → *>)`. Replacing `{login}`
with `*` keeps every other condition of the filter (the group, AD's disabled bit, FreeIPA's lock)
and makes the login parts presence tests.
- Found → update the identity's email and the role (`adminFilter`); the user's own name and email
stay as an admin set them (§8.4). Gone → the user is disabled
(`is_active = false`, sessions deleted, as an admin's disable), audit `directory_disabled`
"… is no longer in the directory". The directory account is the company's record of the person,
so it disables the whole user, password and OpenID account included.
- The directory unreachable or the bind refused → change nothing, warn on the dashboard
(`directory_check`). The run would disable more than half of these users (and at least 3) →
change nothing, warn: a broken filter or base DN is likelier than a mass layoff.
- It never re-enables: an admin does that on Users (they may have disabled the person themselves).
## 5. UI (05)
- **Login** (`/login`): by mode (§1.1): the button, the form, or both; "Email or username" when
LDAP is on; in `oidc` mode the **Admin sign-in** link; `sso_error` messages (§3.2);
`password_sign_in_off` says "Use Sign in with <label>".
- **Sign out**: follows `redirect` when the answer has one (§3.5).
- **Settings → Sign-in** (admin, new tab): the mode (§1.1, with the `mixed` warning and the "N
people can't sign in yet" list for `oidc`), then two cards, OpenID Connect and Directory (LDAP),
each with the §3.1 / §4.1 fields, presets, Test, the two URIs with Copy (OIDC), the warnings of
§3.5 and §4.5, and a link to the admin guide page.
- **Users**: a **Signs in with** column (Password, <label>, Directory: any of them); a user's
drawer shows their connected accounts (email, groups, last used) with Disconnect; role read-only
with "From <label>" when mapped.
- **My account → Sign-in**: password (Change password, two-step when they have a password), the
OpenID account (**Connect <label>** / its email and **Disconnect**, in `mixed` and `oidc`), the
directory account (read-only).
## 6. Tests
- `sso/claims.test.ts`: dotted paths (Keycloak `realm_access.roles`, `resource_access.x.roles`,
Zitadel's `urn:…` object), allow and role decisions, `trustEmail`.
- `sso/oidc.test.ts` against a **fake provider** (`test/fake-oidc.ts`: `node:http` + `node:crypto`,
RS256 and ES256 keys, discovery, JWKS, authorize redirect, token, userinfo, refresh, end
session; no new dependency): a full sign-in; bad state, nonce, issuer, audience, expired token,
wrong signature each refused; userinfo `sub` mismatch refused; §3.3 (connect, `taken`, email
match only without a password and only verified, `not_connected`, never a new user); the three
modes (§1.1: password refused for a user in `oidc` mode, allowed for an admin; start 404 in
`password` mode); Disconnect's `last_way_in`; the session check removing an `oidc` session on
`invalid_grant`, keeping it on 503 and leaving password sessions alone; logout's `redirect`; the
callback writing no `code=` to the log.
- `sso/ldap.test.ts`: filter building (`{login}` = `*)(uid=*))(|(uid=*` is escaped; `{login}` →
`*` for the check), empty password refused without a bind, `ldap://` to a remote host refused.
- `test/ldap-live.test.ts` against a real **lldap** container (`LDAP_LIVE_URL`, like
`contrack-live.test.ts`): sign-in by uid and by email, connect by email, no user → refused,
admin by `memberOf`, a removed user disabled by `directoryCheck`, LDAPS with a private CA through
`NODE_EXTRA_CA_CERTS`. AD presets checked once against a Samba AD DC container (nested groups,
disabled bit, `objectGUID`).
- `test/oidc-live.test.ts` against the owner's **Keycloak** (`OIDC_LIVE_ISSUER` + a test client and
user; a headless sign-in through Keycloak's login form): connect, sign-in, group → admin, signing
out at Keycloak, disabling the user in Keycloak ending the SelfPhoneNG session at the next check.
- `scripts/e2e/sso.mjs`: the login page per mode, the button and errors, in a browser.
## 7. Later (not in S1–S5)
- OIDC Back-Channel Logout.
- More than one OIDC provider; `private_key_jwt`.
- Setting your own password when you have none.
- LDAP group search for directories without `memberOf`; several directory URLs (failover).
- SCIM or a scheduled import of directory users (today an admin creates each user).
- Using the directory as the contacts book (11).
## 8. Order of work
| Task | What | Done when |
|---|---|---|
| S1 | Decision 0004; migration (§2); `auth.mode` (§1.1) with only `password` selectable; break glass; `GET /api/auth/methods`; the `login` field; Users "Signs in with" | `pnpm lint && typecheck && test` pass; sign-in works as before. Done, see §8.1 |
| S2 | OIDC: settings + Test, start/callback, claims (§3.4), §3.3 (connect, disconnect, email match), `mixed` and `oidc` modes, sign out at the provider, login page and My account, fake provider tests | every §6 OIDC case passes against the fake provider. Done, see §8.2 |
| S3 | OIDC session check (§3.5); Keycloak live test; admin guide page `docs/admin-guide/sign-in.md` with the §3.6 table | the owner connects their Keycloak account, signs in with it or their password, a Keycloak group makes them admin, signing out also signs out of Keycloak, and disabling them in Keycloak ends their Keycloak session here within 20 min. Done (against a throwaway Keycloak 26.8.1), see §8.3; the owner's own Keycloak still to try |
| S4 | LDAP: settings + Test, presets, sign-in flow (§4.2), §4.3, unit + lldap live tests | sign-in by uid and email against lldap over LDAPS; admin by group; no user made for a stranger. Done, see §8.4 |
| S5 | `directoryCheck` (§4.5), dashboard warnings; Samba AD check | a user removed from the directory is disabled within an hour; AD nested-group admin works. Done, see §8.5 |
### 8.1 As built: S1
- Migration 0012: `users.password_hash` nullable, `user_identities`, the `sessions` columns (§2).
- `auth.mode` exists; `PATCH /api/settings` refuses anything but `password` until S2 adds the OIDC
check. `POST /api/auth/login` already refuses non-admin passwords when it's `oidc`.
- `signIn` on `UserDto` and `AuthUser`; Users has a **Signs in with** column ("OpenID" until S2
gives it the provider's label).
- `apps/api/test/sign-in.test.ts`.
### 8.2 As built: S2
- `sso/oidc.ts` (discovery cached per settings value, client auth, start/finish, end session,
Test), `sso/claims.ts` (pure), `routes/oidc.ts` (start, callback, both Disconnect routes).
- **ID token signatures are checked** (`enableNonRepudiationChecks`): openid-client by default
trusts TLS to the token endpoint instead (OIDC Core §3.1.3.7 allows it), and the fake provider's
"bad signature" test caught that. Asymmetric algorithms only: a provider signing ID tokens with
HS256 (the client secret) is refused. Keycloak, Authentik, Authelia and the others default to
RS256 or ES256.
- `client_secret_basic` form-encodes the id and secret before base64 (RFC 6749 §2.3.1: `-` →
`%2D`); providers decode it (Keycloak does: the live test's secret has `-+/=`). If one doesn't,
a secret with symbols fails with `invalid_client`: fall back to `client_secret_post` then, if
it's ever seen.
- Callback failures count against the IP only (`isLoginBlocked(ip, null)`); one more code,
`too_many`.
- `must_change_password` doesn't block OpenID sessions (guards and the live socket both use
`mustChange()`): it's about the password, which they didn't use.
- `signIn.oidc` carries the `groups` too, shown in the Users drawer to copy into Settings.
- The callback's own log line has no query; there is no automated check that Fastify's request
log skips it (`logLevel: 'silent'`, as the caller-name route).
- Web: Settings → Sign-in, the login page button and `sso_error` messages, **Admin sign-in** in
`oidc` mode, a Sign-in card on My account and My settings (connect, disconnect), the Users
drawer's connected account, sign out following `redirect`.
- `apps/api/test/oidc.test.ts` (every case with RS256 and with ES256), `fake-oidc.ts`.
- Not yet seen in a browser against a real provider: that's S3 with the owner's Keycloak.
### 8.3 As built: S3
- `sso/oidcCheck.ts`: `checkOidcSessions` (job `oidcCheck`, every 5 minutes, sessions last
checked over 15 minutes ago) and `applyClaims`, which the callback uses too. Refresh, then
userinfo with the new access token (expected subject: the identity's `sub`), then the same
allowed/role rules as a sign-in. `invalid_grant` ends the session; anything else (down, 5xx,
`invalid_client`) keeps it for the next run. Rotated refresh and ID tokens are stored.
- `apps/api/test/oidc-live.test.ts` (`KEYCLOAK_LIVE_URL`, `KEYCLOAK_LIVE_ADMIN`): makes a
throwaway realm (client with a Group Membership mapper, group, user), signs in through
Keycloak's own login form, and deletes the realm. Passed against Keycloak 26.8.1 on 2026-10-25:
Test, connect, sign-in, group → admin, signing out there (302 back to `/login`, and Keycloak asks
for the password again), disabling the user ending our session at the next check.
- `NODE_EXTRA_CA_CERTS` in `.env.example` and 01 §1.2: the PEM goes in `data/tls/` (already
mounted read-only into the api), so no compose change.
- Admin guide: `docs/admin-guide/sign-in.md`. There is no audit log page yet, so failures are
found in the api log (`OpenID sign-in failed`, `reason`).
### 8.4 As built: S4
- `sso/ldap.ts` (ldapts 9.2: `assertLdap`, `withLogin` through `escapeFilter`, `anyLogin`,
`ldapAuthenticate`, `testLdap`), `sso/directory.ts` (entry → user, connecting by email),
`sso/role.ts` (`applyRole`, shared with OpenID). The user's own bind is on a second connection,
so the admin filter is read with the service account.
- Login (§4.2): the SelfPhoneNG password first; when it isn't that, the directory. The "last
active local admin" exception of the first draft is gone: connecting a directory account no
longer removes a password (§1), so there is nothing to protect. Sessions are `method = 'ldap'`.
- The user's name and email here are **not** changed by the directory (as with OpenID): only the
identity's email and the role (`adminFilter`) follow it. §4.3 step 1's "email follows" is
dropped; an admin edits users on Users.
- lldap answers a base search whose filter doesn't match the entry with `noSuchObject`
(0x20), not an empty result: that counts as "not admin".
- Disconnect is `DELETE /api/me/identities/:kind` and `/api/users/:id/identities/:kind`
(`oidc` or `ldap`), with one "is there still a way in" rule for both.
- `apps/api/src/sso/ldap.test.ts`, `apps/api/test/ldap-live.test.ts` (`LDAP_LIVE_URL`,
`LDAP_LIVE_HTTP`, `LDAP_LIVE_LDAPS_URL`; users made through lldap's GraphQL, passwords with an
RFC 3062 password modify). Passed against lldap 0.6.3 on 2026-10-25, LDAPS with a throwaway CA
through `NODE_EXTRA_CA_CERTS`. The AD, FreeIPA and OpenLDAP presets were not checked in S4 (AD:
§8.5).
- Web: Settings → Sign-in → Directory (presets, Test with an optional test login), "Email or
username" on the login page, the directory account on My settings and the Users drawer.
### 8.5 As built: S5
- `checkDirectory` in `sso/directory.ts` (job `directoryCheck`, hourly, `jobs.ts`), `lookupIds` in
`sso/ldap.ts`: one connection, one search per id. Any failure in the run (connect, bind, a
wrong base DN's `noSuchObject`) changes nothing, so a half-done run never reads as "gone".
- **The by-id filter is a filter object, not a string.** ldapts parses ` f` in a string filter
to U+00FF and sends it as UTF-8 (two bytes), so AD's binary `objectGUID` never matched: every
connected AD user looked gone, and the "more than half" guard is what stopped it disabling them
all (the Samba check caught it). `EqualityFilter` with a Buffer value goes out as is; the rest is
`FilterParser.parseString(userFilter with {login} → *)`. Sign-in never searches by id, so S4 was
unaffected.
- Dashboard warning `directory_check` (04 §7.3): setting `system.directoryWarning` (read-only, set
by the system), written by a sign-in's `directory_unavailable` and by the job, cleared by a clean
run. Settings → Sign-in → Directory shows it too, with the hourly check's note.
- The last active admin with a password is kept (logged) when gone, as `applyRole` keeps them.
- `test/directory-check.test.ts` (a fake lookup: update, disable, down, the half guard, last
admin, directory off), `test/ad-live.test.ts` (`AD_LIVE_URL`, `AD_LIVE_ADMIN_PASS`; users and a
nested group made over LDAPS): passed against Samba 4 (`diegogslomp/samba-ad-dc`) on 2026-10-25,
LDAPS with a throwaway CA: sign-in by sAMAccountName, UPN and mail, admin through a nested group,
the disabled bit refused, `objectGUID` as hex, a deleted user disabled. `ldap-live.test.ts`
gained a removed user disabled by the check. The OpenLDAP and FreeIPA presets are still
unchecked against a real server.
### 8.6 Security audit, 2026-10-26
Two separate reviews (one by an independent agent) of the whole branch and every API route. All
207 routes were called signed out and as a plain user: each is guarded as meant. Fixed, each with a
test:
- Login: a directory 503 is counted and audited as a failed sign-in (§4.2); a locked directory
account is a refused password, not "unavailable" (`passwordWorks`: any `ResultCodeError`).
- `next` after OpenID sign-in: no control character or backslash (`safeNext`): "/<tab>/evil" was
an open redirect, browsers drop the tab.
- Directory email matching only for non-admins without a password of their own (§4.3); others
connect on My settings (`POST /api/me/identities/ldap`). Only admins disconnect a directory
account (§4.3, §4.5).
- Session check: the refreshed ID token's claims under userinfo's (§3.5).
- Connect is `POST /api/auth/oidc/connect` (§3.2), not a GET.
- A stored bind password or client secret is only reused for the same URL and bind DN, or issuer
and client ID (400 "enter it again" otherwise, PATCH and Test); SMTP's password is dropped when
the host or username changes.
- `PATCH /api/users/:id` role → 409 `role_managed` when a provider sets it (§1); `UserDto.roleFrom`,
the Users drawer shows "From …" read-only.
- `@fastify/rate-limit` on every route (04 §2.2), stricter on Test buttons; a password asked again
shares the login limiter. `PATCH /api/me/contrack` with nothing to set: 400, not 500.
Left as is: directory usernames can be told apart by timing (one more bind when the entry exists);
the ID token goes to the provider as `id_token_hint` on sign-out (§1).
## 9. Decided
- Owner, 2026-10-25: a user connects their OpenID account to their account and signs in with
either their password or OpenID; the admin picks passwords only, OpenID only, or mixed (§1.1).
- Owner, 2026-10-25: no automatic accounts, from either provider (§1).
- Owner, 2026-10-25: keep the 15-minute session check (§3.5).
- Owner, 2026-10-25: signing out also signs out at the provider (§3.5).
- Owner, 2026-10-25: password sign-ins, directory ones included, keep our two-step sign-in (§1).
- Default (not asked): in OpenID-only mode admins can still sign in with a password (break glass,
§1.1), and pre-made users without a password are matched once by verified email (§3.3).
+25 -2
View File
@@ -3,13 +3,21 @@ import { z } from 'zod';
export const PASSWORD_MIN = 10;
export const LoginSchema = z.object({
email: z.string().trim().min(3, 'Enter your email').max(254),
/** The login form's fields: `login` is an email, or a directory username (12 §4.2). */
export const LoginFields = z.object({
login: z.string().trim().min(1, 'Enter your email').max(254),
password: z.string().min(1, 'Enter your password').max(200),
/** The authenticator code, or a recovery code ("k7m3q-x9tpw"), when two-step sign-in is on. */
totp: z.string().trim().max(20).optional(),
});
/** POST /api/auth/login. `email`, the field's name before 12, is still accepted. */
export const LoginSchema = z.preprocess(
(v) =>
v && typeof v === 'object' && !('login' in v) && 'email' in v ? { ...v, login: v.email } : v,
LoginFields,
);
export const ChangePasswordSchema = z.object({
currentPassword: z.string().min(1, 'Enter your current password').max(200),
newPassword: z
@@ -28,8 +36,23 @@ export type AuthUser = {
role: Role;
mustChangePassword: boolean;
extension: { id: string; number: string; name: string } | null;
signIn: SignInWays;
};
/** The ways a user can sign in (12 §2): their password, a connected OpenID account, the directory. */
export type SignInWays = {
password: boolean;
/** `groups`: what the provider sent at the last sign-in (12 §3.4), to copy into Settings. */
oidc: { email: string | null; groups: string[] } | null;
ldap: boolean;
};
/** 12 §1.1: which ways the login page offers. */
export type AuthMode = 'password' | 'mixed' | 'oidc';
/** GET /api/auth/methods (public): what the login page shows. */
export type AuthMethods = { mode: AuthMode; ldap: boolean; oidc: { label: string } | null };
/** Two-step sign-in (TOTP, 08 §2.4) for the signed-in user. */
export type TotpStatus = { enabled: boolean; recoveryCodesLeft: number };
/** Start: the secret to scan (not stored until a code confirms it). */
+177 -1
View File
@@ -116,6 +116,43 @@ export const SettingsSchemas = {
})
.nullable()
.default(null),
/** Sign-in (12 §1.1): passwords only, OpenID only (admins keep passwords), or both. */
'auth.mode': z.enum(['password', 'mixed', 'oidc']).default('password'),
/** LDAP / LDAPS (12 §4.1). The bind password is encrypted; null = anonymous search. */
'auth.ldap': z
.object({
enabled: z.boolean(),
preset: z.enum(['ad', 'openldap', 'freeipa', 'lldap', 'other']),
url: z.string(),
startTls: z.boolean(),
bindDn: z.string().nullable(),
bindPasswordEnc: z.string().nullable(),
baseDn: z.string(),
userFilter: z.string(),
idAttribute: z.string(),
emailAttribute: z.string(),
nameAttribute: z.string(),
adminFilter: z.string().nullable(),
})
.nullable()
.default(null),
/** OpenID Connect (12 §3.1). The client secret is encrypted; null = a public client. */
'auth.oidc': z
.object({
label: z.string(),
issuer: z.string(),
clientId: z.string(),
clientSecretEnc: z.string().nullable(),
scopes: z.string(),
groupsClaim: z.string(),
adminGroups: z.array(z.string()),
allowedGroups: z.array(z.string()),
trustEmail: z.boolean(),
})
.nullable()
.default(null),
/** Why the directory couldn't be used (12 §4.2, §4.5), for the dashboard; null = fine. */
'system.directoryWarning': z.string().nullable().default(null),
// Not in 02 §3's table but used by the generator (03 §3).
'system.transportHash': z.string().nullable().default(null),
};
@@ -148,12 +185,151 @@ export const ContrackInputSchema = z.object({
});
export type ContrackInput = z.input<typeof ContrackInputSchema>;
const groupList = z
.array(z.string().trim().min(1).max(200))
.max(50)
.transform((l) => [...new Set(l)]);
/** PATCH /api/settings `auth.oidc` and its Test: the client secret is write-only (empty keeps it). */
export const OidcInputSchema = z.object({
label: z.string().trim().min(1, 'Required').max(40),
// Exactly as the provider writes it: the issuer is compared character for character
// (Authentik's ends in "/").
issuer: z.url({ protocol: /^https?$/ }).trim(),
clientId: z.string().trim().min(1, 'Required').max(200),
clientSecret: z.string().max(500).optional(),
clearClientSecret: z.boolean().optional(),
scopes: z
.string()
.trim()
.max(500)
.refine((s) => s.split(/\s+/).includes('openid'), 'Must include openid'),
groupsClaim: z.string().trim().min(1).max(200),
adminGroups: groupList,
allowedGroups: groupList,
trustEmail: z.boolean(),
});
export type OidcInput = z.input<typeof OidcInputSchema>;
/** The directory presets (12 §4.1): they only fill the form. */
export const LDAP_PRESETS = {
ad: {
label: 'Active Directory / Samba AD',
userFilter:
'(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(|(sAMAccountName={login})(userPrincipalName={login})(mail={login})))',
idAttribute: 'objectGUID',
adminFilter: '(memberOf:1.2.840.113556.1.4.1941:=CN=PBX Admins,OU=Groups,DC=example,DC=com)',
},
openldap: {
label: 'OpenLDAP (memberof overlay)',
userFilter: '(&(objectClass=inetOrgPerson)(|(uid={login})(mail={login})))',
idAttribute: 'entryUUID',
adminFilter: '(memberOf=cn=pbx-admins,ou=groups,dc=example,dc=com)',
},
freeipa: {
label: 'FreeIPA',
userFilter: '(&(objectClass=person)(!(nsAccountLock=TRUE))(|(uid={login})(mail={login})))',
idAttribute: 'ipaUniqueID',
adminFilter: '(memberOf=cn=pbx-admins,cn=groups,cn=accounts,dc=example,dc=com)',
},
lldap: {
label: 'lldap',
userFilter: '(&(objectClass=person)(|(uid={login})(mail={login})))',
idAttribute: 'entryUUID',
adminFilter: '(memberOf=cn=pbx-admins,ou=groups,dc=example,dc=com)',
},
other: {
label: 'Other',
userFilter: '(&(objectClass=person)(|(uid={login})(mail={login})))',
idAttribute: 'entryUUID',
adminFilter: '',
},
} as const;
/** PATCH /api/settings `auth.ldap` and its Test: the bind password is write-only. */
export const LdapInputSchema = z.object({
enabled: z.boolean(),
preset: z.enum(['ad', 'openldap', 'freeipa', 'lldap', 'other']),
url: z
.string()
.trim()
.regex(
/^ldaps?:\/\/[^/\s]+\/?$/,
'Like ldaps://dc1.example.com or ldap://ldap.example.com:389',
),
startTls: z.boolean(),
bindDn: z
.string()
.trim()
.max(500)
.transform((v) => v || null),
bindPassword: z.string().max(500).optional(),
clearBindPassword: z.boolean().optional(),
baseDn: z.string().trim().min(1, 'Required').max(500),
userFilter: z
.string()
.trim()
.max(2000)
.refine((v) => v.includes('{login}'), 'Must contain {login}'),
idAttribute: z.string().trim().min(1).max(100),
emailAttribute: z.string().trim().min(1).max(100),
nameAttribute: z.string().trim().min(1).max(100),
adminFilter: z
.string()
.trim()
.max(2000)
.transform((v) => v || null),
});
export type LdapInput = z.input<typeof LdapInputSchema>;
/** POST /api/settings/auth/ldap/test. `user`: with a test login, who it would sign in as. */
export type LdapTestResult =
| {
ok: true;
matching: number;
user?: {
dn: string;
id: string;
email: string | null;
name: string | null;
admin: boolean | null;
matches: string | null;
};
}
| { ok: false; error: string };
/** POST /api/settings/auth/oidc/test. */
export type OidcTestResult =
| {
ok: true;
issuer: string;
algorithms: string[];
pkce: boolean;
refreshTokens: boolean;
endSession: boolean;
redirectUri: string;
postLogoutRedirectUri: string;
}
| { ok: false; error: string };
/** GET /api/settings: every key, secrets replaced by hasPassword / hasCompanyToken. */
export type SettingsDto = {
[K in Exclude<SettingKey, 'smtp' | 'sms.webhookToken' | 'contacts.contrack'>]: SettingValue<K>;
[
K in Exclude<
SettingKey,
'smtp' | 'sms.webhookToken' | 'contacts.contrack' | 'auth.oidc' | 'auth.ldap'
>
]: SettingValue<K>;
} & {
smtp: (Omit<SmtpInput, 'password'> & { hasPassword: boolean }) | null;
'contacts.contrack': { url: string; publicUrl: string | null; hasCompanyToken: boolean } | null;
'auth.oidc':
(Omit<OidcInput, 'clientSecret' | 'clearClientSecret'> & { hasClientSecret: boolean }) | null;
'auth.ldap':
| (Omit<z.output<typeof LdapInputSchema>, 'bindPassword' | 'clearBindPassword'> & {
hasBindPassword: boolean;
})
| null;
};
/** POST /api/ai/test: one endpoint tried with a tiny request. `audio`: the TTS test, a data: URL. */
+4
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { z } from 'zod';
import type { SignInWays } from './auth.ts';
export const RoleSchema = z.enum(['admin', 'user']);
@@ -34,6 +35,9 @@ export type UserDto = {
lastLoginAt: string | null;
createdAt: string;
extension: { id: string; number: string } | null;
signIn: SignInWays;
/** Who sets the role (12 §1): the provider's label, "the directory", or null (set here). */
roleFrom: string | null;
};
export type ListResponse<T> = { items: T[]; nextCursor: string | null };
+56
View File
@@ -44,6 +44,9 @@ importers:
'@fastify/cookie':
specifier: ^11.1.3
version: 11.1.3
'@fastify/rate-limit':
specifier: ^11.2.1
version: 11.2.1
'@fastify/static':
specifier: ^10.1.6
version: 10.1.6
@@ -62,9 +65,15 @@ importers:
fastify:
specifier: ^5.12.7
version: 5.12.7
ldapts:
specifier: ^9.2.0
version: 9.2.0
nodemailer:
specifier: 10.0.11
version: 10.0.11
openid-client:
specifier: ^6.8.8
version: 6.8.8
pg:
specifier: ^8.23.1
version: 8.23.1
@@ -751,6 +760,9 @@ packages:
'@fastify/proxy-addr@5.1.2':
resolution: {integrity: sha512-uqB4QsgVoro0XDC+DIftIMGXHdBPn+lK6Ek3xXAIxPMCJfqxnbtyEoMCm2KPUVOuIosd7Cyw9HawEqMFM/UA+g==}
'@fastify/rate-limit@11.2.1':
resolution: {integrity: sha512-uTQjii3tJUnb6ZyEMcunuvKE3FDKNZEaPpxdyWADDgthHnUV1swdvwuiZfi3DDDgxm1iBrhOkQ/w0yDBQ8qJtA==}
'@fastify/send@4.1.2':
resolution: {integrity: sha512-PitRBXg/N4IrE5ToLuP7lITI5Mz8MmP13dv2aMtMNfaGfc5W5hNOwHqcsdQdqu0Lwm5cpDiYLkiOsZTgmhyt2Q==}
@@ -1912,6 +1924,10 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ip-address@10.7.5:
resolution: {integrity: sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==}
engines: {node: '>= 12'}
ipaddr.js@2.5.0:
resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==}
engines: {node: '>= 10'}
@@ -1935,6 +1951,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
jose@6.2.13:
resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1963,6 +1982,10 @@ packages:
keyv@5.6.0:
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
ldapts@9.2.0:
resolution: {integrity: sha512-5mDg3OT5fDP9DUXga9dCLW8cMW24OTOKT4pv/Qj+f2zCV3qxb+gkZLCzMAqzVTOANoycidKkYpPvkclUQU2aXw==}
engines: {node: '>=22'}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -2171,6 +2194,9 @@ packages:
resolution: {integrity: sha512-/c4P7U7aGpiNu2Rl08q525q2zECkUpSaf2zQPgIStCcAjHAkkQQKwD4sTyi3l91mGcLQrKzL1syXPHymo+Ogtw==}
engines: {node: '>=20.0.0'}
oauth4webapi@3.8.8:
resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==}
obug@3.0.0:
resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==}
engines: {node: '>=12.20.0'}
@@ -2182,6 +2208,9 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
openid-client@6.8.8:
resolution: {integrity: sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -2467,6 +2496,9 @@ packages:
stream-shift@1.0.3:
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
strict-event-emitter-types@2.0.0:
resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -3142,6 +3174,13 @@ snapshots:
'@fastify/forwarded': 3.1.1
ipaddr.js: 2.5.0
'@fastify/rate-limit@11.2.1':
dependencies:
'@lukeed/ms': 2.0.2
fastify-plugin: 6.0.1
ip-address: 10.7.5
toad-cache: 3.7.4
'@fastify/send@4.1.2':
dependencies:
'@lukeed/ms': 2.0.2
@@ -4083,6 +4122,8 @@ snapshots:
inherits@2.0.4: {}
ip-address@10.7.5: {}
ipaddr.js@2.5.0: {}
is-extglob@2.1.1: {}
@@ -4097,6 +4138,8 @@ snapshots:
jiti@2.7.0: {}
jose@6.2.13: {}
js-tokens@4.0.0: {}
jsesc@3.1.0: {}
@@ -4117,6 +4160,10 @@ snapshots:
dependencies:
'@keyv/serialize': 1.1.1
ldapts@9.2.0:
dependencies:
strict-event-emitter-types: 2.0.0
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -4267,6 +4314,8 @@ snapshots:
nodemailer@10.0.11: {}
oauth4webapi@3.8.8: {}
obug@3.0.0: {}
on-exit-leak-free@2.1.2: {}
@@ -4275,6 +4324,11 @@ snapshots:
dependencies:
wrappy: 1.0.2
openid-client@6.8.8:
dependencies:
jose: 6.2.13
oauth4webapi: 3.8.8
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -4527,6 +4581,8 @@ snapshots:
stream-shift@1.0.3: {}
strict-event-emitter-types@2.0.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0