From 784d8ff581edfd3d016bf6b73a934284931029c4 Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Wed, 2 Apr 2025 17:10:49 -0700 Subject: [PATCH] UI: Glimmerize AuthJwt (#30130) * glimmerize auth-jwt * update asserton count * update jwt acceptance test * simplify stubbed popup * remove references to this.window * remove waitFor, will add back if necessary * wip tests * finish auth-jwt integration tests * finish acceptance tests * temp skip unit tests * Revert "temp skip unit tests" This reverts commit 24ed7c9de8f37a597ef1be28b0f3856278b041bb. * temp skip unit tests * remove loading management in parent * polish integration tests, add final acceptance test, revert while loops * refactor window helper and address small component cleanup items --- ui/app/components/auth-jwt.js | 188 +++++++++-------- ui/app/templates/components/auth-form.hbs | 4 - ui/app/templates/components/auth-jwt.hbs | 15 +- ui/tests/acceptance/auth-test.js | 2 +- ui/tests/acceptance/auth/mfa-test.js | 5 +- ui/tests/acceptance/jwt-auth-method-test.js | 2 +- ui/tests/acceptance/oidc-auth-method-test.js | 102 ++++++++- ui/tests/acceptance/saml-auth-method-test.js | 5 +- ui/tests/helpers/oidc-window-stub.js | 38 +--- .../integration/components/auth-jwt-test.js | 196 ++++++++---------- ui/tests/unit/components/auth-jwt-test.js | 88 -------- 11 files changed, 311 insertions(+), 334 deletions(-) delete mode 100644 ui/tests/unit/components/auth-jwt-test.js diff --git a/ui/app/components/auth-jwt.js b/ui/app/components/auth-jwt.js index 55333d620c..e891a2127b 100644 --- a/ui/app/components/auth-jwt.js +++ b/ui/app/components/auth-jwt.js @@ -2,12 +2,13 @@ * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: BUSL-1.1 */ + +import Component from '@glimmer/component'; import Ember from 'ember'; import { service } from '@ember/service'; -// ARG NOTE: Once you remove outer-html after glimmerizing you can remove the outer-html component -import Component from '@ember/component'; -import { task, timeout, waitForEvent } from 'ember-concurrency'; -import { debounce } from '@ember/runloop'; +import { restartableTask, task, timeout, waitForEvent } from 'ember-concurrency'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; const ERROR_WINDOW_CLOSED = 'The provider window was closed before authentication was complete. Your web browser may have blocked or closed a pop-up window. Please check your settings and click Sign In to try again.'; @@ -16,129 +17,145 @@ const ERROR_MISSING_PARAMS = const ERROR_JWT_LOGIN = 'OIDC login is not configured for this mount'; export { ERROR_WINDOW_CLOSED, ERROR_MISSING_PARAMS, ERROR_JWT_LOGIN }; -export default Component.extend({ - tagName: '', +export default class AuthOidcJwt extends Component { + @service store; + @service flags; - store: service(), - flagsService: service('flags'), + // cache values to determine whether or not to refire fetchRole task + _authType; + _authPath; + // set by form inputs + @tracked roleName = null; + @tracked jwt; + // set by auth workflow + @tracked fetchedRole = null; + @tracked errorMessage = null; + @tracked isOIDC = true; - selectedAuthPath: null, - selectedAuthType: null, - roleName: null, - role: null, - errorMessage: null, - isOIDC: true, + constructor() { + super(...arguments); + this._authPath = this.args.selectedAuthPath; + this._authType = this.args.selectedAuthType; + this.fetchRole.perform(); + } - onRoleName() {}, - onLoading() {}, - onError() {}, - onNamespace() {}, + get tasksAreRunning() { + return this.prepareForOIDC.isRunning || this.exchangeOIDC.isRunning; + } - didReceiveAttrs() { - this._super(...arguments); + @action + checkArgUpdate() { // if mount path or type changes we need to check again for JWT configuration - const didChangePath = this._authPath !== this.selectedAuthPath; - const didChangeType = this._authType !== this.selectedAuthType; + const didChangePath = this._authPath !== this.args.selectedAuthPath; + const didChangeType = this._authType !== this.args.selectedAuthType; if (didChangePath || didChangeType) { // path updates as the user types so we need to debounce that event const wait = didChangePath ? 500 : 0; - debounce(this, 'fetchRole', wait); + this.fetchRole.perform(wait); } - this._authPath = this.selectedAuthPath; - this._authType = this.selectedAuthType; - }, - getWindow() { - return this.window || window; - }, + // update cached props + this._authPath = this.args.selectedAuthPath; + this._authType = this.args.selectedAuthType; + } + + fetchRole = restartableTask(async (wait) => { + // task is `restartable` so if the user starts typing again, + // it will cancel and restart from the beginning. + if (wait) await timeout(wait); + + // if we have a custom path is inputted use that, + // otherwise fallback to type (which is the default path) + const path = this.args.selectedAuthPath || this.args.selectedAuthType; - async fetchRole() { - const path = this.selectedAuthPath || this.selectedAuthType; const id = JSON.stringify([path, this.roleName]); - this.setProperties({ role: null, errorMessage: null, isOIDC: true }); + + this.fetchedRole = null; + this.errorMessage = null; + this.isOIDC = true; try { - const role = await this.store.findRecord('role-jwt', id, { - adapterOptions: { namespace: this.namespace }, + this.fetchedRole = await this.store.findRecord('role-jwt', id, { + adapterOptions: { namespace: this.args.namespace }, }); - this.set('role', role); } catch (e) { const error = (e.errors || [])[0]; const errorMessage = e.httpStatus === 400 ? 'Invalid role. Please try again.' : `Error fetching role: ${error}`; // assume OIDC until it's known that the mount is configured for JWT authentication via static keys, JWKS, or OIDC discovery. - this.setProperties({ isOIDC: error !== ERROR_JWT_LOGIN, errorMessage }); + // if the mount is configured for JWT this specific error is returned. + this.isOIDC = error !== ERROR_JWT_LOGIN; + this.errorMessage = errorMessage; } - }, + }); cancelLogin(oidcWindow, errorMessage) { this.closeWindow(oidcWindow); this.handleOIDCError(errorMessage); - }, + } closeWindow(oidcWindow) { this.watchPopup.cancelAll(); this.watchCurrent.cancelAll(); oidcWindow.close(); - }, + } handleOIDCError(err) { - this.onLoading(false); this.prepareForOIDC.cancelAll(); - this.onError(err); - }, + this.args.onError(err); + } // NOTE TO DEVS: Be careful when updating the OIDC flow and ensure the updates // work with implicit flow. See issue https://github.com/hashicorp/vault-plugin-auth-jwt/pull/192 - prepareForOIDC: task(function* (oidcWindow) { - const thisWindow = this.getWindow(); - // show the loading animation in the parent - this.onLoading(true); + prepareForOIDC = task(async (oidcWindow) => { + const thisWindow = window; + // start watching the popup window and the current one this.watchPopup.perform(oidcWindow); this.watchCurrent.perform(oidcWindow); // wait for message posted from oidc callback // see issue https://github.com/hashicorp/vault/issues/12436 // ensure that postMessage event is from expected source + // eslint-disable-next-line no-constant-condition while (true) { - const event = yield waitForEvent(thisWindow, 'message'); + const event = await waitForEvent(thisWindow, 'message'); if (event.origin === thisWindow.origin && event.isTrusted && event.data.source === 'oidc-callback') { return this.exchangeOIDC.perform(event.data, oidcWindow); } - // continue to wait for the correct message } - }), + }); - watchPopup: task(function* (oidcWindow) { + watchPopup = task(async (oidcWindow) => { + // eslint-disable-next-line no-constant-condition while (true) { const WAIT_TIME = Ember.testing ? 50 : 500; - yield timeout(WAIT_TIME); + await timeout(WAIT_TIME); if (!oidcWindow || oidcWindow.closed) { return this.handleOIDCError(ERROR_WINDOW_CLOSED); } } - }), + }); - watchCurrent: task(function* (oidcWindow) { + watchCurrent = task(async (oidcWindow) => { // when user is about to change pages, close the popup window - yield waitForEvent(this.getWindow(), 'beforeunload'); + await waitForEvent(window, 'beforeunload'); oidcWindow.close(); - }), + }); - exchangeOIDC: task(function* (oidcState, oidcWindow) { + exchangeOIDC = task(async (oidcState, oidcWindow) => { if (oidcState === null || oidcState === undefined) { return; } - this.onLoading(true); let { namespace, path, state, code } = oidcState; // The namespace can be either be passed as a query parameter, or be embedded // in the state param in the format `,ns=`. So if // `namespace` is empty, check for namespace in state as well. - if (namespace === '' || this.flagsService.hvdManagedNamespaceRoot) { + // TODO smoke test HVD flag here and add test + if (namespace === '' || this.flags.hvdManagedNamespaceRoot) { const i = state.indexOf(',ns='); if (i >= 0) { // ",ns=" is 4 characters @@ -151,12 +168,13 @@ export default Component.extend({ return this.cancelLogin(oidcWindow, ERROR_MISSING_PARAMS); } const adapter = this.store.adapterFor('auth-method'); - this.onNamespace(namespace); + // pass namespace from state back to AuthForm + this.args.onNamespace(namespace); let resp; // do the OIDC exchange, set the token on the parent component // and submit auth form try { - resp = yield adapter.exchangeOIDC(path, state, code); + resp = await adapter.exchangeOIDC(path, state, code); this.closeWindow(oidcWindow); } catch (e) { // If there was an error on Vault's end, close the popup @@ -165,51 +183,51 @@ export default Component.extend({ } const { mfa_requirement, client_token } = resp.auth; // onSubmit calls doSubmit in auth-form.js - yield this.onSubmit({ mfa_requirement }, null, client_token); - }), + await this.args.onSubmit({ mfa_requirement }, null, client_token); + }); async startOIDCAuth() { - this.onError(null); + this.args.onError(null); - await this.fetchRole(); + await this.fetchRole.perform(); const error = - this.role && !this.role.authUrl + this.fetchedRole && !this.fetchedRole.authUrl ? 'Missing auth_url. Please check that allowed_redirect_uris for the role include this mount path.' : this.errorMessage || null; if (error) { - this.onError(error); + this.args.onError(error); } else { - const win = this.getWindow(); + const win = window; const POPUP_WIDTH = 500; const POPUP_HEIGHT = 600; const left = win.screen.width / 2 - POPUP_WIDTH / 2; const top = win.screen.height / 2 - POPUP_HEIGHT / 2; const oidcWindow = win.open( - this.role.authUrl, + this.fetchedRole.authUrl, 'vaultOIDCWindow', `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},resizable,scrollbars=yes,top=${top},left=${left}` ); this.prepareForOIDC.perform(oidcWindow); } - }, + } - actions: { - onRoleChange(event) { - this.onRoleName(event.target.value); - debounce(this, 'fetchRole', 500); - }, - signIn(event) { - event.preventDefault(); + @action + onRoleInput(event) { + this.roleName = event.target.value; + this.fetchRole.perform(500); + } - if (this.isOIDC) { - this.startOIDCAuth(); - } else { - const { jwt, roleName: role } = this; - this.onSubmit({ role, jwt }); - } - }, - }, -}); + @action + signIn(event) { + event.preventDefault(); + + if (this.isOIDC) { + this.startOIDCAuth(); + } else { + this.args.onSubmit({ role: this.roleName, jwt: this.jwt }); + } + } +} diff --git a/ui/app/templates/components/auth-form.hbs b/ui/app/templates/components/auth-form.hbs index d86c0fd364..e36d738234 100644 --- a/ui/app/templates/components/auth-form.hbs +++ b/ui/app/templates/components/auth-form.hbs @@ -65,15 +65,11 @@ {{#if (or (eq this.selectedAuthBackend.type "jwt") (eq this.selectedAuthBackend.type "oidc"))}} +
@@ -16,7 +16,7 @@ id="role" class="input" type="text" - {{on "input" (action "onRoleChange")}} + {{on "input" this.onRoleInput}} data-test-role />
@@ -44,25 +44,26 @@
{{/unless}}
+ {{! Custom mount path input renders here }} {{yield}}
{{#if this.isOIDC}} {{else}} {{/if}} diff --git a/ui/tests/acceptance/auth-test.js b/ui/tests/acceptance/auth-test.js index 5f80fb61d0..6c315607c9 100644 --- a/ui/tests/acceptance/auth-test.js +++ b/ui/tests/acceptance/auth-test.js @@ -144,7 +144,7 @@ module('Acceptance | auth', function (hooks) { const { type } = backend; const expected = this.expected[type]; const isOidc = ['oidc', 'jwt'].includes(type); - assert.expect(isOidc ? 6 : 2); + assert.expect(isOidc ? 3 : 2); this.assertReq = (req) => { const body = type === 'token' ? req.requestHeaders : JSON.parse(req.requestBody); diff --git a/ui/tests/acceptance/auth/mfa-test.js b/ui/tests/acceptance/auth/mfa-test.js index 78f0804c2e..fc0a81aa8e 100644 --- a/ui/tests/acceptance/auth/mfa-test.js +++ b/ui/tests/acceptance/auth/mfa-test.js @@ -12,8 +12,7 @@ import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { MFA_SELECTORS } from 'vault/tests/helpers/mfa/mfa-selectors'; import { constraintId, setupTotpMfaResponse } from 'vault/tests/helpers/mfa/mfa-helpers'; import { fillInLoginFields } from 'vault/tests/helpers/auth/auth-helpers'; -import { callbackData, WindowStub } from 'vault/tests/helpers/oidc-window-stub'; -import sinon from 'sinon'; +import { callbackData, windowStub } from 'vault/tests/helpers/oidc-window-stub'; const ENT_ONLY = ['saml']; @@ -78,7 +77,7 @@ for (const method of AUTH_METHOD_TEST_CASES) { hooks.beforeEach(async function () { if (options?.hasPopupWindow) { - this.windowStub = sinon.stub(window, 'open').callsFake(() => new WindowStub()); + this.windowStub = windowStub(); } await visit('/vault/auth'); }); diff --git a/ui/tests/acceptance/jwt-auth-method-test.js b/ui/tests/acceptance/jwt-auth-method-test.js index 6a6d83d52d..51f42d0382 100644 --- a/ui/tests/acceptance/jwt-auth-method-test.js +++ b/ui/tests/acceptance/jwt-auth-method-test.js @@ -38,7 +38,7 @@ module('Acceptance | jwt auth method', function (hooks) { const { jwt, role } = JSON.parse(req.requestBody); assert.ok(true, 'request made to auth/jwt/login after submit'); assert.strictEqual(jwt, 'my-test-jwt-token', 'JWT token is sent in body'); - assert.strictEqual(role, undefined, 'role is not sent in body when not filled in'); + assert.strictEqual(role, null, 'role is not sent in body when not filled in'); req.passthrough(); }); await visit('/vault/auth'); diff --git a/ui/tests/acceptance/oidc-auth-method-test.js b/ui/tests/acceptance/oidc-auth-method-test.js index 79c10cc2c3..3c4d7bb22d 100644 --- a/ui/tests/acceptance/oidc-auth-method-test.js +++ b/ui/tests/acceptance/oidc-auth-method-test.js @@ -2,23 +2,24 @@ * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: BUSL-1.1 */ - import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; -import { click, fillIn, find, waitUntil } from '@ember/test-helpers'; +import { click, fillIn, find, visit, waitUntil } from '@ember/test-helpers'; import authPage from 'vault/tests/pages/auth'; import { setupMirage } from 'ember-cli-mirage/test-support'; -import { WindowStub, buildMessage } from 'vault/tests/helpers/oidc-window-stub'; +import { buildMessage, callbackData, windowStub } from 'vault/tests/helpers/oidc-window-stub'; import sinon from 'sinon'; import { Response } from 'miragejs'; import { setupTotpMfaResponse } from 'vault/tests/helpers/mfa/mfa-helpers'; +import { GENERAL } from '../helpers/general-selectors'; +import { ERROR_MISSING_PARAMS, ERROR_WINDOW_CLOSED } from 'vault/components/auth-jwt'; module('Acceptance | oidc auth method', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); hooks.beforeEach(function () { - this.openStub = sinon.stub(window, 'open').callsFake(() => new WindowStub()); + this.openStub = windowStub(); this.setupMocks = (assert) => { this.server.post('/auth/oidc/oidc/auth_url', () => ({ @@ -62,7 +63,6 @@ module('Acceptance | oidc auth method', function (hooks) { test('it should login with oidc when selected from auth methods dropdown', async function (assert) { assert.expect(1); - this.setupMocks(assert); await this.selectMethod('oidc'); @@ -110,9 +110,8 @@ module('Acceptance | oidc auth method', function (hooks) { }, 50); await click('[data-test-auth-submit]'); - await waitUntil(() => find('[data-test-user-menu-trigger]')); - await click('[data-test-user-menu-trigger]'); - await click('#logout'); + await waitUntil(() => find('[data-test-dashboard-card-header="Vault version"]')); + await visit('/vault/logout'); assert .dom('[data-test-select="auth-method"]') .hasValue('oidc', 'Previous auth method selected on logout'); @@ -167,4 +166,91 @@ module('Acceptance | oidc auth method', function (hooks) { await waitUntil(() => find('[data-test-mfa-form]')); assert.dom('[data-test-mfa-form]').exists('it renders TOTP MFA form'); }); + + test('auth service is called with client_token and cluster data', async function (assert) { + const authSpy = sinon.spy(this.owner.lookup('service:auth'), 'authenticate'); + this.setupMocks(); + await this.selectMethod('oidc'); + setTimeout(() => { + window.postMessage(buildMessage().data, window.origin); + }, 50); + await click('[data-test-auth-submit]'); + const [actual] = authSpy.lastCall.args; + const expected = { + // even though this is the oidc auth method, + // the callback has returned a token at this point of the login flow + // and so the backend is 'token' + backend: 'token', + clusterId: '1', + data: { + // data from oidc/callback url + mfa_requirement: undefined, + token: 'root', + }, + selectedAuth: 'oidc', + }; + + assert.propEqual( + actual, + expected, + `authenticate method called with correct args, ${JSON.stringify({ actual, expected })}` + ); + }); + + // test case for https://github.com/hashicorp/vault/issues/12436 + test('it should ignore messages sent from outside the app while waiting for oidc callback', async function (assert) { + assert.expect(3); // one for both message events (2) and one for callback request + this.setupMocks(); + this.server.get('/auth/foo/oidc/callback', () => { + // third assertion + assert.true(true, 'request is made to callback url'); + return { auth: { client_token: 'root' } }; + }); + + let count = 0; + const assertEvent = (event) => { + count++; + // we have to use the same event method, but need to update what it checks for depending on when it's called + const source = count === 1 ? 'miscellaneous-source' : 'oidc-callback'; + assert.strictEqual(event.data.source, source, `message event fires with source: ${event.data.source}`); + }; + window.addEventListener('message', assertEvent); + + await this.selectMethod('oidc'); + + setTimeout(async () => { + // first assertion + window.postMessage(callbackData({ source: 'miscellaneous-source' }), window.origin); + // second assertion + window.postMessage(callbackData({ source: 'oidc-callback' }), window.origin); + }, 50); + + await click('[data-test-auth-submit]'); + // cleanup + window.removeEventListener('message', assertEvent); + }); + + test('it shows error when message posted with state key, wrong params', async function (assert) { + this.setupMocks(); + await this.selectMethod('oidc'); + setTimeout(() => { + // callback params are missing "code" + window.postMessage({ source: 'oidc-callback', state: 'state', foo: 'bar' }, window.origin); + }, 50); + await click('[data-test-auth-submit]'); + assert + .dom(GENERAL.messageError) + .hasText(`Error ${ERROR_MISSING_PARAMS}`, 'displays error when missing params'); + }); + + test('it shows error when popup is closed', async function (assert) { + windowStub({ stub: this.openStub, popup: { closed: true, close: () => {} } }); + + this.setupMocks(); + await this.selectMethod('oidc'); + await click('[data-test-auth-submit]'); + assert + .dom(GENERAL.messageError) + .hasText(`Error ${ERROR_WINDOW_CLOSED}`, 'displays error when missing params'); + }); }); diff --git a/ui/tests/acceptance/saml-auth-method-test.js b/ui/tests/acceptance/saml-auth-method-test.js index 92c7123360..4fec6cb946 100644 --- a/ui/tests/acceptance/saml-auth-method-test.js +++ b/ui/tests/acceptance/saml-auth-method-test.js @@ -5,12 +5,11 @@ import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; -import sinon from 'sinon'; import { click, fillIn, find, waitUntil } from '@ember/test-helpers'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { Response } from 'miragejs'; import authPage from 'vault/tests/pages/auth'; -import { WindowStub } from 'vault/tests/helpers/oidc-window-stub'; +import { windowStub } from 'vault/tests/helpers/oidc-window-stub'; import { setupTotpMfaResponse } from 'vault/tests/helpers/mfa/mfa-helpers'; module('Acceptance | enterprise saml auth method', function (hooks) { @@ -18,7 +17,7 @@ module('Acceptance | enterprise saml auth method', function (hooks) { setupMirage(hooks); hooks.beforeEach(function () { - this.openStub = sinon.stub(window, 'open').callsFake(() => new WindowStub()); + this.openStub = windowStub(); this.server.put('/auth/saml/sso_service_url', () => ({ data: { sso_service_url: 'http://sso-url.hashicorp.com/service', diff --git a/ui/tests/helpers/oidc-window-stub.js b/ui/tests/helpers/oidc-window-stub.js index 78114cc166..baafb662c8 100644 --- a/ui/tests/helpers/oidc-window-stub.js +++ b/ui/tests/helpers/oidc-window-stub.js @@ -2,35 +2,19 @@ * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: BUSL-1.1 */ -import EmberObject from '@ember/object'; -import Evented from '@ember/object/evented'; +import sinon from 'sinon'; -export class WindowStub extends EventTarget { - close() { - this.dispatchEvent(new CustomEvent('close')); // Trigger 'close' event using CustomEvent - } -} +// suggestions for a custom popup +// passing { close: true } automatically closes popups opened from window.open() +// passing { closed: true } sets value on popup window +export const windowStub = ({ stub, popup } = {}) => { + // if already stubbed, don't re-stub + const openStub = stub ? stub : sinon.stub(window, 'open'); -// using Evented is deprecated, but it's the only way we can trigger a message that is trusted -// by calling window.trigger. Using dispatchEvent will always result in an untrusted event. -export const fakeWindow = EmberObject.extend(Evented, { - init() { - this._super(...arguments); - this.on('close', () => { - this.set('closed', true); - }); - }, - get screen() { - return { - height: 600, - width: 500, - }; - }, - origin: 'https://my-vault.com', - closed: false, - open() {}, - close() {}, -}); + const defaultPopup = { close: () => true }; + openStub.returns(popup || defaultPopup); + return openStub; +}; export const buildMessage = (opts) => ({ isTrusted: true, diff --git a/ui/tests/integration/components/auth-jwt-test.js b/ui/tests/integration/components/auth-jwt-test.js index 2f2415060b..df95ceec5f 100644 --- a/ui/tests/integration/components/auth-jwt-test.js +++ b/ui/tests/integration/components/auth-jwt-test.js @@ -12,49 +12,35 @@ import sinon from 'sinon'; import { resolve } from 'rsvp'; import { create } from 'ember-cli-page-object'; import form from '../../pages/components/auth-jwt'; -import { ERROR_WINDOW_CLOSED, ERROR_MISSING_PARAMS, ERROR_JWT_LOGIN } from 'vault/components/auth-jwt'; -import { fakeWindow, buildMessage } from 'vault/tests/helpers/oidc-window-stub'; +import { ERROR_JWT_LOGIN } from 'vault/components/auth-jwt'; +import { callbackData } from 'vault/tests/helpers/oidc-window-stub'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { overrideResponse } from 'vault/tests/helpers/stubs'; import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; const component = create(form); -const windows = []; -fakeWindow.reopen({ - init() { - this._super(...arguments); - windows.push(this); - }, - open() { - return fakeWindow.create(); - }, - close() { - windows.forEach((w) => w.trigger('close')); - }, -}); - -const renderIt = async (context, path = 'jwt') => { +const renderIt = async (context, { path = 'jwt', type = 'jwt' } = {}) => { const handler = (data, e) => { if (e && e.preventDefault) e.preventDefault(); return resolve(); }; - const fake = fakeWindow.create(); - context.set('window', fake); - context.set('handler', sinon.spy(handler)); - context.set('roleName', ''); - context.set('selectedAuthPath', path); + + context.error = ''; + context.handler = sinon.spy(handler); + context.roleName = ''; + context.selectedAuthPath = path; + context.selectedAuthType = type; await render(hbs` `); }; @@ -63,7 +49,8 @@ module('Integration | Component | auth jwt', function (hooks) { setupMirage(hooks); hooks.beforeEach(function () { - this.openSpy = sinon.spy(fakeWindow.proto(), 'open'); + this.windowStub = sinon.stub(window, 'open'); + this.owner.lookup('service:router').reopen({ urlFor() { return 'http://example.com'; @@ -86,8 +73,7 @@ module('Integration | Component | auth jwt', function (hooks) { }); hooks.afterEach(function () { - this.openSpy.restore(); - this.server.shutdown(); + this.windowStub.restore(); }); test('it renders the yield', async function (assert) { @@ -95,6 +81,32 @@ module('Integration | Component | auth jwt', function (hooks) { assert.strictEqual(component.yieldContent, 'Hello!', 'yields properly'); }); + test('it fetches auth_url when type changes', async function (assert) { + assert.expect(2); + await renderIt(this, { path: '', type: 'jwt' }); + // auth_url is requested on initial render so stubbing after rendering the component + // to test auth_url is called when the type changes + this.server.post('/auth/:path/oidc/auth_url', (_, request) => { + assert.true(true, 'request is made to auth_url'); + const { path } = request.params; + assert.strictEqual(path, 'oidc', `path param is updated type: ${path}`); + return { + data: { auth_url: '' }, + }; + }); + this.set('selectedAuthType', 'oidc'); + await settled(); + }); + + test('if auth path exists it uses it to build url request instead of type', async function (assert) { + this.server.post('/auth/:path/oidc/auth_url', (_, request) => { + const { path } = request.params; + assert.strictEqual(path, 'custom-jwt', `path param is custom path: ${path}`); + return {}; + }); + await renderIt(this, { path: 'custom-jwt' }); + }); + test('jwt: it renders and makes auth_url requests', async function (assert) { let postCount = 0; this.server.post('/auth/:path/oidc/auth_url', (_, request) => { @@ -122,9 +134,6 @@ module('Integration | Component | auth jwt', function (hooks) { }); test('oidc: test role: it renders', async function (assert) { - // setting the path also fires off a request to auth_url but this happens inconsistently in tests - // setting here so it doesn't affect the postCount because it's not relevant to what's being tested - this.set('selectedAuthPath', 'foo'); let postCount = 0; this.server.post('/auth/:path/oidc/auth_url', (_, request) => { postCount++; @@ -134,7 +143,7 @@ module('Integration | Component | auth jwt', function (hooks) { data: { auth_url }, }; }); - await renderIt(this); + await renderIt(this, { path: 'foo', type: 'oidc' }); await settled(); await fillIn(AUTH_FORM.roleInput, 'test'); assert @@ -149,8 +158,7 @@ module('Integration | Component | auth jwt', function (hooks) { test('oidc: it fetches auth_url when path changes', async function (assert) { assert.expect(2); - this.set('selectedAuthPath', 'foo'); - await renderIt(this); + await renderIt(this, { path: 'oidc', type: 'oidc' }); // auth_url is requested on initial render so stubbing after rendering the component // to test auth_url is called when the :path changes this.server.post('/auth/:path/oidc/auth_url', (_, request) => { @@ -160,113 +168,87 @@ module('Integration | Component | auth jwt', function (hooks) { data: { auth_url: '' }, }; }); + this.set('selectedAuthPath', 'foo'); await settled(); }); test('oidc: it calls window.open popup window on login', async function (assert) { - await renderIt(this); - this.set('selectedAuthPath', 'foo'); + sinon.replaceGetter(window, 'screen', () => ({ height: 600, width: 500 })); + await renderIt(this, { path: 'foo', type: 'oidc' }); await component.role('test'); component.login(); await waitUntil(() => { - return this.openSpy.calledOnce; + return this.windowStub.calledOnce; }); - cancelTimers(); - await settled(); - - const call = this.openSpy.getCall(0); + const call = this.windowStub.lastCall; assert.deepEqual( call.args, ['http://example.com', 'vaultOIDCWindow', 'width=500,height=600,resizable,scrollbars=yes,top=0,left=0'], 'called with expected args' ); + sinon.restore(); }); - test('oidc: it calls error handler when popup is closed', async function (assert) { - await renderIt(this); - this.set('selectedAuthPath', 'foo'); - await component.role('test'); - component.login(); - await waitUntil(() => { - return this.openSpy.calledOnce; - }); - this.window.close(); - await settled(); - assert.strictEqual(this.error, ERROR_WINDOW_CLOSED, 'calls onError with error string'); - }); - - test('oidc: shows error when message posted with state key, wrong params', async function (assert) { - await renderIt(this); - this.set('selectedAuthPath', 'foo'); - await component.role('test'); - component.login(); - await waitUntil(() => { - return this.openSpy.calledOnce; - }); - this.window.trigger( - 'message', - buildMessage({ data: { source: 'oidc-callback', state: 'state', foo: 'bar' } }) - ); - cancelTimers(); - await settled(); - - assert.strictEqual(this.error, ERROR_MISSING_PARAMS, 'calls onError with params missing error'); - }); - - test('oidc: storage event fires with state key, correct params', async function (assert) { - await renderIt(this); - this.set('selectedAuthPath', 'foo'); - await component.role('test'); - component.login(); - await waitUntil(() => { - return this.openSpy.calledOnce; - }); - this.window.trigger('message', buildMessage()); - await settled(); - const [callbackData, , token] = this.handler.lastCall.args; - assert.propEqual( - callbackData, - { mfa_requirement: undefined }, - 'mfa_requirement is undefined if not returned by response' - ); - assert.strictEqual(token, 'token', 'calls the onSubmit handler with token'); - }); - + // not the greatest test because this test would also pass if the origin matched + // because event.isTrusted is always false (another condition checked by the component) test('oidc: fails silently when event origin does not match window origin', async function (assert) { - await renderIt(this); - this.set('selectedAuthPath', 'foo'); + assert.expect(3); + // prevent test incorrectly passing because the event isn't triggered at all + // by also asserting that the message event fires + const message = { data: callbackData(), origin: 'http://hackerz.com' }; + const assertEvent = (event) => { + assert.propEqual(event.data, message.data, 'message has expected data'); + assert.strictEqual(event.origin, message.origin, 'message has expected origin'); + }; + window.addEventListener('message', assertEvent); + + await renderIt(this, { path: 'foo', type: 'oidc' }); await component.role('test'); component.login(); await waitUntil(() => { - return this.openSpy.calledOnce; + return this.windowStub.calledOnce; }); - this.window.trigger('message', buildMessage({ origin: 'http://hackerz.com' })); + window.dispatchEvent(new MessageEvent('message', message)); cancelTimers(); await settled(); - assert.false(this.handler.called, 'should not call the submit handler'); + + // Cleanup + window.removeEventListener('message', assertEvent); }); test('oidc: fails silently when event is not trusted', async function (assert) { - await renderIt(this); - this.set('selectedAuthPath', 'foo'); + assert.expect(2); + // prevent test incorrectly passing because the event isn't triggered at all + // by also asserting that the message event fires + const messageData = callbackData(); + const assertEvent = (event) => { + assert.propEqual(event.data, messageData, 'message event fires'); + }; + window.addEventListener('message', assertEvent); + + await renderIt(this, { path: 'foo', type: 'oidc' }); await component.role('test'); component.login(); await waitUntil(() => { - return this.openSpy.calledOnce; + return this.windowStub.calledOnce; }); - this.window.trigger('message', buildMessage({ isTrusted: false })); + // mocking a message event is always untrusted (there is no way to override isTrusted on the window object) + window.dispatchEvent(new MessageEvent('message', { data: messageData })); + cancelTimers(); await settled(); - assert.false(this.handler.called, 'should not call the submit handler'); + + // Cleanup + window.removeEventListener('message', assertEvent); }); test('oidc: it should trigger error callback when role is not found', async function (assert) { - await renderIt(this, 'oidc'); + await renderIt(this, { path: 'oidc', type: 'oidc' }); await component.role('foo'); await component.login(); assert.strictEqual( @@ -277,7 +259,7 @@ module('Integration | Component | auth jwt', function (hooks) { }); test('oidc: it should trigger error callback when role is returned without auth_url', async function (assert) { - await renderIt(this, 'oidc'); + await renderIt(this, { path: 'oidc', type: 'oidc' }); await component.role('bar'); await component.login(); assert.strictEqual( diff --git a/ui/tests/unit/components/auth-jwt-test.js b/ui/tests/unit/components/auth-jwt-test.js deleted file mode 100644 index 41ba7e3d65..0000000000 --- a/ui/tests/unit/components/auth-jwt-test.js +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { module, test } from 'qunit'; -import { setupTest } from 'ember-qunit'; -import { settled } from '@ember/test-helpers'; -import EmberObject from '@ember/object'; -import Evented from '@ember/object/evented'; -import sinon from 'sinon'; -import { _cancelTimers as cancelTimers } from '@ember/runloop'; - -const mockWindow = EmberObject.extend(Evented, { - origin: 'http://localhost:4200', - close: () => {}, -}); - -module('Unit | Component | auth-jwt', function (hooks) { - setupTest(hooks); - - hooks.beforeEach(function () { - this.component = this.owner.lookup('component:auth-jwt'); - this.component.set('window', mockWindow.create()); - this.errorSpy = sinon.spy(this.component, 'handleOIDCError'); - }); - - test('it should ignore messages from cross origin windows while waiting for oidc callback', async function (assert) { - assert.expect(2); - this.component.prepareForOIDC.perform(mockWindow.create()); - this.component.window.trigger('message', { origin: 'http://anotherdomain.com', isTrusted: true }); - - assert.true( - this.errorSpy.notCalled, - 'Error handler not triggered while waiting for oidc callback message' - ); - assert.strictEqual(this.component.exchangeOIDC.performCount, 0, 'exchangeOIDC method not fired'); - - cancelTimers(); - await settled(); - }); - - test('it should ignore untrusted messages while waiting for oidc callback', async function (assert) { - assert.expect(2); - this.component.prepareForOIDC.perform(mockWindow.create()); - this.component.window.trigger('message', { origin: 'http://localhost:4200', isTrusted: false }); - assert.ok(this.errorSpy.notCalled, 'Error handler not triggered while waiting for oidc callback message'); - assert.strictEqual(this.component.exchangeOIDC.performCount, 0, 'exchangeOIDC method not fired'); - - cancelTimers(); - await settled(); - }); - - // test case for https://github.com/hashicorp/vault/issues/12436 - test('it should ignore messages sent from outside the app while waiting for oidc callback', async function (assert) { - assert.expect(2); - this.component.prepareForOIDC.perform(mockWindow.create()); - const message = { - origin: 'http://localhost:4200', - isTrusted: true, - data: { - namespace: 'foobar', - path: '/foo/bar', - state: 'authorized', - code: 204, - }, - }; - - this.component.window.trigger('message', message); - message.data.source = 'foo-bar'; - this.component.window.trigger('message', message); - message.data.source = 'oidc-callback'; - this.component.window.trigger('message', message); - - assert.true( - this.errorSpy.notCalled, - 'Error handler not triggered while waiting for oidc callback message' - ); - assert.strictEqual( - this.component.exchangeOIDC.performCount, - 1, - 'exchangeOIDC method fires when oidc callback message is received' - ); - - cancelTimers(); - await settled(); - }); -});