Compare commits

...

7 Commits

Author SHA1 Message Date
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
Zakaria dcb007f8b8 chore(docker): read optional SES SMTP relay settings from ~/.config/thecloud/ses-smtp.env 2026-10-25 07:44:42 -04:00
Zakaria 607ac0803b Merge pull request 'fix(cloudwatch): retain metric-math alarm query definitions (#4594)' (#6) from feat/cloudwatch-upstream-fixes into main
Reviewed-on: #6
2026-10-25 03:08:55 +00:00
6 changed files with 111 additions and 8 deletions
+5
View File
@@ -12,6 +12,11 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/app/data
# Optional SES SMTP relay settings (FLOCI_SERVICES_SES_SMTP_*), kept outside the repo
# because they hold a password. Without the file, sent emails stay in the SES mailbox.
env_file:
- path: ${HOME}/.config/thecloud/ses-smtp.env
required: false
environment:
FLOCI_SERVICES_DOCKER_NETWORK: floci_default
FLOCI_SERVICES_RDS_PROXY_BASE_PORT: "7001"
@@ -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(),
@@ -4446,10 +4450,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",
@@ -57,6 +57,8 @@ public class SmtpRelay {
private static final Logger LOG = Logger.getLogger(SmtpRelay.class);
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. */
public static final String HEADER_CONFIGURATION_SET = "X-SES-CONFIGURATION-SET";
@@ -118,6 +120,9 @@ public class SmtpRelay {
.filter(u -> !u.isBlank())
.ifPresent(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()
.filter(p -> !p.isBlank())
.ifPresent(mailConfig::setPassword);
@@ -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",
@@ -287,7 +287,8 @@ class SmtpRelayServerTest {
String command = line.toUpperCase(Locale.ROOT);
if (command.startsWith("EHLO")) {
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")) {
reply(out, "250 floci-stub");
} else if (command.startsWith("AUTH")) {