feat: replace password login with SSO redirect

Tuwunel only supports SSO login (login_with_password = false).
Replace the username/password form with an SSO redirect button
that initiates the m.login.sso flow via the homeserver, which
redirects to Hydra for OIDC authentication. The callback handler
exchanges the loginToken for a Matrix session via m.login.token.
This commit is contained in:
2026-03-26 09:38:16 +00:00
parent c75bf0ef8f
commit d03e18e99f
2 changed files with 138 additions and 98 deletions

View File

@@ -1,85 +1,63 @@
/* /*
Copyright 2021-2024 New Vector Ltd. Copyright 2021-2024 New Vector Ltd.
Copyright 2026 Sunbeam Studios.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type FC, type FormEvent, useCallback, useRef, useState } from "react"; import { type FC, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@vector-im/compound-web"; import { Button } from "@vector-im/compound-web";
import Logo from "../icons/LogoLarge.svg?react"; import Logo from "../icons/LogoLarge.svg?react";
import { useClient } from "../ClientContext"; import { useClient } from "../ClientContext";
import { FieldRow, InputField, ErrorMessage } from "../input/Input"; import { FieldRow, ErrorMessage } from "../input/Input";
import styles from "./LoginPage.module.css"; import styles from "./LoginPage.module.css";
import { useInteractiveLogin } from "./useInteractiveLogin"; import { useSSORedirect, useSSOCallback } from "./useSSOLogin";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { Link } from "../button/Link";
export const LoginPage: FC = () => { export const LoginPage: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
usePageTitle(t("login_title")); usePageTitle(t("login_title"));
const { client, setClient } = useClient(); const { client, setClient } = useClient();
const login = useInteractiveLogin(client); const homeserver = Config.defaultHomeserverUrl();
const homeserver = Config.defaultHomeserverUrl(); // TODO: Make this configurable
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [loading, setLoading] = useState(false); const ssoRedirect = useSSORedirect();
const [error, setError] = useState<Error>();
// TODO: Handle hitting login page with authenticated client // Handle SSO callback (redirected back with ?loginToken=)
const { loading, error, session } = useSSOCallback(homeserver, client);
const onSubmitLoginForm = useCallback( useEffect(() => {
(e: FormEvent<HTMLFormElement>) => { if (!session || !setClient) return;
e.preventDefault();
setLoading(true);
if (!homeserver || !usernameRef.current || !passwordRef.current) { const [newClient, newSession] = session;
setError(Error("Login parameters are undefined")); setClient(newClient, newSession);
setLoading(false);
return;
}
login(homeserver, usernameRef.current.value, passwordRef.current.value) const locationState = location.state;
.then(async ([client, session]) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment
if (!setClient) { // @ts-ignore
return; if (locationState && locationState.from) {
} // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
void navigate(locationState.from);
} else {
void navigate("/");
}
PosthogAnalytics.instance.eventLogin.track();
}, [session, setClient, navigate, location]);
setClient(client, session); const onClickSSO = (): void => {
if (homeserver) {
ssoRedirect(homeserver);
}
};
const locationState = location.state;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (locationState && locationState.from) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await navigate(locationState.from);
} else {
await navigate("/");
}
PosthogAnalytics.instance.eventLogin.track();
})
.catch((error) => {
setError(error);
setLoading(false);
});
},
[login, location, navigate, homeserver, setClient],
);
// we need to limit the length of the homserver name to not cover the whole loginview input with the string.
let shortendHomeserverName = Config.defaultServerName()?.slice(0, 25);
shortendHomeserverName =
shortendHomeserverName?.length !== Config.defaultServerName()?.length
? shortendHomeserverName + "..."
: shortendHomeserverName;
return ( return (
<> <>
<div className={styles.container}> <div className={styles.container}>
@@ -89,54 +67,20 @@ export const LoginPage: FC = () => {
<h2>{t("log_in")}</h2> <h2>{t("log_in")}</h2>
<h4>{t("login_subheading")}</h4> <h4>{t("login_subheading")}</h4>
<form onSubmit={onSubmitLoginForm}> {error && (
<FieldRow> <FieldRow>
<InputField <ErrorMessage error={error} />
type="text"
ref={usernameRef}
placeholder={t("common.username")}
label={t("common.username")}
autoCorrect="off"
autoCapitalize="none"
prefix="@"
suffix={`:${shortendHomeserverName}`}
data-testid="login_username"
/>
</FieldRow> </FieldRow>
<FieldRow> )}
<InputField <FieldRow>
type="password" <Button
ref={passwordRef} onClick={onClickSSO}
placeholder={t("common.password")} disabled={loading || !homeserver}
label={t("common.password")} data-testid="login_sso"
data-testid="login_password" >
/> {loading ? t("logging_in") : "Sign in with SSO"}
</FieldRow> </Button>
{error && ( </FieldRow>
<FieldRow>
<ErrorMessage error={error} />
</FieldRow>
)}
<FieldRow>
<Button
type="submit"
disabled={loading}
data-testid="login_login"
>
{loading ? t("logging_in") : t("login_title")}
</Button>
</FieldRow>
</form>
</div>
<div className={styles.authLinks}>
<p>{t("login_auth_links_prompt")}</p>
<p>
<Trans i18nKey="login_auth_links">
<Link to="/register">Create an account</Link>
{" Or "}
<Link to="/">Access as a guest</Link>
</Trans>
</p>
</div> </div>
</div> </div>
</div> </div>

96
src/auth/useSSOLogin.ts Normal file
View File

@@ -0,0 +1,96 @@
/*
Copyright 2026 Sunbeam Studios.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useCallback, useEffect, useState } from "react";
import { createClient, type MatrixClient } from "matrix-js-sdk";
import { initClient } from "../utils/matrix";
import { type Session } from "../ClientContext";
/**
* Initiates SSO login by redirecting the user to the homeserver's SSO endpoint.
* The homeserver will redirect to the OIDC provider, and on success redirect
* back to the given callbackUrl with a ?loginToken= parameter.
*/
export function useSSORedirect(): (homeserver: string) => void {
return useCallback((homeserver: string) => {
const callbackUrl = `${window.location.origin}/login`;
const ssoUrl = `${homeserver}/_matrix/client/v3/login/sso/redirect?redirectUrl=${encodeURIComponent(callbackUrl)}`;
window.location.href = ssoUrl;
}, []);
}
/**
* Completes SSO login by exchanging a loginToken (from the SSO callback URL)
* for a full Matrix session via m.login.token.
*/
export function useSSOCallback(
homeserver: string | undefined,
oldClient?: MatrixClient,
): {
loading: boolean;
error: Error | undefined;
session: [MatrixClient, Session] | undefined;
} {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const [session, setSession] = useState<[MatrixClient, Session]>();
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const loginToken = params.get("loginToken");
if (!loginToken || !homeserver) return;
// Clear the loginToken from the URL to prevent re-use on refresh.
const cleanUrl = window.location.pathname;
window.history.replaceState({}, "", cleanUrl);
setLoading(true);
(async (): Promise<void> => {
try {
const authClient = createClient({ baseUrl: homeserver });
const response = await authClient.login("m.login.token", {
token: loginToken,
});
const { user_id, access_token, device_id } = response;
// Sign out the old client if one exists, to avoid crypto session confusion.
await oldClient?.logout(true);
const client = await initClient(
{
baseUrl: homeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
false,
);
setSession([
client,
{
user_id,
access_token,
device_id,
passwordlessUser: false,
},
]);
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)));
} finally {
setLoading(false);
}
})();
}, [homeserver, oldClient]);
return { loading, error, session };
}