Feat/sso #3
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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-')),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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('&', '&'), {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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't the one set here, SelfPhoneNG asks the directory. People sign
|
||||
in with their directory username or email; the first time, they'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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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). */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 };
|
||||
Generated
+56
@@ -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
|
||||
|
||||
Reference in new issue
Block a user