mirror of https://github.com/rancher/dashboard.git
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:
parent
59c3cb1a67
commit
23ee18ef37
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue