This is an automated email from the ASF dual-hosted git repository.

smolnar82 pushed a commit to branch knox_idf
in repository https://gitbox.apache.org/repos/asf/knox.git

commit a2c1470c86bdd0eb45338447c0fc6ed2b5c498a0
Author: Sandor Molnar <[email protected]>
AuthorDate: Wed Aug 12 00:35:01 2026 +0200

    KNOX-3414: publish multiple JWKs and select verification key by kid
    
    Support a manual signing-key rotation for gateway-signed JWTs (KnoxIDF
    OAuth2/OIDC tokens and KnoxSSO/knoxtoken tokens) without invalidating
    already-issued tokens.
    
    - GatewayConfig: new optional param gateway.signing.key.aliases.additional,
      a comma-separated list of signing-keystore aliases retained alongside the
      current gateway.signing.key.alias. New getSigningKeyAliases() returns the
      current key first, then the additional keys (de-duplicated). Defaults to
      just the current key, so single-key deployments are unchanged.
    - JWKSResource: publish one JWK per configured alias on /jwks, each with its
      own kid (SHA-256 thumbprint), instead of only the current key. A 
single-key
      deployment still yields exactly one JWK.
    - DefaultTokenAuthorityService: the single-key RSA verification fallback is
      now kid-aware. When more than one alias is configured it matches the 
token's
      kid header against each configured key's thumbprint and verifies with the
      matching (possibly rotated-out) key; otherwise it falls back to the 
current
      key, preserving the historical behavior. kid stamping on issuance was
      already in place.
    
    Tests: JWKSResource publishes one JWK per alias with distinct kids;
    DefaultTokenAuthorityService selects a rotated-out key by kid and rejects it
    once that alias is removed; GatewayConfigImpl alias parsing/dedup/none.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
---
 .../gateway/config/impl/GatewayConfigImpl.java     | 19 +++++++
 .../token/impl/DefaultTokenAuthorityService.java   | 54 +++++++++++++++++-
 .../gateway/config/impl/GatewayConfigImplTest.java | 28 ++++++++++
 .../impl/DefaultTokenAuthorityServiceTest.java     | 64 ++++++++++++++++++++++
 .../gateway/service/knoxtoken/JWKSResource.java    | 59 +++++++++++++-------
 .../service/knoxtoken/JWKSResourceTest.java        | 57 +++++++++++++++++++
 .../apache/knox/gateway/config/GatewayConfig.java  | 20 +++++++
 7 files changed, 278 insertions(+), 23 deletions(-)

diff --git 
a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
 
b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
index f14bc0fe9..6ad4fdf1d 100644
--- 
a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
+++ 
b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
@@ -1017,6 +1017,25 @@ public class GatewayConfigImpl extends Configuration 
implements GatewayConfig {
     }
   }
 
+  @Override
+  public List<String> getSigningKeyAliases() {
+    final List<String> aliases = new ArrayList<>();
+    final String current = getSigningKeyAlias();
+    if (current != null) {
+      aliases.add(current);
+    }
+    final String additional = get(SIGNING_KEY_ALIASES_ADDITIONAL);
+    if (additional != null && !additional.trim().isEmpty() && 
!"none".equalsIgnoreCase(additional.trim())) {
+      for (String alias : additional.trim().split("\\s*,\\s*")) {
+        // Skip blanks and de-duplicate so the current key is never 
published/checked twice.
+        if (!alias.isEmpty() && !aliases.contains(alias)) {
+          aliases.add(alias);
+        }
+      }
+    }
+    return aliases;
+  }
+
   @Override
   public List<String> getGlobalRulesServices() {
     String value = get( GLOBAL_RULES_SERVICES );
diff --git 
a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityService.java
 
b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityService.java
index 034b27ce5..98b720a3d 100644
--- 
a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityService.java
+++ 
b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityService.java
@@ -32,14 +32,17 @@ import java.security.interfaces.RSAPrivateKey;
 import java.security.interfaces.RSAPublicKey;
 import java.text.ParseException;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 
 import com.nimbusds.jose.JOSEException;
 import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
 import com.nimbusds.jose.JWSSigner;
 import com.nimbusds.jose.JWSVerifier;
 import com.nimbusds.jose.KeyLengthException;
@@ -202,17 +205,62 @@ public class DefaultTokenAuthorityService implements 
JWTokenAuthority, Service {
     try {
       PublicKey key = publicKey;
       if (key == null) {
-        key = 
keystoreService.getSigningKeystore().getCertificate(getSigningKeyAlias()).getPublicKey();
+        key = selectVerificationKey(token);
       }
       final JWSVerifier verifier = new RSASSAVerifier((RSAPublicKey) key);
-      // TODO: interrogate the token for issuer claim in order to determine 
the public key to use for verification
-      // consider jwk for specifying the key too
       return token.verify(verifier);
     } catch (KeyStoreException | KeystoreServiceException e) {
       throw new TokenServiceException("Cannot verify token.", e);
     }
   }
 
+  /**
+   * Selects the public key to verify a gateway-signed RSA token with. When 
more than one signing
+   * key alias is configured, the token's {@code kid} header is matched 
against each configured
+   * key's SHA-256 thumbprint so a token signed by a rotated-out key still 
verifies. When there is a
+   * single configured key, or the token carries no matching {@code kid}, this 
falls back to the
+   * current signing key — the historical single-key behavior.
+   */
+  private PublicKey selectVerificationKey(JWT token) throws KeyStoreException, 
KeystoreServiceException {
+    final KeyStore keystore = keystoreService.getSigningKeystore();
+    final List<String> aliases = getVerificationKeyAliases();
+    // Fast path / backward compatibility: a single configured key means no 
kid selection is needed.
+    if (aliases.size() > 1) {
+      final String kid = extractKid(token);
+      if (kid != null) {
+        for (final String alias : aliases) {
+          final Certificate cert = keystore.getCertificate(alias);
+          if (cert == null || !(cert.getPublicKey() instanceof RSAPublicKey)) {
+            continue;
+          }
+          try {
+            if (kid.equals(TokenUtils.getThumbprint((RSAPublicKey) 
cert.getPublicKey(), "SHA-256"))) {
+              return cert.getPublicKey();
+            }
+          } catch (JOSEException e) {
+            // Cannot compute this key's thumbprint; skip it and try the next 
alias.
+            LOG.errorGettingKid(e.toString());
+          }
+        }
+      }
+    }
+    // No kid, no match, or a single key: verify with the current signing key.
+    return keystore.getCertificate(getSigningKeyAlias()).getPublicKey();
+  }
+
+  private List<String> getVerificationKeyAliases() {
+    final List<String> aliases = config == null ? null : 
config.getSigningKeyAliases();
+    return (aliases == null || aliases.isEmpty()) ? 
Collections.singletonList(getSigningKeyAlias()) : aliases;
+  }
+
+  private static String extractKid(JWT token) {
+    try {
+      return JWSHeader.parse(token.getHeader()).getKeyID();
+    } catch (ParseException e) {
+      return null;
+    }
+  }
+
   private boolean verifyTokenUsingHMAC(JWT token) throws TokenServiceException 
{
     try {
       final JWSVerifier verifier = new MACVerifier(getHmacSecret());
diff --git 
a/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java
 
b/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java
index 0303150be..9a337146b 100644
--- 
a/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java
+++ 
b/gateway-server/src/test/java/org/apache/knox/gateway/config/impl/GatewayConfigImplTest.java
@@ -38,6 +38,7 @@ import java.nio.file.Paths;
 import java.security.KeyStore;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -825,4 +826,31 @@ public class GatewayConfigImplTest {
       System.clearProperty("KNOX_GATEWAY_CONF_DIR");
     }
   }
+
+  @Test
+  public void testSigningKeyAliases() {
+    GatewayConfigImpl config = new GatewayConfigImpl();
+
+    // Default: only the current signing key, so a single-key deployment is 
unchanged.
+    assertEquals(Collections.singletonList(config.getSigningKeyAlias()), 
config.getSigningKeyAliases());
+
+    // Additional aliases follow the current key, in order.
+    config.set(GatewayConfig.SIGNING_KEY_ALIASES_ADDITIONAL, "old-key-1, 
old-key-2");
+    List<String> aliases = config.getSigningKeyAliases();
+    assertEquals(3, aliases.size());
+    assertEquals(config.getSigningKeyAlias(), aliases.get(0));
+    assertTrue(aliases.contains("old-key-1"));
+    assertTrue(aliases.contains("old-key-2"));
+
+    // "none" disables additional aliases.
+    config.set(GatewayConfig.SIGNING_KEY_ALIASES_ADDITIONAL, "none");
+    assertEquals(Collections.singletonList(config.getSigningKeyAlias()), 
config.getSigningKeyAliases());
+
+    // The current alias is never published/checked twice, even if listed as 
additional.
+    config.set(GatewayConfig.SIGNING_KEY_ALIASES_ADDITIONAL, 
config.getSigningKeyAlias() + ", old-key-1");
+    aliases = config.getSigningKeyAliases();
+    assertEquals(2, aliases.size());
+    assertEquals(config.getSigningKeyAlias(), aliases.get(0));
+    assertTrue(aliases.contains("old-key-1"));
+  }
 }
diff --git 
a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityServiceTest.java
 
b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityServiceTest.java
index a11db4f10..4682db8f5 100644
--- 
a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityServiceTest.java
+++ 
b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenAuthorityServiceTest.java
@@ -18,15 +18,21 @@
 package org.apache.knox.gateway.services.token.impl;
 
 import java.io.File;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
 import java.security.Principal;
 import java.security.interfaces.RSAPublicKey;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Optional;
 
+import com.nimbusds.jose.crypto.RSASSASigner;
 import org.apache.knox.gateway.config.GatewayConfig;
 import org.apache.knox.gateway.services.ServiceLifecycleException;
 import org.apache.knox.gateway.services.security.AliasService;
+import org.apache.knox.gateway.services.security.KeystoreService;
 import org.apache.knox.gateway.services.security.MasterService;
 import org.apache.knox.gateway.services.security.impl.DefaultKeystoreService;
 import org.apache.knox.gateway.services.security.token.impl.JWT;
@@ -34,6 +40,8 @@ import 
org.apache.knox.gateway.services.security.token.impl.JWTToken;
 import org.apache.knox.gateway.services.security.token.JWTokenAttributes;
 import 
org.apache.knox.gateway.services.security.token.JWTokenAttributesBuilder;
 import org.apache.knox.gateway.services.security.token.TokenServiceException;
+import org.apache.knox.gateway.services.security.token.TokenUtils;
+import org.apache.knox.gateway.util.X509CertificateUtil;
 
 import org.easymock.EasyMock;
 import org.junit.Test;
@@ -592,6 +600,62 @@ public class DefaultTokenAuthorityServiceTest {
     EasyMock.verify(config, ms, as);
   }
 
+  /**
+   * A token signed by a rotated-out key still verifies as long as that key's 
alias remains
+   * configured as an additional signing-key alias: verification selects the 
key by the token's
+   * {@code kid} header. Without that alias configured (single-key), the same 
token is rejected.
+   */
+  @Test
+  public void testVerifyTokenSelectsKeyByKidAcrossRotation() throws Exception {
+    final KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
+    kpg.initialize(2048);
+    final KeyPair currentPair = kpg.generateKeyPair();
+    final KeyPair rotatedOutPair = kpg.generateKeyPair();
+
+    final KeyStore signingKeystore = KeyStore.getInstance("JKS");
+    signingKeystore.load(null, null);
+    signingKeystore.setCertificateEntry("gateway-identity",
+        X509CertificateUtil.generateCertificate("CN=current", currentPair, 
365, "SHA256withRSA"));
+    signingKeystore.setCertificateEntry("old-signing-key",
+        X509CertificateUtil.generateCertificate("CN=old", rotatedOutPair, 365, 
"SHA256withRSA"));
+
+    final KeystoreService ks = EasyMock.createNiceMock(KeystoreService.class);
+    
EasyMock.expect(ks.getSigningKeystore()).andReturn(signingKeystore).anyTimes();
+    final AliasService as = EasyMock.createNiceMock(AliasService.class);
+
+    // A token signed by the rotated-out key, stamped with that key's kid (as 
issueToken would).
+    final String rotatedOutKid = TokenUtils.getThumbprint((RSAPublicKey) 
rotatedOutPair.getPublic(), "SHA-256");
+    final JWT token = new JWTToken(new 
JWTokenAttributesBuilder().setAlgorithm("RS256").setKid(rotatedOutKid)
+        .setAudiences(Collections.emptyList()).build());
+    token.sign(new RSASSASigner(rotatedOutPair.getPrivate(), true));
+
+    // (1) Rotated-out alias still configured -> kid selection picks it -> 
verifies.
+    final GatewayConfig multiKeyConfig = 
EasyMock.createNiceMock(GatewayConfig.class);
+    
EasyMock.expect(multiKeyConfig.getSigningKeyAlias()).andReturn("gateway-identity").anyTimes();
+    EasyMock.expect(multiKeyConfig.getSigningKeyAliases())
+        .andReturn(Arrays.asList("gateway-identity", 
"old-signing-key")).anyTimes();
+    EasyMock.replay(ks, as, multiKeyConfig);
+
+    DefaultTokenAuthorityService ta = new DefaultTokenAuthorityService();
+    ta.setKeystoreService(ks);
+    ta.setAliasService(as);
+    ta.init(multiKeyConfig, new HashMap<>());
+    assertTrue("Token signed by a still-configured rotated key must verify", 
ta.verifyToken(token));
+
+    // (2) Only the current key configured (single-key) -> the rotated key's 
token is rejected.
+    final GatewayConfig singleKeyConfig = 
EasyMock.createNiceMock(GatewayConfig.class);
+    
EasyMock.expect(singleKeyConfig.getSigningKeyAlias()).andReturn("gateway-identity").anyTimes();
+    EasyMock.expect(singleKeyConfig.getSigningKeyAliases())
+        .andReturn(Collections.singletonList("gateway-identity")).anyTimes();
+    EasyMock.replay(singleKeyConfig);
+
+    ta = new DefaultTokenAuthorityService();
+    ta.setKeystoreService(ks);
+    ta.setAliasService(as);
+    ta.init(singleKeyConfig, new HashMap<>());
+    assertFalse("Without the rotated key configured, its token must not 
verify", ta.verifyToken(token));
+  }
+
   /**
    * Test getSigningCertKid() function
    * @throws Exception
diff --git 
a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/JWKSResource.java
 
b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/JWKSResource.java
index 575caa06c..4d87e077d 100644
--- 
a/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/JWKSResource.java
+++ 
b/gateway-service-knoxtoken/src/main/java/org/apache/knox/gateway/service/knoxtoken/JWKSResource.java
@@ -19,6 +19,7 @@ package org.apache.knox.gateway.service.knoxtoken;
 
 import com.nimbusds.jose.JOSEException;
 import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.jwk.JWK;
 import com.nimbusds.jose.jwk.JWKSet;
 import com.nimbusds.jose.jwk.KeyUse;
 import com.nimbusds.jose.jwk.RSAKey;
@@ -45,6 +46,9 @@ import java.security.KeyStore;
 import java.security.KeyStoreException;
 import java.security.cert.Certificate;
 import java.security.interfaces.RSAPublicKey;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
 
 import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
 
@@ -80,23 +84,26 @@ public class JWKSResource {
   }
 
   private Response getJwks(final String keystore) {
-    JWKSet jwks;
     try {
-      final RSAPublicKey rsa = getPublicKey(keystore);
-      /* no public cert found, return empty set */
-      if(rsa == null) {
-        return Response.ok()
-            .entity(new JWKSet().toJSONObject().toString()).build();
+      // Publish one JWK per configured signing-key alias (current key first, 
then any additional
+      // verification keys). Each JWK carries its own 'kid' (SHA-256 
thumbprint) so a verifier can
+      // select the right key across a key rotation. A single-key deployment 
yields exactly one JWK.
+      final List<JWK> keys = new ArrayList<>();
+      for (final String alias : getSigningKeyAliases()) {
+        final RSAPublicKey rsa = getPublicKey(keystore, alias);
+        /* no public cert for this alias, skip it */
+        if (rsa == null) {
+          continue;
+        }
+        final String kid = TokenUtils.getThumbprint(rsa, "SHA-256");
+        keys.add(new RSAKey.Builder(rsa)
+            .keyUse(KeyUse.SIGNATURE)
+            .algorithm(new JWSAlgorithm(this.signatureAlgorithm))
+            .keyID(kid)
+            .build());
       }
-
-      final String kid = TokenUtils.getThumbprint(rsa, "SHA-256");
-      final RSAKey.Builder builder = new RSAKey.Builder(rsa)
-          .keyUse(KeyUse.SIGNATURE)
-          .algorithm(new JWSAlgorithm(this.signatureAlgorithm))
-          .keyID(kid);
-
-      jwks = new JWKSet(builder.build());
-
+      return Response.ok()
+          .entity(new 
JWKSet(keys).toString()).type(MediaType.APPLICATION_JSON_TYPE).build();
     } catch (KeyStoreException | JOSEException e) {
       return Response.status(500)
           .entity("{\n  \"error\": \"" + e.toString() + "\"\n}\n").build();
@@ -105,19 +112,31 @@ public class JWKSResource {
           "{\n  \"error\": \"" + "keystore " + keystore + " could not be 
found."
               + "\"\n}\n").build();
     }
-    return Response.ok()
-            
.entity(jwks.toString()).type(MediaType.APPLICATION_JSON_TYPE).build();
   }
 
   protected RSAPublicKey getPublicKey(final String keystore) throws 
KeystoreServiceException, KeyStoreException {
+    return getPublicKey(keystore, getSigningKeyAlias());
+  }
+
+  protected RSAPublicKey getPublicKey(final String keystore, final String 
alias) throws KeystoreServiceException, KeyStoreException {
     final KeyStore ks = keystoreService.getSigningKeystore(keystore);
-    final Certificate cert = ks.getCertificate(getSigningKeyAlias());
-    return (cert != null) ? (RSAPublicKey) cert.getPublicKey() : null;
+    final Certificate cert = ks.getCertificate(alias);
+    return (cert != null && cert.getPublicKey() instanceof RSAPublicKey) ? 
(RSAPublicKey) cert.getPublicKey() : null;
+  }
+
+  /**
+   * @return the configured signing-key aliases to publish, falling back to 
the single default
+   * signing key when none are configured (backward-compatible single-key 
behavior).
+   */
+  private List<String> getSigningKeyAliases() {
+    final GatewayConfig config = (GatewayConfig) 
context.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE);
+    final List<String> aliases = (config == null) ? null : 
config.getSigningKeyAliases();
+    return (aliases == null || aliases.isEmpty()) ? 
Collections.singletonList(getSigningKeyAlias()) : aliases;
   }
 
   private String getSigningKeyAlias() {
     final GatewayConfig config = (GatewayConfig) 
context.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE);
-    final String alias = config.getSigningKeyAlias();
+    final String alias = (config == null) ? null : config.getSigningKeyAlias();
     return (alias == null) ? GatewayConfig.DEFAULT_SIGNING_KEY_ALIAS : alias;
   }
 
diff --git 
a/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/JWKSResourceTest.java
 
b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/JWKSResourceTest.java
index 966ca8554..0c27238ce 100644
--- 
a/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/JWKSResourceTest.java
+++ 
b/gateway-service-knoxtoken/src/test/java/org/apache/knox/gateway/service/knoxtoken/JWKSResourceTest.java
@@ -26,6 +26,7 @@ import java.security.PublicKey;
 import java.security.cert.Certificate;
 import java.security.interfaces.RSAPrivateKey;
 import java.security.interfaces.RSAPublicKey;
+import java.util.Arrays;
 import java.util.Collections;
 
 import javax.servlet.ServletContext;
@@ -37,6 +38,7 @@ import org.apache.knox.gateway.services.GatewayServices;
 import org.apache.knox.gateway.services.ServiceType;
 import org.apache.knox.gateway.services.security.AliasService;
 import org.apache.knox.gateway.services.security.KeystoreService;
+import org.apache.knox.gateway.services.security.token.TokenUtils;
 import 
org.apache.knox.gateway.services.security.token.JWTokenAttributesBuilder;
 import org.apache.knox.gateway.services.security.token.impl.JWT;
 import org.apache.knox.gateway.services.security.token.impl.JWTToken;
@@ -133,6 +135,61 @@ public class JWKSResourceTest {
         testToken.verify(verifier));
   }
 
