diff --git a/src/auth/interactiveAuth.test.ts b/src/auth/interactiveAuth.test.ts new file mode 100644 index 000000000..3c2fd99e3 --- /dev/null +++ b/src/auth/interactiveAuth.test.ts @@ -0,0 +1,104 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { afterEach, expect, test, vi } from "vitest"; +import { createClient, type MatrixClient, MatrixError } from "matrix-js-sdk"; +import type * as MatrixJsSdk from "matrix-js-sdk"; + +import { loginWithPassword, registerWithPassword } from "./interactiveAuth"; + +vi.mock("matrix-js-sdk", async (importOriginal) => ({ + ...(await importOriginal()), + createClient: vi.fn(), +})); + +const credentials = { + user_id: "@alice:example.org", + device_id: "AAAA", + access_token: "secret", +}; + +// What interactive auth asks of the client besides the request itself +const authClient = { generateClientSecret: () => "client-secret" }; + +afterEach(() => { + vi.clearAllMocks(); +}); + +test("logs in with a password", async () => { + const login = vi.fn().mockResolvedValue({ ...credentials, extra: "ignored" }); + vi.mocked(createClient).mockReturnValue({ + ...authClient, + login, + } as Partial as MatrixClient); + + await expect( + loginWithPassword("https://hs.example.org", "alice", "hunter2"), + ).resolves.toEqual(credentials); + + expect(createClient).toHaveBeenCalledWith({ + baseUrl: "https://hs.example.org", + }); + expect(login).toHaveBeenCalledWith("m.login.password", { + identifier: { type: "m.id.user", user: "alice" }, + password: "hunter2", + }); +}); + +test("registers, agreeing to the terms and answering the recaptcha", async () => { + // The homeserver turns the first request away with the stages to complete + // No error in it: that is the shape of a challenge rather than a refusal + const challenge = new MatrixError( + { + session: "session", + flows: [{ stages: ["m.login.terms", "m.login.recaptcha"] }], + params: {}, + completed: [], + }, + 401, + ); + const registerRequest = vi + .fn() + .mockImplementation(({ auth }: { auth?: { type: string } }) => { + if (auth === undefined) throw challenge; + challenge.data.completed = [ + ...(challenge.data.completed as string[]), + auth.type, + ]; + if (challenge.data.completed.length < 2) throw challenge; + return credentials; + }); + vi.mocked(createClient).mockReturnValue({ + ...authClient, + registerRequest, + } as Partial as MatrixClient); + + await expect( + registerWithPassword( + "https://hs.example.org", + "alice", + "hunter2", + "captcha", + ), + ).resolves.toEqual(credentials); + + expect(registerRequest).toHaveBeenCalledWith( + expect.objectContaining({ + username: "alice", + password: "hunter2", + auth: expect.objectContaining({ type: "m.login.terms" }), + }), + ); + expect(registerRequest).toHaveBeenCalledWith( + expect.objectContaining({ + auth: expect.objectContaining({ + type: "m.login.recaptcha", + response: "captcha", + }), + }), + ); +}); diff --git a/src/auth/interactiveAuth.ts b/src/auth/interactiveAuth.ts new file mode 100644 index 000000000..0b5813311 --- /dev/null +++ b/src/auth/interactiveAuth.ts @@ -0,0 +1,106 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { + createClient, + InteractiveAuth, + type LoginResponse, + type RegisterResponse, +} from "matrix-js-sdk"; +import { logger } from "matrix-js-sdk/lib/logger"; + +import { type Session } from "../ClientContext"; + +/** What the homeserver hands out for a new session. */ +export type Credentials = Pick< + Session, + "user_id" | "device_id" | "access_token" +>; + +/** + * Logs in to the given homeserver with a password. + */ +export async function loginWithPassword( + homeserver: string, + username: string, + password: string, +): Promise { + const authClient = createClient({ baseUrl: homeserver }); + const interactiveAuth = new InteractiveAuth({ + matrixClient: authClient, + doRequest: async (): Promise => + authClient.login("m.login.password", { + identifier: { + type: "m.id.user", + user: username, + }, + password, + }), + stateUpdated: (): void => {}, + requestEmailToken: async (): Promise<{ sid: string }> => + Promise.resolve({ sid: "" }), + }); + return credentials(await interactiveAuth.attemptAuth()); +} + +/** + * Registers an account on the given homeserver, accepting its terms and + * answering its recaptcha challenge. + */ +export async function registerWithPassword( + homeserver: string, + username: string, + password: string, + recaptchaResponse: string, +): Promise { + const authClient = createClient({ baseUrl: homeserver }); + const interactiveAuth = new InteractiveAuth({ + matrixClient: authClient, + doRequest: async (auth): Promise => + authClient.registerRequest({ + username, + password, + auth: auth || undefined, + }), + stateUpdated: (nextStage, status): void => { + if (status.error) { + throw new Error(status.error); + } + + if (nextStage === "m.login.terms") { + interactiveAuth + .submitAuthDict({ + type: "m.login.terms", + }) + .catch((e) => { + logger.error(e); + }); + } else if (nextStage === "m.login.recaptcha") { + interactiveAuth + .submitAuthDict({ + type: "m.login.recaptcha", + response: recaptchaResponse, + }) + .catch((e) => { + logger.error(e); + }); + } + }, + requestEmailToken: async (): Promise<{ sid: string }> => + Promise.resolve({ sid: "dummy" }), + }); + return credentials(await interactiveAuth.attemptAuth()); +} + +// XXX: attemptAuth claims to return an IAuthData which contains none of these +// things - the js-sdk types may be wrong? +function credentials(authData: unknown): Credentials { + /* eslint-disable camelcase */ + const { user_id, access_token, device_id } = authData as Credentials; + return { user_id, access_token, device_id }; + /* eslint-enable camelcase */ +} diff --git a/src/auth/useInteractiveLogin.ts b/src/auth/useInteractiveLogin.ts index f818abda6..3b4ceb66d 100644 --- a/src/auth/useInteractiveLogin.ts +++ b/src/auth/useInteractiveLogin.ts @@ -6,15 +6,12 @@ Please see LICENSE in the repository root for full details. */ import { useCallback } from "react"; -import { InteractiveAuth } from "matrix-js-sdk"; -import { - createClient, - type LoginResponse, - type MatrixClient, -} from "matrix-js-sdk"; +import { type MatrixClient } from "matrix-js-sdk"; import { initClient } from "../utils/initClient"; import { type Session } from "../ClientContext"; +import { loginWithPassword } from "./interactiveAuth"; + /** * This provides the login method to login using user credentials. * @param oldClient If there is an already authenticated client it should be passed to this hook @@ -36,32 +33,8 @@ export function useInteractiveLogin( ) => Promise<[MatrixClient, Session]> >( async (homeserver: string, username: string, password: string) => { - const authClient = createClient({ baseUrl: homeserver }); - - const interactiveAuth = new InteractiveAuth({ - matrixClient: authClient, - doRequest: async (): Promise => - authClient.login("m.login.password", { - identifier: { - type: "m.id.user", - user: username, - }, - password, - }), - stateUpdated: (): void => {}, - requestEmailToken: async (): Promise<{ sid: string }> => - Promise.resolve({ sid: "" }), - }); - - // XXX: This claims to return an IAuthData which contains none of these - // things - the js-sdk types may be wrong? - /* eslint-disable camelcase,@typescript-eslint/no-explicit-any */ - const { user_id, access_token, device_id } = - (await interactiveAuth.attemptAuth()) as any; - const session = { - user_id, - access_token, - device_id, + const session: Session = { + ...(await loginWithPassword(homeserver, username, password)), passwordlessUser: false, }; @@ -70,13 +43,12 @@ export function useInteractiveLogin( const client = await initClient( { baseUrl: homeserver, - accessToken: access_token, - userId: user_id, - deviceId: device_id, + accessToken: session.access_token, + userId: session.user_id, + deviceId: session.device_id, }, false, ); - /* eslint-enable camelcase */ return [client, session]; }, [oldClient], diff --git a/src/auth/useInteractiveRegistration.ts b/src/auth/useInteractiveRegistration.ts index 4c79b26d1..97f813f7f 100644 --- a/src/auth/useInteractiveRegistration.ts +++ b/src/auth/useInteractiveRegistration.ts @@ -5,19 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { useState, useEffect, useCallback, useRef } from "react"; -import { InteractiveAuth } from "matrix-js-sdk"; -import { - createClient, - type MatrixClient, - type RegisterResponse, -} from "matrix-js-sdk"; -import { logger } from "matrix-js-sdk/lib/logger"; +import { useCallback } from "react"; +import { type MatrixClient } from "matrix-js-sdk"; import { initClient } from "../utils/initClient"; import { type Session } from "../ClientContext"; import { Config } from "../config/Config"; -import { useUrlParams } from "../UrlParams"; +import { registerWithPassword } from "./interactiveAuth"; +import { useRegistrationTerms } from "./useRegistrationTerms"; export const useInteractiveRegistration = ( oldClient?: MatrixClient, @@ -32,31 +27,7 @@ export const useInteractiveRegistration = ( passwordlessUser: boolean, ) => Promise<[MatrixClient, Session]>; } => { - const { isWidget } = useUrlParams(); - const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState( - undefined, - ); - const [recaptchaKey, setRecaptchaKey] = useState( - undefined, - ); - - const authClient = useRef(undefined); - if (!authClient.current) { - authClient.current = createClient({ - baseUrl: Config.defaultHomeserverUrl()!, - }); - } - - useEffect(() => { - if (isWidget) return; - // An empty registerRequest is used to get the privacy policy and recaptcha key. - authClient.current!.registerRequest({}).catch((error) => { - setPrivacyPolicyUrl( - error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url, - ); - setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key); - }); - }, [isWidget]); + const { privacyPolicyUrl, recaptchaKey } = useRegistrationTerms(); const register = useCallback( async ( @@ -66,72 +37,30 @@ export const useInteractiveRegistration = ( recaptchaResponse: string, passwordlessUser: boolean, ): Promise<[MatrixClient, Session]> => { - const interactiveAuth = new InteractiveAuth({ - matrixClient: authClient.current!, - doRequest: async (auth): Promise => - authClient.current!.registerRequest({ - username, - password, - auth: auth || undefined, - }), - stateUpdated: (nextStage, status): void => { - if (status.error) { - throw new Error(status.error); - } + const homeserver = Config.defaultHomeserverUrl()!; + const session: Session = { + ...(await registerWithPassword( + homeserver, + username, + password, + recaptchaResponse, + )), + passwordlessUser, + ...(passwordlessUser ? { tempPassword: password } : {}), + }; - if (nextStage === "m.login.terms") { - interactiveAuth - .submitAuthDict({ - type: "m.login.terms", - }) - .catch((e) => { - logger.error(e); - }); - } else if (nextStage === "m.login.recaptcha") { - interactiveAuth - .submitAuthDict({ - type: "m.login.recaptcha", - response: recaptchaResponse, - }) - .catch((e) => { - logger.error(e); - }); - } - }, - requestEmailToken: async (): Promise<{ sid: string }> => - Promise.resolve({ sid: "dummy" }), - }); - - // XXX: This claims to return an IAuthData which contains none of these - // things - the js-sdk types may be wrong? - /* eslint-disable camelcase,@typescript-eslint/no-explicit-any */ - const { user_id, access_token, device_id } = - (await interactiveAuth.attemptAuth()) as any; await oldClient?.logout(true); const client = await initClient( { - baseUrl: Config.defaultHomeserverUrl()!, - accessToken: access_token, - userId: user_id, - deviceId: device_id, + baseUrl: homeserver, + accessToken: session.access_token, + userId: session.user_id, + deviceId: session.device_id, }, false, ); await client.setDisplayName(displayName); - - const session: Session = { - user_id, - device_id, - access_token, - passwordlessUser, - }; - /* eslint-enable camelcase */ - - if (passwordlessUser) { - session.tempPassword = password; - } - const user = client.getUser(client.getUserId()!)!; user.setRawDisplayName(displayName); user.setDisplayName(displayName); diff --git a/src/auth/useRegistrationTerms.ts b/src/auth/useRegistrationTerms.ts new file mode 100644 index 000000000..b32673872 --- /dev/null +++ b/src/auth/useRegistrationTerms.ts @@ -0,0 +1,46 @@ +/* +Copyright 2022-2024 New Vector Ltd. +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { useEffect, useState } from "react"; +import { createClient } from "matrix-js-sdk"; + +import { Config } from "../config/Config"; +import { useUrlParams } from "../UrlParams"; + +/** + * What the homeserver asks of a registration: its privacy policy to agree to + * and the recaptcha key to answer a challenge with. + */ +export function useRegistrationTerms(): { + privacyPolicyUrl?: string; + recaptchaKey?: string; +} { + const { isWidget } = useUrlParams(); + const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState( + undefined, + ); + const [recaptchaKey, setRecaptchaKey] = useState( + undefined, + ); + + useEffect(() => { + if (isWidget) return; + // An empty registerRequest is used to get the privacy policy and recaptcha key. + createClient({ baseUrl: Config.defaultHomeserverUrl()! }) + .registerRequest({}) + .catch((error) => { + setPrivacyPolicyUrl( + error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en + ?.url, + ); + setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key); + }); + }, [isWidget]); + + return { privacyPolicyUrl, recaptchaKey }; +}