mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
* 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
This commit is contained in:
parent
1d7c6af44d
commit
1582299967
38 changed files with 920 additions and 97 deletions
|
@ -0,0 +1,19 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [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> | |
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthNotHandled](./kibana-plugin-server.authnothandled.md) > [type](./kibana-plugin-server.authnothandled.type.md)
|
||||
|
||||
## AuthNotHandled.type property
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
type: AuthResultType.notHandled;
|
||||
```
|
|
@ -0,0 +1,19 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [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> | |
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthRedirected](./kibana-plugin-server.authredirected.md) > [type](./kibana-plugin-server.authredirected.type.md)
|
||||
|
||||
## AuthRedirected.type property
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
type: AuthResultType.redirected;
|
||||
```
|
|
@ -0,0 +1,15 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthRedirectedParams](./kibana-plugin-server.authredirectedparams.md) > [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;
|
||||
```
|
|
@ -0,0 +1,20 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [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> } & ResponseHeaders</code> | Headers to attach for auth redirect. Must include "location" header |
|
||||
|
|
@ -8,5 +8,5 @@
|
|||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
export declare type AuthResult = Authenticated;
|
||||
export declare type AuthResult = Authenticated | AuthNotHandled | AuthRedirected;
|
||||
```
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
## AuthResultParams interface
|
||||
|
||||
Result of an incoming request authentication.
|
||||
Result of successful authentication.
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
|
|
|
@ -16,4 +16,6 @@ export declare enum AuthResultType
|
|||
| Member | Value | Description |
|
||||
| --- | --- | --- |
|
||||
| authenticated | <code>"authenticated"</code> | |
|
||||
| notHandled | <code>"notHandled"</code> | |
|
||||
| redirected | <code>"redirected"</code> | |
|
||||
|
||||
|
|
|
@ -17,4 +17,6 @@ export interface AuthToolkit
|
|||
| Property | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) | <code>(data?: AuthResultParams) => AuthResult</code> | Authentication is successful with given credentials, allow request to pass through |
|
||||
| [notHandled](./kibana-plugin-server.authtoolkit.nothandled.md) | <code>() => 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> } & ResponseHeaders) => AuthResult</code> | Redirect user to IdP when authRequired: true Allows user to access a resource without redirection when authRequired: 'optional' |
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthToolkit](./kibana-plugin-server.authtoolkit.md) > [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;
|
||||
```
|
|
@ -0,0 +1,15 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthToolkit](./kibana-plugin-server.authtoolkit.md) > [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;
|
||||
```
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [KibanaRequest](./kibana-plugin-server.kibanarequest.md) > [auth](./kibana-plugin-server.kibanarequest.auth.md)
|
||||
|
||||
## KibanaRequest.auth property
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
readonly auth: {
|
||||
isAuthenticated: boolean;
|
||||
};
|
||||
```
|
|
@ -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. |
|
||||
|
|
|
@ -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. |
|
||||
|
|
|
@ -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';
|
||||
```
|
||||
|
|
|
@ -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 | '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' | '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 |
|
||||
|
|
|
@ -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 }),
|
||||
});
|
||||
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
@ -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,
|
||||
|
@ -113,6 +116,9 @@ function createRawRequestMock(customization: DeepPartial<Request> = {}) {
|
|||
{},
|
||||
{
|
||||
app: { xsrfRequired: true } as any,
|
||||
auth: {
|
||||
isAuthenticated: true,
|
||||
},
|
||||
headers: {},
|
||||
path: '/',
|
||||
route: { settings: {} },
|
||||
|
|
|
@ -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,
|
||||
|
@ -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 = {
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
|
|
|
@ -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> => ({
|
||||
|
|
|
@ -67,9 +67,12 @@ export {
|
|||
AuthenticationHandler,
|
||||
AuthHeaders,
|
||||
AuthResultParams,
|
||||
AuthRedirected,
|
||||
AuthRedirectedParams,
|
||||
AuthToolkit,
|
||||
AuthResult,
|
||||
Authenticated,
|
||||
AuthNotHandled,
|
||||
AuthResultType,
|
||||
} from './lifecycle/auth';
|
||||
export { OnPostAuthHandler, OnPostAuthToolkit } from './lifecycle/on_post_auth';
|
||||
|
|
|
@ -50,7 +50,7 @@ describe('http service', () => {
|
|||
await root.shutdown();
|
||||
});
|
||||
describe('#isAuthenticated()', () => {
|
||||
it('returns true if has been authorized', async () => {
|
||||
it('returns true if has been authenticated', async () => {
|
||||
const { http } = await root.setup();
|
||||
const { registerAuth, createRouter, auth } = http;
|
||||
|
||||
|
@ -65,11 +65,11 @@ describe('http service', () => {
|
|||
await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: true });
|
||||
});
|
||||
|
||||
it('returns false if has not been authorized', async () => {
|
||||
it('returns false if has not been authenticated', async () => {
|
||||
const { http } = await root.setup();
|
||||
const { registerAuth, createRouter, auth } = http;
|
||||
|
||||
await registerAuth((req, res, toolkit) => toolkit.authenticated());
|
||||
registerAuth((req, res, toolkit) => toolkit.authenticated());
|
||||
|
||||
const router = createRouter('');
|
||||
router.get(
|
||||
|
@ -81,7 +81,7 @@ describe('http service', () => {
|
|||
await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: false });
|
||||
});
|
||||
|
||||
it('returns false if no authorization mechanism has been registered', async () => {
|
||||
it('returns false if no authentication mechanism has been registered', async () => {
|
||||
const { http } = await root.setup();
|
||||
const { createRouter, auth } = http;
|
||||
|
||||
|
@ -94,6 +94,37 @@ describe('http service', () => {
|
|||
await root.start();
|
||||
await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: false });
|
||||
});
|
||||
|
||||
it('returns true if authenticated on a route with "optional" auth', async () => {
|
||||
const { http } = await root.setup();
|
||||
const { createRouter, auth, registerAuth } = http;
|
||||
|
||||
registerAuth((req, res, toolkit) => toolkit.authenticated());
|
||||
const router = createRouter('');
|
||||
router.get(
|
||||
{ path: '/is-auth', validate: false, options: { authRequired: 'optional' } },
|
||||
(context, req, res) => res.ok({ body: { isAuthenticated: auth.isAuthenticated(req) } })
|
||||
);
|
||||
|
||||
await root.start();
|
||||
await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: true });
|
||||
});
|
||||
|
||||
it('returns false if not authenticated on a route with "optional" auth', async () => {
|
||||
const { http } = await root.setup();
|
||||
const { createRouter, auth, registerAuth } = http;
|
||||
|
||||
registerAuth((req, res, toolkit) => toolkit.notHandled());
|
||||
|
||||
const router = createRouter('');
|
||||
router.get(
|
||||
{ path: '/is-auth', validate: false, options: { authRequired: 'optional' } },
|
||||
(context, req, res) => res.ok({ body: { isAuthenticated: auth.isAuthenticated(req) } })
|
||||
);
|
||||
|
||||
await root.start();
|
||||
await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: false });
|
||||
});
|
||||
});
|
||||
describe('#get()', () => {
|
||||
it('returns authenticated status and allow associate auth state with request', async () => {
|
||||
|
|
|
@ -57,7 +57,7 @@ interface StorageData {
|
|||
}
|
||||
|
||||
describe('OnPreAuth', () => {
|
||||
it('supports registering request inceptors', async () => {
|
||||
it('supports registering a request interceptor', async () => {
|
||||
const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
|
||||
const router = createRouter('/');
|
||||
|
||||
|
@ -415,6 +415,23 @@ describe('Auth', () => {
|
|||
.expect(200, { content: 'ok' });
|
||||
});
|
||||
|
||||
it('blocks access to a resource if credentials are not provided', async () => {
|
||||
const { registerAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
|
||||
const router = createRouter('/');
|
||||
|
||||
router.get({ path: '/', validate: false }, (context, req, res) =>
|
||||
res.ok({ body: { content: 'ok' } })
|
||||
);
|
||||
registerAuth((req, res, t) => t.notHandled());
|
||||
await server.start();
|
||||
|
||||
const result = await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(401);
|
||||
|
||||
expect(result.body.message).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('enables auth for a route by default if registerAuth has been called', async () => {
|
||||
const { registerAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
|
||||
const router = createRouter('/');
|
||||
|
@ -492,11 +509,9 @@ describe('Auth', () => {
|
|||
|
||||
router.get({ path: '/', validate: false }, (context, req, res) => res.ok());
|
||||
const redirectTo = '/redirect-url';
|
||||
registerAuth((req, res) =>
|
||||
res.redirected({
|
||||
headers: {
|
||||
location: redirectTo,
|
||||
},
|
||||
registerAuth((req, res, t) =>
|
||||
t.redirected({
|
||||
location: redirectTo,
|
||||
})
|
||||
);
|
||||
await server.start();
|
||||
|
@ -507,6 +522,19 @@ describe('Auth', () => {
|
|||
expect(response.header.location).toBe(redirectTo);
|
||||
});
|
||||
|
||||
it('throws if redirection url is not provided', async () => {
|
||||
const { registerAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
|
||||
const router = createRouter('/');
|
||||
|
||||
router.get({ path: '/', validate: false }, (context, req, res) => res.ok());
|
||||
registerAuth((req, res, t) => t.redirected({} as any));
|
||||
await server.start();
|
||||
|
||||
await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(500);
|
||||
});
|
||||
|
||||
it(`doesn't expose internal error details`, async () => {
|
||||
const { registerAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
|
||||
const router = createRouter('/');
|
||||
|
@ -865,7 +893,7 @@ describe('Auth', () => {
|
|||
]
|
||||
`);
|
||||
});
|
||||
// eslint-disable-next-line
|
||||
|
||||
it(`doesn't share request object between interceptors`, async () => {
|
||||
const { registerOnPostAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
|
||||
const router = createRouter('/');
|
||||
|
|
|
@ -45,6 +45,89 @@ afterEach(async () => {
|
|||
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
describe('KibanaRequest', () => {
|
||||
describe('auth', () => {
|
||||
describe('isAuthenticated', () => {
|
||||
it('returns false if no auth interceptor was registered', async () => {
|
||||
const { server: innerServer, createRouter } = await server.setup(setupDeps);
|
||||
const router = createRouter('/');
|
||||
router.get(
|
||||
{ path: '/', validate: false, options: { authRequired: true } },
|
||||
(context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } })
|
||||
);
|
||||
await server.start();
|
||||
|
||||
await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(200, {
|
||||
isAuthenticated: false,
|
||||
});
|
||||
});
|
||||
it('returns false if not authenticated on a route with authRequired: "optional"', async () => {
|
||||
const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
|
||||
const router = createRouter('/');
|
||||
registerAuth((req, res, toolkit) => toolkit.notHandled());
|
||||
router.get(
|
||||
{ path: '/', validate: false, options: { authRequired: 'optional' } },
|
||||
(context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } })
|
||||
);
|
||||
await server.start();
|
||||
|
||||
await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(200, {
|
||||
isAuthenticated: false,
|
||||
});
|
||||
});
|
||||
it('returns false if redirected on a route with authRequired: "optional"', async () => {
|
||||
const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
|
||||
const router = createRouter('/');
|
||||
registerAuth((req, res, toolkit) => toolkit.redirected({ location: '/any' }));
|
||||
router.get(
|
||||
{ path: '/', validate: false, options: { authRequired: 'optional' } },
|
||||
(context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } })
|
||||
);
|
||||
await server.start();
|
||||
|
||||
await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(200, {
|
||||
isAuthenticated: false,
|
||||
});
|
||||
});
|
||||
it('returns true if authenticated on a route with authRequired: "optional"', async () => {
|
||||
const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
|
||||
const router = createRouter('/');
|
||||
registerAuth((req, res, toolkit) => toolkit.authenticated());
|
||||
router.get(
|
||||
{ path: '/', validate: false, options: { authRequired: 'optional' } },
|
||||
(context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } })
|
||||
);
|
||||
await server.start();
|
||||
|
||||
await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(200, {
|
||||
isAuthenticated: true,
|
||||
});
|
||||
});
|
||||
it('returns true if authenticated', async () => {
|
||||
const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
|
||||
const router = createRouter('/');
|
||||
registerAuth((req, res, toolkit) => toolkit.authenticated());
|
||||
router.get(
|
||||
{ path: '/', validate: false, options: { authRequired: true } },
|
||||
(context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } })
|
||||
);
|
||||
await server.start();
|
||||
|
||||
await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(200, {
|
||||
isAuthenticated: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('events', () => {
|
||||
describe('aborted$', () => {
|
||||
it('emits once and completes when request aborted', async done => {
|
||||
|
|
|
@ -46,6 +46,286 @@ afterEach(async () => {
|
|||
await server.stop();
|
||||
});
|
||||
|
||||
describe('Options', () => {
|
||||
describe('authRequired', () => {
|
||||
describe('optional', () => {
|
||||
it('User has access to a route if auth mechanism not registered', async () => {
|
||||
const { server: innerServer, createRouter, auth } = await server.setup(setupDeps);
|
||||
const router = createRouter('/');
|
||||
|
||||
router.get(
|
||||
{ path: '/', validate: false, options: { authRequired: 'optional' } },
|
||||
(context, req, res) =>
|
||||
res.ok({
|
||||
body: {
|
||||
httpAuthIsAuthenticated: auth.isAuthenticated(req),
|
||||
requestIsAuthenticated: req.auth.isAuthenticated,
|
||||
},
|
||||
})
|
||||
);
|
||||
await server.start();
|
||||
|
||||
await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(200, {
|
||||
httpAuthIsAuthenticated: false,
|
||||
requestIsAuthenticated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('Authenticated user has access to a route', async () => {
|
||||
const { server: innerServer, createRouter, registerAuth, auth } = await server.setup(
|
||||
setupDeps
|
||||
);
|
||||
const router = createRouter('/');
|
||||
|
||||
registerAuth((req, res, toolkit) => {
|
||||
return toolkit.authenticated();
|
||||
});
|
||||
router.get(
|
||||
{ path: '/', validate: false, options: { authRequired: 'optional' } },
|
||||
(context, req, res) =>
|
||||
res.ok({
|
||||
body: {
|
||||
httpAuthIsAuthenticated: auth.isAuthenticated(req),
|
||||
requestIsAuthenticated: req.auth.isAuthenticated,
|
||||
},
|
||||
})
|
||||
);
|
||||
await server.start();
|
||||
|
||||
await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(200, {
|
||||
httpAuthIsAuthenticated: true,
|
||||
requestIsAuthenticated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('User with no credentials can access a route', async () => {
|
||||
const { server: innerServer, createRouter, registerAuth, auth } = await server.setup(
|
||||
setupDeps
|
||||
);
|
||||
const router = createRouter('/');
|
||||
|
||||
registerAuth((req, res, toolkit) => toolkit.notHandled());
|
||||
|
||||
router.get(
|
||||
{ path: '/', validate: false, options: { authRequired: 'optional' } },
|
||||
(context, req, res) =>
|
||||
res.ok({
|
||||
body: {
|
||||
httpAuthIsAuthenticated: auth.isAuthenticated(req),
|
||||
requestIsAuthenticated: req.auth.isAuthenticated,
|
||||
},
|
||||
})
|
||||
);
|
||||
await server.start();
|
||||
|
||||
await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(200, {
|
||||
httpAuthIsAuthenticated: false,
|
||||
requestIsAuthenticated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('User with invalid credentials cannot access a route', async () => {
|
||||
const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
|
||||
const router = createRouter('/');
|
||||
|
||||
registerAuth((req, res, toolkit) => res.unauthorized());
|
||||
|
||||
router.get(
|
||||
{ path: '/', validate: false, options: { authRequired: 'optional' } },
|
||||
(context, req, res) => res.ok({ body: 'ok' })
|
||||
);
|
||||
await server.start();
|
||||
|
||||
await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('does not redirect user and allows access to a resource', async () => {
|
||||
const { server: innerServer, createRouter, registerAuth, auth } = await server.setup(
|
||||
setupDeps
|
||||
);
|
||||
const router = createRouter('/');
|
||||
|
||||
registerAuth((req, res, toolkit) =>
|
||||
toolkit.redirected({
|
||||
location: '/redirect-to',
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
{ path: '/', validate: false, options: { authRequired: 'optional' } },
|
||||
(context, req, res) =>
|
||||
res.ok({
|
||||
body: {
|
||||
httpAuthIsAuthenticated: auth.isAuthenticated(req),
|
||||
requestIsAuthenticated: req.auth.isAuthenticated,
|
||||
},
|
||||
})
|
||||
);
|
||||
await server.start();
|
||||
|
||||
await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(200, {
|
||||
httpAuthIsAuthenticated: false,
|
||||
requestIsAuthenticated: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('true', () => {
|
||||
it('User has access to a route if auth interceptor is not registered', async () => {
|
||||
const { server: innerServer, createRouter, auth } = await server.setup(setupDeps);
|
||||
const router = createRouter('/');
|
||||
|
||||
router.get(
|
||||
{ path: '/', validate: false, options: { authRequired: true } },
|
||||
(context, req, res) =>
|
||||
res.ok({
|
||||
body: {
|
||||
httpAuthIsAuthenticated: auth.isAuthenticated(req),
|
||||
requestIsAuthenticated: req.auth.isAuthenticated,
|
||||
},
|
||||
})
|
||||
);
|
||||
await server.start();
|
||||
|
||||
await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(200, {
|
||||
httpAuthIsAuthenticated: false,
|
||||
requestIsAuthenticated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('Authenticated user has access to a route', async () => {
|
||||
const { server: innerServer, createRouter, registerAuth, auth } = await server.setup(
|
||||
setupDeps
|
||||
);
|
||||
const router = createRouter('/');
|
||||
|
||||
registerAuth((req, res, toolkit) => {
|
||||
return toolkit.authenticated();
|
||||
});
|
||||
router.get(
|
||||
{ path: '/', validate: false, options: { authRequired: true } },
|
||||
(context, req, res) =>
|
||||
res.ok({
|
||||
body: {
|
||||
httpAuthIsAuthenticated: auth.isAuthenticated(req),
|
||||
requestIsAuthenticated: req.auth.isAuthenticated,
|
||||
},
|
||||
})
|
||||
);
|
||||
await server.start();
|
||||
|
||||
await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(200, {
|
||||
httpAuthIsAuthenticated: true,
|
||||
requestIsAuthenticated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('User with no credentials cannot access a route', async () => {
|
||||
const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
|
||||
const router = createRouter('/');
|
||||
|
||||
registerAuth((req, res, toolkit) => toolkit.notHandled());
|
||||
router.get(
|
||||
{ path: '/', validate: false, options: { authRequired: true } },
|
||||
(context, req, res) => res.ok({ body: 'ok' })
|
||||
);
|
||||
await server.start();
|
||||
|
||||
await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('User with invalid credentials cannot access a route', async () => {
|
||||
const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
|
||||
const router = createRouter('/');
|
||||
|
||||
registerAuth((req, res, toolkit) => res.unauthorized());
|
||||
|
||||
router.get(
|
||||
{ path: '/', validate: false, options: { authRequired: true } },
|
||||
(context, req, res) => res.ok({ body: 'ok' })
|
||||
);
|
||||
await server.start();
|
||||
|
||||
await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('allows redirecting an user', async () => {
|
||||
const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
|
||||
const router = createRouter('/');
|
||||
const redirectUrl = '/redirect-to';
|
||||
|
||||
registerAuth((req, res, toolkit) =>
|
||||
toolkit.redirected({
|
||||
location: redirectUrl,
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
{ path: '/', validate: false, options: { authRequired: true } },
|
||||
(context, req, res) => res.ok({ body: 'ok' })
|
||||
);
|
||||
await server.start();
|
||||
|
||||
const result = await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(302);
|
||||
|
||||
expect(result.header.location).toBe(redirectUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe('false', () => {
|
||||
it('does not try to authenticate a user', async () => {
|
||||
const { server: innerServer, createRouter, registerAuth, auth } = await server.setup(
|
||||
setupDeps
|
||||
);
|
||||
const router = createRouter('/');
|
||||
|
||||
const authHook = jest.fn();
|
||||
registerAuth(authHook);
|
||||
router.get(
|
||||
{ path: '/', validate: false, options: { authRequired: false } },
|
||||
(context, req, res) =>
|
||||
res.ok({
|
||||
body: {
|
||||
httpAuthIsAuthenticated: auth.isAuthenticated(req),
|
||||
requestIsAuthenticated: req.auth.isAuthenticated,
|
||||
},
|
||||
})
|
||||
);
|
||||
await server.start();
|
||||
|
||||
await supertest(innerServer.listener)
|
||||
.get('/')
|
||||
.expect(200, {
|
||||
httpAuthIsAuthenticated: false,
|
||||
requestIsAuthenticated: false,
|
||||
});
|
||||
|
||||
expect(authHook).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Handler', () => {
|
||||
it("Doesn't expose error details if handler throws", async () => {
|
||||
const { server: innerServer, createRouter } = await server.setup(setupDeps);
|
||||
|
|
|
@ -25,11 +25,14 @@ import {
|
|||
lifecycleResponseFactory,
|
||||
LifecycleResponseFactory,
|
||||
isKibanaResponse,
|
||||
ResponseHeaders,
|
||||
} from '../router';
|
||||
|
||||
/** @public */
|
||||
export enum AuthResultType {
|
||||
authenticated = 'authenticated',
|
||||
notHandled = 'notHandled',
|
||||
redirected = 'redirected',
|
||||
}
|
||||
|
||||
/** @public */
|
||||
|
@ -38,10 +41,20 @@ export interface Authenticated extends AuthResultParams {
|
|||
}
|
||||
|
||||
/** @public */
|
||||
export type AuthResult = Authenticated;
|
||||
export interface AuthNotHandled {
|
||||
type: AuthResultType.notHandled;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface AuthRedirected extends AuthRedirectedParams {
|
||||
type: AuthResultType.redirected;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export type AuthResult = Authenticated | AuthNotHandled | AuthRedirected;
|
||||
|
||||
const authResult = {
|
||||
authenticated(data: Partial<AuthResultParams> = {}): AuthResult {
|
||||
authenticated(data: AuthResultParams = {}): AuthResult {
|
||||
return {
|
||||
type: AuthResultType.authenticated,
|
||||
state: data.state,
|
||||
|
@ -49,8 +62,25 @@ const authResult = {
|
|||
responseHeaders: data.responseHeaders,
|
||||
};
|
||||
},
|
||||
notHandled(): AuthResult {
|
||||
return {
|
||||
type: AuthResultType.notHandled,
|
||||
};
|
||||
},
|
||||
redirected(headers: { location: string } & ResponseHeaders): AuthResult {
|
||||
return {
|
||||
type: AuthResultType.redirected,
|
||||
headers,
|
||||
};
|
||||
},
|
||||
isAuthenticated(result: AuthResult): result is Authenticated {
|
||||
return result && result.type === AuthResultType.authenticated;
|
||||
return result?.type === AuthResultType.authenticated;
|
||||
},
|
||||
isNotHandled(result: AuthResult): result is AuthNotHandled {
|
||||
return result?.type === AuthResultType.notHandled;
|
||||
},
|
||||
isRedirected(result: AuthResult): result is AuthRedirected {
|
||||
return result?.type === AuthResultType.redirected;
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -62,7 +92,7 @@ const authResult = {
|
|||
export type AuthHeaders = Record<string, string | string[]>;
|
||||
|
||||
/**
|
||||
* Result of an incoming request authentication.
|
||||
* Result of successful authentication.
|
||||
* @public
|
||||
*/
|
||||
export interface AuthResultParams {
|
||||
|
@ -82,6 +112,18 @@ export interface AuthResultParams {
|
|||
responseHeaders?: AuthHeaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of auth redirection.
|
||||
* @public
|
||||
*/
|
||||
export interface AuthRedirectedParams {
|
||||
/**
|
||||
* Headers to attach for auth redirect.
|
||||
* Must include "location" header
|
||||
*/
|
||||
headers: { location: string } & ResponseHeaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
* A tool set defining an outcome of Auth interceptor for incoming request.
|
||||
|
@ -89,10 +131,23 @@ export interface AuthResultParams {
|
|||
export interface AuthToolkit {
|
||||
/** Authentication is successful with given credentials, allow request to pass through */
|
||||
authenticated: (data?: AuthResultParams) => AuthResult;
|
||||
/**
|
||||
* User has no credentials.
|
||||
* Allows user to access a resource when authRequired: 'optional'
|
||||
* Rejects a request when authRequired: true
|
||||
* */
|
||||
notHandled: () => AuthResult;
|
||||
/**
|
||||
* Redirects user to another location to complete authentication when authRequired: true
|
||||
* Allows user to access a resource without redirection when authRequired: 'optional'
|
||||
* */
|
||||
redirected: (headers: { location: string } & ResponseHeaders) => AuthResult;
|
||||
}
|
||||
|
||||
const toolkit: AuthToolkit = {
|
||||
authenticated: authResult.authenticated,
|
||||
notHandled: authResult.notHandled,
|
||||
redirected: authResult.redirected,
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -109,30 +164,51 @@ export type AuthenticationHandler = (
|
|||
export function adoptToHapiAuthFormat(
|
||||
fn: AuthenticationHandler,
|
||||
log: Logger,
|
||||
onSuccess: (req: Request, data: AuthResultParams) => void = () => undefined
|
||||
onAuth: (request: Request, data: AuthResultParams) => void = () => undefined
|
||||
) {
|
||||
return async function interceptAuth(
|
||||
request: Request,
|
||||
responseToolkit: ResponseToolkit
|
||||
): Promise<Lifecycle.ReturnValue> {
|
||||
const hapiResponseAdapter = new HapiResponseAdapter(responseToolkit);
|
||||
const kibanaRequest = KibanaRequest.from(request, undefined, false);
|
||||
|
||||
try {
|
||||
const result = await fn(
|
||||
KibanaRequest.from(request, undefined, false),
|
||||
lifecycleResponseFactory,
|
||||
toolkit
|
||||
);
|
||||
const result = await fn(kibanaRequest, lifecycleResponseFactory, toolkit);
|
||||
|
||||
if (isKibanaResponse(result)) {
|
||||
return hapiResponseAdapter.handle(result);
|
||||
}
|
||||
|
||||
if (authResult.isAuthenticated(result)) {
|
||||
onSuccess(request, {
|
||||
onAuth(request, {
|
||||
state: result.state,
|
||||
requestHeaders: result.requestHeaders,
|
||||
responseHeaders: result.responseHeaders,
|
||||
});
|
||||
return responseToolkit.authenticated({ credentials: result.state || {} });
|
||||
}
|
||||
|
||||
if (authResult.isRedirected(result)) {
|
||||
// we cannot redirect a user when resources with optional auth requested
|
||||
if (kibanaRequest.route.options.authRequired === 'optional') {
|
||||
return responseToolkit.continue;
|
||||
}
|
||||
|
||||
return hapiResponseAdapter.handle(
|
||||
lifecycleResponseFactory.redirected({
|
||||
// hapi doesn't accept string[] as a valid header
|
||||
headers: result.headers as any,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (authResult.isNotHandled(result)) {
|
||||
if (kibanaRequest.route.options.authRequired === 'optional') {
|
||||
return responseToolkit.continue;
|
||||
}
|
||||
return hapiResponseAdapter.handle(lifecycleResponseFactory.unauthorized());
|
||||
}
|
||||
throw new Error(
|
||||
`Unexpected result from Authenticate. Expected AuthResult or KibanaResponse, but given: ${result}.`
|
||||
);
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { RouteOptions } from 'hapi';
|
||||
import { KibanaRequest } from './request';
|
||||
import { httpServerMock } from '../http_server.mocks';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
@ -117,6 +118,106 @@ describe('KibanaRequest', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('route.options.authRequired property', () => {
|
||||
it('handles required auth: undefined', () => {
|
||||
const auth: RouteOptions['auth'] = undefined;
|
||||
const request = httpServerMock.createRawRequest({
|
||||
route: {
|
||||
settings: {
|
||||
auth,
|
||||
},
|
||||
},
|
||||
});
|
||||
const kibanaRequest = KibanaRequest.from(request);
|
||||
|
||||
expect(kibanaRequest.route.options.authRequired).toBe(true);
|
||||
});
|
||||
it('handles required auth: false', () => {
|
||||
const auth: RouteOptions['auth'] = false;
|
||||
const request = httpServerMock.createRawRequest({
|
||||
route: {
|
||||
settings: {
|
||||
auth,
|
||||
},
|
||||
},
|
||||
});
|
||||
const kibanaRequest = KibanaRequest.from(request);
|
||||
|
||||
expect(kibanaRequest.route.options.authRequired).toBe(false);
|
||||
});
|
||||
it('handles required auth: { mode: "required" }', () => {
|
||||
const auth: RouteOptions['auth'] = { mode: 'required' };
|
||||
const request = httpServerMock.createRawRequest({
|
||||
route: {
|
||||
settings: {
|
||||
auth,
|
||||
},
|
||||
},
|
||||
});
|
||||
const kibanaRequest = KibanaRequest.from(request);
|
||||
|
||||
expect(kibanaRequest.route.options.authRequired).toBe(true);
|
||||
});
|
||||
|
||||
it('handles required auth: { mode: "optional" }', () => {
|
||||
const auth: RouteOptions['auth'] = { mode: 'optional' };
|
||||
const request = httpServerMock.createRawRequest({
|
||||
route: {
|
||||
settings: {
|
||||
auth,
|
||||
},
|
||||
},
|
||||
});
|
||||
const kibanaRequest = KibanaRequest.from(request);
|
||||
|
||||
expect(kibanaRequest.route.options.authRequired).toBe('optional');
|
||||
});
|
||||
|
||||
it('handles required auth: { mode: "try" } as "optional"', () => {
|
||||
const auth: RouteOptions['auth'] = { mode: 'try' };
|
||||
const request = httpServerMock.createRawRequest({
|
||||
route: {
|
||||
settings: {
|
||||
auth,
|
||||
},
|
||||
},
|
||||
});
|
||||
const kibanaRequest = KibanaRequest.from(request);
|
||||
|
||||
expect(kibanaRequest.route.options.authRequired).toBe('optional');
|
||||
});
|
||||
|
||||
it('throws on auth: strategy name', () => {
|
||||
const auth: RouteOptions['auth'] = 'session';
|
||||
const request = httpServerMock.createRawRequest({
|
||||
route: {
|
||||
settings: {
|
||||
auth,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => KibanaRequest.from(request)).toThrowErrorMatchingInlineSnapshot(
|
||||
`"unexpected authentication options: \\"session\\" for route: /"`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on auth: { mode: unexpected mode }', () => {
|
||||
const auth: RouteOptions['auth'] = { mode: undefined };
|
||||
const request = httpServerMock.createRawRequest({
|
||||
route: {
|
||||
settings: {
|
||||
auth,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => KibanaRequest.from(request)).toThrowErrorMatchingInlineSnapshot(
|
||||
`"unexpected authentication options: {} for route: /"`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RouteSchema type inferring', () => {
|
||||
it('should work with config-schema', () => {
|
||||
const body = Buffer.from('body!');
|
||||
|
|
|
@ -143,6 +143,10 @@ export class KibanaRequest<
|
|||
public readonly socket: IKibanaSocket;
|
||||
/** Request events {@link KibanaRequestEvents} */
|
||||
public readonly events: KibanaRequestEvents;
|
||||
public readonly auth: {
|
||||
/* true if the request has been successfully authenticated, otherwise false. */
|
||||
isAuthenticated: boolean;
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
protected readonly [requestSymbol]: Request;
|
||||
|
@ -172,6 +176,11 @@ export class KibanaRequest<
|
|||
this.route = deepFreeze(this.getRouteInfo(request));
|
||||
this.socket = new KibanaSocket(request.raw.req.socket);
|
||||
this.events = this.getEvents(request);
|
||||
|
||||
this.auth = {
|
||||
// missing in fakeRequests, so we cast to false
|
||||
isAuthenticated: Boolean(request.auth?.isAuthenticated),
|
||||
};
|
||||
}
|
||||
|
||||
private getEvents(request: Request): KibanaRequestEvents {
|
||||
|
@ -189,7 +198,7 @@ export class KibanaRequest<
|
|||
const { parse, maxBytes, allow, output } = request.route.settings.payload || {};
|
||||
|
||||
const options = ({
|
||||
authRequired: request.route.settings.auth !== false,
|
||||
authRequired: this.getAuthRequired(request),
|
||||
// some places in LP call KibanaRequest.from(request) manually. remove fallback to true before v8
|
||||
xsrfRequired: (request.route.settings.app as KibanaRouteState)?.xsrfRequired ?? true,
|
||||
tags: request.route.settings.tags || [],
|
||||
|
@ -209,6 +218,31 @@ export class KibanaRequest<
|
|||
options,
|
||||
};
|
||||
}
|
||||
|
||||
private getAuthRequired(request: Request): boolean | 'optional' {
|
||||
const authOptions = request.route.settings.auth;
|
||||
if (typeof authOptions === 'object') {
|
||||
// 'try' is used in the legacy platform
|
||||
if (authOptions.mode === 'optional' || authOptions.mode === 'try') {
|
||||
return 'optional';
|
||||
}
|
||||
if (authOptions.mode === 'required') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// legacy platform routes
|
||||
if (authOptions === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (authOptions === false) return false;
|
||||
throw new Error(
|
||||
`unexpected authentication options: ${JSON.stringify(authOptions)} for route: ${
|
||||
this.url.href
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -116,13 +116,15 @@ export interface RouteConfigOptionsBody {
|
|||
*/
|
||||
export interface RouteConfigOptions<Method extends RouteMethod> {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
authRequired?: boolean;
|
||||
authRequired?: boolean | 'optional';
|
||||
|
||||
/**
|
||||
* Defines xsrf protection requirements for a route:
|
||||
|
|
|
@ -100,9 +100,12 @@ export {
|
|||
AuthResultParams,
|
||||
AuthStatus,
|
||||
AuthToolkit,
|
||||
AuthRedirected,
|
||||
AuthRedirectedParams,
|
||||
AuthResult,
|
||||
AuthResultType,
|
||||
Authenticated,
|
||||
AuthNotHandled,
|
||||
BasePath,
|
||||
IBasePath,
|
||||
CustomHttpResponseOptions,
|
||||
|
|
|
@ -419,7 +419,26 @@ export type AuthenticationHandler = (request: KibanaRequest, response: Lifecycle
|
|||
export type AuthHeaders = Record<string, string | string[]>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type AuthResult = Authenticated;
|
||||
export interface AuthNotHandled {
|
||||
// (undocumented)
|
||||
type: AuthResultType.notHandled;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface AuthRedirected extends AuthRedirectedParams {
|
||||
// (undocumented)
|
||||
type: AuthResultType.redirected;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface AuthRedirectedParams {
|
||||
headers: {
|
||||
location: string;
|
||||
} & ResponseHeaders;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type AuthResult = Authenticated | AuthNotHandled | AuthRedirected;
|
||||
|
||||
// @public
|
||||
export interface AuthResultParams {
|
||||
|
@ -431,7 +450,11 @@ export interface AuthResultParams {
|
|||
// @public (undocumented)
|
||||
export enum AuthResultType {
|
||||
// (undocumented)
|
||||
authenticated = "authenticated"
|
||||
authenticated = "authenticated",
|
||||
// (undocumented)
|
||||
notHandled = "notHandled",
|
||||
// (undocumented)
|
||||
redirected = "redirected"
|
||||
}
|
||||
|
||||
// @public
|
||||
|
@ -444,6 +467,10 @@ export enum AuthStatus {
|
|||
// @public
|
||||
export interface AuthToolkit {
|
||||
authenticated: (data?: AuthResultParams) => AuthResult;
|
||||
notHandled: () => AuthResult;
|
||||
redirected: (headers: {
|
||||
location: string;
|
||||
} & ResponseHeaders) => AuthResult;
|
||||
}
|
||||
|
||||
// @public
|
||||
|
@ -970,6 +997,10 @@ export class KibanaRequest<Params = unknown, Query = unknown, Body = unknown, Me
|
|||
protected readonly [requestSymbol]: Request;
|
||||
constructor(request: Request, params: Params, query: Query, body: Body, withoutSecretHeaders: boolean);
|
||||
// (undocumented)
|
||||
readonly auth: {
|
||||
isAuthenticated: boolean;
|
||||
};
|
||||
// (undocumented)
|
||||
readonly body: Body;
|
||||
readonly events: KibanaRequestEvents;
|
||||
// Warning: (ae-forgotten-export) The symbol "RouteValidator" needs to be exported by the entry point index.d.ts
|
||||
|
@ -1470,7 +1501,7 @@ export interface RouteConfig<P, Q, B, Method extends RouteMethod> {
|
|||
|
||||
// @public
|
||||
export interface RouteConfigOptions<Method extends RouteMethod> {
|
||||
authRequired?: boolean;
|
||||
authRequired?: boolean | 'optional';
|
||||
body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody;
|
||||
tags?: readonly string[];
|
||||
xsrfRequired?: Method extends 'get' ? never : boolean;
|
||||
|
|
|
@ -133,7 +133,7 @@ describe('setupAuthentication()', () => {
|
|||
|
||||
expect(mockAuthToolkit.authenticated).toHaveBeenCalledTimes(1);
|
||||
expect(mockAuthToolkit.authenticated).toHaveBeenCalledWith();
|
||||
expect(mockResponse.redirected).not.toHaveBeenCalled();
|
||||
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
|
||||
expect(mockResponse.internalError).not.toHaveBeenCalled();
|
||||
|
||||
expect(authenticate).not.toHaveBeenCalled();
|
||||
|
@ -156,7 +156,7 @@ describe('setupAuthentication()', () => {
|
|||
state: mockUser,
|
||||
requestHeaders: mockAuthHeaders,
|
||||
});
|
||||
expect(mockResponse.redirected).not.toHaveBeenCalled();
|
||||
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
|
||||
expect(mockResponse.internalError).not.toHaveBeenCalled();
|
||||
|
||||
expect(authenticate).toHaveBeenCalledTimes(1);
|
||||
|
@ -185,7 +185,7 @@ describe('setupAuthentication()', () => {
|
|||
requestHeaders: mockAuthHeaders,
|
||||
responseHeaders: mockAuthResponseHeaders,
|
||||
});
|
||||
expect(mockResponse.redirected).not.toHaveBeenCalled();
|
||||
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
|
||||
expect(mockResponse.internalError).not.toHaveBeenCalled();
|
||||
|
||||
expect(authenticate).toHaveBeenCalledTimes(1);
|
||||
|
@ -198,9 +198,9 @@ describe('setupAuthentication()', () => {
|
|||
|
||||
await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit);
|
||||
|
||||
expect(mockResponse.redirected).toHaveBeenCalledTimes(1);
|
||||
expect(mockResponse.redirected).toHaveBeenCalledWith({
|
||||
headers: { location: '/some/url' },
|
||||
expect(mockAuthToolkit.redirected).toHaveBeenCalledTimes(1);
|
||||
expect(mockAuthToolkit.redirected).toHaveBeenCalledWith({
|
||||
location: '/some/url',
|
||||
});
|
||||
expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled();
|
||||
expect(mockResponse.internalError).not.toHaveBeenCalled();
|
||||
|
@ -217,7 +217,7 @@ describe('setupAuthentication()', () => {
|
|||
expect(error).toBeUndefined();
|
||||
|
||||
expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled();
|
||||
expect(mockResponse.redirected).not.toHaveBeenCalled();
|
||||
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
|
||||
expect(loggingServiceMock.collect(mockSetupAuthenticationParams.loggers).error)
|
||||
.toMatchInlineSnapshot(`
|
||||
Array [
|
||||
|
@ -240,7 +240,7 @@ describe('setupAuthentication()', () => {
|
|||
expect(response.body).toBe(esError);
|
||||
|
||||
expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled();
|
||||
expect(mockResponse.redirected).not.toHaveBeenCalled();
|
||||
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes `WWW-Authenticate` header if `authenticate` fails to authenticate user and provides challenges', async () => {
|
||||
|
@ -265,22 +265,19 @@ describe('setupAuthentication()', () => {
|
|||
expect(options!.headers).toEqual({ 'WWW-Authenticate': 'Negotiate' });
|
||||
|
||||
expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled();
|
||||
expect(mockResponse.redirected).not.toHaveBeenCalled();
|
||||
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns `unauthorized` when authentication can not be handled', async () => {
|
||||
it('returns `notHandled` when authentication can not be handled', async () => {
|
||||
const mockResponse = httpServerMock.createLifecycleResponseFactory();
|
||||
authenticate.mockResolvedValue(AuthenticationResult.notHandled());
|
||||
|
||||
await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit);
|
||||
|
||||
expect(mockResponse.unauthorized).toHaveBeenCalledTimes(1);
|
||||
const [[response]] = mockResponse.unauthorized.mock.calls;
|
||||
|
||||
expect(response!.body).toBeUndefined();
|
||||
expect(mockAuthToolkit.notHandled).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled();
|
||||
expect(mockResponse.redirected).not.toHaveBeenCalled();
|
||||
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -139,10 +139,8 @@ export async function setupAuthentication({
|
|||
// authentication (username and password) or arbitrary external page managed by 3rd party
|
||||
// Identity Provider for SSO authentication mechanisms. Authentication provider is the one who
|
||||
// decides what location user should be redirected to.
|
||||
return response.redirected({
|
||||
headers: {
|
||||
location: authenticationResult.redirectURL!,
|
||||
},
|
||||
return t.redirected({
|
||||
location: authenticationResult.redirectURL!,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -165,9 +163,7 @@ export async function setupAuthentication({
|
|||
}
|
||||
|
||||
authLogger.debug('Could not handle authentication attempt');
|
||||
return response.unauthorized({
|
||||
headers: authenticationResult.authResponseHeaders,
|
||||
});
|
||||
return t.notHandled();
|
||||
});
|
||||
|
||||
authLogger.debug('Successfully registered core authentication handler.');
|
||||
|
|
|
@ -112,8 +112,7 @@ export function setupAuthorization({
|
|||
authz
|
||||
);
|
||||
|
||||
// if we're an anonymous route, we disable all ui capabilities
|
||||
if (request.route.options.authRequired === false) {
|
||||
if (!request.auth.isAuthenticated) {
|
||||
return disableUICapabilities.all(capabilities);
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue