diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 9c4a1cfd33f..1c5514da776 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 b545df98923..d0a369fdea9 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 823e3cfab4a..705278c63dc 100644 --- a/src/utils/oidc/authorize.ts +++ b/src/utils/oidc/authorize.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { completeAuthorizationCodeGrant } from "matrix-js-sdk/src/oidc/authorize"; +import { QueryDict } from "matrix-js-sdk/src/utils"; import { OidcClientConfig } from "matrix-js-sdk/src/autodiscovery"; import { generateOidcAuthorizationUrl } from "matrix-js-sdk/src/oidc/authorize"; import { randomString } from "matrix-js-sdk/src/randomstring"; @@ -49,3 +51,45 @@ 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 { homeserverUrl, tokenResponse, identityServerUrl } = await completeAuthorizationCodeGrant(code, state); + + // @TODO(kerrya) do something with the refresh token https://github.com/vector-im/element-web/issues/25444 + + return { + homeserverUrl: homeserverUrl, + identityServerUrl: identityServerUrl, + accessToken: tokenResponse.access_token, + }; +}; diff --git a/test/components/structures/MatrixChat-test.tsx b/test/components/structures/MatrixChat-test.tsx index 07dbba6ab6f..9b1ea991c7f 100644 --- a/test/components/structures/MatrixChat-test.tsx +++ b/test/components/structures/MatrixChat-test.tsx @@ -16,12 +16,17 @@ 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 { mocked } from "jest-mock"; 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 { 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 { BearerTokenResponse } from "matrix-js-sdk/src/oidc/validate"; import MatrixChat from "../../../src/components/structures/MatrixChat"; import * as StorageManager from "../../../src/utils/StorageManager"; @@ -37,6 +42,10 @@ import { } from "../../test-utils"; import * as leaveRoomUtils from "../../../src/utils/leave-behaviour"; +jest.mock("matrix-js-sdk/src/oidc/authorize", () => ({ + completeAuthorizationCodeGrant: jest.fn(), +})); + describe("", () => { const userId = "@alice:server.org"; const deviceId = "qwertyui"; @@ -64,6 +73,7 @@ describe("", () => { setAccountData: jest.fn(), store: { destroy: jest.fn(), + startup: jest.fn(), }, login: jest.fn(), loginFlows: jest.fn(), @@ -85,6 +95,7 @@ describe("", () => { isStored: jest.fn().mockReturnValue(null), }, getDehydratedDevice: jest.fn(), + whoami: jest.fn(), isRoomEncrypted: jest.fn(), }); let mockClient = getMockClientWithEventEmitter(getMockClientMethods()); @@ -124,16 +135,47 @@ describe("", () => { // 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 +417,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 +432,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 +726,217 @@ describe("", () => { }); }); }); + + 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"; + + const code = "test-oidc-auth-code"; + const state = "test-oidc-state"; + const realQueryParams = { + code, + state: 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: homeserverUrl, + mx_is_url: identityServerUrl, + mx_user_id: userId, + mx_device_id: deviceId, + }; + + const tokenResponse: BearerTokenResponse = { + access_token: accessToken, + refresh_token: "def456", + scope: "test", + token_type: "Bearer", + expires_at: 12345, + }; + + let loginClient!: ReturnType; + + // 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(() => { + mocked(completeAuthorizationCodeGrant).mockClear().mockResolvedValue({ + oidcClientSettings: { + clientId, + issuer, + }, + tokenResponse, + homeserverUrl, + identityServerUrl, + }); + + 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] || ""); + loginClient.whoami.mockResolvedValue({ + user_id: userId, + device_id: deviceId, + is_guest: false, + }); + }); + + it("should fail when query params do not include valid code and state", async () => { + const queryParams = { + code: 123, + state: "abc", + }; + getComponent({ realQueryParams: queryParams }); + + await flushPromises(); + + expect(logger.error).toHaveBeenCalledWith( + "Failed to login via OIDC", + new Error("Invalid query parameters for OIDC native login. `code` and `state` are required."), + ); + + await expectOIDCError(); + }); + + it("should make correct request to complete authorization", async () => { + getComponent({ realQueryParams }); + + await flushPromises(); + + expect(completeAuthorizationCodeGrant).toHaveBeenCalledWith(code, state); + }); + + 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: homeserverUrl, + 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(() => { + mocked(completeAuthorizationCodeGrant).mockRejectedValue(new Error(OidcError.CodeExchangeFailed)); + }); + + 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", 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: " + + 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 3dda4da2e9f..7a554562e9b 100644 --- a/test/utils/oidc/authorize-test.ts +++ b/test/utils/oidc/authorize-test.ts @@ -14,31 +14,33 @@ 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 { completeAuthorizationCodeGrant } from "matrix-js-sdk/src/oidc/authorize"; import * as randomStringUtils from "matrix-js-sdk/src/randomstring"; +import { BearerTokenResponse } from "matrix-js-sdk/src/oidc/validate"; +import { mocked } from "jest-mock"; -import { startOidcLogin } from "../../../src/utils/oidc/authorize"; -import { makeDelegatedAuthConfig, mockOpenIdConfiguration } from "../../test-utils/oidc"; +import { completeOidcLogin, startOidcLogin } from "../../../src/utils/oidc/authorize"; +import { makeDelegatedAuthConfig } from "../../test-utils/oidc"; -describe("startOidcLogin()", () => { +jest.mock("matrix-js-sdk/src/oidc/authorize", () => ({ + ...jest.requireActual("matrix-js-sdk/src/oidc/authorize"), + completeAuthorizationCodeGrant: jest.fn(), +})); + +describe("OIDC authorization", () => { const issuer = "https://auth.com/"; - const homeserver = "https://matrix.org"; + const homeserverUrl = "https://matrix.org"; + const identityServerUrl = "https://is.org"; const clientId = "xyz789"; const baseUrl = "https://test.com"; const delegatedAuthConfig = makeDelegatedAuthConfig(issuer); - const sessionStorageGetSpy = jest.spyOn(sessionStorage.__proto__, "setItem").mockReturnValue(undefined); - // to restore later const realWindowLocation = window.location; beforeEach(() => { - fetchMockJest.mockClear(); - fetchMockJest.resetBehavior(); - - sessionStorageGetSpy.mockClear(); - // @ts-ignore allow delete of non-optional prop delete window.location; // @ts-ignore ugly mocking @@ -47,37 +49,90 @@ describe("startOidcLogin()", () => { origin: baseUrl, }; - fetchMockJest.get( - delegatedAuthConfig.metadata.issuer + ".well-known/openid-configuration", - mockOpenIdConfiguration(), - ); jest.spyOn(randomStringUtils, "randomString").mockRestore(); }); + beforeAll(() => { + fetchMock.get(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`, delegatedAuthConfig.metadata); + }); + afterAll(() => { window.location = realWindowLocation; }); - it("navigates to authorization endpoint with correct parameters", async () => { - await startOidcLogin(delegatedAuthConfig, clientId, homeserver); + describe("startOidcLogin()", () => { + it("navigates to authorization endpoint with correct parameters", async () => { + await startOidcLogin(delegatedAuthConfig, clientId, homeserverUrl); - const expectedScopeWithoutDeviceId = `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:`; + const expectedScopeWithoutDeviceId = `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:`; - const authUrl = new URL(window.location.href); + 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"); + 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(); + // 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(); + }); + }); + + describe("completeOidcLogin()", () => { + const state = "test-state-444"; + const code = "test-code-777"; + const queryDict = { + code, + state: state, + }; + + const tokenResponse: BearerTokenResponse = { + access_token: "abc123", + refresh_token: "def456", + scope: "test", + token_type: "Bearer", + expires_at: 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(() => { + mocked(completeAuthorizationCodeGrant).mockClear().mockResolvedValue({ + oidcClientSettings: { + clientId, + issuer, + }, + tokenResponse, + homeserverUrl, + identityServerUrl, + }); + }); + + 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 make request complete authorization code grant", async () => { + await completeOidcLogin(queryDict); + + expect(completeAuthorizationCodeGrant).toHaveBeenCalledWith(code, state); + }); + + it("should return accessToken, configured homeserver and identityServer", async () => { + const result = await completeOidcLogin(queryDict); + + expect(result).toEqual({ + accessToken: tokenResponse.access_token, + homeserverUrl, + identityServerUrl, + }); + }); }); });