Add support for OpenID Connect implicit authentication flow. (#42069)

This commit is contained in:
Aleh Zasypkin 2019-08-08 15:39:15 +02:00 committed by GitHub
parent 81d7d6c2a3
commit 0d31f52bb8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 535 additions and 203 deletions

View file

@ -616,6 +616,14 @@
'@types/history',
],
},
{
groupSlug: 'jsdom',
groupName: 'jsdom related packages',
packageNames: [
'jsdom',
'@types/jsdom',
],
},
{
groupSlug: 'jsonwebtoken',
groupName: 'jsonwebtoken related packages',

View file

@ -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(`
<!DOCTYPE html>
<title>Kibana OpenID Connect Login</title>
<script nonce="${nonce}">
window.location.replace(
'${basePath}/api/security/v1/oidc?authenticationResponseURI=' + encodeURIComponent(window.location.href)
);
</script>
`)
.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(

View file

@ -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",

View file

@ -218,8 +218,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(

View file

@ -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;

View file

@ -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';

View file

@ -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',
});
});
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' }
);
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 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 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);
});
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);
expect(authenticationResult.error).toEqual(
Boom.badRequest(
'Response session state does not have corresponding state or nonce parameters or redirect URL.'
)
);
});
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));
it('fails if session state is not presented.', 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, {});
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.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
expect(authenticationResult.failed()).toBe(true);
});
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',
}));
});
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',
}));
});
});

View file

@ -17,14 +17,24 @@ import {
AuthenticationProviderSpecificOptions,
} from './base';
/**
* Describes possible OpenID Connect authentication flows.
*/
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.
*/
interface ProviderLoginAttempt {
code?: string;
iss?: string;
loginHint?: string;
}
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.');

View file

@ -18,6 +18,7 @@ export {
AuthenticationResult,
BasicCredentials,
DeauthenticationResult,
OIDCAuthenticationFlow,
} from './authentication';
export { PluginSetupContract } from './plugin';

View file

@ -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'),

View file

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

View file

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

View file

@ -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<typeof getStateAndNonce>;
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');
});
});
});
}

View file

@ -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',
]),
],
},
};

View file

@ -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;

View file

@ -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' }
),
};
}

View file

@ -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<typeof services, {}>;

View file

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

View file

@ -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,
};

View file

@ -3989,6 +3989,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"
@ -6786,7 +6795,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==
@ -21295,6 +21304,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"