Skip to content

Commit 6efe476

Browse files
nilsbehlenclaude
andcommitted
Support push_code_to_phone and add validate-doc-based tests
otpTransactionId() now also returns the transaction id for push/smartphone challenges delivered with client_mode=interactive (push_code_to_phone), so the code the user types is finalized together with that transaction instead of being treated as a fresh OTP (which produced "wrong otp pin"). Enable the test suite by default (skipTests now defaults to false, still skippable with -DskipTests) and add parser-driven tests covering the push, challenge-response/multichallenge and passkey /validate/* response shapes, using bodies captured in the privacyIDEA test-driven documentation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e353f7e commit 6efe476

5 files changed

Lines changed: 826 additions & 2 deletions

File tree

pom.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
<artifactId>maven-surefire-plugin</artifactId>
1919
<version>3.5.3</version>
2020
<configuration>
21-
<skipTests>false</skipTests>
21+
<!-- Tests can be skipped on demand with -DskipTests, but run by default (e.g. in CI). -->
22+
<skipTests>${skipTests}</skipTests>
2223
</configuration>
2324
</plugin>
2425
<plugin>

src/main/java/org/privacyidea/PIResponse.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,12 @@ public String otpTransactionId()
111111
{
112112
for (Challenge challenge : multiChallenge)
113113
{
114-
if (!isPushOrSmartphoneContainer(challenge.getType()) && !TOKEN_TYPE_WEBAUTHN.equals(challenge.getType()))
114+
// A push/smartphone challenge with client_mode "interactive" (push_code_to_phone) requires the user to type
115+
// the code displayed on the phone into an input field, just like a classic OTP. It therefore has to be
116+
// finalized via the OTP transaction id so the entered code is submitted together with this transaction.
117+
boolean interactivePush = isPushOrSmartphoneContainer(challenge.getType()) && "interactive".equals(challenge.getClientMode());
118+
if (interactivePush ||
119+
(!isPushOrSmartphoneContainer(challenge.getType()) && !TOKEN_TYPE_WEBAUTHN.equals(challenge.getType())))
115120
{
116121
return challenge.transactionID;
117122
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*
2+
* Copyright 2026 NetKnights GmbH - nils.behlen@netknights.it
3+
* - Modified
4+
* <p>
5+
* SPDX-License-Identifier: Apache-2.0
6+
* <p>
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
* <p>
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
* <p>
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
*/
19+
package org.privacyidea;
20+
21+
import org.junit.Before;
22+
import org.junit.Test;
23+
24+
import static org.junit.Assert.assertEquals;
25+
import static org.junit.Assert.assertFalse;
26+
import static org.junit.Assert.assertNull;
27+
import static org.junit.Assert.assertTrue;
28+
29+
/**
30+
* Parsing-contract tests for the generic challenge-response / multichallenge {@code /validate/*} surface, driven
31+
* through the wire parser ({@link JSONParser#parsePIResponse(String)}). Bodies are taken from
32+
* {@code validate-doc/challenge-response.md} and {@code validate-doc/multichallenge.md}.
33+
* <p>
34+
* These pin the transaction-id routing the Keycloak provider depends on: {@link PIResponse#otpTransactionId()} for
35+
* interactive (input-field) tokens vs. {@link PIResponse#pushTransactionId()} for pollable push tokens, and how the
36+
* translated {@code preferredClientMode} selects the provider mode.
37+
*/
38+
public class TestChallengeResponseParsing
39+
{
40+
private JSONParser parser;
41+
42+
@Before
43+
public void setup()
44+
{
45+
PrivacyIDEA privacyIDEA = PrivacyIDEA.newBuilder("https://127.0.0.1:1080", "test")
46+
.verifySSL(false)
47+
.logger(new PILogImplementation())
48+
.build();
49+
parser = new JSONParser(privacyIDEA);
50+
}
51+
52+
/**
53+
* Plain HOTP challenge-response (validate-doc/challenge-response.md, call 1): a single interactive token.
54+
* otpTransactionId() carries the transaction; there is no push.
55+
*/
56+
@Test
57+
public void testInteractiveOtpChallenge()
58+
{
59+
PIResponse r = parser.parsePIResponse(hotpChallenge());
60+
61+
assertEquals(AuthenticationStatus.CHALLENGE, r.authentication);
62+
assertFalse(r.value);
63+
assertTrue(r.hasChallenges());
64+
// "interactive" -> provider mode "otp"
65+
assertEquals("otp", r.preferredClientMode);
66+
assertFalse(r.pushAvailable());
67+
assertEquals("08954727052769857579", r.otpTransactionId());
68+
assertNull(r.pushTransactionId());
69+
}
70+
71+
/**
72+
* Mixed HOTP + poll-push challenge sharing one transaction id (validate-doc/multichallenge.md test_03).
73+
* Both accessors must resolve to the shared transaction id, push is available, and preferred mode is push (poll).
74+
*/
75+
@Test
76+
public void testMixedOtpAndPushChallenge()
77+
{
78+
PIResponse r = parser.parsePIResponse(mixedHotpAndPushChallenge());
79+
80+
assertEquals(AuthenticationStatus.CHALLENGE, r.authentication);
81+
assertEquals(2, r.multiChallenge.size());
82+
assertTrue(r.pushAvailable());
83+
// "poll" -> provider mode "push"
84+
assertEquals("push", r.preferredClientMode);
85+
// Interactive HOTP part is reachable via otpTransactionId, push part via pushTransactionId
86+
assertEquals("02108856971392266777", r.otpTransactionId());
87+
assertEquals("02108856971392266777", r.pushTransactionId());
88+
}
89+
90+
/**
91+
* Answering the OTP under the transaction id (validate-doc/challenge-response.md, call 2).
92+
*/
93+
@Test
94+
public void testChallengeResponseAccept()
95+
{
96+
PIResponse r = parser.parsePIResponse(foundMatchingChallenge());
97+
98+
assertTrue(r.value);
99+
assertTrue(r.authenticationSuccessful());
100+
assertFalse(r.hasChallenges());
101+
assertEquals("Found matching challenge", r.message);
102+
}
103+
104+
// --- Response bodies from validate-doc (envelope fields trimmed) ---
105+
106+
private static String hotpChallenge()
107+
{
108+
return "{\"detail\":{" +
109+
"\"client_mode\":\"interactive\"," +
110+
"\"message\":\"please enter otp: \"," +
111+
"\"messages\":[\"please enter otp: \"]," +
112+
"\"multi_challenge\":[{" +
113+
"\"client_mode\":\"interactive\"," +
114+
"\"message\":\"please enter otp: \"," +
115+
"\"serial\":\"hotp1\"," +
116+
"\"transaction_id\":\"08954727052769857579\"," +
117+
"\"type\":\"hotp\"}]," +
118+
"\"preferred_client_mode\":\"interactive\"," +
119+
"\"serial\":\"hotp1\"," +
120+
"\"transaction_id\":\"08954727052769857579\"," +
121+
"\"transaction_ids\":[\"08954727052769857579\"]," +
122+
"\"type\":\"hotp\"}," +
123+
"\"result\":{\"authentication\":\"CHALLENGE\",\"status\":true,\"value\":false}}";
124+
}
125+
126+
private static String mixedHotpAndPushChallenge()
127+
{
128+
return "{\"detail\":{" +
129+
"\"message\":\"Please confirm the authentication on your mobile device!, please enter otp: \"," +
130+
"\"messages\":[\"please enter otp: \",\"Please confirm the authentication on your mobile device!\"]," +
131+
"\"multi_challenge\":[{" +
132+
"\"client_mode\":\"interactive\"," +
133+
"\"message\":\"please enter otp: \"," +
134+
"\"serial\":\"CR2A\"," +
135+
"\"transaction_id\":\"02108856971392266777\"," +
136+
"\"type\":\"hotp\"}," +
137+
"{\"attributes\":{\"hideResponseInput\":true}," +
138+
"\"client_mode\":\"poll\"," +
139+
"\"message\":\"Please confirm the authentication on your mobile device!\"," +
140+
"\"serial\":\"PIPU001\"," +
141+
"\"transaction_id\":\"02108856971392266777\"," +
142+
"\"type\":\"push\"}]," +
143+
"\"preferred_client_mode\":\"poll\"," +
144+
"\"serial\":\"PIPU001\"," +
145+
"\"transaction_id\":\"02108856971392266777\"," +
146+
"\"transaction_ids\":[\"02108856971392266777\",\"02108856971392266777\"]," +
147+
"\"type\":\"push\"}," +
148+
"\"result\":{\"authentication\":\"CHALLENGE\",\"status\":true,\"value\":false}}";
149+
}
150+
151+
private static String foundMatchingChallenge()
152+
{
153+
return "{\"detail\":{" +
154+
"\"message\":\"Found matching challenge\"," +
155+
"\"serial\":\"hotp1\"}," +
156+
"\"result\":{\"authentication\":\"ACCEPT\",\"status\":true,\"value\":true}}";
157+
}
158+
}

0 commit comments

Comments
 (0)