mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-23 20:59:21 +00:00
Compare commits
2 Commits
hs/error-s
...
af/oidc-na
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e89c4ced3 | ||
|
|
e52083e29a |
@@ -209,6 +209,7 @@ A docker compose file `dev-backend-docker-compose.yml` is provided to start the
|
||||
whole stack of components which is required for a local development environment:
|
||||
|
||||
- Minimum Synapse Setup (servername: `synapse.m.localhost`)
|
||||
- Matrix Authentication Service Setup (issuer: `mas.m.localhost`)
|
||||
- MatrixRTC Authorization Service (Note requires Federation API and hence a TLS reverse proxy)
|
||||
- Minimum LiveKit SFU Setup using dev defaults for config
|
||||
- Redis db for completeness
|
||||
@@ -218,6 +219,7 @@ whole stack of components which is required for a local development environment:
|
||||
certificates
|
||||
- Minimum TLS reverse proxy for
|
||||
- Synapse homeserver: `synapse.m.localhost`
|
||||
- Matrix Authentication Service: `mas.m.localhost`
|
||||
- MatrixRTC backend: `matrix-rtc.m.localhost`
|
||||
- Local Element Call development `call.m.localhost` via `yarn dev --host `
|
||||
- Element Web `app.m.localhost`
|
||||
|
||||
@@ -12,6 +12,11 @@ listeners:
|
||||
- names: [client, federation, openid]
|
||||
compress: false
|
||||
|
||||
matrix_authentication_service:
|
||||
enabled: true
|
||||
secret: "mas-matrix-secret"
|
||||
endpoint: http://mas:8080
|
||||
|
||||
database:
|
||||
name: sqlite3
|
||||
args:
|
||||
@@ -46,9 +51,5 @@ rc_message:
|
||||
per_second: 0.5
|
||||
burst_count: 30
|
||||
|
||||
# Required for Element Call in Single Page Mode due to on-the-fly user registration
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
|
||||
report_stats: false
|
||||
serve_server_wellknown: true
|
||||
|
||||
96
backend/dev_mas.yaml
Normal file
96
backend/dev_mas.yaml
Normal file
@@ -0,0 +1,96 @@
|
||||
http:
|
||||
listeners:
|
||||
- name: web
|
||||
resources:
|
||||
- name: discovery
|
||||
- name: human
|
||||
- name: oauth
|
||||
- name: assets
|
||||
- name: adminapi
|
||||
binds:
|
||||
- address: '[::]:8080'
|
||||
proxy_protocol: false
|
||||
trusted_proxies:
|
||||
- 192.168.0.0/16
|
||||
- 172.16.0.0/12
|
||||
- 10.0.0.0/10
|
||||
- 127.0.0.1/8
|
||||
- fd00::/8
|
||||
- ::1/128
|
||||
public_base: https://mas.m.localhost/
|
||||
database:
|
||||
uri: postgres://postgres@mas-db/mas
|
||||
max_connections: 10
|
||||
min_connections: 0
|
||||
connect_timeout: 30
|
||||
idle_timeout: 600
|
||||
max_lifetime: 1800
|
||||
email:
|
||||
from: '"Authentication Service" <root@localhost>'
|
||||
reply_to: '"Authentication Service" <root@localhost>'
|
||||
transport: blackhole
|
||||
secrets:
|
||||
encryption: 91c9eda308d874d1b8ba51c0fe3b7cbb868638c8fbb82d7eec0e6912586bdabd
|
||||
keys:
|
||||
- kid: H30QE7M5eX
|
||||
key: |
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEogIBAAKCAQEA3+t9XjM8LhYMpagIKXnpTXVWxIo5zwM/R8wYRPg0MPGCrOUB
|
||||
i/L/Vof4yK7lIMWtCT724e989PLR4YmG2pXBpB8P7SZ3feggLoUMi8+QNzyKsGD+
|
||||
lYmZd0D/2aVmxBxK83JP3LLodmoHduva2qSBF9YdZ6Greg/IcEdG2UY3iGYeojsQ
|
||||
1Wx7V2+WPeUCtaIONBPk/rwOgWmzAhqGyCXONbvGazElNEuM0fwI278qveP5kNoh
|
||||
aL6HvlaYQbMSGAg1tf06AKOjsJG0CRsvsMdFxuG9GEwd4pJr9+v+OqwnaFJHJ70z
|
||||
kQFC3s/w+xCk4NnO+jSopBptu8ycwjZYMuq2kQIDAQABAoIBAHbDqFL2Sc0H1N1o
|
||||
KiwVhTCYM9U6mz65Mi8aiSTLoKL09aJONGvODrAOnl2SpeSj9AsbYkajh1tEDx3Y
|
||||
m7YECBjMgN3/sREOtUL3PphJFuy1J7o1N9KIkOU3jHwbxk3t07MbxlAAdFuaESt6
|
||||
HTIqXm4OGrqEfTbYeC9VHrbPD1VAFj/OGHsYDurJzhfIlFSlZWZqHNjdNh2HAOJM
|
||||
FElqJWqqR9fj2pYYdpo+oaheI/iIAuWpAgcZOJzaZ4iui4R9i4od+qqQ3EVECPvS
|
||||
/QnezvDpiobShG6WOmrRj3WBtheiPLdNlNB0sVW9h3dHcrkE/l2n5pfArVbHB+wg
|
||||
4e5FEAECgYEA4XjB6hpN1iX66ADf27L5mHymu1hTojZLoQdy76OcjgG+4ZoQl71U
|
||||
OAww4ek9I6Alz+aQqTAnRtLHBKH+xSuO2VoxQfimk71mUmByCr25vIU8mGtXRIJO
|
||||
rtWEVE4HQPhK2LODLm5zbp3I1GsrfNgMCsuA/yse5MIczC8bRFyf+DECgYEA/jzx
|
||||
ddl1asjArcFcQyKFJxdobNqYJ4P+rbDLIOmC9IQ8n/v+ETERCzj/y93yaXXqCBlR
|
||||
uHDzo72F/+SYDDWGYanpRmN2cUv1A0XTUs+dWYjfrscFJUEx8CZh0GeoLE4H3uru
|
||||
GlwqPnc9sMPee98mj4yDMyrNqLx/VaXV+wnpbGECgYAh4JoKSa9+SLCdYVxBT2/v
|
||||
OHN43LmcOto8NLlRRl0EfUCn9xUdJ4Za8YH6v6e/DZYA2dzMfv63xn2+tXRpPbU1
|
||||
9TZHekvVEPUp1XHtKTqaF87V+/LdyVJ3NH+whxTR7zyXuMkyFchkS3Lcb8nV9URB
|
||||
7vfP3zPCHWRkTYOkTuJ+UQKBgFCrj8ZgMOSoPJMlppvayTtFLypTFjJ7rIT6cwnH
|
||||
bnkduIrfD5fu5MSV2nyauT+DXbYiKo8GsBhFm849f41oMnKs0ks2Zi++9UiLkGlX
|
||||
XUs6phc0KUrP7AOSejkBmxgrzk2KZ/DPS8w0U8vR6reNcBPedwb2Tvl6jkDj9QjJ
|
||||
9VohAoGAISjlufqw3y8F/0on1AhqyROlJghTBsQ+xDEBZ9txx/HcKVghqMhBCbFj
|
||||
LRf8B4vH9QXtlZVtPFj0wE3INxsYtilsbD8wbwkxsLeGUFdPDucPacfBSyX+wLRh
|
||||
S1/twrPS9KVhkU5d0TbyfOlEB1OSXZTWZ1n9NaqOZUPf6FAwm78=
|
||||
-----END RSA PRIVATE KEY-----
|
||||
- kid: VrRd3Y2OeF
|
||||
key: |
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIB3zBxhuh275A3piMMDZ2BEM6vsoxswNLrTJRiaY+m80oAoGCCqGSM49
|
||||
AwEHoUQDQgAECqFotDpyEYNrWf2UZaUB0CZz6KiptQL2wi8oRNkKlarjgDDNCBzR
|
||||
dgCokx9C8bLpfqhTJE/6aSe6T19qkaPHIg==
|
||||
-----END EC PRIVATE KEY-----
|
||||
- kid: SHaCwxflXU
|
||||
key: |
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MIGkAgEBBDCdchyP7aFxQce7vA+QMPkMkOaYKbmNoN1fnlKviKsJK1riq+1eKSEe
|
||||
UeUF5BOczfugBwYFK4EEACKhZANiAAR1pIE4xN9xkULiCgMd/uztt4Lnu8FhvEZD
|
||||
3BhUfy5kdBVbYyk1khgKy3k+dQvXaTVkzsHkQN8K78WxlUDlF5zKXLjgkeEiqgz7
|
||||
HU0rr2e8geUiaEE2AkzWhvmIikvhuMo=
|
||||
-----END EC PRIVATE KEY-----
|
||||
- kid: ngjUaMfCuT
|
||||
key: |
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHQCAQEEIE11jnxjUvPk93ylMuIcwcayJsFUhsSH2EqAn97CiHf8oAcGBSuBBAAK
|
||||
oUQDQgAE1XySwFNBUkzZ946MBf2/3ecXVptrauZEQ8d8zqUdBS7wOe5pZwZ15Jx4
|
||||
aZhlusZ3BPl0KiTlWwOlaRDMrw9EGA==
|
||||
-----END EC PRIVATE KEY-----
|
||||
passwords:
|
||||
enabled: true
|
||||
schemes:
|
||||
- version: 1
|
||||
algorithm: argon2id
|
||||
minimum_complexity: 0
|
||||
matrix:
|
||||
kind: synapse
|
||||
homeserver: synapse.m.localhost
|
||||
secret: "mas-matrix-secret"
|
||||
endpoint: http://homeserver:8008/
|
||||
@@ -40,6 +40,23 @@ server {
|
||||
|
||||
}
|
||||
|
||||
# Matrix Authentication Server reverse proxy
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name mas.m.localhost;
|
||||
ssl_certificate /root/ssl/cert.pem;
|
||||
ssl_certificate_key /root/ssl/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass "http://mas:8080";
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
}
|
||||
|
||||
# MatrixRTC reverse proxy
|
||||
# - MatrixRTC Authorization Service
|
||||
# - LiveKit SFU websocket signaling connection
|
||||
|
||||
@@ -15,5 +15,8 @@
|
||||
"delayed_leave_event_delay_ms": 18000,
|
||||
"delayed_leave_event_restart_ms": 4000,
|
||||
"network_error_retry_ms": 100
|
||||
},
|
||||
"oidc_metadata": {
|
||||
"client_name": "Element Call (dev)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,31 @@ services:
|
||||
volumes:
|
||||
- ./backend/synapse_tmp:/data:Z
|
||||
- ./backend/dev_homeserver.yaml:/data/cfg/homeserver.yaml:Z
|
||||
depends_on:
|
||||
- mas
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
mas:
|
||||
# To add users, see `docker exec element-call-mas-1 mas-cli manage register-user -h`
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:latest
|
||||
pull_policy: always
|
||||
hostname: mas
|
||||
volumes:
|
||||
- ./backend/dev_mas.yaml:/config.yaml:ro,Z
|
||||
depends_on:
|
||||
- mas-db
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
mas-db:
|
||||
image: docker.io/postgres:16-alpine
|
||||
hostname: mas-db
|
||||
restart: always
|
||||
shm_size: 128mb
|
||||
environment:
|
||||
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||
- POSTGRES_DB=mas
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
@@ -101,7 +126,9 @@ services:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
- synapse
|
||||
- mas
|
||||
networks:
|
||||
ecbackend:
|
||||
aliases:
|
||||
- mas.m.localhost
|
||||
- matrix-rtc.m.localhost
|
||||
|
||||
@@ -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,23 @@ 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;
|
||||
logo_uri?: 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");
|
||||
}
|
||||
62
src/utils/oidc/registerClient.ts
Normal file
62
src/utils/oidc/registerClient.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
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,
|
||||
logoUri: config.oidc_metadata?.logo_uri,
|
||||
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