Skip to content

Commit

Permalink
UI: Create starter Auth::Page component (#27478)
Browse files Browse the repository at this point in the history
* move OktaNumberChallenge and AuthForm to AuthPage component

* return from didReceiveAttrs if component is being torn down

* update auth form test

* change passed task to an auth action

* update auth form unit test

* fix return

* update jsdoc for auth form

* add docs

* add comments, last little cleanup, pass API error to okta number challenge

* separate tests and move Auth::Page specific logic out of auth form integration test

* fix test typos

* fix page tests
  • Loading branch information
hellobontempo committed Jun 20, 2024
1 parent d4da61f commit 2482674
Show file tree
Hide file tree
Showing 13 changed files with 709 additions and 572 deletions.
103 changes: 15 additions & 88 deletions ui/app/components/auth-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,32 @@
* SPDX-License-Identifier: BUSL-1.1
*/

import Ember from 'ember';
import { next } from '@ember/runloop';
import { service } from '@ember/service';
import { match, or } from '@ember/object/computed';
import { dasherize } from '@ember/string';
import Component from '@ember/component';
import { computed } from '@ember/object';
import { allSupportedAuthBackends, supportedAuthBackends } from 'vault/helpers/supported-auth-backends';
import { task, timeout } from 'ember-concurrency';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import { v4 as uuidv4 } from 'uuid';

/**
* @module AuthForm
* The `AuthForm` is used to sign users into Vault.
* The AuthForm displays the form used to sign users into Vault and passes input data to the Auth::Page component which handles authentication
*
* @example ```js
* // All properties are passed in via query params.
* <AuthForm @wrappedToken={{wrappedToken}} @cluster={{model}} @namespace={{namespaceQueryParam}} @selectedAuth={{authMethod}} @onSuccess={{action this.onSuccess}}/>```
* @example
* <AuthForm @cluster={{model}} @namespace="admin" @selectedAuth="token" @authIsRunning={{this.authenticate.isRunning}} @performAuth={{this.performAuth}} />
*
* @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} wrappedToken - Token that can be used to login if added directly to the URL via the "wrapped_token" query param
* @param {object} cluster - The cluster model which contains information such as cluster id, name and boolean for if the cluster is in standby
* @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} [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.
* @param {function} performAuth - Callback that triggers authenticate task in the parent, backend type (i.e. 'okta') and relevant auth data are passed as args
* @param {string} error - Error returned by the parent authenticate task, message is generated by the auth service handleError method
* @param {boolean} authIsRunning - Boolean that relays whether or not the authenticate task is running
* @param {boolean} delayIsIdle - Boolean that relays whether or not the delayAuthMessageReminder parent task is idle
*/

const DEFAULTS = {
Expand All @@ -49,7 +46,7 @@ export default Component.extend(DEFAULTS, {
csp: service('csp-event'),
version: service(),

// passed in via a query param
// set by query params, passed from parent Auth::Page component
selectedAuth: null,
methods: null,
cluster: null,
Expand All @@ -58,9 +55,6 @@ export default Component.extend(DEFAULTS, {
// internal
oldNamespace: null,

// number answer for okta number challenge if applicable
oktaNumberChallengeAnswer: null,

authMethods: computed('version.isEnterprise', function () {
return this.version.isEnterprise ? allSupportedAuthBackends() : supportedAuthBackends();
}),
Expand All @@ -74,18 +68,13 @@ 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();
}
// don't set any variables if the component is being torn down
if (this.isDestroyed || this.isDestroying) return;
this.set('oldNamespace', ns);
// we only want to trigger this once
if (token && !oldToken) {
Expand Down Expand Up @@ -235,65 +224,7 @@ export default Component.extend(DEFAULTS, {
})
),

showLoading: or('isLoading', 'authenticate.isRunning', 'fetchMethods.isRunning', 'unwrapToken.isRunning'),

authenticate: task(
waitFor(function* (backendType, data) {
const {
selectedAuth,
cluster: { id: clusterId },
} = this;
try {
if (backendType === 'okta') {
this.pollForOktaNumberChallenge.perform(data.nonce, data.path);
} else {
this.delayAuthMessageReminder.perform();
}
const authResponse = yield this.auth.authenticate({
clusterId,
backend: backendType,
data,
selectedAuth,
});
this.onSuccess(authResponse, backendType, data);
} catch (e) {
this.set('isLoading', false);
if (!this.auth.mfaError) {
this.set('error', `Authentication failed: ${this.auth.handleError(e)}`);
}
}
})
),

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) {
yield timeout(0);
} else {
yield timeout(5000);
}
}),
showLoading: or('isLoading', 'authIsRunning', 'fetchMethods.isRunning', 'unwrapToken.isRunning'),

actions: {
doSubmit(passedData, event, token) {
Expand Down Expand Up @@ -326,17 +257,13 @@ export default Component.extend(DEFAULTS, {
data.path = 'okta';
}
}
return this.authenticate.unlinked().perform(backend.type, data);
return this.performAuth(backend.type, data);
},
handleError(e) {
this.setProperties({
isLoading: false,
error: e ? this.auth.handleError(e) : null,
});
},
returnToLoginFromOktaNumberChallenge() {
this.setOktaNumberChallenge(false);
this.set('oktaNumberChallengeAnswer', null);
},
},
});
23 changes: 23 additions & 0 deletions ui/app/components/auth/page.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}

