diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5ad62bee9cf..7ab63a339f2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -119,8 +119,15 @@ jobs: node-version: "lts/*" cache: "pnpm" - - name: Install Deps + - name: Install Deps (layered) + run: "./scripts/layered.sh" + if: matrix.path != 'packages/shared-components' + env: + JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} + + - name: Install Deps (normal) run: "pnpm install" + if: matrix.path == 'packages/shared-components' - name: Cache storybook & vitest uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 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 84c6d8dda9b..097c3c8ebd8 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 8f4b5b5030c..e9185fd8ae7 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": "^3.0.0", "png-chunks-extract": "^1.0.0", diff --git a/apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts b/apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts index dbb83c6a18d..78384197d86 100644 --- a/apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts +++ b/apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts @@ -92,8 +92,10 @@ test.describe("Account user settings tab", () => { authorization_endpoint: `${EXTERNAL_ACCOUNT_MANAGEMENT_URL}authorize`, token_endpoint: `${EXTERNAL_ACCOUNT_MANAGEMENT_URL}token`, revocation_endpoint: `${EXTERNAL_ACCOUNT_MANAGEMENT_URL}revoke`, + registration_endpoint: `${EXTERNAL_ACCOUNT_MANAGEMENT_URL}register`, response_types_supported: ["code"], - grant_types_supported: ["authorization_code"], + grant_types_supported: ["authorization_code", "refresh_token"], + response_modes_supported: ["query", "fragment"], code_challenge_methods_supported: ["S256"], account_management_uri: EXTERNAL_ACCOUNT_MANAGEMENT_URL, }, 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/Lifecycle.test.ts b/apps/web/src/Lifecycle.test.ts index ab0d05154e8..78121c9295c 100644 --- a/apps/web/src/Lifecycle.test.ts +++ b/apps/web/src/Lifecycle.test.ts @@ -11,11 +11,17 @@ Please see LICENSE files in the repository root for full details. import { vi, describe, it, expect, beforeEach, afterEach, type MockedObject } from "vitest"; import { logger } from "matrix-js-sdk/src/logger"; import * as MatrixJs from "matrix-js-sdk/src/matrix"; -import { decodeBase64, encodeUnpaddedBase64 } from "matrix-js-sdk/src/matrix"; +import { decodeBase64, encodeUnpaddedBase64, MatrixClient, OAuth2 } from "matrix-js-sdk/src/matrix"; import * as encryptAESSecretStorageItemModule from "matrix-js-sdk/src/utils/encryptAESSecretStorageItem"; import fetchMock from "@fetch-mock/vitest"; -import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser, mockPlatformPeg } from "test-utils"; -import { makeDelegatedAuthConfig } from "test-utils/oidc"; +import { + flushPromises, + getMockClientWithEventEmitter, + mockClientMethodsUser, + mockClientMethodsServer, + mockPlatformPeg, +} from "test-utils"; +import { makeDelegatedAuthMetadata } from "test-utils/auth"; import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialog"; import * as Lifecycle from "./Lifecycle"; @@ -23,10 +29,9 @@ import { MatrixClientPeg } from "./MatrixClientPeg"; import Modal from "./Modal"; import * as StorageAccess from "./utils/StorageAccess"; import { idbSave } from "./utils/StorageAccess"; -import { OidcClientStore } from "./stores/oidc/OidcClientStore"; import { Action } from "./dispatcher/actions"; import PlatformPeg from "./PlatformPeg"; -import { persistAccessTokenInStorage, persistRefreshTokenInStorage } from "./utils/tokens/tokens"; +import { persistTokens } from "./utils/tokens/tokens"; import { encryptPickleKey } from "./utils/tokens/pickling"; import * as StorageManager from "./utils/StorageManager.ts"; import type BasePlatform from "./BasePlatform.ts"; @@ -49,22 +54,19 @@ describe("Lifecycle", () => { mockPlatform = mockPlatformPeg(); mockClient = getMockClientWithEventEmitter({ ...mockClientMethodsUser(), + ...mockClientMethodsServer(), stopClient: vi.fn(), removeAllListeners: vi.fn(), clearStores: vi.fn(), - getAccountData: vi.fn(), getDeviceId: vi.fn().mockReturnValue(deviceId), isVersionSupported: vi.fn().mockResolvedValue(true), getCrypto: vi.fn(), - getClientWellKnown: vi.fn(), - waitForClientWellKnown: vi.fn(), getThirdpartyProtocols: vi.fn(), store: { destroy: vi.fn(), }, getVersions: vi.fn().mockResolvedValue({ versions: ["v1.1"] }), logout: vi.fn().mockResolvedValue(undefined), - getAccessToken: vi.fn(), getRefreshToken: vi.fn(), isInitialSyncComplete: vi.fn(), setGuest: vi.fn(), @@ -78,6 +80,7 @@ describe("Lifecycle", () => { localStorage.clear(); sessionStorage.clear(); + vi.spyOn(MatrixClient.prototype, "getAuthMetadata").mockResolvedValue(makeDelegatedAuthMetadata()); }); afterEach(() => { @@ -119,8 +122,6 @@ describe("Lifecycle", () => { mx_is_url: identityServerUrl, mx_user_id: userId, mx_device_id: deviceId, - mx_oidc_token_issuer: "test-issuer.dummy", - mx_oidc_client_id: "test-client-id", }; const idbStorageSession = { account: { @@ -304,6 +305,7 @@ describe("Lifecycle", () => { describe("with a refresh token", () => { beforeEach(() => { localStorage.setItem("mx_refresh_token", refreshToken); + localStorage.setItem("mx_oidc_client_id", "test-client-id"); for (const key in localStorageSession) { localStorage.setItem(key, localStorageSession[key]); } @@ -334,7 +336,7 @@ describe("Lifecycle", () => { guest: false, pickleKey: undefined, }, - expect.any(Function), + expect.any(OAuth2), ); }); }); @@ -344,6 +346,7 @@ describe("Lifecycle", () => { let pickleKey: string; beforeEach(async () => { + localStorage.setItem("mx_oidc_client_id", "test-client-id"); for (const key in localStorageSession) { localStorage.setItem(key, localStorageSession[key]); } @@ -355,7 +358,7 @@ describe("Lifecycle", () => { // Indicate that we should have a pickle key localStorage.setItem("mx_has_pickle_key", "true"); - await persistAccessTokenInStorage(credentials.accessToken, pickleKey); + await persistTokens(pickleKey, credentials); }); it("should persist credentials", async () => { @@ -415,7 +418,7 @@ describe("Lifecycle", () => { guest: false, pickleKey, }, - undefined, + expect.any(OAuth2), ); expect(MatrixClientPeg.start).toHaveBeenCalledWith({ rustCryptoStoreKey: expect.any(Uint8Array) }); @@ -423,7 +426,7 @@ describe("Lifecycle", () => { describe("with a refresh token", () => { beforeEach(async () => { - await persistRefreshTokenInStorage(refreshToken, pickleKey); + await persistTokens(pickleKey, { ...credentials, refreshToken }); }); it("should persist credentials", async () => { @@ -454,7 +457,7 @@ describe("Lifecycle", () => { guest: false, pickleKey: pickleKey, }, - expect.any(Function), + expect.any(OAuth2), ); }); }); @@ -486,7 +489,7 @@ describe("Lifecycle", () => { // Indicate that we should have a pickle key localStorage.setItem("mx_has_pickle_key", "true"); - await persistAccessTokenInStorage(credentials.accessToken, pickleKey); + await persistTokens(pickleKey, credentials); }); it("should create and start new matrix client with credentials", async () => { @@ -532,7 +535,7 @@ describe("Lifecycle", () => { // Create a pickle key, and store it, encrypted, in IDB. const pickleKey = (await PlatformPeg.get()!.createPickleKey(credentials.userId, credentials.deviceId))!; localStorage.setItem("mx_has_pickle_key", "true"); - await persistAccessTokenInStorage(credentials.accessToken, pickleKey); + await persistTokens(pickleKey, credentials); // Now destroy the pickle key await PlatformPeg.get()!.destroyPickleKey(credentials.userId, credentials.deviceId); @@ -624,7 +627,6 @@ describe("Lifecycle", () => { }); it("should persist a refreshToken when present", async () => { - localStorage.setItem("mx_oidc_token_issuer", "test-issuer.dummy"); localStorage.setItem("mx_oidc_client_id", "test-client-id"); await setLoggedIn({ @@ -777,34 +779,18 @@ describe("Lifecycle", () => { const clientId = "test-client-id"; const issuer = "https://auth.com/"; - const delegatedAuthConfig = makeDelegatedAuthConfig(issuer); - const idToken = - "eyJhbGciOiJSUzI1NiIsImtpZCI6Imh4ZEhXb0Y5bW4ifQ.eyJzdWIiOiIwMUhQUDJGU0JZREU5UDlFTU04REQ3V1pIUiIsImlzcyI6Imh0dHBzOi8vYXV0aC1vaWRjLmxhYi5lbGVtZW50LmRldi8iLCJpYXQiOjE3MTUwNzE5ODUsImF1dGhfdGltZSI6MTcwNzk5MDMxMiwiY19oYXNoIjoidGt5R1RhUjU5aTk3YXoyTU4yMGdidyIsImV4cCI6MTcxNTA3NTU4NSwibm9uY2UiOiJxaXhwM0hFMmVaIiwiYXVkIjoiMDFIWDk0Mlg3QTg3REgxRUs2UDRaNjI4WEciLCJhdF9oYXNoIjoiNFlFUjdPRlVKTmRTeEVHV2hJUDlnZyJ9.HxODneXvSTfWB5Vc4cf7b8GiN2gdwUuTiyVqZuupWske2HkZiJZUt5Lsxg9BW3gz28POkE0Ln17snlkmy02B_AD3DQxKOOxQCzIIARHdfFvZxgGWsMdFcVQZDW7rtXcqgj-SpVaUQ_8acsgxSrz_DF2o0O4tto0PT6wVUiw8KlBmgWTscWPeAWe-39T-8EiQ8Wi16h6oSPcz2NzOQ7eOM_S9fDkOorgcBkRGLl1nrahrPSdWJSGAeruk5mX4YxN714YThFDyEA2t9YmKpjaiSQ2tT-Xkd7tgsZqeirNs2ni9mIiFX3bRX6t2AhUNzA7MaX9ZyizKGa6go3BESO_oDg"; + const delegatedAuthConfig = makeDelegatedAuthMetadata(issuer); beforeEach(() => { - fetchMock.get(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`, delegatedAuthConfig); - fetchMock.get(`${delegatedAuthConfig.issuer}jwks`, { - status: 200, - headers: { - "Content-Type": "application/json", - }, - keys: [], - }); - // set values in local storage as they would be after a successful oidc authentication localStorage.setItem("mx_oidc_client_id", clientId); - localStorage.setItem("mx_oidc_token_issuer", issuer); - localStorage.setItem("mx_oidc_id_token", idToken); }); it("should not try to create a token refresher without a refresh token", async () => { - await setLoggedIn(credentials); + const cli = await setLoggedIn(credentials); // didn't try to initialise token refresher - expect(fetchMock).toHaveFetchedTimes( - 0, - `${delegatedAuthConfig.issuer}.well-known/openid-configuration`, - ); + expect(cli.http.opts.tokenRefreshFunction).toBeUndefined(); }); it("should not try to create a token refresher without a deviceId", async () => { @@ -824,7 +810,6 @@ describe("Lifecycle", () => { }); it("should not try to create a token refresher without an issuer in session storage", async () => { - localStorage.removeItem("mx_oidc_token_issuer"); await expect( setLoggedIn({ ...credentials, @@ -881,20 +866,16 @@ describe("Lifecycle", () => { }); describe("logout()", () => { - let oidcClientStore!: OidcClientStore; const accessToken = "test-access-token"; const refreshToken = "test-refresh-token"; beforeEach(() => { - oidcClientStore = new OidcClientStore(mockClient); - // stub - vi.spyOn(oidcClientStore, "revokeTokens").mockResolvedValue(undefined); - mockClient.getAccessToken.mockReturnValue(accessToken); mockClient.getRefreshToken.mockReturnValue(refreshToken); + vi.spyOn(OAuth2.prototype, "revokeToken").mockResolvedValue(undefined); }); - it("should call logout on the client when oidcClientStore is falsy", async () => { + it("should call logout on the client when oauth is not used", async () => { logout(); await flushPromises(); @@ -902,24 +883,15 @@ describe("Lifecycle", () => { expect(mockClient.logout).toHaveBeenCalledWith(true); }); - it("should call logout on the client when oidcClientStore.isUserAuthenticatedWithOidc is falsy", async () => { - vi.spyOn(oidcClientStore, "isUserAuthenticatedWithOidc", "get").mockReturnValue(false); - logout(oidcClientStore); - - await flushPromises(); - - expect(mockClient.logout).toHaveBeenCalledWith(true); - expect(oidcClientStore.revokeTokens).not.toHaveBeenCalled(); - }); - - it("should revoke tokens when user is authenticated with oidc", async () => { - vi.spyOn(oidcClientStore, "isUserAuthenticatedWithOidc", "get").mockReturnValue(true); - logout(oidcClientStore); + it("should revoke tokens when user is authenticated with oauth2", async () => { + localStorage.setItem("mx_oidc_client_id", "test-client-id"); + logout(); await flushPromises(); expect(mockClient.logout).not.toHaveBeenCalled(); - expect(oidcClientStore.revokeTokens).toHaveBeenCalledWith(accessToken, refreshToken); + expect(OAuth2.prototype.revokeToken).toHaveBeenCalledWith(accessToken, "access_token"); + expect(OAuth2.prototype.revokeToken).toHaveBeenCalledWith(refreshToken, "refresh_token"); }); }); diff --git a/apps/web/src/Lifecycle.ts b/apps/web/src/Lifecycle.ts index 608545ed6b0..6b2e1e42b70 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"; @@ -56,30 +50,23 @@ import { Action } from "./dispatcher/actions"; import { type OverwriteLoginPayload } from "./dispatcher/payloads/OverwriteLoginPayload"; import { SDKContextClass } from "./contexts/SDKContextClass"; 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 { - getStoredOidcClientId, - getStoredOidcIdTokenClaims, - getStoredOidcTokenIssuer, - persistOidcAuthenticatedSettings, -} from "./utils/oidc/persistOidcSettings"; +import { completeOAuthLogin, type CompleteOAuthLoginResponse } from "./utils/oauth/authorize"; +import { getOAuthErrorMessage } from "./utils/oauth/error"; +import { getOAuthParams, getStoredOAuthClientId, persistOAuthClientId } 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 { type OnLoggedInPayload } from "./dispatcher/payloads/OnLoggedInPayload.ts"; +import { filterBoolean } from "./utils/arrays.ts"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -263,7 +250,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 @@ -279,30 +266,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 attemptOAuthLogin(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 attemptOAuthLogin(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 completeOAuthLogin(urlParams); await configureFromCompletedOAuthLogin({ accessToken, @@ -310,22 +291,20 @@ 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)); + onFailedDelegatedAuthLogin(getOAuthErrorMessage(error as Error)); return false; } } /** - * 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, @@ -333,9 +312,7 @@ export async function configureFromCompletedOAuthLogin({ homeserverUrl, identityServerUrl, clientId, - issuer, - idToken, -}: Omit): Promise { +}: CompleteOAuthLoginResponse): Promise { const { user_id: userId, device_id: deviceId, @@ -352,10 +329,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); + persistOAuthClientId(clientId); return credentials; } @@ -478,7 +455,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 */ @@ -730,9 +707,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 * @@ -782,44 +759,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. @@ -869,21 +808,14 @@ async function doSetLoggedIn( await abortLogin(); } - let storedClientid; + let auth: OAuth2 | undefined; try { - storedClientid = getStoredOidcClientId(); + auth = await hydrateAuth(credentials); } 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.set(createClientWithCreds(credentials, tokenRefresher?.doRefreshAccessToken.bind(tokenRefresher))); + MatrixClientPeg.set(createClientWithCreds(credentials, auth)); const client = MatrixClientPeg.safeGet(); setSentryUser(credentials.userId); @@ -956,8 +888,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); } @@ -1003,12 +942,21 @@ 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 async function logout(): Promise { const client = MatrixClientPeg.get(); if (!client) return; + let oauth: OAuth2 | undefined; + try { + oauth = await hydrateAuth({ + homeserverUrl: client.getHomeserverUrl(), + deviceId: client.getDeviceId()!, + }); + } catch (e) { + console.error("@@", e); + } + PosthogAnalytics.instance.logout(); if (client.isGuest()) { @@ -1022,7 +970,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 ?? null).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 @@ -1269,3 +1217,18 @@ 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. + */ +export async function hydrateAuth( + credentials: Pick, +): Promise { + const storedClientId = getStoredOAuthClientId(); + + const tempClient = new MatrixClient({ baseUrl: credentials.homeserverUrl }); + const authMetadata = await tempClient.getAuthMetadata(); + + return new OAuth2(authMetadata, { ...getOAuthParams(storedClientId), deviceId: credentials.deviceId }); +} diff --git a/apps/web/src/Login.ts b/apps/web/src/Login.ts index 07ba1512f86..388eb3e95ae 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 "./utils/createMatrixClient"; import { ModuleRunner } from "./modules/ModuleRunner"; -import { getOidcClientId } from "./utils/oidc/registerClient"; +import { getOAuthClientId } 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 + * OAuthNativeFlow 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,29 +108,29 @@ 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( + const oauthFlow = await tryInitOAuthNativeFlow( this.delegatedAuthentication, SdkConfig.get().oidc_static_clients, isRegistration, ); - return [oidcFlow]; + return [oauthFlow]; } 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,42 +199,42 @@ 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; } /** - * Prepares an OidcNativeFlow for logging into the server. + * Prepares an OAuthNativeFlow for logging into the server. * * Finds a static clientId for configured issuer, or attempts dynamic registration with the OP, and wraps the * results. * * @param delegatedAuthConfig Auth config from ValidatedServerConfig - * @param staticOidcClientIds static client config from config.json, used during client registration with OP + * @param staticOAuthClientIds 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, - staticOidcClientIds?: IConfigOptions["oidc_static_clients"], +const tryInitOAuthNativeFlow = async ( + delegatedAuthConfig: ValidatedAuthMetadata, + staticOAuthClientIds?: 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"); } - const clientId = await getOidcClientId(delegatedAuthConfig, staticOidcClientIds); + const clientId = await getOAuthClientId(delegatedAuthConfig, staticOAuthClientIds); const flow = { - type: "oidcNativeFlow", + type: "oauthNativeFlow", clientId, - } as OidcNativeFlow; + } as OAuthNativeFlow; return flow; }; diff --git a/apps/web/src/components/structures/MatrixChat.tsx b/apps/web/src/components/structures/MatrixChat.tsx index b079a1fafb3..721cb61d97e 100644 --- a/apps/web/src/components/structures/MatrixChat.tsx +++ b/apps/web/src/components/structures/MatrixChat.tsx @@ -340,11 +340,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()); } @@ -696,7 +692,7 @@ export default class MatrixChat extends React.PureComponent { case "logout": this.stores.legacyCallHandler.hangupAllCalls(); Promise.all([...[...CallStore.instance.connectedCalls].map((call) => call.disconnect())]).finally(() => - Lifecycle.logout(this.stores.oidcClientStore), + Lifecycle.logout(), ); break; case "require_registration": diff --git a/apps/web/src/components/structures/auth/Login.tsx b/apps/web/src/components/structures/auth/Login.tsx index d4cf86cb655..e393e9c996e 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 { startOAuthLogin } 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.renderOAuth2Step(), }; } @@ -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 ( @@ -434,15 +434,15 @@ class LoginComponent extends React.PureComponent { ); }; - private renderOidcNativeStep = (): React.ReactNode => { - const flow = this.state.flows!.find((flow) => flow.type === "oidcNativeFlow")! as OidcNativeFlow; + private renderOAuth2Step = (): React.ReactNode => { + const flow = this.state.flows!.find((flow) => flow.type === "oauthNativeFlow")! as OAuthNativeFlow; return (