diff --git a/res/css/structures/auth/_Login.pcss b/res/css/structures/auth/_Login.pcss index 2eba8cf3d1..eeca1e8e49 100644 --- a/res/css/structures/auth/_Login.pcss +++ b/res/css/structures/auth/_Login.pcss @@ -99,3 +99,8 @@ div.mx_AccessibleButton_kind_link.mx_Login_forgot { align-content: center; padding: 14px; } + +.mx_Login_fullWidthButton { + width: 100%; + margin-bottom: 16px; +} diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index b69c7f3e09..e4ac7f88ce 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -20,7 +20,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { ISSOFlow, SSOAction } from "matrix-js-sdk/src/@types/auth"; import { _t, _td, UserFriendlyError } from "../../../languageHandler"; -import Login, { ClientLoginFlow } from "../../../Login"; +import Login, { ClientLoginFlow, OidcNativeFlow } from "../../../Login"; import { messageForConnectionError, messageForLoginError } from "../../../utils/ErrorUtils"; import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import AuthPage from "../../views/auth/AuthPage"; @@ -39,6 +39,7 @@ import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleBu import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; import { filterBoolean } from "../../../utils/arrays"; import { Features } from "../../../settings/Settings"; +import { startOidcLogin } from "../../../utils/oidc/authorize"; // These are used in several places, and come from the js-sdk's autodiscovery // stuff. We define them here so that they'll be picked up by i18n. @@ -146,6 +147,7 @@ export default class LoginComponent extends React.PureComponent "m.login.cas": () => this.renderSsoStep("cas"), // eslint-disable-next-line @typescript-eslint/naming-convention "m.login.sso": () => this.renderSsoStep("sso"), + "oidcNativeFlow": () => this.renderOidcNativeStep(), }; } @@ -433,7 +435,7 @@ export default class LoginComponent extends React.PureComponent if (!this.state.flows) return null; // this is the ideal order we want to show the flows in - const order = ["m.login.password", "m.login.sso"]; + const order = ["oidcNativeFlow", "m.login.password", "m.login.sso"]; const flows = filterBoolean(order.map((type) => this.state.flows?.find((flow) => flow.type === type))); return ( @@ -466,6 +468,25 @@ export default class LoginComponent extends React.PureComponent ); }; + private renderOidcNativeStep = (): React.ReactNode => { + const flow = this.state.flows!.find((flow) => flow.type === "oidcNativeFlow")! as OidcNativeFlow; + return ( + { + await startOidcLogin( + this.props.serverConfig.delegatedAuthentication!, + flow.clientId, + this.props.serverConfig.hsUrl, + ); + }} + > + {_t("Continue")} + + ); + }; + private renderSsoStep = (loginType: "cas" | "sso"): JSX.Element => { const flow = this.state.flows?.find((flow) => flow.type === "m.login." + loginType) as ISSOFlow; diff --git a/src/utils/oidc/authorize.ts b/src/utils/oidc/authorize.ts new file mode 100644 index 0000000000..22e7a11bce --- /dev/null +++ b/src/utils/oidc/authorize.ts @@ -0,0 +1,73 @@ +/* +Copyright 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. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { + AuthorizationParams, + generateAuthorizationParams, + generateAuthorizationUrl, +} from "matrix-js-sdk/src/oidc/authorize"; + +import { ValidatedDelegatedAuthConfig } from "../ValidatedServerConfig"; + +/** + * Store authorization params for retrieval when returning from OIDC OP + * @param authorizationParams from `generateAuthorizationParams` + * @param delegatedAuthConfig used for future interactions with OP + * @param clientId this client's id as registered with configured issuer + * @param homeserver target homeserver + */ +const storeAuthorizationParams = ( + { redirectUri, state, nonce, codeVerifier }: AuthorizationParams, + { issuer }: ValidatedDelegatedAuthConfig, + clientId: string, + homeserver: 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); +}; + +/** + * Start OIDC authorization code flow + * Generates auth params, stores them in session storage and + * Navigates to configured authorization endpoint + * @param delegatedAuthConfig from discovery + * @param clientId this client's id as registered with configured issuer + * @param homeserver target homeserver + * @returns Promise that resolves after we have navigated to auth endpoint + */ +export const startOidcLogin = async ( + delegatedAuthConfig: ValidatedDelegatedAuthConfig, + clientId: string, + homeserver: 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); + + const authorizationUrl = await generateAuthorizationUrl( + delegatedAuthConfig.authorizationEndpoint, + clientId, + authParams, + ); + + window.location.href = authorizationUrl; +}; diff --git a/src/utils/oidc/registerClient.ts b/src/utils/oidc/registerClient.ts index f292bf5a80..4e2df7832c 100644 --- a/src/utils/oidc/registerClient.ts +++ b/src/utils/oidc/registerClient.ts @@ -44,7 +44,6 @@ const getStaticOidcClientId = (issuer: string, staticOidcClients?: Record, diff --git a/test/components/structures/auth/Login-test.tsx b/test/components/structures/auth/Login-test.tsx index 29a1bfa08e..dbb5bf0f90 100644 --- a/test/components/structures/auth/Login-test.tsx +++ b/test/components/structures/auth/Login-test.tsx @@ -409,7 +409,7 @@ describe("Login", function () { }); // short term during active development, UI will be added in next PRs - it("should show error when oidc native flow is correctly configured but not supported by UI", async () => { + it("should show continue button when oidc native flow is correctly configured", async () => { fetchMock.post(delegatedAuth.registrationEndpoint, { client_id: "abc123" }); getComponent(hsUrl, isUrl, delegatedAuth); @@ -417,10 +417,7 @@ describe("Login", function () { // did not continue with matrix login expect(mockClient.loginFlows).not.toHaveBeenCalled(); - // no oidc native UI yet - expect( - screen.getByText("This homeserver doesn't offer any login flows which are supported by this client."), - ).toBeInTheDocument(); + expect(screen.getByText("Continue")).toBeInTheDocument(); }); /** diff --git a/test/utils/oidc/authorize-test.ts b/test/utils/oidc/authorize-test.ts new file mode 100644 index 0000000000..5abdb19862 --- /dev/null +++ b/test/utils/oidc/authorize-test.ts @@ -0,0 +1,102 @@ +/* +Copyright 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. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import fetchMockJest from "fetch-mock-jest"; +import * as randomStringUtils from "matrix-js-sdk/src/randomstring"; + +import { startOidcLogin } from "../../../src/utils/oidc/authorize"; + +describe("startOidcLogin()", () => { + const issuer = "https://auth.com/"; + const authorizationEndpoint = "https://auth.com/authorization"; + const homeserver = "https://matrix.org"; + const clientId = "xyz789"; + const baseUrl = "https://test.com"; + + const delegatedAuthConfig = { + issuer, + registrationEndpoint: issuer + "registration", + authorizationEndpoint, + tokenEndpoint: issuer + "token", + }; + + const sessionStorageGetSpy = jest.spyOn(sessionStorage.__proto__, "setItem").mockReturnValue(undefined); + const randomStringMockImpl = (length: number) => new Array(length).fill("x").join(""); + + // 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 + window.location = { + href: baseUrl, + origin: baseUrl, + }; + + jest.spyOn(randomStringUtils, "randomString").mockRestore(); + }); + + 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); + }); + + 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(); + }); +});