+  /**
+   * When more than one signing-key alias is configured, the JWKS endpoint 
must publish one JWK per
+   * alias, each carrying its own 'kid' (SHA-256 thumbprint), so verifiers can 
select the right key
+   * across a manual key rotation.
+   */
+  @Test
+  public void testMultipleKeysPublished() throws Exception {
+    /* a second, distinct signing key that stands in for a rotated-out key */
+    final KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
+    kpg.initialize(2048);
+    final RSAPublicKey previousPublicKey = (RSAPublicKey) 
kpg.generateKeyPair().getPublic();
+
+    final ServletContext ctx = EasyMock.createNiceMock(ServletContext.class);
+    final HttpServletRequest req = 
EasyMock.createNiceMock(HttpServletRequest.class);
+    final GatewayServices svcs = 
EasyMock.createNiceMock(GatewayServices.class);
+    final KeystoreService ks = EasyMock.createNiceMock(KeystoreService.class);
+    final KeyStoreSpi keyStoreSpi = EasyMock.createNiceMock(KeyStoreSpi.class);
+    final KeyStore keystore = new KeyStoreMock(keyStoreSpi, null, "test");
+    keystore.load(null);
+    
EasyMock.expect(ks.getSigningKeystore(null)).andReturn(keystore).anyTimes();
+
+    /* distinct cert per alias: 'gateway-identity' (current) and 
'old-signing-key' (rotated-out) */
+    final Certificate currentCert = EasyMock.createNiceMock(Certificate.class);
+    final Certificate previousCert = 
EasyMock.createNiceMock(Certificate.class);
+    
EasyMock.expect(keyStoreSpi.engineGetCertificate("gateway-identity")).andReturn(currentCert).anyTimes();
+    
EasyMock.expect(keyStoreSpi.engineGetCertificate("old-signing-key")).andReturn(previousCert).anyTimes();
+    
EasyMock.expect(currentCert.getPublicKey()).andReturn(publicKey).anyTimes();
+    
EasyMock.expect(previousCert.getPublicKey()).andReturn(previousPublicKey).anyTimes();
+
+    
EasyMock.expect(svcs.getService(ServiceType.KEYSTORE_SERVICE)).andReturn(ks).anyTimes();
+    
EasyMock.expect(svcs.getService(ServiceType.ALIAS_SERVICE)).andReturn(EasyMock.createNiceMock(AliasService.class)).anyTimes();
+    
EasyMock.expect(ctx.getAttribute(GatewayServices.GATEWAY_SERVICES_ATTRIBUTE)).andReturn(svcs).anyTimes();
+    final GatewayConfig config = EasyMock.createNiceMock(GatewayConfig.class);
+    
EasyMock.expect(config.getSigningKeyAlias()).andReturn("gateway-identity").anyTimes();
+    
EasyMock.expect(config.getSigningKeyAliases()).andReturn(Arrays.asList("gateway-identity",
 "old-signing-key")).anyTimes();
+    
EasyMock.expect(ctx.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE)).andReturn(config).anyTimes();
+    EasyMock.replay(ctx, req, svcs, ks, keyStoreSpi, currentCert, 
previousCert, config);
+
+    final JWKSResource jwksResource = new JWKSResource();
+    jwksResource.context = ctx;
+    jwksResource.request = req;
+    jwksResource.init();
+    final Response retResponse = jwksResource.getJwksResponse();
+    Assert.assertEquals(Response.Status.OK.getStatusCode(), 
retResponse.getStatus());
+
+    final JWKSet jwks = JWKSet.parse(retResponse.getEntity().toString());
+    Assert.assertEquals("Expected one JWK per configured alias", 2, 
jwks.getKeys().size());
+    /* both keys are present and addressable by their own kid */
+    final String currentKid = TokenUtils.getThumbprint(publicKey, "SHA-256");
+    final String previousKid = TokenUtils.getThumbprint(previousPublicKey, 
"SHA-256");
+    Assert.assertNotEquals("The two keys must have distinct kids", currentKid, 
previousKid);
+    Assert.assertNotNull("Current key not published under its kid", 
jwks.getKeyByKeyId(currentKid));
+    Assert.assertNotNull("Rotated-out key not published under its kid", 
jwks.getKeyByKeyId(previousKid));
+  }
+
   private JWT getTestToken(final String algorithm) {
     String[] claimArray = new String[6];
     claimArray[0] = "KNOXSSO";
diff --git 
a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java 
b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
index a416d1392..dbefeea68 100644
--- 
a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
+++ 
b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
@@ -21,6 +21,7 @@ import java.net.InetSocketAddress;
 import java.net.UnknownHostException;
 import java.security.KeyStore;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -72,6 +73,10 @@ public interface GatewayConfig {
   String SIGNING_KEYSTORE_PASSWORD_ALIAS = 
"gateway.signing.keystore.password.alias";
   String SIGNING_KEYSTORE_TYPE = "gateway.signing.keystore.type";
   String SIGNING_KEY_ALIAS = "gateway.signing.key.alias";
+  // Comma-separated list of additional signing-keystore aliases whose public 
keys are published on
+  // the JWKS endpoint and accepted (selected by 'kid') when verifying 
gateway-signed JWTs. Lets an
+  // operator retain a previous key across a manual key rotation so 
already-issued tokens still verify.
+  String SIGNING_KEY_ALIASES_ADDITIONAL = 
"gateway.signing.key.aliases.additional";
   String SIGNING_KEY_PASSPHRASE_ALIAS = "gateway.signing.key.passphrase.alias";
   String DEFAULT_SIGNING_KEYSTORE_PASSWORD_ALIAS = "signing.keystore.password";
   String DEFAULT_SIGNING_KEYSTORE_TYPE = KeyStore.getDefaultType();
@@ -463,6 +468,21 @@ public interface GatewayConfig {
    */
   String getSigningKeyPassphraseAlias();
 
+  /**
+   * Returns the ordered list of signing-keystore aliases whose public keys 
the gateway publishes on
+   * the JWKS endpoint and accepts (selected by {@code kid}) when verifying 
gateway-signed JWTs. The
+   * current signing key ({@link #getSigningKeyAlias()}) is always first; any 
additional
+   * verification-only keys (e.g. a previous key retained across a manual key 
rotation) follow.
+   * <p>
+   * Implementations that do not support additional keys return just the 
current signing key, so a
+   * single-key deployment behaves exactly as before.
+   *
+   * @return the current signing key alias followed by any additional 
verification key aliases
+   */
+  default List<String> getSigningKeyAliases() {
+    return getSigningKeyAlias() == null ? Collections.emptyList() : 
Collections.singletonList(getSigningKeyAlias());
+  }
+
 
   List<String> getGlobalRulesServices();
 

Reply via email to