Skip to content

Commit cbc1400

Browse files
committed
[#12] WIP: Add SSO support
- Fixes #12: SSO - Fixes #216: SSO instructions - Fixes #215: Make sure bad password can be fixed during "add clusters" event 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. - Added instructions to Help section in README. - Cluster selection is limited to ONE cluster when using SSO because supporting multiple clusters would lead to a really bad UX (browser opening multiple times, user likely missing some of them) and spaghetti code (because it's more than just async requests since the event loop goes idle while waiting for the user to respond in the browser). - Added new "Refresh Data" experience where it's now possible to refresh (i.e. reload) clusters from the instance without re-authenticating (basic and SSO). - 'Add Clusters' via SSO now shows an info message to inform the user to expect their browser to open, and to click on "Open Lens" after authorization, and like the Login view, provides a Cancel button to abort the process in case something goes wrong in the browser. - While adding clusters via SSO, there is now a notice about the browser opening to generate the tokens for the cluster, as well as a Cancel button in case something goes wrong in the browser.
1 parent db3221e commit cbc1400

37 files changed

+2279
-581
lines changed

CHANGELOG.md

+10-1
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,21 @@
22

33
## UNRELEASED
44

5-
Supports Lens `>= 4.2.2`.
5+
Supports Lens `>= 4.2.3`.
6+
7+
### Added
8+
9+
- SSO support ([#12](https://github.com/Mirantis/lens-extension-cc/issues/12)):
10+
- This adds many UI changes, and makes it possible to connect to Mirantis Container Cloud instances that use Keycloak OAuth (SSO) for access control. Previously, only instances that used basic authentication were supported.
11+
- Notice the new Access button under the "Instance URL" field. Click this button after entering the URL to have the extension detect whether it supports SSO or basic auth. If it's basic auth, you'll get username and password fields as before. If it's [SSO](README.md#sso-support), your default system browser will open so that you can authorize the extension with your SSO account. Use the new Cancel button (in the extension) if something goes wrong during this process.
12+
- When accessing an SSO-enabled instance, only one cluster may be added at a time. This is a [known limitation](README.md#single-cluster-limitation).
13+
- When adding a cluster, your default system browser will open again (because the cluster uses a different Keycloak client than the one used to list the clusters), and you will have another Cancel button (in the extension) in case something goes wrong during this process.
614

715
### Changed
816

917
- Fixed: Emotion styles generated by this extension were conflicting with Emotion styles generated by Lens ([#205](https://github.com/Mirantis/lens-extension-cc/pull/205))
1018
- Fixed: Offline token option should default to false ([#217](https://github.com/Mirantis/lens-extension-cc/issues/217))
19+
- Fixed: There's no way to re-enter the password during an "Add clusters to Lens" event ([#215](https://github.com/Mirantis/lens-extension-cc/issues/215))
1120

1221
## v2.1.2
1322

README.md

+43-5
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,50 @@ $ git push && git push --tags
8282
8383
The `prepublishOnly` script will automatically produce a production build in the `./dist` directory, which will be published.
8484

85-
## Help
85+
## SSO support
8686

87-
### SSO not supported
87+
Mirantis Container Cloud instances that use third-party SSO authentication via __Keycloak__ are supported.
8888

89-
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).
89+
### Keycloak Configuration
9090

91-
### Management clusters not selected by default
91+
Since the integration leverages the `lens://` URL protocol handling feature for extensions, __Lens 4.2__ (or later) is required, and the __Keycloak Client__ of the instance must be configured as follows:
9292

93-
The extension purposely doesn't not add management clusters to the default/initial set of selected clusters after retrieving clusters from a Mirantis Container Cloud instance because they are typically of less interest than workload clusters.
93+
- 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.
94+
- Allow the following redirect URI: `lens://extensions/@mirantis/lens-extension-cc/oauth/code`
95+
96+
> 💡 Be sure to make these configuration adjustments __on every Keycloak Client__ (`kaas` for the management cluster, and `k8s` for child clusters by default) that manages clusters you will want to add. The extension does not know ahead of time whether you have given it the appropriate access, and adding clusters without this configuration will result in an error.
97+
98+
### Authentication flow
99+
100+
The extension will automatically detect when an instance uses SSO (upon clicking the __Access__ button).
101+
102+
If that's the case, Lens will open the instance's SSO authorization page in the system's default browser.
103+
104+
Once authorized, Keycloak will redirect to the `lens://...` URL, triggering the browser to ask permission to open the Lens app to process the request (unless permission was granted previously with the _always allow_ check box for your SSO ID Provider, e.g. `accounts.google.com`):
105+
106+
![Lens protocol permission - always allow](docs/lens-protocol-permission.png)
107+
108+
> ⚠️ Even if you check the "Always allow" box, your browser may still continue to show a popup message waiting for you to click on an "Open Lens.app" button. This is a built-in security feature. Please be on the look out for this popup in your browser whenever accessing your Container Cloud instance, or adding clusters to Lens.
109+
110+
Whether the permission was already given, or upon clicking __Open Lens.app__, Lens will receive focus again, and the extension will then read the list of namespaces and clusters as it normally would when using basic (username/password) authentication.
111+
112+
The temporary browser window used for SSO authorization will likely still be open, and should now be closed manually.
113+
114+
### Single cluster limitation
115+
116+
Due to technical issues with generating a unique kubeConfig per cluster, when the Container Cloud instance uses SSO authorization, cluster selection is __limited to a single cluster__:
117+
118+
![Single cluster SSO limitation](docs/sso-single-cluster-warning.png)
119+
120+
We hope to overcome this limitation in the future.
121+
122+
## FAQ
123+
124+
- Why are management clusters not selected by default?
125+
- The extension purposely doesn't not add management clusters to the default/initial set of selected clusters after retrieving clusters from a Mirantis Container Cloud instance because they are typically of less interest than workload clusters.
126+
- I get an error, "Invalid redirect_uri", when I attempt to access or add my clusters.
127+
- Make sure you have properly [configured](#keycloak-configuration) all your Keycloak clients for use with the extension.
128+
- Why can I only selected one cluster to add at a time?
129+
- See [Single cluster limitation](#single-cluster-limitation) when using SSO.
130+
- I was able to add my cluster to Lens, but Lens fails to show it because of an authentication error.
131+
- Check if the cluster is only accessible over a private network (i.e. VPN) connection, and try opening it in Lens once connected to the network. Even though you can see the cluster in Container Cloud, as well as in the extension, accessing the cluster's details may still require a VPN connection in this case.

docs/lens-protocol-permission.png

52.7 KB
Loading

docs/sso-single-cluster-warning.png

59.8 KB
Loading

src/cc/AddClusters.js

+29-4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useClusterActions } from './store/ClusterActionsProvider';
1111
import { useExtState } from './store/ExtStateProvider';
1212
import { Section as BaseSection } from './Section';
1313
import { layout } from './styles';
14+
import { InlineNotice } from './InlineNotice';
1415
import * as strings from '../strings';
1516

1617
const Section = styled(BaseSection)(function () {
@@ -31,7 +32,8 @@ export const AddClusters = function ({ onAdd, clusters, passwordRequired }) {
3132
} = useExtState();
3233

3334
const {
34-
state: { loading: addingClusters },
35+
state: { loading: addClustersLoading },
36+
actions: clusterActions,
3537
} = useClusterActions();
3638

3739
const [password, setPassword] = useState('');
@@ -50,6 +52,10 @@ export const AddClusters = function ({ onAdd, clusters, passwordRequired }) {
5052
}
5153
};
5254

55+
const handleSsoCancelClick = function () {
56+
clusterActions.ssoCancelAddClusters();
57+
};
58+
5359
//
5460
// RENDER
5561
//
@@ -65,7 +71,7 @@ export const AddClusters = function ({ onAdd, clusters, passwordRequired }) {
6571
style={{ width: 200 }}
6672
type="password"
6773
theme="round-black" // borders on all sides, rounded corners
68-
disabled={addingClusters}
74+
disabled={addClustersLoading}
6975
value={password}
7076
onChange={handlePasswordChange}
7177
/>
@@ -80,11 +86,11 @@ export const AddClusters = function ({ onAdd, clusters, passwordRequired }) {
8086
primary
8187
disabled={
8288
clusters.length <= 0 ||
83-
addingClusters ||
89+
addClustersLoading ||
8490
(passwordRequired && !password)
8591
}
8692
label={strings.addClusters.action.label()}
87-
waiting={addingClusters}
93+
waiting={addClustersLoading}
8894
tooltip={
8995
clusters.length <= 0
9096
? strings.addClusters.action.disabledTip()
@@ -93,6 +99,25 @@ export const AddClusters = function ({ onAdd, clusters, passwordRequired }) {
9399
onClick={handleAddClick}
94100
/>
95101
</div>
102+
103+
{addClustersLoading && (
104+
<>
105+
<InlineNotice>
106+
<p
107+
dangerouslySetInnerHTML={{
108+
__html: strings.addClusters.sso.messageHtml(),
109+
}}
110+
/>
111+
</InlineNotice>
112+
<div>
113+
<Component.Button
114+
primary
115+
label={strings.addClusters.action.ssoCancel()}
116+
onClick={handleSsoCancelClick}
117+
/>
118+
</div>
119+
</>
120+
)}
96121
</Section>
97122
);
98123
};

src/cc/ClusterList.js

+35-17
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import propTypes from 'prop-types';
22
import styled from '@emotion/styled';
33
import { Component } from '@k8slens/extensions';
4-
import { useClusterActions } from './store/ClusterActionsProvider';
4+
import { useClusterLoadingState } from './hooks/useClusterLoadingState';
55
import { Cluster } from './store/Cluster';
66
import { Section } from './Section';
7+
import { InlineNotice, types as noticeTypes, iconSizes } from './InlineNotice';
78
import { layout, mixinFlexColumnGaps } from './styles';
89
import * as strings from '../strings';
910

@@ -26,17 +27,17 @@ const CheckList = styled.div(function () {
2627

2728
export const ClusterList = function ({
2829
clusters,
30+
onlyNamespaces,
2931
selectedClusters,
32+
singleSelectOnly,
3033
onSelection,
3134
onSelectAll,
3235
}) {
3336
//
3437
// STATE
3538
//
3639

37-
const {
38-
state: { loading: addingClusters },
39-
} = useClusterActions();
40+
const loading = useClusterLoadingState();
4041

4142
// only ready clusters can actually be selected
4243
const selectableClusters = clusters.filter((cl) => cl.ready);
@@ -80,6 +81,18 @@ 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+
)}
87+
{singleSelectOnly && (
88+
<InlineNotice type={noticeTypes.WARNING} iconSize={iconSizes.SMALL}>
89+
<small
90+
dangerouslySetInnerHTML={{
91+
__html: strings.clusterList.ssoLimitationHtml(),
92+
}}
93+
/>
94+
</InlineNotice>
95+
)}
8396
<CheckList>
8497
{clusters.sort(compareClusters).map((
8598
cluster // list ALL clusters
@@ -89,36 +102,41 @@ export const ClusterList = function ({
89102
label={`${cluster.namespace} / ${cluster.name}${
90103
cluster.ready ? '' : ` ${strings.clusterList.notReady()}`
91104
}`}
92-
disabled={!cluster.ready || addingClusters}
105+
disabled={!cluster.ready || loading}
93106
value={isClusterSelected(cluster)}
94107
onChange={(checked) => handleClusterSelect(checked, cluster)}
95108
/>
96109
))}
97110
</CheckList>
98-
<div>
99-
<Component.Button
100-
primary
101-
disabled={selectableClusters.length <= 0 || addingClusters}
102-
label={
103-
selectedClusters.length < selectableClusters.length
104-
? strings.clusterList.action.selectAll.label()
105-
: strings.clusterList.action.selectNone.label()
106-
}
107-
onClick={handleSelectAllNone}
108-
/>
109-
</div>
111+
{!singleSelectOnly && (
112+
<div>
113+
<Component.Button
114+
primary
115+
disabled={loading || selectableClusters.length <= 0}
116+
label={
117+
selectedClusters.length < selectableClusters.length
118+
? strings.clusterList.action.selectAll.label()
119+
: strings.clusterList.action.selectNone.label()
120+
}
121+
onClick={handleSelectAllNone}
122+
/>
123+
</div>
124+
)}
110125
</Section>
111126
);
112127
};
113128

114129
ClusterList.propTypes = {
115130
clusters: propTypes.arrayOf(propTypes.instanceOf(Cluster)), // ALL clusters, even non-ready ones
131+
onlyNamespaces: propTypes.arrayOf(propTypes.string), // optional list of namespace IDs to which the list is restricted
116132
selectedClusters: propTypes.arrayOf(propTypes.instanceOf(Cluster)),
133+
singleSelectOnly: propTypes.bool, // true if only one cluster may be selected; false if any number can be selected
117134
onSelection: propTypes.func, // ({ cluster: Cluster, selected: boolean }) => void
118135
onSelectAll: propTypes.func, // ({ selected: boolean }) => void
119136
};
120137

121138
ClusterList.defaultProps = {
139+
singleSelectOnly: false,
122140
clusters: [],
123141
selectedClusters: [],
124142
};

src/cc/InlineNotice.js

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
//
2+
// Inline notice message (i.e. only the icon has color, no background)
3+
//
4+
5+
import propTypes from 'prop-types';
6+
import styled from '@emotion/styled';
7+
import { Component } from '@k8slens/extensions';
8+
import { layout } from './styles';
9+
10+
// notice types enumeration, maps to Material icon
11+
export const types = Object.freeze({
12+
NOTE: 'announcement', // or 'description' could work too
13+
INFO: 'info',
14+
WARNING: 'warning',
15+
ERROR: 'error',
16+
SUCCESS: 'check_circle',
17+
});
18+
19+
// icon size enumeration
20+
export const iconSizes = Object.freeze({
21+
NORMAL: 'NORMAL', // matched to normal <p> text
22+
SMALL: 'SMALL', // good for use with <small>
23+
});
24+
25+
const Notice = styled.div(function ({ iconSize }) {
26+
return {
27+
marginTop: iconSize === iconSizes.SMALL ? 0 : 2, // to center with icon
28+
marginLeft: layout.grid,
29+
};
30+
});
31+
32+
const Container = styled.div(function () {
33+
return {
34+
display: 'flex',
35+
};
36+
});
37+
38+
export const InlineNotice = function ({ type, iconSize, children, ...props }) {
39+
let color;
40+
switch (type) {
41+
case types.NOTE:
42+
color = 'var(--textColorPrimary)';
43+
break;
44+
case types.INFO:
45+
color = 'var(--colorInfo)';
46+
break;
47+
case types.WARNING:
48+
color = 'var(--colorWarning)';
49+
break;
50+
case types.ERROR:
51+
color = 'var(--colorError)';
52+
break;
53+
case types.SUCCESS:
54+
color = 'var(--colorSuccess)';
55+
break;
56+
default:
57+
color = 'var(--colorVague)';
58+
break;
59+
}
60+
61+
return (
62+
<Container type={type} {...props}>
63+
<Component.Icon
64+
material={type}
65+
smallest={iconSize === iconSizes.SMALL}
66+
focusable={false}
67+
interactive={false}
68+
style={{ color }}
69+
/>
70+
<Notice iconSize={iconSize}>{children}</Notice>
71+
</Container>
72+
);
73+
};
74+
75+
InlineNotice.propTypes = {
76+
iconSize: propTypes.oneOf(Object.values(iconSizes)),
77+
type: propTypes.oneOf(Object.values(types)),
78+
79+
// zero or more child nodes
80+
children: propTypes.oneOfType([
81+
propTypes.arrayOf(propTypes.node),
82+
propTypes.node,
83+
]),
84+
};
85+
86+
InlineNotice.defaultProps = {
87+
iconSize: iconSizes.NORMAL,
88+
type: types.INFO,
89+
};

0 commit comments

Comments
 (0)