UI: Fix auth form when token is the preferred type for a namespace (#9944) (#10070)

* normalize token type for ns_token auth mounts

* add changelog

* also check type is supported in route and add test coverage

* Apply suggestion from @hellobontempo

* update test coverage to expect array

* update tests

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
Vault Automation 2025-10-10 16:58:02 -04:00 committed by GitHub
parent a2f69d338c
commit e4309c0e18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 80 additions and 40 deletions

3
changelog/_9944.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:bug
ui (enterprise): Fixes login form so input renders correctly when token is a preferred login method for a namespace.
```

View file

@ -9,7 +9,8 @@ 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 AuthMethodResource from 'vault/resources/auth/method';
import type { NormalizedAuthData, UnauthMountsByType } 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';
@ -76,7 +77,7 @@ import type { Task } from 'ember-concurrency';
* @param {string} oidcProviderQueryParam - oidc provider query param, set in url as "?o=someprovider"
* @param {function} loginAndTransition - 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
* @param {object} visibleAuthMounts - array of AuthMethodResources from unauthenticated request to sys/internal/ui/mounts which returns mount paths tuned with `listing_visibility="unauth"`
* */
export const CSP_ERROR =
@ -88,7 +89,7 @@ interface Args {
loginAndTransition: Task<AuthSuccessResponse, [AuthSuccessResponse]>;
loginSettings: { defaultType: string; backupTypes: string[] | null }; // enterprise only
roleQueryParam?: string;
visibleAuthMounts: UnauthMountsResponse;
visibleAuthMounts: AuthMethodResource[];
}
enum FormView {
@ -112,11 +113,10 @@ export default class AuthPage extends Component<Args> {
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 visibleAuthMounts.reduce((obj, authMount) => {
const { methodType } = authMount;
obj[methodType] ??= []; // if an array doesn't already exist for that methodType, create it
obj[methodType].push({ ...authMount });
return obj;
}, {} as UnauthMountsByType);
}

View file

@ -6,9 +6,9 @@
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/auth-form-helpers';
import { sanitizePath } from 'core/utils/sanitize-path';
import AuthMethodResource from 'vault/resources/auth/method';
export default class AuthRoute extends ClusterRouteBase {
queryParams = {
@ -112,8 +112,11 @@ export default class AuthRoute extends ClusterRouteBase {
const resp = await this.api.sys.internalUiListEnabledVisibleMounts(
this.api.buildHeaders({ token: '' })
);
// return a falsy value if the object is empty
return isEmptyValue(resp.auth) ? null : resp.auth;
const authMounts = this.api.responseObjectToArray(resp.auth, 'path').flatMap((method) => {
const resource = new AuthMethodResource(method, this);
return this.isSupported(resource.methodType) ? [resource] : [];
});
return authMounts.length ? authMounts : null;
} catch {
// catch error if there's a problem fetching mount data (i.e. invalid namespace)
return null;
@ -133,17 +136,18 @@ export default class AuthRoute extends ClusterRouteBase {
const sanitizedParam = sanitizePath(authMount); // strip leading/trailing slashes
// mount paths in visibleAuthMounts always end in a slash, so format for consistency
const formattedPath = `${sanitizedParam}/`;
const mountData = visibleAuthMounts?.[formattedPath];
const mountData = visibleAuthMounts?.find((a) => a.path === formattedPath);
if (mountData) {
return { path: formattedPath, type: mountData.type };
}
const types = supportedTypes(this.version.isEnterprise);
if (types.includes(sanitizedParam)) {
if (this.isSupported(sanitizedParam)) {
return { type: sanitizedParam };
}
// `type` is necessary because it determines which login fields to render.
// If we can't safely glean it from the query param, ignore it and return null.
return null;
}
isSupported = (type = '') => supportedTypes(this.version.isEnterprise).includes(type);
}

View file

@ -106,6 +106,21 @@ module('Acceptance | auth login', function (hooks) {
assert.dom(AUTH_FORM.tabs).doesNotExist();
});
test('it renders default view if sys/internal/ui/mounts has unsupported type', async function (assert) {
this.server.get('/sys/internal/ui/mounts', () => {
return { data: { auth: { 'custom-auth/': { type: 'custom-auth' } } } };
});
await logout();
await visit('/vault/auth');
await waitFor(AUTH_FORM.form);
assert.dom(GENERAL.selectByAttr('auth type')).exists('dropdown renders');
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('token', 'dropdown has default "Token" selected');
// dropdown could still render in "Sign in with other methods" view, so make sure we're not in a weird state
assert.dom(GENERAL.backButton).doesNotExist('it does not render "Back" button');
assert.dom(AUTH_FORM.authForm('token')).exists('it renders token form');
assert.dom(AUTH_FORM.tabs).doesNotExist('it does not render auth tabs');
});
module('listing visibility', function (hooks) {
hooks.beforeEach(async function () {
this.server.get('/sys/internal/ui/mounts', () => {

View file

@ -7,8 +7,10 @@ import { click, currentRouteName, fillIn, visit, waitUntil } from '@ember/test-h
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';
import AuthMethodResource from 'vault/resources/auth/method';
import type { LoginFields } from 'vault/vault/auth/form';
import type ApiService from 'vault/services/api';
export const { rootToken } = VAULT_KEYS;
@ -118,3 +120,7 @@ export const SYS_INTERNAL_UI_MOUNTS = {
type: 'ldap',
},
};
// helper function to stub logic in the AuthRoute to format visibleAuthMounts for integration tests
export const formatAuthMounts = (api: ApiService, mounts = SYS_INTERNAL_UI_MOUNTS) =>
api.responseObjectToArray(mounts, 'path').map((method) => new AuthMethodResource(method, this));

View file

@ -5,7 +5,7 @@
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
import { click, fillIn, waitFor } from '@ember/test-helpers';
import { fillInLoginFields, SYS_INTERNAL_UI_MOUNTS } from 'vault/tests/helpers/auth/auth-helpers';
import { fillInLoginFields, formatAuthMounts } from 'vault/tests/helpers/auth/auth-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { module, test } from 'qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
@ -20,7 +20,8 @@ module('Integration | Component | auth | page | listing visibility', function (h
hooks.beforeEach(function () {
setupTestContext(this);
this.visibleAuthMounts = SYS_INTERNAL_UI_MOUNTS;
this.api = this.owner.lookup('service:api');
this.visibleAuthMounts = formatAuthMounts(this.api);
// 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');
});
@ -77,6 +78,17 @@ module('Integration | Component | auth | page | listing visibility', function (h
.exists('"Sign in with other methods" renders again');
});
test('it renders tabs for types prefixed with "ns_"', async function (assert) {
this.visibleAuthMounts = formatAuthMounts(this.api, { 'token/': { type: 'ns_token' } });
await this.renderComponent();
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist('dropdown does not render');
assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'it renders 1 tab');
assert.dom(AUTH_FORM.tabBtn('token')).exists(`token renders as a tab`);
assert
.dom(AUTH_FORM.tabBtn('token'))
.hasAttribute('aria-selected', 'true', 'it selects the first type by default');
});
// integration tests for ?with= query param
module('with a direct link', function (hooks) {
hooks.beforeEach(function () {

View file

@ -8,9 +8,10 @@ import { click } from '@ember/test-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { SYS_INTERNAL_UI_MOUNTS } from 'vault/tests/helpers/auth/auth-helpers';
import { formatAuthMounts, SYS_INTERNAL_UI_MOUNTS } from 'vault/tests/helpers/auth/auth-helpers';
import setupTestContext from './setup-test-context';
import sinon from 'sinon';
import AuthMethodResource from 'vault/resources/auth/method';
/*
Login settings are an enterprise only feature but the component is version agnostic (and subsequently so are these tests)
@ -118,7 +119,7 @@ module('Integration | Component | auth | page | ent login settings', function (h
defaultType: 'oidc',
backupTypes: ['userpass', 'ldap'],
};
this.visibleAuthMounts = SYS_INTERNAL_UI_MOUNTS;
this.visibleAuthMounts = formatAuthMounts(this.api);
});
test('(default+backups): it hides advanced settings for both views', async function (assert) {
@ -168,11 +169,20 @@ module('Integration | Component | auth | page | ent login settings', function (h
defaultType: 'oidc',
backupTypes: ['userpass', 'ldap'],
};
this.mountData = (path) => ({ [path]: SYS_INTERNAL_UI_MOUNTS[path] });
this.setVisibleMounts = (...paths) => {
// Builds an object in the shape of SYS_INTERNAL_UI_MOUNTS response with only the provided ...paths
const mounts = Object.fromEntries(
paths
.filter((path) => SYS_INTERNAL_UI_MOUNTS[path])
.map((path) => [path, SYS_INTERNAL_UI_MOUNTS[path]])
);
// Format stubbed response the same way the auth route does
return formatAuthMounts(this.api, mounts);
};
});
test('(default+backups): it hides advanced settings for default with visible mount but it renders for backups', async function (assert) {
this.visibleAuthMounts = { ...this.mountData('my_oidc/') };
this.visibleAuthMounts = this.setVisibleMounts('my_oidc/');
await this.renderComponent();
this.assertPathInput(assert, { isHidden: true, value: 'my_oidc/' });
await click(GENERAL.button('Sign in with other methods'));
@ -184,11 +194,7 @@ module('Integration | Component | auth | page | ent login settings', function (h
test('(default+backups): it only renders advanced settings for method without mounts', async function (assert) {
// default and only one backup method have visible mounts
this.visibleAuthMounts = {
...this.mountData('my_oidc/'),
...this.mountData('userpass/'),
...this.mountData('userpass2/'),
};
this.visibleAuthMounts = this.setVisibleMounts('my_oidc/', 'userpass/', 'userpass2/');
await this.renderComponent();
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
await click(GENERAL.button('Sign in with other methods'));
@ -200,7 +206,8 @@ module('Integration | Component | auth | page | ent login settings', function (h
});
test('(default+backups): it hides advanced settings for single backup method with mounts', async function (assert) {
this.visibleAuthMounts = { ...this.mountData('ldap/') };
this.visibleAuthMounts = this.setVisibleMounts('ldap/');
await this.renderComponent();
assert.dom(AUTH_FORM.authForm('oidc')).exists();
assert.dom(AUTH_FORM.advancedSettings).exists();
@ -213,7 +220,7 @@ module('Integration | Component | auth | page | ent login settings', function (h
test('(backups only): it hides advanced settings for single method with mounts', async function (assert) {
this.loginSettings.defaultType = '';
this.visibleAuthMounts = { ...this.mountData('ldap/') };
this.visibleAuthMounts = this.setVisibleMounts('ldap/');
await this.renderComponent();
assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true');
assert.dom(AUTH_FORM.advancedSettings).exists();
@ -224,7 +231,7 @@ module('Integration | Component | auth | page | ent login settings', function (h
module('@directLinkData overrides login settings', function (hooks) {
hooks.beforeEach(function () {
this.mountData = SYS_INTERNAL_UI_MOUNTS;
this.mountData = formatAuthMounts(this.api);
});
module('when there are no visible mounts at all', function (hooks) {
@ -260,14 +267,10 @@ module('Integration | Component | auth | page | ent login settings', function (h
module('when param matches a visible mount path and other visible mounts exist', function (hooks) {
hooks.beforeEach(function () {
this.visibleAuthMounts = {
this.visibleAuthMounts = [
...this.mountData,
'my-okta/': {
description: '',
options: null,
type: 'okta',
},
};
new AuthMethodResource({ path: 'my-okta/', type: 'okta' }),
];
this.directLinkData = { path: 'my-okta/', type: 'okta' };
});

View file

@ -9,6 +9,7 @@ import sinon from 'sinon';
export default (context) => {
context.version = context.owner.lookup('service:version');
context.api = context.owner.lookup('service:api');
context.cluster = { id: '1' };
context.directLinkData = null;
context.loginSettings = null;
@ -17,7 +18,7 @@ export default (context) => {
// mocking as an object with the `perform()` method because loginAndTransition is a concurrency task
context.loginAndTransition = { perform: sinon.spy() };
context.onNamespaceUpdate = sinon.spy();
context.visibleAuthMounts = false;
context.visibleAuthMounts = null;
context.renderComponent = () => {
return render(hbs`<Auth::Page

View file

@ -8,10 +8,6 @@ export interface UnauthMountsByType {
// if the value is "null" there is no mount data for that type
[key: string]: AuthTabMountData[] | null;
}
export interface UnauthMountsResponse {
// key is the mount path
[key: string]: { type: string; description?: string; config?: object | null };
}
interface AuthTabMountData {
path: string;