Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 3f04e41

Browse files
author
Kerry
authored
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
1 parent 3de2bcd commit 3f04e41

File tree

6 files changed

+205
-8
lines changed

6 files changed

+205
-8
lines changed

res/css/structures/auth/_Login.pcss

+5
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,8 @@ div.mx_AccessibleButton_kind_link.mx_Login_forgot {
9999
align-content: center;
100100
padding: 14px;
101101
}
102+
103+
.mx_Login_fullWidthButton {
104+
width: 100%;
105+
margin-bottom: 16px;
106+
}

src/components/structures/auth/Login.tsx

+23-2
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { logger } from "matrix-js-sdk/src/logger";
2020
import { ISSOFlow, SSOAction } from "matrix-js-sdk/src/@types/auth";
2121

2222
import { _t, _td, UserFriendlyError } from "../../../languageHandler";
23-
import Login, { ClientLoginFlow } from "../../../Login";
23+
import Login, { ClientLoginFlow, OidcNativeFlow } from "../../../Login";
2424
import { messageForConnectionError, messageForLoginError } from "../../../utils/ErrorUtils";
2525
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
2626
import AuthPage from "../../views/auth/AuthPage";
@@ -39,6 +39,7 @@ import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleBu
3939
import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
4040
import { filterBoolean } from "../../../utils/arrays";
4141
import { Features } from "../../../settings/Settings";
42+
import { startOidcLogin } from "../../../utils/oidc/authorize";
4243

4344
// These are used in several places, and come from the js-sdk's autodiscovery
4445
// 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>
146147
"m.login.cas": () => this.renderSsoStep("cas"),
147148
// eslint-disable-next-line @typescript-eslint/naming-convention
148149
"m.login.sso": () => this.renderSsoStep("sso"),
150+
"oidcNativeFlow": () => this.renderOidcNativeStep(),
149151
};
150152
}
151153

@@ -433,7 +435,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
433435
if (!this.state.flows) return null;
434436

435437
// this is the ideal order we want to show the flows in
436-
const order = ["m.login.password", "m.login.sso"];
438+
const order = ["oidcNativeFlow", "m.login.password", "m.login.sso"];
437439

438440
const flows = filterBoolean(order.map((type) => this.state.flows?.find((flow) => flow.type === type)));
439441
return (
@@ -466,6 +468,25 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
466468
);
467469
};
468470

