Skip to content

Commit 54add68

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 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, which at this point should ONLY BE 'add clusters' under SSO. Everything else _should_ be working now. *** SHOULD BASIC AUTH SUPPORT BE REMOVED? *** 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.
1 parent f1c047f commit 54add68

38 files changed

+2214
-583
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Supports Lens `>= 4.2.2`.
88

99
- 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))
1010
- Fixed: Offline token option should default to false ([#217](https://github.com/Mirantis/lens-extension-cc/issues/217))
11+
- 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))
1112

1213
## v2.1.2
1314

README.md

+29-2
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,36 @@ The `prepublishOnly` script will automatically produce a production build in the
8484

8585
## Help
8686

87-
### SSO not supported
87+
### SSO support
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+
Mirantis Container Cloud instances that use third-party SSO authentication via __Keycloak__ are supported.
90+
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:
92+
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+
#### Authentication flow
97+
98+
The extension will automatically detect when an instance uses SSO (upon clicking the __Access__ button).
99+
100+
If that's the case, Lens will open the instance's SSO authorization page in the system's default browser.
101+
102+
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`):
103+
104+
![Lens protocol permission - always allow](docs/lens-protocol-permission.png)
105+
106+
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.
107+
108+
The temporary browser window used for SSO authorization will likely still be open, and should now be closed manually.
109+
110+
#### Single cluster limitation
111+
112+
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__:
113+
114+
![Single cluster SSO limitation](docs/sso-single-cluster-warning.png)
115+
116+
We hope to overcome this limitation in the future.
90117

91118
### Management clusters not selected by default
92119

docs/lens-protocol-permission.png

52.7 KB
Loading

docs/sso-single-cluster-warning.png

59.8 KB
Loading

src/cc/AddClusters.js

+26-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,10 +52,15 @@ export const AddClusters = function ({ onAdd, clusters, passwordRequired }) {
5052
}
5153
};
5254

55+
const handleSsoCancelClick = function () {
56+
clusterActions.ssoCancelAddClusters();
57+
};
58+
5359
//
5460
// RENDER
5561
//
5662

63+
// DEBUG TODO: need to show similar SSO notice as for "Access" case, with Cancel button
5764
return (
5865
<Section className="lecc-AddClusters">
5966
<h3>{strings.addClusters.title()}</h3>
@@ -65,7 +72,7 @@ export const AddClusters = function ({ onAdd, clusters, passwordRequired }) {
6572
style={{ width: 200 }}
6673
type="password"
6774
theme="round-black" // borders on all sides, rounded corners
68-
disabled={addingClusters}
75+
disabled={addClustersLoading}
6976
value={password}
7077
onChange={handlePasswordChange}
7178
/>
@@ -80,11 +87,11 @@ export const AddClusters = function ({ onAdd, clusters, passwordRequired }) {
8087
primary
8188
disabled={
8289
clusters.length <= 0 ||
83-
addingClusters ||
90+
addClustersLoading ||
8491
(passwordRequired && !password)
8592
}
8693
label={strings.addClusters.action.label()}
87-
waiting={addingClusters}
94+
waiting={addClustersLoading}
8895
tooltip={
8996
clusters.length <= 0
9097
? strings.addClusters.action.disabledTip()
@@ -93,6 +100,21 @@ export const AddClusters = function ({ onAdd, clusters, passwordRequired }) {
93100
onClick={handleAddClick}
94101
/>
95102
</div>
103+
104+
{addClustersLoading &&
105+
<>
106+
<InlineNotice>
107+
<p dangerouslySetInnerHTML={{ __html: strings.addClusters.sso.messageHtml() }}/>
108+
</InlineNotice>
109+
<div>
110+
<Component.Button
111+
primary
112+
label={strings.addClusters.action.ssoCancel()}
113+
onClick={handleSsoCancelClick}
114+
/>
115+
</div>
116+
</>
117+
}
96118
</Section>
97119
);
98120
};

src/cc/ClusterList.js

+31-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,14 @@ 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 dangerouslySetInnerHTML={{ __html: strings.clusterList.ssoLimitationHtml() }} />
90+
</InlineNotice>
91+
)}
8392
<CheckList>
8493
{clusters.sort(compareClusters).map((
8594
cluster // list ALL clusters
@@ -89,36 +98,41 @@ export const ClusterList = function ({
8998
label={`${cluster.namespace} / ${cluster.name}${
9099
cluster.ready ? '' : ` ${strings.clusterList.notReady()}`
91100
}`}
92-
disabled={!cluster.ready || addingClusters}
101+
disabled={!cluster.ready || loading}
93102
value={isClusterSelected(cluster)}
94103
onChange={(checked) => handleClusterSelect(checked, cluster)}
95104
/>
96105
))}
97106
</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>
107+
{!singleSelectOnly &&
108+
<div>
109+
<Component.Button
110+
primary
111+
disabled={loading || selectableClusters.length <= 0}
112+
label={
113+
selectedClusters.length < selectableClusters.length
114+
? strings.clusterList.action.selectAll.label()
115+
: strings.clusterList.action.selectNone.label()
116+
}
117+
onClick={handleSelectAllNone}
118+
/>
119+
</div>
120+
}
110121
</Section>
111122
);
112123
};
113124

114125
ClusterList.propTypes = {
115126
clusters: propTypes.arrayOf(propTypes.instanceOf(Cluster)), // ALL clusters, even non-ready ones
127+
onlyNamespaces: propTypes.arrayOf(propTypes.string), // optional list of namespace IDs to which the list is restricted
116128
selectedClusters: propTypes.arrayOf(propTypes.instanceOf(Cluster)),
129+
singleSelectOnly: propTypes.bool, // true if only one cluster may be selected; false if any number can be selected
117130
onSelection: propTypes.func, // ({ cluster: Cluster, selected: boolean }) => void
118131
onSelectAll: propTypes.func, // ({ selected: boolean }) => void
119132
};
120133

121134
ClusterList.defaultProps = {
135+
singleSelectOnly: false,
122136
clusters: [],
123137
selectedClusters: [],
124138
};

src/cc/InlineNotice.js

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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 material={type} smallest={iconSize === iconSizes.SMALL} focusable={false} interactive={false} style={{ color }} />
64+
<Notice iconSize={iconSize}>{children}</Notice>
65+
</Container>
66+
);
67+
};
68+
69+
InlineNotice.propTypes = {
70+
iconSize: propTypes.oneOf(Object.values(iconSizes)),
71+
type: propTypes.oneOf(Object.values(types)),
72+
73+
// zero or more child nodes
74+
children: propTypes.oneOfType([
75+
propTypes.arrayOf(propTypes.node),
76+
propTypes.node,
77+
]),
78+
};
79+
80+
InlineNotice.defaultProps = {
81+
iconSize: iconSizes.NORMAL,
82+
type: types.INFO,
83+
};

0 commit comments

Comments
 (0)