diff --git a/apps/api/src/matrix-sso/matrix-sso.config.ts b/apps/api/src/matrix-sso/matrix-sso.config.ts index 2808001..14ab8d2 100644 --- a/apps/api/src/matrix-sso/matrix-sso.config.ts +++ b/apps/api/src/matrix-sso/matrix-sso.config.ts @@ -1,29 +1,30 @@ +import { createHmac } from "node:crypto"; + /** * Hub → Matrix single sign-on configuration. * - * The Hub never holds a Matrix password for its users. A dedicated Matrix - * appservice ("appservice.hub" in tuwunel.toml) owns the hub_* user namespace - * exclusively: with its as_token the Hub registers `@hub_` on first - * use and mints short-lived m.login.tokens for Element via - * POST /_matrix/client/v1/login/get_token (application-service impersonation - * via the ?user_id= query parameter). The login token is what Element - * consumes; the as_token never leaves the API process. + * The Hub never holds a Matrix password for its users. tuwunel (1.9.4) accepts + * `org.matrix.login.jwt` logins signed with a shared HMAC secret + * (`[global.jwt]` in tuwunel.toml) and, with `register_user = true`, creates + * the account on first login. The Hub signs a 60-second JWT for the CURRENT + * session's user, trades it for a broker access token, asks + * POST /_matrix/client/v1/login/get_token for a short-lived m.login.token + * (UIA satisfied by the same JWT), and logs the broker device out again. The + * m.login.token is what Element consumes. * * ENV CONTRACT (read per call, like ocisConfigFromEnv — tests reconfigure * without a rebuild; nothing is read at import time): * - * MATRIX_SSO_AS_TOKEN REQUIRED to enable. The appservice.hub - * as_token from tuwunel.toml. Scoped by the - * homeserver to the exclusive hub_* namespace. - * MATRIX_SSO_HS_TOKEN Optional. The appservice.hub hs_token; when - * set, inbound appservice transactions are - * authenticated instead of acked blindly. + * MATRIX_SSO_JWT_SECRET REQUIRED to enable. Same value as + * `[global.jwt] key` in tuwunel.toml (format HMAC). * MATRIX_SSO_HOMESERVER_URL client-server base, default * ARCHIVE_PROVISIONER_HOMESERVER_URL. * MATRIX_SSO_SERVER_NAME Matrix server_name for MXIDs, default the * homeserver URL's hostname. * MATRIX_SSO_ELEMENT_URL Element Web origin, default the homeserver * URL's origin (chat.occupymedia.cc serves both). + * MATRIX_SSO_JWT_ISSUER `iss` claim, default "tmbh-hub". + * MATRIX_SSO_JWT_AUDIENCE `aud` claim, default the server name. * MATRIX_SSO_LOCALPART_PREFIX prefix for Hub-derived localparts, default * "hub_". The literal "none" disables it — only * do that when every pre-existing Matrix @@ -35,13 +36,16 @@ export interface MatrixSsoConfig { homeserverUrl: URL; serverName: string; elementUrl: URL; - asToken: string; + jwtSecret: string; + issuer: string; + audience: string; localpartPrefix: string; } export class MatrixSsoConfigError extends Error {} const DEFAULT_PREFIX = "hub_"; +const DEFAULT_ISSUER = "tmbh-hub"; function httpUrl(name: string, raw: string): URL { let url: URL; @@ -60,8 +64,8 @@ function trimmed(env: Readonly>, name: string /** null = SSO not configured (routes answer 503); throws on malformed config. */ export function loadMatrixSsoConfig(env: Readonly> = process.env): MatrixSsoConfig | null { - const asToken = trimmed(env, "MATRIX_SSO_AS_TOKEN"); - if (asToken === "") return null; + const jwtSecret = trimmed(env, "MATRIX_SSO_JWT_SECRET"); + if (jwtSecret === "") return null; const rawHomeserver = trimmed(env, "MATRIX_SSO_HOMESERVER_URL") || trimmed(env, "ARCHIVE_PROVISIONER_HOMESERVER_URL"); if (rawHomeserver === "") { throw new MatrixSsoConfigError("MATRIX_SSO_HOMESERVER_URL (or ARCHIVE_PROVISIONER_HOMESERVER_URL) is required"); @@ -79,7 +83,9 @@ export function loadMatrixSsoConfig(env: Readonly): string => Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); + const header = encode({ alg: "HS256", typ: "JWT" }); + const payload = encode({ + sub: localpart, + iss: config.issuer, + aud: config.audience, + iat: nowSeconds, + nbf: nowSeconds - 5, + exp: nowSeconds + SSO_JWT_TTL_SECONDS, + }); + const signature = createHmac("sha256", config.jwtSecret).update(`${header}.${payload}`).digest("base64url"); + return `${header}.${payload}.${signature}`; +} diff --git a/apps/api/src/matrix-sso/matrix-sso.service.ts b/apps/api/src/matrix-sso/matrix-sso.service.ts index 121a75b..497b00e 100644 --- a/apps/api/src/matrix-sso/matrix-sso.service.ts +++ b/apps/api/src/matrix-sso/matrix-sso.service.ts @@ -1,6 +1,6 @@ import { ConflictException, Inject, Injectable, Logger, Optional, ServiceUnavailableException } from "@nestjs/common"; import { withTenant } from "../db/client.js"; -import { loadMatrixSsoConfig, matrixUserIdFor, type MatrixSsoConfig } from "./matrix-sso.config.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 { @@ -25,6 +25,9 @@ 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) : {}; } @@ -43,9 +46,8 @@ async function describe(res: Response): Promise { /** * 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 credentials the Hub touches - * are its own appservice token (homeserver-scoped to the hub_* namespace) and - * the single-use login token handed to the caller. + * 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 { @@ -71,60 +73,45 @@ export class MatrixSsoService { 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_AS_TOKEN and MATRIX_SSO_HOMESERVER_URL"); + 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); } - /** AS register (first use) → get_token via ?user_id= impersonation. Exposed for tests. */ + /** JWT login → get_token (UIA via the same JWT) → logout. Exposed for tests. */ async mintFor(config: MatrixSsoConfig, localpart: string): Promise { - const userId = `@${localpart}:${config.serverName}`; - await this.ensureUser(config, localpart); - const minted = await this.call( - config, - `/_matrix/client/v1/login/get_token?user_id=${encodeURIComponent(userId)}`, - config.asToken, - {}, - "get_token", - ); - const loginToken = minted.login_token; - if (typeof loginToken !== "string" || loginToken === "") { - throw new ServiceUnavailableException("homeserver get_token response missing login_token"); + 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"); } - return { - homeserverUrl: config.homeserverUrl.origin, - elementUrl: config.elementUrl.origin, - userId, - loginToken, - expiresInMs: typeof minted.expires_in_ms === "number" ? minted.expires_in_ms : 120_000, - }; - } - - /** Register @hub_ on first use; an existing account is fine. */ - private async ensureUser(config: MatrixSsoConfig, localpart: string): Promise { - let res: Response; try { - res = await this.fetcher(new URL("/_matrix/client/v3/register", config.homeserverUrl).toString(), { - method: "POST", - headers: { - accept: "application/json", - "content-type": "application/json", - authorization: `Bearer ${config.asToken}`, - }, - body: JSON.stringify({ type: "m.login.application_service", username: localpart, inhibit_login: true }), - redirect: "error", - }); - } catch (err) { - this.logger.warn(`register unreachable: ${err instanceof Error ? err.name : "error"}`); - throw new ServiceUnavailableException("chat homeserver unreachable (register)"); + 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); } - if (res.ok) return; - const detail = await describe(res); - if (detail.includes("M_USER_IN_USE")) return; // already provisioned — fine - this.logger.warn(`register refused: ${detail}`); - throw new ServiceUnavailableException(`chat homeserver refused register (${detail})`); } private async call( @@ -156,6 +143,20 @@ export class MatrixSsoService { 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]), diff --git a/apps/api/src/matrix-sso/matrix-sso.test.ts b/apps/api/src/matrix-sso/matrix-sso.test.ts index 32cadbd..53f5c92 100644 --- a/apps/api/src/matrix-sso/matrix-sso.test.ts +++ b/apps/api/src/matrix-sso/matrix-sso.test.ts @@ -1,11 +1,12 @@ +import { createHmac } from "node:crypto"; import { describe, expect, it } from "vitest"; import { ServiceUnavailableException } from "@nestjs/common"; -import { loadMatrixSsoConfig, matrixUserIdFor, MatrixSsoConfigError, type MatrixSsoConfig } from "./matrix-sso.config.js"; +import { loadMatrixSsoConfig, matrixUserIdFor, signSsoJwt, MatrixSsoConfigError, SSO_JWT_TTL_SECONDS, type MatrixSsoConfig } from "./matrix-sso.config.js"; import { MatrixSsoService, type MatrixFetch } from "./matrix-sso.service.js"; import { driveSessionFromEnv } from "../group-files/drive.controller.js"; const BASE_ENV = { - MATRIX_SSO_AS_TOKEN: "test-as-token", + MATRIX_SSO_JWT_SECRET: "test-shared-secret", MATRIX_SSO_HOMESERVER_URL: "https://chat.example.org", }; @@ -15,14 +16,18 @@ function config(env: Record = BASE_ENV): MatrixSsoConfig { return loaded; } +function decodePart(part: string): Record { + return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as Record; +} + interface Recorded { url: string; authorization: string | null; body: Record; } -/** Fake tuwunel: answers register / get_token, records every call. */ -function fakeHomeserver(overrides: { registerStatus?: number; getTokenStatus?: number } = {}): { fetcher: MatrixFetch; calls: Recorded[] } { +/** Fake tuwunel: answers login / get_token / logout, records every call. */ +function fakeHomeserver(overrides: { getTokenStatus?: number } = {}): { fetcher: MatrixFetch; calls: Recorded[] } { const calls: Recorded[] = []; const fetcher: MatrixFetch = async (url, init) => { const headers = new Headers(init.headers); @@ -32,11 +37,8 @@ function fakeHomeserver(overrides: { registerStatus?: number; getTokenStatus?: n body: typeof init.body === "string" ? (JSON.parse(init.body) as Record) : {}, }); const path = new URL(url).pathname; - if (path === "/_matrix/client/v3/register") { - if (overrides.registerStatus !== undefined) { - return Response.json({ errcode: "M_USER_IN_USE", error: "User ID already taken." }, { status: overrides.registerStatus }); - } - return Response.json({ user_id: "@hub_ada:chat.example.org", home_server: "chat.example.org" }); + if (path === "/_matrix/client/v3/login") { + return Response.json({ access_token: "broker-access", user_id: "@hub_ada:chat.example.org", device_id: "HUB_SSO_BROKER" }); } if (path === "/_matrix/client/v1/login/get_token") { if (overrides.getTokenStatus !== undefined) { @@ -44,26 +46,28 @@ function fakeHomeserver(overrides: { registerStatus?: number; getTokenStatus?: n } return Response.json({ login_token: "single-use-login-token", expires_in_ms: 120000 }); } + if (path === "/_matrix/client/v3/logout") return Response.json({}); return Response.json({ errcode: "M_UNRECOGNIZED" }, { status: 404 }); }; return { fetcher, calls }; } describe("loadMatrixSsoConfig", () => { - it("is disabled without an appservice token", () => { + it("is disabled without a JWT secret", () => { expect(loadMatrixSsoConfig({ MATRIX_SSO_HOMESERVER_URL: "https://chat.example.org" })).toBeNull(); }); - it("derives server name, Element origin and the safe prefix from the homeserver URL", () => { + it("derives server name, Element origin, audience and the safe prefix from the homeserver URL", () => { const loaded = config(); expect(loaded.serverName).toBe("chat.example.org"); expect(loaded.elementUrl.origin).toBe("https://chat.example.org"); - expect(loaded.asToken).toBe("test-as-token"); + expect(loaded.audience).toBe("chat.example.org"); + expect(loaded.issuer).toBe("tmbh-hub"); expect(loaded.localpartPrefix).toBe("hub_"); }); it("falls back to the provisioner homeserver URL", () => { - const loaded = config({ MATRIX_SSO_AS_TOKEN: "s", ARCHIVE_PROVISIONER_HOMESERVER_URL: "https://hs.example.org" }); + const loaded = config({ MATRIX_SSO_JWT_SECRET: "s", ARCHIVE_PROVISIONER_HOMESERVER_URL: "https://hs.example.org" }); expect(loaded.homeserverUrl.origin).toBe("https://hs.example.org"); }); @@ -73,7 +77,7 @@ describe("loadMatrixSsoConfig", () => { }); it("rejects malformed values", () => { - expect(() => loadMatrixSsoConfig({ MATRIX_SSO_AS_TOKEN: "s" })).toThrow(MatrixSsoConfigError); + expect(() => loadMatrixSsoConfig({ MATRIX_SSO_JWT_SECRET: "s" })).toThrow(MatrixSsoConfigError); expect(() => loadMatrixSsoConfig({ ...BASE_ENV, MATRIX_SSO_HOMESERVER_URL: "ftp://x" })).toThrow(MatrixSsoConfigError); expect(() => loadMatrixSsoConfig({ ...BASE_ENV, MATRIX_SSO_LOCALPART_PREFIX: "Hub:" })).toThrow(MatrixSsoConfigError); }); @@ -91,8 +95,27 @@ describe("matrixUserIdFor", () => { }); }); +describe("signSsoJwt", () => { + it("produces an HS256 token tuwunel's [global.jwt] validator accepts", () => { + const loaded = config(); + const token = signSsoJwt(loaded, "hub_ada", 1_000_000); + const [header, payload, signature] = token.split("."); + expect(decodePart(header!)).toEqual({ alg: "HS256", typ: "JWT" }); + expect(decodePart(payload!)).toEqual({ + sub: "hub_ada", + iss: "tmbh-hub", + aud: "chat.example.org", + iat: 1_000_000, + nbf: 1_000_000 - 5, + exp: 1_000_000 + SSO_JWT_TTL_SECONDS, + }); + const expected = createHmac("sha256", "test-shared-secret").update(`${header}.${payload}`).digest("base64url"); + expect(signature).toBe(expected); + }); +}); + describe("MatrixSsoService.mintFor", () => { - it("registers the namespace user, then mints a login token via AS impersonation", async () => { + it("JWT login → get_token with JWT UIA → logout, returning the login token", async () => { const { fetcher, calls } = fakeHomeserver(); const session = await new MatrixSsoService(fetcher).mintFor(config(), "hub_ada"); @@ -104,32 +127,28 @@ describe("MatrixSsoService.mintFor", () => { expiresInMs: 120000, }); expect(calls.map((call) => new URL(call.url).pathname)).toEqual([ - "/_matrix/client/v3/register", + "/_matrix/client/v3/login", "/_matrix/client/v1/login/get_token", + "/_matrix/client/v3/logout", ]); - const [register, getToken] = calls; - expect(register!.authorization).toBe("Bearer test-as-token"); - expect(register!.body).toEqual({ type: "m.login.application_service", username: "hub_ada", inhibit_login: true }); - expect(getToken!.authorization).toBe("Bearer test-as-token"); - expect(new URL(getToken!.url).searchParams.get("user_id")).toBe("@hub_ada:chat.example.org"); + const [login, getToken, logout] = calls; + expect(login!.authorization).toBeNull(); + expect(login!.body.type).toBe("org.matrix.login.jwt"); + expect(login!.body.device_id).toBe("HUB_SSO_BROKER"); + expect(getToken!.authorization).toBe("Bearer broker-access"); + expect(getToken!.body.auth).toEqual({ type: "org.matrix.login.jwt", token: login!.body.token }); + expect(logout!.authorization).toBe("Bearer broker-access"); }); - it("treats an already-provisioned user as success", async () => { - const { fetcher, calls } = fakeHomeserver({ registerStatus: 400 }); - const session = await new MatrixSsoService(fetcher).mintFor(config(), "hub_ada"); - expect(session.loginToken).toBe("single-use-login-token"); - expect(calls).toHaveLength(2); - }); - - it("maps a refused get_token to 503 with the homeserver errcode, never the token", async () => { + it("maps a refused get_token to 503, still logs the broker out, and never echoes the JWT", async () => { const { fetcher, calls } = fakeHomeserver({ getTokenStatus: 403 }); const error = await new MatrixSsoService(fetcher).mintFor(config(), "hub_ada").catch((err: unknown) => err); expect(error).toBeInstanceOf(ServiceUnavailableException); const message = error instanceof Error ? error.message : ""; expect(message).toContain("M_FORBIDDEN"); - expect(message).not.toContain("test-as-token"); - expect(calls).toHaveLength(2); + expect(message).not.toContain(String(calls[0]!.body.token)); + expect(new URL(calls.at(-1)!.url).pathname).toBe("/_matrix/client/v3/logout"); }); it("maps an unreachable homeserver to 503", async () => {