diff --git a/ui/app/components/auth-form.js b/ui/app/components/auth-form.js index 65edd9b12757..42baadfff468 100644 --- a/ui/app/components/auth-form.js +++ b/ui/app/components/auth-form.js @@ -47,43 +47,54 @@ export default Component.extend(DEFAULTS, { wrappedToken: null, // internal oldNamespace: null, + didReceiveAttrs() { this._super(...arguments); - let token = this.get('wrappedToken'); - let newMethod = this.get('selectedAuth'); - let oldMethod = this.get('oldSelectedAuth'); + let { + wrappedToken: token, + oldWrappedToken: oldToken, + oldNamespace: oldNS, + namespace: ns, + selectedAuth: newMethod, + oldSelectedAuth: oldMethod, + } = this; - let ns = this.get('namespace'); - let oldNS = this.get('oldNamespace'); - if (oldNS === null || oldNS !== ns) { - this.get('fetchMethods').perform(); - } - this.set('oldNamespace', ns); - if (oldMethod && oldMethod !== newMethod) { - this.resetDefaults(); - } - this.set('oldSelectedAuth', newMethod); - - if (token) { - this.get('unwrapToken').perform(token); - } + next(() => { + if (!token && (oldNS === null || oldNS !== ns)) { + this.fetchMethods.perform(); + } + this.set('oldNamespace', ns); + // we only want to trigger this once + if (token && !oldToken) { + this.unwrapToken.perform(token); + this.set('oldWrappedToken', token); + } + if (oldMethod && oldMethod !== newMethod) { + this.resetDefaults(); + } + this.set('oldSelectedAuth', newMethod); + }); }, didRender() { this._super(...arguments); - let firstMethod = this.firstMethod(); // on very narrow viewports the active tab may be overflowed, so we scroll it into view here let activeEle = this.element.querySelector('li.is-active'); if (activeEle) { activeEle.scrollIntoView(); } - // set `with` to the first method - if ( - (this.get('fetchMethods.isIdle') && firstMethod && !this.get('selectedAuth')) || - (this.get('selectedAuth') && !this.get('selectedAuthBackend')) - ) { - this.set('selectedAuth', firstMethod); - } + + next(() => { + let firstMethod = this.firstMethod(); + // set `with` to the first method + if ( + !this.wrappedToken && + ((this.get('fetchMethods.isIdle') && firstMethod && !this.get('selectedAuth')) || + (this.get('selectedAuth') && !this.get('selectedAuthBackend'))) + ) { + this.set('selectedAuth', firstMethod); + } + }); }, firstMethod() { @@ -98,18 +109,23 @@ export default Component.extend(DEFAULTS, { }, selectedAuthIsPath: match('selectedAuth', /\/$/), - selectedAuthBackend: computed('methods', 'methods.[]', 'selectedAuth', 'selectedAuthIsPath', function() { - let methods = this.get('methods'); - let selectedAuth = this.get('selectedAuth'); - let keyIsPath = this.get('selectedAuthIsPath'); - if (!methods) { - return {}; - } - if (keyIsPath) { - return methods.findBy('path', selectedAuth); + selectedAuthBackend: computed( + 'wrappedToken', + 'methods', + 'methods.[]', + 'selectedAuth', + 'selectedAuthIsPath', + function() { + let { wrappedToken, methods, selectedAuth, selectedAuthIsPath: keyIsPath } = this; + if (!methods && !wrappedToken) { + return {}; + } + if (keyIsPath) { + return methods.findBy('path', selectedAuth); + } + return BACKENDS.findBy('type', selectedAuth); } - return BACKENDS.findBy('type', selectedAuth); - }), + ), providerPartialName: computed('selectedAuthBackend', function() { let type = this.get('selectedAuthBackend.type') || 'token'; @@ -146,9 +162,7 @@ export default Component.extend(DEFAULTS, { try { let response = yield adapter.toolAction('unwrap', null, { clientToken: token }); this.set('token', response.auth.client_token); - next(() => { - this.send('doSubmit'); - }); + this.send('doSubmit'); } catch (e) { this.set('error', `Token unwrap failed: ${e.errors[0]}`); } @@ -239,7 +253,7 @@ export default Component.extend(DEFAULTS, { let backendMeta = BACKENDS.find( b => (get(b, 'type') || '').toLowerCase() === (get(backend, 'type') || '').toLowerCase() ); - let attributes = get(backendMeta || {}, 'formAttributes') || {}; + let attributes = get(backendMeta || {}, 'formAttributes') || []; data = assign(data, this.getProperties(...attributes)); if (passedData) { diff --git a/ui/app/controllers/vault/cluster/auth.js b/ui/app/controllers/vault/cluster/auth.js index 8733761393e5..e4f1f7b115e4 100644 --- a/ui/app/controllers/vault/cluster/auth.js +++ b/ui/app/controllers/vault/cluster/auth.js @@ -10,7 +10,7 @@ export default Controller.extend({ namespaceQueryParam: alias('clusterController.namespaceQueryParam'), queryParams: [{ authMethod: 'with' }], wrappedToken: alias('vaultController.wrappedToken'), - authMethod: '', + authMethod: 'token', redirectTo: alias('vaultController.redirectTo'), updateNamespace: task(function*(value) { diff --git a/ui/app/mixins/cluster-route.js b/ui/app/mixins/cluster-route.js index 7ed1860728fc..7b33bc0295cf 100644 --- a/ui/app/mixins/cluster-route.js +++ b/ui/app/mixins/cluster-route.js @@ -9,6 +9,7 @@ const CLUSTER = 'vault.cluster'; const CLUSTER_INDEX = 'vault.cluster.index'; const OIDC_CALLBACK = 'vault.cluster.oidc-callback'; const DR_REPLICATION_SECONDARY = 'vault.cluster.replication-dr-promote'; +const EXCLUDED_REDIRECT_URLS = ['/vault/logout']; export { INIT, UNSEAL, AUTH, CLUSTER, CLUSTER_INDEX, DR_REPLICATION_SECONDARY }; @@ -29,13 +30,17 @@ export default Mixin.create({ if ( // only want to redirect if we're going to authenticate targetRoute === AUTH && - transition.targetName !== CLUSTER_INDEX + transition.targetName !== CLUSTER_INDEX && + !EXCLUDED_REDIRECT_URLS.includes(this.router.currentURL) ) { return this.transitionTo(targetRoute, { queryParams: { redirect_to: this.router.currentURL } }); } return this.transitionTo(targetRoute); } + if (transition.abort && targetRoute === this.router.currentRouteName) { + transition.abort(); + } return RSVP.resolve(); }, diff --git a/ui/app/routes/vault/cluster/auth.js b/ui/app/routes/vault/cluster/auth.js index 2ac997372d9a..dcb83b78dbe6 100644 --- a/ui/app/routes/vault/cluster/auth.js +++ b/ui/app/routes/vault/cluster/auth.js @@ -19,9 +19,10 @@ export default ClusterRouteBase.extend({ model() { return this._super(...arguments); }, + resetController(controller) { controller.set('wrappedToken', ''); - controller.set('authMethod', ''); + controller.set('authMethod', 'token'); }, afterModel() { diff --git a/ui/app/routes/vault/cluster/logout.js b/ui/app/routes/vault/cluster/logout.js index c2232706359b..2dd69193871c 100644 --- a/ui/app/routes/vault/cluster/logout.js +++ b/ui/app/routes/vault/cluster/logout.js @@ -22,8 +22,8 @@ export default Route.extend(ModelBoundaryRoute, { this.console.set('isOpen', false); this.console.clearLog(true); this.clearModelCache(); - this.replaceWith('vault.cluster.auth', { queryParams: { redirect_to: '' } }); this.flashMessages.clearMessages(); this.permissions.reset(); + this.replaceWith('vault.cluster.auth'); }, }); diff --git a/ui/app/templates/components/auth-form.hbs b/ui/app/templates/components/auth-form.hbs index 848b6eb5a53e..8fcf03b58eba 100644 --- a/ui/app/templates/components/auth-form.hbs +++ b/ui/app/templates/components/auth-form.hbs @@ -39,7 +39,8 @@ @valueAttribute={{'type'}} @labelAttribute={{'typeDisplay'}} @isFullwidth={{true}} - @onChange={{action (mut selectedAuth)}} + @selectedValue={{this.selectedAuth}} + @onChange={{action (mut this.selectedAuth)}} /> {{/if}} {{#if (or (eq this.selectedAuthBackend.type "jwt") (eq this.selectedAuthBackend.type "oidc"))}} diff --git a/ui/tests/acceptance/auth-test.js b/ui/tests/acceptance/auth-test.js index e6d0a0b88e4b..81ef6a171d02 100644 --- a/ui/tests/acceptance/auth-test.js +++ b/ui/tests/acceptance/auth-test.js @@ -37,20 +37,24 @@ module('Acceptance | auth', function(hooks) { let backends = supportedAuthBackends(); assert.expect(backends.length + 1); await visit('/vault/auth'); - assert.equal(currentURL(), '/vault/auth?with=token'); + assert.equal(currentURL(), '/vault/auth'); for (let backend of backends.reverse()) { await component.selectMethod(backend.type); - assert.equal( - currentURL(), - `/vault/auth?with=${backend.type}`, - `has the correct URL for ${backend.type}` - ); + if (backend.type === 'token') { + assert.equal(currentURL(), `/vault/auth`, `has the correct URL for ${backend.type}`); + } else { + assert.equal( + currentURL(), + `/vault/auth?with=${backend.type}`, + `has the correct URL for ${backend.type}` + ); + } } }); test('it clears token when changing selected auth method', async function(assert) { await visit('/vault/auth'); - assert.equal(currentURL(), '/vault/auth?with=token'); + assert.equal(currentURL(), '/vault/auth'); await component.token('token').selectMethod('github'); await component.selectMethod('token'); assert.equal(component.tokenValue, '', 'it clears the token value when toggling methods'); diff --git a/ui/tests/acceptance/redirect-to-test.js b/ui/tests/acceptance/redirect-to-test.js index 4cdec62df659..2d89732875d3 100644 --- a/ui/tests/acceptance/redirect-to-test.js +++ b/ui/tests/acceptance/redirect-to-test.js @@ -1,28 +1,62 @@ -import { currentURL, visit } from '@ember/test-helpers'; +import { currentURL, visit as _visit, settled } from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; -import authPage from 'vault/tests/pages/auth'; +import { create } from 'ember-cli-page-object'; +import auth from 'vault/tests/pages/auth'; +import consoleClass from 'vault/tests/pages/components/console/ui-panel'; -module('Acceptance | redirect_to functionality', function(hooks) { +const visit = async url => { + try { + await _visit(url); + } catch (e) { + if (e.message !== 'TransitionAborted') { + throw e; + } + } + + await settled(); +}; + +const consoleComponent = create(consoleClass); + +const wrappedAuth = async () => { + await consoleComponent.runCommands(`write -field=token auth/token/create policies=default -wrap-ttl=3m`); + return consoleComponent.lastLogOutput; +}; + +const setupWrapping = async () => { + await auth.logout(); + await auth.visit(); + await auth.tokenInput('root').submit(); + let wrappedToken = await wrappedAuth(); + return wrappedToken; +}; +module('Acceptance | redirect_to query param functionality', function(hooks) { setupApplicationTest(hooks); + hooks.beforeEach(function() { + // normally we'd use the auth.logout helper to visit the route and reset the app, but in this case that + // also routes us to the auth page, and then all of the transitions from the auth page get redirected back + // to the auth page resulting in no redirect_to query param being set + localStorage.clear(); + }); test('redirect to a route after authentication', async function(assert) { let url = '/vault/secrets/secret/create'; await visit(url); assert.equal( currentURL(), - `/vault/auth?redirect_to=${encodeURIComponent(url)}&with=token`, + `/vault/auth?redirect_to=${encodeURIComponent(url)}`, 'encodes url for the query param' ); // the login method on this page does another visit call that we don't want here - await authPage.tokenInput('root').submit(); + await auth.tokenInput('root').submit(); assert.equal(currentURL(), url, 'navigates to the redirect_to url after auth'); }); test('redirect from root does not include redirect_to', async function(assert) { let url = '/'; await visit(url); - assert.equal(currentURL(), `/vault/auth?with=token`, 'there is no redirect_to query param'); + assert.equal(currentURL(), `/vault/auth`, 'there is no redirect_to query param'); }); test('redirect to a route after authentication with a query param', async function(assert) { @@ -30,10 +64,21 @@ module('Acceptance | redirect_to functionality', function(hooks) { await visit(url); assert.equal( currentURL(), - `/vault/auth?redirect_to=${encodeURIComponent(url)}&with=token`, + `/vault/auth?redirect_to=${encodeURIComponent(url)}`, 'encodes url for the query param' ); - await authPage.tokenInput('root').submit(); + await auth.tokenInput('root').submit(); assert.equal(currentURL(), url, 'navigates to the redirect_to with the query param after auth'); }); + + test('redirect to logout with wrapped token authenticates you', async function(assert) { + let wrappedToken = await setupWrapping(); + let url = '/vault/secrets/cubbyhole/create'; + + await auth.logout({ + redirect_to: url, + wrapped_token: wrappedToken, + }); + assert.equal(currentURL(), url, 'authenticates then navigates to the redirect_to url after auth'); + }); }); diff --git a/ui/tests/acceptance/wrapped-token-test.js b/ui/tests/acceptance/wrapped-token-test.js new file mode 100644 index 000000000000..60db5171a29e --- /dev/null +++ b/ui/tests/acceptance/wrapped-token-test.js @@ -0,0 +1,37 @@ +import { module, test } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; +import { currentURL } from '@ember/test-helpers'; +import { create } from 'ember-cli-page-object'; +import auth from 'vault/tests/pages/auth'; +import consoleClass from 'vault/tests/pages/components/console/ui-panel'; + +const consoleComponent = create(consoleClass); + +const wrappedAuth = async () => { + await consoleComponent.runCommands(`write -field=token auth/token/create policies=default -wrap-ttl=3m`); + return consoleComponent.lastLogOutput; +}; + +const setupWrapping = async () => { + await auth.logout(); + await auth.visit(); + await auth.tokenInput('root').submit(); + let token = await wrappedAuth(); + await auth.logout(); + return token; +}; +module('Acceptance | wrapped_token query param functionality', function(hooks) { + setupApplicationTest(hooks); + + test('it authenticates you if the query param is present', async function(assert) { + let token = await setupWrapping(); + await auth.visit({ wrapped_token: token }); + assert.equal(currentURL(), '/vault/secrets', 'authenticates and redirects to home'); + }); + + test('it authenticates when used with the with=token query param', async function(assert) { + let token = await setupWrapping(); + await auth.visit({ wrapped_token: token, with: 'token' }); + assert.equal(currentURL(), '/vault/secrets', 'authenticates and redirects to home'); + }); +});