diff --git a/packages/core/status/core-status-server-internal/src/routes/status.ts b/packages/core/status/core-status-server-internal/src/routes/status.ts index 403686bdf2688..e06d667b4c78b 100644 --- a/packages/core/status/core-status-server-internal/src/routes/status.ts +++ b/packages/core/status/core-status-server-internal/src/routes/status.ts @@ -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: { diff --git a/src/plugins/usage_collection/server/routes/stats/stats.ts b/src/plugins/usage_collection/server/routes/stats/stats.ts index 8c32003f38098..6e4a606216035 100644 --- a/src/plugins/usage_collection/server/routes/stats/stats.ts +++ b/src/plugins/usage_collection/server/routes/stats/stats.ts @@ -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: { diff --git a/x-pack/plugins/security/server/authentication/providers/http.test.ts b/x-pack/plugins/security/server/authentication/providers/http.test.ts index c1e7ba662c513..90ff62294ff3f 100644 --- a/x-pack/plugins/security/server/authentication/providers/http.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/http.test.ts @@ -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 [ diff --git a/x-pack/plugins/security/server/authentication/providers/http.ts b/x-pack/plugins/security/server/authentication/providers/http.ts index 21c2b25d3be8a..fad543702b559 100644 --- a/x-pack/plugins/security/server/authentication/providers/http.ts +++ b/x-pack/plugins/security/server/authentication/providers/http.ts @@ -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; + 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; + /** + * Options relevant to the JWT authentication. + */ + private readonly jwt: HTTPAuthenticationProviderOptions['jwt']; + constructor( protected readonly options: Readonly, httpOptions: Readonly @@ -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,21 @@ 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) + ) { + this.logger.error( + `Attempted to authenticate with JWT credentials 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( diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 47b16b5752794..04b16aebab9cc 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -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(() => diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 5b65dc3bb1ebd..069a470ed64d2 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -279,6 +279,14 @@ export const ConfigSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), autoSchemesEnabled: schema.boolean({ defaultValue: true }), schemes: schema.arrayOf(schema.string(), { defaultValue: ['apikey', 'bearer'] }), + jwt: schema.conditional( + schema.contextRef('serverless'), + true, + schema.object({ + taggedRoutesOnly: schema.boolean({ defaultValue: true }), + }), + schema.never() + ), }), }), audit: schema.object({ diff --git a/x-pack/plugins/security/server/routes/tags.ts b/x-pack/plugins/security/server/routes/tags.ts index 090c04d29757f..a6ffd49d53a52 100644 --- a/x-pack/plugins/security/server/routes/tags.ts +++ b/x-pack/plugins/security/server/routes/tags.ts @@ -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'; diff --git a/x-pack/plugins/task_manager/server/routes/background_task_utilization.test.ts b/x-pack/plugins/task_manager/server/routes/background_task_utilization.test.ts index e70c78b8e2120..322060b4f9b61 100644 --- a/x-pack/plugins/task_manager/server/routes/background_task_utilization.test.ts +++ b/x-pack/plugins/task_manager/server/routes/background_task_utilization.test.ts @@ -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 () => { diff --git a/x-pack/plugins/task_manager/server/routes/background_task_utilization.ts b/x-pack/plugins/task_manager/server/routes/background_task_utilization.ts index 38b1ce9966f33..b72b8ad5a7043 100644 --- a/x-pack/plugins/task_manager/server/routes/background_task_utilization.ts +++ b/x-pack/plugins/task_manager/server/routes/background_task_utilization.ts @@ -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 ( diff --git a/x-pack/plugins/task_manager/server/routes/metrics.test.ts b/x-pack/plugins/task_manager/server/routes/metrics.test.ts index a9703aa7548dd..172e29d61f110 100644 --- a/x-pack/plugins/task_manager/server/routes/metrics.test.ts +++ b/x-pack/plugins/task_manager/server/routes/metrics.test.ts @@ -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 () => { diff --git a/x-pack/plugins/task_manager/server/routes/metrics.ts b/x-pack/plugins/task_manager/server/routes/metrics.ts index 737f2b44fd79e..692227899979b 100644 --- a/x-pack/plugins/task_manager/server/routes/metrics.ts +++ b/x-pack/plugins/task_manager/server/routes/metrics.ts @@ -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'] },