/** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: BUSL-1.1 */ import Component from '@glimmer/component'; import { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import 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'; /** * @module AuthPage * Auth::Page renders the Auth::FormTemplate or MFA component if an mfa validation is returned from the auth request. * It receives configuration settings from the route's model hook and determines the possible form states passed to Auth::FormTemplate. * The model hook refreshes when the namespace input updates and re-requests `sys/internal/ui/mounts` and the login settings endpoint (enterprise only). * * ⚙️ CONFIGURATION OVERVIEW: * The login form either renders a `dropdown` or `tabs` depending on specific configuration combinations. * In some scenarios, the component supports toggling between a default view and an alternate view. * * 📋 Dropdown (default view) * ▸ All supported auth methods show in a dropdown. * ▸ No alternate view. * * 🗂️ Visible mount tabs * ▸ Groups visible mounts (`listing_visibility="unauth"`) by type and displays as tabs. * ▸ Alternate view: full dropdown of all methods. * * 🔗 Direct link (via `?with=` query param) * ▸ If the param references a visible mount, that method renders by default and the mount path is assumed. * ↳ Alternate view: full dropdown. * ▸ If the param references a method type (legacy behavior), the method is preselected in the dropdown or its tab is selected. * ↳ Alternate view: if other methods have visible mounts, the form can toggle between tabs and dropdown. The initial view depends on whether the chosen type is a tab. * * 🏢 Login settings * enterprise only * * ▸ A namespace can define a default method and/or preferred methods (i.e. "backups") and enable child namespaces to inherit these preferences. * ✎ Both set: * ▸ Default method shown initially. * ▸ Alternate view: preferred methods in tab layout. * ✎ Only one set: * ▸ No alternate view. * * 🛠️ Advanced settings toggle reveals the custom path input: * 🚫 No visible mounts: * ▸ UI defaults to method type as path. * ▸ "Advanced settings" shows a path input. * 1️⃣ One visible mount: * ▸ Path is assumed and hidden. * 🔀 Multiple visible mounts: * ▸ Path dropdown is shown. * * @example * * * @param {object} cluster - the ember data cluster model. contains information such as cluster id, name and boolean for if the cluster is in standby * @param {object} directLinkData - mount data built from the "with" query param. If param is a mount path and maps to a visible mount, the login form defaults to this mount. Otherwise the form preselects the passed auth type. * @param {object} loginSettings - * enterprise only * login settings configured for the namespace. If set, specifies a default auth method type and/or backup method types * @param {string} namespaceQueryParam - namespace to login with, updated by typing in to the namespace input * @param {string} oidcProviderQueryParam - oidc provider query param, set in url as "?o=someprovider" * @param {function} onAuthSuccess - callback task in controller that receives the auth response (after MFA, if enabled) when login is successful * @param {function} onNamespaceUpdate - callback task that passes user input to the controller to update the login namespace in the url query params * @param {object} visibleAuthMounts - response from unauthenticated request to sys/internal/ui/mounts which returns mount paths tuned with `listing_visibility="unauth"`. keys are the mount path, values are mount data such as "type" or "description," if it exists * */ export const CSP_ERROR = "This is a standby Vault node but can't communicate with the active node via request forwarding. Sign in at the active node to use the Vault UI."; interface Args { cluster: ClusterModel; directLinkData: { type: string; path?: string } | null; // if "path" key is present then mount data is visible loginSettings: { defaultType: string; backupTypes: string[] | null }; // enterprise only onAuthSuccess: CallableFunction; visibleAuthMounts: UnauthMountsResponse; } interface MfaAuthData { mfaRequirement: object; authMethodType: string; authMountPath: string; } enum FormView { DROPDOWN = 'dropdown', TABS = 'tabs', } export default class AuthPage extends Component { @service declare readonly auth: AuthService; @service('csp-event') declare readonly csp: CspEventService; @tracked canceledMfaAuth = ''; @tracked mfaAuthData: MfaAuthData | null = null; @tracked mfaErrors = ''; get cspError() { const isStandby = this.args.cluster.standby; const hasConnectionViolations = this.csp.connectionViolations.length; return isStandby && hasConnectionViolations ? CSP_ERROR : ''; } get visibleMountsByType() { const visibleAuthMounts = this.args.visibleAuthMounts; if (visibleAuthMounts) { const authMounts = visibleAuthMounts; return Object.entries(authMounts).reduce((obj, [path, mountData]) => { const { type } = mountData; obj[type] ??= []; // if an array doesn't already exist for that type, create it obj[type].push({ path, ...mountData }); return obj; }, {} as UnauthMountsByType); } return null; } get visibleMountTypes(): string[] { return Object.keys(this.visibleMountsByType || {}); } // AUTH FORM STATE GETTERS get formViews() { const { directLinkData, loginSettings } = this.args; if (directLinkData) { return this.directLinkViews; } if (loginSettings) { return this.loginSettingsViews; } if (this.visibleMountsByType) { return this.visibleMountViews; } // If none of the above, the UI renders the standard dropdown with no alternate views return this.standardDropdownView; } get initialAuthType(): string { // First, prioritize canceledMfaAuth since it's set by user interaction. // Next, "type" from direct link since the URL query param overrides any login settings. // Then, first tab which is either the default method, first backup method or first visible mount tab. // Finally, fallback to the most recently used auth method in localStorage. // Token is the default otherwise. const directLinkType = this.args.directLinkData?.type; const firstTab = Object.keys(this.formViews.defaultView?.tabData || {})[0]; return this.canceledMfaAuth || directLinkType || firstTab || this.auth.getAuthType() || 'token'; } get directLinkViews() { const { directLinkData } = this.args; // If "path" key exists we know the "with" query param references a mount with listing_visibility="unauth" // Treat it as a preferred method and hide all other tabs. if (directLinkData?.path) { const tabData = this.filterVisibleMountsByType([directLinkData.type]); const defaultView = this.constructViews(FormView.TABS, tabData); const alternateView = this.constructViews(FormView.DROPDOWN, null); return { defaultView, alternateView }; } // Otherwise, directLinkData just has a "type" key. // Render either visibleMountViews or dropdown with that type preselected return this.visibleMountsByType ? this.visibleMountViews : this.standardDropdownView; } get standardDropdownView() { return { defaultView: this.constructViews(FormView.DROPDOWN, null), alternateView: null, }; } get loginSettingsViews() { const { loginSettings } = this.args; const defaultType = loginSettings?.defaultType; const backupTypes = loginSettings?.backupTypes; // If a default type is not set, render backup methods as the initial view const preferredTypes = defaultType ? [defaultType] : backupTypes; let defaultView; if (preferredTypes) { const tabData = this.filterVisibleMountsByType(preferredTypes); defaultView = this.constructViews(FormView.TABS, tabData); } // Both default and backups must be set for an alternate view to exist let alternateView = null; if (defaultType && backupTypes) { const tabData = this.filterVisibleMountsByType(backupTypes); alternateView = this.constructViews(FormView.TABS, tabData); } return { defaultView, alternateView }; } get visibleMountViews() { const defaultView = this.constructViews(FormView.TABS, this.visibleMountsByType); const alternateView = this.constructViews(FormView.DROPDOWN, null); return { defaultView, alternateView }; } get initialFormState() { const { defaultView, alternateView } = this.formViews; // Helper to check if passed tabs include initialAuthType to render const hasTab = (tabs: object) => Object.keys(tabs).includes(this.initialAuthType); const authIsNotDefaultTab = !hasTab(defaultView?.tabData || {}); const hasAlternateView = !!alternateView; const authIsAlternateTab = hasTab(alternateView?.tabData || {}); // In rare cases, pre-toggle the form to the fallback dropdown or backup tabs, if an alternate view exists. // This is only possible in a couple scenarios: // - The default view renders tabs for visible mounts and the "with" query param references a type that is not a tab. // - Auth type is preset from canceled MFA verification or local storage and it is not in the default (initial) view const showAlternate = authIsNotDefaultTab && (hasAlternateView || authIsAlternateTab); return { initialAuthType: this.initialAuthType, showAlternate }; } // ACTIONS @action async onAuthResponse(normalizedAuthData: NormalizedAuthData) { const hasMfa = 'mfaRequirement' in normalizedAuthData ? normalizedAuthData.mfaRequirement : undefined; if (hasMfa) { // if an mfa requirement exists further action is required 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(resp); } } @action onCancelMfa() { // before resetting mfaAuthData, preserve auth type this.canceledMfaAuth = this.mfaAuthData?.authMethodType ?? ''; this.mfaAuthData = null; } @action onMfaSuccess(authSuccessData: AuthSuccessResponse) { // calls authSuccess in auth.js controller this.args.onAuthSuccess(authSuccessData); } @action onMfaErrorDismiss() { this.mfaAuthData = null; this.mfaErrors = ''; } // HELPERS private filterVisibleMountsByType(authTypes: string[]) { const tabs: UnauthMountsByType = {}; for (const type of authTypes) { // adds visible mounts for each type, if they exist tabs[type] = this.visibleMountsByType?.[type] || null; } return tabs; } private constructViews(view: FormView, tabData: UnauthMountsByType | null) { return { view, tabData }; } }