Repository: hashicorp/vault
Pull request #30489: UI: Add MFA support for SSO methods
Author: login: hellobontempo
Labels: name: ui
name: pr/no-changelog
name: hashicorp-contributed-pr
description: If the PR is HashiCorp (i.e. not-community) contributed
Created: 2025-05-01T21:23:42Z | Merged: 2025-05-02T16:47:37Z

### Description
Adds MFA support for SSO methods

### TODO only if you're a HashiCorp employee
- [ ] **Backport Labels:** If this fix needs to be backported, use the appropriate `backport/` label that matches the desired release branch. Note that in the CE repo, the latest release branch will look like `backport/x.x.x`, but older release branches will be `backport/ent/x.x.x+ent`.
    - [ ] **LTS**: If this fixes a critical security vulnerability or [severity 1](https://www.hashicorp.com/customer-success/enterprise-support) bug, it will also need to be backported to the current [LTS versions](https://developer.hashicorp.com/vault/docs/enterprise/lts#why-is-there-a-risk-to-updating-to-a-non-lts-vault-enterprise-version) of Vault. To ensure this, use **all** available enterprise labels.
- [ ] **ENT Breakage:** If this PR either 1) removes a public function OR 2) changes the signature
  of a public function, even if that change is in a CE file, _double check_ that
  applying the patch for this PR to the ENT repo and running tests doesn't
  break any tests. Sometimes ENT only tests rely on public functions in CE
  files.
- [ ] **Jira:** If this change has an associated Jira, it's referenced either
  in the PR description, commit message, or branch name.
- [ ] **RFC:** If this change has an associated RFC, please link it in the description.
- [ ] **ENT PR:** If this change has an associated ENT PR, please link it in the
  description. Also, make sure the changelog is in this PR, _not_ in your ENT PR.



Diff:
diff --git a/ui/app/components/auth/form-template.ts b/ui/app/components/auth/form-template.ts
index f85cf02a0c8..faadd2f7a09 100644
--- a/ui/app/components/auth/form-template.ts
+++ b/ui/app/components/auth/form-template.ts
@@ -29,7 +29,6 @@ import type { HTMLElementEvent } from 'vault/forms';
  * dynamically renders the corresponding form.
  *
  *
- * @param {string} wrappedToken - Query param value of a wrapped token that can be used to login when added directly to the URL via the "wrapped_token" query param
  * @param {object} cluster - The route model which is the ember data cluster model. contains information such as cluster id, name and boolean for if the cluster is in standby
  * @param {function} handleNamespaceUpdate - callback task that passes user input to the controller and updates the namespace query param in the url
  * @param {string} namespace - namespace query param from the url
@@ -41,6 +40,7 @@ interface Args {
   wrappedToken: string;
   cluster: ClusterModel;
   handleNamespaceUpdate: CallableFunction;
+  preselectedAuthType: string;
   namespace: string;
   onSuccess: CallableFunction;
 }
