fix: Matrix SSO via dedicated hub appservice (AS register + get_token impersonation)
The live tuwunel does not offer m.login.jwt (the jwt.* flat config keys were inert), so the JWT broker flow could never work. Pivot: appservice.hub owns the exclusive hub_* namespace on the homeserver; POST /matrix/session now registers @hub_<username> with the as_token and mints a single-use m.login.token via login/get_token (?user_id= impersonation). Optional MATRIX_SSO_HS_TOKEN authenticates inbound appservice transactions, acked by the new MatrixAppserviceController sink. Response shape unchanged.
This commit is contained in:
parent
fe5f2403d6
commit
4a0d398a76
6 changed files with 591 additions and 149 deletions
|
|
@ -1,6 +1,6 @@
|
|||
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";
|
||||
import { loadMatrixSsoConfig, matrixUserIdFor, type MatrixSsoConfig } from "./matrix-sso.config.js";
|
||||
|
||||
/** What the Hub tells the web client about the caller's chat identity (no secrets). */
|
||||
export interface MatrixIdentity {
|
||||
|
|
@ -25,9 +25,6 @@ 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>) : {};
|
||||
}
|
||||
|
|
@ -46,8 +43,9 @@ async function describe(res: Response): Promise<string> {
|
|||
/**
|
||||
* 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.
|
||||
* Nothing minted here is logged or persisted; the credentials the Hub touches
|
||||
* are its own appservice token (homeserver-scoped to the hub_* namespace) and
|
||||
* the single-use login token handed to the caller.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MatrixSsoService {
|
||||
|
|
@ -73,45 +71,60 @@ export class MatrixSsoService {
|
|||
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");
|
||||
throw new ServiceUnavailableException("chat sign-in is not configured: set MATRIX_SSO_AS_TOKEN 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. */
|
||||
/** AS register (first use) → get_token via ?user_id= impersonation. 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");
|
||||
const userId = `@${localpart}:${config.serverName}`;
|
||||
await this.ensureUser(config, localpart);
|
||||
const minted = await this.call(
|
||||
config,
|
||||
`/_matrix/client/v1/login/get_token?user_id=${encodeURIComponent(userId)}`,
|
||||
config.asToken,
|
||||
{},
|
||||
"get_token",
|
||||
);
|
||||
const loginToken = minted.login_token;
|
||||
if (typeof loginToken !== "string" || loginToken === "") {
|
||||
throw new ServiceUnavailableException("homeserver get_token response missing login_token");
|
||||
}
|
||||
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 {
|
||||
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);
|
||||
res = await this.fetcher(new URL("/_matrix/client/v3/register", config.homeserverUrl).toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${config.asToken}`,
|
||||
},
|
||||
body: JSON.stringify({ type: "m.login.application_service", username: localpart, inhibit_login: true }),
|
||||
redirect: "error",
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`register unreachable: ${err instanceof Error ? err.name : "error"}`);
|
||||
throw new ServiceUnavailableException("chat homeserver unreachable (register)");
|
||||
}
|
||||
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(
|
||||
|
|
@ -143,20 +156,6 @@ export class MatrixSsoService {
|
|||
return record(await res.json().catch(() => null));
|
||||
}
|
||||
|
||||
/** Best-effort: a stray broker device is harmless (fixed id, reused next mint). */
|
||||
private async logout(config: MatrixSsoConfig, accessToken: string): Promise<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]),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue