Compare commits

...

5 Commits

Author SHA1 Message Date
Zakaria 9f17f72cc3 Merge pull request 'fix(apigatewayv2): verify tokens from Floci's own Cognito pools in-process' (#10) from fix/apigatewayv2-local-cognito-issuer into main
Reviewed-on: #10
2026-10-25 13:49:41 +00:00
Zakaria d8d94a773d 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.
2026-10-25 08:57:05 -04:00
Zakaria 957579c8b7 Merge pull request 'fix(cognito): honour PreventUserExistenceErrors=ENABLED on app clients' (#9) from fix/cognito-prevent-user-existence-errors into main
Reviewed-on: #9
2026-10-25 12:27:48 +00:00
Zakaria d9a1a36dc8 fix(cognito): honour PreventUserExistenceErrors=ENABLED on app clients
The setting was stored but never applied, so an unknown username always got
UserNotFoundException and revealed which accounts exist. With ENABLED, answer
as real Cognito does: password sign-in fails with NotAuthorizedException
"Incorrect username or password", ForgotPassword and ResendConfirmationCode
report a simulated delivery, and ConfirmForgotPassword and ConfirmSignUp fail
with CodeMismatchException. LEGACY and unset keep UserNotFoundException.
SRP and USER_AUTH flows are unchanged.
2026-10-25 08:26:34 -04:00
Zakaria 5d4b010710 Merge pull request 'fix(ses): sign in to the SMTP relay with the password, not as an OAuth token' (#8) from fix/ses-smtp-password-auth into main
Reviewed-on: #8
2026-10-25 11:57:20 +00:00
5 changed files with 173 additions and 7 deletions
@@ -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<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
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) {
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<Map<String, RSAPublicKey>> 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);
@@ -291,7 +291,10 @@ final class CognitoAuthFlowHandler {
if (!"UserNotFoundException".equals(ae.getErrorCode())) throw ae;
user = tryUserMigration(pool, client, username, password, null, clientMetadata,
"UserMigration_Authentication");
if (user == null) throw ae;
if (user == null) {
if (!CognitoService.hidesUserExistence(client)) throw ae;
throw new AwsException("NotAuthorizedException", "Incorrect username or password", 400);
}
}
firePreAuthentication(pool, client, user, null, clientMetadata, false);
@@ -2592,7 +2592,8 @@ public class CognitoService implements ResourceProvider {
String confirmationCode) {
UserPool pool = poolStore.get(userPoolId)
.orElseThrow(() -> userPoolNotFound(userPoolId));
CognitoUser user = adminGetUser(client.getUserPoolId(), username);
CognitoUser user = userForClientOrNull(client, username);
if (user == null) throw codeMismatch();
if (verificationCodeService != null && isSignUpConfirmationEnabled(pool)) {
try {
verificationCodeService.consume(client.getUserPoolId(), user.getUsername(),
@@ -2636,7 +2637,8 @@ public class CognitoService implements ResourceProvider {
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found",
400));
UserPool pool = describeUserPool(client.getUserPoolId());
CognitoUser user = adminGetUser(client.getUserPoolId(), username);
CognitoUser user = userForClientOrNull(client, username);
if (user == null) return simulatedCodeDelivery(username);
if (!"UNCONFIRMED".equals(user.getUserStatus())) {
throw new AwsException("NotAuthorizedException",
"User cannot be confirmed. Current status is " + user.getUserStatus(), 400);
@@ -2774,7 +2776,8 @@ public class CognitoService implements ResourceProvider {
public Map<String, Object> forgotPassword(String clientId, String username) {
UserPoolClient client = clientStore.get(clientId)
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found", 400));
CognitoUser user = adminGetUser(client.getUserPoolId(), username);
CognitoUser user = userForClientOrNull(client, username);
if (user == null) return new LinkedHashMap<>(simulatedCodeDelivery(username));
UserPool pool = describeUserPool(client.getUserPoolId());
ensureVerificationWiring();
DeliveryTarget deliveryTarget = resolveForgotPasswordDeliveryTarget(pool, user);
@@ -2800,7 +2803,8 @@ public class CognitoService implements ResourceProvider {
public void confirmForgotPassword(String clientId, String username, String confirmationCode, String newPassword) {
UserPoolClient client = clientStore.get(clientId)
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found", 400));
CognitoUser user = adminGetUser(client.getUserPoolId(), username);
CognitoUser user = userForClientOrNull(client, username);
if (user == null) throw codeMismatch();
ensureVerificationWiring();
try {
verificationCodeService.consume(client.getUserPoolId(), user.getUsername(),
@@ -3075,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<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) {
return getIssuer(poolId) + "/.well-known/jwks.json";
}
@@ -4446,10 +4462,38 @@ public class CognitoService implements ResourceProvider {
}
}
/**
* Whether the app client hides which usernames exist (PreventUserExistenceErrors=ENABLED). Real Cognito then
* answers for an unknown user exactly as for a real one: a failed sign-in is "Incorrect username or password",
* a code request reports a simulated delivery and a code check reports a wrong code. Unset means LEGACY.
*/
static boolean hidesUserExistence(UserPoolClient client) {
return "ENABLED".equals(client.getPreventUserExistenceErrors());
}
/** The user, or null when it does not exist and the client hides that; otherwise UserNotFoundException. */
private CognitoUser userForClientOrNull(UserPoolClient client, String username) {
try {
return adminGetUser(client.getUserPoolId(), username);
} catch (AwsException e) {
if ("UserNotFoundException".equals(e.getErrorCode()) && hidesUserExistence(client)) return null;
throw e;
}
}
private Map<String, String> simulatedCodeDelivery(String username) {
return username != null && username.contains("@")
? Map.of("AttributeName", "email", "DeliveryMedium", "EMAIL", "Destination", maskEmail(username))
: Map.of("AttributeName", "phone_number", "DeliveryMedium", "SMS", "Destination", "+*******0000");
}
private static AwsException codeMismatch() {
return new AwsException("CodeMismatchException", "Invalid verification code provided, please try again.", 400);
}
private AwsException mapVerificationCodeException(VerificationCodeException e) {
return switch (e.getKind()) {
case MISMATCH, NOT_FOUND -> new AwsException("CodeMismatchException",
"Invalid verification code provided, please try again.", 400);
case MISMATCH, NOT_FOUND -> codeMismatch();
case EXPIRED -> new AwsException("ExpiredCodeException",
"Invalid code provided, please request a code again.", 400);
case RATE_LIMIT -> new AwsException("LimitExceededException",
@@ -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");
@@ -373,6 +373,63 @@ class CognitoServiceTest {
null, null, List.of(), null, null);
}
@Nested
class PreventUserExistenceErrors {
private UserPoolClient client(UserPool pool, String preventUserExistenceErrors) {
return service.createUserPoolClient(pool.getId(), "existence-client", false, false, List.of(), List.of(),
null, List.of(), null, List.of("ALLOW_USER_PASSWORD_AUTH"), null, null, List.of(),
preventUserExistenceErrors, List.of(), null, null, null, List.of(), null, null);
}
@Test
void enabledAnswersAnUnknownUserLikeAWrongPassword() {
UserPool pool = createPoolAndUser();
UserPoolClient client = client(pool, "ENABLED");
AwsException unknown = assertThrows(AwsException.class, () -> service.initiateAuth(client.getClientId(),
"USER_PASSWORD_AUTH", Map.of("USERNAME", "nobody", "PASSWORD", "Perm1234!")));
AwsException wrongPassword = assertThrows(AwsException.class, () -> service.initiateAuth(client.getClientId(),
"USER_PASSWORD_AUTH", Map.of("USERNAME", "alice", "PASSWORD", "Wrong1234!")));
assertEquals("NotAuthorizedException", unknown.getErrorCode());
assertEquals(wrongPassword.getErrorCode(), unknown.getErrorCode());
assertEquals(wrongPassword.getMessage(), unknown.getMessage());
}
@Test
void enabledSimulatesCodeDeliveryAndRejectsCodesForAnUnknownUser() {
UserPool pool = createPoolAndUser();
UserPoolClient client = client(pool, "ENABLED");
assertEquals(Map.of("AttributeName", "email", "DeliveryMedium", "EMAIL", "Destination", "n***@e***"),
service.forgotPassword(client.getClientId(), "nobody@example.com"));
assertEquals("EMAIL", service.resendConfirmationCode(client.getClientId(), "nobody@example.com")
.get("DeliveryMedium"));
assertEquals("CodeMismatchException", assertThrows(AwsException.class, () ->
service.confirmForgotPassword(client.getClientId(), "nobody@example.com", "123456", "Perm1234!"))
.getErrorCode());
assertEquals("CodeMismatchException", assertThrows(AwsException.class, () ->
service.confirmSignUp(client.getClientId(), "nobody@example.com", "123456")).getErrorCode());
assertThrows(AwsException.class, () -> service.adminGetUser(pool.getId(), "nobody@example.com"),
"no user is created along the way");
}
@Test
void legacyAndUnsetStillReportUserNotFound() {
UserPool pool = createPoolAndUser();
for (String setting : new String[] {"LEGACY", null}) {
UserPoolClient client = client(pool, setting);
assertEquals("UserNotFoundException", assertThrows(AwsException.class, () -> service.initiateAuth(
client.getClientId(), "USER_PASSWORD_AUTH", Map.of("USERNAME", "nobody", "PASSWORD", "x")))
.getErrorCode());
assertEquals("UserNotFoundException", assertThrows(AwsException.class, () ->
service.forgotPassword(client.getClientId(), "nobody")).getErrorCode());
}
}
}
@ParameterizedTest
@CsvSource({
"Short1!a",
@@ -1312,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<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
// =========================================================================