Typesciptify Token Authentication provider and move its tests to Jest. (#34973)

This commit is contained in:
Aleh Zasypkin 2019-04-12 11:46:34 +02:00 committed by GitHub
parent 0e88c26efa
commit 885eba7792
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 363 additions and 324 deletions

View file

@ -130,7 +130,7 @@ export class BasicAuthenticationProvider {
/**
* Validates whether request contains a login payload and authenticates the
* user if necessary.
* @param HapiJS request instance.
* @param request HapiJS request instance.
*/
private async authenticateViaLoginAttempt(request: RequestWithLoginAttempt) {
this.debug('Trying to authenticate via login attempt.');

View file

@ -4,25 +4,26 @@
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import sinon from 'sinon';
import { errors } from 'elasticsearch';
import { requestFixture } from '../../../__tests__/__fixtures__/request';
import { LoginAttempt } from '../../login_attempt';
import { TokenAuthenticationProvider } from '../token';
import sinon from 'sinon';
import { requestFixture } from '../../__tests__/__fixtures__/request';
import { LoginAttempt } from '../login_attempt';
import { TokenAuthenticationProvider } from './token';
describe('TokenAuthenticationProvider', () => {
describe('`authenticate` method', () => {
let provider;
let callWithRequest;
let callWithInternalUser;
let provider: TokenAuthenticationProvider;
let callWithRequest: sinon.SinonStub;
let callWithInternalUser: sinon.SinonStub;
beforeEach(() => {
callWithRequest = sinon.stub();
callWithInternalUser = sinon.stub();
provider = new TokenAuthenticationProvider({
client: { callWithRequest, callWithInternalUser },
log() {},
basePath: '/base-path'
log() {
// no-op
},
basePath: '/base-path',
});
});
@ -34,7 +35,7 @@ describe('TokenAuthenticationProvider', () => {
null
);
expect(authenticationResult.notHandled()).to.be(true);
expect(authenticationResult.notHandled()).toBe(true);
});
it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => {
@ -43,43 +44,42 @@ describe('TokenAuthenticationProvider', () => {
null
);
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be(
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe(
'/base-path/login?next=%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded'
);
});
it('does not handle authentication if state exists, but accessToken property is missing.',
async () => {
const authenticationResult = await provider.authenticate(
requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }),
{}
);
it('does not handle authentication if state exists, but accessToken property is missing.', async () => {
const authenticationResult = await provider.authenticate(
requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }),
{}
);
expect(authenticationResult.notHandled()).to.be(true);
});
expect(authenticationResult.notHandled()).toBe(true);
});
it('succeeds with valid login attempt and stores in session', async () => {
const user = { username: 'user' };
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('user', 'password');
request.loginAttempt.returns(loginAttempt);
(request.loginAttempt as sinon.SinonStub).returns(loginAttempt);
callWithInternalUser
.withArgs('shield.getAccessToken', { body: { grant_type: 'password', username: 'user', password: 'password' } })
.returns(Promise.resolve({ access_token: 'foo', refresh_token: 'bar' }));
.withArgs('shield.getAccessToken', {
body: { grant_type: 'password', username: 'user', password: 'password' },
})
.resolves({ access_token: 'foo', refresh_token: 'bar' });
callWithRequest
.withArgs(request, 'shield.authenticate')
.returns(Promise.resolve(user));
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
const authenticationResult = await provider.authenticate(request);
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.user).to.be.eql(user);
expect(authenticationResult.state).to.be.eql({ accessToken: 'foo', refreshToken: 'bar' });
expect(request.headers.authorization).to.be.eql(`Bearer foo`);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.state).toEqual({ accessToken: 'foo', refreshToken: 'bar' });
expect(request.headers.authorization).toEqual(`Bearer foo`);
sinon.assert.calledOnce(callWithRequest);
});
@ -90,12 +90,12 @@ describe('TokenAuthenticationProvider', () => {
callWithRequest
.withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate')
.returns(Promise.resolve(user));
.resolves(user);
const authenticationResult = await provider.authenticate(request);
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.user).to.be.eql(user);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
sinon.assert.calledOnce(callWithRequest);
});
@ -106,11 +106,13 @@ describe('TokenAuthenticationProvider', () => {
callWithRequest
.withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate')
.returns(Promise.resolve(user));
.resolves(user);
const authenticationResult = await provider.authenticate(request);
expect(authenticationResult.state).not.to.eql({ authorization: request.headers.authorization });
expect(authenticationResult.state).not.toEqual({
authorization: request.headers.authorization,
});
});
it('succeeds if only state is available.', async () => {
@ -121,13 +123,13 @@ describe('TokenAuthenticationProvider', () => {
callWithRequest
.withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate')
.returns(Promise.resolve(user));
.resolves(user);
const authenticationResult = await provider.authenticate(request, { accessToken });
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.user).to.be.eql(user);
expect(authenticationResult.state).to.be.eql(undefined);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.state).toBeUndefined();
sinon.assert.calledOnce(callWithRequest);
});
@ -140,23 +142,31 @@ describe('TokenAuthenticationProvider', () => {
.rejects({ statusCode: 401 });
callWithInternalUser
.withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: 'bar' } })
.returns(Promise.resolve({ access_token: 'newfoo', refresh_token: 'newbar' }));
.withArgs('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: 'bar' },
})
.resolves({ access_token: 'newfoo', refresh_token: 'newbar' });
callWithRequest
.withArgs(sinon.match({ headers: { authorization: 'Bearer newfoo' } }), 'shield.authenticate')
.withArgs(
sinon.match({ headers: { authorization: 'Bearer newfoo' } }),
'shield.authenticate'
)
.returns(user);
const accessToken = 'foo';
const refreshToken = 'bar';
const authenticationResult = await provider.authenticate(request, { accessToken, refreshToken });
const authenticationResult = await provider.authenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledTwice(callWithRequest);
sinon.assert.calledOnce(callWithInternalUser);
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.user).to.be.eql(user);
expect(authenticationResult.state).to.be.eql({ accessToken: 'newfoo', refreshToken: 'newbar' });
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.state).toEqual({ accessToken: 'newfoo', refreshToken: 'newbar' });
});
it('does not handle `authorization` header with unsupported schema even if state contains valid credentials.', async () => {
@ -167,13 +177,13 @@ describe('TokenAuthenticationProvider', () => {
callWithRequest
.withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate')
.returns(Promise.resolve(user));
.resolves(user);
const authenticationResult = await provider.authenticate(request, { accessToken });
sinon.assert.notCalled(callWithRequest);
expect(request.headers.authorization).to.be('Basic ***');
expect(authenticationResult.notHandled()).to.be(true);
expect(request.headers.authorization).toBe('Basic ***');
expect(authenticationResult.notHandled()).toBe(true);
});
it('authenticates only via `authorization` header even if state is available.', async () => {
@ -183,13 +193,13 @@ describe('TokenAuthenticationProvider', () => {
const user = { username: 'user' };
// GetUser will be called with request's `authorization` header.
callWithRequest.withArgs(request, 'shield.authenticate').returns(Promise.resolve(user));
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
const authenticationResult = await provider.authenticate(request, { authorization });
const authenticationResult = await provider.authenticate(request, { accessToken });
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.user).to.be.eql(user);
expect(authenticationResult.state).not.to.eql({ accessToken });
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.state).not.toEqual({ accessToken });
sinon.assert.calledOnce(callWithRequest);
});
@ -197,50 +207,52 @@ describe('TokenAuthenticationProvider', () => {
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('user', 'password');
request.loginAttempt.returns(loginAttempt);
(request.loginAttempt as sinon.SinonStub).returns(loginAttempt);
const authenticationError = new Error('Invalid credentials');
callWithInternalUser
.withArgs('shield.getAccessToken', { body: { grant_type: 'password', username: 'user', password: 'password' } })
.returns(Promise.reject(authenticationError));
.withArgs('shield.getAccessToken', {
body: { grant_type: 'password', username: 'user', password: 'password' },
})
.rejects(authenticationError);
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.notCalled(callWithRequest);
expect(request.headers).to.not.have.property('authorization');
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.user).to.be.eql(undefined);
expect(authenticationResult.state).to.be.eql(undefined);
expect(authenticationResult.error).to.be.eql(authenticationError);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.error).toEqual(authenticationError);
});
it('fails if user cannot be retrieved during login attempt', async () => {
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('user', 'password');
request.loginAttempt.returns(loginAttempt);
(request.loginAttempt as sinon.SinonStub).returns(loginAttempt);
callWithInternalUser
.withArgs('shield.getAccessToken', { body: { grant_type: 'password', username: 'user', password: 'password' } })
.returns(Promise.resolve({ access_token: 'foo', refresh_token: 'bar' }));
.withArgs('shield.getAccessToken', {
body: { grant_type: 'password', username: 'user', password: 'password' },
})
.resolves({ access_token: 'foo', refresh_token: 'bar' });
const authenticationError = new Error('Some error');
callWithRequest
.withArgs(request, 'shield.authenticate')
.returns(Promise.reject(authenticationError));
callWithRequest.withArgs(request, 'shield.authenticate').rejects(authenticationError);
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledOnce(callWithRequest);
expect(request.headers).to.not.have.property('authorization');
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.user).to.be.eql(undefined);
expect(authenticationResult.state).to.be.eql(undefined);
expect(authenticationResult.error).to.be.eql(authenticationError);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.error).toEqual(authenticationError);
});
it('fails if authentication with token from header fails with unknown error', async () => {
@ -248,18 +260,16 @@ describe('TokenAuthenticationProvider', () => {
const request = requestFixture({ headers: { authorization } });
const authenticationError = new errors.InternalServerError('something went wrong');
callWithRequest
.withArgs(request, 'shield.authenticate')
.rejects(authenticationError);
callWithRequest.withArgs(request, 'shield.authenticate').rejects(authenticationError);
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledOnce(callWithRequest);
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.user).to.be.eql(undefined);
expect(authenticationResult.state).to.be.eql(undefined);
expect(authenticationResult.error).to.be.eql(authenticationError);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.error).toEqual(authenticationError);
});
it('fails if authentication with token from state fails with unknown error.', async () => {
@ -268,18 +278,21 @@ describe('TokenAuthenticationProvider', () => {
const authenticationError = new errors.InternalServerError('something went wrong');
callWithRequest
.withArgs(sinon.match({ headers: { authorization: `Bearer ${accessToken}` } }), 'shield.authenticate')
.withArgs(
sinon.match({ headers: { authorization: `Bearer ${accessToken}` } }),
'shield.authenticate'
)
.rejects(authenticationError);
const authenticationResult = await provider.authenticate(request, { accessToken });
sinon.assert.calledOnce(callWithRequest);
expect(request.headers).to.not.have.property('authorization');
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.user).to.be.eql(undefined);
expect(authenticationResult.state).to.be.eql(undefined);
expect(authenticationResult.error).to.be.eql(authenticationError);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.error).toEqual(authenticationError);
});
it('fails if token refresh is rejected with unknown error', async () => {
@ -289,23 +302,28 @@ describe('TokenAuthenticationProvider', () => {
.withArgs(sinon.match({ headers: { authorization: 'Bearer foo' } }), 'shield.authenticate')
.rejects({ statusCode: 401 });
const refreshError = new errors.InternalServerError('failed to refresh token');
const refreshError = new errors.InternalServerError('failed to refresh token');
callWithInternalUser
.withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: 'bar' } })
.withArgs('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: 'bar' },
})
.rejects(refreshError);
const accessToken = 'foo';
const refreshToken = 'bar';
const authenticationResult = await provider.authenticate(request, { accessToken, refreshToken });
const authenticationResult = await provider.authenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledOnce(callWithRequest);
sinon.assert.calledOnce(callWithInternalUser);
expect(request.headers).to.not.have.property('authorization');
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.user).to.be.eql(undefined);
expect(authenticationResult.state).to.be.eql(undefined);
expect(authenticationResult.error).to.be.eql(refreshError);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.error).toEqual(refreshError);
});
it('redirects non-AJAX requests to /login and clears session if token document is missing', async () => {
@ -319,22 +337,27 @@ describe('TokenAuthenticationProvider', () => {
});
callWithInternalUser
.withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: 'bar' } })
.withArgs('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: 'bar' },
})
.rejects(new errors.BadRequest('failed to refresh token'));
const accessToken = 'foo';
const refreshToken = 'bar';
const authenticationResult = await provider.authenticate(request, { accessToken, refreshToken });
const authenticationResult = await provider.authenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledOnce(callWithRequest);
sinon.assert.calledOnce(callWithInternalUser);
expect(request.headers).to.not.have.property('authorization');
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/base-path/login?next=%2Fsome-path');
expect(authenticationResult.user).to.be.eql(undefined);
expect(authenticationResult.state).to.be.eql(null);
expect(authenticationResult.error).to.be.eql(undefined);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/base-path/login?next=%2Fsome-path');
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toEqual(null);
expect(authenticationResult.error).toBeUndefined();
});
it('redirects non-AJAX requests to /login and clears session if token refresh fails with 400 error', async () => {
@ -345,22 +368,27 @@ describe('TokenAuthenticationProvider', () => {
.rejects({ statusCode: 401 });
callWithInternalUser
.withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: 'bar' } })
.withArgs('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: 'bar' },
})
.rejects(new errors.BadRequest('failed to refresh token'));
const accessToken = 'foo';
const refreshToken = 'bar';
const authenticationResult = await provider.authenticate(request, { accessToken, refreshToken });
const authenticationResult = await provider.authenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledOnce(callWithRequest);
sinon.assert.calledOnce(callWithInternalUser);
expect(request.headers).to.not.have.property('authorization');
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/base-path/login?next=%2Fsome-path');
expect(authenticationResult.user).to.be.eql(undefined);
expect(authenticationResult.state).to.be.eql(null);
expect(authenticationResult.error).to.be.eql(undefined);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/base-path/login?next=%2Fsome-path');
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toEqual(null);
expect(authenticationResult.error).toBeUndefined();
});
it('does not redirect AJAX requests if token refresh fails with 400 error', async () => {
@ -372,21 +400,26 @@ describe('TokenAuthenticationProvider', () => {
const authenticationError = new errors.BadRequest('failed to refresh token');
callWithInternalUser
.withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: 'bar' } })
.withArgs('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: 'bar' },
})
.rejects(authenticationError);
const accessToken = 'foo';
const refreshToken = 'bar';
const authenticationResult = await provider.authenticate(request, { accessToken, refreshToken });
const authenticationResult = await provider.authenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledOnce(callWithRequest);
sinon.assert.calledOnce(callWithInternalUser);
expect(request.headers).to.not.have.property('authorization');
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.error).to.be(authenticationError);
expect(authenticationResult.user).to.be.eql(undefined);
expect(authenticationResult.state).to.be.eql(undefined);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(authenticationError);
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toBeUndefined();
});
it('fails if new access token is rejected after successful refresh', async () => {
@ -397,38 +430,48 @@ describe('TokenAuthenticationProvider', () => {
.rejects({ statusCode: 401 });
callWithInternalUser
.withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: 'bar' } })
.returns(Promise.resolve({ access_token: 'newfoo', refresh_token: 'newbar' }));
.withArgs('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: 'bar' },
})
.resolves({ access_token: 'newfoo', refresh_token: 'newbar' });
const authenticationError = new errors.AuthenticationException('Some error');
callWithRequest
.withArgs(sinon.match({ headers: { authorization: 'Bearer newfoo' } }), 'shield.authenticate')
.withArgs(
sinon.match({ headers: { authorization: 'Bearer newfoo' } }),
'shield.authenticate'
)
.rejects(authenticationError);
const accessToken = 'foo';
const refreshToken = 'bar';
const authenticationResult = await provider.authenticate(request, { accessToken, refreshToken });
const authenticationResult = await provider.authenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledTwice(callWithRequest);
sinon.assert.calledOnce(callWithInternalUser);
expect(request.headers).to.not.have.property('authorization');
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.user).to.be.eql(undefined);
expect(authenticationResult.state).to.be.eql(undefined);
expect(authenticationResult.error).to.be.eql(authenticationError);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.error).toEqual(authenticationError);
});
});
describe('`deauthenticate` method', () => {
let provider;
let callWithInternalUser;
let provider: TokenAuthenticationProvider;
let callWithInternalUser: sinon.SinonStub;
beforeEach(() => {
callWithInternalUser = sinon.stub();
provider = new TokenAuthenticationProvider({
client: { callWithInternalUser },
log() {},
basePath: '/base-path'
client: { callWithInternalUser } as any,
log() {
// no-op
},
basePath: '/base-path',
});
});
@ -439,21 +482,24 @@ describe('TokenAuthenticationProvider', () => {
const refreshToken = 'bar';
let deauthenticateResult = await provider.deauthenticate(request);
expect(deauthenticateResult.notHandled()).to.be(true);
expect(deauthenticateResult.notHandled()).toBe(true);
deauthenticateResult = await provider.deauthenticate(request, {});
expect(deauthenticateResult.notHandled()).to.be(true);
expect(deauthenticateResult.notHandled()).toBe(true);
deauthenticateResult = await provider.deauthenticate(request, { accessToken });
expect(deauthenticateResult.notHandled()).to.be(true);
expect(deauthenticateResult.notHandled()).toBe(true);
deauthenticateResult = await provider.deauthenticate(request, { refreshToken });
expect(deauthenticateResult.notHandled()).to.be(true);
expect(deauthenticateResult.notHandled()).toBe(true);
sinon.assert.notCalled(callWithInternalUser);
deauthenticateResult = await provider.deauthenticate(request, { accessToken, refreshToken });
expect(deauthenticateResult.notHandled()).to.be(false);
deauthenticateResult = await provider.deauthenticate(request, {
accessToken,
refreshToken,
});
expect(deauthenticateResult.notHandled()).toBe(false);
});
it('fails if call to delete access token responds with an error', async () => {
@ -464,19 +510,20 @@ describe('TokenAuthenticationProvider', () => {
const failureReason = new Error('failed to delete token');
callWithInternalUser
.withArgs('shield.deleteAccessToken', { body: { token: accessToken } })
.returns(Promise.reject(failureReason));
.rejects(failureReason);
const authenticationResult = await provider.deauthenticate(request, { accessToken, refreshToken });
const authenticationResult = await provider.deauthenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.deleteAccessToken',
{ body: { token: accessToken } }
);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { token: accessToken },
});
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.error).to.be(failureReason);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('fails if call to delete refresh token responds with an error', async () => {
@ -491,19 +538,20 @@ describe('TokenAuthenticationProvider', () => {
const failureReason = new Error('failed to delete token');
callWithInternalUser
.withArgs('shield.deleteAccessToken', { body: { refresh_token: refreshToken } })
.returns(Promise.reject(failureReason));
.rejects(failureReason);
const authenticationResult = await provider.deauthenticate(request, { accessToken, refreshToken });
const authenticationResult = await provider.deauthenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledTwice(callWithInternalUser);
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.deleteAccessToken',
{ body: { refresh_token: refreshToken } }
);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { refresh_token: refreshToken },
});
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.error).to.be(failureReason);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('redirects to /login if tokens are deleted successfully', async () => {
@ -519,22 +567,21 @@ describe('TokenAuthenticationProvider', () => {
.withArgs('shield.deleteAccessToken', { body: { refresh_token: refreshToken } })
.returns({ invalidated_tokens: 1 });
const authenticationResult = await provider.deauthenticate(request, { accessToken, refreshToken });
const authenticationResult = await provider.deauthenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledTwice(callWithInternalUser);
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.deleteAccessToken',
{ body: { token: accessToken } }
);
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.deleteAccessToken',
{ body: { refresh_token: refreshToken } }
);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { token: accessToken },
});
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { refresh_token: refreshToken },
});
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/base-path/login');
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/base-path/login');
});
it('redirects to /login with optional search parameters if tokens are deleted successfully', async () => {
@ -550,22 +597,21 @@ describe('TokenAuthenticationProvider', () => {
.withArgs('shield.deleteAccessToken', { body: { refresh_token: refreshToken } })
.returns({ created: true });
const authenticationResult = await provider.deauthenticate(request, { accessToken, refreshToken });
const authenticationResult = await provider.deauthenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledTwice(callWithInternalUser);
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.deleteAccessToken',
{ body: { token: accessToken } }
);
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.deleteAccessToken',
{ body: { refresh_token: refreshToken } }
);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { token: accessToken },
});
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { refresh_token: refreshToken },
});
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/base-path/login?yep');
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/base-path/login?yep');
});
});
});

