revert: matrix-sso to the JWT broker flow; tuwunel migrated to [global] profile

tuwunel's login/get_token demands device-bound UIA completed with
org.matrix.login.jwt — the AS-impersonation shortcut cannot mint login
tokens. The [global] profile migration activates [global.jwt] (register_user
+ JWT UIA), so the original flow works: JWT login (auto-registers
@hub_<user>) -> get_token with JWT UIA -> logout broker device.
This commit is contained in:
glass beadian 2026-10-13 16:13:51 +00:00
parent af93176bbe
commit 11f75e945e
3 changed files with 145 additions and 96 deletions

View file

@ -1,29 +1,30 @@
import { createHmac } from "node:crypto";
/** /**
* Hub → Matrix single sign-on configuration. * Hub → Matrix single sign-on configuration.
* *
* The Hub never holds a Matrix password for its users. A dedicated Matrix * The Hub never holds a Matrix password for its users. tuwunel (1.9.4) accepts
* appservice ("appservice.hub" in tuwunel.toml) owns the hub_* user namespace * `org.matrix.login.jwt` logins signed with a shared HMAC secret
* exclusively: with its as_token the Hub registers `@hub_<username>` on first * (`[global.jwt]` in tuwunel.toml) and, with `register_user = true`, creates
* use and mints short-lived m.login.tokens for Element via * the account on first login. The Hub signs a 60-second JWT for the CURRENT
* POST /_matrix/client/v1/login/get_token (application-service impersonation * session's user, trades it for a broker access token, asks
* via the ?user_id= query parameter). The login token is what Element * POST /_matrix/client/v1/login/get_token for a short-lived m.login.token
* consumes; the as_token never leaves the API process. * (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 * ENV CONTRACT (read per call, like ocisConfigFromEnv — tests reconfigure
* without a rebuild; nothing is read at import time): * without a rebuild; nothing is read at import time):
* *
* MATRIX_SSO_AS_TOKEN REQUIRED to enable. The appservice.hub * MATRIX_SSO_JWT_SECRET REQUIRED to enable. Same value as
* as_token from tuwunel.toml. Scoped by the * `[global.jwt] key` in tuwunel.toml (format HMAC).
* 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_HOMESERVER_URL client-server base, default * MATRIX_SSO_HOMESERVER_URL client-server base, default
* ARCHIVE_PROVISIONER_HOMESERVER_URL. * ARCHIVE_PROVISIONER_HOMESERVER_URL.
* MATRIX_SSO_SERVER_NAME Matrix server_name for MXIDs, default the * MATRIX_SSO_SERVER_NAME Matrix server_name for MXIDs, default the
* homeserver URL's hostname. * homeserver URL's hostname.
* MATRIX_SSO_ELEMENT_URL Element Web origin, default the homeserver * MATRIX_SSO_ELEMENT_URL Element Web origin, default the homeserver
* URL's origin (chat.occupymedia.cc serves both). * 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 * MATRIX_SSO_LOCALPART_PREFIX prefix for Hub-derived localparts, default
* "hub_". The literal "none" disables it — only * "hub_". The literal "none" disables it — only
* do that when every pre-existing Matrix * do that when every pre-existing Matrix
@ -35,13 +36,16 @@ export interface MatrixSsoConfig {
homeserverUrl: URL; homeserverUrl: URL;
serverName: string; serverName: string;
elementUrl: URL; elementUrl: URL;
asToken: string; jwtSecret: string;
issuer: string;
audience: string;
localpartPrefix: string; localpartPrefix: string;
} }
export class MatrixSsoConfigError extends Error {} export class MatrixSsoConfigError extends Error {}
const DEFAULT_PREFIX = "hub_"; const DEFAULT_PREFIX = "hub_";
const DEFAULT_ISSUER = "tmbh-hub";
function httpUrl(name: string, raw: string): URL { function httpUrl(name: string, raw: string): URL {
let url: URL; let url: URL;
@ -60,8 +64,8 @@ function trimmed(env: Readonly<Record<string, string | undefined>>, name: string
/** null = SSO not configured (routes answer 503); throws on malformed config. */ /** null = SSO not configured (routes answer 503); throws on malformed config. */
export function loadMatrixSsoConfig(env: Readonly<Record<string, string | undefined>> = process.env): MatrixSsoConfig | null { export function loadMatrixSsoConfig(env: Readonly<Record<string, string | undefined>> = process.env): MatrixSsoConfig | null {
const asToken = trimmed(env, "MATRIX_SSO_AS_TOKEN"); const jwtSecret = trimmed(env, "MATRIX_SSO_JWT_SECRET");
if (asToken === "") return null; if (jwtSecret === "") return null;
const rawHomeserver = trimmed(env, "MATRIX_SSO_HOMESERVER_URL") || trimmed(env, "ARCHIVE_PROVISIONER_HOMESERVER_URL"); const rawHomeserver = trimmed(env, "MATRIX_SSO_HOMESERVER_URL") || trimmed(env, "ARCHIVE_PROVISIONER_HOMESERVER_URL");
if (rawHomeserver === "") { if (rawHomeserver === "") {
throw new MatrixSsoConfigError("MATRIX_SSO_HOMESERVER_URL (or ARCHIVE_PROVISIONER_HOMESERVER_URL) is required"); throw new MatrixSsoConfigError("MATRIX_SSO_HOMESERVER_URL (or ARCHIVE_PROVISIONER_HOMESERVER_URL) is required");
@ -79,7 +83,9 @@ export function loadMatrixSsoConfig(env: Readonly<Record<string, string | undefi
homeserverUrl, homeserverUrl,
serverName, serverName,
elementUrl, elementUrl,
asToken, jwtSecret,
issuer: trimmed(env, "MATRIX_SSO_JWT_ISSUER") || DEFAULT_ISSUER,
audience: trimmed(env, "MATRIX_SSO_JWT_AUDIENCE") || serverName,
localpartPrefix, localpartPrefix,
}; };
} }
@ -97,3 +103,26 @@ export function matrixUserIdFor(config: MatrixSsoConfig, username: string): { lo
if (username === "" || !LOCALPART.test(localpart) || localpart !== `${config.localpartPrefix}${username}`) return null; if (username === "" || !LOCALPART.test(localpart) || localpart !== `${config.localpartPrefix}${username}`) return null;
return { localpart, userId: `@${localpart}:${config.serverName}` }; return { localpart, userId: `@${localpart}:${config.serverName}` };
} }
/** Lifetime of the JWT the Hub hands tuwunel; it is spent within one request. */
export const SSO_JWT_TTL_SECONDS = 60;
/**
* HS256 JWT for tuwunel's `[global.jwt]` validator: `sub` is the localpart
* (tuwunel lowercases and qualifies it with its own server_name), and
* iss/aud/exp/nbf are always present so the homeserver can require them.
*/
export function signSsoJwt(config: MatrixSsoConfig, localpart: string, nowSeconds = Math.floor(Date.now() / 1000)): string {
const encode = (value: Record<string, unknown>): 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}`;
}

View file

@ -1,6 +1,6 @@
import { ConflictException, Inject, Injectable, Logger, Optional, ServiceUnavailableException } from "@nestjs/common"; import { ConflictException, Inject, Injectable, Logger, Optional, ServiceUnavailableException } from "@nestjs/common";
import { withTenant } from "../db/client.js"; 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). */ /** What the Hub tells the web client about the caller's chat identity (no secrets). */
export interface MatrixIdentity { export interface MatrixIdentity {
@ -25,6 +25,9 @@ export type MatrixFetch = (url: string, init: RequestInit) => Promise<Response>;
export const MATRIX_SSO_FETCH: unique symbol = Symbol("MATRIX_SSO_FETCH"); 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<string, unknown> { function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}; return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
} }
@ -43,9 +46,8 @@ async function describe(res: Response): Promise<string> {
/** /**
* Hub → Matrix SSO. Every mint is for the session's own user: the user id is * 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. * derived server-side from `users.username`, never taken from the request.
* Nothing minted here is logged or persisted; the credentials the Hub touches * Nothing minted here is logged or persisted; the broker access token lives
* are its own appservice token (homeserver-scoped to the hub_* namespace) and * for the duration of one request and is logged out before returning.
* the single-use login token handed to the caller.
*/ */
@Injectable() @Injectable()
export class MatrixSsoService { export class MatrixSsoService {
@ -71,60 +73,45 @@ export class MatrixSsoService {
async mintSession(workspaceId: string, userId: string): Promise<MatrixSession> { async mintSession(workspaceId: string, userId: string): Promise<MatrixSession> {
const config = loadMatrixSsoConfig(); const config = loadMatrixSsoConfig();
if (config === null) { 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)); 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"); if (mapped === null) throw new ConflictException("this username cannot be used as a Matrix user id");
return this.mintFor(config, mapped.localpart); 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<MatrixSession> { async mintFor(config: MatrixSsoConfig, localpart: string): Promise<MatrixSession> {
const userId = `@${localpart}:${config.serverName}`; const jwt = signSsoJwt(config, localpart);
await this.ensureUser(config, localpart); const login = await this.call(config, "/_matrix/client/v3/login", null, {
const minted = await this.call( type: "org.matrix.login.jwt",
config, token: jwt,
`/_matrix/client/v1/login/get_token?user_id=${encodeURIComponent(userId)}`, device_id: BROKER_DEVICE_ID,
config.asToken, initial_device_display_name: "Hub sign-in broker",
{}, }, "JWT login");
"get_token", const accessToken = login.access_token;
); const matrixUserId = login.user_id;
const loginToken = minted.login_token; if (typeof accessToken !== "string" || typeof matrixUserId !== "string") {
if (typeof loginToken !== "string" || loginToken === "") { throw new ServiceUnavailableException("homeserver login response missing access_token/user_id");
throw new ServiceUnavailableException("homeserver get_token response missing login_token");
} }
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_<localpart> on first use; an existing account is fine. */
private async ensureUser(config: MatrixSsoConfig, localpart: string): Promise<void> {
let res: Response;
try { try {
res = await this.fetcher(new URL("/_matrix/client/v3/register", config.homeserverUrl).toString(), { const minted = await this.call(config, "/_matrix/client/v1/login/get_token", accessToken, {
method: "POST", auth: { type: "org.matrix.login.jwt", token: jwt },
headers: { }, "get_token");
accept: "application/json", const loginToken = minted.login_token;
"content-type": "application/json", if (typeof loginToken !== "string" || loginToken === "") {
authorization: `Bearer ${config.asToken}`, throw new ServiceUnavailableException("homeserver get_token response missing login_token");
}, }
body: JSON.stringify({ type: "m.login.application_service", username: localpart, inhibit_login: true }), return {
redirect: "error", homeserverUrl: config.homeserverUrl.origin,
}); elementUrl: config.elementUrl.origin,
} catch (err) { userId: matrixUserId,
this.logger.warn(`register unreachable: ${err instanceof Error ? err.name : "error"}`); loginToken,
throw new ServiceUnavailableException("chat homeserver unreachable (register)"); 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( private async call(
@ -156,6 +143,20 @@ export class MatrixSsoService {
return record(await res.json().catch(() => null)); 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<void> {
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<string> { private async usernameFor(workspaceId: string, userId: string): Promise<string> {
const row = await withTenant(workspaceId, (client) => const row = await withTenant(workspaceId, (client) =>
client.query<{ username: string }>("SELECT username FROM users WHERE id = $1", [userId]), client.query<{ username: string }>("SELECT username FROM users WHERE id = $1", [userId]),

View file

@ -1,11 +1,12 @@
import { createHmac } from "node:crypto";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ServiceUnavailableException } from "@nestjs/common"; 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 { MatrixSsoService, type MatrixFetch } from "./matrix-sso.service.js";
import { driveSessionFromEnv } from "../group-files/drive.controller.js"; import { driveSessionFromEnv } from "../group-files/drive.controller.js";
const BASE_ENV = { 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", MATRIX_SSO_HOMESERVER_URL: "https://chat.example.org",
}; };
@ -15,14 +16,18 @@ function config(env: Record<string, string> = BASE_ENV): MatrixSsoConfig {
return loaded; return loaded;
} }
function decodePart(part: string): Record<string, unknown> {
return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as Record<string, unknown>;
}
interface Recorded { interface Recorded {
url: string; url: string;
authorization: string | null; authorization: string | null;
body: Record<string, unknown>; body: Record<string, unknown>;
} }
/** Fake tuwunel: answers register / get_token, records every call. */ /** Fake tuwunel: answers login / get_token / logout, records every call. */
function fakeHomeserver(overrides: { registerStatus?: number; getTokenStatus?: number } = {}): { fetcher: MatrixFetch; calls: Recorded[] } { function fakeHomeserver(overrides: { getTokenStatus?: number } = {}): { fetcher: MatrixFetch; calls: Recorded[] } {
const calls: Recorded[] = []; const calls: Recorded[] = [];
const fetcher: MatrixFetch = async (url, init) => { const fetcher: MatrixFetch = async (url, init) => {
const headers = new Headers(init.headers); 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<string, unknown>) : {}, body: typeof init.body === "string" ? (JSON.parse(init.body) as Record<string, unknown>) : {},
}); });
const path = new URL(url).pathname; const path = new URL(url).pathname;
if (path === "/_matrix/client/v3/register") { if (path === "/_matrix/client/v3/login") {
if (overrides.registerStatus !== undefined) { return Response.json({ access_token: "broker-access", user_id: "@hub_ada:chat.example.org", device_id: "HUB_SSO_BROKER" });
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/v1/login/get_token") { if (path === "/_matrix/client/v1/login/get_token") {
if (overrides.getTokenStatus !== undefined) { 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 }); 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 Response.json({ errcode: "M_UNRECOGNIZED" }, { status: 404 });
}; };
return { fetcher, calls }; return { fetcher, calls };
} }
describe("loadMatrixSsoConfig", () => { 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(); 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(); const loaded = config();
expect(loaded.serverName).toBe("chat.example.org"); expect(loaded.serverName).toBe("chat.example.org");
expect(loaded.elementUrl.origin).toBe("https://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_"); expect(loaded.localpartPrefix).toBe("hub_");
}); });
it("falls back to the provisioner homeserver URL", () => { 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"); expect(loaded.homeserverUrl.origin).toBe("https://hs.example.org");
}); });
@ -73,7 +77,7 @@ describe("loadMatrixSsoConfig", () => {
}); });
it("rejects malformed values", () => { 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_HOMESERVER_URL: "ftp://x" })).toThrow(MatrixSsoConfigError);
expect(() => loadMatrixSsoConfig({ ...BASE_ENV, MATRIX_SSO_LOCALPART_PREFIX: "Hub:" })).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", () => { 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 { fetcher, calls } = fakeHomeserver();
const session = await new MatrixSsoService(fetcher).mintFor(config(), "hub_ada"); const session = await new MatrixSsoService(fetcher).mintFor(config(), "hub_ada");
@ -104,32 +127,28 @@ describe("MatrixSsoService.mintFor", () => {
expiresInMs: 120000, expiresInMs: 120000,
}); });
expect(calls.map((call) => new URL(call.url).pathname)).toEqual([ 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/v1/login/get_token",
"/_matrix/client/v3/logout",
]); ]);
const [register, getToken] = calls; const [login, getToken, logout] = calls;
expect(register!.authorization).toBe("Bearer test-as-token"); expect(login!.authorization).toBeNull();
expect(register!.body).toEqual({ type: "m.login.application_service", username: "hub_ada", inhibit_login: true }); expect(login!.body.type).toBe("org.matrix.login.jwt");
expect(getToken!.authorization).toBe("Bearer test-as-token"); expect(login!.body.device_id).toBe("HUB_SSO_BROKER");
expect(new URL(getToken!.url).searchParams.get("user_id")).toBe("@hub_ada:chat.example.org"); 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 () => { it("maps a refused get_token to 503, still logs the broker out, and never echoes the JWT", 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 () => {
const { fetcher, calls } = fakeHomeserver({ getTokenStatus: 403 }); const { fetcher, calls } = fakeHomeserver({ getTokenStatus: 403 });
const error = await new MatrixSsoService(fetcher).mintFor(config(), "hub_ada").catch((err: unknown) => err); const error = await new MatrixSsoService(fetcher).mintFor(config(), "hub_ada").catch((err: unknown) => err);
expect(error).toBeInstanceOf(ServiceUnavailableException); expect(error).toBeInstanceOf(ServiceUnavailableException);
const message = error instanceof Error ? error.message : ""; const message = error instanceof Error ? error.message : "";
expect(message).toContain("M_FORBIDDEN"); expect(message).toContain("M_FORBIDDEN");
expect(message).not.toContain("test-as-token"); expect(message).not.toContain(String(calls[0]!.body.token));
expect(calls).toHaveLength(2); expect(new URL(calls.at(-1)!.url).pathname).toBe("/_matrix/client/v3/logout");
}); });
it("maps an unreachable homeserver to 503", async () => { it("maps an unreachable homeserver to 503", async () => {