Add support for Epinio's Dex Auth provider

- Add mechanism for plugins to provide login components
- Add epinio login component
- Ensure builtin plugins are not removed on log out
This commit is contained in:
Richard Cox 2022-10-27 17:20:14 +01:00
parent 59c3cb1a67
commit 23ee18ef37
7 changed files with 118 additions and 10 deletions

View File

@ -264,3 +264,7 @@ epinio:
helmChart: Helm Chart helmChart: Helm Chart
warnings: warnings:
noNamespace: There are no namespaces. Please create one before proceeding noNamespace: There are no namespaces. Please create one before proceeding
model:
authConfig:
provider:
epinio: Dex

View File

@ -0,0 +1,60 @@
<script>
import Login from '@shell/mixins/login';
export default {
mixins: [Login],
async fetch() {
// Fetch the dex redirec url.
// The dashboard would normally get this directly from the auth provider, however epinio/dex implement pkce flow
// (additional per request validation). To support this the redirect url is per request and thus generated by the BE per login
// Unforunatly this process also requires the state to be known up front, so for this provider we create it upfront and pass through
// to the auth store
const baseNonce = await this.$store.dispatch('auth/createNonce', { provider: 'epinio' });
const encodedNonce = await this.$store.dispatch('auth/encodeNonce', baseNonce);
const res = await this.$store.dispatch('management/request', { url: `/dex/redirectUrl?state=${ encodedNonce }` });
const redirectAsUrl = new URL(res.redirectUrl);
// We'll need to save this locally for when we exchange the code for a token
const pkceCodeVerifier = redirectAsUrl.searchParams.get('code_verifier');
// The scopes in the redirect url are pulled out and reapplied, however this does not work for dex (a decoded space separator)
const scopes = redirectAsUrl.searchParams.get(`scope`); // This decodes it
const scopesArray = scopes.split(' ');
// redirectTo mangles the different scopes together incorrectly, and we're supply our own mangled version anyway, so nuke
redirectAsUrl.searchParams.delete('scope');
this.redirectOpts = {
provider: this.name,
redirectUrl: redirectAsUrl.toString(),
scopes: scopesArray,
scopesJoinChart: ' ',
nonce: baseNonce,
persistNonce: {
...baseNonce,
pkceCodeVerifier
}
};
},
data() {
return { redirectOpts: {} };
},
methods: {
login() {
this.$store.dispatch('auth/redirectTo', this.redirectOpts);
},
},
};
</script>
<template>
<div class="text-center">
<button ref="btn" class="btn bg-primary" style="font-size: 18px;" :disabled="$fetchState.pending" @click="login">
{{ t('login.loginWithProvider', {provider: displayName}) }}
</button>
</div>
</template>

View File

