diff --git a/x-pack/plugins/security/__snapshots__/index.test.js.snap b/x-pack/plugins/security/__snapshots__/index.test.js.snap index dc07b4cce8c37..7dfd0824d69f4 100644 --- a/x-pack/plugins/security/__snapshots__/index.test.js.snap +++ b/x-pack/plugins/security/__snapshots__/index.test.js.snap @@ -1,5 +1,11 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`config schema authc oidc realm realm is not allowed when authProviders is "['basic']" 1`] = `[ValidationError: child "authc" fails because ["oidc" is not allowed]]`; + +exports[`config schema authc oidc realm returns a validation error when authProviders is "['oidc', 'basic']" and realm is unspecified 1`] = `[ValidationError: child "authc" fails because [child "oidc" fails because [child "realm" fails because ["realm" is required]]]]`; + +exports[`config schema authc oidc realm returns a validation error when authProviders is "['oidc']" and realm is unspecified 1`] = `[ValidationError: child "authc" fails because [child "oidc" fails because [child "realm" fails because ["realm" is required]]]]`; + exports[`config schema with context {"dist":false} produces correct config 1`] = ` Object { "audit": Object { diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index 77166c825f0f6..f0d016a658f61 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -65,6 +65,15 @@ export const security = (kibana) => new kibana.Plugin({ audit: Joi.object({ enabled: Joi.boolean().default(false) }).default(), + authc: Joi.object({}) + .when('authProviders', { + is: Joi.array().items(Joi.string().valid('oidc').required(), Joi.string()), + then: Joi.object({ + oidc: Joi.object({ + realm: Joi.string().required(), + }).default() + }).default() + }) }).default(); }, diff --git a/x-pack/plugins/security/index.test.js b/x-pack/plugins/security/index.test.js index 4bc3f0e6da73b..035ada8dc4d18 100644 --- a/x-pack/plugins/security/index.test.js +++ b/x-pack/plugins/security/index.test.js @@ -7,16 +7,76 @@ import { security } from './index'; import { getConfigSchema } from '../../test_utils'; -const describeWithContext = describe.each([ - [{ dist: false }], - [{ dist: true }] -]); +const describeWithContext = describe.each([[{ dist: false }], [{ dist: true }]]); -describeWithContext('config schema with context %j', (context) => { +describeWithContext('config schema with context %j', context => { it('produces correct config', async () => { const schema = await getConfigSchema(security); - await expect( - schema.validate({}, { context }) - ).resolves.toMatchSnapshot(); + await expect(schema.validate({}, { context })).resolves.toMatchSnapshot(); + }); +}); + +describe('config schema', () => { + describe('authc', () => { + describe('oidc', () => { + describe('realm', () => { + it(`returns a validation error when authProviders is "['oidc']" and realm is unspecified`, async () => { + const schema = await getConfigSchema(security); + const validationResult = schema.validate({ + authProviders: ['oidc'], + }); + expect(validationResult.error).toMatchSnapshot(); + }); + + it(`is valid when authProviders is "['oidc']" and realm is specified`, async () => { + const schema = await getConfigSchema(security); + const validationResult = schema.validate({ + authProviders: ['oidc'], + authc: { + oidc: { + realm: 'realm-1', + }, + }, + }); + expect(validationResult.error).toBeNull(); + expect(validationResult.value).toHaveProperty('authc.oidc.realm', 'realm-1'); + }); + + it(`returns a validation error when authProviders is "['oidc', 'basic']" and realm is unspecified`, async () => { + const schema = await getConfigSchema(security); + const validationResult = schema.validate({ + authProviders: ['oidc', 'basic'], + }); + expect(validationResult.error).toMatchSnapshot(); + }); + + it(`is valid when authProviders is "['oidc', 'basic']" and realm is specified`, async () => { + const schema = await getConfigSchema(security); + const validationResult = schema.validate({ + authProviders: ['oidc', 'basic'], + authc: { + oidc: { + realm: 'realm-1', + }, + }, + }); + expect(validationResult.error).toBeNull(); + expect(validationResult.value).toHaveProperty('authc.oidc.realm', 'realm-1'); + }); + + it(`realm is not allowed when authProviders is "['basic']"`, async () => { + const schema = await getConfigSchema(security); + const validationResult = schema.validate({ + authProviders: ['basic'], + authc: { + oidc: { + realm: 'realm-1', + }, + }, + }); + expect(validationResult.error).toMatchSnapshot(); + }); + }); + }); }); }); diff --git a/x-pack/plugins/security/server/lib/authentication/authenticator.ts b/x-pack/plugins/security/server/lib/authentication/authenticator.ts index 317c9cc87b356..f2bbacbfb12c9 100644 --- a/x-pack/plugins/security/server/lib/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/lib/authentication/authenticator.ts @@ -14,11 +14,13 @@ import { BasicAuthenticationProvider, SAMLAuthenticationProvider, TokenAuthenticationProvider, + OIDCAuthenticationProvider, } from './providers'; import { AuthenticationResult } from './authentication_result'; import { DeauthenticationResult } from './deauthentication_result'; import { Session } from './session'; import { LoginAttempt } from './login_attempt'; +import { AuthenticationProviderSpecificOptions } from './providers/base'; interface ProviderSession { provider: string; @@ -29,11 +31,15 @@ interface ProviderSession { // provider class that can handle specific authentication mechanism. const providerMap = new Map< string, - new (options: AuthenticationProviderOptions) => BaseAuthenticationProvider + new ( + options: AuthenticationProviderOptions, + providerSpecificOptions: AuthenticationProviderSpecificOptions + ) => BaseAuthenticationProvider >([ ['basic', BasicAuthenticationProvider], ['saml', SAMLAuthenticationProvider], ['token', TokenAuthenticationProvider], + ['oidc', OIDCAuthenticationProvider], ]); function assertRequest(request: Legacy.Request) { @@ -62,18 +68,44 @@ function getProviderOptions(server: Legacy.Server) { }; } +/** + * Prepares options object that is specific only to an authentication provider. + * @param server Server instance. + * @param providerType the type of the provider to get the options for. + */ +function getProviderSpecificOptions( + server: Legacy.Server, + providerType: string +): AuthenticationProviderSpecificOptions { + const config = server.config(); + // we can't use `config.has` here as it doesn't currently work with Joi's "alternatives" syntax which we + // are using to make the provider specific configuration required when the auth provider is specified + const authc = config.get>( + `xpack.security.authc` + ); + if (authc && authc[providerType] !== undefined) { + return authc[providerType] as AuthenticationProviderSpecificOptions; + } + + return {}; +} + /** * Instantiates authentication provider based on the provider key from config. * @param providerType Provider type key. * @param options Options to pass to provider's constructor. */ -function instantiateProvider(providerType: string, options: AuthenticationProviderOptions) { +function instantiateProvider( + providerType: string, + options: AuthenticationProviderOptions, + providerSpecificOptions: AuthenticationProviderSpecificOptions +) { const ProviderClassName = providerMap.get(providerType); if (!ProviderClassName) { throw new Error(`Unsupported authentication provider name: ${providerType}.`); } - return new ProviderClassName(options); + return new ProviderClassName(options, providerSpecificOptions); } /** @@ -117,13 +149,13 @@ class Authenticator { const providerOptions = Object.freeze(getProviderOptions(server)); this.providers = new Map( - authProviders.map( - providerType => - [providerType, instantiateProvider(providerType, providerOptions)] as [ - string, - BaseAuthenticationProvider - ] - ) + authProviders.map(providerType => { + const providerSpecificOptions = getProviderSpecificOptions(server, providerType); + return [ + providerType, + instantiateProvider(providerType, providerOptions, providerSpecificOptions), + ] as [string, BaseAuthenticationProvider]; + }) ); } diff --git a/x-pack/plugins/security/server/lib/authentication/providers/base.ts b/x-pack/plugins/security/server/lib/authentication/providers/base.ts index e133ad1f8e864..01c8a260ee673 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/lib/authentication/providers/base.ts @@ -20,6 +20,11 @@ export interface AuthenticationProviderOptions { log: (tags: string[], message: string) => void; } +/** + * Represents available provider specific options. + */ +export type AuthenticationProviderSpecificOptions = Record; + /** * Base class that all authentication providers should extend. */ diff --git a/x-pack/plugins/security/server/lib/authentication/providers/index.ts b/x-pack/plugins/security/server/lib/authentication/providers/index.ts index 0cc59f8a15a36..058bbe3a7f85c 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/index.ts +++ b/x-pack/plugins/security/server/lib/authentication/providers/index.ts @@ -8,3 +8,4 @@ export { BaseAuthenticationProvider, AuthenticationProviderOptions } from './bas export { BasicAuthenticationProvider, BasicCredentials } from './basic'; export { SAMLAuthenticationProvider } from './saml'; export { TokenAuthenticationProvider } from './token'; +export { OIDCAuthenticationProvider } from './oidc'; diff --git a/x-pack/plugins/security/server/lib/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/lib/authentication/providers/oidc.test.ts new file mode 100644 index 0000000000000..f7ecab4bcb8d6 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authentication/providers/oidc.test.ts @@ -0,0 +1,571 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import sinon from 'sinon'; +import Boom from 'boom'; + +import { mockAuthenticationProviderOptions } from './base.mock'; +import { requestFixture } from '../../__tests__/__fixtures__/request'; + +import { OIDCAuthenticationProvider } from './oidc'; + +describe('OIDCAuthenticationProvider', () => { + let provider: OIDCAuthenticationProvider; + let callWithRequest: sinon.SinonStub; + let callWithInternalUser: sinon.SinonStub; + beforeEach(() => { + const providerOptions = mockAuthenticationProviderOptions({ basePath: '/test-base-path' }); + const providerSpecificOptions = { realm: 'oidc1' }; + callWithRequest = providerOptions.client.callWithRequest as sinon.SinonStub; + callWithInternalUser = providerOptions.client.callWithInternalUser as sinon.SinonStub; + + provider = new OIDCAuthenticationProvider(providerOptions, providerSpecificOptions); + }); + + describe('`authenticate` method', () => { + it('does not handle AJAX request that can not be authenticated.', async () => { + const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }); + + const authenticationResult = await provider.authenticate(request, null); + + expect(authenticationResult.notHandled()).toBe(true); + }); + + it('redirects non-AJAX request that can not be authenticated to the OpenId Connect Provider.', async () => { + const request = requestFixture({ path: '/some-path', basePath: '/s/foo' }); + + callWithInternalUser.withArgs('shield.oidcPrepare').resolves({ + 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', + }); + + const authenticationResult = await provider.authenticate(request, null); + + sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcPrepare', { + body: { realm: `oidc1` }, + }); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe( + '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' + ); + expect(authenticationResult.state).toEqual({ + state: 'statevalue', + nonce: 'noncevalue', + nextURL: `/s/foo/some-path`, + }); + }); + + it('redirects third party initiated authentications to the OpenId Connect Provider.', async () => { + const request = requestFixture({ + path: '/api/security/v1/oidc', + search: '?iss=theissuer&login_hint=loginhint', + basePath: '/s/foo', + }); + + callWithInternalUser.withArgs('shield.oidcPrepare').resolves({ + 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', + }); + + const authenticationResult = await provider.authenticate(request, null); + + sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcPrepare', { + body: { iss: `theissuer`, login_hint: `loginhint` }, + }); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe( + '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' + ); + expect(authenticationResult.state).toEqual({ + state: 'statevalue', + nonce: 'noncevalue', + nextURL: `/s/foo/`, + }); + }); + + it('fails if OpenID Connect authentication request preparation fails.', async () => { + const request = requestFixture({ path: '/some-path' }); + + const failureReason = new Error('Realm is misconfigured!'); + callWithInternalUser.withArgs('shield.oidcPrepare').returns(Promise.reject(failureReason)); + + const authenticationResult = await provider.authenticate(request, null); + + sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcPrepare', { + body: { realm: `oidc1` }, + }); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + + it('gets token and redirects user to requested URL if OIDC authentication response is valid.', async () => { + const request = requestFixture({ + path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', + search: '?code=somecodehere&state=somestatehere', + }); + + callWithInternalUser + .withArgs('shield.oidcAuthenticate') + .resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' }); + + const authenticationResult = await provider.authenticate(request, { + state: 'statevalue', + nonce: 'noncevalue', + nextURL: '/test-base-path/some-path', + }); + + sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcAuthenticate', { + body: { + state: 'statevalue', + nonce: 'noncevalue', + redirect_uri: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', + }, + }); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe('/test-base-path/some-path'); + expect(authenticationResult.state).toEqual({ + accessToken: 'some-token', + refreshToken: 'some-refresh-token', + }); + }); + + it('fails if authentication response is presented but session state does not contain the state parameter.', async () => { + const request = requestFixture({ + path: '/api/security/v1/oidc', + search: '?code=somecodehere&state=somestatehere', + }); + + const authenticationResult = await provider.authenticate(request, { + nextURL: '/test-base-path/some-path', + }); + + sinon.assert.notCalled(callWithInternalUser); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toEqual( + Boom.badRequest( + 'Response session state does not have corresponding state or nonce parameters or redirect URL.' + ) + ); + }); + + it('fails if authentication response is presented but session state does not contain redirect URL.', async () => { + const request = requestFixture({ + path: '/api/security/v1/oidc', + search: '?code=somecodehere&state=somestatehere', + }); + + const authenticationResult = await provider.authenticate(request, { + state: 'statevalue', + nonce: 'noncevalue', + }); + + sinon.assert.notCalled(callWithInternalUser); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toEqual( + Boom.badRequest( + 'Response session state does not have corresponding state or nonce parameters or redirect URL.' + ) + ); + }); + + it('fails if session state is not presented.', async () => { + const request = requestFixture({ + path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', + search: '?code=somecodehere&state=somestatehere', + }); + + const authenticationResult = await provider.authenticate(request, {}); + + sinon.assert.notCalled(callWithInternalUser); + + expect(authenticationResult.failed()).toBe(true); + }); + + it('fails if code is invalid.', async () => { + const request = requestFixture({ + path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', + search: '?code=somecodehere&state=somestatehere', + }); + + const failureReason = new Error( + 'Failed to exchange code for Id Token using the Token Endpoint.' + ); + callWithInternalUser + .withArgs('shield.oidcAuthenticate') + .returns(Promise.reject(failureReason)); + + const authenticationResult = await provider.authenticate(request, { + state: 'statevalue', + nonce: 'noncevalue', + nextURL: '/test-base-path/some-path', + }); + + sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcAuthenticate', { + body: { + state: 'statevalue', + nonce: 'noncevalue', + redirect_uri: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', + }, + }); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + + it('succeeds if state contains a valid token.', async () => { + const user = { username: 'user' }; + const request = requestFixture(); + + callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); + + const authenticationResult = await provider.authenticate(request, { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }); + + expect(request.headers.authorization).toBe('Bearer some-valid-token'); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.state).toBe(undefined); + }); + + it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => { + const request = requestFixture({ headers: { authorization: 'Basic some:credentials' } }); + + const authenticationResult = await provider.authenticate(request, { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }); + + sinon.assert.notCalled(callWithRequest); + expect(request.headers.authorization).toBe('Basic some:credentials'); + expect(authenticationResult.notHandled()).toBe(true); + }); + + it('fails if token from the state is rejected because of unknown reason.', async () => { + const request = requestFixture(); + + const failureReason = new Error('Token is not valid!'); + callWithRequest + .withArgs(request, 'shield.authenticate') + .returns(Promise.reject(failureReason)); + + const authenticationResult = await provider.authenticate(request, { + accessToken: 'some-invalid-token', + refreshToken: 'some-invalid-refresh-token', + }); + + expect(request.headers).not.toHaveProperty('authorization'); + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + sinon.assert.neverCalledWith(callWithRequest, 'shield.getAccessToken'); + }); + + it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => { + const user = { username: 'user' }; + const request = requestFixture(); + + callWithRequest + .withArgs( + sinon.match({ headers: { authorization: 'Bearer expired-token' } }), + 'shield.authenticate' + ) + .rejects({ statusCode: 401 }); + + callWithRequest + .withArgs( + sinon.match({ headers: { authorization: 'Bearer new-access-token' } }), + 'shield.authenticate' + ) + .resolves(user); + + callWithInternalUser + .withArgs('shield.getAccessToken', { + body: { grant_type: 'refresh_token', refresh_token: 'valid-refresh-token' }, + }) + .resolves({ access_token: 'new-access-token', refresh_token: 'new-refresh-token' }); + + const authenticationResult = await provider.authenticate(request, { + accessToken: 'expired-token', + refreshToken: 'valid-refresh-token', + }); + + expect(request.headers.authorization).toBe('Bearer new-access-token'); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.state).toEqual({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + }); + }); + + it('fails if token from the state is expired and refresh attempt failed too.', async () => { + const request = requestFixture(); + + callWithRequest + .withArgs( + sinon.match({ headers: { authorization: 'Bearer expired-token' } }), + 'shield.authenticate' + ) + .rejects({ statusCode: 401 }); + + const refreshFailureReason = { + statusCode: 500, + message: 'Something is wrong with refresh token.', + }; + callWithInternalUser + .withArgs('shield.getAccessToken', { + body: { grant_type: 'refresh_token', refresh_token: 'invalid-refresh-token' }, + }) + .returns(Promise.reject(refreshFailureReason)); + + const authenticationResult = await provider.authenticate(request, { + accessToken: 'expired-token', + refreshToken: 'invalid-refresh-token', + }); + + expect(request.headers).not.toHaveProperty('authorization'); + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(refreshFailureReason); + }); + + it('redirects to OpenID Connect Provider for non-AJAX requests if refresh token is expired or already refreshed.', async () => { + const request = requestFixture({ path: '/some-path', basePath: '/s/foo' }); + + callWithInternalUser.withArgs('shield.oidcPrepare').resolves({ + 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', + }); + + callWithRequest + .withArgs( + sinon.match({ headers: { authorization: 'Bearer expired-token' } }), + 'shield.authenticate' + ) + .rejects({ statusCode: 401 }); + + callWithInternalUser + .withArgs('shield.getAccessToken', { + body: { grant_type: 'refresh_token', refresh_token: 'expired-refresh-token' }, + }) + .rejects({ statusCode: 400 }); + + const authenticationResult = await provider.authenticate(request, { + accessToken: 'expired-token', + refreshToken: 'expired-refresh-token', + }); + + sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcPrepare', { + body: { realm: `oidc1` }, + }); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe( + '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' + ); + expect(authenticationResult.state).toEqual({ + state: 'statevalue', + nonce: 'noncevalue', + nextURL: `/s/foo/some-path`, + }); + }); + + it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => { + const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }); + + callWithRequest + .withArgs( + sinon.match({ headers: { authorization: 'Bearer expired-token' } }), + 'shield.authenticate' + ) + .rejects({ statusCode: 401 }); + + callWithInternalUser + .withArgs('shield.getAccessToken', { + body: { grant_type: 'refresh_token', refresh_token: 'expired-refresh-token' }, + }) + .rejects({ statusCode: 400 }); + + const authenticationResult = await provider.authenticate(request, { + accessToken: 'expired-token', + refreshToken: 'expired-refresh-token', + }); + + expect(request.headers).not.toHaveProperty('authorization'); + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toEqual( + Boom.badRequest('Both elasticsearch access and refresh tokens are expired.') + ); + }); + + it('succeeds if `authorization` contains a valid token.', async () => { + const user = { username: 'user' }; + const request = requestFixture({ headers: { authorization: 'Bearer some-valid-token' } }); + + callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); + + const authenticationResult = await provider.authenticate(request); + + expect(request.headers.authorization).toBe('Bearer some-valid-token'); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.state).toBe(undefined); + }); + + it('fails if token from `authorization` header is rejected.', async () => { + const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } }); + + const failureReason = new Error('Token is not valid!'); + callWithRequest + .withArgs(request, 'shield.authenticate') + .returns(Promise.reject(failureReason)); + + const authenticationResult = await provider.authenticate(request); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + + it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => { + const user = { username: 'user' }; + const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } }); + + const failureReason = new Error('Token is not valid!'); + callWithRequest + .withArgs(request, 'shield.authenticate') + .returns(Promise.reject(failureReason)); + + callWithRequest + .withArgs(sinon.match({ headers: { authorization: 'Bearer some-valid-token' } })) + .resolves(user); + + const authenticationResult = await provider.authenticate(request, { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + }); + + describe('`deauthenticate` method', () => { + it('returns `notHandled` if state is not presented or does not include access token.', async () => { + const request = requestFixture(); + + let deauthenticateResult = await provider.deauthenticate(request, {}); + expect(deauthenticateResult.notHandled()).toBe(true); + + deauthenticateResult = await provider.deauthenticate(request, {}); + expect(deauthenticateResult.notHandled()).toBe(true); + + deauthenticateResult = await provider.deauthenticate(request, { nonce: 'x' }); + expect(deauthenticateResult.notHandled()).toBe(true); + + sinon.assert.notCalled(callWithInternalUser); + }); + + it('fails if OpenID Connect logout call fails.', async () => { + const request = requestFixture(); + const accessToken = 'x-oidc-token'; + const refreshToken = 'x-oidc-refresh-token'; + + const failureReason = new Error('Realm is misconfigured!'); + callWithInternalUser.withArgs('shield.oidcLogout').returns(Promise.reject(failureReason)); + + const authenticationResult = await provider.deauthenticate(request, { + accessToken, + refreshToken, + }); + + sinon.assert.calledOnce(callWithInternalUser); + sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcLogout', { + body: { token: accessToken, refresh_token: refreshToken }, + }); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + + it('redirects to /logged_out if `redirect` field in OpenID Connect logout response is null.', async () => { + const request = requestFixture(); + const accessToken = 'x-oidc-token'; + const refreshToken = 'x-oidc-refresh-token'; + + callWithInternalUser.withArgs('shield.oidcLogout').resolves({ redirect: null }); + + const authenticationResult = await provider.deauthenticate(request, { + accessToken, + refreshToken, + }); + + sinon.assert.calledOnce(callWithInternalUser); + sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcLogout', { + body: { token: accessToken, refresh_token: refreshToken }, + }); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe('/test-base-path/logged_out'); + }); + + it('redirects user to the OpenID Connect Provider if RP initiated SLO is supported.', async () => { + const request = requestFixture(); + const accessToken = 'x-oidc-token'; + const refreshToken = 'x-oidc-refresh-token'; + + callWithInternalUser + .withArgs('shield.oidcLogout') + .resolves({ redirect: 'http://fake-idp/logout&id_token_hint=thehint' }); + + const authenticationResult = await provider.deauthenticate(request, { + accessToken, + refreshToken, + }); + + sinon.assert.calledOnce(callWithInternalUser); + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe('http://fake-idp/logout&id_token_hint=thehint'); + }); + }); +}); diff --git a/x-pack/plugins/security/server/lib/authentication/providers/oidc.ts b/x-pack/plugins/security/server/lib/authentication/providers/oidc.ts new file mode 100644 index 0000000000000..7ed9c11915a38 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authentication/providers/oidc.ts @@ -0,0 +1,517 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import type from 'type-detect'; +import { Legacy } from 'kibana'; +import { canRedirectRequest } from '../../can_redirect_request'; +import { getErrorStatusCode } from '../../errors'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; +import { + AuthenticationProviderOptions, + BaseAuthenticationProvider, + AuthenticationProviderSpecificOptions, +} from './base'; + +/** + * The state supported by the provider (for the OpenID Connect handshake or established session). + */ +interface ProviderState { + /** + * Unique identifier of the OpenID Connect request initiated the handshake used to mitigate + * replay attacks. + */ + nonce?: string; + + /** + * Unique identifier of the OpenID Connect request initiated the handshake used to mitigate + * CSRF. + */ + state?: string; + + /** + * URL to redirect user to after successful OpenID Connect handshake. + */ + nextURL?: string; + + /** + * Elasticsearch access token issued as the result of successful OpenID Connect handshake and that should be provided + * with every request to Elasticsearch on behalf of the authenticated user. This token will eventually expire. + */ + accessToken?: string; + + /** + * Once the elasticsearch access token expires the refresh token is used to get a new pair of access/refresh tokens + * without any user involvement. If not used this token will eventually expire as well. + */ + refreshToken?: string; +} + +/** + * Defines the shape of an incoming OpenID Connect Request + */ +type OIDCIncomingRequest = Legacy.Request & { + payload: { + iss?: string; + login_hint?: string; + }; + query: { + iss?: string; + code?: string; + state?: string; + login_hint?: string; + error?: string; + error_description?: string; + }; +}; + +/** + * Checks if the Request object represents an HTTP request regarding authentication with OpenID + * Connect. This can be + * - An HTTP GET request with a query parameter named `iss` as part of a 3rd party initiated authentication + * - An HTTP POST request with a parameter named `iss` as part of a 3rd party initiated authentication + * - An HTTP GET request with a query parameter named `code` as the response to a successful authentication from + * an OpenID Connect Provider + * - An HTTP GET request with a query parameter named `error` as the response to a failed authentication from + * an OpenID Connect Provider + * @param request Request instance. + */ +function isOIDCIncomingRequest(request: Legacy.Request): request is OIDCIncomingRequest { + return ( + (request.payload != null && !!(request.payload as Record).iss) || + (request.query != null && + (!!(request.query as any).iss || + !!(request.query as any).code || + !!(request.query as any).error)) + ); +} + +/** + * Checks the error returned by Elasticsearch as the result of `authenticate` call and returns `true` if request + * has been rejected because of expired token, otherwise returns `false`. + * @param err Error returned from Elasticsearch. + */ +function isAccessTokenExpiredError(err?: any) { + const errorStatusCode = getErrorStatusCode(err); + return ( + errorStatusCode === 401 || + (errorStatusCode === 500 && + err && + err.body && + err.body.error && + err.body.error.reason === 'token document is missing and must be present') + ); +} + +/** + * Provider that supports authentication using an OpenID Connect realm in Elasticsearch. + */ +export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { + private readonly realm: string; + + constructor( + protected readonly options: Readonly, + oidcOptions: Readonly + ) { + super(options); + if (!oidcOptions.realm) { + throw new Error('Realm name must be specified'); + } + + if (type(oidcOptions.realm) !== 'string') { + throw new Error('Realm must be a string'); + } + + this.realm = oidcOptions.realm as string; + } + + /** + * Performs OpenID Connect request authentication. + * @param request Request instance. + * @param [state] Optional state object associated with the provider. + */ + public async authenticate(request: Legacy.Request, state?: ProviderState | null) { + this.debug(`Trying to authenticate user request to ${request.url.path}.`); + + let { + authenticationResult, + headerNotRecognized, // eslint-disable-line prefer-const + } = await this.authenticateViaHeader(request); + if (headerNotRecognized) { + return authenticationResult; + } + + if (state && authenticationResult.notHandled()) { + authenticationResult = await this.authenticateViaState(request, state); + if (authenticationResult.failed() && isAccessTokenExpiredError(authenticationResult.error)) { + authenticationResult = await this.authenticateViaRefreshToken(request, state); + } + } + + if (isOIDCIncomingRequest(request) && authenticationResult.notHandled()) { + // This might be the OpenID Connect Provider redirecting the user to `redirect_uri` after authentication or + // a third party initiating an authentication + authenticationResult = await this.authenticateViaResponseUrl(request, state); + } + + // If we couldn't authenticate by means of all methods above, let's try to + // initiate an OpenID Connect based authentication, otherwise just return the authentication result we have. + // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in + // another tab) + return authenticationResult.notHandled() + ? await this.initiateOIDCAuthentication(request, { realm: this.realm }) + : authenticationResult; + } + + /** + * Attempts to handle a request that might be a third party initiated OpenID connect authentication attempt or the + * OpenID Connect Provider redirecting back the UA after an authentication success/failure. In the former case which + * is signified by the existence of an iss parameter (either in the query of a GET request or the body of a POST + * request) it attempts to start the authentication flow by calling initiateOIDCAuthentication. + * + * In the latter case, it attempts to exchange the authentication response to an elasticsearch access token, passing + * along to Elasticsearch the state and nonce parameters from the user's session. + * + * When login succeeds the elasticsearch access token and refresh token are stored in the state and user is redirected + * to the URL that was requested before authentication flow started or to default Kibana location in case of a third + * party initiated login + * @param request Request instance. + * @param [sessionState] Optional state object associated with the provider. + */ + private async authenticateViaResponseUrl( + request: OIDCIncomingRequest, + sessionState?: ProviderState | null + ) { + this.debug('Trying to authenticate via OpenID Connect response query.'); + // First check to see if this is a Third Party initiated authentication (which can happen via POST or GET) + const iss = (request.query && request.query.iss) || (request.payload && request.payload.iss); + const loginHint = + (request.query && request.query.login_hint) || + (request.payload && request.payload.login_hint); + if (iss) { + this.debug('Authentication has been initiated by a Third Party.'); + // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in + // another tab) + const oidcPrepareParams = loginHint ? { iss, login_hint: loginHint } : { iss }; + return this.initiateOIDCAuthentication(request, oidcPrepareParams); + } + + if (!request.query || !request.query.code) { + this.debug('OpenID Connect Authentication response is not found.'); + return AuthenticationResult.notHandled(); + } + // If it is an authentication response and the users' session state doesn't contain all the necessary information, + // then something unexpected happened and we should fail because Elasticsearch won't be able to validate the + // response. + const { nonce: stateNonce = '', state: stateOIDCState = '', nextURL: stateRedirectURL = '' } = + sessionState || {}; + if (!stateNonce || !stateOIDCState || !stateRedirectURL) { + const message = + 'Response session state does not have corresponding state or nonce parameters or redirect URL.'; + this.debug(message); + return AuthenticationResult.failed(Boom.badRequest(message)); + } + + // We have all the necessary parameters, so attempt to complete the OpenID Connect Authentication + try { + // This operation should be performed on behalf of the user with a privilege that normal + // user usually doesn't have `cluster:admin/xpack/security/oidc/authenticate`. + const { + access_token: accessToken, + refresh_token: refreshToken, + } = await this.options.client.callWithInternalUser('shield.oidcAuthenticate', { + body: { + state: stateOIDCState, + nonce: stateNonce, + // redirect_uri contains the code that es will exchange for an ID Token. Elasticserach + // will do all the required validation and parsing. We pass the path only as we can't be + // sure of the full URL and Elasticsearch doesn't need it anyway + redirect_uri: request.url.path, + }, + }); + + this.debug('Request has been authenticated via OpenID Connect.'); + + return AuthenticationResult.redirectTo(stateRedirectURL, { + accessToken, + refreshToken, + }); + } catch (err) { + this.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); + return AuthenticationResult.failed(err); + } + } + + /** + * Initiates an authentication attempt by either providing the realm name or the issuer to Elasticsearch + * + * @param request Request instance. + * @param params + */ + private async initiateOIDCAuthentication( + request: Legacy.Request, + params: { realm: string } | { iss: string; login_hint?: string }, + sessionState?: ProviderState | null + ) { + this.debug('Trying to initiate OpenID Connect authentication.'); + + // If client can't handle redirect response, we shouldn't initiate OpenID Connect authentication. + if (!canRedirectRequest(request)) { + this.debug('OpenID Connect authentication can not be initiated by AJAX requests.'); + return AuthenticationResult.notHandled(); + } + + try { + /* + * Possibly adds the state and nonce parameter that was saved in the user's session state to + * the params. There is no use case where we would have only a state parameter or only a nonce + * parameter in the session state so we only enrich the params object if we have both + */ + const oidcPrepareParams = + sessionState && sessionState.nonce && sessionState.state + ? { ...params, nonce: sessionState.nonce, state: sessionState.state } + : params; + // This operation should be performed on behalf of the user with a privilege that normal + // user usually doesn't have `cluster:admin/xpack/security/oidc/prepare`. + const { state, nonce, redirect } = await this.options.client.callWithInternalUser( + 'shield.oidcPrepare', + { + body: oidcPrepareParams, + } + ); + + this.debug('Redirecting to OpenID Connect Provider with authentication request.'); + // If this is a third party initiated login, redirect to the base path + const redirectAfterLogin = `${request.getBasePath()}${ + 'iss' in params ? '/' : request.url.path + }`; + return AuthenticationResult.redirectTo( + redirect, + // Store the state and nonce parameters in the session state of the user + { state, nonce, nextURL: redirectAfterLogin } + ); + } catch (err) { + this.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`); + return AuthenticationResult.failed(err); + } + } + + /** + * Validates whether request contains `Bearer ***` Authorization header and just passes it + * forward to Elasticsearch backend. + * @param request Request instance. + */ + private async authenticateViaHeader(request: Legacy.Request) { + this.debug('Trying to authenticate via header.'); + + const authorization = request.headers.authorization; + if (!authorization) { + this.debug('Authorization header is not presented.'); + return { + authenticationResult: AuthenticationResult.notHandled(), + }; + } + + const authenticationSchema = authorization.split(/\s+/)[0]; + if (authenticationSchema.toLowerCase() !== 'bearer') { + this.debug(`Unsupported authentication schema: ${authenticationSchema}`); + return { + authenticationResult: AuthenticationResult.notHandled(), + headerNotRecognized: true, + }; + } + + try { + const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + + this.debug('Request has been authenticated via header.'); + + return { + authenticationResult: AuthenticationResult.succeeded(user), + }; + } catch (err) { + this.debug(`Failed to authenticate request via header: ${err.message}`); + return { + authenticationResult: AuthenticationResult.failed(err), + }; + } + } + + /** + * Tries to extract an elasticsearch access token from state and adds it to the request before it's + * forwarded to Elasticsearch backend. + * @param request Request instance. + * @param state State value previously stored by the provider. + */ + private async authenticateViaState(request: Legacy.Request, { accessToken }: ProviderState) { + this.debug('Trying to authenticate via state.'); + + if (!accessToken) { + this.debug('Elasticsearch access token is not found in state.'); + return AuthenticationResult.notHandled(); + } + + request.headers.authorization = `Bearer ${accessToken}`; + + try { + const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + + this.debug('Request has been authenticated via state.'); + + return AuthenticationResult.succeeded(user); + } catch (err) { + this.debug(`Failed to authenticate request via state: ${err.message}`); + + // Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before, + // otherwise it would have been used or completely rejected by the `authenticateViaHeader`. + // We can't just set `authorization` to `undefined` or `null`, we should remove this property + // entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if + // it's called with this request once again down the line (e.g. in the next authentication provider). + delete request.headers.authorization; + + return AuthenticationResult.failed(err); + } + } + + /** + * This method is only called when authentication via an elasticsearch access token stored in the state failed because + * of expired token. So we should use the elasticsearch refresh token, that is also stored in the state, to extend + * expired elasticsearch access token and authenticate user with it. + * @param request Request instance. + * @param state State value previously stored by the provider. + */ + private async authenticateViaRefreshToken( + request: Legacy.Request, + { refreshToken }: ProviderState + ) { + this.debug('Trying to refresh elasticsearch access token.'); + + if (!refreshToken) { + this.debug('Refresh token is not found in state.'); + return AuthenticationResult.notHandled(); + } + + try { + // Token should be refreshed by the same user that obtained that token. + const { + access_token: newAccessToken, + refresh_token: newRefreshToken, + } = await this.options.client.callWithInternalUser('shield.getAccessToken', { + body: { grant_type: 'refresh_token', refresh_token: refreshToken }, + }); + + this.debug('Elasticsearch access token has been successfully refreshed.'); + + request.headers.authorization = `Bearer ${newAccessToken}`; + + const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + + this.debug('Request has been authenticated via refreshed token.'); + + return AuthenticationResult.succeeded(user, { + accessToken: newAccessToken, + refreshToken: newRefreshToken, + }); + } catch (err) { + this.debug(`Failed to refresh elasticsearch access token: ${err.message}`); + + // Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before, + // otherwise it would have been used or completely rejected by the `authenticateViaHeader`. + // We can't just set `authorization` to `undefined` or `null`, we should remove this property + // entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if + // it's called with this request once again down the line (e.g. in the next authentication provider). + delete request.headers.authorization; + + // There are at least two common cases when refresh token request can fail: + // 1. Refresh token is valid only for 24 hours and if it hasn't been used it expires. + // + // 2. Refresh token is one-time use token and if it has been used already, it is treated in the same way as + // expired token. Even though it's an edge case, there are several perfectly valid scenarios when it can + // happen. E.g. when several simultaneous AJAX request has been sent to Kibana, but elasticsearch access token has + // expired already, so the first request that reaches Kibana uses refresh token to get a new elasticsearch access + // token, but the second concurrent request has no idea about that and tries to refresh access token as well. All + // ends well when first request refreshes the elasticsearch access token and updates session cookie with fresh + // access/refresh token pair. But if user navigates to another page _before_ AJAX request (the one that triggered + // token refresh)responds with updated cookie, then user will have only that old cookie with expired elasticsearch + // access token and refresh token that has been used already. + // + // When user has neither valid access nor refresh token, the only way to resolve this issue is to re-initiate the + // OpenID Connect authentication by requesting a new authentication request to send to the OpenID Connect Provider + // and exchange it's forthcoming response for a new Elasticsearch access/refresh token pair. In case this is an + // AJAX request, we just reply with `400` and clear error message. + // There are two reasons for `400` and not `401`: Elasticsearch search responds with `400` so it seems logical + // to do the same on Kibana side and `401` would force user to logout and do full SLO if it's supported. + if (getErrorStatusCode(err) === 400) { + if (canRedirectRequest(request)) { + this.debug( + 'Both elasticsearch access and refresh tokens are expired. Re-initiating OpenID Connect authentication.' + ); + return this.initiateOIDCAuthentication(request, { realm: this.realm }); + } + + return AuthenticationResult.failed( + Boom.badRequest('Both elasticsearch access and refresh tokens are expired.') + ); + } + + return AuthenticationResult.failed(err); + } + } + + /** + * Invalidates an elasticsearch access token and refresh token that were originally created as a successful response + * to an OpenID Connect based authentication. This does not handle OP initiated Single Logout + * @param request Request instance. + * @param state State value previously stored by the provider. + */ + public async deauthenticate(request: Legacy.Request, state: ProviderState) { + this.debug(`Trying to deauthenticate user via ${request.url.path}.`); + + if (!state || !state.accessToken) { + this.debug('There is no elasticsearch access token to invalidate.'); + return DeauthenticationResult.notHandled(); + } + + try { + const logoutBody = { + body: { + token: state.accessToken, + refresh_token: state.refreshToken, + }, + }; + // This operation should be performed on behalf of the user with a privilege that normal + // user usually doesn't have `cluster:admin/xpack/security/oidc/logout`. + const { redirect } = await this.options.client.callWithInternalUser( + 'shield.oidcLogout', + logoutBody + ); + + this.debug('User session has been successfully invalidated.'); + + // Having non-null `redirect` field within logout response means that the OpenID Connect realm configuration + // supports RP initiated Single Logout and we should redirect user to the specified location in the OpenID Connect + // Provider to properly complete logout. + if (redirect != null) { + this.debug('Redirecting user to the OpenID Connect Provider to complete logout.'); + return DeauthenticationResult.redirectTo(redirect); + } + + return DeauthenticationResult.redirectTo(`${this.options.basePath}/logged_out`); + } catch (err) { + this.debug(`Failed to deauthenticate user: ${err.message}`); + return DeauthenticationResult.failed(err); + } + } + + /** + * Logs message with `debug` level and oidc/security related tags. + * @param message Message to log. + */ + private debug(message: string) { + this.options.log(['debug', 'security', 'oidc'], message); + } +} diff --git a/x-pack/plugins/security/server/routes/api/v1/authenticate.js b/x-pack/plugins/security/server/routes/api/v1/authenticate.js index ffce2137ddfce..b9423c3ec2d85 100644 --- a/x-pack/plugins/security/server/routes/api/v1/authenticate.js +++ b/x-pack/plugins/security/server/routes/api/v1/authenticate.js @@ -71,6 +71,84 @@ export function initAuthenticateApi(server) { } }); + server.route({ + method: 'GET', + path: '/api/security/v1/oidc', + config: { + auth: false, + validate: { + query: Joi.object().keys({ + iss: Joi.string(), + login_hint: Joi.string(), + target_link_uri: Joi.string(), + code: Joi.string(), + error: Joi.string(), + error_description: Joi.string(), + error_uri: Joi.string(), + state: Joi.string() + }) + } + }, + async handler(request, h) { + try { + // We handle the fact that the user might get redirected to Kibana while already having an session + // Return an error notifying the user they are already logged in. + const authenticationResult = await server.plugins.security.authenticate(request); + if (authenticationResult.succeeded()) { + return Boom.forbidden( + 'Sorry, you already have an active Kibana session. ' + + 'If you want to start a new one, please logout from the existing session first.' + ); + } + + if (authenticationResult.redirected()) { + return h.redirect(authenticationResult.redirectURL); + } + + throw Boom.unauthorized(authenticationResult.error); + } catch (err) { + throw wrapError(err); + } + } + }); + + server.route({ + // POST is only allowed for Third Party initiated authentication + method: 'POST', + path: '/api/security/v1/oidc', + config: { + auth: false, + validate: { + query: Joi.object().keys({ + iss: Joi.string(), + login_hint: Joi.string(), + target_link_uri: Joi.string() + }) + } + }, + async handler(request, h) { + try { + // We handle the fact that the user might get redirected to Kibana while already having an session + // in the same exact manner as with saml. Return an error notifying the user they are already logged in. + const authenticationResult = await server.plugins.security.authenticate(request); + if (authenticationResult.succeeded()) { + return Boom.forbidden( + 'Sorry, you already have an active Kibana session. ' + + 'If you want to start a new one, please logout from the existing session first.' + ); + } + + if (authenticationResult.redirected()) { + return h.redirect(authenticationResult.redirectURL); + } + + throw Boom.unauthorized(authenticationResult.error); + } catch (err) { + throw wrapError(err); + } + } + }); + server.route({ method: 'GET', path: '/api/security/v1/logout', diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 4bf21fbea6040..58aaea01666e1 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -14,6 +14,7 @@ require('@kbn/test').runTestsCli([ require.resolve('../test/plugin_api_integration/config.js'), require.resolve('../test/saml_api_integration/config.js'), require.resolve('../test/token_api_integration/config.js'), + require.resolve('../test/oidc_api_integration/config.js'), require.resolve('../test/spaces_api_integration/spaces_only/config'), require.resolve('../test/spaces_api_integration/security_and_spaces/config_trial'), require.resolve('../test/spaces_api_integration/security_and_spaces/config_basic'), diff --git a/x-pack/server/lib/esjs_shield_plugin.js b/x-pack/server/lib/esjs_shield_plugin.js index 22c2c757db4c3..e6e9a6f58fe1f 100644 --- a/x-pack/server/lib/esjs_shield_plugin.js +++ b/x-pack/server/lib/esjs_shield_plugin.js @@ -327,35 +327,78 @@ }); /** - * Invalidates SAML access token. + * Invalidates SAML session based on Logout Request received from the Identity Provider. * - * @param {string} token SAML access token that needs to be invalidated. + * @param {string} queryString URL encoded query string provided by Identity Provider. + * @param {string} acs Assertion consumer service URL to use for SAML request or URL in the + * Kibana to which identity provider will post SAML response. Based on the ACS Elasticsearch + * will choose the right SAML realm to invalidate session. * * @returns {{redirect?: string}} */ - shield.samlLogout = ca({ + shield.samlInvalidate = ca({ method: 'POST', needBody: true, url: { - fmt: '/_security/saml/logout' + fmt: '/_security/saml/invalidate' } }); /** - * Invalidates SAML session based on Logout Request received from the Identity Provider. + * Asks Elasticsearch to prepare an OpenID Connect authentication request to be sent to + * the 3rd-party OpenID Connect provider. * - * @param {string} queryString URL encoded query string provided by Identity Provider. - * @param {string} acs Assertion consumer service URL to use for SAML request or URL in the - * Kibana to which identity provider will post SAML response. Based on the ACS Elasticsearch - * will choose the right SAML realm to invalidate session. + * @param {string} realm The OpenID Connect realm name in Elasticsearch * - * @returns {{redirect?: string}} + * @returns {{state: string, nonce: string, redirect: string}} Object that includes two opaque parameters that need + * to be sent to Elasticsearch with the OpenID Connect response and redirect URL to the OpenID Connect provider that + * will be used to authenticate user. */ - shield.samlInvalidate = ca({ + shield.oidcPrepare = ca({ method: 'POST', needBody: true, url: { - fmt: '/_security/saml/invalidate' + fmt: '/_security/oidc/prepare' + } + }); + + /** + * Sends the URL to which the OpenID Connect Provider redirected the UA to Elasticsearch for validation. + * + * @param {string} state The state parameter that was returned by Elasticsearch in the + * preparation response. + * @param {string} nonce The nonce parameter that was returned by Elasticsearch in the + * preparation response. + * @param {string} redirect_uri The URL to where the UA was redirected by the OpenID Connect provider. + * + * @returns {{username: string, access_token: string, refresh_token; string, expires_in: number}} Object that + * includes name of the user, access token to use for any consequent requests that + * need to be authenticated and a number of seconds after which access token will expire. + */ + shield.oidcAuthenticate = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_security/oidc/authenticate' + } + }); + + /** + * Invalidates an access token and refresh token pair that was generated after an OpenID Connect authentication. + * + * @param {string} token An access token that was created by authenticating to an OpenID Connect realm and + * that needs to be invalidated. + * @param {string} refres_token A refresh token that was created by authenticating to an OpenID Connect realm and + * that needs to be invalidated. + * + * @returns {{redirect?: string}} If the Elasticsearch OpenID Connect realm configuration and the + * OpenID Connect provider supports RP-initiated SLO, a URL to redirect the UA + */ + shield.oidcLogout = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_security/oidc/logout' } }); diff --git a/x-pack/test/oidc_api_integration/apis/index.js b/x-pack/test/oidc_api_integration/apis/index.js new file mode 100644 index 0000000000000..59b1d035a35fe --- /dev/null +++ b/x-pack/test/oidc_api_integration/apis/index.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ loadTestFile }) { + describe('apis OpenID Connect', function () { + this.tags('ciGroup6'); + loadTestFile(require.resolve('./security')); + }); +} diff --git a/x-pack/test/oidc_api_integration/apis/security/index.js b/x-pack/test/oidc_api_integration/apis/security/index.js new file mode 100644 index 0000000000000..2949a5c8c03a9 --- /dev/null +++ b/x-pack/test/oidc_api_integration/apis/security/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ loadTestFile }) { + describe('security', () => { + loadTestFile(require.resolve('./oidc_initiate_auth')); + }); +} diff --git a/x-pack/test/oidc_api_integration/apis/security/oidc_initiate_auth.js b/x-pack/test/oidc_api_integration/apis/security/oidc_initiate_auth.js new file mode 100644 index 0000000000000..e6d33ebbc742c --- /dev/null +++ b/x-pack/test/oidc_api_integration/apis/security/oidc_initiate_auth.js @@ -0,0 +1,518 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import request from 'request'; +import url from 'url'; +import { getStateAndNonce } from '../../fixtures/oidc_tools'; +import { delay } from 'bluebird'; + +export default function ({ getService }) { + const supertest = getService('supertestWithoutAuth'); + + describe('OpenID Connect authentication', () => { + it('should reject API requests if client is not authenticated', async () => { + await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .expect(401); + }); + + describe('initiating handshake', () => { + it('should properly set cookie, return all parameters and redirect user', async () => { + const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three') + .expect(302); + + const cookies = handshakeResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const handshakeCookie = request.cookie(cookies[0]); + expect(handshakeCookie.key).to.be('sid'); + expect(handshakeCookie.value).to.not.be.empty(); + expect(handshakeCookie.path).to.be('/'); + expect(handshakeCookie.httpOnly).to.be(true); + + const redirectURL = url.parse(handshakeResponse.headers.location, true /* parseQueryString */); + expect(redirectURL.href.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`)).to.be(true); + expect(redirectURL.query.scope).to.not.be.empty(); + expect(redirectURL.query.response_type).to.not.be.empty(); + expect(redirectURL.query.client_id).to.not.be.empty(); + expect(redirectURL.query.redirect_uri).to.not.be.empty(); + expect(redirectURL.query.state).to.not.be.empty(); + expect(redirectURL.query.nonce).to.not.be.empty(); + }); + + it('should properly set cookie, return all parameters and redirect user for Third Party initiated', async () => { + const handshakeResponse = await supertest.get('/api/security/v1/oidc?iss=https://test-op.elastic.co') + .expect(302); + + const cookies = handshakeResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const handshakeCookie = request.cookie(cookies[0]); + expect(handshakeCookie.key).to.be('sid'); + expect(handshakeCookie.value).to.not.be.empty(); + expect(handshakeCookie.path).to.be('/'); + expect(handshakeCookie.httpOnly).to.be(true); + + const redirectURL = url.parse(handshakeResponse.headers.location, true /* parseQueryString */); + expect(redirectURL.href.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`)).to.be(true); + expect(redirectURL.query.scope).to.not.be.empty(); + expect(redirectURL.query.response_type).to.not.be.empty(); + expect(redirectURL.query.client_id).to.not.be.empty(); + expect(redirectURL.query.redirect_uri).to.not.be.empty(); + expect(redirectURL.query.state).to.not.be.empty(); + expect(redirectURL.query.nonce).to.not.be.empty(); + }); + + it('should not allow access to the API with the handshake cookie', async () => { + const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three') + .expect(302); + + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); + await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', handshakeCookie.cookieString()) + .expect(401); + }); + + it('AJAX requests should not initiate handshake', async () => { + const ajaxResponse = await supertest.get('/abc/xyz/handshake?one=two three') + .set('kbn-xsrf', 'xxx') + .expect(401); + + expect(ajaxResponse.headers['set-cookie']).to.be(undefined); + }); + }); + + describe('finishing handshake', () => { + let stateAndNonce; + let handshakeCookie; + + beforeEach(async () => { + const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three') + .expect(302); + + handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); + stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens + await supertest + .post('/api/oidc_provider/setup') + .set('kbn-xsrf', 'xxx') + .send({ nonce: stateAndNonce.nonce }) + .expect(200); + }); + + it('should fail if OpenID Connect response is not complemented with handshake cookie', async () => { + await supertest.get(`/api/security/v1/oidc?code=thisisthecode&state=${stateAndNonce.state}`) + .set('kbn-xsrf', 'xxx') + .expect(401); + }); + + it('should fail if state is not matching', async () => { + await supertest.get(`/api/security/v1/oidc?code=thisisthecode&state=someothervalue`) + .set('kbn-xsrf', 'xxx') + .set('Cookie', handshakeCookie.cookieString()) + .expect(401); + }); + + it('should succeed if both the OpenID Connect response and the cookie are provided', async () => { + const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`) + .set('kbn-xsrf', 'xxx') + .set('Cookie', handshakeCookie.cookieString()) + .expect(302); + + // User should be redirected to the URL that initiated handshake. + expect(oidcAuthenticationResponse.headers.location).to.be('/abc/xyz/handshake?one=two%20three'); + + const cookies = oidcAuthenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = request.cookie(cookies[0]); + expect(sessionCookie.key).to.be('sid'); + expect(sessionCookie.value).to.not.be.empty(); + expect(sessionCookie.path).to.be('/'); + expect(sessionCookie.httpOnly).to.be(true); + + const apiResponse = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + expect(apiResponse.body).to.only.have.keys([ + 'username', + 'full_name', + 'email', + 'roles', + 'scope', + 'metadata', + 'enabled', + 'authentication_realm', + 'lookup_realm', + ]); + + expect(apiResponse.body.username).to.be('user1'); + }); + }); + + describe('Complete third party initiated authentication', () => { + it('should authenticate a user when a third party initiates the authentication', async () => { + const handshakeResponse = await supertest.get('/api/security/v1/oidc?iss=https://test-op.elastic.co') + .expect(302); + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); + const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + + // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens + await supertest + .post('/api/oidc_provider/setup') + .set('kbn-xsrf', 'xxx') + .send({ nonce: stateAndNonce.nonce }) + .expect(200); + + const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code2&state=${stateAndNonce.state}`) + .set('kbn-xsrf', 'xxx') + .set('Cookie', handshakeCookie.cookieString()) + .expect(302); + const cookies = oidcAuthenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = request.cookie(cookies[0]); + expect(sessionCookie.key).to.be('sid'); + expect(sessionCookie.value).to.not.be.empty(); + expect(sessionCookie.path).to.be('/'); + expect(sessionCookie.httpOnly).to.be(true); + + const apiResponse = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + expect(apiResponse.body).to.only.have.keys([ + 'username', + 'full_name', + 'email', + 'roles', + 'scope', + 'metadata', + 'enabled', + 'authentication_realm', + 'lookup_realm', + ]); + + expect(apiResponse.body.username).to.be('user2'); + }); + }); + + describe('API access with active session', () => { + let stateAndNonce; + let sessionCookie; + + beforeEach(async () => { + const handshakeResponse = await supertest.get('/abc/xyz') + .expect(302); + + sessionCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); + stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens + await supertest + .post('/api/oidc_provider/setup') + .set('kbn-xsrf', 'xxx') + .send({ nonce: stateAndNonce.nonce }) + .expect(200); + + const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`) + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(302); + + sessionCookie = request.cookie(oidcAuthenticationResponse.headers['set-cookie'][0]); + }); + + it('should extend cookie on every successful non-system API call', async () => { + const apiResponseOne = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(apiResponseOne.headers['set-cookie']).to.not.be(undefined); + const sessionCookieOne = request.cookie(apiResponseOne.headers['set-cookie'][0]); + + expect(sessionCookieOne.value).to.not.be.empty(); + expect(sessionCookieOne.value).to.not.equal(sessionCookie.value); + + const apiResponseTwo = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(apiResponseTwo.headers['set-cookie']).to.not.be(undefined); + const sessionCookieTwo = request.cookie(apiResponseTwo.headers['set-cookie'][0]); + + expect(sessionCookieTwo.value).to.not.be.empty(); + expect(sessionCookieTwo.value).to.not.equal(sessionCookieOne.value); + }); + + it('should not extend cookie for system API calls', async () => { + const systemAPIResponse = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('kbn-system-api', 'true') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(systemAPIResponse.headers['set-cookie']).to.be(undefined); + }); + + it('should fail and preserve session cookie if unsupported authentication schema is used', async () => { + const apiResponse = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Authorization', 'Basic AbCdEf') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + expect(apiResponse.headers['set-cookie']).to.be(undefined); + }); + }); + + describe('logging out', () => { + let sessionCookie; + + beforeEach(async () => { + const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three') + .expect(302); + + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); + const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens + await supertest + .post('/api/oidc_provider/setup') + .set('kbn-xsrf', 'xxx') + .send({ nonce: stateAndNonce.nonce }) + .expect(200); + + const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`) + .set('kbn-xsrf', 'xxx') + .set('Cookie', handshakeCookie.cookieString()) + .expect(302); + + const cookies = oidcAuthenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + sessionCookie = request.cookie(cookies[0]); + }); + + it('should redirect to home page if session cookie is not provided', async () => { + const logoutResponse = await supertest.get('/api/security/v1/logout') + .expect(302); + + expect(logoutResponse.headers['set-cookie']).to.be(undefined); + expect(logoutResponse.headers.location).to.be('/'); + }); + + it('should redirect to the OPs endsession endpoint to complete logout', async () => { + const logoutResponse = await supertest.get('/api/security/v1/logout') + .set('Cookie', sessionCookie.cookieString()) + .expect(302); + + const cookies = logoutResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const logoutCookie = request.cookie(cookies[0]); + expect(logoutCookie.key).to.be('sid'); + expect(logoutCookie.value).to.be.empty(); + expect(logoutCookie.path).to.be('/'); + expect(logoutCookie.httpOnly).to.be(true); + expect(logoutCookie.maxAge).to.be(0); + + const redirectURL = url.parse(logoutResponse.headers.location, true /* parseQueryString */); + expect(redirectURL.href.startsWith(`https://test-op.elastic.co/oauth2/v1/endsession`)).to.be(true); + expect(redirectURL.query.id_token_hint).to.not.be.empty(); + + // Tokens that were stored in the previous cookie should be invalidated as well and old + // session cookie should not allow API access. + const apiResponse = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(400); + + expect(apiResponse.body).to.eql({ + error: 'Bad Request', + message: 'Both elasticsearch access and refresh tokens are expired.', + statusCode: 400 + }); + }); + + it('should reject AJAX requests', async () => { + const ajaxResponse = await supertest.get('/api/security/v1/logout') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(400); + + expect(ajaxResponse.headers['set-cookie']).to.be(undefined); + expect(ajaxResponse.body).to.eql({ + error: 'Bad Request', + message: 'Client should be able to process redirect response.', + statusCode: 400 + }); + }); + }); + + describe('API access with expired access token.', () => { + let sessionCookie; + + beforeEach(async () => { + const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three') + .expect(302); + + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); + const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens + await supertest + .post('/api/oidc_provider/setup') + .set('kbn-xsrf', 'xxx') + .send({ nonce: stateAndNonce.nonce }) + .expect(200); + + const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`) + .set('kbn-xsrf', 'xxx') + .set('Cookie', handshakeCookie.cookieString()) + .expect(302); + + const cookies = oidcAuthenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + sessionCookie = request.cookie(cookies[0]); + }); + + const expectNewSessionCookie = (cookie) => { + expect(cookie.key).to.be('sid'); + expect(cookie.value).to.not.be.empty(); + expect(cookie.path).to.be('/'); + expect(cookie.httpOnly).to.be(true); + expect(cookie.value).to.not.be(sessionCookie.value); + }; + + it('expired access token should be automatically refreshed', async function () { + this.timeout(40000); + + // Access token expiration is set to 15s for API integration tests. + // Let's wait for 20s to make sure token expires. + await delay(20000); + + // This api call should succeed and automatically refresh token. Returned cookie will contain + // the new access and refresh token pair. + const firstResponse = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + const firstResponseCookies = firstResponse.headers['set-cookie']; + expect(firstResponseCookies).to.have.length(1); + + const firstNewCookie = request.cookie(firstResponseCookies[0]); + expectNewSessionCookie(firstNewCookie); + + // Request with old cookie should reuse the same refresh token if within 60 seconds. + // Returned cookie will contain the same new access and refresh token pairs as the first request + const secondResponse = await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + const secondResponseCookies = secondResponse.headers['set-cookie']; + expect(secondResponseCookies).to.have.length(1); + + const secondNewCookie = request.cookie(secondResponseCookies[0]); + expectNewSessionCookie(secondNewCookie); + + expect(firstNewCookie.value).not.to.eql(secondNewCookie.value); + + // The first new cookie with fresh pair of access and refresh tokens should work. + await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', firstNewCookie.cookieString()) + .expect(200); + + // The second new cookie with fresh pair of access and refresh tokens should work. + await supertest + .get('/api/security/v1/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', secondNewCookie.cookieString()) + .expect(200); + }); + }); + + describe('API access with missing access token document.', () => { + let sessionCookie; + + beforeEach(async () => { + const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three') + .expect(302); + + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); + const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens + await supertest + .post('/api/oidc_provider/setup') + .set('kbn-xsrf', 'xxx') + .send({ nonce: stateAndNonce.nonce }) + .expect(200); + + const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`) + .set('kbn-xsrf', 'xxx') + .set('Cookie', handshakeCookie.cookieString()) + .expect(302); + + const cookies = oidcAuthenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + sessionCookie = request.cookie(cookies[0]); + }); + + it('should properly set cookie and start new OIDC handshake', async function () { + // Let's delete tokens from `.security-tokens` index directly to simulate the case when + // Elasticsearch automatically removes access/refresh token document from the index + // after some period of time. + const esResponse = await getService('es').deleteByQuery({ + index: '.security-tokens', + q: 'doc_type:token', + refresh: true, + }); + expect(esResponse).to.have.property('deleted').greaterThan(0); + + const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three') + .set('Cookie', sessionCookie.cookieString()) + .expect(302); + + const cookies = handshakeResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const handshakeCookie = request.cookie(cookies[0]); + expect(handshakeCookie.key).to.be('sid'); + expect(handshakeCookie.value).to.not.be.empty(); + expect(handshakeCookie.path).to.be('/'); + expect(handshakeCookie.httpOnly).to.be(true); + + const redirectURL = url.parse(handshakeResponse.headers.location, true /* parseQueryString */); + expect(redirectURL.href.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`)).to.be(true); + expect(redirectURL.query.scope).to.not.be.empty(); + expect(redirectURL.query.response_type).to.not.be.empty(); + expect(redirectURL.query.client_id).to.not.be.empty(); + expect(redirectURL.query.redirect_uri).to.not.be.empty(); + expect(redirectURL.query.state).to.not.be.empty(); + expect(redirectURL.query.nonce).to.not.be.empty(); + }); + }); + }); +} diff --git a/x-pack/test/oidc_api_integration/config.js b/x-pack/test/oidc_api_integration/config.js new file mode 100644 index 0000000000000..962f51c88cd2f --- /dev/null +++ b/x-pack/test/oidc_api_integration/config.js @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolve } from 'path'; +export default async function ({ readConfigFile }) { + const kibanaAPITestsConfig = await readConfigFile(require.resolve('../../../test/api_integration/config.js')); + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.js')); + const plugin = resolve(__dirname, './fixtures/oidc_provider'); + const kibanaPort = xPackAPITestsConfig.get('servers.kibana.port'); + const jwksPath = resolve(__dirname, './fixtures/jwks.json'); + + + return { + testFiles: [require.resolve('./apis')], + servers: xPackAPITestsConfig.get('servers'), + services: { + es: kibanaAPITestsConfig.get('services.es'), + supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), + }, + junit: { + reportName: 'X-Pack OpenID Connect API Integration Tests', + }, + + esTestCluster: { + ...xPackAPITestsConfig.get('esTestCluster'), + serverArgs: [ + ...xPackAPITestsConfig.get('esTestCluster.serverArgs'), + 'xpack.security.authc.token.enabled=true', + 'xpack.security.authc.token.timeout=15s', + 'xpack.security.authc.realms.oidc.oidc1.order=0', + `xpack.security.authc.realms.oidc.oidc1.rp.client_id=0oa8sqpov3TxMWJOt356`, + `xpack.security.authc.realms.oidc.oidc1.rp.response_type=code`, + `xpack.security.authc.realms.oidc.oidc1.rp.redirect_uri=http://localhost:${kibanaPort}/api/security/v1/oidc`, + `xpack.security.authc.realms.oidc.oidc1.op.authorization_endpoint=https://test-op.elastic.co/oauth2/v1/authorize`, + `xpack.security.authc.realms.oidc.oidc1.op.endsession_endpoint=https://test-op.elastic.co/oauth2/v1/endsession`, + `xpack.security.authc.realms.oidc.oidc1.op.token_endpoint=http://localhost:${kibanaPort}/api/oidc_provider/token_endpoint`, + `xpack.security.authc.realms.oidc.oidc1.op.userinfo_endpoint=http://localhost:${kibanaPort}/api/oidc_provider/userinfo_endpoint`, + `xpack.security.authc.realms.oidc.oidc1.op.issuer=https://test-op.elastic.co`, + `xpack.security.authc.realms.oidc.oidc1.op.jwkset_path=${jwksPath}`, + `xpack.security.authc.realms.oidc.oidc1.claims.principal=sub` + ], + }, + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${plugin}`, + '--xpack.security.authProviders=[\"oidc\"]', + '--xpack.security.authc.oidc.realm=\"oidc1\"', + '--server.xsrf.whitelist', JSON.stringify(['/api/security/v1/oidc', + '/api/oidc_provider/token_endpoint', + '/api/oidc_provider/userinfo_endpoint']) + ], + }, + }; +} diff --git a/x-pack/test/oidc_api_integration/fixtures/README.md b/x-pack/test/oidc_api_integration/fixtures/README.md new file mode 100644 index 0000000000000..9c53ff0ad4b1d --- /dev/null +++ b/x-pack/test/oidc_api_integration/fixtures/README.md @@ -0,0 +1,28 @@ +### Generating key material + +Key material can be generated in the following manner: + +#### Generate a key pair with openssl + +```shell +openssl genrsa 2048 > jwks_private.pem +openssl rsa -in jwks_private.pem -pubout > jwks_public.pem +``` + +#### Create a JWKS from the public key + +For example, with [pem-jwk](https://github.com/dannycoates/pem-jwk) + +```shell +pem-jwk jwks_public.pem > jwks.json +``` + +If the tool used doesn't have an option to wrap the key in a key set, you can manually do that by +placing the json key within a +```javascript +{ + "keys": [] +} +``` + +section \ No newline at end of file diff --git a/x-pack/test/oidc_api_integration/fixtures/jwks.json b/x-pack/test/oidc_api_integration/fixtures/jwks.json new file mode 100644 index 0000000000000..944705b31416b --- /dev/null +++ b/x-pack/test/oidc_api_integration/fixtures/jwks.json @@ -0,0 +1,10 @@ +{ + "keys": [ + { + "kty": "RSA", + "e": "AQAB", + "use": "sig", + "n": "v9-88aGdE4E85PuEycxTA6LkM3TBvNScoeP6A-dd0Myo6-LfBlp1r7BPBWmvi_SC6Zam3U1LE3AekDMwqJg304my0pvh8wOwlmRpgKXDXjvj4s59vdeVNhCB9doIthUABd310o9lyb55fWc_qQYE2LK9AyEjicJswafguH6txV4IwSl13ieZAxni0Ca4CwdzXO1Oi34XjHF8F5x_0puTaQzHn5bPG4fiIJN-pwie0Ba4VEDPO5ca4lLXWVi1bn8xMDTAULrBAXJwDaDdS05KMbc4sPlyQPhtY1gcYvUbozUPYxSWwA7fZgFzV_h-uy_oXf1EXttOxSgog1z3cJzf6Q" + } + ] +} diff --git a/x-pack/test/oidc_api_integration/fixtures/jwks_private.pem b/x-pack/test/oidc_api_integration/fixtures/jwks_private.pem new file mode 100644 index 0000000000000..da6275492a9cc --- /dev/null +++ b/x-pack/test/oidc_api_integration/fixtures/jwks_private.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC/37zxoZ0TgTzk ++4TJzFMDouQzdMG81Jyh4/oD513QzKjr4t8GWnWvsE8Faa+L9ILplqbdTUsTcB6Q +MzComDfTibLSm+HzA7CWZGmApcNeO+Pizn2915U2EIH12gi2FQAF3fXSj2XJvnl9 +Zz+pBgTYsr0DISOJwmzBp+C4fq3FXgjBKXXeJ5kDGeLQJrgLB3Nc7U6LfheMcXwX +nH/Sm5NpDMefls8bh+Igk36nCJ7QFrhUQM87lxriUtdZWLVufzEwNMBQusEBcnAN +oN1LTkoxtziw+XJA+G1jWBxi9RujNQ9jFJbADt9mAXNX+H67L+hd/URe207FKCiD +XPdwnN/pAgMBAAECggEADiKRbMuXIsS2k7fjxGoFA5OQdCn5y8tt7o847+ivhJ5P +I3GHNJSdbt/yMlfi0tCkhEjQ6iSzjy8HUWA0CXeNRUwznEhXkOuIqsui6hNMHTkU +RLUplj63g1AcAtyZH7DUW5pKbcSanw4lLRPaIL2MxdoFCqH6WD+2e12+tFjAvHVc +Bm03+hIt2898ruLQfHLQ1MUegTmXWZ9fqiPizuKwrW9xlCGQbIKoqVHebCMqRFK0 +XGv0NNmUTnNo+uF3++yYHv/TL96EFTmU7QhUFrddONzUXDv0JjyiOnHk32191R9m +V8Y9mq+RT+tMsu+dxr2Yk4Qc5oHYX84p/afQxX8sMQKBgQD9FUsnJASMeg4jNlq8 +XjDsXWu0NoVGTfU9z/9/SU+L6KexubdCoCWxs+8KyA69PZkMW6HMw6BGuDPjTvI9 +1DmRdnVEa5EmUv/CIXZcAM+9Q7yWEocB/JeQOj9mdC/u1/sdQxNg1ae2HqJczl2I +EO4r7YshHQmqCju4lfyEf4rWTwKBgQDCFdm535BIPa4wGRTD7tY/VOCpDeom+PxH +9LUhefJV+G9RhP2jEPW+9D/ux8YAkL4c2kZR9kddLVFAD8jwormjWk6+uL5/jAAI +j6Aor3spBTNpgji6YnRaIk2PDIznFnSDPhdoWGsw8QQQ54wUO8m51cqPSYVZIu2d +U0yYacGQRwKBgFuJkB0gEeUdYG+sATWQe/GB+Kq97YZ4O/OXf7nyMitQgxbtLTOT +6Q5VHmiv42TfGrQ1kFgXiakKhvn4W/WxBQFv7wpIPb+21XrJz52HTZwPG+7L1LkL +O2aXKsdLzup8g/8Ze7DSlk5w1hjrKzlDpmGNEX1wm0Y9XUxuM19ZIkZRAoGAFR/F +s8pWbNZxyABi1zR+kyQM07mU+6rr4nUK5drc+mhwzUGZTY9CAAebkcSik1stpfxH +3RHeEJEnH77YEwDTDal9mpqG+WDmfAgN2X/H+t37C4fF3ttqaIkFQgWOrHQwODyg +1ZWSDSCeXayl/WnIefZ/9np9DgeULyRq2Mfh7m8CgYEAsEhsZyAe7QrCoVMykUSp +sys7qht/9B4QgaeX1pPdaJxLTPIKG0gYldWF1/zyMtiCYVY+MJkwgaVRgjX+8Swa +QfluMQ4YYrurxcdn+nSggGGinM6rt0C319sduKouKChMpzoEQp2RAIH35Of2usmR +k8z9rWWE/VEBo6K1d+3g/rA= +-----END PRIVATE KEY----- diff --git a/x-pack/test/oidc_api_integration/fixtures/jwks_public.pem b/x-pack/test/oidc_api_integration/fixtures/jwks_public.pem new file mode 100644 index 0000000000000..fb7bb23b79ac1 --- /dev/null +++ b/x-pack/test/oidc_api_integration/fixtures/jwks_public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv9+88aGdE4E85PuEycxT +A6LkM3TBvNScoeP6A+dd0Myo6+LfBlp1r7BPBWmvi/SC6Zam3U1LE3AekDMwqJg3 +04my0pvh8wOwlmRpgKXDXjvj4s59vdeVNhCB9doIthUABd310o9lyb55fWc/qQYE +2LK9AyEjicJswafguH6txV4IwSl13ieZAxni0Ca4CwdzXO1Oi34XjHF8F5x/0puT +aQzHn5bPG4fiIJN+pwie0Ba4VEDPO5ca4lLXWVi1bn8xMDTAULrBAXJwDaDdS05K +Mbc4sPlyQPhtY1gcYvUbozUPYxSWwA7fZgFzV/h+uy/oXf1EXttOxSgog1z3cJzf +6QIDAQAB +-----END PUBLIC KEY----- diff --git a/x-pack/test/oidc_api_integration/fixtures/oidc_provider/index.js b/x-pack/test/oidc_api_integration/fixtures/oidc_provider/index.js new file mode 100644 index 0000000000000..4e4f7faab662a --- /dev/null +++ b/x-pack/test/oidc_api_integration/fixtures/oidc_provider/index.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { initRoutes } from './init_routes'; + +export default function (kibana) { + return new kibana.Plugin({ + name: 'oidcProvider', + id: 'oidcProvider', + require: ['elasticsearch'], + + init(server) { + initRoutes(server); + }, + }); +} diff --git a/x-pack/test/oidc_api_integration/fixtures/oidc_provider/init_routes.js b/x-pack/test/oidc_api_integration/fixtures/oidc_provider/init_routes.js new file mode 100644 index 0000000000000..48dd714e46c8b --- /dev/null +++ b/x-pack/test/oidc_api_integration/fixtures/oidc_provider/init_routes.js @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Joi from 'joi'; +import jwt from 'jsonwebtoken'; +import fs from 'fs'; + +export function initRoutes(server) { + let nonce = ''; + + server.route({ + path: '/api/oidc_provider/setup', + method: 'POST', + config: { + auth: false, + validate: { + payload: Joi.object({ + nonce: Joi.string().required(), + }), + }, + }, + handler: (request) => { + nonce = request.payload.nonce; + return {}; + }, + }); + + server.route({ + path: '/api/oidc_provider/token_endpoint', + method: 'POST', + // Token endpoint needs authentication (with the client credentials) but we don't attempt to + // validate this OIDC behavior here + config: { + auth: false, + validate: { + payload: Joi.object({ + grant_type: Joi.string().optional(), + code: Joi.string().optional(), + redirect_uri: Joi.string().optional(), + }), + }, + }, + async handler(request) { + try { + const signingKey = fs.readFileSync(require.resolve('../../../oidc_api_integration/fixtures/jwks_private.pem')); + const userId = request.payload.code.substring(4); + const iat = Math.floor(Date.now() / 1000); + const idToken = JSON.stringify({ + iss: 'https://test-op.elastic.co', + sub: `user${userId}`, + aud: '0oa8sqpov3TxMWJOt356', + nonce, + exp: iat + 3600, + iat, + }); + return { + access_token: `valid-access-token${userId}`, + token_type: 'Bearer', + refresh_token: `valid-refresh-token${userId}`, + expires_in: 3600, + id_token: jwt.sign(idToken, signingKey, { algorithm: 'RS256' }), + }; + } catch (err) { + return err; + } + }, + }); + + server.route({ + path: '/api/oidc_provider/userinfo_endpoint', + method: 'GET', + config: { + auth: false + }, + handler: (request) => { + const accessToken = request.headers.authorization.substring(7); + if (accessToken === 'valid-access-token1') { + return { 'sub': 'user1', + 'name': 'Tony Stark', + 'given_name': 'Tony', + 'family_name': 'Stark', + 'preferred_username': 'ironman', + 'email': 'ironman@avengers.com' + }; + } + if (accessToken === 'valid-access-token2') { + return { 'sub': 'user2', + 'name': 'Peter Parker', + 'given_name': 'Peter', + 'family_name': 'Parker', + 'preferred_username': 'spiderman', + 'email': 'spiderman@avengers.com' + }; + } + if (accessToken === 'valid-access-token3') { + return { 'sub': 'user3', + 'name': 'Bruce Banner', + 'given_name': 'Bruce', + 'family_name': 'Banner', + 'preferred_username': 'hulk', + 'email': 'hulk@avengers.com' + }; + } + return {}; + }, + }); +} diff --git a/x-pack/test/oidc_api_integration/fixtures/oidc_provider/package.json b/x-pack/test/oidc_api_integration/fixtures/oidc_provider/package.json new file mode 100644 index 0000000000000..358c6e2020afe --- /dev/null +++ b/x-pack/test/oidc_api_integration/fixtures/oidc_provider/package.json @@ -0,0 +1,13 @@ +{ + "name": "oidc_provider_plugin", + "version": "1.0.0", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "dependencies": { + "joi": "^13.5.2", + "jsonwebtoken": "^8.3.0" + } +} diff --git a/x-pack/test/oidc_api_integration/fixtures/oidc_tools.js b/x-pack/test/oidc_api_integration/fixtures/oidc_tools.js new file mode 100644 index 0000000000000..d75f8d516b826 --- /dev/null +++ b/x-pack/test/oidc_api_integration/fixtures/oidc_tools.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + +import url from 'url'; + +export function getStateAndNonce(urlWithStateAndNonce) { + const parsedQuery = url.parse(urlWithStateAndNonce, true).query; + return { state: parsedQuery.state, nonce: parsedQuery.nonce }; +}