1
0
Эх сурвалжийг харах

Support rotatating the JWT shared secret (#99278)

This commit adds support to reload the JWT shared secret.
Notably this commit also includes support for a rotatable secret which includes
support for a configurable grace period where the elder value (after rotation)
is still accessible. This allows a time bound leniency where both values are
valid at the same time to help mitigate tightly coupled systems rotations.
The rotatable secret currently only supports checking if it is set or it matches
an external secret. However, future updates will accept a function that
can be be used as input to a 3rd party system that can try the current secret
but automatically fall back to the prior secret if that fails during the grace period.
The implementation of rotatable secret uses a StampedLock with optimistic
reads to help ensure minimal performance impact for the reading and expiry
of the secret.
Jake Landis 2 жил өмнө
parent
commit
19e3036458

+ 5 - 0
docs/changelog/99278.yaml

@@ -0,0 +1,5 @@
+pr: 99278
+summary: Support rotatating the JWT shared secret
+area: Security
+type: enhancement
+issues: []

+ 10 - 1
docs/reference/settings/security-settings.asciidoc

@@ -2266,11 +2266,20 @@ restricts which ones are allowed to submit those JWTs to {es}.
 
 // tag::jwt-client-authentication-shared-secret-tag[]
 `client_authentication.shared_secret` {ess-icon}::
-(<<secure-settings,Secure>>)
+(<<secure-settings,Secure>>, <<reloadable-secure-settings,reloadable>>)
 Secret value string for client authentication.
 Required if `client_authentication.type` is `shared_secret`.
 // end::jwt-client-authentication-shared-secret-tag[]
 
+// tag::jwt-client-authentication-rotation-grace-period-tag[]
+`client_authentication.rotation_grace_period`::
+(<<static-cluster-setting,Static>>)
+Sets the grace period for how long after rotating the `client_authentication.shared_secret`
+is valid. `client_authentication.shared_secret` can be rotated by updating the
+keystore then calling the <<cluster-nodes-reload-secure-settings, reload API>>.
+Defaults to `1m`.
+// end::jwt-client-authentication-rotation-grace-period-tag[]
+
 // tag::jwt-http-connect-timeout-tag[]
 `http.connect_timeout` {ess-icon}::
 (<<static-cluster-setting,Static>>)

+ 130 - 0
server/src/main/java/org/elasticsearch/common/settings/RotatableSecret.java

@@ -0,0 +1,130 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+package org.elasticsearch.common.settings;
+
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.core.Nullable;
+import org.elasticsearch.core.TimeValue;
+
+import java.time.Instant;
+import java.util.concurrent.locks.StampedLock;
+
+/**
+ * A container for a {@link SecureString} that can be rotated with a grace period for the secret that has been rotated out.
+ * Once rotated the prior secret is available for a configured amount of time before it is invalidated.
+ * This allows for secret rotation without temporary failures or the need to tightly orchestrate
+ * multiple parties. This class is threadsafe, however it is also assumes that reading secrets are frequent (i.e. every request)
+ * but rotation is a rare (i.e. once a day).
+ */
+public class RotatableSecret {
+    private Secrets secrets;
+    private final StampedLock stampedLock = new StampedLock();
+
+    /**
+     * @param secret The secret to rotate. {@code null} if the secret is not configured.
+     */
+    public RotatableSecret(@Nullable SecureString secret) {
+        this.secrets = new Secrets(Strings.hasText(secret) ? secret.clone() : null, null, Instant.EPOCH);
+    }
+
+    /**
+     * Rotates the secret iff the new secret and current secret are different. If rotated, the current secret is moved to the prior secret
+     * which is valid for the given grace period and new secret is now considered the current secret.
+     * @param newSecret the secret to rotate in.
+     * @param gracePeriod the time period that the prior secret is valid.
+     */
+    public void rotate(SecureString newSecret, TimeValue gracePeriod) {
+        long stamp = stampedLock.writeLock();
+        try {
+            if (secrets.current == null || secrets.current.equals(newSecret) == false) {
+                secrets = new Secrets(
+                    Strings.hasText(newSecret) ? newSecret.clone() : null,
+                    secrets.current,
+                    Instant.now().plusMillis(gracePeriod.getMillis())
+                );
+            }
+        } finally {
+            stampedLock.unlockWrite(stamp);
+        }
+    }
+
+    /**
+     * @return true if the current or prior value has a non-null and a non-empty value
+     */
+    public boolean isSet() {
+        checkExpired();
+        return Strings.hasText(secrets.current) || Strings.hasText(secrets.prior);
+    }
+
+    /**
+     * Check to see if the current or (non-expired) prior secret matches the passed in secret.
+     * @param secret The secret to match against.
+     * @return true if either the current or (non-expired) prior secret matches.
+     * false if nether match. false if current and prior secret are unset. false if passed in secret is null or empty
+     */
+    public boolean matches(SecureString secret) {
+        checkExpired();
+        if ((Strings.hasText(secrets.current) == false && Strings.hasText(secrets.prior) == false) || Strings.hasText(secret) == false) {
+            return false;
+        }
+        return secrets.current.equals(secret) || (secrets.prior != null && secrets.prior.equals(secret));
+    }
+
+    // for testing only
+    Secrets getSecrets() {
+        return secrets;
+    }
+
+    // for testing only
+    boolean isWriteLocked() {
+        return stampedLock.isWriteLocked();
+    }
+
+    /**
+     * Checks to see if the prior secret TTL has expired. If expired, evict from the backing data structure. Always call this before
+     * reading the secret(s).
+     */
+    private void checkExpired() {
+        boolean needToUnlock = false;
+        long stamp = stampedLock.tryOptimisticRead();
+        boolean expired = secrets.prior != null && secrets.priorValidTill.isBefore(Instant.now()); // optimistic read
+        if (stampedLock.validate(stamp) == false) {
+            // optimism failed...potentially block to obtain the read lock and try the read again
+            stamp = stampedLock.readLock();
+            needToUnlock = true;
+            expired = secrets.prior != null && secrets.priorValidTill.isBefore(Instant.now()); // locked read
+        }
+        try {
+            if (expired) {
+                long stampUpgrade = stampedLock.tryConvertToWriteLock(stamp);
+                if (stampUpgrade == 0) {
+                    // upgrade failed so we need to manually unlock the read lock and grab the write lock
+                    if (needToUnlock) {
+                        stampedLock.unlockRead(stamp);
+                    }
+                    stamp = stampedLock.writeLock();
+                    expired = secrets.prior != null && secrets.priorValidTill.isBefore(Instant.now()); // check again since we had to unlock
+                } else {
+                    stamp = stampUpgrade;
+                }
+                needToUnlock = true;
+                if (expired) {
+                    SecureString prior = secrets.prior;
+                    secrets = new Secrets(secrets.current, null, Instant.EPOCH);
+                    prior.close(); // zero out the memory
+                }
+            }
+        } finally {
+            if (needToUnlock) { // only unlock if we acquired a read or write lock
+                stampedLock.unlock(stamp);
+            }
+        }
+    }
+
+    public record Secrets(SecureString current, SecureString prior, Instant priorValidTill) {};
+}

+ 249 - 0
server/src/test/java/org/elasticsearch/common/settings/RotatableSecretTests.java

@@ -0,0 +1,249 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.common.settings;
+
+import org.elasticsearch.core.TimeValue;
+import org.elasticsearch.test.ESTestCase;
+import org.mockito.stubbing.Answer;
+
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class RotatableSecretTests extends ESTestCase {
+
+    private final SecureString secret1 = new SecureString(randomAlphaOfLength(10));
+    private final SecureString secret2 = new SecureString(randomAlphaOfLength(10));
+    private final SecureString secret3 = new SecureString(randomAlphaOfLength(10));
+
+    public void testBasicRotation() throws Exception {
+        // initial state
+        RotatableSecret rotatableSecret = new RotatableSecret(secret1);
+        assertTrue(rotatableSecret.matches(secret1));
+        assertFalse(rotatableSecret.matches(secret2));
+        assertFalse(rotatableSecret.matches(new SecureString(randomAlphaOfLength(10))));
+        assertTrue(rotatableSecret.isSet());
+        assertEquals(secret1, rotatableSecret.getSecrets().current());
+        assertNull(rotatableSecret.getSecrets().prior());
+        assertEquals(Instant.EPOCH, rotatableSecret.getSecrets().priorValidTill());
+
+        // normal rotation
+        TimeValue expiresIn = TimeValue.timeValueDays(1);
+        rotatableSecret.rotate(secret2, expiresIn);
+        assertTrue(rotatableSecret.matches(secret1));
+        assertTrue(rotatableSecret.matches(secret2));
+        assertFalse(rotatableSecret.matches(new SecureString(randomAlphaOfLength(10))));
+        assertTrue(rotatableSecret.isSet());
+        assertEquals(secret2, rotatableSecret.getSecrets().current());
+        assertEquals(secret1, rotatableSecret.getSecrets().prior());
+        assertTrue(rotatableSecret.getSecrets().priorValidTill().isAfter(Instant.now()));
+        assertTrue(
+            rotatableSecret.getSecrets().priorValidTill().isBefore(Instant.now().plusMillis(TimeValue.timeValueDays(2).getMillis()))
+        );
+
+        // attempt to rotate same value does nothing
+        rotatableSecret.rotate(secret2, TimeValue.timeValueDays(99)); // ignores the new expiry since you can't rotate the same secret
+        assertTrue(rotatableSecret.matches(secret1));
+        assertTrue(rotatableSecret.matches(secret2));
+        assertFalse(rotatableSecret.matches(new SecureString(randomAlphaOfLength(10))));
+        assertTrue(rotatableSecret.isSet());
+        assertEquals(secret2, rotatableSecret.getSecrets().current());
+        assertEquals(secret1, rotatableSecret.getSecrets().prior());
+        assertTrue(rotatableSecret.getSecrets().priorValidTill().isAfter(Instant.now()));
+        assertTrue(
+            rotatableSecret.getSecrets().priorValidTill().isBefore(Instant.now().plusMillis(TimeValue.timeValueDays(2).getMillis()))
+        );
+
+        // rotate with expiry
+        rotatableSecret.rotate(secret3, TimeValue.timeValueMillis(1));
+        Thread.sleep(2); // ensure secret2 is expired
+        assertTrue(rotatableSecret.matches(secret3));
+        assertFalse(rotatableSecret.matches(secret1));
+        assertFalse(rotatableSecret.matches(secret2));
+        assertFalse(rotatableSecret.matches(new SecureString(randomAlphaOfLength(10))));
+        assertTrue(rotatableSecret.isSet());
+        assertEquals(secret3, rotatableSecret.getSecrets().current());
+        assertNull(rotatableSecret.getSecrets().prior());
+        assertTrue(rotatableSecret.getSecrets().priorValidTill().isBefore(Instant.now()));
+
+        // unset current and prior
+        rotatableSecret.rotate(null, TimeValue.ZERO);
+        assertFalse(rotatableSecret.matches(secret3));
+        assertFalse(rotatableSecret.matches(secret1));
+        assertFalse(rotatableSecret.matches(secret2));
+        assertFalse(rotatableSecret.matches(new SecureString(randomAlphaOfLength(10))));
+        assertFalse(rotatableSecret.isSet());
+        assertNull(rotatableSecret.getSecrets().current());
+        assertNull(rotatableSecret.getSecrets().prior());
+        assertTrue(rotatableSecret.getSecrets().priorValidTill().isBefore(Instant.now()));
+    }
+
+    public void testConcurrentReadWhileLocked() throws Exception {
+        // initial state
+        RotatableSecret rotatableSecret = new RotatableSecret(secret1);
+        assertTrue(rotatableSecret.matches(secret1));
+        assertFalse(rotatableSecret.matches(secret2));
+        assertEquals(secret1, rotatableSecret.getSecrets().current());
+        assertNull(rotatableSecret.getSecrets().prior());
+
+        boolean expired = randomBoolean();
+        CountDownLatch latch = new CountDownLatch(1);
+        TimeValue mockGracePeriod = mock(TimeValue.class);  // use a mock to force a long rotation to exercise the concurrency
+        when(mockGracePeriod.getMillis()).then((Answer<Long>) invocation -> {
+            latch.await();
+            return expired ? 0L : Long.MAX_VALUE;
+        });
+
+        // start writer thread
+        Thread t1 = new Thread(() -> rotatableSecret.rotate(secret2, mockGracePeriod));
+        t1.start();
+        assertBusy(() -> assertEquals(Thread.State.WAITING, t1.getState())); // waiting on countdown latch, holds write lock
+        assertTrue(rotatableSecret.isWriteLocked());
+
+        // start reader threads
+        int readers = randomIntBetween(1, 16);
+        Set<Thread> readerThreads = new HashSet<>(readers);
+        for (int i = 0; i < readers; i++) {
+            Thread t = new Thread(() -> {
+                if (randomBoolean()) { // either matches or isSet can block
+                    if (expired) {
+                        assertFalse(rotatableSecret.matches(secret1));
+                    } else {
+                        assertTrue(rotatableSecret.matches(secret1));
+                    }
+                    assertTrue(rotatableSecret.matches(secret2));
+                } else {
+                    assertTrue(rotatableSecret.isSet());
+                }
+            });
+            readerThreads.add(t);
+            t.start();
+        }
+        for (Thread t : readerThreads) {
+            assertBusy(() -> assertEquals(Thread.State.WAITING, t.getState())); // waiting on write lock from thread 1 to be released
+        }
+        assertTrue(rotatableSecret.isWriteLocked());
+        latch.countDown(); // let thread1 finish, which also unblocks the reader threads
+        assertBusy(() -> assertEquals(Thread.State.TERMINATED, t1.getState())); // done with work
+        for (Thread t : readerThreads) {
+            assertBusy(() -> assertEquals(Thread.State.TERMINATED, t.getState())); // done with work
+            t.join();
+        }
+        t1.join();
+        assertFalse(rotatableSecret.isWriteLocked());
+    }
+
+    public void testConcurrentRotations() throws Exception {
+        // initial state
+        RotatableSecret rotatableSecret = new RotatableSecret(secret1);
+        assertTrue(rotatableSecret.matches(secret1));
+        assertFalse(rotatableSecret.matches(secret2));
+        assertEquals(secret1, rotatableSecret.getSecrets().current());
+        assertNull(rotatableSecret.getSecrets().prior());
+
+        // start first rotation
+        AtomicBoolean latch1 = new AtomicBoolean(false); // using boolean as latch to differentiate the kinds of waiting
+        TimeValue mockGracePeriod1 = mock(TimeValue.class);  // use a mock to force a long rotation to exercise the concurrency
+        when(mockGracePeriod1.getMillis()).then((Answer<Long>) invocation -> {
+            while (latch1.get() == false) {
+                Thread.sleep(10); // thread in TIMED_WAITING
+            }
+            return Long.MAX_VALUE;
+        });
+        Thread t1 = new Thread(() -> rotatableSecret.rotate(secret2, mockGracePeriod1));
+        t1.start();
+        assertBusy(() -> assertEquals(Thread.State.TIMED_WAITING, t1.getState())); // waiting on latch, holds write lock
+
+        // start second rotation
+        AtomicBoolean latch2 = new AtomicBoolean(false);
+        TimeValue mockGracePeriod2 = mock(TimeValue.class);  // use a mock to force a long rotation to exercise the concurrency
+        when(mockGracePeriod2.getMillis()).then((Answer<Long>) invocation -> {
+            while (latch2.get() == false) {
+                Thread.sleep(10); // thread in TIMED_WAITING
+            }
+            return Long.MAX_VALUE;
+        });
+        Thread t2 = new Thread(() -> rotatableSecret.rotate(secret3, mockGracePeriod2));
+        t2.start();
+        assertBusy(() -> assertEquals(Thread.State.WAITING, t2.getState())); // waiting on write lock from thread 1
+
+        // start third rotation
+        AtomicBoolean latch3 = new AtomicBoolean(false);
+        TimeValue mockGracePeriod3 = mock(TimeValue.class);  // use a mock to force a long rotation to exercise the concurrency
+        when(mockGracePeriod3.getMillis()).then((Answer<Long>) invocation -> {
+            while (latch3.get() == false) {
+                Thread.sleep(10); // thread in TIMED_WAITING
+            }
+            return Long.MAX_VALUE;
+        });
+        Thread t3 = new Thread(() -> rotatableSecret.rotate(null, mockGracePeriod3));
+        t3.start();
+        assertBusy(() -> assertEquals(Thread.State.WAITING, t3.getState())); // waiting on write lock from thread 1
+
+        // initial state
+        assertEquals(rotatableSecret.getSecrets().current(), secret1);
+        assertNull(rotatableSecret.getSecrets().prior());
+        assertBusy(() -> assertEquals(Thread.State.TIMED_WAITING, t1.getState())); // waiting on latch
+        assertBusy(() -> assertEquals(Thread.State.WAITING, t2.getState())); // waiting on lock
+        assertBusy(() -> assertEquals(Thread.State.WAITING, t3.getState())); // waiting on lock
+
+        latch1.set(true); // let first rotation succeed
+        assertBusy(() -> assertEquals(Thread.State.TERMINATED, t1.getState())); // work done
+        assertBusy(() -> assertEquals(Thread.State.TIMED_WAITING, t2.getState())); // waiting on latch
+        assertBusy(() -> assertEquals(Thread.State.WAITING, t3.getState()));  // waiting lock
+        assertEquals(rotatableSecret.getSecrets().current(), secret2);
+        assertEquals(rotatableSecret.getSecrets().prior(), secret1);
+
+        latch2.set(true); // let second rotation succeed
+        assertBusy(() -> assertEquals(Thread.State.TERMINATED, t1.getState())); // work done
+        assertBusy(() -> assertEquals(Thread.State.TERMINATED, t2.getState())); // work done
+        assertBusy(() -> assertEquals(Thread.State.TIMED_WAITING, t3.getState())); // waiting on latch
+        assertEquals(rotatableSecret.getSecrets().current(), secret3);
+        assertEquals(rotatableSecret.getSecrets().prior(), secret2);
+
+        latch3.set(true); // let third rotation succeed
+        assertBusy(() -> assertEquals(Thread.State.TERMINATED, t1.getState())); // work done
+        assertBusy(() -> assertEquals(Thread.State.TERMINATED, t2.getState())); // work done
+        assertBusy(() -> assertEquals(Thread.State.TERMINATED, t3.getState())); // work done
+        assertEquals(rotatableSecret.getSecrets().current(), null);
+        assertEquals(rotatableSecret.getSecrets().prior(), secret3);
+
+        t1.join();
+        t2.join();
+        t3.join();
+    }
+
+    public void testUnsetThenRotate() {
+        // it is not set on startup
+        RotatableSecret rotatableSecret = new RotatableSecret(null);
+        assertFalse(rotatableSecret.matches(new SecureString(randomAlphaOfLength(10))));
+        assertFalse(rotatableSecret.isSet());
+        assertNull(rotatableSecret.getSecrets().current());
+        assertNull(rotatableSecret.getSecrets().prior());
+        assertEquals(Instant.EPOCH, rotatableSecret.getSecrets().priorValidTill());
+
+        // normal rotation for when it was not set on startup
+        TimeValue expiresIn = TimeValue.timeValueDays(1);
+        rotatableSecret.rotate(secret1, expiresIn);
+        assertTrue(rotatableSecret.matches(secret1));
+        assertFalse(rotatableSecret.matches(new SecureString(randomAlphaOfLength(10))));
+        assertTrue(rotatableSecret.isSet());
+        assertEquals(secret1, rotatableSecret.getSecrets().current());
+        assertNull(rotatableSecret.getSecrets().prior());
+        assertTrue(rotatableSecret.getSecrets().priorValidTill().isAfter(Instant.now()));
+        assertTrue(
+            rotatableSecret.getSecrets().priorValidTill().isBefore(Instant.now().plusMillis(TimeValue.timeValueDays(2).getMillis()))
+        );
+    }
+}

+ 9 - 1
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/jwt/JwtRealmSettings.java

@@ -120,6 +120,7 @@ public class JwtRealmSettings {
     private static final List<String> DEFAULT_ALLOWED_SIGNATURE_ALGORITHMS = Collections.singletonList("RS256");
     private static final boolean DEFAULT_POPULATE_USER_METADATA = true;
     private static final TimeValue DEFAULT_JWT_CACHE_TTL = TimeValue.timeValueMinutes(20);
+    private static final TimeValue DEFAULT_JWT_CLIENT_AUTH_GRACE_PERIOD = TimeValue.timeValueMinutes(1);
     private static final int DEFAULT_JWT_CACHE_SIZE = 100_000;
     private static final int MIN_JWT_CACHE_SIZE = 0;
     private static final TimeValue DEFAULT_HTTP_CONNECT_TIMEOUT = TimeValue.timeValueSeconds(5);
@@ -172,7 +173,8 @@ public class JwtRealmSettings {
                 CLAIMS_MAIL.getPattern(),
                 CLAIMS_NAME.getClaim(),
                 CLAIMS_NAME.getPattern(),
-                POPULATE_USER_METADATA
+                POPULATE_USER_METADATA,
+                CLIENT_AUTH_SHARED_SECRET_ROTATION_GRACE_PERIOD
             )
         );
         // JWT Client settings
@@ -355,6 +357,12 @@ public class JwtRealmSettings {
         "client_authentication.shared_secret"
     );
 
+    public static final Setting.AffixSetting<TimeValue> CLIENT_AUTH_SHARED_SECRET_ROTATION_GRACE_PERIOD = Setting.affixKeySetting(
+        RealmSettings.realmSettingPrefix(TYPE),
+        "client_authentication.rotation_grace_period",
+        key -> Setting.timeSetting(key, DEFAULT_JWT_CLIENT_AUTH_GRACE_PERIOD, Setting.Property.NodeScope)
+    );
+
     // Individual Cache settings
 
     public static final Setting.AffixSetting<TimeValue> JWT_CACHE_TTL = Setting.affixKeySetting(

+ 1 - 0
x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/test/SecuritySingleNodeTestCase.java

@@ -277,6 +277,7 @@ public abstract class SecuritySingleNodeTestCase extends ESSingleNodeTestCase {
      * Creates a new client if the method is invoked for the first time in the context of the current test scope.
      * The returned client gets automatically closed when needed, it shouldn't be closed as part of tests otherwise
      * it cannot be reused by other tests anymore.
+     * Requires that {@link org.elasticsearch.test.ESSingleNodeTestCase#addMockHttpTransport()} is overriden and set to false.
      */
     protected RestClient getRestClient() {
         return getRestClient(client());

+ 162 - 6
x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/jwt/JwtRealmSingleNodeTests.java

@@ -7,31 +7,55 @@
 
 package org.elasticsearch.xpack.security.authc.jwt;
 
+import com.nimbusds.jose.JWSAlgorithm;
 import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.MACSigner;
+import com.nimbusds.jose.jwk.OctetSequenceKey;
 import com.nimbusds.jose.util.Base64URL;
 import com.nimbusds.jwt.JWTClaimsSet;
 import com.nimbusds.jwt.SignedJWT;
 
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.RequestOptions;
+import org.elasticsearch.client.ResponseException;
+import org.elasticsearch.client.RestClient;
+import org.elasticsearch.common.settings.MockSecureSettings;
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.common.util.concurrent.ThreadContext;
 import org.elasticsearch.core.Strings;
+import org.elasticsearch.core.TimeValue;
+import org.elasticsearch.plugins.Plugin;
+import org.elasticsearch.plugins.PluginsService;
 import org.elasticsearch.test.SecuritySettingsSource;
 import org.elasticsearch.test.SecuritySingleNodeTestCase;
+import org.elasticsearch.test.junit.annotations.TestLogging;
+import org.elasticsearch.xpack.core.security.authc.Realm;
+import org.elasticsearch.xpack.security.LocalStateSecurity;
+import org.elasticsearch.xpack.security.Security;
 import org.elasticsearch.xpack.security.authc.Realms;
 
+import java.nio.charset.StandardCharsets;
 import java.text.ParseException;
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 
+import static org.elasticsearch.xpack.core.security.authc.jwt.JwtRealmSettings.CLIENT_AUTH_SHARED_SECRET_ROTATION_GRACE_PERIOD;
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.nullValue;
 
 public class JwtRealmSingleNodeTests extends SecuritySingleNodeTestCase {
 
+    private final String jwt0SharedSecret = "jwt0_shared_secret";
+    private final String jwt1SharedSecret = "jwt1_shared_secret";
+    private final String jwt2SharedSecret = "jwt2_shared_secret";
+    private final String jwtHmacKey = "test-HMAC/secret passphrase-value";
+
     @Override
     protected Settings nodeSettings() {
         final Settings.Builder builder = Settings.builder()
@@ -59,6 +83,7 @@ public class JwtRealmSingleNodeTests extends SecuritySingleNodeTestCase {
             .put("xpack.security.authc.realms.jwt.jwt1.claims.principal", "appid")
             .put("xpack.security.authc.realms.jwt.jwt1.claims.groups", "groups")
             .put("xpack.security.authc.realms.jwt.jwt1.client_authentication.type", "shared_secret")
+            .put("xpack.security.authc.realms.jwt.jwt1.client_authentication.rotation_grace_period", "10m")
             .putList("xpack.security.authc.realms.jwt.jwt1.allowed_signature_algorithms", "HS256", "HS384")
             // 3rd JWT realm
             .put("xpack.security.authc.realms.jwt.jwt2.order", 30)
@@ -70,20 +95,25 @@ public class JwtRealmSingleNodeTests extends SecuritySingleNodeTestCase {
             .put("xpack.security.authc.realms.jwt.jwt2.claims.principal", "email")
             .put("xpack.security.authc.realms.jwt.jwt2.claims.groups", "groups")
             .put("xpack.security.authc.realms.jwt.jwt2.client_authentication.type", "shared_secret")
+            .put("xpack.security.authc.realms.jwt.jwt2.client_authentication.rotation_grace_period", "0s")
             .putList("xpack.security.authc.realms.jwt.jwt2.allowed_signature_algorithms", "HS256", "HS384");
 
         SecuritySettingsSource.addSecureSettings(builder, secureSettings -> {
-            secureSettings.setString("xpack.security.authc.realms.jwt.jwt0.hmac_key", "jwt0_hmac_key");
-            secureSettings.setString("xpack.security.authc.realms.jwt.jwt0.client_authentication.shared_secret", "jwt0_shared_secret");
-            secureSettings.setString("xpack.security.authc.realms.jwt.jwt1.hmac_key", "jwt1_hmac_key");
-            secureSettings.setString("xpack.security.authc.realms.jwt.jwt1.client_authentication.shared_secret", "jwt1_shared_secret");
-            secureSettings.setString("xpack.security.authc.realms.jwt.jwt2.hmac_key", "jwt2_hmac_key");
-            secureSettings.setString("xpack.security.authc.realms.jwt.jwt2.client_authentication.shared_secret", "jwt2_shared_secret");
+            secureSettings.setString("xpack.security.authc.realms.jwt.jwt0.hmac_key", jwtHmacKey);
+            secureSettings.setString("xpack.security.authc.realms.jwt.jwt0.client_authentication.shared_secret", jwt0SharedSecret);
+            secureSettings.setString("xpack.security.authc.realms.jwt.jwt1.hmac_key", jwtHmacKey);
+            secureSettings.setString("xpack.security.authc.realms.jwt.jwt1.client_authentication.shared_secret", jwt1SharedSecret);
+            secureSettings.setString("xpack.security.authc.realms.jwt.jwt2.hmac_key", jwtHmacKey);
+            secureSettings.setString("xpack.security.authc.realms.jwt.jwt2.client_authentication.shared_secret", jwt2SharedSecret);
         });
 
         return builder.build();
     }
 
+    protected boolean addMockHttpTransport() {
+        return false;
+    }
+
     public void testAnyJwtRealmWillExtractTheToken() throws ParseException {
         final List<JwtRealm> jwtRealms = getJwtRealms();
         final JwtRealm jwtRealm = randomFrom(jwtRealms);
@@ -172,6 +202,132 @@ public class JwtRealmSingleNodeTests extends SecuritySingleNodeTestCase {
         assertThat(e2.getMessage(), containsString("Failed to parse JWT claims set"));
     }
 
+    @TestLogging(value = "org.elasticsearch.xpack.security.authc.jwt:DEBUG", reason = "failures can be very difficult to troubleshoot")
+    public void testClientSecretRotation() throws Exception {
+        final List<JwtRealm> jwtRealms = getJwtRealms();
+        Map<String, JwtRealm> realmsByName = jwtRealms.stream().collect(Collectors.toMap(Realm::name, r -> r));
+        JwtRealm realm0 = realmsByName.get("jwt0");
+        JwtRealm realm1 = realmsByName.get("jwt1");
+        JwtRealm realm2 = realmsByName.get("jwt2");
+        // sanity check
+        assertThat(getGracePeriod(realm0), equalTo(CLIENT_AUTH_SHARED_SECRET_ROTATION_GRACE_PERIOD.getDefault(Settings.EMPTY)));
+        assertThat(getGracePeriod(realm1), equalTo(TimeValue.timeValueMinutes(10)));
+        assertThat(getGracePeriod(realm2), equalTo(TimeValue.timeValueSeconds(0)));
+        // create claims and test before rotation
+        RestClient client = getRestClient();
+        // valid jwt for realm0
+        JWTClaimsSet.Builder jwt0Claims = new JWTClaimsSet.Builder();
+        jwt0Claims.audience("es-01")
+            .issuer("my-issuer-01")
+            .subject("me")
+            .claim("groups", "admin")
+            .issueTime(Date.from(Instant.now()))
+            .expirationTime(Date.from(Instant.now().plusSeconds(600)));
+        assertEquals(
+            200,
+            client.performRequest(getRequest(getSignedJWT(jwt0Claims.build()), jwt0SharedSecret)).getStatusLine().getStatusCode()
+        );
+        // valid jwt for realm1
+        JWTClaimsSet.Builder jwt1Claims = new JWTClaimsSet.Builder();
+        jwt1Claims.audience("es-02")
+            .issuer("my-issuer-02")
+            .subject("user-02")
+            .claim("groups", "admin")
+            .claim("appid", "X")
+            .issueTime(Date.from(Instant.now()))
+            .expirationTime(Date.from(Instant.now().plusSeconds(300)));
+        assertEquals(
+            200,
+            client.performRequest(getRequest(getSignedJWT(jwt1Claims.build()), jwt1SharedSecret)).getStatusLine().getStatusCode()
+        );
+        // valid jwt for realm2
+        JWTClaimsSet.Builder jwt2Claims = new JWTClaimsSet.Builder();
+        jwt2Claims.audience("es-03")
+            .issuer("my-issuer-03")
+            .subject("user-03")
+            .claim("groups", "admin")
+            .claim("email", "me@example.com")
+            .issueTime(Date.from(Instant.now()))
+            .expirationTime(Date.from(Instant.now().plusSeconds(300)));
+        assertEquals(
+            200,
+            client.performRequest(getRequest(getSignedJWT(jwt2Claims.build()), jwt2SharedSecret)).getStatusLine().getStatusCode()
+        );
+        // update the secret in the secure settings
+        final MockSecureSettings newSecureSettings = new MockSecureSettings();
+        newSecureSettings.setString(
+            "xpack.security.authc.realms.jwt." + realm0.name() + ".client_authentication.shared_secret",
+            "realm0updatedSecret"
+        );
+        newSecureSettings.setString(
+            "xpack.security.authc.realms.jwt." + realm1.name() + ".client_authentication.shared_secret",
+            "realm1updatedSecret"
+        );
+        newSecureSettings.setString(
+            "xpack.security.authc.realms.jwt." + realm2.name() + ".client_authentication.shared_secret",
+            "realm2updatedSecret"
+        );
+        // reload settings
+        final PluginsService plugins = getInstanceFromNode(PluginsService.class);
+        final LocalStateSecurity localStateSecurity = plugins.filterPlugins(LocalStateSecurity.class).get(0);
+        for (Plugin p : localStateSecurity.plugins()) {
+            if (p instanceof Security securityPlugin) {
+                Settings.Builder newSettingsBuilder = Settings.builder().setSecureSettings(newSecureSettings);
+                securityPlugin.reload(newSettingsBuilder.build());
+            }
+        }
+        // ensure the old value still works for realm 0 (default grace period)
+        assertEquals(
+            200,
+            client.performRequest(getRequest(getSignedJWT(jwt0Claims.build()), jwt0SharedSecret)).getStatusLine().getStatusCode()
+        );
+        assertEquals(
+            200,
+            client.performRequest(getRequest(getSignedJWT(jwt0Claims.build()), "realm0updatedSecret")).getStatusLine().getStatusCode()
+        );
+        // ensure the old value still works for realm 1 (explicit grace period)
+        assertEquals(
+            200,
+            client.performRequest(getRequest(getSignedJWT(jwt1Claims.build()), jwt1SharedSecret)).getStatusLine().getStatusCode()
+        );
+        assertEquals(
+            200,
+            client.performRequest(getRequest(getSignedJWT(jwt1Claims.build()), "realm1updatedSecret")).getStatusLine().getStatusCode()
+        );
+        // ensure the old value does not work for realm 2 (no grace period)
+        ResponseException exception = expectThrows(
+            ResponseException.class,
+            () -> client.performRequest(getRequest(getSignedJWT(jwt2Claims.build()), jwt2SharedSecret)).getStatusLine().getStatusCode()
+        );
+        assertEquals(401, exception.getResponse().getStatusLine().getStatusCode());
+        assertEquals(
+            200,
+            client.performRequest(getRequest(getSignedJWT(jwt2Claims.build()), "realm2updatedSecret")).getStatusLine().getStatusCode()
+        );
+    }
+
+    private SignedJWT getSignedJWT(JWTClaimsSet claimsSet) throws Exception {
+        JWSHeader jwtHeader = new JWSHeader.Builder(JWSAlgorithm.HS256).build();
+        OctetSequenceKey.Builder jwt0signer = new OctetSequenceKey.Builder(jwtHmacKey.getBytes(StandardCharsets.UTF_8));
+        jwt0signer.algorithm(JWSAlgorithm.HS256);
+        SignedJWT jwt = new SignedJWT(jwtHeader, claimsSet);
+        jwt.sign(new MACSigner(jwt0signer.build()));
+        return jwt;
+    }
+
+    private Request getRequest(SignedJWT jwt, String shardSecret) {
+        Request request = new Request("GET", "/_security/_authenticate");
+        RequestOptions.Builder options = RequestOptions.DEFAULT.toBuilder();
+        options.addHeader("Authorization", "Bearer  " + jwt.serialize());
+        options.addHeader("ES-Client-Authentication", "SharedSecret " + shardSecret);
+        request.setOptions(options);
+        return request;
+    }
+
+    private TimeValue getGracePeriod(JwtRealm realm) {
+        return realm.getConfig().getConcreteSetting(CLIENT_AUTH_SHARED_SECRET_ROTATION_GRACE_PERIOD).get(realm.getConfig().settings());
+    }
+
     private void assertJwtToken(JwtAuthenticationToken token, String tokenPrincipal, String sharedSecret, SignedJWT signedJWT)
         throws ParseException {
         assertThat(token.principal(), equalTo(tokenPrincipal));

+ 20 - 4
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java

@@ -77,6 +77,7 @@ import org.elasticsearch.plugins.IngestPlugin;
 import org.elasticsearch.plugins.MapperPlugin;
 import org.elasticsearch.plugins.NetworkPlugin;
 import org.elasticsearch.plugins.Plugin;
+import org.elasticsearch.plugins.ReloadablePlugin;
 import org.elasticsearch.plugins.SearchPlugin;
 import org.elasticsearch.plugins.SystemIndexPlugin;
 import org.elasticsearch.plugins.interceptor.RestServerActionPlugin;
@@ -178,6 +179,7 @@ import org.elasticsearch.xpack.core.security.authc.InternalRealmsSettings;
 import org.elasticsearch.xpack.core.security.authc.Realm;
 import org.elasticsearch.xpack.core.security.authc.RealmConfig;
 import org.elasticsearch.xpack.core.security.authc.RealmSettings;
+import org.elasticsearch.xpack.core.security.authc.jwt.JwtRealmSettings;
 import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken;
 import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine;
 import org.elasticsearch.xpack.core.security.authz.AuthorizationServiceField;
@@ -405,6 +407,7 @@ import static org.elasticsearch.core.Strings.format;
 import static org.elasticsearch.xpack.core.XPackSettings.API_KEY_SERVICE_ENABLED_SETTING;
 import static org.elasticsearch.xpack.core.XPackSettings.HTTP_SSL_ENABLED;
 import static org.elasticsearch.xpack.core.security.SecurityField.FIELD_LEVEL_SECURITY_FEATURE;
+import static org.elasticsearch.xpack.core.security.authc.jwt.JwtRealmSettings.CLIENT_AUTHENTICATION_SHARED_SECRET;
 import static org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore.INCLUDED_RESERVED_ROLES_SETTING;
 import static org.elasticsearch.xpack.security.operator.OperatorPrivileges.OPERATOR_PRIVILEGES_ENABLED;
 import static org.elasticsearch.xpack.security.transport.SSLEngineUtils.extractClientCertificates;
@@ -419,7 +422,8 @@ public class Security extends Plugin
         MapperPlugin,
         ExtensiblePlugin,
         SearchPlugin,
-        RestServerActionPlugin {
+        RestServerActionPlugin,
+        ReloadablePlugin {
 
     public static final String SECURITY_CRYPTO_THREAD_POOL_NAME = XPackField.SECURITY + "-crypto";
 
@@ -545,7 +549,6 @@ public class Security extends Plugin
     private final SetOnce<TokenService> tokenService = new SetOnce<>();
     private final SetOnce<SecurityActionFilter> securityActionFilter = new SetOnce<>();
     private final SetOnce<CrossClusterAccessAuthenticationService> crossClusterAccessAuthcService = new SetOnce<>();
-
     private final SetOnce<SharedGroupFactory> sharedGroupFactory = new SetOnce<>();
     private final SetOnce<DocumentSubsetBitsetCache> dlsBitsetCache = new SetOnce<>();
     private final SetOnce<List<BootstrapCheck>> bootstrapChecks = new SetOnce<>();
@@ -554,10 +557,9 @@ public class Security extends Plugin
     private final SetOnce<ScriptService> scriptServiceReference = new SetOnce<>();
     private final SetOnce<OperatorOnlyRegistry> operatorOnlyRegistry = new SetOnce<>();
     private final SetOnce<OperatorPrivileges.OperatorPrivilegesService> operatorPrivilegesService = new SetOnce<>();
-
     private final SetOnce<ReservedRoleMappingAction> reservedRoleMappingAction = new SetOnce<>();
-
     private final SetOnce<WorkflowService> workflowService = new SetOnce<>();
+    private final SetOnce<Realms> realms = new SetOnce<>();
 
     public Security(Settings settings) {
         this(settings, Collections.emptyList());
@@ -771,6 +773,7 @@ public class Security extends Plugin
         components.add(nativeRoleMappingStore);
         components.add(realms);
         components.add(reservedRealm);
+        this.realms.set(realms);
 
         systemIndices.getMainIndexManager().addStateListener(nativeRoleMappingStore::onSecurityIndexStateChange);
 
@@ -1900,6 +1903,19 @@ public class Security extends Plugin
         return null;
     }
 
+    @Override
+    public void reload(Settings settings) throws Exception {
+        if (enabled) {
+            realms.get().stream().filter(r -> JwtRealmSettings.TYPE.equals(r.realmRef().getType())).forEach(realm -> {
+                if (realm instanceof JwtRealm jwtRealm) {
+                    jwtRealm.rotateClientSecret(
+                        CLIENT_AUTHENTICATION_SHARED_SECRET.getConcreteSettingForNamespace(realm.realmRef().getName()).get(settings)
+                    );
+                }
+            });
+        }
+    }
+
     static final class ValidateLicenseForFIPS implements BiConsumer<DiscoveryNode, ClusterState> {
         private final boolean inFipsMode;
         private final LicenseService licenseService;

+ 15 - 4
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/jwt/JwtRealm.java

@@ -14,6 +14,7 @@ import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.bytes.BytesArray;
 import org.elasticsearch.common.cache.Cache;
 import org.elasticsearch.common.cache.CacheBuilder;
+import org.elasticsearch.common.settings.RotatableSecret;
 import org.elasticsearch.common.settings.SecureString;
 import org.elasticsearch.common.settings.SettingsException;
 import org.elasticsearch.common.util.concurrent.ReleasableLock;
@@ -49,6 +50,7 @@ import java.util.function.Function;
 
 import static java.lang.String.join;
 import static org.elasticsearch.core.Strings.format;
+import static org.elasticsearch.xpack.core.security.authc.jwt.JwtRealmSettings.CLIENT_AUTH_SHARED_SECRET_ROTATION_GRACE_PERIOD;
 
 /**
  * JWT realms supports JWTs as bearer tokens for authenticating to Elasticsearch.
@@ -71,7 +73,7 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
     private final ClaimParser claimParserMail;
     private final ClaimParser claimParserName;
     private final JwtRealmSettings.ClientAuthenticationType clientAuthenticationType;
-    private final SecureString clientAuthenticationSharedSecret;
+    private final RotatableSecret clientAuthenticationSharedSecret;
     private final JwtAuthenticator jwtAuthenticator;
     private final TimeValue allowedClockSkew;
     DelegatedAuthorizationSupport delegatedAuthorizationSupport = null;
@@ -86,9 +88,9 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
 
         this.populateUserMetadata = realmConfig.getSetting(JwtRealmSettings.POPULATE_USER_METADATA);
         this.clientAuthenticationType = realmConfig.getSetting(JwtRealmSettings.CLIENT_AUTHENTICATION_TYPE);
-        final SecureString sharedSecret = realmConfig.getSetting(JwtRealmSettings.CLIENT_AUTHENTICATION_SHARED_SECRET);
-        this.clientAuthenticationSharedSecret = Strings.hasText(sharedSecret) ? sharedSecret : null; // convert "" to null
-
+        this.clientAuthenticationSharedSecret = new RotatableSecret(
+            realmConfig.getSetting(JwtRealmSettings.CLIENT_AUTHENTICATION_SHARED_SECRET)
+        );
         // Validate Client Authentication settings. Throw SettingsException there was a problem.
         JwtUtil.validateClientAuthenticationSettings(
             RealmSettings.getFullSettingKey(realmConfig, JwtRealmSettings.CLIENT_AUTHENTICATION_TYPE),
@@ -443,6 +445,15 @@ public class JwtRealm extends Realm implements CachingRealm, Releasable {
         }, listener::onFailure));
     }
 
+    public void rotateClientSecret(SecureString clientSecret) {
+        this.clientAuthenticationSharedSecret.rotate(clientSecret, config.getSetting(CLIENT_AUTH_SHARED_SECRET_ROTATION_GRACE_PERIOD));
+    }
+
+    // package private for testing
+    RotatableSecret getClientAuthenticationSharedSecret() {
+        return clientAuthenticationSharedSecret;
+    }
+
     /**
      * Clean up JWT cache (if enabled).
      */

+ 6 - 5
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/jwt/JwtUtil.java

@@ -35,6 +35,7 @@ import org.elasticsearch.action.ActionListener;
 import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.SuppressLoggerChecks;
 import org.elasticsearch.common.hash.MessageDigests;
+import org.elasticsearch.common.settings.RotatableSecret;
 import org.elasticsearch.common.settings.SecureString;
 import org.elasticsearch.common.settings.SettingsException;
 import org.elasticsearch.common.ssl.SslConfiguration;
@@ -99,12 +100,12 @@ public class JwtUtil {
         final String clientAuthenticationTypeConfigKey,
         final JwtRealmSettings.ClientAuthenticationType clientAuthenticationType,
         final String clientAuthenticationSharedSecretConfigKey,
-        final SecureString clientAuthenticationSharedSecret
+        final RotatableSecret clientAuthenticationSharedSecret
     ) throws SettingsException {
         switch (clientAuthenticationType) {
             case SHARED_SECRET:
                 // If type is "SharedSecret", the shared secret value must be set
-                if (Strings.hasText(clientAuthenticationSharedSecret) == false) {
+                if (clientAuthenticationSharedSecret.isSet() == false) {
                     throw new SettingsException(
                         "Missing setting for ["
                             + clientAuthenticationSharedSecretConfigKey
@@ -119,7 +120,7 @@ public class JwtUtil {
             case NONE:
             default:
                 // If type is "None", the shared secret value must not be set
-                if (Strings.hasText(clientAuthenticationSharedSecret)) {
+                if (clientAuthenticationSharedSecret.isSet()) {
                     throw new SettingsException(
                         "Setting ["
                             + clientAuthenticationSharedSecretConfigKey
@@ -141,7 +142,7 @@ public class JwtUtil {
 
     public static void validateClientAuthentication(
         final JwtRealmSettings.ClientAuthenticationType type,
-        final SecureString expectedSecret,
+        final RotatableSecret expectedSecret,
         final SecureString actualSecret,
         final String tokenPrincipal
     ) throws Exception {
@@ -149,7 +150,7 @@ public class JwtUtil {
             case SHARED_SECRET:
                 if (Strings.hasText(actualSecret) == false) {
                     throw new Exception("Rejected client. Authentication type is [" + type + "] and secret is missing.");
-                } else if (expectedSecret.equals(actualSecret) == false) {
+                } else if (expectedSecret.matches(actualSecret) == false) {
                     throw new Exception("Rejected client. Authentication type is [" + type + "] and secret did not match.");
                 }
                 LOGGER.trace("Accepted client for token [{}]. Authentication type is [{}] and secret matched.", tokenPrincipal, type);

+ 1 - 1
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/LocalStateSecurity.java

@@ -126,7 +126,7 @@ public class LocalStateSecurity extends LocalStateCompositeXPackPlugin {
         return SecurityTransportXPackInfoAction.class;
     }
 
-    List<Plugin> plugins() {
+    public List<Plugin> plugins() {
         return plugins;
     }
 }

+ 5 - 4
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtUtilTests.java

@@ -6,6 +6,7 @@
  */
 package org.elasticsearch.xpack.security.authc.jwt;
 
+import org.elasticsearch.common.settings.RotatableSecret;
 import org.elasticsearch.common.settings.SecureString;
 import org.elasticsearch.common.settings.SettingsException;
 import org.elasticsearch.xpack.core.security.authc.jwt.JwtRealmSettings;
@@ -28,7 +29,7 @@ public class JwtUtilTests extends JwtTestCase {
             clientAuthenticationTypeKey,
             JwtRealmSettings.ClientAuthenticationType.NONE,
             clientAuthenticationSharedSecretKey,
-            sharedSecretNullOrEmpty
+            new RotatableSecret(sharedSecretNullOrEmpty)
         );
         // If type is None, verify non-empty is rejected
         final Exception exception1 = expectThrows(
@@ -37,7 +38,7 @@ public class JwtUtilTests extends JwtTestCase {
                 clientAuthenticationTypeKey,
                 JwtRealmSettings.ClientAuthenticationType.NONE,
                 clientAuthenticationSharedSecretKey,
-                sharedSecretNonEmpty
+                new RotatableSecret(sharedSecretNonEmpty)
             )
         );
         assertThat(
@@ -60,7 +61,7 @@ public class JwtUtilTests extends JwtTestCase {
             clientAuthenticationTypeKey,
             JwtRealmSettings.ClientAuthenticationType.SHARED_SECRET,
             clientAuthenticationSharedSecretKey,
-            sharedSecretNonEmpty
+            new RotatableSecret(sharedSecretNonEmpty)
         );
         // If type is SharedSecret, verify null or empty is rejected
         final Exception exception2 = expectThrows(
@@ -69,7 +70,7 @@ public class JwtUtilTests extends JwtTestCase {
                 clientAuthenticationTypeKey,
                 JwtRealmSettings.ClientAuthenticationType.SHARED_SECRET,
                 clientAuthenticationSharedSecretKey,
-                sharedSecretNullOrEmpty
+                new RotatableSecret(sharedSecretNullOrEmpty)
             )
         );
         assertThat(