Merge pull request #3064 from richard-cox/oidc-auth-provider

Add Keycloak OIDC provider
This commit is contained in:
Richard Cox 2021-05-25 17:55:23 +01:00 committed by GitHub
commit aada5c79a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 388 additions and 30 deletions

View File

@ -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 (<code>{username}</code>); 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 <code>{username}</code>; 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

View File

@ -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;
}

View File

@ -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
}
},

View File

@ -0,0 +1,21 @@
<script>
import Login from '@/mixins/login';
export default {
mixins: [Login],
methods: {
login() {
this.$store.dispatch('auth/redirectTo', { provider: this.name });
},
},
};
</script>
<template>
<div class="text-center">
<button ref="btn" class="btn bg-primary" style="font-size: 18px;" @click="login">
{{ t('login.loginWithProvider', {provider: displayName}) }}
</button>
</div>
</template>

View File

@ -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',

View File

@ -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

241
edit/auth/oidc.vue Normal file
View File

@ -0,0 +1,241 @@
<script>
import Loading from '@/components/Loading';
import CreateEditView from '@/mixins/create-edit-view';
import AuthConfig from '@/mixins/auth-config';
import CruResource from '@/components/CruResource';
import LabeledInput from '@/components/form/LabeledInput';
import Banner from '@/components/Banner';
import AllowedPrincipals from '@/components/auth/AllowedPrincipals';
import FileSelector from '@/components/form/FileSelector';
import AuthBanner from '@/components/auth/AuthBanner';
import RadioGroup from '@/components/form/RadioGroup';
export default {
components: {
Loading,
CruResource,
LabeledInput,
Banner,
AllowedPrincipals,
FileSelector,
AuthBanner,
RadioGroup
},
mixins: [CreateEditView, AuthConfig],
data() {
return {
customEndpoint: {
value: false,
labels: [
this.t('authConfig.oidc.customEndpoint.standard'),
this.t('authConfig.oidc.customEndpoint.custom'),
],
options: [
false,
true
]
},
keycloak: {
url: null,
realm: null
}
};
},
computed: {
tArgs() {
return {
baseUrl: this.serverSetting,
provider: this.displayName,
username: this.principal.loginName || this.principal.name,
};
},
toSave() {
return {
enabled: true,
oidcConfig: this.model
};
},
},
watch: {
'keycloak.url'() {
this.updateKeycloak();
},
'keycloak.realm'() {
this.updateKeycloak();
}
},
methods: {
updateKeycloak() {
const url = this.keycloak.url.replaceAll(' ', '');
this.model.issuer = `${ url }/auth/realms/${ this.keycloak.realm || '' }`;
this.model.authEndpoint = `${ this.model.issuer || '' }/protocol/openid-connect/auth`;
}
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<div v-else>
<CruResource
:cancel-event="true"
:done-route="doneRoute"
:mode="mode"
:resource="model"
:subtypes="[]"
:validation-passed="true"
:finish-button-mode="model.enabled ? 'edit' : 'enable'"
:can-yaml="false"
:errors="errors"
:show-cancel="showCancel"
@error="e=>errors = e"
@finish="save"
@cancel="cancel"
>
<template v-if="model.enabled && !isEnabling && !editConfig">
<AuthBanner :t-args="tArgs" :disable="disable" :edit="goToEdit">
<template slot="rows">
<tr><td>{{ t(`authConfig.oidc.rancherUrl`) }}: </td><td>{{ model.rancherUrl }}</td></tr>
<tr><td>{{ t(`authConfig.oidc.clientId`) }}: </td><td>{{ model.clientId }}</td></tr>
<tr><td>{{ t(`authConfig.oidc.issuer`) }}: </td><td>{{ model.issuer }}</td></tr>
<tr><td>{{ t(`authConfig.oidc.authEndpoint`) }}: </td><td>{{ model.authEndpoint }}</td></tr>
</template>
</AuthBanner>
<hr />
<AllowedPrincipals :provider="NAME" :auth-config="model" :mode="mode" />
</template>
<template v-else>
<Banner v-if="!model.enabled" :label="t('authConfig.stateBanner.disabled', tArgs)" color="warning" />
<h3>{{ t(`authConfig.oidc.${NAME}`) }}</h3>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model="model.clientId"
:label="t(`authConfig.oidc.clientId`)"
:mode="mode"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model="model.clientSecret"
:label="t(`authConfig.oidc.clientSecret`)"
:mode="mode"
required
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model="model.privateKey"
:label="t(`authConfig.oidc.key.label`)"
:placeholder="t(`authConfig.oidc.key.placeholder`)"
:mode="mode"
type="multiline"
/>
<FileSelector class="role-tertiary add mt-5" :label="t('generic.readFromFile')" :mode="mode" @selected="$set(model, 'privateKey', $event)" />
</div>
<div class="col span-6">
<LabeledInput
v-model="model.certificate"
:label="t(`authConfig.oidc.cert.label`)"
:placeholder="t(`authConfig.oidc.cert.placeholder`)"
:mode="mode"
type="multiline"
/>
<FileSelector class="role-tertiary add mt-5" :label="t('generic.readFromFile')" :mode="mode" @selected="$set(model, 'certificate', $event)" />
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<RadioGroup
v-model="customEndpoint.value"
name="customEndpoint"
label-key="authConfig.oidc.customEndpoint.label"
:labels="customEndpoint.labels"
:options="customEndpoint.options"
>
<template #label>
<h4>{{ t('authConfig.oidc.customEndpoint.label') }}</h4>
</template>
</RadioGroup>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model="keycloak.url"
:label="t(`authConfig.oidc.keycloak.url`)"
:mode="mode"
:required="!customEndpoint.value"
:disabled="customEndpoint.value"
/>
</div>
<div class="col span-6">
<LabeledInput
v-model="keycloak.realm"
:label="t(`authConfig.oidc.keycloak.realm`)"
:mode="mode"
:required="!customEndpoint.value"
:disabled="customEndpoint.value"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model="model.rancherUrl"
:label="t(`authConfig.oidc.rancherUrl`)"
:mode="mode"
required
:disabled="!customEndpoint.value"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model="model.issuer"
:label="t(`authConfig.oidc.issuer`)"
:mode="mode"
required
:disabled="!customEndpoint.value"
/>
</div>
<div class="col span-6">
<LabeledInput
v-model="model.authEndpoint"
:label="t(`authConfig.oidc.authEndpoint`)"
:mode="mode"
required
:disabled="!customEndpoint.value"
/>
</div>
</div>
</template>
</CruResource>
<div v-if="!model.enabled" class="row">
<div class="col span-12">
<Banner color="info" v-html="t('authConfig.associatedWarning', tArgs, true)" />
</div>
</div>
</div>
</template>

View File

@ -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;

View File

@ -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() {

View File

@ -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() {

View File

@ -96,7 +96,7 @@ export default {
<main>
<h1 class="text-center mt-50">
<span v-if="testing">
Testing&hellip;
Testing Configuration&hellip;
</span>
<span v-else>
Logging In&hellip;

View File

@ -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 {
<SelectIconGrid
:rows="rows"
:color-for="colorFor"
name-field="nameDisplay"
name-field="provider"
@clicked="(row) => goTo(row.id)"
/>
</div>

View File

@ -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 };

View File

@ -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;

View File

@ -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;

View File

@ -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
};

View File

@ -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;