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,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
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