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:
@@ -1,59 +1,43 @@
|
||||
/*
|
||||
Copyright 2021-2024 New Vector Ltd.
|
||||
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 { type FC, type FormEvent, useCallback, useRef, useState } from "react";
|
||||
import { type FC, useEffect } from "react";
|
||||
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 Logo from "../icons/LogoLarge.svg?react";
|
||||
import { useClient } from "../ClientContext";
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import { FieldRow, ErrorMessage } from "../input/Input";
|
||||
import styles from "./LoginPage.module.css";
|
||||
import { useInteractiveLogin } from "./useInteractiveLogin";
|
||||
import { useSSORedirect, useSSOCallback } from "./useSSOLogin";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { Config } from "../config/Config";
|
||||
import { Link } from "../button/Link";
|
||||
|
||||
export const LoginPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t("login_title"));
|
||||
|
||||
const { client, setClient } = useClient();
|
||||
const login = useInteractiveLogin(client);
|
||||
const homeserver = Config.defaultHomeserverUrl(); // TODO: Make this configurable
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
const homeserver = Config.defaultHomeserverUrl();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error>();
|
||||
const ssoRedirect = useSSORedirect();
|
||||
|
||||
// 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(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
useEffect(() => {
|
||||
if (!session || !setClient) return;
|
||||
|
||||
if (!homeserver || !usernameRef.current || !passwordRef.current) {
|
||||
setError(Error("Login parameters are undefined"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
login(homeserver, usernameRef.current.value, passwordRef.current.value)
|
||||
.then(async ([client, session]) => {
|
||||
if (!setClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
setClient(client, session);
|
||||
const [newClient, newSession] = session;
|
||||
setClient(newClient, newSession);
|
||||
|
||||
const locationState = location.state;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
@@ -61,25 +45,19 @@ export const LoginPage: FC = () => {
|
||||
if (locationState && locationState.from) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
await navigate(locationState.from);
|
||||
void navigate(locationState.from);
|
||||
} else {
|
||||
await navigate("/");
|
||||
void 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;
|
||||
}, [session, setClient, navigate, location]);
|
||||
|
||||
const onClickSSO = (): void => {
|
||||
if (homeserver) {
|
||||
ssoRedirect(homeserver);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.container}>
|
||||
@@ -89,29 +67,6 @@ export const LoginPage: FC = () => {
|
||||
|
||||
<h2>{t("log_in")}</h2>
|
||||
<h4>{t("login_subheading")}</h4>
|
||||
<form onSubmit={onSubmitLoginForm}>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
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>
|
||||
<InputField
|
||||
type="password"
|
||||
ref={passwordRef}
|
||||
placeholder={t("common.password")}
|
||||
label={t("common.password")}
|
||||
data-testid="login_password"
|
||||
/>
|
||||
</FieldRow>
|
||||
{error && (
|
||||
<FieldRow>
|
||||
<ErrorMessage error={error} />
|
||||
@@ -119,24 +74,13 @@ export const LoginPage: FC = () => {
|
||||
)}
|
||||
<FieldRow>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
data-testid="login_login"
|
||||
onClick={onClickSSO}
|
||||
disabled={loading || !homeserver}
|
||||
data-testid="login_sso"
|
||||
>
|
||||
{loading ? t("logging_in") : t("login_title")}
|
||||
{loading ? t("logging_in") : "Sign in with SSO"}
|
||||
</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>
|
||||
|
||||
96
src/auth/useSSOLogin.ts
Normal file
96
src/auth/useSSOLogin.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user