Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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."));
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down
Loading