Files
Michael TelatynskiGitHubCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2bc9656957 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>
2026-07-08 08:02:25 +00:00

154 lines
5.5 KiB
TypeScript

/*
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, type BearerTokenResponse } from "matrix-js-sdk/src/matrix";
import * as randomStringUtils from "matrix-js-sdk/src/randomstring";
import { Crypto } from "@peculiar/webcrypto";
import { getRandomValues } from "node:crypto";
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");
const webCrypto = new Crypto();
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 = makeDelegatedAuthMetadata(issuer);
// to restore later
const realWindowLocation = window.location;
beforeEach(() => {
// @ts-ignore allow delete of non-optional prop
delete window.location;
// @ts-ignore ugly mocking
window.location = {
href: baseUrl,
origin: baseUrl,
};
jest.spyOn(randomStringUtils, "secureRandomString").mockRestore();
mockPlatformPeg();
Object.defineProperty(window, "crypto", {
value: {
getRandomValues,
randomUUID: jest.fn().mockReturnValue("not-random-uuid"),
subtle: webCrypto.subtle,
},
});
});
afterAll(() => {
// @ts-expect-error
window.location = realWindowLocation;
});
describe("startOAuthLogin()", () => {
it("navigates to authorization endpoint with correct parameters", async () => {
await startOAuthLogin(delegatedAuthConfig, clientId, homeserverUrl);
const expectedScopeWithoutDeviceId = `urn:matrix:client:api:* urn:matrix:client:device:`;
const authUrl = new URL(window.location.href);
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");
// scope ends with a 10char randomstring deviceId
const scope = authUrl.searchParams.get("scope")!;
expect(scope.substring(0, scope.length - 10)).toEqual(expectedScopeWithoutDeviceId);
expect(scope.substring(scope.length - 10)).toBeTruthy();
// random string, just check they are set
expect(authUrl.searchParams.has("state")).toBeTruthy();
expect(authUrl.searchParams.has("code_challenge")).toBeTruthy();
});
it("should prefer response_mode fragment if supported", async () => {
await startOAuthLogin(
{ ...delegatedAuthConfig, response_modes_supported: ["query", "fragment"] },
clientId,
homeserverUrl,
);
const authUrl = new URL(window.location.href);
expect(authUrl.searchParams.get("response_mode")).toEqual("fragment");
});
});
describe("completeOAuth2Login()", () => {
const state = "test-state-444";
const code = "test-code-777";
const params = {
code,
state,
};
const tokenResponse: BearerTokenResponse = {
access_token: "abc123",
refresh_token: "def456",
scope: "test",
token_type: "Bearer",
expires_in: 12345,
};
beforeEach(() => {
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 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 completeOAuthLogin(params);
expect(OAuth2.prototype.completeAuthorizationCodeGrant).toHaveBeenCalledWith(code);
});
it("should return accessToken, configured homeserver and identityServer", async () => {
const result = await completeOAuthLogin(params);
expect(result).toEqual({
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
homeserverUrl,
identityServerUrl,
clientId,
});
});
});
});