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
This commit is contained in:
claire bontempo 2025-04-02 17:10:49 -07:00 committed by GitHub
parent 426088ddfb
commit 784d8ff581
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 311 additions and 334 deletions

View File

@ -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 `<state_id>,ns=<namespace>`. 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 });
}
}
}

View File

@ -65,15 +65,11 @@
{{#if (or (eq this.selectedAuthBackend.type "jwt") (eq this.selectedAuthBackend.type "oidc"))}}
<AuthJwt
@onError={{action "handleError"}}
@onLoading={{action (mut this.isLoading)}}
@namespace={{this.namespace}}
@onNamespace={{action (mut this.namespace)}}
@onSubmit={{action "doSubmit"}}
@onRoleName={{action (mut this.roleName)}}
@roleName={{this.roleName}}
@selectedAuthType={{this.selectedAuthBackend.type}}
@selectedAuthPath={{or this.customPath this.selectedAuthBackend.id}}
@disabled={{or this.authIsRunning this.isLoading}}
>
<AuthFormOptions
@customPath={{this.customPath}}

View File

@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
}}
<form id="auth-form" {{on "submit" (action "signIn")}}>
<form id="auth-form" {{on "submit" this.signIn}} {{did-update this.checkArgUpdate @selectedAuthPath @selectedAuthType}}>
<div class="field">
<label for="role" class="is-label">Role</label>
<div class="control">
@ -16,7 +16,7 @@
id="role"
class="input"
type="text"
{{on "input" (action "onRoleChange")}}
{{on "input" this.onRoleInput}}
data-test-role
/>
</div>
@ -44,25 +44,26 @@
</div>
{{/unless}}
<div data-test-yield-content>
{{! Custom mount path input renders here }}
{{yield}}
</div>
{{#if this.isOIDC}}
<Hds::Button
@text={{concat "Sign in with " (or this.role.providerName "OIDC Provider")}}
@icon={{if @disabled "loading" this.role.providerIcon}}
@text="Sign in with {{or this.fetchedRole.providerName 'OIDC Provider'}}"
@icon={{if this.tasksAreRunning "loading" this.fetchedRole.providerIcon}}
data-test-auth-submit
type="submit"
disabled={{@disabled}}
disabled={{this.tasksAreRunning}}
id="auth-submit"
/>
{{else}}
<Hds::Button
@text="Sign in"
@icon={{if @disabled "loading"}}
@icon={{if this.tasksAreRunning "loading"}}
data-test-auth-submit
type="submit"
disabled={{@disabled}}
disabled={{this.tasksAreRunning}}
id="auth-submit"
/>
{{/if}}

View File

@ -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);

View File

@ -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');
});

View File

@ -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');

View File

@ -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');
});
});

View File

@ -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',

View File

@ -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,

View File

@ -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`
<AuthJwt
@window={{this.window}}
@roleName={{this.roleName}}
@selectedAuthPath={{this.selectedAuthPath}}
@onError={{action (mut this.error)}}
@onLoading={{action (mut this.isLoading)}}
@onNamespace={{action (mut this.namespace)}}
@onSelectedAuth={{action (mut this.selectedAuth)}}
@onSubmit={{action this.handler}}
@onRoleName={{action (mut this.roleName)}}
@selectedAuthType={{this.selectedAuthType}}
@onError={{fn (mut this.error)}}
@onNamespace={{fn (mut this.namespace)}}
@onSelectedAuth={{fn (mut this.selectedAuth)}}
@onSubmit={{this.handler}}
@onRoleName={{fn (mut this.roleName)}}
/>
`);
};
@ -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(

View File

@ -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();
});
});