diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtIssuer.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtIssuer.java index dde44555e5b85..1b6713a55a21d 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtIssuer.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtIssuer.java @@ -38,17 +38,17 @@ record AlgJwkPair(String alg, JWK jwk) {} final List audiencesClaimValue; // claim name is hard-coded to `aud` for OIDC ID Token compatibility final String principalClaimName; // claim name is configurable, EX: Users (sub, oid, email, dn, uid), Clients (azp, appid, client_id) final Map principals; // principals with roles, for sending encoded JWTs into JWT realms for authc/authz verification - final List algAndJwksPkc; - final List algAndJwksHmac; - final AlgJwkPair algAndJwkHmacOidc; + List algAndJwksPkc; + List algAndJwksHmac; + AlgJwkPair algAndJwkHmacOidc; // Computed values - final List algAndJwksAll; - final Set algorithmsAll; - final String encodedJwkSetPkcPrivate; - final String encodedJwkSetPkcPublic; - final String encodedJwkSetHmac; - final String encodedKeyHmacOidc; + List algAndJwksAll; + Set algorithmsAll; + String encodedJwkSetPkcPrivate; + String encodedJwkSetPkcPublic; + String encodedJwkSetHmac; + String encodedKeyHmacOidc; final JwtIssuerHttpsServer httpsServer; JwtIssuer( @@ -94,6 +94,37 @@ record AlgJwkPair(String alg, JWK jwk) {} } } + public void rotate( + final List algAndJwksPkc, + final List algAndJwksHmac, + final AlgJwkPair algAndJwkHmacOidc + ) { + this.algAndJwksPkc = algAndJwksPkc; + this.algAndJwksHmac = algAndJwksHmac; + this.algAndJwkHmacOidc = algAndJwkHmacOidc; + + this.algAndJwksAll = new ArrayList<>(this.algAndJwksPkc.size() + this.algAndJwksHmac.size() + 1); + this.algAndJwksAll.addAll(this.algAndJwksPkc); + this.algAndJwksAll.addAll(this.algAndJwksHmac); + if (this.algAndJwkHmacOidc != null) { + this.algAndJwksAll.add(this.algAndJwkHmacOidc); + } + + final JWKSet jwkSetPkc = new JWKSet(this.algAndJwksPkc.stream().map(p -> p.jwk).toList()); + final JWKSet jwkSetHmac = new JWKSet(this.algAndJwksHmac.stream().map(p -> p.jwk).toList()); + + this.encodedJwkSetPkcPrivate = jwkSetPkc.getKeys().isEmpty() ? null : JwtUtil.serializeJwkSet(jwkSetPkc, false); + this.encodedJwkSetPkcPublic = jwkSetPkc.getKeys().isEmpty() ? null : JwtUtil.serializeJwkSet(jwkSetPkc, true); + this.encodedJwkSetHmac = jwkSetHmac.getKeys().isEmpty() ? null : JwtUtil.serializeJwkSet(jwkSetHmac, false); + this.encodedKeyHmacOidc = (algAndJwkHmacOidc == null) ? null : JwtUtil.serializeJwkHmacOidc(this.algAndJwkHmacOidc.jwk); + + if (this.httpsServer != null) { + final byte[] encodedJwkSetPkcPublicBytes = this.encodedJwkSetPkcPublic.getBytes(StandardCharsets.UTF_8); + this.httpsServer.rotate(encodedJwkSetPkcPublicBytes); + } + + } + @Override public void close() { if (this.httpsServer != null) { diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtIssuerHttpsServer.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtIssuerHttpsServer.java index 24eedcabc622f..c5796f9fe78ed 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtIssuerHttpsServer.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtIssuerHttpsServer.java @@ -68,6 +68,11 @@ public JwtIssuerHttpsServer(final byte[] encodedJwkSetPkcPublicBytes) throws Exc LOGGER.debug("Started [{}]", this.url); } + public void rotate(final byte[] encodedJwkSetPkcPublicBytes) { + this.httpsServer.removeContext(PATH); + this.httpsServer.createContext(PATH, new JwtIssuerHttpHandler(encodedJwkSetPkcPublicBytes)); + } + @Override public void close() throws IOException { if (this.httpsServer != null) { diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtRealmAuthenticateTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtRealmAuthenticateTests.java index 693fdda71882e..8ded8afcac55c 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtRealmAuthenticateTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtRealmAuthenticateTests.java @@ -170,6 +170,53 @@ public void testPkcJwkSetUrlNotFound() throws Exception { } } + /** + * Verify that a JWT realm successfully connects to HTTPS server, and can handle an HTTP 404 Not Found response correctly. + * @throws Exception Unexpected test failure + */ + public void testPkcJwkSetRotation() throws Exception { + final JwtRealmsService jwtRealmsService = this.generateJwtRealmsService(this.createJwtRealmsSettingsBuilder()); + final String principalClaimName = randomFrom(jwtRealmsService.getPrincipalClaimNames()); + + final List allRealms = new ArrayList<>(); // authc and authz realms + final boolean createHttpsServer = true; // force issuer to create HTTPS server for its PKC JWKSet + final JwtIssuer jwtIssuer = this.createJwtIssuer(0, principalClaimName, 12, 1, 1, 1, createHttpsServer); + assertThat(jwtIssuer.httpsServer, is(notNullValue())); + try { + final JwtRealmSettingsBuilder jwtRealmSettingsBuilder = this.createJwtRealmSettingsBuilder(jwtIssuer, 0, 0); + this.jwtIssuerAndRealms = new ArrayList<>(1); + final JwtRealm jwtRealm = this.createJwtRealm(allRealms, jwtRealmsService, jwtIssuer, jwtRealmSettingsBuilder); + final JwtIssuerAndRealm jwtIssuerAndRealm = new JwtIssuerAndRealm(jwtIssuer, jwtRealm, jwtRealmSettingsBuilder); + + jwtRealm.initialize(allRealms, super.licenseState); + this.jwtIssuerAndRealms.add(jwtIssuerAndRealm); // add them so the test will clean them up + + final User user = this.randomUser(jwtIssuerAndRealm.issuer()); + SecureString jwt = this.randomJwt(jwtIssuerAndRealm, user); + final SecureString clientSecret = jwtIssuerAndRealm.realm().clientAuthenticationSharedSecret; + final MinMax jwtAuthcRange = new MinMax(2, 3); + + // Indirectly verify authentication works + this.doMultipleAuthcAuthzAndVerifySuccess(jwtIssuerAndRealm.realm(), user, jwt, clientSecret, jwtAuthcRange); + + // Rotate + this.rotateJWKsJwtIssuer(jwtIssuer); + + SecureString rotatedJwt = this.randomJwt(jwtIssuerAndRealm, user); + // Verify authentication works before performing any failure scenarios + this.doMultipleAuthcAuthzAndVerifySuccess(jwtIssuerAndRealm.realm(), user, rotatedJwt, clientSecret, jwtAuthcRange); + + // Should fail as we are using an old token and have rotated + expectThrows( + Exception.class, + () -> this.doMultipleAuthcAuthzAndVerifySuccess(jwtIssuerAndRealm.realm(), user, jwt, clientSecret, jwtAuthcRange) + ); + + } finally { + jwtIssuer.close(); + } + } + /** * Test token parse failures and authentication failures. * @throws Exception Unexpected test failure diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtRealmTestCase.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtRealmTestCase.java index 68de795f4ebe6..786d73b1ef2d0 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtRealmTestCase.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtRealmTestCase.java @@ -50,6 +50,7 @@ import java.util.Date; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.TreeSet; import java.util.concurrent.ExecutionException; import java.util.stream.IntStream; @@ -189,6 +190,37 @@ protected List generateJwtIssuerRealmPairs( return this.jwtIssuerAndRealms; } + protected void rotateJWKsJwtIssuer( + final JwtIssuer jwtIssuer + ) throws Exception { + final String issuer = jwtIssuer.issuerClaimValue; + + // Allow algorithm repeats, to cover testing of multiple JWKs for same algorithm + final Set algs = jwtIssuer.algorithmsAll; + final List algsPkc = algs.stream().filter(JwtRealmSettings.SUPPORTED_SIGNATURE_ALGORITHMS_PKC::contains).toList(); + final List algsHmac = algs.stream().filter(JwtRealmSettings.SUPPORTED_SIGNATURE_ALGORITHMS_HMAC::contains).toList(); + final List algJwkPairsPkc = JwtTestCase.randomJwks(algsPkc); + // Key setting vs JWKSet setting are mutually exclusive, do not populate both + final List algJwkPairsHmac = new ArrayList<>(JwtTestCase.randomJwks(algsHmac)); // allow remove/add below + final JwtIssuer.AlgJwkPair algJwkPairHmacOidc; + if ((algJwkPairsHmac.size() == 0) || (randomBoolean())) { + algJwkPairHmacOidc = null; // list(0||1||N) => Key=null and JWKSet(N) + } else { + // Change one of the HMAC random bytes keys to an OIDC UTF8 key. Put it in either the Key setting or JWKSet setting. + final JwtIssuer.AlgJwkPair algJwkPairRandomBytes = algJwkPairsHmac.get(0); + final OctetSequenceKey jwkHmacRandomBytes = JwtTestCase.conditionJwkHmacForOidc((OctetSequenceKey) algJwkPairRandomBytes.jwk()); + final JwtIssuer.AlgJwkPair algJwkPairUtf8Bytes = new JwtIssuer.AlgJwkPair(algJwkPairRandomBytes.alg(), jwkHmacRandomBytes); + if ((algJwkPairsHmac.size() == 1) && (randomBoolean())) { + algJwkPairHmacOidc = algJwkPairUtf8Bytes; // list(1) => Key=OIDC and JWKSet(0) + algJwkPairsHmac.remove(0); + } else { + algJwkPairHmacOidc = null; // list(N) => Key=null and JWKSet(OIDC+N-1) + algJwkPairsHmac.set(0, algJwkPairUtf8Bytes); + } + } + jwtIssuer.rotate(algJwkPairsPkc, algJwkPairsHmac, algJwkPairHmacOidc); + } + protected JwtIssuer createJwtIssuer( final int i, final String principalClaimName, @@ -618,6 +650,7 @@ protected void doMultipleAuthcAuthzAndVerifySuccess( } } catch (Throwable t) { final Exception authcFailed = new Exception("Authentication test failed."); + LOGGER.error(t.getMessage()); realmFailureExceptions.forEach(authcFailed::addSuppressed); // realm exceptions authcFailed.addSuppressed(t); // final throwable (ex: assertThat) LOGGER.error("Unexpected exception.", authcFailed);