View file

@ -4,37 +4,52 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { getErrorStatusCode } from '../../errors';
import { Request } from 'hapi';
import { Cluster } from 'src/legacy/core_plugins/elasticsearch';
import { canRedirectRequest } from '../../can_redirect_request';
import { getErrorStatusCode } from '../../errors';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { LoginAttempt } from '../login_attempt';
/**
* Object that represents available provider options.
* @typedef {{
* basePath: string,
* client: Client,
* log: Function
* }} ProviderOptions
* Represents available provider options.
*/
interface ProviderOptions {
basePath: string;
client: Cluster;
log: (tags: string[], message: string) => void;
}
/**
* Object that represents return value of internal header auth
* @typedef {{
* authenticationResult: AuthenticationResult,
* headerNotRecognized?: boolean
* }} HeaderAuthAttempt
* The state supported by the provider.
*/
interface ProviderState {
/**
* Access token issued as the result of successful authentication and that should be provided with
* every request to Elasticsearch on behalf of the authenticated user. This token will eventually expire.
*/
accessToken?: string;
/**
* Once access token expires the refresh token is used to get a new pair of access/refresh tokens
* without any user involvement. If not used this token will eventually expire as well.
*/
refreshToken?: string;
}
type RequestWithLoginAttempt = Request & {
loginAttempt: () => LoginAttempt;
};
/**
* If request with access token fails with `401 Unauthorized` then this token is no
* longer valid and we should try to refresh it. Another use case that we should
* temporarily support (until elastic/elasticsearch#38866 is fixed) is when token
* document has been removed and ES responds with `500 Internal Server Error`.
* @param {Object} err Error returned from Elasticsearch.
* @returns {boolean}
* @param err Error returned from Elasticsearch.
*/
function isAccessTokenExpiredError(err) {
function isAccessTokenExpiredError(err?: any) {
const errorStatusCode = getErrorStatusCode(err);
return (
errorStatusCode === 401 ||
@ -50,39 +65,29 @@ function isAccessTokenExpiredError(err) {
* Provider that supports token-based request authentication.
*/
export class TokenAuthenticationProvider {
/**
* Server options that may be needed by authentication provider.
* @type {?ProviderOptions}
* @protected
*/
_options = null;
/**
* Instantiates TokenAuthenticationProvider.
* @param {ProviderOptions} options Provider options object.
* @param options Options that may be needed by authentication provider.
*/
constructor(options) {
this._options = options;
}
constructor(private readonly options: ProviderOptions) {}
/**
* Performs token-based request authentication
* @param {Hapi.Request} request HapiJS request instance.
* @param {Object} [state] Optional state object associated with the provider.
* @returns {Promise.<AuthenticationResult>}
* @param request HapiJS request instance.
* @param [state] Optional state object associated with the provider.
*/
async authenticate(request, state) {
this._options.log(['debug', 'security', 'token'], `Trying to authenticate user request to ${request.url.path}.`);
public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) {
this.debug(`Trying to authenticate user request to ${request.url.path}.`);
// first try from login payload
let authenticationResult = await this._authenticateViaLoginAttempt(request);
let authenticationResult = await this.authenticateViaLoginAttempt(request);
// if there isn't a payload, try header-based token auth
if (authenticationResult.notHandled()) {
const {
authenticationResult: headerAuthResult,
headerNotRecognized,
} = await this._authenticateViaHeader(request);
} = await this.authenticateViaHeader(request);
if (headerNotRecognized) {
return headerAuthResult;
}
@ -91,16 +96,16 @@ export class TokenAuthenticationProvider {
// if we still can't attempt auth, try authenticating via state (session token)
if (authenticationResult.notHandled() && state) {
authenticationResult = await this._authenticateViaState(request, state);
authenticationResult = await this.authenticateViaState(request, state);
if (authenticationResult.failed() && isAccessTokenExpiredError(authenticationResult.error)) {
authenticationResult = await this._authenticateViaRefreshToken(request, state);
authenticationResult = await this.authenticateViaRefreshToken(request, state);
}
}
// finally, if authentication still can not be handled for this
// request/state combination, redirect to the login page if appropriate
if (authenticationResult.notHandled() && canRedirectRequest(request)) {
authenticationResult = AuthenticationResult.redirectTo(this._getLoginPageURL(request));
authenticationResult = AuthenticationResult.redirectTo(this.getLoginPageURL(request));
}
return authenticationResult;
@ -108,59 +113,59 @@ export class TokenAuthenticationProvider {
/**
* Redirects user to the login page preserving query string parameters.
* @param {Hapi.Request} request HapiJS request instance.
* @param {Object} state State value previously stored by the provider.
* @returns {Promise.<DeauthenticationResult>}
* @param request HapiJS request instance.
* @param state State value previously stored by the provider.
*/
async deauthenticate(request, state) {
this._options.log(['debug', 'security', 'token'], `Trying to deauthenticate user via ${request.url.path}.`);
public async deauthenticate(request: Request, state?: ProviderState | null) {
this.debug(`Trying to deauthenticate user via ${request.url.path}.`);
if (!state || !state.accessToken || !state.refreshToken) {
this._options.log(['debug', 'security', 'token'], 'There are no access and refresh tokens to invalidate.');
this.debug('There are no access and refresh tokens to invalidate.');
return DeauthenticationResult.notHandled();
}
this._options.log(['debug', 'security', 'token'], 'Token-based logout has been initiated by the user.');
this.debug('Token-based logout has been initiated by the user.');
try {
// First invalidate the access token.
const { invalidated_tokens: invalidatedAccessTokensCount } = await this._options.client.callWithInternalUser(
'shield.deleteAccessToken',
{ body: { token: state.accessToken } }
);
const {
invalidated_tokens: invalidatedAccessTokensCount,
} = await this.options.client.callWithInternalUser('shield.deleteAccessToken', {
body: { token: state.accessToken },
});
if (invalidatedAccessTokensCount === 0) {
this._options.log(['debug', 'security', 'token'], 'User access token was already invalidated.');
this.debug('User access token was already invalidated.');
} else if (invalidatedAccessTokensCount === 1) {
this._options.log(['debug', 'security', 'token'], 'User access token has been successfully invalidated.');
this.debug('User access token has been successfully invalidated.');
} else {
this._options.log(['debug', 'security', 'token'],
this.debug(
`${invalidatedAccessTokensCount} user access tokens were invalidated, this is unexpected.`
);
}
// Then invalidate the refresh token.
const { invalidated_tokens: invalidatedRefreshTokensCount } = await this._options.client.callWithInternalUser(
'shield.deleteAccessToken',
{ body: { refresh_token: state.refreshToken } }
);
const {
invalidated_tokens: invalidatedRefreshTokensCount,
} = await this.options.client.callWithInternalUser('shield.deleteAccessToken', {
body: { refresh_token: state.refreshToken },
});
if (invalidatedRefreshTokensCount === 0) {
this._options.log(['debug', 'security', 'token'], 'User refresh token was already invalidated.');
this.debug('User refresh token was already invalidated.');
} else if (invalidatedRefreshTokensCount === 1) {
this._options.log(['debug', 'security', 'token'], 'User refresh token has been successfully invalidated.');
this.debug('User refresh token has been successfully invalidated.');
} else {
this._options.log(['debug', 'security', 'token'],
this.debug(
`${invalidatedRefreshTokensCount} user refresh tokens were invalidated, this is unexpected.`
);
}
return DeauthenticationResult.redirectTo(
`${this._options.basePath}/login${request.url.search || ''}`
`${this.options.basePath}/login${request.url.search || ''}`
);
} catch(err) {
this._options.log(['debug', 'security', 'token'], `Failed invalidating user's access token: ${err.message}`);
} catch (err) {
this.debug(`Failed invalidating user's access token: ${err.message}`);
return DeauthenticationResult.failed(err);
}
}
@ -168,59 +173,48 @@ export class TokenAuthenticationProvider {
/**
* Validates whether request contains `Bearer ***` Authorization header and just passes it
* forward to Elasticsearch backend.
* @param {Hapi.Request} request HapiJS request instance.
* @returns {Promise.<HeaderAuthAttempt>}
* @private
* @param request HapiJS request instance.
*/
async _authenticateViaHeader(request) {
this._options.log(['debug', 'security', 'token'], 'Trying to authenticate via header.');
private async authenticateViaHeader(request: Request) {
this.debug('Trying to authenticate via header.');
const authorization = request.headers.authorization;
if (!authorization) {
this._options.log(['debug', 'security', 'token'], 'Authorization header is not presented.');
return {
authenticationResult: AuthenticationResult.notHandled()
};
this.debug('Authorization header is not presented.');
return { authenticationResult: AuthenticationResult.notHandled() };
}
const authenticationSchema = authorization.split(/\s+/)[0];
if (authenticationSchema.toLowerCase() !== 'bearer') {
this._options.log(['debug', 'security', 'token'], `Unsupported authentication schema: ${authenticationSchema}`);
return {
authenticationResult: AuthenticationResult.notHandled(),
headerNotRecognized: true
};
this.debug(`Unsupported authentication schema: ${authenticationSchema}`);
return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true };
}
try {
const user = await this._options.client.callWithRequest(request, 'shield.authenticate');
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this._options.log(['debug', 'security', 'token'], 'Request has been authenticated via header.');
this.debug('Request has been authenticated via header.');
// We intentionally do not store anything in session state because token
// header auth can only be used on a request by request basis.
return {
authenticationResult: AuthenticationResult.succeeded(user)
};
} catch(err) {
this._options.log(['debug', 'security', 'token'], `Failed to authenticate request via header: ${err.message}`);
return {
authenticationResult: AuthenticationResult.failed(err)
};
return { authenticationResult: AuthenticationResult.succeeded(user) };
} catch (err) {
this.debug(`Failed to authenticate request via header: ${err.message}`);
return { authenticationResult: AuthenticationResult.failed(err) };
}
}
/**
* @param {Hapi.Request} request HapiJS request instance.
* @returns {Promise.<AuthenticationResult>}
* @private
* Validates whether request contains a login payload and authenticates the
* user if necessary.
* @param request HapiJS request instance.
*/
async _authenticateViaLoginAttempt(request) {
this._options.log(['debug', 'security', 'token'], 'Trying to authenticate via login attempt.');
private async authenticateViaLoginAttempt(request: RequestWithLoginAttempt) {
this.debug('Trying to authenticate via login attempt.');
const credentials = request.loginAttempt().getCredentials();
if (!credentials) {
this._options.log(['debug', 'security', 'token'], 'Username and password not found in payload.');
this.debug('Username and password not found in payload.');
return AuthenticationResult.notHandled();
}
@ -230,31 +224,31 @@ export class TokenAuthenticationProvider {
const {
access_token: accessToken,
refresh_token: refreshToken,
} = await this._options.client.callWithInternalUser(
'shield.getAccessToken',
{ body: { grant_type: 'password', username, password } }
);
} = await this.options.client.callWithInternalUser('shield.getAccessToken', {
body: { grant_type: 'password', username, password },
});
this._options.log(['debug', 'security', 'token'], 'Get token API request to Elasticsearch successful');
this.debug('Get token API request to Elasticsearch successful');
// We validate that both access and refresh tokens exist in the response
// so other private methods in this class can rely on them both existing.
if (!accessToken) {
throw new Error('Unexpected response from get token API - no access token present');
}
if (!refreshToken) {
throw new Error('Unexpected response from get token API - no refresh token present');
}
// Then attempt to query for the user details using the new token
request.headers.authorization = `Bearer ${accessToken}`;
const user = await this._options.client.callWithRequest(request, 'shield.authenticate');
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this._options.log(['debug', 'security', 'token'], 'User has been authenticated with new access token');
this.debug('User has been authenticated with new access token');
return AuthenticationResult.succeeded(user, { accessToken, refreshToken });
} catch(err) {
this._options.log(['debug', 'security', 'token'], `Failed to authenticate request via login attempt: ${err.message}`);
} catch (err) {
this.debug(`Failed to authenticate request via login attempt: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
@ -270,28 +264,26 @@ export class TokenAuthenticationProvider {
/**
* Tries to extract authorization header from the state and adds it to the request before
* it's forwarded to Elasticsearch backend.
* @param {Hapi.Request} request HapiJS request instance.
* @param {Object} state State value previously stored by the provider.
* @returns {Promise.<AuthenticationResult>}
* @private
* @param request HapiJS request instance.
* @param state State value previously stored by the provider.
*/
async _authenticateViaState(request, { accessToken }) {
this._options.log(['debug', 'security', 'token'], 'Trying to authenticate via state.');
private async authenticateViaState(request: Request, { accessToken }: ProviderState) {
this.debug('Trying to authenticate via state.');
if (!accessToken) {
this._options.log(['debug', 'security', 'token'], 'Access token is not found in state.');
this.debug('Access token is not found in state.');
return AuthenticationResult.notHandled();
}
try {
request.headers.authorization = `Bearer ${accessToken}`;
const user = await this._options.client.callWithRequest(request, 'shield.authenticate');
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this._options.log(['debug', 'security', 'token'], 'Request has been authenticated via state.');
this.debug('Request has been authenticated via state.');
return AuthenticationResult.succeeded(user);
} catch(err) {
this._options.log(['debug', 'security', 'token'], `Failed to authenticate request via state: ${err.message}`);
} catch (err) {
this.debug(`Failed to authenticate request via state: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
@ -308,16 +300,14 @@ export class TokenAuthenticationProvider {
* This method is only called when authentication via access token stored in the state failed because of expired
* token. So we should use refresh token, that is also stored in the state, to extend expired access token and
* authenticate user with it.
* @param {Hapi.Request} request HapiJS request instance.
* @param {Object} state State value previously stored by the provider.
* @returns {Promise.<AuthenticationResult>}
* @private
* @param request HapiJS request instance.
* @param state State value previously stored by the provider.
*/
async _authenticateViaRefreshToken(request, { refreshToken }) {
this._options.log(['debug', 'security', 'token'], 'Trying to refresh access token.');
private async authenticateViaRefreshToken(request: Request, { refreshToken }: ProviderState) {
this.debug('Trying to refresh access token.');
if (!refreshToken) {
this._options.log(['debug', 'security', 'token'], 'Refresh token is not found in state.');
this.debug('Refresh token is not found in state.');
return AuthenticationResult.notHandled();
}
@ -326,34 +316,34 @@ export class TokenAuthenticationProvider {
// kibana system user.
const {
access_token: newAccessToken,
refresh_token: newRefreshToken
} = await this._options.client.callWithInternalUser(
'shield.getAccessToken',
{ body: { grant_type: 'refresh_token', refresh_token: refreshToken } }
);
refresh_token: newRefreshToken,
} = await this.options.client.callWithInternalUser('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: refreshToken },
});
this._options.log(['debug', 'security', 'token'], `Request to refresh token via Elasticsearch's get token API successful`);
this.debug(`Request to refresh token via Elasticsearch's get token API successful`);
// We validate that both access and refresh tokens exist in the response
// so other private methods in this class can rely on them both existing.
if (!newAccessToken) {
throw new Error('Unexpected response from get token API - no access token present');
}
if (!newRefreshToken) {
throw new Error('Unexpected response from get token API - no refresh token present');
}
request.headers.authorization = `Bearer ${newAccessToken}`;
const user = await this._options.client.callWithRequest(request, 'shield.authenticate');
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this._options.log(['debug', 'security', 'token'], 'Request has been authenticated via refreshed token.');
this.debug('Request has been authenticated via refreshed token.');
return AuthenticationResult.succeeded(
user,
{ accessToken: newAccessToken, refreshToken: newRefreshToken }
);
return AuthenticationResult.succeeded(user, {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
});
} catch (err) {
this._options.log(['debug', 'security', 'token'], `Failed to refresh access token: ${err.message}`);
this.debug(`Failed to refresh access token: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
@ -365,13 +355,10 @@ export class TokenAuthenticationProvider {
// If refresh fails with `400` then refresh token is no longer valid and we should clear session
// and redirect user to the login page to re-authenticate.
if (getErrorStatusCode(err) === 400 && canRedirectRequest(request)) {
this._options.log(
['debug', 'security', 'token'],
'Clearing session since both access and refresh tokens are expired.'
);
this.debug('Clearing session since both access and refresh tokens are expired.');
// Set state to `null` to let `Authenticator` know that we want to clear current session.
return AuthenticationResult.redirectTo(this._getLoginPageURL(request), null);
return AuthenticationResult.redirectTo(this.getLoginPageURL(request), null);
}
return AuthenticationResult.failed(err);
@ -380,12 +367,18 @@ export class TokenAuthenticationProvider {
/**
* Constructs login page URL using current url path as `next` query string parameter.
* @param {Hapi.Request} request HapiJS request instance.
* @returns {string}
* @private
* @param request HapiJS request instance.
*/
_getLoginPageURL(request) {
private getLoginPageURL(request: Request) {
const nextURL = encodeURIComponent(`${request.getBasePath()}${request.url.path}`);
return `${this._options.basePath}/login?next=${nextURL}`;
return `${this.options.basePath}/login?next=${nextURL}`;
}
/**
* Logs message with `debug` level and token/security related tags.
* @param message Message to log.
*/
private debug(message: string) {
this.options.log(['debug', 'security', 'token'], message);
}
}