Skip to content

Commit 2e8bf04

Browse files
committed
Login form: support .well-known delegation for homeserver url
1 parent d9a33d5 commit 2e8bf04

3 files changed

Lines changed: 110 additions & 5 deletions

File tree

src/pages/LoginPage.tsx

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
splitMxid,
4242
getSupportedLoginFlows,
4343
getAuthMetadata,
44+
resolveBaseUrlWithWellKnown,
4445
} from "../synapse/matrix";
4546
import { SetExternalAuthProvider } from "../utils/config";
4647

@@ -119,14 +120,15 @@ const LoginPage = () => {
119120
const [oidcUrl, setOIDCUrl] = useState("");
120121
const [ssoBaseUrl, setSSOBaseUrl] = useState("");
121122
const [baseUrl, setBaseUrl] = useState(base_url || "");
123+
const [resolvedBaseUrl, setResolvedBaseUrl] = useState(base_url || "");
122124
const loginToken = new URLSearchParams(window.location.search).get("loginToken");
123125
const [loginMethod, setLoginMethod] = useState<LoginMethod>("credentials");
124126
const [serverVersion, setServerVersion] = useState("");
125127
const [matrixVersions, setMatrixVersions] = useState("");
126128

127129
useEffect(() => {
128130
if (base_url) {
129-
checkServerInfo(base_url as string);
131+
resolveAndCheckServerInfo(base_url as string);
130132
}
131133
// eslint-disable-next-line react-hooks/exhaustive-deps
132134
}, []);
@@ -177,7 +179,12 @@ const LoginPage = () => {
177179
const cleanUrl = window.location.href.replace(window.location.search, "");
178180
window.history.replaceState({}, "", cleanUrl);
179181

180-
login(auth).catch(error => {
182+
const authWithResolved = {
183+
...auth,
184+
base_url: resolvedBaseUrl || auth.base_url,
185+
};
186+
187+
login(authWithResolved).catch(error => {
181188
setLoading(false);
182189
notify(
183190
typeof error === "string"
@@ -213,6 +220,7 @@ const LoginPage = () => {
213220
setMatrixVersions("");
214221
setOIDCUrl("");
215222
setBaseUrl("");
223+
setResolvedBaseUrl("");
216224
setSupportPassAuth(false);
217225
return;
218226
}
@@ -237,6 +245,7 @@ const LoginPage = () => {
237245
const supportPass = loginFlows.find(f => f.type === "m.login.password") !== undefined;
238246
const supportSSO = loginFlows.find(f => f.type === "m.login.sso") !== undefined;
239247
setBaseUrl(url);
248+
setResolvedBaseUrl(url);
240249
setSupportPassAuth(supportPass);
241250
setSSOBaseUrl(supportSSO ? url : "");
242251

@@ -267,7 +276,25 @@ const LoginPage = () => {
267276
setSSOBaseUrl("");
268277
setOIDCUrl("");
269278
setBaseUrl("");
279+
setResolvedBaseUrl("");
280+
}
281+
};
282+
283+
const resolveAndCheckServerInfo = async (url: string, updateFormValue?: (nextUrl: string) => void) => {
284+
if (!url) {
285+
return;
286+
}
287+
288+
if (!isValidBaseUrl(url)) {
289+
checkServerInfo(url);
290+
return;
291+
}
292+
293+
const resolvedUrl = await resolveBaseUrlWithWellKnown(url);
294+
if (resolvedUrl !== url && updateFormValue) {
295+
updateFormValue(resolvedUrl);
270296
}
297+
checkServerInfo(resolvedUrl);
271298
};
272299

273300
const icfg = useInstanceConfig();
@@ -300,6 +327,7 @@ const LoginPage = () => {
300327
shouldValidate: true,
301328
shouldDirty: true,
302329
});
330+
setResolvedBaseUrl(url);
303331
checkServerInfo(url);
304332
}
305333
}
@@ -315,7 +343,15 @@ const LoginPage = () => {
315343

316344
// Trigger validation only when user finishes typing/selecting
317345
form.trigger("base_url");
318-
checkServerInfo(value);
346+
const updateFormValue =
347+
restrictBaseUrlMultiple || restrictBaseUrlSingle
348+
? undefined
349+
: (nextUrl: string) =>
350+
form.setValue("base_url", nextUrl, {
351+
shouldValidate: true,
352+
shouldDirty: true,
353+
});
354+
resolveAndCheckServerInfo(value, updateFormValue);
319355
};
320356

321357
useEffect(() => {
@@ -355,7 +391,15 @@ const LoginPage = () => {
355391
shouldValidate: true,
356392
shouldDirty: true,
357393
});
358-
checkServerInfo(serverURL);
394+
const updateFormValue =
395+
restrictBaseUrlMultiple || restrictBaseUrlSingle
396+
? undefined
397+
: (nextUrl: string) =>
398+
form.setValue("base_url", nextUrl, {
399+
shouldValidate: true,
400+
shouldDirty: true,
401+
});
402+
resolveAndCheckServerInfo(serverURL, updateFormValue);
359403
}
360404
}, 0);
361405

src/synapse/matrix.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { isValidBaseUrl, splitMxid } from "./matrix";
1+
import { fetchUtils } from "react-admin";
2+
3+
import { isValidBaseUrl, splitMxid, resolveBaseUrlWithWellKnown } from "./matrix";
4+
5+
jest.mock("react-admin", () => ({
6+
fetchUtils: {
7+
fetchJson: jest.fn(),
8+
},
9+
}));
210

311
describe("splitMxid", () => {
412
it("splits valid MXIDs", () =>
@@ -21,3 +29,28 @@ describe("isValidBaseUrl", () => {
2129
it("rejects base URLs with path", () => expect(isValidBaseUrl("http://foo.bar/path")).toBeFalsy());
2230
it("rejects invalid base URLs", () => expect(isValidBaseUrl("http:/foo.bar")).toBeFalsy());
2331
});
32+
33+
describe("resolveBaseUrlWithWellKnown", () => {
34+
const fetchJsonMock = fetchUtils.fetchJson as jest.Mock;
35+
36+
afterEach(() => {
37+
fetchJsonMock.mockReset();
38+
});
39+
40+
it("returns well-known base_url when present", async () => {
41+
fetchJsonMock.mockResolvedValueOnce({
42+
json: {
43+
"m.homeserver": { base_url: "https://api.example.com" },
44+
},
45+
});
46+
47+
await expect(resolveBaseUrlWithWellKnown("https://example.com")).resolves.toBe("https://api.example.com");
48+
expect(fetchJsonMock).toHaveBeenCalledWith("https://example.com/.well-known/matrix/client", { method: "GET" });
49+
});
50+
51+
it("falls back to provided URL when well-known fails", async () => {
52+
fetchJsonMock.mockRejectedValueOnce(new Error("nope"));
53+
54+
await expect(resolveBaseUrlWithWellKnown("https://example.com/")).resolves.toBe("https://example.com");
55+
});
56+
});

src/synapse/matrix.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,34 @@ export const splitMxid = mxid => {
1010

1111
export const isValidBaseUrl = baseUrl => /^(http|https):\/\/[a-zA-Z0-9\-.]+(:\d{1,5})?\/?$/.test(baseUrl);
1212

13+
/**
14+
* Resolve a base URL using /.well-known/matrix/client if present.
15+
* Falls back to the provided URL if lookup fails or is invalid.
16+
*/
17+
export const resolveBaseUrlWithWellKnown = async (baseUrl: string): Promise<string> => {
18+
if (!baseUrl) return baseUrl;
19+
const cleaned = baseUrl.replace(/\/+$/g, "");
20+
let origin: string;
21+
try {
22+
origin = new URL(cleaned).origin;
23+
} catch {
24+
return cleaned;
25+
}
26+
27+
const wellKnownUrl = `${origin}/.well-known/matrix/client`;
28+
try {
29+
const response = await fetchUtils.fetchJson(wellKnownUrl, { method: "GET" });
30+
const wkBaseUrl = response.json?.["m.homeserver"]?.base_url;
31+
if (typeof wkBaseUrl === "string" && wkBaseUrl.trim() !== "") {
32+
return wkBaseUrl.replace(/\/+$/g, "");
33+
}
34+
} catch {
35+
// ignore and fall back to the provided URL
36+
}
37+
38+
return cleaned;
39+
};
40+
1341
/**
1442
* Resolve the homeserver URL using the well-known lookup
1543
* @param domain the domain part of an MXID

0 commit comments

Comments
 (0)