Skip to content

Commit fa6363b

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.
1 parent c9370fd commit fa6363b

23 files changed

+1174
-313
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/Login.js

+215-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 { useClusters } from './store/ClustersProvider';
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,92 @@ 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, username: prefsUsername },
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 { actions: clustersActions } = useClusters();
83+
84+
// NOTE: while this does include the individual flags above, it may include
85+
// others we don't need to know details about here, but still need to be
86+
// responsive to
87+
const loading = useClusterLoadingState();
88+
89+
const [url, setUrl] = useState(cloudUrl || '');
90+
const [username, setUsername] = useState(prefsUsername || '');
91+
const [password, setPassword] = useState('');
92+
const [basicValid, setBasicValid] = useState(false); // if basic auth fields are valid
93+
94+
// {boolean} true if user has clicked the Access button; false otherwise
95+
const [accessClicked, setAccessClicked] = useState(false);
96+
97+
const usesSso = !!config?.keycloakLogin;
4198

4299
//
43100
// EVENTS
44101
//
45102

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

50126
const handleUsernameChange = function (value) {
@@ -55,8 +131,38 @@ export const Login = function ({ loading, disabled, onLogin, ...props }) {
55131
setPassword(value);
56132
};
57133

58-
const handleClustersClick = function () {
59-
onLogin({ cloudUrl, username, password });
134+
const handleAccessClick = function () {
135+
const normUrl = normalizeUrl(url);
136+
setUrl(normUrl); // update to actual URL we'll use
137+
setAccessClicked(true);
138+
139+
basicAuthActions.reset();
140+
ssoAuthActions.reset();
141+
clustersActions.reset();
142+
143+
// we're accessing a different instance, so nothing we may have already will
144+
// work there
145+
setUsername('');
146+
setPassword('');
147+
authAccess.clearCredentials();
148+
authAccess.clearTokens();
149+
extActions.setAuthAccess(authAccess);
150+
151+
// save URL as `cloudUrl` in preferences since the user claims it's valid
152+
extActions.setCloudUrl(normUrl);
153+
154+
// NOTE: if the config loads successfully and we see that the instance is
155+
// set for SSO auth, our effect() below that checks for `configLoaded`
156+
// will auto-trigger onLogin(), which will then trigger SSO auth
157+
configActions.load(normUrl); // implicit reset of current config, if any
158+
};
159+
160+
const handleLoginClick = function () {
161+
basicAuthActions.reset();
162+
ssoAuthActions.reset();
163+
clustersActions.reset();
164+
165+
startLogin();
60166
};
61167

62168
//
@@ -65,9 +171,45 @@ export const Login = function ({ loading, disabled, onLogin, ...props }) {
65171

66172
useEffect(
67173
function () {
68-
setValid(!!(cloudUrl && username && password));
174+
setBasicValid(!!(url && username && password));
175+
},
176+
[username, password, url]
177+
);
178+
179+
// on load, if we already have an instance URL but haven't yet loaded the config,
180+
// load it immediately so we can show the username/password fields right away
181+
// and save the user a 'click & wait' if the instance uses basic auth
182+
useEffect(
183+
function () {
184+
if (cloudUrl && !configLoading && !configLoaded) {
185+
configActions.load(cloudUrl);
186+
}
187+
},
188+
[cloudUrl, configLoading, configLoaded, configActions]
189+
);
190+
191+
useEffect(
192+
function () {
193+
if (configLoaded && !configError && accessClicked) {
194+
setAccessClicked(false);
195+
196+
// start the SSO login process if the instance uses SSO since the user has
197+
// clicked on the Access button indicating intent to take action
198+
if (usesSso) {
199+
startLogin();
200+
}
201+
}
69202
},
70-
[username, password, cloudUrl]
203+
[
204+
configLoaded,
205+
configError,
206+
config,
207+
url,
208+
extActions,
209+
startLogin,
210+
accessClicked,
211+
usesSso,
212+
]
71213
);
72214

73215
//
@@ -85,54 +227,70 @@ export const Login = function ({ loading, disabled, onLogin, ...props }) {
85227
theme="round-black" // borders on all sides, rounded corners
86228
id="lecc-login-url"
87229
disabled={loading}
88-
value={cloudUrl}
230+
value={url}
89231
onChange={handleUrlChange}
90232
/>
91233
</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>
234+
{(!configLoaded || configError || url !== cloudUrl || usesSso) && (
235+
<div>
236+
<Component.Button
237+
primary
238+
disabled={loading}
239+
label={strings.login.action.access()}
240+
waiting={configLoading}
241+
onClick={handleAccessClick}
242+
/>
243+
</div>
244+
)}
245+
{configLoaded && !configError && url === cloudUrl && !usesSso && (
246+
<>
247+
<Message>
248+
<Component.Icon material="info" />
249+
<p>{strings.login.basic.message()}</p>
250+
</Message>
251+
<Field>
252+
<label htmlFor="lecc-login-username">
253+
{strings.login.username.label()}
254+
</label>
255+
<Component.Input
256+
type="text"
257+
theme="round-black" // borders on all sides, rounded corners
258+
id="lecc-login-username"
259+
disabled={loading}
260+
value={username}
261+
onChange={handleUsernameChange}
262+
/>
263+
</Field>
264+
<Field>
265+
<label htmlFor="lecc-login-password">
266+
{strings.login.password.label()}
267+
</label>
268+
<Component.Input
269+
type="password"
270+
theme="round-black" // borders on all sides, rounded corners
271+
id="lecc-login-password"
272+
disabled={loading}
273+
value={password}
274+
onChange={handlePasswordChange}
275+
/>
276+
</Field>
277+
<div>
278+
<Component.Button
279+
primary
280+
disabled={loading || !basicValid}
281+
label={strings.login.action.login()}
282+
waiting={basicAuthLoading}
283+
onClick={handleLoginClick}
284+
/>
285+
</div>
286+
</>
287+
)}
288+
{ssoAuthLoading && (
289+
<Message>
290+
<Component.Icon material="info" />
291+
<p>{strings.login.sso.message()}</p>
292+
</Message>
293+
)}
127294
</Section>
128295
);
129296
};
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)