Skip to content

Commit

Permalink
Add an optional authentication mode for HTTP resources (elastic#58589)
Browse files Browse the repository at this point in the history
* add authRequred: 'optional'

* expose auth status via request context

* update security plugin to use notHandled auth outcome

* capabilities service uses optional auth

* update tests

* attach security headers only to unauthorised response

* add isAuthenticated tests for 'optional' auth mode

* security plugin relies on http.auth.isAuthenticated to calc capabilities

* generate docs

* reword test suit names

* update tests

* update test checking isAuth on optional auth path

* address Oleg comments

* add test for auth: try

* fix

* pass isAuthenticted as boolean via context

* remove response header from notHandled

* update docs

* add redirected for auth interceptor

* security plugin uses t.redirected to be compat with auth: optional

* update docs

* require location header in the interface

* address comments #1

* declare isAuthenticated on KibanaRequest

* remove auth.isAuthenticated from scope

* update docs

* remove unnecessary comment

* do not fail on FakrRequest

* small improvements
  • Loading branch information
mshustov committed Mar 7, 2020
1 parent 0219d77 commit 203a413
Show file tree
Hide file tree
Showing 38 changed files with 920 additions and 97 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthNotHandled](./kibana-plugin-server.authnothandled.md)

## AuthNotHandled interface


<b>Signature:</b>

```typescript
export interface AuthNotHandled
```

## Properties

| Property | Type | Description |
| --- | --- | --- |
| [type](./kibana-plugin-server.authnothandled.type.md) | <code>AuthResultType.notHandled</code> | |

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthNotHandled](./kibana-plugin-server.authnothandled.md) &gt; [type](./kibana-plugin-server.authnothandled.type.md)

## AuthNotHandled.type property

<b>Signature:</b>

```typescript
type: AuthResultType.notHandled;
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthRedirected](./kibana-plugin-server.authredirected.md)

## AuthRedirected interface


<b>Signature:</b>

```typescript
export interface AuthRedirected extends AuthRedirectedParams
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [type](./kibana-plugin-server.authredirected.type.md) | <code>AuthResultType.redirected</code> | |
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthRedirected](./kibana-plugin-server.authredirected.md) &gt; [type](./kibana-plugin-server.authredirected.type.md)

## AuthRedirected.type property

<b>Signature:</b>

```typescript
type: AuthResultType.redirected;
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthRedirectedParams](./kibana-plugin-server.authredirectedparams.md) &gt; [headers](./kibana-plugin-server.authredirectedparams.headers.md)

## AuthRedirectedParams.headers property

Headers to attach for auth redirect. Must include "location" header

<b>Signature:</b>

```typescript
headers: {
location: string;
} & ResponseHeaders;
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthRedirectedParams](./kibana-plugin-server.authredirectedparams.md)

## AuthRedirectedParams interface

Result of auth redirection.

<b>Signature:</b>

```typescript
export interface AuthRedirectedParams
```

## Properties

| Property | Type | Description |
| --- | --- | --- |
| [headers](./kibana-plugin-server.authredirectedparams.headers.md) | <code>{</code><br/><code> location: string;</code><br/><code> } &amp; ResponseHeaders</code> | Headers to attach for auth redirect. Must include "location" header |

Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
<b>Signature:</b>

```typescript
export declare type AuthResult = Authenticated;
export declare type AuthResult = Authenticated | AuthNotHandled | AuthRedirected;
```
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## AuthResultParams interface

Result of an incoming request authentication.
Result of successful authentication.

<b>Signature:</b>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ export declare enum AuthResultType
| Member | Value | Description |
| --- | --- | --- |
| authenticated | <code>&quot;authenticated&quot;</code> | |
| notHandled | <code>&quot;notHandled&quot;</code> | |
| redirected | <code>&quot;redirected&quot;</code> | |

Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ export interface AuthToolkit
| Property | Type | Description |
| --- | --- | --- |
| [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) | <code>(data?: AuthResultParams) =&gt; AuthResult</code> | Authentication is successful with given credentials, allow request to pass through |
| [notHandled](./kibana-plugin-server.authtoolkit.nothandled.md) | <code>() =&gt; AuthResult</code> | User has no credentials. Allows user to access a resource when authRequired: 'optional' Rejects a request when authRequired: true |
| [redirected](./kibana-plugin-server.authtoolkit.redirected.md) | <code>(headers: {</code><br/><code> location: string;</code><br/><code> } &amp; ResponseHeaders) =&gt; AuthResult</code> | Redirect user to IdP when authRequired: true Allows user to access a resource without redirection when authRequired: 'optional' |

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthToolkit](./kibana-plugin-server.authtoolkit.md) &gt; [notHandled](./kibana-plugin-server.authtoolkit.nothandled.md)

