Adapt OAuth2 implementation to Matrix Spec v1.18 (#34026)

* Adapt OAuth2 implementation to Matrix Spec v1.18

* Handle more cases of oidc->oauth

* Fix test

* Fix read back of oauth2 context

* Iterate

* Fix tests

* Discard changes to apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts

* Fix test

* Fix test

* Fix test

* Potential fix for pull request finding 'Unused variable, import, function or class'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* Iterate

* Iterate

* Fix test

---------

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
This commit is contained in:
Michael Telatynski
2026-07-08 08:02:25 +00:00
committed by GitHub
co-authored by Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
parent 38e29c51c4
commit 2bc9656957
69 changed files with 761 additions and 1738 deletions
+8 -1
View File
@@ -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
@@ -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<string>(() => {
return window.mxPlatformPeg.get().getOidcCallbackUrl().toString();
return window.mxPlatformPeg.get().getOAuthCallbackUrl().toString();
}),
).resolves.toMatch(/io\.element\.(desktop|nightly):\/vector\/webapp\//);
});
-6
View File
@@ -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",
-1
View File
@@ -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",
@@ -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,
},
+17 -18
View File
@@ -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<OidcRegistrationClientMetadata> {
public async getOAuthClientMetadata(): Promise<OAuthRegistrationRequest> {
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
+31 -59
View File
@@ -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");
});
});
+72 -109
View File
@@ -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<boolean> {
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<URLParams["oidc_fragment"]>,
responseMode: "fragment" | "query",
): Promise<boolean> {
console.log("We have OIDC params - attempting OIDC login");
async function attemptOAuthLogin(urlParams: NonNullable<URLParams["oauth2"]>): Promise<boolean> {
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<CompleteOidcLoginResponse, "idTokenClaims">): Promise<IMatrixClientCreds> {
}: CompleteOAuthLoginResponse): Promise<IMatrixClientCreds> {
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<s
}
/**
* Called after a successful token login or OIDC authorization.
* Called after a successful token login or OAuth2 authorization.
* Clear storage then save new credentials in storage
* @param credentials as returned from login
*/
@@ -495,7 +472,7 @@ async function onSuccessfulDelegatedAuthLogin(credentials: IMatrixClientCreds):
type TryAgainFunction = () => 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<M
return doSetLoggedIn(credentials, overwrite, false);
}
/**
* When we have a authenticated via OIDC-native flow and have a refresh token
* try to create a token refresher.
* @param credentials from current session
* @param clientId OIDC client ID
* @throws If credentials.refreshToken or credentials.deviceId is falsy, or if no token issuer is stored
* @returns Promise that resolves to a TokenRefresher
*/
async function createOidcTokenRefresher(
credentials: IMatrixClientCreds,
clientId: string,
): Promise<OidcTokenRefresher> {
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<void
localStorage.setItem("mx_user_id", credentials.userId);
localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest));
await persistAccessTokenInStorage(credentials.accessToken, credentials.pickleKey);
await persistRefreshTokenInStorage(credentials.refreshToken, credentials.pickleKey);
await persistTokens(credentials.pickleKey, credentials);
if (credentials.pickleKey) {
localStorage.setItem("mx_has_pickle_key", String(true));
@@ -985,17 +916,25 @@ let _isLoggingOut = false;
/**
* Logs out the current session.
* When user has authenticated using OIDC native flow revoke tokens with OIDC provider.
* When user has authenticated using OAuth2 native flow revoke tokens with OAuth2 provider.
* Otherwise, call /logout on the homeserver.
* @param client
* @param oidcClientStore
* @param oauth
*/
async function doLogout(client: MatrixClient, oidcClientStore?: OidcClientStore): Promise<void> {
if (oidcClientStore?.isUserAuthenticatedWithOidc) {
const accessToken = client.getAccessToken() ?? undefined;
const refreshToken = client.getRefreshToken() ?? undefined;
async function doLogout(client: MatrixClient, oauth: OAuth2 | null): Promise<void> {
if (oauth) {
const accessToken = client.getAccessToken();
const refreshToken = client.getRefreshToken();
await oidcClientStore.revokeTokens(accessToken, refreshToken);
await Promise.all(
filterBoolean([
accessToken ? oauth.revokeToken(accessToken, "access_token") : null,
refreshToken ? oauth.revokeToken(refreshToken, "refresh_token") : null,
]),
);
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<void> {
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<IMatrixClientCreds, "homeserverUrl" | "deviceId">,
): Promise<OAuth2> {
const storedClientId = getStoredOAuthClientId();
const tempClient = new MatrixClient({ baseUrl: credentials.homeserverUrl });
const authMetadata = await tempClient.getAuthMetadata();
return new OAuth2(authMetadata, { ...getOAuthParams(storedClientId), deviceId: credentials.deviceId });
}
+34 -34
View File
@@ -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<ClientLoginFlow> = [];
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<Array<ClientLoginFlow>> {
// 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<OidcNativeFlow> when oidc native authentication flow is supported and correctly configured
* @returns Promise<OAuthNativeFlow> 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<OidcNativeFlow> => {
): Promise<OAuthNativeFlow> => {
// 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;
};
@@ -340,11 +340,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
);
// 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<IProps, IState> {
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":
@@ -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<IProps, IState> {
"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<IProps, IState> {
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<IProps, IState> {
);
};
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 (
<Button
className="mx_Login_fullWidthButton"
kind="primary"
size="md"
onClick={async () => {
await startOidcLogin(
await startOAuthLogin(
this.props.serverConfig.delegatedAuthentication!,
flow.clientId,
this.props.serverConfig.hsUrl,
@@ -32,7 +32,7 @@ import * as Lifecycle from "../../../Lifecycle";
import { type IMatrixClientCreds } from "../../../utils/createMatrixClient";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import AuthPage from "../../views/auth/AuthPage";
import Login, { type OidcNativeFlow } from "../../../Login";
import Login, { type OAuthNativeFlow } from "../../../Login";
import dis from "../../../dispatcher/dispatcher";
import SSOButtons from "../../views/elements/SSOButtons";
import ServerPicker from "../../views/elements/ServerPicker";
@@ -46,7 +46,7 @@ import { AuthHeaderDisplay } from "./header/AuthHeaderDisplay";
import { AuthHeaderProvider } from "./header/AuthHeaderProvider";
import SettingsStore from "../../../settings/SettingsStore";
import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import { startOidcLogin } from "../../../utils/oidc/authorize";
import { startOAuthLogin } from "../../../utils/oauth/authorize";
const debuglog = (...args: any[]): void => {
if (SettingsStore.getValue("debug_registration")) {
@@ -124,7 +124,7 @@ interface IState {
ssoFlow?: SSOFlow;
// the OIDC native login flow, when supported and enabled
// if present, must be used for registration
oidcNativeFlow?: OidcNativeFlow;
oauthNativeFlow?: OAuthNativeFlow;
}
export default class Registration extends React.Component<IProps, IState> {
@@ -225,12 +225,12 @@ export default class Registration extends React.Component<IProps, IState> {
this.loginLogic.setDelegatedAuthentication(serverConfig.delegatedAuthentication);
let ssoFlow: SSOFlow | undefined;
let oidcNativeFlow: OidcNativeFlow | undefined;
let oauthNativeFlow: OAuthNativeFlow | undefined;
try {
const loginFlows = await this.loginLogic.getFlows(true);
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
ssoFlow = loginFlows.find((f) => f.type === "m.login.sso" || f.type === "m.login.cas") as SSOFlow;
oidcNativeFlow = loginFlows.find((f) => f.type === "oidcNativeFlow") as OidcNativeFlow;
oauthNativeFlow = loginFlows.find((f) => f.type === "oauthNativeFlow") as OAuthNativeFlow;
} catch (e) {
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
logger.error("Failed to get login flows to check for SSO support", e);
@@ -241,10 +241,10 @@ export default class Registration extends React.Component<IProps, IState> {
({ flows }) => ({
matrixClient: cli,
ssoFlow,
oidcNativeFlow,
oauthNativeFlow,
// if we are using oidc native we won't continue with flow discovery on HS
// so set an empty array to indicate flows are no longer loading
flows: oidcNativeFlow ? [] : flows,
flows: oauthNativeFlow ? [] : flows,
busy: false,
}),
resolve,
@@ -253,7 +253,7 @@ export default class Registration extends React.Component<IProps, IState> {
// don't need to check with homeserver for login flows
// since we are going to use OIDC native flow
if (oidcNativeFlow) {
if (oauthNativeFlow) {
return;
}
@@ -546,16 +546,16 @@ export default class Registration extends React.Component<IProps, IState> {
<Spinner />
</div>
);
} else if (this.state.matrixClient && this.state.oidcNativeFlow) {
} else if (this.state.matrixClient && this.state.oauthNativeFlow) {
return (
<Button
className="mx_Login_fullWidthButton"
kind="primary"
size="md"
onClick={async () => {
await startOidcLogin(
await startOAuthLogin(
this.props.serverConfig.delegatedAuthentication!,
this.state.oidcNativeFlow!.clientId,
this.state.oauthNativeFlow!.clientId,
this.props.serverConfig.hsUrl,
this.props.serverConfig.isUrl,
true /* isRegistration */,
@@ -26,7 +26,6 @@ import AccessibleButton from "../../views/elements/AccessibleButton";
import Spinner from "../../views/elements/Spinner";
import AuthHeader from "../../views/auth/AuthHeader";
import AuthBody from "../../views/auth/AuthBody";
import { SDKContext } from "../../../contexts/SDKContext";
import { type URLParams } from "../../../vector/url_utils.ts";
enum LoginView {
@@ -61,9 +60,6 @@ interface IState {
}
export default class SoftLogout extends React.Component<IProps, IState> {
public static contextType = SDKContext;
declare public context: React.ContextType<typeof SDKContext>;
public constructor(props: IProps) {
super(props);
@@ -92,7 +88,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
if (!wipeData) return;
logger.log("Clearing data from soft-logged-out session");
Lifecycle.logout(this.context.oidcClientStore);
Lifecycle.logout();
});
};
@@ -18,17 +18,24 @@ import {
signInByGeneratingQR,
} from "matrix-js-sdk/src/rendezvous";
import { logger } from "matrix-js-sdk/src/logger";
import { AutoDiscovery, MatrixClient, OAuthGrantType, type OidcClientConfig, type XOR } from "matrix-js-sdk/src/matrix";
import {
AutoDiscovery,
MatrixClient,
OAuthGrantType,
type ValidatedAuthMetadata,
type XOR,
} from "matrix-js-sdk/src/matrix";
import { sleep } from "matrix-js-sdk/src/utils";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { Click, Mode, Phase } from "./LoginWithQR-types";
import LoginWithQRFlow from "./LoginWithQRFlow";
import { type CompleteOidcLoginResponse } from "../../../utils/oidc/authorize";
import { getOidcClientId } from "../../../utils/oidc/registerClient.ts";
import { type CompleteOAuthLoginResponse } from "../../../utils/oauth/authorize";
import { getOAuthClientId } from "../../../utils/oauth/registerClient.ts";
import SdkConfig from "../../../SdkConfig.ts";
import { type Context } from "../../../utils/oauth/persistOAuthSettings.ts";
export type QrLoginCredentials = Omit<CompleteOidcLoginResponse, "idTokenClaims"> &
export type QrLoginCredentials = CompleteOAuthLoginResponse &
Awaited<ReturnType<MSC4108SignInWithQR["shareSecrets"]>> & {
deviceId: string;
};
@@ -101,26 +108,10 @@ interface IState {
*/
failureReason?: FailureReason;
/**
* TODO
* Details of the server we are logging into, set after initial protocol negotiation.
*/
loginServerDetails?: {
/**
* TODO
*/
homeserverUrl: string;
/**
* TODO
*/
identityServerUrl?: string;
/**
* TODO
*/
metadata: OidcClientConfig;
/**
* TODO
*/
clientId: string;
};
loginServerDetails?: Pick<Context, "homeserverUrl" | "identityServerUrl" | "metadata"> &
Pick<Context["authContext"], "clientId">;
}
export enum LoginWithQRFailureReason {
@@ -141,7 +132,7 @@ export type FailureReason = RendezvousFailureReason | LoginWithQRFailureReason;
*/
async function resolveServerURLs(
serverNameOrBaseUrl: string,
): Promise<Pick<Partial<NonNullable<IState["loginServerDetails"]>>, "homeserverUrl" | "identityServerUrl">> {
): Promise<Pick<Partial<Context>, "homeserverUrl" | "identityServerUrl">> {
if (serverNameOrBaseUrl.startsWith("http://") || serverNameOrBaseUrl.startsWith("https://")) {
// treat as base URL and skip discovery
return {
@@ -262,7 +253,7 @@ export default class LoginWithQR extends React.Component<Props, IState> {
throw new Error("Failed to discover homeserver URL");
}
let metadata: OidcClientConfig;
let metadata: ValidatedAuthMetadata;
let clientId: string;
try {
// Create a new client as the homeserver URL may not be the same as we used for the secure channel
@@ -270,7 +261,7 @@ export default class LoginWithQR extends React.Component<Props, IState> {
if (!metadata.grant_types_supported.includes(OAuthGrantType.DeviceAuthorization)) {
throw new Error("Server does not support Device Authorization Grant");
}
clientId = await getOidcClientId(metadata, SdkConfig.get().oidc_static_clients);
clientId = await getOAuthClientId(metadata, SdkConfig.get().oidc_static_clients);
} catch (e) {
this.setState({
phase: Phase.Error,
@@ -355,8 +346,6 @@ export default class LoginWithQR extends React.Component<Props, IState> {
refreshToken: tokenResponse.refresh_token,
homeserverUrl,
clientId,
idToken: tokenResponse.id_token,
issuer: metadata!.issuer,
identityServerUrl,
secrets,
deviceId,
@@ -15,7 +15,7 @@ import {
type SSOFlow,
type SSOAction,
type IIdentityProvider,
DELEGATED_OIDC_COMPATIBILITY,
OAUTH_AWARE_PREFERRED_FLOW_FIELD,
} from "matrix-js-sdk/src/matrix";
import { type Signup } from "@matrix-org/analytics-events/types/typescript/Signup";
import { Button, Tooltip } from "@vector-im/compound-web";
@@ -90,7 +90,7 @@ const SSOButton: React.FC<ISSOButtonProps> = ({
let label: string;
if (idp) {
label = _t("auth|continue_with_idp", { provider: idp.name });
} else if (DELEGATED_OIDC_COMPATIBILITY.findIn<boolean>(flow)) {
} else if (OAUTH_AWARE_PREFERRED_FLOW_FIELD.findIn<boolean>(flow)) {
label = _t("action|continue");
} else {
label = _t("auth|sign_in_with_sso");
@@ -18,7 +18,7 @@ import ToggleSwitch from "../../elements/ToggleSwitch";
import { DeviceDetailHeading } from "./DeviceDetailHeading";
import { DeviceVerificationStatusCard } from "./DeviceVerificationStatusCard";
import { type ExtendedDevice } from "./types";
import { getManageDeviceUrl } from "../../../../utils/oidc/urls.ts";
import { getManageDeviceUrl } from "../../../../utils/oauth/urls.ts";
interface Props {
device: ExtendedDevice;
@@ -23,9 +23,8 @@ import ChangePassword from "../../ChangePassword";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import { SettingsSubsection, SettingsSubsectionText } from "../../shared/SettingsSubsection";
import { SDKContext } from "../../../../../contexts/SDKContext";
import { UserPersonalInfoSettings } from "../../UserPersonalInfoSettings";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
import { SDKContext } from "../../../../../contexts/SDKContext.ts";
interface IProps {
closeSettingsFn: () => void;
@@ -90,8 +89,8 @@ const AccountUserSettingsTab: React.FC<IProps> = ({ closeSettingsFn }) => {
const [canSetAvatar, setCanSetAvatar] = React.useState<boolean>(false);
const [canChangePassword, setCanChangePassword] = React.useState<boolean>(false);
const cli = useMatrixClientContext();
const sdkContext = useContext(SDKContext);
const cli = sdkContext.client!;
useEffect(() => {
(async () => {
@@ -103,8 +102,8 @@ const AccountUserSettingsTab: React.FC<IProps> = ({ closeSettingsFn }) => {
// the enabled flag value.
const canChangePassword = !changePasswordCap || changePasswordCap["enabled"] !== false;
await sdkContext.oidcClientStore.readyPromise; // wait for the store to be ready
const externalAccountManagementUrl = sdkContext.oidcClientStore.accountManagementEndpoint;
const authMetadata = await cli.getAuthMetadata().catch(() => {});
const externalAccountManagementUrl = authMetadata?.account_management_uri;
// https://spec.matrix.org/v1.7/client-server-api/#m3pid_changes-capability
// We support as far back as v1.1 which doesn't have m.3pid_changes
// so the behaviour for when it is missing has to be assume true
@@ -121,7 +120,7 @@ const AccountUserSettingsTab: React.FC<IProps> = ({ closeSettingsFn }) => {
setExternalAccountManagementUrl(externalAccountManagementUrl);
setCanChangePassword(canChangePassword);
})();
}, [cli, sdkContext.oidcClientStore]);
}, [cli]);
const onPasswordChangeError = useCallback((err: Error): void => {
logger.error("Failed to change password: " + err);
@@ -31,7 +31,7 @@ import QuestionDialog from "../../../dialogs/QuestionDialog";
import { type FilterVariation } from "../../devices/filter";
import { OtherSessionsSectionHeading } from "../../devices/OtherSessionsSectionHeading";
import { SettingsSection } from "../../shared/SettingsSection";
import { getManageDeviceUrl } from "../../../../../utils/oidc/urls.ts";
import { getManageDeviceUrl } from "../../../../../utils/oauth/urls.ts";
import { SDKContext } from "../../../../../contexts/SDKContext";
import Spinner from "../../../elements/Spinner";
@@ -154,12 +154,12 @@ const SessionManagerTab: React.FC<{
* See https://github.com/matrix-org/matrix-spec-proposals/pull/3824
*/
const accountManagement = useAsyncMemo(async () => {
await sdkContext.oidcClientStore.readyPromise; // wait for the store to be ready
const authMetadata = await matrixClient.getAuthMetadata().catch(() => {});
return {
endpoint: sdkContext.oidcClientStore.accountManagementEndpoint,
actionsSupported: sdkContext.oidcClientStore.accountManagementActionsSupported,
endpoint: authMetadata?.account_management_uri,
actionsSupported: authMetadata?.account_management_actions_supported,
};
}, [sdkContext.oidcClientStore]);
}, [matrixClient]);
const disableMultipleSignout = !!accountManagement?.endpoint;
const userId = matrixClient?.getUserId();
const currentUserMember = (userId && matrixClient?.getUser(userId)) || undefined;
@@ -82,7 +82,6 @@ import { KeyboardShortcut } from "../settings/KeyboardShortcut";
import { ModuleApi } from "../../../modules/Api.ts";
import { useModuleSpacePanelItems } from "../../../modules/ExtrasApi.ts";
import { UserMenuViewModel } from "../../../viewmodels/menus/UserMenuViewModel.ts";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx";
import { SDKContext } from "../../../contexts/SDKContext.ts";
import { OwnProfileStore } from "../../../stores/OwnProfileStore.ts";
@@ -390,7 +389,8 @@ const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(
);
const SpacePanel: React.FC = () => {
const client = useMatrixClientContext();
const sdkContext = useContext(SDKContext);
const client = sdkContext.client!;
const [dragging, setDragging] = useState(false);
const [isPanelCollapsed, setPanelCollapsed] = useState(true);
const ref = useRef<HTMLDivElement>(null);
@@ -398,7 +398,6 @@ const SpacePanel: React.FC = () => {
if (ref.current) UIStore.instance.trackElementDimensions("SpacePanel", ref.current);
return () => UIStore.instance.stopTrackingElementDimensions("SpacePanel");
}, []);
const sdkContext = useContext(SDKContext);
useDispatcher(defaultDispatcher, (payload: ActionPayload) => {
if (payload.action === Action.ToggleSpacePanel) {
@@ -413,7 +412,6 @@ const SpacePanel: React.FC = () => {
defaultDispatcher,
client,
isPanelCollapsed,
sdkContext.oidcClientStore.accountManagementEndpoint,
),
);
-15
View File
@@ -22,7 +22,6 @@ import TypingStore from "../stores/TypingStore";
import { UserProfilesStore } from "../stores/UserProfilesStore";
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import { WidgetPermissionStore } from "../stores/widgets/WidgetPermissionStore";
import { OidcClientStore } from "../stores/oidc/OidcClientStore";
import WidgetStore from "../stores/WidgetStore";
import ResizeNotifier from "../utils/ResizeNotifier";
import { MultiRoomViewStore } from "../stores/MultiRoomViewStore";
@@ -70,7 +69,6 @@ export class SDKContextClass {
protected _LegacyCallHandler?: LegacyCallHandler;
protected _TypingStore?: TypingStore;
protected _UserProfilesStore?: UserProfilesStore;
protected _OidcClientStore?: OidcClientStore;
protected _ResizeNotifier?: ResizeNotifier;
protected _MultiRoomViewStore?: MultiRoomViewStore;
protected _Notifier?: Notifier;
@@ -181,18 +179,6 @@ export class SDKContextClass {
return this._UserProfilesStore;
}
public get oidcClientStore(): OidcClientStore {
if (!this.client) {
throw new Error("Unable to create OidcClientStore without a client");
}
if (!this._OidcClientStore) {
this._OidcClientStore = new OidcClientStore(this.client);
}
return this._OidcClientStore;
}
// This is getting increasingly tenuous to have here but we still have class components so it's
// awkward to consume multiple contexts in them. This should be replaced with ResizeObservers
// anyway really.
@@ -219,7 +205,6 @@ export class SDKContextClass {
public onLoggedOut(): void {
this._UserProfilesStore = undefined;
this._OidcClientStore = undefined;
this._client = undefined;
}
}
-183
View File
@@ -1,183 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type MatrixClient, discoverAndValidateOIDCIssuerWellKnown } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { OidcClient } from "oidc-client-ts";
import {
getStoredOidcTokenIssuer,
getStoredOidcClientId,
getStoredOidcIdToken,
} from "../../utils/oidc/persistOidcSettings";
import PlatformPeg from "../../PlatformPeg";
/**
* @experimental
* Stores information about configured OIDC provider
*
* In OIDC Native mode the client is registered with OIDC directly and maintains an OIDC token.
*
* In OIDC Aware mode, the client is aware that the Server is using OIDC, but is using the standard Matrix APIs for most things.
* (Notable exceptions are account management, where a link to the account management endpoint will be provided instead.)
*
* Otherwise, the store is not operating. Auth is then in Legacy mode and everything uses normal Matrix APIs.
*/
export class OidcClientStore {
private oidcClient?: OidcClient;
private initialisingOidcClientPromise: Promise<void> | undefined;
private authenticatedIssuer?: string; // set only in OIDC-native mode
private _accountManagementEndpoint?: string;
private _accountManagementActionsSupported?: string[];
/**
* Promise which resolves once this store is read to use, which may mean there is no OIDC client if we're in legacy mode,
* or we just have the account management endpoint if running in OIDC-aware mode.
*/
public readonly readyPromise: Promise<void>;
public constructor(private readonly matrixClient: MatrixClient) {
this.readyPromise = this.init();
}
private async init(): Promise<void> {
this.authenticatedIssuer = getStoredOidcTokenIssuer();
if (this.authenticatedIssuer) {
await this.getOidcClient();
} else {
// We are not in OIDC Native mode, as we have no locally stored issuer. Check if the server delegates auth to OIDC.
try {
const authMetadata = await this.matrixClient.getAuthMetadata();
this.setAccountManagementEndpoint(
authMetadata.account_management_uri,
authMetadata.issuer,
authMetadata.account_management_actions_supported,
);
} catch (e) {
console.log("Auth issuer not found", e);
}
}
}
/**
* True when the active user is authenticated via OIDC
*/
public get isUserAuthenticatedWithOidc(): boolean {
return !!this.authenticatedIssuer;
}
private setAccountManagementEndpoint(
endpoint: string | undefined,
issuer: string,
actionsSupported?: string[],
): void {
// if no account endpoint is configured default to the issuer
const url = new URL(endpoint ?? issuer);
const idToken = getStoredOidcIdToken();
if (idToken) {
url.searchParams.set("id_token_hint", idToken);
}
this._accountManagementEndpoint = url.toString();
this._accountManagementActionsSupported = actionsSupported;
}
public get accountManagementEndpoint(): string | undefined {
return this._accountManagementEndpoint;
}
public get accountManagementActionsSupported(): string[] | undefined {
return this._accountManagementActionsSupported;
}
/**
* Revokes provided access and refresh tokens with the configured OIDC provider
* @param accessToken
* @param refreshToken
* @returns Promise that resolves when tokens have been revoked
* @throws when OidcClient cannot be initialised, or revoking either token fails
*/
public async revokeTokens(accessToken?: string, refreshToken?: string): Promise<void> {
const client = await this.getOidcClient();
if (!client) {
throw new Error("No OIDC client");
}
const results = await Promise.all([
this.tryRevokeToken(client, accessToken, "access_token"),
this.tryRevokeToken(client, refreshToken, "refresh_token"),
]);
if (results.some((success) => !success)) {
throw new Error("Failed to revoke tokens");
}
}
/**
* Try to revoke a given token
* @param oidcClient
* @param token
* @param tokenType passed to revocation endpoint as token type hint
* @returns Promise that resolved with boolean whether the token revocation succeeded or not
*/
private async tryRevokeToken(
oidcClient: OidcClient,
token: string | undefined,
tokenType: "access_token" | "refresh_token",
): Promise<boolean> {
try {
if (!token) {
return false;
}
await oidcClient.revokeToken(token, tokenType);
return true;
} catch (error) {
logger.error(`Failed to revoke ${tokenType}`, error);
return false;
}
}
private async getOidcClient(): Promise<OidcClient | undefined> {
if (!this.oidcClient && !this.initialisingOidcClientPromise) {
this.initialisingOidcClientPromise = this.initOidcClient();
}
await this.initialisingOidcClientPromise;
this.initialisingOidcClientPromise = undefined;
return this.oidcClient;
}
/**
* Tries to initialise an OidcClient using stored clientId and OIDC discovery.
* Assigns this.oidcClient and accountManagement endpoint.
* Logs errors and does not throw when oidc client cannot be initialised.
* @returns promise that resolves when initialising OidcClient succeeds or fails
*/
private async initOidcClient(): Promise<void> {
if (!this.authenticatedIssuer) {
logger.error("Cannot initialise OIDC client without issuer.");
return;
}
try {
const clientId = getStoredOidcClientId();
const authMetadata = await discoverAndValidateOIDCIssuerWellKnown(this.authenticatedIssuer);
this.setAccountManagementEndpoint(
authMetadata.account_management_uri,
authMetadata.issuer,
authMetadata.account_management_actions_supported,
);
this.oidcClient = new OidcClient({
authority: authMetadata.issuer,
signingKeys: authMetadata.signingKeys ?? undefined,
redirect_uri: PlatformPeg.get()!.getOidcCallbackUrl().href,
client_id: clientId,
});
} catch (error) {
logger.error("Failed to initialise OidcClientStore", error);
}
}
}
+2 -2
View File
@@ -14,7 +14,7 @@ import {
type IClientWellKnown,
MatrixClient,
MatrixError,
type OidcClientConfig,
type ValidatedAuthMetadata,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
@@ -288,7 +288,7 @@ export default class AutoDiscoveryUtils {
// This isn't inherently auto-discovery but used to be in an earlier incarnation of the MSC,
// and shuttling the data together makes a lot of sense
let delegatedAuthentication: OidcClientConfig | undefined;
let delegatedAuthentication: ValidatedAuthMetadata | undefined;
let delegatedAuthenticationError: Error | undefined;
try {
const tempClient = new MatrixClient({ baseUrl: preferredHomeserverUrl });
+3 -3
View File
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { type ValidatedAuthMetadata } from "matrix-js-sdk/src/matrix";
export interface ValidatedServerConfig {
hsUrl: string;
@@ -24,8 +24,8 @@ export interface ValidatedServerConfig {
/**
* Config related to delegated authentication
* Included when delegated auth is configured and valid, otherwise undefined.
* From issuer's .well-known/openid-configuration.
* From {@link MatrixClient.getAuthMetadata}.
* Used for OIDC native flow authentication.
*/
delegatedAuthentication?: OidcClientConfig;
delegatedAuthentication?: ValidatedAuthMetadata;
}
+14 -6
View File
@@ -17,11 +17,13 @@ import {
LocalStorageCryptoStore,
RoomNameType,
type RoomNameState,
type TokenRefreshFunction,
EventTimelineSet,
EventTimeline,
type OAuth2,
TokenRefresher,
} from "matrix-js-sdk/src/matrix";
import { VerificationMethod } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import indexeddbWorkerFactory from "../workers/indexeddbWorkerFactory";
import SettingsStore from "../settings/SettingsStore";
@@ -29,6 +31,7 @@ import { crossSigningCallbacks } from "../SecurityManager";
import IdentityAuthClient from "../IdentityAuthClient";
import { _t } from "../languageHandler";
import { formatList } from "./FormattingUtils";
import { persistTokens } from "./tokens/tokens.ts";
const localStorage = window.localStorage;
@@ -116,14 +119,19 @@ function roomNameGenerator(_: string, state: RoomNameState): string | null {
* Create a new matrix client from credentials with all the options needed.
*
* @param creds The credentials to create the client with
* @param tokenRefreshFunction Optional function to call when the access token is expired
* @param oauth The OAuth2 instance for OAuth2-native sessions
*
* @returns {MatrixClient} the newly-created MatrixClient
*/
export function createClientWithCreds(
creds: IMatrixClientCreds,
tokenRefreshFunction?: TokenRefreshFunction,
): MatrixClient {
export function createClientWithCreds(creds: IMatrixClientCreds, oauth?: OAuth2): MatrixClient {
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,
+127
View File
@@ -0,0 +1,127 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { OAuth2, OAuth2Error, type ValidatedAuthMetadata } from "matrix-js-sdk/src/matrix";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { OAuthClientError } from "./error";
import PlatformPeg from "../../PlatformPeg";
import { type URLParams } from "../../vector/url_utils.ts";
import { getOAuthParams, loadAuthContext, storeAuthContext } from "./persistOAuthSettings.ts";
const RESPONSE_MODE = "fragment";
/**
* Start OAuth2 authorization code flow
* Generates auth params, stores them in session storage and
* Navigates to configured authorization endpoint
* @param authMetadata from {@link MatrixClient.getAuthMetdata}
* @param clientId this client's id as registered with configured issuer
* @param homeserverUrl target homeserver
* @param identityServerUrl OPTIONAL target identity server
* @param isRegistration if true will set the prompt to "create"
* @returns Promise that resolves after we have navigated to auth endpoint
*/
export const startOAuthLogin = async (
authMetadata: ValidatedAuthMetadata,
clientId: string,
homeserverUrl: string,
identityServerUrl?: string,
isRegistration?: boolean,
): Promise<void> => {
const platform = PlatformPeg.get()!;
const state = secureRandomString(32) + platform.getOAuthClientState();
const auth = new OAuth2(authMetadata, getOAuthParams(clientId));
storeAuthContext({
authContext: auth.context,
metadata: authMetadata,
homeserverUrl,
identityServerUrl,
state,
});
const authorizationUrl = await auth.generateAuthorizationCodeGrantUrl(
state,
RESPONSE_MODE,
isRegistration ? "create" : undefined,
);
window.location.href = authorizationUrl;
};
/**
* Gets `code` and `state` response params
*
* @param urlParams - the parameters to read
* @returns code and state
* @throws when code and state are not valid strings
*/
const getCodeAndStateFromParams = ({
code,
state,
}: NonNullable<URLParams["oauth2"]>): { code: string; state: string } => {
if (!code || typeof code !== "string" || !state || typeof state !== "string") {
throw new Error(OAuthClientError.InvalidFragmentParameters);
}
return { code, state };
};
/**
* Return type for {@link completeOAuthLogin}
* Contains all the credentials gathered from a successful OIDC login
*/
export type CompleteOAuthLoginResponse = {
/**
* URL of the homeserver selected during login
*/
homeserverUrl: string;
/**
* Identity server URL as discovered during login
*/
identityServerUrl?: string;
/**
* Access Token gained from OIDC token issuer
*/
accessToken: string;
/**
* Refresh Token gained from OIDC token issuer, when falsy token cannot be refreshed
*/
refreshToken?: string;
/**
* This client's ID as registered with the OIDC issuer
*/
clientId: string;
};
/**
* Attempt to complete authorization code flow to get an access token
* @param urlParams the parameters extracted from the app-load URI.
* @returns Promise that resolves with a CompleteOAuthLoginResponse when login was successful
* @throws When we failed to get a valid access token
*/
export const completeOAuthLogin = async (
urlParams: NonNullable<URLParams["oauth2"]>,
): Promise<CompleteOAuthLoginResponse> => {
const { code, state } = getCodeAndStateFromParams(urlParams);
const context = loadAuthContext();
if (context?.state !== state) {
throw new Error(OAuth2Error.MissingOrInvalidStoredState);
}
const bearerToken = await new OAuth2(context.metadata, context.authContext).completeAuthorizationCodeGrant(code);
return {
homeserverUrl: context.homeserverUrl,
identityServerUrl: context.identityServerUrl,
accessToken: bearerToken.access_token,
refreshToken: bearerToken.refresh_token,
clientId: context.authContext.clientId,
};
};
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { type ReactNode } from "react";
import { OidcError } from "matrix-js-sdk/src/oidc/error";
import { OAuth2Error } from "matrix-js-sdk/src/matrix";
import { _t } from "../../languageHandler";
@@ -15,8 +15,7 @@ import { _t } from "../../languageHandler";
* Errors thrown by EW 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.",
export enum OAuthClientError {
InvalidFragmentParameters = "Invalid fragment parameters for OIDC native login. `code` and `state` are required.",
}
@@ -26,15 +25,13 @@ export enum OidcClientError {
* @param error
* @returns a friendly translated error message for user consumption
*/
export const getOidcErrorMessage = (error: Error): string | ReactNode => {
export const getOAuthErrorMessage = (error: Error): string | ReactNode => {
switch (error.message) {
case OidcError.MissingOrInvalidStoredState:
case OAuth2Error.MissingOrInvalidStoredState:
return _t("auth|oidc|missing_or_invalid_stored_state");
case OidcClientError.InvalidQueryParameters:
case OidcClientError.InvalidFragmentParameters:
case OidcError.CodeExchangeFailed:
case OidcError.InvalidBearerTokenResponse:
case OidcError.InvalidIdToken:
case OAuthClientError.InvalidFragmentParameters:
case OAuth2Error.CodeExchangeFailed:
case OAuth2Error.InvalidBearerTokenResponse:
default:
return _t("auth|oidc|generic_auth_error");
}
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { type ValidatedAuthMetadata } from "matrix-js-sdk/src/matrix";
/**
* Check the create prompt is supported by the OP, if so, we can do a registration flow
@@ -14,7 +14,7 @@ import { type OidcClientConfig } from "matrix-js-sdk/src/matrix";
* @param delegatedAuthConfig config as returned from discovery
* @returns whether user registration is supported
*/
export const isUserRegistrationSupported = (delegatedAuthConfig: OidcClientConfig): boolean => {
export const isUserRegistrationSupported = (delegatedAuthConfig: ValidatedAuthMetadata): boolean => {
const supportedPrompts = delegatedAuthConfig.prompt_values_supported;
return Array.isArray(supportedPrompts) && supportedPrompts?.includes("create");
};
@@ -0,0 +1,42 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, beforeEach } from "vitest";
import { getStoredOAuthClientId, persistOAuthClientId } from "./persistOAuthSettings";
describe("persist OAuth2 settings", () => {
vi.spyOn(localStorage, "getItem");
vi.spyOn(localStorage, "setItem");
beforeEach(() => {
localStorage.clear();
});
const clientId = "test-client-id";
describe("persistOAuthClientId", () => {
it("should set clientId in localStorage", () => {
persistOAuthClientId(clientId);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_oidc_client_id", clientId);
});
});
describe("getStoredOAuthClientId()", () => {
it("should return clientId from localStorage", () => {
localStorage.setItem("mx_oidc_client_id", clientId);
expect(getStoredOAuthClientId()).toEqual(clientId);
expect(localStorage.getItem).toHaveBeenCalledWith("mx_oidc_client_id");
});
it("should throw when no clientId in localStorage", () => {
expect(() => getStoredOAuthClientId()).toThrow("OAuth client ID not found in storage");
});
});
});
@@ -0,0 +1,82 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type OAuth2, type ValidatedAuthMetadata } from "matrix-js-sdk/src/matrix";
import PlatformPeg from "../../PlatformPeg.ts";
const clientIdLocalStorageKey = "mx_oidc_client_id";
const stateSessionStorageKey = "mx_oauth_state";
/**
* Persists OAuth2 clientId in local storage
* Only set after successful authentication
* @param clientId
*/
export const persistOAuthClientId = (clientId: string): void => {
localStorage.setItem(clientIdLocalStorageKey, clientId);
};
/**
* Retrieves stored oauth client id from local storage.
* The presence of a client ID in storage implies that the user is authenticated via OAuth.
* @returns clientId
* @throws when clientId is not found in local storage
*/
export const getStoredOAuthClientId = (): string => {
const clientId = localStorage.getItem(clientIdLocalStorageKey);
if (!clientId) {
throw new Error("OAuth client ID not found in storage");
}
return clientId;
};
type OAuth2Context = ConstructorParameters<typeof OAuth2>[1];
/**
* Utility function to get the OAuth parameters needed to construct an OAuth2 instance
* @param clientId - the registered OAuth client ID
*/
export function getOAuthParams(clientId: string): OAuth2Context {
const platform = PlatformPeg.get()!;
const redirectUri = platform.getOAuthCallbackUrl().href;
return { clientId, redirectUri };
}
/**
* Temporary context for authorization code flow
* Persisted via sessionStorage to be recalled when authentication navigates the tab away and back again
*/
export interface Context {
/** The state string we included in the auth url */
state: string;
/** The URL of the homeserver the user is logging into */
homeserverUrl: string;
/** The URL of the identity server the user is using */
identityServerUrl: string | undefined;
/** The metadata received from {@link MatrixClient.getAuthMetadata} at time of initiating the auth dance */
metadata: ValidatedAuthMetadata;
/** The context needed for the SDK's OAuth2 to resume the auth flow */
authContext: Required<OAuth2Context>;
}
/**
* Retrieve the context of the ongoing authorization code flow from sessionStorage
*/
export function loadAuthContext(): Context | null {
const value = sessionStorage.getItem(stateSessionStorageKey);
return JSON.parse(value!);
}
/**
* Temporary storage for the authorization code flow
* @param context - the data to store in sessionStorage
*/
export function storeAuthContext(context: Context): void {
sessionStorage.setItem(stateSessionStorageKey, JSON.stringify(context));
}
@@ -7,43 +7,43 @@ Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { registerOidcClient, type OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { OAuth2, type ValidatedAuthMetadata } from "matrix-js-sdk/src/matrix";
import { type IConfigOptions } from "../../IConfigOptions";
import PlatformPeg from "../../PlatformPeg";
/**
* Get the statically configured clientId for the issuer
* @param issuer delegated auth OIDC issuer
* @param staticOidcClients static client config from config.json
* @param issuer delegated auth OAuth2 issuer
* @param staticOAuthClients static client config from config.json
* @returns clientId if found, otherwise undefined
*/
const getStaticOidcClientId = (
const getStaticOAuthClientId = (
issuer: string,
staticOidcClients?: IConfigOptions["oidc_static_clients"],
staticOAuthClients?: IConfigOptions["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;
return staticOAuthClients?.[issuerWithTrailingSlash]?.client_id;
};
/**
* Get the clientId for an OIDC OP
* Get the clientId for an OAuth2 OP
* Checks statically configured clientIds first
* Then attempts dynamic registration with the OP
* @param delegatedAuthConfig Auth config from ValidatedServerConfig
* @param staticOidcClients static client config from config.json
* @param staticOAuthClients static client config from config.json
* @returns Promise<string> resolves with clientId
* @throws if no clientId is found
*/
export const getOidcClientId = async (
delegatedAuthConfig: OidcClientConfig,
staticOidcClients?: IConfigOptions["oidc_static_clients"],
export const getOAuthClientId = async (
delegatedAuthConfig: ValidatedAuthMetadata,
staticOAuthClients?: IConfigOptions["oidc_static_clients"],
): Promise<string> => {
const staticClientId = getStaticOidcClientId(delegatedAuthConfig.issuer, staticOidcClients);
const staticClientId = getStaticOAuthClientId(delegatedAuthConfig.issuer, staticOAuthClients);
if (staticClientId) {
logger.debug(`Using static clientId for issuer ${delegatedAuthConfig.issuer}`);
return staticClientId;
}
return await registerOidcClient(delegatedAuthConfig, await PlatformPeg.get()!.getOidcClientMetadata());
return await OAuth2.registerClient(delegatedAuthConfig, await PlatformPeg.get()!.getOAuthClientMetadata());
};
-36
View File
@@ -1,36 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { OidcTokenRefresher, type AccessTokens } from "matrix-js-sdk/src/matrix";
import { type IdTokenClaims } from "oidc-client-ts";
import PlatformPeg from "../../PlatformPeg";
import { persistAccessTokenInStorage, persistRefreshTokenInStorage } from "../tokens/tokens";
/**
* OidcTokenRefresher that implements token persistence.
* Stores tokens in the same way as login flow in Lifecycle.
*/
export class TokenRefresher extends OidcTokenRefresher {
public constructor(
issuer: string,
clientId: string,
redirectUri: string,
deviceId: string,
idTokenClaims: IdTokenClaims,
private readonly userId: string,
) {
super(issuer, clientId, redirectUri, deviceId, idTokenClaims);
}
public async persistTokens({ accessToken, refreshToken }: AccessTokens): Promise<void> {
const pickleKey = (await PlatformPeg.get()?.getPickleKey(this.userId, this.deviceId)) ?? undefined;
await persistAccessTokenInStorage(accessToken, pickleKey);
await persistRefreshTokenInStorage(refreshToken, pickleKey);
}
}
-143
View File
@@ -1,143 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { completeAuthorizationCodeGrant, generateOidcAuthorizationUrl } from "matrix-js-sdk/src/oidc/authorize";
import { type OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { type IdTokenClaims } from "oidc-client-ts";
import { OidcClientError } from "./error";
import PlatformPeg from "../../PlatformPeg";
import { type URLParams } from "../../vector/url_utils.ts";
/**
* 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
* @param identityServerUrl OPTIONAL target identity server
* @param isRegistration if true will set the prompt to "create"
* @returns Promise that resolves after we have navigated to auth endpoint
*/
export const startOidcLogin = async (
delegatedAuthConfig: OidcClientConfig,
clientId: string,
homeserverUrl: string,
identityServerUrl?: string,
isRegistration?: boolean,
): Promise<void> => {
const redirectUri = PlatformPeg.get()!.getOidcCallbackUrl().href;
const nonce = secureRandomString(10);
const prompt = isRegistration ? "create" : undefined;
const authorizationUrl = await generateOidcAuthorizationUrl({
metadata: delegatedAuthConfig,
redirectUri,
clientId,
homeserverUrl,
identityServerUrl,
nonce,
prompt,
urlState: PlatformPeg.get()?.getOidcClientState(),
responseMode: delegatedAuthConfig.response_modes_supported?.includes("fragment") ? "fragment" : "query",
});
window.location.href = authorizationUrl;
};
/**
* Gets `code` and `state` response params
*
* @param urlParams - the parameters to read
* @param responseMode - the response_mode used in the auth request
* @returns code and state
* @throws when code and state are not valid strings
*/
const getCodeAndStateFromParams = (
{ code, state }: NonNullable<URLParams["oidc_fragment"]>,
responseMode: "fragment" | "query",
): { code: string; state: string } => {
if (!code || typeof code !== "string" || !state || typeof state !== "string") {
if (responseMode === "fragment") {
throw new Error(OidcClientError.InvalidFragmentParameters);
} else {
throw new Error(OidcClientError.InvalidQueryParameters);
}
}
return { code, state };
};
/**
* Return type for {@link completeOidcLogin}
* Contains all the credentials gathered from a successful OIDC login
*/
export type CompleteOidcLoginResponse = {
/**
* URL of the homeserver selected during login
*/
homeserverUrl: string;
/**
* Identity server URL as discovered during login
*/
identityServerUrl?: string;
/**
* Access Token gained from OIDC token issuer
*/
accessToken: string;
/**
* Refresh Token gained from OIDC token issuer, when falsy token cannot be refreshed
*/
refreshToken?: string;
/**
* ID Token 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;
};
/**
* Attempt to complete authorization code flow to get an access token
* @param urlParams the parameters extracted from the app-load URI.
* @param responseMode - the response_mode used in the auth request
* @returns Promise that resolves with a CompleteOidcLoginResponse when login was successful
* @throws When we failed to get a valid access token
*/
export const completeOidcLogin = async (
urlParams: NonNullable<URLParams["oidc_fragment"]>,
responseMode: "fragment" | "query",
): Promise<CompleteOidcLoginResponse> => {
const { code, state } = getCodeAndStateFromParams(urlParams, responseMode);
const { homeserverUrl, tokenResponse, idTokenClaims, identityServerUrl, oidcClientSettings } =
await completeAuthorizationCodeGrant(code, state, responseMode);
return {
homeserverUrl,
identityServerUrl,
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
idToken: tokenResponse.id_token,
clientId: oidcClientSettings.clientId,
issuer: oidcClientSettings.issuer,
idTokenClaims,
};
};
@@ -1,116 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, beforeEach } from "vitest";
import { type IdTokenClaims } from "oidc-client-ts";
import { decodeIdToken } from "matrix-js-sdk/src/matrix";
import {
getStoredOidcClientId,
getStoredOidcIdToken,
getStoredOidcIdTokenClaims,
getStoredOidcTokenIssuer,
persistOidcAuthenticatedSettings,
} from "./persistOidcSettings";
vi.mock("matrix-js-sdk/src/matrix");
describe("persist OIDC settings", () => {
vi.spyOn(localStorage, "getItem");
vi.spyOn(localStorage, "setItem");
beforeEach(() => {
localStorage.clear();
});
const clientId = "test-client-id";
const issuer = "https://auth.org/";
const idToken = "test-id-token";
const idTokenClaims: IdTokenClaims = {
// audience is this client
aud: "123",
// issuer matches
iss: issuer,
sub: "123",
exp: 123,
iat: 456,
};
describe("persistOidcAuthenticatedSettings", () => {
it("should set clientId and issuer in localStorage", () => {
persistOidcAuthenticatedSettings(clientId, issuer, idToken);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_oidc_client_id", clientId);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_oidc_token_issuer", issuer);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_oidc_id_token", idToken);
});
it("should not set idToken in localStorage when idToken is undefined", () => {
persistOidcAuthenticatedSettings(clientId, issuer, undefined);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_oidc_client_id", clientId);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_oidc_token_issuer", issuer);
expect(localStorage.getItem("mx_oidc_id_token")).toBeFalsy();
});
});
describe("getStoredOidcTokenIssuer()", () => {
it("should return issuer from localStorage", () => {
localStorage.setItem("mx_oidc_token_issuer", issuer);
expect(getStoredOidcTokenIssuer()).toEqual(issuer);
expect(localStorage.getItem).toHaveBeenCalledWith("mx_oidc_token_issuer");
});
it("should return undefined when no issuer in localStorage", () => {
expect(getStoredOidcTokenIssuer()).toBeUndefined();
});
});
describe("getStoredOidcClientId()", () => {
it("should return clientId from localStorage", () => {
localStorage.setItem("mx_oidc_client_id", clientId);
expect(getStoredOidcClientId()).toEqual(clientId);
expect(localStorage.getItem).toHaveBeenCalledWith("mx_oidc_client_id");
});
it("should throw when no clientId in localStorage", () => {
expect(() => getStoredOidcClientId()).toThrow("Oidc client id not found in storage");
});
});
describe("getStoredOidcIdToken()", () => {
it("should return token from localStorage", () => {
localStorage.setItem("mx_oidc_id_token", idToken);
expect(getStoredOidcIdToken()).toEqual(idToken);
expect(localStorage.getItem).toHaveBeenCalledWith("mx_oidc_id_token");
});
it("should return undefined when no token in localStorage", () => {
expect(getStoredOidcIdToken()).toBeUndefined();
});
});
describe("getStoredOidcIdTokenClaims()", () => {
it("should return claims from localStorage", () => {
localStorage.setItem("mx_oidc_id_token_claims", JSON.stringify(idTokenClaims));
expect(getStoredOidcIdTokenClaims()).toEqual(idTokenClaims);
expect(localStorage.getItem).toHaveBeenCalledWith("mx_oidc_id_token_claims");
});
it("should return claims extracted from id_token in localStorage", () => {
localStorage.setItem("mx_oidc_id_token", idToken);
vi.mocked(decodeIdToken).mockReturnValue(idTokenClaims);
expect(getStoredOidcIdTokenClaims()).toEqual(idTokenClaims);
expect(decodeIdToken).toHaveBeenCalledWith(idToken);
expect(localStorage.getItem).toHaveBeenCalledWith("mx_oidc_id_token_claims");
});
it("should return undefined when no claims in localStorage", () => {
expect(getStoredOidcIdTokenClaims()).toBeUndefined();
});
});
});
@@ -1,85 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type IdTokenClaims } from "oidc-client-ts";
import { decodeIdToken } from "matrix-js-sdk/src/matrix";
const clientIdStorageKey = "mx_oidc_client_id";
const tokenIssuerStorageKey = "mx_oidc_token_issuer";
const idTokenStorageKey = "mx_oidc_id_token";
/**
* @deprecated in favour of using idTokenStorageKey
*/
const idTokenClaimsStorageKey = "mx_oidc_id_token_claims";
/**
* Persists oidc clientId and issuer in local storage
* Only set after successful authentication
* @param clientId
* @param issuer
* @param idToken
* @param idTokenClaims
*/
export const persistOidcAuthenticatedSettings = (
clientId: string,
issuer: string,
idToken: string | undefined,
): void => {
localStorage.setItem(clientIdStorageKey, clientId);
localStorage.setItem(tokenIssuerStorageKey, issuer);
if (idToken) {
localStorage.setItem(idTokenStorageKey, idToken);
}
};
/**
* Retrieve stored oidc issuer from local storage
* When user has token from OIDC issuer, this will be set
* @returns issuer or undefined
*/
export const getStoredOidcTokenIssuer = (): string | undefined => {
return localStorage.getItem(tokenIssuerStorageKey) ?? undefined;
};
/**
* Retrieves stored oidc client id from local storage
* @returns clientId
* @throws when clientId is not found in local storage
*/
export const getStoredOidcClientId = (): string => {
const clientId = localStorage.getItem(clientIdStorageKey);
if (!clientId) {
throw new Error("Oidc client id not found in storage");
}
return clientId;
};
/**
* Retrieve stored id token claims from stored id token or local storage
* @returns idTokenClaims or undefined
*/
export const getStoredOidcIdTokenClaims = (): IdTokenClaims | undefined => {
const idToken = getStoredOidcIdToken();
if (idToken) {
return decodeIdToken(idToken);
}
const idTokenClaims = localStorage.getItem(idTokenClaimsStorageKey);
if (!idTokenClaims) {
return;
}
return JSON.parse(idTokenClaims) as IdTokenClaims;
};
/**
* Retrieve stored id token from local storage
* @returns idToken or undefined
*/
export const getStoredOidcIdToken = (): string | undefined => {
return localStorage.getItem(idTokenStorageKey) ?? undefined;
};
+11 -26
View File
@@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { type AccessTokens } from "matrix-js-sdk/src/matrix";
import decryptAESSecretStorageItem from "matrix-js-sdk/src/utils/decryptAESSecretStorageItem";
import encryptAESSecretStorageItem from "matrix-js-sdk/src/utils/encryptAESSecretStorageItem";
import { type AESEncryptedSecretStoragePayload } from "matrix-js-sdk/src/types";
@@ -116,7 +117,7 @@ export async function tryDecryptToken(
* @param hasTokenStorageKey Localstorage key for an item which stores whether we expect to have a token in indexeddb,
* eg "mx_has_access_token".
*/
export async function persistTokenInStorage(
async function persistTokenInStorage(
storageKey: string,
tokenName: string,
token: string | undefined,
@@ -174,40 +175,24 @@ export async function persistTokenInStorage(
}
/**
* Wraps {@link persistTokenInStorage} with accessToken storage keys
* Wraps {@link persistTokenInStorage} with accessToken & refreshToken storage keys
*
* @param token - The token to store. When undefined, any existing accessToken is removed from storage.
* @param pickleKey - Pickle key: used to derive the key used to encrypt token. If `undefined`, the token will be stored
* unencrypted.
* @param tokens - The tokens to persist
* @param pickleKey - Pickle key: used to derive the key used to encrypt token.
* If `undefined`, the token will be stored unencrypted.
*/
export async function persistAccessTokenInStorage(
token: string | undefined,
pickleKey: string | undefined,
): Promise<void> {
return persistTokenInStorage(
export async function persistTokens(pickleKey: string | undefined, tokens: AccessTokens): Promise<void> {
await persistTokenInStorage(
ACCESS_TOKEN_STORAGE_KEY,
ACCESS_TOKEN_IV,
token,
tokens.accessToken,
pickleKey,
HAS_ACCESS_TOKEN_STORAGE_KEY,
);
}
/**
* Wraps {@link persistTokenInStorage} with refreshToken storage keys.
*
* @param token - The token to store. When undefined, any existing refreshToken is removed from storage.
* @param pickleKey - Pickle key: used to derive the key used to encrypt token. If `undefined`, the token will be stored
* unencrypted.
*/
export async function persistRefreshTokenInStorage(
token: string | undefined,
pickleKey: string | undefined,
): Promise<void> {
return persistTokenInStorage(
await persistTokenInStorage(
REFRESH_TOKEN_STORAGE_KEY,
REFRESH_TOKEN_IV,
token,
tokens.refreshToken,
pickleKey,
HAS_REFRESH_TOKEN_STORAGE_KEY,
);
+10 -9
View File
@@ -12,7 +12,7 @@ import { vi, describe, it, expect, afterAll, beforeEach } from "vitest";
import fetchMock from "@fetch-mock/vitest";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { mockPlatformPeg, unmockPlatformPeg } from "test-utils";
import { makeDelegatedAuthConfig } from "test-utils/oidc";
import { makeDelegatedAuthMetadata } from "test-utils/auth";
import { type RefCallback } from "react";
import { loadApp } from "./app.tsx";
@@ -54,7 +54,7 @@ describe("sso_redirect_options", () => {
oidc_static_clients: { [issuer]: { client_id: "12345" } },
});
// Signal we support v1.1 to pass the minimum js-sdk compatibility bar
// Signal we support v1.15 to use stable Native OIDC support
// Signal we support v1.15 to use stable Native OAuth2 support
fetchMock.get("https://synapse/_matrix/client/versions", { versions: ["v1.1", "v1.15"] });
});
@@ -69,17 +69,18 @@ describe("sso_redirect_options", () => {
expect(startSingleSignOnSpy).toHaveBeenCalledWith(expect.any(MatrixClient), "sso", "/room/#room:server");
});
it("should redirect for native OIDC", async () => {
const authConfig = { ...makeDelegatedAuthConfig(issuer), response_modes_supported: ["query", "fragment"] };
it("should redirect for native OAuth2", async () => {
const authConfig = {
...makeDelegatedAuthMetadata(issuer),
response_modes_supported: ["query", "fragment"],
};
fetchMock.get("https://synapse/_matrix/client/v1/auth_metadata", authConfig);
fetchMock.get(`${authConfig.issuer}.well-known/openid-configuration`, authConfig);
fetchMock.get(authConfig.jwks_uri!, { keys: [] });
const startOidcLoginSpy = vi.spyOn(window.location, "href", "set");
const startOAuthLoginSpy = vi.spyOn(window.location, "href", "set");
await loadApp({}, vi.fn() as RefCallback<MatrixChat>);
expect(startOidcLoginSpy).toHaveBeenCalledWith(
"https://auth.org/auth?client_id=12345&redirect_uri=https%3A%2F%2Fapp.element.io%2F%3Fno_universal_links%3Dtrue&response_type=code&scope=openid+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Aapi%3A*+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Adevice%3AABCDEFGHIJ&nonce=ABCDEFGHIJ&state=10000000100040008000100000000000&code_challenge=awE81eIsGff70JahvrTqWRbGKLI10ooyo_Xm1sxuZvU&code_challenge_method=S256&response_mode=fragment",
expect(startOAuthLoginSpy).toHaveBeenCalledWith(
"https://auth.org/auth?response_type=code&response_mode=fragment&client_id=12345&redirect_uri=https%3A%2F%2Fapp.element.io%2F%3Fno_universal_links%3Dtrue&scope=urn%3Amatrix%3Aclient%3Aapi%3A*+urn%3Amatrix%3Aclient%3Adevice%3AABCDEFGHIJ&state=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef&code_challenge_method=S256&code_challenge=ymW9_yTzfYF1Km4N7W4OC6jQ7xoj91DUulQHWfmrROM",
);
});
});
+6 -6
View File
@@ -34,7 +34,7 @@ import { RoomView } from "../components/structures/RoomView";
import RoomAvatar from "../components/views/avatars/RoomAvatar";
import { ModuleNotificationDecoration } from "../modules/components/ModuleNotificationDecoration";
import Login from "../Login.ts";
import { startOidcLogin } from "../utils/oidc/authorize.ts";
import { startOAuthLogin } from "../utils/oauth/authorize.ts";
logger.log(`Application is running in ${process.env.NODE_ENV} mode`);
@@ -44,7 +44,7 @@ function onTokenLoginCompleted(urlParams: URLParams, fragmentAfterLogin: string)
const url = new URL(window.location.href);
// if we did a token login, we're now left with the login token as query param in the url; clear it out
for (const param in { ...urlParams.legacy_sso, ...urlParams.oidc_query }) {
for (const param in { ...urlParams.legacy_sso }) {
url.searchParams.delete(param);
}
@@ -68,9 +68,9 @@ async function redirectToSso(config: ValidatedServerConfig): Promise<boolean> {
});
const flows = await login.getFlows();
const nativeOidcFlow = flows.find((flow) => "clientId" in flow);
if (nativeOidcFlow && config.delegatedAuthentication) {
await startOidcLogin(config.delegatedAuthentication, nativeOidcFlow.clientId, config.hsUrl, config.isUrl);
const nativeOAuthFlow = flows.find((flow) => "clientId" in flow);
if (nativeOAuthFlow && config.delegatedAuthentication) {
await startOAuthLogin(config.delegatedAuthentication, nativeOAuthFlow.clientId, config.hsUrl, config.isUrl);
return true;
}
@@ -112,7 +112,7 @@ export async function loadApp(urlParams: URLParams, matrixChatRef: React.Ref<Mat
// Before we continue, let's see if we're supposed to do an SSO redirect
const [userId] = await Lifecycle.getStoredSessionOwner();
const hasPossibleToken = !!userId;
const isReturningFromSso = !!urlParams.legacy_sso || !!urlParams.oidc_fragment || !!urlParams.oidc_query;
const isReturningFromSso = !!urlParams.legacy_sso || !!urlParams.oauth2;
const ssoRedirects = config.sso_redirect_options || {};
let autoRedirect = ssoRedirects.immediate === true;
// XXX: This path matching is a bit brittle, but better to do it early instead of in the app code.
@@ -86,7 +86,7 @@ describe("ElectronPlatform", () => {
it("should return oidc client state as expected", async () => {
const platform = new ElectronPlatform();
await platform.getConfig();
expect(platform.getOidcClientState()).toMatchInlineSnapshot(`":element-desktop-ssoid:session-id"`);
expect(platform.getOAuthClientState()).toMatchInlineSnapshot(`":element-desktop-ssoid:session-id"`);
});
it("dispatches view settings action on preferences event", () => {
@@ -14,7 +14,7 @@ import {
type MatrixClient,
type Room,
type MatrixEvent,
type OidcRegistrationClientMetadata,
type OAuthRegistrationRequest,
} from "matrix-js-sdk/src/matrix";
import React from "react";
import { logger } from "matrix-js-sdk/src/logger";
@@ -531,28 +531,28 @@ export default class ElectronPlatform extends BasePlatform {
return (SdkConfig.get() as unknown as Record<string, string>)["web_base_url"] ?? "https://app.element.io";
}
public get defaultOidcClientUri(): string {
public get defaultOAuthClientUri(): string {
// Default to element.io as our scheme `io.element.desktop` is within its scope on default MAS policies
return "https://element.io";
}
public async getOidcClientMetadata(): Promise<OidcRegistrationClientMetadata> {
const baseMetadata = await super.getOidcClientMetadata();
public async getOAuthClientMetadata(): Promise<OAuthRegistrationRequest> {
const baseMetadata = await super.getOAuthClientMetadata();
return {
...baseMetadata,
applicationType: "native",
application_type: "native",
};
}
public getOidcClientState(): string {
public getOAuthClientState(): string {
return `:${SSO_ID_KEY}:${this.sessionId}`;
}
/**
* The URL to return to after a successful OIDC authentication
*/
public getOidcCallbackUrl(): URL {
const url = super.getOidcCallbackUrl();
public getOAuthCallbackUrl(): URL {
const url = super.getOAuthCallbackUrl();
url.protocol = this.protocol;
// Trim the double slash into a single slash to comply with https://datatracker.ietf.org/doc/html/rfc8252#section-7.1
if (url.href.startsWith(`${url.protocol}//`)) {
@@ -269,7 +269,7 @@ describe("WebPlatform", () => {
expect(spy).toHaveBeenCalledWith(expect.anything(), { bgColor: "#f00" });
});
describe("getOidcCallbackUrl()", () => {
describe("getOAuthCallbackUrl()", () => {
it("should not include the 'updated' query param in the redirect URI", () => {
Object.defineProperty(window, "location", {
value: {
@@ -280,7 +280,7 @@ describe("WebPlatform", () => {
writable: true,
});
const platform = new WebPlatform();
const url = platform.getOidcCallbackUrl();
const url = platform.getOAuthCallbackUrl();
expect(url.searchParams.has("updated")).toBe(false);
expect(url.searchParams.get("no_universal_links")).toEqual("true");
+3 -10
View File
@@ -45,18 +45,11 @@ describe("parseUrlParameters", () => {
expect(parsed.params.legacy_sso?.loginToken).toEqual("foobar");
});
it("should parse oidc parameters from fragment", () => {
it("should parse oauth2 parameters from fragment", () => {
const u = new URL("https://app.element.io/#code=foobar&state=barfoo");
const parsed = parseAppUrl(u);
expect(parsed.params.oidc_fragment?.code).toEqual("foobar");
expect(parsed.params.oidc_fragment?.state).toEqual("barfoo");
});
it("should parse oidc parameters from query", () => {
const u = new URL("https://app.element.io/?code=foobar&state=barfoo");
const parsed = parseAppUrl(u);
expect(parsed.params.oidc_query?.code).toEqual("foobar");
expect(parsed.params.oidc_query?.state).toEqual("barfoo");
expect(parsed.params.oauth2?.code).toEqual("foobar");
expect(parsed.params.oauth2?.state).toEqual("barfoo");
});
it("should parse guest parameters", () => {
+2 -7
View File
@@ -54,16 +54,11 @@ const urlParameterConfig = {
keys: ["loginToken"],
location: "query",
},
// Fragment params for OIDC login, added by the Identity Provider
oidc_fragment: {
// Fragment params for OAuth2 login, added by the Identity Provider
oauth2: {
keys: ["code", "state"],
location: "fragment",
},
// Query params for OIDC login, added by the Identity Provider, used as fallback when fragment is unsupported
oidc_query: {
keys: ["code", "state"],
location: "query",
},
// Fragment params relating to 3pid (email) invites, added in url within the invite email itself
threepid: {
keys: ["client_secret", "session_id", "hs_url", "is_url", "sid"],
@@ -39,7 +39,6 @@ export class UserMenuViewModel
client: MatrixClient,
ownProfileStore: OwnProfileStore,
isPanelCollapsed: boolean,
accountManagementEndpoint?: string,
): UserMenuSnapshot {
const hasHomePage = !!getHomePageUrl(SdkConfig.get(), client);
const isAuthenticated = !client.isGuest();
@@ -58,7 +57,7 @@ export class UserMenuViewModel
displayName,
avatarUrl,
expanded: !isPanelCollapsed,
manageAccountHref: accountManagementEndpoint,
manageAccountHref: undefined, // loaded async
showAvatar: isAuthenticated,
userStatus: ownProfileStore.userStatus,
showUserStatus: SettingsStore.getValue("feature_user_status") && isAuthenticated,
@@ -80,19 +79,11 @@ export class UserMenuViewModel
private readonly dispatcher: MatrixDispatcher,
private readonly client: MatrixClient,
isPanelCollapsed: boolean,
accountManagementEndpoint?: string,
) {
super(
props,
UserMenuViewModel.computeSnapshot(
client,
props.ownProfileStore,
isPanelCollapsed,
accountManagementEndpoint,
),
);
super(props, UserMenuViewModel.computeSnapshot(client, props.ownProfileStore, isPanelCollapsed));
this.setStatusVm = new UserMenuSetStatusViewModel({ client, ownProfileStore: props.ownProfileStore });
props.ownProfileStore.on(UPDATE_EVENT, this.recalculateProfile);
this.loadAuthMetadata();
}
public dispose(): void {
@@ -166,4 +157,9 @@ export class UserMenuViewModel
logger.warn("Failed to clear user status", err);
});
};
private async loadAuthMetadata(): Promise<void> {
const authMetadata = await this.client.getAuthMetadata().catch(() => {});
this.snapshot.merge({ manageAccountHref: authMetadata?.account_management_uri });
}
}
@@ -6,4 +6,4 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
export { makeDelegatedAuthConfig, mockOpenIdConfiguration } from "matrix-js-sdk/src/testing";
export { makeDelegatedAuthMetadata } from "matrix-js-sdk/src/testing";
+2 -2
View File
@@ -27,7 +27,7 @@ import {
type IPushRules,
RelationType,
JoinRule,
type OidcClientConfig,
type ValidatedAuthMetadata,
type GroupCall,
type EventStatus,
type ICreateRoomOpts,
@@ -778,7 +778,7 @@ export function mkRoomState(
export function mkServerConfig(
hsUrl: string,
isUrl: string,
delegatedAuthentication?: OidcClientConfig,
delegatedAuthentication?: ValidatedAuthMetadata,
): ValidatedServerConfig {
return {
hsUrl,
@@ -57,6 +57,7 @@ describe("<LoggedInView />", () => {
setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
deleteExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(true),
getAuthMetadata: jest.fn().mockRejectedValue(new Error("Legacy auth")),
});
const mediaHandler = new MediaHandler(mockClient);
const mockSdkContext = new TestSDKContext();
@@ -9,13 +9,19 @@ Please see LICENSE files in the repository root for full details.
import React, { type ComponentProps, createRef, type RefObject } from "react";
import { fireEvent, render, type RenderResult, screen, waitFor, within, act } from "jest-matrix-react";
import { type Mocked, mocked } from "jest-mock-vitest-adapter";
import { ClientEvent, type MatrixClient, MatrixEvent, Room, SyncState } from "matrix-js-sdk/src/matrix";
import {
ClientEvent,
type MatrixClient,
MatrixEvent,
Room,
SyncState,
OAuth2,
type BearerTokenResponse,
OAuth2Error,
} from "matrix-js-sdk/src/matrix";
import { type MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler";
import * as MatrixJs from "matrix-js-sdk/src/matrix";
import { completeAuthorizationCodeGrant } from "matrix-js-sdk/src/oidc/authorize";
import { logger } from "matrix-js-sdk/src/logger";
import { OidcError } from "matrix-js-sdk/src/oidc/error";
import { type BearerTokenResponse } from "matrix-js-sdk/src/oidc/validate";
import { sleep } from "matrix-js-sdk/src/utils";
import {
CryptoEvent,
@@ -45,7 +51,7 @@ import {
unmockClientPeg,
} from "../../../test-utils";
import * as leaveRoomUtils from "../../../../src/utils/leave-behaviour";
import { OidcClientError } from "../../../../src/utils/oidc/error";
import { OAuthClientError } from "../../../../src/utils/oauth/error";
import { CallStore } from "../../../../src/stores/CallStore";
import { type Call } from "../../../../src/models/Call";
import { PosthogAnalytics } from "../../../../src/PosthogAnalytics";
@@ -68,12 +74,9 @@ import { ShareFormat } from "../../../../src/dispatcher/payloads/SharePayload.ts
import { clearStorage } from "../../../../src/Lifecycle";
import UserSettingsDialog from "../../../../src/components/views/dialogs/UserSettingsDialog.tsx";
import { SDKContextClass } from "../../../../src/contexts/SDKContextClass";
import { makeDelegatedAuthConfig } from "../../../test-utils/oidc.ts";
import { makeDelegatedAuthMetadata } from "../../../test-utils/auth.ts";
import { type QrLoginCredentials } from "../../../../src/components/views/auth/LoginWithQR.tsx";
jest.mock("matrix-js-sdk/src/oidc/authorize", () => ({
completeAuthorizationCodeGrant: jest.fn(),
}));
import { storeAuthContext } from "../../../../src/utils/oauth/persistOAuthSettings.ts";
// Stub out ThemeWatcher as the necessary bits for themes are done in element-web's index.html and thus are lacking here,
// plus JSDOM's implementation of CSSStyleDeclaration has a bunch of differences to real browsers which cause issues.
@@ -193,6 +196,7 @@ describe("<MatrixChat />", () => {
logout: jest.fn(),
getDeviceId: jest.fn(),
forget: () => Promise.resolve(),
getAuthMetadata: jest.fn().mockRejectedValue(new Error("Legacy auth")),
});
let mockClient: Mocked<MatrixClient>;
const serverConfig = {
@@ -268,6 +272,10 @@ describe("<MatrixChat />", () => {
bootstrapDeferred = Promise.withResolvers();
await clearAllModals();
jest.spyOn(OAuth2.prototype, "completeAuthorizationCodeGrant").mockImplementation(
(code) => new Promise<BearerTokenResponse>(() => {}),
);
});
afterEach(async () => {
@@ -341,16 +349,16 @@ describe("<MatrixChat />", () => {
describe("qr login", () => {
beforeEach(() => {
const authConfig = makeDelegatedAuthConfig();
const authConfig = makeDelegatedAuthMetadata();
defaultProps.config.validated_server_config!.delegatedAuthentication = authConfig;
fetchMock.post(authConfig.registration_endpoint!, { client_id: "abc123" });
mockPlatformPeg({
getOidcClientMetadata: jest.fn().mockReturnValue({
clientName: "App name",
clientUri: "https://company",
redirectUris: ["https://app"],
logoUri: "https://company/logo.png",
applicationType: "web",
getOAuthClientMetadata: jest.fn().mockReturnValue({
client_name: "App name",
client_uri: "https://company",
redirect_uris: ["https://app"],
logo_uri: "https://company/logo.png",
application_type: "web",
}),
});
jest.spyOn(qrLogin, "signInByGeneratingQR").mockReturnValue(new Promise(() => {}));
@@ -384,8 +392,6 @@ describe("<MatrixChat />", () => {
accessToken: "at",
homeserverUrl: "https://homeserver",
clientId: "ci",
idToken: "it",
issuer: defaultProps.config.validated_server_config!.delegatedAuthentication!.issuer,
deviceId: "di",
secrets: {
cross_signing: {
@@ -452,7 +458,6 @@ describe("<MatrixChat />", () => {
});
describe("when query params have a OIDC params", () => {
const issuer = "https://auth.com/";
const homeserverUrl = "https://matrix.org";
const identityServerUrl = "https://is.org";
const clientId = "xyz789";
@@ -460,7 +465,7 @@ describe("<MatrixChat />", () => {
const code = "test-oidc-auth-code";
const state = "test-oidc-state";
const urlParams = {
oidc_fragment: {
oauth2: {
code,
state: state,
},
@@ -472,15 +477,14 @@ describe("<MatrixChat />", () => {
const tokenResponse: BearerTokenResponse = {
access_token: accessToken,
refresh_token: undefined,
id_token: "ghi789",
scope: "test",
token_type: "Bearer",
expires_at: 12345,
expires_in: 12345,
};
let loginClient!: ReturnType<typeof getMockClientWithEventEmitter>;
const expectOIDCError = async (
const expectOAuthError = async (
errorMessage = "Something went wrong during authentication. Go to the sign in page and try again.",
): Promise<void> => {
await flushPromises();
@@ -490,24 +494,7 @@ describe("<MatrixChat />", () => {
};
beforeEach(() => {
mocked(completeAuthorizationCodeGrant)
.mockClear()
.mockResolvedValue({
oidcClientSettings: {
clientId,
issuer,
},
tokenResponse,
homeserverUrl,
identityServerUrl,
idTokenClaims: {
aud: "123",
iss: issuer,
sub: "123",
exp: 123,
iat: 456,
},
});
mocked(OAuth2.prototype.completeAuthorizationCodeGrant).mockResolvedValue(tokenResponse);
loginClient = getMockClientWithEventEmitter(getMockClientMethods());
// this is used to create a temporary client during login
@@ -521,11 +508,23 @@ describe("<MatrixChat />", () => {
device_id: deviceId,
is_guest: false,
});
storeAuthContext({
homeserverUrl,
identityServerUrl,
metadata: makeDelegatedAuthMetadata(),
state,
authContext: {
clientId,
codeVerifier: "123456",
deviceId,
redirectUri: "https://cb",
},
});
});
it("should fail when query params do not include valid code and state", async () => {
it("should fail when fragment params do not include valid code and state", async () => {
const urlParams = {
oidc_query: {
oauth2: {
code: "",
state: "abc",
},
@@ -535,11 +534,11 @@ describe("<MatrixChat />", () => {
await flushPromises();
expect(logger.error).toHaveBeenCalledWith(
"Failed to login via OIDC",
new Error(OidcClientError.InvalidQueryParameters),
"Failed to login via OAuth",
new Error(OAuthClientError.InvalidFragmentParameters),
);
await expectOIDCError();
await expectOAuthError();
});
it("should make correct request to complete authorization", async () => {
@@ -547,7 +546,7 @@ describe("<MatrixChat />", () => {
await flushPromises();
expect(completeAuthorizationCodeGrant).toHaveBeenCalledWith(code, state, "fragment");
expect(OAuth2.prototype.completeAuthorizationCodeGrant).toHaveBeenCalledWith(code);
});
it("should look up userId using access token", async () => {
@@ -571,10 +570,10 @@ describe("<MatrixChat />", () => {
await flushPromises();
expect(logger.error).toHaveBeenCalledWith(
"Failed to login via OIDC",
"Failed to login via OAuth",
new Error("Failed to retrieve userId using accessToken"),
);
await expectOIDCError();
await expectOAuthError();
});
it("should call onTokenLoginCompleted", async () => {
@@ -586,23 +585,26 @@ describe("<MatrixChat />", () => {
describe("when login fails", () => {
beforeEach(() => {
mocked(completeAuthorizationCodeGrant).mockRejectedValue(new Error(OidcError.CodeExchangeFailed));
mocked(OAuth2.prototype.completeAuthorizationCodeGrant).mockRejectedValue(
new Error(OAuth2Error.CodeExchangeFailed),
);
});
it("should log and return to welcome page with correct error when login state is not found", async () => {
mocked(completeAuthorizationCodeGrant).mockRejectedValue(
new Error(OidcError.MissingOrInvalidStoredState),
sessionStorage.clear();
mocked(OAuth2.prototype.completeAuthorizationCodeGrant).mockRejectedValue(
new Error(OAuth2Error.MissingOrInvalidStoredState),
);
getComponent({ urlParams });
await flushPromises();
expect(logger.error).toHaveBeenCalledWith(
"Failed to login via OIDC",
new Error(OidcError.MissingOrInvalidStoredState),
"Failed to login via OAuth",
new Error(OAuth2Error.MissingOrInvalidStoredState),
);
await expectOIDCError(
await expectOAuthError(
"We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.",
);
});
@@ -613,12 +615,12 @@ describe("<MatrixChat />", () => {
await flushPromises();
expect(logger.error).toHaveBeenCalledWith(
"Failed to login via OIDC",
new Error(OidcError.CodeExchangeFailed),
"Failed to login via OAuth",
new Error(OAuth2Error.CodeExchangeFailed),
);
// warning dialog
await expectOIDCError();
await expectOAuthError();
});
it("should not clear storage", async () => {
@@ -636,7 +638,6 @@ describe("<MatrixChat />", () => {
await flushPromises();
expect(sessionStorageSetSpy).not.toHaveBeenCalledWith("mx_oidc_client_id", clientId);
expect(sessionStorageSetSpy).not.toHaveBeenCalledWith("mx_oidc_token_issuer", issuer);
});
});
@@ -658,7 +659,6 @@ describe("<MatrixChat />", () => {
getComponent({ urlParams });
await waitFor(() => expect(localStorage.getItem("mx_oidc_client_id")).toEqual(clientId));
await waitFor(() => expect(localStorage.getItem("mx_oidc_token_issuer")).toEqual(issuer));
});
it("should set logged in and start MatrixClient", async () => {
@@ -9,22 +9,26 @@ import React from "react";
import { fireEvent, render, screen, waitForElementToBeRemoved } from "jest-matrix-react";
import { mocked, type MockedObject } from "jest-mock-vitest-adapter";
import fetchMock from "@fetch-mock/jest";
import { DELEGATED_OIDC_COMPATIBILITY, IdentityProviderBrand, type OidcClientConfig } from "matrix-js-sdk/src/matrix";
import {
OAUTH_AWARE_PREFERRED_FLOW_FIELD,
IdentityProviderBrand,
type ValidatedAuthMetadata,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import * as Matrix from "matrix-js-sdk/src/matrix";
import { OidcError } from "matrix-js-sdk/src/oidc/error";
import { OAuth2Error } from "matrix-js-sdk/src/matrix";
import SdkConfig from "../../../../../src/SdkConfig";
import { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../../test-utils";
import Login from "../../../../../src/components/structures/auth/Login";
import type BasePlatform from "../../../../../src/BasePlatform";
import * as registerClientUtils from "../../../../../src/utils/oidc/registerClient";
import { makeDelegatedAuthConfig } from "../../../../test-utils/oidc";
import * as registerClientUtils from "../../../../../src/utils/oauth/registerClient";
import { makeDelegatedAuthMetadata } from "../../../../test-utils/auth";
import { ModuleApi } from "../../../../../src/modules/Api.ts";
jest.useRealTimers();
const oidcStaticClientsConfig = {
const oauthStaticClientsConfig = {
"https://staticallyregisteredissuer.org/": {
client_id: "static-clientId-123",
},
@@ -42,7 +46,7 @@ describe("Login", function () {
SdkConfig.put({
brand: "test-brand",
disable_custom_urls: true,
oidc_static_clients: oidcStaticClientsConfig,
oidc_static_clients: oauthStaticClientsConfig,
});
mockClient.login.mockClear().mockResolvedValue({
access_token: "TOKEN",
@@ -72,7 +76,7 @@ describe("Login", function () {
function getRawComponent(
hsUrl = "https://matrix.org",
isUrl = "https://vector.im",
delegatedAuthentication?: OidcClientConfig,
delegatedAuthentication?: ValidatedAuthMetadata,
) {
return (
<Login
@@ -84,7 +88,7 @@ describe("Login", function () {
);
}
function getComponent(hsUrl?: string, isUrl?: string, delegatedAuthentication?: OidcClientConfig) {
function getComponent(hsUrl?: string, isUrl?: string, delegatedAuthentication?: ValidatedAuthMetadata) {
return render(getRawComponent(hsUrl, isUrl, delegatedAuthentication));
}
@@ -269,7 +273,7 @@ describe("Login", function () {
flows: [
{
type: "m.login.sso",
[DELEGATED_OIDC_COMPATIBILITY.name]: true,
[OAUTH_AWARE_PREFERRED_FLOW_FIELD.name]: true,
},
{
type: "m.login.password",
@@ -393,7 +397,7 @@ describe("Login", function () {
const hsUrl = "https://matrix.org";
const isUrl = "https://vector.im";
const issuer = "https://test.com/";
const delegatedAuth = makeDelegatedAuthConfig(issuer);
const delegatedAuth = makeDelegatedAuthMetadata(issuer);
beforeEach(() => {
jest.spyOn(logger, "error");
});
@@ -402,9 +406,9 @@ describe("Login", function () {
jest.spyOn(logger, "error").mockRestore();
});
it("should attempt to register oidc client", async () => {
it("should attempt to register oauth client", async () => {
// dont mock, spy so we can check config values were correctly passed
jest.spyOn(registerClientUtils, "getOidcClientId");
jest.spyOn(registerClientUtils, "getOAuthClientId");
fetchMock.post(delegatedAuth.registration_endpoint!, { status: 500 });
getComponent(hsUrl, isUrl, delegatedAuth);
@@ -413,7 +417,7 @@ describe("Login", function () {
// tried to register
expect(fetchMock).toHaveFetched(delegatedAuth.registration_endpoint);
// called with values from config
expect(registerClientUtils.getOidcClientId).toHaveBeenCalledWith(delegatedAuth, oidcStaticClientsConfig);
expect(registerClientUtils.getOAuthClientId).toHaveBeenCalledWith(delegatedAuth, oauthStaticClientsConfig);
});
it("should fallback to normal login when client registration fails", async () => {
@@ -425,8 +429,8 @@ describe("Login", function () {
// tried to register
expect(fetchMock).toHaveFetched(delegatedAuth.registration_endpoint);
expect(logger.error).toHaveBeenCalledWith(
"Failed to get oidc native flow",
new Error(OidcError.DynamicRegistrationFailed),
"Failed to get OAuth2 native flow",
new Error(OAuth2Error.DynamicRegistrationFailed),
);
// continued with normal setup
@@ -436,7 +440,7 @@ describe("Login", function () {
});
// short term during active development, UI will be added in next PRs
it("should show continue button when oidc native flow is correctly configured", async () => {
it("should show continue button when oauth native flow is correctly configured", async () => {
fetchMock.post(delegatedAuth.registration_endpoint!, { client_id: "abc123" });
getComponent(hsUrl, isUrl, delegatedAuth);
@@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from "jest-matrix-react";
import { createClient, type MatrixClient, MatrixError, type OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { createClient, type MatrixClient, MatrixError, type ValidatedAuthMetadata } from "matrix-js-sdk/src/matrix";
import { mocked, type MockedObject } from "jest-mock-vitest-adapter";
import fetchMock from "@fetch-mock/jest";
@@ -21,11 +21,11 @@ import {
unmockPlatformPeg,
} from "../../../../test-utils";
import Registration from "../../../../../src/components/structures/auth/Registration";
import { makeDelegatedAuthConfig } from "../../../../test-utils/oidc";
import { startOidcLogin } from "../../../../../src/utils/oidc/authorize";
import { makeDelegatedAuthMetadata } from "../../../../test-utils/auth";
import { startOAuthLogin } from "../../../../../src/utils/oauth/authorize";
jest.mock("../../../../../src/utils/oidc/authorize", () => ({
startOidcLogin: jest.fn(),
jest.mock("../../../../../src/utils/oauth/authorize", () => ({
startOAuthLogin: jest.fn(),
}));
jest.mock("matrix-js-sdk/src/matrix", () => ({
@@ -92,7 +92,7 @@ describe("Registration", function () {
function getRawComponent(
hsUrl = defaultHsUrl,
isUrl = defaultIsUrl,
authConfig?: OidcClientConfig,
authConfig?: ValidatedAuthMetadata,
mobileRegister?: boolean,
) {
return (
@@ -104,7 +104,12 @@ describe("Registration", function () {
);
}
function getComponent(hsUrl?: string, isUrl?: string, authConfig?: OidcClientConfig, mobileRegister?: boolean) {
function getComponent(
hsUrl?: string,
isUrl?: string,
authConfig?: ValidatedAuthMetadata,
mobileRegister?: boolean,
) {
return render(getRawComponent(hsUrl, isUrl, authConfig, mobileRegister));
}
@@ -155,7 +160,7 @@ describe("Registration", function () {
});
describe("when delegated authentication is configured and enabled", () => {
const authConfig = makeDelegatedAuthConfig();
const authConfig = makeDelegatedAuthMetadata();
const clientId = "test-client-id";
authConfig.prompt_values_supported = ["create"];
@@ -168,15 +173,6 @@ describe("Registration", function () {
},
},
});
fetchMock.get(`${defaultHsUrl}/_matrix/client/unstable/org.matrix.msc2965/auth_issuer`, {
issuer: authConfig.issuer,
});
fetchMock.get("https://auth.org/.well-known/openid-configuration", {
...authConfig,
signingKeys: undefined,
});
fetchMock.get(authConfig.jwks_uri!, { keys: [] });
});
it("should display oidc-native continue button", async () => {
@@ -194,7 +190,7 @@ describe("Registration", function () {
fireEvent.click(await screen.findByText("Continue"));
expect(startOidcLogin).toHaveBeenCalledWith(
expect(startOAuthLogin).toHaveBeenCalledWith(
authConfig,
clientId,
defaultHsUrl,
@@ -28,7 +28,7 @@ import { UIFeature } from "../../../../../src/settings/UIFeature";
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
import { TestSDKContext } from "../../../TestSDKContext.ts";
import { type FeatureSettingKey } from "../../../../../src/settings/Settings.tsx";
import { mockOpenIdConfiguration } from "../../../../test-utils/oidc.ts";
import { makeDelegatedAuthMetadata } from "../../../../test-utils/auth.ts";
mockPlatformPeg({
supportsSpellCheckSettings: jest.fn().mockReturnValue(false),
@@ -74,7 +74,7 @@ describe("<UserSettingsDialog />", () => {
getPushers: jest.fn().mockResolvedValue([]),
getProfileInfo: jest.fn().mockResolvedValue({}),
getMediaConfig: jest.fn(),
getAuthMetadata: jest.fn().mockResolvedValue(mockOpenIdConfiguration()),
getAuthMetadata: jest.fn().mockResolvedValue(makeDelegatedAuthMetadata()),
});
sdkContext = new TestSDKContext();
sdkContext._client = mockClient;
@@ -16,7 +16,7 @@ import {
RendezvousError,
RendezvousIntent,
} from "matrix-js-sdk/src/rendezvous";
import { mockOpenIdConfiguration } from "matrix-js-sdk/src/testing";
import { makeDelegatedAuthMetadata } from "matrix-js-sdk/src/testing";
import {
AutoDiscovery,
AutoDiscoveryAction,
@@ -61,7 +61,7 @@ function makeClient() {
getClientWellKnown: jest.fn().mockReturnValue({}),
getCrypto: jest.fn().mockReturnValue({}),
getDomain: jest.fn(),
getAuthMetadata: jest.fn().mockReturnValue(mockOpenIdConfiguration()),
getAuthMetadata: jest.fn().mockReturnValue(makeDelegatedAuthMetadata()),
} as unknown as MatrixClient);
cli.http = new MatrixHttpApi(cli, {
@@ -330,24 +330,23 @@ describe("<LoginWithQR />", () => {
test("should handle qr login", async () => {
fetchMock.get("https://hs/_matrix/client/versions", {
unstable_features: {},
versions: ["v1.1", "v1.5", "v1.6", "v1.8", "v1.9"],
versions: ["v1.1", "v1.5", "v1.6", "v1.8", "v1.9", "v1.15"],
});
const authMetadata = {
...mockOpenIdConfiguration("https://auth.org/", [OAuthGrantType.DeviceAuthorization]),
jwks_uri: undefined,
};
fetchMock.get("https://hs/_matrix/client/unstable/org.matrix.msc2965/auth_metadata", authMetadata);
const authMetadata = makeDelegatedAuthMetadata("https://auth.org/", [
OAuthGrantType.DeviceAuthorization,
]);
fetchMock.get("https://hs/_matrix/client/v1/auth_metadata", authMetadata);
fetchMock.post(authMetadata.registration_endpoint!, {
client_id: "!client_id!",
});
mockPlatformPeg({
getOidcClientMetadata: jest.fn().mockReturnValue({
clientName: "App name",
clientUri: "https://company",
redirectUris: ["https://app"],
logoUri: "https://company/logo.png",
applicationType: "web",
getOAuthClientMetadata: jest.fn().mockReturnValue({
client_name: "App name",
client_uri: "https://company",
redirect_uris: ["https://app"],
logo_uri: "https://company/logo.png",
application_type: "web",
}),
});
@@ -26,7 +26,6 @@ import {
flushPromises,
} from "../../../../../../test-utils";
import { UIFeature } from "../../../../../../../src/settings/UIFeature";
import { type OidcClientStore } from "../../../../../../../src/stores/oidc/OidcClientStore";
import MatrixClientContext from "../../../../../../../src/contexts/MatrixClientContext";
import Modal from "../../../../../../../src/Modal";
@@ -77,6 +76,8 @@ describe("<AccountUserSettingsTab />", () => {
getThreePids: jest.fn(),
getIdentityServerUrl: jest.fn(),
deleteThreePid: jest.fn(),
getMediaConfig: jest.fn(),
getAuthMetadata: jest.fn().mockRejectedValue(new Error("not implemented")),
});
mockClient.getCapabilities.mockResolvedValue({});
@@ -89,9 +90,6 @@ describe("<AccountUserSettingsTab />", () => {
stores = new TestSDKContext();
stores._client = mockClient;
// stub out this store completely to avoid mocking initialisation
const mockOidcClientStore = {} as unknown as OidcClientStore;
jest.spyOn(stores, "oidcClientStore", "get").mockReturnValue(mockOidcClientStore);
});
afterEach(() => {
@@ -107,10 +105,9 @@ describe("<AccountUserSettingsTab />", () => {
it("show account management link in expected format", async () => {
const accountManagementLink = "https://id.server.org/my-account";
const mockOidcClientStore = {
accountManagementEndpoint: accountManagementLink,
} as unknown as OidcClientStore;
jest.spyOn(stores, "oidcClientStore", "get").mockReturnValue(mockOidcClientStore);
mockClient.getAuthMetadata.mockResolvedValue({
account_management_uri: accountManagementLink,
} as any);
render(getComponent());
@@ -134,10 +131,9 @@ describe("<AccountUserSettingsTab />", () => {
);
// account is managed externally when we have delegated auth configured
const accountManagementLink = "https://id.server.org/my-account";
const mockOidcClientStore = {
accountManagementEndpoint: accountManagementLink,
} as unknown as OidcClientStore;
jest.spyOn(stores, "oidcClientStore", "get").mockReturnValue(mockOidcClientStore);
mockClient.getAuthMetadata.mockResolvedValue({
account_management_uri: accountManagementLink,
} as any);
render(getComponent());
await flushPromises();
@@ -207,11 +203,6 @@ describe("<AccountUserSettingsTab />", () => {
describe("3pids", () => {
beforeEach(() => {
const mockOidcClientStore = {
accountManagementEndpoint: undefined,
} as unknown as OidcClientStore;
jest.spyOn(stores, "oidcClientStore", "get").mockReturnValue(mockOidcClientStore);
mockClient.getCapabilities.mockResolvedValue({
"m.3pid_changes": {
enabled: true,
@@ -34,7 +34,6 @@ import {
type MatrixClient,
} from "matrix-js-sdk/src/matrix";
import { mocked, type MockedObject } from "jest-mock-vitest-adapter";
import fetchMock from "@fetch-mock/jest";
import {
clearAllModals,
@@ -58,8 +57,7 @@ import SettingsStore from "../../../../../../../src/settings/SettingsStore";
import { getClientInformationEventType } from "../../../../../../../src/utils/device/clientInformation";
import { SDKContext } from "../../../../../../../src/contexts/SDKContext";
import { TestSDKContext } from "../../../../../TestSDKContext.ts";
import { type OidcClientStore } from "../../../../../../../src/stores/oidc/OidcClientStore";
import { makeDelegatedAuthConfig } from "../../../../../../test-utils/oidc";
import { makeDelegatedAuthMetadata } from "../../../../../../test-utils/auth";
import MatrixClientContext from "../../../../../../../src/contexts/MatrixClientContext";
mockPlatformPeg();
@@ -1180,10 +1178,10 @@ describe("<SessionManagerTab />", () => {
describe("for an OIDC-aware server", () => {
beforeEach(() => {
// just do an ugly mock here to avoid mocking initialisation
const mockOidcClientStore = {
accountManagementEndpoint: "https://issuer.org/account",
} as unknown as OidcClientStore;
jest.spyOn(sdkContext, "oidcClientStore", "get").mockReturnValue(mockOidcClientStore);
mockClient.getAuthMetadata.mockResolvedValue({
...makeDelegatedAuthMetadata(),
account_management_uri: "https://issuer.org/account",
} as any);
});
// signing out the current device works as usual
@@ -1637,7 +1635,7 @@ describe("<SessionManagerTab />", () => {
enabled: true,
},
});
const delegatedAuthConfig = makeDelegatedAuthConfig(issuer);
const delegatedAuthConfig = makeDelegatedAuthMetadata(issuer);
mockClient.getAuthMetadata.mockResolvedValue({
...delegatedAuthConfig,
grant_types_supported: [
@@ -1646,13 +1644,6 @@ describe("<SessionManagerTab />", () => {
],
});
mockCrypto.exportSecretsBundle = jest.fn();
fetchMock.route(delegatedAuthConfig.jwks_uri!, {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
});
it("renders qr code login section", async () => {
@@ -123,6 +123,7 @@ describe("<SpacePanel />", () => {
removeListener: jest.fn(),
isVersionSupported: jest.fn().mockResolvedValue(true),
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
getAuthMetadata: jest.fn().mockRejectedValue(new Error("Legacy auth")),
} as unknown as MatrixClient;
const sdkContext = new TestSDKContext();
const SpacePanel = wrapInSdkContext(wrapInMatrixClientContext(UnwrappedSpacePanel), sdkContext);
@@ -9,7 +9,6 @@ Please see LICENSE files in the repository root for full details.
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { SDKContextClass } from "../../../src/contexts/SDKContextClass";
import { OidcClientStore } from "../../../src/stores/oidc/OidcClientStore";
import { UserProfilesStore } from "../../../src/stores/UserProfilesStore";
import { createTestClient } from "../../test-utils";
import { TestSDKContext } from "../TestSDKContext.ts";
@@ -35,10 +34,6 @@ describe("SDKContextClass", () => {
expect(() => sdkContext.userProfilesStore).toThrow("Unable to create UserProfilesStore without a client");
});
it("oidcClientStore should raise an error without a client", () => {
expect(() => sdkContext.oidcClientStore).toThrow("Unable to create OidcClientStore without a client");
});
describe("when SDKContext has a client", () => {
beforeEach(() => {
sdkContext._client = client;
@@ -57,12 +52,5 @@ describe("SDKContextClass", () => {
sdkContext._client = client;
expect(sdkContext.userProfilesStore).not.toBe(store);
});
it("oidcClientstore should return a OidcClientStore", () => {
const store = sdkContext.oidcClientStore;
expect(store).toBeInstanceOf(OidcClientStore);
// it should return the same instance
expect(sdkContext.oidcClientStore).toBe(store);
});
});
});
@@ -1,260 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import fetchMock from "@fetch-mock/jest";
import { mocked } from "jest-mock";
import { OidcClient } from "oidc-client-ts";
import { logger } from "matrix-js-sdk/src/logger";
import { discoverAndValidateOIDCIssuerWellKnown } from "matrix-js-sdk/src/matrix";
import { OidcError } from "matrix-js-sdk/src/oidc/error";
import { OidcClientStore } from "../../../../src/stores/oidc/OidcClientStore";
import { flushPromises, getMockClientWithEventEmitter, mockPlatformPeg } from "../../../test-utils";
import { makeDelegatedAuthConfig } from "../../../test-utils/oidc";
jest.mock("matrix-js-sdk/src/matrix", () => ({
...jest.requireActual("matrix-js-sdk/src/matrix"),
discoverAndValidateOIDCIssuerWellKnown: jest.fn(),
}));
describe("OidcClientStore", () => {
const clientId = "test-client-id";
const authConfig = makeDelegatedAuthConfig();
const account = authConfig.issuer + "account";
const accountManagementActionsSupported = ["action1", "action2"];
const mockClient = getMockClientWithEventEmitter({
getAuthMetadata: jest.fn(),
});
beforeEach(() => {
localStorage.clear();
localStorage.setItem("mx_oidc_client_id", clientId);
localStorage.setItem("mx_oidc_token_issuer", authConfig.issuer);
mocked(discoverAndValidateOIDCIssuerWellKnown)
.mockClear()
.mockResolvedValue({
...authConfig,
account_management_uri: account,
account_management_actions_supported: accountManagementActionsSupported,
authorization_endpoint: "authorization-endpoint",
token_endpoint: "token-endpoint",
});
jest.spyOn(logger, "error").mockClear();
fetchMock.get(`${authConfig.issuer}.well-known/openid-configuration`, authConfig);
fetchMock.get(`${authConfig.issuer}jwks`, { keys: [] });
mockPlatformPeg();
});
describe("isUserAuthenticatedWithOidc()", () => {
it("should return true when an issuer is in session storage", () => {
const store = new OidcClientStore(mockClient);
expect(store.isUserAuthenticatedWithOidc).toEqual(true);
});
it("should return false when no issuer is in session storage", () => {
localStorage.clear();
const store = new OidcClientStore(mockClient);
expect(store.isUserAuthenticatedWithOidc).toEqual(false);
});
});
describe("initialising oidcClient", () => {
it("should initialise oidc client from constructor", () => {
const store = new OidcClientStore(mockClient);
// started initialising
// @ts-ignore private property
expect(store.initialisingOidcClientPromise).toBeTruthy();
});
it("should fallback to stored issuer when no client well known is available", async () => {
const store = new OidcClientStore(mockClient);
// successfully created oidc client
// @ts-ignore private property
expect(await store.getOidcClient()).toBeTruthy();
});
it("should log and return when no clientId is found in storage", async () => {
localStorage.removeItem("mx_oidc_client_id");
const store = new OidcClientStore(mockClient);
// no oidc client
// @ts-ignore private property
expect(await store.getOidcClient()).toEqual(undefined);
expect(logger.error).toHaveBeenCalledWith(
"Failed to initialise OidcClientStore",
new Error("Oidc client id not found in storage"),
);
});
it("should log and return when discovery and validation fails", async () => {
mocked(discoverAndValidateOIDCIssuerWellKnown).mockRejectedValue(new Error(OidcError.OpSupport));
const store = new OidcClientStore(mockClient);
await store.readyPromise;
expect(logger.error).toHaveBeenCalledWith(
"Failed to initialise OidcClientStore",
new Error(OidcError.OpSupport),
);
// no oidc client
// @ts-ignore private property
expect(await store.getOidcClient()).toEqual(undefined);
});
it("should create oidc client correctly", async () => {
const store = new OidcClientStore(mockClient);
// @ts-ignore private property
const client = await store.getOidcClient();
expect(client?.settings.client_id).toEqual(clientId);
expect(client?.settings.authority).toEqual(authConfig.issuer);
});
it("should set account management endpoint when configured", async () => {
const store = new OidcClientStore(mockClient);
// @ts-ignore private property
await store.getOidcClient();
expect(store.accountManagementEndpoint).toEqual(account);
});
it("should set account management actions supported when configured", async () => {
const store = new OidcClientStore(mockClient);
// @ts-ignore private property
await store.getOidcClient();
expect(store.accountManagementActionsSupported).toEqual(accountManagementActionsSupported);
});
it("should set account management endpoint to issuer when not configured", async () => {
mocked(discoverAndValidateOIDCIssuerWellKnown)
.mockClear()
.mockResolvedValue({
...authConfig,
account_management_uri: undefined,
authorization_endpoint: "authorization-endpoint",
token_endpoint: "token-endpoint",
});
const store = new OidcClientStore(mockClient);
await store.readyPromise;
expect(store.accountManagementEndpoint).toEqual(authConfig.issuer);
});
it("should reuse initialised oidc client", async () => {
const store = new OidcClientStore(mockClient);
// @ts-ignore private property
store.getOidcClient();
// @ts-ignore private property
store.getOidcClient();
await flushPromises();
// finished initialising
// @ts-ignore private property
expect(await store.getOidcClient()).toBeTruthy();
// @ts-ignore private property
store.getOidcClient();
// only called once for multiple calls to getOidcClient
// before and after initialisation is complete
expect(discoverAndValidateOIDCIssuerWellKnown).toHaveBeenCalledTimes(1);
});
});
describe("revokeTokens()", () => {
const accessToken = "test-access-token";
const refreshToken = "test-refresh-token";
beforeEach(() => {
// spy and call through
jest.spyOn(OidcClient.prototype, "revokeToken").mockClear();
fetchMock.clearHistory();
fetchMock.removeRoute("revocation-endpoint");
fetchMock.post(
authConfig.revocation_endpoint,
{
status: 200,
},
{ name: "revocation-endpoint" },
);
});
it("should throw when oidcClient could not be initialised", async () => {
// make oidcClient initialisation fail
localStorage.removeItem("mx_oidc_token_issuer");
const store = new OidcClientStore(mockClient);
await expect(() => store.revokeTokens(accessToken, refreshToken)).rejects.toThrow("No OIDC client");
});
it("should revoke access and refresh tokens", async () => {
const store = new OidcClientStore(mockClient);
await store.revokeTokens(accessToken, refreshToken);
expect(fetchMock).toHaveFetchedTimes(2, authConfig.revocation_endpoint);
expect(OidcClient.prototype.revokeToken).toHaveBeenCalledWith(accessToken, "access_token");
expect(OidcClient.prototype.revokeToken).toHaveBeenCalledWith(refreshToken, "refresh_token");
});
it("should still attempt to revoke refresh token when access token revocation fails", async () => {
// fail once, then succeed
fetchMock.removeRoute("revocation-endpoint");
fetchMock
.postOnce(authConfig.revocation_endpoint, {
status: 404,
})
.post(authConfig.revocation_endpoint, {
status: 200,
});
const store = new OidcClientStore(mockClient);
await expect(() => store.revokeTokens(accessToken, refreshToken)).rejects.toThrow(
"Failed to revoke tokens",
);
expect(fetchMock).toHaveFetchedTimes(2, authConfig.revocation_endpoint);
expect(OidcClient.prototype.revokeToken).toHaveBeenCalledWith(accessToken, "access_token");
});
});
describe("OIDC Aware", () => {
beforeEach(() => {
localStorage.clear();
});
it("should resolve account management endpoint", async () => {
mockClient.getAuthMetadata.mockResolvedValue({
...authConfig,
account_management_uri: account,
account_management_actions_supported: accountManagementActionsSupported,
});
const store = new OidcClientStore(mockClient);
await store.readyPromise;
expect(store.accountManagementEndpoint).toBe(account);
expect(store.accountManagementActionsSupported).toEqual(accountManagementActionsSupported);
});
});
});
@@ -11,7 +11,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import fetchMock from "@fetch-mock/jest";
import AutoDiscoveryUtils from "../../../src/utils/AutoDiscoveryUtils";
import { mockOpenIdConfiguration } from "../../test-utils/oidc";
import { makeDelegatedAuthMetadata } from "../../test-utils/auth";
describe("AutoDiscoveryUtils", () => {
beforeEach(() => {
@@ -224,115 +224,15 @@ describe("AutoDiscoveryUtils", () => {
it("should validate delegated oidc auth", async () => {
const issuer = "https://auth.matrix.org/";
fetchMock.get(
`${validHsConfig["m.homeserver"].base_url}/_matrix/client/unstable/org.matrix.msc2965/auth_issuer`,
{
issuer,
},
);
fetchMock.get(`${issuer}.well-known/openid-configuration`, {
...mockOpenIdConfiguration(issuer),
"scopes_supported": ["openid", "email"],
"response_modes_supported": ["form_post", "query", "fragment"],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"none",
],
"token_endpoint_auth_signing_alg_values_supported": [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES256K",
],
"revocation_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"none",
],
"revocation_endpoint_auth_signing_alg_values_supported": [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES256K",
],
"introspection_endpoint": `${issuer}oauth2/introspect`,
"introspection_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
"private_key_jwt",
"none",
],
"introspection_endpoint_auth_signing_alg_values_supported": [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES256K",
],
"userinfo_endpoint": `${issuer}oauth2/userinfo`,
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": [
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"PS256",
"PS384",
"PS512",
"ES256K",
],
"userinfo_signing_alg_values_supported": [
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"PS256",
"PS384",
"PS512",
"ES256K",
],
"display_values_supported": ["page"],
"claim_types_supported": ["normal"],
"claims_supported": ["iss", "sub", "aud", "iat", "exp", "nonce", "auth_time", "at_hash", "c_hash"],
"claims_parameter_supported": false,
"request_parameter_supported": false,
"request_uri_parameter_supported": false,
"prompt_values_supported": ["none", "login", "create"],
"device_authorization_endpoint": `${issuer}oauth2/device`,
"org.matrix.matrix-authentication-service.graphql_endpoint": `${issuer}graphql`,
"account_management_uri": `${issuer}account/`,
"account_management_actions_supported": [
fetchMock.get(`${validHsConfig["m.homeserver"].base_url}/_matrix/client/versions`, { versions: ["v1.15"] });
fetchMock.get(`${validHsConfig["m.homeserver"].base_url}/_matrix/client/v1/auth_metadata`, {
...makeDelegatedAuthMetadata(issuer),
scopes_supported: ["email"],
response_modes_supported: ["query", "fragment"],
prompt_values_supported: ["none", "login", "create"],
device_authorization_endpoint: `${issuer}oauth2/device`,
account_management_uri: `${issuer}account/`,
account_management_actions_supported: [
"org.matrix.profile",
"org.matrix.sessions_list",
"org.matrix.session_view",
@@ -340,9 +240,6 @@ describe("AutoDiscoveryUtils", () => {
"org.matrix.cross_signing_reset",
],
});
fetchMock.get(`${issuer}jwks`, {
keys: [],
});
const discoveryResult = {
...validIsConfig,
@@ -366,7 +263,6 @@ describe("AutoDiscoveryUtils", () => {
account_management_uri: "https://auth.matrix.org/account/",
authorization_endpoint: "https://auth.matrix.org/auth",
registration_endpoint: "https://auth.matrix.org/registration",
signingKeys: [],
token_endpoint: "https://auth.matrix.org/token",
}),
warning: null,
@@ -6,36 +6,29 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import fetchMock from "@fetch-mock/jest";
import { completeAuthorizationCodeGrant } from "matrix-js-sdk/src/oidc/authorize";
import { OAuth2, type BearerTokenResponse } from "matrix-js-sdk/src/matrix";
import * as randomStringUtils from "matrix-js-sdk/src/randomstring";
import { type BearerTokenResponse } from "matrix-js-sdk/src/oidc/validate";
import { mocked } from "jest-mock";
import { Crypto } from "@peculiar/webcrypto";
import { getRandomValues } from "node:crypto";
import { completeOidcLogin, startOidcLogin } from "../../../../src/utils/oidc/authorize";
import { makeDelegatedAuthConfig } from "../../../test-utils/oidc";
import { OidcClientError } from "../../../../src/utils/oidc/error";
import { completeOAuthLogin, startOAuthLogin } from "../../../../src/utils/oauth/authorize";
import { makeDelegatedAuthMetadata } from "../../../test-utils/auth";
import { OAuthClientError } from "../../../../src/utils/oauth/error";
import { mockPlatformPeg } from "../../../test-utils";
import { storeAuthContext } from "../../../../src/utils/oauth/persistOAuthSettings.ts";
jest.unmock("matrix-js-sdk/src/randomstring");
jest.mock("matrix-js-sdk/src/oidc/authorize", () => ({
...jest.requireActual("matrix-js-sdk/src/oidc/authorize"),
completeAuthorizationCodeGrant: jest.fn(),
}));
const webCrypto = new Crypto();
describe("OIDC authorization", () => {
describe("OAuth2 authorization", () => {
const issuer = "https://auth.com/";
const homeserverUrl = "https://matrix.org";
const identityServerUrl = "https://is.org";
const clientId = "xyz789";
const baseUrl = "https://test.com";
const delegatedAuthConfig = makeDelegatedAuthConfig(issuer);
const delegatedAuthConfig = makeDelegatedAuthMetadata(issuer);
// to restore later
const realWindowLocation = window.location;
@@ -58,8 +51,6 @@ describe("OIDC authorization", () => {
subtle: webCrypto.subtle,
},
});
fetchMock.get(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`, delegatedAuthConfig);
});
afterAll(() => {
@@ -67,15 +58,15 @@ describe("OIDC authorization", () => {
window.location = realWindowLocation;
});
describe("startOidcLogin()", () => {
describe("startOAuthLogin()", () => {
it("navigates to authorization endpoint with correct parameters", async () => {
await startOidcLogin(delegatedAuthConfig, clientId, homeserverUrl);
await startOAuthLogin(delegatedAuthConfig, clientId, homeserverUrl);
const expectedScopeWithoutDeviceId = `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:`;
const expectedScopeWithoutDeviceId = `urn:matrix:client:api:* urn:matrix:client:device:`;
const authUrl = new URL(window.location.href);
expect(authUrl.searchParams.get("response_mode")).toEqual("query");
expect(authUrl.searchParams.get("response_mode")).toEqual("fragment");
expect(authUrl.searchParams.get("response_type")).toEqual("code");
expect(authUrl.searchParams.get("client_id")).toEqual(clientId);
expect(authUrl.searchParams.get("code_challenge_method")).toEqual("S256");
@@ -87,12 +78,11 @@ describe("OIDC authorization", () => {
// random string, just check they are set
expect(authUrl.searchParams.has("state")).toBeTruthy();
expect(authUrl.searchParams.has("nonce")).toBeTruthy();
expect(authUrl.searchParams.has("code_challenge")).toBeTruthy();
});
it("should prefer response_mode fragment if supported", async () => {
await startOidcLogin(
await startOAuthLogin(
{ ...delegatedAuthConfig, response_modes_supported: ["query", "fragment"] },
clientId,
homeserverUrl,
@@ -104,68 +94,59 @@ describe("OIDC authorization", () => {
});
});
describe("completeOidcLogin()", () => {
describe("completeOAuth2Login()", () => {
const state = "test-state-444";
const code = "test-code-777";
const params = {
code,
state: state,
state,
};
const tokenResponse: BearerTokenResponse = {
access_token: "abc123",
refresh_token: "def456",
id_token: "ghi789",
scope: "test",
token_type: "Bearer",
expires_at: 12345,
expires_in: 12345,
};
beforeEach(() => {
mocked(completeAuthorizationCodeGrant)
.mockClear()
.mockResolvedValue({
oidcClientSettings: {
clientId,
issuer,
},
tokenResponse,
homeserverUrl,
identityServerUrl,
idTokenClaims: {
aud: "123",
iss: issuer,
sub: "123",
exp: 123,
iat: 456,
},
});
jest.spyOn(OAuth2.prototype, "completeAuthorizationCodeGrant").mockResolvedValue(tokenResponse);
storeAuthContext({
state,
homeserverUrl,
metadata: delegatedAuthConfig,
identityServerUrl,
authContext: {
codeVerifier: "123456",
clientId,
deviceId: "DEADB33F",
redirectUri: "https://test.com/callback",
},
});
});
it("should throw when query params do not include state and code", async () => {
await expect(async () => await completeOidcLogin({}, "query")).rejects.toThrow(
OidcClientError.InvalidQueryParameters,
it("should throw when fragment params do not include state and code", async () => {
await expect(async () => await completeOAuthLogin({})).rejects.toThrow(
OAuthClientError.InvalidFragmentParameters,
);
});
it("should make request complete authorization code grant", async () => {
await completeOidcLogin(params, "fragment");
await completeOAuthLogin(params);
expect(completeAuthorizationCodeGrant).toHaveBeenCalledWith(code, state, "fragment");
expect(OAuth2.prototype.completeAuthorizationCodeGrant).toHaveBeenCalledWith(code);
});
it("should return accessToken, configured homeserver and identityServer", async () => {
const result = await completeOidcLogin(params, "query");
const result = await completeOAuthLogin(params);
expect(result).toEqual({
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
homeserverUrl,
identityServerUrl,
issuer,
clientId,
idToken: "ghi789",
idTokenClaims: result.idTokenClaims,
});
});
});
@@ -7,25 +7,24 @@ Please see LICENSE files in the repository root for full details.
*/
import fetchMock from "@fetch-mock/jest";
import { OidcError } from "matrix-js-sdk/src/oidc/error";
import { type OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { OAuth2Error } from "matrix-js-sdk/src/matrix";
import { getOidcClientId } from "../../../../src/utils/oidc/registerClient";
import { getOAuthClientId } from "../../../../src/utils/oauth/registerClient";
import { mockPlatformPeg } from "../../../test-utils";
import PlatformPeg from "../../../../src/PlatformPeg";
import { makeDelegatedAuthConfig } from "../../../test-utils/oidc";
import { makeDelegatedAuthMetadata } from "../../../test-utils/auth";
describe("getOidcClientId()", () => {
describe("getOAuthClientId()", () => {
const issuer = "https://auth.com/";
const clientName = "Element";
const baseUrl = "https://just.testing";
const dynamicClientId = "xyz789";
const staticOidcClients = {
const staticOAuthClients = {
[issuer]: {
client_id: "abc123",
},
};
const delegatedAuthConfig = makeDelegatedAuthConfig(issuer);
const delegatedAuthConfig = makeDelegatedAuthMetadata(issuer);
beforeEach(() => {
fetchMock.removeRoutes();
@@ -35,12 +34,12 @@ describe("getOidcClientId()", () => {
return baseUrl;
},
});
Object.defineProperty(PlatformPeg.get(), "defaultOidcClientUri", {
Object.defineProperty(PlatformPeg.get(), "defaultOAuthClientUri", {
get(): string {
return baseUrl;
},
});
Object.defineProperty(PlatformPeg.get(), "getOidcCallbackUrl", {
Object.defineProperty(PlatformPeg.get(), "getOAuthCallbackUrl", {
value: () => ({
href: baseUrl,
}),
@@ -48,31 +47,7 @@ describe("getOidcClientId()", () => {
});
it("should return static clientId when configured", async () => {
expect(await getOidcClientId(delegatedAuthConfig, staticOidcClients)).toEqual("abc123");
// didn't try to register
expect(fetchMock).toHaveFetchedTimes(0);
});
it("should throw when no static clientId is configured and no registration endpoint", async () => {
const authConfigWithoutRegistration: OidcClientConfig = makeDelegatedAuthConfig(
"https://issuerWithoutStaticClientId.org/",
);
authConfigWithoutRegistration.registration_endpoint = undefined;
await expect(getOidcClientId(authConfigWithoutRegistration, staticOidcClients)).rejects.toThrow(
OidcError.DynamicRegistrationNotSupported,
);
// didn't try to register
expect(fetchMock).toHaveFetchedTimes(0);
});
it("should handle when staticOidcClients object is falsy", async () => {
const authConfigWithoutRegistration: OidcClientConfig = {
...delegatedAuthConfig,
registration_endpoint: undefined,
};
await expect(getOidcClientId(authConfigWithoutRegistration)).rejects.toThrow(
OidcError.DynamicRegistrationNotSupported,
);
expect(await getOAuthClientId(delegatedAuthConfig, staticOAuthClients)).toEqual("abc123");
// didn't try to register
expect(fetchMock).toHaveFetchedTimes(0);
});
@@ -82,7 +57,7 @@ describe("getOidcClientId()", () => {
status: 200,
body: JSON.stringify({ client_id: dynamicClientId }),
});
expect(await getOidcClientId(delegatedAuthConfig)).toEqual(dynamicClientId);
expect(await getOAuthClientId(delegatedAuthConfig)).toEqual(dynamicClientId);
// didn't try to register
expect(fetchMock).toHaveFetched(delegatedAuthConfig.registration_endpoint!, {
headers: {
@@ -96,7 +71,6 @@ describe("getOidcClientId()", () => {
response_types: ["code"],
grant_types: ["authorization_code", "refresh_token"],
redirect_uris: [baseUrl],
id_token_signed_response_alg: "RS256",
token_endpoint_auth_method: "none",
application_type: "web",
logo_uri: `${baseUrl}/vector-icons/1024.png`,
@@ -108,7 +82,7 @@ describe("getOidcClientId()", () => {
fetchMock.post(delegatedAuthConfig.registration_endpoint!, {
status: 500,
});
await expect(getOidcClientId(delegatedAuthConfig)).rejects.toThrow(OidcError.DynamicRegistrationFailed);
await expect(getOAuthClientId(delegatedAuthConfig)).rejects.toThrow(OAuth2Error.DynamicRegistrationFailed);
});
it("should throw when registration response is invalid", async () => {
@@ -117,6 +91,6 @@ describe("getOidcClientId()", () => {
// no clientId in response
body: "{}",
});
await expect(getOidcClientId(delegatedAuthConfig)).rejects.toThrow(OidcError.DynamicRegistrationInvalid);
await expect(getOAuthClientId(delegatedAuthConfig)).rejects.toThrow(OAuth2Error.DynamicRegistrationInvalid);
});
});
@@ -1,88 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import fetchMock from "@fetch-mock/jest";
import { mocked } from "jest-mock";
import { TokenRefresher } from "../../../../src/utils/oidc/TokenRefresher";
import { persistAccessTokenInStorage, persistRefreshTokenInStorage } from "../../../../src/utils/tokens/tokens";
import { mockPlatformPeg } from "../../../test-utils";
import { makeDelegatedAuthConfig } from "../../../test-utils/oidc";
jest.mock("../../../../src/utils/tokens/tokens", () => ({
persistAccessTokenInStorage: jest.fn(),
persistRefreshTokenInStorage: jest.fn(),
}));
describe("TokenRefresher", () => {
const clientId = "test-client-id";
const issuer = "https://auth.com/";
const redirectUri = "https://test.com";
const deviceId = "test-device-id";
const userId = "@alice:server.org";
const accessToken = "test-access-token";
const refreshToken = "test-refresh-token";
const authConfig = makeDelegatedAuthConfig(issuer);
const idTokenClaims = {
aud: "123",
iss: issuer,
sub: "123",
exp: 123,
iat: 456,
};
beforeEach(() => {
fetchMock.get(`${issuer}.well-known/openid-configuration`, authConfig);
fetchMock.get(`${issuer}jwks`, {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
mocked(persistAccessTokenInStorage).mockResolvedValue(undefined);
mocked(persistRefreshTokenInStorage).mockResolvedValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should persist tokens with a pickle key", async () => {
const pickleKey = "test-pickle-key";
const getPickleKey = jest.fn().mockResolvedValue(pickleKey);
mockPlatformPeg({ getPickleKey });
const refresher = new TokenRefresher(issuer, clientId, redirectUri, deviceId, idTokenClaims, userId);
await refresher.oidcClientReady;
await refresher.persistTokens({ accessToken, refreshToken });
expect(getPickleKey).toHaveBeenCalledWith(userId, deviceId);
expect(persistAccessTokenInStorage).toHaveBeenCalledWith(accessToken, pickleKey);
expect(persistRefreshTokenInStorage).toHaveBeenCalledWith(refreshToken, pickleKey);
});
it("should persist tokens without a pickle key", async () => {
const getPickleKey = jest.fn().mockResolvedValue(null);
mockPlatformPeg({ getPickleKey });
const refresher = new TokenRefresher(issuer, clientId, redirectUri, deviceId, idTokenClaims, userId);
await refresher.oidcClientReady;
await refresher.persistTokens({ accessToken, refreshToken });
expect(getPickleKey).toHaveBeenCalledWith(userId, deviceId);
expect(persistAccessTokenInStorage).toHaveBeenCalledWith(accessToken, undefined);
expect(persistRefreshTokenInStorage).toHaveBeenCalledWith(refreshToken, undefined);
});
});
@@ -63,15 +63,12 @@ describe("UserMenuViewModel", () => {
});
it("should show a link for account management", async () => {
const vm = new UserMenuViewModel(
{ ownProfileStore: mockOwnProfileStore },
dispatcher,
client,
true,
"https://example.org/",
);
client.getAuthMetadata.mockResolvedValue({ account_management_uri: "https://example.org/" } as any);
const vm = new UserMenuViewModel({ ownProfileStore: mockOwnProfileStore }, dispatcher, client, true);
vm.setOpen(true);
expect(vm.getSnapshot().manageAccountHref).toEqual("https://example.org/");
await waitFor(() => {
expect(vm.getSnapshot().manageAccountHref).toEqual("https://example.org/");
});
});
it("should generate a menu options for a guest", () => {
-1
View File
@@ -252,7 +252,6 @@ export default (env: string, argv: Record<string, any>): webpack.Configuration =
"@matrix-org/react-sdk-module-api": getPackageRoot("@matrix-org/react-sdk-module-api"),
// and matrix-widget-api
"matrix-widget-api": getPackageRoot("matrix-widget-api"),
"oidc-client-ts": getPackageRoot("oidc-client-ts"),
// Make shared-components imports resolve to EW deps
"@vector-im/compound-web": getPackageRoot("@vector-im/compound-web", ""),
+1 -1
View File
@@ -137,7 +137,7 @@ export default withMermaid({
{ text: "Memory profiling", link: "/memory-profiles-and-leaks.md" },
{ text: "Jitsi", link: "/jitsi-dev.md" },
{ text: "Feature flags", link: "/feature-flags.md" },
{ text: "OIDC and delegated authentication", link: "/oidc.md" },
{ text: "OAuth and delegated authentication", link: "/oauth.md" },
{ text: "Release Process", link: "/release.md" },
{ text: "MVVM", link: "/MVVM.md" },
{ text: "Settings", link: "/settings.md" },
+1 -3
View File
@@ -298,7 +298,6 @@ The following subproperties are available:
2. `logo_uri`: Optional URI for the client logo.
3. `tos_uri`: Optional URI for the client's terms of service.
4. `policy_uri`: Optional URI for the client's privacy policy.
5. `contacts`: Optional list of contact emails for the client.
As an example:
@@ -308,8 +307,7 @@ As an example:
"client_uri": "https://example.com",
"logo_uri": "https://example.com/logo.png",
"tos_uri": "https://example.com/tos",
"policy_uri": "https://example.com/policy",
"contacts": ["support@example.com"]
"policy_uri": "https://example.com/policy"
}
}
```
+5 -5
View File
@@ -1,13 +1,13 @@
# OIDC and delegated authentication
# OAuth2 and delegated authentication
See https://areweoidcyet.com/client-implementation-guide/ for implementation details.
Element Web uses [MSC2965: OIDC provider discovery](https://github.com/matrix-org/matrix-spec-proposals/pull/2965) to discover the configured provider.
Where a valid MSC2965 configuration is discovered, OIDC native login flow will be the only login option offered.
Element Web will attempt to [dynamically register](https://openid.net/specs/openid-connect-registration-1_0.html) with the configured OP.
Element Web uses [/auth_metadata](https://spec.matrix.org/v1.18/client-server-api/#get_matrixclientv1auth_metadata) to discover the configured provider.
Where a valid configuration is discovered, OAuth2 native login flow will be the only login option offered.
Element Web will attempt to [dynamically register](https://spec.matrix.org/v1.18/client-server-api/#client-registration) with the configured OP.
Then, authentication will be completed [as described here](https://areweoidcyet.com/client-implementation-guide/).
#### Statically configured OIDC clients
#### Statically configured OAuth2 clients
Clients that are already registered with the OP can configure their `client_id` in `config.json`.
Where static configuration exists for the OP dynamic client registration will not be attempted.
+1 -6
View File
@@ -261,8 +261,6 @@ overrides:
pretty-format@30>react-is: 19.2.6
'@types/react': ^19.2.10
'@types/react-dom': ^19.2.3
oidc-client-ts: 3.5.0
jwt-decode: 4.0.0
caniuse-lite: 1.0.30001799
markdown-it: 14.1.1
matrix-widget-api: ^1.17.0
@@ -649,9 +647,6 @@ importers:
mime:
specifier: ^4.0.4
version: 4.1.0
oidc-client-ts:
specifier: 3.5.0
version: 3.5.0
opus-recorder:
specifier: ^8.0.3
version: 8.0.5
@@ -6563,7 +6558,7 @@ packages:
focus-trap: ^7
fuse.js: ^7
idb-keyval: ^6
jwt-decode: 4.0.0
jwt-decode: ^4
nprogress: ^0.2
qrcode: ^1.5
sortablejs: ^1
-2
View File
@@ -114,8 +114,6 @@ overrides:
pretty-format@30>react-is: 19.2.6
"@types/react": "catalog:"
"@types/react-dom": "catalog:"
oidc-client-ts: 3.5.0
jwt-decode: 4.0.0
caniuse-lite: 1.0.30001799
markdown-it: 14.1.1
matrix-widget-api: "^1.17.0"