diff --git a/changelog/15998.txt b/changelog/15998.txt
new file mode 100644
index 000000000..69274f6c3
--- /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 f86004426..3c53106d7 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 6d4208ce2..eddbb9b27 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 000000000..7c9566e11
--- /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 41eb3dcb6..496ceaf1c 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 95cf6b88d..6134ac014 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 149fb0bec..db216af8a 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 000000000..307ec3f6e
--- /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 05f3350d0..442a1f471 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 000000000..a11596050
--- /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 1e0e3f916..9de6d2079 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');
});