Skip to content

Commit 31feec0

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. 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).
1 parent e2562b3 commit 31feec0

37 files changed

+1828
-523
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Supports Lens `>= 4.2.1`.
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/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 }) {
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}>
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)