mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-29 21:15:19 +00:00
Start supporting OAuth 2.0 authentication API
This commit is contained in:
@@ -90,6 +90,7 @@
|
|||||||
"e2ee_unsupported_description": "Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
|
"e2ee_unsupported_description": "Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
|
||||||
"generic": "Something went wrong",
|
"generic": "Something went wrong",
|
||||||
"generic_description": "Submitting debug logs will help us track down the problem.",
|
"generic_description": "Submitting debug logs will help us track down the problem.",
|
||||||
|
"homeserver_misconfig": "Misconfigured homeserver",
|
||||||
"insufficient_capacity": "Insufficient capacity",
|
"insufficient_capacity": "Insufficient capacity",
|
||||||
"insufficient_capacity_description": "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
|
"insufficient_capacity_description": "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
|
||||||
"matrix_rtc_focus_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
|
"matrix_rtc_focus_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
|||||||
|
|
||||||
import { HomePage } from "./home/HomePage";
|
import { HomePage } from "./home/HomePage";
|
||||||
import { LoginPage } from "./auth/LoginPage";
|
import { LoginPage } from "./auth/LoginPage";
|
||||||
|
import { OidcRedirectPage } from "./auth/OidcRedirectPage";
|
||||||
import { RegisterPage } from "./auth/RegisterPage";
|
import { RegisterPage } from "./auth/RegisterPage";
|
||||||
import { RoomPage } from "./room/RoomPage";
|
import { RoomPage } from "./room/RoomPage";
|
||||||
import { ClientProvider } from "./ClientContext";
|
import { ClientProvider } from "./ClientContext";
|
||||||
@@ -88,6 +89,7 @@ export const App: FC<Props> = ({ vm }) => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<SentryRoute path="/" element={<HomePage />} />
|
<SentryRoute path="/" element={<HomePage />} />
|
||||||
<SentryRoute path="/login" element={<LoginPage />} />
|
<SentryRoute path="/login" element={<LoginPage />} />
|
||||||
|
<SentryRoute path="/after_login" element={<OidcRedirectPage />} />
|
||||||
<SentryRoute path="/register" element={<RegisterPage />} />
|
<SentryRoute path="/register" element={<RegisterPage />} />
|
||||||
<SentryRoute path="*" element={<RoomPage />} />
|
<SentryRoute path="*" element={<RoomPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const TestComponent: FC<
|
|||||||
<ClientContextProvider
|
<ClientContextProvider
|
||||||
value={{
|
value={{
|
||||||
state: "valid",
|
state: "valid",
|
||||||
|
oidcClientConfig: null,
|
||||||
disconnected: false,
|
disconnected: false,
|
||||||
supportedFeatures: {
|
supportedFeatures: {
|
||||||
reactions: true,
|
reactions: true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2021-2024 New Vector Ltd.
|
Copyright 2021-2025 New Vector Ltd.
|
||||||
|
|
||||||
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.
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||||
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
|
import { ClientEvent, type OidcClientConfig, type MatrixClient } from "matrix-js-sdk";
|
||||||
|
|
||||||
import type { WidgetApi } from "matrix-widget-api";
|
import type { WidgetApi } from "matrix-widget-api";
|
||||||
import { ErrorPage } from "./FullScreenView";
|
import { ErrorPage } from "./FullScreenView";
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
RegistrationType,
|
RegistrationType,
|
||||||
} from "./analytics/PosthogAnalytics";
|
} from "./analytics/PosthogAnalytics";
|
||||||
import { useEventTarget } from "./useEvents";
|
import { useEventTarget } from "./useEvents";
|
||||||
|
import { getAuthMetadata } from "./utils/oidc/discovery";
|
||||||
import { OpenElsewhereError } from "./RichError";
|
import { OpenElsewhereError } from "./RichError";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -42,6 +43,7 @@ export type ClientState = ValidClientState | ErrorState;
|
|||||||
|
|
||||||
export type ValidClientState = {
|
export type ValidClientState = {
|
||||||
state: "valid";
|
state: "valid";
|
||||||
|
oidcClientConfig: OidcClientConfig | null,
|
||||||
authenticated?: AuthenticatedClient;
|
authenticated?: AuthenticatedClient;
|
||||||
// 'Disconnected' rather than 'connected' because it tracks specifically
|
// 'Disconnected' rather than 'connected' because it tracks specifically
|
||||||
// whether the client is supposed to be connected but is not
|
// whether the client is supposed to be connected but is not
|
||||||
@@ -74,17 +76,20 @@ export const useClientState = (): ClientState | undefined => use(ClientContext);
|
|||||||
export function useClient(): {
|
export function useClient(): {
|
||||||
client?: MatrixClient;
|
client?: MatrixClient;
|
||||||
setClient?: (client: MatrixClient, session: Session) => void;
|
setClient?: (client: MatrixClient, session: Session) => void;
|
||||||
|
oidcClientConfig: OidcClientConfig | null | undefined;
|
||||||
} {
|
} {
|
||||||
let client;
|
let client;
|
||||||
let setClient;
|
let setClient;
|
||||||
|
let oidcClientConfig;
|
||||||
|
|
||||||
const clientState = useClientState();
|
const clientState = useClientState();
|
||||||
if (clientState?.state === "valid") {
|
if (clientState?.state === "valid") {
|
||||||
client = clientState.authenticated?.client;
|
client = clientState.authenticated?.client;
|
||||||
setClient = clientState.setClient;
|
setClient = clientState.setClient;
|
||||||
|
oidcClientConfig = clientState.oidcClientConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { client, setClient };
|
return { client, setClient, oidcClientConfig };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain representation of the `ClientContext` as a helper for old components that expected an object with multiple fields.
|
// Plain representation of the `ClientContext` as a helper for old components that expected an object with multiple fields.
|
||||||
@@ -140,6 +145,12 @@ interface Props {
|
|||||||
export const ClientProvider: FC<Props> = ({ children }) => {
|
export const ClientProvider: FC<Props> = ({ children }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// null = no OIDC config, undefined = loading
|
||||||
|
const [oidcClientConfig, setOidcClientConfig] = useState<
|
||||||
|
OidcClientConfig | null | undefined
|
||||||
|
>(undefined);
|
||||||
|
const [oidcErr, setOidcErr] = useState<Error | undefined>(undefined);
|
||||||
|
|
||||||
// null = signed out, undefined = loading
|
// null = signed out, undefined = loading
|
||||||
const [initClientState, setInitClientState] = useState<
|
const [initClientState, setInitClientState] = useState<
|
||||||
InitResult | null | undefined
|
InitResult | null | undefined
|
||||||
@@ -153,6 +164,11 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||||||
if (initializing.current) return;
|
if (initializing.current) return;
|
||||||
initializing.current = true;
|
initializing.current = true;
|
||||||
|
|
||||||
|
if (!widget) {
|
||||||
|
// TODO: spec says this may change over time & should be refreshed upon cache expiry
|
||||||
|
getAuthMetadata().then(setOidcClientConfig, setOidcErr);
|
||||||
|
}
|
||||||
|
|
||||||
loadClient()
|
loadClient()
|
||||||
.then((initResult) => {
|
.then((initResult) => {
|
||||||
setInitClientState(initResult);
|
setInitClientState(initResult);
|
||||||
@@ -251,12 +267,18 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||||||
const [supportsReactions, setSupportsReactions] = useState(false);
|
const [supportsReactions, setSupportsReactions] = useState(false);
|
||||||
const [supportsThumbnails, setSupportsThumbnails] = useState(false);
|
const [supportsThumbnails, setSupportsThumbnails] = useState(false);
|
||||||
|
|
||||||
const state: ClientState | undefined = useMemo(() => {
|
const state = useMemo((): ClientState | undefined => {
|
||||||
if (alreadyOpenedErr) {
|
const error = alreadyOpenedErr || oidcErr;
|
||||||
return { state: "error", error: alreadyOpenedErr };
|
if (error) {
|
||||||
|
return { state: "error", error };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initClientState === undefined) return undefined;
|
if (
|
||||||
|
initClientState === undefined ||
|
||||||
|
oidcClientConfig === undefined
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const authenticated =
|
const authenticated =
|
||||||
initClientState === null
|
initClientState === null
|
||||||
@@ -270,6 +292,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
state: "valid",
|
state: "valid",
|
||||||
|
oidcClientConfig,
|
||||||
authenticated,
|
authenticated,
|
||||||
setClient,
|
setClient,
|
||||||
disconnected: isDisconnected,
|
disconnected: isDisconnected,
|
||||||
@@ -280,6 +303,8 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
alreadyOpenedErr,
|
alreadyOpenedErr,
|
||||||
|
oidcErr,
|
||||||
|
oidcClientConfig,
|
||||||
changePassword,
|
changePassword,
|
||||||
initClientState,
|
initClientState,
|
||||||
logout,
|
logout,
|
||||||
@@ -345,8 +370,9 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||||||
};
|
};
|
||||||
}, [initClientState, onSync]);
|
}, [initClientState, onSync]);
|
||||||
|
|
||||||
if (alreadyOpenedErr) {
|
const error = alreadyOpenedErr || oidcErr;
|
||||||
return <ErrorPage widget={widget} error={alreadyOpenedErr} />;
|
if (error) {
|
||||||
|
return <ErrorPage widget={widget} error={error} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ClientContext value={state}>{children}</ClientContext>;
|
return <ClientContext value={state}>{children}</ClientContext>;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
import { ErrorIcon, PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
import type { FC, ReactNode } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
import { ErrorView } from "./ErrorView";
|
import { ErrorView } from "./ErrorView";
|
||||||
@@ -51,3 +51,29 @@ export class OpenElsewhereError extends RichError {
|
|||||||
super("App opened in another tab", <OpenElsewhere />);
|
super("App opened in another tab", <OpenElsewhere />);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HomeserverMisconfig: FC<{message?: string}> = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// TODO: don't want to show "Return to home screen" button for an error as fatal as this
|
||||||
|
return (
|
||||||
|
<ErrorView
|
||||||
|
widget={widget}
|
||||||
|
Icon={ErrorIcon}
|
||||||
|
title={t("error.homeserver_misconfig")}
|
||||||
|
fatal={true}
|
||||||
|
>
|
||||||
|
{props.message && (
|
||||||
|
<p>
|
||||||
|
{props.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</ErrorView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export class HomeserverMisconfigError extends RichError {
|
||||||
|
public constructor(message?: string) {
|
||||||
|
super(message || "Unknown error", <HomeserverMisconfig message={message} />);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2021-2024 New Vector Ltd.
|
Copyright 2021-2025 New Vector Ltd.
|
||||||
|
|
||||||
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, type FormEvent, type ReactElement, useCallback, useRef, useState } from "react";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { Button } from "@vector-im/compound-web";
|
import { Button } from "@vector-im/compound-web";
|
||||||
@@ -19,13 +19,14 @@ 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";
|
import { Link } from "../button/Link";
|
||||||
|
import { LoadingPage } from "../FullScreenView";
|
||||||
|
import { getOidcClientId } from "../utils/oidc/registerClient";
|
||||||
|
import { startOidcLogin } from "../utils/oidc/authorize";
|
||||||
|
|
||||||
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 login = useInteractiveLogin(client);
|
|
||||||
const homeserver = Config.defaultHomeserverUrl(); // TODO: Make this configurable
|
const homeserver = Config.defaultHomeserverUrl(); // TODO: Make this configurable
|
||||||
const usernameRef = useRef<HTMLInputElement>(null);
|
const usernameRef = useRef<HTMLInputElement>(null);
|
||||||
const passwordRef = useRef<HTMLInputElement>(null);
|
const passwordRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -36,19 +37,23 @@ export const LoginPage: FC = () => {
|
|||||||
|
|
||||||
// TODO: Handle hitting login page with authenticated client
|
// TODO: Handle hitting login page with authenticated client
|
||||||
|
|
||||||
const onSubmitLoginForm = useCallback(
|
let node: ReactElement;
|
||||||
(e: FormEvent<HTMLFormElement>) => {
|
const { client, setClient, oidcClientConfig } = useClient();
|
||||||
e.preventDefault();
|
if (oidcClientConfig === null) {
|
||||||
setLoading(true);
|
const login = useInteractiveLogin(client);
|
||||||
|
const onSubmitLoginForm = useCallback(
|
||||||
|
async (e: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
if (!homeserver || !usernameRef.current || !passwordRef.current) {
|
if (!homeserver) {
|
||||||
setError(Error("Login parameters are undefined"));
|
setError(Error("Login parameters are undefined"));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
login(homeserver, usernameRef.current.value, passwordRef.current.value)
|
try {
|
||||||
.then(async ([client, session]) => {
|
const [client, session] = await login(homeserver, usernameRef.current?.value ?? "", passwordRef.current?.value ?? "");
|
||||||
if (!setClient) {
|
if (!setClient) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -66,20 +71,84 @@ export const LoginPage: FC = () => {
|
|||||||
await navigate("/");
|
await navigate("/");
|
||||||
}
|
}
|
||||||
PosthogAnalytics.instance.eventLogin.track();
|
PosthogAnalytics.instance.eventLogin.track();
|
||||||
})
|
} catch (error: any) {
|
||||||
.catch((error) => {
|
|
||||||
setError(error);
|
setError(error);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
}
|
||||||
},
|
},
|
||||||
[login, location, navigate, homeserver, setClient],
|
[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.
|
// 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);
|
let shortendHomeserverName = Config.defaultServerName()?.slice(0, 25);
|
||||||
shortendHomeserverName =
|
shortendHomeserverName =
|
||||||
shortendHomeserverName?.length !== Config.defaultServerName()?.length
|
shortendHomeserverName?.length !== Config.defaultServerName()?.length
|
||||||
? shortendHomeserverName + "..."
|
? shortendHomeserverName + "..."
|
||||||
: shortendHomeserverName;
|
: shortendHomeserverName;
|
||||||
|
node = (
|
||||||
|
<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} />
|
||||||
|
</FieldRow>
|
||||||
|
)}
|
||||||
|
<FieldRow>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
data-testid="login_login"
|
||||||
|
>
|
||||||
|
{loading ? t("logging_in") : t("login_title")}
|
||||||
|
</Button>
|
||||||
|
</FieldRow>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
} else if (oidcClientConfig !== undefined) {
|
||||||
|
if (!homeserver) {
|
||||||
|
setError(Error("No homeserver is configured"));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
node = <Button
|
||||||
|
kind="primary"
|
||||||
|
onClick={async () => {
|
||||||
|
// TODO: get the clientId at the same time as doing OIDC discovery
|
||||||
|
const clientId = await getOidcClientId(oidcClientConfig);
|
||||||
|
await startOidcLogin(
|
||||||
|
oidcClientConfig,
|
||||||
|
clientId,
|
||||||
|
homeserver,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("room_auth_view_continue_button") /* TODO: make a dedicated word for this */}
|
||||||
|
</Button>;
|
||||||
|
} else {
|
||||||
|
return <LoadingPage />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
@@ -89,44 +158,7 @@ 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}>
|
{node}
|
||||||
<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} />
|
|
||||||
</FieldRow>
|
|
||||||
)}
|
|
||||||
<FieldRow>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
data-testid="login_login"
|
|
||||||
>
|
|
||||||
{loading ? t("logging_in") : t("login_title")}
|
|
||||||
</Button>
|
|
||||||
</FieldRow>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.authLinks}>
|
<div className={styles.authLinks}>
|
||||||
<p>{t("login_auth_links_prompt")}</p>
|
<p>{t("login_auth_links_prompt")}</p>
|
||||||
|
|||||||
104
src/auth/OidcRedirectPage.tsx
Normal file
104
src/auth/OidcRedirectPage.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, type FC } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { createClient, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import { useClient } from "../ClientContext";
|
||||||
|
import { usePageTitle } from "../usePageTitle";
|
||||||
|
import { completeOidcLogin } from "../utils/oidc/authorize";
|
||||||
|
import { initClient } from "../utils/matrix";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: Yoinked from element-web, so move to SDK if possible
|
||||||
|
* Gets information about the owner of a given access token.
|
||||||
|
* @returns Promise that resolves with whoami response
|
||||||
|
* @throws when whoami request fails
|
||||||
|
*/
|
||||||
|
async function getUserIdFromAccessToken(
|
||||||
|
accessToken: string,
|
||||||
|
homeserverUrl: string,
|
||||||
|
): Promise<ReturnType<MatrixClient["whoami"]>> {
|
||||||
|
try {
|
||||||
|
const client = createClient({
|
||||||
|
baseUrl: homeserverUrl,
|
||||||
|
accessToken: accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await client.whoami();
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error("Failed to retrieve userId using accessToken");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OidcRedirectPage: FC = async () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// TODO: probably want a new page title
|
||||||
|
usePageTitle(t("login_title"));
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [_, setError] = useState<Error>();
|
||||||
|
|
||||||
|
const { setClient } = useClient();
|
||||||
|
if (!setClient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: make reactive
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams(location.search);
|
||||||
|
const { accessToken, refreshToken, homeserverUrl, idToken, clientId, issuer } =
|
||||||
|
await completeOidcLogin(queryParams);
|
||||||
|
|
||||||
|
const {
|
||||||
|
user_id: userId,
|
||||||
|
device_id: deviceId,
|
||||||
|
} = await getUserIdFromAccessToken(accessToken, homeserverUrl);
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
user_id: userId,
|
||||||
|
access_token: accessToken,
|
||||||
|
device_id: deviceId!, // TODO: make sure this really is always defined
|
||||||
|
passwordlessUser: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.debug(`TODO: use ${refreshToken}`);
|
||||||
|
const client = await initClient(
|
||||||
|
{
|
||||||
|
baseUrl: homeserverUrl,
|
||||||
|
accessToken,
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
setClient(client, session);
|
||||||
|
console.debug(`TODO: use ${clientId}, ${issuer}, ${idToken}`);
|
||||||
|
// persistOidcAuthenticatedSettings(clientId, issuer, idToken);
|
||||||
|
|
||||||
|
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("/");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setError(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<></>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2022-2024 New Vector Ltd.
|
Copyright 2022-2025 New Vector Ltd.
|
||||||
|
|
||||||
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.
|
||||||
@@ -151,6 +151,25 @@ export interface ConfigOptions {
|
|||||||
*/
|
*/
|
||||||
membership_event_expiry_ms?: number;
|
membership_event_expiry_ms?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for OIDC issuers where a static client_id has been issued for the app.
|
||||||
|
* Otherwise dynamic client registration is attempted.
|
||||||
|
*/
|
||||||
|
oidc_static_clients?: {
|
||||||
|
[issuer: string]: { client_id: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
oidc_metadata?: {
|
||||||
|
client_name?: string;
|
||||||
|
client_uri?: string;
|
||||||
|
redirect_uris?: string[];
|
||||||
|
logo_uri?: string;
|
||||||
|
application_type?: string;
|
||||||
|
tos_uri?: string;
|
||||||
|
policy_uri?: string;
|
||||||
|
contacts?: string[];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overrides members from ConfigOptions that are always provided by the
|
// Overrides members from ConfigOptions that are always provided by the
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ function renderWithMockClient(
|
|||||||
<ClientContextProvider
|
<ClientContextProvider
|
||||||
value={{
|
value={{
|
||||||
state: "valid",
|
state: "valid",
|
||||||
|
oidcClientConfig: null,
|
||||||
disconnected: false,
|
disconnected: false,
|
||||||
supportedFeatures: {
|
supportedFeatures: {
|
||||||
reactions: true,
|
reactions: true,
|
||||||
|
|||||||
96
src/utils/oidc/authorize.ts
Normal file
96
src/utils/oidc/authorize.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { completeAuthorizationCodeGrant, generateOidcAuthorizationUrl } from "matrix-js-sdk/src/oidc/authorize";
|
||||||
|
import { OidcClientConfig } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
|
||||||
|
import { type IdTokenClaims } from "oidc-client-ts";
|
||||||
|
|
||||||
|
import { getOidcCallbackUrl } from "./callbackUrl";
|
||||||
|
import { OidcClientError } from "./error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: Mostly yoinked from element-web, so move to SDK if possible
|
||||||
|
*
|
||||||
|
* Start OIDC authorization code flow
|
||||||
|
* Generates auth params, stores them in session storage and
|
||||||
|
* Navigates to configured authorization endpoint
|
||||||
|
* @param delegatedAuthConfig from discovery
|
||||||
|
* @param clientId this client's id as registered with configured issuer
|
||||||
|
* @param homeserverUrl target homeserver
|
||||||
|
* @returns Promise that resolves after we have navigated to auth endpoint
|
||||||
|
*/
|
||||||
|
export async function startOidcLogin(
|
||||||
|
delegatedAuthConfig: OidcClientConfig,
|
||||||
|
clientId: string,
|
||||||
|
homeserverUrl: string,
|
||||||
|
isRegistration?: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const redirectUri = getOidcCallbackUrl().href;
|
||||||
|
|
||||||
|
const nonce = secureRandomString(10);
|
||||||
|
|
||||||
|
const prompt = isRegistration ? "create" : undefined;
|
||||||
|
|
||||||
|
const authorizationUrl = await generateOidcAuthorizationUrl({
|
||||||
|
metadata: delegatedAuthConfig,
|
||||||
|
redirectUri,
|
||||||
|
clientId,
|
||||||
|
homeserverUrl,
|
||||||
|
identityServerUrl: undefined,
|
||||||
|
nonce,
|
||||||
|
prompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.href = authorizationUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Mostly yoinked from element-web, so move to SDK if possible
|
||||||
|
type CompleteOidcLoginResponse = {
|
||||||
|
// url of the homeserver selected during login
|
||||||
|
homeserverUrl: string;
|
||||||
|
// accessToken gained from OIDC token issuer
|
||||||
|
accessToken: string;
|
||||||
|
// refreshToken gained from OIDC token issuer, when falsy token cannot be refreshed
|
||||||
|
refreshToken?: string;
|
||||||
|
// idToken gained from OIDC token issuer
|
||||||
|
idToken: string;
|
||||||
|
// this client's id as registered with the OIDC issuer
|
||||||
|
clientId: string;
|
||||||
|
// issuer used during authentication
|
||||||
|
issuer: string;
|
||||||
|
// claims of the given access token; used during token refresh to validate new tokens
|
||||||
|
idTokenClaims: IdTokenClaims;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: Mostly yoinked from element-web, so move to SDK if possible
|
||||||
|
*
|
||||||
|
* Attempt to complete authorization code flow to get an access token
|
||||||
|
* @param queryParams the query-parameters extracted from the real query-string of the starting URI.
|
||||||
|
* @returns Promise that resolves with a CompleteOidcLoginResponse when login was successful
|
||||||
|
* @throws When we failed to get a valid access token
|
||||||
|
*/
|
||||||
|
export async function completeOidcLogin(queryParams: URLSearchParams): Promise<CompleteOidcLoginResponse> {
|
||||||
|
const code = queryParams.get("code");
|
||||||
|
const state = queryParams.get("state");
|
||||||
|
if (!code || !state) {
|
||||||
|
throw new Error(OidcClientError.InvalidQueryParameters);
|
||||||
|
}
|
||||||
|
const { homeserverUrl, tokenResponse, idTokenClaims, oidcClientSettings } =
|
||||||
|
await completeAuthorizationCodeGrant(code, state);
|
||||||
|
|
||||||
|
return {
|
||||||
|
homeserverUrl,
|
||||||
|
accessToken: tokenResponse.access_token,
|
||||||
|
refreshToken: tokenResponse.refresh_token,
|
||||||
|
idToken: tokenResponse.id_token,
|
||||||
|
clientId: oidcClientSettings.clientId,
|
||||||
|
issuer: oidcClientSettings.issuer,
|
||||||
|
idTokenClaims,
|
||||||
|
};
|
||||||
|
}
|
||||||
14
src/utils/oidc/callbackUrl.ts
Normal file
14
src/utils/oidc/callbackUrl.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL to return to after a successful OIDC authentication
|
||||||
|
*/
|
||||||
|
export function getOidcCallbackUrl(): URL {
|
||||||
|
// TODO: save the path somewhere
|
||||||
|
return new URL("after_login", window.location.origin);
|
||||||
|
}
|
||||||
30
src/utils/oidc/discovery.ts
Normal file
30
src/utils/oidc/discovery.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MatrixClient, MatrixError, OidcClientConfig } from "matrix-js-sdk";
|
||||||
|
|
||||||
|
import { Config } from "../../config/Config";
|
||||||
|
import { HomeserverMisconfigError } from "../../RichError";
|
||||||
|
|
||||||
|
export async function getAuthMetadata(): Promise<OidcClientConfig | null> {
|
||||||
|
const baseUrl = Config.defaultHomeserverUrl(); // TODO: Make this configurable
|
||||||
|
if (!baseUrl) {
|
||||||
|
throw new Error("No homeserver URL configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempClient = new MatrixClient({ baseUrl });
|
||||||
|
try {
|
||||||
|
return await tempClient.getAuthMetadata();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof MatrixError && e.httpStatus === 404 && e.errcode === "M_UNRECOGNIZED") {
|
||||||
|
// 404 M_UNRECOGNIZED means the server does not support OIDC
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
throw new HomeserverMisconfigError(e instanceof Error ? e.message : undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/utils/oidc/error.ts
Normal file
16
src/utils/oidc/error.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: Yoinked from element-web, so move to SDK if possible
|
||||||
|
*
|
||||||
|
* Errors thrown by EC during OIDC native flow authentication.
|
||||||
|
* Intended to be logged, not read by users.
|
||||||
|
*/
|
||||||
|
export enum OidcClientError {
|
||||||
|
InvalidQueryParameters = "Invalid query parameters for OIDC native login. `code` and `state` are required.",
|
||||||
|
}
|
||||||
21
src/utils/oidc/isUserRegistrationSupported.ts
Normal file
21
src/utils/oidc/isUserRegistrationSupported.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// TODO: File yoinked from element-web, so move to SDK if possible
|
||||||
|
|
||||||
|
import { type OidcClientConfig } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the create prompt is supported by the OP, if so, we can do a registration flow
|
||||||
|
* https://openid.net/specs/openid-connect-prompt-create-1_0.html
|
||||||
|
* @param delegatedAuthConfig config as returned from discovery
|
||||||
|
* @returns whether user registration is supported
|
||||||
|
*/
|
||||||
|
export function isUserRegistrationSupported(delegatedAuthConfig: OidcClientConfig): boolean {
|
||||||
|
const supportedPrompts = delegatedAuthConfig.prompt_values_supported;
|
||||||
|
return Array.isArray(supportedPrompts) && supportedPrompts?.includes("create");
|
||||||
|
}
|
||||||
61
src/utils/oidc/registerClient.ts
Normal file
61
src/utils/oidc/registerClient.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { registerOidcClient, type OidcClientConfig } from "matrix-js-sdk";
|
||||||
|
|
||||||
|
import { ConfigOptions } from "../../config/ConfigOptions";
|
||||||
|
import { Config } from "../../config/Config";
|
||||||
|
import { getOidcCallbackUrl } from "./callbackUrl";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: Mostly yoinked from element-web, so move to SDK if possible
|
||||||
|
*
|
||||||
|
* Get the statically configured clientId for the issuer
|
||||||
|
* @param issuer delegated auth OIDC issuer
|
||||||
|
* @param staticOidcClients static client config from config.json
|
||||||
|
* @returns clientId if found, otherwise undefined
|
||||||
|
*/
|
||||||
|
function getStaticOidcClientId(
|
||||||
|
issuer: string,
|
||||||
|
staticOidcClients?: ConfigOptions["oidc_static_clients"],
|
||||||
|
): string | undefined {
|
||||||
|
// static_oidc_clients are configured with a trailing slash
|
||||||
|
const issuerWithTrailingSlash = issuer.endsWith("/") ? issuer : issuer + "/";
|
||||||
|
return staticOidcClients?.[issuerWithTrailingSlash]?.client_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: Mostly yoinked from element-web, so move to SDK if possible
|
||||||
|
*
|
||||||
|
* Get the statically configured clientId for an OIDC OP
|
||||||
|
* @param delegatedAuthConfig Auth config from OP
|
||||||
|
* @returns resolves with clientId
|
||||||
|
* @throws if no clientId is found
|
||||||
|
*/
|
||||||
|
export async function getOidcClientId(
|
||||||
|
delegatedAuthConfig: OidcClientConfig,
|
||||||
|
): Promise<string> {
|
||||||
|
const config = Config.get();
|
||||||
|
const staticClientId = getStaticOidcClientId(delegatedAuthConfig.issuer, config.oidc_static_clients);
|
||||||
|
if (staticClientId) {
|
||||||
|
logger.debug(`Using static clientId for issuer ${delegatedAuthConfig.issuer}`);
|
||||||
|
return staticClientId;
|
||||||
|
}
|
||||||
|
return await registerOidcClient(
|
||||||
|
delegatedAuthConfig,
|
||||||
|
{
|
||||||
|
clientName: config.oidc_metadata?.client_name ?? "Element Call",
|
||||||
|
clientUri: config.oidc_metadata?.client_uri ?? window.location.origin,
|
||||||
|
redirectUris: [getOidcCallbackUrl().href],
|
||||||
|
applicationType: "web",
|
||||||
|
contacts: config.oidc_metadata?.contacts,
|
||||||
|
tosUri: config.oidc_metadata?.tos_uri,
|
||||||
|
policyUri: config.oidc_metadata?.policy_uri,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user