diff --git a/docs/reference/configuration-reference/security-settings.md b/docs/reference/configuration-reference/security-settings.md index b01ab3dd8788f..f3f9d55e1b37a 100644 --- a/docs/reference/configuration-reference/security-settings.md +++ b/docs/reference/configuration-reference/security-settings.md @@ -74,6 +74,23 @@ 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 {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 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: + + ```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/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.test.tsx b/x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx index b86326fbeedcd..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 @@ -6,43 +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 { findTestSubject, mountWithIntl, nextTick, shallowWithIntl } from '@kbn/test-jest-helpers'; +import { i18n } from '@kbn/i18n'; +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 [ @@ -398,6 +414,149 @@ describe('LoginForm', () => { ]); }); + 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=()' + )}`; + + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + + window.location = { ...window.location, href: currentURL, origin: 'https://some-host.com' }; + const wrapper = renderWithI18n( + + + + ); + + expect(window.location.origin).toBe('https://some-host.com'); + + expectPageModeRenderResult(wrapper, PageMode.Selector); + + 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: title?.textContent ?? '', + hint: hint?.textContent ?? '', + icon: icon?.getAttribute('data-euiicon-type') ?? null, + }; + }); + + 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=()' + )}`; + + window.location = { + ...window.location, + href: currentURL, + origin: 'https://some-host.com', + }; + + const coreStartMock = coreMock.createStart({ + basePath: '/some-base-path', + }); + + const rendered = renderWithI18n( + + + + ); + + expect(window.location.origin).toBe('https://some-host.com'); + + 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((await rendered.findByTestId('loginErrorMessage')).textContent).toEqual( + i18n.translate('xpack.security.noAuthProvidersForDomain', { + defaultMessage: + 'No authentication providers have been configured for this origin (https://some-host.com).', + }) + ); + }); + 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 819c395221971..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 @@ -131,8 +131,18 @@ const assistanceCss = (theme: UseEuiTheme) => css` `; 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; + /** + * 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 +152,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 +173,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: this.noProvidersMessage, + } + : { type: MessageType.None }), mode, previousMode: mode, }; @@ -238,6 +260,10 @@ export class LoginForm extends Component { }; public renderContent() { + if (this.availableProviders.length === 0) { + return; + } + switch (this.state.mode) { case PageMode.Form: return this.renderLoginForm(); @@ -341,7 +367,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) => { @@ -515,9 +542,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 }>( @@ -605,9 +630,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/config.test.ts b/x-pack/platform/plugins/shared/security/server/config.test.ts index a4e89472cb352..2245cb9cab2f9 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,121 @@ 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 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'; + + 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({ diff --git a/x-pack/platform/plugins/shared/security/server/config.ts b/x-pack/platform/plugins/shared/security/server/config.ts index 5c5ac7c12944e..ec57370821ba7 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 }>; } @@ -41,6 +42,16 @@ const providerOptionsSchema = (providerType: string, optionsSchema: Type) = schema.never() ); +const providerOriginSchema = schema.uri({ + validate(originConfig) { + const url = new URL(originConfig); + + if (originConfig !== url.origin) { + return `expected a lower-case origin (scheme, host, and optional port) but got: ${originConfig}`; + } + }, +}); + function getCommonProviderSchemaProperties(overrides: Partial = {}) { return { enabled: schema.boolean({ defaultValue: true }), @@ -49,6 +60,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/plugins/shared/security/server/routes/views/login.ts b/x-pack/platform/plugins/shared/security/server/routes/views/login.ts index 93eeafe2cc77f..47d19826e4224 100644 --- a/x-pack/platform/plugins/shared/security/server/routes/views/login.ts +++ b/x-pack/platform/plugins/shared/security/server/routes/views/login.ts @@ -85,7 +85,9 @@ export function defineLoginRoutes({ const providers = sortedProviders.map(({ type, name }) => { // 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, }; }); 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..230baa8c57178 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,12 +85,17 @@ 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, 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, @@ -98,6 +103,23 @@ 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', + }, + 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 49bc6038d88c8..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 @@ -274,5 +274,13 @@ 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/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); + }); }); }