Matrix: isolate named account auth

This commit is contained in:
Gustavo Madeira Santana 2026-03-17 23:06:04 +00:00
parent b29af9acad
commit 4edd5c8eeb
No known key found for this signature in database
3 changed files with 148 additions and 20 deletions

View File

@ -262,6 +262,59 @@ describe("resolveMatrixConfig", () => {
expect(resolved.userId).toBe("");
});
it("does not inherit base or global auth secrets for non-default accounts", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://base.example.org",
accessToken: "base-token",
password: "base-pass", // pragma: allowlist secret
deviceId: "BASEDEVICE",
accounts: {
ops: {
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
password: "ops-pass", // pragma: allowlist secret
},
},
},
},
} as CoreConfig;
const env = {
MATRIX_ACCESS_TOKEN: "global-token",
MATRIX_PASSWORD: "global-pass",
MATRIX_DEVICE_ID: "GLOBALDEVICE",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.accessToken).toBeUndefined();
expect(resolved.password).toBe("ops-pass");
expect(resolved.deviceId).toBeUndefined();
});
it("does not inherit a base password for non-default accounts", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://base.example.org",
password: "base-pass", // pragma: allowlist secret
accounts: {
ops: {
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
},
},
},
},
} as CoreConfig;
const env = {
MATRIX_PASSWORD: "global-pass",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.password).toBeUndefined();
});
it("rejects insecure public http Matrix homeservers", () => {
expect(() => validateMatrixHomeserverUrl("http://matrix.example.org")).toThrow(
"Matrix homeserver must use https:// unless it targets a private or loopback host",
@ -479,6 +532,56 @@ describe("resolveMatrixAuth", () => {
expect(auth.deviceId).toBe("OPSDEVICE");
});
it("uses named-account password auth instead of inheriting the base access token", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue(null);
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(false);
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
access_token: "ops-token",
user_id: "@ops:example.org",
device_id: "OPSDEVICE",
});
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "legacy-token",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
password: "ops-pass", // pragma: allowlist secret
},
},
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
accountId: "ops",
});
expect(doRequestSpy).toHaveBeenCalledWith(
"POST",
"/_matrix/client/v3/login",
undefined,
expect.objectContaining({
type: "m.login.password",
identifier: { type: "m.id.user", user: "@ops:example.org" },
password: "ops-pass",
}),
);
expect(auth).toMatchObject({
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
deviceId: "OPSDEVICE",
});
});
it("resolves missing whoami identity fields for token auth", async () => {
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
user_id: "@bot:example.org",

View File

@ -67,16 +67,35 @@ function resolveMatrixStringField(params: {
accountValue?: string;
scopedEnvValue?: string;
globalEnvValue?: string;
includeBaseConfig?: boolean;
includeGlobalEnv?: boolean;
}): string {
return (
params.accountValue ||
params.scopedEnvValue ||
readMatrixBaseConfigField(params.matrix, params.field) ||
params.globalEnvValue ||
(params.includeBaseConfig === false
? ""
: readMatrixBaseConfigField(params.matrix, params.field)) ||
(params.includeGlobalEnv === false ? "" : params.globalEnvValue) ||
""
);
}
function resolveMatrixAccountAuthField(params: {
matrix: ReturnType<typeof resolveMatrixBaseConfig>;
field: Extract<MatrixConfigStringField, "userId" | "accessToken" | "password" | "deviceId">;
accountValue?: string;
scopedEnvValue?: string;
globalEnvValue?: string;
isDefaultAccount: boolean;
}): string {
return resolveMatrixStringField({
...params,
includeBaseConfig: params.isDefaultAccount,
includeGlobalEnv: params.isDefaultAccount,
});
}
function clampMatrixInitialSyncLimit(value: unknown): number | undefined {
return typeof value === "number" ? Math.max(0, Math.floor(value)) : undefined;
}
@ -248,36 +267,42 @@ export function resolveMatrixConfigForAccount(
scopedEnvValue: scopedEnv.homeserver,
globalEnvValue: globalEnv.homeserver,
});
const userIdSource =
accountField("userId") ||
scopedEnv.userId ||
(normalizedAccountId === DEFAULT_ACCOUNT_ID
? readMatrixBaseConfigField(matrix, "userId") || globalEnv.userId || ""
: "");
const userId = userIdSource;
const isDefaultAccount = normalizedAccountId === DEFAULT_ACCOUNT_ID;
const userId = resolveMatrixAccountAuthField({
matrix,
field: "userId",
accountValue: accountField("userId"),
scopedEnvValue: scopedEnv.userId,
globalEnvValue: globalEnv.userId,
isDefaultAccount,
});
const accessToken =
resolveMatrixStringField({
resolveMatrixAccountAuthField({
matrix,
field: "accessToken",
accountValue: accountField("accessToken"),
scopedEnvValue: scopedEnv.accessToken,
globalEnvValue: globalEnv.accessToken,
isDefaultAccount,
}) || undefined;
const password =
resolveMatrixStringField({
resolveMatrixAccountAuthField({
matrix,
field: "password",
accountValue: accountField("password"),
scopedEnvValue: scopedEnv.password,
globalEnvValue: globalEnv.password,
isDefaultAccount,
}) || undefined;
const deviceId =
resolveMatrixAccountAuthField({
matrix,
field: "deviceId",
accountValue: accountField("deviceId"),
scopedEnvValue: scopedEnv.deviceId,
globalEnvValue: globalEnv.deviceId,
isDefaultAccount,
}) || undefined;
const deviceIdSource =
accountField("deviceId") ||
scopedEnv.deviceId ||
(normalizedAccountId === DEFAULT_ACCOUNT_ID
? readMatrixBaseConfigField(matrix, "deviceId") || globalEnv.deviceId || ""
: "");
const deviceId = deviceIdSource || undefined;
const deviceName =
resolveMatrixStringField({
matrix,

View File

@ -55,7 +55,7 @@ describe("ensureMatrixCryptoRuntime", () => {
it("rethrows non-crypto module errors without bootstrapping", async () => {
const runCommand = vi.fn();
const requireFn = vi.fn(() => {
throw new Error("Cannot find module '@vector-im/matrix-bot-sdk'");
throw new Error("Cannot find module 'not-the-matrix-crypto-runtime'");
});
await expect(
@ -66,7 +66,7 @@ describe("ensureMatrixCryptoRuntime", () => {
resolveFn: () => "/tmp/download-lib.js",
nodeExecutable: "/usr/bin/node",
}),
).rejects.toThrow("Cannot find module '@vector-im/matrix-bot-sdk'");
).rejects.toThrow("Cannot find module 'not-the-matrix-crypto-runtime'");
expect(runCommand).not.toHaveBeenCalled();
expect(requireFn).toHaveBeenCalledTimes(1);