From 3f04e41c21e6c48b4620d837ddfbb99cfcb25f14 Mon Sep 17 00:00:00 2001
From: Kerry <kerrya@element.io>
Date: Thu, 29 Jun 2023 09:08:56 +1200
Subject: [PATCH] OIDC: navigate to authorization endpoint (#11096)

* add delegatedauthentication to validated server config

* dynamic client registration functions

* test OP registration functions

* add stubbed nativeOidc flow setup in Login

* cover more error cases in Login

* tidy

* test dynamic client registration in Login

* comment oidc_static_clients

* register oidc inside Login.getFlows

* strict fixes

* remove unused code

* and imports

* comments

* comments 2

* util functions to get static client id

* check static client ids in login flow

* remove dead code

* OidcRegistrationClientMetadata type

* navigate to oidc authorize url

* navigate to oidc authorize url

* test

* adjust for js-sdk code

* update test for response_mode query

* use new types

* strict

* tidy
---
 res/css/structures/auth/_Login.pcss           |   5 +
 src/components/structures/auth/Login.tsx      |  25 ++++-
 src/utils/oidc/authorize.ts                   |  73 +++++++++++++
 src/utils/oidc/registerClient.ts              |   1 -
 .../components/structures/auth/Login-test.tsx |   7 +-
 test/utils/oidc/authorize-test.ts             | 102 ++++++++++++++++++
 6 files changed, 205 insertions(+), 8 deletions(-)
 create mode 100644 src/utils/oidc/authorize.ts
 create mode 100644 test/utils/oidc/authorize-test.ts

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<IProps, IState>
             "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<IProps, IState>
         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<IProps, IState>
         );
     };
 
+    private renderOidcNativeStep = (): React.ReactNode => {
+        const flow = this.state.flows!.find((flow) => flow.type === "oidcNativeFlow")! as OidcNativeFlow;
+        return (
+            <AccessibleButton
+                className="mx_Login_fullWidthButton"
+                kind="primary"
+                onClick={async () => {
+                    await startOidcLogin(
+                        this.props.serverConfig.delegatedAuthentication!,
+                        flow.clientId,
+                        this.props.serverConfig.hsUrl,
+                    );
+                }}
+            >
+                {_t("Continue")}
+            </AccessibleButton>
+        );
+    };
+
     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<void> => {
+    // 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<string
  */
 export const getOidcClientId = async (
     delegatedAuthConfig: ValidatedDelegatedAuthConfig,
-    // these are used in the following PR
     clientName: string,
     baseUrl: string,
     staticOidcClients?: Record<string, string>,
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();
+    });
+});