diff --git a/ui/.template-lintrc.js b/ui/.template-lintrc.js index 3936c7dd47..91614d5a0a 100644 --- a/ui/.template-lintrc.js +++ b/ui/.template-lintrc.js @@ -10,9 +10,6 @@ module.exports = { rules: { 'no-action': 'off', - 'no-implicit-this': { - allow: ['supported-auth-backends'], - }, 'require-input-label': 'off', 'no-array-prototype-extensions': 'off', // from bump to ember-template-lint@6.0.0 diff --git a/ui/app/adapters/auth-method.js b/ui/app/adapters/auth-method.js index 418e0e3366..46326a8958 100644 --- a/ui/app/adapters/auth-method.js +++ b/ui/app/adapters/auth-method.js @@ -83,16 +83,6 @@ export default ApplicationAdapter.extend({ return this.url(snapshot.id); }, - exchangeOIDC(path, state, code) { - return this.ajax(`/v1/auth/${encodePath(path)}/oidc/callback`, 'GET', { data: { state, code } }); - }, - - pollSAMLToken(path, token_poll_id, client_verifier) { - return this.ajax(`/v1/auth/${encodePath(path)}/token`, 'PUT', { - data: { token_poll_id, client_verifier }, - }); - }, - tune(path, data) { const url = `${this.buildURL()}/${this.pathForType()}/${encodePath(path)}tune`; return this.ajax(url, 'POST', { data }); diff --git a/ui/app/adapters/cluster.js b/ui/app/adapters/cluster.js index 5a8984ec33..39f99d8eea 100644 --- a/ui/app/adapters/cluster.js +++ b/ui/app/adapters/cluster.js @@ -121,29 +121,8 @@ export default ApplicationAdapter.extend({ }); }, - authenticate({ backend, data }) { - const { role, jwt, token, password, username, path, nonce } = data; - const url = this.urlForAuth(backend, username, path); - const verb = backend === 'token' ? 'GET' : 'POST'; - const options = { - unauthenticated: true, - }; - if (backend === 'token') { - options.headers = { - 'X-Vault-Token': token, - }; - } else if (backend === 'jwt' || backend === 'oidc') { - options.data = { role, jwt }; - } else if (backend === 'okta') { - options.data = { password, nonce }; - } else { - options.data = token ? { token, password } : { password }; - } - - return this.ajax(url, verb, options); - }, - - mfaValidate({ mfa_request_id, mfa_constraints }) { + mfaValidate(mfaRequirement) { + const { mfaRequestId: mfa_request_id, mfaConstraints: mfa_constraints } = mfaRequirement; const options = { data: { mfa_request_id, @@ -175,26 +154,6 @@ export default ApplicationAdapter.extend({ return `${this.buildURL()}/${endpoint}`; }, - urlForAuth(type, username, path) { - const authBackend = type.toLowerCase(); - const authURLs = { - github: 'login', - jwt: 'login', - oidc: 'login', - userpass: `login/${encodeURIComponent(username)}`, - ldap: `login/${encodeURIComponent(username)}`, - okta: `login/${encodeURIComponent(username)}`, - radius: `login/${encodeURIComponent(username)}`, - token: 'lookup-self', - }; - const urlSuffix = authURLs[authBackend]; - const urlPrefix = path && authBackend !== 'token' ? path : authBackend; - if (!urlSuffix) { - throw new Error(`There is no auth url for ${type}.`); - } - return `/v1/auth/${urlPrefix}/${urlSuffix}`; - }, - urlForReplication(replicationMode, clusterMode, endpoint) { let suffix; const errString = `Calls to replication ${endpoint} endpoint are not currently allowed in the vault cluster adapater`; diff --git a/ui/app/adapters/role-jwt.js b/ui/app/adapters/role-jwt.js deleted file mode 100644 index 3c45115f4b..0000000000 --- a/ui/app/adapters/role-jwt.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import ApplicationAdapter from './application'; -import { service } from '@ember/service'; -import { encodePath } from 'vault/utils/path-encoding-helpers'; - -export default ApplicationAdapter.extend({ - router: service(), - - findRecord(store, type, id, snapshot) { - let [path, role] = JSON.parse(id); - path = encodePath(path); - - const namespace = snapshot?.adapterOptions.namespace; - const url = `/v1/auth/${path}/oidc/auth_url`; - let redirect_uri = `${window.location.origin}${this.router.urlFor('vault.cluster.oidc-callback', { - auth_path: path, - })}`; - - if (namespace) { - redirect_uri = `${window.location.origin}${this.router.urlFor( - 'vault.cluster.oidc-callback', - { auth_path: path }, - { queryParams: { namespace } } - )}`; - } - - return this.ajax(url, 'POST', { - data: { - role, - redirect_uri, - }, - }); - }, -}); diff --git a/ui/app/adapters/role-saml.js b/ui/app/adapters/role-saml.js deleted file mode 100644 index 095fbdc1a0..0000000000 --- a/ui/app/adapters/role-saml.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import ApplicationAdapter from './application'; -import { service } from '@ember/service'; -import { encodePath } from 'vault/utils/path-encoding-helpers'; -import { v4 as uuidv4 } from 'uuid'; - -export default ApplicationAdapter.extend({ - router: service(), - - // generateClientChallenge generates a client challenge from a verifier. - // The client challenge is the base64(sha256(verifier)). The verifier is - // later presented to the server to obtain the resulting Vault token. - async generateClientChallenge(verifier) { - const encoder = new TextEncoder(); - const data = encoder.encode(verifier); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = new Uint8Array(hashBuffer); - return btoa(String.fromCharCode.apply(null, hashArray)); - }, - - async findRecord(store, type, id, snapshot) { - let [path, role] = JSON.parse(id); - path = preparePathSegment(path); - - // Create the ACS URL based on the cluster the UI is targeting - let acs_url = `${window.location.origin}/v1/`; - let namespace = snapshot?.adapterOptions.namespace; - if (namespace) { - namespace = preparePathSegment(namespace); - acs_url = acs_url.concat(namespace, '/'); - } - acs_url = acs_url.concat('auth/', path, '/callback'); - - // Create the client verifier and challenge - const verifier = uuidv4(); - const challenge = await this.generateClientChallenge(verifier); - // Kick off the authentication flow by generating the SSO service URL - // It requires the client challenge generated from the verifier. We'll - // later provide the verifier to match up with the challenge on the server - // when we poll for the Vault token by its returned token poll ID. - const response = await this.ajax(`/v1/auth/${path}/sso_service_url`, 'PUT', { - data: { - acs_url, - role, - client_challenge: challenge, - client_type: 'browser', - }, - }); - return { - ...response.data, - client_verifier: verifier, - }; - }, -}); - -// preparePathSegment prepares the given segment for being included in a URL -// path by trimming leading and trailing forward slashes and URL encoding. -function preparePathSegment(segment) { - segment = segment.replace(/^\//, ''); - segment = segment.replace(/\/$/, ''); - return encodePath(segment); -} diff --git a/ui/app/components/auth/form-template.ts b/ui/app/components/auth/form-template.ts index 18bb3d8227..56be00b5cd 100644 --- a/ui/app/components/auth/form-template.ts +++ b/ui/app/components/auth/form-template.ts @@ -7,7 +7,7 @@ import Component from '@glimmer/component'; import { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; -import { supportedTypes } from 'vault/utils/supported-login-methods'; +import { supportedTypes } from 'vault/utils/auth-form-helpers'; import type VersionService from 'vault/services/version'; import type ClusterModel from 'vault/models/cluster'; @@ -30,7 +30,7 @@ import type { HTMLElementEvent } from 'vault/forms'; * @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 {object} defaultView - The `FormView` (see the interface below) data to render the initial view. * @param {object} initialFormState - sets selectedAuthMethod and showAlternateView based on the login form configuration computed in parent component - * @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 + * @param {function} onSuccess - callback after the initial authentication request, if an mfaRequirement exists the parent renders the mfa form otherwise it fires the authSuccess action in the auth controller and handles transitioning to the app * @param {array} visibleMountTypes - array of auth method types that have mounts with listing_visibility="unauth" * */ diff --git a/ui/app/components/auth/form/base.ts b/ui/app/components/auth/form/base.ts index 672251b0f7..d9dbdb2684 100644 --- a/ui/app/components/auth/form/base.ts +++ b/ui/app/components/auth/form/base.ts @@ -9,16 +9,16 @@ import { service } from '@ember/service'; import { task } from 'ember-concurrency'; import { waitFor } from '@ember/test-waiters'; import { sanitizePath } from 'core/utils/sanitize-path'; -import { POSSIBLE_FIELDS } from 'vault/utils/supported-login-methods'; +import { POSSIBLE_FIELDS } from 'vault/utils/auth-form-helpers'; +import { ResponseError } from '@hashicorp/vault-client-typescript'; -import type AuthService from 'vault/vault/services/auth'; +import type { HTMLElementEvent } from 'vault/forms'; +import type { LoginFields, NormalizedAuthData, NormalizeAuthResponseKeys } from 'vault/vault/auth/form'; +import type { AuthResponseAuthKey, AuthResponseDataKey } from 'vault/vault/auth/methods'; +import type ApiService from 'vault/services/api'; import type ClusterModel from 'vault/models/cluster'; import type FlagsService from 'vault/services/flags'; import type VersionService from 'vault/services/version'; -import type { AuthResponse } from 'vault/vault/services/auth'; -import type { HTMLElementEvent } from 'vault/forms'; -import type { LoginFields } from 'vault/vault/auth/form'; -import type { MfaRequirementApiResponse, ParsedMfaRequirement } from 'vault/vault/auth/mfa'; /** * @module Auth::Base @@ -36,8 +36,11 @@ interface Args { onSuccess: CallableFunction; } -export default class AuthBase extends Component { - @service declare readonly auth: AuthService; +// This an "abstract" class because it is not meant to be instantiated directly and should be extended from by each auth method type. +// If at any point the Vault UI wants to support a dynamic list of login methods (for example, via custom auth plugins) this class can be +// refactored to handle general auth types, but the Vault UI does not currently support this. +export default abstract class AuthBase extends Component { + @service declare readonly api: ApiService; @service declare readonly flags: FlagsService; @service declare readonly version: VersionService; @@ -52,41 +55,39 @@ export default class AuthBase extends Component { login = task( waitFor(async (formData) => { try { - const authResponse = await this.auth.authenticate({ - clusterId: this.args.cluster.id, - backend: this.args.authType, - data: formData, - selectedAuth: this.args.authType, - }); - - const path = formData?.path; - this.handleAuthResponse(authResponse, path); + const normalizedAuthData = await this.loginRequest(formData); + // calls onAuthResponse in parent auth/page.js component + this.args.onSuccess(normalizedAuthData); } catch (error) { - this.onError(error as Error); + this.onError(error as ResponseError); } }) ); - // Standard methods get mfa_requirements from the authenticate method in the auth service - // methodData is necessary if there's an MfaRequirement because persisting auth data happens after that - handleAuthResponse(authResponse: AuthResponse | ParsedMfaRequirement, path?: string) { - const methodData: { selectedAuth: string; path?: string } = { selectedAuth: this.args.authType, path }; - // calls onAuthResponse in parent auth/page.js component - this.args.onSuccess(authResponse, methodData); - } + // This method must be defined by child components and invokes the relevant login method + abstract loginRequest(formData: Record): Promise; - // SSO methods with a different token exchange workflow skip the auth service authenticate method - // and need mfa handle separately - handleMfa(mfaRequirement: MfaRequirementApiResponse, path: string) { - const parsedMfaAuthResponse = this.auth._parseMfaResponse(mfaRequirement); - this.handleAuthResponse(parsedMfaAuthResponse, path); - } + // Optional method for canceling any additional login items that may be relevant the + // authentication workflow for that method, such as canceling polling tasks. + cancelLogin?(): void; - onError(error: Error | string) { - if (!this.auth.mfaErrors) { - const errorMessage = `Authentication failed: ${this.auth.handleError(error)}`; - this.args.onError(errorMessage); + async onError(error: ResponseError | string) { + // Cancel any additional login items that may be relevant to that method. + // For example, polling tasks or popup windows. + if (this.cancelLogin) { + this.cancelLogin(); } + + let errorMessage = ''; + if (typeof error === 'string') { + errorMessage = error; + } else { + // If error has not been parsed already then parse and render error message + const { message } = await this.api.parseError(error, 'An error occurred, check the Vault logs.'); + errorMessage = message; + } + + this.args.onError(`Authentication failed: ${errorMessage}`); } parseFormData(formData: FormData) { @@ -116,9 +117,28 @@ export default class AuthBase extends Component { // HVD managed clusters can only input child namespaces, manually prepend with the hvd root namespace = namespace ? `${hvdRootNs}/${namespace}` : hvdRootNs; } + // The namespace input is only sent because some methods use it for additional login steps (i.e. generating callback URLs). + // It is not used to set the header for the actual login request because the API service sets the namespace header using + // the namespace service, which is updated when the URL "namespace" query param changes. data['namespace'] = namespace; } return data; } + + // normalize auth data so stored token data has the same keys regardless of auth type + normalizeAuthResponse = ( + authResponse: AuthResponseAuthKey | AuthResponseDataKey, + { authMountPath, displayName, token, ttl }: NormalizeAuthResponseKeys + ) => { + return { + // authResponse will include enforcement data in the `mfaRequirement` key - if MFA is configured. + ...authResponse, + authMethodType: this.args.authType, + authMountPath, + displayName, + token, + ttl, + }; + }; } diff --git a/ui/app/components/auth/form/github.ts b/ui/app/components/auth/form/github.ts index 6ab336fc74..e7087dbbe1 100644 --- a/ui/app/components/auth/form/github.ts +++ b/ui/app/components/auth/form/github.ts @@ -5,6 +5,8 @@ import AuthBase from './base'; +import type { GithubLoginApiResponse } from 'vault/vault/auth/methods'; + /** * @module Auth::Form::Github * see Auth::Base @@ -12,4 +14,22 @@ import AuthBase from './base'; export default class AuthFormGithub extends AuthBase { loginFields = [{ name: 'token', label: 'Github token' }]; + + async loginRequest(formData: { path: string; token: string }) { + const { path, token } = formData; + + const { auth } = (await this.api.auth.githubLogin(path, { + token, + })) as GithubLoginApiResponse; + + const { org, username } = auth?.metadata || {}; + const displayName = org && username ? `${org}/${username}` : username || org || ''; + + return this.normalizeAuthResponse(auth, { + authMountPath: path, + displayName, + token: auth.clientToken, + ttl: auth.leaseDuration, + }); + } } diff --git a/ui/app/components/auth/form/ldap.ts b/ui/app/components/auth/form/ldap.ts index 15dec866ce..a2c7c6a402 100644 --- a/ui/app/components/auth/form/ldap.ts +++ b/ui/app/components/auth/form/ldap.ts @@ -5,6 +5,8 @@ import AuthBase from './base'; +import type { UsernameLoginResponse } from 'vault/vault/auth/methods'; + /** * @module Auth::Form::Ldap * see Auth::Base @@ -12,4 +14,19 @@ import AuthBase from './base'; export default class AuthFormLdap extends AuthBase { loginFields = [{ name: 'username' }, { name: 'password' }]; + + async loginRequest(formData: { path: string; username: string; password: string }) { + const { path, username, password } = formData; + + const { auth } = (await this.api.auth.ldapLogin(username, path, { + password, + })) as UsernameLoginResponse; + + return this.normalizeAuthResponse(auth, { + authMountPath: path, + displayName: auth?.metadata?.username, + token: auth.clientToken, + ttl: auth.leaseDuration, + }); + } } diff --git a/ui/app/components/auth/form/oidc-jwt.hbs b/ui/app/components/auth/form/oidc-jwt.hbs index 9e291c489e..1b70155cd1 100644 --- a/ui/app/components/auth/form/oidc-jwt.hbs +++ b/ui/app/components/auth/form/oidc-jwt.hbs @@ -32,7 +32,7 @@ {{yield to="advancedSettings"}} diff --git a/ui/app/components/auth/form/oidc-jwt.ts b/ui/app/components/auth/form/oidc-jwt.ts index c01037ea2d..95057cc52b 100644 --- a/ui/app/components/auth/form/oidc-jwt.ts +++ b/ui/app/components/auth/form/oidc-jwt.ts @@ -5,19 +5,23 @@ import AuthBase from './base'; import Ember from 'ember'; -import { tracked } from '@glimmer/tracking'; -import { service } from '@ember/service'; -import { restartableTask, task, timeout, waitForEvent } from 'ember-concurrency'; import { action } from '@ember/object'; -import { waitFor } from '@ember/test-waiters'; -import errorMessage from 'vault/utils/error-message'; +import { dasherize } from '@ember/string'; +import { + DOMAIN_PROVIDER_MAP, + ERROR_JWT_LOGIN, + ERROR_MISSING_PARAMS, + ERROR_POPUP_FAILED, + ERROR_WINDOW_CLOSED, +} from 'vault/utils/auth-form-helpers'; +import { restartableTask, task, timeout, waitForEvent } from 'ember-concurrency'; +import { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import parseURL from 'core/utils/parse-url'; -import type AdapterError from 'vault/@ember-data/adapter/error'; -import type AuthService from 'vault/vault/services/auth'; -import type FlagsService from 'vault/services/flags'; -import type RoleJwtModel from 'vault/models/role-jwt'; -import type Store from '@ember-data/store'; import type { HTMLElementEvent } from 'vault/forms'; +import type { JwtOidcAuthUrlResponse, JwtOidcLoginApiResponse } from 'vault/vault/auth/methods'; +import type RouterService from '@ember/routing/router-service'; /** * @module Auth::Form::OidcJwt @@ -30,26 +34,17 @@ import type { HTMLElementEvent } from 'vault/forms'; interface JwtLoginData { namespace?: string; - path?: string; + path: string; role?: string; jwt?: string; } -interface OidcLoginData { - token: string; +interface UrlParseData { + hostname: string; } -const ERROR_WINDOW_CLOSED = - 'The provider window was closed before authentication was complete. Your web browser may have blocked or closed a pop-up window. Please check your settings and click Sign In to try again.'; -const ERROR_MISSING_PARAMS = - 'The callback from the provider did not supply all of the required parameters. Please click Sign In to try again. If the problem persists, you may want to contact your administrator.'; -const ERROR_JWT_LOGIN = 'OIDC login is not configured for this mount'; -export { ERROR_WINDOW_CLOSED, ERROR_MISSING_PARAMS, ERROR_JWT_LOGIN }; - export default class AuthFormOidcJwt extends AuthBase { - @service declare readonly auth: AuthService; - @service declare readonly flags: FlagsService; - @service declare readonly store: Store; + @service declare readonly router: RouterService; loginFields = [ { @@ -62,96 +57,132 @@ export default class AuthFormOidcJwt extends AuthBase { _formData: FormData = new FormData(); // set during auth prep and login workflow - @tracked fetchedRole: RoleJwtModel | null = null; + @tracked authUrl: string | null = null; @tracked errorMessage = ''; @tracked isOIDC = true; - get tasksAreRunning() { - return this.prepareForOIDC.isRunning || this.exchangeOIDC.isRunning; - } - get icon() { - return this?.fetchedRole?.providerIcon || ''; + // Right now there is a bug in HDS where the name includes a space, this line can be removed when we + // upgrade to an HDS version with the corrected icon name + if (this.provider === 'Ping Identity') return 'ping-identity '; + return this.provider ? dasherize(this.provider.toLowerCase()) : ''; } get providerName() { - return `with ${this?.fetchedRole?.providerName || 'OIDC Provider'}`; + return `with ${this.provider || 'OIDC Provider'}`; + } + + get provider() { + const { hostname } = parseURL(this.authUrl) as UrlParseData; + if (hostname) { + const firstMatch = Object.keys(DOMAIN_PROVIDER_MAP).find((name) => hostname.includes(name)); + return firstMatch ? DOMAIN_PROVIDER_MAP[firstMatch as keyof typeof DOMAIN_PROVIDER_MAP] : null; + } + return null; } @action initializeFormData(element: HTMLFormElement) { this._formData = new FormData(element); - this.fetchRole.perform(); + this.fetchAuthUrl.perform(); } @action updateFormData(event: HTMLElementEvent) { const { name, value } = event.target; + // the selectedAuthMethod dropdown is unrelated to login data so no need to track it in the form state. + if (name === 'selectedAuthMethod') return; this._formData?.set(name, value); - // re-fetch role if the following inputs have changed. namespace is not included because + // re-request auth_url if the following inputs have changed. namespace is not included because // when it changes the route model refreshes and a new component instantiates. if (['path', 'role'].includes(name)) { - this.fetchRole.perform(500); + this.fetchAuthUrl.perform(500); } } - fetchRole = restartableTask(async (wait = 0) => { + fetchAuthUrl = restartableTask(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); const { namespace = '', path = '', role = '' } = this.parseFormData(this._formData); - const id = JSON.stringify([path, role]); + const redirectUri = this.generateRedirectUri(namespace, path); // reset state - this.fetchedRole = null; + this.authUrl = null; this.errorMessage = ''; try { - this.fetchedRole = await this.store.findRecord('role-jwt', id, { - adapterOptions: { namespace }, - }); + const { data } = (await this.api.auth.jwtOidcRequestAuthorizationUrl(path, { + role, + redirectUri, + })) as JwtOidcAuthUrlResponse; + this.authUrl = data.authUrl; this.isOIDC = true; } catch (e) { - const { httpStatus } = e as AdapterError; - const message = errorMessage(e); - // track errors but they only display on submit + const { status, message } = await this.api.parseError(e); + // errors are tracked here but they only display on submit this.errorMessage = - httpStatus === 400 ? 'Invalid role. Please try again.' : `Error fetching role: ${message}`; - // if the mount is configured for JWT authentication via static keys, JWKS, or OIDC discovery + // A 400 is returned if OIDC is configured but does not have a default role set. + status === 400 ? 'Invalid role. Please try again.' : `Error fetching role: ${message}`; + // If the mount is configured for JWT authentication via static keys, JWKS, or OIDC discovery // this specific error is returned. Flip the isOIDC boolean accordingly, otherwise assume OIDC. - this.isOIDC = message !== ERROR_JWT_LOGIN; + this.isOIDC = !message.includes(ERROR_JWT_LOGIN); } }); - login = task( - waitFor(async (submitData) => { - if (this.isOIDC) { - this.startOIDCAuth(); - } else { - this.continueLogin(submitData); + generateRedirectUri(namespace = '', path = '') { + const origin = window.location.origin; + const qp = namespace ? { namespace } : {}; + const routeUrl = this.router.urlFor( + 'vault.cluster.oidc-callback', + { auth_path: path }, + { queryParams: qp } + ); + return `${origin}${routeUrl}`; + } + + // * LOGIN WORKFLOW BEGINS + async loginRequest(formData: JwtLoginData) { + if (this.isOIDC) { + return await this.loginOidc(); + } else { + return await this.loginJwt(formData); + } + } + + async loginJwt(formData: JwtLoginData) { + const { path, jwt, role } = formData; + const { auth } = (await this.api.auth.jwtLogin(path, { jwt, role })) as JwtOidcLoginApiResponse; + // displayName is not returned by auth response and is set in persistAuthData + return this.normalizeAuthResponse(auth, { + authMountPath: path, + token: auth.clientToken, + ttl: auth.leaseDuration, + }); + } + + async loginOidc() { + const oidcWindow = await this.startOIDCAuth(); + if (oidcWindow) { + try { + // Initiate watching for the popup and current window + this.watchPopup.perform(oidcWindow); + this.watchCurrent.perform(oidcWindow); + const eventData = await this.prepareForOIDC(); + const { auth, path } = await this.exchangeOIDC(eventData); + // displayName is not returned by auth response and is set in persistAuthData + return this.normalizeAuthResponse(auth, { + authMountPath: path, + token: auth.clientToken, + ttl: auth.leaseDuration, + }); + } finally { + this.closeWindow(oidcWindow); } - }) - ); - - async continueLogin(data: JwtLoginData | OidcLoginData) { - try { - // TODO CMB backend should probably be path, but holding off refactor since api service may remove need all together - // OIDC callback returns a token so authenticate with that - const backend = this.isOIDC && 'token' in data ? 'token' : this.args.authType; - - const authResponse = await this.auth.authenticate({ - clusterId: this.args.cluster.id, - backend, - data, - selectedAuth: this.args.authType, - }); - - // responsible for redirect after auth data is persisted - this.handleAuthResponse(authResponse); - } catch (error) { - this.onError(error as Error); + } else { + throw `Failed to open OIDC popup window. ${ERROR_POPUP_FAILED}`; } } @@ -159,42 +190,39 @@ export default class AuthFormOidcJwt extends AuthBase { // 1. request oidc/auth_url to check for config errors, if none continue // 2. open popup window at auth_url async startOIDCAuth() { - await this.fetchRole.perform(); + await this.fetchAuthUrl.perform(); - const error = - this.fetchedRole && !this.fetchedRole.authUrl - ? 'Missing auth_url. Please check that allowed_redirect_uris for the role include this mount path.' - : this.errorMessage || null; - - if (error) { - this.onError(error); - } else { - const win = window; - const POPUP_WIDTH = 500; - const POPUP_HEIGHT = 600; - const left = win.screen.width / 2 - POPUP_WIDTH / 2; - const top = win.screen.height / 2 - POPUP_HEIGHT / 2; - const oidcWindow = win.open( - this.fetchedRole?.authUrl, - 'vaultOIDCWindow', - `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},resizable,scrollbars=yes,top=${top},left=${left}` - ); - - this.prepareForOIDC.perform(oidcWindow); + if (!this.authUrl) { + const error = + // authUrl is an empty string if the request succeeds but a role is not properly configured. + this.authUrl === '' + ? 'Missing auth_url. Please check that allowed_redirect_uris for the role include this mount path.' + : this.errorMessage || 'Unknown OIDC error. Check the Vault logs and try again.'; + throw error; } + + const win = window; + const POPUP_WIDTH = 500; + const POPUP_HEIGHT = 600; + const left = win.screen.width / 2 - POPUP_WIDTH / 2; + const top = win.screen.height / 2 - POPUP_HEIGHT / 2; + const oidcWindow = win.open( + this.authUrl, + 'vaultOIDCWindow', + `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},resizable,scrollbars=yes,top=${top},left=${left}` + ); + + return oidcWindow; } // * OIDC AUTH PART 2 // 3. watch popups for premature closure - // 4. wait message event from window.postMessage() in oidc-callback route - prepareForOIDC = task(async (oidcWindow) => { + // 4. wait for message event from window.postMessage() in oidc-callback route + async prepareForOIDC() { // NOTE TO DEVS: Be careful when updating the OIDC flow and ensure the updates // work with implicit flow. See issue https://github.com/hashicorp/vault-plugin-auth-jwt/pull/192 const thisWindow = window; - // start watching the popup window and the current one - this.watchPopup.perform(oidcWindow); - this.watchCurrent.perform(oidcWindow); // eslint-disable-next-line no-constant-condition while (true) { // wait for message posted from oidc callback, see issue https://github.com/hashicorp/vault/issues/12436 @@ -202,48 +230,32 @@ export default class AuthFormOidcJwt extends AuthBase { const event = (await waitForEvent(thisWindow, 'message')) as unknown as MessageEvent; if (event.origin === thisWindow.origin && event.isTrusted && event.data.source === 'oidc-callback') { // event.data are params from the oidc callback url parsed by getParamsForCallback in the oidc-callback route - return this.exchangeOIDC.perform(event.data, oidcWindow); + return event.data; } } - }); + } // * OIDC AUTH PART 3 // 5. check parsed url for expected state params // 6. if successful, request client_token from oidc/callback // 7. close popups and continue login with client_token - exchangeOIDC = task(async (oidcState, oidcWindow) => { - if (oidcState === null || oidcState === undefined) { - return; - } + async exchangeOIDC(oidcState: { path: string; state: string; code: string }) { const { path, state, code } = oidcState; if (!path || !state || !code) { - return this.cancelLogin(oidcWindow, ERROR_MISSING_PARAMS); + throw ERROR_MISSING_PARAMS; } - let resp; // do the OIDC exchange, set the token and continue login flow - try { - const adapter = this.store.adapterFor('auth-method'); - resp = await adapter.exchangeOIDC(path, state, code); - this.closeWindow(oidcWindow); - } catch (e) { - // If there was an error on Vault's end, close the popup - // and show the error on the login screen - return this.cancelLogin(oidcWindow, errorMessage(e)); - } - - const { client_token, mfa_requirement } = resp.auth; - if (mfa_requirement) { - return this.handleMfa(mfa_requirement, path); - } else if (client_token) { - return this.continueLogin({ token: client_token }); - } else { - // If there's a problem with the OIDC exchange the auth workflow should fail earlier. - // Including this catch just in case, though it's unlikely this will be hit. - this.handleOIDCError('Missing token. Please try again.'); - } - }); + const { auth } = (await this.api.auth.jwtOidcCallback( + path, + undefined, + code, + state + )) as JwtOidcLoginApiResponse; + return { auth, path }; + } + //* END LOGIN METHODS // MANAGE POPUPS watchPopup = task(async (oidcWindow) => { @@ -253,7 +265,10 @@ export default class AuthFormOidcJwt extends AuthBase { await timeout(WAIT_TIME); if (!oidcWindow || oidcWindow.closed) { - return this.handleOIDCError(ERROR_WINDOW_CLOSED); + // Since watchPopup isn't awaited, errors thrown here won't bubble up + // and so we must call onError directly instead. + this.onError(ERROR_WINDOW_CLOSED); + return; } } }); @@ -264,9 +279,8 @@ export default class AuthFormOidcJwt extends AuthBase { oidcWindow.close(); }); - cancelLogin(oidcWindow: Window, errorMessage: string) { - this.closeWindow(oidcWindow); - this.handleOIDCError(errorMessage); + cancelLogin() { + this.login.cancelAll(); } closeWindow(oidcWindow: Window) { @@ -274,10 +288,4 @@ export default class AuthFormOidcJwt extends AuthBase { this.watchCurrent.cancelAll(); oidcWindow.close(); } - - handleOIDCError(err: string) { - this.prepareForOIDC.cancelAll(); - this.exchangeOIDC.cancelAll(); - this.onError(err); - } } diff --git a/ui/app/components/auth/form/okta.hbs b/ui/app/components/auth/form/okta.hbs index f9e4852406..b34ab6d80a 100644 --- a/ui/app/components/auth/form/okta.hbs +++ b/ui/app/components/auth/form/okta.hbs @@ -7,7 +7,7 @@ {{else}}
diff --git a/ui/app/components/auth/form/okta.ts b/ui/app/components/auth/form/okta.ts index 38cc306d45..3d40aa0494 100644 --- a/ui/app/components/auth/form/okta.ts +++ b/ui/app/components/auth/form/okta.ts @@ -5,14 +5,12 @@ import AuthBase from './base'; import { action } from '@ember/object'; -import { service } from '@ember/service'; import { task, timeout } from 'ember-concurrency'; import { tracked } from '@glimmer/tracking'; import { waitFor } from '@ember/test-waiters'; -import errorMessage from 'vault/utils/error-message'; import uuid from 'core/utils/uuid'; -import type AuthService from 'vault/vault/services/auth'; +import type { OktaVerifyApiResponse, UsernameLoginResponse } from 'vault/vault/auth/methods'; /** * @module Auth::Form::Okta @@ -20,41 +18,34 @@ import type AuthService from 'vault/vault/services/auth'; * */ export default class AuthFormOkta extends AuthBase { - @service declare readonly auth: AuthService; - @tracked challengeAnswer = ''; @tracked oktaVerifyError = ''; @tracked showNumberChallenge = false; loginFields = [{ name: 'username' }, { name: 'password' }]; - login = task( - waitFor(async (data) => { - // wait for 1s to wait to see if there is a login error before polling - await timeout(1000); + async loginRequest(formData: { path: string; username: string; password: string }) { + const { path, username, password } = formData; + // wait for 1s to wait to see if there is a login error before polling + await timeout(1000); - data.nonce = uuid(); - this.pollForOktaNumberChallenge.perform(data.nonce, data.path); + const nonce = uuid(); + this.pollForOktaNumberChallenge.perform(nonce, path); - try { - // selecting the correct okta verify answer on the personal device resolves this request - const authResponse = await this.auth.authenticate({ - clusterId: this.args.cluster.id, - backend: this.args.authType, - data, - selectedAuth: this.args.authType, - }); + // If an Okta MFA challenge is configured for the end user this request resolves when it is completed. + // If a user fails the MFA challenge (e.g. Okta number challenge) this POST login request fails. + const { auth } = (await this.api.auth.oktaLogin(username, path, { + nonce, + password, + })) as UsernameLoginResponse; - this.handleAuthResponse(authResponse); - } catch (error) { - // if a user fails the okta verify challenge, the POST login request fails (made by this.auth.authenticate above) - // bubble those up for consistency instead of managing error state in this component - this.onError(error as Error); - // cancel polling tasks and reset state - this.reset(); - } - }) - ); + return this.normalizeAuthResponse(auth, { + authMountPath: path, + displayName: auth?.metadata?.username, + token: auth.clientToken, + ttl: auth.leaseDuration, + }); + } pollForOktaNumberChallenge = task( waitFor(async (nonce, mountPath) => { @@ -68,36 +59,35 @@ export default class AuthFormOkta extends AuthBase { } // display correct number so user can select on personal MFA device - this.challengeAnswer = verifyNumber ?? ''; + this.challengeAnswer = verifyNumber?.toString() ?? ''; }) ); @action async requestOktaVerify(nonce: string, mountPath: string) { - const url = `/v1/auth/${mountPath}/verify/${nonce}`; try { - const response = await this.auth.ajax(url, 'GET', {}); - return response.data.correct_answer; + const { data } = (await this.api.auth.oktaVerify(nonce, mountPath)) as OktaVerifyApiResponse; + return data.correctAnswer; } catch (e) { - const error = e as Response; - if (error?.status === 404) { + const { status, message } = await this.api.parseError(e); + if (status === 404) { // if error status is 404 return null to keep polling for a response return null; } else { // this would be unusual, but handling just in case - this.oktaVerifyError = errorMessage(e); + this.oktaVerifyError = message; return; } } } @action - reset() { + cancelLogin() { // reset tracked variables and stop polling tasks this.challengeAnswer = ''; this.oktaVerifyError = ''; this.showNumberChallenge = false; - this.login.cancelAll(); this.pollForOktaNumberChallenge.cancelAll(); + this.login.cancelAll(); } } diff --git a/ui/app/components/auth/form/radius.ts b/ui/app/components/auth/form/radius.ts index 143d7e86ab..a68ad9bdda 100644 --- a/ui/app/components/auth/form/radius.ts +++ b/ui/app/components/auth/form/radius.ts @@ -5,6 +5,8 @@ import AuthBase from './base'; +import type { UsernameLoginResponse } from 'vault/vault/auth/methods'; + /** * @module Auth::Form::Radius * see Auth::Base @@ -12,4 +14,19 @@ import AuthBase from './base'; export default class AuthFormRadius extends AuthBase { loginFields = [{ name: 'username' }, { name: 'password' }]; + + async loginRequest(formData: { path: string; username: string; password: string }) { + const { path, username, password } = formData; + + const { auth } = (await this.api.auth.radiusLoginWithUsername(username, path, { + password, + })) as UsernameLoginResponse; + + return this.normalizeAuthResponse(auth, { + authMountPath: path, + displayName: auth?.metadata?.username, + token: auth.clientToken, + ttl: auth.leaseDuration, + }); + } } diff --git a/ui/app/components/auth/form/saml.hbs b/ui/app/components/auth/form/saml.hbs index 6c8df26b4c..05f9fe78f1 100644 --- a/ui/app/components/auth/form/saml.hbs +++ b/ui/app/components/auth/form/saml.hbs @@ -18,7 +18,7 @@ {{yield to="advancedSettings"}} - + {{else}} diff --git a/ui/app/components/auth/form/saml.ts b/ui/app/components/auth/form/saml.ts index 1db25b38ab..7829601126 100644 --- a/ui/app/components/auth/form/saml.ts +++ b/ui/app/components/auth/form/saml.ts @@ -5,34 +5,26 @@ import AuthBase from './base'; import Ember from 'ember'; -import { service } from '@ember/service'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; +import { SamlWriteSsoServiceUrlRequestClientTypeEnum } from '@hashicorp/vault-client-typescript'; +import { sanitizePath } from 'core/utils/sanitize-path'; import { task, timeout, waitForEvent } from 'ember-concurrency'; -import { tracked } from '@glimmer/tracking'; -import errorMessage from 'vault/utils/error-message'; +import uuid from 'core/utils/uuid'; +import { ERROR_POPUP_FAILED, ERROR_TIMEOUT, ERROR_WINDOW_CLOSED } from 'vault/utils/auth-form-helpers'; -import type AdapterError from 'vault/@ember-data/adapter/error'; -import type AuthMethodAdapter from 'vault/vault/adapters/auth-method'; -import type AuthService from 'vault/vault/services/auth'; -import type RoleSamlModel from 'vault/models/role-saml'; -import type Store from '@ember-data/store'; +import type { SamlLoginApiResponse, SamlSsoServiceUrlApiResponse } from 'vault/vault/auth/methods'; /** * @module Auth::Form::Saml * see Auth::Base */ -const ERROR_WINDOW_CLOSED = - 'The provider window was closed before authentication was complete. Your web browser may have blocked or closed a pop-up window. Please check your settings and click "Sign in" to try again.'; -const ERROR_TIMEOUT = 'The authentication request has timed out. Please click "Sign in" to try again.'; - -export { ERROR_WINDOW_CLOSED }; - +interface SamlRole { + ssoServiceUrl: string; + tokenPollId: string; + clientVerifier: string; +} export default class AuthFormSaml extends AuthBase { - @service declare readonly auth: AuthService; - @service declare readonly store: Store; - - @tracked fetchedRole: RoleSamlModel | null = null; - loginFields = [ { name: 'role', @@ -44,10 +36,6 @@ export default class AuthFormSaml extends AuthBase { return window.isSecureContext; } - get tasksAreRunning() { - return this.login.isRunning || this.exchangeSAMLTokenPollID.isRunning; - } - /* Saml auth flow on login button click: * 1. find role-saml record which returns role info * 2. open popup at url defined returned from role @@ -55,125 +43,104 @@ export default class AuthFormSaml extends AuthBase { * 4. poll vault for 200 token response * 5. close popup, stop polling, and trigger onSubmit with token data */ - login = task(async (formData) => { + async loginRequest(formData: { namespace: string; path: string; role: string }) { // submit data is parsed by base.ts and a path will always have a value. // either the default of auth type, or the custom inputted path - const { role, path, namespace } = formData; + const { namespace, path, role } = formData; + const fetchedRole = await this.fetchSamlRole({ namespace, path, role }); + const samlWindow = await this.startSAMLAuth(fetchedRole.ssoServiceUrl); + if (samlWindow) { + try { + // start watching the popup window and the current one + this.watchPopup.perform(samlWindow); + this.watchCurrent.perform(samlWindow); - await this.startSAMLAuth({ role, path, namespace }); - }); + const { auth } = await this.exchangeSAMLTokenPollID(fetchedRole, { path }); - // Fetch role to get sso_service_url and open popup - async startSAMLAuth({ role = '', path = '', namespace = '' }) { - try { - const id = JSON.stringify([path, role]); - this.fetchedRole = await this.store.findRecord('role-saml', id, { - adapterOptions: { namespace }, - }); - } catch (error) { - this.onError(errorMessage(error)); - return; - } - - if (this.fetchedRole) { - const win = window; - const POPUP_WIDTH = 500; - const POPUP_HEIGHT = 600; - const left = win.screen.width / 2 - POPUP_WIDTH / 2; - const top = win.screen.height / 2 - POPUP_HEIGHT / 2; - const samlWindow = win.open( - this.fetchedRole.ssoServiceURL, - 'vaultSAMLWindow', - `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},resizable,scrollbars=yes,top=${top},left=${left}` - ); - - await this.exchangeSAMLTokenPollID.perform(samlWindow, { path }); + // displayName is not included in auth response - it is set in persistAuthData + return this.normalizeAuthResponse(auth, { + authMountPath: path, + token: auth.clientToken, + ttl: auth.leaseDuration, + }); + } finally { + this.closeWindow(samlWindow); + } + } else { + throw `Failed to open SAML popup window. ${ERROR_POPUP_FAILED}`; } } - exchangeSAMLTokenPollID = task(async (samlWindow, { path }) => { - // start watching the popup window and the current one - this.watchPopup.perform(samlWindow); - this.watchCurrent.perform(samlWindow); + cancelLogin() { + this.login.cancelAll(); + } - let resp; - try { - resp = await this.pollForToken(samlWindow, { path }); - this.closeWindow(samlWindow); - } catch (error) { - this.cancelLogin(samlWindow, errorMessage(error)); - return; - } + // Fetch role to get sso_service_url which is where popup is opened + async fetchSamlRole({ namespace = '', path = '', role = '' }): Promise { + // Create the client verifier and challenge + const verifier = uuid(); + const clientChallenge = await this.generateClientChallenge(verifier); + const acsUrl = this.generateAcsUrl(path, namespace); + const clientType = SamlWriteSsoServiceUrlRequestClientTypeEnum.BROWSER; // 'browser' + // Kick off the authentication flow by generating the SSO service URL + // It requires the client challenge generated from the verifier. We'll + // later provide the verifier to match up with the challenge on the server + // when we poll for the Vault token by its returned token poll ID. + const { data } = (await this.api.auth.samlWriteSsoServiceUrl(path, { + acsUrl, + clientChallenge, + clientType, + role, + })) as SamlSsoServiceUrlApiResponse; + return { + ...data, + clientVerifier: verifier, + }; + } - // We've got a response from the polling request - // pass MFA data or use the Vault token (client_token) to continue the auth - const mfa_requirement = resp?.mfa_requirement; - const client_token = resp?.client_token; - if (mfa_requirement) { - this.handleMfa(mfa_requirement, path); - return; - } - if (client_token) { - this.continueLogin({ token: client_token }); - return; - } + async startSAMLAuth(ssoServiceUrl: string): Promise { + const win = window; + const POPUP_WIDTH = 500; + const POPUP_HEIGHT = 600; + const left = win.screen.width / 2 - POPUP_WIDTH / 2; + const top = win.screen.height / 2 - POPUP_HEIGHT / 2; + const samlWindow = win.open( + ssoServiceUrl, + 'vaultSAMLWindow', + `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},resizable,scrollbars=yes,top=${top},left=${left}` + ); - // If there's a problem with the SAML exchange the auth workflow should fail earlier. - // Including this catch just in case, though it's unlikely this will be hit. - this.handleSAMLError('Missing token. Please try again.'); - return; - }); + return samlWindow; + } - async pollForToken(samlWindow: Window, { path = '' }) { - // Poll every one second for the token to become available + async exchangeSAMLTokenPollID(fetchedRole: SamlRole, { path = '' }) { const WAIT_TIME = Ember.testing ? 50 : 1000; - const MAX_TIME = Ember.testing ? 3 : 180; // 180 is 3 minutes in seconds + const MAX_TRIES = Ember.testing ? 3 : 180; // 180 is 3 minutes in seconds - const adapter = this.store.adapterFor('auth-method') as AuthMethodAdapter; // Wait up to 3 minutes for a token to become available - for (let attempt = 0; attempt < MAX_TIME; attempt++) { + for (let attempt = 0; attempt < MAX_TRIES; attempt++) { + // Poll every one second for the token to become available await timeout(WAIT_TIME); try { - const resp = await adapter.pollSAMLToken( - path, - this.fetchedRole?.tokenPollID, - this.fetchedRole?.clientVerifier - ); - - if (resp?.auth) { - // Exit loop if response - return resp.auth; - } + const { clientVerifier, tokenPollId } = fetchedRole; + // Exit loop if there's a response + return (await this.api.auth.samlWriteToken(path, { + clientVerifier, + tokenPollId, + })) as SamlLoginApiResponse; } catch (e) { - const error = e as AdapterError; - if (error.httpStatus === 401) { + const { message, status } = await this.api.parseError(e); + if (status === 401) { // Continue to retry on 401 Unauthorized continue; } - throw error; + // Just throw the message string because parent onError method will fail if it attempts to re-parse an error. + throw message; } } - this.cancelLogin(samlWindow, ERROR_TIMEOUT); - return; - } - - async continueLogin(data: { token: string }) { - try { - const authResponse = await this.auth.authenticate({ - clusterId: this.args.cluster.id, - backend: 'token', - data, - selectedAuth: this.args.authType, - }); - - // responsible for redirect after auth data is persisted - this.handleAuthResponse(authResponse); - } catch (e) { - const error = e as AdapterError; - this.onError(errorMessage(error)); - } + throw ERROR_TIMEOUT; } // MANAGE POPUPS @@ -184,7 +151,10 @@ export default class AuthFormSaml extends AuthBase { await timeout(WAIT_TIME); if (!samlWindow || samlWindow.closed) { - return this.handleSAMLError(ERROR_WINDOW_CLOSED); + // Since watchPopup isn't awaited, errors thrown here won't bubble up + // and so we must call onError directly instead. + this.onError(ERROR_WINDOW_CLOSED); + return; } } }); @@ -195,19 +165,29 @@ export default class AuthFormSaml extends AuthBase { samlWindow?.close(); }); - cancelLogin(samlWindow: Window, errorMessage: string) { - this.closeWindow(samlWindow); - this.handleSAMLError(errorMessage); - } - closeWindow(samlWindow: Window) { this.watchPopup.cancelAll(); this.watchCurrent.cancelAll(); samlWindow.close(); } - handleSAMLError(errorMessage: string) { - this.exchangeSAMLTokenPollID.cancelAll(); - this.onError(errorMessage); + // generates a client challenge from a verifier for PKCE (Proof Key for Code Exchange). + // The client challenge is the base64(sha256(verifier)). The verifier is + // later presented to the server to obtain the resulting Vault token. + async generateClientChallenge(verifier: string) { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = new Uint8Array(hashBuffer); + return btoa(String.fromCharCode(...hashArray)); + } + + generateAcsUrl(path: string, namespace: string) { + const baseUrl = `${window.location.origin}/v1`; + const ns = namespace ? `${encodePath(sanitizePath(namespace))}/` : ''; + const mountPath = encodePath(sanitizePath(path)); + // example with "admin" namespace: '${VAULT_ADDR}/v1/admin/auth/saml/callback'; + // example with "root" namespace: '${VAULT_ADDR}/v1/auth/saml/callback'; + return `${baseUrl}/${ns}auth/${mountPath}/callback`; } } diff --git a/ui/app/components/auth/form/token.ts b/ui/app/components/auth/form/token.ts index 130017d4a2..b356a6b8f2 100644 --- a/ui/app/components/auth/form/token.ts +++ b/ui/app/components/auth/form/token.ts @@ -5,6 +5,8 @@ import AuthBase from './base'; +import type { TokenLoginApiResponse } from 'vault/vault/auth/methods'; + /** * @module Auth::Form::Token * see Auth::Base @@ -12,4 +14,19 @@ import AuthBase from './base'; export default class AuthFormToken extends AuthBase { loginFields = [{ name: 'token' }]; + + async loginRequest(formData: { token: string }) { + const { token } = formData; + + const { data } = (await this.api.auth.tokenLookUpSelf( + this.api.buildHeaders({ token }) + )) as TokenLoginApiResponse; + + // normalize auth data so stored token data has the same keys regardless of auth type + return this.normalizeAuthResponse(data, { + authMountPath: '', + token: data.id, + ttl: data.ttl, + }); + } } diff --git a/ui/app/components/auth/form/userpass.ts b/ui/app/components/auth/form/userpass.ts index de28d0243a..3999bf6b4e 100644 --- a/ui/app/components/auth/form/userpass.ts +++ b/ui/app/components/auth/form/userpass.ts @@ -5,6 +5,8 @@ import AuthBase from './base'; +import type { UsernameLoginResponse } from 'vault/vault/auth/methods'; + /** * @module Auth::Form::Userpass * see Auth::Base @@ -12,4 +14,20 @@ import AuthBase from './base'; export default class AuthFormUserpass extends AuthBase { loginFields = [{ name: 'username' }, { name: 'password' }]; + + async loginRequest(formData: { path: string; username: string; password: string }) { + const { path, username, password } = formData; + + const { auth } = (await this.api.auth.userpassLogin(username, path, { + password, + })) as UsernameLoginResponse; + + // normalize auth data so stored token data has the same keys regardless of auth type + return this.normalizeAuthResponse(auth, { + authMountPath: path, + displayName: auth?.metadata?.username, + token: auth.clientToken, + ttl: auth.leaseDuration, + }); + } } diff --git a/ui/app/components/auth/page.ts b/ui/app/components/auth/page.ts index d804c6355f..e54621bae3 100644 --- a/ui/app/components/auth/page.ts +++ b/ui/app/components/auth/page.ts @@ -8,8 +8,8 @@ import { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; -import type { AuthResponse, AuthResponseWithMfa } from 'vault/vault/services/auth'; -import type { UnauthMountsByType, UnauthMountsResponse } from 'vault/vault/auth/form'; +import type { AuthSuccessResponse } from 'vault/vault/services/auth'; +import type { NormalizedAuthData, UnauthMountsByType, UnauthMountsResponse } from 'vault/vault/auth/form'; import type AuthService from 'vault/vault/services/auth'; import type ClusterModel from 'vault/models/cluster'; import type CspEventService from 'vault/services/csp-event'; @@ -89,9 +89,9 @@ interface Args { } interface MfaAuthData { - mfa_requirement: object; - path: string; - selectedAuth: string; + mfaRequirement: object; + authMethodType: string; + authMountPath: string; } enum FormView { @@ -235,35 +235,33 @@ export default class AuthPage extends Component { // ACTIONS @action - onAuthResponse(authResponse: AuthResponse | AuthResponseWithMfa, { selectedAuth = '', path = '' }) { - const mfa_requirement = 'mfa_requirement' in authResponse ? authResponse.mfa_requirement : undefined; - /* - Checking for an mfa_requirement happens in two places. - If doSubmit in is called directly (by the component) mfa is just handled here. - - Login methods submitted using a child form component of are first checked for mfa - in the Auth::LoginForm "authenticate" task, and then that data eventually bubbles up here. - */ - if (mfa_requirement) { + async onAuthResponse(normalizedAuthData: NormalizedAuthData) { + const hasMfa = 'mfaRequirement' in normalizedAuthData ? normalizedAuthData.mfaRequirement : undefined; + + if (hasMfa) { // if an mfa requirement exists further action is required - this.mfaAuthData = { mfa_requirement, selectedAuth, path }; + const { authMethodType, authMountPath } = normalizedAuthData; + const parsedMfaResponse = this.auth.parseMfaResponse(hasMfa); + this.mfaAuthData = { mfaRequirement: parsedMfaResponse, authMethodType, authMountPath }; } else { + // Persist auth data in local storage + const resp = await this.auth.authSuccess(this.args.cluster.id, normalizedAuthData); // calls authSuccess in auth.js controller - this.args.onAuthSuccess(authResponse); + this.args.onAuthSuccess(resp); } } @action onCancelMfa() { // before resetting mfaAuthData, preserve auth type - this.canceledMfaAuth = this.mfaAuthData?.selectedAuth ?? ''; + this.canceledMfaAuth = this.mfaAuthData?.authMethodType ?? ''; this.mfaAuthData = null; } @action - onMfaSuccess(authResponse: AuthResponse) { + onMfaSuccess(authSuccessData: AuthSuccessResponse) { // calls authSuccess in auth.js controller - this.args.onAuthSuccess(authResponse); + this.args.onAuthSuccess(authSuccessData); } @action diff --git a/ui/app/components/control-group.js b/ui/app/components/control-group.js index c5ff165922..5a0d6ce306 100644 --- a/ui/app/components/control-group.js +++ b/ui/app/components/control-group.js @@ -24,7 +24,7 @@ export default Component.extend({ this.set('controlGroupResponse', data); }, - currentUserEntityId: alias('auth.authData.entity_id'), + currentUserEntityId: alias('auth.authData.entityId'), currentUserIsRequesting: computed('currentUserEntityId', 'model.requestEntity.id', function () { if (!this.model.requestEntity) return false; diff --git a/ui/app/components/mfa/mfa-form.js b/ui/app/components/mfa/mfa-form.js index 5fc8c2fafa..e7d307401f 100644 --- a/ui/app/components/mfa/mfa-form.js +++ b/ui/app/components/mfa/mfa-form.js @@ -10,6 +10,7 @@ import { tracked } from '@glimmer/tracking'; import { action, set } from '@ember/object'; import { task, timeout } from 'ember-concurrency'; import { numberToWord } from 'vault/helpers/number-to-word'; +import errorMessage from 'vault/utils/error-message'; /** * @module MfaForm * The MfaForm component is used to enter a passcode when mfa is required to login @@ -19,7 +20,7 @@ import { numberToWord } from 'vault/helpers/number-to-word'; * * ``` * @param {string} clusterId - id of selected cluster - * @param {object} authData - data from initial auth request -- { mfa_requirement, backend, data } + * @param {object} authData - data from initial auth request -- { mfaRequirement, backend, data } * @param {function} onSuccess - fired when passcode passes validation * @param {function} onError - fired for multi-method or non-passcode method validation errors */ @@ -46,7 +47,7 @@ export default class MfaForm extends Component { } get constraints() { - return this.args.authData.mfa_requirement.mfa_constraints; + return this.args.authData.mfaRequirement.mfaConstraints; } get multiConstraint() { return this.constraints.length > 1; @@ -98,8 +99,7 @@ export default class MfaForm extends Component { } else if (this.singlePasscode) { this.error = TOTP_VALIDATION_ERROR; } else { - const errorMessage = this.auth.handleError(error).join('. '); - this.args.onError(errorMessage); + this.args.onError(errorMessage(error)); } } } diff --git a/ui/app/components/sidebar/user-menu.js b/ui/app/components/sidebar/user-menu.js index 0b7305708a..58c3f6d481 100644 --- a/ui/app/components/sidebar/user-menu.js +++ b/ui/app/components/sidebar/user-menu.js @@ -19,10 +19,10 @@ export default class SidebarUserMenuComponent extends Component { get hasEntityId() { // root users will not have an entity_id because they are not associated with an entity. // in order to use the MFA end user setup they need an entity_id - return !!this.auth.authData?.entity_id; + return !!this.auth.authData?.entityId; } get isUserpass() { - return this.auth.authData?.backend?.type === 'userpass'; + return this.auth.authData?.authMethodType === 'userpass'; } get isRenewing() { diff --git a/ui/app/controllers/vault/cluster/auth.js b/ui/app/controllers/vault/cluster/auth.js index c5a8e99a3a..c366e64272 100644 --- a/ui/app/controllers/vault/cluster/auth.js +++ b/ui/app/controllers/vault/cluster/auth.js @@ -39,7 +39,7 @@ export default Controller.extend({ updateNamespace: task(function* (value) { const ns = this.fullNamespaceFromInput(value); - this.namespaceService.setNamespace(ns, true); + this.namespaceService.setNamespace(ns); yield this.customMessages.fetchMessages(); this.set('namespaceQueryParam', ns); // if user is inputting a namespace, maintain input focus as the param updates diff --git a/ui/app/controllers/vault/cluster/mfa-setup.js b/ui/app/controllers/vault/cluster/mfa-setup.js index 65c98d10d4..78d727a461 100644 --- a/ui/app/controllers/vault/cluster/mfa-setup.js +++ b/ui/app/controllers/vault/cluster/mfa-setup.js @@ -16,7 +16,7 @@ export default class VaultClusterMfaSetupController extends Controller { @tracked qrCode = ''; get entityId() { - return this.auth.authData.entity_id; + return this.auth.authData.entityId; } @action isUUIDVerified(verified) { diff --git a/ui/app/helpers/supported-auth-backends.js b/ui/app/helpers/supported-auth-backends.js deleted file mode 100644 index c27f6b9a37..0000000000 --- a/ui/app/helpers/supported-auth-backends.js +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { helper as buildHelper } from '@ember/component/helper'; - -/** - * These are all the auth methods with which a user can log into the UI. - */ - -const SUPPORTED_AUTH_BACKENDS = [ - { - type: 'token', - typeDisplay: 'Token', - description: 'Token authentication.', - tokenPath: 'id', - displayNamePath: 'display_name', - formAttributes: ['token'], - }, - { - type: 'userpass', - typeDisplay: 'Username', - description: 'A simple username and password backend.', - tokenPath: 'client_token', - displayNamePath: 'metadata.username', - formAttributes: ['username', 'password'], - }, - { - type: 'ldap', - typeDisplay: 'LDAP', - description: 'LDAP authentication.', - tokenPath: 'client_token', - displayNamePath: 'metadata.username', - formAttributes: ['username', 'password'], - }, - { - type: 'okta', - typeDisplay: 'Okta', - description: 'Authenticate with your Okta username and password.', - tokenPath: 'client_token', - displayNamePath: 'metadata.username', - formAttributes: ['username', 'password'], - }, - { - type: 'jwt', - typeDisplay: 'JWT', - description: 'Authenticate using JWT or OIDC provider.', - tokenPath: 'client_token', - displayNamePath: 'display_name', - formAttributes: ['role', 'jwt'], - }, - { - type: 'oidc', - typeDisplay: 'OIDC', - description: 'Authenticate using JWT or OIDC provider.', - tokenPath: 'client_token', - displayNamePath: 'display_name', - formAttributes: ['role', 'jwt'], - }, - { - type: 'radius', - typeDisplay: 'RADIUS', - description: 'Authenticate with your RADIUS username and password.', - tokenPath: 'client_token', - displayNamePath: 'metadata.username', - formAttributes: ['username', 'password'], - }, - { - type: 'github', - typeDisplay: 'GitHub', - description: 'GitHub authentication.', - tokenPath: 'client_token', - displayNamePath: ['metadata.org', 'metadata.username'], - formAttributes: ['token'], - }, -]; - -const ENTERPRISE_AUTH_METHODS = [ - { - type: 'saml', - typeDisplay: 'SAML', - description: 'Authenticate using SAML provider.', - tokenPath: 'client_token', - displayNamePath: 'display_name', - formAttributes: ['role'], - }, -]; - -export function supportedAuthBackends() { - return [...SUPPORTED_AUTH_BACKENDS]; -} - -export function allSupportedAuthBackends() { - return [...SUPPORTED_AUTH_BACKENDS, ...ENTERPRISE_AUTH_METHODS]; -} - -export default buildHelper(supportedAuthBackends); diff --git a/ui/app/models/auth-method.js b/ui/app/models/auth-method.js index 58c82450ef..25da90e272 100644 --- a/ui/app/models/auth-method.js +++ b/ui/app/models/auth-method.js @@ -12,7 +12,7 @@ import lazyCapabilities from 'vault/macros/lazy-capabilities'; import { action } from '@ember/object'; import { camelize } from '@ember/string'; import { WHITESPACE_WARNING } from 'vault/utils/forms/validators'; -import { supportedTypes } from 'vault/utils/supported-login-methods'; +import { supportedTypes } from 'vault/utils/auth-form-helpers'; import engineDisplayData from 'vault/helpers/engines-display-data'; const validations = { diff --git a/ui/app/models/role-saml.js b/ui/app/models/role-saml.js deleted file mode 100644 index 430845b094..0000000000 --- a/ui/app/models/role-saml.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import Model, { attr } from '@ember-data/model'; - -export default class RoleSamlModel extends Model { - @attr('string') ssoServiceURL; - @attr('string') tokenPollID; - @attr('string') clientVerifier; -} diff --git a/ui/app/resources/auth/method.ts b/ui/app/resources/auth/method.ts index d01f5b5aa8..567bbaa696 100644 --- a/ui/app/resources/auth/method.ts +++ b/ui/app/resources/auth/method.ts @@ -5,7 +5,7 @@ import { baseResourceFactory } from 'vault/resources/base-factory'; import { service } from '@ember/service'; -import { supportedTypes } from 'vault/utils/supported-login-methods'; +import { supportedTypes } from 'vault/utils/auth-form-helpers'; import engineDisplayData from 'vault/helpers/engines-display-data'; import type { Mount } from 'vault/mount'; diff --git a/ui/app/routes/vault/cluster.js b/ui/app/routes/vault/cluster.js index 2695365b0e..70046b6403 100644 --- a/ui/app/routes/vault/cluster.js +++ b/ui/app/routes/vault/cluster.js @@ -156,7 +156,7 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, { } try { - const entity_id = this.auth.authData?.entity_id; + const entity_id = this.auth.authData?.entityId; const entity = entity_id ? entity_id : `root_${uuidv4()}`; this.analytics.identifyUser(entity, { diff --git a/ui/app/routes/vault/cluster/access/reset-password.js b/ui/app/routes/vault/cluster/access/reset-password.js index a44e80bfda..88b43e4f2d 100644 --- a/ui/app/routes/vault/cluster/access/reset-password.js +++ b/ui/app/routes/vault/cluster/access/reset-password.js @@ -16,17 +16,17 @@ export default class VaultClusterAccessResetPasswordRoute extends Route { async model() { // Password reset is only available on userpass type auth mounts - if (this.auth.authData?.backend?.type !== 'userpass') { + if (this.auth.authData?.authMethodType !== 'userpass') { throw new Error(ERROR_UNAVAILABLE); } - const { backend, displayName } = this.auth.authData; - if (!backend.mountPath || !displayName) { + const { authMountPath, displayName } = this.auth.authData; + if (!authMountPath || !displayName) { throw new Error(ERROR_UNAVAILABLE); } try { const capabilities = await this.store.findRecord( 'capabilities', - `auth/${encodePath(backend.mountPath)}/users/${encodePath(displayName)}/password` + `auth/${encodePath(authMountPath)}/users/${encodePath(displayName)}/password` ); // Check that the user has ability to update password if (!capabilities.canUpdate) { @@ -36,7 +36,7 @@ export default class VaultClusterAccessResetPasswordRoute extends Route { // If capabilities can't be queried, default to letting the API decide } return { - backend: backend.mountPath, + backend: authMountPath, username: displayName, }; } diff --git a/ui/app/routes/vault/cluster/auth.js b/ui/app/routes/vault/cluster/auth.js index be32ae9054..57a40f2a44 100644 --- a/ui/app/routes/vault/cluster/auth.js +++ b/ui/app/routes/vault/cluster/auth.js @@ -7,7 +7,7 @@ import { service } from '@ember/service'; import ClusterRouteBase from './cluster-route-base'; import config from 'vault/config/environment'; import { isEmptyValue } from 'core/helpers/is-empty-value'; -import { supportedTypes } from 'vault/utils/supported-login-methods'; +import { supportedTypes } from 'vault/utils/auth-form-helpers'; import { sanitizePath } from 'core/utils/sanitize-path'; export default class AuthRoute extends ClusterRouteBase { @@ -80,12 +80,14 @@ export default class AuthRoute extends ClusterRouteBase { async unwrapToken(token, clusterId) { try { const { auth } = await this.api.sys.unwrap({}, this.api.buildHeaders({ token })); - return await this.auth.authenticate({ - clusterId, - backend: 'token', - data: { token: auth.clientToken }, - selectedAuth: 'token', - }); + const authData = { + ...auth, + authMethodType: 'token', + authMountPath: '', + token: auth.clientToken, + ttl: auth.leaseDuration, + }; + return await this.auth.authSuccess(clusterId, authData); } catch (e) { const { message } = await this.api.parseError(e); this.controllerFor('vault.cluster.auth').unwrapTokenError = message; diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js index 5c4b477fe4..27c67a4ef2 100644 --- a/ui/app/services/auth.js +++ b/ui/app/services/auth.js @@ -6,8 +6,7 @@ import Ember from 'ember'; import { task, timeout } from 'ember-concurrency'; import { getOwner } from '@ember/owner'; -import { isArray } from '@ember/array'; -import { computed, get } from '@ember/object'; +import { computed } from '@ember/object'; import { alias } from '@ember/object/computed'; import Service, { inject as service } from '@ember/service'; import { capitalize } from '@ember/string'; @@ -15,13 +14,11 @@ import { resolve, reject } from 'rsvp'; import getStorage from 'vault/lib/token-storage'; import ENV from 'vault/config/environment'; -import { allSupportedAuthBackends } from 'vault/helpers/supported-auth-backends'; import { addToArray } from 'vault/helpers/add-to-array'; const TOKEN_SEPARATOR = '☃'; const TOKEN_PREFIX = 'vault-'; const ROOT_PREFIX = '_root_'; -const BACKENDS = allSupportedAuthBackends(); export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX }; @@ -81,9 +78,9 @@ export default Service.extend({ if (!tokenName) { return; } - - const { tokenExpirationEpoch } = this.getTokenData(tokenName); - const expirationDate = new Date(0); + const tokenData = this.getTokenData(tokenName); + const tokenExpirationEpoch = tokenData ? tokenData?.tokenExpirationEpoch : undefined; + const expirationDate = new Date(0); // Creates a "zeroed" date object return tokenExpirationEpoch ? expirationDate.setUTCMilliseconds(tokenExpirationEpoch) : null; }), @@ -118,15 +115,8 @@ export default Service.extend({ if (!token) { return; } - const backend = this.backendFromTokenName(token); const stored = this.getTokenData(token); - return Object.assign(stored, { - backend: { - // add mount path for password reset - mountPath: stored.backend.mountPath, - ...BACKENDS.find((b) => b.type === backend), - }, - }); + return Object.assign(stored); }), init() { @@ -223,15 +213,19 @@ export default Service.extend({ return this.ajax(url, 'POST', { namespace }); }, - calculateExpiration(resp, now) { - const ttl = resp.ttl || resp.lease_duration; - const tokenExpirationEpoch = resp.expire_time ? new Date(resp.expire_time).getTime() : now + ttl * 1e3; - - return { ttl, tokenExpirationEpoch }; + // ttl is originally either the "ttl" or "lease_duration" returned by the auth data response + calculateExpiration({ now, ttl, expireTime }) { + // First check if the ttl is falsy, including 0, before converting to milliseconds. + // Obviously a ttl of zero seconds is not recommended, but root tokens have a `0` ttl because they never expire. + // Note - this is different from mount configurations where a `ttl: 0` actually means the value is "unset" and to use system defaults. + const convertToMilliseconds = () => (ttl ? now + ttl * 1e3 : null); + const tokenExpirationEpoch = expireTime ? new Date(expireTime).getTime() : convertToMilliseconds(); + // To avoid confusion, if a TTL is `0` return null + return { ttl: ttl || null, tokenExpirationEpoch }; }, - setExpirationSettings(resp, now) { - if (resp.renewable) { + setExpirationSettings(renewable, now) { + if (renewable) { this.set('expirationCalcTS', now); this.set('allowExpiration', false); } else { @@ -239,13 +233,14 @@ export default Service.extend({ } }, - calculateRootNamespace(currentNamespace, namespace_path, backend) { + calculateRootNamespace(currentNamespace, namespacePath, backend) { + // namespace_path is only returned for methods that use a token exchange to authenticate (i.e. token, oidc) // here we prefer namespace_path if its defined, // else we look and see if there's already a namespace saved // and then finally we'll use the current query param if the others // haven't set a value yet // all of the typeof checks are necessary because the root namespace is '' - let userRootNamespace = namespace_path && namespace_path.replace(/\/$/, ''); + let userRootNamespace = namespacePath && namespacePath.replace(/\/$/, ''); // renew-self does not return namespace_path, so we manually setting in renew(). // so if we're logging in with token and there's no namespace_path, we can assume // that the token belongs to the root namespace @@ -261,107 +256,72 @@ export default Service.extend({ return userRootNamespace; }, - // TODO CMB changes below are stopgaps until this method is un-abstracted - // end goal is for each auth method's component to handle setting relevant parameters. - // this method should just accept an arg of data to persist from the response as well as: - // 1. generate token name and set token data - // 2. calculate and set expiration - // 3. (maybe) calculate root namespace - async persistAuthData() { - const [firstArg, resp] = arguments; + async persistAuthData(clusterId, authResponseData) { + // An empty string denotes the "root" namespace const currentNamespace = this.namespaceService.path || ''; - // dropdown vs tab format - // - // TODO adding ANOTHER conditional until this method is un-abstracted :( - const mountPath = firstArg?.path || firstArg?.data?.path || firstArg?.selectedAuth; - let tokenName; - let options; - let backend; + // Only pull out the necessary data + const { authMethodType, authMountPath, entityId, policies, renewable, token, ttl } = authResponseData; - // TODO move setting current backend, options, etc to method's component - if (typeof firstArg === 'string') { - tokenName = firstArg; - backend = this.backendFromTokenName(tokenName); - } else { - options = firstArg; - // backend is old news since it's confusing whether it refers to the auth mount path or auth type, - // new auth flow explicitly defines "selectedAuth" and "path" - backend = options?.backend || options.selectedAuth; - } + // Lookup token for additional data that may be missing from the method's login response + const { displayName, expireTime, namespacePath } = await this.lookupTokenData(token, !!currentNamespace, { + displayName: authResponseData?.displayName, + expireTime: authResponseData?.expireTime, + namespacePath: authResponseData?.namespacePath, + }); - const currentBackend = { - mountPath, - ...BACKENDS.find((b) => b.type === backend), - }; + const userRootNamespace = this.calculateRootNamespace(currentNamespace, namespacePath, authMethodType); - const { entity_id, policies, renewable, namespace_path } = resp; - const userRootNamespace = this.calculateRootNamespace(currentNamespace, namespace_path, backend); - const data = { - userRootNamespace, - displayName: null, // set below - backend: currentBackend, - token: resp.client_token || get(resp, currentBackend.tokenPath), + const persistedTokenData = { + authMethodType, + authMountPath, + displayName: displayName || authMethodType, + entityId, policies, renewable, - entity_id, + token, + userRootNamespace, + // Only include namespacePath if it exists + ...(namespacePath && { namespacePath }), }; - tokenName = this.generateTokenName( - { - backend, - clusterId: (options && options.clusterId) || this.activeClusterId, - }, - resp.policies - ); - + // Set stored ttl and tokenExpirationEpoch const now = this.now(); - - Object.assign(data, this.calculateExpiration(resp, now)); - this.setExpirationSettings(resp, now); - + const { ttl: calculatedTtl, tokenExpirationEpoch } = this.calculateExpiration({ now, ttl, expireTime }); + persistedTokenData.ttl = calculatedTtl; + persistedTokenData.tokenExpirationEpoch = tokenExpirationEpoch; + this.setExpirationSettings(renewable, now); // ensure we don't call renew-self within tests // this is intentionally not included in setExpirationSettings so we can unit test that method if (Ember.testing) this.set('allowExpiration', false); - data.displayName = await this.setDisplayName(resp, currentBackend.displayNamePath, tokenName); - + // Set token name and store data + const tokenName = this.generateTokenName({ backend: authMethodType, clusterId }, policies); this.set('tokens', addToArray(this.tokens, tokenName)); - this.setTokenData(tokenName, data); - + this.setTokenData(tokenName, persistedTokenData); return resolve({ - namespace: currentNamespace || data.userRootNamespace, + namespace: currentNamespace || persistedTokenData.userRootNamespace, token: tokenName, - isRoot: policies.includes('root'), + isRoot: (policies || []).includes('root'), }); }, - async setDisplayName(resp, displayNamePath, tokenName) { - let displayName; - - // first check if auth response includes a display name - displayName = isArray(displayNamePath) - ? displayNamePath.map((name) => get(resp, name)).join('/') - : get(resp, displayNamePath); - - // if not, check stored token data - if (!displayName) { - displayName = (this.getTokenData(tokenName) || {}).displayName; - } - - // this is a fallback for any methods that don't return a display name from the initial auth request (i.e. JWT) - // or for OIDC/SAML with mfa configured because the mfa/validate endpoint does not consistently - // return display_name (or metadata that includes something to be used as such). - // this if block can be removed if/when the API consistently returns a display_name. - if (!displayName) { - // if still nothing, request token data as a last resort + async lookupTokenData(token, hasNamespace, { displayName, expireTime, namespacePath }) { + // Only lookup if we're missing displayName or namespacePath in a non-root namespace + if (!displayName || (!namespacePath && hasNamespace)) { try { - const { data } = await this.lookupSelf(resp.client_token); - displayName = data.display_name; + const { data } = await this.lookupSelf(token); + return { + displayName: displayName || data?.display_name, + namespacePath: namespacePath || data?.namespace_path, + expireTime: expireTime || data?.expire_time, + }; } catch { - // silently fail since we're just trying to set a display name + // It would be unusual for this request to fail, but swallowing it because we're + // essentially setting "nice to have" data here. } } - return displayName; + // Return original values as fallback + return { displayName, namespacePath, expireTime }; }, setTokenData(token, data) { @@ -377,22 +337,21 @@ export default Service.extend({ }, renew() { - const tokenName = this.currentTokenName; const currentlyRenewing = this.isRenewing; - if (currentlyRenewing) return; this.isRenewing = true; return this.renewCurrentToken().then( - (resp) => { + async (resp) => { this.isRenewing = false; - const namespacePath = this.namespaceService.path; - const response = resp.data || resp.auth; - // renew-self does not return namespace_path, so manually add it if it exists - if (!response?.namespace_path && namespacePath) { - response.namespace_path = namespacePath; - } - return this.persistAuthData(tokenName, response); + // If we renewing, authData already exists so all we really need to update are the token and expiration details + const { authMethodType, authMountPath, displayName } = this.authData; + const normalizedAuthData = this.normalizeAuthData(resp.auth, { + authMethodType, + authMountPath, + displayName, + }); + return await this.persistAuthData(this.activeClusterId, normalizedAuthData); }, (e) => { this.isRenewing = false; @@ -461,14 +420,14 @@ export default Service.extend({ }); }, - _parseMfaResponse(mfa_requirement) { - // mfa_requirement response comes back in a shape that is not easy to work with + parseMfaResponse(mfaRequirement) { + // mfaRequirement response comes back in a shape that is not easy to work with // convert to array of objects and add necessary properties to satisfy the view - if (mfa_requirement) { - const { mfa_request_id, mfa_constraints } = mfa_requirement; + if (mfaRequirement) { + const { mfaRequestId, mfaConstraints } = mfaRequirement; const constraints = []; - for (const key in mfa_constraints) { - const methods = mfa_constraints[key].any; + for (const key in mfaConstraints) { + const methods = mfaConstraints[key].any; const isMulti = methods.length > 1; // friendly label for display in MfaForm @@ -482,57 +441,32 @@ export default Service.extend({ selectedMethod: isMulti ? null : methods[0], }); } - - return { - mfa_requirement: { mfa_request_id, mfa_constraints: constraints }, - }; + return { mfaRequestId, mfaConstraints: constraints }; } return {}; }, - async authenticate(/*{clusterId, backend, data, selectedAuth}*/) { - const [options] = arguments; - const adapter = this.clusterAdapter(); - const resp = await adapter.authenticate(options); - - if (resp.auth?.mfa_requirement) { - return this._parseMfaResponse(resp.auth?.mfa_requirement); - } - - return this.authSuccess(options, resp.auth || resp.data); + async totpValidate({ clusterId, mfaRequirement, authMethodType, authMountPath }) { + // mfa/validate consistently returns data inside the "auth" key + const { auth } = await this.clusterAdapter().mfaValidate(mfaRequirement); + const normalizedAuthData = this.normalizeAuthData(auth, { authMethodType, authMountPath }); + return this.authSuccess(clusterId, normalizedAuthData); }, - async totpValidate({ mfa_requirement, ...options }) { - const resp = await this.clusterAdapter().mfaValidate(mfa_requirement); - return this.authSuccess(options, resp.auth || resp.data); - }, - - async authSuccess(options, response) { + async authSuccess(clusterId, authResponse) { // persist selectedAuth to localStorage to rehydrate auth form on logout - localStorage.setItem('selectedAuth', options.selectedAuth); - const authData = await this.persistAuthData(options, response, this.namespaceService.path); - await this.permissions.getPaths.perform(); + localStorage.setItem('selectedAuth', authResponse.authMethodType); + const authData = await this.persistAuthData(clusterId, authResponse); + this.permissions.getPaths.perform(); return authData; }, - handleError(e) { - if (e.errors) { - return e.errors.map((error) => { - if (error.detail) { - return error.detail; - } - return error; - }); - } - return [e]; - }, - getAuthType() { // check localStorage first const selectedAuth = localStorage.getItem('selectedAuth'); if (selectedAuth) return selectedAuth; // fallback to authData which discerns backend type from token - return this.authData ? this.authData.backend.type : null; + return this.authData ? this.authData.authMethodType : null; }, deleteCurrentToken() { @@ -547,20 +481,29 @@ export default Service.extend({ this.set('tokens', tokenNames); }, - getOktaNumberChallengeAnswer(nonce, mount) { - const url = `/v1/auth/${mount}/verify/${nonce}`; - return this.ajax(url, 'GET', {}).then( - (resp) => { - return resp.data.correct_answer; - }, - (e) => { - // if error status is 404, return and keep polling for a response - if (e.status === 404) { - return null; - } else { - throw e; - } - } - ); + // Depending on where auth happens (mfa/validate, renew-self or the method's login) the auth data + // varies slightly (i.e. "ttl" vs "lease_duration"). Normalize it so stored authData contains consistent keys. + // (Also, the API service returns camel cased keys and raw ajax requests return snake cased params.) + normalizeAuthData(authData, { authMethodType, authMountPath, displayName }) { + const displayNameFromMetadata = (metadata) => + metadata + ? ['org', 'username'] + .map((key) => (key in metadata ? metadata[key] : null)) + .filter(Boolean) + .join('/') + : ''; + + return { + authMethodType, + authMountPath, + entityId: authData?.entity_id, + expireTime: authData?.expire_time, + token: authData?.client_token, + renewable: authData?.renewable, + ttl: authData?.lease_duration, + policies: authData?.policies, + // not all methods return a display name or metadata, if this is still empty it will be gleaned from lookup-self + displayName: displayName || displayNameFromMetadata(authData?.metadata), + }; }, }); diff --git a/ui/app/utils/auth-form-helpers.ts b/ui/app/utils/auth-form-helpers.ts new file mode 100644 index 0000000000..a87552f834 --- /dev/null +++ b/ui/app/utils/auth-form-helpers.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +/** + * The web UI only supports logging in with these auth methods. + * This is a subset of the methods found in the `all-engines-metadata` util, + * which includes all the methods that can be enabled and mounted. + */ + +const BASE_LOGIN_METHODS = ['github', 'jwt', 'ldap', 'oidc', 'okta', 'radius', 'token', 'userpass']; + +export const ENTERPRISE_LOGIN_METHODS = ['saml']; + +export const supportedTypes = (isEnterprise: boolean) => { + return isEnterprise ? [...BASE_LOGIN_METHODS, ...ENTERPRISE_LOGIN_METHODS] : [...BASE_LOGIN_METHODS]; +}; + +// this ensures no unexpected params are injected and submitted in the login form +// 'namespace' and 'path' are intentionally omitted because they are handled explicitly +export const POSSIBLE_FIELDS = ['role', 'jwt', 'password', 'token', 'username']; + +// maps OIDC provider domain to display name for oidc-jwt auth form +export const DOMAIN_PROVIDER_MAP = { + 'github.com': 'GitHub', + 'gitlab.com': 'GitLab', + 'google.com': 'Google', + 'ping.com': 'Ping Identity', + 'okta.com': 'Okta', + 'auth0.com': 'Auth0', + 'login.microsoftonline.com': 'Azure', +}; + +export const ERROR_POPUP_FAILED = + 'Your web browser may have blocked or closed a pop-up window. Please check your settings and click "Sign in" to try again.'; + +export const ERROR_WINDOW_CLOSED = `The provider window was closed before authentication was complete. ${ERROR_POPUP_FAILED}`; + +export const ERROR_JWT_LOGIN = 'OIDC login is not configured for this mount'; + +export const ERROR_MISSING_PARAMS = + 'The callback from the provider did not supply all of the required parameters. Please click Sign In to try again. If the problem persists, you may want to contact your administrator.'; + +export const ERROR_TIMEOUT = 'The authentication request has timed out. Please click "Sign in" to try again.'; diff --git a/ui/app/utils/supported-login-methods.ts b/ui/app/utils/supported-login-methods.ts deleted file mode 100644 index f552e9c5f2..0000000000 --- a/ui/app/utils/supported-login-methods.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 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: 'Userpass', - }, - { - 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); - -// this ensures no unexpected params are injected and submitted in the login form -// 'namespace' and 'path' are intentionally omitted because they are handled explicitly -export const POSSIBLE_FIELDS = ['role', 'jwt', 'password', 'token', 'username']; diff --git a/ui/lib/ldap/addon/components/accounts-checked-out.ts b/ui/lib/ldap/addon/components/accounts-checked-out.ts index ca3963f5e1..0876e9a32f 100644 --- a/ui/lib/ldap/addon/components/accounts-checked-out.ts +++ b/ui/lib/ldap/addon/components/accounts-checked-out.ts @@ -40,7 +40,7 @@ export default class LdapAccountsCheckedOutComponent extends Component { // filter status to only show checked out accounts associated to the current user // if disable_check_in_enforcement is true on the library set then all checked out accounts are displayed return this.args.statuses.filter((status) => { - const authEntityId = this.auth.authData?.entity_id; + const authEntityId = this.auth.authData?.entityId; const isRoot = !status.borrower_entity_id && !authEntityId; // root user will not have an entity id and it won't be populated on status const isEntity = status.borrower_entity_id === authEntityId; const library = this.findLibrary(status.library); diff --git a/ui/mirage/factories/login-rule.js b/ui/mirage/factories/login-rule.js index 8d2aa55a2b..dda7875dbe 100644 --- a/ui/mirage/factories/login-rule.js +++ b/ui/mirage/factories/login-rule.js @@ -7,7 +7,7 @@ import { Factory } from 'miragejs'; export default Factory.extend({ name: (i) => `Login rule ${i}`, - namespace: (i) => `namespace-${i}`, + namespace_path: (i) => `namespace-${i}`, default_auth_type: 'okta', backup_auth_types: () => ['oidc', 'token'], disable_inheritance: false, diff --git a/ui/mirage/handlers/custom-login.js b/ui/mirage/handlers/custom-login.js index 6f7b7602f2..c8090f6bb1 100644 --- a/ui/mirage/handlers/custom-login.js +++ b/ui/mirage/handlers/custom-login.js @@ -48,13 +48,21 @@ export default function (server) { server.get('sys/internal/ui/default-auth-methods', (schema, req) => { const nsHeader = req.requestHeaders['X-Vault-Namespace']; // if no namespace is passed, assume root - const namespace = !nsHeader ? '' : nsHeader; - const nsRule = schema.db['loginRules'].findBy({ namespace }); - if (nsRule) { - // only data relevant to login form is returned - const { default_auth_type, backup_auth_types } = nsRule; - return { data: { default_auth_type, backup_auth_types } }; + const findRule = (ns = '') => schema.db['loginRules'].findBy({ namespace_path: ns }); + + let rule = findRule(nsHeader || ''); + + if (!rule && nsHeader?.includes('/')) { + // for simplicity, tests only nest namespaces one level, e.g. "test-ns/child" + const [parent] = nsHeader.split('/'); + const parentRule = findRule(parent); + rule = parentRule?.disable_inheritance ? null : parentRule; } - return new Response(404, {}, { errors: [] }); + + // Fallback to root namespace settings to simulate inheritance if no rule exists or parent has disabled inheritance + rule = rule || findRule(); + + const { default_auth_type, backup_auth_types, disable_inheritance } = rule || {}; + return { data: { default_auth_type, backup_auth_types, disable_inheritance } }; }); } diff --git a/ui/mirage/scenarios/custom-login.js b/ui/mirage/scenarios/custom-login.js index cfeb372f99..bafb297e19 100644 --- a/ui/mirage/scenarios/custom-login.js +++ b/ui/mirage/scenarios/custom-login.js @@ -5,17 +5,25 @@ export default function (server) { server.create('login-rule', { - name: 'Root namespace default', - namespace: '', - default_auth_type: 'userpass', - backup_auth_types: ['okta', 'token'], - disable_inheritance: true, + name: 'root-rule', + namespace_path: '', + default_auth_type: 'okta', + backup_auth_types: ['token'], + disable_inheritance: false, }); server.create('login-rule', { - namespace: 'admin', + namespace_path: 'admin', default_auth_type: 'oidc', backup_auth_types: ['token'], }); + server.create('login-rule', { + name: 'ns-rule', + namespace_path: 'test-ns', + default_auth_type: 'ldap', + backup_auth_types: [], + disable_inheritance: true, + }); + // generated with defaults set by ui/mirage/factories/login-rule.js server.create('login-rule', { default_auth_type: 'jwt', backup_auth_types: [] }); // namespace-2 server.create('login-rule', { default_auth_type: '', backup_auth_types: ['oidc', 'jwt'] }); // namespace-3 server.create('login-rule', { default_auth_type: '', backup_auth_types: ['token'] }); // namespace-4 diff --git a/ui/tests/acceptance/auth/auth-list-test.js b/ui/tests/acceptance/auth/auth-list-test.js index 6962f5899f..3fea95ea9f 100644 --- a/ui/tests/acceptance/auth/auth-list-test.js +++ b/ui/tests/acceptance/auth/auth-list-test.js @@ -26,21 +26,15 @@ module('Acceptance | auth backend list', function (hooks) { hooks.beforeEach(async function () { await login(); + }); + + test('userpass secret backend', async function (assert) { this.path1 = `userpass-${uuidv4()}`; this.path2 = `userpass-${uuidv4()}`; this.user1 = 'user1'; this.user2 = 'user2'; await runCmd([mountAuthCmd('userpass', this.path1), mountAuthCmd('userpass', this.path2)], false); - }); - - hooks.afterEach(async function () { - await login(); - await runCmd([deleteAuthCmd(this.path1), deleteAuthCmd(this.path2)], false); - return; - }); - - test('userpass secret backend', async function (assert) { // helper function to create a user in the specified backend async function createUser(backendPath, username) { await click(GENERAL.linkedBlock(backendPath)); @@ -69,6 +63,9 @@ module('Acceptance | auth backend list', function (hooks) { await click(SELECTORS.methods); await click(GENERAL.linkedBlock(this.path1)); assert.dom(SELECTORS.listItem).hasText(this.user1, 'user1 exists in the list'); + + await login(); + await runCmd([deleteAuthCmd(this.path1), deleteAuthCmd(this.path2)], false); }); module('auth methods are linkable and link to correct view', function (hooks) { diff --git a/ui/tests/acceptance/auth/auth-login-test.js b/ui/tests/acceptance/auth/auth-login-test.js index a1fdfccd63..601995351e 100644 --- a/ui/tests/acceptance/auth/auth-login-test.js +++ b/ui/tests/acceptance/auth/auth-login-test.js @@ -7,19 +7,12 @@ import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { click, currentURL, fillIn, typeIn, visit, waitFor } 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'; +import { createNS, createPolicyCmd, deleteNS, mountEngineCmd, runCmd } from 'vault/tests/helpers/commands'; import { - createNS, - createPolicyCmd, - deleteNS, - mountAuthCmd, - mountEngineCmd, - runCmd, -} from 'vault/tests/helpers/commands'; -import { + AUTH_METHOD_LOGIN_DATA, + fillInLoginFields, login, - loginMethod, loginNs, logout, SYS_INTERNAL_UI_MOUNTS, @@ -28,11 +21,11 @@ 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'; import sinon from 'sinon'; +import { supportedTypes } from 'vault/utils/auth-form-helpers'; -const ENT_AUTH_METHODS = ['saml']; const { rootToken } = VAULT_KEYS; -module('Acceptance | auth login form', function (hooks) { +module('Acceptance | auth login', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); @@ -48,11 +41,11 @@ module('Acceptance | auth login form', function (hooks) { }); test('it selects auth method if "with" query param is a supported auth method', async function (assert) { - const backends = supportedAuthBackends(); - assert.expect(backends.length); - for (const backend of backends.reverse()) { - await visit(`/vault/auth?with=${backend.type}`); - assert.dom(AUTH_FORM.selectMethod).hasValue(backend.type); + const authTypes = supportedTypes(false); + assert.expect(authTypes.length); + for (const backend of authTypes.reverse()) { + await visit(`/vault/auth?with=${backend}`); + assert.dom(AUTH_FORM.selectMethod).hasValue(backend); } }); @@ -171,135 +164,155 @@ module('Acceptance | auth login form', function (hooks) { }); }); + // PAYLOAD TESTS FOR EACH AUTH METHOD + // Assertion count is one for the URL and one for each payload key module('it sends the right payload when authenticating', function (hooks) { hooks.beforeEach(function () { - this.assertReq = () => {}; - this.server.get('/auth/token/lookup-self', (schema, req) => { - this.assertReq(req); - return req.passthrough(); - }); - this.server.post('/auth/:mount/login', (schema, req) => { - // github only - this.assertReq(req); - return req.passthrough(); - }); - this.server.post('/auth/:mount/oidc/auth_url', (schema, req) => { - // For JWT and OIDC - this.assertReq(req); - return req.passthrough(); - }); - this.server.post('/auth/:mount/login/:username', (schema, req) => { - this.assertReq(req); - return req.passthrough(); - }); - this.server.put('/auth/:mount/sso_service_url', (schema, req) => { - // SAML only (enterprise) - this.assertReq(req); - return req.passthrough(); - }); - this.expected = { - token: { - url: '/v1/auth/token/lookup-self', - payload: { - 'X-Vault-Token': 'some-token', - }, - }, - userpass: { - url: '/v1/auth/custom-userpass/login/some-username', - payload: { - password: 'some-password', - }, - }, - ldap: { - url: '/v1/auth/custom-ldap/login/some-username', - payload: { - password: 'some-password', - }, - }, - okta: { - url: '/v1/auth/custom-okta/login/some-username', - payload: { - password: 'some-password', - }, - }, - jwt: { - url: '/v1/auth/custom-jwt/oidc/auth_url', - payload: { - redirect_uri: 'http://localhost:7357/ui/vault/auth/custom-jwt/oidc/callback', - role: 'some-role', - }, - }, - oidc: { - url: '/v1/auth/custom-oidc/oidc/auth_url', - payload: { - redirect_uri: 'http://localhost:7357/ui/vault/auth/custom-oidc/oidc/callback', - role: 'some-role', - }, - }, - radius: { - url: '/v1/auth/custom-radius/login/some-username', - payload: { - password: 'some-password', - }, - }, - github: { - url: '/v1/auth/custom-github/login', - payload: { - token: 'some-token', - }, - }, - saml: { - url: '/v1/auth/custom-saml/sso_service_url', - payload: { - role: 'some-role', - }, - }, + this.assertAuthRequest = (assert, req, expectedPayload) => { + const body = JSON.parse(req.requestBody); + assert.true(true, `it calls the correct URL: ${req.url}`); + + for (const [expKey, expValue] of Object.entries(expectedPayload)) { + assert.strictEqual(body[expKey], expValue, `payload includes ${expKey}: ${expValue}`); + } + }; + + this.fillAndLogIn = async () => { + await visit('/vault/auth'); + await fillIn(AUTH_FORM.selectMethod, this.authType); + + const loginData = { ...AUTH_METHOD_LOGIN_DATA[this.authType], path: `custom-${this.authType}` }; + await fillInLoginFields(loginData, { toggleOptions: true }); + await click(GENERAL.submitButton); }; }); - for (const backend of allSupportedAuthBackends().reverse()) { - test(`for ${backend.type} ${ - ENT_AUTH_METHODS.includes(backend.type) ? '(enterprise)' : '' - }`, async function (assert) { - const { type } = backend; - const expected = this.expected[type]; - const isOidc = ['oidc', 'jwt'].includes(type); - assert.expect(isOidc ? 3 : 2); + test('token', async function (assert) { + assert.expect(2); + this.authType = 'token'; + this.expectedPayload = { 'x-vault-token': 'mysupersecuretoken' }; + const headerKey = 'x-vault-token'; + const expectedToken = this.expectedPayload[headerKey]; - this.assertReq = (req) => { - const body = type === 'token' ? req.requestHeaders : JSON.parse(req.requestBody); - if (isOidc && !body.role) { - // OIDC and JWT auth form calls the endpoint every time the role or mount is updated. - // if role is not provided, it means we haven't filled out the full info yet so don't - // validate the payload until all data is provided - // eslint-disable-next-line qunit/no-early-return - return {}; - } - assert.strictEqual(req.url, expected.url, `${type} calls the correct URL`); - Object.keys(expected.payload).forEach((expKey) => { - assert.strictEqual( - body[expKey], - expected.payload[expKey], - `${type} payload includes ${expKey} with expected value` - ); - }); - }; - await visit('/vault/auth'); - await fillIn(AUTH_FORM.selectMethod, type); - - if (type !== 'token') { - // set custom mount - await click(AUTH_FORM.advancedSettings); - await fillIn(GENERAL.inputByAttr('path'), `custom-${type}`); - } - for (const key of backend.formAttributes) { - // fill in all form items, except JWT which is not rendered - if (key === 'jwt') return; - await fillIn(GENERAL.inputByAttr(key), `some-${key}`); - } - await click(GENERAL.submitButton); + this.server.get('/auth/token/lookup-self', (schema, req) => { + const actualToken = req.requestHeaders[headerKey]; + assert.true(true, `it calls the correct URL: ${req.url}`); + assert.strictEqual(actualToken, expectedToken, 'headers include token'); + req.passthrough(); }); - } + + await visit('/vault/auth'); + await fillIn(AUTH_FORM.selectMethod, this.authType); + const loginData = AUTH_METHOD_LOGIN_DATA[this.authType]; + await fillInLoginFields(loginData); + await click(GENERAL.submitButton); + }); + + test('github', async function (assert) { + assert.expect(2); + this.authType = 'github'; + this.expectedPayload = { token: 'mysupersecuretoken' }; + this.server.post('/auth/custom-github/login', (schema, req) => { + this.assertAuthRequest(assert, req, this.expectedPayload); + req.passthrough(); + }); + + await this.fillAndLogIn(); + }); + + test('ldap', async function (assert) { + assert.expect(2); + this.authType = 'ldap'; + this.expectedPayload = { password: 'some-password' }; + this.server.post('/auth/custom-ldap/login/matilda', (schema, req) => { + this.assertAuthRequest(assert, req, this.expectedPayload); + req.passthrough(); + }); + + await this.fillAndLogIn(); + }); + + test('jwt', async function (assert) { + // auth_url is hit twice (once when inputs are filled and again on submit) + // so the assertion count is doubled + assert.expect(6); + this.authType = 'jwt'; + this.expectedPayload = { + redirect_uri: 'http://localhost:7357/ui/vault/auth/custom-jwt/oidc/callback', + role: 'some-dev', + }; + this.server.post('/auth/custom-jwt/oidc/auth_url', (schema, req) => { + this.assertAuthRequest(assert, req, this.expectedPayload); + req.passthrough(); + }); + + await this.fillAndLogIn(); + }); + + test('oidc', async function (assert) { + // auth_url is hit twice (once when inputs are filled and again on submit) + // so the assertion count is doubled + assert.expect(6); + this.authType = 'oidc'; + this.expectedPayload = { + redirect_uri: 'http://localhost:7357/ui/vault/auth/custom-oidc/oidc/callback', + role: 'some-dev', + }; + this.server.post('/auth/custom-oidc/oidc/auth_url', (schema, req) => { + this.assertAuthRequest(assert, req, this.expectedPayload); + req.passthrough(); + }); + + await this.fillAndLogIn(); + }); + + test('okta', async function (assert) { + assert.expect(2); + this.authType = 'okta'; + this.expectedPayload = { password: 'some-password' }; + this.server.post('/auth/custom-okta/login/matilda', (schema, req) => { + this.assertAuthRequest(assert, req, this.expectedPayload); + req.passthrough(); + }); + + await this.fillAndLogIn(); + }); + + test('radius', async function (assert) { + assert.expect(2); + this.authType = 'radius'; + this.expectedPayload = { password: 'some-password' }; + this.server.post('/auth/custom-radius/login/matilda', (schema, req) => { + this.assertAuthRequest(assert, req, this.expectedPayload); + req.passthrough(); + }); + + await this.fillAndLogIn(); + }); + + test('userpass', async function (assert) { + assert.expect(2); + this.authType = 'userpass'; + this.expectedPayload = { password: 'some-password' }; + this.server.post('/auth/custom-userpass/login/matilda', (schema, req) => { + this.assertAuthRequest(assert, req, this.expectedPayload); + req.passthrough(); + }); + + await this.fillAndLogIn(); + }); + + test('enterprise: saml', async function (assert) { + assert.expect(2); + this.authType = 'saml'; + this.expectedPayload = { role: 'some-dev' }; + this.server.post('/auth/custom-saml/sso_service_url', (schema, req) => { + this.assertAuthRequest(assert, req, this.expectedPayload); + req.passthrough(); + }); + + await this.fillAndLogIn(); + }); }); test('it does not call renew-self after successful login with non-renewable token', async function (assert) { @@ -313,25 +326,23 @@ module('Acceptance | auth login form', function (hooks) { }); module('Enterprise', function () { - // this test is specifically to cover a token renewal bug within namespaces + // this test is 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 + // incorrectly setting userRootNamespace to '' (which denotes 'root'). + // this caused subsequent capability checks to 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) { - // Sinon spy for clipboard - const clipboardSpy = sinon.stub(navigator.clipboard, 'writeText').resolves(); + const setTokenDataSpy = sinon.spy(this.owner.lookup('service:auth'), 'setTokenData'); const uid = uuidv4(); const ns = `admin-${uid}`; + // log in to root to create namespace await login(); await runCmd(createNS(ns), false); - // login to namespace, mount userpass, create policy and user + // log in to namespace, create policy and generate token await loginNs(ns); const db = `database-${uid}`; - const userpass = `userpass-${uid}`; - const user = 'bob'; - const policyName = `policy-${userpass}`; + const policyName = `policy-${uid}`; const policy = ` path "${db}/" { capabilities = ["list"] @@ -340,37 +351,33 @@ module('Acceptance | auth login form', function (hooks) { capabilities = ["read","list"] } `; - await runCmd([ - mountAuthCmd('userpass', userpass), + const token = await runCmd([ mountEngineCmd('database', db), createPolicyCmd(policyName, policy), - `write auth/${userpass}/users/${user} password=${user} token_policies=${policyName}`, + `write auth/token/create policies=${policyName} -field=client_token`, ]); - const inputValues = { - username: user, - password: user, - path: userpass, - namespace: ns, - }; - - // login as user just to get token (this is the only way to generate a token in the UI right now..) - await loginMethod(inputValues, { authType: 'userpass', toggleOptions: true }); - await click(GENERAL.button('user-menu-trigger')); - await click(GENERAL.copyButton); - assert.true(clipboardSpy.calledOnce, 'Clipboard was called once'); - const token = clipboardSpy.firstCall.args[0]; - clipboardSpy.restore(); // restore original clipboard // login with token to reproduce bug 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'); + // renew token await click(GENERAL.button('user-menu-trigger')); await click('[data-test-user-menu-item="renew token"]'); + // confirm setTokenData is called with correct args + const [tokenName, { authMethodType, displayName, userRootNamespace, namespacePath }] = + setTokenDataSpy.lastCall.args; + assert.strictEqual(tokenName, 'vault-token☃1', 'setTokenData is called with tokenName'); + assert.strictEqual(authMethodType, 'token', 'setTokenData is called with authMethodType'); + assert.strictEqual(displayName, 'token', 'setTokenData is called with displayName'); + assert.strictEqual(userRootNamespace, ns, 'setTokenData is called with userRootNamespace'); + assert.strictEqual(namespacePath, `${ns}/`, 'setTokenData is called with namespacePath'); + // navigate out and back to overview tab to re-request capabilities + // (before the bug fix, the view would not render and instead would show a 403) await click(GENERAL.secretTab('Roles')); await click(GENERAL.tab('overview')); assert.strictEqual( diff --git a/ui/tests/acceptance/auth/login-settings-test.js b/ui/tests/acceptance/auth/login-settings-test.js index f691410d49..215054c7bc 100644 --- a/ui/tests/acceptance/auth/login-settings-test.js +++ b/ui/tests/acceptance/auth/login-settings-test.js @@ -6,41 +6,25 @@ import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { click, fillIn, typeIn, visit, waitFor } from '@ember/test-helpers'; -import { runCmd } from 'vault/tests/helpers/commands'; -import { login, logout, rootToken } from 'vault/tests/helpers/auth/auth-helpers'; import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import customLoginHandler from 'vault/mirage/handlers/custom-login'; +import customLoginScenario from 'vault/mirage/scenarios/custom-login'; // Auth form login settings // This feature has thorough integration test coverage so only testing a few scenarios and direct link functionality // Tests for read/list views are in ui/tests/acceptance/config-ui/login-settings-test.js module('Acceptance | Enterprise | auth form custom login settings', function (hooks) { setupApplicationTest(hooks); - hooks.beforeEach(async function () { - await login(); - await runCmd([ - `write sys/namespaces/test-ns -force`, - `write test-ns/sys/namespaces/child -force`, - `write sys/config/ui/login/default-auth/root-rule backup_auth_types=token default_auth_type=okta disable_inheritance=false namespace_path=""`, - `write sys/config/ui/login/default-auth/ns-rule default_auth_type=ldap disable_inheritance=true namespace_path=test-ns`, - `write sys/auth/my_oidc type=oidc`, - `write sys/auth/my_oidc/tune listing_visibility="unauth"`, - ]); - return await logout(); - }); + setupMirage(hooks); - hooks.afterEach(async function () { - // cleanup login rules - await visit('/vault/auth?with=token'); - await fillIn(GENERAL.inputByAttr('token'), rootToken); - await click(GENERAL.submitButton); - await runCmd([ - 'delete sys/config/ui/login/default-auth/root-rule', - 'delete sys/config/ui/login/default-auth/ns-rule', - 'delete sys/auth/my_oidc', - 'delete test-ns/sys/namespaces/child -f', - 'delete sys/namespaces/test-ns -f', - ]); + hooks.beforeEach(async function () { + customLoginHandler(this.server); + customLoginScenario(this.server); + // mirage scenario sets: + // root namespace with 'okta' as default and 'token' as backup + // 'test-ns' with 'ldap' as default and no backups }); test('it renders login settings for root namespace', async function (assert) { @@ -67,27 +51,34 @@ module('Acceptance | Enterprise | auth form custom login settings', function (ho // type in so that the namespace is "test-ns/child" await typeIn(GENERAL.inputByAttr('namespace'), '/child'); await waitFor(AUTH_FORM.authForm('okta')); - assert - .dom(AUTH_FORM.authForm('okta')) - .exists('it inherits view from root namespace because "test-ns" settings are not inheritable'); + assert.dom(AUTH_FORM.authForm('okta')).exists('it updates to render child namespace settings'); }); - test('it ignores login settings if query param references a visible mount path', async function (assert) { - await visit('/vault/auth?with=my_oidc%2F'); - await waitFor(AUTH_FORM.tabBtn('oidc')); - assert - .dom(AUTH_FORM.tabBtn('oidc')) - .hasAttribute('aria-selected', 'true', 'it selects tab matching query param'); - assert.dom(AUTH_FORM.authForm('oidc')).exists(); - assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); - await click(GENERAL.button('Sign in with other methods')); - assert.dom(GENERAL.selectByAttr('auth type')).exists('dropdown renders as fallback view'); - }); + module('listing visibility', function (hooks) { + hooks.beforeEach(function () { + this.server.get('/sys/internal/ui/mounts', () => { + // Stub a visible mount that does NOT match a type in the login settings + return { data: { auth: { 'my_oidc/': { description: '', options: {}, type: 'oidc' } } } }; + }); + }); - test('it ignores login settings if query param references a valid type', async function (assert) { - await visit('/vault/auth?with=userpass'); - assert.dom(GENERAL.selectByAttr('auth type')).hasValue('userpass', 'dropdown selects userpass'); - await click(GENERAL.backButton); - assert.dom(AUTH_FORM.tabBtn('oidc')).exists('it renders tabs on "Back" because visible mounts exist'); + test('it ignores login settings if query param references a visible mount path', async function (assert) { + await visit('/vault/auth?with=my_oidc%2F'); + await waitFor(AUTH_FORM.tabBtn('oidc')); + assert + .dom(AUTH_FORM.tabBtn('oidc')) + .hasAttribute('aria-selected', 'true', 'it selects tab matching query param'); + assert.dom(AUTH_FORM.authForm('oidc')).exists(); + assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); + await click(GENERAL.button('Sign in with other methods')); + assert.dom(GENERAL.selectByAttr('auth type')).exists('dropdown renders as fallback view'); + }); + + test('it ignores login settings if query param references a valid type', async function (assert) { + await visit('/vault/auth?with=userpass'); + assert.dom(GENERAL.selectByAttr('auth type')).hasValue('userpass', 'dropdown selects userpass'); + await click(GENERAL.backButton); + assert.dom(AUTH_FORM.tabBtn('oidc')).exists('it renders tabs on "Back" because visible mounts exist'); + }); }); }); diff --git a/ui/tests/acceptance/config-ui/messages/messages-unauth-auth-test.js b/ui/tests/acceptance/config-ui/messages/messages-unauth-auth-test.js index 8db8eaa6b6..1b6b701196 100644 --- a/ui/tests/acceptance/config-ui/messages/messages-unauth-auth-test.js +++ b/ui/tests/acceptance/config-ui/messages/messages-unauth-auth-test.js @@ -5,7 +5,7 @@ import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; -import { click, visit, fillIn, currentRouteName, currentURL } from '@ember/test-helpers'; +import { click, visit, fillIn, currentRouteName, currentURL, waitFor } from '@ember/test-helpers'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { CUSTOM_MESSAGES } from 'vault/tests/helpers/config-ui/message-selectors'; import { setupMirage } from 'ember-cli-mirage/test-support'; @@ -158,6 +158,7 @@ module('Acceptance | auth custom messages auth tests', function (hooks) { // log in to namespace await loginNs('world'); + await waitFor(CUSTOM_MESSAGES.alertTitle(id)); assert .dom(CUSTOM_MESSAGES.alertTitle(id)) .hasText('active authenticated message title', 'title is correct') diff --git a/ui/tests/acceptance/jwt-auth-method-test.js b/ui/tests/acceptance/jwt-auth-method-test.js index b0763537de..9217f26563 100644 --- a/ui/tests/acceptance/jwt-auth-method-test.js +++ b/ui/tests/acceptance/jwt-auth-method-test.js @@ -8,7 +8,7 @@ import { setupApplicationTest } from 'ember-qunit'; import { click, visit, fillIn, waitFor } from '@ember/test-helpers'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { Response } from 'miragejs'; -import { ERROR_JWT_LOGIN } from 'vault/components/auth/form/oidc-jwt'; +import { ERROR_JWT_LOGIN } from 'vault/utils/auth-form-helpers'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import { overrideResponse } from 'vault/tests/helpers/stubs'; diff --git a/ui/tests/acceptance/oidc-auth-method-test.js b/ui/tests/acceptance/oidc-auth-method-test.js index 8e95d3548a..3a57c66425 100644 --- a/ui/tests/acceptance/oidc-auth-method-test.js +++ b/ui/tests/acceptance/oidc-auth-method-test.js @@ -16,7 +16,9 @@ import { import { Response } from 'miragejs'; import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; -import { ERROR_MISSING_PARAMS, ERROR_WINDOW_CLOSED } from 'vault/components/auth/form/oidc-jwt'; +import { ERROR_MISSING_PARAMS, ERROR_POPUP_FAILED, ERROR_WINDOW_CLOSED } from 'vault/utils/auth-form-helpers'; +import { getErrorResponse } from 'vault/tests/helpers/api/error-response'; +import sinon from 'sinon'; module('Acceptance | oidc auth method', function (hooks) { setupApplicationTest(hooks); @@ -43,18 +45,9 @@ module('Acceptance | oidc auth method', function (hooks) { auth: { client_token: 'root' }, })); - // select method from dropdown or click auth path tab - this.selectMethod = async (method, useLink) => { - if (useLink) { - await click(AUTH_FORM.tabBtn(method)); - } else { - await fillIn(AUTH_FORM.selectMethod, method); - } - }; - // ensure clean state - localStorage.removeItem('selectedAuth'); - // Cannot log out here because it will cause the internal mount request to be hit before the mocks can interrupt it + // Cannot use logout() here because it will hit the internal mount request before the mocks can interrupt it + window.localStorage.clear(); }); hooks.afterEach(async function () { @@ -64,8 +57,8 @@ module('Acceptance | oidc auth method', function (hooks) { test('it should login with oidc when selected from auth methods dropdown', async function (assert) { assert.expect(1); this.setupMocks(assert); - await logout(); - await this.selectMethod('oidc'); + await visit('/vault/auth'); + await fillIn(AUTH_FORM.selectMethod, 'oidc'); triggerMessageEvent('oidc'); @@ -98,7 +91,7 @@ module('Acceptance | oidc auth method', function (hooks) { test('it should populate oidc auth method on logout', async function (assert) { this.setupMocks(); await logout(); - await this.selectMethod('oidc'); + await fillIn(AUTH_FORM.selectMethod, 'oidc'); triggerMessageEvent('oidc'); @@ -122,9 +115,9 @@ module('Acceptance | oidc auth method', function (hooks) { return new Response(400, {}, { errors }); }); - await this.selectMethod('oidc'); + await fillIn(AUTH_FORM.selectMethod, 'oidc'); assert.dom(GENERAL.inputByAttr('jwt')).doesNotExist('JWT Token input hidden for OIDC'); - await this.selectMethod('jwt'); + await fillIn(AUTH_FORM.selectMethod, 'jwt'); assert.dom(GENERAL.inputByAttr('jwt')).exists('JWT Token input renders for JWT configured method'); await click(AUTH_FORM.advancedSettings); await fillIn(GENERAL.inputByAttr('path'), 'foo'); @@ -139,17 +132,15 @@ module('Acceptance | oidc auth method', function (hooks) { return new Response(status, {}, { errors }); }); await logout(); - await this.selectMethod('oidc'); + await fillIn(AUTH_FORM.selectMethod, 'oidc'); await click(GENERAL.submitButton); - assert - .dom('[data-test-message-error-description]') - .hasText('Authentication failed: Invalid role. Please try again.'); + assert.dom(GENERAL.messageError).hasText('Error Authentication failed: Invalid role. Please try again.'); await fillIn(GENERAL.inputByAttr('role'), 'test'); await click(GENERAL.submitButton); assert - .dom('[data-test-message-error-description]') - .hasText('Authentication failed: Error fetching role: permission denied'); + .dom(GENERAL.messageError) + .hasText('Error Authentication failed: Error fetching role: permission denied'); }); // test case for https://github.com/hashicorp/vault/issues/12436 @@ -171,7 +162,7 @@ module('Acceptance | oidc auth method', function (hooks) { }; window.addEventListener('message', assertEvent); await logout(); - await this.selectMethod('oidc'); + await fillIn(AUTH_FORM.selectMethod, 'oidc'); setTimeout(() => { // first assertion @@ -188,7 +179,7 @@ module('Acceptance | oidc auth method', function (hooks) { test('it shows error when message posted with state key, wrong params', async function (assert) { this.setupMocks(); await logout(); - await this.selectMethod('oidc'); + await fillIn(AUTH_FORM.selectMethod, 'oidc'); setTimeout(() => { // callback params are missing "code" window.postMessage({ source: 'oidc-callback', state: 'state', foo: 'bar' }, window.origin); @@ -199,15 +190,39 @@ module('Acceptance | oidc auth method', function (hooks) { .hasText(`Error Authentication failed: ${ERROR_MISSING_PARAMS}`, 'displays error when missing params'); }); - test('it shows error when popup is closed', async function (assert) { + test('it shows error when popup is prematurely closed ', async function (assert) { windowStub({ stub: this.openStub, popup: { closed: true, close: () => {} } }); this.setupMocks(); await logout(); - await this.selectMethod('oidc'); + await fillIn(AUTH_FORM.selectMethod, 'oidc'); + await click(GENERAL.submitButton); + assert.dom(GENERAL.messageError).hasText(`Error Authentication failed: ${ERROR_WINDOW_CLOSED}`); + }); + + test('it renders error when window fails to open', async function (assert) { + this.openStub.returns(null); + this.setupMocks(); + await logout(); + await fillIn(AUTH_FORM.selectMethod, 'oidc'); await click(GENERAL.submitButton); assert .dom(GENERAL.messageError) - .hasText(`Error Authentication failed: ${ERROR_WINDOW_CLOSED}`, 'displays error when missing params'); + .hasText(`Error Authentication failed: Failed to open OIDC popup window. ${ERROR_POPUP_FAILED}`); + }); + + test('it renders api errors if oidc callback request fails', async function (assert) { + await logout(); + this.server.post('/auth/oidc/oidc/auth_url', () => ({ + data: { auth_url: 'http://example.com' }, + })); + const api = this.owner.lookup('service:api'); + const oidcCallbackStub = sinon.stub(api.auth, 'jwtOidcCallback'); + oidcCallbackStub.rejects(getErrorResponse({ errors: ['something went terribly wrong!'] }, 500)); + await fillIn(AUTH_FORM.selectMethod, 'oidc'); + triggerMessageEvent('oidc'); + await click(GENERAL.submitButton); + assert.dom(GENERAL.messageError).hasText('Error Authentication failed: something went terribly wrong!'); + oidcCallbackStub.restore(); }); }); diff --git a/ui/tests/acceptance/redirect-to-test.js b/ui/tests/acceptance/redirect-to-test.js index d79717d44e..ef5b71a942 100644 --- a/ui/tests/acceptance/redirect-to-test.js +++ b/ui/tests/acceptance/redirect-to-test.js @@ -3,12 +3,11 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { currentURL, visit as _visit, settled, fillIn, click } from '@ember/test-helpers'; +import { currentURL, visit as _visit, settled, fillIn, click, waitUntil } from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; -import { create } from 'ember-cli-page-object'; import { login } from 'vault/tests/helpers/auth/auth-helpers'; -import consoleClass from 'vault/tests/pages/components/console/ui-panel'; +import { runCmd } from 'vault/tests/helpers/commands'; import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; @@ -24,29 +23,12 @@ const visit = async (url) => { await settled(); }; -const consoleComponent = create(consoleClass); - -const wrappedAuth = async () => { - await consoleComponent.toggle(); - await settled(); - await consoleComponent.runCommands( - `write -field=token auth/token/create policies=default -wrap-ttl=5m`, - false - ); - await settled(); - // because of flaky test, trying to capture the token using a dom selector instead of the page object - const token = document.querySelector('[data-test-component="console/log-text"] pre').textContent; - if (token.includes('Error')) { - throw new Error(`Error mounting secrets engine: ${token}`); - } - return token; -}; - const setupWrapping = async () => { await login(); - const wrappedToken = await wrappedAuth(); + const wrappedToken = await runCmd(`write -field=token auth/token/create policies=default -wrap-ttl=5m`); return wrappedToken; }; + module('Acceptance | redirect_to query param functionality', function (hooks) { setupApplicationTest(hooks); @@ -68,6 +50,7 @@ module('Acceptance | redirect_to query param functionality', function (hooks) { // the login method on this page does another visit call that we don't want here await fillIn(GENERAL.inputByAttr('token'), 'root'); await click(GENERAL.submitButton); + await waitUntil(() => currentURL().includes('vault/secrets')); assert.strictEqual(currentURL(), url, 'navigates to the redirect_to url after auth'); }); @@ -87,6 +70,7 @@ module('Acceptance | redirect_to query param functionality', function (hooks) { await fillIn(AUTH_FORM.selectMethod, 'token'); await fillIn(GENERAL.inputByAttr('token'), 'root'); await click(GENERAL.submitButton); + await waitUntil(() => currentURL().includes('vault/secrets')); assert.strictEqual(currentURL(), url, 'navigates to the redirect_to with the query param after auth'); }); @@ -95,6 +79,7 @@ module('Acceptance | redirect_to query param functionality', function (hooks) { const url = '/vault/secrets/cubbyhole/create'; await visit(`/vault/logout?redirect_to=${url}&wrapped_token=${wrappedToken}`); + await waitUntil(() => currentURL().includes('vault/secrets')); assert.strictEqual(currentURL(), url, 'authenticates then navigates to the redirect_to url after auth'); }); }); diff --git a/ui/tests/acceptance/saml-auth-method-test.js b/ui/tests/acceptance/saml-auth-method-test.js index 4f2163a51f..f27ca3d201 100644 --- a/ui/tests/acceptance/saml-auth-method-test.js +++ b/ui/tests/acceptance/saml-auth-method-test.js @@ -5,7 +5,7 @@ import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; -import { click, fillIn, find, visit, waitUntil } from '@ember/test-helpers'; +import { click, fillIn, find, visit, waitUntil, waitFor } from '@ember/test-helpers'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { Response } from 'miragejs'; import { DELAY_IN_MS, windowStub } from 'vault/tests/helpers/oidc-window-stub'; @@ -13,6 +13,9 @@ import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { logout } from 'vault/tests/helpers/auth/auth-helpers'; +import { getErrorResponse } from 'vault/tests/helpers/api/error-response'; +import sinon from 'sinon'; +import { ERROR_POPUP_FAILED, ERROR_TIMEOUT, ERROR_WINDOW_CLOSED } from 'vault/utils/auth-form-helpers'; module('Acceptance | enterprise saml auth method', function (hooks) { setupApplicationTest(hooks); @@ -20,13 +23,13 @@ module('Acceptance | enterprise saml auth method', function (hooks) { hooks.beforeEach(async function () { this.openStub = windowStub(); - this.server.put('/auth/saml/sso_service_url', () => ({ + this.server.post('/auth/saml/sso_service_url', () => ({ data: { sso_service_url: 'test/fake/sso/route', // we aren't actually opening a popup so use a fake url token_poll_id: '1234', }, })); - this.server.put('/auth/saml/token', () => ({ + this.server.post('/auth/saml/token', () => ({ auth: { client_token: 'root' }, })); // ensure clean state @@ -59,7 +62,7 @@ module('Acceptance | enterprise saml auth method', function (hooks) { }, }, })); - this.server.put('/auth/test-path/sso_service_url', () => { + this.server.post('/auth/test-path/sso_service_url', () => { assert.ok(true, 'role request made to correct non-standard mount path'); return { data: { @@ -68,7 +71,7 @@ module('Acceptance | enterprise saml auth method', function (hooks) { }, }; }); - this.server.put('/auth/test-path/token', () => { + this.server.post('/auth/test-path/token', () => { assert.ok(true, 'login request made to correct non-standard mount path'); return { auth: { client_token: 'root' }, @@ -92,7 +95,7 @@ module('Acceptance | enterprise saml auth method', function (hooks) { test('it should render API errors from sso_service_url', async function (assert) { assert.expect(1); - this.server.put('/auth/saml/sso_service_url', () => { + this.server.post('/auth/saml/sso_service_url', () => { return new Response( 400, { 'Content-Type': 'application/json' }, @@ -105,27 +108,58 @@ module('Acceptance | enterprise saml auth method', function (hooks) { await fillIn(AUTH_FORM.selectMethod, 'saml'); await click(GENERAL.submitButton); assert - .dom('[data-test-message-error-description]') - .hasText("Authentication failed: missing required 'role' parameter", 'shows API error from role fetch'); + .dom(GENERAL.messageError) + .hasText( + "Error Authentication failed: missing required 'role' parameter", + 'shows API error from role fetch' + ); }); - test('it should render API errors from saml token login url', async function (assert) { + test('it should render timeout error from polling token', async function (assert) { assert.expect(1); - this.server.put('/auth/saml/token', () => { - return new Response( - 400, - { 'Content-Type': 'application/json' }, - JSON.stringify({ errors: ['something went wrong'] }) - ); - }); + const api = this.owner.lookup('service:api'); + const samlWriteTokenStub = sinon.stub(api.auth, 'samlWriteToken'); + samlWriteTokenStub.rejects(getErrorResponse({}, 401)); + await waitUntil(() => find(AUTH_FORM.selectMethod), { timeout: DELAY_IN_MS }); + await fillIn(AUTH_FORM.selectMethod, 'saml'); + await click(GENERAL.submitButton); + // Polling timeout is 1 second for testing environments + await waitFor(GENERAL.messageError, { timeout: 1000 }); + assert.dom(GENERAL.messageError).hasText(`Error Authentication failed: ${ERROR_TIMEOUT}`); + }); + + test('it renders error when popup is prematurely closed', async function (assert) { + windowStub({ stub: this.openStub, popup: { closed: true, close: () => {} } }); + + await logout(); + await fillIn(AUTH_FORM.selectMethod, 'saml'); + await click(GENERAL.submitButton); + assert.dom(GENERAL.messageError).hasText(`Error Authentication failed: ${ERROR_WINDOW_CLOSED}`); + }); + + test('it renders error when window fails to open', async function (assert) { + this.openStub.returns(null); + + await logout(); + await fillIn(AUTH_FORM.selectMethod, 'saml'); + await click(GENERAL.submitButton); + assert + .dom(GENERAL.messageError) + .hasText(`Error Authentication failed: Failed to open SAML popup window. ${ERROR_POPUP_FAILED}`); + }); + + test('it should render API errors from saml token polling url', async function (assert) { + assert.expect(1); + const api = this.owner.lookup('service:api'); + const samlWriteTokenStub = sinon.stub(api.auth, 'samlWriteToken'); + samlWriteTokenStub.rejects(getErrorResponse({ errors: ['something went wrong'] }, 400)); // select saml auth type await waitUntil(() => find(AUTH_FORM.selectMethod), { timeout: DELAY_IN_MS }); await fillIn(AUTH_FORM.selectMethod, 'saml'); await click(GENERAL.submitButton); - assert - .dom('[data-test-message-error-description]') - .hasText('Authentication failed: something went wrong', 'shows API error from login attempt'); + assert.dom(GENERAL.messageError).hasText('Error Authentication failed: something went wrong'); + samlWriteTokenStub.restore(); }); test('it should populate saml auth method on logout', async function (assert) { diff --git a/ui/tests/acceptance/wrapped-token-test.js b/ui/tests/acceptance/wrapped-token-test.js index 07230683e3..751e303671 100644 --- a/ui/tests/acceptance/wrapped-token-test.js +++ b/ui/tests/acceptance/wrapped-token-test.js @@ -75,18 +75,11 @@ module(`Acceptance | wrapped_token query param functionality`, function (hooks) this.server.post('/sys/wrapping/unwrap', () => { return { auth: { client_token: '12345' } }; }); - const authSpy = sinon.spy(this.owner.lookup('service:auth'), 'authenticate'); + const authSpy = sinon.spy(this.owner.lookup('service:auth'), 'authSuccess'); await visit(`/vault/logout?wrapped_token=${this.token}`); - const [actual] = authSpy.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)} ` - ); + const [clusterId, { authMethodType, token }] = authSpy.lastCall.args; + assert.strictEqual(clusterId, '1', 'authSuccess is called with clusterId'); + assert.strictEqual(authMethodType, 'token', 'authSuccess is called with token as the authMethodType'); + assert.strictEqual(token, '12345', 'authSuccess is called with the client_token'); }); }); diff --git a/ui/tests/helpers/auth/auth-helpers.ts b/ui/tests/helpers/auth/auth-helpers.ts index 13c6a75ef8..bd1b48df35 100644 --- a/ui/tests/helpers/auth/auth-helpers.ts +++ b/ui/tests/helpers/auth/auth-helpers.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { click, fillIn, visit } from '@ember/test-helpers'; +import { click, currentRouteName, fillIn, visit, waitUntil } 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 { GENERAL } from 'vault/tests/helpers/general-selectors'; @@ -29,7 +29,8 @@ export const login = async (token = rootToken) => { await fillIn(AUTH_FORM.selectMethod, 'token'); await fillIn(GENERAL.inputByAttr('token'), token); - return click(GENERAL.submitButton); + await click(GENERAL.submitButton); + return await waitUntil(() => currentRouteName() === 'vault.cluster.dashboard'); }; export const loginNs = async (ns: string, token = rootToken) => { @@ -41,7 +42,8 @@ export const loginNs = async (ns: string, token = rootToken) => { await fillIn(AUTH_FORM.selectMethod, 'token'); await fillIn(GENERAL.inputByAttr('token'), token); - return click(GENERAL.submitButton); + await click(GENERAL.submitButton); + return await waitUntil(() => currentRouteName() === 'vault.cluster.dashboard'); }; // LOGIN WITH NON-TOKEN METHODS @@ -56,7 +58,8 @@ export const loginMethod = async ( await fillIn(AUTH_FORM.selectMethod, type); await fillInLoginFields(loginFields, options); - return click(GENERAL.submitButton); + await click(GENERAL.submitButton); + return await waitUntil(() => currentRouteName() === 'vault.cluster.dashboard'); }; export const fillInLoginFields = async (loginFields: LoginFields, { toggleOptions = false } = {}) => { @@ -71,7 +74,7 @@ export const fillInLoginFields = async (loginFields: LoginFields, { toggleOption const LOGIN_DATA = { token: { token: 'mysupersecuretoken' }, - username: { username: 'matilda', password: 'password' }, + username: { username: 'matilda', password: 'some-password' }, role: { role: 'some-dev' }, }; // maps auth type to login input data diff --git a/ui/tests/helpers/auth/response-stubs.ts b/ui/tests/helpers/auth/response-stubs.ts index 4a96da246f..85c31b9545 100644 --- a/ui/tests/helpers/auth/response-stubs.ts +++ b/ui/tests/helpers/auth/response-stubs.ts @@ -318,17 +318,10 @@ export const RESPONSE_STUBS = { // the "backend" key should be completely removable export const TOKEN_DATA = { github: { - backend: { - description: 'GitHub authentication.', - displayNamePath: ['metadata.org', 'metadata.username'], - formAttributes: ['token'], - mountPath: 'github', - tokenPath: 'client_token', - type: 'github', - typeDisplay: 'GitHub', - }, + authMethodType: 'github', + authMountPath: 'github', displayName: `${RESPONSE_STUBS.github.auth.metadata.org}/${RESPONSE_STUBS.github.auth.metadata.username}`, - entity_id: RESPONSE_STUBS.github.auth.entity_id, + entityId: RESPONSE_STUBS.github.auth.entity_id, policies: RESPONSE_STUBS.github.auth.policies, renewable: RESPONSE_STUBS.github.auth.renewable, token: RESPONSE_STUBS.github.auth.client_token, @@ -337,17 +330,10 @@ export const TOKEN_DATA = { userRootNamespace: '', }, ldap: { - backend: { - description: 'LDAP authentication.', - displayNamePath: 'metadata.username', - formAttributes: ['username', 'password'], - mountPath: 'ldap', - tokenPath: 'client_token', - type: 'ldap', - typeDisplay: 'LDAP', - }, + authMethodType: 'ldap', + authMountPath: 'ldap', displayName: RESPONSE_STUBS.ldap.auth.metadata.username, - entity_id: RESPONSE_STUBS.ldap.auth.entity_id, + entityId: RESPONSE_STUBS.ldap.auth.entity_id, policies: RESPONSE_STUBS.ldap.auth.policies, renewable: RESPONSE_STUBS.ldap.auth.renewable, token: RESPONSE_STUBS.ldap.auth.client_token, @@ -356,55 +342,34 @@ export const TOKEN_DATA = { userRootNamespace: '', }, jwt: { - backend: { - description: 'Authenticate using JWT or OIDC provider.', - displayNamePath: 'display_name', - formAttributes: ['role', 'jwt'], - mountPath: 'jwt', - tokenPath: 'client_token', - type: 'jwt', - typeDisplay: 'JWT', - }, + authMethodType: 'jwt', + authMountPath: 'jwt', displayName: RESPONSE_STUBS.jwt['lookup-self'].data.display_name, - entity_id: RESPONSE_STUBS.jwt['lookup-self'].data.entity_id, - policies: RESPONSE_STUBS.jwt['lookup-self'].data.policies, - renewable: RESPONSE_STUBS.jwt['lookup-self'].data.renewable, - token: RESPONSE_STUBS.jwt['lookup-self'].data.id, + entityId: RESPONSE_STUBS.jwt.login.auth.entity_id, + policies: RESPONSE_STUBS.jwt.login.auth.policies, + renewable: RESPONSE_STUBS.jwt.login.auth.renewable, + token: RESPONSE_STUBS.jwt.login.auth.client_token, tokenExpirationEpoch: 1752425319766, - ttl: RESPONSE_STUBS.jwt['lookup-self'].data.ttl, + ttl: RESPONSE_STUBS.jwt.login.auth.lease_duration, userRootNamespace: '', }, oidc: { - backend: { - description: 'Token authentication.', - displayNamePath: 'display_name', - formAttributes: ['token'], - mountPath: 'oidc', - tokenPath: 'id', - type: 'token', - typeDisplay: 'Token', - }, + authMethodType: 'oidc', + authMountPath: 'oidc', displayName: RESPONSE_STUBS.oidc['lookup-self'].data.display_name, - entity_id: RESPONSE_STUBS.oidc['lookup-self'].data.entity_id, - policies: RESPONSE_STUBS.oidc['lookup-self'].data.policies, - renewable: RESPONSE_STUBS.oidc['lookup-self'].data.renewable, - token: RESPONSE_STUBS.oidc['lookup-self'].data.id, + entityId: RESPONSE_STUBS.oidc['oidc/callback'].auth.entity_id, + policies: RESPONSE_STUBS.oidc['oidc/callback'].auth.policies, + renewable: RESPONSE_STUBS.oidc['oidc/callback'].auth.renewable, + token: RESPONSE_STUBS.oidc['oidc/callback'].auth.client_token, tokenExpirationEpoch: 1752349314961, - ttl: RESPONSE_STUBS.oidc['lookup-self'].data.ttl, + ttl: RESPONSE_STUBS.oidc['oidc/callback'].auth.lease_duration, userRootNamespace: '', }, okta: { - backend: { - description: 'Authenticate with your Okta username and password.', - displayNamePath: 'metadata.username', - formAttributes: ['username', 'password'], - mountPath: 'okta', - tokenPath: 'client_token', - type: 'okta', - typeDisplay: 'Okta', - }, + authMethodType: 'okta', + authMountPath: 'okta', displayName: RESPONSE_STUBS.okta.auth.metadata.username, - entity_id: RESPONSE_STUBS.okta.auth.entity_id, + entityId: RESPONSE_STUBS.okta.auth.entity_id, policies: RESPONSE_STUBS.okta.auth.policies, renewable: RESPONSE_STUBS.okta.auth.renewable, token: RESPONSE_STUBS.okta.auth.client_token, @@ -413,17 +378,10 @@ export const TOKEN_DATA = { userRootNamespace: '', }, radius: { - backend: { - description: 'Authenticate with your RADIUS username and password.', - displayNamePath: 'metadata.username', - formAttributes: ['username', 'password'], - mountPath: 'radius', - tokenPath: 'client_token', - type: 'radius', - typeDisplay: 'RADIUS', - }, + authMethodType: 'radius', + authMountPath: 'radius', displayName: RESPONSE_STUBS.radius.auth.metadata.username, - entity_id: RESPONSE_STUBS.radius.auth.entity_id, + entityId: RESPONSE_STUBS.radius.auth.entity_id, policies: RESPONSE_STUBS.radius.auth.policies, renewable: RESPONSE_STUBS.radius.auth.renewable, token: RESPONSE_STUBS.radius.auth.client_token, @@ -432,36 +390,22 @@ export const TOKEN_DATA = { userRootNamespace: '', }, token: { - userRootNamespace: '', + authMethodType: 'token', + authMountPath: '', displayName: 'token', - backend: { - mountPath: 'token', - type: 'token', - typeDisplay: 'Token', - description: 'Token authentication.', - tokenPath: 'id', - displayNamePath: 'display_name', - formAttributes: ['token'], - }, - token: RESPONSE_STUBS.token.data.id, + entityId: RESPONSE_STUBS.token.data.entity_id, policies: RESPONSE_STUBS.token.data.policies, renewable: RESPONSE_STUBS.token.data.renewable, - entity_id: RESPONSE_STUBS.token.data.entity_id, - ttl: RESPONSE_STUBS.token.data.ttl, + token: RESPONSE_STUBS.token.data.id, tokenExpirationEpoch: 1747413884837, + ttl: RESPONSE_STUBS.token.data.ttl, + userRootNamespace: '', }, userpass: { - backend: { - description: 'A simple username and password backend.', - displayNamePath: 'metadata.username', - formAttributes: ['username', 'password'], - mountPath: 'userpass', - tokenPath: 'client_token', - type: 'userpass', - typeDisplay: 'Username', - }, + authMethodType: 'userpass', + authMountPath: 'userpass', displayName: RESPONSE_STUBS.userpass.auth.metadata.username, - entity_id: RESPONSE_STUBS.userpass.auth.entity_id, + entityId: RESPONSE_STUBS.userpass.auth.entity_id, policies: RESPONSE_STUBS.userpass.auth.policies, renewable: RESPONSE_STUBS.userpass.auth.renewable, token: RESPONSE_STUBS.userpass.auth.client_token, @@ -471,17 +415,10 @@ export const TOKEN_DATA = { }, // ENTERPRISE ONLY saml: { - backend: { - description: 'Token authentication.', - displayNamePath: 'display_name', - formAttributes: ['token'], - mountPath: 'saml', - tokenPath: 'id', - type: 'token', - typeDisplay: 'Token', - }, + authMethodType: 'saml', + authMountPath: 'saml', displayName: RESPONSE_STUBS.saml['lookup-self'].data.display_name, - entity_id: RESPONSE_STUBS.saml['lookup-self'].data.entity_id, + entityId: RESPONSE_STUBS.saml['lookup-self'].data.entity_id, policies: RESPONSE_STUBS.saml['lookup-self'].data.policies, renewable: RESPONSE_STUBS.saml['lookup-self'].data.renewable, token: RESPONSE_STUBS.saml['lookup-self'].data.id, diff --git a/ui/tests/integration/components/auth/form-template-test.js b/ui/tests/integration/components/auth/form-template-test.js index e1ce6caf32..d7678b86c9 100644 --- a/ui/tests/integration/components/auth/form-template-test.js +++ b/ui/tests/integration/components/auth/form-template-test.js @@ -12,9 +12,9 @@ import { setupMirage } from 'ember-cli-mirage/test-support'; import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import { AUTH_METHOD_LOGIN_DATA } from 'vault/tests/helpers/auth/auth-helpers'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; -import { ENTERPRISE_LOGIN_METHODS, supportedTypes } from 'vault/utils/supported-login-methods'; +import { ENTERPRISE_LOGIN_METHODS, ERROR_JWT_LOGIN, supportedTypes } from 'vault/utils/auth-form-helpers'; import { overrideResponse } from 'vault/tests/helpers/stubs'; -import { ERROR_JWT_LOGIN } from 'vault/components/auth/form/oidc-jwt'; +import { getErrorResponse } from 'vault/tests/helpers/api/error-response'; module('Integration | Component | auth | form template', function (hooks) { setupRenderingTest(hooks); @@ -23,6 +23,7 @@ module('Integration | Component | auth | form template', function (hooks) { hooks.beforeEach(function () { window.localStorage.clear(); this.version = this.owner.lookup('service:version'); + this.router = this.owner.lookup('service:router'); this.cluster = { id: '1' }; this.alternateView = null; @@ -65,14 +66,14 @@ module('Integration | Component | auth | form template', function (hooks) { }); test('it displays errors', async function (assert) { - const authenticateStub = sinon.stub(this.owner.lookup('service:auth'), 'authenticate'); - authenticateStub.throws('permission denied'); + const api = this.owner.lookup('service:api'); + // stub auth request for "token" method because it's selected by default + const tokenLookUpSelfStub = sinon.stub(api.auth, 'tokenLookUpSelf'); + tokenLookUpSelfStub.rejects(getErrorResponse({ errors: ['uh oh!'] }, 400)); await this.renderComponent(); await click(GENERAL.submitButton); - assert - .dom(GENERAL.messageError) - .hasText('Error Authentication failed: permission denied: Sinon-provided permission denied'); - authenticateStub.restore(); + assert.dom(GENERAL.messageError).hasText('Error Authentication failed: uh oh!'); + tokenLookUpSelfStub.restore(); }); test('dropdown does not include enterprise methods on community versions', async function (assert) { @@ -108,12 +109,12 @@ module('Integration | Component | auth | form template', function (hooks) { type: 'userpass', }, ], - oidc: [ + ldap: [ { - path: 'my_oidc/', + path: 'my-ldap/', description: '', options: {}, - type: 'oidc', + type: 'ldap', }, ], token: [ @@ -146,19 +147,19 @@ module('Integration | Component | auth | form template', function (hooks) { // click through each tab await click(AUTH_FORM.tabBtn('userpass')); assertSelected('userpass'); - assertUnselected('oidc'); + assertUnselected('ldap'); assertUnselected('token'); assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); - await click(AUTH_FORM.tabBtn('oidc')); - assertSelected('oidc'); + await click(AUTH_FORM.tabBtn('ldap')); + assertSelected('ldap'); assertUnselected('token'); assertUnselected('userpass'); assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); await click(AUTH_FORM.tabBtn('token')); assertSelected('token'); - assertUnselected('oidc'); + assertUnselected('ldap'); assertUnselected('userpass'); assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); }); @@ -185,33 +186,33 @@ module('Integration | Component | auth | form template', function (hooks) { 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('ldap')).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'); + await click(AUTH_FORM.tabBtn('ldap')); + assert.dom(AUTH_FORM.tabBtn('ldap')).hasAttribute('aria-selected', 'true'); assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'false'); await click(GENERAL.button('Sign in with other methods')); 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('ldap')).hasAttribute('aria-selected', 'false'); assert.dom(AUTH_FORM.tabBtn('token')).hasAttribute('aria-selected', 'false'); }); test('it preselects tab from initialFormState', async function (assert) { - this.initialFormState = { initialAuthType: 'oidc', showAlternate: false }; + this.initialFormState = { initialAuthType: 'ldap', showAlternate: false }; await this.renderComponent(); - assert.dom(AUTH_FORM.authForm('oidc')).exists('oidc form renders'); - assert.dom(AUTH_FORM.tabBtn('oidc')).hasAttribute('aria-selected', 'true'); + assert.dom(AUTH_FORM.authForm('ldap')).exists('ldap form renders'); + assert.dom(AUTH_FORM.tabBtn('ldap')).hasAttribute('aria-selected', 'true'); }); test('it renders dropdown and preselects type if initialFormState is not a tab', async function (assert) { - this.initialFormState = { initialAuthType: 'ldap', showAlternate: true }; + this.initialFormState = { initialAuthType: 'okta', showAlternate: true }; await this.renderComponent(); - assert.dom(GENERAL.selectByAttr('auth type')).hasValue('ldap'); + assert.dom(GENERAL.selectByAttr('auth type')).hasValue('okta'); assert.dom(GENERAL.inputByAttr('username')).exists(); assert.dom(GENERAL.inputByAttr('password')).exists(); @@ -244,6 +245,10 @@ module('Integration | Component | auth | form template', function (hooks) { await this.renderComponent(); for (const authType of authMethodTypes) { + let stub; + if (['oidc', 'jwt'].includes(authType)) { + stub = sinon.stub(this.router, 'urlFor').returns('123-example.com'); + } const loginData = AUTH_METHOD_LOGIN_DATA[authType]; const fields = Object.keys(loginData); @@ -267,6 +272,10 @@ module('Integration | Component | auth | form template', function (hooks) { fields.forEach((field) => { assert.dom(GENERAL.inputByAttr(field)).exists(`${authType}: ${field} input renders`); }); + + if (stub) { + stub.restore(); + } } }); @@ -287,9 +296,7 @@ module('Integration | Component | auth | form template', function (hooks) { // in the corresponding the Auth::Form:: integration tests module('oidc-jwt', function (hooks) { hooks.beforeEach(async function () { - this.store = this.owner.lookup('service:store'); - this.routerStub = (path) => - sinon.stub(this.owner.lookup('service:router'), 'urlFor').returns(`/auth/${path}/oidc/callback`); + this.routerStub = (path) => sinon.stub(this.router, 'urlFor').returns(`/auth/${path}/oidc/callback`); }); test('it re-requests the auth_url when authType changes', async function (assert) { diff --git a/ui/tests/integration/components/auth/form/auth-form-test-helper.js b/ui/tests/integration/components/auth/form/auth-form-test-helper.js index 29fcf6b91b..bf6dcc037b 100644 --- a/ui/tests/integration/components/auth/form/auth-form-test-helper.js +++ b/ui/tests/integration/components/auth/form/auth-form-test-helper.js @@ -3,9 +3,10 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { click, fillIn, findAll } from '@ember/test-helpers'; +import { click, findAll } from '@ember/test-helpers'; +import { getErrorResponse } from 'vault/tests/helpers/api/error-response'; import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; -import { AUTH_METHOD_LOGIN_DATA, fillInLoginFields } from 'vault/tests/helpers/auth/auth-helpers'; +import { fillInLoginFields } from 'vault/tests/helpers/auth/auth-helpers'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; /* @@ -13,72 +14,60 @@ NOTE: In the app these components are actually rendered dynamically by Auth::For 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. + +See beforeEach hooks in auth/form/base-test to see setup for each method. */ -export default (test, { standardSubmit = true } = {}) => { +export default (test) => { test('it renders fields', async function (assert) { + const expectedFields = Object.keys(this.loginData); await this.renderComponent(); assert.dom(AUTH_FORM.authForm(this.authType)).exists(`${this.authType}: it renders form component`); const fields = findAll('input'); for (const field of fields) { - assert.true(this.expectedFields.includes(field.name), `it renders field: ${field.name}`); + assert.true(expectedFields.includes(field.name), `it renders field: ${field.name}`); } }); test('it fires onError callback', async function (assert) { - this.authenticateStub.throws('permission denied'); + this.authenticateStub.rejects(getErrorResponse({ errors: ['uh oh!'] }, 500)); await this.renderComponent(); await click(GENERAL.submitButton); const [actual] = this.onError.lastCall.args; - assert.strictEqual( - actual, - 'Authentication failed: permission denied: Sinon-provided permission denied', - 'it calls onError' - ); + assert.strictEqual(actual, 'Authentication failed: uh oh!', 'it calls onError'); }); test('it fires onSuccess callback', async function (assert) { - this.authenticateStub.returns('success!'); + this.authenticateStub.resolves(this.authResponse); await this.renderComponent(); await click(GENERAL.submitButton); - const [actual] = this.onSuccess.lastCall.args; - assert.strictEqual(actual, 'success!', 'it calls onSuccess'); + // Only checking for authMethodType because this test just asserts the onSuccess callback fires. + const [{ authMethodType }] = this.onSuccess.lastCall.args; + assert.strictEqual(authMethodType, this.authType, 'it calls onSuccess'); }); - // some methods are tested separately because they have more complex submit logic - if (standardSubmit) { - test('it submits form data with defaults', async function (assert) { - await this.renderComponent(); - const loginData = AUTH_METHOD_LOGIN_DATA[this.authType]; + test('it submits form data with defaults', async function (assert) { + await this.renderComponent(); + await fillInLoginFields(this.loginData); + await click(GENERAL.submitButton); - await fillInLoginFields(loginData); - await click(GENERAL.submitButton); - const [actual] = this.authenticateStub.lastCall.args; - assert.propEqual( - actual.data, - this.expectedSubmit.default, - 'auth service "authenticate" method is called with form data' - ); - }); + // Since each login method accepts different args + // the submit assertion is setup in each method's beforeEach hook + this.assertSubmit(assert, this.authenticateStub.lastCall.args, this.loginData); + }); - // not for testing real-world submit, that happens in acceptance tests. - // component here just yields <:advancedSettings> to test form submits data from yielded inputs - test('it submits form data from yielded inputs', async function (assert) { - await this.renderComponent({ yieldBlock: true }); - const loginData = AUTH_METHOD_LOGIN_DATA[this.authType]; - - await fillInLoginFields(loginData); - await fillIn(GENERAL.inputByAttr('path'), `custom-${this.authType}`); - - await click(GENERAL.submitButton); - const [actual] = this.authenticateStub.lastCall.args; - assert.propEqual( - actual.data, - this.expectedSubmit.custom, - 'auth service "authenticate" method is called with yielded form data' - ); - }); - } + // not for testing real-world submit, that happens in acceptance tests. + // component here just yields <:advancedSettings> to test form submits data from yielded inputs + test('it submits form data from yielded inputs', async function (assert) { + const customPath = `custom-${this.authType}`; + const loginData = { ...this.loginData, path: customPath }; + await this.renderComponent({ yieldBlock: true }); + await fillInLoginFields(loginData); + await click(GENERAL.submitButton); + // Since each login method accepts different args + // the submit assertion is setup in each method's beforeEach hook + this.assertSubmit(assert, this.authenticateStub.lastCall.args, loginData); + }); }; diff --git a/ui/tests/integration/components/auth/form/base-test.js b/ui/tests/integration/components/auth/form/base-test.js index 19afb39dc4..350ffd84b3 100644 --- a/ui/tests/integration/components/auth/form/base-test.js +++ b/ui/tests/integration/components/auth/form/base-test.js @@ -6,10 +6,12 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import hbs from 'htmlbars-inline-precompile'; -import { click, fillIn, find, render } from '@ember/test-helpers'; +import { find, render } from '@ember/test-helpers'; import sinon from 'sinon'; -import testHelper from './auth-form-test-helper'; +import authFormTestHelper from './auth-form-test-helper'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import { AUTH_METHOD_LOGIN_DATA } from 'vault/tests/helpers/auth/auth-helpers'; +import { RESPONSE_STUBS } from 'vault/tests/helpers/auth/response-stubs'; // 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 @@ -18,23 +20,27 @@ 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(); - }); - - hooks.afterEach(function () { - this.authenticateStub.restore(); + const api = this.owner.lookup('service:api'); + this.setup = (authType, loginMethod) => { + this.authType = authType; + this.authenticateStub = sinon.stub(api.auth, loginMethod); + this.authResponse = RESPONSE_STUBS[authType]; + this.loginData = AUTH_METHOD_LOGIN_DATA[authType]; + }; }); module('github', function (hooks) { hooks.beforeEach(function () { - this.authType = 'github'; - this.expectedFields = ['token']; - this.expectedSubmit = { - default: { path: 'github', token: 'mysupersecuretoken' }, - custom: { path: 'custom-github', token: 'mysupersecuretoken' }, + this.setup('github', 'githubLogin'); + this.assertSubmit = (assert, loginRequestArgs, loginData) => { + const [path, { token }] = loginRequestArgs; + // if path is included in loginData, a custom path was submitted + const expectedPath = loginData?.path || this.authType; + assert.strictEqual(path, expectedPath, 'it calls githubLogin with expected path'); + assert.strictEqual(token, loginData.token, 'it calls githubLogin with token'); }; this.renderComponent = ({ yieldBlock = false } = {}) => { if (yieldBlock) { @@ -61,7 +67,11 @@ module('Integration | Component | auth | form | base', function (hooks) { }; }); - testHelper(test); + hooks.afterEach(function () { + this.authenticateStub.restore(); + }); + + authFormTestHelper(test); test('it renders custom label', async function (assert) { await this.renderComponent(); @@ -72,11 +82,14 @@ module('Integration | Component | auth | form | base', function (hooks) { module('ldap', function (hooks) { hooks.beforeEach(function () { - this.authType = 'ldap'; - this.expectedFields = ['username', 'password']; - this.expectedSubmit = { - default: { password: 'password', path: 'ldap', username: 'matilda' }, - custom: { password: 'password', path: 'custom-ldap', username: 'matilda' }, + this.setup('ldap', 'ldapLogin'); + this.assertSubmit = (assert, loginRequestArgs, loginData) => { + const [username, path, { password }] = loginRequestArgs; + // if path is included in loginData, a custom path was submitted + const expectedPath = loginData?.path || this.authType; + assert.strictEqual(path, expectedPath, 'it calls ldapLogin with expected path'); + assert.strictEqual(username, loginData.username, 'it calls ldapLogin with username'); + assert.strictEqual(password, loginData.password, 'it calls ldapLogin with password'); }; this.renderComponent = ({ yieldBlock = false } = {}) => { if (yieldBlock) { @@ -103,16 +116,23 @@ module('Integration | Component | auth | form | base', function (hooks) { }; }); - testHelper(test); + hooks.afterEach(function () { + this.authenticateStub.restore(); + }); + + authFormTestHelper(test); }); module('radius', function (hooks) { hooks.beforeEach(function () { - this.authType = 'radius'; - this.expectedFields = ['username', 'password']; - this.expectedSubmit = { - default: { password: 'password', path: 'radius', username: 'matilda' }, - custom: { password: 'password', path: 'custom-radius', username: 'matilda' }, + this.setup('radius', 'radiusLoginWithUsername'); + this.assertSubmit = (assert, loginRequestArgs, loginData) => { + const [username, path, { password }] = loginRequestArgs; + // if path is included in loginData, a custom path was submitted + const expectedPath = loginData?.path || this.authType; + assert.strictEqual(username, loginData.username, 'it calls radiusLoginWithUsername with username'); + assert.strictEqual(path, expectedPath, 'it calls radiusLoginWithUsername with expected path'); + assert.strictEqual(password, loginData.password, 'it calls radiusLoginWithUsername with password'); }; this.renderComponent = ({ yieldBlock = false } = {}) => { if (yieldBlock) { @@ -139,13 +159,20 @@ module('Integration | Component | auth | form | base', function (hooks) { }; }); - testHelper(test); + hooks.afterEach(function () { + this.authenticateStub.restore(); + }); + + authFormTestHelper(test); }); module('token', function (hooks) { hooks.beforeEach(function () { - this.authType = 'token'; - this.expectedFields = ['token']; + this.setup('token', 'tokenLookUpSelf'); + this.assertSubmit = (assert, loginRequestArgs) => { + const [{ headers }] = loginRequestArgs; + assert.strictEqual(headers['X-Vault-Token'], 'mysupersecuretoken', 'token is submitted as header'); + }; this.renderComponent = ({ yieldBlock = false } = {}) => { if (yieldBlock) { return render(hbs` @@ -171,42 +198,23 @@ module('Integration | Component | auth | form | base', function (hooks) { }; }); - testHelper(test, { standardSubmit: false }); - - test('it submits form data with defaults', async function (assert) { - await this.renderComponent(); - await fillIn(GENERAL.inputByAttr('token'), 'mytoken'); - await click(GENERAL.submitButton); - const [actual] = this.authenticateStub.lastCall.args; - assert.propEqual( - actual.data, - { token: 'mytoken' }, - 'auth service "authenticate" method is called with form data' - ); + hooks.afterEach(function () { + this.authenticateStub.restore(); }); - test('it submits form data from yielded inputs', async function (assert) { - await this.renderComponent({ yieldBlock: true }); - await fillIn(GENERAL.inputByAttr('token'), 'mytoken'); - // token doesn't support custom paths, so testing path is not sent - await fillIn(GENERAL.inputByAttr('path'), `path-${this.authType}`); - await click(GENERAL.submitButton); - const [actual] = this.authenticateStub.lastCall.args; - assert.propEqual( - actual.data, - { token: 'mytoken' }, - 'auth service "authenticate" method is called without "path"' - ); - }); + authFormTestHelper(test); }); module('userpass', function (hooks) { hooks.beforeEach(function () { - this.authType = 'userpass'; - this.expectedFields = ['username', 'password']; - this.expectedSubmit = { - default: { password: 'password', path: 'userpass', username: 'matilda' }, - custom: { password: 'password', path: 'custom-userpass', username: 'matilda' }, + this.setup('userpass', 'userpassLogin'); + this.assertSubmit = (assert, loginRequestArgs, loginData) => { + const [username, path, { password }] = loginRequestArgs; + // if path is included in loginData, a custom path was submitted + const expectedPath = loginData?.path || this.authType; + assert.strictEqual(path, expectedPath, 'it calls userpassLogin with expected path'); + assert.strictEqual(username, loginData.username, 'it calls userpassLogin with username'); + assert.strictEqual(password, loginData.password, 'it calls userpassLogin with password'); }; this.renderComponent = ({ yieldBlock = false } = {}) => { if (yieldBlock) { @@ -233,6 +241,10 @@ module('Integration | Component | auth | form | base', function (hooks) { }; }); - testHelper(test); + hooks.afterEach(function () { + this.authenticateStub.restore(); + }); + + authFormTestHelper(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 index fec8e57905..7f2e3fcd2c 100644 --- a/ui/tests/integration/components/auth/form/oidc-jwt-test.js +++ b/ui/tests/integration/components/auth/form/oidc-jwt-test.js @@ -8,15 +8,16 @@ import { setupRenderingTest } from 'ember-qunit'; import hbs from 'htmlbars-inline-precompile'; import { click, fillIn, find, render, settled, waitUntil } from '@ember/test-helpers'; import { _cancelTimers as cancelTimers } from '@ember/runloop'; -import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import { callbackData } from 'vault/tests/helpers/oidc-window-stub'; -import { ERROR_JWT_LOGIN } from 'vault/components/auth/form/oidc-jwt'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { overrideResponse } from 'vault/tests/helpers/stubs'; import { setupMirage } from 'ember-cli-mirage/test-support'; import * as parseURL from 'core/utils/parse-url'; import sinon from 'sinon'; -import testHelper from './auth-form-test-helper'; +import authFormTestHelper from './auth-form-test-helper'; +import { RESPONSE_STUBS } from 'vault/tests/helpers/auth/response-stubs'; +import { DOMAIN_PROVIDER_MAP, ERROR_JWT_LOGIN } from 'vault/utils/auth-form-helpers'; +import { dasherize } from '@ember/string'; /* The OIDC and JWT mounts call the same endpoint (see docs https://developer.hashicorp.com/vault/docs/auth/jwt ) @@ -34,7 +35,7 @@ const authUrlRequestTests = (test) => { const { role } = JSON.parse(req.requestBody); assert.true(true, 'it makes request to auth_url'); assert.strictEqual(role, '', 'role is empty'); - return { data: { auth_url: '123-example.com' } }; + return { data: { authUrl: '123-example.com' } }; }); await this.renderComponent(); }); @@ -82,36 +83,6 @@ const jwtLoginTests = (test) => { assert.dom(GENERAL.submitButton).hasText('Sign in'); }); - test('it submits form data with defaults', async function (assert) { - await this.renderComponent(); - - await fillIn(GENERAL.inputByAttr('role'), 'some-dev'); - await fillIn(GENERAL.inputByAttr('jwt'), 'some-jwt-token'); - - await click(GENERAL.submitButton); - const [actual] = this.authenticateStub.lastCall.args; - assert.propEqual( - actual.data, - this.expectedSubmit.default, - 'auth service "authenticate" method is called with form data' - ); - }); - - test('it submits form data from yielded inputs', async function (assert) { - await this.renderComponent({ yieldBlock: true }); - await fillIn(GENERAL.inputByAttr('role'), 'some-dev'); - await fillIn(GENERAL.inputByAttr('jwt'), 'some-jwt-token'); - await fillIn(GENERAL.inputByAttr('path'), `custom-${this.authType}`); - - await click(GENERAL.submitButton); - const [actual] = this.authenticateStub.lastCall.args; - assert.propEqual( - actual.data, - this.expectedSubmit.custom, - 'auth service "authenticate" method is called with yielded form data' - ); - }); - test('it does NOT re-request the auth_url when jwt token changes', async function (assert) { assert.expect(1); // the assertion in the stubbed request should not be hit await this.renderComponent(); @@ -126,30 +97,10 @@ const jwtLoginTests = (test) => { }; const oidcLoginTests = (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`); - assert.dom(GENERAL.submitButton).hasText('Sign in with OIDC Provider'); - this.expectedFields.forEach((field) => { - assert.dom(GENERAL.inputByAttr(field)).exists(`${this.authType}: it renders ${field}`); - }); - }); - - test('it renders provider icon and name', async function (assert) { - const parseURLStub = sinon.stub(parseURL, 'default').returns({ hostname: 'auth0.com' }); - this.server.post(`/auth/${this.authType}/oidc/auth_url`, () => { - return { data: { auth_url: '123.auth0.com' } }; - }); - await this.renderComponent(); - assert.dom(GENERAL.submitButton).hasText('Sign in with Auth0'); - assert.dom(GENERAL.icon('auth0')).exists(); - parseURLStub.restore(); - }); - // true success has to be asserted in acceptance tests because it's not possible to mock a trusted message event test('it opens the popup window on submit', async function (assert) { this.server.post(`/auth/${this.authType}/oidc/auth_url`, () => { - return { data: { auth_url: '123-example.com' } }; + return { data: { authUrl: '123-example.com' } }; }); sinon.replaceGetter(window, 'screen', () => ({ height: 600, width: 500 })); await this.renderComponent(); @@ -171,7 +122,7 @@ const oidcLoginTests = (test) => { sinon.restore(); }); - // auth_url error handling on submit + /* Tests for auth_url error handling on submit */ test('it fires onError callback on submit when auth_url request fails with 400', async function (assert) { this.server.post('/auth/:path/oidc/auth_url', () => overrideResponse(400)); await this.renderComponent(); @@ -190,8 +141,8 @@ const oidcLoginTests = (test) => { assert.strictEqual(actual, 'Authentication failed: Error fetching role: permission denied'); }); - test('it fires onError callback on submit when auth_url request is successful but missing auth_url key', async function (assert) { - this.server.post('/auth/:path/oidc/auth_url', () => ({ data: {} })); + test('it fires onError callback on submit when auth_url request is successful but missing auth_url', async function (assert) { + this.server.post('/auth/:path/oidc/auth_url', () => ({ data: { authUrl: '' } })); await this.renderComponent(); await click(GENERAL.submitButton); @@ -202,9 +153,9 @@ const oidcLoginTests = (test) => { 'it calls onError' ); }); - // end auth_url error handling + /* END auth_url error handling */ - // prepareForOIDC logic tests + /* test for prepareForOIDC logic */ test('fails silently when event is not trusted', async function (assert) { assert.expect(2); this.server.post(`/auth/${this.authType}/oidc/auth_url`, () => { @@ -267,6 +218,7 @@ const oidcLoginTests = (test) => { // Cleanup window.removeEventListener('message', assertEvent); }); + /* end of tests for prepareForOIDC logic */ }; module('Integration | Component | auth | form | oidc-jwt', function (hooks) { @@ -274,14 +226,42 @@ module('Integration | Component | auth | form | oidc-jwt', function (hooks) { setupMirage(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(); - - // additional test setup for oidc/jwt business - this.store = this.owner.lookup('service:store'); this.routerStub = sinon.stub(this.owner.lookup('service:router'), 'urlFor').returns('123-example.com'); + const api = this.owner.lookup('service:api'); + + this.oidcLoginSetup = () => { + // authentication request called on submit for oidc login + this.authenticateStub = sinon.stub(api.auth, 'jwtOidcCallback'); + this.authResponse = RESPONSE_STUBS.oidc['oidc/callback']; + this.windowStub = sinon.stub(window, 'open'); + }; + + this.jwtLoginSetup = () => { + // stubbing this request shows JWT token input and does not perform OIDC + this.server.post(`/auth/:path/oidc/auth_url`, () => { + return overrideResponse(400, { errors: [ERROR_JWT_LOGIN] }); + }); + // authentication request called on submit for jwt tokens + this.authenticateStub = sinon.stub(api.auth, 'jwtLogin'); + this.authResponse = RESPONSE_STUBS.jwt.login; + }; + + this.assertSubmit = (assert, loginRequestArgs, loginData) => { + const [path, payload] = loginRequestArgs; + // if path is included in loginData, a custom path was submitted + const expectedPath = loginData?.path || this.authType; + assert.strictEqual(path, expectedPath, `auth request made with path: ${expectedPath}`); + + // iterate through each item in the payload and check its value + for (const field in payload) { + const actualValue = payload[field]; + const expectedValue = loginData[field]; + assert.strictEqual(actualValue, expectedValue, `payload includes field: ${field}`); + } + }; this.renderComponent = ({ yieldBlock = false } = {}) => { if (yieldBlock) { @@ -310,10 +290,14 @@ module('Integration | Component | auth | form | oidc-jwt', function (hooks) { }); hooks.afterEach(function () { - this.authenticateStub.restore(); this.routerStub.restore(); }); + /* TESTS FOR BASE COMPONENT FUNCTIONALITY + These tests intentionally do not set authType as they are asserting type agnostic functionality. + This means the /auth_url request is missing the :path param and the console will output this request error: + 403 http://localhost:7357/v1/auth//oidc/auth_url + */ test('it renders helper text', async function (assert) { await this.renderComponent(); const id = find(GENERAL.inputByAttr('role')).id; @@ -322,10 +306,43 @@ module('Integration | Component | auth | form | oidc-jwt', function (hooks) { .hasText('Vault will use the default role to sign in if this field is left blank.'); }); - module('oidc', function (hooks) { + for (const domain in DOMAIN_PROVIDER_MAP) { + const provider = DOMAIN_PROVIDER_MAP[domain]; + + test(`${provider}: it renders provider icon and name`, async function (assert) { + // parseUrl uses the actual window origin, so stub the util's return instead of authUrl + const parseURLStub = sinon.stub(parseURL, 'default').returns({ hostname: domain }); + await this.renderComponent(); + assert.dom(GENERAL.submitButton).hasText(`Sign in with ${provider}`); + + // Right now there is a bug in HDS where the ping-identity icon name has a trailing whitespace. + // This test should fail when upgrading to an HDS version with the corrected icon name and then we can remove this conditional. + const iconName = domain === 'ping.com' ? 'ping-identity ' : dasherize(provider.toLowerCase()); + // convenience message for HDS upgrade failure, can be removed when we upgrade + const message = + iconName === 'ping-identity ' + ? `If you are attempting to upgrade @hashicorp/design-system-components and this test is failing, please remove the icon override for Ping Identity in oidc-jwt.ts` + : `it renders icon for ${domain}`; + assert.dom(GENERAL.icon(iconName)).exists(message); + parseURLStub.restore(); + }); + } + + test('it does not return provider unless domain matches completely', async function (assert) { + assert.expect(2); + // parseUrl uses the actual window origin, so stub the return + const parseURLStub = sinon + .stub(parseURL, 'default') + .returns({ hostname: `http://custom-auth0-provider.com` }); + await this.renderComponent(); + assert.dom(GENERAL.submitButton).hasText('Sign in with OIDC Provider'); + assert.dom(GENERAL.icon()).doesNotExist(); + parseURLStub.restore(); + }); + + module('@authType: oidc', function (hooks) { hooks.beforeEach(function () { this.authType = 'oidc'; - this.expectedFields = ['role']; }); // base component functionality so outside login workflow modules @@ -333,29 +350,27 @@ module('Integration | Component | auth | form | oidc-jwt', function (hooks) { module('login workflow: jwt token', function (hooks) { hooks.beforeEach(function () { - // stubbing this request shows JWT token input and does not perform OIDC - this.server.post(`/auth/:path/oidc/auth_url`, () => { - return overrideResponse(400, { errors: [ERROR_JWT_LOGIN] }); - }); - this.expectedFields = ['role', 'jwt']; - this.expectedSubmit = { - default: { path: 'oidc', role: 'some-dev', jwt: 'some-jwt-token' }, - custom: { path: 'custom-oidc', role: 'some-dev', jwt: 'some-jwt-token' }, - }; + this.jwtLoginSetup(); + this.loginData = { role: 'some-dev', jwt: 'some-jwt-token' }; }); - testHelper(test, { standardSubmit: false }); + hooks.afterEach(function () { + this.authenticateStub.restore(); + }); + + authFormTestHelper(test); jwtLoginTests(test); }); module('login workflow: oidc', function (hooks) { hooks.beforeEach(function () { - // for oidc login workflow only - this.windowStub = sinon.stub(window, 'open'); + this.oidcLoginSetup(); + this.loginData = { role: 'some-dev' }; }); hooks.afterEach(function () { + this.authenticateStub.restore(); this.windowStub.restore(); }); @@ -363,10 +378,9 @@ module('Integration | Component | auth | form | oidc-jwt', function (hooks) { }); }); - module('jwt', function (hooks) { + module('@authType: jwt', function (hooks) { hooks.beforeEach(function () { this.authType = 'jwt'; - this.expectedFields = ['role']; }); // base component functionality so outside login workflow modules @@ -374,29 +388,27 @@ module('Integration | Component | auth | form | oidc-jwt', function (hooks) { module('login workflow: jwt token', function (hooks) { hooks.beforeEach(function () { - // stubbing this request shows JWT token input and does not perform OIDC - this.server.post(`/auth/:path/oidc/auth_url`, () => { - return overrideResponse(400, { errors: [ERROR_JWT_LOGIN] }); - }); - this.expectedFields = ['role', 'jwt']; - this.expectedSubmit = { - default: { path: 'jwt', role: 'some-dev', jwt: 'some-jwt-token' }, - custom: { path: 'custom-jwt', role: 'some-dev', jwt: 'some-jwt-token' }, - }; + this.jwtLoginSetup(); + this.loginData = { role: 'some-dev', jwt: 'some-jwt-token' }; }); - testHelper(test, { standardSubmit: false }); + hooks.afterEach(function () { + this.authenticateStub.restore(); + }); + + authFormTestHelper(test); jwtLoginTests(test); }); module('login workflow: oidc', function (hooks) { hooks.beforeEach(function () { - // for oidc login workflow only - this.windowStub = sinon.stub(window, 'open'); + this.oidcLoginSetup(); + this.loginData = { role: 'some-dev' }; }); hooks.afterEach(function () { + this.authenticateStub.restore(); this.windowStub.restore(); }); diff --git a/ui/tests/integration/components/auth/form/okta-test.js b/ui/tests/integration/components/auth/form/okta-test.js index f5961df013..49f33fe6cd 100644 --- a/ui/tests/integration/components/auth/form/okta-test.js +++ b/ui/tests/integration/components/auth/form/okta-test.js @@ -6,15 +6,16 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import hbs from 'htmlbars-inline-precompile'; -import { click, fillIn, render } from '@ember/test-helpers'; +import { click, render } from '@ember/test-helpers'; import sinon from 'sinon'; import { setupMirage } from 'ember-cli-mirage/test-support'; -import testHelper from './auth-form-test-helper'; -import { fillInLoginFields } from 'vault/tests/helpers/auth/auth-helpers'; +import { AUTH_METHOD_LOGIN_DATA, fillInLoginFields } from 'vault/tests/helpers/auth/auth-helpers'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import * as uuid from 'core/utils/uuid'; import { Response } from 'miragejs'; +import authFormTestHelper from './auth-form-test-helper'; +import { RESPONSE_STUBS } from 'vault/tests/helpers/auth/response-stubs'; module('Integration | Component | auth | form | okta', function (hooks) { setupRenderingTest(hooks); @@ -22,20 +23,32 @@ module('Integration | Component | auth | form | okta', function (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(); + // stub uuid so auth/okta/verify request can be stubbed using mirage this.nonce = '12345'; - sinon.stub(uuid, 'default').returns(this.nonce); - this.response = { data: { correct_answer: 68 } }; - this.server.get(`/auth/:path/verify/${this.nonce}`, () => this.response); + this.nonceStub = sinon.stub(uuid, 'default').returns(this.nonce); + this.verifyResponse = { data: { correct_answer: 68 } }; + this.server.get(`/auth/:path/verify/${this.nonce}`, () => this.verifyResponse); - this.expectedSubmit = { - default: { path: 'okta', username: 'matilda', password: 'password', nonce: this.nonce }, - custom: { path: 'custom-okta', username: 'matilda', password: 'password', nonce: this.nonce }, + // Auth request stub + const api = this.owner.lookup('service:api'); + this.authenticateStub = sinon.stub(api.auth, 'oktaLogin'); + this.authResponse = RESPONSE_STUBS.okta; + this.loginData = AUTH_METHOD_LOGIN_DATA.okta; + // Resolve response by default, specific tests override this as needed + this.authenticateStub.resolves(this.authResponse); + + this.assertSubmit = (assert, loginRequestArgs, loginData) => { + const [username, path, { nonce, password }] = loginRequestArgs; + // if path is included in loginData, a custom path was submitted + const expectedPath = loginData?.path || this.authType; + assert.strictEqual(path, expectedPath, 'it calls oktaLogin with expected path'); + assert.strictEqual(username, loginData.username, 'it calls oktaLogin with username'); + assert.strictEqual(password, loginData.password, 'it calls oktaLogin with password'); + assert.strictEqual(nonce, this.nonce, 'it calls oktaLogin with nonce'); }; this.renderComponent = ({ yieldBlock = false } = {}) => { @@ -65,55 +78,14 @@ module('Integration | Component | auth | form | okta', function (hooks) { hooks.afterEach(function () { this.authenticateStub.restore(); + this.nonceStub.restore(); }); - testHelper(test, { standardSubmit: false }); - - test('it submits form data with defaults', async function (assert) { - assert.expect(2); - this.server.get(`/auth/okta/verify/${this.nonce}`, () => { - // since the yielded input is name="path" we can also assert the okta/verify response is hit as expected - assert.true(true, 'it requests okta verify with default mount path'); - return this.response; - }); - - await this.renderComponent(); - await fillInLoginFields({ username: 'matilda', password: 'password' }); - - await click(GENERAL.submitButton); - const [actual] = this.authenticateStub.lastCall.args; - assert.propEqual( - actual.data, - this.expectedSubmit.default, - 'auth service "authenticate" method is called with form data' - ); - }); - - // not representative of real-world submit, that happens in acceptance tests. - // component here just yields <:advancedSettings> to test form submits data yielded data - test('it submits form data from yielded inputs', async function (assert) { - assert.expect(2); - this.server.get(`/auth/custom-okta/verify/${this.nonce}`, () => { - // since the yielded input is name="path" we can also assert the okta/verify response is hit as expected - assert.true(true, 'it requests okta verify with custom mount path'); - return this.response; - }); - - await this.renderComponent({ yieldBlock: true }); - await fillInLoginFields({ username: 'matilda', password: 'password' }); - await fillIn(GENERAL.inputByAttr('path'), `custom-${this.authType}`); - await click(GENERAL.submitButton); - const [actual] = this.authenticateStub.lastCall.args; - assert.propEqual( - actual.data, - this.expectedSubmit.custom, - 'auth service "authenticate" method is called with yielded form data' - ); - }); + authFormTestHelper(test); test('it displays okta number challenge answer', async function (assert) { await this.renderComponent(); - await fillInLoginFields({ username: 'matilda', password: 'password' }); + await fillInLoginFields(this.loginData); await click(GENERAL.submitButton); assert .dom('[data-test-okta-number-challenge]') @@ -124,7 +96,7 @@ module('Integration | Component | auth | form | okta', function (hooks) { test('it returns to login when "Back to login" is clicked', async function (assert) { await this.renderComponent(); - await fillInLoginFields({ username: 'matilda', password: 'password' }); + await fillInLoginFields(this.loginData); await click(GENERAL.submitButton); assert.dom('[data-test-okta-number-challenge]').exists(); await click(GENERAL.backButton); @@ -149,20 +121,20 @@ module('Integration | Component | auth | form | okta', function (hooks) { ); // okta/verify returns a 404 until the user interacts with okta via their configured MFA app. // to simulate this interaction we return data on the third request - which ends the polling. - const response = count < 2 ? new Response(404, {}, { errors: [] }) : this.response; + const response = count < 2 ? new Response(404) : this.verifyResponse; return response; }); await this.renderComponent(); - await fillInLoginFields({ username: 'matilda', password: 'password' }); + await fillInLoginFields(this.loginData); await click(GENERAL.submitButton); }); test('it renders error message when okta verify request errors', async function (assert) { - this.server.get(`/auth/okta/verify/${this.nonce}`, () => new Response(500)); + this.server.get(`/auth/okta/verify/${this.nonce}`, () => new Response(500, {}, { errors: ['oh no!!'] })); await this.renderComponent(); - await fillInLoginFields({ username: 'matilda', password: 'password' }); + await fillInLoginFields(this.loginData); await click(GENERAL.submitButton); - assert.dom(GENERAL.messageError).hasText('Error An error occurred, please try again'); + assert.dom(GENERAL.messageError).hasText('Error oh no!!'); }); }); diff --git a/ui/tests/integration/components/auth/form/saml-test.js b/ui/tests/integration/components/auth/form/saml-test.js index 2b2c527cd8..1f767308ef 100644 --- a/ui/tests/integration/components/auth/form/saml-test.js +++ b/ui/tests/integration/components/auth/form/saml-test.js @@ -12,6 +12,11 @@ import { setupMirage } from 'ember-cli-mirage/test-support'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { overrideResponse } from 'vault/tests/helpers/stubs'; import { windowStub } from 'vault/tests/helpers/oidc-window-stub'; +import * as uuid from 'core/utils/uuid'; +import { getErrorResponse } from 'vault/tests/helpers/api/error-response'; +import { RESPONSE_STUBS } from 'vault/tests/helpers/auth/response-stubs'; +import { AUTH_METHOD_LOGIN_DATA } from 'vault/tests/helpers/auth/auth-helpers'; +import authFormTestHelper from './auth-form-test-helper'; module('Integration | Component | auth | form | saml', function (hooks) { setupRenderingTest(hooks); @@ -21,27 +26,39 @@ module('Integration | Component | auth | form | saml', function (hooks) { this.authType = 'saml'; this.expectedFields = ['role']; - this.authenticateStub = sinon.stub(this.owner.lookup('service:auth'), 'authenticate'); - this.store = this.owner.lookup('service:store'); + this.tokenPollId = '4fe2ec01-1f56-b665-0ba2-09c7bca10ae8'; this.cluster = { id: 1 }; this.onError = sinon.spy(); this.onSuccess = sinon.spy(); + // Window stub this.windowStub = windowStub(); sinon.replaceGetter(window, 'screen', () => ({ height: 600, width: 500 })); - + // Auth request stub + const api = this.owner.lookup('service:api'); + this.authenticateStub = sinon.stub(api.auth, 'samlWriteToken'); + this.authResponse = RESPONSE_STUBS.saml['saml/token']; + this.loginData = AUTH_METHOD_LOGIN_DATA.saml; + // stub uuid so verifier can be asserted + this.verifier = uuid.default(); + sinon.stub(uuid, 'default').returns(this.verifier); // role request - this.server.put('/auth/saml/sso_service_url', () => { + this.server.post('/auth/:path/sso_service_url', () => { return { data: { sso_service_url: 'https://my-single-sign-on-url.com', - token_poll_id: '4fe2ec01-1f56-b665-0ba2-09c7bca10ae8', + token_poll_id: this.tokenPollId, }, }; }); - // polling request - this.server.put('/auth/saml/token', () => { - return { auth: { client_token: 'my_token' } }; - }); + + this.assertSubmit = (assert, loginRequestArgs, loginData) => { + const [path, { clientVerifier, tokenPollId }] = loginRequestArgs; + // if path is included in loginData, a custom path was submitted + const expectedPath = loginData?.path || this.authType; + assert.strictEqual(path, expectedPath, 'it calls samlWriteToken with expected path'); + assert.strictEqual(clientVerifier, this.verifier, 'it calls samlWriteToken with verifier'); + assert.strictEqual(tokenPollId, this.tokenPollId, 'it calls samlWriteToken with tokenPollId'); + }; this.renderComponent = ({ yieldBlock = false } = {}) => { if (yieldBlock) { @@ -73,6 +90,8 @@ module('Integration | Component | auth | form | saml', function (hooks) { this.authenticateStub.restore(); }); + authFormTestHelper(test); + test('it renders helper text', async function (assert) { await this.renderComponent(); const id = find(GENERAL.inputByAttr('role')).id; @@ -94,14 +113,14 @@ module('Integration | Component | auth | form | saml', function (hooks) { test('it requests sso_service_url and opens popup on submit if role is empty', async function (assert) { assert.expect(6); - this.server.put('/auth/saml/sso_service_url', (_, req) => { + this.server.post('/auth/saml/sso_service_url', (_, req) => { const { acs_url, role } = JSON.parse(req.requestBody); assert.strictEqual(acs_url, `${window.origin}/v1/auth/saml/callback`, 'it builds acs_url for payload'); assert.strictEqual(role, '', 'role has no value'); return { data: { sso_service_url: 'https://my-single-sign-on-url.com', - token_poll_id: '4fe2ec01-1f56-b665-0ba2-09c7bca10ae8', + token_poll_id: this.tokenPollId, }, }; }); @@ -126,14 +145,14 @@ module('Integration | Component | auth | form | saml', function (hooks) { test('it requests sso_service_url with inputted role and default path', async function (assert) { assert.expect(6); - this.server.put('/auth/saml/sso_service_url', (_, req) => { + this.server.post('/auth/saml/sso_service_url', (_, req) => { const { acs_url, role } = JSON.parse(req.requestBody); assert.strictEqual(acs_url, `${window.origin}/v1/auth/saml/callback`, 'it builds acs_url for payload'); assert.strictEqual(role, 'some-dev', 'payload contains role'); return { data: { sso_service_url: 'https://my-single-sign-on-url.com', - token_poll_id: '4fe2ec01-1f56-b665-0ba2-09c7bca10ae8', + token_poll_id: this.tokenPollId, }, }; }); @@ -157,113 +176,20 @@ module('Integration | Component | auth | form | saml', function (hooks) { ); }); - test('it requests sso_service_url with custom path', async function (assert) { - assert.expect(6); - const path = 'custom-path'; - this.server.put(`/auth/${path}/sso_service_url`, (_, req) => { - const { acs_url, role } = JSON.parse(req.requestBody); - assert.strictEqual( - acs_url, - `${window.origin}/v1/auth/${path}/callback`, - 'it builds acs_url for payload' - ); - assert.strictEqual(role, 'some-dev', 'payload contains role'); - return { - data: { - sso_service_url: 'https://my-single-sign-on-url.com', - token_poll_id: '4fe2ec01-1f56-b665-0ba2-09c7bca10ae8', - }, - }; - }); - - await this.renderComponent({ yieldBlock: true }); - await fillIn(GENERAL.inputByAttr('role'), 'some-dev'); - await fillIn(GENERAL.inputByAttr('path'), path); - await click(GENERAL.submitButton); - - const [sso_service_url, name, dimensions] = this.windowStub.lastCall.args; - assert.strictEqual( - sso_service_url, - 'https://my-single-sign-on-url.com', - 'it calls window opener with sso_service_url returned by role request' - ); - assert.strictEqual(sso_service_url, 'https://my-single-sign-on-url.com'); - assert.strictEqual(name, 'vaultSAMLWindow', 'it calls window opener with expected name'); - assert.strictEqual( - dimensions, - 'width=500,height=600,resizable,scrollbars=yes,top=0,left=0', - 'it calls window opener with expected dimensions' - ); - }); - - test('it polls token request', async function (assert) { - assert.expect(2); // auth/saml/token url should be requested twice - - let count = 0; - this.server.put('/auth/saml/token', () => { - count++; - const msg = - count === 1 - ? 'it makes initial request to token url' - : 'it re-requests token url if httpStatus was 401'; - assert.true(true, msg); - - if (count === 1) { - return overrideResponse(401); - } else { - return { auth: { client_token: 'my_token' } }; - } - }); + test('it re-requests samlWriteToken if 401 is returned', async function (assert) { + assert.expect(1); + this.authenticateStub.onFirstCall().rejects(getErrorResponse({}, 401)); + this.authenticateStub.onSecondCall().rejects(getErrorResponse({}, 401)); + // MAX_TRIES in the component is set to 3 for tests + this.authenticateStub.onThirdCall().resolves(this.authResponse); await this.renderComponent(); await click(GENERAL.submitButton); - }); - - test('it calls auth service with token request callback data', async function (assert) { - await this.renderComponent(); - await click(GENERAL.submitButton); - - const [actual] = this.authenticateStub.lastCall.args; - assert.propEqual( - actual.data, - { - token: 'my_token', - }, - 'auth service "authenticate" method is called token callback data' - ); - }); - - test('it calls onSuccess if auth service authentication is successful', async function (assert) { - const expectedResponse = { - namespace: '', - token: 'my_token', - isRoot: false, - }; - // stub happy response - this.authenticateStub.returns(expectedResponse); - - await this.renderComponent(); - await click(GENERAL.submitButton); - const [actualResponse, methodData] = this.onSuccess.lastCall.args; - assert.propEqual(actualResponse, expectedResponse, 'onSuccess is called with auth response'); - assert.strictEqual(methodData.path, undefined, 'onSuccess is called without path value'); - assert.strictEqual(methodData.selectedAuth, 'saml', 'onSuccess is called with selected auth type'); - }); - - test('it calls onError if auth service authentication fails', async function (assert) { - this.authenticateStub.throws('permission denied!!'); - await this.renderComponent(); - await click(GENERAL.submitButton); - const [actual] = this.onError.lastCall.args; - assert.strictEqual( - actual, - 'Authentication failed: Sinon-provided permission denied!!', - 'onError called with auth service failure' - ); + assert.strictEqual(this.authenticateStub.callCount, 3, 'it polls token request until request resolves'); }); test('it calls onError if sso_service_url request fails', async function (assert) { // role request - this.server.put('/auth/saml/sso_service_url', () => overrideResponse(403)); + this.server.post('/auth/saml/sso_service_url', () => overrideResponse(403)); await this.renderComponent(); await click(GENERAL.submitButton); const [actual] = this.onError.lastCall.args; @@ -275,14 +201,10 @@ module('Integration | Component | auth | form | saml', function (hooks) { }); test('it calls onError if polling token errors in status code that is NOT 401', async function (assert) { - this.server.put('/auth/saml/token', () => overrideResponse(500)); + this.authenticateStub.rejects(getErrorResponse({ errors: ['uh oh!'] }), 500); await this.renderComponent(); await click(GENERAL.submitButton); const [actual] = this.onError.lastCall.args; - assert.strictEqual( - actual, - 'Authentication failed: Ember Data Request PUT /v1/auth/saml/token returned a 500\nPayload (application/json)\n{}', - 'onError called with auth failure' - ); + assert.strictEqual(actual, 'Authentication failed: uh oh!', 'onError called with expected failure'); }); }); diff --git a/ui/tests/integration/components/auth/page/listing-visibility-test.js b/ui/tests/integration/components/auth/page/listing-visibility-test.js index f3ea3b932f..421a3e5123 100644 --- a/ui/tests/integration/components/auth/page/listing-visibility-test.js +++ b/ui/tests/integration/components/auth/page/listing-visibility-test.js @@ -12,6 +12,7 @@ import { setupMirage } from 'ember-cli-mirage/test-support'; import { setupRenderingTest } from 'ember-qunit'; import { setupTotpMfaResponse } from 'vault/tests/helpers/mfa/mfa-helpers'; import setupTestContext from './setup-test-context'; +import sinon from 'sinon'; module('Integration | Component | auth | page | listing visibility', function (hooks) { setupRenderingTest(hooks); @@ -20,6 +21,12 @@ module('Integration | Component | auth | page | listing visibility', function (h hooks.beforeEach(function () { setupTestContext(this); this.visibleAuthMounts = SYS_INTERNAL_UI_MOUNTS; + // extra setup for when the "oidc" is selected and the oidc-jwt component renders + this.routerStub = sinon.stub(this.owner.lookup('service:router'), 'urlFor').returns('123-example.com'); + }); + + hooks.afterEach(function () { + this.routerStub.restore(); }); test('it formats and renders tabs if visible auth mounts exist', async function (assert) { diff --git a/ui/tests/integration/components/auth/page/login-settings-test.js b/ui/tests/integration/components/auth/page/login-settings-test.js index d9b48042b8..03cb239d03 100644 --- a/ui/tests/integration/components/auth/page/login-settings-test.js +++ b/ui/tests/integration/components/auth/page/login-settings-test.js @@ -10,6 +10,7 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import { SYS_INTERNAL_UI_MOUNTS } from 'vault/tests/helpers/auth/auth-helpers'; import setupTestContext from './setup-test-context'; +import sinon from 'sinon'; /* Login settings are an enterprise only feature but the component is version agnostic (and subsequently so are these tests) @@ -28,6 +29,8 @@ module('Integration | Component | auth | page | ent login settings', function (h defaultType: 'oidc', backupTypes: ['userpass', 'ldap'], }; + // extra setup for when the "oidc" is selected and the oidc-jwt component renders + this.routerStub = sinon.stub(this.owner.lookup('service:router'), 'urlFor').returns('123-example.com'); this.assertPathInput = async (assert, { isHidden = false, value = '' } = {}) => { // the path input can render behind the "Advanced settings" toggle or as a hidden input. @@ -45,6 +48,10 @@ module('Integration | Component | auth | page | ent login settings', function (h }; }); + hooks.afterEach(function () { + this.routerStub.restore(); + }); + test('(default+backups): it initially renders default type and toggles to view backup methods', async function (assert) { await this.renderComponent(); assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders default method'); diff --git a/ui/tests/integration/components/auth/page/method-authentication-test.js b/ui/tests/integration/components/auth/page/method-authentication-test.js index 5dc738c30f..4798d8cd8c 100644 --- a/ui/tests/integration/components/auth/page/method-authentication-test.js +++ b/ui/tests/integration/components/auth/page/method-authentication-test.js @@ -5,7 +5,7 @@ import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import { click, fillIn, waitUntil } from '@ember/test-helpers'; -import { ERROR_JWT_LOGIN } from 'vault/components/auth/form/oidc-jwt'; +import { ERROR_JWT_LOGIN } from 'vault/utils/auth-form-helpers'; import { fillInLoginFields } from 'vault/tests/helpers/auth/auth-helpers'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { module, test } from 'qunit'; @@ -150,7 +150,7 @@ module('Integration | Component | auth | page | method authentication', function this.loginData = { role: 'some-dev' }; this.path = this.authType; this.response = RESPONSE_STUBS.oidc['oidc/callback']; - this.tokenName = 'vault-token☃1'; + this.tokenName = 'vault-oidc☃1'; // Requests are stubbed in the order they are hit this.stubRequests = () => { this.server.post(`/auth/${this.path}/oidc/auth_url`, () => { @@ -216,7 +216,7 @@ module('Integration | Component | auth | page | method authentication', function await fillIn(AUTH_FORM.selectMethod, this.authType); await fillInLoginFields({ token: 'mysupersecuretoken' }); await click(GENERAL.submitButton); - + await waitUntil(() => this.onAuthSuccess.calledOnce); const [actual] = this.onAuthSuccess.lastCall.args; const expected = { namespace: '', token: this.tokenName, isRoot: false }; assert.propEqual(actual, expected, `onAuthSuccess called with: ${JSON.stringify(actual)}`); @@ -265,16 +265,16 @@ module('Integration | Component | auth | page | method authentication', function this.path = this.authType; this.loginData = { role: 'some-dev' }; this.response = RESPONSE_STUBS.saml['saml/token']; - this.tokenName = 'vault-token☃1'; + this.tokenName = 'vault-saml☃1'; // Requests are stubbed in the order they are hit this.stubRequests = () => { - this.server.put(`/auth/${this.path}/sso_service_url`, () => ({ + this.server.post(`/auth/${this.path}/sso_service_url`, () => ({ data: { sso_service_url: 'test/fake/sso/route', token_poll_id: '1234', }, })); - this.server.put(`/auth/${this.path}/token`, () => this.response); + this.server.post(`/auth/${this.path}/token`, () => this.response); this.server.get(`/auth/token/lookup-self`, () => RESPONSE_STUBS.saml['lookup-self']); }; this.windowStub = windowStub(); diff --git a/ui/tests/integration/components/auth/page/mfa-test.js b/ui/tests/integration/components/auth/page/mfa-test.js index a7b6307901..e953c773a0 100644 --- a/ui/tests/integration/components/auth/page/mfa-test.js +++ b/ui/tests/integration/components/auth/page/mfa-test.js @@ -8,7 +8,7 @@ import { setupRenderingTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { constraintId, setupTotpMfaResponse } from 'vault/tests/helpers/mfa/mfa-helpers'; import setupTestContext from './setup-test-context'; -import { ERROR_JWT_LOGIN } from 'vault/components/auth/form/oidc-jwt'; +import { ERROR_JWT_LOGIN } from 'vault/utils/auth-form-helpers'; import { overrideResponse } from 'vault/tests/helpers/stubs'; import sinon from 'sinon'; import { triggerMessageEvent, windowStub } from 'vault/tests/helpers/oidc-window-stub'; @@ -144,6 +144,12 @@ module('Integration | Component | auth | page | mfa', function (hooks) { hooks.beforeEach(async function () { setupTestContext(this); + // additional setup for oidc-jwt component + this.routerStub = sinon.stub(this.owner.lookup('service:router'), 'urlFor').returns('123-example.com'); + }); + + hooks.afterEach(function () { + this.routerStub.restore(); }); module('github', function (hooks) { @@ -164,7 +170,6 @@ module('Integration | Component | auth | page | mfa', function (hooks) { this.authType = 'jwt'; this.loginData = { role: 'some-dev', jwt: 'jwttoken' }; this.path = this.authType; - this.routerStub = sinon.stub(this.owner.lookup('service:router'), 'urlFor').returns('123-example.com'); this.stubRequests = () => { this.server.post('/auth/:path/oidc/auth_url', () => @@ -174,10 +179,6 @@ module('Integration | Component | auth | page | mfa', function (hooks) { }; }); - hooks.afterEach(function () { - this.routerStub.restore(); - }); - mfaTests(test); }); @@ -194,13 +195,10 @@ module('Integration | Component | auth | page | mfa', function (hooks) { this.server.get(`/auth/${this.path}/oidc/callback`, () => setupTotpMfaResponse(this.path)); }; - // additional OIDC setup - this.routerStub = sinon.stub(this.owner.lookup('service:router'), 'urlFor').returns('123-example.com'); this.windowStub = windowStub(); }); hooks.afterEach(function () { - this.routerStub.restore(); this.windowStub.restore(); }); @@ -261,13 +259,13 @@ module('Integration | Component | auth | page | mfa', function (hooks) { this.loginData = { role: 'some-dev' }; // Requests are stubbed in the order they are hit this.stubRequests = () => { - this.server.put(`/auth/${this.path}/sso_service_url`, () => ({ + this.server.post(`/auth/${this.path}/sso_service_url`, () => ({ data: { sso_service_url: 'test/fake/sso/route', token_poll_id: '1234', }, })); - this.server.put(`/auth/${this.path}/token`, () => setupTotpMfaResponse(this.authType)); + this.server.post(`/auth/${this.path}/token`, () => setupTotpMfaResponse(this.authType)); }; this.windowStub = windowStub(); }); diff --git a/ui/tests/integration/components/auth/page/page-test.js b/ui/tests/integration/components/auth/page/page-test.js index 9f30aa7530..7f1ec4c15c 100644 --- a/ui/tests/integration/components/auth/page/page-test.js +++ b/ui/tests/integration/components/auth/page/page-test.js @@ -10,6 +10,7 @@ import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { CSP_ERROR } from 'vault/components/auth/page'; import setupTestContext from './setup-test-context'; +import sinon from 'sinon'; /* The AuthPage parents much of the authentication workflow and so it can be used to test lots of auth functionality. @@ -77,6 +78,8 @@ module('Integration | Component | auth | page', function (hooks) { }); test('it selects type in the dropdown if direct link just has type', async function (assert) { + const stub = sinon.stub(this.owner.lookup('service:router'), 'urlFor').returns('123-example.com'); + this.directLinkData = { type: 'oidc' }; await this.renderComponent(); assert.dom(AUTH_FORM.tabBtn('oidc')).doesNotExist('tab does not render'); @@ -89,5 +92,6 @@ module('Integration | Component | auth | page', function (hooks) { assert .dom(GENERAL.button('Sign in with other methods')) .doesNotExist('"Sign in with other methods" does not render'); + stub.restore(); }); }); diff --git a/ui/tests/integration/components/control-group-test.js b/ui/tests/integration/components/control-group-test.js index b5adc84412..db275da24e 100644 --- a/ui/tests/integration/components/control-group-test.js +++ b/ui/tests/integration/components/control-group-test.js @@ -44,7 +44,7 @@ module('Integration | Component | control group', function (hooks) { requestEntity: { id: 'requestor', name: 'entity8509' }, reload: sinon.stub(), }; - const authDataDefaults = { entity_id: 'requestor' }; + const authDataDefaults = { entityId: 'requestor' }; return { model: { @@ -115,7 +115,7 @@ module('Integration | Component | control group', function (hooks) { }); test('authorizer rendering', async function (assert) { - const { model, authData } = setup({ canAuthorize: true }, { entity_id: 'manager' }); + const { model, authData } = setup({ canAuthorize: true }, { entityId: 'manager' }); this.set('model', model); this.set('auth.authData', authData); @@ -138,7 +138,7 @@ module('Integration | Component | control group', function (hooks) { test('authorizer rendering:authorized', async function (assert) { const { model, authData } = setup( { canAuthorize: true, authorizations: [{ id: 'manager', name: 'manager' }] }, - { entity_id: 'manager' } + { entityId: 'manager' } ); this.set('model', model); @@ -153,7 +153,7 @@ module('Integration | Component | control group', function (hooks) { test('authorizer rendering: authorized and success', async function (assert) { const { model, authData } = setup( { approved: true, canAuthorize: true, authorizations: [{ id: 'manager', name: 'manager' }] }, - { entity_id: 'manager' } + { entityId: 'manager' } ); this.set('model', model); @@ -173,7 +173,7 @@ module('Integration | Component | control group', function (hooks) { test('third-party: success', async function (assert) { const { model, authData } = setup( { approved: true, canAuthorize: true, authorizations: [{ id: 'foo', name: 'foo' }] }, - { entity_id: 'manager' } + { entityId: 'manager' } ); this.set('model', model); diff --git a/ui/tests/integration/components/ldap/accounts-checked-out-test.js b/ui/tests/integration/components/ldap/accounts-checked-out-test.js index f48fc67598..978d9f8626 100644 --- a/ui/tests/integration/components/ldap/accounts-checked-out-test.js +++ b/ui/tests/integration/components/ldap/accounts-checked-out-test.js @@ -57,6 +57,10 @@ module('Integration | Component | ldap | AccountsCheckedOut', function (hooks) { }; }); + hooks.afterEach(function () { + this.authStub.restore(); + }); + test('it should render empty state when no accounts are checked out', async function (assert) { this.statuses = [ { account: 'foo', available: true, library: 'test-library' }, @@ -74,7 +78,7 @@ module('Integration | Component | ldap | AccountsCheckedOut', function (hooks) { }); test('it should filter accounts for root user', async function (assert) { - this.authStub.value({}); + this.authStub.value({ entityId: '' }); await this.renderComponent(); @@ -85,7 +89,7 @@ module('Integration | Component | ldap | AccountsCheckedOut', function (hooks) { }); test('it should filter accounts for non root user', async function (assert) { - this.authStub.value({ entity_id: '456' }); + this.authStub.value({ entityId: '456' }); await this.renderComponent(); @@ -107,7 +111,7 @@ module('Integration | Component | ldap | AccountsCheckedOut', function (hooks) { }); test('it should display details in table', async function (assert) { - this.authStub.value({ entity_id: '456' }); + this.authStub.value({ entityId: '456' }); await this.renderComponent(); diff --git a/ui/tests/integration/components/mfa-form-test.js b/ui/tests/integration/components/mfa-form-test.js index fc2b402f9b..391f8b6979 100644 --- a/ui/tests/integration/components/mfa-form-test.js +++ b/ui/tests/integration/components/mfa-form-test.js @@ -21,18 +21,18 @@ module('Integration | Component | mfa-form', function (hooks) { this.onCancel = sinon.spy(); this.clusterId = '123456'; this.mfaAuthData = { - backend: 'userpass', - data: { username: 'foo', password: 'bar' }, + authMethodType: 'userpass', + authMountPath: 'userpass', }; this.authService = this.owner.lookup('service:auth'); - // setup basic totp mfa_requirement + // setup basic totp mfaRequirement // override in tests that require different scenarios this.totpConstraint = this.server.create('mfa-method', { type: 'totp' }); - const { mfa_requirement } = this.authService._parseMfaResponse({ - mfa_request_id: 'test-mfa-id', - mfa_constraints: { test_mfa: { any: [this.totpConstraint] } }, + const mfaRequirement = this.authService.parseMfaResponse({ + mfaRequestId: 'test-mfa-id', + mfaConstraints: { test_mfa: { any: [this.totpConstraint] } }, }); - this.mfaAuthData.mfa_requirement = mfa_requirement; + this.mfaAuthData.mfaRequirement = mfaRequirement; }); test('it should render correct descriptions', async function (assert) { @@ -40,10 +40,10 @@ module('Integration | Component | mfa-form', function (hooks) { const oktaConstraint = this.server.create('mfa-method', { type: 'okta' }); const duoConstraint = this.server.create('mfa-method', { type: 'duo' }); - this.mfaAuthData.mfa_requirement = this.authService._parseMfaResponse({ - mfa_request_id: 'test-mfa-id', - mfa_constraints: { test_mfa_1: { any: [totpConstraint] } }, - }).mfa_requirement; + this.mfaAuthData.mfaRequirement = this.authService.parseMfaResponse({ + mfaRequestId: 'test-mfa-id', + mfaConstraints: { test_mfa_1: { any: [totpConstraint] } }, + }); await render( hbs` { const json = JSON.parse(req.requestBody); @@ -141,8 +141,8 @@ module('Integration | Component | mfa-form', function (hooks) { this.owner.lookup('service:auth').reopen({ // override to avoid authSuccess method since it expects an auth payload - async totpValidate({ mfa_requirement }) { - await this.clusterAdapter().mfaValidate(mfa_requirement); + async totpValidate({ mfaRequirement }) { + await this.clusterAdapter().mfaValidate(mfaRequirement); return 'test response'; }, }); @@ -186,7 +186,7 @@ module('Integration | Component | mfa-form', function (hooks) { ); assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled while loading'); assert.deepEqual(authData, expectedAuthData, 'Mfa auth data passed to validate method'); - await this.clusterAdapter().mfaValidate(authData.mfa_requirement); + await this.clusterAdapter().mfaValidate(authData.mfaRequirement); return 'test response'; }, }); diff --git a/ui/tests/integration/components/sidebar/user-menu-test.js b/ui/tests/integration/components/sidebar/user-menu-test.js index cb6d8c928a..eaad3abbf8 100644 --- a/ui/tests/integration/components/sidebar/user-menu-test.js +++ b/ui/tests/integration/components/sidebar/user-menu-test.js @@ -56,7 +56,7 @@ module('Integration | Component | sidebar-user-menu', function (hooks) { const revokeStub = sinon.stub(this.auth, 'revokeCurrentToken').resolves(); const date = new Date(); sinon.stub(this.auth, 'tokenExpirationDate').value(date.setDate(date.getDate() + 1)); - sinon.stub(this.auth, 'authData').value({ displayName: 'token', renewable: true, entity_id: 'foo' }); + sinon.stub(this.auth, 'authData').value({ displayName: 'token', renewable: true, entityId: 'foo' }); this.auth.set('allowExpiration', true); await render(hbs``); diff --git a/ui/tests/integration/services/auth-test.js b/ui/tests/integration/services/auth-test.js index 68f06b1689..e320d8eabc 100644 --- a/ui/tests/integration/services/auth-test.js +++ b/ui/tests/integration/services/auth-test.js @@ -7,7 +7,7 @@ import { run } from '@ember/runloop'; import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX } from 'vault/services/auth'; -import { setupMirage } from 'ember-cli-mirage/test-support'; +import { TOKEN_DATA } from 'vault/tests/helpers/auth/response-stubs'; function storage() { return { @@ -32,209 +32,34 @@ function storage() { } const ROOT_TOKEN_RESPONSE = { - request_id: 'e6674d7f-c96f-d51f-4463-cc95f0ad307e', - lease_id: '', - renewable: false, - lease_duration: 0, - data: { - accessor: '1dd25306-fdb9-0f43-8169-48ad702041b0', - creation_time: 1477671134, - creation_ttl: 0, - display_name: 'root', - explicit_max_ttl: 0, - id: '', - meta: null, - num_uses: 0, - orphan: true, - path: 'auth/token/root', - policies: ['root'], - ttl: 0, - }, - wrap_info: null, - warnings: null, - auth: null, -}; - -const TOKEN_NON_ROOT_RESPONSE = function () { - return { - request_id: '3ca32cd9-fd40-891d-02d5-ea23138e8642', - lease_id: '', - renewable: false, - lease_duration: 0, - data: { - accessor: '4ef32471-a94c-79ee-c290-aeba4d63bdc9', - creation_time: Math.floor(Date.now() / 1000), - creation_ttl: 2764800, - display_name: 'token', - explicit_max_ttl: 0, - id: '6d83e912-1b21-9df9-b51a-d201b709f3d5', - meta: null, - num_uses: 0, - orphan: false, - path: 'auth/token/create', - policies: ['default', 'userpass'], - renewable: true, - ttl: 2763327, - }, - wrap_info: null, - warnings: null, - auth: null, - }; -}; - -const USERPASS_RESPONSE = { - request_id: '7e5e8d3d-599e-6ef7-7570-f7057fc7c53d', - lease_id: '', - renewable: false, - lease_duration: 0, - data: null, - wrap_info: null, - warnings: null, - auth: { - client_token: '5313ff81-05cb-699f-29d1-b82b4e2906dc', - accessor: '5c5303e7-56d6-ea13-72df-d85411bd9a7d', - policies: ['default'], - metadata: { - username: 'matthew', - }, - lease_duration: 2764800, - renewable: true, - }, -}; - -const GITHUB_RESPONSE = { - request_id: '4913f9cd-a95f-d1f9-5746-4c3af4e15660', - lease_id: '', - renewable: false, - lease_duration: 0, - data: null, - wrap_info: null, - warnings: null, - auth: { - client_token: '0d39b535-598e-54d9-96e3-97493492a5f7', - accessor: 'd8cd894f-bedf-5ce3-f1b5-98f7c6cf8ab4', - policies: ['default'], - metadata: { - org: 'hashicorp', - username: 'meirish', - }, - lease_duration: 2764800, - renewable: true, - }, + ...TOKEN_DATA.token, + policies: ['root'], + ttl: 0, // root tokens have no expiration }; const BATCH_TOKEN_RESPONSE = { - request_id: '60bcef62-cc20-facf-8c0d-1418d05e9a42', - lease_id: '', + ...TOKEN_DATA.token, renewable: false, - lease_duration: 0, - data: { - accessor: '', - creation_time: 1718672331, - creation_ttl: 60, - display_name: 'token', - entity_id: '', - expire_time: '2024-06-17T17:59:51-07:00', - explicit_max_ttl: 0, - id: 'hvb.AAAAAQIUMVkhx9rnA', - issue_time: '2024-06-17T17:58:51-07:00', - meta: null, - num_uses: 0, - orphan: false, - path: 'auth/token/create', - policies: ['default'], - renewable: false, - ttl: 45, - type: 'batch', - }, - wrap_info: null, - warnings: null, - auth: null, - mount_type: 'token', + type: 'batch', }; const USERPASS_BATCH_TOKEN_RESPONSE = { - request_id: 'eb4c31a0-1745-5701-cce7-1668f5839dbf', - lease_id: '', + ...TOKEN_DATA.userpass, renewable: false, - lease_duration: 0, - data: null, - wrap_info: null, - warnings: null, - auth: { - client_token: 'hvb.AAAAAQJ0eGwP5e48S61kBRYmR', - accessor: '', - policies: ['default'], - token_policies: ['default'], - metadata: { - username: 'bob', - }, - lease_duration: 360, - renewable: false, - entity_id: 'b52f8591-02b6-828b-7f36-620afa539126', - token_type: 'batch', - orphan: true, - mfa_requirement: null, - num_uses: 0, - }, - mount_type: '', -}; - -const USERPASS_SERVICE_TOKEN_RESPONSE = { - request_id: 'e735ffad-f2fe-5d1b-14b8-90aeb9d05976', - lease_id: '', - renewable: false, - lease_duration: 0, - data: null, - wrap_info: null, - warnings: null, - auth: { - client_token: 'hvs.CAESINY6Qbs8rm', - accessor: '9bDizzlcIHiXwEOK5mZ6gjHI', - policies: ['default'], - token_policies: ['default'], - metadata: { - username: 'bob', - }, - lease_duration: 360, - renewable: true, - entity_id: 'd9a0cac8-779c-e766-716a-6f80552f0e81', - token_type: 'service', - orphan: true, - mfa_requirement: null, - num_uses: 0, - }, - mount_type: '', + tokenType: 'batch', }; module('Integration | Service | auth', function (hooks) { setupTest(hooks); - setupMirage(hooks); hooks.beforeEach(function () { this.owner.lookup('service:flash-messages').registerTypes(['warning']); this.store = storage(); this.memStore = storage(); - this.server.get('/auth/token/lookup-self', function (_, request) { - const resp = { ...ROOT_TOKEN_RESPONSE }; - resp.id = request.requestHeaders['X-Vault-Token']; - resp.data.id = request.requestHeaders['X-Vault-Token']; - return resp; - }); - this.server.post('/auth/userpass/login/:username', function (_, request) { - const { username } = request.params; - const resp = { ...USERPASS_RESPONSE }; - resp.auth.metadata.username = username; - return resp; - }); - - this.server.post('/auth/github/login', function () { - return { ...GITHUB_RESPONSE }; - }); }); test('token authentication: root token', function (assert) { - assert.expect(6); + assert.expect(5); const done = assert.async(); const self = this; const service = this.owner.factoryFor('service:auth').create({ @@ -252,25 +77,20 @@ module('Integration | Service | auth', function (hooks) { }); run(() => { service - .authenticate({ clusterId: '1', backend: 'token', data: { token: 'test' } }) + .authSuccess('1', ROOT_TOKEN_RESPONSE) .then(() => { const clusterTokenName = service.get('currentTokenName'); const clusterToken = service.get('currentToken'); const authData = service.get('authData'); const expectedTokenName = `${TOKEN_PREFIX}${ROOT_PREFIX}${TOKEN_SEPARATOR}1`; - assert.strictEqual(clusterToken, 'test', 'token is saved properly'); + assert.strictEqual(clusterToken, 'hvs.myvaultgeneratedtoken', 'token is saved properly'); assert.strictEqual( `${TOKEN_PREFIX}${ROOT_PREFIX}${TOKEN_SEPARATOR}1`, clusterTokenName, 'token name is saved properly' ); - assert.strictEqual(authData.backend.type, 'token', 'backend is saved properly'); - assert.strictEqual( - ROOT_TOKEN_RESPONSE.data.display_name, - authData.displayName, - 'displayName is saved properly' - ); + assert.strictEqual(authData.authMethodType, 'token', 'backend is saved properly'); assert.ok( this.memStore.keys().includes(expectedTokenName), 'root token is stored in the memory store' @@ -299,50 +119,40 @@ module('Integration | Service | auth', function (hooks) { }, environment: () => 'development', }); - await service.authenticate({ clusterId: '1', backend: 'token', data: { token: 'test' } }); + await service.authSuccess('1', ROOT_TOKEN_RESPONSE); const clusterTokenName = service.get('currentTokenName'); const clusterToken = service.get('currentToken'); const authData = service.get('authData'); const expectedTokenName = `${TOKEN_PREFIX}${ROOT_PREFIX}${TOKEN_SEPARATOR}1`; - assert.strictEqual(clusterToken, 'test', 'token is saved properly'); + assert.strictEqual(clusterToken, 'hvs.myvaultgeneratedtoken', 'token is saved properly'); assert.strictEqual( `${TOKEN_PREFIX}${ROOT_PREFIX}${TOKEN_SEPARATOR}1`, clusterTokenName, 'token name is saved properly' ); - assert.strictEqual(authData.backend.type, 'token', 'backend is saved properly'); - assert.strictEqual( - ROOT_TOKEN_RESPONSE.data.display_name, - authData.displayName, - 'displayName is saved properly' - ); + assert.strictEqual(authData.authMethodType, 'token', 'backend is saved properly'); assert.ok(this.store.keys().includes(expectedTokenName), 'root token is stored in the store'); assert.strictEqual(this.memStore.keys().length, 0, 'mem storage is empty'); }); test('github authentication', function (assert) { - assert.expect(6); + assert.expect(5); const done = assert.async(); const service = this.owner.factoryFor('service:auth').create({ storage: (type) => (type === 'memory' ? this.memStore : this.store), }); run(() => { - service.authenticate({ clusterId: '1', backend: 'github', data: { token: 'test' } }).then(() => { + service.authSuccess('1', TOKEN_DATA.github).then(() => { const clusterTokenName = service.get('currentTokenName'); const clusterToken = service.get('currentToken'); const authData = service.get('authData'); const expectedTokenName = `${TOKEN_PREFIX}github${TOKEN_SEPARATOR}1`; - assert.strictEqual(GITHUB_RESPONSE.auth.client_token, clusterToken, 'token is saved properly'); + assert.strictEqual(TOKEN_DATA.github.token, clusterToken, 'token is saved properly'); assert.strictEqual(expectedTokenName, clusterTokenName, 'token name is saved properly'); - assert.strictEqual(authData.backend.type, 'github', 'backend is saved properly'); - assert.strictEqual( - GITHUB_RESPONSE.auth.metadata.org + '/' + GITHUB_RESPONSE.auth.metadata.username, - authData.displayName, - 'displayName is saved properly' - ); + assert.strictEqual(authData.authMethodType, 'github', 'backend is saved properly'); assert.strictEqual(this.memStore.keys().length, 0, 'mem storage is empty'); assert.ok(this.store.keys().includes(expectedTokenName), 'normal storage contains the token'); done(); @@ -351,68 +161,45 @@ module('Integration | Service | auth', function (hooks) { }); test('userpass authentication', function (assert) { - assert.expect(4); + assert.expect(3); const done = assert.async(); const service = this.owner.factoryFor('service:auth').create({ storage: () => this.store }); run(() => { - service - .authenticate({ - clusterId: '1', - backend: 'userpass', - data: { username: USERPASS_RESPONSE.auth.metadata.username, password: 'passoword' }, - }) - .then(() => { - const clusterTokenName = service.get('currentTokenName'); - const clusterToken = service.get('currentToken'); - const authData = service.get('authData'); - - assert.strictEqual(USERPASS_RESPONSE.auth.client_token, clusterToken, 'token is saved properly'); - assert.strictEqual( - `${TOKEN_PREFIX}userpass${TOKEN_SEPARATOR}1`, - clusterTokenName, - 'token name is saved properly' - ); - assert.strictEqual(authData.backend.type, 'userpass', 'backend is saved properly'); - assert.strictEqual( - USERPASS_RESPONSE.auth.metadata.username, - authData.displayName, - 'displayName is saved properly' - ); - done(); - }); - }); - }); - - test('token auth expiry with non-root token', function (assert) { - assert.expect(5); - const tokenResp = TOKEN_NON_ROOT_RESPONSE(); - this.server.get('/auth/token/lookup-self', function (_, request) { - const resp = { ...tokenResp }; - resp.id = request.requestHeaders['X-Vault-Token']; - resp.data.id = request.requestHeaders['X-Vault-Token']; - return resp; - }); - - const done = assert.async(); - const service = this.owner.factoryFor('service:auth').create({ storage: () => this.store }); - run(() => { - service.authenticate({ clusterId: '1', backend: 'token', data: { token: 'test' } }).then(() => { + service.authSuccess('1', TOKEN_DATA.userpass).then(() => { const clusterTokenName = service.get('currentTokenName'); const clusterToken = service.get('currentToken'); const authData = service.get('authData'); - assert.strictEqual(clusterToken, 'test', 'token is saved properly'); + assert.strictEqual(TOKEN_DATA.userpass.token, clusterToken, 'token is saved properly'); + assert.strictEqual( + `${TOKEN_PREFIX}userpass${TOKEN_SEPARATOR}1`, + clusterTokenName, + 'token name is saved properly' + ); + assert.strictEqual(authData.authMethodType, 'userpass', 'backend is saved properly'); + done(); + }); + }); + }); + + test('token auth expiry with non-root token', function (assert) { + assert.expect(4); + + const done = assert.async(); + const service = this.owner.factoryFor('service:auth').create({ storage: () => this.store }); + run(() => { + service.authSuccess('1', TOKEN_DATA.token).then(() => { + const clusterTokenName = service.get('currentTokenName'); + const clusterToken = service.get('currentToken'); + const authData = service.get('authData'); + + assert.strictEqual(clusterToken, 'hvs.myvaultgeneratedtoken', 'token is saved properly'); assert.strictEqual( `${TOKEN_PREFIX}token${TOKEN_SEPARATOR}1`, clusterTokenName, 'token name is saved properly' ); - assert.strictEqual(authData.backend.type, 'token', 'backend is saved properly'); - assert.strictEqual( - authData.displayName, - tokenResp.data.display_name, - 'displayName is saved properly' - ); + assert.strictEqual(authData.authMethodType, 'token', 'backend is saved properly'); assert.false(service.get('tokenExpired'), 'token is not expired'); done(); }); @@ -421,61 +208,33 @@ module('Integration | Service | auth', function (hooks) { module('token types', function (hooks) { hooks.beforeEach(function () { - this.server.post('/auth/userpass/login/:username', (_, request) => { - const { username } = request.params; - const resp = - username === 'batch' - ? { ...USERPASS_BATCH_TOKEN_RESPONSE } - : { ...USERPASS_SERVICE_TOKEN_RESPONSE }; - resp.auth.metadata.username = username; - return resp; - }); - this.service = this.owner.factoryFor('service:auth').create({ storage: () => this.store }); }); - module('batch tokens', function () { - test('batch tokens generated by token auth method', async function (assert) { - this.server.get('/auth/token/lookup-self', () => { - return { ...BATCH_TOKEN_RESPONSE }; - }); + test('batch tokens generated by token auth method', async function (assert) { + await this.service.authSuccess('1', BATCH_TOKEN_RESPONSE); - await this.service.authenticate({ - clusterId: '1', - backend: 'token', - data: { token: 'test' }, - }); + // exact expiration time is calculated in unit tests + assert.notEqual( + this.service.tokenExpirationDate, + undefined, + 'expiration is calculated for batch tokens' + ); + }); - // exact expiration time is calculated in unit tests - assert.notEqual( - this.service.tokenExpirationDate, - undefined, - 'expiration is calculated for batch tokens' - ); - }); + test('batch tokens generated by auth methods', async function (assert) { + await this.service.authSuccess('1', USERPASS_BATCH_TOKEN_RESPONSE); - test('batch tokens generated by auth methods', async function (assert) { - await this.service.authenticate({ - clusterId: '1', - backend: 'userpass', - data: { username: 'batch', password: 'password' }, - }); - - // exact expiration time is calculated in unit tests - assert.notEqual( - this.service.tokenExpirationDate, - undefined, - 'expiration is calculated for batch tokens' - ); - }); + // exact expiration time is calculated in unit tests + assert.notEqual( + this.service.tokenExpirationDate, + undefined, + 'expiration is calculated for batch tokens' + ); }); test('service token authentication', async function (assert) { - await this.service.authenticate({ - clusterId: '1', - backend: 'userpass', - data: { username: 'service', password: 'password' }, - }); + await this.service.authSuccess('1', TOKEN_DATA.userpass); // exact expiration time is calculated in unit tests assert.notEqual( diff --git a/ui/tests/unit/adapters/cluster-test.js b/ui/tests/unit/adapters/cluster-test.js index 9998a5545e..48c3c82b0b 100644 --- a/ui/tests/unit/adapters/cluster-test.js +++ b/ui/tests/unit/adapters/cluster-test.js @@ -37,7 +37,7 @@ module('Unit | Adapter | cluster', function (hooks) { assert.strictEqual(url, '/v1/sys/seal-status', 'health url OK'); assert.strictEqual(method, 'GET', 'seal-status method OK'); - let data = { someData: 1 }; + const data = { someData: 1 }; adapter.unseal(data); assert.strictEqual(url, '/v1/sys/unseal', 'unseal url OK'); assert.strictEqual(method, 'PUT', 'unseal method OK'); @@ -47,100 +47,6 @@ module('Unit | Adapter | cluster', function (hooks) { assert.strictEqual(url, '/v1/sys/init', 'init url OK'); assert.strictEqual(method, 'PUT', 'init method OK'); assert.deepEqual({ data, unauthenticated: true }, options, 'init options OK'); - - data = { token: 'token', password: 'password', username: 'username' }; - - adapter.authenticate({ backend: 'token', data }); - assert.strictEqual(url, '/v1/auth/token/lookup-self', 'auth:token url OK'); - assert.strictEqual(method, 'GET', 'auth:token method OK'); - assert.deepEqual( - { headers: { 'X-Vault-Token': 'token' }, unauthenticated: true }, - options, - 'auth:token options OK' - ); - - adapter.authenticate({ backend: 'github', data }); - assert.strictEqual(url, '/v1/auth/github/login', 'auth:github url OK'); - assert.strictEqual(method, 'POST', 'auth:github method OK'); - assert.deepEqual( - { data: { password: 'password', token: 'token' }, unauthenticated: true }, - options, - 'auth:github options OK' - ); - - data = { jwt: 'token', role: 'test' }; - adapter.authenticate({ backend: 'jwt', data }); - assert.strictEqual(url, '/v1/auth/jwt/login', 'auth:jwt url OK'); - assert.strictEqual(method, 'POST', 'auth:jwt method OK'); - assert.deepEqual( - { data: { jwt: 'token', role: 'test' }, unauthenticated: true }, - options, - 'auth:jwt options OK' - ); - - data = { jwt: 'token', role: 'test', path: 'oidc' }; - adapter.authenticate({ backend: 'jwt', data }); - assert.strictEqual(url, '/v1/auth/oidc/login', 'auth:jwt custom mount path, url OK'); - - data = { token: 'token', password: 'password', username: 'username', path: 'path' }; - - adapter.authenticate({ backend: 'token', data }); - assert.strictEqual(url, '/v1/auth/token/lookup-self', 'auth:token url with path OK'); - - adapter.authenticate({ backend: 'github', data }); - assert.strictEqual(url, '/v1/auth/path/login', 'auth:github with path url OK'); - - data = { password: 'password', username: 'username' }; - - adapter.authenticate({ backend: 'userpass', data }); - assert.strictEqual(url, '/v1/auth/userpass/login/username', 'auth:userpass url OK'); - assert.strictEqual(method, 'POST', 'auth:userpass method OK'); - assert.deepEqual( - { data: { password: 'password' }, unauthenticated: true }, - options, - 'auth:userpass options OK' - ); - - adapter.authenticate({ backend: 'radius', data }); - assert.strictEqual(url, '/v1/auth/radius/login/username', 'auth:RADIUS url OK'); - assert.strictEqual(method, 'POST', 'auth:RADIUS method OK'); - assert.deepEqual( - { data: { password: 'password' }, unauthenticated: true }, - options, - 'auth:RADIUS options OK' - ); - - adapter.authenticate({ backend: 'LDAP', data }); - assert.strictEqual(url, '/v1/auth/ldap/login/username', 'ldap:userpass url OK'); - assert.strictEqual(method, 'POST', 'ldap:userpass method OK'); - assert.deepEqual( - { data: { password: 'password' }, unauthenticated: true }, - options, - 'ldap:userpass options OK' - ); - - data = { password: 'password', username: 'username', nonce: 'uuid' }; - adapter.authenticate({ backend: 'okta', data }); - assert.strictEqual(url, '/v1/auth/okta/login/username', 'okta:userpass url OK'); - assert.strictEqual(method, 'POST', 'ldap:userpass method OK'); - assert.deepEqual( - { data: { password: 'password', nonce: 'uuid' }, unauthenticated: true }, - options, - 'okta:userpass options OK' - ); - - // use a custom mount path - data = { password: 'password', username: 'username', path: 'path' }; - - adapter.authenticate({ backend: 'userpass', data }); - assert.strictEqual(url, '/v1/auth/path/login/username', 'auth:userpass with path url OK'); - - adapter.authenticate({ backend: 'LDAP', data }); - assert.strictEqual(url, '/v1/auth/path/login/username', 'auth:LDAP with path url OK'); - - data = { password: 'password', username: 'username', path: 'path', nonce: 'uuid' }; - adapter.authenticate({ backend: 'Okta', data }); - assert.strictEqual(url, '/v1/auth/path/login/username', 'auth:Okta with path url OK'); }); test('cluster replication api urls', function (assert) { diff --git a/ui/tests/unit/models/role-jwt-test.js b/ui/tests/unit/models/role-jwt-test.js deleted file mode 100644 index 6f2e1775df..0000000000 --- a/ui/tests/unit/models/role-jwt-test.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -/* eslint qunit/no-conditional-assertions: "warn" */ -import { module, test } from 'qunit'; -import { setupTest } from 'ember-qunit'; -import { DOMAIN_STRINGS, PROVIDER_WITH_LOGO } from 'vault/models/role-jwt'; - -module('Unit | Model | role-jwt', function (hooks) { - setupTest(hooks); - - test('it exists', function (assert) { - const model = this.owner.lookup('service:store').createRecord('role-jwt'); - assert.ok(!!model); - assert.strictEqual(model.providerName, null, 'no providerName'); - assert.strictEqual(model.providerIcon, null, 'no providerIcon'); - }); - - test('it computes providerName when known provider url match fails', function (assert) { - const model = this.owner.lookup('service:store').createRecord('role-jwt', { - authUrl: 'http://example.com', - }); - - assert.strictEqual(model.providerName, null, 'no providerName'); - assert.strictEqual(model.providerIcon, null, 'no providerIcon'); - }); - - test('it provides a providerName for listed known providers', function (assert) { - assert.expect(14); - Object.keys(DOMAIN_STRINGS).forEach((domain) => { - const model = this.owner.lookup('service:store').createRecord('role-jwt', { - authUrl: `http://provider-${domain}`, - }); - - const expectedName = DOMAIN_STRINGS[domain]; - assert.strictEqual(model.providerName, expectedName, `computes providerName: ${expectedName}`); - let expectedIcon = null; - if (PROVIDER_WITH_LOGO[expectedName]) { - expectedIcon = expectedName.toLowerCase(); - } - assert.strictEqual(model.providerIcon, expectedIcon, `computes providerIcon: ${domain}`); - }); - }); - - test('it does not return provider unless domain matches completely', function (assert) { - assert.expect(2); - const model = this.owner.lookup('service:store').createRecord('role-jwt', { - authUrl: `http://custom-auth0-provider.com`, - }); - assert.strictEqual(model.providerName, null, `no providerName for custom URL`); - assert.strictEqual(model.providerIcon, null, 'no providerIcon for custom URL'); - }); -}); diff --git a/ui/tests/unit/services/auth-test.js b/ui/tests/unit/services/auth-test.js index a91c8c8277..1e4872e18c 100644 --- a/ui/tests/unit/services/auth-test.js +++ b/ui/tests/unit/services/auth-test.js @@ -14,39 +14,46 @@ module('Unit | Service | auth', function (hooks) { }); module('#calculateExpiration', function () { - [ - ['#calculateExpiration w/ttl', { ttl: 30 }, 30], - ['#calculateExpiration w/lease_duration', { lease_duration: 15 }, 15], - ].forEach(([testName, response, ttlValue]) => { - test(testName, function (assert) { - const now = Date.now(); + test('with a non-zero ttl value', function (assert) { + const now = Date.now(); + const ttl = 30; + const expireTime = null; + const calculatedExpiry = this.service.calculateExpiration({ now, ttl, expireTime }); - const resp = this.service.calculateExpiration(response, now); - - assert.strictEqual(resp.ttl, ttlValue, 'returns the ttl'); - assert.strictEqual( - resp.tokenExpirationEpoch, - now + ttlValue * 1e3, - 'calculates expiration from ttl as epoch timestamp' - ); - }); + assert.strictEqual(calculatedExpiry.ttl, 30, 'returns the ttl'); + assert.strictEqual( + calculatedExpiry.tokenExpirationEpoch, + now + ttl * 1e3, + 'calculates expiration from ttl as epoch timestamp' + ); }); - test('#calculateExpiration w/ expire_time', function (assert) { + test('with a zero ttl value', function (assert) { + const now = Date.now(); + const ttl = 0; + const expireTime = null; + const calculatedExpiry = this.service.calculateExpiration({ now, ttl, expireTime }); + + assert.strictEqual(calculatedExpiry.ttl, null, 'returns `null` for the ttl'); + assert.strictEqual(calculatedExpiry.tokenExpirationEpoch, null, 'tokenExpirationEpoch is null'); + }); + + test('#calculateExpiration w/ expireTime', function (assert) { const now = Date.now(); const expirationString = '2024-06-13T09:10:21-07:00'; const expectedExpirationEpoch = new Date(expirationString).getTime(); - const resp = this.service.calculateExpiration( - { ttl: 30, expire_time: '2024-06-13T09:10:21-07:00' }, - now - ); + const calculatedExpiry = this.service.calculateExpiration({ + now, + ttl: 30, + expireTime: '2024-06-13T09:10:21-07:00', + }); - assert.strictEqual(resp.ttl, 30, 'returns ttl'); + assert.strictEqual(calculatedExpiry.ttl, 30, 'returns ttl'); assert.strictEqual( - resp.tokenExpirationEpoch, + calculatedExpiry.tokenExpirationEpoch, expectedExpirationEpoch, - 'calculates expiration from expire_time' + 'calculates expiration from expireTime' ); }); }); @@ -54,10 +61,9 @@ module('Unit | Service | auth', function (hooks) { module('#setExpirationSettings', function () { test('#setExpirationSettings for a renewable token', function (assert) { const now = Date.now(); - const ttl = 30; - const response = { ttl, renewable: true }; + const renewable = true; - this.service.setExpirationSettings(response, now); + this.service.setExpirationSettings(renewable, now); assert.false(this.service.allowExpiration, 'sets allowExpiration to false'); assert.strictEqual(this.service.expirationCalcTS, now, 'sets expirationCalcTS to now'); @@ -65,10 +71,9 @@ module('Unit | Service | auth', function (hooks) { test('#setExpirationSettings for a non-renewable token', function (assert) { const now = Date.now(); - const ttl = 30; - const response = { ttl, renewable: false }; + const renewable = false; - this.service.setExpirationSettings(response, now); + this.service.setExpirationSettings(renewable, now); assert.true(this.service.allowExpiration, 'sets allowExpiration to true'); assert.strictEqual(this.service.expirationCalcTS, null, 'keeps expirationCalcTS as null'); diff --git a/ui/types/vault/auth/form.d.ts b/ui/types/vault/auth/form.d.ts index ec3dd441ed..328d76dc59 100644 --- a/ui/types/vault/auth/form.d.ts +++ b/ui/types/vault/auth/form.d.ts @@ -33,3 +33,21 @@ export interface VisibleAuthMounts { options: null | {}; }; } + +// Auth data returned from each method's login response is +// normalized so each method's information maps to the same key names +export interface NormalizedAuthData extends NormalizeAuthResponseKeys { + authMethodType: string; + expireTime?: string; + namespacePath?: string; + mfaRequirement?: MfaRequirementApiResponse | null; +} + +// This info is not returned within a consistent key name so each auth method is responsible for +// normalizing it +export interface NormalizeAuthResponseKeys { + authMountPath: string; // manually added because not a part of the auth response + displayName?: string; // if not from the "display_name" key, then this may be set from either "meta" or "metadata" + token: string; // was "client_token" or "id" key for some methods + ttl: number; // was "lease_duration" key for some methods +} diff --git a/ui/types/vault/auth/methods.d.ts b/ui/types/vault/auth/methods.d.ts index 227afa5790..ddc0c0488e 100644 --- a/ui/types/vault/auth/methods.d.ts +++ b/ui/types/vault/auth/methods.d.ts @@ -4,36 +4,83 @@ */ import type { ApiResponse, WrapInfo } from 'vault/auth/api'; -import type { POSSIBLE_FIELDS } from 'vault/utils/supported-login-methods'; +import type { POSSIBLE_FIELDS } from 'vault/utils/auth-form-helpers'; import type { MfaRequirementApiResponse } from './mfa'; -// ApiResponse has top-level of response with request_id, etc. -// This interface defines the "auth" key -export interface AuthResponseData extends ApiResponse { - auth: { - accessor: string; - policies: string[] | null; - metadata: Record | null; - lease_duration: number; - renewable: boolean; - entity_id: string; - token_type: string; - orphan: boolean; - mfa_requirement: MfaRequirementApiResponse | null; - }; +// ApiResponse includes top-level fields like request_id, etc. +// Some auth methods return login data under the "auth" key, +// while token exchange flows return it under the "data" key. +// The structure of the returned data varies slightly between these cases. +interface SharedAuthResponseData { + accessor: string; + entityId: string; + policies: string[]; + renewable: boolean; +} + +// AuthResponseAuthKey defines login data inside the "auth" key +interface AuthResponseAuthKey extends SharedAuthResponseData { + clientToken: string; + leaseDuration: number; + metadata: Record; + mfaRequirement: MfaRequirementApiResponse | null; + tokenType: 'service' | 'batch'; +} + +// AuthResponseDataKey defines login data inside the "data" key +interface AuthResponseDataKey extends SharedAuthResponseData { + displayName: string; + expireTime: string; + id: string; // this is the Vault issued token (the equivalent of the clientToken for responses with the "auth" key) + meta: Record | null; + namespacePath?: string; + ttl: number; + type: 'service' | 'batch'; // token type } // METHOD SPECIFIC RESPONSES +export interface GithubLoginApiResponse extends ApiResponse { + auth: AuthResponseAuthKey & { + metadata: { org: string; username: string }; + }; +} + +export interface JwtOidcLoginApiResponse extends ApiResponse { + auth: AuthResponseAuthKey; +} + export interface OidcApiResponse extends ApiResponse { auth: AuthResponseData['auth'] & { client_token: string; }; } -export interface SamlApiResponse extends ApiResponse { - auth: AuthResponseData['auth'] & { - client_token: string; - token_policies: string[]; - num_uses: number; +export interface JwtOidcAuthUrlResponse extends ApiResponse { + data: { authUrl: string }; +} + +export interface OktaVerifyApiResponse extends ApiResponse { + data: { correctAnswer: number }; +} + +export interface SamlLoginApiResponse extends ApiResponse { + auth: AuthResponseAuthKey; +} + +export interface SamlSsoServiceUrlApiResponse extends ApiResponse { + data: { + ssoServiceUrl: string; + tokenPollId: string; + }; +} + +export interface TokenLoginApiResponse extends ApiResponse { + data: AuthResponseDataKey; +} + +// auth types: ldap, okta, radius, userpass +export interface UsernameLoginResponse extends ApiResponse { + auth: AuthResponseAuthKey & { + metadata: { username: string }; }; } diff --git a/ui/types/vault/auth/mfa.d.ts b/ui/types/vault/auth/mfa.d.ts index 815e21eeb0..f2996bb911 100644 --- a/ui/types/vault/auth/mfa.d.ts +++ b/ui/types/vault/auth/mfa.d.ts @@ -21,9 +21,9 @@ interface MfaConstraints { } export interface ParsedMfaRequirement { - mfa_requirement: { - mfa_request_id: string; - mfa_constraints: MfaConstraint[]; + mfaRequirement: { + mfaRequestId: string; + mfaConstraints: MfaConstraint[]; }; } diff --git a/ui/types/vault/services/auth.d.ts b/ui/types/vault/services/auth.d.ts index dee261b39c..df27f5aec7 100644 --- a/ui/types/vault/services/auth.d.ts +++ b/ui/types/vault/services/auth.d.ts @@ -8,17 +8,18 @@ import Service from '@ember/service'; import type { MfaRequirementApiResponse, ParsedMfaRequirement } from 'vault/auth/mfa'; +import type { NormalizedAuthData } from 'vault/auth/form'; -export interface AuthData { +interface AuthData { userRootNamespace: string; token: string; policies: string[]; renewable: boolean; - entity_id: string; + entityId: string; displayName?: string; } -export interface AuthResponse { +export interface AuthSuccessResponse { namespace: string; token: string; // the name of the token in local storage, not the actual token isRoot: boolean; @@ -32,13 +33,7 @@ export default class AuthService extends Service { currentToken: string; mfaErrors: null | Errors[]; setLastFetch: (time: number) => void; - handleError: (error: Error | string) => string | error[] | [error]; - authenticate(params: { - clusterId: string; - backend: string; - data: object; - selectedAuth: string; - }): Promise; + authSuccess(clusterId: string, authData: NormalizedAuthData): Promise; ajax: ( url: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE', @@ -49,5 +44,5 @@ export default class AuthService extends Service { } ) => Promise; getAuthType(): string | undefined; - _parseMfaResponse(mfaResponse: MfaRequirementApiResponse): ParsedMfaRequirement; + parseMfaResponse(mfaResponse: MfaRequirementApiResponse): ParsedMfaRequirement; }