Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add an optional authentication mode for HTTP resources #58589

Merged
merged 33 commits into from
Mar 7, 2020
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
89a1eff
add authRequred: 'optional'
mshustov Feb 26, 2020
0f3a915
expose auth status via request context
mshustov Feb 26, 2020
05809a4
update security plugin to use notHandled auth outcome
mshustov Feb 26, 2020
fc94baa
capabilities service uses optional auth
mshustov Feb 26, 2020
de22f7e
update tests
mshustov Feb 26, 2020
97ac4a3
Merge branch 'master' into issue-41959-optional-auth
mshustov Feb 26, 2020
006edef
attach security headers only to unauthorised response
mshustov Feb 26, 2020
529eeee
add isAuthenticated tests for 'optional' auth mode
mshustov Feb 26, 2020
4c9f0bc
security plugin relies on http.auth.isAuthenticated to calc capabilities
mshustov Feb 26, 2020
a2f2546
generate docs
mshustov Feb 26, 2020
9bec57f
reword test suit names
mshustov Feb 26, 2020
6c4de35
Merge branch 'master' into issue-41959-optional-auth
mshustov Mar 4, 2020
82835fc
update tests
mshustov Mar 4, 2020
526922b
Merge branch 'master' into issue-41959-optional-auth
mshustov Mar 4, 2020
46b5232
update test checking isAuth on optional auth path
mshustov Mar 4, 2020
2c2cbe1
address Oleg comments
mshustov Mar 4, 2020
0801b08
add test for auth: try
mshustov Mar 4, 2020
64ff1ac
fix
mshustov Mar 4, 2020
afa6d4b
pass isAuthenticted as boolean via context
mshustov Mar 4, 2020
cc3d95c
remove response header from notHandled
mshustov Mar 4, 2020
e96158b
update docs
mshustov Mar 4, 2020
9750ce1
add redirected for auth interceptor
mshustov Mar 5, 2020
5efb16a
security plugin uses t.redirected to be compat with auth: optional
mshustov Mar 5, 2020
c5cb1fd
update docs
mshustov Mar 5, 2020
fcb4994
require location header in the interface
mshustov Mar 5, 2020
f1ecba3
address comments #1
mshustov Mar 6, 2020
f6dad7f
declare isAuthenticated on KibanaRequest
mshustov Mar 6, 2020
d1901a8
remove auth.isAuthenticated from scope
mshustov Mar 6, 2020
0275e25
update docs
mshustov Mar 6, 2020
8b2a817
Merge branch 'master' into issue-41959-optional-auth
mshustov Mar 6, 2020
01d995c
remove unnecessary comment
mshustov Mar 6, 2020
f177d69
do not fail on FakrRequest
mshustov Mar 6, 2020
86f4603
small improvements
mshustov Mar 6, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ export interface CapabilitiesStart {
*/
export class CapabilitiesService {
public async start({ appIds, http }: StartDeps): Promise<CapabilitiesStart> {
const route = http.anonymousPaths.isAnonymous(window.location.pathname) ? '/defaults' : '';
const capabilities = await http.post<Capabilities>(`/api/core/capabilities${route}`, {
const capabilities = await http.post<Capabilities>('/api/core/capabilities', {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

body: JSON.stringify({ applications: appIds }),
});

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

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

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

export function registerRoutes(http: InternalHttpServiceSetup, resolver: CapabilitiesResolver) {
const router = http.createRouter('/api/core/capabilities');
const router = http.createRouter('');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: why remove the prefix here?

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

export function registerCapabilitiesRoutes(router: IRouter, resolver: CapabilitiesResolver) {
// Capabilities are fetched on both authenticated and anonymous routes.
// However when `authRequired` is false, authentication is not performed
// and only default capabilities are returned (all disabled), even for authenticated users.
// So we need two endpoints to handle both scenarios.
[true, false].forEach(authRequired => {
router.post(
{
path: authRequired ? '' : '/defaults',
options: {
authRequired,
},
validate: {
body: schema.object({
applications: schema.arrayOf(schema.string()),
}),
},
router.post(
{
path: '/api/core/capabilities',
options: {
authRequired: 'optional',
},
async (ctx, req, res) => {
const { applications } = req.body;
const capabilities = await resolver(req, applications);
return res.ok({
body: capabilities,
});
}
);
});
validate: {
body: schema.object({
applications: schema.arrayOf(schema.string()),
}),
},
},
async (ctx, req, res) => {
const { applications } = req.body;
const capabilities = await resolver(req, applications);
return res.ok({
body: capabilities,
});
}
);
}
4 changes: 2 additions & 2 deletions src/core/server/http/http_server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,7 @@ test('exposes route details of incoming request to a route handler', async () =>
method: 'get',
path: '/',
options: {
authRequired: true,
authRequired: false,
mshustov marked this conversation as resolved.
Show resolved Hide resolved
tags: [],
},
});
Expand Down Expand Up @@ -922,7 +922,7 @@ test('exposes route details of incoming request to a route handler (POST + paylo
method: 'post',
path: '/',
options: {
authRequired: true,
authRequired: false,
tags: [],
body: {
parse: true, // hapi populates the default
Expand Down
27 changes: 23 additions & 4 deletions src/core/server/http/http_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_p
import { adoptToHapiOnPreAuthFormat, OnPreAuthHandler } from './lifecycle/on_pre_auth';
import { adoptToHapiOnPreResponseFormat, OnPreResponseHandler } from './lifecycle/on_pre_response';

import { IRouter } from './router';
import { IRouter, RouteConfigOptions } from './router';
import {
SessionStorageCookieOptions,
createCookieSessionStorageFactory,
Expand Down Expand Up @@ -148,15 +148,15 @@ 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 = ['head', 'get'].includes(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;

this.server.route({
handler: route.handler,
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),
tags: tags ? Array.from(tags) : undefined,
// TODO: This 'validate' section can be removed once the legacy platform is completely removed.
// We are telling Hapi that NP routes can accept any payload, so that it can bypass the default
Expand Down Expand Up @@ -190,6 +190,20 @@ export class HttpServer {
this.server = undefined;
}

private getAuthOption(
authRequired: RouteConfigOptions<any>['authRequired'] = true
mshustov marked this conversation as resolved.
Show resolved Hide resolved
): false | { mode: 'required' | 'optional' } {
if (this.authRegistered) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sets mode explicitly. I had to change the logic to inline it with mode: optional declaration since it cannot be used without auth strategy registration. the previous implementation relies on the internal hapi logic that true === undefined.

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

private setupBasePathRewrite(config: HttpConfig, basePathService: BasePath) {
if (config.basePath === undefined || !config.rewriteBasePath) {
return;
Expand Down Expand Up @@ -303,6 +317,11 @@ export class HttpServer {
// where some plugin read directly from headers to identify whether a user is authenticated.
Object.assign(req.headers, requestHeaders);
}
},
(req, { responseHeaders }) => {
if (responseHeaders) {
this.authResponseHeaders.set(req, responseHeaders);
}
}
),
}));
Expand Down
1 change: 1 addition & 0 deletions src/core/server/http/http_service.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ const createOnPostAuthToolkitMock = (): jest.Mocked<OnPostAuthToolkit> => ({

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

const createOnPreResponseToolkitMock = (): jest.Mocked<OnPreResponseToolkit> => ({
Expand Down
40 changes: 39 additions & 1 deletion src/core/server/http/integration_tests/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,23 @@ describe('Auth', () => {
.expect(200, { content: 'ok' });
});

it('blocks access to a resource if credentials are not recognized', async () => {
mshustov marked this conversation as resolved.
Show resolved Hide resolved
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('/');
Expand Down Expand Up @@ -684,6 +701,27 @@ describe('Auth', () => {
expect(response.header['www-authenticate']).toBe(authResponseHeader['www-authenticate']);
});

it('attach security header to not handled auth response', async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duplicates tests in src/core/server/http/integration_tests/router.test.ts should I remove them?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One seems enough. Not sure in which file it makes more sense to keep it though. There seems to be overlapping 'coverage' between the two test files.

const { registerAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');

const authResponseHeader = {
'www-authenticate': 'from auth interceptor',
};
registerAuth((req, res, toolkit) => {
return toolkit.notHandled({ responseHeaders: authResponseHeader });
});

router.get({ path: '/', validate: false }, (context, req, res) => res.ok());
await server.start();

const response = await supertest(innerServer.listener)
.get('/')
.expect(401);

expect(response.header['www-authenticate']).toBe(authResponseHeader['www-authenticate']);
});

it('attach security header to an error response', async () => {
const { registerAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
Expand Down Expand Up @@ -865,7 +903,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('/');
Expand Down
108 changes: 108 additions & 0 deletions src/core/server/http/integration_tests/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,114 @@ afterEach(async () => {
await server.stop();
});

describe('Options', () => {
describe('authRequired', () => {
describe('optional', () => {
mshustov marked this conversation as resolved.
Show resolved Hide resolved
it('User has access to a route if auth mechanism not registered', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we verify the value of context.auth.isAuthenticated in these tests?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have separate tests for them, but it doesn't hurt since here we can verify:

  • a route was hit
  • auth API returns a correct result

const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');

router.get(
{ path: '/', validate: false, options: { authRequired: 'optional' } },
(context, req, res) => res.ok({ body: 'ok' })
);
await server.start();

await supertest(innerServer.listener)
.get('/')
.expect(200, 'ok');
});

it('Authenticated user has access to a route', async () => {
const { server: innerServer, createRouter, registerAuth } = 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: 'ok' })
);
await server.start();

await supertest(innerServer.listener)
.get('/')
.expect(200, 'ok');
});

it('User with not recognized credentials has access to a route', async () => {
const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
const router = createRouter('/');
const authResponseHeader = {
'www-authenticate': 'from auth interceptor',
};

registerAuth((req, res, toolkit) => {
return toolkit.notHandled({
responseHeaders: authResponseHeader,
});
});
router.get(
{ path: '/', validate: false, options: { authRequired: 'optional' } },
(context, req, res) => res.ok({ body: 'ok' })
);
await server.start();

const response = await supertest(innerServer.listener)
.get('/')
.expect(200, 'ok');

expect(response.header['www-authenticate']).toBe(authResponseHeader['www-authenticate']);
});
});
describe('true', () => {
it('Authenticated user has access to a route', async () => {
const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
const router = createRouter('/');

registerAuth((req, res, toolkit) => {
return toolkit.authenticated();
});
router.get(
{ path: '/', validate: false, options: { authRequired: 'optional' } },
mshustov marked this conversation as resolved.
Show resolved Hide resolved
(context, req, res) => res.ok({ body: 'ok' })
);
await server.start();

await supertest(innerServer.listener)
.get('/')
.expect(200, 'ok');
});

it('User with not recognized credentials has no access to a route', async () => {
const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
const router = createRouter('/');
const authResponseHeader = {
'www-authenticate': 'from auth interceptor',
};

registerAuth((req, res, toolkit) => {
return toolkit.notHandled({
responseHeaders: authResponseHeader,
});
});
router.get(
{ path: '/', validate: false, options: { authRequired: true } },
(context, req, res) => res.ok({ body: 'ok' })
);
await server.start();

const response = await supertest(innerServer.listener)
.get('/')
.expect(401);

expect(response.header['www-authenticate']).toBe(authResponseHeader['www-authenticate']);
});
});
});
});

describe('Handler', () => {
it("Doesn't expose error details if handler throws", async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
Expand Down
Loading