From 5533b5a7e065f3bc2db7d831bf8e9397f5a89984 Mon Sep 17 00:00:00 2001 From: Ryan Godfrey Date: Tue, 21 Oct 2025 16:30:58 -0400 Subject: [PATCH 01/11] Added origin configuration to authc providers. Changed login form to filter available providers based on the origin configuration and the current browser window origin. Also filtered available providers based on the origin header and the configured provider origin properties # Conflicts: # x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts --- .../security-settings.md | 3 ++ .../shared/security/common/login_state.ts | 1 + .../components/login_form/login_form.tsx | 46 +++++++++++++++---- .../server/authentication/authenticator.ts | 26 +++++++++++ .../server/authentication/providers/base.ts | 8 ++++ .../plugins/shared/security/server/config.ts | 2 + .../security/server/routes/views/login.ts | 5 +- 7 files changed, 81 insertions(+), 10 deletions(-) diff --git a/docs/reference/configuration-reference/security-settings.md b/docs/reference/configuration-reference/security-settings.md index b01ab3dd8788f..5a804776e623d 100644 --- a/docs/reference/configuration-reference/security-settings.md +++ b/docs/reference/configuration-reference/security-settings.md @@ -74,6 +74,9 @@ xpack.security.authc.providers...hint ![logo cloud xpack.security.authc.providers...icon ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}") : Custom icon for the provider entry displayed on the Login Selector UI. +xpack.security.authc.providers...origin ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}") +: TODO + xpack.security.authc.providers...showInSelector ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}") : Flag that indicates if the provider should have an entry on the Login Selector UI. Setting this to `false` doesn’t remove the provider from the authentication chain. diff --git a/x-pack/platform/plugins/shared/security/common/login_state.ts b/x-pack/platform/plugins/shared/security/common/login_state.ts index b274883d0146e..f79b88caff64b 100644 --- a/x-pack/platform/plugins/shared/security/common/login_state.ts +++ b/x-pack/platform/plugins/shared/security/common/login_state.ts @@ -15,6 +15,7 @@ export interface LoginSelectorProvider { description?: string; hint?: string; icon?: string; + origin?: string | string[]; } export interface LoginSelector { diff --git a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx index af8c2d1cbb5f4..b60fe837bf2ba 100644 --- a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx +++ b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx @@ -133,6 +133,11 @@ const assistanceCss = (theme: UseEuiTheme) => css` export class LoginForm extends Component { private readonly validator: LoginValidator; + /** + * Available providers that match the current origin. + */ + private readonly availableProviders: LoginSelectorProvider[]; + /** * Optional provider that was suggested by the `auth_provider_hint={providerName}` query string parameter. If provider * doesn't require Kibana native login form then login process is triggered automatically, otherwise Login Selector @@ -142,10 +147,15 @@ export class LoginForm extends Component { constructor(props: LoginFormProps) { super(props); + + this.availableProviders = this.props.selector.providers.filter((provider) => + this.providerMatchesOrigin(provider) + ); + this.validator = new LoginValidator({ shouldValidate: false }); this.suggestedProvider = this.props.authProviderHint - ? this.props.selector.providers.find(({ name }) => name === this.props.authProviderHint) + ? this.availableProviders.find(({ name }) => name === this.props.authProviderHint) : undefined; // Switch to the Form mode right away if provider from the hint requires it. @@ -158,7 +168,14 @@ export class LoginForm extends Component { loadingState: { type: LoadingStateType.None }, username: '', password: '', - message: this.props.message || { type: MessageType.None }, + message: + this.props.message ?? + (this.availableProviders.length === 0 + ? { + type: MessageType.Danger, + content: 'No authentication providers have been configured for this domain.', + } + : { type: MessageType.None }), mode, previousMode: mode, }; @@ -236,6 +253,10 @@ export class LoginForm extends Component { }; public renderContent() { + if (this.availableProviders.length === 0) { + return; + } + switch (this.state.mode) { case PageMode.Form: return this.renderLoginForm(); @@ -339,7 +360,8 @@ export class LoginForm extends Component { }; private renderSelector = () => { - const providers = this.props.selector.providers.filter((p) => p.showInSelector); + const providers = this.availableProviders.filter((p) => p.showInSelector); + return ( {providers.map((provider) => { @@ -513,9 +535,7 @@ export class LoginForm extends Component { }); // We try to log in with the provider that uses login form and has the lowest order. - const providerToLoginWith = this.props.selector.providers.find( - (provider) => provider.usesLoginForm - )!; + const providerToLoginWith = this.availableProviders.find((provider) => provider.usesLoginForm)!; try { const { location } = await this.props.http.post<{ location: string }>( @@ -603,9 +623,17 @@ export class LoginForm extends Component { private showLoginSelector() { return ( this.props.selector.enabled && - this.props.selector.providers.some( - (provider) => !provider.usesLoginForm && provider.showInSelector - ) + this.availableProviders.some((provider) => !provider.usesLoginForm && provider.showInSelector) + ); + } + + private providerMatchesOrigin(provider: LoginSelectorProvider): boolean { + const { origin } = window.location; + return ( + !provider.origin || + (Array.isArray(provider.origin) + ? provider.origin.includes(origin) + : provider.origin === origin) ); } } diff --git a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts index 8a6fc168e1faf..e2350f160be7e 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts @@ -276,6 +276,7 @@ export class Authenticator { name, logger: options.loggers.get(type, name), urls: { loggedOut: (request: KibanaRequest) => this.getLoggedOutURL(request, type) }, + origin: this.options.config.authc.providers[type]?.[name].origin, }), this.options.config.authc.providers[type]?.[name] ), @@ -339,6 +340,31 @@ export class Authenticator { return AuthenticationResult.notHandled(); } + const { origin: originHeader } = request.headers; + + const filteredProviders = providers.filter(([name, provider]) => { + const providerOrigin = provider.getOrigin(); + + return ( + !originHeader || + !providerOrigin || + (Array.isArray(providerOrigin) + ? providerOrigin.includes(originHeader as string) + : providerOrigin === originHeader) + ); + }); + + if (filteredProviders.length === 0) { + this.logger.warn( + `Login attempt for provider with ${ + isLoginAttemptWithProviderName(attempt) + ? `name ${attempt.provider.name}` + : `type "${(attempt.provider as Record).type}"` + } is detected, but originated from an invalid origin.` + ); + return AuthenticationResult.notHandled(); + } + for (const [providerName, provider] of providers) { const startTime = performance.now(); // Check if current session has been set by this provider. diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts index eaf7094cfdb49..a20a3595b860a 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts @@ -41,6 +41,7 @@ export interface AuthenticationProviderOptions { loggedOut: (request: KibanaRequest) => string; }; isElasticCloudDeployment: () => boolean; + origin?: string | string[]; } /** @@ -147,4 +148,11 @@ export abstract class BaseAuthenticationProvider { authenticationInfo.authentication_realm.name === ELASTIC_CLOUD_SSO_REALM_NAME, } as AuthenticatedUser); } + + /** + * Returns the origin option associated with the provider. + */ + getOrigin(): string | string[] | undefined { + return this.options.origin; + } } diff --git a/x-pack/platform/plugins/shared/security/server/config.ts b/x-pack/platform/plugins/shared/security/server/config.ts index 5c5ac7c12944e..2e029a70bea09 100644 --- a/x-pack/platform/plugins/shared/security/server/config.ts +++ b/x-pack/platform/plugins/shared/security/server/config.ts @@ -28,6 +28,7 @@ interface ProvidersCommonConfigType { description?: Type; hint?: Type; icon?: Type; + origin?: Type; session?: Type<{ idleTimeout?: Duration | null; lifespan?: Duration | null }>; } @@ -49,6 +50,7 @@ function getCommonProviderSchemaProperties(overrides: Partial { // Since `config.authc.sortedProviders` is based on `config.authc.providers` config we can // be sure that config is present for every provider in `config.authc.sortedProviders`. - const { showInSelector, description, hint, icon } = config.authc.providers[type]?.[name]!; + + const { showInSelector, description, hint, icon, origin } = + config.authc.providers[type]?.[name]!; const usesLoginForm = shouldProviderUseLoginForm(type); return { type, @@ -95,6 +97,7 @@ export function defineLoginRoutes({ description, hint, icon, + origin, }; }); From feff38590ccddcf2e26854d3bef718298d2bd287 Mon Sep 17 00:00:00 2001 From: Ryan Godfrey Date: Wed, 22 Oct 2025 15:06:42 -0400 Subject: [PATCH 02/11] Updated security settings for new origin setting and updated login page message to use translate function --- .../configuration-reference/security-settings.md | 16 +++++++++++++++- .../login/components/login_form/login_form.tsx | 4 +++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/reference/configuration-reference/security-settings.md b/docs/reference/configuration-reference/security-settings.md index 5a804776e623d..91019ebfe85ff 100644 --- a/docs/reference/configuration-reference/security-settings.md +++ b/docs/reference/configuration-reference/security-settings.md @@ -75,7 +75,21 @@ xpack.security.authc.providers...icon ![logo cloud : Custom icon for the provider entry displayed on the Login Selector UI. xpack.security.authc.providers...origin ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}") -: TODO +: Specifies a list of allowed origins for authentication requests initiated from the UI. Each origin must be a valid URI and is matched against the browser’s `origin` header when fulfilling an authentication request. Providers not matching the browser's location do not appear in the UI. By default, requests are not restricted to specific origins. + + For example: + + ```yaml + xpack.security.authc: + providers: + basic.basic1: + origin: [http://localhost:5601, http://127.0.0.1:5601] + ... + + saml.saml1: + origin: https://elastic.co + ... + ``` xpack.security.authc.providers...showInSelector ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}") : Flag that indicates if the provider should have an entry on the Login Selector UI. Setting this to `false` doesn’t remove the provider from the authentication chain. diff --git a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx index b60fe837bf2ba..f2e9133080f24 100644 --- a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx +++ b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx @@ -173,7 +173,9 @@ export class LoginForm extends Component { (this.availableProviders.length === 0 ? { type: MessageType.Danger, - content: 'No authentication providers have been configured for this domain.', + content: i18n.translate('xpack.security.noAuthProvidersForDomain', { + defaultMessage: 'No authentication providers have been configured for this domain.', + }), } : { type: MessageType.None }), mode, From 34bb5d3befdb41f416cd7452dc6fc5e1579266e5 Mon Sep 17 00:00:00 2001 From: Ryan Godfrey Date: Fri, 24 Oct 2025 14:28:07 -0400 Subject: [PATCH 03/11] added tests for origin config changes --- .../components/login_form/login_form.test.tsx | 132 +++++++++++ .../components/login_form/login_form.tsx | 8 +- .../authentication/authenticator.test.ts | 210 ++++++++++++++++++ .../server/authentication/authenticator.ts | 2 +- .../server/authentication/providers/base.ts | 13 +- 5 files changed, 354 insertions(+), 11 deletions(-) diff --git a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx index b86326fbeedcd..5e35ac30891fa 100644 --- a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx +++ b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx @@ -15,6 +15,7 @@ import { coreMock } from '@kbn/core/public/mocks'; import { findTestSubject, mountWithIntl, nextTick, shallowWithIntl } from '@kbn/test-jest-helpers'; import { LoginForm, MessageType, PageMode } from './login_form'; +import { i18n } from '@kbn/i18n'; function expectPageMode(wrapper: ReactWrapper, mode: PageMode) { const assertions: Array<[string, boolean]> = @@ -398,6 +399,137 @@ describe('LoginForm', () => { ]); }); + it('does not render providers with origin configs that to not match current page', async () => { + const currentURL = `https://some-host.com/login?next=${encodeURIComponent( + '/some-base-path/app/kibana#/home?_g=()' + )}`; + + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + + window.location = { ...window.location, href: currentURL, origin: 'https://some-host.com' }; + const wrapper = mountWithIntl( + + + + ); + + expect(window.location.origin).toBe('https://some-host.com'); + + expectPageMode(wrapper, PageMode.Selector); + + const result = findTestSubject(wrapper, 'loginCard-', '^=').map((card) => { + const hint = findTestSubject(card, 'card-hint'); + return { + title: findTestSubject(card, 'card-title').text(), + hint: hint.exists() ? hint.text() : '', + icon: card.find(EuiIcon).props().type, + }; + }); + + expect(result).toEqual([ + { title: 'Log in with basic/basic', hint: 'Basic hint', icon: 'logoElastic' }, + { title: 'Log in w/SAML', hint: '', icon: 'empty' }, + ]); + }); + + it('does not render any providers and shows error message if no providers match current origin', async () => { + const currentURL = `https://some-host.com/login?next=${encodeURIComponent( + '/some-base-path/app/kibana#/home?_g=()' + )}`; + + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + + window.location = { ...window.location, href: currentURL, origin: 'https://some-host.com' }; + const wrapper = mountWithIntl( + + + + ); + + expect(window.location.origin).toBe('https://some-host.com'); + + expect(findTestSubject(wrapper, 'loginForm').exists()).toBe(false); + expect(findTestSubject(wrapper, 'loginSelector').exists()).toBe(false); + expect(findTestSubject(wrapper, 'loginHelp').exists()).toBe(false); + expect(findTestSubject(wrapper, 'autoLoginOverlay').exists()).toBe(false); + expect(findTestSubject(wrapper, 'loginCard-', '^=').exists()).toBe(false); + + expect(findTestSubject(wrapper, 'loginErrorMessage').text()).toEqual( + i18n.translate('xpack.security.noAuthProvidersForDomain', { + defaultMessage: 'No authentication providers have been configured for this domain.', + }) + ); + }); + it('properly redirects after successful login', async () => { const currentURL = `https://some-host/login?next=${encodeURIComponent( '/some-base-path/app/kibana#/home?_g=()' diff --git a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx index f2e9133080f24..14862fc32acf6 100644 --- a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx +++ b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx @@ -130,6 +130,10 @@ const assistanceCss = (theme: UseEuiTheme) => css` } `; +const noProvidersMessage = i18n.translate('xpack.security.noAuthProvidersForDomain', { + defaultMessage: 'No authentication providers have been configured for this domain.', +}); + export class LoginForm extends Component { private readonly validator: LoginValidator; @@ -173,9 +177,7 @@ export class LoginForm extends Component { (this.availableProviders.length === 0 ? { type: MessageType.Danger, - content: i18n.translate('xpack.security.noAuthProvidersForDomain', { - defaultMessage: 'No authentication providers have been configured for this domain.', - }), + content: noProvidersMessage, } : { type: MessageType.None }), mode, diff --git a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.test.ts b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.test.ts index 7e65f7089e666..f773e90b70944 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.test.ts @@ -1459,6 +1459,216 @@ describe('Authenticator', () => { ); }); }); + + describe('with origin config', () => { + const headersWithOrigin = { authorization: 'Basic .....', origin: 'http://localhost:5601' }; + + const request = httpServerMock.createKibanaRequest({ headers: headersWithOrigin }); + const user = mockAuthenticatedUser(); + + beforeEach(() => { + mockOptions.session.create.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(user, { + authHeaders: headersWithOrigin, + state: {}, // to ensure a new session is created + }) + ); + }); + + it('allows requests with matching origin header', async () => { + jest + .requireMock('./providers/basic') + .BasicAuthenticationProvider.mockImplementation(() => ({ + type: 'basic', + origin: 'http://localhost:5601', + ...mockBasicAuthenticationProvider, + })); + + authenticator = new Authenticator( + getMockOptions({ + providers: { + basic: { basic1: { order: 0 } }, + }, + }) + ); + + await expect( + authenticator.login(request, { + provider: { type: 'basic', name: 'basic1' }, + value: {}, + }) + ).resolves.toEqual( + AuthenticationResult.succeeded(user, { + authHeaders: headersWithOrigin, + state: {}, + }) + ); + expectAuditEvents({ action: 'user_login', outcome: 'success' }); + expect(mockBasicAuthenticationProvider.login).toHaveBeenCalled(); + }); + + it('allows requests without an origin header', async () => { + jest + .requireMock('./providers/basic') + .BasicAuthenticationProvider.mockImplementation(() => ({ + type: 'basic', + origin: 'http://localhost:5601', + ...mockBasicAuthenticationProvider, + })); + + authenticator = new Authenticator( + getMockOptions({ + providers: { + basic: { basic1: { order: 0 } }, + }, + }) + ); + + await expect( + authenticator.login(httpServerMock.createKibanaRequest(), { + provider: { type: 'basic', name: 'basic1' }, + value: {}, + }) + ).resolves.toEqual( + AuthenticationResult.succeeded(user, { + authHeaders: headersWithOrigin, + state: {}, + }) + ); + expectAuditEvents({ action: 'user_login', outcome: 'success' }); + expect(mockBasicAuthenticationProvider.login).toHaveBeenCalled(); + }); + + it('does not attempt to login for requests with non-matching origin header', async () => { + jest + .requireMock('./providers/basic') + .BasicAuthenticationProvider.mockImplementation(() => ({ + type: 'basic', + origin: 'http://127.0.0.1:5601', + ...mockBasicAuthenticationProvider, + })); + + jest.requireMock('./providers/http').HTTPAuthenticationProvider.mockImplementation(() => ({ + type: 'http', + origin: 'http://127.0.0.1:5601', + ...mockHTTPAuthenticationProvider, + })); + + const mockSamlAuthenticationProvider = jest + .requireMock('./providers/saml') + .SAMLAuthenticationProvider.mockImplementation(() => ({ + type: 'saml', + origin: 'http://127.0.0.1:5601', + login: jest.fn(), + authenticate: jest.fn(), + logout: jest.fn(), + getHTTPAuthenticationScheme: jest.fn(), + })); + + authenticator = new Authenticator( + getMockOptions({ + providers: { + basic: { basic1: { order: 0 } }, + saml: { saml1: { order: 1, realm: 'saml1' } }, + }, + }) + ); + + await expect( + authenticator.login(request, { + provider: { type: 'basic', name: 'basic1' }, + value: {}, + }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + + await expect( + authenticator.login(request, { + provider: { type: 'http' }, + value: {}, + }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + + await expect( + authenticator.login(request, { + provider: { type: 'saml', name: 'saml1' }, + value: {}, + }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + + expect(auditLogger.log).not.toHaveBeenCalled(); + expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled(); + expect(mockHTTPAuthenticationProvider.login).not.toHaveBeenCalled(); + expect(mockSamlAuthenticationProvider.login).not.toHaveBeenCalled(); + }); + + it('skips over providers that do not match the origin config', async () => { + const mockSAMLAuthenticationProvider1: jest.Mocked< + PublicMethodsOf + > = { + login: jest.fn(), + authenticate: jest.fn(), + logout: jest.fn(), + getHTTPAuthenticationScheme: jest.fn(), + }; + + const mockSAMLAuthenticationProvider2: jest.Mocked< + PublicMethodsOf + > = { + login: jest.fn().mockResolvedValue( + AuthenticationResult.succeeded(user, { + authHeaders: headersWithOrigin, + state: {}, // to ensure a new session is created + }) + ), + authenticate: jest.fn(), + logout: jest.fn(), + getHTTPAuthenticationScheme: jest.fn(), + }; + + jest + .requireMock('./providers/saml') + .SAMLAuthenticationProvider.mockImplementationOnce(() => ({ + type: 'saml', + origin: 'http://127.0.0.1:5601', + name: 'saml1', + order: 0, + ...mockSAMLAuthenticationProvider1, + })) + .mockImplementationOnce(() => ({ + type: 'saml', + origin: ['http://localhost:5601', 'http://127.0.0.1:5601'], + name: 'saml2', + order: 1, + ...mockSAMLAuthenticationProvider2, + })); + + authenticator = new Authenticator( + getMockOptions({ + providers: { + saml: { saml1: { order: 0, realm: 'saml1' }, saml2: { order: 1, realm: 'saml1' } }, + }, + }) + ); + + await expect( + authenticator.login(request, { + provider: { type: 'saml' }, + value: {}, + }) + ).resolves.toEqual( + AuthenticationResult.succeeded(user, { + authHeaders: headersWithOrigin, + state: {}, + }) + ); + + expectAuditEvents({ action: 'user_login', outcome: 'success' }); + expect(mockSAMLAuthenticationProvider1.login).not.toHaveBeenCalled(); + expect(mockSAMLAuthenticationProvider2.login).toHaveBeenCalled(); + }); + }); }); describe('`authenticate` method', () => { diff --git a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts index e2350f160be7e..6c6380d1b045c 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts @@ -343,7 +343,7 @@ export class Authenticator { const { origin: originHeader } = request.headers; const filteredProviders = providers.filter(([name, provider]) => { - const providerOrigin = provider.getOrigin(); + const providerOrigin = provider.origin; return ( !originHeader || diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts index a20a3595b860a..ed23cacaf6c2d 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts @@ -74,12 +74,18 @@ export abstract class BaseAuthenticationProvider { */ protected readonly logger: Logger; + /** + * The origins that can the provider can be used to authenticate requests from. + */ + public readonly origin: string | string[] | undefined; + /** * Instantiates AuthenticationProvider. * @param options Provider options object. */ constructor(protected readonly options: Readonly) { this.logger = options.logger; + this.origin = options.origin; } /** @@ -148,11 +154,4 @@ export abstract class BaseAuthenticationProvider { authenticationInfo.authentication_realm.name === ELASTIC_CLOUD_SSO_REALM_NAME, } as AuthenticatedUser); } - - /** - * Returns the origin option associated with the provider. - */ - getOrigin(): string | string[] | undefined { - return this.options.origin; - } } From e5bb79174c5e6aa294fe513f2aec2232997cc99f Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Fri, 24 Oct 2025 20:51:11 +0000 Subject: [PATCH 04/11] [CI] Auto-commit changed files from 'node scripts/eslint_all_files --no-cache --fix' --- .../login/components/login_form/login_form.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx index 5e35ac30891fa..ae62316e9e15e 100644 --- a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx +++ b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx @@ -12,10 +12,10 @@ import React from 'react'; import ReactMarkdown from 'react-markdown'; import { coreMock } from '@kbn/core/public/mocks'; +import { i18n } from '@kbn/i18n'; import { findTestSubject, mountWithIntl, nextTick, shallowWithIntl } from '@kbn/test-jest-helpers'; import { LoginForm, MessageType, PageMode } from './login_form'; -import { i18n } from '@kbn/i18n'; function expectPageMode(wrapper: ReactWrapper, mode: PageMode) { const assertions: Array<[string, boolean]> = From d047e40afcf3858058890eb7155d415c7a8ab4e6 Mon Sep 17 00:00:00 2001 From: Ryan Godfrey Date: Mon, 27 Oct 2025 11:38:41 -0400 Subject: [PATCH 05/11] added integration tests for auth provider origin config --- .../security_functional/login_selector.config.ts | 13 ++++++++++++- .../tests/login_selector/basic_functionality.ts | 5 +++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/x-pack/platform/test/security_functional/login_selector.config.ts b/x-pack/platform/test/security_functional/login_selector.config.ts index 28f9e38184066..7ff3e0abada0e 100644 --- a/x-pack/platform/test/security_functional/login_selector.config.ts +++ b/x-pack/platform/test/security_functional/login_selector.config.ts @@ -8,7 +8,7 @@ import { resolve } from 'path'; import { ScoutTestRunConfigCategory } from '@kbn/scout-info'; -import type { FtrConfigProviderContext } from '@kbn/test'; +import { type FtrConfigProviderContext, getUrl } from '@kbn/test'; import { pageObjects } from '../functional/page_objects'; import { services } from '../functional/services'; @@ -85,6 +85,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { realm: 'saml1', description: 'Log-in-with-SAML', icon: 'logoKibana', + origin: getUrl.baseUrl(kibanaFunctionalConfig.get('servers.kibana')), }, unknown_saml: { order: 2, @@ -98,6 +99,16 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { description: 'Never-log-in-with-SAML', icon: 'logoKibana', }, + saml_hidden: { + order: 5, + realm: 'saml_never', + description: 'This-SAML-should-be-hidden', + origin: [ + 'https://some-domain-that-doesnt-exist.com', + 'https://some-other-domain-that-doesnt-exist.com', + ], + icon: 'logoKibana', + }, }, anonymous: { anonymous1: { diff --git a/x-pack/platform/test/security_functional/tests/login_selector/basic_functionality.ts b/x-pack/platform/test/security_functional/tests/login_selector/basic_functionality.ts index 49bc6038d88c8..f81348883c54f 100644 --- a/x-pack/platform/test/security_functional/tests/login_selector/basic_functionality.ts +++ b/x-pack/platform/test/security_functional/tests/login_selector/basic_functionality.ts @@ -274,5 +274,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible(); }); + + it('correctly hides login selector with different origin configuration', async () => { + expect(await testSubjects.exists(`loginCard-saml/saml1`)).to.be(true); + expect(await testSubjects.exists(`loginCard-saml/saml_hidden`)).to.be(false); + }); }); } From 07a2170ddb59d64244f889736c004ff34d2f65a8 Mon Sep 17 00:00:00 2001 From: Ryan Godfrey Date: Mon, 27 Oct 2025 17:23:07 -0400 Subject: [PATCH 06/11] added config tests for new origin property. fixed merge conflict error --- .../authentication/authenticator.test.ts | 22 +++-- .../server/authentication/authenticator.ts | 2 +- .../shared/security/server/config.test.ts | 96 +++++++++++++++++++ 3 files changed, 109 insertions(+), 11 deletions(-) diff --git a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.test.ts b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.test.ts index f773e90b70944..ea569b3b13c0f 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.test.ts @@ -1556,16 +1556,18 @@ describe('Authenticator', () => { ...mockHTTPAuthenticationProvider, })); - const mockSamlAuthenticationProvider = jest - .requireMock('./providers/saml') - .SAMLAuthenticationProvider.mockImplementation(() => ({ - type: 'saml', - origin: 'http://127.0.0.1:5601', - login: jest.fn(), - authenticate: jest.fn(), - logout: jest.fn(), - getHTTPAuthenticationScheme: jest.fn(), - })); + const mockSamlAuthenticationProvider = { + type: 'saml', + origin: 'http://127.0.0.1:5601', + login: jest.fn(), + authenticate: jest.fn(), + logout: jest.fn(), + getHTTPAuthenticationScheme: jest.fn(), + }; + + jest.requireMock('./providers/saml').SAMLAuthenticationProvider.mockImplementation(() => ({ + ...mockSamlAuthenticationProvider, + })); authenticator = new Authenticator( getMockOptions({ diff --git a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts index 6c6380d1b045c..41432be432300 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts @@ -365,7 +365,7 @@ export class Authenticator { return AuthenticationResult.notHandled(); } - for (const [providerName, provider] of providers) { + for (const [providerName, provider] of filteredProviders) { const startTime = performance.now(); // Check if current session has been set by this provider. const ownsSession = diff --git a/x-pack/platform/plugins/shared/security/server/config.test.ts b/x-pack/platform/plugins/shared/security/server/config.test.ts index a4e89472cb352..0119e4a79a795 100644 --- a/x-pack/platform/plugins/shared/security/server/config.test.ts +++ b/x-pack/platform/plugins/shared/security/server/config.test.ts @@ -1392,6 +1392,102 @@ describe('config schema', () => { }); }); + describe('`origin`', () => { + it('should be a valid URI or an array of URIs', () => { + const uriErrorMessage = + '[authc.providers]: types that failed validation:\n' + + '- [authc.providers.0]: expected value of type [array] but got [Object]\n' + + '- [authc.providers.1.basic.provider1.origin]: types that failed validation:\n' + + ' - [origin.0]: value must be a valid URI (see RFC 3986).\n' + + ' - [origin.1]: could not parse array value from json input'; + + const authConfig = { + authc: { + providers: { + basic: { provider1: { order: 0, origin: 'not-a-valid-uri' as any } }, + }, + }, + }; + + expect(() => ConfigSchema.validate(authConfig)).toThrow(uriErrorMessage); + + authConfig.authc.providers.basic.provider1.origin = 'test.com'; + expect(() => ConfigSchema.validate(authConfig)).toThrow(uriErrorMessage); + + authConfig.authc.providers.basic.provider1.origin = 'http:/test.com:5601'; + expect(() => ConfigSchema.validate(authConfig)).toThrow(uriErrorMessage); + + authConfig.authc.providers.basic.provider1.origin = 12345; + expect(() => ConfigSchema.validate(authConfig)).toThrow( + '[authc.providers]: types that failed validation:\n' + + '- [authc.providers.0]: expected value of type [array] but got [Object]\n' + + '- [authc.providers.1.basic.provider1.origin]: types that failed validation:\n' + + ' - [origin.0]: expected value of type [string] but got [number].\n' + + ' - [origin.1]: expected value of type [array] but got [number]' + ); + + authConfig.authc.providers.basic.provider1.origin = { prop: 'should not be an object' }; + expect(() => ConfigSchema.validate(authConfig)).toThrow( + '[authc.providers]: types that failed validation:\n' + + '- [authc.providers.0]: expected value of type [array] but got [Object]\n' + + '- [authc.providers.1.basic.provider1.origin]: types that failed validation:\n' + + ' - [origin.0]: expected value of type [string] but got [Object].\n' + + ' - [origin.1]: expected value of type [array] but got [Object]' + ); + + authConfig.authc.providers.basic.provider1.origin = 'http://test.com:5601'; + expect( + (ConfigSchema.validate(authConfig).authc.providers as any).basic.provider1.origin + ).toEqual('http://test.com:5601'); + + authConfig.authc.providers.basic.provider1.origin = 'http://127.0.0.1:5601'; + expect( + (ConfigSchema.validate(authConfig).authc.providers as any).basic.provider1.origin + ).toEqual('http://127.0.0.1:5601'); + + authConfig.authc.providers.basic.provider1.origin = [ + 'https://elastic.co', + 'https://localhost:5601', + ]; + expect( + (ConfigSchema.validate(authConfig).authc.providers as any).basic.provider1.origin + ).toEqual(['https://elastic.co', 'https://localhost:5601']); + }); + + it('should be allowed for all provider types', () => { + const origin = 'https://elastic.co'; + + const authConfig = ConfigSchema.validate({ + authc: { + providers: { + basic: { basic1: { order: 0, origin } }, + token: { token1: { order: 1, origin } }, + pki: { pki1: { order: 2, origin } }, + kerberos: { kerberos1: { order: 3, origin } }, + oidc: { oidc1: { order: 4, realm: 'oidc-realm', origin } }, + saml: { saml1: { order: 5, realm: 'saml-realm', origin } }, + anonymous: { + anonymous1: { + order: 6, + credentials: 'elasticsearch_anonymous_user', + origin, + }, + }, + }, + }, + }); + + const providers = authConfig.authc.providers as any; + expect(providers.basic.basic1.origin).toEqual(origin); + expect(providers.token.token1.origin).toEqual(origin); + expect(providers.pki.pki1.origin).toEqual(origin); + expect(providers.kerberos.kerberos1.origin).toEqual(origin); + expect(providers.oidc.oidc1.origin).toEqual(origin); + expect(providers.saml.saml1.origin).toEqual(origin); + expect(providers.anonymous.anonymous1.origin).toEqual(origin); + }); + }); + it('`name` should be unique across all provider types', () => { expect(() => ConfigSchema.validate({ From fdbf917d61a7f8efeb7260fb783860de63d36c68 Mon Sep 17 00:00:00 2001 From: Ryan Date: Wed, 29 Oct 2025 09:11:59 -0400 Subject: [PATCH 07/11] Add applies_to version to security-settings documentation Co-authored-by: florent-leborgne --- docs/reference/configuration-reference/security-settings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/configuration-reference/security-settings.md b/docs/reference/configuration-reference/security-settings.md index 91019ebfe85ff..76062061b14a4 100644 --- a/docs/reference/configuration-reference/security-settings.md +++ b/docs/reference/configuration-reference/security-settings.md @@ -74,7 +74,7 @@ xpack.security.authc.providers...hint ![logo cloud xpack.security.authc.providers...icon ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}") : Custom icon for the provider entry displayed on the Login Selector UI. -xpack.security.authc.providers...origin ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}") +xpack.security.authc.providers...origin {applies_to}`stack: ga 9.3` ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}") : Specifies a list of allowed origins for authentication requests initiated from the UI. Each origin must be a valid URI and is matched against the browser’s `origin` header when fulfilling an authentication request. Providers not matching the browser's location do not appear in the UI. By default, requests are not restricted to specific origins. For example: From 069cc4c8f1e5debdb5f0d1d29e8924eef7e36e8f Mon Sep 17 00:00:00 2001 From: Ryan Godfrey Date: Tue, 4 Nov 2025 09:53:30 -0500 Subject: [PATCH 08/11] addressed PR comments --- .../security-settings.md | 2 +- .../components/login_form/login_form.test.tsx | 111 +++++++++++------- .../components/login_form/login_form.tsx | 3 +- .../server/authentication/authenticator.ts | 27 +---- .../server/authentication/providers/base.ts | 7 -- .../shared/security/server/config.test.ts | 19 +++ .../plugins/shared/security/server/config.ts | 18 ++- .../server/routes/views/login.test.ts | 62 ++++++++++ .../login_selector.config.ts | 11 ++ .../login_selector/basic_functionality.ts | 3 + 10 files changed, 185 insertions(+), 78 deletions(-) diff --git a/docs/reference/configuration-reference/security-settings.md b/docs/reference/configuration-reference/security-settings.md index 76062061b14a4..d5b5e912381ff 100644 --- a/docs/reference/configuration-reference/security-settings.md +++ b/docs/reference/configuration-reference/security-settings.md @@ -75,7 +75,7 @@ xpack.security.authc.providers...icon ![logo cloud : Custom icon for the provider entry displayed on the Login Selector UI. xpack.security.authc.providers...origin {applies_to}`stack: ga 9.3` ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}") -: Specifies a list of allowed origins for authentication requests initiated from the UI. Each origin must be a valid URI and is matched against the browser’s `origin` header when fulfilling an authentication request. Providers not matching the browser's location do not appear in the UI. By default, requests are not restricted to specific origins. +: Specifies the origin(s) where the provider will appear to users in the browser. Each origin must be a valid URI only containing an origin. By default, providers are not restricted to specific origins. For example: diff --git a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx index ae62316e9e15e..251221ac4a7e5 100644 --- a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx +++ b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx @@ -6,44 +6,59 @@ */ import { EuiCallOut, EuiIcon, EuiProvider } from '@elastic/eui'; -import { act } from '@testing-library/react'; +import type { RenderResult } from '@testing-library/react'; +import { act, queryByTestId } from '@testing-library/react'; import type { ReactWrapper } from 'enzyme'; import React from 'react'; import ReactMarkdown from 'react-markdown'; import { coreMock } from '@kbn/core/public/mocks'; import { i18n } from '@kbn/i18n'; -import { findTestSubject, mountWithIntl, nextTick, shallowWithIntl } from '@kbn/test-jest-helpers'; +import { + findTestSubject, + mountWithIntl, + nextTick, + renderWithI18n, + shallowWithIntl, +} from '@kbn/test-jest-helpers'; import { LoginForm, MessageType, PageMode } from './login_form'; +function getPageModeAssertions(mode: PageMode): Array<[string, boolean]> { + return mode === PageMode.Form + ? [ + ['loginForm', true], + ['loginSelector', false], + ['loginHelp', false], + ['autoLoginOverlay', false], + ] + : mode === PageMode.Selector + ? [ + ['loginForm', false], + ['loginSelector', true], + ['loginHelp', false], + ['autoLoginOverlay', false], + ] + : [ + ['loginForm', false], + ['loginSelector', false], + ['loginHelp', true], + ['autoLoginOverlay', false], + ]; +} + function expectPageMode(wrapper: ReactWrapper, mode: PageMode) { - const assertions: Array<[string, boolean]> = - mode === PageMode.Form - ? [ - ['loginForm', true], - ['loginSelector', false], - ['loginHelp', false], - ['autoLoginOverlay', false], - ] - : mode === PageMode.Selector - ? [ - ['loginForm', false], - ['loginSelector', true], - ['loginHelp', false], - ['autoLoginOverlay', false], - ] - : [ - ['loginForm', false], - ['loginSelector', false], - ['loginHelp', true], - ['autoLoginOverlay', false], - ]; - for (const [selector, exists] of assertions) { + for (const [selector, exists] of getPageModeAssertions(mode)) { expect(findTestSubject(wrapper, selector).exists()).toBe(exists); } } +function expectPageModeRenderResult(renderResult: RenderResult, mode: PageMode) { + for (const [selector, exists] of getPageModeAssertions(mode)) { + expect(!!renderResult.queryByTestId(selector)).toBe(exists); + } +} + function expectAutoLoginOverlay(wrapper: ReactWrapper) { // Everything should be hidden except for the overlay for (const selector of [ @@ -399,7 +414,7 @@ describe('LoginForm', () => { ]); }); - it('does not render providers with origin configs that to not match current page', async () => { + it('does not render providers with origin configs that do not match current page', async () => { const currentURL = `https://some-host.com/login?next=${encodeURIComponent( '/some-base-path/app/kibana#/home?_g=()' )}`; @@ -407,7 +422,7 @@ describe('LoginForm', () => { const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); window.location = { ...window.location, href: currentURL, origin: 'https://some-host.com' }; - const wrapper = mountWithIntl( + const wrapper = renderWithI18n( { expect(window.location.origin).toBe('https://some-host.com'); - expectPageMode(wrapper, PageMode.Selector); + expectPageModeRenderResult(wrapper, PageMode.Selector); - const result = findTestSubject(wrapper, 'loginCard-', '^=').map((card) => { - const hint = findTestSubject(card, 'card-hint'); + wrapper.queryAllByTestId(/^loginCard-/); + + const result = wrapper.queryAllByTestId(/^loginCard-/).map((card) => { + const hint = queryByTestId(card, 'card-hint'); + const title = queryByTestId(card, 'card-title'); + const icon = card.querySelector('[data-euiicon-type]'); return { - title: findTestSubject(card, 'card-title').text(), - hint: hint.exists() ? hint.text() : '', - icon: card.find(EuiIcon).props().type, + title: title?.textContent ?? '', + hint: hint?.textContent ?? '', + icon: icon?.getAttribute('data-euiicon-type') ?? null, }; }); @@ -471,10 +490,17 @@ describe('LoginForm', () => { '/some-base-path/app/kibana#/home?_g=()' )}`; - const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + window.location = { + ...window.location, + href: currentURL, + origin: 'https://some-host.com', + }; - window.location = { ...window.location, href: currentURL, origin: 'https://some-host.com' }; - const wrapper = mountWithIntl( + const coreStartMock = coreMock.createStart({ + basePath: '/some-base-path', + }); + + const rendered = renderWithI18n( { expect(window.location.origin).toBe('https://some-host.com'); - expect(findTestSubject(wrapper, 'loginForm').exists()).toBe(false); - expect(findTestSubject(wrapper, 'loginSelector').exists()).toBe(false); - expect(findTestSubject(wrapper, 'loginHelp').exists()).toBe(false); - expect(findTestSubject(wrapper, 'autoLoginOverlay').exists()).toBe(false); - expect(findTestSubject(wrapper, 'loginCard-', '^=').exists()).toBe(false); + expect(rendered.queryByTestId('loginForm')).toBeFalsy(); + expect(rendered.queryByTestId('loginSelector')).toBeFalsy(); + expect(rendered.queryByTestId('loginHelp')).toBeFalsy(); + expect(rendered.queryByTestId('autoLoginOverlay')).toBeFalsy(); + expect(rendered.queryAllByTestId(/^loginCard-/).length).toBe(0); - expect(findTestSubject(wrapper, 'loginErrorMessage').text()).toEqual( + expect((await rendered.findByTestId('loginErrorMessage')).textContent).toEqual( i18n.translate('xpack.security.noAuthProvidersForDomain', { - defaultMessage: 'No authentication providers have been configured for this domain.', + defaultMessage: + 'No authentication providers have been configured for this origin (http://localhost).', }) ); }); diff --git a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx index 14862fc32acf6..ad14a3dba87a8 100644 --- a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx +++ b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx @@ -131,7 +131,8 @@ const assistanceCss = (theme: UseEuiTheme) => css` `; const noProvidersMessage = i18n.translate('xpack.security.noAuthProvidersForDomain', { - defaultMessage: 'No authentication providers have been configured for this domain.', + defaultMessage: 'No authentication providers have been configured for this origin ({origin}).', + values: { origin: window.location.origin }, }); export class LoginForm extends Component { diff --git a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts index 41432be432300..d2e856af9f330 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts @@ -340,32 +340,7 @@ export class Authenticator { return AuthenticationResult.notHandled(); } - const { origin: originHeader } = request.headers; - - const filteredProviders = providers.filter(([name, provider]) => { - const providerOrigin = provider.origin; - - return ( - !originHeader || - !providerOrigin || - (Array.isArray(providerOrigin) - ? providerOrigin.includes(originHeader as string) - : providerOrigin === originHeader) - ); - }); - - if (filteredProviders.length === 0) { - this.logger.warn( - `Login attempt for provider with ${ - isLoginAttemptWithProviderName(attempt) - ? `name ${attempt.provider.name}` - : `type "${(attempt.provider as Record).type}"` - } is detected, but originated from an invalid origin.` - ); - return AuthenticationResult.notHandled(); - } - - for (const [providerName, provider] of filteredProviders) { + for (const [providerName, provider] of providers) { const startTime = performance.now(); // Check if current session has been set by this provider. const ownsSession = diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts index ed23cacaf6c2d..eaf7094cfdb49 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts @@ -41,7 +41,6 @@ export interface AuthenticationProviderOptions { loggedOut: (request: KibanaRequest) => string; }; isElasticCloudDeployment: () => boolean; - origin?: string | string[]; } /** @@ -74,18 +73,12 @@ export abstract class BaseAuthenticationProvider { */ protected readonly logger: Logger; - /** - * The origins that can the provider can be used to authenticate requests from. - */ - public readonly origin: string | string[] | undefined; - /** * Instantiates AuthenticationProvider. * @param options Provider options object. */ constructor(protected readonly options: Readonly) { this.logger = options.logger; - this.origin = options.origin; } /** diff --git a/x-pack/platform/plugins/shared/security/server/config.test.ts b/x-pack/platform/plugins/shared/security/server/config.test.ts index 0119e4a79a795..2245cb9cab2f9 100644 --- a/x-pack/platform/plugins/shared/security/server/config.test.ts +++ b/x-pack/platform/plugins/shared/security/server/config.test.ts @@ -1454,6 +1454,25 @@ describe('config schema', () => { ).toEqual(['https://elastic.co', 'https://localhost:5601']); }); + it('should only allow the origin component of the URI', () => { + const uriErrorMessage = + '[authc.providers]: types that failed validation:\n' + + '- [authc.providers.0]: expected value of type [array] but got [Object]\n' + + '- [authc.providers.1.basic.provider1.origin]: types that failed validation:\n' + + ' - [origin.0]: expected a lower-case origin (scheme, host, and optional port) but got: http://test.com/too-long\n' + + ' - [origin.1]: could not parse array value from json input'; + + const authConfig = { + authc: { + providers: { + basic: { provider1: { order: 0, origin: 'http://test.com/too-long' as any } }, + }, + }, + }; + + expect(() => ConfigSchema.validate(authConfig)).toThrow(uriErrorMessage); + }); + it('should be allowed for all provider types', () => { const origin = 'https://elastic.co'; diff --git a/x-pack/platform/plugins/shared/security/server/config.ts b/x-pack/platform/plugins/shared/security/server/config.ts index 2e029a70bea09..d784049a1952f 100644 --- a/x-pack/platform/plugins/shared/security/server/config.ts +++ b/x-pack/platform/plugins/shared/security/server/config.ts @@ -42,6 +42,20 @@ const providerOptionsSchema = (providerType: string, optionsSchema: Type) = schema.never() ); +const providerOriginSchema = schema.uri({ + validate(originConfig) { + try { + const url = new URL(originConfig); + + if (originConfig !== url.origin) { + return `expected a lower-case origin (scheme, host, and optional port) but got: ${originConfig}`; + } + } catch (error) { + return `Invalid origin URI: ${error.message}`; + } + }, +}); + function getCommonProviderSchemaProperties(overrides: Partial = {}) { return { enabled: schema.boolean({ defaultValue: true }), @@ -50,7 +64,9 @@ function getCommonProviderSchemaProperties(overrides: Partial { } }); + it('returns `origin` configuration for providers.', async () => { + license.getFeatures.mockReturnValue({ allowLogin: true, showLogin: true } as any); + + const request = httpServerMock.createKibanaRequest(); + const contextMock = coreMock.createRequestHandlerContext(); + + const cases: Array<[LoginSelectorProvider[], ConfigType['authc']]> = [ + [[], getAuthcConfig({ providers: { basic: { basic1: { order: 0, enabled: false } } } })], + [ + [ + { + name: 'basic1', + type: 'basic', + origin: 'http://example.com', + usesLoginForm: true, + showInSelector: true, + icon: 'logoElasticsearch', + description: 'Log in with Elasticsearch', + }, + ], + getAuthcConfig({ + providers: { basic: { basic1: { order: 0, origin: 'http://example.com' } } }, + }), + ], + [ + [ + { + name: 'token1', + type: 'token', + origin: ['http://example.com', 'http://example-2.com'], + usesLoginForm: true, + showInSelector: true, + icon: 'logoElasticsearch', + description: 'Log in with Elasticsearch', + }, + ], + getAuthcConfig({ + providers: { + token: { + token1: { order: 0, origin: ['http://example.com', 'http://example-2.com'] }, + }, + }, + }), + ], + ]; + + for (const [providers, authcConfig] of cases) { + config.authc = authcConfig; + + const expectedPayload = expect.objectContaining({ + selector: { enabled: false, providers }, + }); + await expect( + routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) + ).resolves.toEqual({ + options: { body: expectedPayload }, + payload: expectedPayload, + status: 200, + }); + } + }); + it('correctly returns `selector` information.', async () => { license.getFeatures.mockReturnValue({ allowLogin: true, showLogin: true } as any); diff --git a/x-pack/platform/test/security_functional/login_selector.config.ts b/x-pack/platform/test/security_functional/login_selector.config.ts index 7ff3e0abada0e..230baa8c57178 100644 --- a/x-pack/platform/test/security_functional/login_selector.config.ts +++ b/x-pack/platform/test/security_functional/login_selector.config.ts @@ -92,6 +92,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { realm: 'unknown_realm', description: 'Do-not-log-in-with-THIS-SAML', icon: 'logoAWS', + origin: [ + 'https://some-domain-that-doesnt-exist.com', + getUrl.baseUrl(kibanaFunctionalConfig.get('servers.kibana')), + ], }, saml_never: { order: 4, @@ -109,6 +113,13 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ], icon: 'logoKibana', }, + saml_hidden_2: { + order: 6, + realm: 'saml_never', + description: 'This-SAML-should-be-hidden', + origin: 'https://some-domain-that-doesnt-exist.com', + icon: 'logoKibana', + }, }, anonymous: { anonymous1: { diff --git a/x-pack/platform/test/security_functional/tests/login_selector/basic_functionality.ts b/x-pack/platform/test/security_functional/tests/login_selector/basic_functionality.ts index f81348883c54f..134bdbd9e10a4 100644 --- a/x-pack/platform/test/security_functional/tests/login_selector/basic_functionality.ts +++ b/x-pack/platform/test/security_functional/tests/login_selector/basic_functionality.ts @@ -277,7 +277,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('correctly hides login selector with different origin configuration', async () => { expect(await testSubjects.exists(`loginCard-saml/saml1`)).to.be(true); + expect(await testSubjects.exists(`loginCard-saml/unknown_saml`)).to.be(true); + expect(await testSubjects.exists(`loginCard-saml/saml_hidden`)).to.be(false); + expect(await testSubjects.exists(`loginCard-saml/saml_hidden_2`)).to.be(false); }); }); } From f69e2d51a16227b4808d98750bbf3da8421abc64 Mon Sep 17 00:00:00 2001 From: Ryan Godfrey Date: Tue, 4 Nov 2025 13:39:00 -0500 Subject: [PATCH 09/11] reverted back end tests for authenticator origin config --- .../authentication/authenticator.test.ts | 212 ------------------ .../server/authentication/authenticator.ts | 1 - 2 files changed, 213 deletions(-) diff --git a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.test.ts b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.test.ts index ea569b3b13c0f..7e65f7089e666 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.test.ts @@ -1459,218 +1459,6 @@ describe('Authenticator', () => { ); }); }); - - describe('with origin config', () => { - const headersWithOrigin = { authorization: 'Basic .....', origin: 'http://localhost:5601' }; - - const request = httpServerMock.createKibanaRequest({ headers: headersWithOrigin }); - const user = mockAuthenticatedUser(); - - beforeEach(() => { - mockOptions.session.create.mockResolvedValue(mockSessVal); - - mockBasicAuthenticationProvider.login.mockResolvedValue( - AuthenticationResult.succeeded(user, { - authHeaders: headersWithOrigin, - state: {}, // to ensure a new session is created - }) - ); - }); - - it('allows requests with matching origin header', async () => { - jest - .requireMock('./providers/basic') - .BasicAuthenticationProvider.mockImplementation(() => ({ - type: 'basic', - origin: 'http://localhost:5601', - ...mockBasicAuthenticationProvider, - })); - - authenticator = new Authenticator( - getMockOptions({ - providers: { - basic: { basic1: { order: 0 } }, - }, - }) - ); - - await expect( - authenticator.login(request, { - provider: { type: 'basic', name: 'basic1' }, - value: {}, - }) - ).resolves.toEqual( - AuthenticationResult.succeeded(user, { - authHeaders: headersWithOrigin, - state: {}, - }) - ); - expectAuditEvents({ action: 'user_login', outcome: 'success' }); - expect(mockBasicAuthenticationProvider.login).toHaveBeenCalled(); - }); - - it('allows requests without an origin header', async () => { - jest - .requireMock('./providers/basic') - .BasicAuthenticationProvider.mockImplementation(() => ({ - type: 'basic', - origin: 'http://localhost:5601', - ...mockBasicAuthenticationProvider, - })); - - authenticator = new Authenticator( - getMockOptions({ - providers: { - basic: { basic1: { order: 0 } }, - }, - }) - ); - - await expect( - authenticator.login(httpServerMock.createKibanaRequest(), { - provider: { type: 'basic', name: 'basic1' }, - value: {}, - }) - ).resolves.toEqual( - AuthenticationResult.succeeded(user, { - authHeaders: headersWithOrigin, - state: {}, - }) - ); - expectAuditEvents({ action: 'user_login', outcome: 'success' }); - expect(mockBasicAuthenticationProvider.login).toHaveBeenCalled(); - }); - - it('does not attempt to login for requests with non-matching origin header', async () => { - jest - .requireMock('./providers/basic') - .BasicAuthenticationProvider.mockImplementation(() => ({ - type: 'basic', - origin: 'http://127.0.0.1:5601', - ...mockBasicAuthenticationProvider, - })); - - jest.requireMock('./providers/http').HTTPAuthenticationProvider.mockImplementation(() => ({ - type: 'http', - origin: 'http://127.0.0.1:5601', - ...mockHTTPAuthenticationProvider, - })); - - const mockSamlAuthenticationProvider = { - type: 'saml', - origin: 'http://127.0.0.1:5601', - login: jest.fn(), - authenticate: jest.fn(), - logout: jest.fn(), - getHTTPAuthenticationScheme: jest.fn(), - }; - - jest.requireMock('./providers/saml').SAMLAuthenticationProvider.mockImplementation(() => ({ - ...mockSamlAuthenticationProvider, - })); - - authenticator = new Authenticator( - getMockOptions({ - providers: { - basic: { basic1: { order: 0 } }, - saml: { saml1: { order: 1, realm: 'saml1' } }, - }, - }) - ); - - await expect( - authenticator.login(request, { - provider: { type: 'basic', name: 'basic1' }, - value: {}, - }) - ).resolves.toEqual(AuthenticationResult.notHandled()); - - await expect( - authenticator.login(request, { - provider: { type: 'http' }, - value: {}, - }) - ).resolves.toEqual(AuthenticationResult.notHandled()); - - await expect( - authenticator.login(request, { - provider: { type: 'saml', name: 'saml1' }, - value: {}, - }) - ).resolves.toEqual(AuthenticationResult.notHandled()); - - expect(auditLogger.log).not.toHaveBeenCalled(); - expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled(); - expect(mockHTTPAuthenticationProvider.login).not.toHaveBeenCalled(); - expect(mockSamlAuthenticationProvider.login).not.toHaveBeenCalled(); - }); - - it('skips over providers that do not match the origin config', async () => { - const mockSAMLAuthenticationProvider1: jest.Mocked< - PublicMethodsOf - > = { - login: jest.fn(), - authenticate: jest.fn(), - logout: jest.fn(), - getHTTPAuthenticationScheme: jest.fn(), - }; - - const mockSAMLAuthenticationProvider2: jest.Mocked< - PublicMethodsOf - > = { - login: jest.fn().mockResolvedValue( - AuthenticationResult.succeeded(user, { - authHeaders: headersWithOrigin, - state: {}, // to ensure a new session is created - }) - ), - authenticate: jest.fn(), - logout: jest.fn(), - getHTTPAuthenticationScheme: jest.fn(), - }; - - jest - .requireMock('./providers/saml') - .SAMLAuthenticationProvider.mockImplementationOnce(() => ({ - type: 'saml', - origin: 'http://127.0.0.1:5601', - name: 'saml1', - order: 0, - ...mockSAMLAuthenticationProvider1, - })) - .mockImplementationOnce(() => ({ - type: 'saml', - origin: ['http://localhost:5601', 'http://127.0.0.1:5601'], - name: 'saml2', - order: 1, - ...mockSAMLAuthenticationProvider2, - })); - - authenticator = new Authenticator( - getMockOptions({ - providers: { - saml: { saml1: { order: 0, realm: 'saml1' }, saml2: { order: 1, realm: 'saml1' } }, - }, - }) - ); - - await expect( - authenticator.login(request, { - provider: { type: 'saml' }, - value: {}, - }) - ).resolves.toEqual( - AuthenticationResult.succeeded(user, { - authHeaders: headersWithOrigin, - state: {}, - }) - ); - - expectAuditEvents({ action: 'user_login', outcome: 'success' }); - expect(mockSAMLAuthenticationProvider1.login).not.toHaveBeenCalled(); - expect(mockSAMLAuthenticationProvider2.login).toHaveBeenCalled(); - }); - }); }); describe('`authenticate` method', () => { diff --git a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts index d2e856af9f330..8a6fc168e1faf 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts @@ -276,7 +276,6 @@ export class Authenticator { name, logger: options.loggers.get(type, name), urls: { loggedOut: (request: KibanaRequest) => this.getLoggedOutURL(request, type) }, - origin: this.options.config.authc.providers[type]?.[name].origin, }), this.options.config.authc.providers[type]?.[name] ), From 352ea5a32dd59da4b6b2c88ea5240c549240559e Mon Sep 17 00:00:00 2001 From: Ryan Godfrey Date: Wed, 5 Nov 2025 10:56:53 -0500 Subject: [PATCH 10/11] moved no origin message declaration inside login form class --- .../login/components/login_form/login_form.test.tsx | 2 +- .../login/components/login_form/login_form.tsx | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx index 251221ac4a7e5..b7d006e3dc08f 100644 --- a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx +++ b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx @@ -552,7 +552,7 @@ describe('LoginForm', () => { expect((await rendered.findByTestId('loginErrorMessage')).textContent).toEqual( i18n.translate('xpack.security.noAuthProvidersForDomain', { defaultMessage: - 'No authentication providers have been configured for this origin (http://localhost).', + 'No authentication providers have been configured for this origin (https://some-host.com).', }) ); }); diff --git a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx index 5f7d6ef21074f..b73042aaccef1 100644 --- a/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx +++ b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx @@ -130,12 +130,12 @@ const assistanceCss = (theme: UseEuiTheme) => css` } `; -const noProvidersMessage = i18n.translate('xpack.security.noAuthProvidersForDomain', { - defaultMessage: 'No authentication providers have been configured for this origin ({origin}).', - values: { origin: window.location.origin }, -}); - export class LoginForm extends Component { + private readonly noProvidersMessage = i18n.translate('xpack.security.noAuthProvidersForDomain', { + defaultMessage: 'No authentication providers have been configured for this origin ({origin}).', + values: { origin: window.location.origin }, + }); + private readonly validator: LoginValidator; /** @@ -178,7 +178,7 @@ export class LoginForm extends Component { (this.availableProviders.length === 0 ? { type: MessageType.Danger, - content: noProvidersMessage, + content: this.noProvidersMessage, } : { type: MessageType.None }), mode, From 24a315223c643719566fcfeb5d341100fe14ebd9 Mon Sep 17 00:00:00 2001 From: Ryan Godfrey Date: Wed, 5 Nov 2025 13:38:04 -0500 Subject: [PATCH 11/11] reworded documentation update. removed unneeded try catch --- .../configuration-reference/security-settings.md | 2 +- .../platform/plugins/shared/security/server/config.ts | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/docs/reference/configuration-reference/security-settings.md b/docs/reference/configuration-reference/security-settings.md index d5b5e912381ff..f3f9d55e1b37a 100644 --- a/docs/reference/configuration-reference/security-settings.md +++ b/docs/reference/configuration-reference/security-settings.md @@ -75,7 +75,7 @@ xpack.security.authc.providers...icon ![logo cloud : Custom icon for the provider entry displayed on the Login Selector UI. xpack.security.authc.providers...origin {applies_to}`stack: ga 9.3` ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}") -: Specifies the origin(s) where the provider will appear to users in the browser. Each origin must be a valid URI only containing an origin. By default, providers are not restricted to specific origins. +: Specifies the origin(s) where the provider will appear to users in the Login Selector UI. Each origin must be a valid URI only containing an origin. By default, providers are not restricted to specific origins. For example: diff --git a/x-pack/platform/plugins/shared/security/server/config.ts b/x-pack/platform/plugins/shared/security/server/config.ts index d784049a1952f..ec57370821ba7 100644 --- a/x-pack/platform/plugins/shared/security/server/config.ts +++ b/x-pack/platform/plugins/shared/security/server/config.ts @@ -44,14 +44,10 @@ const providerOptionsSchema = (providerType: string, optionsSchema: Type) = const providerOriginSchema = schema.uri({ validate(originConfig) { - try { - const url = new URL(originConfig); + const url = new URL(originConfig); - if (originConfig !== url.origin) { - return `expected a lower-case origin (scheme, host, and optional port) but got: ${originConfig}`; - } - } catch (error) { - return `Invalid origin URI: ${error.message}`; + if (originConfig !== url.origin) { + return `expected a lower-case origin (scheme, host, and optional port) but got: ${originConfig}`; } }, });