diff --git a/x-pack/plugins/security/common/login_state.ts b/x-pack/plugins/security/common/login_state.ts
index ad65d36a6c441..35c468a6784e5 100644
--- a/x-pack/plugins/security/common/login_state.ts
+++ b/x-pack/plugins/security/common/login_state.ts
@@ -8,11 +8,7 @@ import { LoginLayout } from './licensing';
interface LoginSelector {
enabled: boolean;
- providers: Array<{
- type: string;
- name: string;
- options: { description?: string; order: number };
- }>;
+ providers: Array<{ type: string; name: string; description?: string }>;
}
export interface LoginState {
diff --git a/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap
index f8dfdadd64259..2dadfd7e69ce2 100644
--- a/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap
+++ b/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap
@@ -23,7 +23,7 @@ exports[`LoginPage disabled form states renders as expected when an unknown logi
diff --git a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx
index e191bfa92f9bf..c4a66014e567f 100644
--- a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx
+++ b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx
@@ -261,8 +261,8 @@ describe('LoginPage', () => {
selector: {
enabled: true,
providers: [
- { type: 'saml', name: 'saml1', options: { description: 'Login w/SAML', order: 0 } },
- { type: 'pki', name: 'pki1', options: { description: 'Login w/PKI', order: 1 } },
+ { type: 'saml', name: 'saml1', description: 'Login w/SAML' },
+ { type: 'pki', name: 'pki1', description: 'Login w/PKI' },
],
},
})
@@ -294,8 +294,8 @@ describe('LoginPage', () => {
selector: {
enabled: true,
providers: [
- { type: 'saml', name: 'saml1', options: { description: 'Login w/SAML', order: 0 } },
- { type: 'pki', name: 'pki1', options: { description: 'Login w/PKI', order: 1 } },
+ { type: 'saml', name: 'saml1', description: 'Login w/SAML' },
+ { type: 'pki', name: 'pki1', description: 'Login w/PKI' },
],
},
})
diff --git a/x-pack/plugins/security/public/authentication/login/login_page.tsx b/x-pack/plugins/security/public/authentication/login/login_page.tsx
index 04e951c96f08c..ab18396706797 100644
--- a/x-pack/plugins/security/public/authentication/login/login_page.tsx
+++ b/x-pack/plugins/security/public/authentication/login/login_page.tsx
@@ -20,13 +20,7 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
-import {
- CoreStart,
- FatalErrorsStart,
- HttpStart,
- IHttpFetchError,
- NotificationsStart,
-} from 'src/core/public';
+import { CoreStart, FatalErrorsStart, HttpStart, NotificationsStart } from 'src/core/public';
import { LoginState } from '../../../common/login_state';
import { BasicLoginForm, DisabledLoginForm } from './components';
@@ -225,7 +219,7 @@ export class LoginPage extends Component {
message={
}
/>
@@ -242,7 +236,16 @@ export class LoginPage extends Component {
fullWidth={true}
onClick={() => this.login(provider.type, provider.name)}
>
- {provider.options.description ?? `${provider.type}/${provider.name}`}
+ {provider.description ?? (
+
+ )}
));
@@ -277,18 +280,17 @@ export class LoginPage extends Component {
private login = async (providerType: string, providerName: string) => {
try {
const { location } = await this.props.http.post<{ location: string }>(
- `${this.props.http.basePath.serverBasePath}/internal/security/login_with`,
+ '/internal/security/login_with',
{ body: JSON.stringify({ providerType, providerName, currentURL: window.location.href }) }
);
window.location.href = location;
} catch (err) {
- this.props.notifications.toasts.addDanger(
- i18n.translate('xpack.security.loginPage.loginSelectorErrorMessage', {
- defaultMessage: 'Could not perform login: {message}. Contact your system administrator.',
- values: { message: (err as IHttpFetchError).message },
- })
- );
+ this.props.notifications.toasts.addError(err, {
+ title: i18n.translate('xpack.security.loginPage.loginSelectorErrorMessage', {
+ defaultMessage: 'Could not perform login.',
+ }),
+ });
}
};
}
diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts
index 03483e66da79d..5dc971c8fc6f0 100644
--- a/x-pack/plugins/security/server/authentication/authenticator.test.ts
+++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts
@@ -5,6 +5,7 @@
*/
jest.mock('./providers/basic');
+jest.mock('./providers/token');
jest.mock('./providers/saml');
jest.mock('./providers/http');
@@ -24,7 +25,7 @@ import { ConfigSchema, createConfig } from '../config';
import { AuthenticationResult } from './authentication_result';
import { Authenticator, AuthenticatorOptions, ProviderSession } from './authenticator';
import { DeauthenticationResult } from './deauthentication_result';
-import { BasicAuthenticationProvider } from './providers';
+import { BasicAuthenticationProvider, SAMLAuthenticationProvider } from './providers';
function getMockOptions({
session,
@@ -32,7 +33,7 @@ function getMockOptions({
http = {},
}: {
session?: AuthenticatorOptions['config']['session'];
- providers?: Record;
+ providers?: Record | string[];
http?: Partial;
} = {}) {
return {
@@ -86,6 +87,19 @@ describe('Authenticator', () => {
);
});
+ it('fails if configured authentication provider is not known.', () => {
+ expect(() => new Authenticator(getMockOptions({ providers: ['super-basic'] }))).toThrowError(
+ 'Unsupported authentication provider name: super-basic.'
+ );
+ });
+
+ it('fails if any of the user specified provider uses reserved __http__ name.', () => {
+ expect(
+ () =>
+ new Authenticator(getMockOptions({ providers: { basic: { __http__: { order: 0 } } } }))
+ ).toThrowError('Provider name "__http__" is reserved.');
+ });
+
describe('HTTP authentication provider', () => {
beforeEach(() => {
jest
@@ -186,7 +200,7 @@ describe('Authenticator', () => {
);
});
- it('fails if login attempt is not provided.', async () => {
+ it('fails if login attempt is not provided or invalid.', async () => {
await expect(
authenticator.login(httpServerMock.createKibanaRequest(), undefined as any)
).rejects.toThrowError(
@@ -198,6 +212,15 @@ describe('Authenticator', () => {
).rejects.toThrowError(
'Login attempt should be an object with non-empty "provider.type" or "provider.name" property.'
);
+
+ await expect(
+ authenticator.login(httpServerMock.createKibanaRequest(), {
+ provider: 'basic',
+ value: {},
+ } as any)
+ ).rejects.toThrowError(
+ 'Login attempt should be an object with non-empty "provider.type" or "provider.name" property.'
+ );
});
it('fails if an authentication provider fails.', async () => {
@@ -253,6 +276,167 @@ describe('Authenticator', () => {
await expect(
authenticator.login(request, { provider: { type: 'token' }, value: {} })
).resolves.toEqual(AuthenticationResult.notHandled());
+
+ await expect(
+ authenticator.login(request, { provider: { name: 'basic2' }, value: {} })
+ ).resolves.toEqual(AuthenticationResult.notHandled());
+ });
+
+ describe('multi-provider scenarios', () => {
+ let mockSAMLAuthenticationProvider1: jest.Mocked>;
+ let mockSAMLAuthenticationProvider2: jest.Mocked>;
+
+ beforeEach(() => {
+ mockSAMLAuthenticationProvider1 = {
+ login: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()),
+ authenticate: jest.fn(),
+ logout: jest.fn(),
+ getHTTPAuthenticationScheme: jest.fn(),
+ };
+
+ mockSAMLAuthenticationProvider2 = {
+ login: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()),
+ authenticate: jest.fn(),
+ logout: jest.fn(),
+ getHTTPAuthenticationScheme: jest.fn(),
+ };
+
+ jest
+ .requireMock('./providers/saml')
+ .SAMLAuthenticationProvider.mockImplementationOnce(() => ({
+ type: 'saml',
+ ...mockSAMLAuthenticationProvider1,
+ }))
+ .mockImplementationOnce(() => ({
+ type: 'saml',
+ ...mockSAMLAuthenticationProvider2,
+ }));
+
+ mockOptions = getMockOptions({
+ providers: {
+ basic: { basic1: { order: 0 } },
+ saml: {
+ saml1: { realm: 'saml1-realm', order: 1 },
+ saml2: { realm: 'saml2-realm', order: 2 },
+ },
+ },
+ });
+ mockSessionStorage = sessionStorageMock.create();
+ mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage);
+
+ authenticator = new Authenticator(mockOptions);
+ });
+
+ it('tries to login only with the provider that has specified name', async () => {
+ const user = mockAuthenticatedUser();
+ const request = httpServerMock.createKibanaRequest();
+
+ mockSAMLAuthenticationProvider2.login.mockResolvedValue(
+ AuthenticationResult.succeeded(user, { state: { token: 'access-token' } })
+ );
+
+ await expect(
+ authenticator.login(request, { provider: { name: 'saml2' }, value: {} })
+ ).resolves.toEqual(
+ AuthenticationResult.succeeded(user, { state: { token: 'access-token' } })
+ );
+
+ expect(mockSessionStorage.set).toHaveBeenCalledTimes(1);
+ expect(mockSessionStorage.set).toHaveBeenCalledWith({
+ ...mockSessVal,
+ provider: { type: 'saml', name: 'saml2' },
+ state: { token: 'access-token' },
+ });
+
+ expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled();
+ expect(mockSAMLAuthenticationProvider1.login).not.toHaveBeenCalled();
+ });
+
+ it('tries to login only with the provider that has specified type', async () => {
+ const request = httpServerMock.createKibanaRequest();
+
+ await expect(
+ authenticator.login(request, { provider: { type: 'saml' }, value: {} })
+ ).resolves.toEqual(AuthenticationResult.notHandled());
+
+ expect(mockSessionStorage.set).not.toHaveBeenCalled();
+
+ expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled();
+ expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledTimes(1);
+ expect(mockSAMLAuthenticationProvider2.login).toHaveBeenCalledTimes(1);
+ expect(mockSAMLAuthenticationProvider1.login.mock.invocationCallOrder[0]).toBeLessThan(
+ mockSAMLAuthenticationProvider2.login.mock.invocationCallOrder[0]
+ );
+ });
+
+ it('returns as soon as provider handles request', async () => {
+ const request = httpServerMock.createKibanaRequest();
+
+ const authenticationResults = [
+ AuthenticationResult.failed(new Error('Fail')),
+ AuthenticationResult.succeeded(mockAuthenticatedUser(), { state: { result: '200' } }),
+ AuthenticationResult.redirectTo('/some/url', { state: { result: '302' } }),
+ ];
+
+ for (const result of authenticationResults) {
+ mockSAMLAuthenticationProvider1.login.mockResolvedValue(result);
+
+ await expect(
+ authenticator.login(request, { provider: { type: 'saml' }, value: {} })
+ ).resolves.toEqual(result);
+ }
+
+ expect(mockSessionStorage.set).toHaveBeenCalledTimes(2);
+ expect(mockSessionStorage.set).toHaveBeenCalledWith({
+ ...mockSessVal,
+ provider: { type: 'saml', name: 'saml1' },
+ state: { result: '200' },
+ });
+ expect(mockSessionStorage.set).toHaveBeenCalledWith({
+ ...mockSessVal,
+ provider: { type: 'saml', name: 'saml1' },
+ state: { result: '302' },
+ });
+
+ expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled();
+ expect(mockSAMLAuthenticationProvider2.login).not.toHaveBeenCalled();
+ expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledTimes(3);
+ });
+
+ it('provides session only if provider name matches', async () => {
+ const request = httpServerMock.createKibanaRequest();
+
+ mockSessionStorage.get.mockResolvedValue({
+ ...mockSessVal,
+ provider: { type: 'saml', name: 'saml2' },
+ });
+
+ const loginAttemptValue = Symbol('attempt');
+ await expect(
+ authenticator.login(request, { provider: { type: 'saml' }, value: loginAttemptValue })
+ ).resolves.toEqual(AuthenticationResult.notHandled());
+
+ expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled();
+
+ expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledTimes(1);
+ expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledWith(
+ request,
+ loginAttemptValue,
+ null
+ );
+
+ expect(mockSAMLAuthenticationProvider2.login).toHaveBeenCalledTimes(1);
+ expect(mockSAMLAuthenticationProvider2.login).toHaveBeenCalledWith(
+ request,
+ loginAttemptValue,
+ mockSessVal.state
+ );
+
+ // Presence of the session has precedence over order.
+ expect(mockSAMLAuthenticationProvider2.login.mock.invocationCallOrder[0]).toBeLessThan(
+ mockSAMLAuthenticationProvider1.login.mock.invocationCallOrder[0]
+ );
+ });
});
it('clears session if it belongs to a different provider.', async () => {
@@ -261,7 +445,10 @@ describe('Authenticator', () => {
const request = httpServerMock.createKibanaRequest();
mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user));
- mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'token' });
+ mockSessionStorage.get.mockResolvedValue({
+ ...mockSessVal,
+ provider: { type: 'token', name: 'token1' },
+ });
await expect(
authenticator.login(request, { provider: { type: 'basic' }, value: credentials })
@@ -277,6 +464,35 @@ describe('Authenticator', () => {
expect(mockSessionStorage.clear).toHaveBeenCalled();
});
+ it('clears session if it belongs to a provider with the name that is registered but has different type.', async () => {
+ const user = mockAuthenticatedUser();
+ const credentials = { username: 'user', password: 'password' };
+ const request = httpServerMock.createKibanaRequest();
+
+ // Re-configure authenticator with `token` provider that uses the name of `basic`.
+ const loginMock = jest.fn().mockResolvedValue(AuthenticationResult.succeeded(user));
+ jest.requireMock('./providers/token').TokenAuthenticationProvider.mockImplementation(() => ({
+ type: 'token',
+ login: loginMock,
+ getHTTPAuthenticationScheme: jest.fn(),
+ }));
+ mockOptions = getMockOptions({ providers: { token: { basic1: { order: 0 } } } });
+ mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage);
+ authenticator = new Authenticator(mockOptions);
+
+ mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user));
+ mockSessionStorage.get.mockResolvedValue(mockSessVal);
+
+ await expect(
+ authenticator.login(request, { provider: { name: 'basic1' }, value: credentials })
+ ).resolves.toEqual(AuthenticationResult.succeeded(user));
+
+ expect(loginMock).toHaveBeenCalledWith(request, credentials, null);
+
+ expect(mockSessionStorage.set).not.toHaveBeenCalled();
+ expect(mockSessionStorage.clear).toHaveBeenCalled();
+ });
+
it('clears session if provider asked to do so.', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest();
@@ -759,7 +975,10 @@ describe('Authenticator', () => {
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.notHandled()
);
- mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'token' });
+ mockSessionStorage.get.mockResolvedValue({
+ ...mockSessVal,
+ provider: { type: 'token', name: 'token1' },
+ });
await expect(authenticator.authenticate(request)).resolves.toEqual(
AuthenticationResult.notHandled()
@@ -777,7 +996,10 @@ describe('Authenticator', () => {
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.notHandled()
);
- mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'token' });
+ mockSessionStorage.get.mockResolvedValue({
+ ...mockSessVal,
+ provider: { type: 'token', name: 'token1' },
+ });
await expect(authenticator.authenticate(request)).resolves.toEqual(
AuthenticationResult.notHandled()
diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts
index 3eaa8fcee0d9c..6eb47cfa83e32 100644
--- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts
+++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts
@@ -14,6 +14,7 @@ import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions }
import {
ElasticsearchErrorHelpers,
IClusterClient,
+ KibanaRequest,
ScopeableRequest,
} from '../../../../../../src/core/server';
import { AuthenticationResult } from '../authentication_result';
@@ -40,39 +41,9 @@ describe('KerberosAuthenticationProvider', () => {
provider = new KerberosAuthenticationProvider(mockOptions);
});
- describe('`authenticate` method', () => {
- it('does not handle authentication via `authorization` header with non-negotiate scheme.', async () => {
- const request = httpServerMock.createKibanaRequest({
- headers: { authorization: 'Bearer some-token' },
- });
-
- await expect(provider.authenticate(request)).resolves.toEqual(
- AuthenticationResult.notHandled()
- );
-
- expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
- expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
- expect(request.headers.authorization).toBe('Bearer some-token');
- });
-
- it('does not handle authentication via `authorization` header with non-negotiate scheme even if state contains a valid token.', async () => {
- const request = httpServerMock.createKibanaRequest({
- headers: { authorization: 'Bearer some-token' },
- });
- const tokenPair = {
- accessToken: 'some-valid-token',
- refreshToken: 'some-valid-refresh-token',
- };
-
- await expect(provider.authenticate(request, tokenPair)).resolves.toEqual(
- AuthenticationResult.notHandled()
- );
-
- expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
- expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
- expect(request.headers.authorization).toBe('Bearer some-token');
- });
-
+ function defineCommonLoginAndAuthenticateTests(
+ operation: (request: KibanaRequest) => Promise
+ ) {
it('does not handle requests that can be authenticated without `Negotiate` header.', async () => {
const request = httpServerMock.createKibanaRequest({ headers: {} });
@@ -80,9 +51,7 @@ describe('KerberosAuthenticationProvider', () => {
mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({});
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
- await expect(provider.authenticate(request, null)).resolves.toEqual(
- AuthenticationResult.notHandled()
- );
+ await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled());
expectAuthenticateCall(mockOptions.client, {
headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` },
@@ -98,33 +67,13 @@ describe('KerberosAuthenticationProvider', () => {
);
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
- await expect(provider.authenticate(request, null)).resolves.toEqual(
- AuthenticationResult.notHandled()
- );
+ await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled());
expectAuthenticateCall(mockOptions.client, {
headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` },
});
});
- it('fails if state is present, but backend does not support Kerberos.', async () => {
- const request = httpServerMock.createKibanaRequest();
- const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' };
-
- const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error());
- const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
- mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason);
- mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
- mockOptions.tokens.refresh.mockResolvedValue(null);
-
- await expect(provider.authenticate(request, tokenPair)).resolves.toEqual(
- AuthenticationResult.failed(failureReason)
- );
-
- expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1);
- expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken);
- });
-
it('fails with `Negotiate` challenge if backend supports Kerberos.', async () => {
const request = httpServerMock.createKibanaRequest({ headers: {} });
@@ -137,7 +86,7 @@ describe('KerberosAuthenticationProvider', () => {
mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason);
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
- await expect(provider.authenticate(request, null)).resolves.toEqual(
+ await expect(operation(request)).resolves.toEqual(
AuthenticationResult.failed(failureReason, {
authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' },
})
@@ -156,9 +105,7 @@ describe('KerberosAuthenticationProvider', () => {
mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason);
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
- await expect(provider.authenticate(request, null)).resolves.toEqual(
- AuthenticationResult.failed(failureReason)
- );
+ await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason));
expectAuthenticateCall(mockOptions.client, {
headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` },
@@ -179,7 +126,7 @@ describe('KerberosAuthenticationProvider', () => {
refresh_token: 'some-refresh-token',
});
- await expect(provider.authenticate(request)).resolves.toEqual(
+ await expect(operation(request)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'kerberos' },
{
@@ -215,7 +162,7 @@ describe('KerberosAuthenticationProvider', () => {
kerberos_authentication_response_token: 'response-token',
});
- await expect(provider.authenticate(request)).resolves.toEqual(
+ await expect(operation(request)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'kerberos' },
{
@@ -249,7 +196,7 @@ describe('KerberosAuthenticationProvider', () => {
);
mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason);
- await expect(provider.authenticate(request)).resolves.toEqual(
+ await expect(operation(request)).resolves.toEqual(
AuthenticationResult.failed(Boom.unauthorized(), {
authResponseHeaders: { 'WWW-Authenticate': 'Negotiate response-token' },
})
@@ -274,7 +221,7 @@ describe('KerberosAuthenticationProvider', () => {
);
mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason);
- await expect(provider.authenticate(request)).resolves.toEqual(
+ await expect(operation(request)).resolves.toEqual(
AuthenticationResult.failed(Boom.unauthorized(), {
authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' },
})
@@ -295,9 +242,7 @@ describe('KerberosAuthenticationProvider', () => {
const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error());
mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason);
- await expect(provider.authenticate(request)).resolves.toEqual(
- AuthenticationResult.failed(failureReason)
- );
+ await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason));
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', {
body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' },
@@ -320,9 +265,7 @@ describe('KerberosAuthenticationProvider', () => {
refresh_token: 'some-refresh-token',
});
- await expect(provider.authenticate(request)).resolves.toEqual(
- AuthenticationResult.failed(failureReason)
- );
+ await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason));
expectAuthenticateCall(mockOptions.client, {
headers: { authorization: 'Bearer some-token' },
@@ -334,6 +277,74 @@ describe('KerberosAuthenticationProvider', () => {
expect(request.headers.authorization).toBe('negotiate spnego');
});
+ }
+
+ describe('`login` method', () => {
+ defineCommonLoginAndAuthenticateTests(request => provider.login(request));
+ });
+
+ describe('`authenticate` method', () => {
+ defineCommonLoginAndAuthenticateTests(request => provider.authenticate(request, null));
+
+ it('does not handle authentication via `authorization` header with non-negotiate scheme.', async () => {
+ const request = httpServerMock.createKibanaRequest({
+ headers: { authorization: 'Bearer some-token' },
+ });
+
+ await expect(provider.authenticate(request)).resolves.toEqual(
+ AuthenticationResult.notHandled()
+ );
+
+ expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
+ expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
+ expect(request.headers.authorization).toBe('Bearer some-token');
+ });
+
+ it('does not handle authentication via `authorization` header with non-negotiate scheme even if state contains a valid token.', async () => {
+ const request = httpServerMock.createKibanaRequest({
+ headers: { authorization: 'Bearer some-token' },
+ });
+ const tokenPair = {
+ accessToken: 'some-valid-token',
+ refreshToken: 'some-valid-refresh-token',
+ };
+
+ await expect(provider.authenticate(request, tokenPair)).resolves.toEqual(
+ AuthenticationResult.notHandled()
+ );
+
+ expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
+ expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
+ expect(request.headers.authorization).toBe('Bearer some-token');
+ });
+
+ it('fails if state is present, but backend does not support Kerberos.', async () => {
+ const request = httpServerMock.createKibanaRequest();
+ const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' };
+
+ const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error());
+ const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
+ mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason);
+ mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
+ mockOptions.tokens.refresh.mockResolvedValue(null);
+
+ await expect(provider.authenticate(request, tokenPair)).resolves.toEqual(
+ AuthenticationResult.failed(failureReason)
+ );
+
+ expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1);
+ expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken);
+ });
+
+ it('does not start SPNEGO if request does not require authentication.', async () => {
+ const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false });
+ await expect(provider.authenticate(request)).resolves.toEqual(
+ AuthenticationResult.notHandled()
+ );
+
+ expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
+ expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
+ });
it('succeeds if state contains a valid token.', async () => {
const user = mockAuthenticatedUser();
@@ -454,6 +465,29 @@ describe('KerberosAuthenticationProvider', () => {
expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1);
expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken);
});
+
+ it('does not re-start SPNEGO if both access and refresh tokens from the state are expired.', async () => {
+ const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false });
+ const tokenPair = { accessToken: 'expired-token', refreshToken: 'some-valid-refresh-token' };
+
+ const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(
+ new (errors.AuthenticationException as any)('Unauthorized', {
+ body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } },
+ })
+ );
+ const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
+ mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason);
+ mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
+
+ mockOptions.tokens.refresh.mockResolvedValue(null);
+
+ await expect(provider.authenticate(request, tokenPair)).resolves.toEqual(
+ AuthenticationResult.notHandled()
+ );
+
+ expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1);
+ expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken);
+ });
});
describe('`logout` method', () => {
diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts
index 963543fae47cb..54c162391dbd0 100644
--- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts
+++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts
@@ -258,9 +258,7 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider {
// If refresh token is no longer valid, then we should clear session and renegotiate using SPNEGO.
if (refreshedTokenPair === null) {
- this.logger.debug(
- 'Both access and refresh tokens are expired. Re-initiating SPNEGO handshake.'
- );
+ this.logger.debug('Both access and refresh tokens are expired.');
return canStartNewSession(request)
? this.authenticateViaSPNEGO(request, state)
: AuthenticationResult.notHandled();
diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts
index 52f695f538688..14fe42aac7599 100644
--- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts
+++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts
@@ -100,6 +100,50 @@ describe('OIDCAuthenticationProvider', () => {
});
});
+ it('redirects user initiated login attempts to the OpenId Connect Provider.', async () => {
+ const request = httpServerMock.createKibanaRequest();
+
+ mockOptions.client.callAsInternalUser.mockResolvedValue({
+ state: 'statevalue',
+ nonce: 'noncevalue',
+ redirect:
+ 'https://op-host/path/login?response_type=code' +
+ '&scope=openid%20profile%20email' +
+ '&client_id=s6BhdRkqt3' +
+ '&state=statevalue' +
+ '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' +
+ '&login_hint=loginhint',
+ });
+
+ await expect(
+ provider.login(request, {
+ type: OIDCLogin.LoginInitiatedByUser,
+ redirectURLPath: '/mock-server-basepath/app/super-kibana',
+ })
+ ).resolves.toEqual(
+ AuthenticationResult.redirectTo(
+ 'https://op-host/path/login?response_type=code' +
+ '&scope=openid%20profile%20email' +
+ '&client_id=s6BhdRkqt3' +
+ '&state=statevalue' +
+ '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' +
+ '&login_hint=loginhint',
+ {
+ state: {
+ state: 'statevalue',
+ nonce: 'noncevalue',
+ nextURL: '/mock-server-basepath/app/super-kibana',
+ realm: 'oidc1',
+ },
+ }
+ )
+ );
+
+ expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', {
+ body: { realm: 'oidc1' },
+ });
+ });
+
function defineAuthenticationFlowTests(
getMocks: () => {
request: KibanaRequest;
@@ -224,6 +268,20 @@ describe('OIDCAuthenticationProvider', () => {
}
);
});
+
+ it('fails if realm from state is different from the realm provider is configured with.', async () => {
+ const { request, attempt } = getMocks();
+
+ await expect(provider.login(request, attempt, { realm: 'other-realm' })).resolves.toEqual(
+ AuthenticationResult.failed(
+ Boom.unauthorized(
+ 'State based on realm "other-realm", but provider with the name "oidc" is configured to use realm "oidc1".'
+ )
+ )
+ );
+
+ expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
+ });
}
describe('authorization code flow', () => {
@@ -263,6 +321,13 @@ describe('OIDCAuthenticationProvider', () => {
);
});
+ it('does not handle non-AJAX request that does not require authentication.', async () => {
+ const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false });
+ await expect(provider.authenticate(request)).resolves.toEqual(
+ AuthenticationResult.notHandled()
+ );
+ });
+
it('redirects non-AJAX request that can not be authenticated to the OpenId Connect Provider.', async () => {
const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' });
@@ -560,6 +625,44 @@ describe('OIDCAuthenticationProvider', () => {
expect(request.headers).not.toHaveProperty('authorization');
});
+
+ it('fails for non-AJAX requests that do not require authentication with user friendly message if refresh token is expired.', async () => {
+ const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false, headers: {} });
+ const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' };
+ const authorization = `Bearer ${tokenPair.accessToken}`;
+
+ const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
+ mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(
+ ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())
+ );
+ mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
+
+ mockOptions.tokens.refresh.mockResolvedValue(null);
+
+ await expect(
+ provider.authenticate(request, { ...tokenPair, realm: 'oidc1' })
+ ).resolves.toEqual(
+ AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.'))
+ );
+
+ expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1);
+ expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken);
+
+ expectAuthenticateCall(mockOptions.client, { headers: { authorization } });
+
+ expect(request.headers).not.toHaveProperty('authorization');
+ });
+
+ it('fails if realm from state is different from the realm provider is configured with.', async () => {
+ const request = httpServerMock.createKibanaRequest();
+ await expect(provider.authenticate(request, { realm: 'other-realm' })).resolves.toEqual(
+ AuthenticationResult.failed(
+ Boom.unauthorized(
+ 'State based on realm "other-realm", but provider with the name "oidc" is configured to use realm "oidc1".'
+ )
+ )
+ );
+ });
});
describe('`logout` method', () => {
diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts
index 1793cf5eedc3b..7904c406e42a5 100644
--- a/x-pack/plugins/security/server/authentication/providers/oidc.ts
+++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts
@@ -32,7 +32,7 @@ export enum OIDCLogin {
* Describes the parameters that are required by the provider to process the initial login request.
*/
export type ProviderLoginAttempt =
- | { type: OIDCLogin.LoginInitiatedByUser; redirectURLPath?: string }
+ | { type: OIDCLogin.LoginInitiatedByUser; redirectURLPath: string }
| {
type: OIDCLogin.LoginWithImplicitFlow | OIDCLogin.LoginWithAuthorizationCodeFlow;
authenticationResponseURI: string;
diff --git a/x-pack/plugins/security/server/authentication/providers/pki.test.ts b/x-pack/plugins/security/server/authentication/providers/pki.test.ts
index b1d1e249984de..638bb5732f3c0 100644
--- a/x-pack/plugins/security/server/authentication/providers/pki.test.ts
+++ b/x-pack/plugins/security/server/authentication/providers/pki.test.ts
@@ -19,6 +19,7 @@ import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions }
import {
ElasticsearchErrorHelpers,
IClusterClient,
+ KibanaRequest,
ScopeableRequest,
} from '../../../../../../src/core/server';
import { AuthenticationResult } from '../authentication_result';
@@ -84,47 +85,15 @@ describe('PKIAuthenticationProvider', () => {
afterEach(() => jest.clearAllMocks());
- describe('`authenticate` method', () => {
- it('does not handle authentication via `authorization` header.', async () => {
- const request = httpServerMock.createKibanaRequest({
- headers: { authorization: 'Bearer some-token' },
- });
-
- await expect(provider.authenticate(request)).resolves.toEqual(
- AuthenticationResult.notHandled()
- );
-
- expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
- expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
- expect(request.headers.authorization).toBe('Bearer some-token');
- });
-
- it('does not handle authentication via `authorization` header even if state contains a valid token.', async () => {
- const request = httpServerMock.createKibanaRequest({
- headers: { authorization: 'Bearer some-token' },
- });
- const state = {
- accessToken: 'some-valid-token',
- peerCertificateFingerprint256: '2A:7A:C2:DD',
- };
-
- await expect(provider.authenticate(request, state)).resolves.toEqual(
- AuthenticationResult.notHandled()
- );
-
- expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
- expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
- expect(request.headers.authorization).toBe('Bearer some-token');
- });
-
+ function defineCommonLoginAndAuthenticateTests(
+ operation: (request: KibanaRequest) => Promise
+ ) {
it('does not handle requests without certificate.', async () => {
const request = httpServerMock.createKibanaRequest({
socket: getMockSocket({ authorized: true }),
});
- await expect(provider.authenticate(request, null)).resolves.toEqual(
- AuthenticationResult.notHandled()
- );
+ await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled());
expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
@@ -135,58 +104,12 @@ describe('PKIAuthenticationProvider', () => {
socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }),
});
- await expect(provider.authenticate(request, null)).resolves.toEqual(
- AuthenticationResult.notHandled()
- );
+ await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled());
expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
});
- it('fails with non-401 error if state is available, peer is authorized, but certificate is not available.', async () => {
- const request = httpServerMock.createKibanaRequest({
- socket: getMockSocket({ authorized: true }),
- });
-
- const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' };
-
- await expect(provider.authenticate(request, state)).resolves.toEqual(
- AuthenticationResult.failed(new Error('Peer certificate is not available'))
- );
-
- expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled();
- });
-
- it('invalidates token and fails with 401 if state is present, but peer certificate is not.', async () => {
- const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() });
- const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' };
-
- await expect(provider.authenticate(request, state)).resolves.toEqual(
- AuthenticationResult.failed(Boom.unauthorized())
- );
-
- expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1);
- expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({
- accessToken: state.accessToken,
- });
- });
-
- it('invalidates token and fails with 401 if new certificate is present, but not authorized.', async () => {
- const request = httpServerMock.createKibanaRequest({
- socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }),
- });
- const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' };
-
- await expect(provider.authenticate(request, state)).resolves.toEqual(
- AuthenticationResult.failed(Boom.unauthorized())
- );
-
- expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1);
- expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({
- accessToken: state.accessToken,
- });
- });
-
it('gets an access token in exchange to peer certificate chain and stores it in the state.', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest({
@@ -202,7 +125,7 @@ describe('PKIAuthenticationProvider', () => {
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' });
- await expect(provider.authenticate(request)).resolves.toEqual(
+ await expect(operation(request)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'pki' },
{
@@ -244,7 +167,7 @@ describe('PKIAuthenticationProvider', () => {
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' });
- await expect(provider.authenticate(request)).resolves.toEqual(
+ await expect(operation(request)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'pki' },
{
@@ -266,6 +189,156 @@ describe('PKIAuthenticationProvider', () => {
expect(request.headers).not.toHaveProperty('authorization');
});
+ it('fails if could not retrieve an access token in exchange to peer certificate chain.', async () => {
+ const request = httpServerMock.createKibanaRequest({
+ socket: getMockSocket({
+ authorized: true,
+ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'),
+ }),
+ });
+
+ const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error());
+ mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason);
+
+ await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason));
+
+ expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1);
+ expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', {
+ body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] },
+ });
+
+ expect(request.headers).not.toHaveProperty('authorization');
+ });
+
+ it('fails if could not retrieve user using the new access token.', async () => {
+ const request = httpServerMock.createKibanaRequest({
+ headers: {},
+ socket: getMockSocket({
+ authorized: true,
+ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'),
+ }),
+ });
+
+ const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error());
+ const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
+ mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason);
+ mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
+ mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' });
+
+ await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason));
+
+ expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1);
+ expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', {
+ body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] },
+ });
+
+ expectAuthenticateCall(mockOptions.client, {
+ headers: { authorization: 'Bearer access-token' },
+ });
+
+ expect(request.headers).not.toHaveProperty('authorization');
+ });
+ }
+
+ describe('`login` method', () => {
+ defineCommonLoginAndAuthenticateTests(request => provider.login(request));
+ });
+
+ describe('`authenticate` method', () => {
+ defineCommonLoginAndAuthenticateTests(request => provider.authenticate(request, null));
+
+ it('does not handle authentication via `authorization` header.', async () => {
+ const request = httpServerMock.createKibanaRequest({
+ headers: { authorization: 'Bearer some-token' },
+ });
+
+ await expect(provider.authenticate(request)).resolves.toEqual(
+ AuthenticationResult.notHandled()
+ );
+
+ expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
+ expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
+ expect(request.headers.authorization).toBe('Bearer some-token');
+ });
+
+ it('does not handle authentication via `authorization` header even if state contains a valid token.', async () => {
+ const request = httpServerMock.createKibanaRequest({
+ headers: { authorization: 'Bearer some-token' },
+ });
+ const state = {
+ accessToken: 'some-valid-token',
+ peerCertificateFingerprint256: '2A:7A:C2:DD',
+ };
+
+ await expect(provider.authenticate(request, state)).resolves.toEqual(
+ AuthenticationResult.notHandled()
+ );
+
+ expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
+ expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
+ expect(request.headers.authorization).toBe('Bearer some-token');
+ });
+
+ it('does not exchange peer certificate to access token if request does not require authentication.', async () => {
+ const request = httpServerMock.createKibanaRequest({
+ routeAuthRequired: false,
+ socket: getMockSocket({
+ authorized: true,
+ peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']),
+ }),
+ });
+ await expect(provider.authenticate(request)).resolves.toEqual(
+ AuthenticationResult.notHandled()
+ );
+
+ expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
+ expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
+ });
+
+ it('fails with non-401 error if state is available, peer is authorized, but certificate is not available.', async () => {
+ const request = httpServerMock.createKibanaRequest({
+ socket: getMockSocket({ authorized: true }),
+ });
+
+ const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' };
+
+ await expect(provider.authenticate(request, state)).resolves.toEqual(
+ AuthenticationResult.failed(new Error('Peer certificate is not available'))
+ );
+
+ expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled();
+ });
+
+ it('invalidates token and fails with 401 if state is present, but peer certificate is not.', async () => {
+ const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() });
+ const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' };
+
+ await expect(provider.authenticate(request, state)).resolves.toEqual(
+ AuthenticationResult.failed(Boom.unauthorized())
+ );
+
+ expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1);
+ expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({
+ accessToken: state.accessToken,
+ });
+ });
+
+ it('invalidates token and fails with 401 if new certificate is present, but not authorized.', async () => {
+ const request = httpServerMock.createKibanaRequest({
+ socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }),
+ });
+ const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' };
+
+ await expect(provider.authenticate(request, state)).resolves.toEqual(
+ AuthenticationResult.failed(Boom.unauthorized())
+ );
+
+ expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1);
+ expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({
+ accessToken: state.accessToken,
+ });
+ });
+
it('invalidates existing token and gets a new one if fingerprints do not match.', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest({
@@ -351,75 +424,45 @@ describe('PKIAuthenticationProvider', () => {
expect(request.headers).not.toHaveProperty('authorization');
});
- it('fails with 401 if existing token is expired, but certificate is not present.', async () => {
- const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() });
+ it('does not exchange peer certificate to a new access token even if existing token is expired and request does not require authentication.', async () => {
+ const request = httpServerMock.createKibanaRequest({
+ routeAuthRequired: false,
+ socket: getMockSocket({
+ authorized: true,
+ peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']),
+ }),
+ });
const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' };
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
- mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(
+ mockScopedClusterClient.callAsCurrentUser.mockRejectedValueOnce(
ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())
);
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
await expect(provider.authenticate(request, state)).resolves.toEqual(
- AuthenticationResult.failed(Boom.unauthorized())
+ AuthenticationResult.notHandled()
);
expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
-
- expect(request.headers).not.toHaveProperty('authorization');
- });
-
- it('fails if could not retrieve an access token in exchange to peer certificate chain.', async () => {
- const request = httpServerMock.createKibanaRequest({
- socket: getMockSocket({
- authorized: true,
- peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'),
- }),
- });
-
- const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error());
- mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason);
-
- await expect(provider.authenticate(request)).resolves.toEqual(
- AuthenticationResult.failed(failureReason)
- );
-
- expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1);
- expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', {
- body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] },
- });
-
expect(request.headers).not.toHaveProperty('authorization');
});
- it('fails if could not retrieve user using the new access token.', async () => {
- const request = httpServerMock.createKibanaRequest({
- headers: {},
- socket: getMockSocket({
- authorized: true,
- peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'),
- }),
- });
+ it('fails with 401 if existing token is expired, but certificate is not present.', async () => {
+ const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() });
+ const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' };
- const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error());
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
- mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason);
+ mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(
+ ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())
+ );
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
- mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' });
- await expect(provider.authenticate(request)).resolves.toEqual(
- AuthenticationResult.failed(failureReason)
+ await expect(provider.authenticate(request, state)).resolves.toEqual(
+ AuthenticationResult.failed(Boom.unauthorized())
);
- expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1);
- expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', {
- body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] },
- });
-
- expectAuthenticateCall(mockOptions.client, {
- headers: { authorization: 'Bearer access-token' },
- });
+ expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
expect(request.headers).not.toHaveProperty('authorization');
});
diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts
index 5cf4ada09e838..a7a43a3031571 100644
--- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts
+++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts
@@ -128,6 +128,26 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
});
+ it('fails if realm from state is different from the realm provider is configured with.', async () => {
+ const request = httpServerMock.createKibanaRequest();
+
+ await expect(
+ provider.login(
+ request,
+ { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' },
+ { realm: 'other-realm' }
+ )
+ ).resolves.toEqual(
+ AuthenticationResult.failed(
+ Boom.unauthorized(
+ 'State based on realm "other-realm", but provider with the name "saml" is configured to use realm "test-realm".'
+ )
+ )
+ );
+
+ expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
+ });
+
it('redirects to the default location if state contains empty redirect URL.', async () => {
const request = httpServerMock.createKibanaRequest();
@@ -409,7 +429,7 @@ describe('SAMLAuthenticationProvider', () => {
});
describe('User initiated login with captured redirect URL', () => {
- it('fails if state is not available', async () => {
+ it('fails if redirectURLPath is not available', async () => {
const request = httpServerMock.createKibanaRequest();
await expect(
@@ -463,6 +483,44 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.logger.warn).not.toHaveBeenCalled();
});
+ it('redirects requests to the IdP remembering combined redirect URL if path is provided in attempt.', async () => {
+ const request = httpServerMock.createKibanaRequest();
+
+ mockOptions.client.callAsInternalUser.mockResolvedValue({
+ id: 'some-request-id',
+ redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20',
+ });
+
+ await expect(
+ provider.login(
+ request,
+ {
+ type: SAMLLogin.LoginInitiatedByUser,
+ redirectURLPath: '/test-base-path/some-path',
+ redirectURLFragment: '#some-fragment',
+ },
+ null
+ )
+ ).resolves.toEqual(
+ AuthenticationResult.redirectTo(
+ 'https://idp-host/path/login?SAMLRequest=some%20request%20',
+ {
+ state: {
+ requestId: 'some-request-id',
+ redirectURL: '/test-base-path/some-path#some-fragment',
+ realm: 'test-realm',
+ },
+ }
+ )
+ );
+
+ expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', {
+ body: { realm: 'test-realm' },
+ });
+
+ expect(mockOptions.logger.warn).not.toHaveBeenCalled();
+ });
+
it('prepends redirect URL fragment with `#` if it does not have one.', async () => {
const request = httpServerMock.createKibanaRequest();
@@ -503,7 +561,7 @@ describe('SAMLAuthenticationProvider', () => {
);
});
- it('redirects non-AJAX requests to the IdP remembering only redirect URL path if fragment is too large.', async () => {
+ it('redirects requests to the IdP remembering only redirect URL path if fragment is too large.', async () => {
const request = httpServerMock.createKibanaRequest();
mockOptions.client.callAsInternalUser.mockResolvedValue({
@@ -543,6 +601,40 @@ describe('SAMLAuthenticationProvider', () => {
);
});
+ it('redirects requests to the IdP remembering base path if redirect URL path in attempt is too large.', async () => {
+ const request = httpServerMock.createKibanaRequest();
+ mockOptions.client.callAsInternalUser.mockResolvedValue({
+ id: 'some-request-id',
+ redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20',
+ });
+
+ await expect(
+ provider.login(
+ request,
+ {
+ type: SAMLLogin.LoginInitiatedByUser,
+ redirectURLPath: `/s/foo/${'some-path'.repeat(11)}`,
+ redirectURLFragment: '#some-fragment',
+ },
+ null
+ )
+ ).resolves.toEqual(
+ AuthenticationResult.redirectTo(
+ 'https://idp-host/path/login?SAMLRequest=some%20request%20',
+ { state: { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } }
+ )
+ );
+
+ expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', {
+ body: { realm: 'test-realm' },
+ });
+
+ expect(mockOptions.logger.warn).toHaveBeenCalledTimes(1);
+ expect(mockOptions.logger.warn).toHaveBeenCalledWith(
+ 'Max URL path size should not exceed 100b but it was 106b. URL is not captured.'
+ );
+ });
+
it('fails if SAML request preparation fails.', async () => {
const request = httpServerMock.createKibanaRequest();
@@ -576,6 +668,13 @@ describe('SAMLAuthenticationProvider', () => {
);
});
+ it('does not handle non-AJAX request that does not require authentication.', async () => {
+ const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false });
+ await expect(provider.authenticate(request)).resolves.toEqual(
+ AuthenticationResult.notHandled()
+ );
+ });
+
it('does not handle authentication via `authorization` header.', async () => {
const request = httpServerMock.createKibanaRequest({
headers: { authorization: 'Bearer some-token' },
@@ -840,6 +939,38 @@ describe('SAMLAuthenticationProvider', () => {
expect(request.headers).not.toHaveProperty('authorization');
});
+ it('fails for non-AJAX requests that do not require authentication with user friendly message if refresh token is expired.', async () => {
+ const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false, headers: {} });
+ const state = {
+ username: 'user',
+ accessToken: 'expired-token',
+ refreshToken: 'expired-refresh-token',
+ realm: 'test-realm',
+ };
+ const authorization = `Bearer ${state.accessToken}`;
+
+ const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
+ mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(
+ ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())
+ );
+ mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
+
+ mockOptions.tokens.refresh.mockResolvedValue(null);
+
+ await expect(provider.authenticate(request, state)).resolves.toEqual(
+ AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.'))
+ );
+
+ expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1);
+ expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken);
+
+ expectAuthenticateCall(mockOptions.client, {
+ headers: { authorization },
+ });
+
+ expect(request.headers).not.toHaveProperty('authorization');
+ });
+
it('re-capture URL for non-AJAX requests if refresh token is expired.', async () => {
const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} });
const state = {
@@ -920,6 +1051,17 @@ describe('SAMLAuthenticationProvider', () => {
'Max URL path size should not exceed 100b but it was 107b. URL is not captured.'
);
});
+
+ it('fails if realm from state is different from the realm provider is configured with.', async () => {
+ const request = httpServerMock.createKibanaRequest();
+ await expect(provider.authenticate(request, { realm: 'other-realm' })).resolves.toEqual(
+ AuthenticationResult.failed(
+ Boom.unauthorized(
+ 'State based on realm "other-realm", but provider with the name "saml" is configured to use realm "test-realm".'
+ )
+ )
+ );
+ });
});
describe('`logout` method', () => {
diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts
index 34390a32dd9d3..46a7ee79ee60c 100644
--- a/x-pack/plugins/security/server/config.test.ts
+++ b/x-pack/plugins/security/server/config.test.ts
@@ -346,6 +346,462 @@ describe('config schema', () => {
`);
});
});
+
+ describe('authc.providers (extended format)', () => {
+ describe('`basic` provider', () => {
+ it('requires `order`', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: { providers: { basic: { basic1: { enabled: true } } } },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1.basic.basic1.order]: expected value of type [number] but got [undefined]"
+`);
+ });
+
+ it('does not allow custom description', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: {
+ providers: { basic: { basic1: { order: 0, description: 'Some description' } } },
+ },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1.basic.basic1.description]: \`basic\` provider does not support custom description."
+`);
+ });
+
+ it('cannot be hidden from selector', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: {
+ providers: { basic: { basic1: { order: 0, showInSelector: false } } },
+ },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1.basic.basic1.showInSelector]: \`basic\` provider only supports \`true\` in \`showInSelector\`."
+`);
+ });
+
+ it('can have only provider of this type', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: { providers: { basic: { basic1: { order: 0 }, basic2: { order: 1 } } } },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1.basic]: Only one \\"basic\\" provider can be configured."
+`);
+ });
+
+ it('can be successfully validated', () => {
+ expect(
+ ConfigSchema.validate({
+ authc: { providers: { basic: { basic1: { order: 0 } } } },
+ }).authc.providers
+ ).toMatchInlineSnapshot(`
+ Object {
+ "basic": Object {
+ "basic1": Object {
+ "enabled": true,
+ "order": 0,
+ "showInSelector": true,
+ },
+ },
+ }
+ `);
+ });
+ });
+
+ describe('`token` provider', () => {
+ it('requires `order`', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: { providers: { token: { token1: { enabled: true } } } },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1.token.token1.order]: expected value of type [number] but got [undefined]"
+`);
+ });
+
+ it('does not allow custom description', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: {
+ providers: { token: { token1: { order: 0, description: 'Some description' } } },
+ },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1.token.token1.description]: \`token\` provider does not support custom description."
+`);
+ });
+
+ it('cannot be hidden from selector', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: {
+ providers: { token: { token1: { order: 0, showInSelector: false } } },
+ },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1.token.token1.showInSelector]: \`token\` provider only supports \`true\` in \`showInSelector\`."
+`);
+ });
+
+ it('can have only provider of this type', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: { providers: { token: { token1: { order: 0 }, token2: { order: 1 } } } },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1.token]: Only one \\"token\\" provider can be configured."
+`);
+ });
+
+ it('can be successfully validated', () => {
+ expect(
+ ConfigSchema.validate({
+ authc: { providers: { token: { token1: { order: 0 } } } },
+ }).authc.providers
+ ).toMatchInlineSnapshot(`
+ Object {
+ "token": Object {
+ "token1": Object {
+ "enabled": true,
+ "order": 0,
+ "showInSelector": true,
+ },
+ },
+ }
+ `);
+ });
+ });
+
+ describe('`pki` provider', () => {
+ it('requires `order`', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: { providers: { pki: { pki1: { enabled: true } } } },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1.pki.pki1.order]: expected value of type [number] but got [undefined]"
+`);
+ });
+
+ it('can have only provider of this type', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: { providers: { pki: { pki1: { order: 0 }, pki2: { order: 1 } } } },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1.pki]: Only one \\"pki\\" provider can be configured."
+`);
+ });
+
+ it('can be successfully validated', () => {
+ expect(
+ ConfigSchema.validate({
+ authc: { providers: { pki: { pki1: { order: 0 } } } },
+ }).authc.providers
+ ).toMatchInlineSnapshot(`
+ Object {
+ "pki": Object {
+ "pki1": Object {
+ "enabled": true,
+ "order": 0,
+ "showInSelector": true,
+ },
+ },
+ }
+ `);
+ });
+ });
+
+ describe('`kerberos` provider', () => {
+ it('requires `order`', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: { providers: { kerberos: { kerberos1: { enabled: true } } } },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1.kerberos.kerberos1.order]: expected value of type [number] but got [undefined]"
+`);
+ });
+
+ it('can have only provider of this type', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: {
+ providers: { kerberos: { kerberos1: { order: 0 }, kerberos2: { order: 1 } } },
+ },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1.kerberos]: Only one \\"kerberos\\" provider can be configured."
+`);
+ });
+
+ it('can be successfully validated', () => {
+ expect(
+ ConfigSchema.validate({
+ authc: { providers: { kerberos: { kerberos1: { order: 0 } } } },
+ }).authc.providers
+ ).toMatchInlineSnapshot(`
+ Object {
+ "kerberos": Object {
+ "kerberos1": Object {
+ "enabled": true,
+ "order": 0,
+ "showInSelector": true,
+ },
+ },
+ }
+ `);
+ });
+ });
+
+ describe('`oidc` provider', () => {
+ it('requires `order`', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: { providers: { oidc: { oidc1: { enabled: true } } } },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1.oidc.oidc1.order]: expected value of type [number] but got [undefined]"
+`);
+ });
+
+ it('requires `realm`', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: { providers: { oidc: { oidc1: { order: 0 } } } },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1.oidc.oidc1.realm]: expected value of type [string] but got [undefined]"
+`);
+ });
+
+ it('can be successfully validated', () => {
+ expect(
+ ConfigSchema.validate({
+ authc: {
+ providers: {
+ oidc: { oidc1: { order: 0, realm: 'oidc1' }, oidc2: { order: 1, realm: 'oidc2' } },
+ },
+ },
+ }).authc.providers
+ ).toMatchInlineSnapshot(`
+ Object {
+ "oidc": Object {
+ "oidc1": Object {
+ "enabled": true,
+ "order": 0,
+ "realm": "oidc1",
+ "showInSelector": true,
+ },
+ "oidc2": Object {
+ "enabled": true,
+ "order": 1,
+ "realm": "oidc2",
+ "showInSelector": true,
+ },
+ },
+ }
+ `);
+ });
+ });
+
+ describe('`saml` provider', () => {
+ it('requires `order`', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: { providers: { saml: { saml1: { enabled: true } } } },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1.saml.saml1.order]: expected value of type [number] but got [undefined]"
+`);
+ });
+
+ it('requires `realm`', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: { providers: { saml: { saml1: { order: 0 } } } },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1.saml.saml1.realm]: expected value of type [string] but got [undefined]"
+`);
+ });
+
+ it('can be successfully validated', () => {
+ expect(
+ ConfigSchema.validate({
+ authc: {
+ providers: {
+ saml: {
+ saml1: { order: 0, realm: 'saml1' },
+ saml2: { order: 1, realm: 'saml2', maxRedirectURLSize: '1kb' },
+ },
+ },
+ },
+ }).authc.providers
+ ).toMatchInlineSnapshot(`
+ Object {
+ "saml": Object {
+ "saml1": Object {
+ "enabled": true,
+ "maxRedirectURLSize": ByteSizeValue {
+ "valueInBytes": 2048,
+ },
+ "order": 0,
+ "realm": "saml1",
+ "showInSelector": true,
+ },
+ "saml2": Object {
+ "enabled": true,
+ "maxRedirectURLSize": ByteSizeValue {
+ "valueInBytes": 1024,
+ },
+ "order": 1,
+ "realm": "saml2",
+ "showInSelector": true,
+ },
+ },
+ }
+ `);
+ });
+ });
+
+ it('`name` should be unique across all provider types', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: {
+ providers: {
+ basic: { provider1: { order: 0 } },
+ saml: {
+ provider2: { order: 1, realm: 'saml1' },
+ provider1: { order: 2, realm: 'saml2' },
+ },
+ },
+ },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1]: Found multiple providers configured with the same name \\"provider1\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider1]"
+`);
+ });
+
+ it('`order` should be unique across all provider types', () => {
+ expect(() =>
+ ConfigSchema.validate({
+ authc: {
+ providers: {
+ basic: { provider1: { order: 0 } },
+ saml: {
+ provider2: { order: 0, realm: 'saml1' },
+ provider3: { order: 2, realm: 'saml2' },
+ },
+ },
+ },
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`
+"[authc.providers]: types that failed validation:
+- [authc.providers.0]: expected value of type [array] but got [Object]
+- [authc.providers.1]: Found multiple providers configured with the same order \\"0\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider2]"
+`);
+ });
+
+ it('can be successfully validated with multiple providers ignoring uniqueness violations in disabled ones', () => {
+ expect(
+ ConfigSchema.validate({
+ authc: {
+ providers: {
+ basic: { basic1: { order: 0 }, basic2: { enabled: false, order: 1 } },
+ saml: {
+ saml1: { order: 1, realm: 'saml1' },
+ saml2: { order: 2, realm: 'saml2' },
+ basic1: { order: 3, realm: 'saml3', enabled: false },
+ },
+ },
+ },
+ }).authc.providers
+ ).toMatchInlineSnapshot(`
+ Object {
+ "basic": Object {
+ "basic1": Object {
+ "enabled": true,
+ "order": 0,
+ "showInSelector": true,
+ },
+ "basic2": Object {
+ "enabled": false,
+ "order": 1,
+ "showInSelector": true,
+ },
+ },
+ "saml": Object {
+ "basic1": Object {
+ "enabled": false,
+ "maxRedirectURLSize": ByteSizeValue {
+ "valueInBytes": 2048,
+ },
+ "order": 3,
+ "realm": "saml3",
+ "showInSelector": true,
+ },
+ "saml1": Object {
+ "enabled": true,
+ "maxRedirectURLSize": ByteSizeValue {
+ "valueInBytes": 2048,
+ },
+ "order": 1,
+ "realm": "saml1",
+ "showInSelector": true,
+ },
+ "saml2": Object {
+ "enabled": true,
+ "maxRedirectURLSize": ByteSizeValue {
+ "valueInBytes": 2048,
+ },
+ "order": 2,
+ "realm": "saml2",
+ "showInSelector": true,
+ },
+ },
+ }
+ `);
+ });
+ });
});
describe('createConfig()', () => {
@@ -405,4 +861,204 @@ describe('createConfig()', () => {
expect(loggingServiceMock.collect(logger).warn).toEqual([]);
});
+
+ it('transforms legacy `authc.providers` into new format', () => {
+ const logger = loggingServiceMock.create().get();
+
+ expect(
+ createConfig(
+ ConfigSchema.validate({
+ authc: {
+ providers: ['saml', 'basic'],
+ saml: { realm: 'saml-realm' },
+ },
+ }),
+ logger,
+ { isTLSEnabled: true }
+ ).authc
+ ).toMatchInlineSnapshot(`
+ Object {
+ "http": Object {
+ "autoSchemesEnabled": true,
+ "enabled": true,
+ "schemes": Array [
+ "apikey",
+ ],
+ },
+ "providers": Object {
+ "basic": Object {
+ "basic": Object {
+ "enabled": true,
+ "order": 1,
+ "showInSelector": true,
+ },
+ },
+ "saml": Object {
+ "saml": Object {
+ "enabled": true,
+ "maxRedirectURLSize": ByteSizeValue {
+ "valueInBytes": 2048,
+ },
+ "order": 0,
+ "realm": "saml-realm",
+ "showInSelector": true,
+ },
+ },
+ },
+ "selector": Object {
+ "enabled": false,
+ },
+ "sortedProviders": Array [
+ Object {
+ "name": "saml",
+ "options": Object {
+ "description": undefined,
+ "order": 0,
+ "showInSelector": true,
+ },
+ "type": "saml",
+ },
+ Object {
+ "name": "basic",
+ "options": Object {
+ "description": undefined,
+ "order": 1,
+ "showInSelector": true,
+ },
+ "type": "basic",
+ },
+ ],
+ }
+ `);
+ });
+
+ it('does not automatically set `authc.selector.enabled` to `true` if legacy `authc.providers` format is used', () => {
+ expect(
+ createConfig(
+ ConfigSchema.validate({
+ authc: { providers: ['saml', 'basic'], saml: { realm: 'saml-realm' } },
+ }),
+ loggingServiceMock.create().get(),
+ { isTLSEnabled: true }
+ ).authc.selector.enabled
+ ).toBe(false);
+
+ // But keep it as `true` if it's explicitly set.
+ expect(
+ createConfig(
+ ConfigSchema.validate({
+ authc: {
+ selector: { enabled: true },
+ providers: ['saml', 'basic'],
+ saml: { realm: 'saml-realm' },
+ },
+ }),
+ loggingServiceMock.create().get(),
+ { isTLSEnabled: true }
+ ).authc.selector.enabled
+ ).toBe(true);
+ });
+
+ it('does not automatically set `authc.selector.enabled` to `true` if less than 2 providers must be shown there', () => {
+ expect(
+ createConfig(
+ ConfigSchema.validate({
+ authc: {
+ providers: {
+ basic: { basic1: { order: 0 } },
+ saml: {
+ saml1: { order: 1, realm: 'saml1', showInSelector: false },
+ saml2: { enabled: false, order: 2, realm: 'saml2' },
+ },
+ },
+ },
+ }),
+ loggingServiceMock.create().get(),
+ { isTLSEnabled: true }
+ ).authc.selector.enabled
+ ).toBe(false);
+ });
+
+ it('automatically set `authc.selector.enabled` to `true` if more than 1 provider must be shown there', () => {
+ expect(
+ createConfig(
+ ConfigSchema.validate({
+ authc: {
+ providers: {
+ basic: { basic1: { order: 0 } },
+ saml: { saml1: { order: 1, realm: 'saml1' }, saml2: { order: 2, realm: 'saml2' } },
+ },
+ },
+ }),
+ loggingServiceMock.create().get(),
+ { isTLSEnabled: true }
+ ).authc.selector.enabled
+ ).toBe(true);
+ });
+
+ it('correctly sorts providers based on the `order`', () => {
+ expect(
+ createConfig(
+ ConfigSchema.validate({
+ authc: {
+ providers: {
+ basic: { basic1: { order: 3 } },
+ saml: { saml1: { order: 2, realm: 'saml1' }, saml2: { order: 1, realm: 'saml2' } },
+ oidc: { oidc1: { order: 0, realm: 'oidc1' }, oidc2: { order: 4, realm: 'oidc2' } },
+ },
+ },
+ }),
+ loggingServiceMock.create().get(),
+ { isTLSEnabled: true }
+ ).authc.sortedProviders
+ ).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "name": "oidc1",
+ "options": Object {
+ "description": undefined,
+ "order": 0,
+ "showInSelector": true,
+ },
+ "type": "oidc",
+ },
+ Object {
+ "name": "saml2",
+ "options": Object {
+ "description": undefined,
+ "order": 1,
+ "showInSelector": true,
+ },
+ "type": "saml",
+ },
+ Object {
+ "name": "saml1",
+ "options": Object {
+ "description": undefined,
+ "order": 2,
+ "showInSelector": true,
+ },
+ "type": "saml",
+ },
+ Object {
+ "name": "basic1",
+ "options": Object {
+ "description": undefined,
+ "order": 3,
+ "showInSelector": true,
+ },
+ "type": "basic",
+ },
+ Object {
+ "name": "oidc2",
+ "options": Object {
+ "description": undefined,
+ "order": 4,
+ "showInSelector": true,
+ },
+ "type": "oidc",
+ },
+ ]
+ `);
+ });
});
diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts
index e143e3d6b1b25..97ff7d00a4336 100644
--- a/x-pack/plugins/security/server/config.ts
+++ b/x-pack/plugins/security/server/config.ts
@@ -258,10 +258,10 @@ export function createConfig(
return {
...config,
authc: {
- ...config.authc,
selector: { ...config.authc.selector, enabled: isLoginSelectorEnabled },
providers,
sortedProviders: Object.freeze(sortedProviders),
+ http: config.authc.http,
},
encryptionKey,
secureCookies,
diff --git a/x-pack/plugins/security/server/routes/authentication/common.test.ts b/x-pack/plugins/security/server/routes/authentication/common.test.ts
index b611ffffee935..e2f9593bc09ee 100644
--- a/x-pack/plugins/security/server/routes/authentication/common.test.ts
+++ b/x-pack/plugins/security/server/routes/authentication/common.test.ts
@@ -13,7 +13,13 @@ import {
RouteConfig,
} from '../../../../../../src/core/server';
import { LICENSE_CHECK_STATE } from '../../../../licensing/server';
-import { Authentication, DeauthenticationResult } from '../../authentication';
+import {
+ Authentication,
+ AuthenticationResult,
+ DeauthenticationResult,
+ OIDCLogin,
+ SAMLLogin,
+} from '../../authentication';
import { defineCommonRoutes } from './common';
import { httpServerMock } from '../../../../../../src/core/server/mocks';
@@ -172,4 +178,260 @@ describe('Common authentication routes', () => {
expect(authc.getCurrentUser).toHaveBeenCalledWith(mockRequest);
});
});
+
+ describe('login_with', () => {
+ let routeHandler: RequestHandler;
+ let routeConfig: RouteConfig;
+ beforeEach(() => {
+ const [acsRouteConfig, acsRouteHandler] = router.post.mock.calls.find(
+ ([{ path }]) => path === '/internal/security/login_with'
+ )!;
+
+ routeConfig = acsRouteConfig;
+ routeHandler = acsRouteHandler;
+ });
+
+ it('correctly defines route.', () => {
+ expect(routeConfig.options).toEqual({ authRequired: false });
+ expect(routeConfig.validate).toEqual({
+ body: expect.any(Type),
+ query: undefined,
+ params: undefined,
+ });
+
+ const bodyValidator = (routeConfig.validate as any).body as Type;
+ expect(
+ bodyValidator.validate({
+ providerType: 'saml',
+ providerName: 'saml1',
+ currentURL: '/some-url',
+ })
+ ).toEqual({
+ providerType: 'saml',
+ providerName: 'saml1',
+ currentURL: '/some-url',
+ });
+
+ expect(
+ bodyValidator.validate({
+ providerType: 'saml',
+ providerName: 'saml1',
+ currentURL: '',
+ })
+ ).toEqual({
+ providerType: 'saml',
+ providerName: 'saml1',
+ currentURL: '',
+ });
+
+ expect(() => bodyValidator.validate({})).toThrowErrorMatchingInlineSnapshot(
+ `"[providerType]: expected value of type [string] but got [undefined]"`
+ );
+
+ expect(() =>
+ bodyValidator.validate({ providerType: 'saml' })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[providerName]: expected value of type [string] but got [undefined]"`
+ );
+
+ expect(() =>
+ bodyValidator.validate({ providerType: 'saml', providerName: 'saml1' })
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[currentURL]: expected value of type [string] but got [undefined]"`
+ );
+
+ expect(() =>
+ bodyValidator.validate({
+ providerType: 'saml',
+ providerName: 'saml1',
+ currentURL: '/some-url',
+ UnknownArg: 'arg',
+ })
+ ).toThrowErrorMatchingInlineSnapshot(`"[UnknownArg]: definition for this key is missing"`);
+ });
+
+ it('returns 500 if login throws unhandled exception.', async () => {
+ const unhandledException = new Error('Something went wrong.');
+ authc.login.mockRejectedValue(unhandledException);
+
+ const request = httpServerMock.createKibanaRequest({
+ body: { providerType: 'saml', providerName: 'saml1', currentURL: '/some-url' },
+ });
+
+ await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
+ status: 500,
+ payload: 'Internal Error',
+ options: {},
+ });
+ });
+
+ it('returns 401 if login fails.', async () => {
+ const failureReason = new Error('Something went wrong.');
+ authc.login.mockResolvedValue(
+ AuthenticationResult.failed(failureReason, {
+ authResponseHeaders: { 'WWW-Something': 'something' },
+ })
+ );
+
+ const request = httpServerMock.createKibanaRequest({
+ body: { providerType: 'saml', providerName: 'saml1', currentURL: '/some-url' },
+ });
+
+ await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
+ status: 401,
+ payload: failureReason,
+ options: { body: failureReason, headers: { 'WWW-Something': 'something' } },
+ });
+ });
+
+ it('returns 401 if login is not handled.', async () => {
+ authc.login.mockResolvedValue(AuthenticationResult.notHandled());
+
+ const request = httpServerMock.createKibanaRequest({
+ body: { providerType: 'saml', providerName: 'saml1', currentURL: '/some-url' },
+ });
+
+ await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
+ status: 401,
+ payload: 'Unauthorized',
+ options: {},
+ });
+ });
+
+ it('returns redirect location from authentication result if any.', async () => {
+ authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path'));
+
+ const request = httpServerMock.createKibanaRequest({
+ body: { providerType: 'saml', providerName: 'saml1', currentURL: '/some-url' },
+ });
+
+ await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
+ status: 200,
+ payload: { location: 'http://redirect-to/path' },
+ options: { body: { location: 'http://redirect-to/path' } },
+ });
+ });
+
+ it('returns location extracted from `next` parameter if authentication result does not specify any.', async () => {
+ authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser()));
+
+ const request = httpServerMock.createKibanaRequest({
+ body: {
+ providerType: 'saml',
+ providerName: 'saml1',
+ currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav',
+ },
+ });
+
+ await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
+ status: 200,
+ payload: { location: '/mock-server-basepath/some-url#/app/nav' },
+ options: { body: { location: '/mock-server-basepath/some-url#/app/nav' } },
+ });
+ });
+
+ it('returns base path if location cannot be extracted from `currentURL` parameter and authentication result does not specify any.', async () => {
+ authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser()));
+
+ const invalidCurrentURLs = [
+ 'https://kibana.com/?next=https://evil.com/mock-server-basepath/some-url#/app/nav',
+ 'https://kibana.com/?next=https://kibana.com:9000/mock-server-basepath/some-url#/app/nav',
+ 'https://kibana.com/?next=kibana.com/mock-server-basepath/some-url#/app/nav',
+ 'https://kibana.com/?next=//mock-server-basepath/some-url#/app/nav',
+ 'https://kibana.com/?next=../mock-server-basepath/some-url#/app/nav',
+ 'https://kibana.com/?next=/some-url#/app/nav',
+ '',
+ ];
+
+ for (const currentURL of invalidCurrentURLs) {
+ const request = httpServerMock.createKibanaRequest({
+ body: { providerType: 'saml', providerName: 'saml1', currentURL },
+ });
+
+ await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
+ status: 200,
+ payload: { location: '/mock-server-basepath/' },
+ options: { body: { location: '/mock-server-basepath/' } },
+ });
+ }
+ });
+
+ it('correctly performs SAML login.', async () => {
+ authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path'));
+
+ const request = httpServerMock.createKibanaRequest({
+ body: {
+ providerType: 'saml',
+ providerName: 'saml1',
+ currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav',
+ },
+ });
+
+ await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
+ status: 200,
+ payload: { location: 'http://redirect-to/path' },
+ options: { body: { location: 'http://redirect-to/path' } },
+ });
+
+ expect(authc.login).toHaveBeenCalledTimes(1);
+ expect(authc.login).toHaveBeenCalledWith(request, {
+ provider: { name: 'saml1' },
+ value: {
+ type: SAMLLogin.LoginInitiatedByUser,
+ redirectURLPath: '/mock-server-basepath/some-url',
+ redirectURLFragment: '#/app/nav',
+ },
+ });
+ });
+
+ it('correctly performs OIDC login.', async () => {
+ authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path'));
+
+ const request = httpServerMock.createKibanaRequest({
+ body: {
+ providerType: 'oidc',
+ providerName: 'oidc1',
+ currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav',
+ },
+ });
+
+ await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
+ status: 200,
+ payload: { location: 'http://redirect-to/path' },
+ options: { body: { location: 'http://redirect-to/path' } },
+ });
+
+ expect(authc.login).toHaveBeenCalledTimes(1);
+ expect(authc.login).toHaveBeenCalledWith(request, {
+ provider: { name: 'oidc1' },
+ value: {
+ type: OIDCLogin.LoginInitiatedByUser,
+ redirectURLPath: '/mock-server-basepath/some-url',
+ },
+ });
+ });
+
+ it('correctly performs generic login.', async () => {
+ authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path'));
+
+ const request = httpServerMock.createKibanaRequest({
+ body: {
+ providerType: 'some-type',
+ providerName: 'some-name',
+ currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav',
+ },
+ });
+
+ await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
+ status: 200,
+ payload: { location: 'http://redirect-to/path' },
+ options: { body: { location: 'http://redirect-to/path' } },
+ });
+
+ expect(authc.login).toHaveBeenCalledTimes(1);
+ expect(authc.login).toHaveBeenCalledWith(request, {
+ provider: { name: 'some-name' },
+ });
+ });
+ });
});
diff --git a/x-pack/plugins/security/server/routes/authentication/common.ts b/x-pack/plugins/security/server/routes/authentication/common.ts
index 30a8f2ce5bb0a..f0f33d6624cc1 100644
--- a/x-pack/plugins/security/server/routes/authentication/common.ts
+++ b/x-pack/plugins/security/server/routes/authentication/common.ts
@@ -121,6 +121,7 @@ export function defineCommonRoutes({ router, authc, basePath, logger }: RouteDef
if (authenticationResult.redirected() || authenticationResult.succeeded()) {
return response.ok({
body: { location: authenticationResult.redirectURL || redirectURL },
+ headers: authenticationResult.authResponseHeaders,
});
}
diff --git a/x-pack/plugins/security/server/routes/views/login.test.ts b/x-pack/plugins/security/server/routes/views/login.test.ts
index 382e5462f7de6..9217d5a437f9c 100644
--- a/x-pack/plugins/security/server/routes/views/login.test.ts
+++ b/x-pack/plugins/security/server/routes/views/login.test.ts
@@ -13,6 +13,8 @@ import {
IRouter,
} from '../../../../../../src/core/server';
import { SecurityLicense } from '../../../common/licensing';
+import { LoginState } from '../../../common/login_state';
+import { ConfigType } from '../../config';
import { defineLoginRoutes } from './login';
import { coreMock, httpServerMock } from '../../../../../../src/core/server/mocks';
@@ -21,10 +23,12 @@ import { routeDefinitionParamsMock } from '../index.mock';
describe('Login view routes', () => {
let router: jest.Mocked;
let license: jest.Mocked;
+ let config: ConfigType;
beforeEach(() => {
const routeParamsMock = routeDefinitionParamsMock.create();
router = routeParamsMock.router;
license = routeParamsMock.license;
+ config = routeParamsMock.config;
defineLoginRoutes(routeParamsMock);
});
@@ -202,5 +206,141 @@ describe('Login view routes', () => {
status: 200,
});
});
+
+ it('returns `requiresSecureConnection: true` if `secureCookies` is enabled in config.', async () => {
+ license.getFeatures.mockReturnValue({ allowLogin: true, showLogin: true } as any);
+
+ const request = httpServerMock.createKibanaRequest();
+ const contextMock = coreMock.createRequestHandlerContext();
+
+ config.secureCookies = true;
+
+ const expectedPayload = expect.objectContaining({ requiresSecureConnection: true });
+ await expect(
+ routeHandler({ core: contextMock } as any, request, kibanaResponseFactory)
+ ).resolves.toEqual({
+ options: { body: expectedPayload },
+ payload: expectedPayload,
+ status: 200,
+ });
+ });
+
+ it('returns `showLoginForm: true` only if either `basic` or `token` provider is enabled.', async () => {
+ license.getFeatures.mockReturnValue({ allowLogin: true, showLogin: true } as any);
+
+ const request = httpServerMock.createKibanaRequest();
+ const contextMock = coreMock.createRequestHandlerContext();
+
+ const cases: Array<[boolean, ConfigType['authc']['sortedProviders']]> = [
+ [false, []],
+ [true, [{ type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }]],
+ [true, [{ type: 'token', name: 'token1', options: { order: 0, showInSelector: true } }]],
+ ];
+
+ for (const [showLoginForm, sortedProviders] of cases) {
+ config.authc.sortedProviders = sortedProviders;
+
+ const expectedPayload = expect.objectContaining({ showLoginForm });
+ 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);
+
+ const request = httpServerMock.createKibanaRequest();
+ const contextMock = coreMock.createRequestHandlerContext();
+
+ const cases: Array<[
+ boolean,
+ ConfigType['authc']['sortedProviders'],
+ LoginState['selector']['providers']
+ ]> = [
+ // selector is disabled, providers shouldn't be returned.
+ [
+ false,
+ [
+ { type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } },
+ { type: 'saml', name: 'saml1', options: { order: 1, showInSelector: true } },
+ ],
+ [],
+ ],
+ // selector is enabled, but only basic/token is available, providers shouldn't be returned.
+ [
+ true,
+ [{ type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }],
+ [],
+ ],
+ // selector is enabled, non-basic/token providers should be returned
+ [
+ true,
+ [
+ {
+ type: 'basic',
+ name: 'basic1',
+ options: { order: 0, showInSelector: true, description: 'some-desc1' },
+ },
+ {
+ type: 'saml',
+ name: 'saml1',
+ options: { order: 1, showInSelector: true, description: 'some-desc2' },
+ },
+ {
+ type: 'saml',
+ name: 'saml2',
+ options: { order: 2, showInSelector: true, description: 'some-desc3' },
+ },
+ ],
+ [
+ { type: 'saml', name: 'saml1', description: 'some-desc2' },
+ { type: 'saml', name: 'saml2', description: 'some-desc3' },
+ ],
+ ],
+ // selector is enabled, only non-basic/token providers that are enabled in selector should be returned.
+ [
+ true,
+ [
+ {
+ type: 'basic',
+ name: 'basic1',
+ options: { order: 0, showInSelector: true, description: 'some-desc1' },
+ },
+ {
+ type: 'saml',
+ name: 'saml1',
+ options: { order: 1, showInSelector: false, description: 'some-desc2' },
+ },
+ {
+ type: 'saml',
+ name: 'saml2',
+ options: { order: 2, showInSelector: true, description: 'some-desc3' },
+ },
+ ],
+ [{ type: 'saml', name: 'saml2', description: 'some-desc3' }],
+ ],
+ ];
+
+ for (const [selectorEnabled, sortedProviders, expectedProviders] of cases) {
+ config.authc.selector.enabled = selectorEnabled;
+ config.authc.sortedProviders = sortedProviders;
+
+ const expectedPayload = expect.objectContaining({
+ selector: { enabled: selectorEnabled, providers: expectedProviders },
+ });
+ await expect(
+ routeHandler({ core: contextMock } as any, request, kibanaResponseFactory)
+ ).resolves.toEqual({
+ options: { body: expectedPayload },
+ payload: expectedPayload,
+ status: 200,
+ });
+ }
+ });
});
});
diff --git a/x-pack/plugins/security/server/routes/views/login.ts b/x-pack/plugins/security/server/routes/views/login.ts
index a8f669551d710..f37411c301dbd 100644
--- a/x-pack/plugins/security/server/routes/views/login.ts
+++ b/x-pack/plugins/security/server/routes/views/login.ts
@@ -60,23 +60,25 @@ export function defineLoginRoutes({
async (context, request, response) => {
const { allowLogin, layout = 'form' } = license.getFeatures();
const { sortedProviders, selector } = config.authc;
+
+ let showLoginForm = false;
+ const providers = [];
+ for (const { type, name, options } of sortedProviders) {
+ if (options.showInSelector) {
+ if (type === 'basic' || type === 'token') {
+ showLoginForm = true;
+ } else if (selector.enabled) {
+ providers.push({ type, name, description: options.description });
+ }
+ }
+ }
+
const loginState: LoginState = {
allowLogin,
layout,
requiresSecureConnection: config.secureCookies,
- showLoginForm: sortedProviders.some(
- ({ type, options: { showInSelector } }) =>
- showInSelector && (type === 'basic' || type === 'token')
- ),
- selector: {
- enabled: selector.enabled,
- providers: selector.enabled
- ? sortedProviders.filter(
- ({ type, options: { showInSelector } }) =>
- showInSelector && type !== 'basic' && type !== 'token'
- )
- : [],
- },
+ showLoginForm,
+ selector: { enabled: selector.enabled, providers },
};
return response.ok({ body: loginState });