Skip to content

Commit 8e36e9f

Browse files
rgodfrey-elastickibanamachineflorent-leborgneelasticmachine
authored andcommitted
Add origin configuration to authc providers (elastic#239993)
Closes [109525](elastic#109525) ## Summary - Added origin configuration to authc providers. - Changed login form to hide providers based on the origin configuration and the current browser window origin. - Filtered providers on the back end based on the origin header and the configured provider origin properties. - Origin configuration is optional and can be one value or an array of values. - All values provided in the origin config must be a valid URI - An error is displayed in the UI if there are no valid auth providers for the domain ### Example 1 ``` xpack.security.authc.providers: basic.basic1: order: 0 origin: [http://127.0.0.1:5601, http://localhost:5601, https://elastic.com] saml.saml1: order: 1 realm: saml1 origin: http://127.0.0.1:5601 saml.saml2: order: 2 realm: saml2 origin: http://localhost:5601 saml.saml3: order: 3 realm: saml3 origin: [http://127.0.0.1:5601, http://localhost:5601, https://elastic.com] saml.saml4: order: 4 realm: saml4 ``` <img width="735" height="585" alt="image" src="https://github.com/user-attachments/assets/d691f692-6470-4d59-aba1-bc598b4b49a2" /> <img width="725" height="597" alt="image" src="https://github.com/user-attachments/assets/28a61462-ef00-484f-b2c9-1816bc50fc54" /> ### Example 2 ``` xpack.security.authc.providers: basic.basic1: order: 0 origin: [http://127.0.0.1:5601, https://elastic.com] saml.saml1: order: 1 realm: saml1 origin: https://elastic.com ``` <img width="772" height="443" alt="image" src="https://github.com/user-attachments/assets/9c332a42-2a48-43ea-b4c5-0d9ab6660b6a" /> ## Release Notes Adds the ability to specify the origin(s) where an authentication provider will appear to users in the Login Selector UI. --------- Co-authored-by: kibanamachine <[email protected]> Co-authored-by: florent-leborgne <[email protected]> Co-authored-by: Elastic Machine <[email protected]>
1 parent 3eb9a63 commit 8e36e9f

File tree

10 files changed

+469
-35
lines changed

10 files changed

+469
-35
lines changed

docs/reference/configuration-reference/security-settings.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,23 @@ xpack.security.authc.providers.<provider-type>.<provider-name>.hint ![logo cloud
7474
xpack.security.authc.providers.<provider-type>.<provider-name>.icon ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}")
7575
: Custom icon for the provider entry displayed on the Login Selector UI.
7676

77+
xpack.security.authc.providers.<provider-type>.<provider-name>.origin {applies_to}`stack: ga 9.3` ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}")
78+
: 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.
79+
80+
For example:
81+
82+
```yaml
83+
xpack.security.authc:
84+
providers:
85+
basic.basic1:
86+
origin: [http://localhost:5601, http://127.0.0.1:5601]
87+
...
88+
89+
saml.saml1:
90+
origin: https://elastic.co
91+
...
92+
```
93+
7794
xpack.security.authc.providers.<provider-type>.<provider-name>.showInSelector ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg "Supported on {{ech}}")
7895
: 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.
7996

x-pack/platform/plugins/shared/security/common/login_state.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface LoginSelectorProvider {
1515
description?: string;
1616
hint?: string;
1717
icon?: string;
18+
origin?: string | string[];
1819
}
1920

