diff --git a/.env.example b/.env.example index 5835676..2deb592 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 58d0544..210f7e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/apps/api/package.json b/apps/api/package.json index f4aa6a9..fc387d0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ee254f6..496e119 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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); diff --git a/apps/api/src/auth/guards.ts b/apps/api/src/auth/guards.ts index 0cc18c1..925aa73 100644 --- a/apps/api/src/auth/guards.ts +++ b/apps/api/src/auth/guards.ts @@ -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 }; diff --git a/apps/api/src/auth/login-limiter.ts b/apps/api/src/auth/login-limiter.ts index 696c598..13b5c47 100644 --- a/apps/api/src/auth/login-limiter.ts +++ b/apps/api/src/auth/login-limiter.ts @@ -15,21 +15,23 @@ function recent(key: string, windowMs: number, now: number): number[] { return list; } -export function isLoginBlocked(ip: string, email: string, now = Date.now()): boolean { +/** `email` null: by IP only (an OpenID callback names no account, 12 §3.2). */ +export function isLoginBlocked(ip: string, email: string | null, now = Date.now()): boolean { return ( recent(`ip:${ip}`, RULES.ip.windowMs, now).length >= RULES.ip.max || - recent(`email:${email.toLowerCase()}`, RULES.email.windowMs, now).length >= RULES.email.max + (email !== null && + recent(`email:${email.toLowerCase()}`, RULES.email.windowMs, now).length >= RULES.email.max) ); } -export function recordLoginFailure(ip: string, email: string, now = Date.now()): void { +export function recordLoginFailure(ip: string, email: string | null, now = Date.now()): void { // Forget keys past every window, at most once a minute: emails tried once and never again // would otherwise stay until restart (08 §6). if (now - lastSweep > MIN) { lastSweep = now; for (const [key, list] of failures) if (now - list.at(-1)! >= LONGEST) failures.delete(key); } - for (const key of [`ip:${ip}`, `email:${email.toLowerCase()}`]) { + for (const key of [`ip:${ip}`, ...(email === null ? [] : [`email:${email.toLowerCase()}`])]) { failures.set(key, [...(failures.get(key) ?? []), now]); } } diff --git a/apps/api/src/auth/sessions.ts b/apps/api/src/auth/sessions.ts index 5aa617a..be71fe0 100644 --- a/apps/api/src/auth/sessions.ts +++ b/apps/api/src/auth/sessions.ts @@ -4,6 +4,7 @@ import { createHash, randomBytes } from 'node:crypto'; import { and, eq, gt, ne } from 'drizzle-orm'; import type { FastifyReply } from 'fastify'; import type { Db } from '../db/client.ts'; +import { encrypt } from '../crypto/secrets.ts'; import { sessions, users } from '../db/schema.ts'; export const COOKIE = 'sp_session'; @@ -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 { 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))); } diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 59421f6..a41589f 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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), diff --git a/apps/api/src/db/migrations/0012_sso.sql b/apps/api/src/db/migrations/0012_sso.sql new file mode 100644 index 0000000..29c0ad7 --- /dev/null +++ b/apps/api/src/db/migrations/0012_sso.sql @@ -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')); \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/0012_snapshot.json b/apps/api/src/db/migrations/meta/0012_snapshot.json new file mode 100644 index 0000000..95d5f3f --- /dev/null +++ b/apps/api/src/db/migrations/meta/0012_snapshot.json @@ -0,0 +1,4487 @@ +{ + "id": "92199db9-7d77-4ea4-8cc5-fe79a0d702e5", + "prevId": "e22c65f0-c8ee-42c8-aa4f-667a4831350d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.announcements": { + "name": "announcements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sound_id": { + "name": "sound_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "announcements_sound_id_sound_files_id_fk": { + "name": "announcements_sound_id_sound_files_id_fk", + "tableFrom": "announcements", + "tableTo": "sound_files", + "columnsFrom": [ + "sound_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_tokens_created_by_users_id_fk": { + "name": "api_tokens_created_by_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_tokens_tokenHash_unique": { + "name": "api_tokens_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_ip": { + "name": "actor_ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "diff": { + "name": "diff", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actor_user_id_users_id_fk": { + "name": "audit_log_actor_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blocked_numbers": { + "name": "blocked_numbers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blocked_numbers_number_unique": { + "name": "blocked_numbers_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.call_extensions": { + "name": "call_extensions", + "schema": "", + "columns": { + "call_id": { + "name": "call_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "call_extensions_call_id_calls_id_fk": { + "name": "call_extensions_call_id_calls_id_fk", + "tableFrom": "call_extensions", + "tableTo": "calls", + "columnsFrom": [ + "call_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "call_extensions_extension_id_extensions_id_fk": { + "name": "call_extensions_extension_id_extensions_id_fk", + "tableFrom": "call_extensions", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "call_extensions_call_id_extension_id_role_pk": { + "name": "call_extensions_call_id_extension_id_role_pk", + "columns": [ + "call_id", + "extension_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "call_extensions_role_check": { + "name": "call_extensions_role_check", + "value": "\"call_extensions\".\"role\" in ('caller', 'rang', 'answered')" + } + }, + "isRLSEnabled": false + }, + "public.calls": { + "name": "calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "linkedid": { + "name": "linkedid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "call_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "call_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "answered_at": { + "name": "answered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "from_number": { + "name": "from_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_number": { + "name": "to_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "did_id": { + "name": "did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "answered_by_extension_id": { + "name": "answered_by_extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "talk_seconds": { + "name": "talk_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "recording_id": { + "name": "recording_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "calls_started_at_index": { + "name": "calls_started_at_index", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calls_did_id_dids_id_fk": { + "name": "calls_did_id_dids_id_fk", + "tableFrom": "calls", + "tableTo": "dids", + "columnsFrom": [ + "did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "calls_answered_by_extension_id_extensions_id_fk": { + "name": "calls_answered_by_extension_id_extensions_id_fk", + "tableFrom": "calls", + "tableTo": "extensions", + "columnsFrom": [ + "answered_by_extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "calls_recording_id_recordings_id_fk": { + "name": "calls_recording_id_recordings_id_fk", + "tableFrom": "calls", + "tableTo": "recordings", + "columnsFrom": [ + "recording_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "calls_linkedid_unique": { + "name": "calls_linkedid_unique", + "nullsNotDistinct": false, + "columns": [ + "linkedid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cdr": { + "name": "cdr", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "uniqueid": { + "name": "uniqueid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linkedid": { + "name": "linkedid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_at": { + "name": "start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "answer_at": { + "name": "answer_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "end_at": { + "name": "end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billsec": { + "name": "billsec", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "src": { + "name": "src", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dst": { + "name": "dst", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dcontext": { + "name": "dcontext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "clid": { + "name": "clid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dstchannel": { + "name": "dstchannel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastapp": { + "name": "lastapp", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastdata": { + "name": "lastdata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disposition": { + "name": "disposition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accountcode": { + "name": "accountcode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userfield": { + "name": "userfield", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sp_did": { + "name": "sp_did", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sp_direction": { + "name": "sp_direction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sp_recording": { + "name": "sp_recording", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cdr_linkedid_index": { + "name": "cdr_linkedid_index", + "columns": [ + { + "expression": "linkedid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conferences": { + "name": "conferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pin_enc": { + "name": "pin_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_pin_enc": { + "name": "admin_pin_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_members": { + "name": "max_members", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "record": { + "name": "record", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "conferences_number_unique": { + "name": "conferences_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_shares": { + "name": "contact_shares", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "from_user_id": { + "name": "from_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "to_user_id": { + "name": "to_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact": { + "name": "contact", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contact_shares_to_user_id_index": { + "name": "contact_shares_to_user_id_index", + "columns": [ + { + "expression": "to_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"contact_shares\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contact_shares_from_user_id_users_id_fk": { + "name": "contact_shares_from_user_id_users_id_fk", + "tableFrom": "contact_shares", + "tableTo": "users", + "columnsFrom": [ + "from_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "contact_shares_to_user_id_users_id_fk": { + "name": "contact_shares_to_user_id_users_id_fk", + "tableFrom": "contact_shares", + "tableTo": "users", + "columnsFrom": [ + "to_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "contact_shares_status_check": { + "name": "contact_shares_status_check", + "value": "\"contact_shares\".\"status\" in ('pending', 'accepted', 'denied')" + } + }, + "isRLSEnabled": false + }, + "public.contacts": { + "name": "contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "numbers": { + "name": "numbers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "contacts_owner_user_id_users_id_fk": { + "name": "contacts_owner_user_id_users_id_fk", + "tableFrom": "contacts", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contrack_call_log": { + "name": "contrack_call_log", + "schema": "", + "columns": { + "call_id": { + "name": "call_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "interaction_id": { + "name": "interaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logged_at": { + "name": "logged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "contrack_call_log_call_id_calls_id_fk": { + "name": "contrack_call_log_call_id_calls_id_fk", + "tableFrom": "contrack_call_log", + "tableTo": "calls", + "columnsFrom": [ + "call_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contrack_call_log_user_id_users_id_fk": { + "name": "contrack_call_log_user_id_users_id_fk", + "tableFrom": "contrack_call_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "contrack_call_log_call_id_user_id_pk": { + "name": "contrack_call_log_call_id_user_id_pk", + "columns": [ + "call_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mac": { + "name": "mac", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "phone_vendor", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provisioning_password_enc": { + "name": "provisioning_password_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_provisioned_at": { + "name": "last_provisioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_provisioned_ip": { + "name": "last_provisioned_ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "devices_extension_id_extensions_id_fk": { + "name": "devices_extension_id_extensions_id_fk", + "tableFrom": "devices", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "devices_mac_unique": { + "name": "devices_mac_unique", + "nullsNotDistinct": false, + "columns": [ + "mac" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.did_sms_users": { + "name": "did_sms_users", + "schema": "", + "columns": { + "did_id": { + "name": "did_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "did_sms_users_did_id_dids_id_fk": { + "name": "did_sms_users_did_id_dids_id_fk", + "tableFrom": "did_sms_users", + "tableTo": "dids", + "columnsFrom": [ + "did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "did_sms_users_user_id_users_id_fk": { + "name": "did_sms_users_user_id_users_id_fk", + "tableFrom": "did_sms_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "did_sms_users_did_id_user_id_pk": { + "name": "did_sms_users_did_id_user_id_pk", + "columns": [ + "did_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dids": { + "name": "dids", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trunk_id": { + "name": "trunk_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "caller_id_name_prefix": { + "name": "caller_id_name_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record_inbound": { + "name": "record_inbound", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sms_enabled": { + "name": "sms_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "e911_address": { + "name": "e911_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dids_trunk_id_trunks_id_fk": { + "name": "dids_trunk_id_trunks_id_fk", + "tableFrom": "dids", + "tableTo": "trunks", + "columnsFrom": [ + "trunk_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dids_number_unique": { + "name": "dids_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.emergency_events": { + "name": "emergency_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "extension_number": { + "name": "extension_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dialed": { + "name": "dialed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caller_id_used": { + "name": "caller_id_used", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notified": { + "name": "notified", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.extensions": { + "name": "extensions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sip_password_enc": { + "name": "sip_password_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webrtc_enabled": { + "name": "webrtc_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "max_contacts": { + "name": "max_contacts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "ring_seconds": { + "name": "ring_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 25 + }, + "voicemail_enabled": { + "name": "voicemail_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "voicemail_pin_enc": { + "name": "voicemail_pin_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voicemail_email": { + "name": "voicemail_email", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "voicemail_attach_audio": { + "name": "voicemail_attach_audio", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "voicemail_delete_after_email": { + "name": "voicemail_delete_after_email", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "outbound_class": { + "name": "outbound_class", + "type": "outbound_class", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'national'" + }, + "outbound_caller_id_did_id": { + "name": "outbound_caller_id_did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "emergency_caller_id_did_id": { + "name": "emergency_caller_id_did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "emergency_location": { + "name": "emergency_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record_mode": { + "name": "record_mode", + "type": "record_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'never'" + }, + "pickup_group": { + "name": "pickup_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "moh_class": { + "name": "moh_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "blf_numbers": { + "name": "blf_numbers", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "call_report": { + "name": "call_report", + "type": "call_report", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'off'" + }, + "call_report_time": { + "name": "call_report_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'08:00'" + }, + "call_report_weekday": { + "name": "call_report_weekday", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "call_report_summaries": { + "name": "call_report_summaries", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "call_report_sent_at": { + "name": "call_report_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "extensions_user_id_users_id_fk": { + "name": "extensions_user_id_users_id_fk", + "tableFrom": "extensions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "extensions_outbound_caller_id_did_id_dids_id_fk": { + "name": "extensions_outbound_caller_id_did_id_dids_id_fk", + "tableFrom": "extensions", + "tableTo": "dids", + "columnsFrom": [ + "outbound_caller_id_did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "extensions_emergency_caller_id_did_id_dids_id_fk": { + "name": "extensions_emergency_caller_id_did_id_dids_id_fk", + "tableFrom": "extensions", + "tableTo": "dids", + "columnsFrom": [ + "emergency_caller_id_did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "extensions_number_unique": { + "name": "extensions_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + }, + "extensions_userId_unique": { + "name": "extensions_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generator_runs": { + "name": "generator_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "files_changed": { + "name": "files_changed", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "commands": { + "name": "commands", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.holidays": { + "name": "holidays", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "recurring_yearly": { + "name": "recurring_yearly", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ivr_options": { + "name": "ivr_options", + "schema": "", + "columns": { + "ivr_id": { + "name": "ivr_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "digit": { + "name": "digit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ivr_options_ivr_id_ivrs_id_fk": { + "name": "ivr_options_ivr_id_ivrs_id_fk", + "tableFrom": "ivr_options", + "tableTo": "ivrs", + "columnsFrom": [ + "ivr_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ivr_options_ivr_id_digit_pk": { + "name": "ivr_options_ivr_id_digit_pk", + "columns": [ + "ivr_id", + "digit" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ivr_options_digit_check": { + "name": "ivr_options_digit_check", + "value": "\"ivr_options\".\"digit\" ~ '^[0-9*#]$'" + } + }, + "isRLSEnabled": false + }, + "public.ivrs": { + "name": "ivrs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "greeting_sound_id": { + "name": "greeting_sound_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invalid_sound_id": { + "name": "invalid_sound_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "timeout_seconds": { + "name": "timeout_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "max_retries": { + "name": "max_retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "allow_direct_dial": { + "name": "allow_direct_dial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timeout_destination": { + "name": "timeout_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "invalid_destination": { + "name": "invalid_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ivrs_greeting_sound_id_sound_files_id_fk": { + "name": "ivrs_greeting_sound_id_sound_files_id_fk", + "tableFrom": "ivrs", + "tableTo": "sound_files", + "columnsFrom": [ + "greeting_sound_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "ivrs_invalid_sound_id_sound_files_id_fk": { + "name": "ivrs_invalid_sound_id_sound_files_id_fk", + "tableFrom": "ivrs", + "tableTo": "sound_files", + "columnsFrom": [ + "invalid_sound_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ivrs_number_unique": { + "name": "ivrs_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.moh_classes": { + "name": "moh_classes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shuffle": { + "name": "shuffle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "moh_classes_name_unique": { + "name": "moh_classes_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbound_routes": { + "name": "outbound_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "patterns": { + "name": "patterns", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "required_class": { + "name": "required_class", + "type": "outbound_class", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trunk_ids": { + "name": "trunk_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true + }, + "caller_id_did_id": { + "name": "caller_id_did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "outbound_routes_caller_id_did_id_dids_id_fk": { + "name": "outbound_routes_caller_id_did_id_dids_id_fk", + "tableFrom": "outbound_routes", + "tableTo": "dids", + "columnsFrom": [ + "caller_id_did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paging_group_members": { + "name": "paging_group_members", + "schema": "", + "columns": { + "paging_group_id": { + "name": "paging_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "paging_group_members_paging_group_id_paging_groups_id_fk": { + "name": "paging_group_members_paging_group_id_paging_groups_id_fk", + "tableFrom": "paging_group_members", + "tableTo": "paging_groups", + "columnsFrom": [ + "paging_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "paging_group_members_extension_id_extensions_id_fk": { + "name": "paging_group_members_extension_id_extensions_id_fk", + "tableFrom": "paging_group_members", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "paging_group_members_paging_group_id_extension_id_pk": { + "name": "paging_group_members_paging_group_id_extension_id_pk", + "columns": [ + "paging_group_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paging_groups": { + "name": "paging_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duplex": { + "name": "duplex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "paging_groups_number_unique": { + "name": "paging_groups_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_calls": { + "name": "queue_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "uniqueid": { + "name": "uniqueid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linkedid": { + "name": "linkedid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "outcome": { + "name": "outcome", + "type": "queue_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "wait_seconds": { + "name": "wait_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_extension_id": { + "name": "agent_extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "queue_calls_queue_id_joined_at_index": { + "name": "queue_calls_queue_id_joined_at_index", + "columns": [ + { + "expression": "queue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "joined_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "queue_calls_uniqueid_index": { + "name": "queue_calls_uniqueid_index", + "columns": [ + { + "expression": "uniqueid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "queue_calls_queue_id_queues_id_fk": { + "name": "queue_calls_queue_id_queues_id_fk", + "tableFrom": "queue_calls", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "queue_calls_agent_extension_id_extensions_id_fk": { + "name": "queue_calls_agent_extension_id_extensions_id_fk", + "tableFrom": "queue_calls", + "tableTo": "extensions", + "columnsFrom": [ + "agent_extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_member_state": { + "name": "queue_member_state", + "schema": "", + "columns": { + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "paused": { + "name": "paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "queue_member_state_queue_id_queues_id_fk": { + "name": "queue_member_state_queue_id_queues_id_fk", + "tableFrom": "queue_member_state", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "queue_member_state_extension_id_extensions_id_fk": { + "name": "queue_member_state_extension_id_extensions_id_fk", + "tableFrom": "queue_member_state", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "queue_member_state_queue_id_extension_id_pk": { + "name": "queue_member_state_queue_id_extension_id_pk", + "columns": [ + "queue_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_members": { + "name": "queue_members", + "schema": "", + "columns": { + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "penalty": { + "name": "penalty", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "queue_members_queue_id_queues_id_fk": { + "name": "queue_members_queue_id_queues_id_fk", + "tableFrom": "queue_members", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "queue_members_extension_id_extensions_id_fk": { + "name": "queue_members_extension_id_extensions_id_fk", + "tableFrom": "queue_members", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "queue_members_queue_id_extension_id_pk": { + "name": "queue_members_queue_id_extension_id_pk", + "columns": [ + "queue_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queues": { + "name": "queues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy": { + "name": "strategy", + "type": "queue_strategy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rrmemory'" + }, + "member_ring_seconds": { + "name": "member_ring_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "retry_seconds": { + "name": "retry_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "wrapup_seconds": { + "name": "wrapup_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_wait_seconds": { + "name": "max_wait_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300 + }, + "max_callers": { + "name": "max_callers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "moh_class": { + "name": "moh_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "announce_position": { + "name": "announce_position", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "announce_every_seconds": { + "name": "announce_every_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "join_when_no_agents": { + "name": "join_when_no_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "record": { + "name": "record", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sla_seconds": { + "name": "sla_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "caller_id_name_prefix": { + "name": "caller_id_name_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timeout_destination": { + "name": "timeout_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "queues_number_unique": { + "name": "queues_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recordings": { + "name": "recordings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "linkedid": { + "name": "linkedid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_done_at": { + "name": "ai_done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ai_error": { + "name": "ai_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "recordings_filePath_unique": { + "name": "recordings_filePath_unique", + "nullsNotDistinct": false, + "columns": [ + "file_path" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ring_group_members": { + "name": "ring_group_members", + "schema": "", + "columns": { + "ring_group_id": { + "name": "ring_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ring_group_members_ring_group_id_ring_groups_id_fk": { + "name": "ring_group_members_ring_group_id_ring_groups_id_fk", + "tableFrom": "ring_group_members", + "tableTo": "ring_groups", + "columnsFrom": [ + "ring_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ring_group_members_extension_id_extensions_id_fk": { + "name": "ring_group_members_extension_id_extensions_id_fk", + "tableFrom": "ring_group_members", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ring_group_members_ring_group_id_extension_id_pk": { + "name": "ring_group_members_ring_group_id_extension_id_pk", + "columns": [ + "ring_group_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ring_groups": { + "name": "ring_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy": { + "name": "strategy", + "type": "ring_strategy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ringall'" + }, + "ring_seconds": { + "name": "ring_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 25 + }, + "caller_id_name_prefix": { + "name": "caller_id_name_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "music_instead_of_ring": { + "name": "music_instead_of_ring", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "no_answer_destination": { + "name": "no_answer_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ring_groups_number_unique": { + "name": "ring_groups_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'password'" + }, + "idp_refresh_enc": { + "name": "idp_refresh_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idp_id_token_enc": { + "name": "idp_id_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idp_checked_at": { + "name": "idp_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_index": { + "name": "sessions_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sessions_method_check": { + "name": "sessions_method_check", + "value": "\"sessions\".\"method\" in ('password', 'ldap', 'oidc')" + } + }, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sms_messages": { + "name": "sms_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "did_id": { + "name": "did_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remote_number": { + "name": "remote_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media": { + "name": "media", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_by_user_id": { + "name": "sent_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sms_messages_did_id_remote_number_created_at_index": { + "name": "sms_messages_did_id_remote_number_created_at_index", + "columns": [ + { + "expression": "did_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "remote_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sms_messages_did_id_dids_id_fk": { + "name": "sms_messages_did_id_dids_id_fk", + "tableFrom": "sms_messages", + "tableTo": "dids", + "columnsFrom": [ + "did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sms_messages_sent_by_user_id_users_id_fk": { + "name": "sms_messages_sent_by_user_id_users_id_fk", + "tableFrom": "sms_messages", + "tableTo": "users", + "columnsFrom": [ + "sent_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sms_messages_direction_check": { + "name": "sms_messages_direction_check", + "value": "\"sms_messages\".\"direction\" in ('in', 'out')" + } + }, + "isRLSEnabled": false + }, + "public.sound_files": { + "name": "sound_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moh_class_id": { + "name": "moh_class_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tts_text": { + "name": "tts_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asterisk_name": { + "name": "asterisk_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sound_files_moh_class_id_moh_classes_id_fk": { + "name": "sound_files_moh_class_id_moh_classes_id_fk", + "tableFrom": "sound_files", + "tableTo": "moh_classes", + "columnsFrom": [ + "moh_class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sound_files_asteriskName_unique": { + "name": "sound_files_asteriskName_unique", + "nullsNotDistinct": false, + "columns": [ + "asterisk_name" + ] + } + }, + "policies": {}, + "checkConstraints": { + "sound_files_kind_check": { + "name": "sound_files_kind_check", + "value": "\"sound_files\".\"kind\" in ('prompt', 'moh')" + }, + "sound_files_source_check": { + "name": "sound_files_source_check", + "value": "\"sound_files\".\"source\" in ('upload', 'tts', 'phone')" + } + }, + "isRLSEnabled": false + }, + "public.system_events": { + "name": "system_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "system_events_created_at_index": { + "name": "system_events_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "system_events_severity_check": { + "name": "system_events_severity_check", + "value": "\"system_events\".\"severity\" in ('info', 'warning', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.time_conditions": { + "name": "time_conditions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "open_ranges": { + "name": "open_ranges", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "use_holidays": { + "name": "use_holidays", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "override_digit": { + "name": "override_digit", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "override_state": { + "name": "override_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "open_destination": { + "name": "open_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "closed_destination": { + "name": "closed_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "holiday_destination": { + "name": "holiday_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "time_conditions_overrideDigit_unique": { + "name": "time_conditions_overrideDigit_unique", + "nullsNotDistinct": false, + "columns": [ + "override_digit" + ] + } + }, + "policies": {}, + "checkConstraints": { + "time_conditions_override_digit_check": { + "name": "time_conditions_override_digit_check", + "value": "\"time_conditions\".\"override_digit\" between 1 and 9" + }, + "time_conditions_override_state_check": { + "name": "time_conditions_override_state_check", + "value": "\"time_conditions\".\"override_state\" in ('open', 'closed')" + } + }, + "isRLSEnabled": false + }, + "public.trunks": { + "name": "trunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "trunk_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'voipms'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "server_host": { + "name": "server_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_port": { + "name": "server_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5060 + }, + "transport": { + "name": "transport", + "type": "sip_transport", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'udp'" + }, + "auth_mode": { + "name": "auth_mode", + "type": "trunk_auth_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'registration'" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_domain": { + "name": "from_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "codecs": { + "name": "codecs", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{ulaw}'" + }, + "srtp": { + "name": "srtp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "max_channels": { + "name": "max_channels", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "voipms_api_username": { + "name": "voipms_api_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voipms_api_password_enc": { + "name": "voipms_api_password_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "trunks_name_unique": { + "name": "trunks_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_identities": { + "name": "user_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "identity_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_identities_kind_subject_unique": { + "name": "user_identities_kind_subject_unique", + "nullsNotDistinct": false, + "columns": [ + "kind", + "subject" + ] + }, + "user_identities_userId_kind_unique": { + "name": "user_identities_userId_kind_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "kind" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_runtime_state": { + "name": "user_runtime_state", + "schema": "", + "columns": { + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "dnd": { + "name": "dnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cfu": { + "name": "cfu", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cfb": { + "name": "cfb", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cfna": { + "name": "cfna", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_runtime_state_extension_id_extensions_id_fk": { + "name": "user_runtime_state_extension_id_extensions_id_fk", + "tableFrom": "user_runtime_state", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "citext", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "totp_secret_enc": { + "name": "totp_secret_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_recovery_hashes": { + "name": "totp_recovery_hashes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "totp_last_step": { + "name": "totp_last_step", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "contrack_token_enc": { + "name": "contrack_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contrack_log_calls": { + "name": "contrack_log_calls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "contrack_log_summaries": { + "name": "contrack_log_summaries", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "contrack_log_since": { + "name": "contrack_log_since", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voicemail_messages": { + "name": "voicemail_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "msg_id": { + "name": "msg_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caller_number": { + "name": "caller_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caller_name": { + "name": "caller_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_s": { + "name": "duration_s", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailed_at": { + "name": "emailed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "transcript": { + "name": "transcript", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "voicemail_messages_extension_id_extensions_id_fk": { + "name": "voicemail_messages_extension_id_extensions_id_fk", + "tableFrom": "voicemail_messages", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "voicemail_messages_msgId_unique": { + "name": "voicemail_messages_msgId_unique", + "nullsNotDistinct": false, + "columns": [ + "msg_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "webhook_id": { + "name": "webhook_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhooks", + "columnsFrom": [ + "webhook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_enc": { + "name": "secret_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.call_direction": { + "name": "call_direction", + "schema": "public", + "values": [ + "inbound", + "outbound", + "internal" + ] + }, + "public.call_report": { + "name": "call_report", + "schema": "public", + "values": [ + "off", + "hourly", + "daily", + "weekly" + ] + }, + "public.call_status": { + "name": "call_status", + "schema": "public", + "values": [ + "answered", + "missed", + "voicemail", + "abandoned", + "busy", + "failed" + ] + }, + "public.identity_kind": { + "name": "identity_kind", + "schema": "public", + "values": [ + "oidc", + "ldap" + ] + }, + "public.outbound_class": { + "name": "outbound_class", + "schema": "public", + "values": [ + "internal", + "local", + "national", + "international" + ] + }, + "public.phone_vendor": { + "name": "phone_vendor", + "schema": "public", + "values": [ + "yealink", + "grandstream", + "polycom", + "generic" + ] + }, + "public.queue_outcome": { + "name": "queue_outcome", + "schema": "public", + "values": [ + "answered", + "abandoned", + "timed_out" + ] + }, + "public.queue_strategy": { + "name": "queue_strategy", + "schema": "public", + "values": [ + "ringall", + "leastrecent", + "fewestcalls", + "random", + "rrmemory", + "linear" + ] + }, + "public.record_mode": { + "name": "record_mode", + "schema": "public", + "values": [ + "never", + "inbound", + "outbound", + "all" + ] + }, + "public.ring_strategy": { + "name": "ring_strategy", + "schema": "public", + "values": [ + "ringall", + "hunt" + ] + }, + "public.sip_transport": { + "name": "sip_transport", + "schema": "public", + "values": [ + "udp", + "tcp", + "tls" + ] + }, + "public.trunk_auth_mode": { + "name": "trunk_auth_mode", + "schema": "public", + "values": [ + "registration", + "ip" + ] + }, + "public.trunk_provider": { + "name": "trunk_provider", + "schema": "public", + "values": [ + "voipms", + "generic" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "user" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index 2997d7f..2e12f72 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1792788393381, "tag": "0011_contrack_call_log", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1792951905226, + "tag": "0012_sso", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index a3d9433..6849a51 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -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: " "; 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', { diff --git a/apps/api/src/jobs.ts b/apps/api/src/jobs.ts index 998cc3a..6be84b3 100644 --- a/apps/api/src/jobs.ts +++ b/apps/api/src/jobs.ts @@ -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 }); }); diff --git a/apps/api/src/live/hub.ts b/apps/api/src/live/hub.ts index e13d8bb..d76dda4 100644 --- a/apps/api/src/live/hub.ts +++ b/apps/api/src/live/hub.ts @@ -4,7 +4,7 @@ import type { FastifyInstance } from 'fastify'; import type { WebSocket } from '@fastify/websocket'; import { eq } from 'drizzle-orm'; import type { ExtensionLive, LiveMessage } from '@selfphoneng/shared'; -import { COOKIE, getSessionUser } from '../auth/sessions.ts'; +import { COOKIE, getSessionUser, mustChange } from '../auth/sessions.ts'; import { extensions } from '../db/schema.ts'; import type { LiveState } from './state.ts'; @@ -103,7 +103,7 @@ export async function liveRoutes(app: FastifyInstance) { for (const c of clients) void getSessionUser(app.db, c.token, false) .then((f) => { - if (!f || f.user.mustChangePassword || (f.user.role === 'admin') !== c.admin) + if (!f || mustChange(f) || (f.user.role === 'admin') !== c.admin) c.ws.close(4401, 'unauthorized'); }) .catch(() => {}); // database down: keep the socket, check again next time @@ -123,7 +123,7 @@ export async function liveRoutes(app: FastifyInstance) { const token = req.cookies[COOKIE]; const found = token ? await getSessionUser(app.db, token) : null; // Browsers can't read an HTTP 401 on an upgrade, so open and close with 4401 (04 §5.2). - if (!found || found.user.mustChangePassword) return ws.close(4401, 'unauthorized'); + if (!found || mustChange(found)) return ws.close(4401, 'unauthorized'); const mine = await app.db .select({ n: extensions.number }) .from(extensions) diff --git a/apps/api/src/routes/about.ts b/apps/api/src/routes/about.ts index b03d756..dc4a5aa 100644 --- a/apps/api/src/routes/about.ts +++ b/apps/api/src/routes/about.ts @@ -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 diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 285d53b..564ea74 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -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 { 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 { 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 => { + 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'); diff --git a/apps/api/src/routes/contacts.ts b/apps/api/src/routes/contacts.ts index 01f821b..4d021e6 100644 --- a/apps/api/src/routes/contacts.ts +++ b/apps/api/src/routes/contacts.ts @@ -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(), - ); - 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()); + 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) => { diff --git a/apps/api/src/routes/integrations.ts b/apps/api/src/routes/integrations.ts index fd2cda3..48c62c2 100644 --- a/apps/api/src/routes/integrations.ts +++ b/apps/api/src/routes/integrations.ts @@ -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(), diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 02e35c1..25a16da 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -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) diff --git a/apps/api/src/routes/oidc.ts b/apps/api/src/routes/oidc.ts new file mode 100644 index 0000000..5c894c4 --- /dev/null +++ b/apps/api/src/routes/oidc.ts @@ -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 "//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 { + 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; + 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); + }); +} diff --git a/apps/api/src/routes/provisioning.ts b/apps/api/src/routes/provisioning.ts index 17529db..b129b1c 100644 --- a/apps/api/src/routes/provisioning.ts +++ b/apps/api/src/routes/provisioning.ts @@ -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) diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 1d94319..5ab0a51 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -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([ 'system.restartRequired', 'system.transportHash', + 'system.directoryWarning', 'sms.webhookToken', 'setup.completed', ]); async function toDto(db: Db): Promise { - 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) { 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 { + 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 { + 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 => { + 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 => { + 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 => { + app.post('/api/ai/test', { config: SLOW }, async (req): Promise => { 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', diff --git a/apps/api/src/routes/system.ts b/apps/api/src/routes/system.ts index a5fc66b..82c9208 100644 --- a/apps/api/src/routes/system.ts +++ b/apps/api/src/routes/system.ts @@ -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 => ({ checks: await insideChecks(diag()), })); - app.post('/api/system/diagnostics/network', async (req): Promise => { - 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 => { + 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( diff --git a/apps/api/src/routes/trunks.ts b/apps/api/src/routes/trunks.ts index 7844f4e..6bb3e2d 100644 --- a/apps/api/src/routes/trunks.ts +++ b/apps/api/src/routes/trunks.ts @@ -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 => { + app.post('/api/trunks/:id/test', { config: SLOW }, async (req): Promise => { const { id } = IdParams.parse(req.params); const t = await getTrunk(db, id); if (!t.enabled) throw badRequest('Turn the trunk on first'); diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts index 9ea2bf8..371d095 100644 --- a/apps/api/src/routes/users.ts +++ b/apps/api/src/routes/users.ts @@ -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 { + 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 => + 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 { 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 { 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) => { diff --git a/apps/api/src/sso/claims.test.ts b/apps/api/src/sso/claims.test.ts new file mode 100644 index 0000000..37487a7 --- /dev/null +++ b/apps/api/src/sso/claims.test.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import { describe, expect, it } from 'vitest'; +import { claimValues, decide, verifiedEmail } from './claims.ts'; + +describe('OpenID claims (12 §3.4)', () => { + const keycloak = { + groups: ['/pbx-admins', 'pbx-users'], + realm_access: { roles: ['offline_access', 'pbx-admin'] }, + resource_access: { selfphoneng: { roles: ['operator'] } }, + 'urn:zitadel:iam:org:project:roles': { 'pbx-admin': { '123': 'example.com' } }, + hd: 'example.com', + }; + + it('reads dotted paths, whole keys first', () => { + expect(claimValues(keycloak, 'groups')).toEqual(['/pbx-admins', 'pbx-users']); + expect(claimValues(keycloak, 'realm_access.roles')).toContain('pbx-admin'); + expect(claimValues(keycloak, 'resource_access.selfphoneng.roles')).toEqual(['operator']); + expect(claimValues(keycloak, 'urn:zitadel:iam:org:project:roles')).toEqual(['pbx-admin']); + expect(claimValues(keycloak, 'hd')).toEqual(['example.com']); + expect(claimValues(keycloak, 'missing.path')).toEqual([]); + expect(claimValues({ 'a.b': ['x'], a: { b: ['y'] } }, 'a.b')).toEqual(['x']); + }); + + it('decides allowed and role, comparing names exactly', () => { + const rules = { groupsClaim: 'groups', adminGroups: [], allowedGroups: [] }; + expect(decide(rules, keycloak)).toMatchObject({ allowed: true, admin: null }); + expect(decide({ ...rules, adminGroups: ['pbx-admins'] }, keycloak).admin).toBe(false); + expect(decide({ ...rules, adminGroups: ['/pbx-admins'] }, keycloak).admin).toBe(true); + expect(decide({ ...rules, allowedGroups: ['sales'] }, keycloak).allowed).toBe(false); + expect(decide({ ...rules, allowedGroups: ['sales', 'pbx-users'] }, keycloak).allowed).toBe( + true, + ); + }); + + it('takes an email only when verified, or trusted', () => { + expect(verifiedEmail({ email: 'a@x.com', email_verified: true }, false)).toBe('a@x.com'); + expect(verifiedEmail({ email: 'a@x.com', email_verified: 'true' }, false)).toBeNull(); + expect(verifiedEmail({ email: 'a@x.com' }, false)).toBeNull(); + expect(verifiedEmail({ email: 'a@x.com' }, true)).toBe('a@x.com'); + expect(verifiedEmail({}, true)).toBeNull(); + }); +}); diff --git a/apps/api/src/sso/claims.ts b/apps/api/src/sso/claims.ts new file mode 100644 index 0000000..0266111 --- /dev/null +++ b/apps/api/src/sso/claims.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// OpenID claims → groups, allowed, role (12 §3.4). Pure: no I/O. + +type Claims = Record; + +/** + * The value at a dotted path. The whole remaining path is tried as one key first, so claim + * names with dots or colons (Zitadel's "urn:zitadel:iam:org:project:roles") work, and + * "resource_access.selfphoneng.roles" walks down. + */ +export function claimAt(claims: unknown, path: string): unknown { + if (!claims || typeof claims !== 'object') return undefined; + const o = claims as Claims; + if (path in o) return o[path]; + const dot = path.indexOf('.'); + return dot < 0 ? undefined : claimAt(o[path.slice(0, dot)], path.slice(dot + 1)); +} + +/** A string → [it]; an array → its strings; an object → its keys (Zitadel's roles). */ +export function claimValues(claims: unknown, path: string): string[] { + const v = claimAt(claims, path); + if (typeof v === 'string') return [v]; + if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string'); + if (v && typeof v === 'object') return Object.keys(v); + return []; +} + +export type OidcRules = { groupsClaim: string; adminGroups: string[]; allowedGroups: string[] }; + +export type ClaimsDecision = { + groups: string[]; + allowed: boolean; + /** null: the role is managed in SelfPhoneNG (no adminGroups). */ + admin: boolean | null; +}; + +export function decide(rules: OidcRules, claims: Claims): ClaimsDecision { + const groups = claimValues(claims, rules.groupsClaim); + const any = (list: string[]) => list.some((g) => groups.includes(g)); + return { + groups, + allowed: rules.allowedGroups.length === 0 || any(rules.allowedGroups), + admin: rules.adminGroups.length === 0 ? null : any(rules.adminGroups), + }; +} + +/** The email, when the provider says it's verified (or the admin trusts its emails, 12 §3.3). */ +export function verifiedEmail(claims: Claims, trustEmail: boolean): string | null { + const email = typeof claims.email === 'string' ? claims.email.trim() : ''; + if (!email) return null; + return claims.email_verified === true || trustEmail ? email : null; +} diff --git a/apps/api/src/sso/directory.ts b/apps/api/src/sso/directory.ts new file mode 100644 index 0000000..efbcbe5 --- /dev/null +++ b/apps/api/src/sso/directory.ts @@ -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 { + 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 { + 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>; +}): Promise { + 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; + 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); +} diff --git a/apps/api/src/sso/ldap.test.ts b/apps/api/src/sso/ldap.test.ts new file mode 100644 index 0000000..3ce48a6 --- /dev/null +++ b/apps/api/src/sso/ldap.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/sso/ldap.ts b/apps/api/src/sso/ldap.ts new file mode 100644 index 0000000..300b047 --- /dev/null +++ b/apps/api/src/sso/ldap.ts @@ -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>; + +/** 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 { + 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 { + 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 { + 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 { + 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 { + // 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(() => {}); + } +} + +/** `(&(=))`: still there, and still let in (§4.5). */ +export function byIdFilter(s: Pick, 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> { + const c = await connect(s); + try { + const found = new Map(); + 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(() => {}); + } +} diff --git a/apps/api/src/sso/oidc.ts b/apps/api/src/sso/oidc.ts new file mode 100644 index 0000000..c899028 --- /dev/null +++ b/apps/api/src/sso/oidc.ts @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// OpenID Connect relying party (12 §3) on openid-client: discovery, PKCE + state + nonce, the +// code grant, userinfo, test, signing out at the provider. No protocol code of our own. +import * as oidc from 'openid-client'; +import type { OidcTestResult, SettingValue } from '@selfphoneng/shared'; +import { decrypt } from '../crypto/secrets.ts'; + +export type OidcSettings = NonNullable>; + +/** http:// only for the fake provider in tests; a real provider is always https (12 §3.1). */ +const insecureOk = (u: URL) => + process.env.NODE_ENV === 'test' && ['127.0.0.1', 'localhost'].includes(u.hostname); + +export function assertIssuer(issuer: string): void { + const u = new URL(issuer); + if (u.protocol !== 'https:' && !insecureOk(u)) throw new Error('The issuer must be https://'); +} + +export const redirectUri = (host: string) => `https://${host}/api/auth/oidc/callback`; +export const postLogoutUri = (host: string) => `https://${host}/login`; + +let cached: { key: string; config: oidc.Configuration } | null = null; + +/** + * Discovers the provider once per settings value (a change or a failure means the next call + * discovers again) and picks client_secret_basic, else _post, else none for a public client. + */ +export async function oidcConfig(s: OidcSettings): Promise { + const key = JSON.stringify(s); + if (cached?.key === key) return cached.config; + const issuer = new URL(s.issuer); + assertIssuer(s.issuer); + const insecure = insecureOk(issuer); + const found = await oidc.discovery(issuer, s.clientId, undefined, oidc.None(), { + timeout: 5, + ...(insecure && { execute: [oidc.allowInsecureRequests] }), + }); + const meta = found.serverMetadata(); + const secret = s.clientSecretEnc ? decrypt(s.clientSecretEnc) : null; + const methods = meta.token_endpoint_auth_methods_supported ?? ['client_secret_basic']; + const auth = !secret + ? oidc.None() + : methods.includes('client_secret_basic') || !methods.includes('client_secret_post') + ? oidc.ClientSecretBasic(secret) + : oidc.ClientSecretPost(secret); + const config = new oidc.Configuration(meta, s.clientId, undefined, auth); + config.timeout = 5; + // Check ID token signatures too (by default TLS to the token endpoint is trusted instead). + oidc.enableNonRepudiationChecks(config); + if (insecure) oidc.allowInsecureRequests(config); + cached = { key, config }; + return config; +} + +/** What the sp_oidc cookie holds between start and callback (12 §3.2). */ +export type PendingSignIn = { + state: string; + nonce: string; + verifier: string; + next: string; + /** Connecting from My account: the session that asked (12 §3.3). */ + connectSessionId: string | null; + exp: number; +}; + +export async function startSignIn( + s: OidcSettings, + host: string, + next: string, + connectSessionId: string | null, +): Promise<{ url: URL; pending: PendingSignIn }> { + const config = await oidcConfig(s); + const pending: PendingSignIn = { + state: oidc.randomState(), + nonce: oidc.randomNonce(), + verifier: oidc.randomPKCECodeVerifier(), + next, + connectSessionId, + exp: Date.now() + 600_000, + }; + const url = oidc.buildAuthorizationUrl(config, { + redirect_uri: redirectUri(host), + scope: s.scopes, + state: pending.state, + nonce: pending.nonce, + code_challenge: await oidc.calculatePKCECodeChallenge(pending.verifier), + code_challenge_method: 'S256', + }); + return { url, pending }; +} + +export type SignInResult = { + /** " " (12 §2). */ + subject: string; + /** The ID token's claims, then userinfo's on top. */ + claims: Record; + idToken: string; + refreshToken: string | null; +}; + +/** Checks the provider's answer (state, PKCE, nonce, ID token) and reads userinfo. */ +export async function finishSignIn( + s: OidcSettings, + currentUrl: URL, + pending: PendingSignIn, +): Promise { + const config = await oidcConfig(s); + const tokens = await oidc.authorizationCodeGrant(config, currentUrl, { + pkceCodeVerifier: pending.verifier, + expectedState: pending.state, + expectedNonce: pending.nonce, + idTokenExpected: true, + }); + const id = tokens.claims()!; + let claims: Record = { ...id }; + // Several providers put email or groups only in userinfo (12 §3.2 step 4). + if (config.serverMetadata().userinfo_endpoint) + claims = { ...claims, ...(await oidc.fetchUserInfo(config, tokens.access_token, id.sub)) }; + return { + subject: `${id.iss} ${id.sub}`, + claims, + idToken: tokens.id_token!, + refreshToken: tokens.refresh_token ?? null, + }; +} + +/** RP-initiated logout (12 §3.5): where to send the browser, or null when the provider can't. */ +export async function endSessionUrl( + s: OidcSettings, + host: string, + idToken: string, +): Promise { + const config = await oidcConfig(s); + if (!config.serverMetadata().end_session_endpoint) return null; + return oidc + .buildEndSessionUrl(config, { + id_token_hint: idToken, + post_logout_redirect_uri: postLogoutUri(host), + }) + .toString(); +} + +/** A provider error as one line for the log and the admin (never a token). */ +export function oidcError(err: unknown): string { + if (err instanceof oidc.ResponseBodyError) + return `${err.error}${err.error_description ? `: ${err.error_description}` : ''}`; + if (err instanceof oidc.AuthorizationResponseError) + return `${err.error}${err.error_description ? `: ${err.error_description}` : ''}`; + return (err as Error).message; +} + +/** + * Settings → Sign-in → Test (12 §3.1): discovery, then a code grant with a code that can't + * exist: `invalid_grant` means the client id and secret were accepted. + */ +export async function testOidc(s: OidcSettings, host: string): Promise { + try { + cached = null; + const config = await oidcConfig(s); + const meta = config.serverMetadata(); + try { + await oidc.genericGrantRequest(config, 'authorization_code', { + code: 'selfphoneng-test-not-a-code', + redirect_uri: redirectUri(host), + code_verifier: oidc.randomPKCECodeVerifier(), + }); + } catch (err) { + const code = err instanceof oidc.ResponseBodyError ? err.error : null; + if (code === 'invalid_client' || code === 'unauthorized_client') + return { ok: false, error: `The provider refused the client ID or secret (${code})` }; + if (code !== 'invalid_grant' && code !== 'invalid_request') + return { ok: false, error: `The token endpoint answered: ${oidcError(err)}` }; + } + return { + ok: true, + issuer: meta.issuer, + algorithms: meta.id_token_signing_alg_values_supported ?? [], + pkce: meta.code_challenge_methods_supported?.includes('S256') ?? false, + refreshTokens: meta.grant_types_supported?.includes('refresh_token') ?? false, + endSession: Boolean(meta.end_session_endpoint), + redirectUri: redirectUri(host), + postLogoutRedirectUri: postLogoutUri(host), + }; + } catch (err) { + return { ok: false, error: oidcError(err) }; + } +} diff --git a/apps/api/src/sso/oidcCheck.ts b/apps/api/src/sso/oidcCheck.ts new file mode 100644 index 0000000..f86ec9d --- /dev/null +++ b/apps/api/src/sso/oidcCheck.ts @@ -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 { + 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 { + 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>; + let claims: Record; + 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); + } +} diff --git a/apps/api/src/sso/role.ts b/apps/api/src/sso/role.ts new file mode 100644 index 0000000..b4970e7 --- /dev/null +++ b/apps/api/src/sso/role.ts @@ -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 { + 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 } }, + }); +} diff --git a/apps/api/test/ad-live.test.ts b/apps/api/test/ad-live.test.ts new file mode 100644 index 0000000..4f5fe94 --- /dev/null +++ b/apps/api/test/ad-live.test.ts @@ -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>; + 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); + }); +}); diff --git a/apps/api/test/app.ts b/apps/api/test/app.ts index 41a1c3c..e6acf24 100644 --- a/apps/api/test/app.ts +++ b/apps/api/test/app.ts @@ -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-')), diff --git a/apps/api/test/directory-check.test.ts b/apps/api/test/directory-check.test.ts new file mode 100644 index 0000000..4391cad --- /dev/null +++ b/apps/api/test/directory-check.test.ts @@ -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>; + 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(); + }); +}); diff --git a/apps/api/test/directory-signin.test.ts b/apps/api/test/directory-signin.test.ts new file mode 100644 index 0000000..273f73d --- /dev/null +++ b/apps/api/test/directory-signin.test.ts @@ -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>; + 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(); + } + }); +}); diff --git a/apps/api/test/fake-oidc.ts b/apps/api/test/fake-oidc.ts new file mode 100644 index 0000000..8a738b2 --- /dev/null +++ b/apps/api/test/fake-oidc.ts @@ -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(); + const access = new Map(); + const refresh = new Map(); + + const server = createServer(async (req, res) => { + const url = new URL(req.url!, issuer); + const json = (code: number, body: unknown) => { + res.writeHead(code, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + if (url.pathname === '/.well-known/openid-configuration') + return json(200, { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + userinfo_endpoint: `${issuer}/userinfo`, + jwks_uri: `${issuer}/jwks`, + end_session_endpoint: `${issuer}/logout`, + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: [alg], + code_challenge_methods_supported: ['S256'], + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + }); + if (url.pathname === '/jwks') + return json(200, { + keys: [{ ...pair.publicKey.export({ format: 'jwk' }), kid: 'k1', alg, use: 'sig' }], + }); + if (url.pathname === '/authorize') { + const p = url.searchParams; + if (p.get('client_id') !== clientId || p.get('code_challenge_method') !== 'S256') + return json(400, { error: 'invalid_request' }); + const code = b64u(randomBytes(16)); + codes.set(code, { + nonce: p.get('nonce')!, + challenge: p.get('code_challenge')!, + user: { ...st.user }, + }); + const back = new URL(p.get('redirect_uri')!); + back.searchParams.set('code', code); + back.searchParams.set('state', p.get('state')!); + res.writeHead(302, { location: back.href }); + return res.end(); + } + if (url.pathname === '/token' && req.method === 'POST') { + 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((r) => server.listen(0, '127.0.0.1', r)); + const issuer = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + return { + issuer, + clientId, + clientSecret, + st, + close: () => new Promise((r) => server.close(() => r())), + }; +} + +async function body(req: IncomingMessage): Promise { + let s = ''; + for await (const chunk of req) s += chunk; + return s; +} diff --git a/apps/api/test/ldap-live.test.ts b/apps/api/test/ldap-live.test.ts new file mode 100644 index 0000000..7446c7d --- /dev/null +++ b/apps/api/test/ldap-live.test.ts @@ -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:) 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>; + 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; 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()); + } + }); +}); diff --git a/apps/api/test/oidc-live.test.ts b/apps/api/test/oidc-live.test.ts new file mode 100644 index 0000000..1b0efae --- /dev/null +++ b/apps/api/test/oidc-live.test.ts @@ -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(); + 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>; + 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 = /]*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>) => { + 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); + }); +}); diff --git a/apps/api/test/oidc.test.ts b/apps/api/test/oidc.test.ts new file mode 100644 index 0000000..92fa741 --- /dev/null +++ b/apps/api/test/oidc.test.ts @@ -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>; + let idp: Awaited>; + let admin: string; + let oidcSettings: OidcSettings; + + beforeAll(async () => { + idp = await startFakeOidc(alg); + t = await createTestApp(); + await t.clearMustChange(ADMIN.email); + admin = await t.login(ADMIN.email, ADMIN.password); + oidcSettings = { + label: 'Keycloak', + issuer: idp.issuer, + clientId: idp.clientId, + clientSecretEnc: encrypt(idp.clientSecret), + scopes: 'openid email profile', + groupsClaim: 'groups', + adminGroups: [], + allowedGroups: [], + trustEmail: false, + }; + await setSetting(t.db, 'auth.oidc', oidcSettings); + await setSetting(t.db, 'auth.mode', 'mixed'); + }); + afterAll(async () => { + await t?.close(); + await idp?.close(); + }); + beforeEach(() => { + resetLoginLimiter(); + idp.st.tamper = {}; + idp.st.user = { sub: 'u-1', email: 'ann@example.com', email_verified: true }; + 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 }) => + 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); + } + }); + }); diff --git a/apps/api/test/rate-limit.test.ts b/apps/api/test/rate-limit.test.ts new file mode 100644 index 0000000..700f8a8 --- /dev/null +++ b/apps/api/test/rate-limit.test.ts @@ -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>; + 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'); + }); +}); diff --git a/apps/api/test/schema.test.ts b/apps/api/test/schema.test.ts index 3e512df..f64ff4c 100644 --- a/apps/api/test/schema.test.ts +++ b/apps/api/test/schema.test.ts @@ -15,7 +15,7 @@ const EXPECTED: Record> = { 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) => { diff --git a/apps/api/test/sign-in.test.ts b/apps/api/test/sign-in.test.ts new file mode 100644 index 0000000..5531847 --- /dev/null +++ b/apps/api/test/sign-in.test.ts @@ -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>; + 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 ', + }; + 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); + }); +}); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index cfb90c8..4885981 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -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(method: string, url: string, body: Blob): Promise { 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('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>('GET', '/api/users').then((r) => r.items), + /** temporaryPassword is null in OpenID-only mode (12 §3.3). */ create: (u: UserCreate) => - api<{ user: UserDto; temporaryPassword: string }>('POST', '/api/users', u), + api<{ user: UserDto; temporaryPassword: string | null }>('POST', '/api/users', u), update: (id: string, u: UserUpdate) => api('PATCH', `/api/users/${id}`, u), remove: (id: string) => api<{ ok: true }>('DELETE', `/api/users/${id}`), resetPassword: (id: string) => api<{ temporaryPassword: string }>('POST', `/api/users/${id}/reset-password`), resetTwoStep: (id: string) => api<{ ok: true }>('POST', `/api/users/${id}/reset-two-step`, {}), + 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('POST', '/api/settings/auth/oidc/test', v), + /** 12 §4.1 */ + testLdap: (v: LdapInput & { testLogin?: string; testPassword?: string }) => + api('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 { + const r = await authApi.logout().catch(() => null); + if (r?.redirect) window.location.assign(r.redirect); +} + export const aboutApi = { get: () => api('GET', '/api/about') }; export const aiApi = { diff --git a/apps/web/src/components/SignInCard.tsx b/apps/web/src/components/SignInCard.tsx new file mode 100644 index 0000000..398e392 --- /dev/null +++ b/apps/web/src/components/SignInCard.tsx @@ -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 ( +
+

Sign-in

+ {params.get('sso') === 'connected' && ( +

+ Connected. You can now sign in with {oidc?.label ?? 'OpenID'}. +

+ )} + {error && {ssoErrorText(error, oidc?.label ?? 'OpenID')}} +
+

Password

+ {ways.password ? ( + + Change password + + ) : ( +

+ You have no password here. An admin can give you one with Reset password. +

+ )} +
+ {ways.ldap ? ( +

+ Your company directory account is connected: its password works here too. Only an admin + can disconnect it. +

+ ) : ( + methods.data?.ldap && + )} + {oidc && ( +
+

{oidc.label}

+ {ways.oidc ? ( + <> +

+ Connected{ways.oidc.email ? ` as ${ways.oidc.email}` : ''}. You can sign in with{' '} + {oidc.label} + {ways.password ? ' or your password' : ''}. +

+
+ +
+ {disconnect.error && {disconnect.error.message}} + + ) : ( + <> +

+ Connect your {oidc.label} account to sign in with it as well. +

+
+ {/* The browser goes to the provider and comes back (a POST first: 12 §3.3). */} + +
+ {connect.error && {connect.error.message}} + + )} +
+ )} +
+ ); +} + +/** 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 ( +
{ + e.preventDefault(); + connect.mutate(); + }} + > +

Company directory

+

+ Connect your directory account to sign in with its password as well. +

+ + {(a) => ( + setV({ ...v, login: e.target.value })} + /> + )} + + + {(a) => ( + setV({ ...v, password: e.target.value })} + /> + )} + +
+ +
+ {connect.error && {connect.error.message}} +
+ ); +} diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index 810e607..3358746 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -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 ( -
+ {ssoError && ( +
+ {ssoErrorText(ssoError, oidc?.label ?? 'OpenID')} +
+ )} + {oidc && ( +
+ {/* A full page load: the browser goes to the provider and comes back signed in. */} + + Sign in with {oidc.label} + + {showForm && ( +

+ or with your password +

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

My account

-
-

Password

- - Change password - -
- + + {/* Two-step sign-in guards the password, so it needs one (12 §1). */} + {me.data?.signIn.password && }
); } diff --git a/apps/web/src/pages/admin/AdminLayout.tsx b/apps/web/src/pages/admin/AdminLayout.tsx index f60f390..f275275 100644 --- a/apps/web/src/pages/admin/AdminLayout.tsx +++ b/apps/web/src/pages/admin/AdminLayout.tsx @@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { Navigate, NavLink, Outlet, useNavigate } from 'react-router'; import type { AuthUser } from '@selfphoneng/shared'; -import { authApi, systemApi } from '../../api/client.ts'; +import { signOut, systemApi } from '../../api/client.ts'; import { ThemeToggle } from '../../components/ThemeToggle.tsx'; import { Brand, Button } from '../../components/ui.tsx'; import { subscribeLive, useLive } from '../../live/store.ts'; @@ -43,7 +43,7 @@ export function AdminLayout({ user }: { user: AuthUser }) { const setup = useQuery({ queryKey: ['setup'], queryFn: systemApi.setupStatus }); async function logout() { - await authApi.logout().catch(() => undefined); + await signOut(); // may leave for the OpenID provider's sign-out page (12 §3.5) qc.clear(); nav('/login', { replace: true }); } diff --git a/apps/web/src/pages/admin/SettingsPage.tsx b/apps/web/src/pages/admin/SettingsPage.tsx index c39d7f6..d14f95f 100644 --- a/apps/web/src/pages/admin/SettingsPage.tsx +++ b/apps/web/src/pages/admin/SettingsPage.tsx @@ -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' && } {settings.data && tab === 'ai' && } {settings.data && tab === 'contacts' && } + {settings.data && tab === 'sign-in' && ( +
+ + +
+ )} ); } @@ -1361,3 +1372,425 @@ export function ContactsTab({ s }: { s: SettingsDto }) { ); } + +const lines = (s: string) => + s + .split(/[\n,]/) + .map((x) => x.trim()) + .filter(Boolean); + +/** Sign-in (spec 12 §5): the mode, and the OpenID Connect provider. */ +function SignInTab({ s }: { s: SettingsDto }) { + const save = useSave(); + const o = s['auth.oidc']; + const [mode, setMode] = useState(s['auth.mode']); + const [v, setV] = useState({ + label: o?.label ?? 'Keycloak', + issuer: o?.issuer ?? '', + clientId: o?.clientId ?? 'selfphoneng', + clientSecret: '', + scopes: o?.scopes ?? 'openid email profile', + groupsClaim: o?.groupsClaim ?? 'groups', + adminGroups: o?.adminGroups.join('\n') ?? '', + allowedGroups: o?.allowedGroups.join('\n') ?? '', + trustEmail: o?.trustEmail ?? false, + }); + const input = (): OidcInput => ({ + ...v, + clientSecret: v.clientSecret || undefined, + adminGroups: lines(v.adminGroups), + allowedGroups: lines(v.allowedGroups), + }); + const test = useMutation({ mutationFn: () => settingsApi.testOidc(input()) }); + const set = (k: keyof typeof v) => (e: { target: { value: string } }) => + setV({ ...v, [k]: e.target.value }); + const host = window.location.host; + const uris = [ + ['Redirect URI', `https://${host}/api/auth/oidc/callback`], + ['Post-logout redirect URI', `https://${host}/login`], + ] as const; + const text = (k: keyof typeof v, label: string, hint?: string, placeholder?: string) => ( + + {(a) => ( + + )} + + ); + const area = (k: 'adminGroups' | 'allowedGroups', label: string, hint: string) => ( + + {(a) =>