Compare commits

...

7 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
Zakaria 2486869b6b fix(ses): sign in to the SMTP relay with the password, not as an OAuth token
Vert.x mail prefers XOAUTH2 whenever the server offers it, so against Gmail the
app password was sent as an OAuth token and refused (535 5.7.8). Limit the
relay to the password-based SASL methods.
2026-10-25 07:52:52 -04:00
Zakaria 327173c8f6 Merge pull request 'chore(docker): read optional SES SMTP relay settings from ~/.config/thecloud/ses-smtp.env' (#7) from feat/ses-smtp-relay-env into main
Reviewed-on: #7
2026-10-25 11:46:41 +00:00
7 changed files with 180 additions and 8 deletions
@@ -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);
@@ -291,7 +291,10 @@ final class CognitoAuthFlowHandler {
if (!"UserNotFoundException".equals(ae.getErrorCode())) throw ae; if (!"UserNotFoundException".equals(ae.getErrorCode())) throw ae;
user = tryUserMigration(pool, client, username, password, null, clientMetadata, user = tryUserMigration(pool, client, username, password, null, clientMetadata,
"UserMigration_Authentication"); "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); firePreAuthentication(pool, client, user, null, clientMetadata, false);
@@ -2592,7 +2592,8 @@ public class CognitoService implements ResourceProvider {
String confirmationCode) { String confirmationCode) {
UserPool pool = poolStore.get(userPoolId) UserPool pool = poolStore.get(userPoolId)
.orElseThrow(() -> userPoolNotFound(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)) { if (verificationCodeService != null && isSignUpConfirmationEnabled(pool)) {
try { try {
verificationCodeService.consume(client.getUserPoolId(), user.getUsername(), verificationCodeService.consume(client.getUserPoolId(), user.getUsername(),
@@ -2636,7 +2637,8 @@ public class CognitoService implements ResourceProvider {
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found", .orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found",
400)); 400));
UserPool pool = describeUserPool(client.getUserPoolId()); 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())) { if (!"UNCONFIRMED".equals(user.getUserStatus())) {
throw new AwsException("NotAuthorizedException", throw new AwsException("NotAuthorizedException",
"User cannot be confirmed. Current status is " + user.getUserStatus(), 400); "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) { public Map<String, Object> forgotPassword(String clientId, String username) {
UserPoolClient client = clientStore.get(clientId) UserPoolClient client = clientStore.get(clientId)
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found", 400)); .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()); UserPool pool = describeUserPool(client.getUserPoolId());
ensureVerificationWiring(); ensureVerificationWiring();
DeliveryTarget deliveryTarget = resolveForgotPasswordDeliveryTarget(pool, user); 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) { public void confirmForgotPassword(String clientId, String username, String confirmationCode, String newPassword) {
UserPoolClient client = clientStore.get(clientId) UserPoolClient client = clientStore.get(clientId)
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found", 400)); .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(); ensureVerificationWiring();
try { try {
verificationCodeService.consume(client.getUserPoolId(), user.getUsername(), verificationCodeService.consume(client.getUserPoolId(), user.getUsername(),
@@ -3075,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";
} }
@@ -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) { private AwsException mapVerificationCodeException(VerificationCodeException e) {
return switch (e.getKind()) { return switch (e.getKind()) {
case MISMATCH, NOT_FOUND -> new AwsException("CodeMismatchException", case MISMATCH, NOT_FOUND -> codeMismatch();
"Invalid verification code provided, please try again.", 400);
case EXPIRED -> new AwsException("ExpiredCodeException", case EXPIRED -> new AwsException("ExpiredCodeException",
"Invalid code provided, please request a code again.", 400); "Invalid code provided, please request a code again.", 400);
case RATE_LIMIT -> new AwsException("LimitExceededException", case RATE_LIMIT -> new AwsException("LimitExceededException",
@@ -57,6 +57,8 @@ public class SmtpRelay {
private static final Logger LOG = Logger.getLogger(SmtpRelay.class); private static final Logger LOG = Logger.getLogger(SmtpRelay.class);
private static final long RELAY_TIMEOUT_SECONDS = 30; private static final long RELAY_TIMEOUT_SECONDS = 30;
/** Every SASL method the mail client supports except XOAUTH2, which expects a token. */
static final String PASSWORD_AUTH_METHODS = "CRAM-SHA256 CRAM-SHA1 CRAM-MD5 DIGEST-MD5 LOGIN PLAIN";
/** SES control header naming the configuration set to apply to a raw send. */ /** SES control header naming the configuration set to apply to a raw send. */
public static final String HEADER_CONFIGURATION_SET = "X-SES-CONFIGURATION-SET"; public static final String HEADER_CONFIGURATION_SET = "X-SES-CONFIGURATION-SET";
@@ -118,6 +120,9 @@ public class SmtpRelay {
.filter(u -> !u.isBlank()) .filter(u -> !u.isBlank())
.ifPresent(u -> { .ifPresent(u -> {
mailConfig.setUsername(u); mailConfig.setUsername(u);
// smtp-pass is a password, never an OAuth token: without this the client prefers
// XOAUTH2 whenever the server offers it (Gmail does) and the login is refused.
mailConfig.setAuthMethods(PASSWORD_AUTH_METHODS);
config.services().ses().smtpPass() config.services().ses().smtpPass()
.filter(p -> !p.isBlank()) .filter(p -> !p.isBlank())
.ifPresent(mailConfig::setPassword); .ifPresent(mailConfig::setPassword);
@@ -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");
@@ -373,6 +373,63 @@ class CognitoServiceTest {
null, null, List.of(), null, null); 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 @ParameterizedTest
@CsvSource({ @CsvSource({
"Short1!a", "Short1!a",
@@ -1312,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
// ========================================================================= // =========================================================================
@@ -287,7 +287,8 @@ class SmtpRelayServerTest {
String command = line.toUpperCase(Locale.ROOT); String command = line.toUpperCase(Locale.ROOT);
if (command.startsWith("EHLO")) { if (command.startsWith("EHLO")) {
reply(out, "250-floci-stub"); reply(out, "250-floci-stub");
reply(out, "250 AUTH PLAIN"); // Gmail also offers OAuth sign-in, which a password must never be sent as.
reply(out, "250 AUTH PLAIN XOAUTH2 OAUTHBEARER");
} else if (command.startsWith("HELO")) { } else if (command.startsWith("HELO")) {
reply(out, "250 floci-stub"); reply(out, "250 floci-stub");
} else if (command.startsWith("AUTH")) { } else if (command.startsWith("AUTH")) {