diff --git a/ui/app/components/auth/form/base.ts b/ui/app/components/auth/form/base.ts index d44513a32c..24c93818c6 100644 --- a/ui/app/components/auth/form/base.ts +++ b/ui/app/components/auth/form/base.ts @@ -78,7 +78,7 @@ export default class AuthBase extends Component { this.args.onSuccess(authResponse); } - onError(error: Error) { + onError(error: Error | string) { if (!this.auth.mfaErrors) { const errorMessage = `Authentication failed: ${this.auth.handleError(error)}`; this.args.onError(errorMessage); diff --git a/ui/app/components/auth/form/oidc-jwt.hbs b/ui/app/components/auth/form/oidc-jwt.hbs new file mode 100644 index 0000000000..3b1cfd7944 --- /dev/null +++ b/ui/app/components/auth/form/oidc-jwt.hbs @@ -0,0 +1,45 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
level since "path" and "namespace" are yielded }} + {{on "input" this.updateFormData}} + {{on "submit" this.onSubmit}} + data-test-auth-form={{@authType}} +> + + {{yield to="namespace"}} + +
+ {{yield to="back"}} + {{yield to="authSelectOptions"}} + {{yield to="error"}} + + + + {{#unless this.isOIDC}} + + JWT Token + + {{/unless}} + + {{yield to="advancedSettings"}} + + + + {{yield to="footer"}} +
+
\ No newline at end of file diff --git a/ui/app/components/auth/form/oidc-jwt.js b/ui/app/components/auth/form/oidc-jwt.js deleted file mode 100644 index 516d1986f8..0000000000 --- a/ui/app/components/auth/form/oidc-jwt.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import AuthBase from './base'; - -/** - * @module Auth::Form::OidcJwt - * see Auth::Base - * - * OIDC can be configured at 'jwt' or 'oidc', see https://developer.hashicorp.com/vault/docs/auth/jwt - * we use the same template because displaying the JWT token input depends on the error message returned when fetching - * the role - */ - -export default class AuthFormOidcJwt extends AuthBase { - loginFields = [ - { - name: 'role', - helperText: 'Vault will use the default role to sign in if this field is left blank.', - }, - ]; -} diff --git a/ui/app/components/auth/form/oidc-jwt.ts b/ui/app/components/auth/form/oidc-jwt.ts new file mode 100644 index 0000000000..36c7b8be94 --- /dev/null +++ b/ui/app/components/auth/form/oidc-jwt.ts @@ -0,0 +1,280 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import AuthBase from './base'; +import Ember from 'ember'; +import { tracked } from '@glimmer/tracking'; +import { service } from '@ember/service'; +import { restartableTask, task, timeout, waitForEvent } from 'ember-concurrency'; +import { action } from '@ember/object'; +import { sanitizePath } from 'core/utils/sanitize-path'; +import { waitFor } from '@ember/test-waiters'; +import errorMessage from 'vault/utils/error-message'; + +import type AdapterError from 'vault/@ember-data/adapter/error'; +import type AuthService from 'vault/vault/services/auth'; +import type FlagsService from 'vault/services/flags'; +import type RoleJwtModel from 'vault/models/role-jwt'; +import type Store from '@ember-data/store'; +import type { HTMLElementEvent } from 'vault/forms'; + +/** + * @module Auth::Form::OidcJwt + * see Auth::Base + * + * OIDC can be configured at 'jwt' or 'oidc', see https://developer.hashicorp.com/vault/docs/auth/jwt + * we use the same template because displaying the JWT token input depends on the error message + * returned when fetching :path/oidc/auth_url + */ + +interface JwtLoginData { + namespace: string; + path?: string; + role?: string; + jwt?: string; +} + +interface OidcLoginData { + token: string; + mfa_requirement?: object; +} + +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.'; +const ERROR_MISSING_PARAMS = + 'The callback from the provider did not supply all of the required parameters. Please click Sign In to try again. If the problem persists, you may want to contact your administrator.'; +const ERROR_JWT_LOGIN = 'OIDC login is not configured for this mount'; +export { ERROR_WINDOW_CLOSED, ERROR_MISSING_PARAMS, ERROR_JWT_LOGIN }; + +export default class AuthFormOidcJwt extends AuthBase { + @service declare readonly auth: AuthService; + @service declare readonly flags: FlagsService; + @service declare readonly store: Store; + + loginFields = [ + { + name: 'role', + helperText: 'Vault will use the default role to sign in if this field is left blank.', + }, + ]; + + // set by form inputs + _formData: FormData | null = null; + + // set during auth prep and login workflow + @tracked fetchedRole: RoleJwtModel | null = null; + @tracked errorMessage = ''; + @tracked isOIDC = true; + + get tasksAreRunning() { + return this.prepareForOIDC.isRunning || this.exchangeOIDC.isRunning; + } + + get icon() { + return this?.fetchedRole?.providerIcon || ''; + } + + get providerName() { + return `with ${this?.fetchedRole?.providerName || 'OIDC Provider'}`; + } + + @action + initializeFormData(element: HTMLFormElement) { + this._formData = new FormData(element); + this.fetchRole.perform(); + } + + @action + updateFormData(event: HTMLElementEvent) { + const { name, value } = event.target; + this._formData?.set(name, value); + + // only fetch role if the following inputs have changed + if (['path', 'role', 'namespace'].includes(name)) { + this.fetchRole.perform(500); + } + } + + fetchRole = restartableTask(async (wait = 0) => { + // task is restartable so if the user starts typing again, + // it will cancel and restart from the beginning. + if (wait) await timeout(wait); + + const namespace = this._formData?.get('namespace') || ''; + const path = sanitizePath(this._formData?.get('path')) || this.args.authType; + const role = this._formData?.get('role') || ''; + const id = JSON.stringify([path, role]); + + // reset state + this.fetchedRole = null; + this.errorMessage = ''; + + try { + this.fetchedRole = await this.store.findRecord('role-jwt', id, { + adapterOptions: { namespace }, + }); + this.isOIDC = true; + } catch (e) { + const { httpStatus } = e as AdapterError; + const message = errorMessage(e); + // track errors but they only display on submit + this.errorMessage = + httpStatus === 400 ? 'Invalid role. Please try again.' : `Error fetching role: ${message}`; + // if the mount is configured for JWT authentication via static keys, JWKS, or OIDC discovery + // this specific error is returned. Flip the isOIDC boolean accordingly, otherwise assume OIDC. + this.isOIDC = message !== ERROR_JWT_LOGIN; + } + }); + + login = task( + waitFor(async (submitData) => { + if (this.isOIDC) { + this.startOIDCAuth(); + } else { + this.continueLogin(submitData); + } + }) + ); + + async continueLogin(data: JwtLoginData | OidcLoginData) { + try { + // OIDC callback returns a token so authenticate with that + const backend = this.isOIDC && 'token' in data ? 'token' : this.args.authType; + + const authResponse = await this.auth.authenticate({ + clusterId: this.args.cluster.id, + backend, + data, + selectedAuth: this.args.authType, + }); + // responsible for redirect after auth data is persisted + this.handleAuthResponse(authResponse); + } catch (error) { + this.onError(error as Error); + } + } + + // * OIDC AUTH PART 1 + // 1. request oidc/auth_url to check for config errors, if none continue + // 2. open popup window at auth_url + async startOIDCAuth() { + await this.fetchRole.perform(); + + const error = + 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); + } else { + 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.fetchedRole?.authUrl, + 'vaultOIDCWindow', + `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},resizable,scrollbars=yes,top=${top},left=${left}` + ); + + this.prepareForOIDC.perform(oidcWindow); + } + } + + // * OIDC AUTH PART 2 + // 3. watch popups for premature closure + // 4. wait message event from window.postMessage() in oidc-callback route + prepareForOIDC = task(async (oidcWindow) => { + // 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 + const thisWindow = window; + + // start watching the popup window and the current one + this.watchPopup.perform(oidcWindow); + this.watchCurrent.perform(oidcWindow); + // eslint-disable-next-line no-constant-condition + while (true) { + // wait for message posted from oidc callback, see issue https://github.com/hashicorp/vault/issues/12436 + // ensure that postMessage event is from expected source + const event = (await waitForEvent(thisWindow, 'message')) as unknown as MessageEvent; + if (event.origin === thisWindow.origin && event.isTrusted && event.data.source === 'oidc-callback') { + // event.data are params from the oidc callback url parsed by getParamsForCallback in the oidc-callback route + return this.exchangeOIDC.perform(event.data, oidcWindow); + } + } + }); + + // * OIDC AUTH PART 3 + // 5. check parsed url for expected state params + // 6. if successful, request client_token from oidc/callback + // 7. close popups and continue login with client_token + exchangeOIDC = task(async (oidcState, oidcWindow) => { + if (oidcState === null || oidcState === undefined) { + return; + } + + const { path, state, code } = oidcState; + if (!path || !state || !code) { + return this.cancelLogin(oidcWindow, ERROR_MISSING_PARAMS); + } + + // TODO CMB - when wiring up components check if this is still necessary + // pass namespace from state back to AuthForm + // this.args.onNamespace(namespace); + + let resp; + // do the OIDC exchange, set the token and continue login flow + try { + const adapter = this.store.adapterFor('auth-method'); + resp = await adapter.exchangeOIDC(path, state, code); + this.closeWindow(oidcWindow); + } catch (e) { + // If there was an error on Vault's end, close the popup + // and show the error on the login screen + return this.cancelLogin(oidcWindow, errorMessage(e)); + } + + const { client_token, mfa_requirement } = resp.auth; + const oidcExchangeData = { token: client_token, mfa_requirement }; + await this.continueLogin(oidcExchangeData); + }); + + // MANAGE POPUPS + watchPopup = task(async (oidcWindow) => { + // eslint-disable-next-line no-constant-condition + while (true) { + const WAIT_TIME = Ember.testing ? 50 : 500; + + await timeout(WAIT_TIME); + if (!oidcWindow || oidcWindow.closed) { + return this.handleOIDCError(ERROR_WINDOW_CLOSED); + } + } + }); + + watchCurrent = task(async (oidcWindow) => { + // when user is about to change pages, close the popup window + await waitForEvent(window, 'beforeunload'); + oidcWindow.close(); + }); + + cancelLogin(oidcWindow: Window, errorMessage: string) { + this.closeWindow(oidcWindow); + this.handleOIDCError(errorMessage); + } + + closeWindow(oidcWindow: Window) { + this.watchPopup.cancelAll(); + this.watchCurrent.cancelAll(); + oidcWindow.close(); + } + + handleOIDCError(err: string) { + this.prepareForOIDC.cancelAll(); + this.onError(err); + } +} diff --git a/ui/tests/integration/components/auth/form-template-test.js b/ui/tests/integration/components/auth/form-template-test.js index 74d8c5f207..df133430b8 100644 --- a/ui/tests/integration/components/auth/form-template-test.js +++ b/ui/tests/integration/components/auth/form-template-test.js @@ -18,6 +18,8 @@ import { ENTERPRISE_LOGIN_METHODS, } from 'vault/utils/supported-login-methods'; import { Response } from 'miragejs'; +import { overrideResponse } from 'vault/tests/helpers/stubs'; +import { ERROR_JWT_LOGIN } from 'vault/components/auth/form/oidc-jwt'; module('Integration | Component | auth | form template', function (hooks) { setupRenderingTest(hooks); @@ -45,6 +47,7 @@ module('Integration | Component | auth | form template', function (hooks) { }; }); + // test to select each method is in "ent" module to include enterprise methods test('it selects token by default', async function (assert) { await this.renderComponent(); assert.dom(GENERAL.selectByAttr('auth type')).hasValue('token'); @@ -424,4 +427,63 @@ module('Integration | Component | auth | form template', function (hooks) { assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist('dropdown does not render'); }); }); + + // AUTH METHOD SPECIFIC TESTS + // since the template yields each auth
some assertions are best done here instead of + // in the corresponding the Auth::Form:: integration tests + module('oidc-jwt', function (hooks) { + hooks.beforeEach(async function () { + this.store = this.owner.lookup('service:store'); + this.routerStub = sinon.stub(this.owner.lookup('service:router'), 'urlFor').returns('123-example.com'); + }); + + test('it re-requests the auth_url when authType changes', async function (assert) { + assert.expect(2); // auth_url should be hit twice, one for each type selection + let expectedType = 'oidc'; + this.server.post(`/auth/:path/oidc/auth_url`, (_, req) => { + assert.strictEqual( + req.params.path, + expectedType, + `it makes request to auth_url for selected type: ${expectedType}` + ); + return { data: { auth_url: '123-example.com' } }; + }); + await this.renderComponent(); + // auth_url should be requested once when "oidc" is selected + await fillIn(GENERAL.selectByAttr('auth type'), 'oidc'); + // auth_url should be requested again when "jwt" is selected + expectedType = 'jwt'; + await fillIn(GENERAL.selectByAttr('auth type'), 'jwt'); + }); + + // for simplicity the auth types are configured as their namesake but type isn't relevant. + // these tests assert that CONFIG changes from OIDC -> JWT render correctly and vice versa + // so the order the requests are hit is what matters. + test('"OIDC" to "JWT" configuration: it updates the form when the auth_url response changes', async function (assert) { + this.server.post(`/auth/oidc/oidc/auth_url`, () => ({ data: { auth_url: '123-example.com' } })); // this return means mount is configured as oidc + this.server.post(`/auth/jwt/oidc/auth_url`, () => overrideResponse(400, { errors: [ERROR_JWT_LOGIN] })); // this return means the mount is configured as jwt + await this.renderComponent(); + + // select mount configured for OIDC first + await fillIn(GENERAL.selectByAttr('auth type'), 'oidc'); + assert.dom(GENERAL.inputByAttr('jwt')).doesNotExist(); + // then select mount configured for JWT + await fillIn(GENERAL.selectByAttr('auth type'), 'jwt'); + assert.dom(GENERAL.inputByAttr('jwt')).exists(); + }); + + test('"JWT" to "OIDC" configuration: it updates the form when the auth_url response changes', async function (assert) { + this.server.post(`/auth/jwt/oidc/auth_url`, () => overrideResponse(400, { errors: [ERROR_JWT_LOGIN] })); // this return means the mount is configured as jwt + this.server.post(`/auth/oidc/oidc/auth_url`, () => ({ data: { auth_url: '123-example.com' } })); // this return means mount is configured as oidc + await this.renderComponent(); + + // select mount configured for JWT first + await fillIn(GENERAL.selectByAttr('auth type'), 'jwt'); + assert.dom(GENERAL.inputByAttr('jwt')).exists(); + + // then select mount configured for OIDC + await fillIn(GENERAL.selectByAttr('auth type'), 'oidc'); + assert.dom(GENERAL.inputByAttr('jwt')).doesNotExist(); + }); + }); }); diff --git a/ui/tests/integration/components/auth/form/oidc-jwt-test.js b/ui/tests/integration/components/auth/form/oidc-jwt-test.js index 86938cc213..31ab4fc29a 100644 --- a/ui/tests/integration/components/auth/form/oidc-jwt-test.js +++ b/ui/tests/integration/components/auth/form/oidc-jwt-test.js @@ -6,23 +6,305 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import hbs from 'htmlbars-inline-precompile'; -import { find, render } from '@ember/test-helpers'; -import sinon from 'sinon'; -import { setupMirage } from 'ember-cli-mirage/test-support'; -import testHelper from './test-helper'; +import { click, fillIn, find, render, settled, waitUntil } from '@ember/test-helpers'; +import { _cancelTimers as cancelTimers } from '@ember/runloop'; +import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; +import { callbackData } from 'vault/tests/helpers/oidc-window-stub'; +import { ERROR_JWT_LOGIN } from 'vault/components/auth/form/oidc-jwt'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import { overrideResponse } from 'vault/tests/helpers/stubs'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import * as parseURL from 'core/utils/parse-url'; +import sinon from 'sinon'; +import testHelper from './test-helper'; + +/* +The OIDC and JWT mounts call the same endpoint (see docs https://developer.hashicorp.com/vault/docs/auth/jwt ) +because of this the same component is used to render both method types. +The module name refers to the selected auth type and there is test coverage in each to cover +1. auth url request (fetching role) is made when expected +2. JWT token login flow +2. OIDC exchange/login situation +*/ + +const authUrlRequestTests = (test) => { + test('it requests auth_url when it initially renders', async function (assert) { + assert.expect(2); + this.server.post(`/auth/${this.authType}/oidc/auth_url`, (_, req) => { + const { role } = JSON.parse(req.requestBody); + assert.true(true, 'it makes request to auth_url'); + assert.strictEqual(role, '', 'role is empty'); + return { data: { auth_url: '123-example.com' } }; + }); + await this.renderComponent(); + }); + + test('it re-requests auth_url when input changes: role', async function (assert) { + // request assertions should be hit twice, once on initial render and again on role change + assert.expect(4); + let count = 0; + this.server.post(`/auth/${this.authType}/oidc/auth_url`, (_, req) => { + count++; + const { role } = JSON.parse(req.requestBody); + assert.true(true, 'it makes request to auth_url'); + const expectedRole = count === 1 ? '' : 'myrole'; + assert.strictEqual(role, expectedRole, 'payload has expected role'); + return { data: { auth_url: '123-example.com' } }; + }); + await this.renderComponent({ yieldBlock: true }); + await fillIn(GENERAL.inputByAttr('role'), 'myrole'); + }); + + test('it re-requests auth_url when input changes: path', async function (assert) { + assert.expect(2); + let firstRequest, secondRequest; + this.server.post(`/auth/${this.authType}/oidc/auth_url`, () => { + firstRequest = true; + return { data: { auth_url: '123-example.com' } }; + }); + this.server.post(`/auth/mypath/oidc/auth_url`, () => { + secondRequest = true; + return { data: { auth_url: '123-example.com' } }; + }); + await this.renderComponent({ yieldBlock: true }); + await fillIn(GENERAL.inputByAttr('path'), 'mypath'); + + // asserting this way instead of inside the request to ensure each endpoint is hit. + // (asserting within each request would rely on assertion count and could result in a false positive) + assert.true(firstRequest, 'it makes FIRST request to auth_url with default path'); + assert.true(secondRequest, 'it makes SECOND request to auth_url with custom path'); + }); + + test('it re-requests auth_url when input changes: namespace', async function (assert) { + // request assert should be hit twice, once on initial render and again on namespace change + assert.expect(2); + this.server.post(`/auth/${this.authType}/oidc/auth_url`, () => { + assert.true(true, 'it makes request to auth_url'); + return { data: { auth_url: '123-example.com' } }; + }); + await render(hbs` + + <:namespace> + + + + `); + await fillIn(GENERAL.inputByAttr('namespace'), 'mynamespace'); + }); +}; + +const jwtLoginTests = (test) => { + test('it renders sign in button text', async function (assert) { + await this.renderComponent(); + assert.dom(AUTH_FORM.login).hasText('Sign in'); + }); + + test('it submits form data with defaults', async function (assert) { + await this.renderComponent(); + + await fillIn(GENERAL.inputByAttr('role'), 'some-dev'); + await fillIn(GENERAL.inputByAttr('jwt'), 'some-jwt-token'); + + await click(AUTH_FORM.login); + const [actual] = this.authenticateStub.lastCall.args; + assert.propEqual( + actual.data, + this.expectedSubmit.default, + 'auth service "authenticate" method is called with form data' + ); + }); + + test('it submits form data from yielded inputs', async function (assert) { + await this.renderComponent({ yieldBlock: true }); + await fillIn(GENERAL.inputByAttr('role'), 'some-dev'); + await fillIn(GENERAL.inputByAttr('jwt'), 'some-jwt-token'); + await fillIn(GENERAL.inputByAttr('path'), `custom-${this.authType}`); + + await click(AUTH_FORM.login); + const [actual] = this.authenticateStub.lastCall.args; + assert.propEqual( + actual.data, + this.expectedSubmit.custom, + 'auth service "authenticate" method is called with yielded form data' + ); + }); + + test('it does NOT re-request the auth_url when jwt token changes', async function (assert) { + assert.expect(1); // the assertion in the stubbed request should not be hit + await this.renderComponent(); + this.server.post(`/auth/${this.authType}/oidc/auth_url`, () => { + // we can't throw an error here because the component catches error handling. + // setting this assertion to fail intentionally because this request should not have been made + assert.false(true, 'request made to auth_url and it should not have been requested'); + }); + await fillIn(GENERAL.inputByAttr('jwt'), 'mytoken'); + assert.dom(GENERAL.inputByAttr('jwt')).hasValue('mytoken'); + }); +}; + +const oidcLoginTests = (test) => { + test('it renders fields', async function (assert) { + await this.renderComponent(); + assert.dom(AUTH_FORM.authForm(this.authType)).exists(`${this.authType}: it renders form component`); + assert.dom(AUTH_FORM.login).hasText('Sign in with OIDC Provider'); + this.expectedFields.forEach((field) => { + assert.dom(GENERAL.inputByAttr(field)).exists(`${this.authType}: it renders ${field}`); + }); + }); + + test('it renders provider icon and name', async function (assert) { + const parseURLStub = sinon.stub(parseURL, 'default').returns({ hostname: 'auth0.com' }); + this.server.post(`/auth/${this.authType}/oidc/auth_url`, () => { + return { data: { auth_url: '123.auth0.com' } }; + }); + await this.renderComponent(); + assert.dom(AUTH_FORM.login).hasText('Sign in with Auth0'); + assert.dom(GENERAL.icon('auth0')).exists(0); + parseURLStub.restore(); + }); + + // true success has to be asserted in acceptance tests because it's not possible to mock a trusted message event + test('it opens the popup window on submit', async function (assert) { + this.server.post(`/auth/${this.authType}/oidc/auth_url`, () => { + return { data: { auth_url: '123-example.com' } }; + }); + sinon.replaceGetter(window, 'screen', () => ({ height: 600, width: 500 })); + await this.renderComponent(); + await fillIn(GENERAL.inputByAttr('role'), 'test'); + await click(AUTH_FORM.login); + await waitUntil(() => { + return this.windowStub.calledOnce; + }); + + const [authURL, windowName, windowDimensions] = this.windowStub.lastCall.args; + + assert.strictEqual(authURL, '123-example.com', 'window stub called with auth_url'); + assert.strictEqual(windowName, 'vaultOIDCWindow', 'window stub called with name'); + assert.strictEqual( + windowDimensions, + 'width=500,height=600,resizable,scrollbars=yes,top=0,left=0', + 'window stub called with dimensions' + ); + sinon.restore(); + }); + + // auth_url error handling on submit + test('it fires onError callback on submit when auth_url request fails with 400', async function (assert) { + this.server.post('/auth/:path/oidc/auth_url', () => overrideResponse(400)); + await this.renderComponent(); + await click(AUTH_FORM.login); + + const [actual] = this.onError.lastCall.args; + assert.strictEqual(actual, 'Authentication failed: Invalid role. Please try again.'); + }); + + test('it fires onError callback on submit when auth_url request fails with 403', async function (assert) { + this.server.post('/auth/:path/oidc/auth_url', () => overrideResponse(403)); + await this.renderComponent(); + await click(AUTH_FORM.login); + + const [actual] = this.onError.lastCall.args; + assert.strictEqual(actual, 'Authentication failed: Error fetching role: permission denied'); + }); + + test('it fires onError callback on submit when auth_url request is successful but missing auth_url key', async function (assert) { + this.server.post('/auth/:path/oidc/auth_url', () => ({ data: {} })); + await this.renderComponent(); + await click(AUTH_FORM.login); + + const [actual] = this.onError.lastCall.args; + assert.strictEqual( + actual, + 'Authentication failed: Missing auth_url. Please check that allowed_redirect_uris for the role include this mount path.', + 'it calls onError' + ); + }); + // end auth_url error handling + + // prepareForOIDC logic tests + test('fails silently when event is not trusted', async function (assert) { + assert.expect(2); + this.server.post(`/auth/${this.authType}/oidc/auth_url`, () => { + return { data: { auth_url: '123-example.com' } }; + }); + // 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 this.renderComponent(); + await fillIn(GENERAL.inputByAttr('role'), 'test'); + await click(AUTH_FORM.login); + await waitUntil(() => { + return this.windowStub.calledOnce; + }); + // 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.onSuccess.called, 'onSuccess is not called'); + + // Cleanup + window.removeEventListener('message', assertEvent); + }); + + // not the greatest test because this assertion would also pass if the event.origin === window.origin. + // because event.isTrusted is always false (another condition checked by the component) + // but this is good enough because the origin logic is checked first in the conditional. + test('it fails silently when event origin does not match window origin', async function (assert) { + assert.expect(3); + this.server.post(`/auth/${this.authType}/oidc/auth_url`, () => { + return { data: { auth_url: '123-example.com' } }; + }); + // 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 this.renderComponent(); + await fillIn(GENERAL.inputByAttr('role'), 'test'); + await click(AUTH_FORM.login); + await waitUntil(() => { + return this.windowStub.calledOnce; + }); + + window.dispatchEvent(new MessageEvent('message', message)); + cancelTimers(); + await settled(); + assert.false(this.onSuccess.called, 'onSuccess is not called'); + + // Cleanup + window.removeEventListener('message', assertEvent); + }); +}; module('Integration | Component | auth | form | oidc-jwt', function (hooks) { setupRenderingTest(hooks); setupMirage(hooks); hooks.beforeEach(function () { - this.expectedFields = ['role']; - this.authenticateStub = sinon.stub(this.owner.lookup('service:auth'), 'authenticate'); this.cluster = { id: 1 }; this.onError = sinon.spy(); this.onSuccess = sinon.spy(); + + // additional test setup for oidc/jwt business + this.store = this.owner.lookup('service:store'); + this.routerStub = sinon.stub(this.owner.lookup('service:router'), 'urlFor').returns('123-example.com'); + this.renderComponent = ({ yieldBlock = false } = {}) => { if (yieldBlock) { return render(hbs` @@ -60,24 +342,82 @@ module('Integration | Component | auth | form | oidc-jwt', function (hooks) { module('oidc', function (hooks) { hooks.beforeEach(function () { this.authType = 'oidc'; - this.expectedSubmit = { - default: { path: 'oidc', role: 'some-dev' }, - custom: { path: 'custom-oidc', role: 'some-dev' }, - }; + this.expectedFields = ['role']; }); - testHelper(test); + // base component functionality so outside login workflow modules + authUrlRequestTests(test); + + module('login workflow: jwt token', function (hooks) { + hooks.beforeEach(function () { + // stubbing this request shows JWT token input and does not perform OIDC + this.server.post(`/auth/:path/oidc/auth_url`, () => { + return overrideResponse(400, { errors: [ERROR_JWT_LOGIN] }); + }); + this.expectedFields = ['role', 'jwt']; + this.expectedSubmit = { + default: { path: 'oidc', role: 'some-dev', jwt: 'some-jwt-token' }, + custom: { path: 'custom-oidc', role: 'some-dev', jwt: 'some-jwt-token' }, + }; + }); + + testHelper(test, { standardSubmit: false }); + + jwtLoginTests(test); + }); + + module('login workflow: oidc', function (hooks) { + hooks.beforeEach(function () { + // for oidc login workflow only + this.windowStub = sinon.stub(window, 'open'); + }); + + hooks.afterEach(function () { + this.windowStub.restore(); + }); + + oidcLoginTests(test); + }); }); module('jwt', function (hooks) { hooks.beforeEach(function () { this.authType = 'jwt'; - this.expectedSubmit = { - default: { path: 'jwt', role: 'some-dev' }, - custom: { path: 'custom-jwt', role: 'some-dev' }, - }; + this.expectedFields = ['role']; }); - testHelper(test); + // base component functionality so outside login workflow modules + authUrlRequestTests(test); + + module('login workflow: jwt token', function (hooks) { + hooks.beforeEach(function () { + // stubbing this request shows JWT token input and does not perform OIDC + this.server.post(`/auth/:path/oidc/auth_url`, () => { + return overrideResponse(400, { errors: [ERROR_JWT_LOGIN] }); + }); + this.expectedFields = ['role', 'jwt']; + this.expectedSubmit = { + default: { path: 'jwt', role: 'some-dev', jwt: 'some-jwt-token' }, + custom: { path: 'custom-jwt', role: 'some-dev', jwt: 'some-jwt-token' }, + }; + }); + + testHelper(test, { standardSubmit: false }); + + jwtLoginTests(test); + }); + + module('login workflow: oidc', function (hooks) { + hooks.beforeEach(function () { + // for oidc login workflow only + this.windowStub = sinon.stub(window, 'open'); + }); + + hooks.afterEach(function () { + this.windowStub.restore(); + }); + + oidcLoginTests(test); + }); }); }); diff --git a/ui/types/ember-data/types/registries/adapter.d.ts b/ui/types/ember-data/types/registries/adapter.d.ts index 5eadb0f38d..af930fd105 100644 --- a/ui/types/ember-data/types/registries/adapter.d.ts +++ b/ui/types/ember-data/types/registries/adapter.d.ts @@ -6,6 +6,7 @@ import Application from 'vault/adapters/application'; import Adapter from 'ember-data/adapter'; import ModelRegistry from 'ember-data/types/registries/model'; +import AuthMethodAdapter from 'vault/vault/adapters/auth-method'; import PkiIssuerAdapter from 'vault/adapters/pki/issuer'; import PkiTidyAdapter from 'vault/adapters/pki/tidy'; import LdapRoleAdapter from 'vault/adapters/ldap/role'; @@ -19,6 +20,7 @@ import SyncAssociationAdapter from 'vault/adapters/sync/association'; * Catch-all for ember-data. */ export default interface AdapterRegistry { + 'auth-method': AuthMethodAdapter; 'ldap/library': LdapLibraryAdapter; 'ldap/role': LdapRoleAdapter; 'pki/issuer': PkiIssuerAdapter; diff --git a/ui/types/vault/adapters/auth-method.d.ts b/ui/types/vault/adapters/auth-method.d.ts new file mode 100644 index 0000000000..ae8c824754 --- /dev/null +++ b/ui/types/vault/adapters/auth-method.d.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Store from '@ember-data/store'; +import { AdapterRegistry } from 'ember-data/adapter'; +import type { AuthData } from 'vault/services/auth'; + +export default interface AuthMethodAdapter extends AdapterRegistry { + exchangeOIDC: ( + path: string, + state: string, + code: string + ) => Promise<{ + auth: AuthData; + }>; +} diff --git a/ui/types/vault/services/auth.d.ts b/ui/types/vault/services/auth.d.ts index 9e4fc779bf..827ce72a86 100644 --- a/ui/types/vault/services/auth.d.ts +++ b/ui/types/vault/services/auth.d.ts @@ -15,6 +15,8 @@ export interface AuthData { renewable: boolean; entity_id: string; displayName?: string; + mfa_requirement: object; + client_token: string; } export default class AuthService extends Service { @@ -22,11 +24,11 @@ export default class AuthService extends Service { currentToken: string; mfaErrors: null | Errors[]; setLastFetch: (time: number) => void; - handleError: (error: Error) => string | error[] | [error]; + handleError: (error: Error | string) => string | error[] | [error]; authenticate(params: { clusterId: string; backend: string; - data: Record; + data: object; selectedAuth: string; }): Promise; ajax: (