diff --git a/changelog/12800.txt b/changelog/12800.txt new file mode 100644 index 000000000..38aadc017 --- /dev/null +++ b/changelog/12800.txt @@ -0,0 +1,3 @@ +```release-note:feature +**OIDC Authorization Code Flow**: The Vault UI now supports OIDC Authorization Code Flow +``` diff --git a/ui/app/components/nav-header.js b/ui/app/components/nav-header.js index 509b88ed4..ce5f5638f 100644 --- a/ui/app/components/nav-header.js +++ b/ui/app/components/nav-header.js @@ -1,10 +1,21 @@ import Component from '@ember/component'; +import { inject as service } from '@ember/service'; +import { computed } from '@ember/object'; + export default Component.extend({ + router: service(), 'data-test-navheader': true, classNameBindings: 'consoleFullscreen:panel-fullscreen', tagName: 'header', navDrawerOpen: false, consoleFullscreen: false, + hideLinks: computed('router.currentRouteName', function() { + let currentRoute = this.router.currentRouteName; + if ('vault.cluster.identity.oidc-provider' === currentRoute) { + return true; + } + return false; + }), actions: { toggleNavDrawer(isOpen) { if (isOpen !== undefined) { diff --git a/ui/app/components/oidc-consent-block.js b/ui/app/components/oidc-consent-block.js new file mode 100644 index 000000000..b5a9e6fbd --- /dev/null +++ b/ui/app/components/oidc-consent-block.js @@ -0,0 +1,59 @@ +/** + * @module OidcConsentBlock + * OidcConsentBlock components are used to show the consent form for the OIDC Authorization Code Flow + * + * @example + * ```js + * + * ``` + * @param {string} redirect - redirect is the URL where successful consent will redirect to + * @param {string} code - code is the string required to pass back to redirect on successful OIDC auth + * @param {string} [state] - state is a string which is required to return on redirect if provided, but optional generally + */ + +import Ember from 'ember'; +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; + +const validParameters = ['code', 'state']; +export default class OidcConsentBlockComponent extends Component { + @tracked didCancel = false; + + get win() { + return this.window || window; + } + + buildUrl(urlString, params) { + try { + let url = new URL(urlString); + Object.keys(params).forEach(key => { + if (params[key] && validParameters.includes(key)) { + url.searchParams.append(key, params[key]); + } + }); + return url; + } catch (e) { + console.debug('DEBUG: parsing url failed for', urlString); + throw new Error('Invalid URL'); + } + } + + @action + handleSubmit(evt) { + evt.preventDefault(); + let { redirect, ...params } = this.args; + let redirectUrl = this.buildUrl(redirect, params); + if (Ember.testing) { + this.args.testRedirect(redirectUrl.toString()); + } else { + this.win.location.replace(redirectUrl); + } + } + + @action + handleCancel(evt) { + evt.preventDefault(); + this.didCancel = true; + } +} diff --git a/ui/app/components/token-expire-warning.js b/ui/app/components/token-expire-warning.js index 479865264..eb2884b50 100644 --- a/ui/app/components/token-expire-warning.js +++ b/ui/app/components/token-expire-warning.js @@ -1,5 +1,14 @@ -import Component from '@ember/component'; +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; -export default Component.extend({ - tagName: '', -}); +export default class TokenExpireWarning extends Component { + @service router; + + get showWarning() { + let currentRoute = this.router.currentRouteName; + if ('vault.cluster.identity.oidc-provider' === currentRoute) { + return false; + } + return !!this.args.expirationDate; + } +} diff --git a/ui/app/controllers/vault/cluster/identity/oidc-provider.js b/ui/app/controllers/vault/cluster/identity/oidc-provider.js new file mode 100644 index 000000000..8b656eca9 --- /dev/null +++ b/ui/app/controllers/vault/cluster/identity/oidc-provider.js @@ -0,0 +1,24 @@ +import Controller from '@ember/controller'; + +export default class VaultClusterIdentityOidcProviderController extends Controller { + queryParams = [ + 'scope', // * + 'response_type', // * + 'client_id', // * + 'redirect_uri', // * + 'state', // * + 'nonce', // * + 'display', + 'prompt', + 'max_age', + ]; + scope = null; + response_type = null; + client_id = null; + redirect_uri = null; + state = null; + nonce = null; + display = null; + prompt = null; + max_age = null; +} diff --git a/ui/app/mixins/cluster-route.js b/ui/app/mixins/cluster-route.js index 67dae1086..66127b89b 100644 --- a/ui/app/mixins/cluster-route.js +++ b/ui/app/mixins/cluster-route.js @@ -7,6 +7,7 @@ const AUTH = 'vault.cluster.auth'; const CLUSTER = 'vault.cluster'; const CLUSTER_INDEX = 'vault.cluster.index'; const OIDC_CALLBACK = 'vault.cluster.oidc-callback'; +const OIDC_PROVIDER = 'vault.cluster.identity.oidc-provider'; const DR_REPLICATION_SECONDARY = 'vault.cluster.replication-dr-promote'; const DR_REPLICATION_SECONDARY_DETAILS = 'vault.cluster.replication-dr-promote.details'; const EXCLUDED_REDIRECT_URLS = ['/vault/logout']; @@ -20,7 +21,9 @@ export default Mixin.create({ transitionToTargetRoute(transition = {}) { const targetRoute = this.targetRouteName(transition); - + if (OIDC_PROVIDER === this.router.currentRouteName || OIDC_PROVIDER === transition?.to?.name) { + return RSVP.resolve(); + } if ( targetRoute && targetRoute !== this.routeName && diff --git a/ui/app/router.js b/ui/app/router.js index 090def1fa..95a6e83e5 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -139,6 +139,10 @@ Router.map(function() { } this.route('not-found', { path: '/*path' }); + + this.route('identity', function() { + this.route('oidc-provider', { path: '/oidc/provider/:oidc_name/authorize' }); + }); }); this.route('not-found', { path: '/*path' }); }); diff --git a/ui/app/routes/vault/cluster/identity/oidc-provider.js b/ui/app/routes/vault/cluster/identity/oidc-provider.js new file mode 100644 index 000000000..7f5a4c66a --- /dev/null +++ b/ui/app/routes/vault/cluster/identity/oidc-provider.js @@ -0,0 +1,115 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +const AUTH = 'vault.cluster.auth'; +const PROVIDER = 'vault.cluster.identity.oidc-provider'; + +export default class VaultClusterIdentityOidcProviderRoute extends Route { + @service auth; + @service router; + + get win() { + return this.window || window; + } + + _redirect(url, params) { + let redir = this._buildUrl(url, params); + this.win.location.replace(redir); + } + + beforeModel(transition) { + const currentToken = this.auth.get('currentTokenName'); + let { redirect_to, ...qp } = transition.to.queryParams; + console.debug('DEBUG: removing redirect_to', redirect_to); + if (!currentToken && 'none' === qp.prompt?.toLowerCase()) { + this._redirect(qp.redirect_uri, { + state: qp.state, + error: 'login_required', + }); + } else if (!currentToken || 'login' === qp.prompt?.toLowerCase()) { + if ('login' === qp.prompt?.toLowerCase()) { + this.auth.deleteCurrentToken(); + qp.prompt = null; + } + let { cluster_name } = this.paramsFor('vault.cluster'); + let url = this.router.urlFor(transition.to.name, transition.to.params, { queryParams: qp }); + return this.transitionTo(AUTH, cluster_name, { queryParams: { redirect_to: url } }); + } + } + + _redirectToAuth(oidcName, queryParams, logout = false) { + let { cluster_name } = this.paramsFor('vault.cluster'); + let currentRoute = this.router.urlFor(PROVIDER, oidcName, { queryParams }); + if (logout) { + this.auth.deleteCurrentToken(); + } + return this.transitionTo(AUTH, cluster_name, { queryParams: { redirect_to: currentRoute } }); + } + + _buildUrl(urlString, params) { + try { + let url = new URL(urlString); + Object.keys(params).forEach(key => { + if (params[key]) { + url.searchParams.append(key, params[key]); + } + }); + return url; + } catch (e) { + console.debug('DEBUG: parsing url failed for', urlString); + throw new Error('Invalid URL'); + } + } + + _handleSuccess(response, baseUrl, state) { + const { code } = response; + let redirectUrl = this._buildUrl(baseUrl, { code, state }); + this.win.location.replace(redirectUrl); + } + _handleError(errorResp, baseUrl) { + let redirectUrl = this._buildUrl(baseUrl, { ...errorResp }); + this.win.location.replace(redirectUrl); + } + + async model(params) { + let { oidc_name, ...qp } = params; + let decodedRedirect = decodeURI(qp.redirect_uri); + let url = this._buildUrl(`${this.win.origin}/v1/identity/oidc/provider/${oidc_name}/authorize`, qp); + try { + const response = await this.auth.ajax(url, 'GET', {}); + if ('consent' === qp.prompt?.toLowerCase()) { + return { + consent: { + code: response.code, + redirect: decodedRedirect, + state: qp.state, + }, + }; + } + this._handleSuccess(response, decodedRedirect, qp.state); + } catch (errorRes) { + let resp = await errorRes.json(); + let code = resp.error; + if (code === 'max_age_violation') { + this._redirectToAuth(oidc_name, qp, true); + } else if (code === 'invalid_redirect_uri') { + return { + error: { + title: 'Redirect URI mismatch', + message: + 'The provided redirect_uri is not in the list of allowed redirect URIs. Please make sure you are sending a valid redirect URI from your application.', + }, + }; + } else if (code === 'invalid_client_id') { + return { + error: { + title: 'Invalid client ID', + message: 'Your client ID is invalid. Please update your configuration and try again.', + }, + }; + } else { + this._handleError(resp, decodedRedirect); + } + } + } +} diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js index 9b90965d3..d8ceb1455 100644 --- a/ui/app/services/auth.js +++ b/ui/app/services/auth.js @@ -97,7 +97,7 @@ export default Service.extend({ } else if (response.status >= 200 && response.status < 300) { return resolve(response.json()); } else { - return reject(); + return reject(response); } }); }, diff --git a/ui/app/templates/components/nav-header.hbs b/ui/app/templates/components/nav-header.hbs index c6ad8738c..8a60fecdc 100644 --- a/ui/app/templates/components/nav-header.hbs +++ b/ui/app/templates/components/nav-header.hbs @@ -9,30 +9,32 @@ {{/unless}} -