diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 5fb627ec707..ecf6db46e09 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -2,7 +2,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020, 2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -65,6 +65,7 @@ import AbstractLocalStorageSettingsHandler from "./settings/handlers/AbstractLoc import { OverwriteLoginPayload } from "./dispatcher/payloads/OverwriteLoginPayload"; import { SdkContextClass } from "./contexts/SDKContext"; import { messageForLoginError } from "./utils/ErrorUtils"; +import { completeOidcLogin } from "./utils/oidc/authorize"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -182,6 +183,9 @@ export async function getStoredSessionOwner(): Promise<[string, boolean] | [null } /** + * If query string includes OIDC authorization code flow parameters attempt to login using oidc flow + * Else, we may be returning from SSO - attempt token login + * * @param {Object} queryParams string->string map of the * query-parameters extracted from the real query-string of the starting * URI. @@ -189,6 +193,92 @@ export async function getStoredSessionOwner(): Promise<[string, boolean] | [null * @param {string} defaultDeviceDisplayName * @param {string} fragmentAfterLogin path to go to after a successful login, only used for "Try again" * + * @returns {Promise} promise which resolves to true if we completed the delegated auth login + * else false + */ +export async function attemptDelegatedAuthLogin( + queryParams: QueryDict, + defaultDeviceDisplayName?: string, + fragmentAfterLogin?: string, +): Promise { + if (queryParams.code && queryParams.state) { + return attemptOidcNativeLogin(queryParams); + } + + return attemptTokenLogin(queryParams, defaultDeviceDisplayName, fragmentAfterLogin); +} + +/** + * Attempt to login by completing OIDC authorization code flow + * @param queryParams string->string map of the query-parameters extracted from the real query-string of the starting URI. + * @returns Promise that resolves to true when login succceeded, else false + */ +async function attemptOidcNativeLogin(queryParams: QueryDict): Promise { + try { + const { accessToken, homeserverUrl, identityServerUrl } = await completeOidcLogin(queryParams); + + const { + user_id: userId, + device_id: deviceId, + is_guest: isGuest, + } = await getUserIdFromAccessToken(accessToken, homeserverUrl, identityServerUrl); + + const credentials = { + accessToken, + homeserverUrl, + identityServerUrl, + deviceId, + userId, + isGuest, + }; + + logger.debug("Logged in via OIDC native flow"); + await onSuccessfulDelegatedAuthLogin(credentials); + return true; + } catch (error) { + logger.error("Failed to login via OIDC", error); + + // TODO(kerrya) nice error messages https://github.com/vector-im/element-web/issues/25665 + await onFailedDelegatedAuthLogin(_t("Something went wrong.")); + return false; + } +} + +/** + * Gets information about the owner of a given access token. + * @param accessToken + * @param homeserverUrl + * @param identityServerUrl + * @returns Promise that resolves with whoami response + * @throws when whoami request fails + */ +async function getUserIdFromAccessToken( + accessToken: string, + homeserverUrl: string, + identityServerUrl?: string, +): Promise> { + try { + const client = createClient({ + baseUrl: homeserverUrl, + accessToken: accessToken, + idBaseUrl: identityServerUrl, + }); + + return await client.whoami(); + } catch (error) { + logger.error("Failed to retrieve userId using accessToken", error); + throw new Error("Failed to retrieve userId using accessToken"); + } +} + +/** + * @param {QueryDict} queryParams string->string map of the + * query-parameters extracted from the real query-string of the starting + * URI. + * + * @param {string} defaultDeviceDisplayName + * @param {string} fragmentAfterLogin path to go to after a successful login, only used for "Try again" + * * @returns {Promise} promise which resolves to true if we completed the token * login, else false */ diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index b65e559ef02..cbe92910eb5 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -316,13 +316,17 @@ export default class MatrixChat extends React.PureComponent { // the first thing to do is to try the token params in the query-string // if the session isn't soft logged out (ie: is a clean session being logged in) if (!Lifecycle.isSoftLogout()) { - Lifecycle.attemptTokenLogin( + Lifecycle.attemptDelegatedAuthLogin( this.props.realQueryParams, this.props.defaultDeviceDisplayName, this.getFragmentAfterLogin(), ).then(async (loggedIn): Promise => { - if (this.props.realQueryParams?.loginToken) { - // remove the loginToken from the URL regardless + if ( + this.props.realQueryParams?.loginToken || + this.props.realQueryParams?.code || + this.props.realQueryParams?.state + ) { + // remove the loginToken or auth code from the URL regardless this.props.onTokenLoginCompleted(); } @@ -341,7 +345,6 @@ export default class MatrixChat extends React.PureComponent { // if the user has followed a login or register link, don't reanimate // the old creds, but rather go straight to the relevant page const firstScreen = this.screenAfterLogin ? this.screenAfterLogin.screen : null; - const restoreSuccess = await this.loadSession(); if (restoreSuccess) { return true; diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 7e3eabb1239..241c2dcc719 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -477,6 +477,7 @@ export default class LoginComponent extends React.PureComponent this.props.serverConfig.delegatedAuthentication!, flow.clientId, this.props.serverConfig.hsUrl, + this.props.serverConfig.isUrl, ); }} > diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index d2e280ff134..25eeaf2840d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -101,6 +101,7 @@ "Failed to transfer call": "Failed to transfer call", "Permission Required": "Permission Required", "You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room", + "Something went wrong.": "Something went wrong.", "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.": "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.", "We couldn't log you in": "We couldn't log you in", "Try again": "Try again", diff --git a/src/utils/oidc/authorize.ts b/src/utils/oidc/authorize.ts index 22e7a11bce1..1ea1ae90b7c 100644 --- a/src/utils/oidc/authorize.ts +++ b/src/utils/oidc/authorize.ts @@ -18,7 +18,9 @@ import { AuthorizationParams, generateAuthorizationParams, generateAuthorizationUrl, + completeAuthorizationCodeGrant, } from "matrix-js-sdk/src/oidc/authorize"; +import { QueryDict } from "matrix-js-sdk/src/utils"; import { ValidatedDelegatedAuthConfig } from "../ValidatedServerConfig"; @@ -31,16 +33,65 @@ import { ValidatedDelegatedAuthConfig } from "../ValidatedServerConfig"; */ const storeAuthorizationParams = ( { redirectUri, state, nonce, codeVerifier }: AuthorizationParams, - { issuer }: ValidatedDelegatedAuthConfig, + delegatedAuthConfig: ValidatedDelegatedAuthConfig, clientId: string, - homeserver: string, + homeserverUrl: string, + identityServerUrl?: string, ): void => { window.sessionStorage.setItem(`oidc_${state}_nonce`, nonce); window.sessionStorage.setItem(`oidc_${state}_redirectUri`, redirectUri); window.sessionStorage.setItem(`oidc_${state}_codeVerifier`, codeVerifier); window.sessionStorage.setItem(`oidc_${state}_clientId`, clientId); - window.sessionStorage.setItem(`oidc_${state}_issuer`, issuer); - window.sessionStorage.setItem(`oidc_${state}_homeserver`, homeserver); + window.sessionStorage.setItem(`oidc_${state}_delegatedAuthConfig`, JSON.stringify(delegatedAuthConfig)); + window.sessionStorage.setItem(`oidc_${state}_homeserverUrl`, homeserverUrl); + if (identityServerUrl) { + window.sessionStorage.setItem(`oidc_${state}_identityServerUrl`, identityServerUrl); + } +}; + +type StoredAuthorizationParams = Omit, "state" | "scope"> & { + delegatedAuthConfig: ValidatedDelegatedAuthConfig; + clientId: string; + homeserverUrl: string; + identityServerUrl: string; +}; + +/** + * Validate that stored params are present and valid + * @param params as retrieved from session storage + * @returns validated stored authorization params + * @throws when params are invalid or missing + */ +const validateStoredAuthorizationParams = (params: Partial): StoredAuthorizationParams => { + const requiredStringProperties = ["nonce", "redirectUri", "codeVerifier", "clientId", "homeserverUrl"]; + if ( + requiredStringProperties.every((key: string) => params[key] && typeof params[key] === "string") && + (params.identityServerUrl === undefined || typeof params.identityServerUrl === "string") && + !!params.delegatedAuthConfig + ) { + return params as StoredAuthorizationParams; + } + throw new Error("Cannot complete OIDC login: required properties not found in session storage"); +}; + +const retrieveAuthorizationParams = (state: string): StoredAuthorizationParams => { + const nonce = window.sessionStorage.getItem(`oidc_${state}_nonce`); + const redirectUri = window.sessionStorage.getItem(`oidc_${state}_redirectUri`); + const codeVerifier = window.sessionStorage.getItem(`oidc_${state}_codeVerifier`); + const clientId = window.sessionStorage.getItem(`oidc_${state}_clientId`); + const homeserverUrl = window.sessionStorage.getItem(`oidc_${state}_homeserverUrl`); + const identityServerUrl = window.sessionStorage.getItem(`oidc_${state}_identityServerUrl`) ?? undefined; + const delegatedAuthConfig = window.sessionStorage.getItem(`oidc_${state}_delegatedAuthConfig`); + + return validateStoredAuthorizationParams({ + nonce, + redirectUri, + codeVerifier, + clientId, + homeserverUrl, + identityServerUrl, + delegatedAuthConfig: delegatedAuthConfig ? JSON.parse(delegatedAuthConfig) : undefined, + }); }; /** @@ -55,13 +106,14 @@ const storeAuthorizationParams = ( export const startOidcLogin = async ( delegatedAuthConfig: ValidatedDelegatedAuthConfig, clientId: string, - homeserver: string, + homeserverUrl: string, + identityServerUrl?: string, ): Promise => { // TODO(kerrya) afterloginfragment https://github.com/vector-im/element-web/issues/25656 const redirectUri = window.location.origin; const authParams = generateAuthorizationParams({ redirectUri }); - storeAuthorizationParams(authParams, delegatedAuthConfig, clientId, homeserver); + storeAuthorizationParams(authParams, delegatedAuthConfig, clientId, homeserverUrl, identityServerUrl); const authorizationUrl = await generateAuthorizationUrl( delegatedAuthConfig.authorizationEndpoint, @@ -71,3 +123,46 @@ export const startOidcLogin = async ( window.location.href = authorizationUrl; }; + +/** + * Gets `code` and `state` query params + * + * @param queryParams + * @returns code and state + * @throws when code and state are not valid strings + */ +const getCodeAndStateFromQueryParams = (queryParams: QueryDict): { code: string; state: string } => { + const code = queryParams["code"]; + const state = queryParams["state"]; + + if (!code || typeof code !== "string" || !state || typeof state !== "string") { + throw new Error("Invalid query parameters for OIDC native login. `code` and `state` are required."); + } + return { code, state }; +}; + +/** + * Attempt to complete authorization code flow to get an access token + * @param queryParams the query-parameters extracted from the real query-string of the starting URI. + * @returns Promise that resolves with accessToken, identityServerUrl, and homeserverUrl when login was successful + * @throws When we failed to get a valid access token + */ +export const completeOidcLogin = async ( + queryParams: QueryDict, +): Promise<{ + homeserverUrl: string; + identityServerUrl?: string; + accessToken: string; +}> => { + const { code, state } = getCodeAndStateFromQueryParams(queryParams); + + const storedAuthorizationParams = retrieveAuthorizationParams(state); + + const bearerTokenResponse = await completeAuthorizationCodeGrant(code, storedAuthorizationParams); + // @TODO(kerrya) do something with the refresh token https://github.com/vector-im/element-web/issues/25444 + return { + homeserverUrl: storedAuthorizationParams.homeserverUrl, + identityServerUrl: storedAuthorizationParams.identityServerUrl, + accessToken: bearerTokenResponse.access_token, + }; +}; diff --git a/test/components/structures/MatrixChat-test.tsx b/test/components/structures/MatrixChat-test.tsx index 07dbba6ab6f..1e2b52a5b29 100644 --- a/test/components/structures/MatrixChat-test.tsx +++ b/test/components/structures/MatrixChat-test.tsx @@ -16,12 +16,16 @@ limitations under the License. import React, { ComponentProps } from "react"; import { fireEvent, render, RenderResult, screen, within } from "@testing-library/react"; -import fetchMockJest from "fetch-mock-jest"; +import fetchMock from "fetch-mock-jest"; import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client"; import { SyncState } from "matrix-js-sdk/src/sync"; import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler"; import * as MatrixJs from "matrix-js-sdk/src/matrix"; import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { generateAuthorizationParams } 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 * as OidcValidation from "matrix-js-sdk/src/oidc/validate"; import MatrixChat from "../../../src/components/structures/MatrixChat"; import * as StorageManager from "../../../src/utils/StorageManager"; @@ -64,6 +68,7 @@ describe("", () => { setAccountData: jest.fn(), store: { destroy: jest.fn(), + startup: jest.fn(), }, login: jest.fn(), loginFlows: jest.fn(), @@ -85,6 +90,7 @@ describe("", () => { isStored: jest.fn().mockReturnValue(null), }, getDehydratedDevice: jest.fn(), + whoami: jest.fn(), isRoomEncrypted: jest.fn(), }); let mockClient = getMockClientWithEventEmitter(getMockClientMethods()); @@ -120,20 +126,52 @@ describe("", () => { const localStorageGetSpy = jest.spyOn(localStorage.__proto__, "getItem").mockReturnValue(undefined); const localStorageClearSpy = jest.spyOn(localStorage.__proto__, "clear"); const sessionStorageSetSpy = jest.spyOn(sessionStorage.__proto__, "setItem"); + const sessionStorageGetSpy = jest.spyOn(sessionStorage.__proto__, "getItem"); // make test results readable filterConsole("Failed to parse localStorage object"); + /** + * Wait for a bunch of stuff to happen + * between deciding we are logged in and removing the spinner + * including waiting for initial sync + */ + const waitForSyncAndLoad = async (client: MatrixClient, withoutSecuritySetup?: boolean): Promise => { + // need to wait for different elements depending on which flow + // without security setup we go to a loading page + if (withoutSecuritySetup) { + // we think we are logged in, but are still waiting for the /sync to complete + await screen.findByText("Logout"); + // initial sync + client.emit(ClientEvent.Sync, SyncState.Prepared, null); + // wait for logged in view to load + await screen.findByLabelText("User menu"); + + // otherwise we stay on login and load from there for longer + } else { + // we are logged in, but are still waiting for the /sync to complete + await screen.findByText("Syncing…"); + // initial sync + client.emit(ClientEvent.Sync, SyncState.Prepared, null); + } + + // let things settle + await flushPromises(); + // and some more for good measure + // this proved to be a little flaky + await flushPromises(); + }; + beforeEach(async () => { mockClient = getMockClientWithEventEmitter(getMockClientMethods()); - fetchMockJest.get("https://test.com/_matrix/client/versions", { + fetchMock.get("https://test.com/_matrix/client/versions", { unstable_features: {}, versions: [], }); localStorageGetSpy.mockReset(); localStorageSetSpy.mockReset(); sessionStorageSetSpy.mockReset(); - jest.spyOn(StorageManager, "idbLoad").mockRestore(); + jest.spyOn(StorageManager, "idbLoad").mockReset(); jest.spyOn(StorageManager, "idbSave").mockResolvedValue(undefined); jest.spyOn(defaultDispatcher, "dispatch").mockClear(); @@ -375,32 +413,6 @@ describe("", () => { return renderResult; }; - const waitForSyncAndLoad = async (client: MatrixClient, withoutSecuritySetup?: boolean): Promise => { - // need to wait for different elements depending on which flow - // without security setup we go to a loading page - if (withoutSecuritySetup) { - // we think we are logged in, but are still waiting for the /sync to complete - await screen.findByText("Logout"); - // initial sync - client.emit(ClientEvent.Sync, SyncState.Prepared, null); - // wait for logged in view to load - await screen.findByLabelText("User menu"); - - // otherwise we stay on login and load from there for longer - } else { - // we are logged in, but are still waiting for the /sync to complete - await screen.findByText("Syncing…"); - // initial sync - client.emit(ClientEvent.Sync, SyncState.Prepared, null); - } - - // let things settle - await flushPromises(); - // and some more for good measure - // this proved to be a little flaky - await flushPromises(); - }; - const getComponentAndLogin = async (withoutSecuritySetup?: boolean): Promise => { await getComponentAndWaitForReady(); @@ -416,7 +428,7 @@ describe("", () => { beforeEach(() => { loginClient = getMockClientWithEventEmitter(getMockClientMethods()); // this is used to create a temporary client during login - jest.spyOn(MatrixJs, "createClient").mockReturnValue(loginClient); + jest.spyOn(MatrixJs, "createClient").mockClear().mockReturnValue(loginClient); loginClient.login.mockClear().mockResolvedValue({ access_token: "TOKEN", @@ -710,4 +722,245 @@ describe("", () => { }); }); }); + + describe("when query params have a OIDC params", () => { + const issuer = "https://auth.com/"; + const authorizationEndpoint = "https://auth.com/authorization"; + const homeserver = "https://matrix.org"; + const identityServerUrl = "https://is.org"; + const clientId = "xyz789"; + const baseUrl = "https://test.com"; + + const delegatedAuthConfig = { + issuer, + registrationEndpoint: issuer + "registration", + authorizationEndpoint, + tokenEndpoint: issuer + "token", + }; + const authorizationParams = generateAuthorizationParams({ redirectUri: baseUrl }); + const storedAuthorizationParams = { + nonce: authorizationParams.nonce, + redirectUri: baseUrl, + codeVerifier: authorizationParams.codeVerifier, + clientId, + homeserverUrl: homeserver, + identityServerUrl, + delegatedAuthConfig: JSON.stringify(delegatedAuthConfig), + }; + const code = "test-oidc-auth-code"; + const realQueryParams = { + code, + state: authorizationParams.state, + }; + + const userId = "@alice:server.org"; + const deviceId = "test-device-id"; + const accessToken = "test-access-token-from-oidc"; + + const mockLocalStorage: Record = { + // these are only going to be set during login + mx_hs_url: homeserver, + mx_is_url: identityServerUrl, + mx_user_id: userId, + mx_device_id: deviceId, + }; + + let loginClient!: ReturnType; + + const validBearerTokenResponse = { + token_type: "Bearer", + access_token: accessToken, + refresh_token: "test_refresh_token", + id_token: "test_id_token", + expires_in: 12345, + }; + + // for now when OIDC fails for any reason we just bump back to welcome + // error handling screens in https://github.com/vector-im/element-web/issues/25665 + const expectOIDCError = async (): Promise => { + await flushPromises(); + // just check we're back on welcome page + expect(document.querySelector(".mx_Welcome")!).toBeInTheDocument(); + }; + + beforeEach(() => { + // annoying to mock jwt decoding used in validateIdToken + jest.spyOn(OidcValidation, "validateIdToken") + .mockClear() + .mockImplementation(() => {}); + + jest.spyOn(logger, "error").mockClear(); + }); + + beforeEach(() => { + loginClient = getMockClientWithEventEmitter(getMockClientMethods()); + // this is used to create a temporary client during login + jest.spyOn(MatrixJs, "createClient").mockReturnValue(loginClient); + + jest.spyOn(logger, "error").mockClear(); + jest.spyOn(logger, "log").mockClear(); + + localStorageGetSpy.mockImplementation((key: unknown) => mockLocalStorage[key as string] || ""); + sessionStorageGetSpy.mockImplementation((key: string) => { + const itemKey = key.split(`oidc_${authorizationParams.state}_`).pop(); + return storedAuthorizationParams[itemKey] || null; + }); + + fetchMock.resetHistory(); + fetchMock.post( + delegatedAuthConfig.tokenEndpoint, + { + status: 200, + body: validBearerTokenResponse, + }, + { overwriteRoutes: true }, + ); + + loginClient.whoami.mockResolvedValue({ + user_id: userId, + device_id: deviceId, + is_guest: false, + }); + }); + + it("should fail when authorization params are not found in session storage", async () => { + sessionStorageGetSpy.mockReturnValue(undefined); + getComponent({ realQueryParams }); + + await flushPromises(); + + expect(logger.error).toHaveBeenCalledWith( + "Failed to login via OIDC", + new Error("Cannot complete OIDC login: required properties not found in session storage"), + ); + + await expectOIDCError(); + }); + + it("should attempt to get access token", async () => { + getComponent({ realQueryParams }); + + expect(fetchMock).toHaveFetched(delegatedAuthConfig.tokenEndpoint); + }); + + it("should look up userId using access token", async () => { + getComponent({ realQueryParams }); + + await flushPromises(); + + // check we used a client with the correct accesstoken + expect(MatrixJs.createClient).toHaveBeenCalledWith({ + baseUrl: homeserver, + accessToken, + idBaseUrl: identityServerUrl, + }); + expect(loginClient.whoami).toHaveBeenCalled(); + }); + + it("should log error and return to welcome page when userId lookup fails", async () => { + loginClient.whoami.mockRejectedValue(new Error("oups")); + getComponent({ realQueryParams }); + + await flushPromises(); + + expect(logger.error).toHaveBeenCalledWith( + "Failed to login via OIDC", + new Error("Failed to retrieve userId using accessToken"), + ); + await expectOIDCError(); + }); + + it("should call onTokenLoginCompleted", async () => { + const onTokenLoginCompleted = jest.fn(); + getComponent({ realQueryParams, onTokenLoginCompleted }); + + await flushPromises(); + + expect(onTokenLoginCompleted).toHaveBeenCalled(); + }); + + describe("when login fails", () => { + beforeEach(() => { + fetchMock.post( + delegatedAuthConfig.tokenEndpoint, + { + status: 500, + }, + { overwriteRoutes: true }, + ); + }); + + it("should log and return to welcome page", async () => { + getComponent({ realQueryParams }); + + await flushPromises(); + + expect(logger.error).toHaveBeenCalledWith( + "Failed to login via OIDC", + new Error(OidcError.CodeExchangeFailed), + ); + + // warning dialog + await expectOIDCError(); + }); + + it("should not clear storage", async () => { + getComponent({ realQueryParams }); + + await flushPromises(); + + expect(loginClient.clearStores).not.toHaveBeenCalled(); + }); + }); + + describe("when login succeeds", () => { + beforeEach(() => { + localStorageGetSpy.mockImplementation((key: unknown) => mockLocalStorage[key as string] || ""); + jest.spyOn(StorageManager, "idbLoad").mockImplementation( + async (_table: string, key: string | string[]) => (key === "mx_access_token" ? accessToken : null), + ); + loginClient.getProfileInfo.mockResolvedValue({ + displayname: "Ernie", + }); + }); + + it("should persist login credentials", async () => { + getComponent({ realQueryParams }); + + await flushPromises(); + + expect(localStorageSetSpy).toHaveBeenCalledWith("mx_hs_url", storedAuthorizationParams.homeserverUrl); + expect(localStorageSetSpy).toHaveBeenCalledWith("mx_user_id", userId); + expect(localStorageSetSpy).toHaveBeenCalledWith("mx_has_access_token", "true"); + expect(localStorageSetSpy).toHaveBeenCalledWith("mx_device_id", deviceId); + }); + + it("should set logged in and start MatrixClient", async () => { + getComponent({ realQueryParams }); + + await flushPromises(); + await flushPromises(); + + expect(logger.log).toHaveBeenCalledWith( + "setLoggedIn: mxid: " + + userId + + " deviceId: " + + deviceId + + " guest: " + + false + + " hs: " + + storedAuthorizationParams.homeserverUrl + + " softLogout: " + + false, + " freshLogin: " + false, + ); + + // client successfully started + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: "client_started" }); + + // check we get to logged in view + await waitForSyncAndLoad(loginClient, true); + }); + }); + }); }); diff --git a/test/utils/oidc/authorize-test.ts b/test/utils/oidc/authorize-test.ts index 5abdb19862a..f911f750d3e 100644 --- a/test/utils/oidc/authorize-test.ts +++ b/test/utils/oidc/authorize-test.ts @@ -14,15 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import fetchMockJest from "fetch-mock-jest"; +import fetchMock from "fetch-mock-jest"; +import { Method } from "matrix-js-sdk/src/http-api"; +import { generateAuthorizationParams } from "matrix-js-sdk/src/oidc/authorize"; import * as randomStringUtils from "matrix-js-sdk/src/randomstring"; +import * as OidcValidation from "matrix-js-sdk/src/oidc/validate"; -import { startOidcLogin } from "../../../src/utils/oidc/authorize"; +import { completeOidcLogin, startOidcLogin } from "../../../src/utils/oidc/authorize"; -describe("startOidcLogin()", () => { +describe("OIDC authorization", () => { const issuer = "https://auth.com/"; const authorizationEndpoint = "https://auth.com/authorization"; const homeserver = "https://matrix.org"; + const identityServerUrl = "https://is.org"; const clientId = "xyz789"; const baseUrl = "https://test.com"; @@ -33,17 +37,20 @@ describe("startOidcLogin()", () => { tokenEndpoint: issuer + "token", }; - const sessionStorageGetSpy = jest.spyOn(sessionStorage.__proto__, "setItem").mockReturnValue(undefined); + const sessionStorageSetSpy = jest.spyOn(sessionStorage.__proto__, "setItem").mockReturnValue(undefined); + const sessionStorageGetSpy = jest.spyOn(sessionStorage.__proto__, "getItem").mockReturnValue(undefined); + const randomStringMockImpl = (length: number) => new Array(length).fill("x").join(""); // to restore later const realWindowLocation = window.location; beforeEach(() => { - fetchMockJest.mockClear(); - fetchMockJest.resetBehavior(); + fetchMock.mockClear(); + fetchMock.resetBehavior(); - sessionStorageGetSpy.mockClear(); + sessionStorageSetSpy.mockClear(); + sessionStorageGetSpy.mockReset(); // @ts-ignore allow delete of non-optional prop delete window.location; @@ -54,49 +61,150 @@ describe("startOidcLogin()", () => { }; jest.spyOn(randomStringUtils, "randomString").mockRestore(); + + // annoying to mock jwt decoding used in validateIdToken + jest.spyOn(OidcValidation, "validateIdToken") + .mockClear() + .mockImplementation(() => {}); }); afterAll(() => { window.location = realWindowLocation; }); - it("should store authorization params in session storage", async () => { - jest.spyOn(randomStringUtils, "randomString").mockReset().mockImplementation(randomStringMockImpl); - await startOidcLogin(delegatedAuthConfig, clientId, homeserver); - - const state = randomStringUtils.randomString(8); - - expect(sessionStorageGetSpy).toHaveBeenCalledWith(`oidc_${state}_nonce`, randomStringUtils.randomString(8)); - expect(sessionStorageGetSpy).toHaveBeenCalledWith(`oidc_${state}_redirectUri`, baseUrl); - expect(sessionStorageGetSpy).toHaveBeenCalledWith( - `oidc_${state}_codeVerifier`, - randomStringUtils.randomString(64), - ); - expect(sessionStorageGetSpy).toHaveBeenCalledWith(`oidc_${state}_clientId`, clientId); - expect(sessionStorageGetSpy).toHaveBeenCalledWith(`oidc_${state}_issuer`, issuer); - expect(sessionStorageGetSpy).toHaveBeenCalledWith(`oidc_${state}_homeserver`, homeserver); + describe("startOidcLogin()", () => { + it("should store authorization params in session storage", async () => { + jest.spyOn(randomStringUtils, "randomString").mockReset().mockImplementation(randomStringMockImpl); + await startOidcLogin(delegatedAuthConfig, clientId, homeserver); + + const state = randomStringUtils.randomString(8); + + expect(sessionStorageSetSpy).toHaveBeenCalledWith(`oidc_${state}_nonce`, randomStringUtils.randomString(8)); + expect(sessionStorageSetSpy).toHaveBeenCalledWith(`oidc_${state}_redirectUri`, baseUrl); + expect(sessionStorageSetSpy).toHaveBeenCalledWith( + `oidc_${state}_codeVerifier`, + randomStringUtils.randomString(64), + ); + expect(sessionStorageSetSpy).toHaveBeenCalledWith(`oidc_${state}_clientId`, clientId); + expect(sessionStorageSetSpy).toHaveBeenCalledWith(`oidc_${state}_homeserverUrl`, homeserver); + expect(sessionStorageSetSpy).toHaveBeenCalledWith( + `oidc_${state}_delegatedAuthConfig`, + JSON.stringify(delegatedAuthConfig), + ); + }); + + it("navigates to authorization endpoint with correct parameters", async () => { + await startOidcLogin(delegatedAuthConfig, clientId, homeserver); + + const expectedScopeWithoutDeviceId = `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:`; + + const authUrl = new URL(window.location.href); + + expect(authUrl.searchParams.get("response_mode")).toEqual("query"); + 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("nonce")).toBeTruthy(); + expect(authUrl.searchParams.has("code_challenge")).toBeTruthy(); + }); }); - it("navigates to authorization endpoint with correct parameters", async () => { - await startOidcLogin(delegatedAuthConfig, clientId, homeserver); - - const expectedScopeWithoutDeviceId = `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:`; - - const authUrl = new URL(window.location.href); - - expect(authUrl.searchParams.get("response_mode")).toEqual("query"); - 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(); + describe("completeOidcLogin()", () => { + const authorizationParams = generateAuthorizationParams({ redirectUri: baseUrl }); + const storedAuthorizationParams = { + nonce: authorizationParams.nonce, + redirectUri: baseUrl, + codeVerifier: authorizationParams.codeVerifier, + clientId, + homeserverUrl: homeserver, + identityServerUrl: identityServerUrl, + delegatedAuthConfig: JSON.stringify(delegatedAuthConfig), + }; + const code = "test-code-777"; + const queryDict = { + code, + state: authorizationParams.state, + }; + const validBearerTokenResponse = { + token_type: "Bearer", + access_token: "test_access_token", + refresh_token: "test_refresh_token", + expires_in: 12345, + }; - // 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(); + beforeEach(() => { + sessionStorageGetSpy.mockImplementation((key: string) => { + const itemKey = key.split(`oidc_${authorizationParams.state}_`).pop(); + return storedAuthorizationParams[itemKey] || null; + }); + + fetchMock.post(delegatedAuthConfig.tokenEndpoint, { + status: 200, + body: validBearerTokenResponse, + }); + }); + + it("should throw when query params do not include state and code", async () => { + expect(async () => await completeOidcLogin({})).rejects.toThrow( + "Invalid query parameters for OIDC native login. `code` and `state` are required.", + ); + }); + + it("should throw when authorization params are not found in session storage", async () => { + const queryDict = { + code: "abc123", + state: "not-the-same-state-we-put-things-in-local-storage-with", + }; + + expect(async () => await completeOidcLogin(queryDict)).rejects.toThrow( + "Cannot complete OIDC login: required properties not found in session storage", + ); + // tried to retreive using state as part of storage key + expect(sessionStorageGetSpy).toHaveBeenCalledWith(`oidc_${queryDict.state}_nonce`); + }); + + it("should make request to token endpoint", async () => { + await completeOidcLogin(queryDict); + + expect(fetchMock).toHaveBeenCalledWith(delegatedAuthConfig.tokenEndpoint, { + method: Method.Post, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `grant_type=authorization_code&client_id=${clientId}&code_verifier=${authorizationParams.codeVerifier}&redirect_uri=https%3A%2F%2Ftest.com&code=${code}`, + }); + }); + + it("should return accessToken, configured homeserver and identityServer", async () => { + const result = await completeOidcLogin(queryDict); + + expect(result).toEqual({ + accessToken: validBearerTokenResponse.access_token, + homeserverUrl: homeserver, + identityServerUrl, + }); + }); + + it("should handle when no identityServer is stored in session storage", async () => { + // session storage mock without identity server stored + const { identityServerUrl: _excludingThis, ...storedParamsWithoutIs } = storedAuthorizationParams; + sessionStorageGetSpy.mockImplementation((key: string) => { + const itemKey = key.split(`oidc_${authorizationParams.state}_`).pop(); + return storedParamsWithoutIs[itemKey] || null; + }); + const result = await completeOidcLogin(queryDict); + + expect(result).toEqual({ + accessToken: validBearerTokenResponse.access_token, + homeserverUrl: homeserver, + identityServerUrl: undefined, + }); + }); }); });