diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d284f6c..c818594e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [Unreleased] +#### Fixed +- Fixed an issue where selecting the wrong number in a Push Number Challenge returned a generic error instead of a distinct exception [SDKS-5114] + ## [4.8.5] #### Fixed diff --git a/forgerock-authenticator/src/main/java/org/forgerock/android/auth/PushResponder.java b/forgerock-authenticator/src/main/java/org/forgerock/android/auth/PushResponder.java index 2b0e2eb5..13bbb7c4 100644 --- a/forgerock-authenticator/src/main/java/org/forgerock/android/auth/PushResponder.java +++ b/forgerock-authenticator/src/main/java/org/forgerock/android/auth/PushResponder.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2020 - 2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -23,6 +23,7 @@ import org.forgerock.android.auth.exception.ChallengeResponseException; import org.forgerock.android.auth.exception.PushMechanismException; +import org.forgerock.android.auth.exception.PushNumberChallengeException; import org.forgerock.android.auth.util.RequestBuilder; import org.jetbrains.annotations.NotNull; import org.json.JSONException; @@ -354,6 +355,19 @@ public void onResponse(@NotNull Call call, @NotNull Response response) { return; } listener.onSuccess(null); + } else if (response.code() == 400 && pushNotification.getPushType() == PushType.CHALLENGE) { + String challengeMessage = "Number challenge failed."; + try { + if (response.body() != null) { + JSONObject json = new JSONObject(response.body().string()); + String amMessage = json.optString("message", null); + if (amMessage != null && !amMessage.isEmpty()) { + challengeMessage = amMessage; + } + } + } catch (IOException | JSONException ignored) {} + Logger.warn(TAG, "Push 400 response for CHALLENGE notification: %s", challengeMessage); + listener.onException(new PushNumberChallengeException(challengeMessage)); } else { listener.onException(new PushMechanismException("Communication with " + "server returned " + response.code() + " code.")); diff --git a/forgerock-authenticator/src/main/java/org/forgerock/android/auth/exception/PushNumberChallengeException.java b/forgerock-authenticator/src/main/java/org/forgerock/android/auth/exception/PushNumberChallengeException.java new file mode 100644 index 00000000..eb72a8be --- /dev/null +++ b/forgerock-authenticator/src/main/java/org/forgerock/android/auth/exception/PushNumberChallengeException.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package org.forgerock.android.auth.exception; + +/** + * Represents an error that occurs during Push Number Challenge validation, + * such as when the user selects an incorrect number or the challenge cannot be processed. + */ +public class PushNumberChallengeException extends PushMechanismException { + + /** + * Create a new exception containing a message. + * @param detailMessage The message cause of the exception. + */ + public PushNumberChallengeException(String detailMessage) { + super(detailMessage); + } + + /** + * Create a new exception containing a message and a cause. + * @param detailMessage The message cause of the exception. + * @param throwable The throwable cause of the exception. + */ + public PushNumberChallengeException(String detailMessage, Throwable throwable) { + super(detailMessage, throwable); + } + +} diff --git a/forgerock-authenticator/src/test/java/org/forgerock/android/auth/PushResponderTest.java b/forgerock-authenticator/src/test/java/org/forgerock/android/auth/PushResponderTest.java index 767caf00..1d55d324 100644 --- a/forgerock-authenticator/src/test/java/org/forgerock/android/auth/PushResponderTest.java +++ b/forgerock-authenticator/src/test/java/org/forgerock/android/auth/PushResponderTest.java @@ -11,6 +11,7 @@ import org.forgerock.android.auth.exception.InvalidNotificationException; import org.forgerock.android.auth.exception.MechanismCreationException; import org.forgerock.android.auth.exception.PushMechanismException; +import org.forgerock.android.auth.exception.PushNumberChallengeException; import org.json.JSONObject; import org.junit.After; import org.junit.Assert; @@ -36,6 +37,7 @@ import static java.net.HttpURLConnection.HTTP_NOT_FOUND; import static junit.framework.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -267,6 +269,113 @@ public void testShouldSignJWTCorrectly() throws Exception { assertEquals(hash, jwtSignature); } + // ----------------------------------------------------------------------- + // Push Number Challenge (400) tests + // ----------------------------------------------------------------------- + + @Test + public void testReplyAuthenticationMessageNumberChallenge400() { + // AC (a): 400 on a CHALLENGE notification with AM JSON body → PushNumberChallengeException + // surfaces AM's message; notification stays pending=true, approved=false. + server.enqueue(new MockResponse() + .setResponseCode(400) + .setBody("{\"code\":400,\"reason\":\"Bad Request\",\"message\":\"Number challenge predicate not met.\"}")); + + PushNotification notification = null; + try { + notification = newChallengePushNotification(); + } catch (Exception e) { + Assert.fail("Failed to build challenge notification: " + e.getMessage()); + } + + try { + PushResponder.getInstance(storageClient).authentication(notification, true, pushListenerFuture); + pushListenerFuture.get(); + Assert.fail("Should throw PushNumberChallengeException"); + } catch (Exception e) { + assertTrue(e.getCause() instanceof PushNumberChallengeException); + assertTrue(e.getLocalizedMessage().contains("Number challenge predicate not met.")); + assertTrue(notification.isPending()); + assertFalse(notification.isApproved()); + } + } + + @Test + public void testReplyAuthenticationMessageNumberChallenge400NoAmMessage() { + // AC (a2): 400 on a CHALLENGE notification with no body → fallback message used; + // notification stays pending=true, approved=false. + server.enqueue(new MockResponse().setResponseCode(400)); + + PushNotification notification = null; + try { + notification = newChallengePushNotification(); + } catch (Exception e) { + Assert.fail("Failed to build challenge notification: " + e.getMessage()); + } + + try { + PushResponder.getInstance(storageClient).authentication(notification, true, pushListenerFuture); + pushListenerFuture.get(); + Assert.fail("Should throw PushNumberChallengeException"); + } catch (Exception e) { + assertTrue(e.getCause() instanceof PushNumberChallengeException); + assertTrue(e.getLocalizedMessage().contains("Number challenge failed.")); + assertTrue(notification.isPending()); + assertFalse(notification.isApproved()); + } + } + + @Test + public void testReplyAuthenticationMessage400DefaultNotificationIsNotNumberChallengeException() throws Exception { + // AC (b): 400 on a DEFAULT notification → PushMechanismException (NOT PushNumberChallengeException) + // with "400 code" in the message. Regression guard. + server.enqueue(new MockResponse().setResponseCode(400)); + + PushNotification notification = newPushNotification(); + + try { + PushResponder.getInstance(storageClient).authentication(notification, true, pushListenerFuture); + pushListenerFuture.get(); + Assert.fail("Should throw PushMechanismException"); + } catch (Exception e) { + assertTrue(e.getCause() instanceof PushMechanismException); + assertFalse(e.getCause() instanceof PushNumberChallengeException); + assertTrue(e.getLocalizedMessage().contains("400 code")); + } + } + + @Test + public void testReplyAuthenticationMessage500ChallengeNotificationIsNotNumberChallengeException() throws Exception { + // AC (c): 500 on a CHALLENGE notification → PushMechanismException (NOT PushNumberChallengeException) + // with "500 code" in the message. Regression guard. + server.enqueue(new MockResponse().setResponseCode(500)); + + PushNotification notification = newChallengePushNotification(); + + try { + PushResponder.getInstance(storageClient).authentication(notification, true, pushListenerFuture); + pushListenerFuture.get(); + Assert.fail("Should throw PushMechanismException"); + } catch (Exception e) { + assertTrue(e.getCause() instanceof PushMechanismException); + assertFalse(e.getCause() instanceof PushNumberChallengeException); + assertTrue(e.getLocalizedMessage().contains("500 code")); + } + } + + @Test + public void testReplyAuthenticationMessageNumberChallenge200Success() throws Exception { + // AC (d): 200 on a CHALLENGE notification → onSuccess called; isPending=false, isApproved=true. + server.enqueue(new MockResponse()); + + PushNotification notification = newChallengePushNotification(); + PushResponder.getInstance(storageClient).authentication(notification, true, pushListenerFuture); + pushListenerFuture.get(); + + assertFalse(notification.isPending()); + assertTrue(notification.isApproved()); + } + private PushNotification newPushNotification() throws InvalidNotificationException, MechanismCreationException { Calendar time = Calendar.getInstance(); PushNotification pushNotification = PushNotification.builder() @@ -286,6 +395,26 @@ private PushNotification newPushNotification() throws InvalidNotificationExcepti return pushNotification; } + private PushNotification newChallengePushNotification() throws InvalidNotificationException, MechanismCreationException { + Calendar time = Calendar.getInstance(); + PushNotification pushNotification = PushNotification.builder() + .setMechanismUID(MECHANISM_UID) + .setMessageId(MESSAGE_ID) + .setChallenge(CHALLENGE) + .setAmlbCookie(AMLB_COOKIE) + .setTimeAdded(time) + .setTimeExpired(time) + .setApproved(false) + .setPending(true) + .setTtl(TTL) + .setPushType("challenge") + .build(); + + pushNotification.setPushMechanism(newPushMechanism()); + + return pushNotification; + } + private PushMechanism newPushMechanism() throws MechanismCreationException { HttpUrl baseUrl = server.url("/"); PushMechanism push = PushMechanism.builder()