diff --git a/assets/translations/en-us.yaml b/assets/translations/en-us.yaml index c2f8fdbb0f..bf3f499016 100644 --- a/assets/translations/en-us.yaml +++ b/assets/translations/en-us.yaml @@ -228,7 +228,7 @@ authConfig: unrestricted: Allow any valid user allowedPrincipalIds: title: Authorized Users & Groups - associatedWarning: 'Note: The {provider} user you authenticate as will be associated as an alternate way to login to the {vendor} user you are currently logged in as ({username}); all the global permissions, project, and cluster role bindings of this {vendor} user will also apply to the {provider} user.' + associatedWarning: 'Note: The {provider} user you authenticate as will be associated as an alternate way to login to the {vendor} user you are currently logged in as {username}; all the global permissions, project, and cluster role bindings of this {vendor} user will also apply to the {provider} user.' github: clientId: label: Client ID @@ -376,6 +376,27 @@ authConfig: graphEndpoint: Graph Endpoint tokenEndpoint: Token Endpoint authEndpoint: Auth Endpoint + oidc: + oidc: Configure an OIDC account + keycloakoidc: Configure a Keycloak OIDC account + rancherUrl: Rancher URL + clientId: Client ID + clientSecret: Client Secret + customEndpoint: + label: Endpoints + custom: Specify (advanced) + standard: Generate + keycloak: + url: Keycloak URL + realm: Keycloak Realm + issuer: Issuer + authEndpoint: Auth Endpoint + cert: + label: Certificate + placeholder: Paste in the certificate, starting with -----BEGIN CERTIFICATE----- + key: + label: Private Key + placeholder: Paste in the private key, typically starting with -----BEGIN RSA PRIVATE KEY----- stateBanner: disabled: 'The {provider} authentication provider is currently disabled.' enabled: 'The {provider} authentication provider is currently enabled.' @@ -3912,6 +3933,10 @@ model: ldap: LDAP saml: SAML oauth: OAuth + oidc: OIDC + name: + keycloak: Keycloak (SAML) + keycloakoidc: Keycloak (OIDC) provider: system: System local: Local @@ -3928,6 +3953,8 @@ model: okta: Okta freeipa: FreeIPA googleoauth: Google + oidc: OIDC + keycloakoidc: Keycloak cluster: name: Cluster Name diff --git a/components/InfoBox.vue b/components/InfoBox.vue index 306f0fe84d..0c48479299 100644 --- a/components/InfoBox.vue +++ b/components/InfoBox.vue @@ -37,7 +37,6 @@ export default { .step-number { border-radius: var(--border-radius); background: var(--secondary); - color: var(--body-bg); display: inline-block; padding: 5px 10px; } diff --git a/components/auth/Principal.vue b/components/auth/Principal.vue index 2e96b09aed..fa32a3a7df 100644 --- a/components/auth/Principal.vue +++ b/components/auth/Principal.vue @@ -24,14 +24,16 @@ export default { return; } + const principalId = escape(this.value).replace(/\//g, '%2F'); + try { this.principal = await this.$store.dispatch('rancher/find', { type: NORMAN.PRINCIPAL, id: this.value, - opt: { url: `/v3/principals/${ escape(this.value).replace(/\//g, '%2F') }` } + opt: { url: `/v3/principals/${ principalId }` } }); } catch (e) { - // Meh... + console.error('Failed to fetch principal', this.value, principalId); // eslint-disable-line no-console } }, diff --git a/components/auth/login/oidc.vue b/components/auth/login/oidc.vue new file mode 100644 index 0000000000..1c3f2b7c4b --- /dev/null +++ b/components/auth/login/oidc.vue @@ -0,0 +1,21 @@ + + + diff --git a/config/product/auth.js b/config/product/auth.js index 29b12372cf..2025fb2dc7 100644 --- a/config/product/auth.js +++ b/config/product/auth.js @@ -168,6 +168,7 @@ export function init(store) { componentForType(`${ MANAGEMENT.AUTH_CONFIG }/adfs`, 'auth/saml'); componentForType(`${ MANAGEMENT.AUTH_CONFIG }/googleoauth`, 'auth/googleoauth'); componentForType(`${ MANAGEMENT.AUTH_CONFIG }/azuread`, 'auth/azuread'); + componentForType(`${ MANAGEMENT.AUTH_CONFIG }/keycloakoidc`, 'auth/oidc'); basicType([ 'config', diff --git a/docs/developer/auth-providers.md b/docs/developer/auth-providers.md index 3b804f2439..6a84038c63 100644 --- a/docs/developer/auth-providers.md +++ b/docs/developer/auth-providers.md @@ -25,7 +25,7 @@ data: ## Keycloak -### Developer Set Up +### Developer Set Up (SAML) Use the steps below to set up a Keycloak instance for dev environments and configure an Auth Provider for it. 1. Bring up a local Keycloak instance in docker using the instructions at [here](https://www.keycloak.org/getting-started/getting-started-docker). @@ -46,3 +46,16 @@ Use the steps below to set up a Keycloak instance for dev environments and confi | Certificate | See Private Key section above| | Metadata | For the SAML Metadata, download as per Rancher docs. Be sure to follow the `NOTE` instructions regarding `EntitiesDescriptor` and `EntityDescriptor`. For a better set of instructions see [step 6](https://gist.github.com/PhilipSchmid/506b33cd74ddef4064d30fba50635c5b)| +### Developer Set Up (OIDC) +1. In Vue UI set up the Keycloak OIDC provider with the following values + | Field | Value | + |-------|-------| + | Client ID | Find via the keycloak console | + | Client Secret | Find via the keycloak console (client's credentials tab) | + | Private Key (optional) | | + | Certificate (optional) | | + | Keycloak URL | URL of keycloak instance (no path) | + | Keycloak Realm | Find via the keycloak console (above menu on left or in path after /realms/) | + +> The user used when enabling the provider must be an Admin or in a group + diff --git a/edit/auth/oidc.vue b/edit/auth/oidc.vue new file mode 100644 index 0000000000..8f1cd65f9f --- /dev/null +++ b/edit/auth/oidc.vue @@ -0,0 +1,241 @@ + + + diff --git a/mixins/auth-config.js b/mixins/auth-config.js index 07257baf09..cc0d8a01e1 100644 --- a/mixins/auth-config.js +++ b/mixins/auth-config.js @@ -1,8 +1,10 @@ import { _EDIT } from '@/config/query-params'; import { NORMAN, MANAGEMENT } from '@/config/types'; import { AFTER_SAVE_HOOKS, BEFORE_SAVE_HOOKS } from '@/mixins/child-hook'; +import { BASE_SCOPES } from '@/store/auth'; import { addObject, findBy } from '@/utils/array'; import { set } from '@/utils/object'; +import { exceptionToErrorsArray } from '@/utils/error'; export default { beforeCreate() { @@ -40,6 +42,7 @@ export default { this.$set(this.model, 'rancherApiHost', this.serverUrl); } } + if (!this.model.enabled) { this.applyDefaults(); } @@ -125,11 +128,13 @@ export default { } try { if (this.editConfig || !wasEnabled) { - if (configType === 'oauth') { + if (configType === 'oauth' || configType === 'oidc') { const code = await this.$store.dispatch('auth/test', { provider: this.model.id, body: this.model }); obj.code = code; - } if (configType === 'saml') { + } + + if (configType === 'saml') { if (!this.model.accessMode) { this.model.accessMode = 'unrestricted'; } @@ -146,15 +151,23 @@ export default { } await this.model.doAction('testAndApply', obj, { redirectUnauthorized: false }); } - // Reload principals to get the new ones from the provider + + // Reload principals to get the new ones from the provider (including 'me') this.principals = await this.$store.dispatch('rancher/findAll', { type: NORMAN.PRINCIPAL, opt: { url: '/v3/principals', force: true } }); this.model.allowedPrincipalIds = this.model.allowedPrincipalIds || []; - if ( this.me && !this.model.allowedPrincipalIds.includes(this.me.id) ) { - addObject(this.model.allowedPrincipalIds, this.me.id); + + if ( this.me) { + if (!this.model.allowedPrincipalIds.includes(this.me.id) ) { + addObject(this.model.allowedPrincipalIds, this.me.id); + } + // Session has switched to new 'me', ensure we react + this.$store.commit('auth/loggedInAs', this.me.id); + } else { + console.warn(`Unable to find principal marked as 'me'`); // eslint-disable-line no-console } } if (wasEnabled && configType === 'oauth') { @@ -169,7 +182,7 @@ export default { btnCb(true); } catch (err) { - this.errors = Array.isArray(err) ? err : [err]; + this.errors = exceptionToErrorsArray(err); btnCb(false); this.model.enabled = wasEnabled; this.isEnabling = false; @@ -187,6 +200,9 @@ export default { await clone.save(); } await this.reloadModel(); + // Covers case where user disables... then enables in same visit to page + this.applyDefaults(); + this.showLdap = false; btnCb(true); } catch (err) { @@ -226,6 +242,18 @@ export default { applyDefaults() { switch (this.value.configType) { + case 'oidc': { + const serverUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, this.serverUrl.length - 1) : this.serverUrl; + + // AuthConfig + set(this.model, 'accessMode', 'unrestricted'); // This should remain as unrestricted, enabling will fail otherwise + + // KeyCloakOIDCConfig --> OIDCConfig + set(this.model, 'rancherUrl', `${ serverUrl }/verify-auth`); + set(this.model, 'scope', BASE_SCOPES.keycloakoidc[0]); + break; + } + case 'saml': set(this.model, 'accessMode', 'unrestricted'); break; diff --git a/models/management.cattle.io.authconfig.js b/models/management.cattle.io.authconfig.js index ded7201174..d53bced647 100644 --- a/models/management.cattle.io.authconfig.js +++ b/models/management.cattle.io.authconfig.js @@ -13,8 +13,11 @@ export const configType = { googleoauth: 'oauth', local: '', github: 'oauth', + keycloakoidc: 'oidc' }; +const imageOverrides = { keycloakoidc: 'keycloak' }; + export default { _availableActions() { const out = this._standardActions; @@ -32,6 +35,10 @@ export default { }, nameDisplay() { + return this.$rootGetters['i18n/withFallback'](`model.authConfig.name."${ this.id }"`, null, this.provider); + }, + + provider() { return this.$rootGetters['i18n/withFallback'](`model.authConfig.provider."${ this.id }"`, null, this.id); }, @@ -44,7 +51,7 @@ export default { }, icon() { - return require(`~/assets/images/vendor/${ this.id }.svg`); + return require(`~/assets/images/vendor/${ imageOverrides[this.id] || this.id }.svg`); }, state() { diff --git a/pages/account/index.vue b/pages/account/index.vue index 7186360c9c..ac699b66c2 100644 --- a/pages/account/index.vue +++ b/pages/account/index.vue @@ -45,7 +45,14 @@ export default { }, principal() { - return this.$store.getters['rancher/byId'](NORMAN.PRINCIPAL, this.$store.getters['auth/principalId']) || {}; + const principalId = this.$store.getters['auth/principalId']; + const principal = this.$store.getters['rancher/byId'](NORMAN.PRINCIPAL, principalId); + + if (!principal) { + console.error('Failed to find principal with id: ', principalId); // eslint-disable-line no-console + } + + return principal || {}; }, apiKeys() { diff --git a/pages/auth/verify.vue b/pages/auth/verify.vue index ed1c2ecf67..d8f70364e4 100644 --- a/pages/auth/verify.vue +++ b/pages/auth/verify.vue @@ -96,7 +96,7 @@ export default {

- Testing… + Testing Configuration… Logging In… diff --git a/pages/c/_cluster/auth/config/index.vue b/pages/c/_cluster/auth/config/index.vue index e6e5338a4d..fd80851347 100644 --- a/pages/c/_cluster/auth/config/index.vue +++ b/pages/c/_cluster/auth/config/index.vue @@ -59,7 +59,7 @@ export default { methods: { colorFor(row) { - const types = ['ldap', 'oauth', 'saml']; + const types = ['ldap', 'oauth', 'saml', 'oidc']; const idx = types.indexOf(row.configType); @@ -97,7 +97,7 @@ export default { diff --git a/plugins/steve/actions.js b/plugins/steve/actions.js index e40757e387..a46e4cb9a3 100644 --- a/plugins/steve/actions.js +++ b/plugins/steve/actions.js @@ -14,7 +14,7 @@ export const _ALL_IF_AUTHED = 'allIfAuthed'; export const _NONE = 'none'; export default { - request({ dispatch, rootGetters }, opt) { + async request({ dispatch, rootGetters }, opt) { // Handle spoofed types instead of making an actual request // Spoofing is handled here to ensure it's done for both yaml and form editing. // It became apparent that this was the only place that both intersected @@ -23,8 +23,10 @@ export default { const id = rest.join('/'); // Cover case where id contains '/' const isApi = scheme === SPOOFED_API_PREFIX; const typemapGetter = id ? 'getSpoofedInstance' : 'getSpoofedInstances'; + const schemas = rootGetters['cluster/all'](SCHEMA); - const instance = rootGetters[`type-map/${ typemapGetter }`](type, id); + // getters return async getSpoofedInstance/getSpoofedInstances fn + const instance = await rootGetters[`type-map/${ typemapGetter }`](type, id); const data = isApi ? createYaml(schemas, type, instance) : instance; return id && !isApi ? data : { data }; diff --git a/plugins/steve/resource-instance.js b/plugins/steve/resource-instance.js index 50f25f80d4..5e8bd97c1e 100644 --- a/plugins/steve/resource-instance.js +++ b/plugins/steve/resource-instance.js @@ -1262,6 +1262,8 @@ export default { let field, key, val, displayKey; for ( let i = 0 ; i < keys.length ; i++ ) { + const fieldErrors = []; + key = keys[i]; field = fields[key]; val = get(data, key); @@ -1291,15 +1293,15 @@ export default { } } if (fieldType === 'boolean') { - validateBoolean(val, field, displayKey, this.$rootGetters, errors); + validateBoolean(val, field, displayKey, this.$rootGetters, fieldErrors); } else { - validateLength(val, field, displayKey, this.$rootGetters, errors); - validateChars(val, field, displayKey, this.$rootGetters, errors); + validateLength(val, field, displayKey, this.$rootGetters, fieldErrors); + validateChars(val, field, displayKey, this.$rootGetters, fieldErrors); } - if (errors.length > 0) { - errors.push(this.t('validation.required', { key: displayKey })); - + if (fieldErrors.length > 0) { + fieldErrors.push(this.t('validation.required', { key: displayKey })); + errors.push(...fieldErrors); continue; } @@ -1314,8 +1316,9 @@ export default { Vue.set(data, key, val); } - errors.push(...validateDnsLikeTypes(val, fieldType, displayKey, this.$rootGetters, errors)); + fieldErrors.push(...validateDnsLikeTypes(val, fieldType, displayKey, this.$rootGetters, fieldErrors)); } + errors.push(...fieldErrors); } let { customValidationRules } = this; diff --git a/store/auth.js b/store/auth.js index 055f37c6cf..61bda2d3df 100644 --- a/store/auth.js +++ b/store/auth.js @@ -6,7 +6,10 @@ import { GITHUB_SCOPE, GITHUB_NONCE, GITHUB_REDIRECT } from '@/config/query-para import { base64Encode } from '@/utils/crypto'; export const BASE_SCOPES = { - github: ['read:org'], googleoauth: ['openid profile email'], azuread: [] + github: ['read:org'], + googleoauth: ['openid profile email'], + azuread: [], + keycloakoidc: ['profile,email'] }; const KEY = 'rc_nonce'; @@ -211,7 +214,7 @@ export const actions = { return openAuthPopup(idpRedirectUrl, provider); } else { - // github, google, azuread + // github, google, azuread, oidc const res = await driver.doAction('configureTest', body); const { redirectUrl } = res; diff --git a/utils/auth.js b/utils/auth.js index 8bff32dc3f..6535b8f47d 100644 --- a/utils/auth.js +++ b/utils/auth.js @@ -63,6 +63,8 @@ export function returnTo(opt, vm) { export const authProvidersInfo = async(store) => { const rows = await store.dispatch(`management/findAll`, { type: MANAGEMENT.AUTH_CONFIG }); const nonLocal = rows.filter(x => x.name !== 'local'); + // Generic OIDC is returned via API but not supported (and will be removed or fixed in future) + const supportedNonLocal = nonLocal.filter(x => x.id !== 'oidc'); const enabled = nonLocal.filter(x => x.enabled === true ); const enabledLocation = enabled.length === 1 ? { @@ -72,7 +74,7 @@ export const authProvidersInfo = async(store) => { } : null; return { - nonLocal, + nonLocal: supportedNonLocal, enabledLocation, enabled }; diff --git a/utils/error.js b/utils/error.js index 3beb8a72ce..9033c2de25 100644 --- a/utils/error.js +++ b/utils/error.js @@ -41,9 +41,11 @@ export function stringify(err) { } else if ( err.url ) { str = `from ${ err.url }`; } - } else { + } + + if (!str) { // Good luck... - str = `${ err }`; + str = JSON.stringify(err); } return str;