Add OpenID Connect auth provider (#36201) (#36838)

The OpenID Connect authProvider is the accompanying authProvider for the OpenID Connect authentication realm in Elasticsearch. This is very similar to the saml authProvider in most ways with three noticeable differences:

- We require explicit configuration regarding the Elasticsearch realm name instead of trying to build an environment aware string (like ACS URL in saml) and pass that to Elasticsearch for it to resolve the realm.
- We do not support multiple values for the realm specific nonces (state and nonce) as we do with requestId in the SAML realm. Instead if an existing value ( for state and nonce) is present in the user's session, we pass that to Elasticsearch to be reused. The end goal is the same, allow a better UX for users attempting many requests over different tabs in the same browser context.
- IDP initiated SSO ( Third Party initiated authentication in OIDC-speak ) is implemented but starts as an unsolicited request to initiate the handshake, instead of an unsolicited request with an authentication response (which is not supported here)

This change also adds a fake plugin named oidc_provider to be used in integration tests for mocking calls to the token and userinfo endpoint of an OpenID Connect Provider

This does not support the OpenID Connect Implicit flow as that depends on fragment handling/processing as described for instance in the spec

Co-Authored-By: Brandon Kobel <kobelb@elastic.co>
This commit is contained in:
Brandon Kobel 2019-05-21 21:57:39 -04:00 committed by Larry Gregory
parent 1f9aa3cdc0
commit 1b4f6e0245
23 changed files with 2187 additions and 33 deletions

View file

@ -1,5 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`config schema authc oidc realm realm is not allowed when authProviders is "['basic']" 1`] = `[ValidationError: child "authc" fails because ["oidc" is not allowed]]`;
exports[`config schema authc oidc realm returns a validation error when authProviders is "['oidc', 'basic']" and realm is unspecified 1`] = `[ValidationError: child "authc" fails because [child "oidc" fails because [child "realm" fails because ["realm" is required]]]]`;
exports[`config schema authc oidc realm returns a validation error when authProviders is "['oidc']" and realm is unspecified 1`] = `[ValidationError: child "authc" fails because [child "oidc" fails because [child "realm" fails because ["realm" is required]]]]`;
exports[`config schema with context {"dist":false} produces correct config 1`] = `
Object {
"audit": Object {

View file

@ -65,6 +65,15 @@ export const security = (kibana) => new kibana.Plugin({
audit: Joi.object({
enabled: Joi.boolean().default(false)
}).default(),
authc: Joi.object({})
.when('authProviders', {
is: Joi.array().items(Joi.string().valid('oidc').required(), Joi.string()),
then: Joi.object({
oidc: Joi.object({
realm: Joi.string().required(),
}).default()
}).default()
})
}).default();
},

View file

@ -7,16 +7,76 @@
import { security } from './index';
import { getConfigSchema } from '../../test_utils';
const describeWithContext = describe.each([
[{ dist: false }],
[{ dist: true }]
]);
const describeWithContext = describe.each([[{ dist: false }], [{ dist: true }]]);
describeWithContext('config schema with context %j', (context) => {
describeWithContext('config schema with context %j', context => {
it('produces correct config', async () => {
const schema = await getConfigSchema(security);
await expect(
schema.validate({}, { context })
).resolves.toMatchSnapshot();
await expect(schema.validate({}, { context })).resolves.toMatchSnapshot();
});
});
describe('config schema', () => {
describe('authc', () => {
describe('oidc', () => {
describe('realm', () => {
it(`returns a validation error when authProviders is "['oidc']" and realm is unspecified`, async () => {
const schema = await getConfigSchema(security);
const validationResult = schema.validate({
authProviders: ['oidc'],
});
expect(validationResult.error).toMatchSnapshot();
});
it(`is valid when authProviders is "['oidc']" and realm is specified`, async () => {
const schema = await getConfigSchema(security);
const validationResult = schema.validate({
authProviders: ['oidc'],
authc: {
oidc: {
realm: 'realm-1',
},
},
});
expect(validationResult.error).toBeNull();
expect(validationResult.value).toHaveProperty('authc.oidc.realm', 'realm-1');
});
it(`returns a validation error when authProviders is "['oidc', 'basic']" and realm is unspecified`, async () => {
const schema = await getConfigSchema(security);
const validationResult = schema.validate({
authProviders: ['oidc', 'basic'],
});
expect(validationResult.error).toMatchSnapshot();
});
it(`is valid when authProviders is "['oidc', 'basic']" and realm is specified`, async () => {
const schema = await getConfigSchema(security);
const validationResult = schema.validate({
authProviders: ['oidc', 'basic'],
authc: {
oidc: {
realm: 'realm-1',
},
},
});
expect(validationResult.error).toBeNull();
expect(validationResult.value).toHaveProperty('authc.oidc.realm', 'realm-1');
});
it(`realm is not allowed when authProviders is "['basic']"`, async () => {
const schema = await getConfigSchema(security);
const validationResult = schema.validate({
authProviders: ['basic'],
authc: {
oidc: {
realm: 'realm-1',
},
},
});
expect(validationResult.error).toMatchSnapshot();
});
});
});
});
});

View file

@ -14,11 +14,13 @@ import {
BasicAuthenticationProvider,
SAMLAuthenticationProvider,
TokenAuthenticationProvider,
OIDCAuthenticationProvider,
} from './providers';
import { AuthenticationResult } from './authentication_result';
import { DeauthenticationResult } from './deauthentication_result';
import { Session } from './session';
import { LoginAttempt } from './login_attempt';
import { AuthenticationProviderSpecificOptions } from './providers/base';
interface ProviderSession {
provider: string;
@ -29,11 +31,15 @@ interface ProviderSession {
// provider class that can handle specific authentication mechanism.
const providerMap = new Map<
string,
new (options: AuthenticationProviderOptions) => BaseAuthenticationProvider
new (
options: AuthenticationProviderOptions,
providerSpecificOptions: AuthenticationProviderSpecificOptions
) => BaseAuthenticationProvider
>([
['basic', BasicAuthenticationProvider],
['saml', SAMLAuthenticationProvider],
['token', TokenAuthenticationProvider],
['oidc', OIDCAuthenticationProvider],
]);
function assertRequest(request: Legacy.Request) {
@ -62,18 +68,44 @@ function getProviderOptions(server: Legacy.Server) {
};
}
/**
* Prepares options object that is specific only to an authentication provider.
* @param server Server instance.
* @param providerType the type of the provider to get the options for.
*/
function getProviderSpecificOptions(
server: Legacy.Server,
providerType: string
): AuthenticationProviderSpecificOptions {
const config = server.config();
// we can't use `config.has` here as it doesn't currently work with Joi's "alternatives" syntax which we
// are using to make the provider specific configuration required when the auth provider is specified
const authc = config.get<Record<string, AuthenticationProviderSpecificOptions | undefined>>(
`xpack.security.authc`
);
if (authc && authc[providerType] !== undefined) {
return authc[providerType] as AuthenticationProviderSpecificOptions;
}
return {};
}
/**
* Instantiates authentication provider based on the provider key from config.
* @param providerType Provider type key.
* @param options Options to pass to provider's constructor.
*/
function instantiateProvider(providerType: string, options: AuthenticationProviderOptions) {
function instantiateProvider(
providerType: string,
options: AuthenticationProviderOptions,
providerSpecificOptions: AuthenticationProviderSpecificOptions
) {
const ProviderClassName = providerMap.get(providerType);
if (!ProviderClassName) {
throw new Error(`Unsupported authentication provider name: ${providerType}.`);
}
return new ProviderClassName(options);
return new ProviderClassName(options, providerSpecificOptions);
}
/**
@ -117,13 +149,13 @@ class Authenticator {
const providerOptions = Object.freeze(getProviderOptions(server));
this.providers = new Map(
authProviders.map(
providerType =>
[providerType, instantiateProvider(providerType, providerOptions)] as [
string,
BaseAuthenticationProvider
]
)
authProviders.map(providerType => {
const providerSpecificOptions = getProviderSpecificOptions(server, providerType);
return [
providerType,
instantiateProvider(providerType, providerOptions, providerSpecificOptions),
] as [string, BaseAuthenticationProvider];
})
);
}

View file

@ -20,6 +20,11 @@ export interface AuthenticationProviderOptions {
log: (tags: string[], message: string) => void;
}
/**
* Represents available provider specific options.
*/
export type AuthenticationProviderSpecificOptions = Record<string, unknown>;
/**
* Base class that all authentication providers should extend.
*/

View file

@ -8,3 +8,4 @@ export { BaseAuthenticationProvider, AuthenticationProviderOptions } from './bas
export { BasicAuthenticationProvider, BasicCredentials } from './basic';
export { SAMLAuthenticationProvider } from './saml';
export { TokenAuthenticationProvider } from './token';
export { OIDCAuthenticationProvider } from './oidc';

View file

@ -0,0 +1,571 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import sinon from 'sinon';
import Boom from 'boom';
import { mockAuthenticationProviderOptions } from './base.mock';
import { requestFixture } from '../../__tests__/__fixtures__/request';
import { OIDCAuthenticationProvider } from './oidc';
describe('OIDCAuthenticationProvider', () => {
let provider: OIDCAuthenticationProvider;
let callWithRequest: sinon.SinonStub;
let callWithInternalUser: sinon.SinonStub;
beforeEach(() => {
const providerOptions = mockAuthenticationProviderOptions({ basePath: '/test-base-path' });
const providerSpecificOptions = { realm: 'oidc1' };
callWithRequest = providerOptions.client.callWithRequest as sinon.SinonStub;
callWithInternalUser = providerOptions.client.callWithInternalUser as sinon.SinonStub;
provider = new OIDCAuthenticationProvider(providerOptions, providerSpecificOptions);
});
describe('`authenticate` method', () => {
it('does not handle AJAX request that can not be authenticated.', async () => {
const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } });
const authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.notHandled()).toBe(true);
});
it('redirects non-AJAX request that can not be authenticated to the OpenId Connect Provider.', async () => {
const request = requestFixture({ path: '/some-path', basePath: '/s/foo' });
callWithInternalUser.withArgs('shield.oidcPrepare').resolves({
state: 'statevalue',
nonce: 'noncevalue',
redirect:
'https://op-host/path/login?response_type=code' +
'&scope=openid%20profile%20email' +
'&client_id=s6BhdRkqt3' +
'&state=statevalue' +
'&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc',
});
const authenticationResult = await provider.authenticate(request, null);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcPrepare', {
body: { realm: `oidc1` },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe(
'https://op-host/path/login?response_type=code' +
'&scope=openid%20profile%20email' +
'&client_id=s6BhdRkqt3' +
'&state=statevalue' +
'&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc'
);
expect(authenticationResult.state).toEqual({
state: 'statevalue',
nonce: 'noncevalue',
nextURL: `/s/foo/some-path`,
});
});
it('redirects third party initiated authentications to the OpenId Connect Provider.', async () => {
const request = requestFixture({
path: '/api/security/v1/oidc',
search: '?iss=theissuer&login_hint=loginhint',
basePath: '/s/foo',
});
callWithInternalUser.withArgs('shield.oidcPrepare').resolves({
state: 'statevalue',
nonce: 'noncevalue',
redirect:
'https://op-host/path/login?response_type=code' +
'&scope=openid%20profile%20email' +
'&client_id=s6BhdRkqt3' +
'&state=statevalue' +
'&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' +
'&login_hint=loginhint',
});
const authenticationResult = await provider.authenticate(request, null);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcPrepare', {
body: { iss: `theissuer`, login_hint: `loginhint` },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe(
'https://op-host/path/login?response_type=code' +
'&scope=openid%20profile%20email' +
'&client_id=s6BhdRkqt3' +
'&state=statevalue' +
'&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' +
'&login_hint=loginhint'
);
expect(authenticationResult.state).toEqual({
state: 'statevalue',
nonce: 'noncevalue',
nextURL: `/s/foo/`,
});
});
it('fails if OpenID Connect authentication request preparation fails.', async () => {
const request = requestFixture({ path: '/some-path' });
const failureReason = new Error('Realm is misconfigured!');
callWithInternalUser.withArgs('shield.oidcPrepare').returns(Promise.reject(failureReason));
const authenticationResult = await provider.authenticate(request, null);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcPrepare', {
body: { realm: `oidc1` },
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('gets token and redirects user to requested URL if OIDC authentication response is valid.', async () => {
const request = requestFixture({
path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere',
search: '?code=somecodehere&state=somestatehere',
});
callWithInternalUser
.withArgs('shield.oidcAuthenticate')
.resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' });
const authenticationResult = await provider.authenticate(request, {
state: 'statevalue',
nonce: 'noncevalue',
nextURL: '/test-base-path/some-path',
});
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcAuthenticate', {
body: {
state: 'statevalue',
nonce: 'noncevalue',
redirect_uri: '/api/security/v1/oidc?code=somecodehere&state=somestatehere',
},
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/test-base-path/some-path');
expect(authenticationResult.state).toEqual({
accessToken: 'some-token',
refreshToken: 'some-refresh-token',
});
});
it('fails if authentication response is presented but session state does not contain the state parameter.', async () => {
const request = requestFixture({
path: '/api/security/v1/oidc',
search: '?code=somecodehere&state=somestatehere',
});
const authenticationResult = await provider.authenticate(request, {
nextURL: '/test-base-path/some-path',
});
sinon.assert.notCalled(callWithInternalUser);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toEqual(
Boom.badRequest(
'Response session state does not have corresponding state or nonce parameters or redirect URL.'
)
);
});
it('fails if authentication response is presented but session state does not contain redirect URL.', async () => {
const request = requestFixture({
path: '/api/security/v1/oidc',
search: '?code=somecodehere&state=somestatehere',
});
const authenticationResult = await provider.authenticate(request, {
state: 'statevalue',
nonce: 'noncevalue',
});
sinon.assert.notCalled(callWithInternalUser);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toEqual(
Boom.badRequest(
'Response session state does not have corresponding state or nonce parameters or redirect URL.'
)
);
});
it('fails if session state is not presented.', async () => {
const request = requestFixture({
path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere',
search: '?code=somecodehere&state=somestatehere',
});
const authenticationResult = await provider.authenticate(request, {});
sinon.assert.notCalled(callWithInternalUser);
expect(authenticationResult.failed()).toBe(true);
});
it('fails if code is invalid.', async () => {
const request = requestFixture({
path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere',
search: '?code=somecodehere&state=somestatehere',
});
const failureReason = new Error(
'Failed to exchange code for Id Token using the Token Endpoint.'
);
callWithInternalUser
.withArgs('shield.oidcAuthenticate')
.returns(Promise.reject(failureReason));
const authenticationResult = await provider.authenticate(request, {
state: 'statevalue',
nonce: 'noncevalue',
nextURL: '/test-base-path/some-path',
});
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcAuthenticate', {
body: {
state: 'statevalue',
nonce: 'noncevalue',
redirect_uri: '/api/security/v1/oidc?code=somecodehere&state=somestatehere',
},
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('succeeds if state contains a valid token.', async () => {
const user = { username: 'user' };
const request = requestFixture();
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
});
expect(request.headers.authorization).toBe('Bearer some-valid-token');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toBe(undefined);
});
it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => {
const request = requestFixture({ headers: { authorization: 'Basic some:credentials' } });
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
});
sinon.assert.notCalled(callWithRequest);
expect(request.headers.authorization).toBe('Basic some:credentials');
expect(authenticationResult.notHandled()).toBe(true);
});
it('fails if token from the state is rejected because of unknown reason.', async () => {
const request = requestFixture();
const failureReason = new Error('Token is not valid!');
callWithRequest
.withArgs(request, 'shield.authenticate')
.returns(Promise.reject(failureReason));
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-invalid-token',
refreshToken: 'some-invalid-refresh-token',
});
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
sinon.assert.neverCalledWith(callWithRequest, 'shield.getAccessToken');
});
it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => {
const user = { username: 'user' };
const request = requestFixture();
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer expired-token' } }),
'shield.authenticate'
)
.rejects({ statusCode: 401 });
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer new-access-token' } }),
'shield.authenticate'
)
.resolves(user);
callWithInternalUser
.withArgs('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: 'valid-refresh-token' },
})
.resolves({ access_token: 'new-access-token', refresh_token: 'new-refresh-token' });
const authenticationResult = await provider.authenticate(request, {
accessToken: 'expired-token',
refreshToken: 'valid-refresh-token',
});
expect(request.headers.authorization).toBe('Bearer new-access-token');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toEqual({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
});
});
it('fails if token from the state is expired and refresh attempt failed too.', async () => {
const request = requestFixture();
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer expired-token' } }),
'shield.authenticate'
)
.rejects({ statusCode: 401 });
const refreshFailureReason = {
statusCode: 500,
message: 'Something is wrong with refresh token.',
};
callWithInternalUser
.withArgs('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: 'invalid-refresh-token' },
})
.returns(Promise.reject(refreshFailureReason));
const authenticationResult = await provider.authenticate(request, {
accessToken: 'expired-token',
refreshToken: 'invalid-refresh-token',
});
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(refreshFailureReason);
});
it('redirects to OpenID Connect Provider for non-AJAX requests if refresh token is expired or already refreshed.', async () => {
const request = requestFixture({ path: '/some-path', basePath: '/s/foo' });
callWithInternalUser.withArgs('shield.oidcPrepare').resolves({
state: 'statevalue',
nonce: 'noncevalue',
redirect:
'https://op-host/path/login?response_type=code' +
'&scope=openid%20profile%20email' +
'&client_id=s6BhdRkqt3' +
'&state=statevalue' +
'&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc',
});
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer expired-token' } }),
'shield.authenticate'
)
.rejects({ statusCode: 401 });
callWithInternalUser
.withArgs('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: 'expired-refresh-token' },
})
.rejects({ statusCode: 400 });
const authenticationResult = await provider.authenticate(request, {
accessToken: 'expired-token',
refreshToken: 'expired-refresh-token',
});
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcPrepare', {
body: { realm: `oidc1` },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe(
'https://op-host/path/login?response_type=code' +
'&scope=openid%20profile%20email' +
'&client_id=s6BhdRkqt3' +
'&state=statevalue' +
'&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc'
);
expect(authenticationResult.state).toEqual({
state: 'statevalue',
nonce: 'noncevalue',
nextURL: `/s/foo/some-path`,
});
});
it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => {
const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } });
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer expired-token' } }),
'shield.authenticate'
)
.rejects({ statusCode: 401 });
callWithInternalUser
.withArgs('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: 'expired-refresh-token' },
})
.rejects({ statusCode: 400 });
const authenticationResult = await provider.authenticate(request, {
accessToken: 'expired-token',
refreshToken: 'expired-refresh-token',
});
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toEqual(
Boom.badRequest('Both elasticsearch access and refresh tokens are expired.')
);
});
it('succeeds if `authorization` contains a valid token.', async () => {
const user = { username: 'user' };
const request = requestFixture({ headers: { authorization: 'Bearer some-valid-token' } });
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
const authenticationResult = await provider.authenticate(request);
expect(request.headers.authorization).toBe('Bearer some-valid-token');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toBe(undefined);
});
it('fails if token from `authorization` header is rejected.', async () => {
const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } });
const failureReason = new Error('Token is not valid!');
callWithRequest
.withArgs(request, 'shield.authenticate')
.returns(Promise.reject(failureReason));
const authenticationResult = await provider.authenticate(request);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => {
const user = { username: 'user' };
const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } });
const failureReason = new Error('Token is not valid!');
callWithRequest
.withArgs(request, 'shield.authenticate')
.returns(Promise.reject(failureReason));
callWithRequest
.withArgs(sinon.match({ headers: { authorization: 'Bearer some-valid-token' } }))
.resolves(user);
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
});
describe('`deauthenticate` method', () => {
it('returns `notHandled` if state is not presented or does not include access token.', async () => {
const request = requestFixture();
let deauthenticateResult = await provider.deauthenticate(request, {});
expect(deauthenticateResult.notHandled()).toBe(true);
deauthenticateResult = await provider.deauthenticate(request, {});
expect(deauthenticateResult.notHandled()).toBe(true);
deauthenticateResult = await provider.deauthenticate(request, { nonce: 'x' });
expect(deauthenticateResult.notHandled()).toBe(true);
sinon.assert.notCalled(callWithInternalUser);
});
it('fails if OpenID Connect logout call fails.', async () => {
const request = requestFixture();
const accessToken = 'x-oidc-token';
const refreshToken = 'x-oidc-refresh-token';
const failureReason = new Error('Realm is misconfigured!');
callWithInternalUser.withArgs('shield.oidcLogout').returns(Promise.reject(failureReason));
const authenticationResult = await provider.deauthenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcLogout', {
body: { token: accessToken, refresh_token: refreshToken },
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('redirects to /logged_out if `redirect` field in OpenID Connect logout response is null.', async () => {
const request = requestFixture();
const accessToken = 'x-oidc-token';
const refreshToken = 'x-oidc-refresh-token';
callWithInternalUser.withArgs('shield.oidcLogout').resolves({ redirect: null });
const authenticationResult = await provider.deauthenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcLogout', {
body: { token: accessToken, refresh_token: refreshToken },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/test-base-path/logged_out');
});
it('redirects user to the OpenID Connect Provider if RP initiated SLO is supported.', async () => {
const request = requestFixture();
const accessToken = 'x-oidc-token';
const refreshToken = 'x-oidc-refresh-token';
callWithInternalUser
.withArgs('shield.oidcLogout')
.resolves({ redirect: 'http://fake-idp/logout&id_token_hint=thehint' });
const authenticationResult = await provider.deauthenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledOnce(callWithInternalUser);
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('http://fake-idp/logout&id_token_hint=thehint');
});
});
});

View file

@ -0,0 +1,517 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import type from 'type-detect';
import { Legacy } from 'kibana';
import { canRedirectRequest } from '../../can_redirect_request';
import { getErrorStatusCode } from '../../errors';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import {
AuthenticationProviderOptions,
BaseAuthenticationProvider,
AuthenticationProviderSpecificOptions,
} from './base';
/**
* The state supported by the provider (for the OpenID Connect handshake or established session).
*/
interface ProviderState {
/**
* Unique identifier of the OpenID Connect request initiated the handshake used to mitigate
* replay attacks.
*/
nonce?: string;
/**
* Unique identifier of the OpenID Connect request initiated the handshake used to mitigate
* CSRF.
*/
state?: string;
/**
* URL to redirect user to after successful OpenID Connect handshake.
*/
nextURL?: string;
/**
* Elasticsearch access token issued as the result of successful OpenID Connect handshake and that should be provided
* with every request to Elasticsearch on behalf of the authenticated user. This token will eventually expire.
*/
accessToken?: string;
/**
* Once the elasticsearch access token expires the refresh token is used to get a new pair of access/refresh tokens
* without any user involvement. If not used this token will eventually expire as well.
*/
refreshToken?: string;
}
/**
* Defines the shape of an incoming OpenID Connect Request
*/
type OIDCIncomingRequest = Legacy.Request & {
payload: {
iss?: string;
login_hint?: string;
};
query: {
iss?: string;
code?: string;
state?: string;
login_hint?: string;
error?: string;
error_description?: string;
};
};
/**
* Checks if the Request object represents an HTTP request regarding authentication with OpenID
* Connect. This can be
* - An HTTP GET request with a query parameter named `iss` as part of a 3rd party initiated authentication
* - An HTTP POST request with a parameter named `iss` as part of a 3rd party initiated authentication
* - An HTTP GET request with a query parameter named `code` as the response to a successful authentication from
* an OpenID Connect Provider
* - An HTTP GET request with a query parameter named `error` as the response to a failed authentication from
* an OpenID Connect Provider
* @param request Request instance.
*/
function isOIDCIncomingRequest(request: Legacy.Request): request is OIDCIncomingRequest {
return (
(request.payload != null && !!(request.payload as Record<string, unknown>).iss) ||
(request.query != null &&
(!!(request.query as any).iss ||
!!(request.query as any).code ||
!!(request.query as any).error))
);
}
/**
* Checks the error returned by Elasticsearch as the result of `authenticate` call and returns `true` if request
* has been rejected because of expired token, otherwise returns `false`.
* @param err Error returned from Elasticsearch.
*/
function isAccessTokenExpiredError(err?: any) {
const errorStatusCode = getErrorStatusCode(err);
return (
errorStatusCode === 401 ||
(errorStatusCode === 500 &&
err &&
err.body &&
err.body.error &&
err.body.error.reason === 'token document is missing and must be present')
);
}
/**
* Provider that supports authentication using an OpenID Connect realm in Elasticsearch.
*/
export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
private readonly realm: string;
constructor(
protected readonly options: Readonly<AuthenticationProviderOptions>,
oidcOptions: Readonly<AuthenticationProviderSpecificOptions>
) {
super(options);
if (!oidcOptions.realm) {
throw new Error('Realm name must be specified');
}
if (type(oidcOptions.realm) !== 'string') {
throw new Error('Realm must be a string');
}
this.realm = oidcOptions.realm as string;
}
/**
* Performs OpenID Connect request authentication.
* @param request Request instance.
* @param [state] Optional state object associated with the provider.
*/
public async authenticate(request: Legacy.Request, state?: ProviderState | null) {
this.debug(`Trying to authenticate user request to ${request.url.path}.`);
let {
authenticationResult,
headerNotRecognized, // eslint-disable-line prefer-const
} = await this.authenticateViaHeader(request);
if (headerNotRecognized) {
return authenticationResult;
}
if (state && authenticationResult.notHandled()) {
authenticationResult = await this.authenticateViaState(request, state);
if (authenticationResult.failed() && isAccessTokenExpiredError(authenticationResult.error)) {
authenticationResult = await this.authenticateViaRefreshToken(request, state);
}
}
if (isOIDCIncomingRequest(request) && authenticationResult.notHandled()) {
// This might be the OpenID Connect Provider redirecting the user to `redirect_uri` after authentication or
// a third party initiating an authentication
authenticationResult = await this.authenticateViaResponseUrl(request, state);
}
// If we couldn't authenticate by means of all methods above, let's try to
// initiate an OpenID Connect based authentication, otherwise just return the authentication result we have.
// We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in
// another tab)
return authenticationResult.notHandled()
? await this.initiateOIDCAuthentication(request, { realm: this.realm })
: authenticationResult;
}
/**
* Attempts to handle a request that might be a third party initiated OpenID connect authentication attempt or the
* OpenID Connect Provider redirecting back the UA after an authentication success/failure. In the former case which
* is signified by the existence of an iss parameter (either in the query of a GET request or the body of a POST
* request) it attempts to start the authentication flow by calling initiateOIDCAuthentication.
*
* In the latter case, it attempts to exchange the authentication response to an elasticsearch access token, passing
* along to Elasticsearch the state and nonce parameters from the user's session.
*
* When login succeeds the elasticsearch access token and refresh token are stored in the state and user is redirected
* to the URL that was requested before authentication flow started or to default Kibana location in case of a third
* party initiated login
* @param request Request instance.
* @param [sessionState] Optional state object associated with the provider.
*/
private async authenticateViaResponseUrl(
request: OIDCIncomingRequest,
sessionState?: ProviderState | null
) {
this.debug('Trying to authenticate via OpenID Connect response query.');
// First check to see if this is a Third Party initiated authentication (which can happen via POST or GET)
const iss = (request.query && request.query.iss) || (request.payload && request.payload.iss);
const loginHint =
(request.query && request.query.login_hint) ||
(request.payload && request.payload.login_hint);
if (iss) {
this.debug('Authentication has been initiated by a Third Party.');
// We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in
// another tab)
const oidcPrepareParams = loginHint ? { iss, login_hint: loginHint } : { iss };
return this.initiateOIDCAuthentication(request, oidcPrepareParams);
}
if (!request.query || !request.query.code) {
this.debug('OpenID Connect Authentication response is not found.');
return AuthenticationResult.notHandled();
}
// If it is an authentication response and the users' session state doesn't contain all the necessary information,
// then something unexpected happened and we should fail because Elasticsearch won't be able to validate the
// response.
const { nonce: stateNonce = '', state: stateOIDCState = '', nextURL: stateRedirectURL = '' } =
sessionState || {};
if (!stateNonce || !stateOIDCState || !stateRedirectURL) {
const message =
'Response session state does not have corresponding state or nonce parameters or redirect URL.';
this.debug(message);
return AuthenticationResult.failed(Boom.badRequest(message));
}
// We have all the necessary parameters, so attempt to complete the OpenID Connect Authentication
try {
// This operation should be performed on behalf of the user with a privilege that normal
// user usually doesn't have `cluster:admin/xpack/security/oidc/authenticate`.
const {
access_token: accessToken,
refresh_token: refreshToken,
} = await this.options.client.callWithInternalUser('shield.oidcAuthenticate', {
body: {
state: stateOIDCState,
nonce: stateNonce,
// redirect_uri contains the code that es will exchange for an ID Token. Elasticserach
// will do all the required validation and parsing. We pass the path only as we can't be
// sure of the full URL and Elasticsearch doesn't need it anyway
redirect_uri: request.url.path,
},
});
this.debug('Request has been authenticated via OpenID Connect.');
return AuthenticationResult.redirectTo(stateRedirectURL, {
accessToken,
refreshToken,
});
} catch (err) {
this.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`);
return AuthenticationResult.failed(err);
}
}
/**
* Initiates an authentication attempt by either providing the realm name or the issuer to Elasticsearch
*
* @param request Request instance.
* @param params
*/
private async initiateOIDCAuthentication(
request: Legacy.Request,
params: { realm: string } | { iss: string; login_hint?: string },
sessionState?: ProviderState | null
) {
this.debug('Trying to initiate OpenID Connect authentication.');
// If client can't handle redirect response, we shouldn't initiate OpenID Connect authentication.
if (!canRedirectRequest(request)) {
this.debug('OpenID Connect authentication can not be initiated by AJAX requests.');
return AuthenticationResult.notHandled();
}
try {
/*
* Possibly adds the state and nonce parameter that was saved in the user's session state to
* the params. There is no use case where we would have only a state parameter or only a nonce
* parameter in the session state so we only enrich the params object if we have both
*/
const oidcPrepareParams =
sessionState && sessionState.nonce && sessionState.state
? { ...params, nonce: sessionState.nonce, state: sessionState.state }
: params;
// This operation should be performed on behalf of the user with a privilege that normal
// user usually doesn't have `cluster:admin/xpack/security/oidc/prepare`.
const { state, nonce, redirect } = await this.options.client.callWithInternalUser(
'shield.oidcPrepare',
{
body: oidcPrepareParams,
}
);
this.debug('Redirecting to OpenID Connect Provider with authentication request.');
// If this is a third party initiated login, redirect to the base path
const redirectAfterLogin = `${request.getBasePath()}${
'iss' in params ? '/' : request.url.path
}`;
return AuthenticationResult.redirectTo(
redirect,
// Store the state and nonce parameters in the session state of the user
{ state, nonce, nextURL: redirectAfterLogin }
);
} catch (err) {
this.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`);
return AuthenticationResult.failed(err);
}
}
/**
* Validates whether request contains `Bearer ***` Authorization header and just passes it
* forward to Elasticsearch backend.
* @param request Request instance.
*/
private async authenticateViaHeader(request: Legacy.Request) {
this.debug('Trying to authenticate via header.');
const authorization = request.headers.authorization;
if (!authorization) {
this.debug('Authorization header is not presented.');
return {
authenticationResult: AuthenticationResult.notHandled(),
};
}
const authenticationSchema = authorization.split(/\s+/)[0];
if (authenticationSchema.toLowerCase() !== 'bearer') {
this.debug(`Unsupported authentication schema: ${authenticationSchema}`);
return {
authenticationResult: AuthenticationResult.notHandled(),
headerNotRecognized: true,
};
}
try {
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this.debug('Request has been authenticated via header.');
return {
authenticationResult: AuthenticationResult.succeeded(user),
};
} catch (err) {
this.debug(`Failed to authenticate request via header: ${err.message}`);
return {
authenticationResult: AuthenticationResult.failed(err),
};
}
}
/**
* Tries to extract an elasticsearch access token from state and adds it to the request before it's
* forwarded to Elasticsearch backend.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaState(request: Legacy.Request, { accessToken }: ProviderState) {
this.debug('Trying to authenticate via state.');
if (!accessToken) {
this.debug('Elasticsearch access token is not found in state.');
return AuthenticationResult.notHandled();
}
request.headers.authorization = `Bearer ${accessToken}`;
try {
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this.debug('Request has been authenticated via state.');
return AuthenticationResult.succeeded(user);
} catch (err) {
this.debug(`Failed to authenticate request via state: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
// We can't just set `authorization` to `undefined` or `null`, we should remove this property
// entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if
// it's called with this request once again down the line (e.g. in the next authentication provider).
delete request.headers.authorization;
return AuthenticationResult.failed(err);
}
}
/**
* This method is only called when authentication via an elasticsearch access token stored in the state failed because
* of expired token. So we should use the elasticsearch refresh token, that is also stored in the state, to extend
* expired elasticsearch access token and authenticate user with it.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaRefreshToken(
request: Legacy.Request,
{ refreshToken }: ProviderState
) {
this.debug('Trying to refresh elasticsearch access token.');
if (!refreshToken) {
this.debug('Refresh token is not found in state.');
return AuthenticationResult.notHandled();
}
try {
// Token should be refreshed by the same user that obtained that token.
const {
access_token: newAccessToken,
refresh_token: newRefreshToken,
} = await this.options.client.callWithInternalUser('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: refreshToken },
});
this.debug('Elasticsearch access token has been successfully refreshed.');
request.headers.authorization = `Bearer ${newAccessToken}`;
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this.debug('Request has been authenticated via refreshed token.');
return AuthenticationResult.succeeded(user, {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
});
} catch (err) {
this.debug(`Failed to refresh elasticsearch access token: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
// We can't just set `authorization` to `undefined` or `null`, we should remove this property
// entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if
// it's called with this request once again down the line (e.g. in the next authentication provider).
delete request.headers.authorization;
// There are at least two common cases when refresh token request can fail:
// 1. Refresh token is valid only for 24 hours and if it hasn't been used it expires.
//
// 2. Refresh token is one-time use token and if it has been used already, it is treated in the same way as
// expired token. Even though it's an edge case, there are several perfectly valid scenarios when it can
// happen. E.g. when several simultaneous AJAX request has been sent to Kibana, but elasticsearch access token has
// expired already, so the first request that reaches Kibana uses refresh token to get a new elasticsearch access
// token, but the second concurrent request has no idea about that and tries to refresh access token as well. All
// ends well when first request refreshes the elasticsearch access token and updates session cookie with fresh
// access/refresh token pair. But if user navigates to another page _before_ AJAX request (the one that triggered
// token refresh)responds with updated cookie, then user will have only that old cookie with expired elasticsearch
// access token and refresh token that has been used already.
//
// When user has neither valid access nor refresh token, the only way to resolve this issue is to re-initiate the
// OpenID Connect authentication by requesting a new authentication request to send to the OpenID Connect Provider
// and exchange it's forthcoming response for a new Elasticsearch access/refresh token pair. In case this is an
// AJAX request, we just reply with `400` and clear error message.
// There are two reasons for `400` and not `401`: Elasticsearch search responds with `400` so it seems logical
// to do the same on Kibana side and `401` would force user to logout and do full SLO if it's supported.
if (getErrorStatusCode(err) === 400) {
if (canRedirectRequest(request)) {
this.debug(
'Both elasticsearch access and refresh tokens are expired. Re-initiating OpenID Connect authentication.'
);
return this.initiateOIDCAuthentication(request, { realm: this.realm });
}
return AuthenticationResult.failed(
Boom.badRequest('Both elasticsearch access and refresh tokens are expired.')
);
}
return AuthenticationResult.failed(err);
}
}
/**
* Invalidates an elasticsearch access token and refresh token that were originally created as a successful response
* to an OpenID Connect based authentication. This does not handle OP initiated Single Logout
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
public async deauthenticate(request: Legacy.Request, state: ProviderState) {
this.debug(`Trying to deauthenticate user via ${request.url.path}.`);
if (!state || !state.accessToken) {
this.debug('There is no elasticsearch access token to invalidate.');
return DeauthenticationResult.notHandled();
}
try {
const logoutBody = {
body: {
token: state.accessToken,
refresh_token: state.refreshToken,
},
};
// This operation should be performed on behalf of the user with a privilege that normal
// user usually doesn't have `cluster:admin/xpack/security/oidc/logout`.
const { redirect } = await this.options.client.callWithInternalUser(
'shield.oidcLogout',
logoutBody
);
this.debug('User session has been successfully invalidated.');
// Having non-null `redirect` field within logout response means that the OpenID Connect realm configuration
// supports RP initiated Single Logout and we should redirect user to the specified location in the OpenID Connect
// Provider to properly complete logout.
if (redirect != null) {
this.debug('Redirecting user to the OpenID Connect Provider to complete logout.');
return DeauthenticationResult.redirectTo(redirect);
}
return DeauthenticationResult.redirectTo(`${this.options.basePath}/logged_out`);
} catch (err) {
this.debug(`Failed to deauthenticate user: ${err.message}`);
return DeauthenticationResult.failed(err);
}
}
/**
* Logs message with `debug` level and oidc/security related tags.
* @param message Message to log.
*/
private debug(message: string) {
this.options.log(['debug', 'security', 'oidc'], message);
}
}

