import { ConflictException, Inject, Injectable, Logger, Optional, ServiceUnavailableException } from "@nestjs/common"; import { withTenant } from "../db/client.js"; import { loadMatrixSsoConfig, matrixUserIdFor, signSsoJwt, type MatrixSsoConfig } from "./matrix-sso.config.js"; /** What the Hub tells the web client about the caller's chat identity (no secrets). */ export interface MatrixIdentity { configured: boolean; homeserverUrl: string | null; elementUrl: string | null; /** Full MXID the Hub signs this user in as; null when SSO is not configured. */ userId: string | null; } /** A freshly minted single-use m.login.token for Element's `?loginToken=` flow. */ export interface MatrixSession { homeserverUrl: string; elementUrl: string; userId: string; loginToken: string; expiresInMs: number; } /** Minimal fetch surface so tests stub the homeserver without HTTP. */ export type MatrixFetch = (url: string, init: RequestInit) => Promise; export const MATRIX_SSO_FETCH: unique symbol = Symbol("MATRIX_SSO_FETCH"); /** Stable broker device: a failed logout leaves at most ONE stray device per user. */ const BROKER_DEVICE_ID = "HUB_SSO_BROKER"; function record(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : {}; } /** Homeserver error text WITHOUT echoing request bodies (they carry the JWT). */ async function describe(res: Response): Promise { try { const body = record(await res.json()); const errcode = typeof body.errcode === "string" ? body.errcode : "unknown"; return `HTTP ${res.status} ${errcode}`; } catch { return `HTTP ${res.status}`; } } /** * Hub → Matrix SSO. Every mint is for the session's own user: the user id is * derived server-side from `users.username`, never taken from the request. * Nothing minted here is logged or persisted; the broker access token lives * for the duration of one request and is logged out before returning. */ @Injectable() export class MatrixSsoService { private readonly logger = new Logger("MatrixSso"); private readonly fetcher: MatrixFetch; constructor(@Optional() @Inject(MATRIX_SSO_FETCH) fetcher?: MatrixFetch) { this.fetcher = fetcher ?? ((url, init) => fetch(url, init)); } async identity(workspaceId: string, userId: string): Promise { const config = loadMatrixSsoConfig(); if (config === null) return { configured: false, homeserverUrl: null, elementUrl: null, userId: null }; const mapped = matrixUserIdFor(config, await this.usernameFor(workspaceId, userId)); return { configured: true, homeserverUrl: config.homeserverUrl.origin, elementUrl: config.elementUrl.origin, userId: mapped?.userId ?? null, }; } async mintSession(workspaceId: string, userId: string): Promise { const config = loadMatrixSsoConfig(); if (config === null) { throw new ServiceUnavailableException("chat sign-in is not configured: set MATRIX_SSO_JWT_SECRET and MATRIX_SSO_HOMESERVER_URL"); } const mapped = matrixUserIdFor(config, await this.usernameFor(workspaceId, userId)); if (mapped === null) throw new ConflictException("this username cannot be used as a Matrix user id"); return this.mintFor(config, mapped.localpart); } /** JWT login → get_token (UIA via the same JWT) → logout. Exposed for tests. */ async mintFor(config: MatrixSsoConfig, localpart: string): Promise { const jwt = signSsoJwt(config, localpart); const login = await this.call(config, "/_matrix/client/v3/login", null, { type: "org.matrix.login.jwt", token: jwt, device_id: BROKER_DEVICE_ID, initial_device_display_name: "Hub sign-in broker", }, "JWT login"); const accessToken = login.access_token; const matrixUserId = login.user_id; if (typeof accessToken !== "string" || typeof matrixUserId !== "string") { throw new ServiceUnavailableException("homeserver login response missing access_token/user_id"); } try { const minted = await this.call(config, "/_matrix/client/v1/login/get_token", accessToken, { auth: { type: "org.matrix.login.jwt", token: jwt }, }, "get_token"); const loginToken = minted.login_token; if (typeof loginToken !== "string" || loginToken === "") { throw new ServiceUnavailableException("homeserver get_token response missing login_token"); } return { homeserverUrl: config.homeserverUrl.origin, elementUrl: config.elementUrl.origin, userId: matrixUserId, loginToken, expiresInMs: typeof minted.expires_in_ms === "number" ? minted.expires_in_ms : 120_000, }; } finally { await this.logout(config, accessToken); } } private async call( config: MatrixSsoConfig, path: string, accessToken: string | null, json: Record, step: string, ): Promise> { const headers: Record = { accept: "application/json", "content-type": "application/json" }; if (accessToken !== null) headers.authorization = `Bearer ${accessToken}`; let res: Response; try { res = await this.fetcher(new URL(path, config.homeserverUrl).toString(), { method: "POST", headers, body: JSON.stringify(json), redirect: "error", }); } catch (err) { this.logger.warn(`${step} unreachable: ${err instanceof Error ? err.name : "error"}`); throw new ServiceUnavailableException(`chat homeserver unreachable (${step})`); } if (!res.ok) { const detail = await describe(res); this.logger.warn(`${step} refused: ${detail}`); throw new ServiceUnavailableException(`chat homeserver refused ${step} (${detail})`); } return record(await res.json().catch(() => null)); } /** Best-effort: a stray broker device is harmless (fixed id, reused next mint). */ private async logout(config: MatrixSsoConfig, accessToken: string): Promise { try { await this.fetcher(new URL("/_matrix/client/v3/logout", config.homeserverUrl).toString(), { method: "POST", headers: { accept: "application/json", "content-type": "application/json", authorization: `Bearer ${accessToken}` }, body: "{}", redirect: "error", }); } catch { this.logger.warn("broker logout failed; device HUB_SSO_BROKER will be reused on the next mint"); } } private async usernameFor(workspaceId: string, userId: string): Promise { const row = await withTenant(workspaceId, (client) => client.query<{ username: string }>("SELECT username FROM users WHERE id = $1", [userId]), ); const username = row.rows[0]?.username; if (username === undefined) throw new ConflictException("no username on this account"); return username; } }