-
Notifications
You must be signed in to change notification settings - Fork 24.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add integration test for JWT realm with c2id OP (#84691)
We have an existing QA test and fixture that integrates the OIDC realm with the c2id OIDC server. This commit extends that test suite and fixture to also test that the JWT id tokens generated by c2id can be used to authenticate to the Elasticsearch JWT realm Co-authored-by: Justin Cranford <89857999+justincr-elastic@users.noreply.github.com>
- Loading branch information
1 parent
9bba923
commit aaf66f9
Showing
5 changed files
with
486 additions
and
281 deletions.
There are no files selected for viewing
170 changes: 170 additions & 0 deletions
170
...-op-tests/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtWithOidcAuthIT.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,170 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License | ||
* 2.0; you may not use this file except in compliance with the Elastic License | ||
* 2.0. | ||
*/ | ||
|
||
package org.elasticsearch.xpack.security.authc.jwt; | ||
|
||
import com.nimbusds.oauth2.sdk.ResponseType; | ||
import com.nimbusds.oauth2.sdk.Scope; | ||
import com.nimbusds.oauth2.sdk.id.ClientID; | ||
import com.nimbusds.oauth2.sdk.id.State; | ||
import com.nimbusds.openid.connect.sdk.AuthenticationRequest; | ||
import com.nimbusds.openid.connect.sdk.Nonce; | ||
import com.nimbusds.openid.connect.sdk.OIDCScopeValue; | ||
|
||
import org.elasticsearch.client.RequestOptions; | ||
import org.elasticsearch.client.ResponseException; | ||
import org.elasticsearch.rest.RestStatus; | ||
import org.elasticsearch.rest.RestUtils; | ||
import org.elasticsearch.test.TestMatchers; | ||
import org.elasticsearch.test.TestSecurityClient; | ||
import org.elasticsearch.xpack.core.security.user.User; | ||
import org.elasticsearch.xpack.security.authc.oidc.C2IdOpTestCase; | ||
import org.hamcrest.Matchers; | ||
import org.junit.Before; | ||
import org.junit.BeforeClass; | ||
|
||
import java.io.IOException; | ||
import java.net.URI; | ||
import java.util.HashMap; | ||
import java.util.List; | ||
import java.util.Map; | ||
|
||
import static org.hamcrest.Matchers.contains; | ||
import static org.hamcrest.Matchers.greaterThan; | ||
import static org.hamcrest.Matchers.hasKey; | ||
|
||
/** | ||
* Tests that the Elasticsearch "jwt" realm ({@link JwtRealm}) can be used to authenticate using a JWT that is actually an ID Token | ||
* from a real OIDC Originating Party (OP). | ||
* This test is intentionally minimal - the bulk of the JWT functionality is tested elsewhere - the purpose of this test is purely to | ||
* verify successful integration an OIDC OP. | ||
*/ | ||
public class JwtWithOidcAuthIT extends C2IdOpTestCase { | ||
|
||
// configured in the Elasticearch node test fixture | ||
private static final List<String> ALLOWED_AUDIENCES = List.of("elasticsearch-jwt1", "elasticsearch-jwt2"); | ||
private static final String JWT_REALM_NAME = "op-jwt"; | ||
|
||
// Constants for role mapping | ||
private static final String ROLE_NAME = "jwt_role"; | ||
private static final String SHARED_SECRET = "jwt-realm-shared-secret"; | ||
|
||
// Randomised values | ||
private static String clientId; | ||
private static String redirectUri; | ||
|
||
/** | ||
* Register an OIDC client so we can generate a JWT in C2id (which only supports dynamic configuration). | ||
*/ | ||
@BeforeClass | ||
public static void registerClient() throws IOException { | ||
clientId = randomFrom(ALLOWED_AUDIENCES); | ||
redirectUri = "https://" + randomAlphaOfLength(4) + ".rp.example.com/" + randomAlphaOfLength(6); | ||
String clientSecret = randomAlphaOfLength(24); | ||
String clientSetup = """ | ||
{ | ||
"grant_types": [ "implicit" ], | ||
"response_types": [ "token id_token" ], | ||
"preferred_client_id": "%s", | ||
"preferred_client_secret": "%s", | ||
"redirect_uris": [ "%s" ] | ||
}""".formatted(clientId, clientSecret, redirectUri); | ||
registerClients(clientSetup); | ||
} | ||
|
||
@Before | ||
public void setupRoleMapping() throws Exception { | ||
try (var restClient = getElasticsearchClient()) { | ||
var client = new TestSecurityClient(restClient); | ||
final String mappingJson = """ | ||
{ | ||
"roles": [ "%s" ], | ||
"enabled": true, | ||
"rules": { | ||
"all": [ | ||
{ "field": { "realm.name": "%s" } }, | ||
{ "field": { "metadata.jwt_claim_sub": "%s" } } | ||
] | ||
} | ||
} | ||
""".formatted(ROLE_NAME, JWT_REALM_NAME, TEST_SUBJECT_ID); | ||
client.putRoleMapping(getTestName(), mappingJson); | ||
} | ||
} | ||
|
||
public void testAuthenticateWithOidcIssuedJwt() throws Exception { | ||
final String state = randomAlphaOfLength(42); | ||
final String nonce = randomAlphaOfLength(42); | ||
final AuthenticationRequest oidcAuthRequest = new AuthenticationRequest.Builder( | ||
new ResponseType("id_token", "token"), | ||
new Scope(OIDCScopeValue.OPENID), | ||
new ClientID(clientId), | ||
new URI(redirectUri) | ||
).endpointURI(new URI(C2ID_AUTH_ENDPOINT)).state(new State(state)).nonce(new Nonce(nonce)).build(); | ||
|
||
final String implicitFlowURI = authenticateAtOP(oidcAuthRequest.toURI()); | ||
|
||
assertThat("Hash value of URI should be a JWT", implicitFlowURI, Matchers.containsString("#")); | ||
|
||
/* | ||
* In OIDC's implicit flow, the JWT is provided in the URI as a hash fragment using form encoding | ||
* (See Section 4.2.2 of the OAuth2 spec - https://www.rfc-editor.org/rfc/rfc6749.html#section-4.2.2) | ||
* We're not trying to do OIDC - we're just trying to get an id_token shaped JWT from a real OIDC OP server (c2id) and use it | ||
* to authenticated against our JWT realm. | ||
* So, we extract the hash fragment, and decode it as a query string (which is not quite form encoding, but does the job). | ||
* The three-part-encoded JWT id_token will be in the "id_token" field | ||
*/ | ||
final int hashChar = implicitFlowURI.indexOf('#'); | ||
final Map<String, String> hashParams = new HashMap<>(); | ||
RestUtils.decodeQueryString(implicitFlowURI.substring(hashChar + 1), 0, hashParams); | ||
|
||
assertThat("Hash value of URI [" + implicitFlowURI + "] should be a JWT with an id Token", hashParams, hasKey("id_token")); | ||
String idJwt = hashParams.get("id_token"); | ||
|
||
final Map<String, Object> authenticateResponse = authenticateWithJwtAndSharedSecret(idJwt, SHARED_SECRET); | ||
assertThat(authenticateResponse, Matchers.hasEntry(User.Fields.USERNAME.getPreferredName(), TEST_SUBJECT_ID)); | ||
assertThat(authenticateResponse, Matchers.hasKey(User.Fields.ROLES.getPreferredName())); | ||
assertThat((List<?>) authenticateResponse.get(User.Fields.ROLES.getPreferredName()), contains(ROLE_NAME)); | ||
|
||
// Use an incorrect shared secret and check it fails | ||
ResponseException ex = expectThrows( | ||
ResponseException.class, | ||
() -> authenticateWithJwtAndSharedSecret(idJwt, "not-" + SHARED_SECRET) | ||
); | ||
assertThat(ex.getResponse(), TestMatchers.hasStatusCode(RestStatus.UNAUTHORIZED)); | ||
|
||
// Modify the JWT payload and check it fails | ||
final int dot = idJwt.indexOf('.'); | ||
assertThat(dot, greaterThan(0)); | ||
// change the first character of the payload section of the encoded JWT | ||
final String corruptToken = idJwt.substring(0, dot) + "." + transformChar(idJwt.charAt(dot + 1)) + idJwt.substring(dot + 2); | ||
ex = expectThrows(ResponseException.class, () -> authenticateWithJwtAndSharedSecret(corruptToken, SHARED_SECRET)); | ||
assertThat(ex.getResponse(), TestMatchers.hasStatusCode(RestStatus.UNAUTHORIZED)); | ||
} | ||
|
||
private Map<String, Object> authenticateWithJwtAndSharedSecret(String idJwt, String sharedSecret) throws IOException { | ||
final Map<String, Object> authenticateResponse = super.callAuthenticateApiUsingBearerToken( | ||
idJwt, | ||
RequestOptions.DEFAULT.toBuilder() | ||
.addHeader(JwtRealm.HEADER_CLIENT_AUTHENTICATION, JwtRealm.HEADER_SHARED_SECRET_AUTHENTICATION_SCHEME + " " + sharedSecret) | ||
.build() | ||
); | ||
return authenticateResponse; | ||
} | ||
|
||
private char transformChar(char c) { | ||
if (Character.isLowerCase(c)) { | ||
return Character.toUpperCase(c); | ||
} | ||
if (Character.isUpperCase(c)) { | ||
return Character.toLowerCase(c); | ||
} | ||
// For anything non-alphabetic we can just return a random alpha char | ||
return randomAlphaOfLength(1).charAt(0); | ||
} | ||
|
||
} |
Oops, something went wrong.