diff --git a/changelog/15998.txt b/changelog/15998.txt new file mode 100644 index 000000000000..69274f6c3ff4 --- /dev/null +++ b/changelog/15998.txt @@ -0,0 +1,3 @@ +```release-note:feature +ui: UI support for Okta Number Challenge. +``` diff --git a/ui/app/adapters/cluster.js b/ui/app/adapters/cluster.js index f8600442640d..3c53106d7126 100644 --- a/ui/app/adapters/cluster.js +++ b/ui/app/adapters/cluster.js @@ -107,7 +107,7 @@ export default ApplicationAdapter.extend({ }, authenticate({ backend, data }) { - const { role, jwt, token, password, username, path } = data; + const { role, jwt, token, password, username, path, nonce } = data; const url = this.urlForAuth(backend, username, path); const verb = backend === 'token' ? 'GET' : 'POST'; let options = { @@ -119,6 +119,8 @@ export default ApplicationAdapter.extend({ }; } else if (backend === 'jwt' || backend === 'oidc') { options.data = { role, jwt }; + } else if (backend === 'okta') { + options.data = { password, nonce }; } else { options.data = token ? { token, password } : { password }; } diff --git a/ui/app/components/auth-form.js b/ui/app/components/auth-form.js index 6d4208ce28c6..eddbb9b27b04 100644 --- a/ui/app/components/auth-form.js +++ b/ui/app/components/auth-form.js @@ -18,13 +18,17 @@ const BACKENDS = supportedAuthBackends(); * * @example ```js * // All properties are passed in via query params. - * ``` + * ``` * * @param {string} wrappedToken - The auth method that is currently selected in the dropdown. * @param {object} cluster - The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. * @param {string} namespace- The currently active namespace. * @param {string} selectedAuth - The auth method that is currently selected in the dropdown. - * @param {function} onSuccess - Fired on auth success + * @param {function} onSuccess - Fired on auth success. + * @param {function} [setOktaNumberChallenge] - Sets whether we are waiting for okta number challenge to be used to sign in. + * @param {boolean} [waitingForOktaNumberChallenge=false] - Determines if we are waiting for the Okta Number Challenge to sign in. + * @param {function} [setCancellingAuth] - Sets whether we are cancelling or not the login authentication for Okta Number Challenge. + * @param {boolean} [cancelAuthForOktaNumberChallenge=false] - Determines if we are cancelling the login authentication for the Okta Number Challenge. */ const DEFAULTS = { @@ -51,6 +55,9 @@ export default Component.extend(DEFAULTS, { oldNamespace: null, authMethods: BACKENDS, + // number answer for okta number challenge if applicable + oktaNumberChallengeAnswer: null, + didReceiveAttrs() { this._super(...arguments); let { @@ -60,8 +67,14 @@ export default Component.extend(DEFAULTS, { namespace: ns, selectedAuth: newMethod, oldSelectedAuth: oldMethod, + cancelAuthForOktaNumberChallenge: cancelAuth, } = this; - + // if we are cancelling the login then we reset the number challenge answer and cancel the current authenticate and polling tasks + if (cancelAuth) { + this.set('oktaNumberChallengeAnswer', null); + this.authenticate.cancelAll(); + this.pollForOktaNumberChallenge.cancelAll(); + } next(() => { if (!token && (oldNS === null || oldNS !== ns)) { this.fetchMethods.perform(); @@ -219,7 +232,11 @@ export default Component.extend(DEFAULTS, { cluster: { id: clusterId }, } = this; try { - this.delayAuthMessageReminder.perform(); + if (backendType === 'okta') { + this.pollForOktaNumberChallenge.perform(data.nonce, data.path); + } else { + this.delayAuthMessageReminder.perform(); + } const authResponse = yield this.auth.authenticate({ clusterId, backend: backendType, @@ -236,6 +253,28 @@ export default Component.extend(DEFAULTS, { }) ), + pollForOktaNumberChallenge: task(function* (nonce, mount) { + // yield for 1s to wait to see if there is a login error before polling + yield timeout(1000); + if (this.error) { + return; + } + let response = null; + this.setOktaNumberChallenge(true); + this.setCancellingAuth(false); + // keep polling /auth/okta/verify/:nonce API every 1s until a response is given with the correct number for the Okta Number Challenge + while (response === null) { + // when testing, the polling loop causes promises to be rejected making acceptance tests fail + // so disable the poll in tests + if (Ember.testing) { + return; + } + yield timeout(1000); + response = yield this.auth.getOktaNumberChallengeAnswer(nonce, mount); + } + this.set('oktaNumberChallengeAnswer', response); + }), + delayAuthMessageReminder: task(function* () { if (Ember.testing) { this.showLoading = true; @@ -275,6 +314,14 @@ export default Component.extend(DEFAULTS, { if (this.customPath || backend.id) { data.path = this.customPath || backend.id; } + // add nonce field for okta backend + if (backend.type === 'okta') { + data.nonce = crypto.randomUUID(); + // add a default path of okta if it doesn't exist to be used for Okta Number Challenge + if (!data.path) { + data.path = 'okta'; + } + } return this.authenticate.unlinked().perform(backend.type, data); }, handleError(e) { @@ -283,5 +330,9 @@ export default Component.extend(DEFAULTS, { error: e ? this.auth.handleError(e) : null, }); }, + returnToLoginFromOktaNumberChallenge() { + this.setOktaNumberChallenge(false); + this.set('oktaNumberChallengeAnswer', null); + }, }, }); diff --git a/ui/app/components/okta-number-challenge.js b/ui/app/components/okta-number-challenge.js new file mode 100644 index 000000000000..7c9566e11546 --- /dev/null +++ b/ui/app/components/okta-number-challenge.js @@ -0,0 +1,24 @@ +import Component from '@glimmer/component'; + +/** + * @module OktaNumberChallenge + * OktaNumberChallenge components are used to display loading screen and correct answer for Okta Number Challenge when signing in through Okta + * + * @example + * ```js + * + * ``` + * @param {number} correctAnswer - The correct answer to click for the okta number challenge. + * @param {boolean} hasError - Determines if there is an error being thrown. + * @param {function} onReturnToLogin - Sets waitingForOktaNumberChallenge to false if want to return to main login. + */ + +export default class OktaNumberChallenge extends Component { + get oktaNumberChallengeCorrectAnswer() { + return this.args.correctAnswer; + } + + get errorThrown() { + return this.args.hasError; + } +} diff --git a/ui/app/controllers/vault/cluster/auth.js b/ui/app/controllers/vault/cluster/auth.js index 41eb3dcb6ec3..496ceaf1c8bc 100644 --- a/ui/app/controllers/vault/cluster/auth.js +++ b/ui/app/controllers/vault/cluster/auth.js @@ -85,5 +85,9 @@ export default Controller.extend({ mfaErrors: null, }); }, + cancelAuthentication() { + this.set('cancelAuth', true); + this.set('waitingForOktaNumberChallenge', false); + }, }, }); diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js index 95cf6b88dc36..6134ac014ea4 100644 --- a/ui/app/services/auth.js +++ b/ui/app/services/auth.js @@ -441,4 +441,21 @@ export default Service.extend({ backend: BACKENDS.findBy('type', backend), }); }), + + getOktaNumberChallengeAnswer(nonce, mount) { + const url = `/v1/auth/${mount}/verify/${nonce}`; + return this.ajax(url, 'GET', {}).then( + (resp) => { + return resp.data.correct_answer; + }, + (e) => { + // if error status is 404, return and keep polling for a response + if (e.status === 404) { + return null; + } else { + throw e; + } + } + ); + }, }); diff --git a/ui/app/templates/components/auth-form.hbs b/ui/app/templates/components/auth-form.hbs index 149fb0bec9ca..db216af8a376 100644 --- a/ui/app/templates/components/auth-form.hbs +++ b/ui/app/templates/components/auth-form.hbs @@ -1,176 +1,186 @@
- {{#if this.hasMethodsWithPath}} -
+ {{/if}} \ No newline at end of file diff --git a/ui/app/templates/components/okta-number-challenge.hbs b/ui/app/templates/components/okta-number-challenge.hbs new file mode 100644 index 000000000000..307ec3f6e36a --- /dev/null +++ b/ui/app/templates/components/okta-number-challenge.hbs @@ -0,0 +1,38 @@ +
+
+
+

+ To finish signing in, you will need to complete an additional MFA step.

+ {{#if this.errorThrown}} +
+ + +
+ {{else if this.oktaNumberChallengeCorrectAnswer}} +
+

Okta + verification

+

Select the following number to complete verification:

+

{{this.oktaNumberChallengeCorrectAnswer}}

+
+ {{else}} +
+
+ +
+

Please wait...

+
+
+
+ {{/if}} +
+
+
\ No newline at end of file diff --git a/ui/app/templates/vault/cluster/auth.hbs b/ui/app/templates/vault/cluster/auth.hbs index 05f3350d09c3..442a1f4711e5 100644 --- a/ui/app/templates/vault/cluster/auth.hbs +++ b/ui/app/templates/vault/cluster/auth.hbs @@ -27,9 +27,13 @@ + {{else if this.waitingForOktaNumberChallenge}} + {{/if}}

- {{if this.mfaAuthData "Authenticate" "Sign in to Vault"}} + {{if (or this.mfaAuthData this.waitingForOktaNumberChallenge) "Authenticate" "Sign in to Vault"}}

{{/if}} @@ -113,6 +117,10 @@ @redirectTo={{this.redirectTo}} @selectedAuth={{this.authMethod}} @onSuccess={{action "onAuthResponse"}} + @setOktaNumberChallenge={{fn (mut this.waitingForOktaNumberChallenge)}} + @waitingForOktaNumberChallenge={{this.waitingForOktaNumberChallenge}} + @setCancellingAuth={{fn (mut this.cancelAuth)}} + @cancelAuthForOktaNumberChallenge={{this.cancelAuth}} /> {{/if}} diff --git a/ui/tests/integration/components/okta-number-challenge-test.js b/ui/tests/integration/components/okta-number-challenge-test.js new file mode 100644 index 000000000000..a115960500c4 --- /dev/null +++ b/ui/tests/integration/components/okta-number-challenge-test.js @@ -0,0 +1,69 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, click } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | okta-number-challenge', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.oktaNumberChallengeAnswer = null; + this.hasError = false; + }); + + test('it should render correct descriptions', async function (assert) { + await render(hbs``); + + assert + .dom('[data-test-okta-number-challenge-description]') + .includesText( + 'To finish signing in, you will need to complete an additional MFA step.', + 'Correct description renders' + ); + assert + .dom('[data-test-okta-number-challenge-loading]') + .includesText('Please wait...', 'Correct loading description renders'); + }); + + test('it should show correct number for okta number challenge', async function (assert) { + this.set('oktaNumberChallengeAnswer', 1); + await render(hbs``); + assert + .dom('[data-test-okta-number-challenge-description]') + .includesText( + 'To finish signing in, you will need to complete an additional MFA step.', + 'Correct description renders' + ); + assert + .dom('[data-test-okta-number-challenge-verification-type]') + .includesText('Okta verification', 'Correct verification type renders'); + + assert + .dom('[data-test-okta-number-challenge-verification-description]') + .includesText( + 'Select the following number to complete verification:', + 'Correct verification description renders' + ); + assert + .dom('[data-test-okta-number-challenge-answer]') + .includesText('1', 'Correct okta number challenge answer renders'); + }); + + test('it should show error screen', async function (assert) { + this.set('hasError', true); + await render( + hbs`` + ); + assert + .dom('[data-test-okta-number-challenge-description]') + .includesText( + 'To finish signing in, you will need to complete an additional MFA step.', + 'Correct description renders' + ); + assert + .dom('[data-test-error]') + .includesText('There was a problem', 'Displays error that there was a problem'); + await click('[data-test-return-from-okta-number-challenge]'); + assert.true(this.returnToLogin, 'onReturnToLogin was triggered'); + }); +}); diff --git a/ui/tests/unit/adapters/cluster-test.js b/ui/tests/unit/adapters/cluster-test.js index 1e0e3f9163fa..9de6d20796e4 100644 --- a/ui/tests/unit/adapters/cluster-test.js +++ b/ui/tests/unit/adapters/cluster-test.js @@ -114,11 +114,12 @@ module('Unit | Adapter | cluster', function (hooks) { 'ldap:userpass options OK' ); + data = { password: 'password', username: 'username', nonce: 'uuid' }; adapter.authenticate({ backend: 'okta', data }); assert.equal(url, '/v1/auth/okta/login/username', 'okta:userpass url OK'); assert.equal(method, 'POST', 'ldap:userpass method OK'); assert.deepEqual( - { data: { password: 'password' }, unauthenticated: true }, + { data: { password: 'password', nonce: 'uuid' }, unauthenticated: true }, options, 'okta:userpass options OK' ); @@ -132,6 +133,7 @@ module('Unit | Adapter | cluster', function (hooks) { adapter.authenticate({ backend: 'LDAP', data }); assert.equal(url, '/v1/auth/path/login/username', 'auth:LDAP with path url OK'); + data = { password: 'password', username: 'username', path: 'path', nonce: 'uuid' }; adapter.authenticate({ backend: 'Okta', data }); assert.equal(url, '/v1/auth/path/login/username', 'auth:Okta with path url OK'); });