{{#if this.waitingForOktaNumberChallenge}}
<OktaNumberChallenge
@correctAnswer={{this.oktaNumberChallengeAnswer}}
@hasError={{this.authError}}
@onReturnToLogin={{this.onCancel}}
/>
{{else}}
<AuthForm
@wrappedToken={{@wrappedToken}}
@cluster={{@cluster}}
@namespace={{@namespace}}
@selectedAuth={{@selectedAuth}}
@error={{this.authError}}
@performAuth={{this.performAuth}}
@authIsRunning={{this.authenticate.isRunning}}
@delayIsIdle={{this.delayAuthMessageReminder.isIdle}}
/>
{{/if}}
108 changes: 108 additions & 0 deletions ui/app/components/auth/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

import Component from '@glimmer/component';
import Ember from 'ember';
import { service } from '@ember/service';
import { task, timeout } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

/**
* @module AuthPage
* The Auth::Page wraps OktaNumberChallenge and AuthForm to manage the login flow and is responsible for calling the authenticate method
*
* @example
* <Auth::Page @wrappedToken={{this.wrappedToken}} @cluster={{this.model}} @namespace={{this.namespaceQueryParam}} @selectedAuth={{this.authMethod}} @onSuccess={{action "onAuthResponse"}} />
*
* @param {string} wrappedToken - Query param value of a wrapped token that can be used to login when added directly to the URL via the "wrapped_token" query param
* @param {object} cluster - The route model which is the ember data cluster model. contains information such as cluster id, name and boolean for if the cluster is in standby
* @param {string} namespace- Namespace query param, passed to AuthForm and set by typing in namespace input or URL
* @param {string} selectedAuth - The auth method selected in the dropdown, passed to auth service's authenticate method
* @param {function} onSuccess - Callback that fires the "onAuthResponse" action in the auth controller and handles transitioning after success
*/

export default class AuthPageComponent extends Component {
@service auth;

@tracked authError = null;
@tracked oktaNumberChallengeAnswer = '';
@tracked waitingForOktaNumberChallenge = false;
@action
performAuth(backendType, data) {
this.authenticate.unlinked().perform(backendType, data);
}
@task
@waitFor
*delayAuthMessageReminder() {
if (Ember.testing) {
yield timeout(0);
} else {
yield timeout(5000);
}
}
@task
@waitFor
*authenticate(backendType, data) {
const {
selectedAuth,
cluster: { id: clusterId },
} = this.args;
try {
if (backendType === 'okta') {
this.pollForOktaNumberChallenge.perform(data.nonce, data.path);
} else {
this.delayAuthMessageReminder.perform();
}
const authResponse = yield this.auth.authenticate({
clusterId,
backend: backendType,
data,
selectedAuth,
});

this.args.onSuccess(authResponse, backendType, data);
} catch (e) {
if (!this.auth.mfaError) {
this.authError = `Authentication failed: ${this.auth.handleError(e)}`;
}
}
}

@task
@waitFor
*pollForOktaNumberChallenge(nonce, mount) {
// yield for 1s to wait to see if there is a login error before polling
yield timeout(1000);
if (this.authError) return;

this.waitingForOktaNumberChallenge = true;
// keep polling /auth/okta/verify/:nonce API every 1s until response returns with correct_number
let response = null;
while (response === null) {
// disable polling for tests otherwise promises reject and acceptance tests fail
if (Ember.testing) return;

yield timeout(1000);
response = yield this.auth.getOktaNumberChallengeAnswer(nonce, mount);
}
// display correct number so user can select on personal MFA device
this.oktaNumberChallengeAnswer = response;
}

@action
onCancel() {
// reset variables and stop polling tasks if canceling login
this.authError = null;
this.oktaNumberChallengeAnswer = null;
this.waitingForOktaNumberChallenge = false;
this.authenticate.cancelAll();
this.pollForOktaNumberChallenge.cancelAll();
}
}
4 changes: 0 additions & 4 deletions ui/app/controllers/vault/cluster/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,5 @@ export default Controller.extend({
mfaErrors: null,
});
},
cancelAuthentication() {
this.set('cancelAuth', true);
this.set('waitingForOktaNumberChallenge', false);
},
},
});
Loading

0 comments on commit 2482674

Please sign in to comment.