[7.x] Provide realm name for OIDC/SAML authenticate requests. (#45756)

This commit is contained in:
Aleh Zasypkin 2019-09-16 13:33:41 +02:00 committed by GitHub
parent f85c7402f6
commit 7b12301f2d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 255 additions and 111 deletions

View file

@ -300,6 +300,8 @@
* @param {Array.<string>} ids A list of encrypted request tokens returned within SAML
* preparation response.
* @param {string} content SAML response returned by identity provider.
* @param {string} [realm] Optional string used to identify the name of the OpenID Connect realm
* that should be used to authenticate request.
*
* @returns {{username: string, access_token: string, expires_in: number}} Object that
* includes name of the user, access token to use for any consequent requests that
@ -373,6 +375,8 @@
* @param {string} nonce The nonce parameter that was returned by Elasticsearch in the
* preparation response.
* @param {string} redirect_uri The URL to where the UA was redirected by the OpenID Connect provider.
* @param {string} [realm] Optional string used to identify the name of the OpenID Connect realm
* that should be used to authenticate request.
*
* @returns {{username: string, access_token: string, refresh_token; string, expires_in: number}} Object that
* includes name of the user, access token to use for any consequent requests that
@ -391,7 +395,7 @@
*
* @param {string} token An access token that was created by authenticating to an OpenID Connect realm and
* that needs to be invalidated.
* @param {string} refres_token A refresh token that was created by authenticating to an OpenID Connect realm and
* @param {string} refresh_token A refresh token that was created by authenticating to an OpenID Connect realm and
* that needs to be invalidated.
*
* @returns {{redirect?: string}} If the Elasticsearch OpenID Connect realm configuration and the

View file

@ -105,7 +105,14 @@ describe('OIDCAuthenticationProvider', () => {
sinon.assert.calledWithExactly(
mockOptions.client.callAsInternalUser,
'shield.oidcAuthenticate',
{ body: { state: 'statevalue', nonce: 'noncevalue', redirect_uri: expectedRedirectURI } }
{
body: {
state: 'statevalue',
nonce: 'noncevalue',
redirect_uri: expectedRedirectURI,
realm: 'oidc1',
},
}
);
expect(authenticationResult.redirected()).toBe(true);
@ -180,7 +187,14 @@ describe('OIDCAuthenticationProvider', () => {
sinon.assert.calledWithExactly(
mockOptions.client.callAsInternalUser,
'shield.oidcAuthenticate',
{ body: { state: 'statevalue', nonce: 'noncevalue', redirect_uri: expectedRedirectURI } }
{
body: {
state: 'statevalue',
nonce: 'noncevalue',
redirect_uri: expectedRedirectURI,
realm: 'oidc1',
},
}
);
expect(authenticationResult.failed()).toBe(true);

View file

@ -197,7 +197,12 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
access_token: accessToken,
refresh_token: refreshToken,
} = await this.options.client.callAsInternalUser('shield.oidcAuthenticate', {
body: { state: stateOIDCState, nonce: stateNonce, redirect_uri: authenticationResponseURI },
body: {
state: stateOIDCState,
nonce: stateNonce,
redirect_uri: authenticationResponseURI,
realm: this.realm,
},
});
this.logger.debug('Request has been authenticated via OpenID Connect.');

View file

@ -29,9 +29,11 @@ describe('SAMLAuthenticationProvider', () => {
it('gets token and redirects user to requested URL if SAML Response is valid.', async () => {
const request = httpServerMock.createKibanaRequest();
mockOptions.client.callAsInternalUser
.withArgs('shield.samlAuthenticate')
.resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' });
mockOptions.client.callAsInternalUser.withArgs('shield.samlAuthenticate').resolves({
username: 'user',
access_token: 'some-token',
refresh_token: 'some-refresh-token',
});
const authenticationResult = await provider.login(
request,
@ -48,6 +50,7 @@ describe('SAMLAuthenticationProvider', () => {
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/test-base-path/some-path');
expect(authenticationResult.state).toEqual({
username: 'user',
accessToken: 'some-token',
refreshToken: 'some-refresh-token',
});
@ -141,6 +144,40 @@ describe('SAMLAuthenticationProvider', () => {
expect(authenticationResult.error).toBe(failureReason);
});
it('uses `realm` name instead of `acs` if it is specified for SAML authenticate request.', async () => {
const request = httpServerMock.createKibanaRequest();
// Create new provider instance with additional `realm` option.
const customMockOptions = mockAuthenticationProviderOptions();
provider = new SAMLAuthenticationProvider(customMockOptions, { realm: 'test-realm' });
customMockOptions.client.callAsInternalUser.withArgs('shield.samlAuthenticate').resolves({
username: 'user',
access_token: 'some-token',
refresh_token: 'some-refresh-token',
});
const authenticationResult = await provider.login(
request,
{ samlResponse: 'saml-response-xml' },
{ requestId: 'some-request-id', nextURL: '/test-base-path/some-path' }
);
sinon.assert.calledWithExactly(
customMockOptions.client.callAsInternalUser,
'shield.samlAuthenticate',
{ body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } }
);
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/test-base-path/some-path');
expect(authenticationResult.state).toEqual({
username: 'user',
accessToken: 'some-token',
refreshToken: 'some-refresh-token',
});
});
describe('IdP initiated login with existing session', () => {
it('fails if new SAML Response is rejected.', async () => {
const request = httpServerMock.createKibanaRequest();
@ -158,7 +195,11 @@ describe('SAMLAuthenticationProvider', () => {
const authenticationResult = await provider.login(
request,
{ samlResponse: 'saml-response-xml' },
{ accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token' }
{
username: 'user',
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
}
);
sinon.assert.calledWithExactly(
@ -192,14 +233,20 @@ describe('SAMLAuthenticationProvider', () => {
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(failureReason);
mockOptions.client.callAsInternalUser
.withArgs('shield.samlAuthenticate')
.resolves({ access_token: 'new-invalid-token', refresh_token: 'new-invalid-token' });
mockOptions.client.callAsInternalUser.withArgs('shield.samlAuthenticate').resolves({
username: 'user',
access_token: 'new-invalid-token',
refresh_token: 'new-invalid-token',
});
const authenticationResult = await provider.login(
request,
{ samlResponse: 'saml-response-xml' },
{ accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token' }
{
username: 'user',
accessToken: 'existing-valid-token',
refreshToken: 'existing-valid-refresh-token',
}
);
sinon.assert.calledWithExactly(
@ -214,7 +261,8 @@ describe('SAMLAuthenticationProvider', () => {
it('fails if fails to invalidate existing access/refresh tokens.', async () => {
const request = httpServerMock.createKibanaRequest();
const tokenPair = {
const state = {
username: 'user',
accessToken: 'existing-valid-token',
refreshToken: 'existing-valid-refresh-token',
};
@ -224,51 +272,19 @@ describe('SAMLAuthenticationProvider', () => {
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
mockOptions.client.callAsInternalUser
.withArgs('shield.samlAuthenticate')
.resolves({ access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token' });
mockOptions.client.callAsInternalUser.withArgs('shield.samlAuthenticate').resolves({
username: 'user',
access_token: 'new-valid-token',
refresh_token: 'new-valid-refresh-token',
});
const failureReason = new Error('Failed to invalidate token!');
mockOptions.tokens.invalidate.withArgs(tokenPair).rejects(failureReason);
mockOptions.tokens.invalidate.rejects(failureReason);
const authenticationResult = await provider.login(
request,
{ samlResponse: 'saml-response-xml' },
tokenPair
);
sinon.assert.calledWithExactly(
mockOptions.client.callAsInternalUser,
'shield.samlAuthenticate',
{ body: { ids: [], content: 'saml-response-xml' } }
);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('redirects to the home page if new SAML Response is for the same user.', async () => {
const request = httpServerMock.createKibanaRequest();
const tokenPair = {
accessToken: 'existing-valid-token',
refreshToken: 'existing-valid-refresh-token',
};
const user = { username: 'user', authentication_realm: { name: 'saml1' } };
mockScopedClusterClient(mockOptions.client)
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
mockOptions.client.callAsInternalUser
.withArgs('shield.samlAuthenticate')
.resolves({ access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token' });
mockOptions.tokens.invalidate.withArgs(tokenPair).resolves();
const authenticationResult = await provider.login(
request,
{ samlResponse: 'saml-response-xml' },
tokenPair
state
);
sinon.assert.calledWithExactly(
@ -278,7 +294,53 @@ describe('SAMLAuthenticationProvider', () => {
);
sinon.assert.calledOnce(mockOptions.tokens.invalidate);
sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair);
sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, {
accessToken: state.accessToken,
refreshToken: state.refreshToken,
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('redirects to the home page if new SAML Response is for the same user.', async () => {
const request = httpServerMock.createKibanaRequest();
const state = {
username: 'user',
accessToken: 'existing-valid-token',
refreshToken: 'existing-valid-refresh-token',
};
const user = { username: 'user', authentication_realm: { name: 'saml1' } };
mockScopedClusterClient(mockOptions.client)
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
mockOptions.client.callAsInternalUser.withArgs('shield.samlAuthenticate').resolves({
username: 'user',
access_token: 'new-valid-token',
refresh_token: 'new-valid-refresh-token',
});
mockOptions.tokens.invalidate.resolves();
const authenticationResult = await provider.login(
request,
{ samlResponse: 'saml-response-xml' },
state
);
sinon.assert.calledWithExactly(
mockOptions.client.callAsInternalUser,
'shield.samlAuthenticate',
{ body: { ids: [], content: 'saml-response-xml' } }
);
sinon.assert.calledOnce(mockOptions.tokens.invalidate);
sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, {
accessToken: state.accessToken,
refreshToken: state.refreshToken,
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/base-path/');
@ -286,7 +348,8 @@ describe('SAMLAuthenticationProvider', () => {
it('redirects to `overwritten_session` if new SAML Response is for the another user.', async () => {
const request = httpServerMock.createKibanaRequest();
const tokenPair = {
const state = {
username: 'user',
accessToken: 'existing-valid-token',
refreshToken: 'existing-valid-refresh-token',
};
@ -294,7 +357,7 @@ describe('SAMLAuthenticationProvider', () => {
const existingUser = { username: 'user', authentication_realm: { name: 'saml1' } };
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
sinon.match({ headers: { authorization: `Bearer ${state.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(existingUser);
@ -311,12 +374,12 @@ describe('SAMLAuthenticationProvider', () => {
.withArgs('shield.samlAuthenticate')
.resolves({ access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token' });
mockOptions.tokens.invalidate.withArgs(tokenPair).resolves();
mockOptions.tokens.invalidate.resolves();
const authenticationResult = await provider.login(
request,
{ samlResponse: 'saml-response-xml' },
tokenPair
state
);
sinon.assert.calledWithExactly(
@ -326,7 +389,10 @@ describe('SAMLAuthenticationProvider', () => {
);
sinon.assert.calledOnce(mockOptions.tokens.invalidate);
sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair);
sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, {
accessToken: state.accessToken,
refreshToken: state.refreshToken,
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/base-path/overwritten_session');
@ -334,15 +400,16 @@ describe('SAMLAuthenticationProvider', () => {
it('redirects to `overwritten_session` if new SAML Response is for another realm.', async () => {
const request = httpServerMock.createKibanaRequest();
const tokenPair = {
const state = {
username: 'user',
accessToken: 'existing-valid-token',
refreshToken: 'existing-valid-refresh-token',
};
const existingUser = { username: 'user', authentication_realm: { name: 'saml1' } };
const existingUser = { username: 'user' };
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
sinon.match({ headers: { authorization: `Bearer ${state.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(existingUser);
@ -355,16 +422,18 @@ describe('SAMLAuthenticationProvider', () => {
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(newUser);
mockOptions.client.callAsInternalUser
.withArgs('shield.samlAuthenticate')
.resolves({ access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token' });
mockOptions.client.callAsInternalUser.withArgs('shield.samlAuthenticate').resolves({
username: 'new-user',
access_token: 'new-valid-token',
refresh_token: 'new-valid-refresh-token',
});
mockOptions.tokens.invalidate.withArgs(tokenPair).resolves();
mockOptions.tokens.invalidate.resolves();
const authenticationResult = await provider.login(
request,
{ samlResponse: 'saml-response-xml' },
tokenPair
state
);
sinon.assert.calledWithExactly(
@ -374,7 +443,10 @@ describe('SAMLAuthenticationProvider', () => {
);
sinon.assert.calledOnce(mockOptions.tokens.invalidate);
sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair);
sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, {
accessToken: state.accessToken,
refreshToken: state.refreshToken,
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/base-path/overwritten_session');
@ -397,6 +469,7 @@ describe('SAMLAuthenticationProvider', () => {
});
const authenticationResult = await provider.authenticate(request, {
username: 'user',
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
});
@ -479,17 +552,18 @@ describe('SAMLAuthenticationProvider', () => {
it('succeeds if state contains a valid token.', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest();
const tokenPair = {
const state = {
username: 'user',
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
};
const authorization = `Bearer ${tokenPair.accessToken}`;
const authorization = `Bearer ${state.accessToken}`;
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } }))
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request, tokenPair);
const authenticationResult = await provider.authenticate(request, state);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.succeeded()).toBe(true);
@ -500,7 +574,8 @@ describe('SAMLAuthenticationProvider', () => {
it('fails if token from the state is rejected because of unknown reason.', async () => {
const request = httpServerMock.createKibanaRequest();
const tokenPair = {
const state = {
username: 'user',
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
};
@ -508,12 +583,12 @@ describe('SAMLAuthenticationProvider', () => {
const failureReason = { statusCode: 500, message: 'Token is not valid!' };
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
sinon.match({ headers: { authorization: `Bearer ${state.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(failureReason);
const authenticationResult = await provider.authenticate(request, tokenPair);
const authenticationResult = await provider.authenticate(request, state);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
@ -523,11 +598,15 @@ describe('SAMLAuthenticationProvider', () => {
it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest();
const tokenPair = { accessToken: 'expired-token', refreshToken: 'valid-refresh-token' };
const state = {
username: 'user',
accessToken: 'expired-token',
refreshToken: 'valid-refresh-token',
};
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
sinon.match({ headers: { authorization: `Bearer ${state.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects({ statusCode: 401 });
@ -540,10 +619,10 @@ describe('SAMLAuthenticationProvider', () => {
.resolves(user);
mockOptions.tokens.refresh
.withArgs(tokenPair.refreshToken)
.withArgs(state.refreshToken)
.resolves({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' });
const authenticationResult = await provider.authenticate(request, tokenPair);
const authenticationResult = await provider.authenticate(request, state);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.succeeded()).toBe(true);
@ -552,6 +631,7 @@ describe('SAMLAuthenticationProvider', () => {
});
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toEqual({
username: 'user',
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
});
@ -559,11 +639,15 @@ describe('SAMLAuthenticationProvider', () => {
it('fails if token from the state is expired and refresh attempt failed with unknown reason too.', async () => {
const request = httpServerMock.createKibanaRequest();
const tokenPair = { accessToken: 'expired-token', refreshToken: 'invalid-refresh-token' };
const state = {
username: 'user',
accessToken: 'expired-token',
refreshToken: 'invalid-refresh-token',
};
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
sinon.match({ headers: { authorization: `Bearer ${state.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects({ statusCode: 401 });
@ -572,9 +656,9 @@ describe('SAMLAuthenticationProvider', () => {
statusCode: 500,
message: 'Something is wrong with refresh token.',
};
mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).rejects(refreshFailureReason);
mockOptions.tokens.refresh.withArgs(state.refreshToken).rejects(refreshFailureReason);
const authenticationResult = await provider.authenticate(request, tokenPair);
const authenticationResult = await provider.authenticate(request, state);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
@ -583,18 +667,22 @@ describe('SAMLAuthenticationProvider', () => {
it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => {
const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } });
const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' };
const state = {
username: 'user',
accessToken: 'expired-token',
refreshToken: 'expired-refresh-token',
};
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
sinon.match({ headers: { authorization: `Bearer ${state.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects({ statusCode: 401 });
mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
mockOptions.tokens.refresh.withArgs(state.refreshToken).resolves(null);
const authenticationResult = await provider.authenticate(request, tokenPair);
const authenticationResult = await provider.authenticate(request, state);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
@ -605,7 +693,11 @@ describe('SAMLAuthenticationProvider', () => {
it('initiates SAML handshake for non-AJAX requests if access token document is missing.', async () => {
const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' });
const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' };
const state = {
username: 'user',
accessToken: 'expired-token',
refreshToken: 'expired-refresh-token',
};
mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').resolves({
id: 'some-request-id',
@ -614,7 +706,7 @@ describe('SAMLAuthenticationProvider', () => {
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
sinon.match({ headers: { authorization: `Bearer ${state.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects({
@ -622,9 +714,9 @@ describe('SAMLAuthenticationProvider', () => {
body: { error: { reason: 'token document is missing and must be present' } },
});
mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
mockOptions.tokens.refresh.withArgs(state.refreshToken).resolves(null);
const authenticationResult = await provider.authenticate(request, tokenPair);
const authenticationResult = await provider.authenticate(request, state);
sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlPrepare', {
body: { acs: `test-protocol://test-hostname:1234/base-path/api/security/v1/saml` },
@ -642,7 +734,11 @@ describe('SAMLAuthenticationProvider', () => {
it('initiates SAML handshake for non-AJAX requests if refresh token is expired.', async () => {
const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' });
const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' };
const state = {
username: 'user',
accessToken: 'expired-token',
refreshToken: 'expired-refresh-token',
};
mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').resolves({
id: 'some-request-id',
@ -651,14 +747,14 @@ describe('SAMLAuthenticationProvider', () => {
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
sinon.match({ headers: { authorization: `Bearer ${state.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects({ statusCode: 401 });
mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
mockOptions.tokens.refresh.withArgs(state.refreshToken).resolves(null);
const authenticationResult = await provider.authenticate(request, tokenPair);
const authenticationResult = await provider.authenticate(request, state);
sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlPrepare', {
body: { acs: `test-protocol://test-hostname:1234/base-path/api/security/v1/saml` },
@ -759,6 +855,7 @@ describe('SAMLAuthenticationProvider', () => {
mockOptions.client.callAsInternalUser.withArgs('shield.samlLogout').rejects(failureReason);
const authenticationResult = await provider.logout(request, {
username: 'user',
accessToken,
refreshToken,
});
@ -808,6 +905,7 @@ describe('SAMLAuthenticationProvider', () => {
.resolves({ redirect: null });
const authenticationResult = await provider.logout(request, {
username: 'user',
accessToken,
refreshToken,
});
@ -831,6 +929,7 @@ describe('SAMLAuthenticationProvider', () => {
.resolves({ redirect: undefined });
const authenticationResult = await provider.logout(request, {
username: 'user',
accessToken,
refreshToken,
});
@ -856,6 +955,7 @@ describe('SAMLAuthenticationProvider', () => {
.resolves({ redirect: null });
const authenticationResult = await provider.logout(request, {
username: 'user',
accessToken,
refreshToken,
});
@ -877,6 +977,7 @@ describe('SAMLAuthenticationProvider', () => {
.resolves({ redirect: null });
const authenticationResult = await provider.logout(request, {
username: 'user',
accessToken: 'x-saml-token',
refreshToken: 'x-saml-refresh-token',
});
@ -981,6 +1082,7 @@ describe('SAMLAuthenticationProvider', () => {
.resolves({ redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' });
const authenticationResult = await provider.logout(request, {
username: 'user',
accessToken,
refreshToken,
});
@ -998,6 +1100,7 @@ describe('SAMLAuthenticationProvider', () => {
.resolves({ redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' });
const authenticationResult = await provider.logout(request, {
username: 'user',
accessToken: 'x-saml-token',
refreshToken: 'x-saml-refresh-token',
});

View file

@ -17,6 +17,11 @@ import { canRedirectRequest } from '..';
* The state supported by the provider (for the SAML handshake or established session).
*/
interface ProviderState extends Partial<TokenPair> {
/**
* Username of the SAML authenticated user.
*/
username?: string;
/**
* Unique identifier of the SAML request initiated the handshake.
*/
@ -255,19 +260,21 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// This operation should be performed on behalf of the user with a privilege that normal
// user usually doesn't have `cluster:admin/xpack/security/saml/authenticate`.
const {
username,
access_token: accessToken,
refresh_token: refreshToken,
} = await this.options.client.callAsInternalUser('shield.samlAuthenticate', {
body: {
ids: stateRequestId ? [stateRequestId] : [],
content: samlResponse,
...(this.realm ? { realm: this.realm } : {}),
},
});
this.logger.debug('Login has been performed with SAML response.');
return AuthenticationResult.redirectTo(
stateRedirectURL || `${this.options.basePath.get(request)}/`,
{ state: { accessToken, refreshToken } }
{ state: { username, accessToken, refreshToken } }
);
} catch (err) {
this.logger.debug(`Failed to log in with SAML response: ${err.message}`);
@ -310,19 +317,30 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
}
const newState = payloadAuthenticationResult.state as ProviderState;
let differentUser = newState.username !== user.username;
// Then use received tokens to retrieve user information. We need just username and authentication
// realm, once ES starts returning this info from `saml/authenticate` we can get rid of this call.
const newUserAuthenticationResult = await this.authenticateViaState(request, newState);
if (newUserAuthenticationResult.failed()) {
return newUserAuthenticationResult;
}
// If realm name is not configured we can't be sure that user is authenticated by the same realm
// even though user name is the same.
if (!differentUser && !this.realm) {
// Then use received tokens to retrieve user information. We need just username and authentication
// realm, once ES starts returning this info from `saml/authenticate` we can get rid of this call.
const newUserAuthenticationResult = await this.authenticateViaState(request, newState);
if (newUserAuthenticationResult.failed()) {
return newUserAuthenticationResult;
}
if (newUserAuthenticationResult.user === undefined) {
// Should never happen, but if it does - it's a bug.
return AuthenticationResult.failed(
new Error('Could not retrieve user information using tokens produced for the SAML payload.')
);
if (newUserAuthenticationResult.user === undefined) {
// Should never happen, but if it does - it's a bug.
return AuthenticationResult.failed(
new Error(
'Could not retrieve user information using tokens produced for the SAML payload.'
)
);
}
differentUser =
newUserAuthenticationResult.user.authentication_realm.name !==
user.authentication_realm.name;
}
// Now let's invalidate tokens from the existing session.
@ -337,10 +355,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
return AuthenticationResult.failed(err);
}
if (
newUserAuthenticationResult.user.username !== user.username ||
newUserAuthenticationResult.user.authentication_realm.name !== user.authentication_realm.name
) {
if (differentUser) {
this.logger.debug(
'Login initiated by Identity Provider is for a different user than currently authenticated.'
);
@ -389,7 +404,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
*/
private async authenticateViaRefreshToken(
request: KibanaRequest,
{ refreshToken }: ProviderState
{ username, refreshToken }: ProviderState
) {
this.logger.debug('Trying to refresh access token.');
@ -428,7 +443,10 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
const user = await this.getUser(request, authHeaders);
this.logger.debug('Request has been authenticated via refreshed token.');
return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair });
return AuthenticationResult.succeeded(user, {
authHeaders,
state: { username, ...refreshedTokenPair },
});
} catch (err) {
this.logger.debug(
`Failed to authenticate user using newly refreshed access token: ${err.message}`