mirror of
https://github.com/hashicorp/vault.git
synced 2026-05-05 04:16:31 +02:00
UI: Create starter Auth::Page component (#27478)
* 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
This commit is contained in:
parent
d4da61fc4e
commit
2482674312
@ -3,7 +3,6 @@
|
||||
* 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';
|
||||
@ -11,27 +10,25 @@ 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 = {
|
||||
@ -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,
|
||||
@ -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();
|
||||
}),
|
||||
@ -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) {
|
||||
@ -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) {
|
||||
@ -326,7 +257,7 @@ 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({
|
||||
@ -334,9 +265,5 @@ export default Component.extend(DEFAULTS, {
|
||||
error: e ? this.auth.handleError(e) : null,
|
||||
});
|
||||
},
|
||||
returnToLoginFromOktaNumberChallenge() {
|
||||
this.setOktaNumberChallenge(false);
|
||||
this.set('oktaNumberChallengeAnswer', null);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
23
ui/app/components/auth/page.hbs
Normal file
23
ui/app/components/auth/page.hbs
Normal file
@ -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
ui/app/components/auth/page.js
Normal file
108
ui/app/components/auth/page.js
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
@ -101,9 +101,5 @@ export default Controller.extend({
|
||||
mfaErrors: null,
|
||||
});
|
||||
},
|
||||
cancelAuthentication() {
|
||||
this.set('cancelAuth', true);
|
||||
this.set('waitingForOktaNumberChallenge', false);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -3,202 +3,192 @@
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
<div class="auth-form" data-test-auth-form>
|
||||
{{#if (and this.waitingForOktaNumberChallenge (not this.cancelAuthForOktaNumberChallenge))}}
|
||||
<OktaNumberChallenge
|
||||
@correctAnswer={{this.oktaNumberChallengeAnswer}}
|
||||
@hasError={{(not-eq this.error null)}}
|
||||
@onReturnToLogin={{action "returnToLoginFromOktaNumberChallenge"}}
|
||||
/>
|
||||
{{else}}
|
||||
{{#if this.hasMethodsWithPath}}
|
||||
<nav class="tabs is-marginless">
|
||||
<ul>
|
||||
{{#each this.methodsToShow as |method|}}
|
||||
{{#let (or method.path method.type) as |methodKey|}}
|
||||
<li
|
||||
class={{if
|
||||
(and
|
||||
this.selectedAuthIsPath (eq (or this.selectedAuthBackend.path this.selectedAuthBackend.type) methodKey)
|
||||
)
|
||||
"is-active"
|
||||
""
|
||||
}}
|
||||
data-test-auth-method={{method.id}}
|
||||
>
|
||||
<LinkTo
|
||||
@route="vault.cluster.auth"
|
||||
@model={{this.cluster.name}}
|
||||
@query={{hash with=methodKey}}
|
||||
data-test-auth-method-link={{method.type}}
|
||||
>
|
||||
{{or method.id (capitalize method.type)}}
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{/let}}
|
||||
{{/each}}
|
||||
<li class={{unless this.selectedAuthIsPath "is-active" ""}} data-test-auth-method>
|
||||
<LinkTo
|
||||
@route="vault.cluster.auth"
|
||||
@model={{this.cluster.name}}
|
||||
@query={{hash with="token"}}
|
||||
data-test-auth-method-link="other"
|
||||
{{#if this.hasMethodsWithPath}}
|
||||
<nav class="tabs is-marginless">
|
||||
<ul>
|
||||
{{#each this.methodsToShow as |method|}}
|
||||
{{#let (or method.path method.type) as |methodKey|}}
|
||||
<li
|
||||
class={{if
|
||||
(and this.selectedAuthIsPath (eq (or this.selectedAuthBackend.path this.selectedAuthBackend.type) methodKey))
|
||||
"is-active"
|
||||
""
|
||||
}}
|
||||
data-test-auth-method={{method.id}}
|
||||
>
|
||||
Other
|
||||
</LinkTo>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{{/if}}
|
||||
<div class="box is-marginless is-shadowless">
|
||||
<MessageError @errorMessage={{if (and this.cluster.standby this.cspError) this.cspError this.error}} />
|
||||
{{#if this.selectedAuthBackend.path}}
|
||||
<div class="has-bottom-margin-s">
|
||||
<p class="is-label">{{this.selectedAuthBackend.path}}</p>
|
||||
<span class="description has-text-grey" data-test-description={{true}}>
|
||||
{{this.selectedAuthBackend.mountDescription}}
|
||||
</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (or (not this.hasMethodsWithPath) (not this.selectedAuthIsPath))}}
|
||||
<Select
|
||||
@label="Method"
|
||||
@name="auth-method"
|
||||
@options={{this.authMethods}}
|
||||
@valueAttribute={{"type"}}
|
||||
@labelAttribute={{"typeDisplay"}}
|
||||
@isFullwidth={{true}}
|
||||
@selectedValue={{this.selectedAuth}}
|
||||
@onChange={{action (mut this.selectedAuth)}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if (or (eq this.selectedAuthBackend.type "jwt") (eq this.selectedAuthBackend.type "oidc"))}}
|
||||
<AuthJwt
|
||||
@onError={{action "handleError"}}
|
||||
@onLoading={{action (mut this.isLoading)}}
|
||||
@namespace={{this.namespace}}
|
||||
@onNamespace={{action (mut this.namespace)}}
|
||||
@onSubmit={{action "doSubmit"}}
|
||||
@onRoleName={{action (mut this.roleName)}}
|
||||
@roleName={{this.roleName}}
|
||||
@selectedAuthType={{this.selectedAuthBackend.type}}
|
||||
@selectedAuthPath={{or this.customPath this.selectedAuthBackend.id}}
|
||||
@disabled={{or this.authenticate.isRunning this.isLoading}}
|
||||
>
|
||||
<AuthFormOptions
|
||||
@customPath={{this.customPath}}
|
||||
@onPathChange={{action (mut this.customPath)}}
|
||||
@selectedAuthIsPath={{this.selectedAuthIsPath}}
|
||||
/>
|
||||
</AuthJwt>
|
||||
{{else if (eq this.selectedAuthBackend.type "saml")}}
|
||||
<AuthSaml
|
||||
@onError={{action "handleError"}}
|
||||
@onLoading={{action (mut this.isLoading)}}
|
||||
@namespace={{this.namespace}}
|
||||
@onNamespace={{action (mut this.namespace)}}
|
||||
@onSubmit={{action "doSubmit"}}
|
||||
@onRoleName={{action (mut this.roleName)}}
|
||||
@roleName={{this.roleName}}
|
||||
@selectedAuthType={{this.selectedAuthBackend.type}}
|
||||
@selectedAuthPath={{or this.customPath this.selectedAuthBackend.id}}
|
||||
@disabled={{or this.authenticate.isRunning this.isLoading}}
|
||||
>
|
||||
<AuthFormOptions
|
||||
@customPath={{this.customPath}}
|
||||
@onPathChange={{action (mut this.customPath)}}
|
||||
@selectedAuthIsPath={{this.selectedAuthIsPath}}
|
||||
/>
|
||||
</AuthSaml>
|
||||
{{else}}
|
||||
<form id="auth-form" onsubmit={{action "doSubmit" null}}>
|
||||
{{#if (eq this.providerName "github")}}
|
||||
<div class="field">
|
||||
<label for="token" class="is-label">GitHub token</label>
|
||||
<div class="control">
|
||||
<Input
|
||||
@type="password"
|
||||
@value={{this.token}}
|
||||
name="token"
|
||||
id="token"
|
||||
class="input"
|
||||
data-test-token={{true}}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{{else if (eq this.providerName "token")}}
|
||||
<div class="field">
|
||||
<label for="token" class="is-label">Token</label>
|
||||
<div class="control">
|
||||
<Input
|
||||
@type="password"
|
||||
@value={{this.token}}
|
||||
name="token"
|
||||
class="input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-test-token={{true}}
|
||||
id="token"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="field">
|
||||
<label for="username" class="is-label">Username</label>
|
||||
<div class="control">
|
||||
<Input
|
||||
@value={{this.username}}
|
||||
name="username"
|
||||
id="username"
|
||||
class="input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-test-username={{true}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password" class="is-label">Password</label>
|
||||
<div class="control">
|
||||
<Input
|
||||
@value={{this.password}}
|
||||
name="password"
|
||||
id="password"
|
||||
@type="password"
|
||||
class="input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-test-password={{true}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (not-eq this.selectedAuthBackend.type "token")}}
|
||||
<AuthFormOptions
|
||||
@customPath={{this.customPath}}
|
||||
@onPathChange={{action (mut this.customPath)}}
|
||||
@selectedAuthIsPath={{this.selectedAuthIsPath}}
|
||||
/>
|
||||
{{/if}}
|
||||
<Hds::Button
|
||||
@text="Sign in"
|
||||
@icon={{if this.authenticate.isRunning "loading"}}
|
||||
data-test-auth-submit={{true}}
|
||||
type="submit"
|
||||
disabled={{this.authenticate.isRunning}}
|
||||
id="auth-submit"
|
||||
/>
|
||||
{{#if (and this.delayAuthMessageReminder.isIdle this.showLoading)}}
|
||||
<AlertInline
|
||||
class="has-top-padding-s"
|
||||
@type="info"
|
||||
@message="If login takes longer than usual, you may need to check your device for an MFA notification, or contact your administrator if login times out."
|
||||
data-test-auth-message="push"
|
||||
/>
|
||||
{{/if}}
|
||||
</form>
|
||||
{{/if}}
|
||||
</div>
|
||||
<LinkTo
|
||||
@route="vault.cluster.auth"
|
||||
@model={{this.cluster.name}}
|
||||
@query={{hash with=methodKey}}
|
||||
data-test-auth-method-link={{method.type}}
|
||||
>
|
||||
{{or method.id (capitalize method.type)}}
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{/let}}
|
||||
{{/each}}
|
||||
<li class={{unless this.selectedAuthIsPath "is-active" ""}} data-test-auth-method="other">
|
||||
<LinkTo
|
||||
@route="vault.cluster.auth"
|
||||
@model={{this.cluster.name}}
|
||||
@query={{hash with="token"}}
|
||||
data-test-auth-method-link="other"
|
||||
>
|
||||
Other
|
||||
</LinkTo>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{{/if}}
|
||||
<div class="box is-marginless is-shadowless">
|
||||
<MessageError @errorMessage={{if (and this.cluster.standby this.cspError) this.cspError this.error}} />
|
||||
{{#if this.selectedAuthBackend.path}}
|
||||
<div class="has-bottom-margin-s">
|
||||
<p class="is-label">{{this.selectedAuthBackend.path}}</p>
|
||||
<span class="description has-text-grey" data-test-description={{true}}>
|
||||
{{this.selectedAuthBackend.mountDescription}}
|
||||
</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (or (not this.hasMethodsWithPath) (not this.selectedAuthIsPath))}}
|
||||
<Select
|
||||
@label="Method"
|
||||
@name="auth-method"
|
||||
@options={{this.authMethods}}
|
||||
@valueAttribute={{"type"}}
|
||||
@labelAttribute={{"typeDisplay"}}
|
||||
@isFullwidth={{true}}
|
||||
@selectedValue={{this.selectedAuth}}
|
||||
@onChange={{action (mut this.selectedAuth)}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if (or (eq this.selectedAuthBackend.type "jwt") (eq this.selectedAuthBackend.type "oidc"))}}
|
||||
<AuthJwt
|
||||
@onError={{action "handleError"}}
|
||||
@onLoading={{action (mut this.isLoading)}}
|
||||
@namespace={{this.namespace}}
|
||||
@onNamespace={{action (mut this.namespace)}}
|
||||
@onSubmit={{action "doSubmit"}}
|
||||
@onRoleName={{action (mut this.roleName)}}
|
||||
@roleName={{this.roleName}}
|
||||
@selectedAuthType={{this.selectedAuthBackend.type}}
|
||||
@selectedAuthPath={{or this.customPath this.selectedAuthBackend.id}}
|
||||
@disabled={{or this.authIsRunning this.isLoading}}
|
||||
>
|
||||
<AuthFormOptions
|
||||
@customPath={{this.customPath}}
|
||||
@onPathChange={{action (mut this.customPath)}}
|
||||
@selectedAuthIsPath={{this.selectedAuthIsPath}}
|
||||
/>
|
||||
</AuthJwt>
|
||||
{{else if (eq this.selectedAuthBackend.type "saml")}}
|
||||
<AuthSaml
|
||||
@onError={{action "handleError"}}
|
||||
@onLoading={{action (mut this.isLoading)}}
|
||||
@namespace={{this.namespace}}
|
||||
@onNamespace={{action (mut this.namespace)}}
|
||||
@onSubmit={{action "doSubmit"}}
|
||||
@onRoleName={{action (mut this.roleName)}}
|
||||
@roleName={{this.roleName}}
|
||||
@selectedAuthType={{this.selectedAuthBackend.type}}
|
||||
@selectedAuthPath={{or this.customPath this.selectedAuthBackend.id}}
|
||||
@disabled={{or this.authIsRunning this.isLoading}}
|
||||
>
|
||||
<AuthFormOptions
|
||||
@customPath={{this.customPath}}
|
||||
@onPathChange={{action (mut this.customPath)}}
|
||||
@selectedAuthIsPath={{this.selectedAuthIsPath}}
|
||||
/>
|
||||
</AuthSaml>
|
||||
{{else}}
|
||||
<form id="auth-form" onsubmit={{action "doSubmit" null}}>
|
||||
{{#if (eq this.providerName "github")}}
|
||||
<div class="field">
|
||||
<label for="token" class="is-label">GitHub token</label>
|
||||
<div class="control">
|
||||
<Input
|
||||
@type="password"
|
||||
@value={{this.token}}
|
||||
name="token"
|
||||
id="token"
|
||||
class="input"
|
||||
data-test-token={{true}}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{{else if (eq this.providerName "token")}}
|
||||
<div class="field">
|
||||
<label for="token" class="is-label">Token</label>
|
||||
<div class="control">
|
||||
<Input
|
||||
@type="password"
|
||||
@value={{this.token}}
|
||||
name="token"
|
||||
class="input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-test-token={{true}}
|
||||
id="token"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="field">
|
||||
<label for="username" class="is-label">Username</label>
|
||||
<div class="control">
|
||||
<Input
|
||||
@value={{this.username}}
|
||||
name="username"
|
||||
id="username"
|
||||
class="input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-test-username
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password" class="is-label">Password</label>
|
||||
<div class="control">
|
||||
<Input
|
||||
@value={{this.password}}
|
||||
name="password"
|
||||
id="password"
|
||||
@type="password"
|
||||
class="input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-test-password
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (not-eq this.selectedAuthBackend.type "token")}}
|
||||
<AuthFormOptions
|
||||
@customPath={{this.customPath}}
|
||||
@onPathChange={{action (mut this.customPath)}}
|
||||
@selectedAuthIsPath={{this.selectedAuthIsPath}}
|
||||
/>
|
||||
{{/if}}
|
||||
<Hds::Button
|
||||
@text="Sign in"
|
||||
@icon={{if this.authIsRunning "loading"}}
|
||||
data-test-auth-submit={{true}}
|
||||
type="submit"
|
||||
disabled={{this.authIsRunning}}
|
||||
id="auth-submit"
|
||||
/>
|
||||
{{#if (and this.delayIsIdle this.showLoading)}}
|
||||
<AlertInline
|
||||
class="has-top-padding-s"
|
||||
@type="info"
|
||||
@message="If login takes longer than usual, you may need to check your device for an MFA notification, or contact your administrator if login times out."
|
||||
data-test-auth-message="push"
|
||||
/>
|
||||
{{/if}}
|
||||
</form>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
@ -2,42 +2,35 @@
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
{{! todo move to auth/ folder? }}
|
||||
<div class="auth-form" data-test-okta-number-challenge>
|
||||
<div class="box is-marginless is-shadowless">
|
||||
<div class="field has-top-margin-xs">
|
||||
<p data-test-okta-number-challenge-description>
|
||||
To finish signing in, you will need to complete an additional MFA step.</p>
|
||||
{{#if @hasError}}
|
||||
<div class="has-top-margin-s">
|
||||
<MessageError @errorMessage="There was a problem" />
|
||||
<Hds::Button
|
||||
@text="Return to login"
|
||||
@color="secondary"
|
||||
{{on "click" @onReturnToLogin}}
|
||||
data-test-return-from-okta-number-challenge
|
||||
/>
|
||||
</div>
|
||||
{{else if @correctAnswer}}
|
||||
<div class="has-top-margin-s">
|
||||
<p class="has-text-black has-text-weight-semibold" data-test-okta-number-challenge-verification-type>Okta
|
||||
verification</p>
|
||||
<p data-test-okta-number-challenge-verification-description>Select the following number to complete verification:</p>
|
||||
<h1
|
||||
class="title has-font-weight-normal has-top-margin-m has-bottom-margin-s"
|
||||
data-test-okta-number-challenge-answer
|
||||
>{{@correctAnswer}}</h1>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="has-top-margin-l has-bottom-margin-m">
|
||||
<div class="is-flex-row">
|
||||
<FlightIcon @name="loading" />
|
||||
<div class="has-left-margin-xs">
|
||||
<p data-test-okta-number-challenge-loading>Please wait...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<p data-test-okta-number-challenge-description>
|
||||
To finish signing in, you will need to complete an additional MFA step.
|
||||
</p>
|
||||
{{#if @hasError}}
|
||||
<MessageError class="has-top-margin-s" @errorMessage={{@hasError}} />
|
||||
{{else if @correctAnswer}}
|
||||
<p class="has-top-margin-s has-text-black has-text-weight-semibold" data-test-verification-type>
|
||||
Okta verification
|
||||
</p>
|
||||
<p data-test-description>Select the following number to complete verification:</p>
|
||||
<h1 class="title has-font-weight-normal has-top-margin-m has-bottom-margin-s" data-test-answer>
|
||||
{{@correctAnswer}}
|
||||
</h1>
|
||||
{{else}}
|
||||
<div class="has-top-margin-l has-bottom-margin-m is-flex-row">
|
||||
<FlightIcon @name="loading" />
|
||||
<p class="has-left-margin-xs" data-test-loading>Please wait...</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<Hds::Button
|
||||
@text="Back to login"
|
||||
@icon="arrow-left"
|
||||
@color="tertiary"
|
||||
class="has-bottom-margin-s has-left-margin-s"
|
||||
{{on "click" @onReturnToLogin}}
|
||||
data-test-back-button
|
||||
/>
|
||||
</div>
|
||||
@ -38,17 +38,9 @@
|
||||
@color="tertiary"
|
||||
{{on "click" (fn (mut this.mfaAuthData) null)}}
|
||||
/>
|
||||
{{else if this.waitingForOktaNumberChallenge}}
|
||||
<Hds::Button
|
||||
@text="Back to login"
|
||||
@icon="arrow-left"
|
||||
@isIconOnly={{true}}
|
||||
@color="tertiary"
|
||||
{{on "click" (action "cancelAuthentication")}}
|
||||
/>
|
||||
{{/if}}
|
||||
<h1 class="title is-3">
|
||||
{{if (or this.mfaAuthData this.waitingForOktaNumberChallenge) "Authenticate" "Sign in to Vault"}}
|
||||
{{if this.mfaAuthData "Authenticate" "Sign in to Vault"}}
|
||||
</h1>
|
||||
</div>
|
||||
{{/if}}
|
||||
@ -101,17 +93,12 @@
|
||||
@onError={{fn (mut this.mfaErrors)}}
|
||||
/>
|
||||
{{else}}
|
||||
<AuthForm
|
||||
<Auth::Page
|
||||
@wrappedToken={{this.wrappedToken}}
|
||||
@cluster={{this.model}}
|
||||
@namespace={{this.namespaceQueryParam}}
|
||||
@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}}
|
||||
</:content>
|
||||
|
||||
15
ui/tests/helpers/auth/auth-form-selectors.ts
Normal file
15
ui/tests/helpers/auth/auth-form-selectors.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export const AUTH_FORM = {
|
||||
form: '[data-test-auth-form]',
|
||||
login: '[data-test-auth-submit]',
|
||||
tabs: (method: string) => (method ? `[data-test-auth-method="${method}"]` : '[data-test-auth-method]'),
|
||||
description: '[data-test-description]',
|
||||
roleInput: '[data-test-role]',
|
||||
input: (item: string) => `[data-test-${item}]`, // i.e. role, token, password or username
|
||||
mountPathInput: '[data-test-auth-form-mount-path]',
|
||||
moreOptions: '[data-test-auth-form-options-toggle]',
|
||||
};
|
||||
@ -17,6 +17,7 @@ export const GENERAL = {
|
||||
secretTab: (name: string) => `[data-test-secret-list-tab="${name}"]`,
|
||||
flashMessage: '[data-test-flash-message]',
|
||||
latestFlashContent: '[data-test-flash-message]:last-of-type [data-test-flash-message-body]',
|
||||
inlineAlert: '[data-test-inline-alert]',
|
||||
|
||||
filter: (name: string) => `[data-test-filter="${name}"]`,
|
||||
filterInput: '[data-test-filter-input]',
|
||||
|
||||
@ -4,172 +4,120 @@
|
||||
*/
|
||||
|
||||
import { later, _cancelTimers as cancelTimers } from '@ember/runloop';
|
||||
import EmberObject from '@ember/object';
|
||||
import { resolve } from 'rsvp';
|
||||
import Service from '@ember/service';
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, settled } from '@ember/test-helpers';
|
||||
import { click, fillIn, render, settled } from '@ember/test-helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import sinon from 'sinon';
|
||||
import { create } from 'ember-cli-page-object';
|
||||
import authForm from '../../pages/components/auth-form';
|
||||
import { validate } from 'uuid';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { Response } from 'miragejs';
|
||||
|
||||
const component = create(authForm);
|
||||
|
||||
const workingAuthService = Service.extend({
|
||||
authenticate() {
|
||||
return resolve({});
|
||||
},
|
||||
handleError() {},
|
||||
setLastFetch() {},
|
||||
});
|
||||
|
||||
const routerService = Service.extend({
|
||||
transitionTo() {
|
||||
return {
|
||||
followRedirects() {
|
||||
return resolve();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
|
||||
|
||||
module('Integration | Component | auth form', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:router', routerService);
|
||||
this.router = this.owner.lookup('service:router');
|
||||
this.onSuccess = sinon.spy();
|
||||
this.selectedAuth = 'token';
|
||||
this.performAuth = sinon.spy();
|
||||
this.renderComponent = async () => {
|
||||
return render(hbs`
|
||||
<AuthForm
|
||||
@wrappedToken={{this.wrappedToken}}
|
||||
@cluster={{this.cluster}}
|
||||
@selectedAuth={{this.selectedAuth}}
|
||||
@performAuth={{this.performAuth}}
|
||||
@authIsRunning={{this.authIsRunning}}
|
||||
@delayIsIdle={{this.delayIsIdle}}
|
||||
/>`);
|
||||
};
|
||||
});
|
||||
|
||||
const CSP_ERR_TEXT = `Error This is a standby Vault node but can't communicate with the active node via request forwarding. Sign in at the active node to use the Vault UI.`;
|
||||
test('it renders error on CSP violation', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.set('cluster', EmberObject.create({ standby: true }));
|
||||
this.set('selectedAuth', 'token');
|
||||
await render(hbs`<AuthForm @cluster={{this.cluster}} @selectedAuth={{this.selectedAuth}} />`);
|
||||
assert.false(component.errorMessagePresent, false);
|
||||
this.owner.lookup('service:csp-event').handleEvent({ violatedDirective: 'connect-src' });
|
||||
await settled();
|
||||
assert.strictEqual(component.errorText, CSP_ERR_TEXT);
|
||||
test('it calls performAuth on submit', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await fillIn(AUTH_FORM.input('token'), '123token');
|
||||
await click(AUTH_FORM.login);
|
||||
const [type, data] = this.performAuth.lastCall.args;
|
||||
assert.strictEqual(type, 'token', 'performAuth is called with type');
|
||||
assert.propEqual(data, { token: '123token' }, 'performAuth is called with data');
|
||||
});
|
||||
|
||||
test('it renders with vault style errors', async function (assert) {
|
||||
assert.expect(1);
|
||||
this.server.get('/auth/token/lookup-self', () => {
|
||||
return new Response(400, { 'Content-Type': 'application/json' }, { errors: ['Not allowed'] });
|
||||
});
|
||||
|
||||
this.set('cluster', EmberObject.create({}));
|
||||
this.set('selectedAuth', 'token');
|
||||
await render(hbs`<AuthForm @cluster={{this.cluster}} @selectedAuth={{this.selectedAuth}} />`);
|
||||
await component.login();
|
||||
assert.strictEqual(component.errorText, 'Error Authentication failed: Not allowed');
|
||||
test('it disables sign in button when authIsRunning', async function (assert) {
|
||||
this.authIsRunning = true;
|
||||
await this.renderComponent();
|
||||
assert.dom(AUTH_FORM.login).isDisabled('sign in button is disabled');
|
||||
assert.dom(`${AUTH_FORM.login} [data-test-icon="loading"]`).exists('sign in button renders loading icon');
|
||||
});
|
||||
|
||||
test('it renders AdapterError style errors', async function (assert) {
|
||||
assert.expect(1);
|
||||
this.server.get('/auth/token/lookup-self', () => {
|
||||
return new Response(400, { 'Content-Type': 'application/json' }, { errors: ['API Error here'] });
|
||||
});
|
||||
|
||||
this.set('cluster', EmberObject.create({}));
|
||||
this.set('selectedAuth', 'token');
|
||||
await render(hbs`<AuthForm @cluster={{this.cluster}} @selectedAuth={{this.selectedAuth}} />`);
|
||||
return component.login().then(() => {
|
||||
assert.strictEqual(
|
||||
component.errorText,
|
||||
'Error Authentication failed: API Error here',
|
||||
'shows the error from the API'
|
||||
test('it renders alert info message when delayIsIdle', async function (assert) {
|
||||
this.delayIsIdle = true;
|
||||
this.authIsRunning = true;
|
||||
await this.renderComponent();
|
||||
assert
|
||||
.dom(GENERAL.inlineAlert)
|
||||
.hasText(
|
||||
'If login takes longer than usual, you may need to check your device for an MFA notification, or contact your administrator if login times out.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('it renders no tabs when no methods are passed', async function (assert) {
|
||||
const methods = {
|
||||
'approle/': {
|
||||
type: 'approle',
|
||||
},
|
||||
};
|
||||
this.server.get('/sys/internal/ui/mounts', () => {
|
||||
return { data: { auth: methods } };
|
||||
return {
|
||||
data: {
|
||||
auth: {
|
||||
'approle/': {
|
||||
type: 'approle',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
await render(hbs`<AuthForm @cluster={{this.cluster}} />`);
|
||||
await this.renderComponent();
|
||||
|
||||
assert.strictEqual(component.tabs.length, 0, 'renders a tab for every backend');
|
||||
server.shutdown();
|
||||
assert.dom(AUTH_FORM.tabs()).doesNotExist();
|
||||
});
|
||||
|
||||
test('it renders all the supported methods and Other tab when methods are present', async function (assert) {
|
||||
const methods = {
|
||||
'foo/': {
|
||||
type: 'userpass',
|
||||
},
|
||||
'approle/': {
|
||||
type: 'approle',
|
||||
},
|
||||
};
|
||||
this.server.get('/sys/internal/ui/mounts', () => {
|
||||
return { data: { auth: methods } };
|
||||
return {
|
||||
data: {
|
||||
auth: {
|
||||
'foo/': {
|
||||
type: 'userpass',
|
||||
},
|
||||
'approle/': {
|
||||
type: 'approle',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
this.set('cluster', EmberObject.create({}));
|
||||
await render(hbs`<AuthForm @cluster={{this.cluster}} />`);
|
||||
|
||||
assert.strictEqual(component.tabs.length, 2, 'renders a tab for userpass and Other');
|
||||
assert.strictEqual(component.tabs.objectAt(0).name, 'foo', 'uses the path in the label');
|
||||
assert.strictEqual(component.tabs.objectAt(1).name, 'Other', 'second tab is the Other tab');
|
||||
await this.renderComponent();
|
||||
|
||||
assert.dom(AUTH_FORM.tabs()).exists({ count: 2 });
|
||||
assert.dom(AUTH_FORM.tabs('foo')).exists('tab uses the path in the label');
|
||||
assert.dom(AUTH_FORM.tabs('other')).exists('second tab is the Other tab');
|
||||
});
|
||||
|
||||
test('it renders the description', async function (assert) {
|
||||
const methods = {
|
||||
'approle/': {
|
||||
type: 'userpass',
|
||||
description: 'app description',
|
||||
},
|
||||
};
|
||||
this.selectedAuth = null;
|
||||
this.server.get('/sys/internal/ui/mounts', () => {
|
||||
return { data: { auth: methods } };
|
||||
return {
|
||||
data: {
|
||||
auth: {
|
||||
'approle/': {
|
||||
type: 'userpass',
|
||||
description: 'app description',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
this.set('cluster', EmberObject.create({}));
|
||||
await render(hbs`<AuthForm @cluster={{this.cluster}} />`);
|
||||
|
||||
assert.strictEqual(
|
||||
component.descriptionText,
|
||||
'app description',
|
||||
'renders a description for auth methods'
|
||||
);
|
||||
});
|
||||
|
||||
test('it calls authenticate with the correct path', async function (assert) {
|
||||
this.owner.unregister('service:auth');
|
||||
this.owner.register('service:auth', workingAuthService);
|
||||
this.auth = this.owner.lookup('service:auth');
|
||||
const authSpy = sinon.spy(this.auth, 'authenticate');
|
||||
const methods = {
|
||||
'foo/': {
|
||||
type: 'userpass',
|
||||
},
|
||||
};
|
||||
this.server.get('/sys/internal/ui/mounts', () => {
|
||||
return { data: { auth: methods } };
|
||||
});
|
||||
|
||||
this.set('cluster', EmberObject.create({}));
|
||||
this.set('selectedAuth', 'foo/');
|
||||
await render(hbs`<AuthForm @cluster={{this.cluster}} @selectedAuth={{this.selectedAuth}} />`);
|
||||
await component.login();
|
||||
|
||||
await settled();
|
||||
assert.ok(authSpy.calledOnce, 'a call to authenticate was made');
|
||||
const { data } = authSpy.getCall(0).args[0];
|
||||
assert.strictEqual(data.path, 'foo', 'uses the id for the path');
|
||||
authSpy.restore();
|
||||
await this.renderComponent();
|
||||
assert.dom(AUTH_FORM.description).hasText('app description');
|
||||
});
|
||||
|
||||
test('it renders no tabs when no supported methods are present in passed methods', async function (assert) {
|
||||
@ -181,45 +129,14 @@ module('Integration | Component | auth form', function (hooks) {
|
||||
this.server.get('/sys/internal/ui/mounts', () => {
|
||||
return { data: { auth: methods } };
|
||||
});
|
||||
this.set('cluster', EmberObject.create({}));
|
||||
await render(hbs`<AuthForm @cluster={{this.cluster}} />`);
|
||||
await this.renderComponent();
|
||||
|
||||
server.shutdown();
|
||||
assert.strictEqual(component.tabs.length, 0, 'renders a tab for every backend');
|
||||
});
|
||||
|
||||
test('it makes a request to unwrap if passed a wrappedToken and logs in', async function (assert) {
|
||||
assert.expect(3);
|
||||
this.owner.register('service:auth', workingAuthService);
|
||||
this.auth = this.owner.lookup('service:auth');
|
||||
const authSpy = sinon.stub(this.auth, 'authenticate');
|
||||
this.server.post('/sys/wrapping/unwrap', (_, req) => {
|
||||
assert.strictEqual(req.url, '/v1/sys/wrapping/unwrap', 'makes call to unwrap the token');
|
||||
assert.strictEqual(
|
||||
req.requestHeaders['X-Vault-Token'],
|
||||
wrappedToken,
|
||||
'uses passed wrapped token for the unwrap'
|
||||
);
|
||||
return {
|
||||
auth: {
|
||||
client_token: '12345',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const wrappedToken = '54321';
|
||||
this.set('wrappedToken', wrappedToken);
|
||||
this.set('cluster', EmberObject.create({}));
|
||||
await render(
|
||||
hbs`<AuthForm @cluster={{this.cluster}} @wrappedToken={{this.wrappedToken}} @onSuccess={{this.onSuccess}} />`
|
||||
);
|
||||
later(() => cancelTimers(), 50);
|
||||
await settled();
|
||||
assert.ok(authSpy.calledOnce, 'a call to authenticate was made');
|
||||
authSpy.restore();
|
||||
assert.dom(AUTH_FORM.tabs()).doesNotExist();
|
||||
});
|
||||
|
||||
test('it shows an error if unwrap errors', async function (assert) {
|
||||
assert.expect(1);
|
||||
this.wrappedToken = '54321';
|
||||
this.server.post('/sys/wrapping/unwrap', () => {
|
||||
return new Response(
|
||||
400,
|
||||
@ -228,16 +145,11 @@ module('Integration | Component | auth form', function (hooks) {
|
||||
);
|
||||
});
|
||||
|
||||
this.set('wrappedToken', '54321');
|
||||
await render(hbs`<AuthForm @cluster={{this.cluster}} @wrappedToken={{this.wrappedToken}} />`);
|
||||
await this.renderComponent();
|
||||
later(() => cancelTimers(), 50);
|
||||
|
||||
await settled();
|
||||
assert.strictEqual(
|
||||
component.errorText,
|
||||
'Error Token unwrap failed: There was an error unwrapping!',
|
||||
'shows the error'
|
||||
);
|
||||
assert.dom(GENERAL.messageError).hasText('Error Token unwrap failed: There was an error unwrapping!');
|
||||
});
|
||||
|
||||
test('it should retain oidc role when mount path is changed', async function (assert) {
|
||||
@ -269,36 +181,14 @@ module('Integration | Component | auth form', function (hooks) {
|
||||
},
|
||||
});
|
||||
|
||||
this.set('cluster', EmberObject.create({}));
|
||||
await render(hbs`<AuthForm @cluster={{this.cluster}} />`);
|
||||
await this.renderComponent();
|
||||
|
||||
await component.selectMethod('oidc');
|
||||
await component.oidcRole('foo');
|
||||
await component.oidcMoreOptions();
|
||||
await component.oidcMountPath('foo-oidc');
|
||||
assert.dom('[data-test-role]').hasValue('foo', 'role is retained when mount path is changed');
|
||||
await component.login();
|
||||
});
|
||||
|
||||
test('it should set nonce value as uuid for okta method type', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
this.server.post('/auth/okta/login/foo', (_, req) => {
|
||||
const { nonce } = JSON.parse(req.requestBody);
|
||||
assert.true(validate(nonce), 'Nonce value passed as uuid for okta login');
|
||||
return {
|
||||
auth: {
|
||||
client_token: '12345',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
this.set('cluster', EmberObject.create({}));
|
||||
await render(hbs`<AuthForm @cluster={{this.cluster}} />`);
|
||||
|
||||
await component.selectMethod('okta');
|
||||
await component.username('foo');
|
||||
await component.password('bar');
|
||||
await component.login();
|
||||
await fillIn(GENERAL.selectByAttr('auth-method'), 'oidc');
|
||||
await fillIn(AUTH_FORM.input('role'), 'foo');
|
||||
await click(AUTH_FORM.moreOptions);
|
||||
await fillIn(AUTH_FORM.input('role'), 'foo');
|
||||
await fillIn(AUTH_FORM.mountPathInput, 'foo-oidc');
|
||||
assert.dom(AUTH_FORM.input('role')).hasValue('foo', 'role is retained when mount path is changed');
|
||||
await click(AUTH_FORM.login);
|
||||
});
|
||||
});
|
||||
|
||||
205
ui/tests/integration/components/auth/page-test.js
Normal file
205
ui/tests/integration/components/auth/page-test.js
Normal file
@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { later, _cancelTimers as cancelTimers } from '@ember/runloop';
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { click, fillIn, render, settled } from '@ember/test-helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import sinon from 'sinon';
|
||||
import { validate } from 'uuid';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { Response } from 'miragejs';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
|
||||
|
||||
module('Integration | Component | auth | page ', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.router = this.owner.lookup('service:router');
|
||||
this.auth = this.owner.lookup('service:auth');
|
||||
this.cluster = { id: '1' };
|
||||
this.selectedAuth = 'token';
|
||||
this.onSuccess = sinon.spy();
|
||||
|
||||
this.renderComponent = async () => {
|
||||
return render(hbs`
|
||||
<Auth::Page
|
||||
@wrappedToken={{this.wrappedToken}}
|
||||
@cluster={{this.cluster}}
|
||||
@namespace={{this.namespaceQueryParam}}
|
||||
@selectedAuth={{this.authMethod}}
|
||||
@onSuccess={{this.onSuccess}}
|
||||
/>
|
||||
`);
|
||||
};
|
||||
});
|
||||
const CSP_ERR_TEXT = `Error This is a standby Vault node but can't communicate with the active node via request forwarding. Sign in at the active node to use the Vault UI.`;
|
||||
test('it renders error on CSP violation', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.cluster.standby = true;
|
||||
await this.renderComponent();
|
||||
assert.dom(GENERAL.messageError).doesNotExist();
|
||||
this.owner.lookup('service:csp-event').handleEvent({ violatedDirective: 'connect-src' });
|
||||
await settled();
|
||||
assert.dom(GENERAL.messageError).hasText(CSP_ERR_TEXT);
|
||||
});
|
||||
|
||||
test('it renders with vault style errors', async function (assert) {
|
||||
assert.expect(1);
|
||||
this.server.get('/auth/token/lookup-self', () => {
|
||||
return new Response(400, { 'Content-Type': 'application/json' }, { errors: ['Not allowed'] });
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
await click(AUTH_FORM.login);
|
||||
assert.dom(GENERAL.messageError).hasText('Error Authentication failed: Not allowed');
|
||||
});
|
||||
|
||||
test('it renders AdapterError style errors', async function (assert) {
|
||||
assert.expect(1);
|
||||
this.server.get('/auth/token/lookup-self', () => {
|
||||
return new Response(400, { 'Content-Type': 'application/json' }, { errors: ['API Error here'] });
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
await click(AUTH_FORM.login);
|
||||
assert
|
||||
.dom(GENERAL.messageError)
|
||||
.hasText('Error Authentication failed: API Error here', 'shows the error from the API');
|
||||
});
|
||||
|
||||
test('it calls auth service authenticate method with expected args', async function (assert) {
|
||||
assert.expect(1);
|
||||
const authenticateStub = sinon.stub(this.auth, 'authenticate');
|
||||
this.selectedAuth = 'foo/'; // set to a non-default path
|
||||
this.server.get('/sys/internal/ui/mounts', () => {
|
||||
return {
|
||||
data: {
|
||||
auth: {
|
||||
'foo/': {
|
||||
type: 'userpass',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
await fillIn(AUTH_FORM.input('username'), 'sandy');
|
||||
await fillIn(AUTH_FORM.input('password'), '1234');
|
||||
await click(AUTH_FORM.login);
|
||||
const [actual] = authenticateStub.lastCall.args;
|
||||
const expectedArgs = {
|
||||
backend: 'userpass',
|
||||
clusterId: '1',
|
||||
data: {
|
||||
username: 'sandy',
|
||||
password: '1234',
|
||||
path: 'foo',
|
||||
},
|
||||
selectedAuth: 'foo/',
|
||||
};
|
||||
assert.propEqual(
|
||||
actual,
|
||||
expectedArgs,
|
||||
`it calls auth service authenticate method with expected args: ${JSON.stringify(actual)} `
|
||||
);
|
||||
});
|
||||
|
||||
test('it calls onSuccess with expected args', async function (assert) {
|
||||
assert.expect(3);
|
||||
this.server.get(`auth/token/lookup-self`, () => {
|
||||
return {
|
||||
data: {
|
||||
policies: ['default'],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
await fillIn(AUTH_FORM.input('token'), 'mytoken');
|
||||
await click(AUTH_FORM.login);
|
||||
const [authResponse, backendType, data] = this.onSuccess.lastCall.args;
|
||||
const expected = { isRoot: false, namespace: '', token: 'vault-token☃1' };
|
||||
|
||||
assert.propEqual(
|
||||
authResponse,
|
||||
expected,
|
||||
`it calls onSuccess with response: ${JSON.stringify(authResponse)} `
|
||||
);
|
||||
assert.strictEqual(backendType, 'token', `it calls onSuccess with backend type: ${backendType}`);
|
||||
assert.propEqual(data, { token: 'mytoken' }, `it calls onSuccess with data: ${JSON.stringify(data)}`);
|
||||
});
|
||||
|
||||
test('it makes a request to unwrap if passed a wrappedToken and logs in', async function (assert) {
|
||||
assert.expect(3);
|
||||
const authenticateStub = sinon.stub(this.auth, 'authenticate');
|
||||
this.wrappedToken = '54321';
|
||||
|
||||
this.server.post('/sys/wrapping/unwrap', (_, req) => {
|
||||
assert.strictEqual(req.url, '/v1/sys/wrapping/unwrap', 'makes call to unwrap the token');
|
||||
assert.strictEqual(
|
||||
req.requestHeaders['X-Vault-Token'],
|
||||
this.wrappedToken,
|
||||
'uses passed wrapped token for the unwrap'
|
||||
);
|
||||
return {
|
||||
auth: {
|
||||
client_token: '12345',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
later(() => cancelTimers(), 50);
|
||||
await settled();
|
||||
const [actual] = authenticateStub.lastCall.args;
|
||||
assert.propEqual(
|
||||
actual,
|
||||
{
|
||||
backend: 'token',
|
||||
clusterId: '1',
|
||||
data: {
|
||||
token: '12345',
|
||||
},
|
||||
selectedAuth: 'token',
|
||||
},
|
||||
`it calls auth service authenticate method with correct args: ${JSON.stringify(actual)} `
|
||||
);
|
||||
});
|
||||
|
||||
test('it should set nonce value as uuid for okta method type', async function (assert) {
|
||||
assert.expect(4);
|
||||
this.server.post('/auth/okta/login/foo', (_, req) => {
|
||||
const { nonce } = JSON.parse(req.requestBody);
|
||||
assert.true(validate(nonce), 'Nonce value passed as uuid for okta login');
|
||||
return {
|
||||
auth: {
|
||||
client_token: '12345',
|
||||
policies: ['default'],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
await fillIn(GENERAL.selectByAttr('auth-method'), 'okta');
|
||||
await fillIn(AUTH_FORM.input('username'), 'foo');
|
||||
await fillIn(AUTH_FORM.input('password'), 'bar');
|
||||
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. Please wait... Back to login',
|
||||
'renders okta number challenge on submit'
|
||||
);
|
||||
await click('[data-test-back-button]');
|
||||
assert.dom(AUTH_FORM.form).exists('renders auth form on return to login');
|
||||
assert.dom(GENERAL.selectByAttr('auth-method')).hasValue('okta', 'preserves method type on back');
|
||||
});
|
||||
});
|
||||
@ -7,32 +7,40 @@ import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import sinon from 'sinon';
|
||||
|
||||
module('Integration | Component | okta-number-challenge', function (hooks) {
|
||||
module('Integration | Component | auth | okta-number-challenge', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.oktaNumberChallengeAnswer = null;
|
||||
this.hasError = false;
|
||||
this.onCancel = sinon.spy();
|
||||
this.renderComponent = async () => {
|
||||
return render(hbs`
|
||||
<OktaNumberChallenge
|
||||
@correctAnswer={{this.oktaNumberChallengeAnswer}}
|
||||
@hasError={{this.hasError}}
|
||||
@onReturnToLogin={{this.onCancel}}
|
||||
/>
|
||||
`);
|
||||
};
|
||||
});
|
||||
|
||||
test('it should render correct descriptions', async function (assert) {
|
||||
await render(hbs`<OktaNumberChallenge @correctAnswer={{this.oktaNumberChallengeAnswer}}/>`);
|
||||
|
||||
await this.renderComponent();
|
||||
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');
|
||||
assert.dom('[data-test-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`<OktaNumberChallenge @correctAnswer={{this.oktaNumberChallengeAnswer}}/>`);
|
||||
this.oktaNumberChallengeAnswer = 1;
|
||||
await this.renderComponent();
|
||||
assert
|
||||
.dom('[data-test-okta-number-challenge-description]')
|
||||
.includesText(
|
||||
@ -40,35 +48,30 @@ module('Integration | Component | okta-number-challenge', function (hooks) {
|
||||
'Correct description renders'
|
||||
);
|
||||
assert
|
||||
.dom('[data-test-okta-number-challenge-verification-type]')
|
||||
.dom('[data-test-verification-type]')
|
||||
.includesText('Okta verification', 'Correct verification type renders');
|
||||
|
||||
assert
|
||||
.dom('[data-test-okta-number-challenge-verification-description]')
|
||||
.dom('[data-test-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');
|
||||
assert.dom('[data-test-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`<OktaNumberChallenge @correctAnswer={{this.oktaNumberChallengeAnswer}} @hasError={{this.hasError}} @onReturnToLogin={{fn (mut this.returnToLogin) true}}/>`
|
||||
);
|
||||
this.hasError = 'Authentication failed: multi-factor authentication denied';
|
||||
await this.renderComponent();
|
||||
|
||||
assert
|
||||
.dom('[data-test-okta-number-challenge-description]')
|
||||
.includesText(
|
||||
.hasTextContaining(
|
||||
'To finish signing in, you will need to complete an additional MFA step.',
|
||||
'Correct description renders'
|
||||
);
|
||||
assert
|
||||
.dom('[data-test-message-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');
|
||||
assert.dom('[data-test-message-error]').hasText(`Error ${this.hasError}`);
|
||||
await click('[data-test-back-button]');
|
||||
assert.true(this.onCancel.calledOnce, 'onCancel is called');
|
||||
});
|
||||
});
|
||||
|
||||
@ -16,24 +16,23 @@ module('Unit | Component | auth-form', function (hooks) {
|
||||
const component = this.owner.lookup('component:auth-form');
|
||||
component.reopen({
|
||||
methods: [], // eslint-disable-line
|
||||
// performAuth is a callback passed from the parent component
|
||||
// that is called in the return of the doSubmit method
|
||||
// this component is not glimmerized and testing this functionality
|
||||
// in an integration test requires additional role setup so
|
||||
// stubbing here to test it is called with the correct args
|
||||
// eslint-disable-next-line
|
||||
authenticate: {
|
||||
unlinked() {
|
||||
return {
|
||||
perform(type, data) {
|
||||
assert.deepEqual(
|
||||
type,
|
||||
'token',
|
||||
`Token type correctly passed to authenticate method for ${component.providerName}`
|
||||
);
|
||||
assert.deepEqual(
|
||||
data,
|
||||
{ token: component.token },
|
||||
`Token passed to authenticate method for ${component.providerName}`
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
performAuth(type, data) {
|
||||
assert.deepEqual(
|
||||
type,
|
||||
'token',
|
||||
`Token type correctly passed to authenticate method for ${component.providerName}`
|
||||
);
|
||||
assert.deepEqual(
|
||||
data,
|
||||
{ token: component.token },
|
||||
`Token passed to authenticate method for ${component.providerName}`
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user