From 2f30fd18189c380cb415bd7ab7397b527045530a Mon Sep 17 00:00:00 2001 From: Joseph Caiani Date: Fri, 22 Mar 2019 23:36:40 -0400 Subject: [PATCH 1/2] Add OpenID IDP Form to Cluster Settings OAuth Page --- frontend/public/components/app.jsx | 2 +- .../components/cluster-settings/oauth.tsx | 1 + .../cluster-settings/openid-idp-form.tsx | 297 ++++++++++++++++++ 3 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 frontend/public/components/cluster-settings/openid-idp-form.tsx diff --git a/frontend/public/components/app.jsx b/frontend/public/components/app.jsx index 101edd39a58..8fbb782f0f5 100644 --- a/frontend/public/components/app.jsx +++ b/frontend/public/components/app.jsx @@ -262,7 +262,7 @@ class App extends React.PureComponent { import('./storage/create-pvc' /* webpackChunkName: "create-pvc" */).then(m => m.CreatePVC)} /> import('./monitoring' /* webpackChunkName: "monitoring" */).then(m => m.MonitoringUI)} /> - + import('./cluster-settings/openid-idp-form' /* webpackChunkName: "openid-idp-form" */).then(m => m.AddOpenIDPage)} /> import('./cluster-settings/htpasswd-idp-form' /* webpackChunkName: "htpasswd-idp-form" */).then(m => m.AddHTPasswdPage)} /> import('./cluster-settings/cluster-settings' /* webpackChunkName: "cluster-settings" */).then(m => m.ClusterSettingsPage)} /> diff --git a/frontend/public/components/cluster-settings/oauth.tsx b/frontend/public/components/cluster-settings/oauth.tsx index fdcc3d4957a..d4184251005 100644 --- a/frontend/public/components/cluster-settings/oauth.tsx +++ b/frontend/public/components/cluster-settings/oauth.tsx @@ -53,6 +53,7 @@ const OAuthDetails: React.SFC = ({obj}: {obj: K8sResourceKind const { identityProviders, tokenConfig = {} } = obj.spec; const addIDPItems = { htpasswd: 'HTPasswd', + oidconnect: 'OpenID Connect', }; return
diff --git a/frontend/public/components/cluster-settings/openid-idp-form.tsx b/frontend/public/components/cluster-settings/openid-idp-form.tsx new file mode 100644 index 00000000000..b07b1a4c678 --- /dev/null +++ b/frontend/public/components/cluster-settings/openid-idp-form.tsx @@ -0,0 +1,297 @@ +/* eslint-disable no-undef, no-unused-vars */ + +import * as React from 'react'; +import * as _ from 'lodash-es'; +import { Helmet } from 'react-helmet'; + +import { OAuthModel, SecretModel, ConfigMapModel } from '../../models'; +import { k8sCreate, k8sGet, k8sPatch, K8sResourceKind, referenceFor } from '../../module/k8s'; +import { + AsyncComponent, + ButtonBar, + Dropdown, + PromiseComponent, + history, + resourceObjPath, +} from '../utils'; + +// The name of the cluster-scoped OAuth configuration resource. +const oauthResourceName = 'cluster'; + +const DroppableFileInput = (props) => import('../utils/file-input').then(c => c.DroppableFileInput)} {...props} />; + +export class AddOpenIDPage extends PromiseComponent { + readonly state: AddOpenIDIDPPageState = { + name: 'openidconnect', + mappingMethod: 'claim', + clientID: '', + clientSecretFileContent: '', + preferredUserName: 'preferred_username', + claimName: 'name', + claimEmail: 'email', + issuer: '', + caFileContent: '', + inProgress: false, + errorMessage: '', + }; + + getOAuthResource(): Promise { + return this.handlePromise(k8sGet(OAuthModel, oauthResourceName)); + } + + createOIDClientSecret(): Promise { + const secret = { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + generateName: 'openid-client-secret-', + namespace: 'openshift-config', + }, + stringData: { + clientSecret: this.state.clientSecretFileContent, + }, + }; + + return this.handlePromise(k8sCreate(SecretModel, secret)); + } + + createOIDCA(): Promise { + const ca = { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + generateName: 'openid-config-map-', + namespace: 'openshift-config', + }, + stringData: { + ca: this.state.caFileContent, + }, + }; + + return this.handlePromise(k8sCreate(ConfigMapModel, ca)); + } + + addOpenIDIDP(oauth: K8sResourceKind, secretName: string, caName: string): Promise { + const { name, mappingMethod, clientID, issuer, preferredUserName, claimName, claimEmail } = this.state; + const openID = _.isEmpty(caName) ? { + name, + type: 'OpenID', + mappingMethod, + openID: { + clientID, + clientSecret: { + name: secretName, + }, + issuer, + claims: { + preferredUsername: [preferredUserName], + name: [claimName], + email: [claimEmail], + }, + }, + } : { + name, + type: 'OpenID', + mappingMethod, + openID: { + clientID, + clientSecret: { + name: secretName, + }, + ca: { + name: caName, + }, + issuer, + claims: { + preferredUsername: [preferredUserName], + name: [claimName], + email: [claimEmail], + }, + }, + } + ; + const patch = _.isEmpty(oauth.spec.identityProviders) + ? { op: 'add', path: '/spec/identityProviders', value: [openID] } + : { op: 'add', path: '/spec/identityProviders/-', value: openID }; + return this.handlePromise(k8sPatch(OAuthModel, oauth, [patch])); + } + + submit: React.FormEventHandler = (e) => { + e.preventDefault(); + if (!this.state.clientSecretFileContent) { + this.setState({errorMessage: 'You must specify a Client Secret file.'}); + return; + } + + // Clear any previous errors. + this.setState({errorMessage: ''}); + this.getOAuthResource().then((oauth: K8sResourceKind) => { + if (_.isEmpty(this.state.caFileContent)) { + return this.createOIDClientSecret() + .then((secret: K8sResourceKind) => this.addOpenIDIDP(oauth, secret.metadata.name, '')) + .then(() => { + history.push(resourceObjPath(oauth, referenceFor(oauth))); + }); + } + let secretValue; + return this.createOIDClientSecret() + .then((secret: K8sResourceKind) => { + secretValue = secret; + this.createOIDCA() + .then((ca:K8sResourceKind) => this.addOpenIDIDP(oauth, secretValue.metadata.name, ca.metadata.name)); + }) + .then(() => { + history.push(resourceObjPath(oauth, referenceFor(oauth))); + }); + }); + }; + + oidTextValueChanged: React.ReactEventHandler = (event) => { + this.setState({[event.currentTarget.id]: event.currentTarget.value}); + }; + + oidCBValueChanged: React.ReactEventHandler = event => { + this.setState({[event.currentTarget.id]: event.currentTarget.checked}); + }; + + mappingMethodChanged = (mappingMethod: string) => { + this.setState({mappingMethod}); + }; + + clientSecretFileChanged = (clientSecretFileContent: string) => { + this.setState({clientSecretFileContent}); + }; + + caFileChanged = (caFileContent: string) => { + this.setState({caFileContent}); + }; + + render() { + const { name, mappingMethod, clientID, clientSecretFileContent, preferredUserName, claimName, claimEmail, issuer, caFileContent } = this.state; + const title = 'Add Identity Provider: OpenID Connect'; + const mappingMethods = { + 'claim': 'Claim', + 'lookup': 'Lookup', + 'add': 'Add', + }; + return
+ + {title} + +
+

{title}

+

+ Integrate with an OpenID Connect identity provider using an Authorization Code Flow. +

+
+ + +

+ Unique name of the new identity provider. This cannot be changed later. +

+
+
+ + +
+ { /* TODO: Add doc link when available in 4.0 docs. */ } + Specifies how new identities are mapped to users when they log in. +
+
+
+ + +
+
+ +
+
+ + +
+
+

Claims

+

The first non-empty claim is used. At least one claim is required.

+
+ + +
+
+ + +
+
+ + +
+
+

More options

+
+ +
+ + + + +
+
; + } +} + +type AddOpenIDIDPPageState = { + name: string; + mappingMethod: 'claim' | 'lookup' | 'add'; + clientID: string; + clientSecretFileContent: string; + preferredUserName: string; + claimName: string; + claimEmail: string; + issuer: string; + caFileContent: string; + inProgress: boolean; + errorMessage: string; +}; From 6d205f3e27b623608012290a006e5a3f6623ff33 Mon Sep 17 00:00:00 2001 From: Samuel Padgett Date: Tue, 26 Mar 2019 19:30:04 -0400 Subject: [PATCH 2/2] Improve OpenID IDP Form * Allow users to set more than one claim value and extra scopes * Add generic `ListInput` component * Clean up promises --- .../cluster-settings/openid-idp-form.tsx | 171 ++++++++---------- .../public/components/utils/_list-input.scss | 15 ++ frontend/public/components/utils/index.tsx | 2 + .../public/components/utils/list-input.tsx | 75 ++++++++ frontend/public/style.scss | 1 + 5 files changed, 171 insertions(+), 93 deletions(-) create mode 100644 frontend/public/components/utils/_list-input.scss create mode 100644 frontend/public/components/utils/list-input.tsx diff --git a/frontend/public/components/cluster-settings/openid-idp-form.tsx b/frontend/public/components/cluster-settings/openid-idp-form.tsx index b07b1a4c678..035fc3fdd47 100644 --- a/frontend/public/components/cluster-settings/openid-idp-form.tsx +++ b/frontend/public/components/cluster-settings/openid-idp-form.tsx @@ -10,6 +10,7 @@ import { AsyncComponent, ButtonBar, Dropdown, + ListInput, PromiseComponent, history, resourceObjPath, @@ -22,15 +23,16 @@ const DroppableFileInput = (props) => import('../u export class AddOpenIDPage extends PromiseComponent { readonly state: AddOpenIDIDPPageState = { - name: 'openidconnect', + name: 'openid', mappingMethod: 'claim', clientID: '', clientSecretFileContent: '', - preferredUserName: 'preferred_username', - claimName: 'name', - claimEmail: 'email', + claimPreferredUsernames: [], + claimNames: [], + claimEmails: [], issuer: '', caFileContent: '', + extraScopes: [], inProgress: false, errorMessage: '', }; @@ -39,7 +41,7 @@ export class AddOpenIDPage extends PromiseComponent { return this.handlePromise(k8sGet(OAuthModel, oauthResourceName)); } - createOIDClientSecret(): Promise { + createClientSecret(): Promise { const secret = { apiVersion: 'v1', kind: 'Secret', @@ -55,61 +57,54 @@ export class AddOpenIDPage extends PromiseComponent { return this.handlePromise(k8sCreate(SecretModel, secret)); } - createOIDCA(): Promise { + createCAConfigMap(): Promise { + const { caFileContent } = this.state; + if (!caFileContent) { + return Promise.resolve(null); + } + const ca = { apiVersion: 'v1', kind: 'ConfigMap', metadata: { - generateName: 'openid-config-map-', + generateName: 'openid-ca-', namespace: 'openshift-config', }, stringData: { - ca: this.state.caFileContent, + ca: caFileContent, }, }; return this.handlePromise(k8sCreate(ConfigMapModel, ca)); } - addOpenIDIDP(oauth: K8sResourceKind, secretName: string, caName: string): Promise { - const { name, mappingMethod, clientID, issuer, preferredUserName, claimName, claimEmail } = this.state; - const openID = _.isEmpty(caName) ? { - name, - type: 'OpenID', - mappingMethod, - openID: { - clientID, - clientSecret: { - name: secretName, - }, - issuer, - claims: { - preferredUsername: [preferredUserName], - name: [claimName], - email: [claimEmail], - }, - }, - } : { + addOpenIDIDP(oauth: K8sResourceKind, clientSecretName: string, caName: string): Promise { + const { name, mappingMethod, clientID, issuer, extraScopes, claimPreferredUsernames, claimNames, claimEmails } = this.state; + const openID: any = { name, type: 'OpenID', mappingMethod, openID: { clientID, clientSecret: { - name: secretName, - }, - ca: { - name: caName, + name: clientSecretName, }, issuer, + extraScopes, claims: { - preferredUsername: [preferredUserName], - name: [claimName], - email: [claimEmail], + preferredUsername: claimPreferredUsernames, + name: claimNames, + email: claimEmails, }, }, + }; + + if (caName) { + openID.ca = { + name: caName, + }; } - ; + const patch = _.isEmpty(oauth.spec.identityProviders) ? { op: 'add', path: '/spec/identityProviders', value: [openID] } : { op: 'add', path: '/spec/identityProviders/-', value: openID }; @@ -126,32 +121,46 @@ export class AddOpenIDPage extends PromiseComponent { // Clear any previous errors. this.setState({errorMessage: ''}); this.getOAuthResource().then((oauth: K8sResourceKind) => { - if (_.isEmpty(this.state.caFileContent)) { - return this.createOIDClientSecret() - .then((secret: K8sResourceKind) => this.addOpenIDIDP(oauth, secret.metadata.name, '')) - .then(() => { - history.push(resourceObjPath(oauth, referenceFor(oauth))); - }); - } - let secretValue; - return this.createOIDClientSecret() - .then((secret: K8sResourceKind) => { - secretValue = secret; - this.createOIDCA() - .then((ca:K8sResourceKind) => this.addOpenIDIDP(oauth, secretValue.metadata.name, ca.metadata.name)); - }) - .then(() => { - history.push(resourceObjPath(oauth, referenceFor(oauth))); - }); + const promises = [ + this.createClientSecret(), + this.createCAConfigMap(), + ]; + + Promise.all(promises).then(([secret, configMap]) => { + const caName = configMap ? configMap.metadata.name : ''; + return this.addOpenIDIDP(oauth, secret.metadata.name, caName); + }).then(() => { + history.push(resourceObjPath(oauth, referenceFor(oauth))); + }); }); }; - oidTextValueChanged: React.ReactEventHandler = (event) => { - this.setState({[event.currentTarget.id]: event.currentTarget.value}); + nameChanged: React.ReactEventHandler = (event) => { + this.setState({name: event.currentTarget.value}); + }; + + clientIDChanged: React.ReactEventHandler = (event) => { + this.setState({clientID: event.currentTarget.value}); }; - oidCBValueChanged: React.ReactEventHandler = event => { - this.setState({[event.currentTarget.id]: event.currentTarget.checked}); + issuerChanged: React.ReactEventHandler = (event) => { + this.setState({issuer: event.currentTarget.value}); + }; + + claimPreferredUsernamesChanged = (claimPreferredUsernames: string[]) => { + this.setState({claimPreferredUsernames}); + }; + + claimNamesChanged = (claimNames: string[]) => { + this.setState({claimNames}); + }; + + claimEmailsChanged = (claimEmails: string[]) => { + this.setState({claimEmails}); + }; + + extraScopesChanged = (extraScopes: string[]) => { + this.setState({extraScopes}); }; mappingMethodChanged = (mappingMethod: string) => { @@ -167,7 +176,7 @@ export class AddOpenIDPage extends PromiseComponent { }; render() { - const { name, mappingMethod, clientID, clientSecretFileContent, preferredUserName, claimName, claimEmail, issuer, caFileContent } = this.state; + const { name, mappingMethod, clientID, clientSecretFileContent, issuer, caFileContent } = this.state; const title = 'Add Identity Provider: OpenID Connect'; const mappingMethods = { 'claim': 'Claim', @@ -187,7 +196,7 @@ export class AddOpenIDPage extends PromiseComponent { ClientID
@@ -227,42 +235,17 @@ export class AddOpenIDPage extends PromiseComponent {

Claims

The first non-empty claim is used. At least one claim is required.

-
- - -
-
- - -
-
- - -
+ + +

More options

@@ -270,9 +253,10 @@ export class AddOpenIDPage extends PromiseComponent { onChange={this.caFileChanged} inputFileData={caFileContent} id="caFileContent" - label="CA" + label="CA File" hideContents />
+ @@ -287,11 +271,12 @@ type AddOpenIDIDPPageState = { mappingMethod: 'claim' | 'lookup' | 'add'; clientID: string; clientSecretFileContent: string; - preferredUserName: string; - claimName: string; - claimEmail: string; + claimPreferredUsernames: string[]; + claimNames: string[]; + claimEmails: string[]; issuer: string; caFileContent: string; + extraScopes: string[]; inProgress: boolean; errorMessage: string; }; diff --git a/frontend/public/components/utils/_list-input.scss b/frontend/public/components/utils/_list-input.scss new file mode 100644 index 00000000000..0d70f86d680 --- /dev/null +++ b/frontend/public/components/utils/_list-input.scss @@ -0,0 +1,15 @@ +.co-list-input__add-btn { + padding-left: 0; +} + +.co-list-input__remove-btn { + margin-top: -6px; +} + +.co-list-input__row { + display: flex; +} + +.co-list-input__value { + flex-grow: 1; +} diff --git a/frontend/public/components/utils/index.tsx b/frontend/public/components/utils/index.tsx index e4fad8f9aa7..4054e11f713 100644 --- a/frontend/public/components/utils/index.tsx +++ b/frontend/public/components/utils/index.tsx @@ -47,6 +47,8 @@ export * from './k8s-watcher'; export * from './workload-pause'; export * from './list-dropdown'; export * from './status-icon'; +export * from './list-input'; + /* Add the enum for NameValueEditorPair here and not in its namesake file because the editor should always be loaded asynchronously in order not to bloat the vendor file. The enum reference into the editor diff --git a/frontend/public/components/utils/list-input.tsx b/frontend/public/components/utils/list-input.tsx new file mode 100644 index 00000000000..12bc8ebd0ac --- /dev/null +++ b/frontend/public/components/utils/list-input.tsx @@ -0,0 +1,75 @@ +/* eslint-disable no-undef, no-unused-vars */ + +import * as React from 'react'; +import * as _ from 'lodash-es'; + +export class ListInput extends React.Component { + readonly state: ListInputState = { + values: [''], + }; + + componentDidUpdate(prevProps: ListInputProps, prevState: ListInputState) { + if (prevState.values !== this.state.values) { + const values = _.compact(this.state.values); + this.props.onChange(values); + } + } + + valueChanged(i: number, v: string) { + this.setState(state => { + const values = [...state.values]; + values[i] = v; + return { values }; + }); + } + + addValue() { + this.setState(state => ({ values: [...state.values, '']})); + } + + removeValue(i: number) { + this.setState(state => { + const values = [...state.values]; + values.splice(i, 1); + return { + values: _.isEmpty(values) ? [''] : values, + }; + }); + } + + render() { + const { label } = this.props; + const { values } = this.state; + return ( +
+ + {_.map(values, (v: string, i: number) => ( +
+
+ ) => this.valueChanged(i, e.currentTarget.value)} /> +
+
+