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"