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
@@ -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,