@@ -67,7 +67,7 @@ export default class AuthFormTemplate extends Component<Args> {
   @tracked showOtherMethods = false;
 
   // auth login variables
-  @tracked selectedAuthMethod = 'token';
+  @tracked selectedAuthMethod = '';
   @tracked errorMessage = '';
 
   displayName = (type: string) => {
@@ -77,6 +77,7 @@ export default class AuthFormTemplate extends Component<Args> {
 
   constructor(owner: unknown, args: Args) {
     super(owner, args);
+    this.selectedAuthMethod = this.args.preselectedAuthType || 'token';
     this.fetchMounts.perform();
   }
 
@@ -131,7 +132,8 @@ export default class AuthFormTemplate extends Component<Args> {
   handleAuthSelect(element: string, event: HTMLElementEvent<HTMLInputElement> | null, idx: number) {
     if (element === 'tab') {
       this.setAuthTypeFromTab(idx);
-    } else if (event?.target?.value) {
+    }
+    if (element === 'dropdown' && event?.target.value) {
       this.selectedAuthMethod = event.target.value;
     }
   }
@@ -142,10 +144,10 @@ export default class AuthFormTemplate extends Component<Args> {
 
     if (this.renderTabs) {
       // reset selected auth method to first tab
-      this.handleAuthSelect('tab', null, 0);
+      this.setAuthTypeFromTab(0);
     } else {
       // all methods render, reset dropdown
-      this.selectedAuthMethod = 'token';
+      this.selectedAuthMethod = this.args.preselectedAuthType || 'token';
     }
   }
 
@@ -199,10 +201,14 @@ export default class AuthFormTemplate extends Component<Args> {
             return obj;
           }, {});
 
-          // set tracked selected auth type to first tab
-          this.setAuthTypeFromTab(0);
-          // hide other methods to prioritize tabs (visible mounts)
-          this.showOtherMethods = false;
+          // SET FORM STATE
+          if (!this.args.preselectedAuthType) {
+            // set selectedAuthMethod auth type to first tab if nothing has been preselected
+            this.setAuthTypeFromTab(0);
+          }
+          // selectedTabIndex returns -1 if selectedAuthMethod is not a tab (i.e. preselected method exists)
+          // so display the dropdown of other methods (and not tabs)
+          this.showOtherMethods = this.selectedTabIndex < 0 ? true : false;
         }
       } catch (e) {
         // if for some reason there's an error fetching mounts, swallow and just show standard form
diff --git a/ui/app/components/auth/form/base.ts b/ui/app/components/auth/form/base.ts
index bc15b311072..de34c450293 100644
--- a/ui/app/components/auth/form/base.ts
+++ b/ui/app/components/auth/form/base.ts
@@ -15,8 +15,8 @@ import type AuthService from 'vault/vault/services/auth';
 import type ClusterModel from 'vault/models/cluster';
 import type FlagsService from 'vault/services/flags';
 import type VersionService from 'vault/services/version';
-import type { AuthData } from 'vault/vault/services/auth';
 import type { HTMLElementEvent } from 'vault/forms';
+import type { MfaRequirementApiResponse, ParsedMfaRequirement } from 'vault/vault/auth/mfa';
 
 /**
  * @module Auth::Base
@@ -34,6 +34,17 @@ interface Args {
   onSuccess: CallableFunction;
 }
 
+interface AuthResponse {
+  namespace: string;
+  token: string; // the name of the token in local storage, not the actual token
+  isRoot: boolean;
+}
+
+export type LoginFields = Partial<Record<(typeof POSSIBLE_FIELDS)[number], string | undefined>> & {
+  path?: string | undefined;
+  namespace?: string | undefined;
+};
+
 export default class AuthBase extends Component<Args> {
   @service declare readonly auth: AuthService;
   @service declare readonly flags: FlagsService;
@@ -57,16 +68,27 @@ export default class AuthBase extends Component<Args> {
           selectedAuth: this.args.authType,
         });
 
-        this.handleAuthResponse(authResponse, this.args.authType, formData);
+        const path = formData?.path;
+        this.handleAuthResponse(authResponse, path);
       } catch (error) {
         this.onError(error as Error);
       }
     })
   );
 
-  handleAuthResponse(authResponse: AuthData, authType: string, formData?: object) {
+  // 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, authType, formData);
+    this.args.onSuccess(authResponse, methodData);
+  }
+
+  // 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);
   }
 
   onError(error: Error | string) {
@@ -77,13 +99,13 @@ export default class AuthBase extends Component<Args> {
   }
 
   parseFormData(formData: FormData) {
-    const data: Record<string, FormDataEntryValue | null> = {};
+    const data: LoginFields = {};
 
     // iterate over method specific fields
     for (const field of POSSIBLE_FIELDS) {
       const value = formData.get(field);
       if (value) {
-        data[field] = value;
+        data[field] = typeof value === 'string' ? value : undefined;
       }
     }
 
diff --git a/ui/app/components/auth/form/oidc-jwt.ts b/ui/app/components/auth/form/oidc-jwt.ts
index 76d141761b8..a95c771c020 100644
--- a/ui/app/components/auth/form/oidc-jwt.ts
+++ b/ui/app/components/auth/form/oidc-jwt.ts
@@ -37,7 +37,6 @@ interface JwtLoginData {
 
 interface OidcLoginData {
   token: string;
-  mfa_requirement?: object;
 }
 
 const ERROR_WINDOW_CLOSED =
@@ -149,7 +148,7 @@ export default class AuthFormOidcJwt extends AuthBase {
       });
 
       // responsible for redirect after auth data is persisted
-      this.handleAuthResponse(authResponse, this.args.authType);
+      this.handleAuthResponse(authResponse);
     } catch (error) {
       this.onError(error as Error);
     }
@@ -238,8 +237,15 @@ export default class AuthFormOidcJwt extends AuthBase {
     }
 
     const { client_token, mfa_requirement } = resp.auth;
-    const oidcExchangeData = { token: client_token, mfa_requirement };
-    await this.continueLogin(oidcExchangeData);
+    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.');
+    }
   });
 
   // MANAGE POPUPS
@@ -274,6 +280,7 @@ export default class AuthFormOidcJwt extends AuthBase {
 
   handleOIDCError(err: string) {
     this.prepareForOIDC.cancelAll();
+    this.exchangeOIDC.cancelAll();
     this.onError(err);
   }
 }
diff --git a/ui/app/components/auth/form/okta.ts b/ui/app/components/auth/form/okta.ts
index f7033ef0ec2..38cc306d451 100644
--- a/ui/app/components/auth/form/okta.ts
+++ b/ui/app/components/auth/form/okta.ts
@@ -45,7 +45,7 @@ export default class AuthFormOkta extends AuthBase {
           selectedAuth: this.args.authType,
         });
 
-        this.handleAuthResponse(authResponse, this.args.authType);
+        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
diff --git a/ui/app/components/auth/form/saml.ts b/ui/app/components/auth/form/saml.ts
index 17e115a0300..60edb0a9d19 100644
--- a/ui/app/components/auth/form/saml.ts
+++ b/ui/app/components/auth/form/saml.ts
@@ -21,11 +21,6 @@ import type Store from '@ember-data/store';
  * see Auth::Base
  */
 
-interface SamlLoginData {
-  token: string;
-  mfa_requirement: object | null;
-}
-
 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.';
@@ -104,18 +99,32 @@ export default class AuthFormSaml extends AuthBase {
     // TODO CMB - when wiring up components check if this is still necessary
     // pass namespace from state back to AuthForm
     // this.args.onNamespace(namespace);
+    let resp;
     try {
-      const authData = await this.pollForToken(samlWindow, { path });
-      // We've obtained the Vault token for the authentication flow now log in or pass MFA data
-
-      if (authData?.client_token) {
-        const samlExchangeData = { token: authData.client_token, mfa_requirement: authData?.mfa_requirement };
-        await this.continueLogin(samlExchangeData);
-        this.closeWindow(samlWindow);
-      }
+      resp = await this.pollForToken(samlWindow, { path });
+      this.closeWindow(samlWindow);
     } catch (error) {
       this.cancelLogin(samlWindow, errorMessage(error));
+      return;
+    }
+
+    // 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;
     }
+
+    // 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;
   });
 
   async pollForToken(samlWindow: Window, { path = '' }) {
@@ -153,7 +162,7 @@ export default class AuthFormSaml extends AuthBase {
     return;
   }
 
-  async continueLogin(data: SamlLoginData) {
+  async continueLogin(data: { token: string }) {
     try {
       const authResponse = await this.auth.authenticate({
         clusterId: this.args.cluster.id,
@@ -163,7 +172,7 @@ export default class AuthFormSaml extends AuthBase {
       });
 
       // responsible for redirect after auth data is persisted
-      this.handleAuthResponse(authResponse, this.args.authType);
+      this.handleAuthResponse(authResponse);
     } catch (e) {
       const error = e as AdapterError;
       this.onError(errorMessage(error));
diff --git a/ui/app/components/auth/login-form.js b/ui/app/components/auth/login-form.js
index 49f298308df..9e54cc40316 100644
--- a/ui/app/components/auth/login-form.js
+++ b/ui/app/components/auth/login-form.js
@@ -73,7 +73,7 @@ export default class AuthLoginFormComponent extends Component {
       if (data?.mfa_requirement) {
         const parsedMfaAuthResponse = this.auth._parseMfaResponse(data.mfa_requirement);
         // calls onAuthResponse in parent auth/page.js component
-        this.args.onSuccess(parsedMfaAuthResponse, backendType, data);
+        this.args.onSuccess(parsedMfaAuthResponse, { selectedAuth: backendType });
         // return here because mfa-form.js will finish login/authentication flow after mfa validation
         return;
       }
@@ -86,7 +86,7 @@ export default class AuthLoginFormComponent extends Component {
       });
 
       // calls onAuthResponse in auth/page.js
-      this.args.onSuccess(authResponse, backendType, data);
+      this.args.onSuccess(authResponse, { selectedAuth: backendType });
     } catch (e) {
       if (!this.auth.mfaError) {
         this.authError = `Authentication failed: ${this.auth.handleError(e)}`;
diff --git a/ui/app/components/auth/page.js b/ui/app/components/auth/page.js
index 63c3d1987ce..c5a4eabfa19 100644
--- a/ui/app/components/auth/page.js
+++ b/ui/app/components/auth/page.js
@@ -58,7 +58,7 @@ export default class AuthPage extends Component {
   }
 
   @action
-  onAuthResponse(authResponse, backend, data) {
+  onAuthResponse(authResponse, { selectedAuth, path }) {
     const { mfa_requirement } = authResponse;
     /*
     Checking for an mfa_requirement happens in two places.
@@ -69,7 +69,7 @@ export default class AuthPage extends Component {
     */
     if (mfa_requirement) {
       // if an mfa requirement exists further action is required
-      this.mfaAuthData = { mfa_requirement, backend, data };
+      this.mfaAuthData = { mfa_requirement, selectedAuth, path };
     } else {
       // calls authSuccess in auth.js controller
       this.args.onAuthSuccess(authResponse);
@@ -79,7 +79,7 @@ export default class AuthPage extends Component {
   @action
   onCancelMfa() {
     // before resetting mfaAuthData, preserve auth type
-    this.preselectedAuthType = this.mfaAuthData.backend;
+    this.preselectedAuthType = this.mfaAuthData.selectedAuth;
     this.mfaAuthData = null;
   }
 
diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js
index b84c8c40420..5780c96745b 100644
--- a/ui/app/services/auth.js
+++ b/ui/app/services/auth.js
@@ -261,20 +261,32 @@ export default Service.extend({
     return userRootNamespace;
   },
 
+  // TODO 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;
     const currentNamespace = this.namespaceService.path || '';
     // dropdown vs tab format
-    const mountPath = firstArg?.data?.path || firstArg?.selectedAuth;
+    //
+    // 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;
+
+    // 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 = options.backend;
+      // 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;
     }
 
     const currentBackend = {
diff --git a/ui/tests/acceptance/wrapped-token-test.js b/ui/tests/acceptance/wrapped-token-test.js
index f11d2518cb0..4bdac61854f 100644
--- a/ui/tests/acceptance/wrapped-token-test.js
+++ b/ui/tests/acceptance/wrapped-token-test.js
@@ -64,12 +64,10 @@ module(`Acceptance | wrapped_token query param functionality`, function (hooks)
     assert.dom(AUTH_FORM.form).doesNotExist();
     await click(`${GENERAL.pageError.error} button`);
     waitFor(AUTH_FORM.form);
-    assert.strictEqual(
-      currentURL(),
-      '/vault/auth?with=token',
-      'it goes back to login route and clears wrapped_token param'
-    );
-    assert.dom(AUTH_FORM.form).exists();
+    const url = currentURL();
+    assert.false(url.includes('wrapped_token='), `url does not include wrapped_token param: ${url}`);
+    assert.strictEqual(currentRouteName(), 'vault.cluster.auth', 'it navigates back to auth route');
+    assert.dom(AUTH_FORM.form).exists('it navigates back to login form');
   });
 
   test('it makes request to authentication service with expected args', async function (assert) {
diff --git a/ui/tests/integration/components/auth/form/saml-test.js b/ui/tests/integration/components/auth/form/saml-test.js
index 7312632d725..6b23fc8be36 100644
--- a/ui/tests/integration/components/auth/form/saml-test.js
+++ b/ui/tests/integration/components/auth/form/saml-test.js
@@ -227,7 +227,6 @@ module('Integration | Component | auth | form | saml', function (hooks) {
     assert.propEqual(
       actual.data,
       {
-        mfa_requirement: undefined,
         token: 'my_token',
       },
       'auth service "authenticate" method is called token callback data'
@@ -245,9 +244,10 @@ module('Integration | Component | auth | form | saml', function (hooks) {
 
     await this.renderComponent();
     await click(AUTH_FORM.login);
-    const [actualResponse, type] = this.onSuccess.lastCall.args;
+    const [actualResponse, methodData] = this.onSuccess.lastCall.args;
     assert.propEqual(actualResponse, expectedResponse, 'onSuccess is called with auth response');
-    assert.strictEqual(type, 'saml', 'onSuccess is called with auth type');
+    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) {
diff --git a/ui/tests/integration/components/auth/login-form-test.js b/ui/tests/integration/components/auth/login-form-test.js
index 8a26d82f64e..2b75279d501 100644
--- a/ui/tests/integration/components/auth/login-form-test.js
+++ b/ui/tests/integration/components/auth/login-form-test.js
@@ -112,7 +112,7 @@ module('Integration | Component | auth | login-form', function (hooks) {
   });
 
   test('it calls onSuccess with expected args', async function (assert) {
-    assert.expect(3);
+    assert.expect(2);
     this.server.get(`auth/token/lookup-self`, () => {
       return {
         data: {
@@ -124,7 +124,7 @@ module('Integration | Component | auth | login-form', function (hooks) {
     await this.renderComponent();
     await fillIn(GENERAL.inputByAttr('token'), 'mytoken');
     await click(AUTH_FORM.login);
-    const [authResponse, backendType, data] = this.onSuccess.lastCall.args;
+    const [authResponse, data] = this.onSuccess.lastCall.args;
     const expected = { isRoot: false, namespace: '', token: 'vault-token☃1' };
 
     assert.propEqual(
@@ -132,8 +132,11 @@ module('Integration | Component | auth | login-form', function (hooks) {
       expected,
       `it calls onSuccess with response: ${JSON.stringify(authResponse)} `
     );
-    assert.strictEqual(backendType, 'token', `it calls onSuccess with backend type: ${backendType}`);
-    assert.propEqual(data, { token: 'mytoken' }, `it calls onSuccess with data: ${JSON.stringify(data)}`);
+    assert.propEqual(
+      data,
+      { selectedAuth: 'token' },
+      `it calls onSuccess with backend type: ${JSON.stringify(data)}}`
+    );
   });
 
   test('it should set nonce value as uuid for okta method type', async function (assert) {
diff --git a/ui/types/vault/adapters/auth-method.d.ts b/ui/types/vault/adapters/auth-method.d.ts
index fb08f0417c5..59c1b53e1b7 100644
--- a/ui/types/vault/adapters/auth-method.d.ts
+++ b/ui/types/vault/adapters/auth-method.d.ts
@@ -5,21 +5,9 @@
 
 import Store from '@ember-data/store';
 import { AdapterRegistry } from 'ember-data/adapter';
-import type { AuthData } from 'vault/services/auth';
+import { OidcApiResponse, SamlApiResponse } from 'vault/auth/methods';
 
 export default interface AuthMethodAdapter extends AdapterRegistry {
-  exchangeOIDC: (
-    path: string,
-    state: string,
-    code: string
-  ) => Promise<{
-    auth: AuthData;
-  }>;
-  pollSAMLToken: (
-    path: string,
-    tokenPollID: string,
-    clientVerifier: string
-  ) => Promise<{
-    auth: AuthData;
-  }>;
+  exchangeOIDC: (path: string, state: string, code: string) => Promise<OidcApiResponse>;
+  pollSAMLToken: (path: string, tokenPollID: string, clientVerifier: string) => Promise<SamlApiResponse>;
 }
diff --git a/ui/types/vault/api.d.ts b/ui/types/vault/api.d.ts
index d5247c6b876..1fb3171d659 100644
--- a/ui/types/vault/api.d.ts
+++ b/ui/types/vault/api.d.ts
@@ -13,8 +13,8 @@ export interface WrapInfo {
 }
 
 export interface ApiResponse {
-  auth: unknown;
-  data: unknown;
+  data: Record<string, unknown> | null;
+  auth: Record<string, unknown> | null;
   lease_duration: number;
   lease_id: string;
   mount_type: string;
diff --git a/ui/types/vault/auth/methods.d.ts b/ui/types/vault/auth/methods.d.ts
new file mode 100644
index 00000000000..227afa5790c
--- /dev/null
+++ b/ui/types/vault/auth/methods.d.ts
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import type { ApiResponse, WrapInfo } from 'vault/auth/api';
+import type { POSSIBLE_FIELDS } from 'vault/utils/supported-login-methods';
+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<string, unknown> | null;
+    lease_duration: number;
+    renewable: boolean;
+    entity_id: string;
+    token_type: string;
+    orphan: boolean;
+    mfa_requirement: MfaRequirementApiResponse | null;
+  };
+}
+
+// METHOD SPECIFIC RESPONSES
+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;
+  };
+}
diff --git a/ui/types/vault/auth/mfa.d.ts b/ui/types/vault/auth/mfa.d.ts
new file mode 100644
index 00000000000..815e21eeb08
--- /dev/null
+++ b/ui/types/vault/auth/mfa.d.ts
@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+export interface MfaRequirementApiResponse {
+  mfa_request_id: string;
+  mfa_constraints: MfaConstraints;
+}
+
+interface MfaConstraint {
+  type: string;
+  id: string;
+  uses_passcode: boolean;
+}
+
+interface MfaConstraints {
+  [key: string]: {
+    any: MfaConstraint[];
+  };
+}
+
+export interface ParsedMfaRequirement {
+  mfa_requirement: {
+    mfa_request_id: string;
+    mfa_constraints: MfaConstraint[];
+  };
+}
+
+interface MfaMethod {
+  type: string;
+  id: string;
+  uses_passcode: boolean;
+  label: string;
+}
+
+interface MfaConstraint {
+  name: string;
+  methods: MfaMethod[];
+  selectedMethod: MfaMethod;
+}
diff --git a/ui/types/vault/services/auth.d.ts b/ui/types/vault/services/auth.d.ts
index 827ce72a86e..f77d4b766a0 100644
--- a/ui/types/vault/services/auth.d.ts
+++ b/ui/types/vault/services/auth.d.ts
@@ -7,6 +7,7 @@
 // add properties as needed
 
 import Service from '@ember/service';
+import type { MfaRequirementApiResponse, ParsedMfaRequirement } from 'vault/auth/mfa';
 
 export interface AuthData {
   userRootNamespace: string;
@@ -15,8 +16,7 @@ export interface AuthData {
   renewable: boolean;
   entity_id: string;
   displayName?: string;
-  mfa_requirement: object;
-  client_token: string;
+  mfa_requirement?: MfaRequirementApiResponse;
 }
 
 export default class AuthService extends Service {
@@ -40,4 +40,5 @@ export default class AuthService extends Service {
       data?: Record<string, unknown>;
     }
   ) => Promise<any>;
+  _parseMfaResponse(mfaResponse: MfaRequirementApiResponse): ParsedMfaRequirement;
 }


Reviews:
user: login: hellobontempo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T21:24:21Z
user: login: hellobontempo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T21:26:45Z
user: login: hellobontempo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T00:32:37Z
user: login: lane-wetmore
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T16:37:17Z
user: login: hellobontempo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T16:47:16Z

Review comments:
diff_hunk: @@ -29,7 +29,6 @@ import type { HTMLElementEvent } from 'vault/forms';
  * dynamically renders the corresponding form.
  *
  *
- * @param {string} wrappedToken - Query param value of a wrapped token that can be used to login when added directly to the URL via the "wrapped_token" query param
path: ui/app/components/auth/form-template.ts
user: login: hellobontempo
body: 🧹 
created_at: 2025-05-01T21:24:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,16 +64,27 @@ export default class AuthBase extends Component<Args> {
           selectedAuth: this.args.authType,
         });
 
-        this.handleAuthResponse(authResponse, this.args.authType, formData);
+        const path = formData?.path;
+        this.handleAuthResponse(authResponse, path);
       } catch (error) {
         this.onError(error as Error);
       }
     })
   );
 
-  handleAuthResponse(authResponse: AuthData, authType: string, formData?: object) {
+  // Standard methods get mfa_requirements from the authenticate method in the auth service
path: ui/app/components/auth/form/base.ts
user: login: hellobontempo
body: Long term plan is that each method component no longer call `this.auth.authenticate` and instead uses the api service for to explicitly make its login request. Once the auth logic isn't so abstracted handling MFA can be consolidated to one place (either here or the auth service) because we will have more control over the authResponse.
created_at: 2025-05-01T21:26:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -73,7 +73,7 @@ export default class AuthLoginFormComponent extends Component {
       if (data?.mfa_requirement) {
         const parsedMfaAuthResponse = this.auth._parseMfaResponse(data.mfa_requirement);
         // calls onAuthResponse in parent auth/page.js component
-        this.args.onSuccess(parsedMfaAuthResponse, backendType, data);
path: ui/app/components/auth/login-form.js
user: login: hellobontempo
body: I'm still referencing old components and tests as I wire things up. Updated here, too to double check changing these args in the parent `page.js` didn't break anything unexpected.
created_at: 2025-05-02T00:32:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -104,18 +99,32 @@ export default class AuthFormSaml extends AuthBase {
     // TODO CMB - when wiring up components check if this is still necessary
     // pass namespace from state back to AuthForm
     // this.args.onNamespace(namespace);
+    let resp;
     try {
-      const authData = await this.pollForToken(samlWindow, { path });
-      // We've obtained the Vault token for the authentication flow now log in or pass MFA data
-
-      if (authData?.client_token) {
-        const samlExchangeData = { token: authData.client_token, mfa_requirement: authData?.mfa_requirement };
-        await this.continueLogin(samlExchangeData);
-        this.closeWindow(samlWindow);
-      }
+      resp = await this.pollForToken(samlWindow, { path });
+      this.closeWindow(samlWindow);
     } catch (error) {
       this.cancelLogin(samlWindow, errorMessage(error));
+      return;
+    }
+
+    // We've go a response from the polling request
path: ui/app/components/auth/form/saml.ts
user: login: lane-wetmore
body: nit: small typo
```suggestion
    // We've got a response from the polling request
```
created_at: 2025-05-02T16:35:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -104,18 +99,32 @@ export default class AuthFormSaml extends AuthBase {
     // TODO CMB - when wiring up components check if this is still necessary
     // pass namespace from state back to AuthForm
     // this.args.onNamespace(namespace);
+    let resp;
     try {
-      const authData = await this.pollForToken(samlWindow, { path });
-      // We've obtained the Vault token for the authentication flow now log in or pass MFA data
-
-      if (authData?.client_token) {
-        const samlExchangeData = { token: authData.client_token, mfa_requirement: authData?.mfa_requirement };
-        await this.continueLogin(samlExchangeData);
-        this.closeWindow(samlWindow);
-      }
+      resp = await this.pollForToken(samlWindow, { path });
+      this.closeWindow(samlWindow);
     } catch (error) {
       this.cancelLogin(samlWindow, errorMessage(error));
+      return;
+    }
+
+    // We've go a response from the polling request
path: ui/app/components/auth/form/saml.ts
user: login: hellobontempo
body: thanks!
created_at: 2025-05-02T16:47:16Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-01T21:27:42Z
body: Build Results:
All builds succeeded! :white_check_mark:
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-01T21:31:14Z
body: CI Results: failed :x:

author_association: NONE