mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[7.x] Provide realm name for OIDC/SAML authenticate requests. (#45756)
This commit is contained in:
parent
f85c7402f6
commit
7b12301f2d
5 changed files with 255 additions and 111 deletions
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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.');
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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}`
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue