From d9a1a36dc848e28765b8788ff5bc3c9b621c0971 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Sun, 25 Oct 2026 08:26:34 -0400 Subject: [PATCH] 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. --- .../cognito/CognitoAuthFlowHandler.java | 5 +- .../services/cognito/CognitoService.java | 44 ++++++++++++-- .../services/cognito/CognitoServiceTest.java | 57 +++++++++++++++++++ 3 files changed, 99 insertions(+), 7 deletions(-) diff --git a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoAuthFlowHandler.java b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoAuthFlowHandler.java index 7f84616..748a801 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoAuthFlowHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoAuthFlowHandler.java @@ -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); diff --git a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoService.java b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoService.java index 2003179..2df8ccd 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoService.java +++ b/src/main/java/io/github/hectorvent/floci/services/cognito/CognitoService.java @@ -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 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 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", diff --git a/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoServiceTest.java index 268e90e..64f05d0 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cognito/CognitoServiceTest.java @@ -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", -- 2.52.0