Skip to content

Commit e2c7f3b

Browse files
committed
[#12] WIP: Add SSO support
Fixes #12 REMOVE HTTPS CERT WORKAROUND BEFORE MERGE TO MASTER! MCC instance with SSO enabled for testing currently has invalid cert chain, so this is needed as a temporary workaround during development. SEE '// DEBUG' COMMENTS for what still needs refactoring. Done: - Renamed existing `AuthProvider` to `BasicAuthProvider`. This provider will fail with an error notification if it's used with an MCC instance that requires SSO auth. - Added new `SsoAuthProvider` for handling all things SSO. This provider will fail with an error notification if it's used with an MCC instance that does not support Keycloak SSO auth. - Moved a few effects out of View.js and into useClusterLoder.js hook to cut down on module size. - Added 'SSO support' section to README with instructions on how to configure the MCC instance's Keycloak Client to work with the extension since it relies on `lens://` protocol handler requests. - Added new util.js method to make console logging more helpful and consistent, replaced all existing `console` calls with new `logger` calls. - `AuthClient` now supports both Basic auth and SSO auth. - Auto-refreshing tokens under SSO works. - Possible to activate a cluster without having to re-query for the list of clusters that was already loaded (if any). - Renamed ClustersProvider to ClusterDataProvider to make it more different from ClusterActionsProvider when we use it throughout the code (we had `clustersActions` and `clusterActions` objects; now we have `clusterDataActions` and `clusterActions` with less chance of a mistake). - Fixed a bug in eventBus.ts where an exception thrown in an event handler would cause the event bus to infinitely call that handler in a loop. - Added 'Refresh' feature where once clusters are loaded, the 'Sign in' button changes to 'Refresh' and clicking it uses existing creds to reload/refresh the cluster list without going through full auth again (if creds are still valid). - Added ability to filter clusters to a list of specified namespaces via the 'add clusters' extension event.
1 parent c9370fd commit e2c7f3b

24 files changed

+1407
-386
lines changed

README.md

+7-2
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,14 @@ The `prepublishOnly` script will automatically produce a production build in the
8080

8181
## Help
8282

83-
### SSO not supported
83+
### SSO support
8484

85-
Mirantis Container Cloud instances that use third-party SSO authentication (e.g. Google OAuth) are __not supported__ at this time. We plan on adding support [soon](https://github.com/Mirantis/lens-extension-cc/issues/12).
85+
Mirantis Container Cloud instances that use third-party SSO authentication via __Keycloak__ are supported.
86+
87+
Since the integration leverages the `lens://` URL protocol handling feature for extensions, __Lens 4.1__ is required, and the __Keycloak Client__ of the instance must be configured as follows:
88+
89+
- Allow requests from the `"*"` origin. This is because the internal Electron browser used by the Lens App uses a random port. Therefore, the originating URL cannot be predicted.
90+
- Allow the following redirect URI: `lens://extensions/@mirantis/lens-extension-cc/oauth/code`
8691

8792
### Management clusters not selected by default
8893

src/cc/ClusterList.js

+5
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const CheckList = styled.div(function () {
2626

2727
export const ClusterList = function ({
2828
clusters,
29+
onlyNamespaces,
2930
selectedClusters,
3031
onSelection,
3132
onSelectAll,
@@ -80,6 +81,9 @@ export const ClusterList = function ({
8081
return (
8182
<Section className="lecc-ClusterList">
8283
<h3>{strings.clusterList.title()}</h3>
84+
{onlyNamespaces && (
85+
<p>{strings.clusterList.onlyNamespaces(onlyNamespaces)}</p>
86+
)}
8387
<CheckList>
8488
{clusters.sort(compareClusters).map((
8589
cluster // list ALL clusters
@@ -113,6 +117,7 @@ export const ClusterList = function ({
113117

114118
ClusterList.propTypes = {
115119
clusters: propTypes.arrayOf(propTypes.instanceOf(Cluster)), // ALL clusters, even non-ready ones
120+
onlyNamespaces: propTypes.arrayOf(propTypes.string), // optional list of namespace IDs to which the list is restricted
116121
selectedClusters: propTypes.arrayOf(propTypes.instanceOf(Cluster)),
117122
onSelection: propTypes.func, // ({ cluster: Cluster, selected: boolean }) => void
118123
onSelectAll: propTypes.func, // ({ selected: boolean }) => void

src/cc/Login.js

+236-57
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
import { useState, useEffect } from 'react';
2-
import propTypes from 'prop-types';
1+
import { useState, useEffect, useCallback } from 'react';
32
import styled from '@emotion/styled';
43
import { Component } from '@k8slens/extensions';
54
import { layout } from './styles';
65
import { Section } from './Section';
6+
import { useExtState } from './store/ExtStateProvider';
7+
import { useConfig } from './store/ConfigProvider';
8+
import { useBasicAuth } from './store/BasicAuthProvider';
9+
import { useSsoAuth } from './store/SsoAuthProvider';
10+
import { useClusterData } from './store/ClusterDataProvider';
11+
import { useClusterLoadingState } from './hooks/useClusterLoadingState';
12+
import { normalizeUrl } from './netUtil';
713
import * as strings from '../strings';
814

915
const urlClassName = 'lecc-Login--url';
@@ -29,22 +35,95 @@ const Field = styled.div(function () {
2935
};
3036
});
3137

32-
export const Login = function ({ loading, disabled, onLogin, ...props }) {
38+
const Message = styled.div(function () {
39+
return {
40+
display: 'flex',
41+
42+
p: {
43+
marginTop: 2,
44+
marginLeft: 3,
45+
},
46+
};
47+
});
48+
49+
export const Login = function () {
3350
//
3451
// STATE
3552
//
3653

37-
const [cloudUrl, setCloudUrl] = useState(props.cloudUrl || '');
38-
const [username, setUsername] = useState(props.username || '');
39-
const [password, setPassword] = useState(props.password || '');
40-
const [valid, setValid] = useState(false);
54+
const {
55+
state: {
56+
authAccess,
57+
prefs: { cloudUrl },
58+
},
59+
actions: extActions,
60+
} = useExtState();
61+
62+
const {
63+
state: {
64+
loading: configLoading,
65+
loaded: configLoaded,
66+
error: configError,
67+
config,
68+
},
69+
actions: configActions,
70+
} = useConfig();
71+
72+
const {
73+
state: { loading: basicAuthLoading },
74+
actions: basicAuthActions,
75+
} = useBasicAuth();
76+
77+
const {
78+
state: { loading: ssoAuthLoading },
79+
actions: ssoAuthActions,
80+
} = useSsoAuth();
81+
82+
const {
83+
state: { loaded: clusterDataLoaded, error: clusterDataError },
84+
actions: clusterDataActions,
85+
} = useClusterData();
86+
87+
// NOTE: while this does include the individual flags above, it may include
88+
// others we don't need to know details about here, but still need to be
89+
// responsive to
90+
const loading = useClusterLoadingState();
91+
92+
const [url, setUrl] = useState(cloudUrl || '');
93+
const [username, setUsername] = useState(authAccess.username || '');
94+
const [password, setPassword] = useState(authAccess.password || '');
95+
const [basicValid, setBasicValid] = useState(false); // if basic auth fields are valid
96+
97+
// {boolean} true if user has clicked the Access button; false otherwise
98+
const [accessClicked, setAccessClicked] = useState(false);
99+
100+
const usesSso = !!config?.keycloakLogin;
41101

42102
//
43103
// EVENTS
44104
//
45105

106+
const startLogin = useCallback(
107+
function () {
108+
authAccess.resetCredentials();
109+
authAccess.resetTokens();
110+
authAccess.usesSso = usesSso;
111+
112+
if (!usesSso) {
113+
authAccess.username = username;
114+
authAccess.password = password;
115+
}
116+
117+
// capture changes to auth details so far, and trigger basic or SSO login in
118+
// useClusterLoader() effect (because this will result in an updated authAccess
119+
// object that has the right configuration per updates above)
120+
extActions.setAuthAccess(authAccess);
121+
},
122+
[authAccess, extActions, usesSso, username, password]
123+
);
124+
46125
const handleUrlChange = function (value) {
47-
setCloudUrl(value);
126+
setUrl(value);
48127
};
49128

50129
const handleUsernameChange = function (value) {
@@ -55,8 +134,52 @@ export const Login = function ({ loading, disabled, onLogin, ...props }) {
55134
setPassword(value);
56135
};
57136

58-
const handleClustersClick = function () {
59-
onLogin({ cloudUrl, username, password });
137+
const handleAccessClick = function () {
138+
const normUrl = normalizeUrl(url);
139+
setUrl(normUrl); // update to actual URL we'll use
140+
setAccessClicked(true);
141+
142+
basicAuthActions.reset();
143+
ssoAuthActions.reset();
144+
clusterDataActions.reset();
145+
146+
// we're accessing a different instance, so nothing we may have already will
147+
// work there
148+
setUsername('');
149+
setPassword('');
150+
authAccess.resetCredentials();
151+
authAccess.resetTokens();
152+
extActions.setAuthAccess(authAccess);
153+
154+
// save URL as `cloudUrl` in preferences since the user claims it's valid
155+
extActions.setCloudUrl(normUrl);
156+
157+
// NOTE: if the config loads successfully and we see that the instance is
158+
// set for SSO auth, our effect() below that checks for `configLoaded`
159+
// will auto-trigger onLogin(), which will then trigger SSO auth
160+
configActions.load(normUrl); // implicit reset of current config, if any
161+
};
162+
163+
const handleLoginClick = function () {
164+
if (
165+
clusterDataLoaded &&
166+
!clusterDataError &&
167+
url === cloudUrl &&
168+
authAccess.isValid() &&
169+
username === authAccess.username &&
170+
(authAccess.usesSso || password === authAccess.password)
171+
) {
172+
// DEBUG TODO: test this use case under SSO
173+
// just do a cluster data refresh instead of going through auth again
174+
clusterDataActions.load({ cloudUrl, config, authAccess });
175+
} else {
176+
// no cluster data, or something auth-related has changed: do a full
177+
// re-auth and cluster reload
178+
basicAuthActions.reset();
179+
ssoAuthActions.reset();
180+
clusterDataActions.reset();
181+
startLogin();
182+
}
60183
};
61184

62185
//
@@ -65,9 +188,45 @@ export const Login = function ({ loading, disabled, onLogin, ...props }) {
65188

66189
useEffect(
67190
function () {
68-
setValid(!!(cloudUrl && username && password));
191+
setBasicValid(!!(url && username && password));
192+
},
193+
[username, password, url]
194+
);
195+
196+
// on load, if we already have an instance URL but haven't yet loaded the config,
197+
// load it immediately so we can show the username/password fields right away
198+
// and save the user a 'click & wait' if the instance uses basic auth
199+
useEffect(
200+
function () {
201+
if (cloudUrl && !configLoading && !configLoaded) {
202+
configActions.load(cloudUrl);
203+
}
204+
},
205+
[cloudUrl, configLoading, configLoaded, configActions]
206+
);
207+
208+
useEffect(
209+
function () {
210+
if (configLoaded && !configError && accessClicked) {
211+
setAccessClicked(false);
212+
213+
// start the SSO login process if the instance uses SSO since the user has
214+
// clicked on the Access button indicating intent to take action
215+
if (usesSso) {
216+
startLogin();
217+
}
218+
}
69219
},
70-
[username, password, cloudUrl]
220+
[
221+
configLoaded,
222+
configError,
223+
config,
224+
url,
225+
extActions,
226+
startLogin,
227+
accessClicked,
228+
usesSso,
229+
]
71230
);
72231

73232
//
@@ -85,54 +244,74 @@ export const Login = function ({ loading, disabled, onLogin, ...props }) {
85244
theme="round-black" // borders on all sides, rounded corners
86245
id="lecc-login-url"
87246
disabled={loading}
88-
value={cloudUrl}
247+
value={url}
89248
onChange={handleUrlChange}
90249
/>
91250
</Field>
92-
<Field>
93-
<label htmlFor="lecc-login-username">
94-
{strings.login.username.label()}
95-
</label>
96-
<Component.Input
97-
type="text"
98-
theme="round-black" // borders on all sides, rounded corners
99-
id="lecc-login-username"
100-
disabled={loading}
101-
value={username}
102-
onChange={handleUsernameChange}
103-
/>
104-
</Field>
105-
<Field>
106-
<label htmlFor="lecc-login-password">
107-
{strings.login.password.label()}
108-
</label>
109-
<Component.Input
110-
type="password"
111-
theme="round-black" // borders on all sides, rounded corners
112-
id="lecc-login-password"
113-
disabled={loading}
114-
value={password}
115-
onChange={handlePasswordChange}
116-
/>
117-
</Field>
118-
<div>
119-
<Component.Button
120-
primary
121-
disabled={loading || disabled || !valid}
122-
label={strings.login.action.label()}
123-
waiting={loading}
124-
onClick={handleClustersClick}
125-
/>
126-
</div>
251+
{(!configLoaded || configError || url !== cloudUrl || usesSso) && (
252+
<div>
253+
<Component.Button
254+
primary
255+
disabled={loading}
256+
label={strings.login.action.access()}
257+
waiting={configLoading}
258+
onClick={handleAccessClick}
259+
/>
260+
</div>
261+
)}
262+
{configLoaded && !configError && url === cloudUrl && !usesSso && (
263+
<>
264+
<Message>
265+
<Component.Icon material="info" />
266+
<p>{strings.login.basic.message()}</p>
267+
</Message>
268+
<Field>
269+
<label htmlFor="lecc-login-username">
270+
{strings.login.username.label()}
271+
</label>
272+
<Component.Input
273+
type="text"
274+
theme="round-black" // borders on all sides, rounded corners
275+
id="lecc-login-username"
276+
disabled={loading}
277+
value={username}
278+
onChange={handleUsernameChange}
279+
/>
280+
</Field>
281+
<Field>
282+
<label htmlFor="lecc-login-password">
283+
{strings.login.password.label()}
284+
</label>
285+
<Component.Input
286+
type="password"
287+
theme="round-black" // borders on all sides, rounded corners
288+
id="lecc-login-password"
289+
disabled={loading}
290+
value={password}
291+
onChange={handlePasswordChange}
292+
/>
293+
</Field>
294+
<div>
295+
<Component.Button
296+
primary
297+
disabled={loading || !basicValid}
298+
label={
299+
clusterDataLoaded && !clusterDataError
300+
? strings.login.action.refresh()
301+
: strings.login.action.login()
302+
}
303+
waiting={basicAuthLoading}
304+
onClick={handleLoginClick}
305+
/>
306+
</div>
307+
</>
308+
)}
309+
{ssoAuthLoading && (
310+
<Message>
311+
<Component.Icon material="info" />
312+
<p>{strings.login.sso.message()}</p>
313+
</Message>
314+
)}
127315
</Section>
128316
);
129317
};
130-
131-
Login.propTypes = {
132-
onLogin: propTypes.func.isRequired,
133-
loading: propTypes.bool, // if data fetch related to login is taking place
134-
disabled: propTypes.bool, // if login should be disabled entirely
135-
cloudUrl: propTypes.string,
136-
username: propTypes.string,
137-
password: propTypes.string,
138-
};

0 commit comments

Comments
 (0)