|
| 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