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 });