View file

@ -71,6 +71,84 @@ export function initAuthenticateApi(server) {
}
});
server.route({
method: 'GET',
path: '/api/security/v1/oidc',
config: {
auth: false,
validate: {
query: Joi.object().keys({
iss: Joi.string(),
login_hint: Joi.string(),
target_link_uri: Joi.string(),
code: Joi.string(),
error: Joi.string(),
error_description: Joi.string(),
error_uri: Joi.string(),
state: Joi.string()
})
}
},
async handler(request, h) {
try {
// We handle the fact that the user might get redirected to Kibana while already having an session
// Return an error notifying the user they are already logged in.
const authenticationResult = await server.plugins.security.authenticate(request);
if (authenticationResult.succeeded()) {
return Boom.forbidden(
'Sorry, you already have an active Kibana session. ' +
'If you want to start a new one, please logout from the existing session first.'
);
}
if (authenticationResult.redirected()) {
return h.redirect(authenticationResult.redirectURL);
}
throw Boom.unauthorized(authenticationResult.error);
} catch (err) {
throw wrapError(err);
}
}
});
server.route({
// POST is only allowed for Third Party initiated authentication
method: 'POST',
path: '/api/security/v1/oidc',
config: {
auth: false,
validate: {
query: Joi.object().keys({
iss: Joi.string(),
login_hint: Joi.string(),
target_link_uri: Joi.string()
})
}
},
async handler(request, h) {
try {
// We handle the fact that the user might get redirected to Kibana while already having an session
// in the same exact manner as with saml. Return an error notifying the user they are already logged in.
const authenticationResult = await server.plugins.security.authenticate(request);
if (authenticationResult.succeeded()) {
return Boom.forbidden(
'Sorry, you already have an active Kibana session. ' +
'If you want to start a new one, please logout from the existing session first.'
);
}
if (authenticationResult.redirected()) {
return h.redirect(authenticationResult.redirectURL);
}
throw Boom.unauthorized(authenticationResult.error);
} catch (err) {
throw wrapError(err);
}
}
});
server.route({
method: 'GET',
path: '/api/security/v1/logout',

View file

@ -14,6 +14,7 @@ require('@kbn/test').runTestsCli([
require.resolve('../test/plugin_api_integration/config.js'),
require.resolve('../test/saml_api_integration/config.js'),
require.resolve('../test/token_api_integration/config.js'),
require.resolve('../test/oidc_api_integration/config.js'),
require.resolve('../test/spaces_api_integration/spaces_only/config'),
require.resolve('../test/spaces_api_integration/security_and_spaces/config_trial'),
require.resolve('../test/spaces_api_integration/security_and_spaces/config_basic'),

View file

@ -326,21 +326,6 @@
}
});
/**
* Invalidates SAML access token.
*
* @param {string} token SAML access token that needs to be invalidated.
*
* @returns {{redirect?: string}}
*/
shield.samlLogout = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/saml/logout'
}
});
/**
* Invalidates SAML session based on Logout Request received from the Identity Provider.
*
@ -359,6 +344,64 @@
}
});
/**
* Asks Elasticsearch to prepare an OpenID Connect authentication request to be sent to
* the 3rd-party OpenID Connect provider.
*
* @param {string} realm The OpenID Connect realm name in Elasticsearch
*
* @returns {{state: string, nonce: string, redirect: string}} Object that includes two opaque parameters that need
* to be sent to Elasticsearch with the OpenID Connect response and redirect URL to the OpenID Connect provider that
* will be used to authenticate user.
*/
shield.oidcPrepare = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/oidc/prepare'
}
});
/**
* Sends the URL to which the OpenID Connect Provider redirected the UA to Elasticsearch for validation.
*
* @param {string} state The state parameter that was returned by Elasticsearch in the
* preparation response.
* @param {string} nonce The nonce parameter that was returned by Elasticsearch in the
* preparation response.
* @param {string} redirect_uri The URL to where the UA was redirected by the OpenID Connect provider.
*
* @returns {{username: string, access_token: string, refresh_token; string, expires_in: number}} Object that
* includes name of the user, access token to use for any consequent requests that
* need to be authenticated and a number of seconds after which access token will expire.
*/
shield.oidcAuthenticate = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/oidc/authenticate'
}
});
/**
* Invalidates an access token and refresh token pair that was generated after an OpenID Connect authentication.
*
* @param {string} token An access token that was created by authenticating to an OpenID Connect realm and
* that needs to be invalidated.
* @param {string} refres_token A refresh token that was created by authenticating to an OpenID Connect realm and
* that needs to be invalidated.
*
* @returns {{redirect?: string}} If the Elasticsearch OpenID Connect realm configuration and the
* OpenID Connect provider supports RP-initiated SLO, a URL to redirect the UA
*/
shield.oidcLogout = ca({
method: 'POST',
needBody: true,
url: {
fmt: '/_security/oidc/logout'
}
});
/**
* Refreshes an access token.
*

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export default function ({ loadTestFile }) {
describe('apis OpenID Connect', function () {
this.tags('ciGroup6');
loadTestFile(require.resolve('./security'));
});
}

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export default function ({ loadTestFile }) {
describe('security', () => {
loadTestFile(require.resolve('./oidc_initiate_auth'));
});
}

View file

@ -0,0 +1,518 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import request from 'request';
import url from 'url';
import { getStateAndNonce } from '../../fixtures/oidc_tools';
import { delay } from 'bluebird';
export default function ({ getService }) {
const supertest = getService('supertestWithoutAuth');
describe('OpenID Connect authentication', () => {
it('should reject API requests if client is not authenticated', async () => {
await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.expect(401);
});
describe('initiating handshake', () => {
it('should properly set cookie, return all parameters and redirect user', async () => {
const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three')
.expect(302);
const cookies = handshakeResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
const handshakeCookie = request.cookie(cookies[0]);
expect(handshakeCookie.key).to.be('sid');
expect(handshakeCookie.value).to.not.be.empty();
expect(handshakeCookie.path).to.be('/');
expect(handshakeCookie.httpOnly).to.be(true);
const redirectURL = url.parse(handshakeResponse.headers.location, true /* parseQueryString */);
expect(redirectURL.href.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`)).to.be(true);
expect(redirectURL.query.scope).to.not.be.empty();
expect(redirectURL.query.response_type).to.not.be.empty();
expect(redirectURL.query.client_id).to.not.be.empty();
expect(redirectURL.query.redirect_uri).to.not.be.empty();
expect(redirectURL.query.state).to.not.be.empty();
expect(redirectURL.query.nonce).to.not.be.empty();
});
it('should properly set cookie, return all parameters and redirect user for Third Party initiated', async () => {
const handshakeResponse = await supertest.get('/api/security/v1/oidc?iss=https://test-op.elastic.co')
.expect(302);
const cookies = handshakeResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
const handshakeCookie = request.cookie(cookies[0]);
expect(handshakeCookie.key).to.be('sid');
expect(handshakeCookie.value).to.not.be.empty();
expect(handshakeCookie.path).to.be('/');
expect(handshakeCookie.httpOnly).to.be(true);
const redirectURL = url.parse(handshakeResponse.headers.location, true /* parseQueryString */);
expect(redirectURL.href.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`)).to.be(true);
expect(redirectURL.query.scope).to.not.be.empty();
expect(redirectURL.query.response_type).to.not.be.empty();
expect(redirectURL.query.client_id).to.not.be.empty();
expect(redirectURL.query.redirect_uri).to.not.be.empty();
expect(redirectURL.query.state).to.not.be.empty();
expect(redirectURL.query.nonce).to.not.be.empty();
});
it('should not allow access to the API with the handshake cookie', async () => {
const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three')
.expect(302);
const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]);
await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(401);
});
it('AJAX requests should not initiate handshake', async () => {
const ajaxResponse = await supertest.get('/abc/xyz/handshake?one=two three')
.set('kbn-xsrf', 'xxx')
.expect(401);
expect(ajaxResponse.headers['set-cookie']).to.be(undefined);
});
});
describe('finishing handshake', () => {
let stateAndNonce;
let handshakeCookie;
beforeEach(async () => {
const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three')
.expect(302);
handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]);
stateAndNonce = getStateAndNonce(handshakeResponse.headers.location);
// Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens
await supertest
.post('/api/oidc_provider/setup')
.set('kbn-xsrf', 'xxx')
.send({ nonce: stateAndNonce.nonce })
.expect(200);
});
it('should fail if OpenID Connect response is not complemented with handshake cookie', async () => {
await supertest.get(`/api/security/v1/oidc?code=thisisthecode&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.expect(401);
});
it('should fail if state is not matching', async () => {
await supertest.get(`/api/security/v1/oidc?code=thisisthecode&state=someothervalue`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(401);
});
it('should succeed if both the OpenID Connect response and the cookie are provided', async () => {
const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(302);
// User should be redirected to the URL that initiated handshake.
expect(oidcAuthenticationResponse.headers.location).to.be('/abc/xyz/handshake?one=two%20three');
const cookies = oidcAuthenticationResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
const sessionCookie = request.cookie(cookies[0]);
expect(sessionCookie.key).to.be('sid');
expect(sessionCookie.value).to.not.be.empty();
expect(sessionCookie.path).to.be('/');
expect(sessionCookie.httpOnly).to.be(true);
const apiResponse = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
expect(apiResponse.body).to.only.have.keys([
'username',
'full_name',
'email',
'roles',
'scope',
'metadata',
'enabled',
'authentication_realm',
'lookup_realm',
]);
expect(apiResponse.body.username).to.be('user1');
});
});
describe('Complete third party initiated authentication', () => {
it('should authenticate a user when a third party initiates the authentication', async () => {
const handshakeResponse = await supertest.get('/api/security/v1/oidc?iss=https://test-op.elastic.co')
.expect(302);
const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]);
const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location);
// Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens
await supertest
.post('/api/oidc_provider/setup')
.set('kbn-xsrf', 'xxx')
.send({ nonce: stateAndNonce.nonce })
.expect(200);
const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code2&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(302);
const cookies = oidcAuthenticationResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
const sessionCookie = request.cookie(cookies[0]);
expect(sessionCookie.key).to.be('sid');
expect(sessionCookie.value).to.not.be.empty();
expect(sessionCookie.path).to.be('/');
expect(sessionCookie.httpOnly).to.be(true);
const apiResponse = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
expect(apiResponse.body).to.only.have.keys([
'username',
'full_name',
'email',
'roles',
'scope',
'metadata',
'enabled',
'authentication_realm',
'lookup_realm',
]);
expect(apiResponse.body.username).to.be('user2');
});
});
describe('API access with active session', () => {
let stateAndNonce;
let sessionCookie;
beforeEach(async () => {
const handshakeResponse = await supertest.get('/abc/xyz')
.expect(302);
sessionCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]);
stateAndNonce = getStateAndNonce(handshakeResponse.headers.location);
// Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens
await supertest
.post('/api/oidc_provider/setup')
.set('kbn-xsrf', 'xxx')
.send({ nonce: stateAndNonce.nonce })
.expect(200);
const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(302);
sessionCookie = request.cookie(oidcAuthenticationResponse.headers['set-cookie'][0]);
});
it('should extend cookie on every successful non-system API call', async () => {
const apiResponseOne = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
expect(apiResponseOne.headers['set-cookie']).to.not.be(undefined);
const sessionCookieOne = request.cookie(apiResponseOne.headers['set-cookie'][0]);
expect(sessionCookieOne.value).to.not.be.empty();
expect(sessionCookieOne.value).to.not.equal(sessionCookie.value);
const apiResponseTwo = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
expect(apiResponseTwo.headers['set-cookie']).to.not.be(undefined);
const sessionCookieTwo = request.cookie(apiResponseTwo.headers['set-cookie'][0]);
expect(sessionCookieTwo.value).to.not.be.empty();
expect(sessionCookieTwo.value).to.not.equal(sessionCookieOne.value);
});
it('should not extend cookie for system API calls', async () => {
const systemAPIResponse = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('kbn-system-api', 'true')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
expect(systemAPIResponse.headers['set-cookie']).to.be(undefined);
});
it('should fail and preserve session cookie if unsupported authentication schema is used', async () => {
const apiResponse = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Authorization', 'Basic AbCdEf')
.set('Cookie', sessionCookie.cookieString())
.expect(401);
expect(apiResponse.headers['set-cookie']).to.be(undefined);
});
});
describe('logging out', () => {
let sessionCookie;
beforeEach(async () => {
const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three')
.expect(302);
const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]);
const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location);
// Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens
await supertest
.post('/api/oidc_provider/setup')
.set('kbn-xsrf', 'xxx')
.send({ nonce: stateAndNonce.nonce })
.expect(200);
const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(302);
const cookies = oidcAuthenticationResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
sessionCookie = request.cookie(cookies[0]);
});
it('should redirect to home page if session cookie is not provided', async () => {
const logoutResponse = await supertest.get('/api/security/v1/logout')
.expect(302);
expect(logoutResponse.headers['set-cookie']).to.be(undefined);
expect(logoutResponse.headers.location).to.be('/');
});
it('should redirect to the OPs endsession endpoint to complete logout', async () => {
const logoutResponse = await supertest.get('/api/security/v1/logout')
.set('Cookie', sessionCookie.cookieString())
.expect(302);
const cookies = logoutResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
const logoutCookie = request.cookie(cookies[0]);
expect(logoutCookie.key).to.be('sid');
expect(logoutCookie.value).to.be.empty();
expect(logoutCookie.path).to.be('/');
expect(logoutCookie.httpOnly).to.be(true);
expect(logoutCookie.maxAge).to.be(0);
const redirectURL = url.parse(logoutResponse.headers.location, true /* parseQueryString */);
expect(redirectURL.href.startsWith(`https://test-op.elastic.co/oauth2/v1/endsession`)).to.be(true);
expect(redirectURL.query.id_token_hint).to.not.be.empty();
// Tokens that were stored in the previous cookie should be invalidated as well and old
// session cookie should not allow API access.
const apiResponse = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(400);
expect(apiResponse.body).to.eql({
error: 'Bad Request',
message: 'Both elasticsearch access and refresh tokens are expired.',
statusCode: 400
});
});
it('should reject AJAX requests', async () => {
const ajaxResponse = await supertest.get('/api/security/v1/logout')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(400);
expect(ajaxResponse.headers['set-cookie']).to.be(undefined);
expect(ajaxResponse.body).to.eql({
error: 'Bad Request',
message: 'Client should be able to process redirect response.',
statusCode: 400
});
});
});
describe('API access with expired access token.', () => {
let sessionCookie;
beforeEach(async () => {
const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three')
.expect(302);
const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]);
const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location);
// Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens
await supertest
.post('/api/oidc_provider/setup')
.set('kbn-xsrf', 'xxx')
.send({ nonce: stateAndNonce.nonce })
.expect(200);
const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(302);
const cookies = oidcAuthenticationResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
sessionCookie = request.cookie(cookies[0]);
});
const expectNewSessionCookie = (cookie) => {
expect(cookie.key).to.be('sid');
expect(cookie.value).to.not.be.empty();
expect(cookie.path).to.be('/');
expect(cookie.httpOnly).to.be(true);
expect(cookie.value).to.not.be(sessionCookie.value);
};
it('expired access token should be automatically refreshed', async function () {
this.timeout(40000);
// Access token expiration is set to 15s for API integration tests.
// Let's wait for 20s to make sure token expires.
await delay(20000);
// This api call should succeed and automatically refresh token. Returned cookie will contain
// the new access and refresh token pair.
const firstResponse = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
const firstResponseCookies = firstResponse.headers['set-cookie'];
expect(firstResponseCookies).to.have.length(1);
const firstNewCookie = request.cookie(firstResponseCookies[0]);
expectNewSessionCookie(firstNewCookie);
// Request with old cookie should reuse the same refresh token if within 60 seconds.
// Returned cookie will contain the same new access and refresh token pairs as the first request
const secondResponse = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
const secondResponseCookies = secondResponse.headers['set-cookie'];
expect(secondResponseCookies).to.have.length(1);
const secondNewCookie = request.cookie(secondResponseCookies[0]);
expectNewSessionCookie(secondNewCookie);
expect(firstNewCookie.value).not.to.eql(secondNewCookie.value);
// The first new cookie with fresh pair of access and refresh tokens should work.
await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', firstNewCookie.cookieString())
.expect(200);
// The second new cookie with fresh pair of access and refresh tokens should work.
await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', secondNewCookie.cookieString())
.expect(200);
});
});
describe('API access with missing access token document.', () => {
let sessionCookie;
beforeEach(async () => {
const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three')
.expect(302);
const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]);
const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location);
// Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens
await supertest
.post('/api/oidc_provider/setup')
.set('kbn-xsrf', 'xxx')
.send({ nonce: stateAndNonce.nonce })
.expect(200);
const oidcAuthenticationResponse = await supertest.get(`/api/security/v1/oidc?code=code1&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(302);
const cookies = oidcAuthenticationResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
sessionCookie = request.cookie(cookies[0]);
});
it('should properly set cookie and start new OIDC handshake', async function () {
// Let's delete tokens from `.security-tokens` index directly to simulate the case when
// Elasticsearch automatically removes access/refresh token document from the index
// after some period of time.
const esResponse = await getService('es').deleteByQuery({
index: '.security-tokens',
q: 'doc_type:token',
refresh: true,
});
expect(esResponse).to.have.property('deleted').greaterThan(0);
const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three')
.set('Cookie', sessionCookie.cookieString())
.expect(302);
const cookies = handshakeResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
const handshakeCookie = request.cookie(cookies[0]);
expect(handshakeCookie.key).to.be('sid');
expect(handshakeCookie.value).to.not.be.empty();
expect(handshakeCookie.path).to.be('/');
expect(handshakeCookie.httpOnly).to.be(true);
const redirectURL = url.parse(handshakeResponse.headers.location, true /* parseQueryString */);
expect(redirectURL.href.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`)).to.be(true);
expect(redirectURL.query.scope).to.not.be.empty();
expect(redirectURL.query.response_type).to.not.be.empty();
expect(redirectURL.query.client_id).to.not.be.empty();
expect(redirectURL.query.redirect_uri).to.not.be.empty();
expect(redirectURL.query.state).to.not.be.empty();
expect(redirectURL.query.nonce).to.not.be.empty();
});
});
});
}

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { resolve } from 'path';
export default async function ({ readConfigFile }) {
const kibanaAPITestsConfig = await readConfigFile(require.resolve('../../../test/api_integration/config.js'));
const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.js'));
const plugin = resolve(__dirname, './fixtures/oidc_provider');
const kibanaPort = xPackAPITestsConfig.get('servers.kibana.port');
const jwksPath = resolve(__dirname, './fixtures/jwks.json');
return {
testFiles: [require.resolve('./apis')],
servers: xPackAPITestsConfig.get('servers'),
services: {
es: kibanaAPITestsConfig.get('services.es'),
supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'),
},
junit: {
reportName: 'X-Pack OpenID Connect API Integration Tests',
},
esTestCluster: {
...xPackAPITestsConfig.get('esTestCluster'),
serverArgs: [
...xPackAPITestsConfig.get('esTestCluster.serverArgs'),
'xpack.security.authc.token.enabled=true',
'xpack.security.authc.token.timeout=15s',
'xpack.security.authc.realms.oidc.oidc1.order=0',
`xpack.security.authc.realms.oidc.oidc1.rp.client_id=0oa8sqpov3TxMWJOt356`,
`xpack.security.authc.realms.oidc.oidc1.rp.response_type=code`,
`xpack.security.authc.realms.oidc.oidc1.rp.redirect_uri=http://localhost:${kibanaPort}/api/security/v1/oidc`,
`xpack.security.authc.realms.oidc.oidc1.op.authorization_endpoint=https://test-op.elastic.co/oauth2/v1/authorize`,
`xpack.security.authc.realms.oidc.oidc1.op.endsession_endpoint=https://test-op.elastic.co/oauth2/v1/endsession`,
`xpack.security.authc.realms.oidc.oidc1.op.token_endpoint=http://localhost:${kibanaPort}/api/oidc_provider/token_endpoint`,
`xpack.security.authc.realms.oidc.oidc1.op.userinfo_endpoint=http://localhost:${kibanaPort}/api/oidc_provider/userinfo_endpoint`,
`xpack.security.authc.realms.oidc.oidc1.op.issuer=https://test-op.elastic.co`,
`xpack.security.authc.realms.oidc.oidc1.op.jwkset_path=${jwksPath}`,
`xpack.security.authc.realms.oidc.oidc1.claims.principal=sub`
],
},
kbnTestServer: {
...xPackAPITestsConfig.get('kbnTestServer'),
serverArgs: [
...xPackAPITestsConfig.get('kbnTestServer.serverArgs'),
`--plugin-path=${plugin}`,
'--xpack.security.authProviders=[\"oidc\"]',
'--xpack.security.authc.oidc.realm=\"oidc1\"',
'--server.xsrf.whitelist', JSON.stringify(['/api/security/v1/oidc',
'/api/oidc_provider/token_endpoint',
'/api/oidc_provider/userinfo_endpoint'])
],
},
};
}

