;
@@ -65,11 +66,13 @@ function createKibanaRequestMock({
routeAuthRequired,
validation = {},
kibanaRouteState = { xsrfRequired: true },
+ auth = { isAuthenticated: true },
}: RequestFixtureOptions
= {}) {
const queryString = stringify(query, { sort: false });
return KibanaRequest.from
(
createRawRequestMock({
+ auth,
headers,
params,
query,
@@ -113,6 +116,9 @@ function createRawRequestMock(customization: DeepPartial = {}) {
{},
{
app: { xsrfRequired: true } as any,
+ auth: {
+ isAuthenticated: true,
+ },
headers: {},
path: '/',
route: { settings: {} },
diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts
index cffdffab0d0cf..f898ed0ea1a99 100644
--- a/src/core/server/http/http_server.ts
+++ b/src/core/server/http/http_server.ts
@@ -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['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;
diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts
index 30032ff5da796..442bc93190d86 100644
--- a/src/core/server/http/http_service.mock.ts
+++ b/src/core/server/http/http_service.mock.ts
@@ -115,6 +115,8 @@ const createOnPostAuthToolkitMock = (): jest.Mocked => ({
const createAuthToolkitMock = (): jest.Mocked => ({
authenticated: jest.fn(),
+ notHandled: jest.fn(),
+ redirected: jest.fn(),
});
const createOnPreResponseToolkitMock = (): jest.Mocked => ({
diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts
index 8f4c02680f8a3..a75eb04fa0120 100644
--- a/src/core/server/http/index.ts
+++ b/src/core/server/http/index.ts
@@ -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';
diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts
index 425d8cac1893e..7b1630a7de0be 100644
--- a/src/core/server/http/integration_tests/core_services.test.ts
+++ b/src/core/server/http/integration_tests/core_services.test.ts
@@ -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 () => {
diff --git a/src/core/server/http/integration_tests/lifecycle.test.ts b/src/core/server/http/integration_tests/lifecycle.test.ts
index 6dc7ece1359df..0f0d54e88daca 100644
--- a/src/core/server/http/integration_tests/lifecycle.test.ts
+++ b/src/core/server/http/integration_tests/lifecycle.test.ts
@@ -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('/');
diff --git a/src/core/server/http/integration_tests/request.test.ts b/src/core/server/http/integration_tests/request.test.ts
index bc1bbc881315a..85270174fbc04 100644
--- a/src/core/server/http/integration_tests/request.test.ts
+++ b/src/core/server/http/integration_tests/request.test.ts
@@ -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 => {
diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts
index a1523781010d4..ee5b0c50acafb 100644
--- a/src/core/server/http/integration_tests/router.test.ts
+++ b/src/core/server/http/integration_tests/router.test.ts
@@ -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);
diff --git a/src/core/server/http/lifecycle/auth.ts b/src/core/server/http/lifecycle/auth.ts
index 036ab0211c2ff..2eaf7e0f6fbfe 100644
--- a/src/core/server/http/lifecycle/auth.ts
+++ b/src/core/server/http/lifecycle/auth.ts
@@ -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 = {}): 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;
/**
- * 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 {
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}.`
);
diff --git a/src/core/server/http/router/request.test.ts b/src/core/server/http/router/request.test.ts
index 032027c234485..fb999dc60e39c 100644
--- a/src/core/server/http/router/request.test.ts
+++ b/src/core/server/http/router/request.test.ts
@@ -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!');
diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts
index bb2db6367f701..f266677c1a172 100644
--- a/src/core/server/http/router/request.ts
+++ b/src/core/server/http/router/request.ts
@@ -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
+ }`
+ );
+ }
}
/**
diff --git a/src/core/server/http/router/route.ts b/src/core/server/http/router/route.ts
index d1458ef4ad063..bb0a8616e7222 100644
--- a/src/core/server/http/router/route.ts
+++ b/src/core/server/http/router/route.ts
@@ -116,13 +116,15 @@ export interface RouteConfigOptionsBody {
*/
export interface RouteConfigOptions {
/**
- * 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:
diff --git a/src/core/server/index.ts b/src/core/server/index.ts
index 8e481171116fa..80eabe778ece3 100644
--- a/src/core/server/index.ts
+++ b/src/core/server/index.ts
@@ -100,9 +100,12 @@ export {
AuthResultParams,
AuthStatus,
AuthToolkit,
+ AuthRedirected,
+ AuthRedirectedParams,
AuthResult,
AuthResultType,
Authenticated,
+ AuthNotHandled,
BasePath,
IBasePath,
CustomHttpResponseOptions,
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index 30695df33345a..f7afe7a6a290a 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -419,7 +419,26 @@ export type AuthenticationHandler = (request: KibanaRequest, response: Lifecycle
export type AuthHeaders = Record;
// @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 {
// @public
export interface RouteConfigOptions {
- authRequired?: boolean;
+ authRequired?: boolean | 'optional';
body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody;
tags?: readonly string[];
xsrfRequired?: Method extends 'get' ? never : boolean;
diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts
index 35261d4a8711a..973c322285db1 100644
--- a/x-pack/plugins/security/server/authentication/index.test.ts
+++ b/x-pack/plugins/security/server/authentication/index.test.ts
@@ -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();
});
});
diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts
index 72b6da72fc25a..5109c174344d8 100644
--- a/x-pack/plugins/security/server/authentication/index.ts
+++ b/x-pack/plugins/security/server/authentication/index.ts
@@ -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.');
diff --git a/x-pack/plugins/security/server/authorization/index.ts b/x-pack/plugins/security/server/authorization/index.ts
index 00a50dd5b8821..4cbc76ecb6be4 100644
--- a/x-pack/plugins/security/server/authorization/index.ts
+++ b/x-pack/plugins/security/server/authorization/index.ts
@@ -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);
}