## AuthToolkit.notHandled property

User has no credentials. Allows user to access a resource when authRequired: 'optional' Rejects a request when authRequired: true

<b>Signature:</b>

```typescript
notHandled: () => AuthResult;
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthToolkit](./kibana-plugin-server.authtoolkit.md) &gt; [redirected](./kibana-plugin-server.authtoolkit.redirected.md)

## AuthToolkit.redirected property

Redirect user to IdP when authRequired: true Allows user to access a resource without redirection when authRequired: 'optional'

<b>Signature:</b>

```typescript
redirected: (headers: {
location: string;
} & ResponseHeaders) => AuthResult;
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [KibanaRequest](./kibana-plugin-server.kibanarequest.md) &gt; [auth](./kibana-plugin-server.kibanarequest.auth.md)

## KibanaRequest.auth property

<b>Signature:</b>

```typescript
readonly auth: {
isAuthenticated: boolean;
};
```
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export declare class KibanaRequest<Params = unknown, Query = unknown, Body = unk

| Property | Modifiers | Type | Description |
| --- | --- | --- | --- |
| [auth](./kibana-plugin-server.kibanarequest.auth.md) | | <code>{</code><br/><code> isAuthenticated: boolean;</code><br/><code> }</code> | |
| [body](./kibana-plugin-server.kibanarequest.body.md) | | <code>Body</code> | |
| [events](./kibana-plugin-server.kibanarequest.events.md) | | <code>KibanaRequestEvents</code> | Request events [KibanaRequestEvents](./kibana-plugin-server.kibanarequestevents.md) |
| [headers](./kibana-plugin-server.kibanarequest.headers.md) | | <code>Headers</code> | Readonly copy of incoming request headers. |
Expand Down
5 changes: 4 additions & 1 deletion docs/development/core/server/kibana-plugin-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [AssistanceAPIResponse](./kibana-plugin-server.assistanceapiresponse.md) | |
| [AssistantAPIClientParams](./kibana-plugin-server.assistantapiclientparams.md) | |
| [Authenticated](./kibana-plugin-server.authenticated.md) | |
| [AuthResultParams](./kibana-plugin-server.authresultparams.md) | Result of an incoming request authentication. |
| [AuthNotHandled](./kibana-plugin-server.authnothandled.md) | |
| [AuthRedirected](./kibana-plugin-server.authredirected.md) | |
| [AuthRedirectedParams](./kibana-plugin-server.authredirectedparams.md) | Result of auth redirection. |
| [AuthResultParams](./kibana-plugin-server.authresultparams.md) | Result of successful authentication. |
| [AuthToolkit](./kibana-plugin-server.authtoolkit.md) | A tool set defining an outcome of Auth interceptor for incoming request. |
| [CallAPIOptions](./kibana-plugin-server.callapioptions.md) | The set of options that defines how API call should be made and result be processed. |
| [Capabilities](./kibana-plugin-server.capabilities.md) | The read-only set of capabilities available for the current UI session. Capabilities are simple key-value pairs of (string, boolean), where the string denotes the capability ID, and the boolean is a flag indicating if the capability is enabled or disabled. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

## RouteConfigOptions.authRequired property

A flag shows that authentication for a route: `enabled` when true `disabled` when false
Defines authentication mode for a route: - true. A user has to have valid credentials to access a resource - false. A user can access a resource without any credentials. - 'optional'. A user can access a resource if has valid credentials or no credentials at all. Can be useful when we grant access to a resource but want to identify a user if possible.

Enabled by default.
Defaults to `true` if an auth mechanism is registered.

<b>Signature:</b>

```typescript
authRequired?: boolean;
authRequired?: boolean | 'optional';
```
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface RouteConfigOptions<Method extends RouteMethod>

| Property | Type | Description |
| --- | --- | --- |
| [authRequired](./kibana-plugin-server.routeconfigoptions.authrequired.md) | <code>boolean</code> | A flag shows that authentication for a route: <code>enabled</code> when true <code>disabled</code> when false<!-- -->Enabled by default. |
| [authRequired](./kibana-plugin-server.routeconfigoptions.authrequired.md) | <code>boolean &#124; 'optional'</code> | Defines authentication mode for a route: - true. A user has to have valid credentials to access a resource - false. A user can access a resource without any credentials. - 'optional'. A user can access a resource if has valid credentials or no credentials at all. Can be useful when we grant access to a resource but want to identify a user if possible.<!-- -->Defaults to <code>true</code> if an auth mechanism is registered. |
| [body](./kibana-plugin-server.routeconfigoptions.body.md) | <code>Method extends 'get' &#124; 'options' ? undefined : RouteConfigOptionsBody</code> | Additional body options [RouteConfigOptionsBody](./kibana-plugin-server.routeconfigoptionsbody.md)<!-- -->. |
| [tags](./kibana-plugin-server.routeconfigoptions.tags.md) | <code>readonly string[]</code> | Additional metadata tag strings to attach to the route. |
| [xsrfRequired](./kibana-plugin-server.routeconfigoptions.xsrfrequired.md) | <code>Method extends 'get' ? never : boolean</code> | Defines xsrf protection requirements for a route: - true. Requires an incoming POST/PUT/DELETE request to contain <code>kbn-xsrf</code> header. - false. Disables xsrf protection.<!-- -->Set to true by default |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ export interface CapabilitiesStart {
*/
export class CapabilitiesService {
public async start({ appIds, http }: StartDeps): Promise<CapabilitiesStart> {
const route = http.anonymousPaths.isAnonymous(window.location.pathname) ? '/defaults' : '';
const capabilities = await http.post<Capabilities>(`/api/core/capabilities${route}`, {
const capabilities = await http.post<Capabilities>('/api/core/capabilities', {
body: JSON.stringify({ applications: appIds }),
});

Expand Down
4 changes: 2 additions & 2 deletions src/core/server/capabilities/capabilities_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ describe('CapabilitiesService', () => {
});

it('registers the capabilities routes', async () => {
expect(http.createRouter).toHaveBeenCalledWith('/api/core/capabilities');
expect(router.post).toHaveBeenCalledTimes(2);
expect(http.createRouter).toHaveBeenCalledWith('');
expect(router.post).toHaveBeenCalledTimes(1);
expect(router.post).toHaveBeenCalledWith(expect.any(Object), expect.any(Function));
});

Expand Down
2 changes: 1 addition & 1 deletion src/core/server/capabilities/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,6 @@ import { InternalHttpServiceSetup } from '../../http';
import { registerCapabilitiesRoutes } from './resolve_capabilities';

export function registerRoutes(http: InternalHttpServiceSetup, resolver: CapabilitiesResolver) {
const router = http.createRouter('/api/core/capabilities');
const router = http.createRouter('');
registerCapabilitiesRoutes(router, resolver);
}
44 changes: 19 additions & 25 deletions src/core/server/capabilities/routes/resolve_capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,30 +22,24 @@ import { IRouter } from '../../http';
import { CapabilitiesResolver } from '../resolve_capabilities';

export function registerCapabilitiesRoutes(router: IRouter, resolver: CapabilitiesResolver) {
// Capabilities are fetched on both authenticated and anonymous routes.
// However when `authRequired` is false, authentication is not performed
// and only default capabilities are returned (all disabled), even for authenticated users.
// So we need two endpoints to handle both scenarios.
[true, false].forEach(authRequired => {
router.post(
{
path: authRequired ? '' : '/defaults',
options: {
authRequired,
},
validate: {
body: schema.object({
applications: schema.arrayOf(schema.string()),
}),
},
router.post(
{
path: '/api/core/capabilities',
options: {
authRequired: 'optional',
},
async (ctx, req, res) => {
const { applications } = req.body;
const capabilities = await resolver(req, applications);
return res.ok({
body: capabilities,
});
}
);
});
validate: {
body: schema.object({
applications: schema.arrayOf(schema.string()),
}),
},
},
async (ctx, req, res) => {
const { applications } = req.body;
const capabilities = await resolver(req, applications);
return res.ok({
body: capabilities,
});
}
);
}
6 changes: 6 additions & 0 deletions src/core/server/http/http_server.mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { OnPostAuthToolkit } from './lifecycle/on_post_auth';
import { OnPreAuthToolkit } from './lifecycle/on_pre_auth';

interface RequestFixtureOptions<P = any, Q = any, B = any> {
auth?: { isAuthenticated: boolean };
headers?: Record<string, string>;
params?: Record<string, any>;
body?: Record<string, any>;
Expand Down Expand Up @@ -65,11 +66,13 @@ function createKibanaRequestMock<P = any, Q = any, B = any>({
routeAuthRequired,
validation = {},
kibanaRouteState = { xsrfRequired: true },
auth = { isAuthenticated: true },
}: RequestFixtureOptions<P, Q, B> = {}) {
const queryString = stringify(query, { sort: false });

return KibanaRequest.from<P, Q, B>(
createRawRequestMock({
auth,
headers,
params,
query,
Expand Down Expand Up @@ -113,6 +116,9 @@ function createRawRequestMock(customization: DeepPartial<Request> = {}) {
{},
{
app: { xsrfRequired: true } as any,
auth: {
isAuthenticated: true,
},
headers: {},
path: '/',
route: { settings: {} },
Expand Down
24 changes: 19 additions & 5 deletions src/core/server/http/http_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth';
import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_post_auth';
import { adoptToHapiOnPreAuthFormat, OnPreAuthHandler } from './lifecycle/on_pre_auth';
import { adoptToHapiOnPreResponseFormat, OnPreResponseHandler } from './lifecycle/on_pre_response';

import { IRouter, KibanaRouteState, isSafeMethod } from './router';
import { IRouter, RouteConfigOptions, KibanaRouteState, isSafeMethod } from './router';
import {
SessionStorageCookieOptions,
createCookieSessionStorageFactory,
Expand Down Expand Up @@ -148,7 +147,7 @@ export class HttpServer {
this.log.debug(`registering route handler for [${route.path}]`);
// Hapi does not allow payload validation to be specified for 'head' or 'get' requests
const validate = isSafeMethod(route.method) ? undefined : { payload: true };
const { authRequired = true, tags, body = {} } = route.options;
const { authRequired, tags, body = {} } = route.options;
const { accepts: allow, maxBytes, output, parse } = body;

const kibanaRouteState: KibanaRouteState = {
Expand All @@ -160,8 +159,7 @@ export class HttpServer {
method: route.method,
path: route.path,
options: {
// Enforcing the comparison with true because plugins could overwrite the auth strategy by doing `options: { authRequired: authStrategy as any }`
auth: authRequired === true ? undefined : false,
auth: this.getAuthOption(authRequired),
app: kibanaRouteState,
tags: tags ? Array.from(tags) : undefined,
// TODO: This 'validate' section can be removed once the legacy platform is completely removed.
Expand Down Expand Up @@ -196,6 +194,22 @@ export class HttpServer {
this.server = undefined;
}

private getAuthOption(
authRequired: RouteConfigOptions<any>['authRequired'] = true
): undefined | false | { mode: 'required' | 'optional' } {
if (this.authRegistered === false) return undefined;

if (authRequired === true) {
return { mode: 'required' };
}
if (authRequired === 'optional') {
return { mode: 'optional' };
}
if (authRequired === false) {
return false;
}
}

private setupBasePathRewrite(config: HttpConfig, basePathService: BasePath) {
if (config.basePath === undefined || !config.rewriteBasePath) {
return;
Expand Down
2 changes: 2 additions & 0 deletions src/core/server/http/http_service.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ const createOnPostAuthToolkitMock = (): jest.Mocked<OnPostAuthToolkit> => ({

const createAuthToolkitMock = (): jest.Mocked<AuthToolkit> => ({
authenticated: jest.fn(),
notHandled: jest.fn(),
redirected: jest.fn(),
});

const createOnPreResponseToolkitMock = (): jest.Mocked<OnPreResponseToolkit> => ({
Expand Down
Loading

0 comments on commit 203a413

Please sign in to comment.