Skip to content

Commit

Permalink
Allow Kibana to restrict the usage of JWT for a predefined set of rou…
Browse files Browse the repository at this point in the history
…tes only.
  • Loading branch information
azasypkin committed Aug 15, 2023
1 parent 3640633 commit 120de73
Show file tree
Hide file tree
Showing 11 changed files with 260 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
5 changes: 4 additions & 1 deletion src/plugins/usage_collection/server/routes/stats/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
108 changes: 108 additions & 0 deletions x-pack/plugins/security/server/authentication/providers/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 [
Expand Down
31 changes: 31 additions & 0 deletions x-pack/plugins/security/server/authentication/providers/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}

/**
Expand All @@ -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>
Expand All @@ -44,6 +59,7 @@ export class HTTPAuthenticationProvider extends BaseAuthenticationProvider {
this.supportedSchemes = new Set(
[...httpOptions.supportedSchemes].map((scheme) => scheme.toLowerCase())
);
this.jwt = httpOptions.jwt;
}

/**
Expand Down Expand Up @@ -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(
Expand Down
90 changes: 90 additions & 0 deletions x-pack/plugins/security/server/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(() =>
Expand Down
8 changes: 8 additions & 0 deletions x-pack/plugins/security/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
6 changes: 6 additions & 0 deletions x-pack/plugins/security/server/routes/tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/task_manager/server/routes/metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
3 changes: 3 additions & 0 deletions x-pack/plugins/task_manager/server/routes/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
Expand Down

0 comments on commit 120de73

Please sign in to comment.