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.
This commit is contained in:
Zakaria
2026-10-25 08:26:34 -04:00
parent 5d4b010710
commit d9a1a36dc8
3 changed files with 99 additions and 7 deletions
@@ -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(),
@@ -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) { 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",
@@ -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",