mirror of
https://github.com/hashicorp/vault.git
synced 2026-05-05 12:26:34 +02:00
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:
parent
426088ddfb
commit
784d8ff581
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user