From 4c396f85a328054c79812f4a0ae16a39ccfedc29 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 8 Aug 2019 18:02:46 +0200 Subject: [PATCH] [7.x] Add support for OpenID Connect implicit authentication flow. (#42938) --- .../server/routes/api/v1/authenticate.js | 92 ++++++-- x-pack/package.json | 2 + .../server/authentication/authenticator.ts | 4 +- .../security/server/authentication/index.ts | 2 +- .../server/authentication/providers/index.ts | 2 +- .../authentication/providers/oidc.test.ts | 217 ++++++++++-------- .../server/authentication/providers/oidc.ts | 77 ++++--- x-pack/plugins/security/server/index.ts | 1 + x-pack/scripts/functional_tests.js | 3 +- .../{ => authorization_code_flow}/index.js | 4 +- .../oidc_auth.js} | 0 .../apis/implicit_flow/index.ts | 15 ++ .../apis/implicit_flow/oidc_auth.ts | 142 ++++++++++++ .../{config.js => config.ts} | 27 +-- .../fixtures/oidc_provider/init_routes.js | 19 +- .../fixtures/oidc_tools.ts | 44 ++++ .../index.js => ftr_provider_context.d.ts} | 10 +- .../implicit_flow.config.ts | 36 +++ .../{fixtures/oidc_tools.js => services.ts} | 11 +- yarn.lock | 16 +- 20 files changed, 524 insertions(+), 200 deletions(-) rename x-pack/test/oidc_api_integration/apis/{ => authorization_code_flow}/index.js (76%) rename x-pack/test/oidc_api_integration/apis/{security/oidc_initiate_auth.js => authorization_code_flow/oidc_auth.js} (100%) create mode 100644 x-pack/test/oidc_api_integration/apis/implicit_flow/index.ts create mode 100644 x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts rename x-pack/test/oidc_api_integration/{config.js => config.ts} (79%) create mode 100644 x-pack/test/oidc_api_integration/fixtures/oidc_tools.ts rename x-pack/test/oidc_api_integration/{apis/security/index.js => ftr_provider_context.d.ts} (56%) create mode 100644 x-pack/test/oidc_api_integration/implicit_flow.config.ts rename x-pack/test/oidc_api_integration/{fixtures/oidc_tools.js => services.ts} (53%) diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js b/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js index 66d099d1c5f73..f546b53d9961b 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js @@ -7,8 +7,9 @@ import Boom from 'boom'; import Joi from 'joi'; import { schema } from '@kbn/config-schema'; -import { canRedirectRequest, wrapError } from '../../../../../../../plugins/security/server'; +import { canRedirectRequest, wrapError, OIDCAuthenticationFlow } from '../../../../../../../plugins/security/server'; import { KibanaRequest } from '../../../../../../../../src/core/server'; +import { createCSPRuleString, generateCSPNonce } from '../../../../../../../../src/legacy/server/csp'; export function initAuthenticateApi({ authc: { login, logout }, config }, server) { @@ -82,8 +83,39 @@ export function initAuthenticateApi({ authc: { login, logout }, config }, server } }); + /** + * The route should be configured as a redirect URI in OP when OpenID Connect implicit flow + * is used, so that we can extract authentication response from URL fragment and send it to + * the `/api/security/v1/oidc` route. + */ + server.route({ + method: 'GET', + path: '/api/security/v1/oidc/implicit', + config: { auth: false }, + async handler(request, h) { + const legacyConfig = server.config(); + const basePath = legacyConfig.get('server.basePath'); + + const nonce = await generateCSPNonce(); + const cspRulesHeader = createCSPRuleString(legacyConfig.get('csp.rules'), nonce); + return h.response(` + + Kibana OpenID Connect Login + + `) + .header('cache-control', 'private, no-cache, no-store') + .header('content-security-policy', cspRulesHeader) + .type('text/html'); + } + }); + server.route({ // POST is only allowed for Third Party initiated authentication + // Consider splitting this route into two (GET and POST) when it's migrated to New Platform. method: ['GET', 'POST'], path: '/api/security/v1/oidc', config: { @@ -97,31 +129,55 @@ export function initAuthenticateApi({ authc: { login, logout }, config }, server error: Joi.string(), error_description: Joi.string(), error_uri: Joi.string().uri(), - state: Joi.string() - }).unknown() + state: Joi.string(), + authenticationResponseURI: Joi.string(), + }).unknown(), } }, async handler(request, h) { try { + const query = request.query || {}; + const payload = request.payload || {}; + + // An HTTP GET request with a query parameter named `authenticationResponseURI` that includes URL fragment OpenID + // Connect Provider sent during implicit authentication flow to the Kibana own proxy page that extracted that URL + // fragment and put it into `authenticationResponseURI` query string parameter for this endpoint. See more details + // at https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth + let loginAttempt; + if (query.authenticationResponseURI) { + loginAttempt = { + flow: OIDCAuthenticationFlow.Implicit, + authenticationResponseURI: query.authenticationResponseURI, + }; + } else if (query.code || query.error) { + // An HTTP GET request with a query parameter named `code` (or `error`) as the response to a successful (or + // failed) authentication from an OpenID Connect Provider during authorization code authentication flow. + // See more details at https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth. + loginAttempt = { + flow: OIDCAuthenticationFlow.AuthorizationCode, + // We pass the path only as we can't be sure of the full URL and Elasticsearch doesn't need it anyway. + authenticationResponseURI: request.url.path, + }; + } else if (query.iss || payload.iss) { + // An HTTP GET request with a query parameter named `iss` or an HTTP POST request with the same parameter in the + // payload as part of a 3rd party initiated authentication. See more details at + // https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin + loginAttempt = { + flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, + iss: query.iss || payload.iss, + loginHint: query.login_hint || payload.login_hint, + }; + } + + if (!loginAttempt) { + throw Boom.badRequest('Unrecognized login attempt.'); + } + // 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 login(KibanaRequest.from(request), { provider: 'oidc', - // 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 - value: { - code: request.query && request.query.code, - iss: (request.query && request.query.iss) || (request.payload && request.payload.iss), - loginHint: - (request.query && request.query.login_hint) || - (request.payload && request.payload.login_hint), - }, + value: loginAttempt }); if (authenticationResult.succeeded()) { return Boom.forbidden( diff --git a/x-pack/package.json b/x-pack/package.json index 9b7730831ac6d..035f380c5f49d 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -62,6 +62,7 @@ "@types/jest": "^24.0.9", "@types/joi": "^13.4.2", "@types/js-yaml": "^3.11.1", + "@types/jsdom": "^12.2.4", "@types/json-stable-stringify": "^1.0.32", "@types/jsonwebtoken": "^7.2.7", "@types/lodash": "^3.10.1", @@ -110,6 +111,7 @@ "babel-plugin-require-context-hook": "npm:babel-plugin-require-context-hook-babel7@1.0.0", "babel-plugin-transform-react-remove-prop-types": "0.4.24", "base64-js": "^1.2.1", + "base64url": "^3.0.1", "chalk": "^2.4.1", "chance": "1.0.18", "checksum": "0.1.1", diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 19008e4289f5b..fc7731cb85a4e 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -220,8 +220,8 @@ export class Authenticator { const sessionStorage = this.options.sessionStorageFactory.asScoped(request); - // If we detect an existing session that belongs to a different provider than the one request to - // perform a login we should clear such session. + // If we detect an existing session that belongs to a different provider than the one requested + // to perform a login we should clear such session. let existingSession = await this.getSessionValue(sessionStorage); if (existingSession && existingSession.provider !== attempt.provider) { this.logger.debug( diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 1d5f13f96efed..557a9dc3577cf 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -20,7 +20,7 @@ export { canRedirectRequest } from './can_redirect_request'; export { Authenticator, ProviderLoginAttempt } from './authenticator'; export { AuthenticationResult } from './authentication_result'; export { DeauthenticationResult } from './deauthentication_result'; -export { BasicCredentials } from './providers'; +export { BasicCredentials, OIDCAuthenticationFlow } from './providers'; interface SetupAuthenticationParams { core: CoreSetup; diff --git a/x-pack/plugins/security/server/authentication/providers/index.ts b/x-pack/plugins/security/server/authentication/providers/index.ts index a1b71e5106fd5..856c614775a93 100644 --- a/x-pack/plugins/security/server/authentication/providers/index.ts +++ b/x-pack/plugins/security/server/authentication/providers/index.ts @@ -13,4 +13,4 @@ export { BasicAuthenticationProvider, BasicCredentials } from './basic'; export { KerberosAuthenticationProvider } from './kerberos'; export { SAMLAuthenticationProvider, isSAMLRequestQuery } from './saml'; export { TokenAuthenticationProvider } from './token'; -export { OIDCAuthenticationProvider } from './oidc'; +export { OIDCAuthenticationProvider, OIDCAuthenticationFlow } from './oidc'; 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 c9a0e4350d886..f15ac103a81a0 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -15,7 +15,8 @@ import { mockScopedClusterClient, } from './base.mock'; -import { OIDCAuthenticationProvider } from './oidc'; +import { KibanaRequest } from '../../../../../../src/core/server'; +import { OIDCAuthenticationProvider, OIDCAuthenticationFlow, ProviderLoginAttempt } from './oidc'; describe('OIDCAuthenticationProvider', () => { let provider: OIDCAuthenticationProvider; @@ -56,6 +57,7 @@ describe('OIDCAuthenticationProvider', () => { }); const authenticationResult = await provider.login(request, { + flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, iss: 'theissuer', loginHint: 'loginhint', }); @@ -80,123 +82,138 @@ describe('OIDCAuthenticationProvider', () => { }); }); - it('gets token and redirects user to requested URL if OIDC authentication response is valid.', async () => { - const request = httpServerMock.createKibanaRequest({ - path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', + function defineAuthenticationFlowTests( + getMocks: () => { + request: KibanaRequest; + attempt: ProviderLoginAttempt; + expectedRedirectURI?: string; + } + ) { + it('gets token and redirects user to requested URL if OIDC authentication response is valid.', async () => { + const { request, attempt, expectedRedirectURI } = getMocks(); + + mockOptions.client.callAsInternalUser + .withArgs('shield.oidcAuthenticate') + .resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' }); + + const authenticationResult = await provider.login(request, attempt, { + state: 'statevalue', + nonce: 'noncevalue', + nextURL: '/base-path/some-path', + }); + + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.oidcAuthenticate', + { body: { state: 'statevalue', nonce: 'noncevalue', redirect_uri: expectedRedirectURI } } + ); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.redirectURL).toBe('/base-path/some-path'); + expect(authenticationResult.state).toEqual({ + accessToken: 'some-token', + refreshToken: 'some-refresh-token', + }); }); - mockOptions.client.callAsInternalUser - .withArgs('shield.oidcAuthenticate') - .resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' }); + it('fails if authentication response is presented but session state does not contain the state parameter.', async () => { + const { request, attempt } = getMocks(); - const authenticationResult = await provider.login( - request, - { code: 'somecodehere' }, - { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/some-path' } - ); + const authenticationResult = await provider.login(request, attempt, { + nextURL: '/base-path/some-path', + }); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.oidcAuthenticate', - { - body: { - state: 'statevalue', - nonce: 'noncevalue', - redirect_uri: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', - }, - } - ); + sinon.assert.notCalled(mockOptions.client.callAsInternalUser); - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/base-path/some-path'); - expect(authenticationResult.state).toEqual({ - accessToken: 'some-token', - refreshToken: 'some-refresh-token', + 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 the state parameter.', async () => { - const request = httpServerMock.createKibanaRequest({ path: '/api/security/v1/oidc' }); - - const authenticationResult = await provider.login( - request, - { code: 'somecodehere' }, - { nextURL: '/base-path/some-path' } - ); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); - - 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 = httpServerMock.createKibanaRequest({ path: '/api/security/v1/oidc' }); - - const authenticationResult = await provider.login( - request, - { code: 'somecodehere' }, - { state: 'statevalue', nonce: 'noncevalue' } - ); + it('fails if authentication response is presented but session state does not contain redirect URL.', async () => { + const { request, attempt } = getMocks(); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + const authenticationResult = await provider.login(request, attempt, { + state: 'statevalue', + nonce: 'noncevalue', + }); - 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.' - ) - ); - }); + sinon.assert.notCalled(mockOptions.client.callAsInternalUser); - it('fails if session state is not presented.', async () => { - const request = httpServerMock.createKibanaRequest({ - path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', + 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.' + ) + ); }); - const authenticationResult = await provider.login(request, { code: 'somecodehere' }, {}); + it('fails if session state is not presented.', async () => { + const { request, attempt } = getMocks(); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + const authenticationResult = await provider.login(request, attempt, {}); - expect(authenticationResult.failed()).toBe(true); - }); + sinon.assert.notCalled(mockOptions.client.callAsInternalUser); - it('fails if code is invalid.', async () => { - const request = httpServerMock.createKibanaRequest({ - path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', + expect(authenticationResult.failed()).toBe(true); }); - const failureReason = new Error( - 'Failed to exchange code for Id Token using the Token Endpoint.' - ); - mockOptions.client.callAsInternalUser - .withArgs('shield.oidcAuthenticate') - .returns(Promise.reject(failureReason)); - - const authenticationResult = await provider.login( - request, - { code: 'somecodehere' }, - { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/some-path' } - ); - - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.oidcAuthenticate', - { - body: { - state: 'statevalue', - nonce: 'noncevalue', - redirect_uri: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', - }, - } - ); + it('fails if authentication response is not valid.', async () => { + const { request, attempt, expectedRedirectURI } = getMocks(); + + const failureReason = new Error( + 'Failed to exchange code for Id Token using the Token Endpoint.' + ); + mockOptions.client.callAsInternalUser + .withArgs('shield.oidcAuthenticate') + .returns(Promise.reject(failureReason)); + + const authenticationResult = await provider.login(request, attempt, { + state: 'statevalue', + nonce: 'noncevalue', + nextURL: '/base-path/some-path', + }); + + sinon.assert.calledWithExactly( + mockOptions.client.callAsInternalUser, + 'shield.oidcAuthenticate', + { body: { state: 'statevalue', nonce: 'noncevalue', redirect_uri: expectedRedirectURI } } + ); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + }); + } + + describe('authorization code flow', () => { + defineAuthenticationFlowTests(() => ({ + request: httpServerMock.createKibanaRequest({ + path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', + }), + attempt: { + flow: OIDCAuthenticationFlow.AuthorizationCode, + authenticationResponseURI: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', + }, + expectedRedirectURI: '/api/security/v1/oidc?code=somecodehere&state=somestatehere', + })); + }); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); + describe('implicit flow', () => { + defineAuthenticationFlowTests(() => ({ + request: httpServerMock.createKibanaRequest({ + path: + '/api/security/v1/oidc?authenticationResponseURI=http://kibana/api/security/v1/oidc/implicit#id_token=sometoken', + }), + attempt: { + flow: OIDCAuthenticationFlow.Implicit, + authenticationResponseURI: + 'http://kibana/api/security/v1/oidc/implicit#id_token=sometoken', + }, + expectedRedirectURI: 'http://kibana/api/security/v1/oidc/implicit#id_token=sometoken', + })); }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index 42c4405065e18..27945ccd02cd1 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -18,14 +18,24 @@ import { } from './base'; /** - * Describes the parameters that are required by the provider to process the initial login request. + * Describes possible OpenID Connect authentication flows. */ -interface ProviderLoginAttempt { - code?: string; - iss?: string; - loginHint?: string; +export enum OIDCAuthenticationFlow { + Implicit = 'implicit', + AuthorizationCode = 'authorization-code', + InitiatedBy3rdParty = 'initiated-by-3rd-party', } +/** + * Describes the parameters that are required by the provider to process the initial login request. + */ +export type ProviderLoginAttempt = + | { + flow: OIDCAuthenticationFlow.Implicit | OIDCAuthenticationFlow.AuthorizationCode; + authenticationResponseURI: string; + } + | { flow: OIDCAuthenticationFlow.InitiatedBy3rdParty; iss: string; loginHint?: string }; + /** * The state supported by the provider (for the OpenID Connect handshake or established session). */ @@ -86,9 +96,25 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { ) { this.logger.debug('Trying to perform a login.'); - // This might be the OpenID Connect Provider redirecting the user to `redirect_uri` after authentication or - // a third party initiating an authentication - return await this.loginWithOIDCPayload(request, attempt, state); + if (attempt.flow === OIDCAuthenticationFlow.InitiatedBy3rdParty) { + this.logger.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 = attempt.loginHint + ? { iss: attempt.iss, login_hint: attempt.loginHint } + : { iss: attempt.iss }; + return this.initiateOIDCAuthentication(request, oidcPrepareParams); + } else if (attempt.flow === OIDCAuthenticationFlow.Implicit) { + this.logger.debug('OpenID Connect Implicit Authentication flow is used.'); + } else { + this.logger.debug('OpenID Connect Authorization Code Authentication flow is used.'); + } + + return await this.loginWithAuthenticationResponse( + request, + attempt.authenticationResponseURI, + state + ); } /** @@ -140,31 +166,17 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * 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 attempt Login attempt description. + * @param authenticationResponseURI This URI contains the authentication response returned from the OP and may contain + * authorization code that es will exchange for an ID Token in case of Authorization Code authentication flow. Or + * id/access tokens in case of Implicit authentication flow. Elasticsearch will do all the required validation and + * parsing for both successful and failed responses. * @param [sessionState] Optional state object associated with the provider. */ - private async loginWithOIDCPayload( + private async loginWithAuthenticationResponse( request: KibanaRequest, - { iss, loginHint, code }: ProviderLoginAttempt, + authenticationResponseURI: string, sessionState?: ProviderState | null ) { - this.logger.debug('Trying to authenticate via OpenID Connect response query.'); - - // First check to see if this is a Third Party initiated authentication. - if (iss) { - this.logger.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 (!code) { - this.logger.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. @@ -185,14 +197,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { access_token: accessToken, refresh_token: refreshToken, } = await this.options.client.callAsInternalUser('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, - }, + body: { state: stateOIDCState, nonce: stateNonce, redirect_uri: authenticationResponseURI }, }); this.logger.debug('Request has been authenticated via OpenID Connect.'); diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index b62cc84973960..2bc38990f45ac 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -18,6 +18,7 @@ export { AuthenticationResult, BasicCredentials, DeauthenticationResult, + OIDCAuthenticationFlow, } from './authentication'; export { PluginSetupContract } from './plugin'; diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index a8f9fdea0f0d6..8040ec874fa47 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -17,7 +17,8 @@ require('@kbn/test').runTestsCli([ require.resolve('../test/kerberos_api_integration/anonymous_access.config'), 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/oidc_api_integration/config.ts'), + require.resolve('../test/oidc_api_integration/implicit_flow.config.ts'), 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/test/oidc_api_integration/apis/index.js b/x-pack/test/oidc_api_integration/apis/authorization_code_flow/index.js similarity index 76% rename from x-pack/test/oidc_api_integration/apis/index.js rename to x-pack/test/oidc_api_integration/apis/authorization_code_flow/index.js index 59b1d035a35fe..0ef60bb929826 100644 --- a/x-pack/test/oidc_api_integration/apis/index.js +++ b/x-pack/test/oidc_api_integration/apis/authorization_code_flow/index.js @@ -5,8 +5,8 @@ */ export default function ({ loadTestFile }) { - describe('apis OpenID Connect', function () { + describe('apis', function () { this.tags('ciGroup6'); - loadTestFile(require.resolve('./security')); + loadTestFile(require.resolve('./oidc_auth')); }); } diff --git a/x-pack/test/oidc_api_integration/apis/security/oidc_initiate_auth.js b/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.js similarity index 100% rename from x-pack/test/oidc_api_integration/apis/security/oidc_initiate_auth.js rename to x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.js diff --git a/x-pack/test/oidc_api_integration/apis/implicit_flow/index.ts b/x-pack/test/oidc_api_integration/apis/implicit_flow/index.ts new file mode 100644 index 0000000000000..22ce3b17a5949 --- /dev/null +++ b/x-pack/test/oidc_api_integration/apis/implicit_flow/index.ts @@ -0,0 +1,15 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function({ loadTestFile }: FtrProviderContext) { + describe('apis', function() { + this.tags('ciGroup6'); + loadTestFile(require.resolve('./oidc_auth')); + }); +} diff --git a/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts b/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts new file mode 100644 index 0000000000000..613f10054fd84 --- /dev/null +++ b/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts @@ -0,0 +1,142 @@ +/* + * 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 { JSDOM } from 'jsdom'; +import request, { Cookie } from 'request'; +import { createTokens, getStateAndNonce } from '../../fixtures/oidc_tools'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + + describe('OpenID Connect Implicit Flow authentication', () => { + describe('finishing handshake', () => { + let stateAndNonce: ReturnType; + let handshakeCookie: Cookie; + + 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); + }); + + it('should return an HTML page that will parse URL fragment', async () => { + const response = await supertest.get('/api/security/v1/oidc/implicit').expect(200); + const dom = new JSDOM(response.text, { + runScripts: 'dangerously', + beforeParse(window) { + // JSDOM doesn't support changing of `window.location` and throws an exception if script + // tries to do that and we have to workaround this behaviour. + Object.defineProperty(window, 'location', { + value: { + href: + 'https://kibana.com/api/security/v1/oidc/implicit#token=some_token&access_token=some_access_token', + replace(newLocation: string) { + this.href = newLocation; + }, + }, + }); + }, + }); + + // Check that proxy page is returned with proper headers. + const scriptNonce = dom.window.document.querySelector('script')!.getAttribute('nonce'); + expect(scriptNonce).to.have.length(16); + expect(response.headers['content-type']).to.be('text/html; charset=utf-8'); + expect(response.headers['cache-control']).to.be('private, no-cache, no-store'); + expect(response.headers['content-security-policy']).to.be( + `script-src 'unsafe-eval' 'nonce-${scriptNonce}'; worker-src blob:; child-src blob:` + ); + + // Check that script that forwards URL fragment worked correctly. + expect(dom.window.location.href).to.be( + '/api/security/v1/oidc?authenticationResponseURI=https%3A%2F%2Fkibana.com%2Fapi%2Fsecurity%2Fv1%2Foidc%2Fimplicit%23token%3Dsome_token%26access_token%3Dsome_access_token' + ); + }); + + it('should fail if OpenID Connect response is not complemented with handshake cookie', async () => { + const { idToken, accessToken } = createTokens('1', stateAndNonce.nonce); + const authenticationResponse = `https://kibana.com/api/security/v1/oidc/implicit#id_token=${idToken}&state=${stateAndNonce.state}&token_type=bearer&access_token=${accessToken}`; + + await supertest + .get( + `/api/security/v1/oidc?authenticationResponseURI=${encodeURIComponent( + authenticationResponse + )}` + ) + .set('kbn-xsrf', 'xxx') + .expect(401); + }); + + it('should fail if state is not matching', async () => { + const { idToken, accessToken } = createTokens('1', stateAndNonce.nonce); + const authenticationResponse = `https://kibana.com/api/security/v1/oidc/implicit#id_token=${idToken}&state=$someothervalue&token_type=bearer&access_token=${accessToken}`; + + await supertest + .get( + `/api/security/v1/oidc?authenticationResponseURI=${encodeURIComponent( + authenticationResponse + )}` + ) + .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 { idToken, accessToken } = createTokens('1', stateAndNonce.nonce); + const authenticationResponse = `https://kibana.com/api/security/v1/oidc/implicit#id_token=${idToken}&state=${stateAndNonce.state}&token_type=bearer&access_token=${accessToken}`; + + const oidcAuthenticationResponse = await supertest + .get( + `/api/security/v1/oidc?authenticationResponseURI=${encodeURIComponent( + authenticationResponse + )}` + ) + .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', + 'metadata', + 'enabled', + 'authentication_realm', + 'lookup_realm', + ]); + + expect(apiResponse.body.username).to.be('user1'); + }); + }); + }); +} diff --git a/x-pack/test/oidc_api_integration/config.js b/x-pack/test/oidc_api_integration/config.ts similarity index 79% rename from x-pack/test/oidc_api_integration/config.js rename to x-pack/test/oidc_api_integration/config.ts index 7aed861108112..f40db4ccbba0a 100644 --- a/x-pack/test/oidc_api_integration/config.js +++ b/x-pack/test/oidc_api_integration/config.ts @@ -5,21 +5,19 @@ */ import { resolve } from 'path'; -export default async function ({ readConfigFile }) { - const kibanaAPITestsConfig = await readConfigFile(require.resolve('../../../test/api_integration/config.js')); +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { services } from './services'; + +export default async function({ readConfigFile }: FtrConfigProviderContext) { 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')], + testFiles: [require.resolve('./apis/authorization_code_flow')], servers: xPackAPITestsConfig.get('servers'), - services: { - es: kibanaAPITestsConfig.get('services.es'), - supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), - }, + services, junit: { reportName: 'X-Pack OpenID Connect API Integration Tests', }, @@ -41,7 +39,7 @@ export default async function ({ readConfigFile }) { `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` + `xpack.security.authc.realms.oidc.oidc1.claims.principal=sub`, ], }, @@ -50,11 +48,14 @@ export default async function ({ readConfigFile }) { serverArgs: [ ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), `--plugin-path=${plugin}`, - '--xpack.security.authc.providers=[\"oidc\"]', - '--xpack.security.authc.oidc.realm=\"oidc1\"', - '--server.xsrf.whitelist', JSON.stringify(['/api/security/v1/oidc', + '--xpack.security.authc.providers=["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']) + '/api/oidc_provider/userinfo_endpoint', + ]), ], }, }; 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 index 48dd714e46c8b..7e16633646dbc 100644 --- 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 @@ -5,8 +5,7 @@ */ import Joi from 'joi'; -import jwt from 'jsonwebtoken'; -import fs from 'fs'; +import { createTokens } from '../oidc_tools'; export function initRoutes(server) { let nonce = ''; @@ -44,24 +43,16 @@ export function initRoutes(server) { }, }, async handler(request) { + const userId = request.payload.code.substring(4); + const { accessToken, idToken } = createTokens(userId, nonce); 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}`, + access_token: accessToken, token_type: 'Bearer', refresh_token: `valid-refresh-token${userId}`, expires_in: 3600, - id_token: jwt.sign(idToken, signingKey, { algorithm: 'RS256' }), + id_token: idToken, }; } catch (err) { return err; diff --git a/x-pack/test/oidc_api_integration/fixtures/oidc_tools.ts b/x-pack/test/oidc_api_integration/fixtures/oidc_tools.ts new file mode 100644 index 0000000000000..32bb9346f5d47 --- /dev/null +++ b/x-pack/test/oidc_api_integration/fixtures/oidc_tools.ts @@ -0,0 +1,44 @@ +/* + * 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 base64url from 'base64url'; +import { createHash } from 'crypto'; +import fs from 'fs'; +import jwt from 'jsonwebtoken'; +import url from 'url'; + +export function getStateAndNonce(urlWithStateAndNonce: string) { + const parsedQuery = url.parse(urlWithStateAndNonce, true).query; + return { state: parsedQuery.state as string, nonce: parsedQuery.nonce as string }; +} + +export function createTokens(userId: string, nonce: string) { + const signingKey = fs.readFileSync(require.resolve('./jwks_private.pem')); + const iat = Math.floor(Date.now() / 1000); + + const accessToken = `valid-access-token${userId}`; + const accessTokenHashBuffer = createHash('sha256') + .update(accessToken) + .digest(); + + return { + accessToken, + idToken: jwt.sign( + JSON.stringify({ + iss: 'https://test-op.elastic.co', + sub: `user${userId}`, + aud: '0oa8sqpov3TxMWJOt356', + nonce, + exp: iat + 3600, + iat, + // See more details on `at_hash` at https://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDToken + at_hash: base64url(accessTokenHashBuffer.slice(0, accessTokenHashBuffer.length / 2)), + }), + signingKey, + { algorithm: 'RS256' } + ), + }; +} diff --git a/x-pack/test/oidc_api_integration/apis/security/index.js b/x-pack/test/oidc_api_integration/ftr_provider_context.d.ts similarity index 56% rename from x-pack/test/oidc_api_integration/apis/security/index.js rename to x-pack/test/oidc_api_integration/ftr_provider_context.d.ts index 2949a5c8c03a9..e3add3748f56d 100644 --- a/x-pack/test/oidc_api_integration/apis/security/index.js +++ b/x-pack/test/oidc_api_integration/ftr_provider_context.d.ts @@ -4,8 +4,8 @@ * 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')); - }); -} +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; + +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/oidc_api_integration/implicit_flow.config.ts b/x-pack/test/oidc_api_integration/implicit_flow.config.ts new file mode 100644 index 0000000000000..a7854488097a6 --- /dev/null +++ b/x-pack/test/oidc_api_integration/implicit_flow.config.ts @@ -0,0 +1,36 @@ +/* + * 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 { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +// eslint-disable-next-line import/no-default-export +export default async function({ readConfigFile }: FtrConfigProviderContext) { + const oidcAPITestsConfig = await readConfigFile(require.resolve('./config.ts')); + + return { + ...oidcAPITestsConfig.getAll(), + testFiles: [require.resolve('./apis/implicit_flow')], + + junit: { + reportName: 'X-Pack OpenID Connect API Integration Tests (Implicit Flow)', + }, + + esTestCluster: { + ...oidcAPITestsConfig.get('esTestCluster'), + serverArgs: oidcAPITestsConfig.get('esTestCluster.serverArgs').map((arg: string) => { + if (arg.startsWith('xpack.security.authc.realms.oidc.oidc1.rp.response_type')) { + return 'xpack.security.authc.realms.oidc.oidc1.rp.response_type=id_token token'; + } + + if (arg.startsWith('xpack.security.authc.realms.oidc.oidc1.op.token_endpoint')) { + return 'xpack.security.authc.realms.oidc.oidc1.op.token_endpoint=should_not_be_used'; + } + + return arg; + }), + }, + }; +} diff --git a/x-pack/test/oidc_api_integration/fixtures/oidc_tools.js b/x-pack/test/oidc_api_integration/services.ts similarity index 53% rename from x-pack/test/oidc_api_integration/fixtures/oidc_tools.js rename to x-pack/test/oidc_api_integration/services.ts index d75f8d516b826..e4ff6048a8cce 100644 --- a/x-pack/test/oidc_api_integration/fixtures/oidc_tools.js +++ b/x-pack/test/oidc_api_integration/services.ts @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { services as apiIntegrationServices } from '../api_integration/services'; -import url from 'url'; - -export function getStateAndNonce(urlWithStateAndNonce) { - const parsedQuery = url.parse(urlWithStateAndNonce, true).query; - return { state: parsedQuery.state, nonce: parsedQuery.nonce }; -} +export const services = { + es: apiIntegrationServices.es, + supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, +}; diff --git a/yarn.lock b/yarn.lock index e72fa723db16e..c20a3abd79576 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3974,6 +3974,15 @@ resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.12.1.tgz#5c6f4a1eabca84792fbd916f0cb40847f123c656" integrity sha512-SGGAhXLHDx+PK4YLNcNGa6goPf9XRWQNAUUbffkwVGGXIxmDKWyGGL4inzq2sPmExu431Ekb9aEMn9BkPqEYFA== +"@types/jsdom@^12.2.4": + version "12.2.4" + resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-12.2.4.tgz#845cd4d43f95b8406d9b724ec30c03edadcd9528" + integrity sha512-q+De3S/Ri6U9uPx89YA1XuC+QIBgndIfvBaaJG0pRT8Oqa75k4Mr7G9CRZjIvlbLGIukO/31DFGFJYlQBmXf/A== + dependencies: + "@types/node" "*" + "@types/tough-cookie" "*" + parse5 "^4.0.0" + "@types/json-schema@*": version "6.0.1" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-6.0.1.tgz#a761975746f1c1b2579c62e3a4b5e88f986f7e2e" @@ -6780,7 +6789,7 @@ base64id@1.0.0: resolved "https://registry.yarnpkg.com/base64id/-/base64id-1.0.0.tgz#47688cb99bb6804f0e06d3e763b1c32e57d8e6b6" integrity sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY= -base64url@^3.0.0: +base64url@^3.0.0, base64url@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d" integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A== @@ -21254,6 +21263,11 @@ parse5@^3.0.1, parse5@^3.0.2: dependencies: "@types/node" "*" +parse5@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608" + integrity sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA== + parseqs@0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"