Allow Kibana to restrict the usage of JWT for a predefined set of routes only. (#163806)

## Summary

Allow Kibana to restrict the usage of JWT for a predefined set of routes
only in Serverless environment by default. This capability is not
available in non-Serverless environment.

Any route that needs to be accessed in Serverless environemnt using JWT
as a means of authentication should include `security:acceptJWT` tag.

## How to test

If you'd like to generate your own JWT to test the PR, please follow the
steps outlined in
https://github.com/elastic/kibana/pull/159117#issue-1743796706 or just
run functional test server and use static JWT from the Serverless test.

This PR also generated a Serverless Docker image that you can use in
your Dev/QA MKI cluster.

- [x] Implementation functionality and add unit tests
- [x] Update metrics/status routes to include new `security:acceptJWT`
tag
- [x] Update serverless test suite to include a test for
`security:acceptJWT`

__Fixes: https://github.com/elastic/kibana/issues/162632__

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Aleh Zasypkin 2023-08-23 14:57:32 +02:00 committed by GitHub
parent 40ba6b619d
commit 5aee5da843
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 363 additions and 10 deletions

2
.github/CODEOWNERS vendored
View file

@ -595,7 +595,7 @@ packages/kbn-search-api-panels @elastic/enterprise-search-frontend
examples/search_examples @elastic/kibana-data-discovery
packages/kbn-search-response-warnings @elastic/kibana-data-discovery
x-pack/plugins/searchprofiler @elastic/platform-deployment-management
x-pack/test/security_api_integration/packages/helpers @elastic/kibana-core
x-pack/test/security_api_integration/packages/helpers @elastic/kibana-security
x-pack/plugins/security @elastic/kibana-security
x-pack/plugins/security_solution_ess @elastic/security-solution
x-pack/test/cases_api_integration/common/plugins/security_solution @elastic/response-ops

View file

@ -82,7 +82,10 @@ export const registerStatusRoute = ({
path: '/api/status',
options: {
authRequired: 'optional',
tags: ['api'], // ensures that unauthenticated calls receive a 401 rather than a 302 redirect to login page
// The `api` tag ensures that unauthenticated calls receive a 401 rather than a 302 redirect to login page.
// The `security:acceptJWT` tag allows route to be accessed with JWT credentials. It points to
// ROUTE_TAG_ACCEPT_JWT from '@kbn/security-plugin/server' that cannot be imported here directly.
tags: ['api', 'security:acceptJWT'],
access: 'public', // needs to be public to allow access from "system" users like k8s readiness probes.
},
validate: {

View file

@ -55,7 +55,10 @@ export function registerStatsRoute({
path: '/api/stats',
options: {
authRequired: !config.allowAnonymous,
tags: ['api'], // ensures that unauthenticated calls receive a 401 rather than a 302 redirect to login page
// The `api` tag ensures that unauthenticated calls receive a 401 rather than a 302 redirect to login page.
// The `security:acceptJWT` tag allows route to be accessed with JWT credentials. It points to
// ROUTE_TAG_ACCEPT_JWT from '@kbn/security-plugin/server' that cannot be imported here directly.
tags: ['api', 'security:acceptJWT'],
access: 'public', // needs to be public to allow access from "system" users like metricbeat.
},
validate: {

View file

@ -62,12 +62,14 @@ function getMockOptions({
selector,
accessAgreementMessage,
customLogoutURL,
configContext = {},
}: {
providers?: Record<string, unknown> | string[];
http?: Partial<AuthenticatorOptions['config']['authc']['http']>;
selector?: AuthenticatorOptions['config']['authc']['selector'];
accessAgreementMessage?: string;
customLogoutURL?: string;
configContext?: Record<string, unknown>;
} = {}) {
const auditService = auditServiceMock.create();
auditLogger = auditLoggerMock.create();
@ -86,10 +88,10 @@ function getMockOptions({
loggers: loggingSystemMock.create(),
getServerBaseURL: jest.fn(),
config: createConfig(
ConfigSchema.validate({
authc: { selector, providers, http },
...accessAgreementObj,
}),
ConfigSchema.validate(
{ authc: { selector, providers, http }, ...accessAgreementObj },
configContext
),
loggingSystemMock.create().get(),
{ isTLSEnabled: false }
),
@ -317,6 +319,23 @@ describe('Authenticator', () => {
});
});
it('includes JWT options if specified', () => {
new Authenticator(
getMockOptions({
providers: { basic: { basic1: { order: 0 } } },
http: { jwt: { taggedRoutesOnly: true } },
configContext: { serverless: true },
})
);
expect(
jest.requireMock('./providers/http').HTTPAuthenticationProvider
).toHaveBeenCalledWith(expect.anything(), {
supportedSchemes: new Set(['apikey', 'bearer', 'basic']),
jwt: { taggedRoutesOnly: true },
});
});
it('does not include additional schemes if `autoSchemesEnabled` is disabled', () => {
new Authenticator(
getMockOptions({

View file

@ -648,7 +648,13 @@ export class Authenticator {
throw new Error(`Provider name "${options.name}" is reserved.`);
}
this.providers.set(options.name, new HTTPAuthenticationProvider(options, { supportedSchemes }));
this.providers.set(
options.name,
new HTTPAuthenticationProvider(options, {
supportedSchemes,
jwt: this.options.config.authc.http.jwt,
})
);
}
/**

View file

@ -15,6 +15,7 @@ import { mockAuthenticationProviderOptions } from './base.mock';
import { HTTPAuthenticationProvider } from './http';
import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock';
import { securityMock } from '../../mocks';
import { ROUTE_TAG_ACCEPT_JWT } from '../../routes/tags';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
@ -144,6 +145,113 @@ describe('HTTPAuthenticationProvider', () => {
}
});
it('succeeds for JWT authentication if not restricted to tagged routes.', async () => {
const header = 'Bearer header.body.signature';
const user = mockAuthenticatedUser({ authentication_realm: { name: 'jwt1', type: 'jwt' } });
const request = httpServerMock.createKibanaRequest({ headers: { authorization: header } });
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(user);
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
mockOptions.client.asScoped.mockClear();
const provider = new HTTPAuthenticationProvider(mockOptions, {
supportedSchemes: new Set(['bearer']),
});
await expect(provider.authenticate(request)).resolves.toEqual(
AuthenticationResult.succeeded({
...user,
authentication_provider: { type: 'http', name: 'http' },
})
);
expectAuthenticateCall(mockOptions.client, { headers: { authorization: header } });
expect(request.headers.authorization).toBe(header);
});
it('succeeds for non-JWT authentication if JWT restricted to tagged routes.', async () => {
const header = 'Basic xxx';
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest({ headers: { authorization: header } });
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(user);
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
mockOptions.client.asScoped.mockClear();
const provider = new HTTPAuthenticationProvider(mockOptions, {
supportedSchemes: new Set(['bearer', 'basic']),
jwt: { taggedRoutesOnly: true },
});
await expect(provider.authenticate(request)).resolves.toEqual(
AuthenticationResult.succeeded({
...user,
authentication_provider: { type: 'http', name: 'http' },
})
);
expectAuthenticateCall(mockOptions.client, { headers: { authorization: header } });
expect(request.headers.authorization).toBe(header);
});
it('succeeds for JWT authentication if restricted to tagged routes and route is tagged.', async () => {
const header = 'Bearer header.body.signature';
const user = mockAuthenticatedUser({ authentication_realm: { name: 'jwt1', type: 'jwt' } });
const request = httpServerMock.createKibanaRequest({
headers: { authorization: header },
routeTags: [ROUTE_TAG_ACCEPT_JWT],
});
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(user);
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
mockOptions.client.asScoped.mockClear();
const provider = new HTTPAuthenticationProvider(mockOptions, {
supportedSchemes: new Set(['bearer']),
jwt: { taggedRoutesOnly: true },
});
await expect(provider.authenticate(request)).resolves.toEqual(
AuthenticationResult.succeeded({
...user,
authentication_provider: { type: 'http', name: 'http' },
})
);
expectAuthenticateCall(mockOptions.client, { headers: { authorization: header } });
expect(request.headers.authorization).toBe(header);
});
it('fails for JWT authentication if restricted to tagged routes and route is NOT tagged.', async () => {
const header = 'Bearer header.body.signature';
const user = mockAuthenticatedUser({ authentication_realm: { name: 'jwt1', type: 'jwt' } });
const request = httpServerMock.createKibanaRequest({ headers: { authorization: header } });
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(user);
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
mockOptions.client.asScoped.mockClear();
const provider = new HTTPAuthenticationProvider(mockOptions, {
supportedSchemes: new Set(['bearer']),
jwt: { taggedRoutesOnly: true },
});
await expect(provider.authenticate(request)).resolves.toEqual(
AuthenticationResult.notHandled()
);
expectAuthenticateCall(mockOptions.client, { headers: { authorization: header } });
expect(request.headers.authorization).toBe(header);
});
it('fails if authentication via `authorization` header with supported scheme fails.', async () => {
const failureReason = new errors.ResponseError(securityMock.createApiResponse({ body: {} }));
for (const { schemes, header } of [

View file

@ -9,12 +9,22 @@ import type { KibanaRequest } from '@kbn/core/server';
import type { AuthenticationProviderOptions } from './base';
import { BaseAuthenticationProvider } from './base';
import { ROUTE_TAG_ACCEPT_JWT } from '../../routes/tags';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { HTTPAuthorizationHeader } from '../http_authentication';
/**
* A type-string of the Elasticsearch JWT realm.
*/
const JWT_REALM_TYPE = 'jwt';
interface HTTPAuthenticationProviderOptions {
supportedSchemes: Set<string>;
jwt?: {
// When set, only routes marked with `ROUTE_TAG_ACCEPT_JWT` tag will accept JWT as a means of authentication.
taggedRoutesOnly: boolean;
};
}
/**
@ -32,6 +42,11 @@ export class HTTPAuthenticationProvider extends BaseAuthenticationProvider {
*/
private readonly supportedSchemes: Set<string>;
/**
* Options relevant to the JWT authentication.
*/
private readonly jwt: HTTPAuthenticationProviderOptions['jwt'];
constructor(
protected readonly options: Readonly<AuthenticationProviderOptions>,
httpOptions: Readonly<HTTPAuthenticationProviderOptions>
@ -44,6 +59,7 @@ export class HTTPAuthenticationProvider extends BaseAuthenticationProvider {
this.supportedSchemes = new Set(
[...httpOptions.supportedSchemes].map((scheme) => scheme.toLowerCase())
);
this.jwt = httpOptions.jwt;
}
/**
@ -79,6 +95,23 @@ export class HTTPAuthenticationProvider extends BaseAuthenticationProvider {
this.logger.debug(
`Request to ${request.url.pathname}${request.url.search} has been authenticated via authorization header with "${authorizationHeader.scheme}" scheme.`
);
// If Kibana is configured to restrict JWT authentication only to selected routes, ensure that the route is marked
// with the `ROUTE_TAG_ACCEPT_JWT` tag to bypass that restriction.
if (
user.authentication_realm.type === JWT_REALM_TYPE &&
this.jwt?.taggedRoutesOnly &&
!request.route.options.tags.includes(ROUTE_TAG_ACCEPT_JWT)
) {
// Log a portion of the JWT signature to make debugging easier.
const jwtExcerpt = authorizationHeader.credentials.slice(-10);
this.logger.error(
`Attempted to authenticate with JWT credentials (…${jwtExcerpt}) against ${request.url.pathname}${request.url.search}, but it's not allowed. ` +
`Ensure that the route is defined with the "${ROUTE_TAG_ACCEPT_JWT}" tag.`
);
return AuthenticationResult.notHandled();
}
return AuthenticationResult.succeeded(user);
} catch (err) {
this.logger.debug(

View file

@ -183,6 +183,68 @@ describe('config schema', () => {
"showNavLinks": true,
}
`);
expect(ConfigSchema.validate({}, { serverless: true, dist: true })).toMatchInlineSnapshot(`
Object {
"audit": Object {
"enabled": false,
},
"authc": Object {
"http": Object {
"autoSchemesEnabled": true,
"enabled": true,
"jwt": Object {
"taggedRoutesOnly": true,
},
"schemes": Array [
"apikey",
"bearer",
],
},
"providers": Object {
"anonymous": undefined,
"basic": Object {
"basic": Object {
"accessAgreement": undefined,
"description": undefined,
"enabled": true,
"hint": undefined,
"icon": undefined,
"order": 0,
"session": Object {
"idleTimeout": undefined,
"lifespan": undefined,
},
"showInSelector": true,
},
},
"kerberos": undefined,
"oidc": undefined,
"pki": undefined,
"saml": undefined,
"token": undefined,
},
"selector": Object {},
},
"cookieName": "sid",
"enabled": true,
"loginAssistanceMessage": "",
"public": Object {},
"secureCookies": false,
"session": Object {
"cleanupInterval": "PT1H",
"idleTimeout": "P3D",
"lifespan": "P30D",
},
"showInsecureClusterWarning": true,
"showNavLinks": true,
"ui": Object {
"roleManagementEnabled": true,
"roleMappingManagementEnabled": true,
"userManagementEnabled": true,
},
}
`);
});
it('should throw error if xpack.security.encryptionKey is less than 32 characters', () => {
@ -1412,6 +1474,34 @@ describe('config schema', () => {
});
});
describe('authc.http', () => {
it('should not allow xpack.security.authc.http.jwt.* to be configured outside of the serverless context', () => {
expect(() =>
ConfigSchema.validate(
{ authc: { http: { jwt: { taggedRoutesOnly: false } } } },
{ serverless: false }
)
).toThrowErrorMatchingInlineSnapshot(
`"[authc.http.jwt]: a value wasn't expected to be present"`
);
});
it('should allow xpack.security.authc.http.jwt.* to be configured inside of the serverless context', () => {
expect(
ConfigSchema.validate(
{ authc: { http: { jwt: { taggedRoutesOnly: false } } } },
{ serverless: true }
).ui
).toMatchInlineSnapshot(`
Object {
"roleManagementEnabled": true,
"roleMappingManagementEnabled": true,
"userManagementEnabled": true,
}
`);
});
});
describe('ui', () => {
it('should not allow xpack.security.ui.* to be configured outside of the serverless context', () => {
expect(() =>

View file

@ -279,6 +279,11 @@ export const ConfigSchema = schema.object({
enabled: schema.boolean({ defaultValue: true }),
autoSchemesEnabled: schema.boolean({ defaultValue: true }),
schemes: schema.arrayOf(schema.string(), { defaultValue: ['apikey', 'bearer'] }),
jwt: offeringBasedSchema({
serverless: schema.object({
taggedRoutesOnly: schema.boolean({ defaultValue: true }),
}),
}),
}),
}),
audit: schema.object({

View file

@ -25,3 +25,9 @@ export const ROUTE_TAG_CAN_REDIRECT = 'security:canRedirect';
* parties, require special handling.
*/
export const ROUTE_TAG_AUTH_FLOW = 'security:authFlow';
/**
* If `xpack.security.authc.http.jwt.taggedRoutesOnly` flag is set, then only routes marked with this tag will accept
* JWT as a means of authentication.
*/
export const ROUTE_TAG_ACCEPT_JWT = 'security:acceptJWT';

View file

@ -57,11 +57,13 @@ describe('backgroundTaskUtilizationRoute', () => {
`"/internal/task_manager/_background_task_utilization"`
);
expect(config1.options?.authRequired).toEqual(true);
expect(config1.options?.tags).toEqual(['security:acceptJWT']);
const [config2] = router.get.mock.calls[1];
expect(config2.path).toMatchInlineSnapshot(`"/api/task_manager/_background_task_utilization"`);
expect(config2.options?.authRequired).toEqual(true);
expect(config2.options?.tags).toEqual(['security:acceptJWT']);
});
it(`sets "authRequired" to false when config.unsafe.authenticate_background_task_utilization is set to false`, async () => {

View file

@ -117,6 +117,9 @@ export function backgroundTaskUtilizationRoute(
options: {
access: 'public', // access must be public to allow "system" users, like metrics collectors, to access these routes
authRequired: routeOption.isAuthenticated ?? true,
// The `security:acceptJWT` tag allows route to be accessed with JWT credentials. It points to
// ROUTE_TAG_ACCEPT_JWT from '@kbn/security-plugin/server' that cannot be imported here directly.
tags: ['security:acceptJWT'],
},
},
async function (

View file

@ -28,6 +28,7 @@ describe('metricsRoute', () => {
const [config] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/api/task_manager/metrics"`);
expect(config.options?.tags).toEqual(['security:acceptJWT']);
});
it('emits resetMetric$ event when route is accessed and reset query param is true', async () => {

View file

@ -48,6 +48,9 @@ export function metricsRoute(params: MetricsRouteParams) {
path: `/api/task_manager/metrics`,
options: {
access: 'public',
// The `security:acceptJWT` tag allows route to be accessed with JWT credentials. It points to
// ROUTE_TAG_ACCEPT_JWT from '@kbn/security-plugin/server' that cannot be imported here directly.
tags: ['security:acceptJWT'],
},
// Uncomment when we determine that we can restrict API usage to Global admins based on telemetry
// options: { tags: ['access:taskManager'] },

View file

@ -1,6 +1,6 @@
{
"type": "shared-common",
"id": "@kbn/security-api-integration-helpers",
"owner": "@elastic/kibana-core",
"owner": "@elastic/kibana-security",
"devOnly": true
}

View file

@ -13,6 +13,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./security/anonymous'));
loadTestFile(require.resolve('./security/api_keys'));
loadTestFile(require.resolve('./security/authentication'));
loadTestFile(require.resolve('./security/authentication_http'));
loadTestFile(require.resolve('./security/authorization'));
loadTestFile(require.resolve('./security/misc'));
loadTestFile(require.resolve('./security/response_headers'));

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const svlCommonApi = getService('svlCommonApi');
const supertest = getService('supertestWithoutAuth');
describe('security/authentication/http', function () {
it('allows JWT HTTP authentication only for selected routes', async () => {
const jsonWebToken =
'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2tpYmFuYS5lbGFzdGljLmNvL2p3dC8iLCJzdWIiOiJlbGFzdGljLWFnZW50IiwiYXVkIjoiZWxhc3RpY3NlYXJjaCIsIm5hbWUiOiJFbGFzdGljIEFnZW50IiwiaWF0Ijo5NDY2ODQ4MDAsImV4cCI6NDA3MDkwODgwMH0.P7RHKZlLskS5DfVRqoVO4ivoIq9rXl2-GW6hhC9NvTSkwphYivcjpTVcyENZvxTTvJJNqcyx6rF3T-7otTTIHBOZIMhZauc5dob-sqcN_mT2htqm3BpSdlJlz60TBq6diOtlNhV212gQCEJMPZj0MNj7kZRj_GsECrTaU7FU0A3HAzkbdx15vQJMKZiFbbQCVI7-X2J0bZzQKIWfMHD-VgHFwOe6nomT-jbYIXtCBDd6fNj1zTKRl-_uzjVqNK-h8YW1h6tE4xvZmXyHQ1-9yNKZIWC7iEaPkBLaBKQulLU5MvW3AtVDUhzm6--5H1J85JH5QhRrnKYRon7ZW5q1AQ';
// Check 5 routes that are currently known to accept JWT as a means of authentication.
for (const allowedPath of [
'/api/status',
'/api/stats',
'/api/task_manager/_background_task_utilization',
'/internal/task_manager/_background_task_utilization',
'/api/task_manager/metrics',
]) {
await supertest
.get(allowedPath)
.set('Authorization', `Bearer ${jsonWebToken}`)
.set('ES-Client-Authentication', 'SharedSecret my_super_secret')
.set(svlCommonApi.getInternalRequestHeader())
.expect(200);
}
// Make sure it's not possible to use JWT to have interactive sessions.
await supertest
.get('/')
.set('Authorization', `Bearer ${jsonWebToken}`)
.set('ES-Client-Authentication', 'SharedSecret my_super_secret')
.expect(401);
// Make sure it's not possible to use JWT to access any other APIs.
await supertest
.get('/internal/security/me')
.set('Authorization', `Bearer ${jsonWebToken}`)
.set('ES-Client-Authentication', 'SharedSecret my_super_secret')
.set(svlCommonApi.getInternalRequestHeader())
.expect(401);
});
});
}

View file

@ -28,6 +28,8 @@ export default async () => {
'../../test/security_api_integration/plugins/saml_provider'
);
const jwksPath = require.resolve('@kbn/security-api-integration-helpers/oidc/jwks.json');
return {
servers,
@ -35,8 +37,25 @@ export default async () => {
license: 'trial',
from: 'snapshot',
serverArgs: [
'xpack.security.authc.realms.file.file1.order=-100',
'xpack.security.authc.realms.jwt.jwt1.order=-98',
`xpack.security.authc.realms.jwt.jwt1.token_type=access_token`,
'xpack.security.authc.realms.jwt.jwt1.client_authentication.type=shared_secret',
`xpack.security.authc.realms.jwt.jwt1.client_authentication.shared_secret=my_super_secret`,
`xpack.security.authc.realms.jwt.jwt1.allowed_issuer=https://kibana.elastic.co/jwt/`,
`xpack.security.authc.realms.jwt.jwt1.allowed_subjects=elastic-agent`,
'xpack.security.authc.realms.jwt.jwt1.allowed_audiences=elasticsearch',
`xpack.security.authc.realms.jwt.jwt1.allowed_signature_algorithms=[RS256]`,
`xpack.security.authc.realms.jwt.jwt1.claims.principal=sub`,
`xpack.security.authc.realms.jwt.jwt1.pkc_jwkset_path=${jwksPath}`,
// TODO: We should set this flag to `false` as soon as we fully migrate tests to SAML and file realms.
`xpack.security.authc.realms.native.native1.enabled=true`,
`xpack.security.authc.realms.native.native1.order=-97`,
'xpack.security.authc.token.enabled=true',
'xpack.security.authc.realms.saml.cloud-saml-kibana.order=0',
'xpack.security.authc.realms.saml.cloud-saml-kibana.order=101',
`xpack.security.authc.realms.saml.cloud-saml-kibana.idp.metadata.path=${idpPath}`,
'xpack.security.authc.realms.saml.cloud-saml-kibana.idp.entity_id=http://www.elastic.co/saml1',
`xpack.security.authc.realms.saml.cloud-saml-kibana.sp.entity_id=http://localhost:${servers.kibana.port}`,