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:
co-authored by
Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
parent
38e29c51c4
commit
2bc9656957
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
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 { OAuth2Error } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { getOAuthClientId } from "../../../../src/utils/oauth/registerClient";
|
||||
import { mockPlatformPeg } from "../../../test-utils";
|
||||
import PlatformPeg from "../../../../src/PlatformPeg";
|
||||
import { makeDelegatedAuthMetadata } from "../../../test-utils/auth";
|
||||
|
||||
describe("getOAuthClientId()", () => {
|
||||
const issuer = "https://auth.com/";
|
||||
const clientName = "Element";
|
||||
const baseUrl = "https://just.testing";
|
||||
const dynamicClientId = "xyz789";
|
||||
const staticOAuthClients = {
|
||||
[issuer]: {
|
||||
client_id: "abc123",
|
||||
},
|
||||
};
|
||||
const delegatedAuthConfig = makeDelegatedAuthMetadata(issuer);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.removeRoutes();
|
||||
mockPlatformPeg();
|
||||
Object.defineProperty(PlatformPeg.get(), "baseUrl", {
|
||||
get(): string {
|
||||
return baseUrl;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(PlatformPeg.get(), "defaultOAuthClientUri", {
|
||||
get(): string {
|
||||
return baseUrl;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(PlatformPeg.get(), "getOAuthCallbackUrl", {
|
||||
value: () => ({
|
||||
href: baseUrl,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should return static clientId when configured", async () => {
|
||||
expect(await getOAuthClientId(delegatedAuthConfig, staticOAuthClients)).toEqual("abc123");
|
||||
// didn't try to register
|
||||
expect(fetchMock).toHaveFetchedTimes(0);
|
||||
});
|
||||
|
||||
it("should make correct request to register client", async () => {
|
||||
fetchMock.post(delegatedAuthConfig.registration_endpoint!, {
|
||||
status: 200,
|
||||
body: JSON.stringify({ client_id: dynamicClientId }),
|
||||
});
|
||||
expect(await getOAuthClientId(delegatedAuthConfig)).toEqual(dynamicClientId);
|
||||
// didn't try to register
|
||||
expect(fetchMock).toHaveFetched(delegatedAuthConfig.registration_endpoint!, {
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
body: {
|
||||
client_name: clientName,
|
||||
client_uri: baseUrl,
|
||||
response_types: ["code"],
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
redirect_uris: [baseUrl],
|
||||
token_endpoint_auth_method: "none",
|
||||
application_type: "web",
|
||||
logo_uri: `${baseUrl}/vector-icons/1024.png`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw when registration request fails", async () => {
|
||||
fetchMock.post(delegatedAuthConfig.registration_endpoint!, {
|
||||
status: 500,
|
||||
});
|
||||
await expect(getOAuthClientId(delegatedAuthConfig)).rejects.toThrow(OAuth2Error.DynamicRegistrationFailed);
|
||||
});
|
||||
|
||||
it("should throw when registration response is invalid", async () => {
|
||||
fetchMock.post(delegatedAuthConfig.registration_endpoint!, {
|
||||
status: 200,
|
||||
// no clientId in response
|
||||
body: "{}",
|
||||
});
|
||||
await expect(getOAuthClientId(delegatedAuthConfig)).rejects.toThrow(OAuth2Error.DynamicRegistrationInvalid);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user