View file

@ -0,0 +1,28 @@
### Generating key material
Key material can be generated in the following manner:
#### Generate a key pair with openssl
```shell
openssl genrsa 2048 > jwks_private.pem
openssl rsa -in jwks_private.pem -pubout > jwks_public.pem
```
#### Create a JWKS from the public key
For example, with [pem-jwk](https://github.com/dannycoates/pem-jwk)
```shell
pem-jwk jwks_public.pem > jwks.json
```
If the tool used doesn't have an option to wrap the key in a key set, you can manually do that by
placing the json key within a
```javascript
{
"keys": []
}
```
section

View file

@ -0,0 +1,10 @@
{
"keys": [
{
"kty": "RSA",
"e": "AQAB",
"use": "sig",
"n": "v9-88aGdE4E85PuEycxTA6LkM3TBvNScoeP6A-dd0Myo6-LfBlp1r7BPBWmvi_SC6Zam3U1LE3AekDMwqJg304my0pvh8wOwlmRpgKXDXjvj4s59vdeVNhCB9doIthUABd310o9lyb55fWc_qQYE2LK9AyEjicJswafguH6txV4IwSl13ieZAxni0Ca4CwdzXO1Oi34XjHF8F5x_0puTaQzHn5bPG4fiIJN-pwie0Ba4VEDPO5ca4lLXWVi1bn8xMDTAULrBAXJwDaDdS05KMbc4sPlyQPhtY1gcYvUbozUPYxSWwA7fZgFzV_h-uy_oXf1EXttOxSgog1z3cJzf6Q"
}
]
}

View file

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC/37zxoZ0TgTzk
+4TJzFMDouQzdMG81Jyh4/oD513QzKjr4t8GWnWvsE8Faa+L9ILplqbdTUsTcB6Q
MzComDfTibLSm+HzA7CWZGmApcNeO+Pizn2915U2EIH12gi2FQAF3fXSj2XJvnl9
Zz+pBgTYsr0DISOJwmzBp+C4fq3FXgjBKXXeJ5kDGeLQJrgLB3Nc7U6LfheMcXwX
nH/Sm5NpDMefls8bh+Igk36nCJ7QFrhUQM87lxriUtdZWLVufzEwNMBQusEBcnAN
oN1LTkoxtziw+XJA+G1jWBxi9RujNQ9jFJbADt9mAXNX+H67L+hd/URe207FKCiD
XPdwnN/pAgMBAAECggEADiKRbMuXIsS2k7fjxGoFA5OQdCn5y8tt7o847+ivhJ5P
I3GHNJSdbt/yMlfi0tCkhEjQ6iSzjy8HUWA0CXeNRUwznEhXkOuIqsui6hNMHTkU
RLUplj63g1AcAtyZH7DUW5pKbcSanw4lLRPaIL2MxdoFCqH6WD+2e12+tFjAvHVc
Bm03+hIt2898ruLQfHLQ1MUegTmXWZ9fqiPizuKwrW9xlCGQbIKoqVHebCMqRFK0
XGv0NNmUTnNo+uF3++yYHv/TL96EFTmU7QhUFrddONzUXDv0JjyiOnHk32191R9m
V8Y9mq+RT+tMsu+dxr2Yk4Qc5oHYX84p/afQxX8sMQKBgQD9FUsnJASMeg4jNlq8
XjDsXWu0NoVGTfU9z/9/SU+L6KexubdCoCWxs+8KyA69PZkMW6HMw6BGuDPjTvI9
1DmRdnVEa5EmUv/CIXZcAM+9Q7yWEocB/JeQOj9mdC/u1/sdQxNg1ae2HqJczl2I
EO4r7YshHQmqCju4lfyEf4rWTwKBgQDCFdm535BIPa4wGRTD7tY/VOCpDeom+PxH
9LUhefJV+G9RhP2jEPW+9D/ux8YAkL4c2kZR9kddLVFAD8jwormjWk6+uL5/jAAI
j6Aor3spBTNpgji6YnRaIk2PDIznFnSDPhdoWGsw8QQQ54wUO8m51cqPSYVZIu2d
U0yYacGQRwKBgFuJkB0gEeUdYG+sATWQe/GB+Kq97YZ4O/OXf7nyMitQgxbtLTOT
6Q5VHmiv42TfGrQ1kFgXiakKhvn4W/WxBQFv7wpIPb+21XrJz52HTZwPG+7L1LkL
O2aXKsdLzup8g/8Ze7DSlk5w1hjrKzlDpmGNEX1wm0Y9XUxuM19ZIkZRAoGAFR/F
s8pWbNZxyABi1zR+kyQM07mU+6rr4nUK5drc+mhwzUGZTY9CAAebkcSik1stpfxH
3RHeEJEnH77YEwDTDal9mpqG+WDmfAgN2X/H+t37C4fF3ttqaIkFQgWOrHQwODyg
1ZWSDSCeXayl/WnIefZ/9np9DgeULyRq2Mfh7m8CgYEAsEhsZyAe7QrCoVMykUSp
sys7qht/9B4QgaeX1pPdaJxLTPIKG0gYldWF1/zyMtiCYVY+MJkwgaVRgjX+8Swa
QfluMQ4YYrurxcdn+nSggGGinM6rt0C319sduKouKChMpzoEQp2RAIH35Of2usmR
k8z9rWWE/VEBo6K1d+3g/rA=
-----END PRIVATE KEY-----

View file

@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv9+88aGdE4E85PuEycxT
A6LkM3TBvNScoeP6A+dd0Myo6+LfBlp1r7BPBWmvi/SC6Zam3U1LE3AekDMwqJg3
04my0pvh8wOwlmRpgKXDXjvj4s59vdeVNhCB9doIthUABd310o9lyb55fWc/qQYE
2LK9AyEjicJswafguH6txV4IwSl13ieZAxni0Ca4CwdzXO1Oi34XjHF8F5x/0puT
aQzHn5bPG4fiIJN+pwie0Ba4VEDPO5ca4lLXWVi1bn8xMDTAULrBAXJwDaDdS05K
Mbc4sPlyQPhtY1gcYvUbozUPYxSWwA7fZgFzV/h+uy/oXf1EXttOxSgog1z3cJzf
6QIDAQAB
-----END PUBLIC KEY-----

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { initRoutes } from './init_routes';
export default function (kibana) {
return new kibana.Plugin({
name: 'oidcProvider',
id: 'oidcProvider',
require: ['elasticsearch'],
init(server) {
initRoutes(server);
},
});
}

View file

@ -0,0 +1,110 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Joi from 'joi';
import jwt from 'jsonwebtoken';
import fs from 'fs';
export function initRoutes(server) {
let nonce = '';
server.route({
path: '/api/oidc_provider/setup',
method: 'POST',
config: {
auth: false,
validate: {
payload: Joi.object({
nonce: Joi.string().required(),
}),
},
},
handler: (request) => {
nonce = request.payload.nonce;
return {};
},
});
server.route({
path: '/api/oidc_provider/token_endpoint',
method: 'POST',
// Token endpoint needs authentication (with the client credentials) but we don't attempt to
// validate this OIDC behavior here
config: {
auth: false,
validate: {
payload: Joi.object({
grant_type: Joi.string().optional(),
code: Joi.string().optional(),
redirect_uri: Joi.string().optional(),
}),
},
},
async handler(request) {
try {
const signingKey = fs.readFileSync(require.resolve('../../../oidc_api_integration/fixtures/jwks_private.pem'));
const userId = request.payload.code.substring(4);
const iat = Math.floor(Date.now() / 1000);
const idToken = JSON.stringify({
iss: 'https://test-op.elastic.co',
sub: `user${userId}`,
aud: '0oa8sqpov3TxMWJOt356',
nonce,
exp: iat + 3600,
iat,
});
return {
access_token: `valid-access-token${userId}`,
token_type: 'Bearer',
refresh_token: `valid-refresh-token${userId}`,
expires_in: 3600,
id_token: jwt.sign(idToken, signingKey, { algorithm: 'RS256' }),
};
} catch (err) {
return err;
}
},
});
server.route({
path: '/api/oidc_provider/userinfo_endpoint',
method: 'GET',
config: {
auth: false
},
handler: (request) => {
const accessToken = request.headers.authorization.substring(7);
if (accessToken === 'valid-access-token1') {
return { 'sub': 'user1',
'name': 'Tony Stark',
'given_name': 'Tony',
'family_name': 'Stark',
'preferred_username': 'ironman',
'email': 'ironman@avengers.com'
};
}
if (accessToken === 'valid-access-token2') {
return { 'sub': 'user2',
'name': 'Peter Parker',
'given_name': 'Peter',
'family_name': 'Parker',
'preferred_username': 'spiderman',
'email': 'spiderman@avengers.com'
};
}
if (accessToken === 'valid-access-token3') {
return { 'sub': 'user3',
'name': 'Bruce Banner',
'given_name': 'Bruce',
'family_name': 'Banner',
'preferred_username': 'hulk',
'email': 'hulk@avengers.com'
};
}
return {};
},
});
}

View file

@ -0,0 +1,13 @@
{
"name": "oidc_provider_plugin",
"version": "1.0.0",
"kibana": {
"version": "kibana",
"templateVersion": "1.0.0"
},
"license": "Apache-2.0",
"dependencies": {
"joi": "^13.5.2",
"jsonwebtoken": "^8.3.0"
}
}

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import url from 'url';
export function getStateAndNonce(urlWithStateAndNonce) {
const parsedQuery = url.parse(urlWithStateAndNonce, true).query;
return { state: parsedQuery.state, nonce: parsedQuery.nonce };
}