claire bontempo 31051ef1e4
UI: Implement api service in auth components (#31085)
* change entity_id to camel casing, remove "backends" key from stored auth data

* fix tokenExpirationEpoch returning NaN, use authSuccess in auth service tests

* camel case mfa_requirement references

* refactor auth service

* implement api service for token method

* implement api service in standard auth methods

* add lookupSelf request to persistAuthData method in auht service instead of calling in components

* implement api service in oidc-jwt component

* implement api service in okta component

* implement api service in saml component

* use api service for wrapped_token query param

* remaining test updates, enterprise tests and stabilize auth helpers

* upate renew() to use new persistAuthData method, add a test

* revert as this will be addressed upstream

* rename supported-login-methods to auth-form-helpers and delete old supported-auth-backends helper, update tests

* cleanup normalize after testing mfa validation for each auth method

* update type declarations, set displayName in each method component

* stabilize redirect tests by waiting for login before asserting url

* stabilize tests

* modernize typescript syntax, move error const to util

* use mirage instead of vault server to resolve test race conditions

* fix file import
2025-07-09 10:11:23 -07:00

287 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
* <Auth::Page
* @cluster={{this.model.clusterModel}}
* @directLinkData={{this.model.directLinkData}}
* @loginSettings={{this.model.loginSettings}}
* @namespaceQueryParam={{this.namespaceQueryParam}}
* @oidcProviderQueryParam={{this.oidcProvider}}
* @onAuthSuccess={{action "authSuccess"}}
* @onNamespaceUpdate={{perform this.updateNamespace}}
* @visibleAuthMounts={{this.model.visibleAuthMounts}}
* />
*
* @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<Args> {
@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 };
}
}