Compare commits
2 Commits
957579c8b7
...
9f17f72cc3
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f17f72cc3 | |||
| d8d94a773d |
+26
@@ -4,8 +4,10 @@ import com.fasterxml.jackson.databind.JsonNode;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import io.github.hectorvent.floci.config.EmulatorConfig;
|
import io.github.hectorvent.floci.config.EmulatorConfig;
|
||||||
import io.github.hectorvent.floci.core.common.SsrfProtection;
|
import io.github.hectorvent.floci.core.common.SsrfProtection;
|
||||||
|
import io.github.hectorvent.floci.services.cognito.CognitoService;
|
||||||
import jakarta.annotation.PreDestroy;
|
import jakarta.annotation.PreDestroy;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.enterprise.inject.Instance;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import org.apache.hc.client5.http.DnsResolver;
|
import org.apache.hc.client5.http.DnsResolver;
|
||||||
import org.apache.hc.client5.http.SystemDefaultDnsResolver;
|
import org.apache.hc.client5.http.SystemDefaultDnsResolver;
|
||||||
@@ -37,6 +39,7 @@ import java.util.Base64;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
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
|
* 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 CloseableHttpClient httpClient;
|
||||||
private final boolean allowPrivateNetworkTargets;
|
private final boolean allowPrivateNetworkTargets;
|
||||||
private final Map<String, CachedJwks> jwksCache = new ConcurrentHashMap<>();
|
private final Map<String, CachedJwks> jwksCache = new ConcurrentHashMap<>();
|
||||||
|
private Function<String, Optional<Map<String, RSAPublicKey>>> 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
|
@Inject
|
||||||
|
public JwtSignatureVerifier(ObjectMapper objectMapper, EmulatorConfig config, Instance<CognitoService> 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<String, Optional<Map<String, RSAPublicKey>>> localIssuerKeys) {
|
||||||
|
this.localIssuerKeys = localIssuerKeys;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public JwtSignatureVerifier(ObjectMapper objectMapper, EmulatorConfig config) {
|
public JwtSignatureVerifier(ObjectMapper objectMapper, EmulatorConfig config) {
|
||||||
this(objectMapper, SystemDefaultDnsResolver.INSTANCE,
|
this(objectMapper, SystemDefaultDnsResolver.INSTANCE,
|
||||||
config.security().allowPrivateJwtTargets());
|
config.security().allowPrivateJwtTargets());
|
||||||
@@ -162,6 +184,10 @@ public class JwtSignatureVerifier implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private RSAPublicKey resolveKey(String issuer, String kid) throws JwtVerificationException {
|
private RSAPublicKey resolveKey(String issuer, String kid) throws JwtVerificationException {
|
||||||
|
Optional<Map<String, RSAPublicKey>> local = localIssuerKeys.apply(issuer);
|
||||||
|
if (local.isPresent()) {
|
||||||
|
return local.get().get(kid);
|
||||||
|
}
|
||||||
CachedJwks cached = jwksCache.get(issuer);
|
CachedJwks cached = jwksCache.get(issuer);
|
||||||
if (cached != null && !cached.isExpired()) {
|
if (cached != null && !cached.isExpired()) {
|
||||||
RSAPublicKey key = cached.keysByKid().get(kid);
|
RSAPublicKey key = cached.keysByKid().get(kid);
|
||||||
|
|||||||
@@ -3079,6 +3079,18 @@ public class CognitoService implements ResourceProvider {
|
|||||||
return overrideId;
|
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<Map<String, RSAPublicKey>> 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) {
|
public String getJwksUri(String poolId) {
|
||||||
return getIssuer(poolId) + "/.well-known/jwks.json";
|
return getIssuer(poolId) + "/.well-known/jwks.json";
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -98,6 +98,26 @@ class JwtSignatureVerifierTest {
|
|||||||
verifier.verify(token, issuer); // does not throw
|
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
|
@Test
|
||||||
void rejectsATokenSignedWithADifferentKey() throws Exception {
|
void rejectsATokenSignedWithADifferentKey() throws Exception {
|
||||||
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
|
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
|
||||||
|
|||||||
@@ -1369,6 +1369,22 @@ class CognitoServiceTest {
|
|||||||
assertEquals("http://localhost:4566/custompool", service.getIssuer(pool.getId()));
|
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<String, java.security.interfaces.RSAPublicKey> 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
|
// Groups
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user