471+
private renderOidcNativeStep = (): React.ReactNode => {
472+
const flow = this.state.flows!.find((flow) => flow.type === "oidcNativeFlow")! as OidcNativeFlow;
473+
return (
474+
<AccessibleButton
475+
className="mx_Login_fullWidthButton"
476+
kind="primary"
477+
onClick={async () => {
478+
await startOidcLogin(
479+
this.props.serverConfig.delegatedAuthentication!,
480+
flow.clientId,
481+
this.props.serverConfig.hsUrl,
482+
);
483+
}}
484+
>
485+
{_t("Continue")}
486+
</AccessibleButton>
487+
);
488+
};
489+
469490
private renderSsoStep = (loginType: "cas" | "sso"): JSX.Element => {
470491
const flow = this.state.flows?.find((flow) => flow.type === "m.login." + loginType) as ISSOFlow;
471492

src/utils/oidc/authorize.ts

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import {
18+
AuthorizationParams,
19+
generateAuthorizationParams,
20+
generateAuthorizationUrl,
21+
} from "matrix-js-sdk/src/oidc/authorize";
22+
23+
import { ValidatedDelegatedAuthConfig } from "../ValidatedServerConfig";
24+
25+
/**
26+
* Store authorization params for retrieval when returning from OIDC OP
27+
* @param authorizationParams from `generateAuthorizationParams`
28+
* @param delegatedAuthConfig used for future interactions with OP
29+
* @param clientId this client's id as registered with configured issuer
30+
* @param homeserver target homeserver
31+
*/
32+
const storeAuthorizationParams = (
33+
{ redirectUri, state, nonce, codeVerifier }: AuthorizationParams,
34+
{ issuer }: ValidatedDelegatedAuthConfig,
35+
clientId: string,
36+
homeserver: string,
37+
): void => {
38+
window.sessionStorage.setItem(`oidc_${state}_nonce`, nonce);
39+
window.sessionStorage.setItem(`oidc_${state}_redirectUri`, redirectUri);
40+
window.sessionStorage.setItem(`oidc_${state}_codeVerifier`, codeVerifier);
41+
window.sessionStorage.setItem(`oidc_${state}_clientId`, clientId);
42+
window.sessionStorage.setItem(`oidc_${state}_issuer`, issuer);
43+
window.sessionStorage.setItem(`oidc_${state}_homeserver`, homeserver);
44+
};
45+
46+
/**
47+
* Start OIDC authorization code flow
48+
* Generates auth params, stores them in session storage and
49+
* Navigates to configured authorization endpoint
50+
* @param delegatedAuthConfig from discovery
51+
* @param clientId this client's id as registered with configured issuer
52+
* @param homeserver target homeserver
53+
* @returns Promise that resolves after we have navigated to auth endpoint
54+
*/
55+
export const startOidcLogin = async (
56+
delegatedAuthConfig: ValidatedDelegatedAuthConfig,
57+
clientId: string,
58+
homeserver: string,
59+
): Promise<void> => {
60+
// TODO(kerrya) afterloginfragment https://github.com/vector-im/element-web/issues/25656
61+
const redirectUri = window.location.origin;
62+
const authParams = generateAuthorizationParams({ redirectUri });
63+
64+
storeAuthorizationParams(authParams, delegatedAuthConfig, clientId, homeserver);
65+
66+
const authorizationUrl = await generateAuthorizationUrl(
67+
delegatedAuthConfig.authorizationEndpoint,
68+
clientId,
69+
authParams,
70+
);
71+
72+
window.location.href = authorizationUrl;
73+
};

src/utils/oidc/registerClient.ts

-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ const getStaticOidcClientId = (issuer: string, staticOidcClients?: Record<string
4444
*/
4545
export const getOidcClientId = async (
4646
delegatedAuthConfig: ValidatedDelegatedAuthConfig,
47-
// these are used in the following PR
4847
clientName: string,
4948
baseUrl: string,
5049
staticOidcClients?: Record<string, string>,

test/components/structures/auth/Login-test.tsx

+2-5
Original file line numberDiff line numberDiff line change
@@ -409,18 +409,15 @@ describe("Login", function () {
409409
});
410410

411411
// short term during active development, UI will be added in next PRs
412-
it("should show error when oidc native flow is correctly configured but not supported by UI", async () => {
412+
it("should show continue button when oidc native flow is correctly configured", async () => {
413413
fetchMock.post(delegatedAuth.registrationEndpoint, { client_id: "abc123" });
414414
getComponent(hsUrl, isUrl, delegatedAuth);
415415

416416
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
417417

418418
// did not continue with matrix login
419419
expect(mockClient.loginFlows).not.toHaveBeenCalled();
420-
// no oidc native UI yet
421-
expect(
422-
screen.getByText("This homeserver doesn't offer any login flows which are supported by this client."),
423-
).toBeInTheDocument();
420+
expect(screen.getByText("Continue")).toBeInTheDocument();
424421
});
425422

426423
/**

test/utils/oidc/authorize-test.ts

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import fetchMockJest from "fetch-mock-jest";
18+
import * as randomStringUtils from "matrix-js-sdk/src/randomstring";
19+
20+
import { startOidcLogin } from "../../../src/utils/oidc/authorize";
21+
22+
describe("startOidcLogin()", () => {
23+
const issuer = "https://auth.com/";
24+
const authorizationEndpoint = "https://auth.com/authorization";
25+
const homeserver = "https://matrix.org";
26+
const clientId = "xyz789";
27+
const baseUrl = "https://test.com";
28+
29+
const delegatedAuthConfig = {
30+
issuer,
31+
registrationEndpoint: issuer + "registration",
32+
authorizationEndpoint,
33+
tokenEndpoint: issuer + "token",
34+
};
35+
36+
const sessionStorageGetSpy = jest.spyOn(sessionStorage.__proto__, "setItem").mockReturnValue(undefined);
37+
const randomStringMockImpl = (length: number) => new Array(length).fill("x").join("");
38+
39+
// to restore later
40+
const realWindowLocation = window.location;
41+
42+
beforeEach(() => {
43+
fetchMockJest.mockClear();
44+
fetchMockJest.resetBehavior();
45+
46+
sessionStorageGetSpy.mockClear();
47+
48+
// @ts-ignore allow delete of non-optional prop
49+
delete window.location;
50+
// @ts-ignore ugly mocking
51+
window.location = {
52+
href: baseUrl,
53+
origin: baseUrl,
54+
};
55+
56+
jest.spyOn(randomStringUtils, "randomString").mockRestore();
57+
});
58+
59+
afterAll(() => {
60+
window.location = realWindowLocation;
61+
});
62+
63+
it("should store authorization params in session storage", async () => {
64+
jest.spyOn(randomStringUtils, "randomString").mockReset().mockImplementation(randomStringMockImpl);
65+
await startOidcLogin(delegatedAuthConfig, clientId, homeserver);
66+
67+
const state = randomStringUtils.randomString(8);
68+
69+
expect(sessionStorageGetSpy).toHaveBeenCalledWith(`oidc_${state}_nonce`, randomStringUtils.randomString(8));
70+
expect(sessionStorageGetSpy).toHaveBeenCalledWith(`oidc_${state}_redirectUri`, baseUrl);
71+
expect(sessionStorageGetSpy).toHaveBeenCalledWith(
72+
`oidc_${state}_codeVerifier`,
73+
randomStringUtils.randomString(64),
74+
);
75+
expect(sessionStorageGetSpy).toHaveBeenCalledWith(`oidc_${state}_clientId`, clientId);
76+
expect(sessionStorageGetSpy).toHaveBeenCalledWith(`oidc_${state}_issuer`, issuer);
77+
expect(sessionStorageGetSpy).toHaveBeenCalledWith(`oidc_${state}_homeserver`, homeserver);
78+
});
79+
80+
it("navigates to authorization endpoint with correct parameters", async () => {
81+
await startOidcLogin(delegatedAuthConfig, clientId, homeserver);
82+
83+
const expectedScopeWithoutDeviceId = `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:`;
84+
85+
const authUrl = new URL(window.location.href);
86+
87+
expect(authUrl.searchParams.get("response_mode")).toEqual("query");
88+
expect(authUrl.searchParams.get("response_type")).toEqual("code");
89+
expect(authUrl.searchParams.get("client_id")).toEqual(clientId);
90+
expect(authUrl.searchParams.get("code_challenge_method")).toEqual("S256");
91+
92+
// scope ends with a 10char randomstring deviceId
93+
const scope = authUrl.searchParams.get("scope")!;
94+
expect(scope.substring(0, scope.length - 10)).toEqual(expectedScopeWithoutDeviceId);
95+
expect(scope.substring(scope.length - 10)).toBeTruthy();
96+
97+
// random string, just check they are set
98+
expect(authUrl.searchParams.has("state")).toBeTruthy();
99+
expect(authUrl.searchParams.has("nonce")).toBeTruthy();
100+
expect(authUrl.searchParams.has("code_challenge")).toBeTruthy();
101+
});
102+
});

0 commit comments

Comments
 (0)