2021
export interface LoginSelector {

x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.test.tsx

Lines changed: 183 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,43 +6,59 @@
66
*/
77

88
import { EuiCallOut, EuiIcon, EuiProvider } from '@elastic/eui';
9-
import { act } from '@testing-library/react';
9+
import type { RenderResult } from '@testing-library/react';
10+
import { act, queryByTestId } from '@testing-library/react';
1011
import type { ReactWrapper } from 'enzyme';
1112
import React from 'react';
1213
import ReactMarkdown from 'react-markdown';
1314

1415
import { coreMock } from '@kbn/core/public/mocks';
15-
import { findTestSubject, mountWithIntl, nextTick, shallowWithIntl } from '@kbn/test-jest-helpers';
16+
import { i18n } from '@kbn/i18n';
17+
import {
18+
findTestSubject,
19+
mountWithIntl,
20+
nextTick,
21+
renderWithI18n,
22+
shallowWithIntl,
23+
} from '@kbn/test-jest-helpers';
1624

1725
import { LoginForm, MessageType, PageMode } from './login_form';
1826

27+
function getPageModeAssertions(mode: PageMode): Array<[string, boolean]> {
28+
return mode === PageMode.Form
29+
? [
30+
['loginForm', true],
31+
['loginSelector', false],
32+
['loginHelp', false],
33+
['autoLoginOverlay', false],
34+
]
35+
: mode === PageMode.Selector
36+
? [
37+
['loginForm', false],
38+
['loginSelector', true],
39+
['loginHelp', false],
40+
['autoLoginOverlay', false],
41+
]
42+
: [
43+
['loginForm', false],
44+
['loginSelector', false],
45+
['loginHelp', true],
46+
['autoLoginOverlay', false],
47+
];
48+
}
49+
1950
function expectPageMode(wrapper: ReactWrapper, mode: PageMode) {
20-
const assertions: Array<[string, boolean]> =
21-
mode === PageMode.Form
22-
? [
23-
['loginForm', true],
24-
['loginSelector', false],
25-
['loginHelp', false],
26-
['autoLoginOverlay', false],
27-
]
28-
: mode === PageMode.Selector
29-
? [
30-
['loginForm', false],
31-
['loginSelector', true],
32-
['loginHelp', false],
33-
['autoLoginOverlay', false],
34-
]
35-
: [
36-
['loginForm', false],
37-
['loginSelector', false],
38-
['loginHelp', true],
39-
['autoLoginOverlay', false],
40-
];
41-
for (const [selector, exists] of assertions) {
51+
for (const [selector, exists] of getPageModeAssertions(mode)) {
4252
expect(findTestSubject(wrapper, selector).exists()).toBe(exists);
4353
}
4454
}
4555

56+
function expectPageModeRenderResult(renderResult: RenderResult, mode: PageMode) {
57+
for (const [selector, exists] of getPageModeAssertions(mode)) {
58+
expect(!!renderResult.queryByTestId(selector)).toBe(exists);
59+
}
60+
}
61+
4662
function expectAutoLoginOverlay(wrapper: ReactWrapper) {
4763
// Everything should be hidden except for the overlay
4864
for (const selector of [
@@ -398,6 +414,149 @@ describe('LoginForm', () => {
398414
]);
399415
});
400416

417+
it('does not render providers with origin configs that do not match current page', async () => {
418+
const currentURL = `https://some-host.com/login?next=${encodeURIComponent(
419+
'/some-base-path/app/kibana#/home?_g=()'
420+
)}`;
421+
422+
const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' });
423+
424+
window.location = { ...window.location, href: currentURL, origin: 'https://some-host.com' };
425+
const wrapper = renderWithI18n(
426+
<EuiProvider>
427+
<LoginForm
428+
http={coreStartMock.http}
429+
notifications={coreStartMock.notifications}
430+
loginAssistanceMessage=""
431+
selector={{
432+
enabled: true,
433+
providers: [
434+
{
435+
type: 'basic',
436+
name: 'basic',
437+
usesLoginForm: true,
438+
hint: 'Basic hint',
439+
icon: 'logoElastic',
440+
showInSelector: true,
441+
},
442+
{
443+
type: 'saml',
444+
name: 'saml1',
445+
description: 'Log in w/SAML',
446+
origin: ['https://some-host.com', 'https://some-other-host.com'],
447+
usesLoginForm: false,
448+
showInSelector: true,
449+
},
450+
{
451+
type: 'pki',
452+
name: 'pki1',
453+
description: 'Log in w/PKI',
454+
hint: 'PKI hint',
455+
origin: 'https://not-some-host.com',
456+
usesLoginForm: false,
457+
showInSelector: true,
458+
},
459+
],
460+
}}
461+
/>
462+
</EuiProvider>
463+
);
464+
465+
expect(window.location.origin).toBe('https://some-host.com');
466+
467+
expectPageModeRenderResult(wrapper, PageMode.Selector);
468+
469+
wrapper.queryAllByTestId(/^loginCard-/);
470+
471+
const result = wrapper.queryAllByTestId(/^loginCard-/).map((card) => {
472+
const hint = queryByTestId(card, 'card-hint');
473+
const title = queryByTestId(card, 'card-title');
474+
const icon = card.querySelector('[data-euiicon-type]');
475+
return {
476+
title: title?.textContent ?? '',
477+
hint: hint?.textContent ?? '',
478+
icon: icon?.getAttribute('data-euiicon-type') ?? null,
479+
};
480+
});
481+
482+
expect(result).toEqual([
483+
{ title: 'Log in with basic/basic', hint: 'Basic hint', icon: 'logoElastic' },
484+
{ title: 'Log in w/SAML', hint: '', icon: 'empty' },
485+
]);
486+
});
487+
488+
it('does not render any providers and shows error message if no providers match current origin', async () => {
489+
const currentURL = `https://some-host.com/login?next=${encodeURIComponent(
490+
'/some-base-path/app/kibana#/home?_g=()'
491+
)}`;
492+
493+
window.location = {
494+
...window.location,
495+
href: currentURL,
496+
origin: 'https://some-host.com',
497+
};
498+
499+
const coreStartMock = coreMock.createStart({
500+
basePath: '/some-base-path',
501+
});
502+
503+
const rendered = renderWithI18n(
504+
<EuiProvider>
505+
<LoginForm
506+
http={coreStartMock.http}
507+
notifications={coreStartMock.notifications}
508+
loginAssistanceMessage=""
509+
selector={{
510+
enabled: true,
511+
providers: [
512+
{
513+
type: 'basic',
514+
name: 'basic',
515+
usesLoginForm: true,
516+
hint: 'Basic hint',
517+
icon: 'logoElastic',
518+
origin: 'https://not-some-host.com',
519+
showInSelector: true,
520+
},
521+
{
522+
type: 'saml',
523+
name: 'saml1',
524+
description: 'Log in w/SAML',
525+
origin: ['https://not-some-host.com', 'https://not-some-other-host.com'],
526+
usesLoginForm: false,
527+
showInSelector: true,
528+
},
529+
{
530+
type: 'pki',
531+
name: 'pki1',
532+
description: 'Log in w/PKI',
533+
hint: 'PKI hint',
534+
origin: 'https://not-some-host.com',
535+
usesLoginForm: false,
536+
showInSelector: true,
537+
},
538+
],
539+
}}
540+
/>
541+
</EuiProvider>
542+
);
543+
544+
expect(window.location.origin).toBe('https://some-host.com');
545+
546+
expect(rendered.queryByTestId('loginForm')).toBeFalsy();
547+
expect(rendered.queryByTestId('loginSelector')).toBeFalsy();
548+
expect(rendered.queryByTestId('loginHelp')).toBeFalsy();
549+
expect(rendered.queryByTestId('autoLoginOverlay')).toBeFalsy();
550+
expect(rendered.queryAllByTestId(/^loginCard-/).length).toBe(0);
551+
552+
expect((await rendered.findByTestId('loginErrorMessage')).textContent).toEqual(
553+
i18n.translate('xpack.security.noAuthProvidersForDomain', {
554+
defaultMessage:
555+
'No authentication providers have been configured for this origin (https://some-host.com).',
556+
})
557+
);
558+
});
559+
401560
it('properly redirects after successful login', async () => {
402561
const currentURL = `https://some-host/login?next=${encodeURIComponent(
403562
'/some-base-path/app/kibana#/home?_g=()'

x-pack/platform/plugins/shared/security/public/authentication/login/components/login_form/login_form.tsx

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,18 @@ const assistanceCss = (theme: UseEuiTheme) => css`
131131
`;
132132

133133
export class LoginForm extends Component<LoginFormProps, State> {
134+
private readonly noProvidersMessage = i18n.translate('xpack.security.noAuthProvidersForDomain', {
135+
defaultMessage: 'No authentication providers have been configured for this origin ({origin}).',
136+
values: { origin: window.location.origin },
137+
});
138+
134139
private readonly validator: LoginValidator;
135140

141+
/**
142+
* Available providers that match the current origin.
143+
*/
144+
private readonly availableProviders: LoginSelectorProvider[];
145+
136146
/**
137147
* Optional provider that was suggested by the `auth_provider_hint={providerName}` query string parameter. If provider
138148
* 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<LoginFormProps, State> {
142152

143153
constructor(props: LoginFormProps) {
144154
super(props);
155+
156+
this.availableProviders = this.props.selector.providers.filter((provider) =>
157+
this.providerMatchesOrigin(provider)
158+
);
159+
145160
this.validator = new LoginValidator({ shouldValidate: false });
146161

147162
this.suggestedProvider = this.props.authProviderHint
148-
? this.props.selector.providers.find(({ name }) => name === this.props.authProviderHint)
163+
? this.availableProviders.find(({ name }) => name === this.props.authProviderHint)
149164
: undefined;
150165

151166
// Switch to the Form mode right away if provider from the hint requires it.
@@ -158,7 +173,14 @@ export class LoginForm extends Component<LoginFormProps, State> {
158173
loadingState: { type: LoadingStateType.None },
159174
username: '',
160175
password: '',
161-
message: this.props.message || { type: MessageType.None },
176+
message:
177+
this.props.message ??
178+
(this.availableProviders.length === 0
179+
? {
180+
type: MessageType.Danger,
181+
content: this.noProvidersMessage,
182+
}
183+
: { type: MessageType.None }),
162184
mode,
163185
previousMode: mode,
164186
};
@@ -238,6 +260,10 @@ export class LoginForm extends Component<LoginFormProps, State> {
238260
};
239261

240262
public renderContent() {
263+
if (this.availableProviders.length === 0) {
264+
return;
265+
}
266+
241267
switch (this.state.mode) {
242268
case PageMode.Form:
243269
return this.renderLoginForm();
@@ -341,7 +367,8 @@ export class LoginForm extends Component<LoginFormProps, State> {
341367
};
342368

343369
private renderSelector = () => {
344-
const providers = this.props.selector.providers.filter((p) => p.showInSelector);
370+
const providers = this.availableProviders.filter((p) => p.showInSelector);
371+
345372
return (
346373
<EuiPanel data-test-subj="loginSelector" paddingSize="none">
347374
{providers.map((provider) => {
@@ -515,9 +542,7 @@ export class LoginForm extends Component<LoginFormProps, State> {
515542
});
516543

517544
// We try to log in with the provider that uses login form and has the lowest order.
518-
const providerToLoginWith = this.props.selector.providers.find(
519-
(provider) => provider.usesLoginForm
520-
)!;
545+
const providerToLoginWith = this.availableProviders.find((provider) => provider.usesLoginForm)!;
521546

522547
try {
523548
const { location } = await this.props.http.post<{ location: string }>(
@@ -605,9 +630,17 @@ export class LoginForm extends Component<LoginFormProps, State> {
605630
private showLoginSelector() {
606631
return (
607632
this.props.selector.enabled &&
608-
this.props.selector.providers.some(
609-
(provider) => !provider.usesLoginForm && provider.showInSelector
610-
)
633+
this.availableProviders.some((provider) => !provider.usesLoginForm && provider.showInSelector)
634+
);
635+
}
636+
637+
private providerMatchesOrigin(provider: LoginSelectorProvider): boolean {
638+
const { origin } = window.location;
639+
return (
640+
!provider.origin ||
641+
(Array.isArray(provider.origin)
642+
? provider.origin.includes(origin)
643+
: provider.origin === origin)
611644
);
612645
}
613646
}

0 commit comments

Comments
 (0)