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.
168 lines
6.8 KiB
TypeScript
168 lines
6.8 KiB
TypeScript
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;
|
|
}
|
|
}
|