Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/playwright/e2e/launch/oidc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { test, expect } from "../../element-desktop-test.js";

declare global {
interface ElectronPlatform {
getOidcCallbackUrl(): URL;
getOAuthCallbackUrl(): URL;
}

interface Window {
Expand All @@ -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\//);
});
Expand Down
6 changes: 0 additions & 6 deletions apps/web/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 0 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
35 changes: 17 additions & 18 deletions apps/web/src/BasePlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
90 changes: 31 additions & 59 deletions apps/web/src/Lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,27 @@ 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";
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";
Expand All @@ -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(),
Expand All @@ -78,6 +80,7 @@ describe("Lifecycle", () => {

localStorage.clear();
sessionStorage.clear();
vi.spyOn(MatrixClient.prototype, "getAuthMetadata").mockResolvedValue(makeDelegatedAuthMetadata());
});

afterEach(() => {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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]);
}
Expand Down Expand Up @@ -334,7 +336,7 @@ describe("Lifecycle", () => {
guest: false,
pickleKey: undefined,
},
expect.any(Function),
expect.any(OAuth2),
);
});
});
Expand All @@ -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]);
}
Expand All @@ -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 () => {
Expand Down Expand Up @@ -415,15 +418,15 @@ describe("Lifecycle", () => {
guest: false,
pickleKey,
},
undefined,
expect.any(OAuth2),
);

expect(MatrixClientPeg.start).toHaveBeenCalledWith({ rustCryptoStoreKey: expect.any(Uint8Array) });
});

describe("with a refresh token", () => {
beforeEach(async () => {
await persistRefreshTokenInStorage(refreshToken, pickleKey);
await persistTokens(pickleKey, { ...credentials, refreshToken });
});

it("should persist credentials", async () => {
Expand Down Expand Up @@ -454,7 +457,7 @@ describe("Lifecycle", () => {
guest: false,
pickleKey: pickleKey,
},
expect.any(Function),
expect.any(OAuth2),
);
});
});
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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,
Expand Down Expand Up @@ -881,45 +866,32 @@ 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();

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");
});
});

Expand Down
Loading
Loading