feat: in-app Chat (Matrix JWT SSO) + Drive (embedded oCIS)

- api: matrix-sso module (tuwunel jwt -> login/get_token), POST /matrix/session, GET /matrix/identity, drive controller
- web: /chat full-height Element view, /files Library+Drive tabs, embedded-app frame component
- edge: /hub-sso.html handshake route (origin-pinned CSP), X-Frame-Options strip on files
- oCIS: csp.yaml frame-ancestors app.occupymedia.cc
- server config still required: tuwunel [global.jwt], MATRIX_SSO_JWT_SECRET, oCIS restart
This commit is contained in:
glass beadian 2026-10-13 14:57:07 +00:00
parent a2aa70c961
commit fe5f2403d6
22 changed files with 1057 additions and 8 deletions

View file

@ -37,9 +37,10 @@ import { DirectorModule } from "./director/director.module.js";
import { AnalyticsController } from "./analytics/analytics.controller";
import { SnapshotService } from "./analytics/snapshot.service";
import { CollectivesModule } from "./collectives/collectives.module.js";
import { MatrixSsoModule } from "./matrix-sso/matrix-sso.module.js";
@Module({
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule],
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule, MatrixSsoModule],
controllers: [
DemoController,
PublishingController,

View file

@ -0,0 +1,55 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import { RbacGuard, RequirePermission } from "../rbac/rbac.guard.js";
/**
* How the in-app Drive view reaches the oCIS web UI.
*
* interactive oCIS web uses its own identity provider. The user signs in
* ONCE inside the embedded frame; oCIS web keeps its OIDC
* tokens in the files origin's storage, which the Hub frame
* shares (app.* and files.* are the same site), so later
* visits open signed in.
* oidc oCIS and the Hub trust the same external OIDC issuer; the
* embedded oCIS web completes its authorization-code flow
* silently against the issuer's existing session.
*/
export type DriveAuthMode = "interactive" | "oidc";
export interface DriveSession {
/** Origin of the oCIS web UI, or null when the drive is not deployed. */
driveUrl: string | null;
authMode: DriveAuthMode;
}
/**
* ENV CONTRACT (read per call):
*
* FILES_DRIVE_URL oCIS web origin; default FILES_PUBLIC_BASE (the same
* host the library links already point at).
* FILES_DRIVE_AUTH "oidc" once oCIS and the Hub share an issuer; anything
* else (or unset) is "interactive".
*/
export function driveSessionFromEnv(env: Readonly<Record<string, string | undefined>> = process.env): DriveSession {
const raw = env.FILES_DRIVE_URL?.trim() || env.FILES_PUBLIC_BASE?.trim() || "";
let driveUrl: string | null = null;
if (raw !== "") {
try {
const url = new URL(raw);
if (url.protocol === "https:" || url.protocol === "http:") driveUrl = url.origin;
} catch {
driveUrl = null;
}
}
return { driveUrl, authMode: env.FILES_DRIVE_AUTH?.trim() === "oidc" ? "oidc" : "interactive" };
}
/** GET /drive/session — runtime drive location for the embedded Drive view. */
@Controller("drive")
@UseGuards(RbacGuard)
export class DriveController {
@Get("session")
@RequirePermission("media.view")
session(): DriveSession {
return driveSessionFromEnv();
}
}

View file

@ -1,6 +1,7 @@
import { Module } from "@nestjs/common";
import { GroupFilesController, PublicFileShareController } from "./group-files.controller.js";
import { GroupFilesService } from "./group-files.service.js";
import { DriveController } from "./drive.controller.js";
/**
* Group files module — the metadata index + share/link lifecycle over
@ -20,7 +21,7 @@ import { GroupFilesService } from "./group-files.service.js";
* deployment is absent instead of breaking startup.
*/
@Module({
controllers: [GroupFilesController, PublicFileShareController],
controllers: [GroupFilesController, PublicFileShareController, DriveController],
providers: [GroupFilesService],
exports: [GroupFilesService],
})

View file

@ -0,0 +1,128 @@
import { createHmac } from "node:crypto";
/**
* Hub → Matrix single sign-on configuration.
*
* 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_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
* account belongs to the Hub user of the same
* name (otherwise a Hub sign-up named after an
* existing chat user would sign in AS them).
*/
export interface MatrixSsoConfig {
homeserverUrl: URL;
serverName: string;
elementUrl: URL;
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;
try {
url = new URL(raw);
} catch {
throw new MatrixSsoConfigError(`${name} must be an absolute URL`);
}
if (url.protocol !== "https:" && url.protocol !== "http:") throw new MatrixSsoConfigError(`${name} must be http(s)`);
return url;
}
function trimmed(env: Readonly<Record<string, string | undefined>>, name: string): string {
return env[name]?.trim() ?? "";
}
/** null = SSO not configured (routes answer 503); throws on malformed config. */
export function loadMatrixSsoConfig(env: Readonly<Record<string, string | undefined>> = process.env): MatrixSsoConfig | 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");
}
const homeserverUrl = httpUrl("MATRIX_SSO_HOMESERVER_URL", rawHomeserver);
const serverName = trimmed(env, "MATRIX_SSO_SERVER_NAME") || homeserverUrl.hostname;
const rawElement = trimmed(env, "MATRIX_SSO_ELEMENT_URL");
const elementUrl = rawElement === "" ? new URL(homeserverUrl.origin) : httpUrl("MATRIX_SSO_ELEMENT_URL", rawElement);
const rawPrefix = trimmed(env, "MATRIX_SSO_LOCALPART_PREFIX");
const localpartPrefix = rawPrefix === "" ? DEFAULT_PREFIX : rawPrefix === "none" ? "" : rawPrefix;
if (!LOCALPART.test(`${localpartPrefix}x`)) {
throw new MatrixSsoConfigError("MATRIX_SSO_LOCALPART_PREFIX may only contain a-z 0-9 . _ = - /");
}
return {
homeserverUrl,
serverName,
elementUrl,
jwtSecret,
issuer: trimmed(env, "MATRIX_SSO_JWT_ISSUER") || DEFAULT_ISSUER,
audience: trimmed(env, "MATRIX_SSO_JWT_AUDIENCE") || serverName,
localpartPrefix,
};
}
/** Matrix user-ID grammar for localparts (spec "User Identifiers"). */
const LOCALPART = /^[a-z0-9._=\-/]+$/;
/**
* Hub username → Matrix user ID. Hub usernames are already [a-z0-9_.-]
* (016_username_auth.sql), a subset of the localpart grammar; anything else is
* refused rather than rewritten so two Hub users can never map to one MXID.
*/
export function matrixUserIdFor(config: MatrixSsoConfig, username: string): { localpart: string; userId: string } | null {
const localpart = `${config.localpartPrefix}${username.toLowerCase()}`;
if (username === "" || !LOCALPART.test(localpart) || localpart !== `${config.localpartPrefix}${username}`) return null;
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

@ -0,0 +1,35 @@
import { Controller, Get, HttpCode, Inject, Post, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { RbacGuard, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { requireSession } from "../http/validation.js";
import { MatrixSsoService, type MatrixIdentity, type MatrixSession } from "./matrix-sso.service.js";
/**
* Hub → Matrix single sign-on for the in-app Chat view.
*
* GET /matrix/identity the caller's MXID + Element/homeserver origins (no secrets)
* POST /matrix/session mint a single-use m.login.token for the caller
*
* Any authenticated member may use both: chat is not a privileged surface, and
* the token is only ever minted for the session's OWN user. POST because each
* call creates a homeserver-side credential; responses are never cached.
*/
@Controller("matrix")
@UseGuards(RbacGuard)
export class MatrixSsoController {
constructor(@Inject(MatrixSsoService) private readonly sso: MatrixSsoService) {}
@Get("identity")
async identity(@Req() request: Request & AuthenticatedRequest): Promise<MatrixIdentity> {
const session = requireSession(request);
return this.sso.identity(session.workspaceId, session.userId);
}
@Post("session")
@HttpCode(200)
async session(@Req() request: Request & AuthenticatedRequest): Promise<MatrixSession> {
const session = requireSession(request);
request.res?.setHeader("Cache-Control", "no-store");
return this.sso.mintSession(session.workspaceId, session.userId);
}
}

View file

@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { MatrixSsoController } from "./matrix-sso.controller.js";
import { MatrixSsoService } from "./matrix-sso.service.js";
/**
* Matrix SSO module — signs Hub users into Element without a Matrix password.
* Env is read per request (see matrix-sso.config.ts), so a deployment without
* MATRIX_SSO_JWT_SECRET boots fine and the mint route answers 503.
*/
@Module({
controllers: [MatrixSsoController],
providers: [MatrixSsoService],
})
export class MatrixSsoModule {}

View file

@ -0,0 +1,168 @@
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<Response>;
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> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
/** Homeserver error text WITHOUT echoing request bodies (they carry the JWT). */
async function describe(res: Response): Promise<string> {
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<MatrixIdentity> {
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<MatrixSession> {
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<MatrixSession> {
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<string, unknown>,
step: string,
): Promise<Record<string, unknown>> {
const headers: Record<string, string> = { 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<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> {
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;
}
}

View file

@ -0,0 +1,181 @@
import { createHmac } from "node:crypto";
import { describe, expect, it } from "vitest";
import { ServiceUnavailableException } from "@nestjs/common";
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_JWT_SECRET: "test-shared-secret",
MATRIX_SSO_HOMESERVER_URL: "https://chat.example.org",
};
function config(env: Record<string, string> = BASE_ENV): MatrixSsoConfig {
const loaded = loadMatrixSsoConfig(env);
if (loaded === null) throw new Error("expected config");
return loaded;
}
function decodePart(part: string): Record<string, unknown> {
return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as Record<string, unknown>;
}
interface Recorded {
url: string;
authorization: string | null;
body: Record<string, unknown>;
}
/** 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);
calls.push({
url,
authorization: headers.get("authorization"),
body: typeof init.body === "string" ? (JSON.parse(init.body) as Record<string, unknown>) : {},
});
const path = new URL(url).pathname;
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) {
return Response.json({ errcode: "M_FORBIDDEN", error: "Login via an existing session is not enabled" }, { status: overrides.getTokenStatus });
}
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 a JWT secret", () => {
expect(loadMatrixSsoConfig({ MATRIX_SSO_HOMESERVER_URL: "https://chat.example.org" })).toBeNull();
});
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.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_JWT_SECRET: "s", ARCHIVE_PROVISIONER_HOMESERVER_URL: "https://hs.example.org" });
expect(loaded.homeserverUrl.origin).toBe("https://hs.example.org");
});
it("only drops the prefix on the explicit 'none' sentinel (an empty compose passthrough keeps it)", () => {
expect(config({ ...BASE_ENV, MATRIX_SSO_LOCALPART_PREFIX: "" }).localpartPrefix).toBe("hub_");
expect(config({ ...BASE_ENV, MATRIX_SSO_LOCALPART_PREFIX: "none" }).localpartPrefix).toBe("");
});
it("rejects malformed values", () => {
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);
});
});
describe("matrixUserIdFor", () => {
it("prefixes and qualifies Hub usernames", () => {
expect(matrixUserIdFor(config(), "ada.l")).toEqual({ localpart: "hub_ada.l", userId: "@hub_ada.l:chat.example.org" });
});
it("refuses usernames tuwunel would rewrite (case folding would merge accounts)", () => {
expect(matrixUserIdFor(config(), "Ada")).toBeNull();
expect(matrixUserIdFor(config(), "ada lovelace")).toBeNull();
expect(matrixUserIdFor(config(), "")).toBeNull();
});
});
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("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");
expect(session).toEqual({
homeserverUrl: "https://chat.example.org",
elementUrl: "https://chat.example.org",
userId: "@hub_ada:chat.example.org",
loginToken: "single-use-login-token",
expiresInMs: 120000,
});
expect(calls.map((call) => new URL(call.url).pathname)).toEqual([
"/_matrix/client/v3/login",
"/_matrix/client/v1/login/get_token",
"/_matrix/client/v3/logout",
]);
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("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(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 () => {
const fetcher: MatrixFetch = async () => {
throw new TypeError("fetch failed");
};
await expect(new MatrixSsoService(fetcher).mintFor(config(), "hub_ada")).rejects.toBeInstanceOf(ServiceUnavailableException);
});
});
describe("driveSessionFromEnv", () => {
it("defaults to the library's public base and interactive sign-in", () => {
expect(driveSessionFromEnv({ FILES_PUBLIC_BASE: "https://files.example.org/" })).toEqual({
driveUrl: "https://files.example.org",
authMode: "interactive",
});
});
it("prefers FILES_DRIVE_URL and honours FILES_DRIVE_AUTH=oidc", () => {
expect(driveSessionFromEnv({ FILES_DRIVE_URL: "https://drive.example.org", FILES_PUBLIC_BASE: "https://x", FILES_DRIVE_AUTH: "oidc" })).toEqual({
driveUrl: "https://drive.example.org",
authMode: "oidc",
});
});
it("reports no drive when nothing usable is configured", () => {
expect(driveSessionFromEnv({}).driveUrl).toBeNull();
expect(driveSessionFromEnv({ FILES_DRIVE_URL: "not a url" }).driveUrl).toBeNull();
});
});