From 03fbde820b4d54b25fd3a92da4d939af3a235c4b Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Wed, 23 Apr 2025 11:06:19 -0700 Subject: [PATCH] UI: Build Okta auth method component (#30316) * finish okta auth method * convert to ts * move login fields down * rename arg to prep for mfa business --- ui/app/components/auth/form/base.ts | 21 +-- ui/app/components/auth/form/okta.hbs | 37 ++++++ ui/app/components/auth/form/okta.js | 15 --- ui/app/components/auth/form/okta.ts | 104 +++++++++++++++ ui/lib/core/addon/utils/uuid.js | 14 ++ ui/lib/core/app/utils/uuid.js | 6 + .../components/auth/form/okta-test.js | 124 +++++++++++++++++- .../components/auth/form/test-helper.js | 81 ++++++------ ui/types/vault/services/auth.d.ts | 11 +- 9 files changed, 343 insertions(+), 70 deletions(-) create mode 100644 ui/app/components/auth/form/okta.hbs delete mode 100644 ui/app/components/auth/form/okta.js create mode 100644 ui/app/components/auth/form/okta.ts create mode 100644 ui/lib/core/addon/utils/uuid.js create mode 100644 ui/lib/core/app/utils/uuid.js diff --git a/ui/app/components/auth/form/base.ts b/ui/app/components/auth/form/base.ts index 7be8d6dec4..d44513a32c 100644 --- a/ui/app/components/auth/form/base.ts +++ b/ui/app/components/auth/form/base.ts @@ -8,8 +8,10 @@ import { action } from '@ember/object'; import { service } from '@ember/service'; import { task } from 'ember-concurrency'; import { waitFor } from '@ember/test-waiters'; +import { sanitizePath } from 'core/utils/sanitize-path'; import type AuthService from 'vault/vault/services/auth'; +import type { AuthData } from 'vault/vault/services/auth'; import type ClusterModel from 'vault/models/cluster'; import type { HTMLElementEvent } from 'vault/forms'; @@ -39,7 +41,9 @@ export default class AuthBase extends Component { const data: Record = {}; for (const key of formData.keys()) { - data[key] = formData.get(key); + const value = formData.get(key); + // strip leading or trailing slashes from path for consistency + data[key] = key === 'path' ? sanitizePath(value) : value; } // If path is not included in the submitted form data, @@ -53,28 +57,25 @@ export default class AuthBase extends Component { } login = task( - waitFor(async (data) => { + waitFor(async (formData) => { try { const authResponse = await this.auth.authenticate({ clusterId: this.args.cluster.id, backend: this.args.authType, - data, + data: formData, selectedAuth: this.args.authType, }); - // responsible for redirect after auth data is persisted - this.onSuccess(authResponse); + this.handleAuthResponse(authResponse); } catch (error) { this.onError(error as Error); } }) ); - // if we move auth service authSuccess method here (or to each auth method component) - // then call that before calling parent this.args.onSuccess - onSuccess(authResponse: object) { - // responsible for redirect after auth data is persisted - this.args.onSuccess(authResponse, this.args.authType); + handleAuthResponse(authResponse: AuthData) { + // calls onAuthResponse in parent auth/page.js component + this.args.onSuccess(authResponse); } onError(error: Error) { diff --git a/ui/app/components/auth/form/okta.hbs b/ui/app/components/auth/form/okta.hbs new file mode 100644 index 0000000000..5e230e7fd7 --- /dev/null +++ b/ui/app/components/auth/form/okta.hbs @@ -0,0 +1,37 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{#if this.showNumberChallenge}} + +{{else}} +
+ + {{yield to="namespace"}} + +
+ {{yield to="back"}} + {{yield to="authSelectOptions"}} + {{yield to="error"}} + + + + {{yield to="advancedSettings"}} + + + + {{yield to="footer"}} +
+
+{{/if}} \ No newline at end of file diff --git a/ui/app/components/auth/form/okta.js b/ui/app/components/auth/form/okta.js deleted file mode 100644 index 5cdc6847ee..0000000000 --- a/ui/app/components/auth/form/okta.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import AuthBase from './base'; - -/** - * @module Auth::Form::Okta - * see Auth::Base - * */ - -export default class AuthFormOkta extends AuthBase { - loginFields = [{ name: 'username' }, { name: 'password' }]; -} diff --git a/ui/app/components/auth/form/okta.ts b/ui/app/components/auth/form/okta.ts new file mode 100644 index 0000000000..abd0199f8e --- /dev/null +++ b/ui/app/components/auth/form/okta.ts @@ -0,0 +1,104 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import AuthBase from './base'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import { task, timeout } from 'ember-concurrency'; +import { tracked } from '@glimmer/tracking'; +import { waitFor } from '@ember/test-waiters'; +import errorMessage from 'vault/utils/error-message'; +import uuid from 'core/utils/uuid'; + +import type AuthService from 'vault/vault/services/auth'; + +/** + * @module Auth::Form::Okta + * see Auth::Base + * */ + +export default class AuthFormOkta extends AuthBase { + @service declare readonly auth: AuthService; + + @tracked challengeAnswer = ''; + @tracked oktaVerifyError = ''; + @tracked showNumberChallenge = false; + + loginFields = [{ name: 'username' }, { name: 'password' }]; + + login = task( + waitFor(async (data) => { + // wait for 1s to wait to see if there is a login error before polling + await timeout(1000); + + data.nonce = uuid(); + this.pollForOktaNumberChallenge.perform(data.nonce, data.path); + + try { + // selecting the correct okta verify answer on the personal device resolves this request + const authResponse = await this.auth.authenticate({ + clusterId: this.args.cluster.id, + backend: this.args.authType, + data, + selectedAuth: this.args.authType, + }); + + this.handleAuthResponse(authResponse); + } catch (error) { + // if a user fails the okta verify challenge, the POST login request fails (made by this.auth.authenticate above) + // bubble those up for consistency instead of managing error state in this component + this.onError(error as Error); + // cancel polling tasks and reset state + this.reset(); + } + }) + ); + + pollForOktaNumberChallenge = task( + waitFor(async (nonce, mountPath) => { + this.showNumberChallenge = true; + + // keep polling /auth/okta/verify/:nonce API every 1s until response returns with correct_number + let verifyNumber = null; + while (verifyNumber === null) { + await timeout(1000); + // verifyNumber = await this.requestOktaVerify(nonce, mountPath); + verifyNumber = await this.requestOktaVerify(nonce, mountPath); + } + + // display correct number so user can select on personal MFA device + this.challengeAnswer = verifyNumber ?? ''; + }) + ); + + @action + async requestOktaVerify(nonce: string, mountPath: string) { + const url = `/v1/auth/${mountPath}/verify/${nonce}`; + try { + const response = await this.auth.ajax(url, 'GET', {}); + return response.data.correct_answer; + } catch (e) { + const error = e as Response; + if (error?.status === 404) { + // if error status is 404 return null to keep polling for a response + return null; + } else { + // this would be unusual, but handling just in case + this.oktaVerifyError = errorMessage(e); + return; + } + } + } + + @action + reset() { + // reset tracked variables and stop polling tasks + this.challengeAnswer = ''; + this.oktaVerifyError = ''; + this.showNumberChallenge = false; + this.login.cancelAll(); + this.pollForOktaNumberChallenge.cancelAll(); + } +} diff --git a/ui/lib/core/addon/utils/uuid.js b/ui/lib/core/addon/utils/uuid.js new file mode 100644 index 0000000000..b4a7797e10 --- /dev/null +++ b/ui/lib/core/addon/utils/uuid.js @@ -0,0 +1,14 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { v4 as uuidv4 } from 'uuid'; + +/** + * Use this instead of uuidv4() so that generated UUIDs can be stubbed in tests. + */ + +export default function uuid() { + return uuidv4(); +} diff --git a/ui/lib/core/app/utils/uuid.js b/ui/lib/core/app/utils/uuid.js new file mode 100644 index 0000000000..ab3544f395 --- /dev/null +++ b/ui/lib/core/app/utils/uuid.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +export { default } from 'core/utils/uuid'; diff --git a/ui/tests/integration/components/auth/form/okta-test.js b/ui/tests/integration/components/auth/form/okta-test.js index 6ee2734ade..591cb2921e 100644 --- a/ui/tests/integration/components/auth/form/okta-test.js +++ b/ui/tests/integration/components/auth/form/okta-test.js @@ -6,10 +6,15 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import hbs from 'htmlbars-inline-precompile'; -import { render } from '@ember/test-helpers'; +import { click, fillIn, render } from '@ember/test-helpers'; import sinon from 'sinon'; import { setupMirage } from 'ember-cli-mirage/test-support'; import testHelper from './test-helper'; +import { LOGIN_DATA } from 'vault/tests/helpers/auth/auth-helpers'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; +import * as uuid from 'core/utils/uuid'; +import { Response } from 'miragejs'; module('Integration | Component | auth | form | okta', function (hooks) { setupRenderingTest(hooks); @@ -18,15 +23,28 @@ module('Integration | Component | auth | form | okta', function (hooks) { hooks.beforeEach(function () { this.authType = 'okta'; this.expectedFields = ['username', 'password']; - this.authenticateStub = sinon.stub(this.owner.lookup('service:auth'), 'authenticate'); this.cluster = { id: 1 }; this.onError = sinon.spy(); this.onSuccess = sinon.spy(); + // stub uuid so auth/okta/verify request can be stubbed using mirage + this.nonce = '12345'; + sinon.stub(uuid, 'default').returns(this.nonce); + this.response = { data: { correct_answer: 68 } }; + this.server.get(`/auth/:path/verify/${this.nonce}`, () => this.response); + this.expectedSubmit = { - default: { path: 'okta', username: 'matilda', password: 'password' }, - custom: { path: 'custom-okta', username: 'matilda', password: 'password' }, + default: { path: 'okta', username: 'matilda', password: 'password', nonce: this.nonce }, + custom: { path: 'custom-okta', username: 'matilda', password: 'password', nonce: this.nonce }, }; + + this.fillInForm = async () => { + const { loginData } = LOGIN_DATA.username; + for (const [field, value] of Object.entries(loginData)) { + await fillIn(GENERAL.inputByAttr(field), value); + } + }; + this.renderComponent = ({ yieldBlock = false } = {}) => { if (yieldBlock) { return render(hbs` @@ -52,5 +70,101 @@ module('Integration | Component | auth | form | okta', function (hooks) { }; }); - testHelper(test); + testHelper(test, { standardSubmit: false }); + + test('it submits form data with defaults', async function (assert) { + assert.expect(2); + this.server.get(`/auth/okta/verify/${this.nonce}`, () => { + // since the yielded input is name="path" we can also assert the okta/verify response is hit as expected + assert.true(true, 'it requests okta verify with default mount path'); + return this.response; + }); + + await this.renderComponent(); + await this.fillInForm(); + 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' + ); + }); + + // not representative of real-world submit, that happens in acceptance tests. + // component here just yields <:advancedSettings> to test form submits data yielded data + test('it submits form data from yielded inputs', async function (assert) { + assert.expect(2); + this.server.get(`/auth/custom-okta/verify/${this.nonce}`, () => { + // since the yielded input is name="path" we can also assert the okta/verify response is hit as expected + assert.true(true, 'it requests okta verify with custom mount path'); + return this.response; + }); + + await this.renderComponent({ yieldBlock: true }); + await this.fillInForm(); + 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 displays okta number challenge answer', async function (assert) { + await this.renderComponent(); + await this.fillInForm(); + await click(AUTH_FORM.login); + assert + .dom('[data-test-okta-number-challenge]') + .hasText( + 'To finish signing in, you will need to complete an additional MFA step. Okta verification Select the following number to complete verification: 68 Back to login' + ); + }); + + test('it returns to login when "Back to login" is clicked', async function (assert) { + await this.renderComponent(); + await this.fillInForm(); + await click(AUTH_FORM.login); + assert.dom('[data-test-okta-number-challenge]').exists(); + await click(GENERAL.backButton); + assert.dom(AUTH_FORM.authForm('okta')).exists('it returns to okta form'); + assert.dom('[data-test-okta-number-challenge]').doesNotExist(); + assert.dom(GENERAL.inputByAttr('username')).hasValue('', 'username is cleared'); + assert.dom(GENERAL.inputByAttr('password')).hasValue('', 'password is cleared'); + }); + + test('it shows loading state when polling okta verify request', async function (assert) { + assert.expect(2); + let count = 0; + this.server.get(`/auth/okta/verify/${this.nonce}`, () => { + count++; + assert.dom('[data-test-okta-number-challenge]').hasText( + 'To finish signing in, you will need to complete an additional MFA step. Please wait... Back to login', + count === 1 + ? // the response hasn't returned anything yet, so the first assertion is just the initial state + 'it shows loading message before polling initiates' + : // by now the response has returned a 404 so this asserts error handling works as expected + 'it shows loading message while response returns 404' + ); + // okta/verify returns a 404 until the user interacts with okta via their configured MFA app. + // to simulate this interaction we return data on the third request - which ends the polling. + const response = count < 2 ? new Response(404, {}, { errors: [] }) : this.response; + return response; + }); + + await this.renderComponent(); + await this.fillInForm(); + await click(AUTH_FORM.login); + }); + + test('it renders error message when okta verify request errors', async function (assert) { + this.server.get(`/auth/okta/verify/${this.nonce}`, () => new Response(500)); + await this.renderComponent(); + await this.fillInForm(); + await click(AUTH_FORM.login); + assert.dom(GENERAL.messageError).hasText('Error An error occurred, please try again'); + }); }); diff --git a/ui/tests/integration/components/auth/form/test-helper.js b/ui/tests/integration/components/auth/form/test-helper.js index 71f8d20f74..bc6a9dfe12 100644 --- a/ui/tests/integration/components/auth/form/test-helper.js +++ b/ui/tests/integration/components/auth/form/test-helper.js @@ -15,7 +15,7 @@ This is intentional to test component logic specific to auth/form/base or auth/f separately from auth/form-template. */ -export default (test) => { +export default (test, { standardSubmit = true } = {}) => { test('it renders fields', async function (assert) { await this.renderComponent(); assert.dom(AUTH_FORM.authForm(this.authType)).exists(`${this.authType}: it renders form component`); @@ -46,47 +46,50 @@ export default (test) => { assert.strictEqual(actual, 'success!', 'it calls onSuccess'); }); - test('it submits form data with defaults', async function (assert) { - await this.renderComponent(); - const { options } = AUTH_METHOD_MAP.find((m) => m.authType === this.authType); - const { loginData } = options; + // some methods are tested separately because they have more complex submit logic + if (standardSubmit) { + test('it submits form data with defaults', async function (assert) { + await this.renderComponent(); + const { options } = AUTH_METHOD_MAP.find((m) => m.authType === this.authType); + const { loginData } = options; - for (const [field, value] of Object.entries(loginData)) { - await fillIn(GENERAL.inputByAttr(field), value); - } - 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' - ); - }); + for (const [field, value] of Object.entries(loginData)) { + await fillIn(GENERAL.inputByAttr(field), value); + } + 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' + ); + }); - // not for testing real-world submit, that happens in acceptance tests - // component here just yields <:advancedSettings> to test form submits data from yielded inputs - test('it submits form data from yielded inputs', async function (assert) { - await this.renderComponent({ yieldBlock: true }); - const { options } = AUTH_METHOD_MAP.find((m) => m.authType === this.authType); - const { loginData } = options; + // not for testing real-world submit, that happens in acceptance tests. + // component here just yields <:advancedSettings> to test form submits data from yielded inputs + test('it submits form data from yielded inputs', async function (assert) { + await this.renderComponent({ yieldBlock: true }); + const { options } = AUTH_METHOD_MAP.find((m) => m.authType === this.authType); + const { loginData } = options; - for (const [field, value] of Object.entries(loginData)) { - await fillIn(GENERAL.inputByAttr(field), value); - } + for (const [field, value] of Object.entries(loginData)) { + await fillIn(GENERAL.inputByAttr(field), value); + } - if (this.authType === 'token') { - // token doesn't support custom paths, so just test yielding functionality - await fillIn(GENERAL.inputByAttr('yield'), `yield-${this.authType}`); - } else { - await fillIn(GENERAL.inputByAttr('path'), `custom-${this.authType}`); - } + if (this.authType === 'token') { + // token doesn't support custom paths, so just test yielding functionality + await fillIn(GENERAL.inputByAttr('yield'), `yield-${this.authType}`); + } else { + 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' - ); - }); + 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' + ); + }); + } }; diff --git a/ui/types/vault/services/auth.d.ts b/ui/types/vault/services/auth.d.ts index 0fd663c2eb..9e4fc779bf 100644 --- a/ui/types/vault/services/auth.d.ts +++ b/ui/types/vault/services/auth.d.ts @@ -20,6 +20,7 @@ export interface AuthData { export default class AuthService extends Service { authData: AuthData; currentToken: string; + mfaErrors: null | Errors[]; setLastFetch: (time: number) => void; handleError: (error: Error) => string | error[] | [error]; authenticate(params: { @@ -28,5 +29,13 @@ export default class AuthService extends Service { data: Record; selectedAuth: string; }): Promise; - mfaErrors: null | Errors[]; + ajax: ( + url: string, + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + options?: { + headers?: Record; + namespace?: string; + data?: Record; + } + ) => Promise; }