@ -14,7 +14,6 @@ import Password from '@shell/components/form/Password';
import { sortBy } from '@shell/utils/sort'; import { sortBy } from '@shell/utils/sort';
import { configType } from '@shell/models/management.cattle.io.authconfig'; import { configType } from '@shell/models/management.cattle.io.authconfig';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { importLogin } from '@shell/utils/dynamic-importer';
import { _ALL_IF_AUTHED, _MULTI } from '@shell/plugins/dashboard-store/actions'; import { _ALL_IF_AUTHED, _MULTI } from '@shell/plugins/dashboard-store/actions';
import { MANAGEMENT, NORMAN } from '@shell/config/types'; import { MANAGEMENT, NORMAN } from '@shell/config/types';
import { SETTING } from '@shell/config/settings'; import { SETTING } from '@shell/config/settings';
@ -177,7 +176,7 @@ export default {
created() { created() {
this.providerComponents = this.providers.map((name) => { this.providerComponents = this.providers.map((name) => {
return importLogin(configType[name]); return this.$store.getters['type-map/importLogin'](configType[name] || name);
}); });
}, },

View File

@ -43,6 +43,12 @@ export default {
try { try {
parsed = JSON.parse(base64Decode((stateStr))); parsed = JSON.parse(base64Decode((stateStr)));
} catch (err) { } catch (err) {
const out = store.getters['i18n/t'](`login.error`);
console.error('Failed to parse nonce'); // eslint-disable-line no-console
redirect(`/auth/login?err=${ escape(out) }`);
return; return;
} }

View File

@ -1,6 +1,6 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const contextFolders = ['chart', 'cloud-credential', 'content', 'detail', 'edit', 'list', 'machine-config', 'models', 'promptRemove', 'l10n', 'windowComponents', 'dialog', 'formatters']; const contextFolders = ['chart', 'cloud-credential', 'content', 'detail', 'edit', 'list', 'machine-config', 'models', 'promptRemove', 'l10n', 'windowComponents', 'dialog', 'formatters', 'login'];
const contextMap = contextFolders.reduce((map, obj) => { const contextMap = contextFolders.reduce((map, obj) => {
map[obj] = true; map[obj] = true;

View File

@ -160,7 +160,10 @@ export const actions = {
return findBy(authConfigs, 'id', id); return findBy(authConfigs, 'id', id);
}, },
setNonce({ dispatch }, opt) { /**
* Create the basic json object used for the nonce (this includes the random nonce/state)
*/
createNonce(ctx, opt) {
const out = { nonce: randomStr(16), to: 'vue' }; const out = { nonce: randomStr(16), to: 'vue' };
if ( opt.test ) { if ( opt.test ) {
@ -171,7 +174,15 @@ export const actions = {
out.provider = opt.provider; out.provider = opt.provider;
} }
const strung = JSON.stringify(out); return out;
},
/**
* Save nonce details. Information it contains will be used to validate auth requests/responses
* Note - this may be structurally different than the nonce we encode and send
*/
saveNonce(ctx, opt) {
const strung = JSON.stringify(opt);
this.$cookies.set(KEY, strung, { this.$cookies.set(KEY, strung, {
path: '/', path: '/',
@ -182,6 +193,15 @@ export const actions = {
return strung; return strung;
}, },
/**
* Convert the nonce into something we can send
*/
encodeNonce(ctx, nonce) {
const stringify = JSON.stringify(nonce);
return base64Encode(stringify, 'url');
},
async redirectTo({ state, commit, dispatch }, opt = {}) { async redirectTo({ state, commit, dispatch }, opt = {}) {
const provider = opt.provider; const provider = opt.provider;
let redirectUrl = opt.redirectUrl; let redirectUrl = opt.redirectUrl;
@ -200,7 +220,13 @@ export const actions = {
returnToUrl = `${ window.location.origin }/verify-auth-azure`; returnToUrl = `${ window.location.origin }/verify-auth-azure`;
} }
const nonce = await dispatch('setNonce', opt); // The base nonce that will be sent server way
const baseNonce = opt.nonce || await dispatch('createNonce', opt);
// Save a possibly expanded nonce
await dispatch('saveNonce', opt.persistNonce || baseNonce);
// Convert the base nonce in to something we can transmit
const encodedNonce = await dispatch('encodeNonce', baseNonce);
const fromQuery = unescape(parseUrl(redirectUrl).query?.[GITHUB_SCOPE] || ''); const fromQuery = unescape(parseUrl(redirectUrl).query?.[GITHUB_SCOPE] || '');
const scopes = fromQuery.split(/[, ]+/).filter(x => !!x); const scopes = fromQuery.split(/[, ]+/).filter(x => !!x);
@ -216,8 +242,8 @@ export const actions = {
let url = removeParam(redirectUrl, GITHUB_SCOPE); let url = removeParam(redirectUrl, GITHUB_SCOPE);
const params = { const params = {
[GITHUB_SCOPE]: scopes.join(','), [GITHUB_SCOPE]: scopes.join(opt.scopesJoinChart || ','), // Some providers won't accept comma separated scopes
[GITHUB_NONCE]: base64Encode(nonce, 'url') [GITHUB_NONCE]: encodedNonce
}; };
if (!url.includes(GITHUB_REDIRECT)) { if (!url.includes(GITHUB_REDIRECT)) {
@ -249,9 +275,16 @@ export const actions = {
return ERR_NONCE; return ERR_NONCE;
} }
const body = { code };
// If the request came with a pkce code ensure we also sent that in the verify
if (parsed.pkceCodeVerifier) {
body.code_verifier = parsed.pkceCodeVerifier;
}
return dispatch('login', { return dispatch('login', {
provider, provider,
body: { code } body
}); });
}, },

View File

@ -137,7 +137,7 @@ import {
ensureRegex, escapeHtml, escapeRegex, ucFirst, pluralize ensureRegex, escapeHtml, escapeRegex, ucFirst, pluralize
} from '@shell/utils/string'; } from '@shell/utils/string';
import { import {
importChart, importList, importDetail, importEdit, listProducts, loadProduct, importCustomPromptRemove, resolveList, resolveEdit, resolveWindowComponent, importWindowComponent, resolveChart, resolveDetail, importDialog importChart, importList, importDetail, importEdit, listProducts, loadProduct, importCustomPromptRemove, resolveList, resolveEdit, resolveWindowComponent, importWindowComponent, importLogin, resolveChart, resolveDetail, importDialog
} from '@shell/utils/dynamic-importer'; } from '@shell/utils/dynamic-importer';
import { NAME as EXPLORER } from '@shell/config/product/explorer'; import { NAME as EXPLORER } from '@shell/config/product/explorer';
@ -1152,6 +1152,12 @@ export const getters = {
}; };
}, },
importLogin(state, getters, rootState) {
return (authType) => {
return loadExtension(rootState, 'login', authType, importLogin);
};
},
componentFor(state, getters) { componentFor(state, getters) {
return (type, subType) => { return (type, subType) => {
let key = type; let key = type;