diff --git a/ui/app/components/auth/fields.hbs b/ui/app/components/auth/fields.hbs new file mode 100644 index 0000000000..f4afd32827 --- /dev/null +++ b/ui/app/components/auth/fields.hbs @@ -0,0 +1,22 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{#each @loginFields as |field|}} + {{#let field.name field.label field.helperText as |name label helperText|}} + + {{or label (capitalize name)}} + {{#if helperText}} + {{helperText}} + {{/if}} + + {{/let}} +{{/each}} \ No newline at end of file diff --git a/ui/app/components/auth/fields.ts b/ui/app/components/auth/fields.ts new file mode 100644 index 0000000000..f1f2bd15f8 --- /dev/null +++ b/ui/app/components/auth/fields.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// TODO pending feedback from the security team, we may keep autocomplete="off" for login fields + +import Component from '@glimmer/component'; + +interface Args { + loginFields: Field[]; +} + +interface Field { + name: string; // sets input name + label?: string; // label will be "name" capitalized unless label exists + helperText?: string; +} + +export default class AuthFields extends Component { + // token or password should render as "password" types, otherwise render text inputs + setInputType = (field: string) => (['token', 'password'].includes(field) ? 'password' : 'text'); + + setAutocomplete = (fieldName: string) => { + switch (fieldName) { + case 'password': + return 'current-password'; + case 'token': + return 'off'; + default: + return fieldName; + } + }; +} diff --git a/ui/app/components/auth/form-template.hbs b/ui/app/components/auth/form-template.hbs new file mode 100644 index 0000000000..faca4824ec --- /dev/null +++ b/ui/app/components/auth/form-template.hbs @@ -0,0 +1,107 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{#if this.formComponent}} + {{#let (component this.formComponent) as |AuthFormComponent|}} + {{! renders Auth::Form::Base or Auth::Form::}} + + <:namespace> + {{#if this.version.isEnterprise}} + + {{/if}} + + + <:back> + {{#if this.showOtherMethods}} + + {{/if}} + + + {{! TABS OR DROPDOWN }} + <:authSelectOptions> +
+ {{#if this.renderTabs}} + + {{else}} + + Auth method + + {{#each this.availableMethodTypes as |type|}} + + {{/each}} + + + {{/if}} +
+ + + <:error> + {{#if this.errorMessage}} + + {{/if}} + + + <:advancedSettings> + {{! tabs render their own mount path inputs and token does not support custom paths }} + {{#if (and (not this.renderTabs) (not-eq this.selectedAuthMethod "token"))}} + + + Mount path + + If this authentication method was mounted using a non-default path, input it below. Otherwise Vault will + assume the default path + {{this.selectedAuthMethod}} + . + + + {{/if}} + + + <:footer> + {{#if this.renderTabs}} + + {{/if}} + +
+ {{/let}} +{{/if}} \ No newline at end of file diff --git a/ui/app/components/auth/form-template.ts b/ui/app/components/auth/form-template.ts new file mode 100644 index 0000000000..f85cf02a0c --- /dev/null +++ b/ui/app/components/auth/form-template.ts @@ -0,0 +1,213 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { restartableTask, timeout } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; +import { ALL_LOGIN_METHODS, supportedTypes } from 'vault/utils/supported-login-methods'; +import { getRelativePath } from 'core/utils/sanitize-path'; + +import type FlagsService from 'vault/services/flags'; +import type Store from '@ember-data/store'; +import type VersionService from 'vault/services/version'; +import type ClusterModel from 'vault/models/cluster'; +import type { HTMLElementEvent } from 'vault/forms'; + +/** + * @module Auth::FormTemplate + * This component is responsible for managing the layout and display logic for the auth form. When initialized it fetches + * the unauthenticated sys/internal/ui/mounts endpoint to check the listing_visibility configuration of available mounts. + * If mounts have been configured as listing_visibility="unauth" then tabs render for the corresponding method types, + * otherwise all auth methods display in a dropdown list. The endpoint is re-requested anytime the namespace input is updated. + * + * When auth type changes (by selecting a new one from the dropdown or select a tab), the form component updates and + * dynamically renders the corresponding form. + * + * + * @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 {function} handleNamespaceUpdate - callback task that passes user input to the controller and updates the namespace query param in the url + * @param {string} namespace - namespace query param from the url + * @param {function} onSuccess - callback after the initial authentication request, if an mfa_requirement exists the parent renders the mfa form otherwise it fires the authSuccess action in the auth controller and handles transitioning to the app + * + * */ + +interface Args { + wrappedToken: string; + cluster: ClusterModel; + handleNamespaceUpdate: CallableFunction; + namespace: string; + onSuccess: CallableFunction; +} + +interface AuthTabs { + // key is the auth method type + [key: string]: MountData[]; +} + +interface MountData { + path: string; + type: string; + description?: string; + config?: object | null; +} + +export default class AuthFormTemplate extends Component { + @service declare readonly flags: FlagsService; + @service declare readonly store: Store; + @service declare readonly version: VersionService; + + // form display logic + @tracked authTabs: AuthTabs | null = null; + @tracked showOtherMethods = false; + + // auth login variables + @tracked selectedAuthMethod = 'token'; + @tracked errorMessage = ''; + + displayName = (type: string) => { + const displayName = ALL_LOGIN_METHODS?.find((t) => t.type === type)?.displayName; + return displayName || type; + }; + + constructor(owner: unknown, args: Args) { + super(owner, args); + this.fetchMounts.perform(); + } + + get availableMethodTypes() { + return supportedTypes(this.version.isEnterprise); + } + + get formComponent() { + const { selectedAuthMethod } = this; + // isSupported means there is a component file defined for that auth type + const isSupported = this.availableMethodTypes.includes(selectedAuthMethod); + const formFile = () => (['oidc', 'jwt'].includes(selectedAuthMethod) ? 'oidc-jwt' : selectedAuthMethod); + const component = isSupported ? formFile() : 'base'; + + // an Auth::Form:: component exists for each method in supported-login-methods + return `auth/form/${component}`; + } + + get namespaceInput() { + const namespaceQueryParam = this.args.namespace; + if (this.flags.hvdManagedNamespaceRoot) { + // When managed, the user isn't allowed to edit the prefix `admin/` + // so prefill just the relative path in the namespace input + const path = getRelativePath(namespaceQueryParam, this.flags.hvdManagedNamespaceRoot); + return path ? `/${path}` : ''; + } + return namespaceQueryParam; + } + + get renderTabs() { + // renders tabs if listing visibility is set (auth tabs exist) + // and user has NOT clicked "Sign in with other" + if (this.authTabs && !this.showOtherMethods) { + return true; + } + return false; + } + + get selectedTabIndex() { + if (this.authTabs) { + return Object.keys(this.authTabs).indexOf(this.selectedAuthMethod); + } + return 0; + } + + setAuthTypeFromTab(idx: number) { + const authTypes = this.authTabs ? Object.keys(this.authTabs) : []; + this.selectedAuthMethod = authTypes[idx] || ''; + } + + @action + handleAuthSelect(element: string, event: HTMLElementEvent | null, idx: number) { + if (element === 'tab') { + this.setAuthTypeFromTab(idx); + } else if (event?.target?.value) { + this.selectedAuthMethod = event.target.value; + } + } + + @action + toggleView() { + this.showOtherMethods = !this.showOtherMethods; + + if (this.renderTabs) { + // reset selected auth method to first tab + this.handleAuthSelect('tab', null, 0); + } else { + // all methods render, reset dropdown + this.selectedAuthMethod = 'token'; + } + } + + @action + handleError(message: string) { + this.errorMessage = message; + } + + @action + handleNamespaceUpdate(event: HTMLElementEvent) { + // update query param + this.args.handleNamespaceUpdate(event.target.value); + // reset tabs + this.authTabs = null; + // fetch mounts for that namespace + this.fetchMounts.perform(500); + } + + fetchMounts = restartableTask( + waitFor(async (wait = 0) => { + // task is `restartable` so if the user starts typing again, + // it will cancel and restart from the beginning. + if (wait) await timeout(wait); + + try { + // clear ember data store before re-requesting.. :( + this.store.unloadAll('auth-method'); + + // unauthMounts are tuned with listing_visibility="unauth" + const unauthMounts = await this.store.findAll('auth-method', { + adapterOptions: { + unauthenticated: true, + }, + }); + + if (unauthMounts.length !== 0) { + this.authTabs = unauthMounts.reduce((obj: AuthTabs, m) => { + // serialize the ember data model into a regular ol' object + const mountData = m.serialize(); + const methodType = mountData.type; + if (!Object.keys(obj).includes(methodType)) { + // create a new empty array for that type + obj[methodType] = []; + } + + if (Array.isArray(obj[methodType])) { + // push mount data into corresponding type's array + obj[methodType].push(mountData); + } + + return obj; + }, {}); + + // set tracked selected auth type to first tab + this.setAuthTypeFromTab(0); + // hide other methods to prioritize tabs (visible mounts) + this.showOtherMethods = false; + } + } catch (e) { + // if for some reason there's an error fetching mounts, swallow and just show standard form + this.authTabs = null; + } + }) + ); +} diff --git a/ui/app/components/auth/form/base.hbs b/ui/app/components/auth/form/base.hbs new file mode 100644 index 0000000000..34107cc303 --- /dev/null +++ b/ui/app/components/auth/form/base.hbs @@ -0,0 +1,29 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+ + {{yield to="namespace"}} + +
+ {{yield to="back"}} + {{yield to="authSelectOptions"}} + {{yield to="error"}} + + + + {{yield to="advancedSettings"}} + + + + {{yield to="footer"}} +
+
\ No newline at end of file diff --git a/ui/app/components/auth/form/base.ts b/ui/app/components/auth/form/base.ts new file mode 100644 index 0000000000..f28cd0e945 --- /dev/null +++ b/ui/app/components/auth/form/base.ts @@ -0,0 +1,79 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; + +import type AuthService from 'vault/vault/services/auth'; +import type ClusterModel from 'vault/models/cluster'; +import type { HTMLElementEvent } from 'vault/forms'; + +/** + * @module Auth::Base + * + * @param {string} authType - chosen login method type + * @param {object} cluster - The cluster model which contains information such as cluster id, name and boolean for if the cluster is in standby + * @param {function} onError - callback if there is a login error + * @param {function} onSuccess - calls onAuthResponse in auth/page redirects if successful + */ + +interface Args { + authType: string; + cluster: ClusterModel; + onError: CallableFunction; + onSuccess: CallableFunction; +} + +export default class AuthBase extends Component { + @service declare readonly auth: AuthService; + + @action + onSubmit(event: HTMLElementEvent) { + event.preventDefault(); + const formData = new FormData(event.target as HTMLFormElement); + const data: Record = {}; + + for (const key of formData.keys()) { + data[key] = formData.get(key); + } + + this.login.unlinked().perform(data); + } + + login = task( + waitFor(async (data) => { + try { + const authResponse = await this.auth.authenticate({ + clusterId: this.args.cluster.id, + backend: this.args.authType, + data, + selectedAuth: this.args.authType, + }); + + // responsible for redirect after auth data is persisted + this.onSuccess(authResponse); + } catch (error) { + this.onError(error as Error); + } + }) + ); + + // if we move auth service authSuccess method here (or to each auth method component) + // then call that before calling parent this.args.onSuccess + onSuccess(authResponse: object) { + // responsible for redirect after auth data is persisted + this.args.onSuccess(authResponse, this.args.authType); + } + + onError(error: Error) { + if (!this.auth.mfaErrors) { + const errorMessage = `Authentication failed: ${this.auth.handleError(error)}`; + this.args.onError(errorMessage); + } + } +} diff --git a/ui/app/components/auth/form/github.js b/ui/app/components/auth/form/github.js new file mode 100644 index 0000000000..6ab336fc74 --- /dev/null +++ b/ui/app/components/auth/form/github.js @@ -0,0 +1,15 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import AuthBase from './base'; + +/** + * @module Auth::Form::Github + * see Auth::Base + */ + +export default class AuthFormGithub extends AuthBase { + loginFields = [{ name: 'token', label: 'Github token' }]; +} diff --git a/ui/app/components/auth/form/ldap.js b/ui/app/components/auth/form/ldap.js new file mode 100644 index 0000000000..15dec866ce --- /dev/null +++ b/ui/app/components/auth/form/ldap.js @@ -0,0 +1,15 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import AuthBase from './base'; + +/** + * @module Auth::Form::Ldap + * see Auth::Base + */ + +export default class AuthFormLdap extends AuthBase { + loginFields = [{ name: 'username' }, { name: 'password' }]; +} diff --git a/ui/app/components/auth/form/oidc-jwt.js b/ui/app/components/auth/form/oidc-jwt.js new file mode 100644 index 0000000000..516d1986f8 --- /dev/null +++ b/ui/app/components/auth/form/oidc-jwt.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import AuthBase from './base'; + +/** + * @module Auth::Form::OidcJwt + * see Auth::Base + * + * OIDC can be configured at 'jwt' or 'oidc', see https://developer.hashicorp.com/vault/docs/auth/jwt + * we use the same template because displaying the JWT token input depends on the error message returned when fetching + * the role + */ + +export default class AuthFormOidcJwt extends AuthBase { + loginFields = [ + { + name: 'role', + helperText: 'Vault will use the default role to sign in if this field is left blank.', + }, + ]; +} diff --git a/ui/app/components/auth/form/okta.js b/ui/app/components/auth/form/okta.js new file mode 100644 index 0000000000..5cdc6847ee --- /dev/null +++ b/ui/app/components/auth/form/okta.js @@ -0,0 +1,15 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import AuthBase from './base'; + +/** + * @module Auth::Form::Okta + * see Auth::Base + * */ + +export default class AuthFormOkta extends AuthBase { + loginFields = [{ name: 'username' }, { name: 'password' }]; +} diff --git a/ui/app/components/auth/form/radius.js b/ui/app/components/auth/form/radius.js new file mode 100644 index 0000000000..143d7e86ab --- /dev/null +++ b/ui/app/components/auth/form/radius.js @@ -0,0 +1,15 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import AuthBase from './base'; + +/** + * @module Auth::Form::Radius + * see Auth::Base + */ + +export default class AuthFormRadius extends AuthBase { + loginFields = [{ name: 'username' }, { name: 'password' }]; +} diff --git a/ui/app/components/auth/form/saml.js b/ui/app/components/auth/form/saml.js new file mode 100644 index 0000000000..e330f250ee --- /dev/null +++ b/ui/app/components/auth/form/saml.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import AuthBase from './base'; + +/** + * @module Auth::Form::Saml + * see Auth::Base + */ + +export default class AuthFormSaml extends AuthBase { + loginFields = [ + { + name: 'role', + helperText: 'Vault will use the default role to sign in if this field is left blank.', + }, + ]; +} diff --git a/ui/app/components/auth/form/token.js b/ui/app/components/auth/form/token.js new file mode 100644 index 0000000000..130017d4a2 --- /dev/null +++ b/ui/app/components/auth/form/token.js @@ -0,0 +1,15 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import AuthBase from './base'; + +/** + * @module Auth::Form::Token + * see Auth::Base + * */ + +export default class AuthFormToken extends AuthBase { + loginFields = [{ name: 'token' }]; +} diff --git a/ui/app/components/auth/form/userpass.js b/ui/app/components/auth/form/userpass.js new file mode 100644 index 0000000000..d6c00e08bd --- /dev/null +++ b/ui/app/components/auth/form/userpass.js @@ -0,0 +1,15 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import AuthBase from './base'; + +/** + * @module Auth::Form::Userpass + * + * */ + +export default class AuthFormUserpass extends AuthBase { + loginFields = [{ name: 'username' }, { name: 'password' }]; +} diff --git a/ui/app/components/auth/namespace-input.hbs b/ui/app/components/auth/namespace-input.hbs new file mode 100644 index 0000000000..e0494f4a30 --- /dev/null +++ b/ui/app/components/auth/namespace-input.hbs @@ -0,0 +1,47 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+ {{#if @hvdManagedNamespace}} + + Namespace + + + + + + + + {{else}} + + Namespace + + {{/if}} +
\ No newline at end of file diff --git a/ui/app/components/auth/tabs.hbs b/ui/app/components/auth/tabs.hbs new file mode 100644 index 0000000000..c1e5193eac --- /dev/null +++ b/ui/app/components/auth/tabs.hbs @@ -0,0 +1,45 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + + + {{#each-in @authTabs as |methodType mounts|}} + {{@displayNameHelper methodType}} + +
+ {{! Elements "behind" tabs always render on the DOM and are just superficially hidden/shown. + However, for accessibility, we only want to render form inputs relevant to the selected method. + By wrapping the elements in this conditional, it only renders them when the tab is selected. }} + {{#if (eq @selectedAuthMethod methodType)}} + {{#if (gt mounts.length 1)}} + {{! render dropdown of mount paths }} + + Mount path + + {{#each mounts as |mount|}} + + {{/each}} + + + {{else}} + {{#let (get mounts "0") as |mount|}} + {{#if (and (eq @selectedAuthMethod "token") mount.description)}} + {{! the token auth method does't support a custom path }} + {{mount.description}} data-test-description + {{else}} + {{! if it's the only available mount path render a readonly input }} + + Mount path + {{#if mount.description}} + {{mount.description}} + {{/if}} + + {{/if}} + {{/let}} + {{/if}} + {{/if}} +
+
+ {{/each-in}} +
\ No newline at end of file diff --git a/ui/app/styles/helper-classes/colors.scss b/ui/app/styles/helper-classes/colors.scss index 0d1056b434..179aa7fcd2 100644 --- a/ui/app/styles/helper-classes/colors.scss +++ b/ui/app/styles/helper-classes/colors.scss @@ -8,6 +8,10 @@ /* This helper includes styles referencing background color, border color, and text color. */ // background colors +.background-neutral-50 { + background: color_variables.$neutral-50; +} + .has-background-white-bis { background: color_variables.$ui-gray-050; } diff --git a/ui/app/styles/utils/_color_variables.scss b/ui/app/styles/utils/_color_variables.scss index 7b714b2d14..05986d5a43 100644 --- a/ui/app/styles/utils/_color_variables.scss +++ b/ui/app/styles/utils/_color_variables.scss @@ -3,6 +3,16 @@ * SPDX-License-Identifier: BUSL-1.1 */ +// HDS TOKENS + +// Grey +$neutral-50: var(--token-color-palette-neutral-50); + +/* +DEPRECATED +below variables are deprecated, use HDS tokens instead +*/ + // UI Gray $ui-gray-010: #fbfbfc; $ui-gray-050: #f7f8fa; diff --git a/ui/app/utils/supported-login-methods.ts b/ui/app/utils/supported-login-methods.ts new file mode 100644 index 0000000000..244dc1d76f --- /dev/null +++ b/ui/app/utils/supported-login-methods.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +/** + * The web UI only supports logging in with these auth methods. + * The method data is all related to logic for authenticating via that method. + * This is a subset of the methods found in the `mountable-auth-methods` util, + * which lists all the methods that can be enabled and mounted. + */ + +export const BASE_LOGIN_METHODS = [ + { + type: 'token', + displayName: 'Token', + }, + { + type: 'userpass', + displayName: 'Username', + }, + { + type: 'ldap', + displayName: 'LDAP', + }, + { + type: 'okta', + displayName: 'Okta', + }, + { + type: 'jwt', + displayName: 'JWT', + }, + { + type: 'oidc', + displayName: 'OIDC', + }, + { + type: 'radius', + displayName: 'RADIUS', + }, + { + type: 'github', + displayName: 'GitHub', + }, +]; + +export const ENTERPRISE_LOGIN_METHODS = [ + { + type: 'saml', + displayName: 'SAML', + }, +]; + +export const ALL_LOGIN_METHODS = [...BASE_LOGIN_METHODS, ...ENTERPRISE_LOGIN_METHODS]; + +export const supportedTypes = (isEnterprise: boolean) => + isEnterprise ? ALL_LOGIN_METHODS.map((m) => m.type) : BASE_LOGIN_METHODS.map((m) => m.type); diff --git a/ui/tests/acceptance/auth-list-test.js b/ui/tests/acceptance/auth/auth-list-test.js similarity index 100% rename from ui/tests/acceptance/auth-list-test.js rename to ui/tests/acceptance/auth/auth-list-test.js diff --git a/ui/tests/acceptance/auth-test.js b/ui/tests/acceptance/auth/auth-test.js similarity index 82% rename from ui/tests/acceptance/auth-test.js rename to ui/tests/acceptance/auth/auth-test.js index 6c315607c9..261483e5e6 100644 --- a/ui/tests/acceptance/auth-test.js +++ b/ui/tests/acceptance/auth/auth-test.js @@ -5,7 +5,7 @@ import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; -import { click, currentURL, visit, waitUntil, find, fillIn } from '@ember/test-helpers'; +import { click, currentURL, visit, waitUntil, find, fillIn, typeIn } from '@ember/test-helpers'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { allSupportedAuthBackends, supportedAuthBackends } from 'vault/helpers/supported-auth-backends'; import VAULT_KEYS from 'vault/tests/helpers/vault-keys'; @@ -16,7 +16,7 @@ import { mountEngineCmd, runCmd, } from 'vault/tests/helpers/commands'; -import { login, loginMethod, loginNs, logout } from 'vault/tests/helpers/auth/auth-helpers'; +import { login, loginMethod, loginNs } from 'vault/tests/helpers/auth/auth-helpers'; import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import { v4 as uuidv4 } from 'uuid'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; @@ -212,62 +212,44 @@ module('Acceptance | auth', function (hooks) { assert.strictEqual(currentURL(), '/vault/dashboard'); }); - module('Enterprise', function (hooks) { - hooks.beforeEach(async function () { + module('Enterprise', function () { + // this test is specifically to cover a token renewal bug within namespaces + // namespace_path isn't returned by the renew-self response and so the auth service was + // incorrectly setting userRootNamespace to '' (which denotes 'root'). this caused + // subsequent capability checks fail because they would not be queried with the appropriate namespace header + // if this test fails because a POST /v1/sys/capabilities-self returns a 403, then we have a problem! + test('it sets namespace when renewing token', async function (assert) { const uid = uuidv4(); - this.ns = `admin-${uid}`; + const ns = `admin-${uid}`; // log in to root to create namespace await login(); - await runCmd(createNS(this.ns), false); + await runCmd(createNS(ns), false); // login to namespace, mount userpass, create policy and user - await loginNs(this.ns); - this.db = `database-${uid}`; - this.userpass = `userpass-${uid}`; - this.user = 'bob'; - this.policyName = `policy-${this.userpass}`; - this.policy = ` - path "${this.db}/" { + await loginNs(ns); + const db = `database-${uid}`; + const userpass = `userpass-${uid}`; + const user = 'bob'; + const policyName = `policy-${userpass}`; + const policy = ` + path "${db}/" { capabilities = ["list"] } - path "${this.db}/roles" { + path "${db}/roles" { capabilities = ["read","list"] } `; await runCmd([ - mountAuthCmd('userpass', this.userpass), - mountEngineCmd('database', this.db), - createPolicyCmd(this.policyName, this.policy), - `write auth/${this.userpass}/users/${this.user} password=${this.user} token_policies=${this.policyName}`, - ]); - return await logout(); - }); - - hooks.afterEach(async function () { - await visit(`/vault/logout?namespace=${this.ns}`); - await fillIn(AUTH_FORM.namespaceInput, ''); // clear login form namespace input - await login(); - await runCmd([`delete sys/namespaces/${this.ns}`], false); - }); - - // this test is specifically to cover a token renewal bug within namespaces - // namespace_path isn't returned by the renew-self response and so the auth service was - // incorrectly setting userRootNamespace to '' (which denotes 'root') - // making subsequent capability checks fail because they would not be queried with the appropriate namespace header - // if this test fails because a POST /v1/sys/capabilities-self returns a 403, then we have a problem! - test('it sets namespace when renewing token', async function (assert) { - await login(); - await runCmd([ - mountAuthCmd('userpass', this.userpass), - mountEngineCmd('database', this.db), - createPolicyCmd(this.policyName, this.policy), - `write auth/${this.userpass}/users/${this.user} password=${this.user} token_policies=${this.policyName}`, + mountAuthCmd('userpass', userpass), + mountEngineCmd('database', db), + createPolicyCmd(policyName, policy), + `write auth/${userpass}/users/${user} password=${user} token_policies=${policyName}`, ]); const inputValues = { - username: this.user, - password: this.user, - 'auth-form-mount-path': this.userpass, - 'auth-form-ns-input': this.ns, + username: user, + password: user, + 'auth-form-mount-path': userpass, + 'auth-form-ns-input': ns, }; // login as user just to get token (this is the only way to generate a token in the UI right now..) @@ -276,8 +258,8 @@ module('Acceptance | auth', function (hooks) { const token = find('[data-test-copy-button]').getAttribute('data-test-copy-button'); // login with token to reproduce bug - await loginNs(this.ns, token); - await visit(`/vault/secrets/${this.db}/overview?namespace=${this.ns}`); + await loginNs(ns, token); + await visit(`/vault/secrets/${db}/overview?namespace=${ns}`); assert .dom('[data-test-overview-card="Roles"]') .hasText('Roles Create new', 'database overview renders'); @@ -289,9 +271,30 @@ module('Acceptance | auth', function (hooks) { await click(GENERAL.tab('overview')); assert.strictEqual( currentURL(), - `/vault/secrets/${this.db}/overview?namespace=${this.ns}`, + `/vault/secrets/${db}/overview?namespace=${ns}`, 'it navigates to database overview' ); + + // cleanup + await visit(`/vault/logout?namespace=${ns}`); + await fillIn(AUTH_FORM.namespaceInput, ''); // clear login form namespace input + await login(); + await runCmd([`delete sys/namespaces/${ns}`], false); + }); + + test('it sets namespace header for sys/internal/ui/mounts request when namespace is inputted', async function (assert) { + assert.expect(1); + await visit('/vault/auth'); + + this.server.get('/sys/internal/ui/mounts', (schema, req) => { + assert.strictEqual( + req.requestHeaders['X-Vault-Namespace'], + 'admin', + 'request header contains expected namespace' + ); + return { errors: ['permission denied'] }; + }); + await typeIn(AUTH_FORM.namespaceInput, 'admin'); }); }); }); diff --git a/ui/tests/acceptance/auth/mfa-test.js b/ui/tests/acceptance/auth/mfa-test.js index fc0a81aa8e..634172977c 100644 --- a/ui/tests/acceptance/auth/mfa-test.js +++ b/ui/tests/acceptance/auth/mfa-test.js @@ -11,64 +11,16 @@ import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { MFA_SELECTORS } from 'vault/tests/helpers/mfa/mfa-selectors'; import { constraintId, setupTotpMfaResponse } from 'vault/tests/helpers/mfa/mfa-helpers'; -import { fillInLoginFields } from 'vault/tests/helpers/auth/auth-helpers'; +import { AUTH_METHOD_MAP, fillInLoginFields } from 'vault/tests/helpers/auth/auth-helpers'; import { callbackData, windowStub } from 'vault/tests/helpers/oidc-window-stub'; const ENT_ONLY = ['saml']; -// See AUTH_METHOD_TEST_CASES for how request data maps to method types -// authRequest is the request made on submit and what returns mfa_validation requirements (if any) -// additionalRequest are any third party requests the auth method expects -const REQUEST_DATA = { - username: { - loginData: { username: 'matilda', password: 'password' }, - stubRequests: (server, path) => - server.post(`/auth/${path}/login/matilda`, () => setupTotpMfaResponse(path)), - }, - github: { - loginData: { token: 'mysupersecuretoken' }, - stubRequests: (server, path) => server.post(`/auth/${path}/login`, () => setupTotpMfaResponse(path)), - }, - oidc: { - loginData: { role: 'some-dev' }, - hasPopupWindow: true, - stubRequests: (server, path) => { - server.get(`/auth/${path}/oidc/callback`, () => setupTotpMfaResponse(path)); - server.post(`/auth/${path}/oidc/auth_url`, () => ({ - data: { auth_url: 'http://dev-foo-bar.com' }, - })); - }, - }, - saml: { - loginData: { role: 'some-dev' }, - hasPopupWindow: true, - stubRequests: (server, path) => { - server.put(`/auth/${path}/token`, () => setupTotpMfaResponse(path)); - server.put(`/auth/${path}/sso_service_url`, () => ({ - data: { sso_service_url: 'http://sso-url.hashicorp.com/service', token_poll_id: '1234' }, - })); - }, - }, -}; - -// maps auth type to request data (line breaks to help separate and clarify which methods share request paths) -const AUTH_METHOD_TEST_CASES = [ - { authType: 'github', options: REQUEST_DATA.github }, - - { authType: 'userpass', options: REQUEST_DATA.username }, - { authType: 'ldap', options: REQUEST_DATA.username }, - { authType: 'okta', options: REQUEST_DATA.username }, - { authType: 'radius', options: REQUEST_DATA.username }, - - { authType: 'oidc', options: REQUEST_DATA.oidc }, - { authType: 'jwt', options: REQUEST_DATA.oidc }, - - // ENTERPRISE ONLY - { authType: 'saml', options: REQUEST_DATA.saml }, -]; - -for (const method of AUTH_METHOD_TEST_CASES) { +for (const method of AUTH_METHOD_MAP) { const { authType, options } = method; + // token doesn't support MFA + if (authType === 'token') continue; + const isEntMethod = ENT_ONLY.includes(authType); // adding "enterprise" to the module title filters it out of the test runner for the CE repo module(`Acceptance | auth | mfa ${authType}${isEntMethod ? ' enterprise' : ''}`, function (hooks) { @@ -90,7 +42,7 @@ for (const method of AUTH_METHOD_TEST_CASES) { test(`${authType}: it displays mfa requirement for default paths`, async function (assert) { this.mountPath = authType; - options.stubRequests(this.server, this.mountPath); + options.stubRequests(this.server, this.mountPath, setupTotpMfaResponse(this.mountPath)); const loginKeys = Object.keys(options.loginData); assert.expect(3 + loginKeys.length); @@ -123,7 +75,7 @@ for (const method of AUTH_METHOD_TEST_CASES) { test(`${authType}: it displays mfa requirement for custom paths`, async function (assert) { this.mountPath = `${authType}-custom`; - options.stubRequests(this.server, this.mountPath); + options.stubRequests(this.server, this.mountPath, setupTotpMfaResponse(this.mountPath)); const loginKeys = Object.keys(options.loginData); assert.expect(3 + loginKeys.length); @@ -160,7 +112,7 @@ for (const method of AUTH_METHOD_TEST_CASES) { test(`${authType}: it submits mfa requirement for default paths`, async function (assert) { assert.expect(2); this.mountPath = authType; - options.stubRequests(this.server, this.mountPath); + options.stubRequests(this.server, this.mountPath, setupTotpMfaResponse(this.mountPath)); const expectedOtp = '12345'; server.post('/sys/mfa/validate', async (_, req) => { @@ -190,7 +142,7 @@ for (const method of AUTH_METHOD_TEST_CASES) { assert.expect(2); this.mountPath = `${authType}-custom`; - options.stubRequests(this.server, this.mountPath); + options.stubRequests(this.server, this.mountPath, setupTotpMfaResponse(this.mountPath)); const expectedOtp = '12345'; server.post('/sys/mfa/validate', async (_, req) => { diff --git a/ui/tests/helpers/auth/auth-form-selectors.ts b/ui/tests/helpers/auth/auth-form-selectors.ts index 7ae91c570f..ab40f1b16b 100644 --- a/ui/tests/helpers/auth/auth-form-selectors.ts +++ b/ui/tests/helpers/auth/auth-form-selectors.ts @@ -8,12 +8,17 @@ 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]'), + tabBtn: (method: string) => `[data-test-auth-method="${method}"] button`, description: '[data-test-description]', roleInput: '[data-test-role]', input: (item: string) => `[data-test-${item}]`, // i.e. jwt, role, token, password or username mountPathInput: '[data-test-auth-form-mount-path]', moreOptions: '[data-test-auth-form-options-toggle]', + advancedSettings: '[data-test-auth-form-options-toggle] button', namespaceInput: '[data-test-auth-form-ns-input]', + managedNsRoot: '[data-test-managed-namespace-root]', logo: '[data-test-auth-logo]', helpText: '[data-test-auth-helptext]', + authForm: (type: string) => `[data-test-auth-form="${type}"]`, + otherMethodsBtn: '[data-test-other-methods-button]', }; diff --git a/ui/tests/helpers/auth/auth-helpers.ts b/ui/tests/helpers/auth/auth-helpers.ts index e34d86d798..233c3ebf71 100644 --- a/ui/tests/helpers/auth/auth-helpers.ts +++ b/ui/tests/helpers/auth/auth-helpers.ts @@ -6,6 +6,7 @@ import { click, fillIn, visit } from '@ember/test-helpers'; import VAULT_KEYS from 'vault/tests/helpers/vault-keys'; import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; +import { Server } from 'miragejs'; const { rootToken } = VAULT_KEYS; @@ -67,3 +68,61 @@ export const fillInLoginFields = async (loginFields: LoginFields, { toggleOption await fillIn(AUTH_FORM.input(input), value); } }; + +// See AUTH_METHOD_MAP for how login data maps to method types, +// stubRequests are the requests made on submit for that method type +export const LOGIN_DATA = { + token: { + loginData: { token: 'mytoken' }, + stubRequests: (server: Server, response: object) => server.get('/auth/token/lookup-self', () => response), + }, + username: { + loginData: { username: 'matilda', password: 'password' }, + stubRequests: (server: Server, path: string, response: object) => + server.post(`/auth/${path}/login/matilda`, () => response), + }, + github: { + loginData: { token: 'mysupersecuretoken' }, + stubRequests: (server: Server, path: string, response: object) => + server.post(`/auth/${path}/login`, () => response), + }, + oidc: { + loginData: { role: 'some-dev' }, + hasPopupWindow: true, + stubRequests: (server: Server, path: string, response: object) => { + server.get(`/auth/${path}/oidc/callback`, () => response); + server.post(`/auth/${path}/oidc/auth_url`, () => { + return { data: { auth_url: 'http://dev-foo-bar.com' } }; + }); + }, + }, + saml: { + loginData: { role: 'some-dev' }, + hasPopupWindow: true, + stubRequests: (server: Server, path: string, response: object) => { + server.put(`/auth/${path}/token`, () => response); + server.put(`/auth/${path}/sso_service_url`, () => { + return { data: { sso_service_url: 'http://sso-url.hashicorp.com/service', token_poll_id: '1234' } }; + }); + }, + }, +}; + +// maps auth type to request data +export const AUTH_METHOD_MAP = [ + { authType: 'token', options: LOGIN_DATA.token }, + { authType: 'github', options: LOGIN_DATA.github }, + + // username and password methods + { authType: 'userpass', options: LOGIN_DATA.username }, + { authType: 'ldap', options: LOGIN_DATA.username }, + { authType: 'okta', options: LOGIN_DATA.username }, + { authType: 'radius', options: LOGIN_DATA.username }, + + // oidc + { authType: 'oidc', options: LOGIN_DATA.oidc }, + { authType: 'jwt', options: LOGIN_DATA.oidc }, + + // ENTERPRISE ONLY + { authType: 'saml', options: LOGIN_DATA.saml }, +]; diff --git a/ui/tests/integration/components/auth/fields-test.js b/ui/tests/integration/components/auth/fields-test.js new file mode 100644 index 0000000000..e1860d43fa --- /dev/null +++ b/ui/tests/integration/components/auth/fields-test.js @@ -0,0 +1,89 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import { find, render } from '@ember/test-helpers'; +import { capitalize } from '@ember/string'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; + +module('Integration | Component | auth | fields', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.loginFields = [ + { name: 'username' }, + { name: 'role', helperText: 'Wow neat role!' }, + { name: 'token', label: 'Super secret token' }, + { name: 'password' }, + ]; + this.renderComponent = () => { + return render(hbs``); + }; + }); + + test('it renders field name as input label if "label" key is not specified', async function (assert) { + await this.renderComponent(); + for (const field of ['username', 'password', 'role']) { + const id = find(GENERAL.inputByAttr(field)).id; + assert + .dom(`#label-${id}`) + .hasText(capitalize(field), `${field} it renders name if "label" key is not present`); + } + }); + + test('it does NOT render "helperText" if not present', async function (assert) { + await this.renderComponent(); + for (const field of ['username', 'password', 'token']) { + const id = find(GENERAL.inputByAttr(field)).id; + assert + .dom(`#helper-text-${id}`) + .doesNotExist(`${field}: it does not render helperText if key is not present`); + } + }); + + test('it renders "helperText" if specified', async function (assert) { + await this.renderComponent(); + const id = find(GENERAL.inputByAttr('role')).id; + assert.dom(`#helper-text-${id}`).hasText('Wow neat role!'); + }); + + test('it renders "label" if specified', async function (assert) { + await this.renderComponent(); + const id = find(GENERAL.inputByAttr('token')).id; + assert.dom(`#label-${id}`).hasText('Super secret token', 'it renders "label" instead of "name"'); + }); + + test('it renders password input types for token and password fields', async function (assert) { + await this.renderComponent(); + assert.dom(GENERAL.inputByAttr('token')).hasAttribute('type', 'password'); + assert.dom(GENERAL.inputByAttr('password')).hasAttribute('type', 'password'); + }); + + test('it renders text input types for other fields', async function (assert) { + await this.renderComponent(); + assert.dom(GENERAL.inputByAttr('username')).hasAttribute('type', 'text'); + assert.dom(GENERAL.inputByAttr('role')).hasAttribute('type', 'text'); + }); + + test('it renders expected autocomplete values', async function (assert) { + await this.renderComponent(); + const expectedValues = { + username: 'username', + role: 'role', + token: 'off', + password: 'current-password', + }; + + for (const field of this.loginFields) { + const { name } = field; + const expected = expectedValues[name]; + assert + .dom(GENERAL.inputByAttr(name)) + .hasAttribute('autocomplete', expected, `${name}: it renders autocomplete value "${expected}"`); + } + }); +}); diff --git a/ui/tests/integration/components/auth/form-template-test.js b/ui/tests/integration/components/auth/form-template-test.js new file mode 100644 index 0000000000..74d8c5f207 --- /dev/null +++ b/ui/tests/integration/components/auth/form-template-test.js @@ -0,0 +1,427 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { click, fillIn, find, findAll, render, typeIn } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import sinon from 'sinon'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; +import { AUTH_METHOD_MAP } from 'vault/tests/helpers/auth/auth-helpers'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import { + ALL_LOGIN_METHODS, + BASE_LOGIN_METHODS, + ENTERPRISE_LOGIN_METHODS, +} from 'vault/utils/supported-login-methods'; +import { Response } from 'miragejs'; + +module('Integration | Component | auth | form template', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.version = this.owner.lookup('service:version'); + this.cluster = { id: '1' }; + this.wrappedToken = ''; + this.namespaceQueryParam = ''; + this.oidcProviderQueryParam = ''; + this.onAuthResponse = sinon.spy(); + this.onNamespaceChange = sinon.spy(); + + this.renderComponent = () => { + return render(hbs` + `); + }; + }); + + test('it selects token by default', async function (assert) { + await this.renderComponent(); + assert.dom(GENERAL.selectByAttr('auth type')).hasValue('token'); + }); + + test('it does not show toggle buttons when listing visibility is not set', async function (assert) { + await this.renderComponent(); + assert.dom(GENERAL.backButton).doesNotExist('"Back" button does not render'); + assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist('"Sign in with other methods" does not render '); + }); + + test('it calls sys/internal/ui/mounts on initial render', async function (assert) { + assert.expect(2); + this.server.get('/sys/internal/ui/mounts', (_, req) => { + assert.true(true, 'request is made to /sys/internal/ui/mounts'); + assert.strictEqual( + req.requestHeaders['X-Vault-Namespace'], + undefined, + 'it does not pass a namespace header' + ); + return {}; + }); + + await this.renderComponent(); + }); + + test('it fails gracefully if sys/internal/ui/mounts request errors', async function (assert) { + assert.expect(2); + this.server.get('/sys/internal/ui/mounts', () => { + assert.true(true, 'request is made to /sys/internal/ui/mounts'); + return new Response(500, {}, { errors: ['something wrong with urls'] }); + }); + await this.renderComponent(); + assert.dom(GENERAL.selectByAttr('auth type')).exists(); + }); + + test('it displays errors', async function (assert) { + await this.renderComponent(); + await click(AUTH_FORM.login); + // this error message text is because the auth service is not stubbed in this test + assert.dom(GENERAL.messageError).hasText('Error Authentication failed: permission denied'); + }); + + module('listing visibility', function (hooks) { + hooks.beforeEach(function () { + this.server.get('/sys/internal/ui/mounts', () => { + return { + data: { + auth: { + 'userpass/': { + description: '', + options: {}, + type: 'userpass', + }, + 'userpass2/': { + description: '', + options: {}, + type: 'userpass', + }, + 'my-oidc/': { + description: '', + options: {}, + type: 'oidc', + }, + 'token/': { + description: 'token based credentials', + options: null, + type: 'token', + }, + }, + }, + }; + }); + }); + + test('it renders mounts configured with listing_visibility="unuath"', async function (assert) { + const expectedTabs = [ + { type: 'userpass', display: 'Username' }, + { type: 'oidc', display: 'OIDC' }, + { type: 'token', display: 'Token' }, + ]; + await this.renderComponent(); + assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist('dropdown does not render'); + // there are 4 mount paths returned in the stubbed sys/internal/ui/mounts response above, + // but two are of the same type so only expect 3 tabs + assert.dom(AUTH_FORM.tabs()).exists({ count: 3 }, 'it groups mount paths by type and renders 3 tabs'); + expectedTabs.forEach((m) => { + assert.dom(AUTH_FORM.tabs(m.type)).exists(`${m.type} renders as a tab`); + assert.dom(AUTH_FORM.tabs(m.type)).hasText(m.display, `${m.type} renders expected display name`); + }); + }); + + test('it selects each auth tab and renders form for that type', async function (assert) { + await this.renderComponent(); + const assertSelected = (type) => { + assert.dom(AUTH_FORM.authForm(type)).exists(`${type}: form renders when tab is selected`); + assert.dom(AUTH_FORM.tabBtn(type)).hasAttribute('aria-selected', 'true'); + }; + const assertUnselected = (type) => { + assert.dom(AUTH_FORM.authForm(type)).doesNotExist(`${type}: form does NOT render`); + assert.dom(AUTH_FORM.tabBtn(type)).hasAttribute('aria-selected', 'false'); + }; + // click through each tab + await click(AUTH_FORM.tabBtn('userpass')); + assertSelected('userpass'); + assertUnselected('oidc'); + assertUnselected('token'); + assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); + + await click(AUTH_FORM.tabBtn('oidc')); + assertSelected('oidc'); + assertUnselected('token'); + assertUnselected('userpass'); + assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); + + await click(AUTH_FORM.tabBtn('token')); + assertSelected('token'); + assertUnselected('oidc'); + assertUnselected('userpass'); + assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); + }); + + test('it renders the mount description', async function (assert) { + await this.renderComponent(); + await click(AUTH_FORM.tabBtn('token')); + assert.dom('section p').hasText('token based credentials data-test-description'); + }); + + test('it renders a dropdown if multiple mount paths are returned', async function (assert) { + await this.renderComponent(); + await click(AUTH_FORM.tabBtn('userpass')); + const dropdownOptions = findAll(`${GENERAL.selectByAttr('path')} option`).map((o) => o.value); + const expectedPaths = ['userpass/', 'userpass2/']; + expectedPaths.forEach((p) => { + assert.true(dropdownOptions.includes(p), `dropdown includes path: ${p}`); + }); + }); + + test('it renders a readonly input if only one mount path is returned', async function (assert) { + await this.renderComponent(); + await click(AUTH_FORM.tabBtn('oidc')); + assert.dom(GENERAL.inputByAttr('path')).hasAttribute('readonly'); + assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/'); + }); + + test('it clicks "Sign in with other methods"', async function (assert) { + await this.renderComponent(); + assert.dom(AUTH_FORM.tabs()).exists({ count: 3 }, 'tabs render by default'); + assert.dom(GENERAL.backButton).doesNotExist(); + await click(AUTH_FORM.otherMethodsBtn); + assert + .dom(AUTH_FORM.otherMethodsBtn) + .doesNotExist('"Sign in with other methods" does not render after it is clicked'); + assert + .dom(GENERAL.selectByAttr('auth type')) + .exists('clicking "Sign in with other methods" renders dropdown instead of tabs'); + await click(GENERAL.backButton); + assert.dom(GENERAL.backButton).doesNotExist('"Back" button does not render after it is clicked'); + assert.dom(AUTH_FORM.tabs()).exists({ count: 3 }, 'clicking "Back" renders tabs again'); + assert.dom(AUTH_FORM.otherMethodsBtn).exists('"Sign in with other methods" renders again'); + }); + + test('it resets selected tab after clicking "Sign in with other methods" and then "Back"', async function (assert) { + await this.renderComponent(); + assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true'); + assert.dom(AUTH_FORM.tabBtn('oidc')).hasAttribute('aria-selected', 'false'); + assert.dom(AUTH_FORM.tabBtn('token')).hasAttribute('aria-selected', 'false'); + + // select a different tab before clicking "Sign in with other methods" + await click(AUTH_FORM.tabBtn('oidc')); + assert.dom(AUTH_FORM.tabBtn('oidc')).hasAttribute('aria-selected', 'true'); + assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'false'); + await click(AUTH_FORM.otherMethodsBtn); + assert.dom(GENERAL.selectByAttr('auth type')).exists('it renders dropdown instead of tabs'); + await click(GENERAL.backButton); + // assert tab selection is reset + assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true'); + assert.dom(AUTH_FORM.tabBtn('oidc')).hasAttribute('aria-selected', 'false'); + assert.dom(AUTH_FORM.tabBtn('token')).hasAttribute('aria-selected', 'false'); + }); + }); + + module('community', function (hooks) { + hooks.beforeEach(function () { + this.version.type = 'community'; + }); + + test('it does not render the namespace input on community', async function (assert) { + await this.renderComponent(); + assert.dom(GENERAL.inputByAttr('namespace')).doesNotExist(); + }); + + test('dropdown does not include enterprise methods', async function (assert) { + const supported = BASE_LOGIN_METHODS.map((m) => m.type); + const unsupported = ENTERPRISE_LOGIN_METHODS.map((m) => m.type); + assert.expect(supported.length + unsupported.length); + await this.renderComponent(); + const dropdownOptions = findAll(`${GENERAL.selectByAttr('auth type')} option`).map((o) => o.value); + + supported.forEach((m) => { + assert.true(dropdownOptions.includes(m), `dropdown includes supported method: ${m}`); + }); + unsupported.forEach((m) => { + assert.false(dropdownOptions.includes(m), `dropdown does NOT include unsupported method: ${m}`); + }); + }); + }); + + // tests with "enterprise" in the title are filtered out from CE test runs + // naming the module 'ent' so these tests still run on the CE repo + module('ent', function (hooks) { + hooks.beforeEach(function () { + this.version.type = 'enterprise'; + this.version.features = ['Namespaces']; + this.namespaceQueryParam = ''; + }); + + // in th ent module to test ALL supported login methods + // iterating in tests should generally be avoided, but purposefully wanted to test the component + // renders as expected as auth types change + test('it selects each supported auth type and renders its form and relevant fields', async function (assert) { + const fieldCount = AUTH_METHOD_MAP.map((m) => Object.keys(m.options.loginData).length); + const sum = fieldCount.reduce((a, b) => a + b, 0); + const methodCount = AUTH_METHOD_MAP.length; + // 3 assertions per method, plus an assertion for each expected field + assert.expect(3 * methodCount + sum); // count at time of writing is 40 + + await this.renderComponent(); + for (const method of AUTH_METHOD_MAP) { + const { authType, options } = method; + + const fields = Object.keys(options.loginData); + await fillIn(GENERAL.selectByAttr('auth type'), authType); + + assert.dom(GENERAL.selectByAttr('auth type')).hasValue(authType), `${authType}: it selects type`; + assert.dom(AUTH_FORM.authForm(authType)).exists(`${authType}: it renders form component`); + + // token is the only method that does not support a custom mount path + if (authType !== 'token') { + // jwt and oidc render the same component so the toggle remains open switching between those types + const element = find(AUTH_FORM.advancedSettings); + if (element.ariaExpanded === 'false') { + await click(AUTH_FORM.advancedSettings); + } + } + + const assertion = authType === 'token' ? 'doesNotExist' : 'exists'; + assert.dom(GENERAL.inputByAttr('path'))[assertion](`${authType}: mount path input ${assertion}`); + + fields.forEach((field) => { + assert.dom(GENERAL.inputByAttr(field)).exists(`${authType}: ${field} input renders`); + }); + } + }); + + test('it disables namespace input when an oidc provider query param exists', async function (assert) { + this.oidcProviderQueryParam = 'myprovider'; + await this.renderComponent(); + assert.dom(GENERAL.inputByAttr('namespace')).isDisabled(); + }); + + test('dropdown includes enterprise methods', async function (assert) { + const supported = ALL_LOGIN_METHODS.map((m) => m.type); + assert.expect(supported.length); + await this.renderComponent(); + + const dropdownOptions = findAll(`${GENERAL.selectByAttr('auth type')} option`).map((o) => o.value); + supported.forEach((m) => { + assert.true(dropdownOptions.includes(m), `dropdown includes supported method: ${m}`); + }); + }); + + test('it re-requests mount data when a namespace is inputted', async function (assert) { + assert.expect(3); + const expectedNs = 'test-ns1'; + + let count = 0; + this.server.get('/sys/internal/ui/mounts', () => { + count++; + const msg = count === 1 ? 'on initial render' : 'when namespace is inputted'; + assert.true(true, `/sys/internal/ui/mounts is called ${msg}`); + return {}; + }); + + await this.renderComponent(); + await fillIn(GENERAL.inputByAttr('namespace'), expectedNs); + const [actual] = this.onNamespaceChange.lastCall.args; + assert.strictEqual(actual, expectedNs, 'callback has expected args'); + }); + + test('it re-requests mount data when namespace input is prefilled and then updated', async function (assert) { + assert.expect(3); + this.namespaceQueryParam = 'admin'; + const childNs = '/test-ns1'; + + let count = 0; + this.server.get('/sys/internal/ui/mounts', () => { + count++; + const msg = count === 1 ? 'on initial render' : 'when namespace updates'; + assert.true(true, `/sys/internal/ui/mounts is called ${msg}`); + return {}; + }); + + await this.renderComponent(); + await typeIn(GENERAL.inputByAttr('namespace'), childNs); + const [actual] = this.onNamespaceChange.lastCall.args; + assert.strictEqual(actual, `${this.namespaceQueryParam}${childNs}`, 'callback has expected args'); + }); + + test('it sets namespace for hvd managed clusters', async function (assert) { + this.owner.lookup('service:flags').featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE']; + this.namespaceQueryParam = 'admin/west-coast'; + await this.renderComponent(); + assert.dom(AUTH_FORM.managedNsRoot).hasValue('/admin'); + assert.dom(AUTH_FORM.managedNsRoot).hasAttribute('readonly'); + assert.dom(GENERAL.inputByAttr('namespace')).hasValue('/west-coast'); + }); + + test('it does NOT display tabs when updated namespace has no visible mounts', async function (assert) { + assert.expect(4); + let count = 0; + this.server.get('/sys/internal/ui/mounts', () => { + count++; + const mounts = { + data: { + auth: { + 'userpass2/': { + description: '', + options: {}, + type: 'userpass', + }, + }, + }, + }; + // mocks re-requesting the endpoint when namespace changes by returning + // mounts on initial request, then when a namespace is inputted a second request is made which return NO mounts + const response = count === 1 ? mounts : {}; + return response; + }); + + await this.renderComponent(); + assert.dom(AUTH_FORM.tabs('userpass')).exists('userpass renders as a tab'); + assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist('dropdown does not render'); + await fillIn(GENERAL.inputByAttr('namespace'), 'admin'); + assert.dom(AUTH_FORM.tabs()).doesNotExist('tabs do not render'); + assert.dom(GENERAL.selectByAttr('auth type')).exists('dropdown renders'); + }); + + test('it DOES display tabs when updated namespace has visible mounts', async function (assert) { + assert.expect(4); + let count = 0; + this.server.get('/sys/internal/ui/mounts', () => { + count++; + const mounts = { + data: { + auth: { + 'userpass2/': { + description: '', + options: {}, + type: 'userpass', + }, + }, + }, + }; + // mocks re-requesting the endpoint when namespace changes by returning + // no mounts on initial request, then when a namespace is inputted a second request is made which return mounts + const response = count === 1 ? {} : mounts; + return response; + }); + + await this.renderComponent(); + assert.dom(AUTH_FORM.tabs()).doesNotExist('tabs do not render'); + assert.dom(GENERAL.selectByAttr('auth type')).exists('dropdown renders'); + // fire off second request to sys/internal/mounts + await fillIn(GENERAL.inputByAttr('namespace'), 'admin'); + assert.dom(AUTH_FORM.tabs('userpass')).exists('userpass renders as a tab'); + assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist('dropdown does not render'); + }); + }); +}); diff --git a/ui/tests/integration/components/auth/form/base-test.js b/ui/tests/integration/components/auth/form/base-test.js new file mode 100644 index 0000000000..9c626e2abc --- /dev/null +++ b/ui/tests/integration/components/auth/form/base-test.js @@ -0,0 +1,122 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import { find, render } from '@ember/test-helpers'; +import sinon from 'sinon'; +import testHelper from './test-helper'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; + +// These auth types all use the default methods in auth/form/base +// Any auth types with custom logic should be in a separate test file, i.e. okta + +module('Integration | Component | auth | form | base', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.authenticateStub = sinon.stub(this.owner.lookup('service:auth'), 'authenticate'); + this.cluster = { id: 1 }; + this.onError = sinon.spy(); + this.onSuccess = sinon.spy(); + }); + + module('github', function (hooks) { + hooks.beforeEach(function () { + this.authType = 'github'; + this.expectedFields = ['token']; + this.renderComponent = () => { + return render(hbs` + `); + }; + }); + + testHelper(test); + + test('it renders custom label', async function (assert) { + await this.renderComponent(); + const id = find(GENERAL.inputByAttr('token')).id; + assert.dom(`#label-${id}`).hasText('Github token'); + }); + }); + + module('ldap', function (hooks) { + hooks.beforeEach(function () { + this.authType = 'ldap'; + this.expectedFields = ['username', 'password']; + this.renderComponent = () => { + return render(hbs` + `); + }; + }); + + testHelper(test); + }); + + module('radius', function (hooks) { + hooks.beforeEach(function () { + this.authType = 'radius'; + this.expectedFields = ['username', 'password']; + this.renderComponent = () => { + return render(hbs` + `); + }; + }); + + testHelper(test); + }); + + module('token', function (hooks) { + hooks.beforeEach(function () { + this.authType = 'token'; + this.expectedFields = ['token']; + this.renderComponent = () => { + return render(hbs` + `); + }; + }); + + testHelper(test); + }); + + module('userpass', function (hooks) { + hooks.beforeEach(function () { + this.authType = 'userpass'; + this.expectedFields = ['username', 'password']; + this.renderComponent = () => { + return render(hbs` + `); + }; + }); + + testHelper(test); + }); +}); diff --git a/ui/tests/integration/components/auth/form/oidc-jwt-test.js b/ui/tests/integration/components/auth/form/oidc-jwt-test.js new file mode 100644 index 0000000000..94ec8a705e --- /dev/null +++ b/ui/tests/integration/components/auth/form/oidc-jwt-test.js @@ -0,0 +1,61 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import { find, render } from '@ember/test-helpers'; +import sinon from 'sinon'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import testHelper from './test-helper'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; + +module('Integration | Component | auth | form | oidc-jwt', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.expectedFields = ['role']; + + this.authenticateStub = sinon.stub(this.owner.lookup('service:auth'), 'authenticate'); + this.cluster = { id: 1 }; + this.onError = sinon.spy(); + this.onSuccess = sinon.spy(); + this.renderComponent = () => { + return render(hbs` + + `); + }; + }); + + test('it renders helper text', async function (assert) { + await this.renderComponent(); + const id = find(GENERAL.inputByAttr('role')).id; + assert + .dom(`#helper-text-${id}`) + .hasText('Vault will use the default role to sign in if this field is left blank.'); + }); + + module('oidc', function (hooks) { + hooks.beforeEach(function () { + this.authType = 'oidc'; + }); + + testHelper(test); + }); + + module('jwt', function (hooks) { + hooks.beforeEach(function () { + this.authType = 'jwt'; + }); + + testHelper(test); + }); +}); diff --git a/ui/tests/integration/components/auth/form/okta-test.js b/ui/tests/integration/components/auth/form/okta-test.js new file mode 100644 index 0000000000..1d61951bdb --- /dev/null +++ b/ui/tests/integration/components/auth/form/okta-test.js @@ -0,0 +1,38 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import { render } from '@ember/test-helpers'; +import sinon from 'sinon'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import testHelper from './test-helper'; + +module('Integration | Component | auth | form | okta', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.authType = 'okta'; + this.expectedFields = ['username', 'password']; + + this.authenticateStub = sinon.stub(this.owner.lookup('service:auth'), 'authenticate'); + this.cluster = { id: 1 }; + this.onError = sinon.spy(); + this.onSuccess = sinon.spy(); + this.renderComponent = () => { + return render(hbs` + `); + }; + }); + + testHelper(test); +}); diff --git a/ui/tests/integration/components/auth/form/saml-test.js b/ui/tests/integration/components/auth/form/saml-test.js new file mode 100644 index 0000000000..dbc79acc6f --- /dev/null +++ b/ui/tests/integration/components/auth/form/saml-test.js @@ -0,0 +1,47 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import { find, render } from '@ember/test-helpers'; +import sinon from 'sinon'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import testHelper from './test-helper'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; + +module('Integration | Component | auth | form | saml', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.authType = 'saml'; + this.expectedFields = ['role']; + + this.authenticateStub = sinon.stub(this.owner.lookup('service:auth'), 'authenticate'); + this.cluster = { id: 1 }; + this.onError = sinon.spy(); + this.onSuccess = sinon.spy(); + this.renderComponent = () => { + return render(hbs` + `); + }; + }); + + testHelper(test); + + test('it renders helper text', async function (assert) { + await this.renderComponent(); + const id = find(GENERAL.inputByAttr('role')).id; + assert + .dom(`#helper-text-${id}`) + .hasText('Vault will use the default role to sign in if this field is left blank.'); + }); +}); diff --git a/ui/tests/integration/components/auth/form/test-helper.js b/ui/tests/integration/components/auth/form/test-helper.js new file mode 100644 index 0000000000..6ee55012cb --- /dev/null +++ b/ui/tests/integration/components/auth/form/test-helper.js @@ -0,0 +1,61 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { click, fillIn } from '@ember/test-helpers'; +import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; +import { AUTH_METHOD_MAP } from 'vault/tests/helpers/auth/auth-helpers'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; + +/* +NOTE: In the app these components are actually rendered dynamically by Auth::FormTemplate +and so the components rendered in these tests does not represent "real world" situations. +This is intentional to test component logic specific to auth/form/base or auth/form/ +separately from auth/form-template. +*/ + +export default (test) => { + test('it renders fields', async function (assert) { + await this.renderComponent(); + assert.dom(AUTH_FORM.authForm(this.authType)).exists(`${this.authType}: it renders form component`); + this.expectedFields.forEach((field) => { + assert.dom(GENERAL.inputByAttr(field)).exists(`${this.authType}: it renders ${field}`); + }); + }); + + test('it submits expected form data', async function (assert) { + await this.renderComponent(); + const { options } = AUTH_METHOD_MAP.find((m) => m.authType === this.authType); + const { loginData } = options; + + for (const [field, value] of Object.entries(loginData)) { + await fillIn(GENERAL.inputByAttr(field), value); + } + await click(AUTH_FORM.login); + const [actual] = this.authenticateStub.lastCall.args; + assert.propEqual(actual.data, loginData, 'auth service "authenticate" method is called with form data'); + }); + + test('it fires onError callback', async function (assert) { + this.authenticateStub.throws('permission denied'); + await this.renderComponent(); + await click(AUTH_FORM.login); + + const [actual] = this.onError.lastCall.args; + assert.strictEqual( + actual, + 'Authentication failed: permission denied: Sinon-provided permission denied', + 'it calls onError' + ); + }); + + test('it fires onSuccess callback', async function (assert) { + this.authenticateStub.returns('success!'); + await this.renderComponent(); + await click(AUTH_FORM.login); + + const [actual] = this.onSuccess.lastCall.args; + assert.strictEqual(actual, 'success!', 'it calls onSuccess'); + }); +}; diff --git a/ui/types/vault/models/cluster.d.ts b/ui/types/vault/models/cluster.d.ts new file mode 100644 index 0000000000..5b40ae7162 --- /dev/null +++ b/ui/types/vault/models/cluster.d.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { Model } from 'vault/app-types'; + +export default class ClusterModel extends Model { + id: string; + version: any; + nodes: any; + name: any; + status: any; + standby: any; + type: any; + license: any; + hasChrootNamespace: any; + replicationRedacted: any; + get licenseExpiry(): any; + get licenseState(): any; + get needsInit(): any; + get unsealed(): boolean; + get sealed(): boolean; + get leaderNode(): any; + get sealThreshold(): any; + get sealProgress(): any; + get sealType(): any; + get storageType(): any; + get hcpLinkStatus(): any; + get hasProgress(): boolean; + get usingRaft(): boolean; + mode: any; + get allReplicationDisabled(): any; + get anyReplicationEnabled(): any; + dr: any; + performance: any; + rm: any; + get drMode(): any; + get replicationMode(): any; + get replicationModeForDisplay(): 'Disaster Recovery' | 'Performance'; + get replicationIsInitializing(): boolean; +} diff --git a/ui/types/vault/services/auth.d.ts b/ui/types/vault/services/auth.d.ts index 510796c87b..0fd663c2eb 100644 --- a/ui/types/vault/services/auth.d.ts +++ b/ui/types/vault/services/auth.d.ts @@ -21,4 +21,12 @@ export default class AuthService extends Service { authData: AuthData; currentToken: string; setLastFetch: (time: number) => void; + handleError: (error: Error) => string | error[] | [error]; + authenticate(params: { + clusterId: string; + backend: string; + data: Record; + selectedAuth: string; + }): Promise; + mfaErrors: null | Errors[]; }