mirror of
https://github.com/vector-im/element-call.git
synced 2026-03-19 06:20:25 +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+.",
|
||||
"generic": "Something went wrong",
|
||||
"generic_description": "Submitting debug logs will help us track down the problem.",
|
||||
"homeserver_misconfig": "Misconfigured homeserver",
|
||||
"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.",
|
||||
"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 { LoginPage } from "./auth/LoginPage";
|
||||
import { OidcRedirectPage } from "./auth/OidcRedirectPage";
|
||||
import { RegisterPage } from "./auth/RegisterPage";
|
||||
import { RoomPage } from "./room/RoomPage";
|
||||
import { ClientProvider } from "./ClientContext";
|
||||
@@ -88,6 +89,7 @@ export const App: FC<Props> = ({ vm }) => {
|
||||
<Routes>
|
||||
<SentryRoute path="/" element={<HomePage />} />
|
||||
<SentryRoute path="/login" element={<LoginPage />} />
|
||||
<SentryRoute path="/after_login" element={<OidcRedirectPage />} />
|
||||
<SentryRoute path="/register" element={<RegisterPage />} />
|
||||
<SentryRoute path="*" element={<RoomPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -21,6 +21,7 @@ const TestComponent: FC<
|
||||
<ClientContextProvider
|
||||
value={{
|
||||
state: "valid",
|
||||
oidcClientConfig: null,
|
||||
disconnected: false,
|
||||
supportedFeatures: {
|
||||
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
|
||||
Please see LICENSE in the repository root for full details.
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
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 { ErrorPage } from "./FullScreenView";
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
RegistrationType,
|
||||
} from "./analytics/PosthogAnalytics";
|
||||
import { useEventTarget } from "./useEvents";
|
||||
import { getAuthMetadata } from "./utils/oidc/discovery";
|
||||
import { OpenElsewhereError } from "./RichError";
|
||||
|
||||
declare global {
|
||||
@@ -42,6 +43,7 @@ export type ClientState = ValidClientState | ErrorState;
|
||||
|
||||
export type ValidClientState = {
|
||||
state: "valid";
|
||||
oidcClientConfig: OidcClientConfig | null,
|
||||
authenticated?: AuthenticatedClient;
|
||||
// 'Disconnected' rather than 'connected' because it tracks specifically
|
||||
// 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(): {
|
||||
client?: MatrixClient;
|
||||
setClient?: (client: MatrixClient, session: Session) => void;
|
||||
oidcClientConfig: OidcClientConfig | null | undefined;
|
||||
} {
|
||||
let client;
|
||||
let setClient;
|
||||
let oidcClientConfig;
|
||||
|
||||
const clientState = useClientState();
|
||||
if (clientState?.state === "valid") {
|
||||
client = clientState.authenticated?.client;
|
||||
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.
|
||||
@@ -140,6 +145,12 @@ interface Props {
|
||||
export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
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
|
||||
const [initClientState, setInitClientState] = useState<
|
||||
InitResult | null | undefined
|
||||
@@ -153,6 +164,11 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
if (initializing.current) return;
|
||||
initializing.current = true;
|
||||
|
||||
if (!widget) {
|
||||
// TODO: spec says this may change over time & should be refreshed upon cache expiry
|
||||
getAuthMetadata().then(setOidcClientConfig, setOidcErr);
|
||||
}
|
||||
|
||||
loadClient()
|
||||
.then((initResult) => {
|
||||
setInitClientState(initResult);
|
||||
@@ -251,12 +267,18 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
const [supportsReactions, setSupportsReactions] = useState(false);
|
||||
const [supportsThumbnails, setSupportsThumbnails] = useState(false);
|
||||
|
||||
const state: ClientState | undefined = useMemo(() => {
|
||||
if (alreadyOpenedErr) {
|
||||
return { state: "error", error: alreadyOpenedErr };
|
||||
const state = useMemo((): ClientState | undefined => {
|
||||
const error = alreadyOpenedErr || oidcErr;
|
||||
if (error) {
|
||||
return { state: "error", error };
|
||||
}
|
||||
|
||||
if (initClientState === undefined) return undefined;
|
||||
if (
|
||||
initClientState === undefined ||
|
||||
oidcClientConfig === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const authenticated =
|
||||
initClientState === null
|
||||
@@ -270,6 +292,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
|
||||
return {
|
||||
state: "valid",
|
||||
oidcClientConfig,
|
||||
authenticated,
|
||||
setClient,
|
||||
disconnected: isDisconnected,
|
||||
@@ -280,6 +303,8 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
};
|
||||
}, [
|
||||
alreadyOpenedErr,
|
||||
oidcErr,
|
||||
oidcClientConfig,
|
||||
changePassword,
|
||||
initClientState,
|
||||
logout,
|
||||
@@ -345,8 +370,9 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
};
|
||||
}, [initClientState, onSync]);
|
||||
|
||||
if (alreadyOpenedErr) {
|
||||
return <ErrorPage widget={widget} error={alreadyOpenedErr} />;
|
||||
const error = alreadyOpenedErr || oidcErr;
|
||||
if (error) {
|
||||
return <ErrorPage widget={widget} error={error} />;
|
||||
}
|
||||
|
||||
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 { 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 { ErrorView } from "./ErrorView";
|
||||
@@ -51,3 +51,29 @@ export class OpenElsewhereError extends RichError {
|
||||
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
|
||||
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 { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
@@ -19,13 +19,14 @@ import { usePageTitle } from "../usePageTitle";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { Config } from "../config/Config";
|
||||
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 = () => {
|
||||
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);
|
||||
@@ -36,19 +37,23 @@ export const LoginPage: FC = () => {
|
||||
|
||||
// TODO: Handle hitting login page with authenticated client
|
||||
|
||||
const onSubmitLoginForm = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
let node: ReactElement;
|
||||
const { client, setClient, oidcClientConfig } = useClient();
|
||||
if (oidcClientConfig === null) {
|
||||
const login = useInteractiveLogin(client);
|
||||
const onSubmitLoginForm = useCallback(
|
||||
async (e: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
if (!homeserver || !usernameRef.current || !passwordRef.current) {
|
||||
setError(Error("Login parameters are undefined"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!homeserver) {
|
||||
setError(Error("Login parameters are undefined"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
login(homeserver, usernameRef.current.value, passwordRef.current.value)
|
||||
.then(async ([client, session]) => {
|
||||
try {
|
||||
const [client, session] = await login(homeserver, usernameRef.current?.value ?? "", passwordRef.current?.value ?? "");
|
||||
if (!setClient) {
|
||||
return;
|
||||
}
|
||||
@@ -66,20 +71,84 @@ export const LoginPage: FC = () => {
|
||||
await navigate("/");
|
||||
}
|
||||
PosthogAnalytics.instance.eventLogin.track();
|
||||
})
|
||||
.catch((error) => {
|
||||
} catch (error: any) {
|
||||
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;
|
||||
}
|
||||
},
|
||||
[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;
|
||||
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 (
|
||||
<>
|
||||
<div className={styles.container}>
|
||||
@@ -89,44 +158,7 @@ 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} />
|
||||
</FieldRow>
|
||||
)}
|
||||
<FieldRow>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
data-testid="login_login"
|
||||
>
|
||||
{loading ? t("logging_in") : t("login_title")}
|
||||
</Button>
|
||||
</FieldRow>
|
||||
</form>
|
||||
{node}
|
||||
</div>
|
||||
<div className={styles.authLinks}>
|
||||
<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
|
||||
Please see LICENSE in the repository root for full details.
|
||||
@@ -151,6 +151,25 @@ export interface ConfigOptions {
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -75,6 +75,7 @@ function renderWithMockClient(
|
||||
<ClientContextProvider
|
||||
value={{
|
||||
state: "valid",
|
||||
oidcClientConfig: null,
|
||||
disconnected: false,
|
||||
supportedFeatures: {
|
||||
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