From 2b3ba9c82ec1fd12614797fd0569596c93f2f51a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 29 Jun 2026 14:43:36 +0100 Subject: [PATCH 01/14] Adapt OAuth2 implementation to Matrix Spec v1.18 --- .../playwright/e2e/launch/oidc.spec.ts | 4 +- apps/web/.eslintrc.cjs | 6 - apps/web/package.json | 1 - apps/web/src/BasePlatform.ts | 35 ++- apps/web/src/IConfigOptions.ts | 7 +- apps/web/src/Lifecycle.ts | 167 +++++------ apps/web/src/Login.ts | 52 ++-- apps/web/src/MatrixClientPeg.ts | 39 ++- .../src/components/structures/MatrixChat.tsx | 9 +- .../src/components/structures/auth/Login.tsx | 10 +- .../structures/auth/Registration.tsx | 20 +- .../components/structures/auth/SoftLogout.tsx | 6 +- .../src/components/views/auth/LoginWithQR.tsx | 42 +-- .../components/views/elements/SSOButtons.tsx | 4 +- .../views/settings/devices/DeviceDetails.tsx | 2 +- .../tabs/user/AccountUserSettingsTab.tsx | 10 +- .../settings/tabs/user/SessionManagerTab.tsx | 27 +- .../components/views/spaces/SpacePanel.tsx | 7 +- apps/web/src/contexts/SDKContext.ts | 18 +- apps/web/src/stores/oidc/OidcClientStore.ts | 183 ------------ apps/web/src/utils/AutoDiscoveryUtils.tsx | 4 +- apps/web/src/utils/ValidatedServerConfig.ts | 6 +- .../src/utils/{oidc => oauth}/authorize.ts | 92 +++---- apps/web/src/utils/{oidc => oauth}/error.ts | 11 +- .../isUserRegistrationSupported.ts | 4 +- .../utils/oauth/persistOAuthSettings.test.ts | 42 +++ .../src/utils/oauth/persistOAuthSettings.ts | 83 ++++++ .../utils/{oidc => oauth}/registerClient.ts | 6 +- .../src/utils/{oidc => oauth}/urls.test.ts | 0 apps/web/src/utils/{oidc => oauth}/urls.ts | 0 apps/web/src/utils/oidc/TokenRefresher.ts | 36 --- .../utils/oidc/persistOidcSettings.test.ts | 116 -------- .../web/src/utils/oidc/persistOidcSettings.ts | 85 ------ apps/web/src/utils/tokens/tokens.ts | 37 +-- apps/web/src/vector/app.tsx | 6 +- .../src/vector/platform/ElectronPlatform.tsx | 16 +- apps/web/src/vector/url_utils.test.ts | 13 +- apps/web/src/vector/url_utils.ts | 9 +- apps/web/test/test-utils/{oidc.ts => auth.ts} | 2 +- apps/web/test/test-utils/test-utils.ts | 4 +- apps/web/test/unit-tests/Lifecycle-test.ts | 87 +++--- .../test/unit-tests/MatrixClientPeg-test.ts | 20 +- .../components/structures/MatrixChat-test.tsx | 120 ++++---- .../components/structures/auth/Login-test.tsx | 22 +- .../structures/auth/Registration-test.tsx | 28 +- .../views/dialogs/UserSettingsDialog-test.tsx | 4 +- .../settings/devices/LoginWithQR-test.tsx | 23 +- .../tabs/user/AccountUserSettingsTab-test.tsx | 19 +- .../tabs/user/SessionManagerTab-test.tsx | 21 +- .../unit-tests/contexts/SdkContext-test.ts | 12 - .../stores/oidc/OidcClientStore-test.ts | 260 ------------------ .../utils/AutoDiscoveryUtils-test.tsx | 228 ++++++++------- .../utils/{oidc => oauth}/authorize-test.ts | 73 ++--- .../{oidc => oauth}/registerClient-test.ts | 40 +-- .../utils/oidc/TokenRefresher-test.ts | 88 ------ apps/web/test/unit-tests/vector/app-test.ts | 15 +- .../vector/platform/ElectronPlatform-test.ts | 2 +- .../vector/platform/WebPlatform-test.ts | 4 +- apps/web/webpack.config.ts | 1 - docs/.vitepress/config.ts | 2 +- docs/config.md | 4 +- docs/{oidc.md => oauth.md} | 10 +- pnpm-lock.yaml | 7 +- pnpm-workspace.yaml | 2 - 64 files changed, 719 insertions(+), 1594 deletions(-) delete mode 100644 apps/web/src/stores/oidc/OidcClientStore.ts rename apps/web/src/utils/{oidc => oauth}/authorize.ts (52%) rename apps/web/src/utils/{oidc => oauth}/error.ts (73%) rename apps/web/src/utils/{oidc => oauth}/isUserRegistrationSupported.ts (87%) create mode 100644 apps/web/src/utils/oauth/persistOAuthSettings.test.ts create mode 100644 apps/web/src/utils/oauth/persistOAuthSettings.ts rename apps/web/src/utils/{oidc => oauth}/registerClient.ts (87%) rename apps/web/src/utils/{oidc => oauth}/urls.test.ts (100%) rename apps/web/src/utils/{oidc => oauth}/urls.ts (100%) delete mode 100644 apps/web/src/utils/oidc/TokenRefresher.ts delete mode 100644 apps/web/src/utils/oidc/persistOidcSettings.test.ts delete mode 100644 apps/web/src/utils/oidc/persistOidcSettings.ts rename apps/web/test/test-utils/{oidc.ts => auth.ts} (71%) delete mode 100644 apps/web/test/unit-tests/stores/oidc/OidcClientStore-test.ts rename apps/web/test/unit-tests/utils/{oidc => oauth}/authorize-test.ts (64%) rename apps/web/test/unit-tests/utils/{oidc => oauth}/registerClient-test.ts (65%) delete mode 100644 apps/web/test/unit-tests/utils/oidc/TokenRefresher-test.ts rename docs/{oidc.md => oauth.md} (52%) diff --git a/apps/desktop/playwright/e2e/launch/oidc.spec.ts b/apps/desktop/playwright/e2e/launch/oidc.spec.ts index c741217a582..f366dd2e5ef 100644 --- a/apps/desktop/playwright/e2e/launch/oidc.spec.ts +++ b/apps/desktop/playwright/e2e/launch/oidc.spec.ts @@ -9,7 +9,7 @@ import { test, expect } from "../../element-desktop-test.js"; declare global { interface ElectronPlatform { - getOidcCallbackUrl(): URL; + getOAuthCallbackUrl(): URL; } interface Window { @@ -29,7 +29,7 @@ test.describe("OIDC Native", () => { test("should use OIDC callback URL without authority component", async ({ page }) => { await expect( page.evaluate(() => { - return window.mxPlatformPeg.get().getOidcCallbackUrl().toString(); + return window.mxPlatformPeg.get().getOAuthCallbackUrl().toString(); }), ).resolves.toMatch(/io\.element\.(desktop|nightly):\/vector\/webapp\//); }); diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs index c8265edfd51..7e9b34dac3e 100644 --- a/apps/web/.eslintrc.cjs +++ b/apps/web/.eslintrc.cjs @@ -138,12 +138,6 @@ module.exports = { "!matrix-js-sdk/src/extensible_events_v1/PollResponseEvent", "!matrix-js-sdk/src/extensible_events_v1/PollEndEvent", "!matrix-js-sdk/src/extensible_events_v1/InvalidEventError", - "!matrix-js-sdk/src/oidc", - "!matrix-js-sdk/src/oidc/discovery", - "!matrix-js-sdk/src/oidc/authorize", - "!matrix-js-sdk/src/oidc/validate", - "!matrix-js-sdk/src/oidc/error", - "!matrix-js-sdk/src/oidc/register", "!matrix-js-sdk/src/webrtc", "!matrix-js-sdk/src/webrtc/call", "!matrix-js-sdk/src/webrtc/callFeed", diff --git a/apps/web/package.json b/apps/web/package.json index 2cce02de56b..1cf46a4df4c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -87,7 +87,6 @@ "matrix-widget-api": "^1.16.1", "memoize-one": "^6.0.0", "mime": "^4.0.4", - "oidc-client-ts": "^3.0.1", "opus-recorder": "^8.0.3", "pako": "^2.0.3", "png-chunks-extract": "^1.0.0", diff --git a/apps/web/src/BasePlatform.ts b/apps/web/src/BasePlatform.ts index 8f2ee414446..f95767fa00b 100644 --- a/apps/web/src/BasePlatform.ts +++ b/apps/web/src/BasePlatform.ts @@ -15,7 +15,7 @@ import { type Room, type SSOAction, encodeUnpaddedBase64, - type OidcRegistrationClientMetadata, + type OAuthRegistrationRequest, MatrixEventEvent, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; @@ -431,42 +431,41 @@ export default abstract class BasePlatform { } /** - * Fallback Client URI to use for OIDC client registration for if one is not specified in config.json + * Fallback Client URI to use for OAuth2 client registration for if one is not specified in config.json */ - public get defaultOidcClientUri(): string { + public get defaultOAuthClientUri(): string { return window.location.origin; } /** - * Metadata to use for dynamic OIDC client registrations + * Metadata to use for dynamic OAuth2 client registrations */ - public async getOidcClientMetadata(): Promise { + public async getOAuthClientMetadata(): Promise { const config = SdkConfig.get(); return { - clientName: config.brand, - clientUri: config.oidc_metadata?.client_uri ?? this.defaultOidcClientUri, - redirectUris: [this.getOidcCallbackUrl().href], - logoUri: config.oidc_metadata?.logo_uri ?? new URL("vector-icons/1024.png", this.baseUrl).href, - applicationType: "web", - contacts: config.oidc_metadata?.contacts, - tosUri: config.oidc_metadata?.tos_uri ?? config.terms_and_conditions_links?.[0]?.url, - policyUri: config.oidc_metadata?.policy_uri ?? config.privacy_policy_url, + client_name: config.brand, + client_uri: config.oidc_metadata?.client_uri ?? this.defaultOAuthClientUri, + redirect_uris: [this.getOAuthCallbackUrl().href], + logo_uri: config.oidc_metadata?.logo_uri ?? new URL("vector-icons/1024.png", this.baseUrl).href, + application_type: "web", + tos_uri: config.oidc_metadata?.tos_uri ?? config.terms_and_conditions_links?.[0]?.url, + policy_uri: config.oidc_metadata?.policy_uri ?? config.privacy_policy_url, }; } /** - * Suffix to append to the `state` parameter of OIDC /auth calls. Will be round-tripped to the callback URI. + * Suffix to append to the `state` parameter of OAuth2 /auth calls. Will be round-tripped to the callback URI. * Currently only required for ElectronPlatform for passing element-desktop-ssoid. */ - public getOidcClientState(): string { + public getOAuthClientState(): string { return ""; } /** - * The URL to return to after a successful OIDC authentication + * The URL to return to after a successful OAuth2 authentication */ - public getOidcCallbackUrl(): URL { - // The redirect URL has to exactly match that registered at the OIDC server, so + public getOAuthCallbackUrl(): URL { + // The redirect URL has to exactly match that registered at the OAuth2 server, so // build it from scratch to avoid leaking ephemeral query params (e.g. `updated`). const url = new URL(window.location.origin + window.location.pathname); // Set no_universal_links=true to prevent the callback being handled by Element X installed on macOS Apple Silicon diff --git a/apps/web/src/IConfigOptions.ts b/apps/web/src/IConfigOptions.ts index a3a6a32c91d..99151f03e79 100644 --- a/apps/web/src/IConfigOptions.ts +++ b/apps/web/src/IConfigOptions.ts @@ -194,24 +194,25 @@ export interface IConfigOptions { }; /** - * Configuration for OIDC issuers where a static client_id has been issued for the app. + * Configuration for OAuth2 issuers where a static client_id has been issued for the app. * Otherwise dynamic client registration is attempted. * The issuer URL must have a trailing `/`. * OPTIONAL + * Refers to `oidc` only for legacy compatibility */ oidc_static_clients?: { [issuer: string]: { client_id: string }; }; /** - * Configuration for OIDC dynamic registration where a static OIDC client is not configured. + * Configuration for OAuth2 dynamic registration where a static OAuth2 client is not configured. + * Refers to `oidc` only for legacy compatibility */ oidc_metadata?: { client_uri?: string; logo_uri?: string; tos_uri?: string; policy_uri?: string; - contacts?: string[]; }; modules?: string[]; diff --git a/apps/web/src/Lifecycle.ts b/apps/web/src/Lifecycle.ts index 14d6fc8024e..2ac918db3fe 100644 --- a/apps/web/src/Lifecycle.ts +++ b/apps/web/src/Lifecycle.ts @@ -10,13 +10,7 @@ Please see LICENSE files in the repository root for full details. */ import { type ReactNode } from "react"; -import { - createClient, - type MatrixClient, - SSOAction, - type OidcTokenRefresher, - decodeBase64, -} from "matrix-js-sdk/src/matrix"; +import { MatrixClient, OAuth2, createClient, SSOAction, decodeBase64 } from "matrix-js-sdk/src/matrix"; import { type AESEncryptedSecretStoragePayload } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; @@ -58,29 +52,26 @@ import { Action } from "./dispatcher/actions"; import { type OverwriteLoginPayload } from "./dispatcher/payloads/OverwriteLoginPayload"; import { SdkContextClass } from "./contexts/SDKContext"; import { messageForLoginError } from "./utils/ErrorUtils"; -import { completeOidcLogin, type CompleteOidcLoginResponse } from "./utils/oidc/authorize"; -import { getOidcErrorMessage } from "./utils/oidc/error"; -import { type OidcClientStore } from "./stores/oidc/OidcClientStore"; +import { completeOidcLogin, type CompleteOidcLoginResponse } from "./utils/oauth/authorize"; +import { getOidcErrorMessage } from "./utils/oauth/error"; import { + getOAuthParams, getStoredOidcClientId, - getStoredOidcIdTokenClaims, - getStoredOidcTokenIssuer, persistOidcAuthenticatedSettings, -} from "./utils/oidc/persistOidcSettings"; +} from "./utils/oauth/persistOAuthSettings"; import { ACCESS_TOKEN_IV, ACCESS_TOKEN_STORAGE_KEY, HAS_ACCESS_TOKEN_STORAGE_KEY, HAS_REFRESH_TOKEN_STORAGE_KEY, - persistAccessTokenInStorage, - persistRefreshTokenInStorage, + persistTokens, REFRESH_TOKEN_IV, REFRESH_TOKEN_STORAGE_KEY, tryDecryptToken, } from "./utils/tokens/tokens"; -import { TokenRefresher } from "./utils/oidc/TokenRefresher"; import { checkBrowserSupport } from "./SupportedBrowser"; import { type URLParams } from "./vector/url_utils.ts"; +import { filterBoolean } from "./utils/arrays.ts"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -264,7 +255,7 @@ export async function getStoredSessionOwner(): Promise<[string, boolean] | [null } /** - * If query string includes OIDC authorization code flow parameters attempt to login using oidc flow + * If query string includes OAuth2 authorization code flow parameters attempt to login using oauth flow * Else, we may be returning from SSO - attempt token login * * @param urlParams the parameters read in at app load time from the url @@ -280,30 +271,24 @@ export async function attemptDelegatedAuthLogin( defaultDeviceDisplayName?: string, fragmentAfterLogin?: string, ): Promise { - if (urlParams.oidc_fragment) { - return attemptOidcNativeLogin(urlParams.oidc_fragment, "fragment"); - } else if (urlParams.oidc_query) { - return attemptOidcNativeLogin(urlParams.oidc_query, "query"); + if (urlParams.oauth2) { + return attemptOidcNativeLogin(urlParams.oauth2); } return attemptTokenLogin(urlParams["legacy_sso"], defaultDeviceDisplayName, fragmentAfterLogin); } /** - * Attempt to login by completing OIDC authorization code flow - * @param urlParams subset of app-load url parameters relating to oidc auth - * @param responseMode - the response_mode used in the auth request + * Attempt to login by completing OAuth2 authorization code flow + * @param urlParams subset of app-load url parameters relating to oauth auth * @returns Promise that resolves to true when login succeeded, else false */ -async function attemptOidcNativeLogin( - urlParams: NonNullable, - responseMode: "fragment" | "query", -): Promise { - console.log("We have OIDC params - attempting OIDC login"); +async function attemptOidcNativeLogin(urlParams: NonNullable): Promise { + console.log("We have OAuth2 params - attempting login"); try { - const { accessToken, refreshToken, homeserverUrl, identityServerUrl, idToken, clientId, issuer } = - await completeOidcLogin(urlParams, responseMode); + const { accessToken, refreshToken, homeserverUrl, identityServerUrl, clientId } = + await completeOidcLogin(urlParams); await configureFromCompletedOAuthLogin({ accessToken, @@ -311,13 +296,11 @@ async function attemptOidcNativeLogin( homeserverUrl, identityServerUrl, clientId, - issuer, - idToken, }); return true; } catch (error) { - logger.error("Failed to login via OIDC", error); + logger.error("Failed to login via OAuth", error); onFailedDelegatedAuthLogin(getOidcErrorMessage(error as Error)); return false; @@ -325,8 +308,8 @@ async function attemptOidcNativeLogin( } /** - * Exchange the given OIDC credentials for {@link IMatrixClientCreds}, additionally persisting them to storage. - * @param creds the credentials from the OIDC flow + * Exchange the given OAuth2 credentials for {@link IMatrixClientCreds}, additionally persisting them to storage. + * @param creds the credentials from the OAuth2 flow */ export async function configureFromCompletedOAuthLogin({ accessToken, @@ -334,9 +317,7 @@ export async function configureFromCompletedOAuthLogin({ homeserverUrl, identityServerUrl, clientId, - issuer, - idToken, -}: Omit): Promise { +}: CompleteOidcLoginResponse): Promise { const { user_id: userId, device_id: deviceId, @@ -353,10 +334,10 @@ export async function configureFromCompletedOAuthLogin({ isGuest, }; - logger.debug("Logged in via OIDC native flow"); + logger.debug("Logged in via OAuth2 native flow"); await onSuccessfulDelegatedAuthLogin(credentials); // this needs to happen after success handler which clears storages - persistOidcAuthenticatedSettings(clientId, issuer, idToken); + persistOidcAuthenticatedSettings(clientId); return credentials; } @@ -479,7 +460,7 @@ async function loadOrCreatePickleKey(credentials: IMatrixClientCreds): Promise void; /** - * Display a friendly error to the user when token login or OIDC authorization fails + * Display a friendly error to the user when token login or OAuth2 authorization fails * @param description error description * @param tryAgain OPTIONAL function to call on try again button from error dialog */ @@ -731,9 +712,9 @@ async function handleLoadSessionFailure(e: unknown, loadSessionOpts?: ILoadSessi * Also stops the old MatrixClient and clears old credentials/etc out of * storage before starting the new client. * - * This function does not work for OIDC login. + * This function does not work for OAuth2 login. * Storage is cleared early in the process so the required data is lost. - * You must use {@link attemptDelegatedAuthLogin} followed by {@link restoreSessionFromStorage} for OIDC login. + * You must use {@link attemptDelegatedAuthLogin} followed by {@link restoreSessionFromStorage} for OAuth2 login. * * @param {IMatrixClientCreds} credentials The credentials to use * @@ -783,44 +764,6 @@ export async function hydrateSession(credentials: IMatrixClientCreds): Promise { - if (!credentials.refreshToken) { - throw new Error("A refresh token must be supplied in order to create an OIDC token refresher."); - } - // stored token issuer indicates we authenticated via OIDC-native flow - const tokenIssuer = getStoredOidcTokenIssuer(); - if (!tokenIssuer) { - throw new Error("Cannot create an OIDC token refresher as no stored OIDC token issuer was found."); - } - - const idTokenClaims = getStoredOidcIdTokenClaims(); - const redirectUri = PlatformPeg.get()!.getOidcCallbackUrl().href; - const deviceId = credentials.deviceId; - if (!deviceId) { - throw new Error("Expected deviceId in user credentials."); - } - const tokenRefresher = new TokenRefresher( - tokenIssuer, - clientId, - redirectUri, - deviceId, - idTokenClaims!, - credentials.userId, - ); - return tokenRefresher; -} - /** * optionally clears localstorage, persists new credentials * to localstorage, starts the new client. @@ -870,21 +813,14 @@ async function doSetLoggedIn( await abortLogin(); } - let storedClientid; + let auth: OAuth2 | undefined; try { - storedClientid = getStoredOidcClientId(); + auth = await hydrateAuth(); } catch {} - let tokenRefresher; - if (credentials.refreshToken && storedClientid) { - tokenRefresher = await createOidcTokenRefresher(credentials, storedClientid); - } else { - logger.debug("No refresh token was supplied: access token will not be refreshed"); - } - // check the session lock just before creating the new client checkSessionLock(); - MatrixClientPeg.replaceUsingCreds(credentials, tokenRefresher?.doRefreshAccessToken.bind(tokenRefresher)); + MatrixClientPeg.replaceUsingCreds(credentials, auth ?? null); const client = MatrixClientPeg.safeGet(); setSentryUser(credentials.userId); @@ -957,8 +893,7 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise { - if (oidcClientStore?.isUserAuthenticatedWithOidc) { - const accessToken = client.getAccessToken() ?? undefined; - const refreshToken = client.getRefreshToken() ?? undefined; +async function doLogout(client: MatrixClient, oauth: OAuth2 | null): Promise { + if (oauth) { + const accessToken = client.getAccessToken(); + const refreshToken = client.getRefreshToken(); + + await Promise.all( + filterBoolean([ + accessToken ? oauth.revokeToken(accessToken, "access_token") : null, + refreshToken ? oauth.revokeToken(refreshToken, "refresh_token") : null, + ]), + ); - await oidcClientStore.revokeTokens(accessToken, refreshToken); + client.stopClient(); + client.http.abort(); } else { await client.logout(true); } @@ -1004,10 +947,10 @@ async function doLogout(client: MatrixClient, oidcClientStore?: OidcClientStore) /** * Logs the current session out and transitions to the logged-out state - * @param oidcClientStore store instance from SDKContext */ -export function logout(oidcClientStore?: OidcClientStore): void { +export function logout(): void { const client = MatrixClientPeg.get(); + const oauth = MatrixClientPeg.oauth; if (!client) return; PosthogAnalytics.instance.logout(); @@ -1023,7 +966,7 @@ export function logout(oidcClientStore?: OidcClientStore): void { _isLoggingOut = true; PlatformPeg.get()?.destroyPickleKey(client.getSafeUserId(), client.getDeviceId() ?? ""); - doLogout(client, oidcClientStore).then(onLoggedOut, (err) => { + doLogout(client, oauth).then(onLoggedOut, (err) => { // Just throwing an error here is going to be very unhelpful // if you're trying to log out because your server's down and // you want to log into a different server, so just forget the @@ -1270,3 +1213,17 @@ window.mxLoginWithAccessToken = async (hsUrl: string, accessToken: string): Prom false, ); }; + +/** + * Instantiate an OAuth2 instance from storage + * Returned promise will reject if the session or the server are not OAuth2-native. + */ +async function hydrateAuth(): Promise { + const storedClientId = getStoredOidcClientId(); + const homeserverUrl = localStorage.getItem(HOMESERVER_URL_KEY)!; + + const tempClient = new MatrixClient({ baseUrl: homeserverUrl }); + const authMetadata = await tempClient.getAuthMetadata(); + + return new OAuth2(authMetadata, getOAuthParams(storedClientId)); +} diff --git a/apps/web/src/Login.ts b/apps/web/src/Login.ts index 34b6513ad1f..b459929eed3 100644 --- a/apps/web/src/Login.ts +++ b/apps/web/src/Login.ts @@ -11,43 +11,43 @@ import { createClient, type MatrixClient, type LoginFlow, - DELEGATED_OIDC_COMPATIBILITY, + OAUTH_AWARE_PREFERRED_FLOW_FIELD, type ILoginFlow, type LoginRequest, - type OidcClientConfig, + type ValidatedAuthMetadata, type ISSOFlow, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { type IMatrixClientCreds } from "./MatrixClientPeg"; import { ModuleRunner } from "./modules/ModuleRunner"; -import { getOidcClientId } from "./utils/oidc/registerClient"; +import { getOidcClientId } from "./utils/oauth/registerClient"; import { type IConfigOptions } from "./IConfigOptions"; import SdkConfig from "./SdkConfig"; -import { isUserRegistrationSupported } from "./utils/oidc/isUserRegistrationSupported"; +import { isUserRegistrationSupported } from "./utils/oauth/isUserRegistrationSupported"; /** * Login flows supported by this client * LoginFlow type use the client API /login endpoint * OidcNativeFlow is specific to this client */ -export type ClientLoginFlow = LoginFlow | OidcNativeFlow; +export type ClientLoginFlow = LoginFlow | OAuthNativeFlow; interface ILoginOptions { defaultDeviceDisplayName?: string; /** * Delegated auth config from server's .well-known. * - * If this property is set, we will attempt an OIDC login using the delegated auth settings. - * The caller is responsible for checking that OIDC is enabled in the labs settings. + * If this property is set, we will attempt an OAuth2 login using the delegated auth settings. + * The caller is responsible for checking that OAuth2 is enabled in the labs settings. */ - delegatedAuthentication?: OidcClientConfig; + delegatedAuthentication?: ValidatedAuthMetadata; } export default class Login { private flows: Array = []; private readonly defaultDeviceDisplayName?: string; - private delegatedAuthentication?: OidcClientConfig; + private delegatedAuthentication?: ValidatedAuthMetadata; private tempClient: MatrixClient | null = null; // memoize public constructor( @@ -82,7 +82,7 @@ export default class Login { * Set delegated authentication config, clears tempClient. * @param delegatedAuthentication delegated auth config, from ValidatedServerConfig */ - public setDelegatedAuthentication(delegatedAuthentication?: OidcClientConfig): void { + public setDelegatedAuthentication(delegatedAuthentication?: ValidatedAuthMetadata): void { this.tempClient = null; // clear memoization this.delegatedAuthentication = delegatedAuthentication; } @@ -108,7 +108,7 @@ export default class Login { * @returns Promise that resolves to supported login flows */ public async getFlows(isRegistration?: boolean): Promise> { - // try to use oidc native flow if we have delegated auth config + // try to use oauth2 native flow if we have delegated auth config if (this.delegatedAuthentication) { try { const oidcFlow = await tryInitOidcNativeFlow( @@ -118,19 +118,19 @@ export default class Login { ); return [oidcFlow]; } catch (error) { - logger.error("Failed to get oidc native flow", error); + logger.error("Failed to get OAuth2 native flow", error); } } - // oidc native flow not supported, continue with matrix login + // OAuth2 native flow not supported, continue with matrix login const client = this.createTemporaryClient(); const { flows }: { flows: LoginFlow[] } = await client.loginFlows(); - // If an m.login.sso flow is present which is also flagged as being for MSC3824 OIDC compatibility then we only + // If an m.login.sso flow is present which is also flagged as being for MSC3824 OAuth compatibility then we only // return that flow as (per MSC3824) it is the only one that the user should be offered to give the best experience - const oidcCompatibilityFlow = flows.find( - (f) => f.type === "m.login.sso" && DELEGATED_OIDC_COMPATIBILITY.findIn(f as ISSOFlow), + const oauthCompatibilityFlow = flows.find( + (f) => f.type === "m.login.sso" && OAUTH_AWARE_PREFERRED_FLOW_FIELD.findIn(f as ISSOFlow), ); - this.flows = oidcCompatibilityFlow ? [oidcCompatibilityFlow] : flows; + this.flows = oauthCompatibilityFlow ? [oauthCompatibilityFlow] : flows; return this.flows; } @@ -199,13 +199,13 @@ export default class Login { } /** - * Describes the OIDC native login flow + * Describes the OAuth2 native login flow * Separate from js-sdk's `LoginFlow` as this does not use the same /login flow * to which that type belongs. */ -export interface OidcNativeFlow extends ILoginFlow { - type: "oidcNativeFlow"; - // this client's id as registered with the configured OIDC OP +export interface OAuthNativeFlow extends ILoginFlow { + type: "oauthNativeFlow"; + // this client's id as registered with the configured OAuth2 OP clientId: string; } /** @@ -217,14 +217,14 @@ export interface OidcNativeFlow extends ILoginFlow { * @param delegatedAuthConfig Auth config from ValidatedServerConfig * @param staticOidcClientIds static client config from config.json, used during client registration with OP * @param isRegistration true when we are attempting registration - * @returns Promise when oidc native authentication flow is supported and correctly configured + * @returns Promise when oauth native authentication flow is supported and correctly configured * @throws when client can't register with OP, or any unexpected error */ const tryInitOidcNativeFlow = async ( - delegatedAuthConfig: OidcClientConfig, + delegatedAuthConfig: ValidatedAuthMetadata, staticOidcClientIds?: IConfigOptions["oidc_static_clients"], isRegistration?: boolean, -): Promise => { +): Promise => { // if registration is not supported, bail before attempting to get the clientId if (isRegistration && !isUserRegistrationSupported(delegatedAuthConfig)) { throw new Error("Registration is not supported by OP"); @@ -232,9 +232,9 @@ const tryInitOidcNativeFlow = async ( const clientId = await getOidcClientId(delegatedAuthConfig, staticOidcClientIds); const flow = { - type: "oidcNativeFlow", + type: "oauthNativeFlow", clientId, - } as OidcNativeFlow; + } as OAuthNativeFlow; return flow; }; diff --git a/apps/web/src/MatrixClientPeg.ts b/apps/web/src/MatrixClientPeg.ts index a25c733dc3c..ad17e03bb81 100644 --- a/apps/web/src/MatrixClientPeg.ts +++ b/apps/web/src/MatrixClientPeg.ts @@ -16,10 +16,11 @@ import { type IStartClientOpts, type MatrixClient, MemoryStore, + type OAuth2, PendingEventOrdering, type RoomNameState, RoomNameType, - type TokenRefreshFunction, + TokenRefresher, } from "matrix-js-sdk/src/matrix"; import { VerificationMethod } from "matrix-js-sdk/src/types"; import * as utils from "matrix-js-sdk/src/utils"; @@ -42,6 +43,7 @@ import { formatList } from "./utils/FormattingUtils"; import SdkConfig from "./SdkConfig"; import { setDeviceIsolationMode } from "./settings/controllers/DeviceIsolationModeController.ts"; import { initialiseDehydrationIfEnabled } from "./utils/device/dehydration"; +import { persistTokens } from "./utils/tokens/tokens.ts"; export interface IMatrixClientCreds { homeserverUrl: string; @@ -98,6 +100,12 @@ export interface IMatrixClientPeg { */ safeGet(): MatrixClient; + /** + * The Matrix JS SDK OAuth2 instance response for this session. + * Only present when a client is set and the session is OAuth2-native. + */ + get oauth(): OAuth2 | null; + /** * Unset the current MatrixClient */ @@ -147,11 +155,11 @@ export interface IMatrixClientPeg { * Replace this MatrixClientPeg's client with a client instance that has * homeserver / identity server URLs and active credentials * - * @param {IMatrixClientCreds} creds The new credentials to use. - * @param {TokenRefreshFunction} tokenRefreshFunction OPTIONAL function used by MatrixClient to attempt token refresh - * see {@link ICreateClientOpts.tokenRefreshFunction} + * @param creds The new credentials to use. + * @param oauth The OAuth2 object to use for token refreshes, or null if not applicable. + * Only specified for OAuth-native sessions. */ - replaceUsingCreds(creds: IMatrixClientCreds, tokenRefreshFunction?: TokenRefreshFunction): void; + replaceUsingCreds(creds: IMatrixClientCreds, oauth: OAuth2 | null): void; } /** @@ -169,9 +177,14 @@ class MatrixClientPegClass implements IMatrixClientPeg { initialSyncLimit: 20, }; + private oauth2: OAuth2 | null = null; private matrixClient: MatrixClient | null = null; private justRegisteredUserId: string | null = null; + public get oauth(): OAuth2 | null { + return this.oauth2; + } + public get(): MatrixClient | null { return this.matrixClient; } @@ -185,6 +198,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { public unset(): void { this.matrixClient = null; + this.oauth2 = null; MatrixActionCreators.stop(); } @@ -224,8 +238,8 @@ class MatrixClientPegClass implements IMatrixClientPeg { } } - public replaceUsingCreds(creds: IMatrixClientCreds, tokenRefreshFunction?: TokenRefreshFunction): void { - this.createClient(creds, tokenRefreshFunction); + public replaceUsingCreds(creds: IMatrixClientCreds, oauth: OAuth2 | null): void { + this.createClient(creds, oauth); } private onUnexpectedStoreClose = async (): Promise => { @@ -414,7 +428,15 @@ class MatrixClientPegClass implements IMatrixClientPeg { }); } - private createClient(creds: IMatrixClientCreds, tokenRefreshFunction?: TokenRefreshFunction): void { + private createClient(creds: IMatrixClientCreds, oauth: OAuth2 | null): void { + let tokenRefreshFunction: ICreateClientOpts["tokenRefreshFunction"]; + if (creds.refreshToken && oauth) { + const tokenRefresher = new TokenRefresher(oauth, persistTokens.bind(null, creds.pickleKey)); + tokenRefreshFunction = tokenRefresher?.tokenRefreshFunction; + } else { + logger.debug("No refresh token was supplied: access token will not be refreshed"); + } + const opts: ICreateClientOpts = { baseUrl: creds.homeserverUrl, idBaseUrl: creds.identityServerUrl, @@ -467,6 +489,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { this.matrixClient = createMatrixClient(opts); this.matrixClient.setGuest(Boolean(creds.guest)); + this.oauth2 = oauth; const notifTimelineSet = new EventTimelineSet(undefined, { timelineSupport: true, diff --git a/apps/web/src/components/structures/MatrixChat.tsx b/apps/web/src/components/structures/MatrixChat.tsx index 71094c91637..ecdd3921f4d 100644 --- a/apps/web/src/components/structures/MatrixChat.tsx +++ b/apps/web/src/components/structures/MatrixChat.tsx @@ -349,11 +349,7 @@ export default class MatrixChat extends React.PureComponent { ); // remove the loginToken or auth code from the URL regardless - if ( - !!this.props.urlParams.legacy_sso || - !!this.props.urlParams.oidc_fragment || - !!this.props.urlParams.oidc_query - ) { + if (!!this.props.urlParams.legacy_sso || !!this.props.urlParams.oauth2) { this.props.onTokenLoginCompleted(this.props.urlParams, this.getFragmentAfterLogin()); } @@ -705,7 +701,7 @@ export default class MatrixChat extends React.PureComponent { case "logout": LegacyCallHandler.instance.hangupAllCalls(); Promise.all([...[...CallStore.instance.connectedCalls].map((call) => call.disconnect())]).finally(() => - Lifecycle.logout(this.stores.oidcClientStore), + Lifecycle.logout(), ); break; case "require_registration": @@ -1547,6 +1543,7 @@ export default class MatrixChat extends React.PureComponent { */ private onLoggedIn(): void { this.stores.client = MatrixClientPeg.safeGet(); + this.stores.oauth = MatrixClientPeg.oauth ?? undefined; StorageManager.tryPersistStorage(); // If we're loading the app for the first time, we can now transition to a splash screen while we wait for the diff --git a/apps/web/src/components/structures/auth/Login.tsx b/apps/web/src/components/structures/auth/Login.tsx index dca3b30ee0f..b7b7e3f0cac 100644 --- a/apps/web/src/components/structures/auth/Login.tsx +++ b/apps/web/src/components/structures/auth/Login.tsx @@ -13,7 +13,7 @@ import { type SSOFlow, SSOAction } from "matrix-js-sdk/src/matrix"; import { Button } from "@vector-im/compound-web"; import { _t, UserFriendlyError } from "../../../languageHandler"; -import Login, { type ClientLoginFlow, type OidcNativeFlow } from "../../../Login"; +import Login, { type ClientLoginFlow, type OAuthNativeFlow } from "../../../Login"; import { messageForConnectionError, messageForLoginError } from "../../../utils/ErrorUtils"; import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import AuthPage from "../../views/auth/AuthPage"; @@ -31,7 +31,7 @@ import AuthHeader from "../../views/auth/AuthHeader"; import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton"; import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; import { filterBoolean } from "../../../utils/arrays"; -import { startOidcLogin } from "../../../utils/oidc/authorize"; +import { startOidcLogin } from "../../../utils/oauth/authorize"; import { ModuleApi } from "../../../modules/Api.ts"; interface IProps { @@ -122,7 +122,7 @@ class LoginComponent extends React.PureComponent { "m.login.cas": () => this.renderSsoStep("cas"), // eslint-disable-next-line @typescript-eslint/naming-convention "m.login.sso": () => this.renderSsoStep("sso"), - "oidcNativeFlow": () => this.renderOidcNativeStep(), + "oauthNativeFlow": () => this.renderOidcNativeStep(), }; } @@ -402,7 +402,7 @@ class LoginComponent extends React.PureComponent { if (!this.state.flows) return null; // this is the ideal order we want to show the flows in - const order = ["oidcNativeFlow", "m.login.password", "m.login.sso"]; + const order = ["oauthNativeFlow", "m.login.password", "m.login.sso"]; const flows = filterBoolean(order.map((type) => this.state.flows?.find((flow) => flow.type === type))); return ( @@ -436,7 +436,7 @@ class LoginComponent extends React.PureComponent { }; private renderOidcNativeStep = (): React.ReactNode => { - const flow = this.state.flows!.find((flow) => flow.type === "oidcNativeFlow")! as OidcNativeFlow; + const flow = this.state.flows!.find((flow) => flow.type === "oauthNativeFlow")! as OAuthNativeFlow; return (