Skip to content

Commit eefc307

Browse files
rgodfrey-elasticelasticmachine
authored andcommitted
Fix IDP Initiated Authentication for Multiple OIDC Providers (elastic#243869)
Closes elastic#190177 ## Summary Fixes a bug where Authentication does not work when there are multiple OIDC providers configured. When OIDC authentication is initiated using the `iss`, ES sends back the redirect URL and realm configured for that OIDC provider. If the realm from ES does not match the configured realm in Kibana then the OIDC provider will now return not handled instead of redirecting the user to the OIDC redirect URL. This allows the authentication chain to move onto the second OIDC provider instead of being rejected by the first provider after being redirected. ## Release Notes Fixes a bug preventing IDP initiated login with multiple OIDC providers --------- Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
1 parent b5e8406 commit eefc307

9 files changed

Lines changed: 353 additions & 11 deletions

File tree

.buildkite/ftr_platform_stateful_configs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ enabled:
310310
- x-pack/platform/test/security_api_integration/oidc_implicit_flow.config.ts
311311
- x-pack/platform/test/security_api_integration/oidc.config.ts
312312
- x-pack/platform/test/security_api_integration/oidc.http2.config.ts
313+
- x-pack/platform/test/security_api_integration/oidc_multiple_realms.config.ts
313314
- x-pack/platform/test/security_api_integration/pki.config.ts
314315
- x-pack/platform/test/security_api_integration/saml.config.ts
315316
- x-pack/platform/test/security_api_integration/saml.http2.config.ts

x-pack/platform/plugins/shared/security/server/authentication/providers/oidc.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ describe('OIDCAuthenticationProvider', () => {
6565
mockOptions.client.asInternalUser.transport.request.mockResolvedValue({
6666
state: 'statevalue',
6767
nonce: 'noncevalue',
68+
realm: 'oidc1',
6869
redirect:
6970
'https://op-host/path/login?response_type=code' +
7071
'&scope=openid%20profile%20email' +
@@ -113,6 +114,7 @@ describe('OIDCAuthenticationProvider', () => {
113114
mockOptions.client.asInternalUser.transport.request.mockResolvedValue({
114115
state: 'statevalue',
115116
nonce: 'noncevalue',
117+
realm: 'oidc1',
116118
redirect:
117119
'https://op-host/path/login?response_type=code' +
118120
'&scope=openid%20profile%20email' +
@@ -154,6 +156,70 @@ describe('OIDCAuthenticationProvider', () => {
154156
});
155157
});
156158

159+
describe('returns "NotHandled" result if ES realm does not match provider', () => {
160+
it('when initiated by the User', async () => {
161+
const request = httpServerMock.createKibanaRequest();
162+
163+
mockOptions.client.asInternalUser.transport.request.mockResolvedValue({
164+
state: 'statevalue',
165+
nonce: 'noncevalue',
166+
realm: 'other-realm',
167+
redirect:
168+
'https://op-host/path/login?response_type=code' +
169+
'&scope=openid%20profile%20email' +
170+
'&client_id=s6BhdRkqt3' +
171+
'&state=statevalue' +
172+
'&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' +
173+
'&login_hint=loginhint',
174+
});
175+
176+
await expect(
177+
provider.login(request, {
178+
type: OIDCLogin.LoginInitiatedByUser,
179+
redirectURL: '/mock-server-basepath/app/super-kibana#some-hash',
180+
})
181+
).resolves.toEqual(AuthenticationResult.notHandled());
182+
183+
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1);
184+
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
185+
method: 'POST',
186+
path: '/_security/oidc/prepare',
187+
body: { realm: 'oidc1' },
188+
});
189+
});
190+
191+
it('when initiated by 3rd party', async () => {
192+
const request = httpServerMock.createKibanaRequest();
193+
194+
mockOptions.client.asInternalUser.transport.request.mockResolvedValue({
195+
state: 'statevalue',
196+
nonce: 'noncevalue',
197+
realm: 'other-realm',
198+
redirect:
199+
'https://op-host/path/login?response_type=code' +
200+
'&scope=openid%20profile%20email' +
201+
'&client_id=s6BhdRkqt3' +
202+
'&state=statevalue' +
203+
'&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' +
204+
'&login_hint=loginhint',
205+
});
206+
207+
await expect(
208+
provider.login(request, {
209+
type: OIDCLogin.LoginInitiatedBy3rdParty,
210+
iss: 'some-issuer',
211+
})
212+
).resolves.toEqual(AuthenticationResult.notHandled());
213+
214+
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1);
215+
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
216+
method: 'POST',
217+
path: '/_security/oidc/prepare',
218+
body: { iss: 'some-issuer' },
219+
});
220+
});
221+
});
222+
157223
it('fails if OpenID Connect authentication request preparation fails.', async () => {
158224
const request = httpServerMock.createKibanaRequest();
159225

@@ -392,6 +458,7 @@ describe('OIDCAuthenticationProvider', () => {
392458
mockOptions.client.asInternalUser.transport.request.mockResolvedValue({
393459
state: 'statevalue',
394460
nonce: 'noncevalue',
461+
realm: 'oidc1',
395462
redirect:
396463
'https://op-host/path/login?response_type=code' +
397464
'&scope=openid%20profile%20email' +

x-pack/platform/plugins/shared/security/server/authentication/providers/oidc.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,13 +304,21 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
304304
// user usually doesn't have `cluster:admin/xpack/security/oidc/prepare`.
305305
// We can replace generic `transport.request` with a dedicated API method call once
306306
// https://github.com/elastic/elasticsearch/issues/67189 is resolved.
307-
const { state, nonce, redirect } =
307+
const { state, nonce, realm, redirect } =
308308
(await this.options.client.asInternalUser.transport.request({
309309
method: 'POST',
310310
path: '/_security/oidc/prepare',
311311
body: params,
312312
})) as any;
313313

314+
if (realm !== this.realm) {
315+
this.logger.debug(
316+
`Provider is configured with the "${this.realm}" realm and isn't compatible with the "${realm}" ` +
317+
`realm used by Elasticsearch to prepare the authentication request. Skipping provider…`
318+
);
319+
return AuthenticationResult.notHandled();
320+
}
321+
314322
this.logger.debug('Redirecting to OpenID Connect Provider with authentication request.');
315323
return AuthenticationResult.redirectTo(
316324
redirect,
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import type { FtrConfigProviderContext } from '@kbn/test';
9+
10+
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
11+
const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts'));
12+
const kibanaPort = xPackAPITestsConfig.get('servers.kibana.port');
13+
const jwksPath = require.resolve('@kbn/security-api-integration-helpers/oidc/jwks.json');
14+
const oidcAPITestsConfig = await readConfigFile(require.resolve('./oidc.config.ts'));
15+
16+
return {
17+
...oidcAPITestsConfig.getAll(),
18+
testFiles: [require.resolve('./tests/oidc/multiple_realms')],
19+
20+
junit: {
21+
reportName: 'X-Pack Security API Integration Tests (OIDC - Multiple OIDC Realms)',
22+
},
23+
24+
esTestCluster: {
25+
...oidcAPITestsConfig.get('esTestCluster'),
26+
serverArgs: [
27+
...oidcAPITestsConfig.get('esTestCluster.serverArgs'),
28+
'xpack.security.authc.realms.oidc.oidc2.order=1',
29+
`xpack.security.authc.realms.oidc.oidc2.rp.client_id=0oa8sqpov3TxMWJOt356`,
30+
`xpack.security.authc.realms.oidc.oidc2.rp.client_secret=0oa8sqpov3TxMWJOt356`,
31+
`xpack.security.authc.realms.oidc.oidc2.rp.response_type=code`,
32+
`xpack.security.authc.realms.oidc.oidc2.rp.redirect_uri=http://localhost:${kibanaPort}/api/security/oidc/callback`,
33+
`xpack.security.authc.realms.oidc.oidc2.op.authorization_endpoint=https://test-op-2.elastic.co/oauth2/v1/authorize`,
34+
`xpack.security.authc.realms.oidc.oidc2.op.endsession_endpoint=https://test-op-2.elastic.co/oauth2/v1/endsession`,
35+
`xpack.security.authc.realms.oidc.oidc2.op.token_endpoint=http://localhost:${kibanaPort}/api/oidc_provider/token_endpoint/${encodeURIComponent(
36+
'https://test-op-2.elastic.co'
37+
)}`,
38+
`xpack.security.authc.realms.oidc.oidc2.op.userinfo_endpoint=http://localhost:${kibanaPort}/api/oidc_provider/userinfo_endpoint`,
39+
`xpack.security.authc.realms.oidc.oidc2.op.issuer=https://test-op-2.elastic.co`,
40+
`xpack.security.authc.realms.oidc.oidc2.op.jwkset_path=${jwksPath}`,
41+
`xpack.security.authc.realms.oidc.oidc2.claims.principal=sub`,
42+
],
43+
},
44+
45+
kbnTestServer: {
46+
...oidcAPITestsConfig.get('kbnTestServer'),
47+
serverArgs: [
48+
...oidcAPITestsConfig
49+
.get('kbnTestServer.serverArgs')
50+
.filter(
51+
(arg: string) =>
52+
!arg.includes('xpack.security.authProviders') &&
53+
!arg.includes('xpack.security.authc.oidc.realm')
54+
),
55+
'--xpack.security.authc.providers.oidc.oidc1.order=0',
56+
'--xpack.security.authc.providers.oidc.oidc1.realm="oidc1"',
57+
'--xpack.security.authc.providers.oidc.oidc2.order=1',
58+
'--xpack.security.authc.providers.oidc.oidc2.realm="oidc2"',
59+
],
60+
},
61+
};
62+
}

x-pack/platform/test/security_api_integration/packages/helpers/oidc/oidc_tools.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ function fromBase64(base64: string) {
1818
return base64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
1919
}
2020

21-
export function createTokens(userId: string, nonce: string) {
21+
export function createTokens(userId: string, nonce: string, issuer: string) {
2222
const idTokenHeader = fromBase64(
2323
Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64')
2424
);
@@ -29,7 +29,7 @@ export function createTokens(userId: string, nonce: string) {
2929
const idTokenBody = fromBase64(
3030
Buffer.from(
3131
JSON.stringify({
32-
iss: 'https://test-op.elastic.co',
32+
iss: issuer,
3333
sub: `user${userId}`,
3434
aud: '0oa8sqpov3TxMWJOt356',
3535
nonce,

x-pack/platform/test/security_api_integration/plugins/oidc_provider/server/init_routes.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,22 @@ export function initRoutes(router: IRouter) {
6565

6666
router.post(
6767
{
68-
path: '/api/oidc_provider/token_endpoint',
69-
security: { authz: { enabled: false, reason: '' } },
70-
validate: { body: (value) => ({ value }) },
68+
path: '/api/oidc_provider/token_endpoint/{issuer?}',
69+
security: { authc: { enabled: false, reason: '' }, authz: { enabled: false, reason: '' } },
70+
validate: {
71+
body: (value) => ({ value }),
72+
params: schema.object({
73+
issuer: schema.string({ defaultValue: 'https://test-op.elastic.co' }),
74+
}),
75+
},
7176
// Token endpoint needs authentication (with the client credentials) but we don't attempt to
7277
// validate this OIDC behavior here
73-
options: { authRequired: false, xsrfRequired: false },
78+
options: { xsrfRequired: false },
7479
},
7580
(context, request, response) => {
7681
const userId = request.body.code.substring(4);
77-
const { accessToken, idToken } = createTokens(userId, nonce);
82+
83+
const { accessToken, idToken } = createTokens(userId, nonce, request.params.issuer);
7884
return response.ok({
7985
body: {
8086
access_token: accessToken,

x-pack/platform/test/security_api_integration/tests/oidc/implicit_flow/oidc_auth.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,11 @@ export default function ({ getService }: FtrProviderContext) {
8383
});
8484

8585
it('should fail if OpenID Connect response is not complemented with handshake cookie', async () => {
86-
const { idToken, accessToken } = createTokens('1', stateAndNonce.nonce);
86+
const { idToken, accessToken } = createTokens(
87+
'1',
88+
stateAndNonce.nonce,
89+
'https://test-op.elastic.co'
90+
);
8791
const authenticationResponse = `https://kibana.com/api/security/oidc/implicit#id_token=${idToken}&state=${stateAndNonce.state}&token_type=bearer&access_token=${accessToken}`;
8892

8993
const unauthenticatedResponse = await supertest
@@ -99,7 +103,11 @@ export default function ({ getService }: FtrProviderContext) {
99103
});
100104

101105
it('should fail if state is not matching', async () => {
102-
const { idToken, accessToken } = createTokens('1', stateAndNonce.nonce);
106+
const { idToken, accessToken } = createTokens(
107+
'1',
108+
stateAndNonce.nonce,
109+
'https://test-op.elastic.co'
110+
);
103111
const authenticationResponse = `https://kibana.com/api/security/oidc/implicit#id_token=${idToken}&state=$someothervalue&token_type=bearer&access_token=${accessToken}`;
104112

105113
const unauthenticatedResponse = await supertest
@@ -116,7 +124,11 @@ export default function ({ getService }: FtrProviderContext) {
116124
});
117125

118126
it('should succeed if both the OpenID Connect response and the cookie are provided', async () => {
119-
const { idToken, accessToken } = createTokens('1', stateAndNonce.nonce);
127+
const { idToken, accessToken } = createTokens(
128+
'1',
129+
stateAndNonce.nonce,
130+
'https://test-op.elastic.co'
131+
);
120132
const authenticationResponse = `https://kibana.com/api/security/oidc/implicit#id_token=${idToken}&state=${stateAndNonce.state}&token_type=bearer&access_token=${accessToken}`;
121133

122134
const oidcAuthenticationResponse = await supertest
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import type { FtrProviderContext } from '../../../ftr_provider_context';
9+
10+
export default function ({ loadTestFile }: FtrProviderContext) {
11+
describe('security APIs - OIDC (Multiple OIDC Realms)', function () {
12+
loadTestFile(require.resolve('./oidc_auth'));
13+
});
14+
}

0 commit comments

Comments
 (0)