From d8d94a773d3462c202c0b34a47ca9bf4437057b2 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Sun, 25 Oct 2026 08:57:05 -0400 Subject: [PATCH] fix(apigatewayv2): verify tokens from Floci's own Cognito pools in-process A JWT authorizer on one of Floci's own user pools could never pass: the pool's issuer is Floci's base URL (plain HTTP on localhost or a Compose service name), and the verifier only fetches discovery and JWKS documents over HTTPS or from a private IP literal. When the configured issuer is exactly one of this emulator's pool issuers, read the pool's signing keys directly; they are the keys it publishes at its JWKS URL. Every other issuer keeps the strict fetch. --- .../apigatewayv2/JwtSignatureVerifier.java | 26 +++++++++++++++++++ .../services/cognito/CognitoService.java | 12 +++++++++ .../JwtSignatureVerifierTest.java | 20 ++++++++++++++ .../services/cognito/CognitoServiceTest.java | 16 ++++++++++++ 4 files changed, 74 insertions(+) diff --git a/src/main/java/io/github/hectorvent/floci/services/apigatewayv2/JwtSignatureVerifier.java b/src/main/java/io/github/hectorvent/floci/services/apigatewayv2/JwtSignatureVerifier.java index a959e5a..7456c03 100644 --- a/src/main/java/io/github/hectorvent/floci/services/apigatewayv2/JwtSignatureVerifier.java +++ b/src/main/java/io/github/hectorvent/floci/services/apigatewayv2/JwtSignatureVerifier.java @@ -4,8 +4,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.hectorvent.floci.config.EmulatorConfig; import io.github.hectorvent.floci.core.common.SsrfProtection; +import io.github.hectorvent.floci.services.cognito.CognitoService; import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.SystemDefaultDnsResolver; @@ -37,6 +39,7 @@ import java.util.Base64; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; /** * Verifies the RS256 signature of a token presented to an HTTP API v2 JWT authorizer, the same @@ -66,8 +69,27 @@ public class JwtSignatureVerifier implements AutoCloseable { private final CloseableHttpClient httpClient; private final boolean allowPrivateNetworkTargets; private final Map jwksCache = new ConcurrentHashMap<>(); + private Function>> localIssuerKeys = issuer -> Optional.empty(); + /** + * An issuer that is one of this emulator's own Cognito pools is served at Floci's base URL, which + * is plain HTTP on a hostname such as {@code localhost} or a Compose service name, so the HTTPS + * and address rules below would refuse to fetch it. Its keys are read from the pool instead: + * they are the same keys the pool publishes at its JWKS URL. + */ @Inject + public JwtSignatureVerifier(ObjectMapper objectMapper, EmulatorConfig config, Instance cognito) { + this(objectMapper, config); + withLocalIssuerKeys(issuer -> cognito.isResolvable() + ? cognito.get().localIssuerKeys(issuer) : Optional.empty()); + } + + /** Where the keys of in-process issuers come from; package-private so tests can stand one in. */ + JwtSignatureVerifier withLocalIssuerKeys(Function>> localIssuerKeys) { + this.localIssuerKeys = localIssuerKeys; + return this; + } + public JwtSignatureVerifier(ObjectMapper objectMapper, EmulatorConfig config) { this(objectMapper, SystemDefaultDnsResolver.INSTANCE, config.security().allowPrivateJwtTargets()); @@ -162,6 +184,10 @@ public class JwtSignatureVerifier implements AutoCloseable { } private RSAPublicKey resolveKey(String issuer, String kid) throws JwtVerificationException { + Optional> local = localIssuerKeys.apply(issuer); + if (local.isPresent()) { + return local.get().get(kid); + } CachedJwks cached = jwksCache.get(issuer); if (cached != null && !cached.isExpired()) { RSAPublicKey key = cached.keysByKid().get(kid); diff --git a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoService.java b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoService.java index 2df8ccd..947b593 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoService.java +++ b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoService.java @@ -3079,6 +3079,18 @@ public class CognitoService implements ResourceProvider { return overrideId; } + /** + * The signing keys, by kid, of this emulator's own pool that {@code issuer} names exactly, so a + * JWT authorizer can verify the pool's tokens in-process; empty for every other issuer. + */ + public Optional> localIssuerKeys(String issuer) { + if (issuer == null) return Optional.empty(); + String trimmed = issuer.replaceAll("/+$", ""); + String poolId = trimmed.substring(trimmed.lastIndexOf('/') + 1); + if (poolId.isEmpty() || !getIssuer(poolId).equals(trimmed)) return Optional.empty(); + return poolStore.get(poolId).map(pool -> Map.of(getSigningKeyId(pool), getSigningPublicKey(pool))); + } + public String getJwksUri(String poolId) { return getIssuer(poolId) + "/.well-known/jwks.json"; } diff --git a/src/test/java/io/github/hectorvent/floci/services/apigatewayv2/JwtSignatureVerifierTest.java b/src/test/java/io/github/hectorvent/floci/services/apigatewayv2/JwtSignatureVerifierTest.java index 934689d..d186e4c 100644 --- a/src/test/java/io/github/hectorvent/floci/services/apigatewayv2/JwtSignatureVerifierTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/apigatewayv2/JwtSignatureVerifierTest.java @@ -98,6 +98,26 @@ class JwtSignatureVerifierTest { verifier.verify(token, issuer); // does not throw } + @Test + void verifiesAnInProcessIssuerWithoutFetchingIt() throws Exception { + // Floci's own Cognito issuer: plain HTTP on a hostname, which the strict fetch rules refuse. + String localIssuer = "http://floci:4566/us-east-1_local"; + strictVerifier.withLocalIssuerKeys(candidate -> candidate.equals(localIssuer) + ? java.util.Optional.of(Map.of("test-key-1", publicKey)) : java.util.Optional.empty()); + + assertDoesNotThrow(() -> strictVerifier.verify(signToken("test-key-1", privateKey), localIssuer)); + assertThrows(JwtSignatureVerifier.JwtVerificationException.class, + () -> strictVerifier.verify(signToken("other-key", privateKey), localIssuer)); + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(2048); + String forged = signToken("test-key-1", (RSAPrivateKey) gen.generateKeyPair().getPrivate()); + assertThrows(JwtSignatureVerifier.JwtVerificationException.class, + () -> strictVerifier.verify(forged, localIssuer)); + // Any other plain-HTTP issuer still goes through the strict fetch, and is refused. + assertThrows(JwtSignatureVerifier.JwtVerificationException.class, + () -> strictVerifier.verify(signToken("test-key-1", privateKey), "http://floci:4566/us-east-1_other")); + } + @Test void rejectsATokenSignedWithADifferentKey() throws Exception { KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); diff --git a/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoServiceTest.java index 64f05d0..7d01c5a 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoServiceTest.java @@ -1369,6 +1369,22 @@ class CognitoServiceTest { assertEquals("http://localhost:4566/custompool", service.getIssuer(pool.getId())); } + @Test + void localIssuerKeysServeOnlyThisEmulatorsOwnPoolIssuers() throws Exception { + UserPool pool = createPoolAndUser(); + UserPoolClient client = clientWithFlows(pool, "ALLOW_USER_PASSWORD_AUTH"); + String token = (String) ((Map) service.initiateAuth(client.getClientId(), "USER_PASSWORD_AUTH", + Map.of("USERNAME", "alice", "PASSWORD", "Perm1234!")).get("AuthenticationResult")).get("AccessToken"); + String kid = MAPPER.readTree(Base64.getUrlDecoder().decode(token.split("\\.")[0])).path("kid").asText(); + + Map keys = service.localIssuerKeys(service.getIssuer(pool.getId())).orElseThrow(); + assertTrue(keys.containsKey(kid), "the key that signed the pool's tokens"); + assertTrue(service.localIssuerKeys(service.getIssuer(pool.getId()) + "/").isPresent()); + assertTrue(service.localIssuerKeys("https://cognito-idp.us-east-1.amazonaws.com/" + pool.getId()).isEmpty()); + assertTrue(service.localIssuerKeys(service.getIssuer("us-east-1_missing")).isEmpty()); + assertTrue(service.localIssuerKeys(null).isEmpty()); + } + // ========================================================================= // Groups // ========================================================================= -- 2.52.0