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();
