From fe5f2403d64bd4597df3034821c2fa878e522e34 Mon Sep 17 00:00:00 2001 From: glass beadian Date: Tue, 13 Oct 2026 14:57:07 +0000 Subject: [PATCH] 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 --- apps/api/src/app.module.ts | 3 +- apps/api/src/group-files/drive.controller.ts | 55 ++++++ .../api/src/group-files/group-files.module.ts | 3 +- apps/api/src/matrix-sso/matrix-sso.config.ts | 128 +++++++++++++ .../src/matrix-sso/matrix-sso.controller.ts | 35 ++++ apps/api/src/matrix-sso/matrix-sso.module.ts | 14 ++ apps/api/src/matrix-sso/matrix-sso.service.ts | 168 ++++++++++++++++ apps/api/src/matrix-sso/matrix-sso.test.ts | 181 ++++++++++++++++++ apps/web/src/app/(main)/chat/page.tsx | 144 ++++++++++++++ apps/web/src/app/(main)/files/drive/page.tsx | 57 ++++++ apps/web/src/app/(main)/files/layout.tsx | 13 +- apps/web/src/app/(main)/layout.tsx | 2 +- apps/web/src/app/(main)/more/page.tsx | 2 +- .../web/src/components/embed/embedded-app.tsx | 121 ++++++++++++ apps/web/src/components/files/files-tabs.tsx | 38 ++++ .../src/components/shell/command-palette.tsx | 2 + apps/web/src/components/shell/sidebar.tsx | 2 +- infra/occupymedia/docker-compose.app.yml | 8 + .../occupymedia/edge/docker-compose.edge.yml | 1 + infra/occupymedia/edge/hub-sso/chat-sso.html | 63 ++++++ infra/occupymedia/edge/nginx.conf | 22 +++ infra/occupymedia/files/csp.yaml | 3 +- 22 files changed, 1057 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/group-files/drive.controller.ts create mode 100644 apps/api/src/matrix-sso/matrix-sso.config.ts create mode 100644 apps/api/src/matrix-sso/matrix-sso.controller.ts create mode 100644 apps/api/src/matrix-sso/matrix-sso.module.ts create mode 100644 apps/api/src/matrix-sso/matrix-sso.service.ts create mode 100644 apps/api/src/matrix-sso/matrix-sso.test.ts create mode 100644 apps/web/src/app/(main)/chat/page.tsx create mode 100644 apps/web/src/app/(main)/files/drive/page.tsx create mode 100644 apps/web/src/components/embed/embedded-app.tsx create mode 100644 apps/web/src/components/files/files-tabs.tsx create mode 100644 infra/occupymedia/edge/hub-sso/chat-sso.html diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 47f69de..da327b7 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/group-files/drive.controller.ts b/apps/api/src/group-files/drive.controller.ts new file mode 100644 index 0000000..8ef2564 --- /dev/null +++ b/apps/api/src/group-files/drive.controller.ts @@ -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> = 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(); + } +} diff --git a/apps/api/src/group-files/group-files.module.ts b/apps/api/src/group-files/group-files.module.ts index 5f693ae..071b53f 100644 --- a/apps/api/src/group-files/group-files.module.ts +++ b/apps/api/src/group-files/group-files.module.ts @@ -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], }) diff --git a/apps/api/src/matrix-sso/matrix-sso.config.ts b/apps/api/src/matrix-sso/matrix-sso.config.ts new file mode 100644 index 0000000..14ab8d2 --- /dev/null +++ b/apps/api/src/matrix-sso/matrix-sso.config.ts @@ -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>, name: string): string { + return env[name]?.trim() ?? ""; +} + +/** null = SSO not configured (routes answer 503); throws on malformed config. */ +export function loadMatrixSsoConfig(env: Readonly> = 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 => 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.controller.ts b/apps/api/src/matrix-sso/matrix-sso.controller.ts new file mode 100644 index 0000000..52e3445 --- /dev/null +++ b/apps/api/src/matrix-sso/matrix-sso.controller.ts @@ -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 { + const session = requireSession(request); + return this.sso.identity(session.workspaceId, session.userId); + } + + @Post("session") + @HttpCode(200) + async session(@Req() request: Request & AuthenticatedRequest): Promise { + const session = requireSession(request); + request.res?.setHeader("Cache-Control", "no-store"); + return this.sso.mintSession(session.workspaceId, session.userId); + } +} diff --git a/apps/api/src/matrix-sso/matrix-sso.module.ts b/apps/api/src/matrix-sso/matrix-sso.module.ts new file mode 100644 index 0000000..6ee58e5 --- /dev/null +++ b/apps/api/src/matrix-sso/matrix-sso.module.ts @@ -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 {} diff --git a/apps/api/src/matrix-sso/matrix-sso.service.ts b/apps/api/src/matrix-sso/matrix-sso.service.ts new file mode 100644 index 0000000..497b00e --- /dev/null +++ b/apps/api/src/matrix-sso/matrix-sso.service.ts @@ -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; + +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; + } +} diff --git a/apps/api/src/matrix-sso/matrix-sso.test.ts b/apps/api/src/matrix-sso/matrix-sso.test.ts new file mode 100644 index 0000000..53f5c92 --- /dev/null +++ b/apps/api/src/matrix-sso/matrix-sso.test.ts @@ -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 = BASE_ENV): MatrixSsoConfig { + const loaded = loadMatrixSsoConfig(env); + if (loaded === null) throw new Error("expected config"); + 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 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) : {}, + }); + 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(); + }); +}); diff --git a/apps/web/src/app/(main)/chat/page.tsx b/apps/web/src/app/(main)/chat/page.tsx new file mode 100644 index 0000000..8373bbb --- /dev/null +++ b/apps/web/src/app/(main)/chat/page.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { EmbeddedApp } from "../../../components/embed/embedded-app"; +import { api, errorMessage } from "../../../lib/api-client"; +import { useApiResource } from "../../../lib/use-api"; + +/** + * In-app Chat: Element Web, full height inside the Hub shell, signed in as the + * Hub user without a Matrix password. + * + * Handshake (see infra/occupymedia/edge/hub-sso/chat-sso.html): + * 1. GET /matrix/identity → the MXID this Hub account maps to. + * 2. The frame opens Element's origin at /hub-sso.html#expect=. If + * Element already holds a live session for that MXID it goes straight to + * Element — no token is minted, the device (and its E2EE keys) survives. + * 3. Otherwise the page posts `tmbh:chat-sso:need-token`; we POST + * /matrix/session and post the single-use m.login.token back, pinned to + * Element's origin. Element completes `/?loginToken=…` and keeps its own + * session from then on. + * Without SSO configured (or the API offline) the frame shows Element's own + * sign-in screen: sign in once there and it stays signed in. + */ + +/** Same host the nav linked to before Chat moved in-app. */ +const FALLBACK_CHAT_URL = "https://chat.occupymedia.cc"; +/** A frame that keeps asking for tokens is failing token login; stop minting. */ +const MAX_MINTS = 3; +const ELEMENT_ALLOW = "camera; microphone; display-capture; fullscreen; autoplay; clipboard-read; clipboard-write"; + +interface MatrixIdentity { + configured: boolean; + homeserverUrl: string | null; + elementUrl: string | null; + userId: string | null; +} + +interface MatrixSession { + homeserverUrl: string; + elementUrl: string; + userId: string; + loginToken: string; + expiresInMs: number; +} + +const identityView = (identity: MatrixIdentity): MatrixIdentity | null => identity; + +function isNeedToken(data: unknown): boolean { + return typeof data === "object" && data !== null && "type" in data && data.type === "tmbh:chat-sso:need-token"; +} + +function originOf(url: string): string | null { + try { + return new URL(url).origin; + } catch { + return null; + } +} + +export default function ChatPage(): React.ReactElement { + const identity = useApiResource("/matrix/identity", identityView, null); + const frameRef = useRef(null); + const mints = useRef(0); + const tokenSent = useRef(false); + const [busy, setBusy] = useState(null); + const [mintError, setMintError] = useState(null); + + const elementOrigin = originOf(identity.data?.elementUrl ?? FALLBACK_CHAT_URL) ?? FALLBACK_CHAT_URL; + const ssoUser = identity.data?.configured === true ? identity.data.userId : null; + const src = + identity.source === "loading" + ? null + : ssoUser !== null + ? `${elementOrigin}/hub-sso.html#expect=${encodeURIComponent(ssoUser)}` + : `${elementOrigin}/`; + + useEffect(() => { + if (ssoUser === null) return; + function onMessage(event: MessageEvent): void { + const target = frameRef.current?.contentWindow ?? null; + if (target === null || event.source !== target || event.origin !== elementOrigin || !isNeedToken(event.data)) return; + if (mints.current >= MAX_MINTS) { + setBusy(null); + setMintError("Chat sign-in keeps failing. Open chat in a new tab, or retry."); + return; + } + mints.current += 1; + setBusy("Signing in to chat…"); + api + .post("/matrix/session") + .then((session) => { + const current = frameRef.current?.contentWindow ?? null; + if (current === null) return; + tokenSent.current = true; + current.postMessage({ type: "tmbh:chat-sso:token", loginToken: session.loginToken }, elementOrigin); + }) + .catch((err: unknown) => { + setBusy(null); + setMintError(errorMessage(err)); + }); + } + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [ssoUser, elementOrigin]); + + // The load after a token was handed over is Element itself: sign-in is done. + const handleFrameLoad = useCallback(() => { + if (!tokenSent.current) return; + tokenSent.current = false; + setBusy(null); + }, []); + + const handleRetry = useCallback(() => { + mints.current = 0; + tokenSent.current = false; + setBusy(null); + setMintError(null); + if (identity.error !== null) identity.reload(); + }, [identity]); + + const notice = + identity.source === "sample" + ? "Hub offline — sign in to chat directly." + : identity.source === "live" && identity.error === null && ssoUser === null + ? "Automatic sign-in is not set up — sign in once here and chat stays signed in." + : ssoUser !== null + ? ssoUser + : undefined; + + return ( + + ); +} diff --git a/apps/web/src/app/(main)/files/drive/page.tsx b/apps/web/src/app/(main)/files/drive/page.tsx new file mode 100644 index 0000000..aa96efd --- /dev/null +++ b/apps/web/src/app/(main)/files/drive/page.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { EmbeddedApp } from "../../../../components/embed/embedded-app"; +import { useFilesLang } from "../../../../components/files/i18n"; +import { useApiResource } from "../../../../lib/use-api"; + +/** + * Drive — the oCIS web UI (personal space, shares, office documents) embedded + * full height under the Files tabs. The group Library tab keeps the Hub-owned + * features (upload into a collective, share with a collective, retention). + * + * Sign-in follows GET /drive/session's authMode: "interactive" means oCIS's + * own identity provider — the user signs in once inside the frame and oCIS + * web keeps its tokens (tokenStorageLocal) on the files origin, which this + * frame shares because app.* and files.* are the same site. "oidc" means oCIS + * and the Hub share an issuer and the frame signs in silently. + */ + +/** Same host FILES_PUBLIC_BASE defaults to; used only while the API is offline. */ +const FALLBACK_DRIVE_URL = "https://files.occupymedia.cc"; + +interface DriveSession { + driveUrl: string | null; + authMode: "interactive" | "oidc"; +} + +const driveView = (session: DriveSession): DriveSession | null => session; + +export default function DrivePage(): React.ReactElement { + const { t } = useFilesLang(); + const drive = useApiResource("/drive/session", driveView, null); + + const driveUrl = drive.source === "sample" ? FALLBACK_DRIVE_URL : drive.data?.driveUrl ?? null; + const notConfigured = drive.source === "live" && drive.error === null && driveUrl === null; + const error = + drive.error ?? + (notConfigured ? t({ en: "The drive is not configured on this server (FILES_DRIVE_URL).", es: "La unidad no está configurada en este servidor (FILES_DRIVE_URL)." }) : null); + const notice = + drive.data?.authMode === "oidc" + ? t({ en: "Collective files live in the Library tab.", es: "Los archivos de los colectivos están en la pestaña Biblioteca." }) + : t({ + en: "Sign in once inside the drive — it stays signed in. Collective files live in the Library tab.", + es: "Inicia sesión una vez dentro de la unidad y quedará abierta. Los archivos de los colectivos están en la pestaña Biblioteca.", + }); + + return ( + + ); +} diff --git a/apps/web/src/app/(main)/files/layout.tsx b/apps/web/src/app/(main)/files/layout.tsx index 1457feb..9df5711 100644 --- a/apps/web/src/app/(main)/files/layout.tsx +++ b/apps/web/src/app/(main)/files/layout.tsx @@ -2,12 +2,21 @@ import type { ReactNode } from "react"; import { FilesLangProvider } from "../../../components/files/i18n"; +import { FilesTabs } from "../../../components/files/files-tabs"; /** * Files shell. Like the wiki, the language lives in localStorage per browser * (lang-context explains why it is not an account setting) and is provided here - * so every Files screen and sub-component reads it through one context. + * so every Files screen and sub-component reads it through one context. The tab + * strip switches between the group Library and the embedded oCIS Drive. */ export default function FilesLayout({ children }: { children: ReactNode }): React.ReactElement { - return {children}; + return ( + +
+ +
{children}
+
+
+ ); } diff --git a/apps/web/src/app/(main)/layout.tsx b/apps/web/src/app/(main)/layout.tsx index 518bf76..f581d0a 100644 --- a/apps/web/src/app/(main)/layout.tsx +++ b/apps/web/src/app/(main)/layout.tsx @@ -15,7 +15,7 @@ import { useIsMobile } from "../../hooks/use-is-mobile"; const titles: Record = { streams: "Home", search: "Search", inbox: "Inbox", approvals: "Approvals", more: "More", compose: "New post", calendar: "Calendar", video: "Video", analytics: "Analytics", connections: "Connections", team: "Team", - audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files", + audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files", chat: "Chat", }; const rootTabs = new Set(["/streams", "/search", "/inbox", "/more"]); const scrollPositions = new Map(); diff --git a/apps/web/src/app/(main)/more/page.tsx b/apps/web/src/app/(main)/more/page.tsx index 092f6b2..3f40f0e 100644 --- a/apps/web/src/app/(main)/more/page.tsx +++ b/apps/web/src/app/(main)/more/page.tsx @@ -77,7 +77,7 @@ export default function MorePage(): React.ReactElement { - + diff --git a/apps/web/src/components/embed/embedded-app.tsx b/apps/web/src/components/embed/embedded-app.tsx new file mode 100644 index 0000000..ee8a242 --- /dev/null +++ b/apps/web/src/components/embed/embedded-app.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useState, type ReactNode, type Ref } from "react"; +import { Button } from "../ui/primitives"; +import { Loading } from "../brand/loading"; + +/** A frame that has not fired `load` by then is reported as unreachable. */ +const LOAD_TIMEOUT_MS = 30_000; + +/** + * Full-height in-app view of a sibling occupymedia.cc service (Element, oCIS + * web) — the Hub sidebar stays put and only
hosts the frame. + * + * The frame mounts once per `src` and again on every Retry; `busyLabel` keeps the overlay up + * across in-frame navigations the parent knows about (e.g. Element's token + * sign-in redirect). A frame that never loads within LOAD_TIMEOUT_MS shows the + * error panel. A frame refused by the target's CSP / X-Frame-Options still + * fires `load` (with the browser's block page), so "Open in new tab" stays in + * the header permanently rather than only in the error panel. + */ +export function EmbeddedApp({ + title, + src, + externalHref, + busyLabel = null, + error = null, + notice, + frameRef, + onFrameLoad, + onRetry, + allow, +}: { + title: string; + /** null while the parent is still resolving where to point the frame. */ + src: string | null; + externalHref: string; + busyLabel?: string | null; + error?: string | null; + notice?: ReactNode; + frameRef?: Ref; + onFrameLoad?: () => void; + /** Called on Retry, before the frame remounts — clear parent-side errors here. */ + onRetry?: () => void; + allow?: string; +}): React.ReactElement { + const [attempt, setAttempt] = useState(0); + const [loaded, setLoaded] = useState(false); + const [timedOut, setTimedOut] = useState(false); + + useEffect(() => { + setLoaded(false); + setTimedOut(false); + if (src === null) return; + const timer = window.setTimeout(() => setTimedOut(true), LOAD_TIMEOUT_MS); + return () => window.clearTimeout(timer); + }, [src, attempt]); + + function retry(): void { + onRetry?.(); + setAttempt((n) => n + 1); + } + + const failure = error ?? (timedOut && !loaded ? `${title} did not respond. It may be down or blocking embedding.` : null); + const overlay = failure === null && (src === null || !loaded || busyLabel !== null); + + return ( +
+
+

{title}

+ {notice !== undefined &&
{notice}
} + + Open in new tab ↗ + +
+
+ {src !== null && failure === null && ( +