Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Review#2: new unit tests, return response headers from login_with if …
Browse files Browse the repository at this point in the history
…provided by the authentication result, make `redirectURLPath` required in user initiated OIDC login attempt, handle part of the review feedback.
azasypkin committed Mar 19, 2020
1 parent d6d4c2d commit a572c52
Showing 17 changed files with 1,859 additions and 258 deletions.
6 changes: 1 addition & 5 deletions x-pack/plugins/security/common/login_state.ts
Original file line number Diff line number Diff line change
@@ -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 {

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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' },
],
},
})
34 changes: 18 additions & 16 deletions x-pack/plugins/security/public/authentication/login/login_page.tsx
Original file line number Diff line number Diff line change
@@ -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<Props, State> {
message={
<FormattedMessage
id="xpack.security.loginPage.unknownLayoutMessage"
defaultMessage="Refer to the Kibana logs for more details and refresh to try again."
defaultMessage="See the Kibana logs for details and try reloading the page."
/>
}
/>
@@ -242,7 +236,16 @@ export class LoginPage extends Component<Props, State> {
fullWidth={true}
onClick={() => this.login(provider.type, provider.name)}
>
{provider.options.description ?? `${provider.type}/${provider.name}`}
{provider.description ?? (
<FormattedMessage
id="xpack.security.loginPage.loginProviderDescription"
defaultMessage="Login with {providerType}/{providerName}"
values={{
providerType: provider.type,
providerName: provider.name,
}}
/>
)}
</EuiButton>
));

@@ -277,18 +280,17 @@ export class LoginPage extends Component<Props, State> {
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.',
}),
});
}
};
}
234 changes: 228 additions & 6 deletions x-pack/plugins/security/server/authentication/authenticator.test.ts
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@
*/

jest.mock('./providers/basic');
jest.mock('./providers/token');
jest.mock('./providers/saml');
jest.mock('./providers/http');

@@ -24,15 +25,15 @@ 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,
providers,
http = {},
}: {
session?: AuthenticatorOptions['config']['session'];
providers?: Record<string, unknown>;
providers?: Record<string, unknown> | string[];
http?: Partial<AuthenticatorOptions['config']['authc']['http']>;
} = {}) {
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<PublicMethodsOf<SAMLAuthenticationProvider>>;
let mockSAMLAuthenticationProvider2: jest.Mocked<PublicMethodsOf<SAMLAuthenticationProvider>>;

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()
176 changes: 105 additions & 71 deletions x-pack/plugins/security/server/authentication/providers/kerberos.test.ts
Original file line number Diff line number Diff line change
@@ -14,6 +14,7 @@ import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions }
import {
ElasticsearchErrorHelpers,
IClusterClient,
KibanaRequest,
ScopeableRequest,
} from '../../../../../../src/core/server';
import { AuthenticationResult } from '../authentication_result';
@@ -40,49 +41,17 @@ 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<AuthenticationResult>
) {
it('does not handle requests that can be authenticated without `Negotiate` header.', async () => {
const request = httpServerMock.createKibanaRequest({ headers: {} });

const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
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', () => {
Original file line number Diff line number Diff line change
@@ -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();
103 changes: 103 additions & 0 deletions x-pack/plugins/security/server/authentication/providers/oidc.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Original file line number Diff line number Diff line change
@@ -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;
311 changes: 177 additions & 134 deletions x-pack/plugins/security/server/authentication/providers/pki.test.ts

Large diffs are not rendered by default.

146 changes: 144 additions & 2 deletions x-pack/plugins/security/server/authentication/providers/saml.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
656 changes: 656 additions & 0 deletions x-pack/plugins/security/server/config.test.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion x-pack/plugins/security/server/config.ts
Original file line number Diff line number Diff line change
@@ -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,
264 changes: 263 additions & 1 deletion x-pack/plugins/security/server/routes/authentication/common.test.ts
Original file line number Diff line number Diff line change
@@ -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<any, any, any>;
let routeConfig: RouteConfig<any, any, any, any>;
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<any>;
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' },
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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,
});
}

140 changes: 140 additions & 0 deletions x-pack/plugins/security/server/routes/views/login.test.ts
Original file line number Diff line number Diff line change
@@ -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<IRouter>;
let license: jest.Mocked<SecurityLicense>;
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,
});
}
});
});
});
28 changes: 15 additions & 13 deletions x-pack/plugins/security/server/routes/views/login.ts
Original file line number Diff line number Diff line change
@@ -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 });

0 comments on commit a572c52

Please sign in to comment.