From f36a006571c5d24ec20ed0c6444e32e38c5573da Mon Sep 17 00:00:00 2001 From: maximthomas Date: Tue, 13 Oct 2026 13:55:14 +0300 Subject: [PATCH 1/4] Fix #1170: let an HttpCallback header complete /json/authenticate when other callbacks cannot be filled WindowsDesktopSSO presents an HttpCallback followed by a script TextOutputCallback. A bodyless request carrying the Authorization header filled the HttpCallback, but the script callback can never be filled from a request, so the manager converted every callback to JSON and the HttpCallback handler answered 400. An HttpCallback has no JSON representation, so when one is present its header now decides the outcome, as in LoginViewBean.processHttpCallback: no header -> the handler's 401 challenge, wherever the HttpCallback sits in the list; header present -> the other callbacks are filled from the request where possible and all callbacks are submitted. Callback sets without an HttpCallback take the previous path unchanged. --- .../authn/RestAuthCallbackHandlerManager.java | 48 +++++++++++- .../RestAuthCallbackHandlerManagerTest.java | 75 +++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthCallbackHandlerManager.java b/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthCallbackHandlerManager.java index f10c0d4f6e..90eb77ef38 100644 --- a/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthCallbackHandlerManager.java +++ b/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthCallbackHandlerManager.java @@ -12,7 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2013-2015 ForgeRock AS. - * Portions copyright 2025 3A Systems LLC. + * Portions copyright 2025-2026 3A Systems LLC. */ package org.forgerock.openam.core.rest.authn; @@ -25,6 +25,7 @@ import java.util.List; import com.google.inject.Singleton; +import com.sun.identity.authentication.spi.HttpCallback; import com.sun.identity.shared.debug.Debug; import org.forgerock.json.JsonValue; import org.forgerock.json.resource.ResourceException; @@ -91,6 +92,9 @@ public JsonValue handleCallbacks(HttpServletRequest request, * headers and request or the headers and request do not contain the required information the method returns * false. * + * If the Callbacks contain an HttpCallback, its header decides the outcome instead, see + * {@link #handleHttpCallbacksInternally(HttpServletRequest, HttpServletResponse, Callback[])}. + * * @param request The HttpServletRequest from the request. * @param response The HttpServletResponse from the request. * @param callbacks The Callbacks to update with their required values from the headers and request. @@ -100,6 +104,12 @@ private boolean handleCallbacksInternally(HttpServletRequest request, HttpServletResponse response, Callback[] callbacks) throws RestAuthException { + for (Callback callback : callbacks) { + if (callback instanceof HttpCallback) { + return handleHttpCallbacksInternally(request, response, callbacks); + } + } + for (Callback callback : callbacks) { RestAuthCallbackHandler restAuthCallbackHandler = @@ -113,6 +123,42 @@ private boolean handleCallbacksInternally(HttpServletRequest request, return true; } + /** + * Updates Callbacks that include an HttpCallback. An HttpCallback has no JSON representation, so the header it + * names decides the outcome, as in the legacy login page: if the header is missing, the HttpCallback handler + * answers with a challenge; if it is present, the Callbacks are submitted. The other Callbacks (such as the + * script fallback of WindowsDesktopSSO) are filled from the headers and request as far as possible and are + * submitted as they are. + * + * @param request The HttpServletRequest from the request. + * @param response The HttpServletResponse from the request. + * @param callbacks The Callbacks to update, at least one of which is an HttpCallback. + * @return Whether or not the Callbacks were successfully updated. + */ + private boolean handleHttpCallbacksInternally(HttpServletRequest request, + HttpServletResponse response, Callback[] callbacks) + throws RestAuthException { + + for (Callback callback : callbacks) { + if (callback instanceof HttpCallback) { + RestAuthCallbackHandler restAuthCallbackHandler = + restAuthCallbackHandlerFactory.getRestAuthCallbackHandler(callback.getClass()); + if (!restAuthCallbackHandler.updateCallbackFromRequest(request, response, callback)) { + return false; + } + } + } + + for (Callback callback : callbacks) { + if (!(callback instanceof HttpCallback)) { + restAuthCallbackHandlerFactory.getRestAuthCallbackHandler(callback.getClass()) + .updateCallbackFromRequest(request, response, callback); + } + } + + return true; + } + /** * Handles the JSON representations of Callbacks, converting them back to Callbacks by setting the values from * the JSONArray to the original Callbacks passed in. diff --git a/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthCallbackHandlerManagerTest.java b/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthCallbackHandlerManagerTest.java index ab8179e120..132f50568d 100644 --- a/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthCallbackHandlerManagerTest.java +++ b/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthCallbackHandlerManagerTest.java @@ -17,6 +17,8 @@ package org.forgerock.openam.core.rest.authn; +import com.sun.identity.authentication.callbacks.ScriptTextOutputCallback; +import com.sun.identity.authentication.spi.HttpCallback; import org.forgerock.json.JsonValue; import org.forgerock.openam.core.rest.authn.callbackhandlers.RestAuthCallbackHandler; import org.forgerock.openam.core.rest.authn.exceptions.RestAuthResponseException; @@ -26,6 +28,7 @@ import org.testng.annotations.Test; import javax.security.auth.callback.Callback; +import javax.security.auth.callback.NameCallback; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.ws.rs.core.HttpHeaders; @@ -291,4 +294,76 @@ public void shouldFailToHandleJsonCallbacksWithMissingJSONCallback() throws Rest //Then fail(); } + + @Test + public void shouldSubmitCallbacksWhenHttpCallbackHeaderIsPresentAndScriptCannotBeFilled() + throws RestAuthException { + + //Given + RestAuthCallbackHandlerManager manager = + new RestAuthCallbackHandlerManager(RestAuthCallbackHandlerFactory.getInstance()); + HttpServletRequest request = postRequest(); + HttpServletResponse response = mock(HttpServletResponse.class); + HttpCallback httpCallback = new HttpCallback("Authorization", "WWW-Authenticate:Negotiate", "401"); + ScriptTextOutputCallback script = new ScriptTextOutputCallback("p.set('skipKerberos', 'true');"); + given(request.getHeader("Authorization")).willReturn("Negotiate YIIB"); + + //When + JsonValue jsonCallbacks = manager.handleCallbacks(request, response, new Callback[]{httpCallback, script}); + + //Then + assertEquals(jsonCallbacks.size(), 0); + assertEquals(httpCallback.getAuthorization(), "Negotiate YIIB"); + } + + @Test + public void shouldChallengeWhenHttpCallbackFollowsCallbackThatCannotBeFilled() throws RestAuthException { + + //Given + RestAuthCallbackHandlerManager manager = + new RestAuthCallbackHandlerManager(RestAuthCallbackHandlerFactory.getInstance()); + HttpServletRequest request = postRequest(); + HttpServletResponse response = mock(HttpServletResponse.class); + HttpCallback httpCallback = new HttpCallback("Authorization", "WWW-Authenticate:Negotiate", "401"); + ScriptTextOutputCallback script = new ScriptTextOutputCallback("p.set('skipKerberos', 'true');"); + + //When + try { + manager.handleCallbacks(request, response, new Callback[]{script, httpCallback}); + fail("Expected a 401 challenge for the missing Authorization header"); + } catch (RestAuthResponseException e) { + //Then + assertEquals(e.getStatusCode(), 401); + assertEquals(e.getResponseHeaders().get("WWW-Authenticate"), "Negotiate"); + } + } + + @Test + public void shouldFillOtherCallbacksFromRequestWhenHttpCallbackHeaderIsPresent() throws RestAuthException { + + //Given + RestAuthCallbackHandlerManager manager = + new RestAuthCallbackHandlerManager(RestAuthCallbackHandlerFactory.getInstance()); + HttpServletRequest request = postRequest(); + HttpServletResponse response = mock(HttpServletResponse.class); + HttpCallback httpCallback = new HttpCallback("Authorization", "WWW-Authenticate:Negotiate", "401"); + NameCallback nameCallback = new NameCallback("User Name:"); + ScriptTextOutputCallback script = new ScriptTextOutputCallback("p.set('skipKerberos', 'true');"); + given(request.getHeader("Authorization")).willReturn("Negotiate YIIB"); + given(request.getHeader("X-OpenAM-Username")).willReturn("demo"); + + //When + JsonValue jsonCallbacks = manager.handleCallbacks(request, response, + new Callback[]{httpCallback, nameCallback, script}); + + //Then + assertEquals(jsonCallbacks.size(), 0); + assertEquals(nameCallback.getName(), "demo"); + } + + private HttpServletRequest postRequest() { + HttpServletRequest request = mock(HttpServletRequest.class); + given(request.getMethod()).willReturn("POST"); + return request; + } } From 34e395980dffec8ed7be9496f78c1584c1ce61df Mon Sep 17 00:00:00 2001 From: maximthomas Date: Tue, 13 Oct 2026 13:57:12 +0300 Subject: [PATCH 2/4] e2e: assert one-request Kerberos SSO over REST and XUI (#1170) The curl and XUI cases pinned the 400 from #1170. They now assert the fixed behaviour: curl --negotiate gets 200 with a tokenId for the Kerberos user, and the XUI's first POST answers 200, the session belongs to the Kerberos user and the page leaves #login. --- e2e/kerberos/kerberos.spec.mjs | 37 ++++++++++++---------------------- 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/e2e/kerberos/kerberos.spec.mjs b/e2e/kerberos/kerberos.spec.mjs index c612f01808..2dd226d4c1 100644 --- a/e2e/kerberos/kerberos.spec.mjs +++ b/e2e/kerberos/kerberos.spec.mjs @@ -24,7 +24,7 @@ * pointed at .work/krb5.conf and .work/krb5cc by KRB5_CONFIG and KRB5CCNAME. * * Requires MIT Kerberos client tools (Debian/Ubuntu package krb5-user); skipped without them. - * Runs before HttpOnly mode is enabled: once #1170 is fixed, the curl case reads tokenId from the response body. + * Runs before HttpOnly mode is enabled: the curl case reads tokenId from the response body. * * /UI/Login is not covered: with the XUI enabled (the default) XUIFilter redirects it to the XUI. */ @@ -54,18 +54,6 @@ const BOOTSTRAP_ENV = { KRB_USER: process.env.KRB_USER ?? USERNAME, }; -// /json/authenticate answers 400 when the Negotiate header arrives with the request that starts the -// login: the module's script TextOutputCallback cannot be filled from the request, so -// RestAuthCallbackHandlerManager converts every callback to JSON and -// RestAuthHttpCallbackHandler.convertToJson rejects the HttpCallback. The browser resends the XUI -// request with the header after the 401 challenge, so the XUI hits the same error. -// The curl and XUI cases assert that 400. Once the endpoint is fixed they turn red: assert the session instead. -const REST_NEGOTIATE_BUG = "/json/authenticate rejects a Negotiate header with 400: " - + "https://github.com/OpenIdentityPlatform/OpenAM/issues/1170"; -// A request without the Authorization header gets the module's 401 first, so only one with the header -// reaches this message. -const HTTP_CALLBACK_REJECTED = "HttpCallback Authorization Header must be specified in the initial request"; - // The browser may answer Negotiate challenges from the OpenAM host only. test.use({ launchOptions: { @@ -173,8 +161,8 @@ test.afterEach(async ({}, testInfo) => { test.describe("OpenAM WindowsDesktopSSO - Kerberos SSO", () => { test("REST: the browser's Negotiate token completes a login started without it", async ({ page }) => { // The module reads the Negotiate header of whichever request continues the login, so a client - // that first obtains authId and then sends the header together with it is not affected by the - // one-step bug. This proves the KDC, the keytab, the module settings and the browser's Kerberos. + // that first obtains authId and then sends the header together with it completes the login too. + // This proves the KDC, the keytab, the module settings and the browser's Kerberos. const authorization = await negotiateHeaderFromBrowser(page); expect(authorization, "the browser should answer the Negotiate challenge").toBeTruthy(); @@ -194,7 +182,7 @@ test.describe("OpenAM WindowsDesktopSSO - Kerberos SSO", () => { expect(String(await idFromSession(page.request, tokenId)).toLowerCase()).toBe(krb.user.toLowerCase()); }); - test("REST: curl --negotiate is rejected with 400 until #1170 is fixed", async () => { + test("REST: curl --negotiate completes the login in one request", async ({ request }) => { const output = execFileSync("curl", [ "-sS", "--negotiate", "-u", ":", "-H", "Content-Type: application/json", @@ -208,12 +196,13 @@ test.describe("OpenAM WindowsDesktopSSO - Kerberos SSO", () => { const status = output.slice(separator + 1); console.log(`/json/authenticate answered ${status}: ${body}`); - // #1170: when this turns red, assert 200, tokenId and the session from idFromSession instead. - expect(status, REST_NEGOTIATE_BUG).toBe("400"); - expect(body, "the 400 should come from curl's Negotiate header").toContain(HTTP_CALLBACK_REJECTED); + expect(status, body).toBe("200"); + const { tokenId } = JSON.parse(body); + expect(tokenId, "the response should carry tokenId").toBeTruthy(); + expect(String(await idFromSession(request, tokenId)).toLowerCase()).toBe(krb.user.toLowerCase()); }); - test("XUI: the Negotiate login is rejected with 400 until #1170 is fixed", async ({ page }) => { + test("XUI: the browser's Negotiate token logs in without a form", async ({ page }) => { page.on("response", (resp) => { if (new URL(resp.url()).pathname.endsWith("/authenticate")) { console.log(`XUI ${resp.request().method()} ${resp.url()} answered ${resp.status()}`); @@ -225,10 +214,10 @@ test.describe("OpenAM WindowsDesktopSSO - Kerberos SSO", () => { await page.goto(`${OPENAM_BASE}/XUI/#login/&authIndexType=module` + `&authIndexValue=${encodeURIComponent(krb.module)}`); const resp = await login; - // #1170: when this turns red, poll idFromSession for the user and assert that no login form is shown instead. - expect(resp.status(), REST_NEGOTIATE_BUG).toBe(400); - expect(await resp.text(), "the 400 should come from the browser's Negotiate header") - .toContain(HTTP_CALLBACK_REJECTED); + expect(resp.status(), await resp.text()).toBe(200); + await expect.poll(async () => String(await idFromSession(page.request)).toLowerCase(), { timeout: 30_000 }) + .toBe(krb.user.toLowerCase()); + await expect(page).not.toHaveURL(/#login/, { timeout: 30_000 }); }); test("fallback: without a ticket the chain continues to the DataStore form", async ({ request }) => { From e6cc8ad45525284641ef049893abf37bfb4d2cbe Mon Sep 17 00:00:00 2001 From: maximthomas Date: Tue, 13 Oct 2026 14:58:02 +0300 Subject: [PATCH 3/4] Submit only output callbacks unfilled next to an HttpCallback (#1170) With the Authorization header present, every other callback was submitted whether or not it could be filled, so a module pairing an HttpCallback with, for example, a PasswordCallback received a null password where master answers 400. Next to an HttpCallback, only TextOutputCallback (which covers the script callback of WindowsDesktopSSO) and NameValueOutputCallback now count as filled. An input callback that cannot be filled from the request still makes the manager convert the callbacks to JSON, which ends in the HttpCallback's 400 as before. The check is folded into the existing loop. The 401 challenge still fires when the callbacks before the HttpCallback are filled or output only; sets without an HttpCallback take the same early return as on master. --- .../authn/RestAuthCallbackHandlerManager.java | 53 ++++--------------- .../RestAuthCallbackHandlerManagerTest.java | 48 ++++++++++++++++- 2 files changed, 58 insertions(+), 43 deletions(-) diff --git a/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthCallbackHandlerManager.java b/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthCallbackHandlerManager.java index 90eb77ef38..14c51f4b63 100644 --- a/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthCallbackHandlerManager.java +++ b/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthCallbackHandlerManager.java @@ -19,12 +19,15 @@ import jakarta.inject.Inject; import javax.security.auth.callback.Callback; +import javax.security.auth.callback.TextOutputCallback; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import com.google.inject.Singleton; +import com.sun.identity.authentication.callbacks.NameValueOutputCallback; import com.sun.identity.authentication.spi.HttpCallback; import com.sun.identity.shared.debug.Debug; import org.forgerock.json.JsonValue; @@ -92,8 +95,9 @@ public JsonValue handleCallbacks(HttpServletRequest request, * headers and request or the headers and request do not contain the required information the method returns * false. * - * If the Callbacks contain an HttpCallback, its header decides the outcome instead, see - * {@link #handleHttpCallbacksInternally(HttpServletRequest, HttpServletResponse, Callback[])}. + * An HttpCallback has no JSON representation, so when one is present, output-only Callbacks (such as the script + * fallback of WindowsDesktopSSO) count as updated. Input Callbacks still have to be filled from the headers and + * request. The HttpCallback handler answers with a challenge if its header is missing. * * @param request The HttpServletRequest from the request. * @param response The HttpServletResponse from the request. @@ -104,18 +108,15 @@ private boolean handleCallbacksInternally(HttpServletRequest request, HttpServletResponse response, Callback[] callbacks) throws RestAuthException { - for (Callback callback : callbacks) { - if (callback instanceof HttpCallback) { - return handleHttpCallbacksInternally(request, response, callbacks); - } - } + boolean hasHttpCallback = Arrays.stream(callbacks).anyMatch(HttpCallback.class::isInstance); for (Callback callback : callbacks) { RestAuthCallbackHandler restAuthCallbackHandler = restAuthCallbackHandlerFactory.getRestAuthCallbackHandler(callback.getClass()); - if (!restAuthCallbackHandler.updateCallbackFromRequest(request, response, callback)) { + if (!restAuthCallbackHandler.updateCallbackFromRequest(request, response, callback) + && !(hasHttpCallback && isOutputOnly(callback))) { return false; } } @@ -123,40 +124,8 @@ private boolean handleCallbacksInternally(HttpServletRequest request, return true; } - /** - * Updates Callbacks that include an HttpCallback. An HttpCallback has no JSON representation, so the header it - * names decides the outcome, as in the legacy login page: if the header is missing, the HttpCallback handler - * answers with a challenge; if it is present, the Callbacks are submitted. The other Callbacks (such as the - * script fallback of WindowsDesktopSSO) are filled from the headers and request as far as possible and are - * submitted as they are. - * - * @param request The HttpServletRequest from the request. - * @param response The HttpServletResponse from the request. - * @param callbacks The Callbacks to update, at least one of which is an HttpCallback. - * @return Whether or not the Callbacks were successfully updated. - */ - private boolean handleHttpCallbacksInternally(HttpServletRequest request, - HttpServletResponse response, Callback[] callbacks) - throws RestAuthException { - - for (Callback callback : callbacks) { - if (callback instanceof HttpCallback) { - RestAuthCallbackHandler restAuthCallbackHandler = - restAuthCallbackHandlerFactory.getRestAuthCallbackHandler(callback.getClass()); - if (!restAuthCallbackHandler.updateCallbackFromRequest(request, response, callback)) { - return false; - } - } - } - - for (Callback callback : callbacks) { - if (!(callback instanceof HttpCallback)) { - restAuthCallbackHandlerFactory.getRestAuthCallbackHandler(callback.getClass()) - .updateCallbackFromRequest(request, response, callback); - } - } - - return true; + private static boolean isOutputOnly(Callback callback) { + return callback instanceof TextOutputCallback || callback instanceof NameValueOutputCallback; } /** diff --git a/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthCallbackHandlerManagerTest.java b/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthCallbackHandlerManagerTest.java index 132f50568d..e990bb90f5 100644 --- a/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthCallbackHandlerManagerTest.java +++ b/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthCallbackHandlerManagerTest.java @@ -17,6 +17,7 @@ package org.forgerock.openam.core.rest.authn; +import com.sun.identity.authentication.callbacks.NameValueOutputCallback; import com.sun.identity.authentication.callbacks.ScriptTextOutputCallback; import com.sun.identity.authentication.spi.HttpCallback; import org.forgerock.json.JsonValue; @@ -29,6 +30,7 @@ import javax.security.auth.callback.Callback; import javax.security.auth.callback.NameCallback; +import javax.security.auth.callback.PasswordCallback; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.ws.rs.core.HttpHeaders; @@ -39,6 +41,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNull; import static org.testng.Assert.fail; public class RestAuthCallbackHandlerManagerTest { @@ -317,7 +320,27 @@ public void shouldSubmitCallbacksWhenHttpCallbackHeaderIsPresentAndScriptCannotB } @Test - public void shouldChallengeWhenHttpCallbackFollowsCallbackThatCannotBeFilled() throws RestAuthException { + public void shouldSubmitNameValueOutputCallbackWhenHttpCallbackHeaderIsPresent() throws RestAuthException { + + //Given + RestAuthCallbackHandlerManager manager = + new RestAuthCallbackHandlerManager(RestAuthCallbackHandlerFactory.getInstance()); + HttpServletRequest request = postRequest(); + HttpServletResponse response = mock(HttpServletResponse.class); + HttpCallback httpCallback = new HttpCallback("Authorization", "WWW-Authenticate:Negotiate", "401"); + NameValueOutputCallback nameValue = new NameValueOutputCallback("realm", "/"); + given(request.getHeader("Authorization")).willReturn("Negotiate YIIB"); + + //When + JsonValue jsonCallbacks = manager.handleCallbacks(request, response, + new Callback[]{httpCallback, nameValue}); + + //Then + assertEquals(jsonCallbacks.size(), 0); + } + + @Test + public void shouldChallengeWhenHttpCallbackFollowsOutputCallback() throws RestAuthException { //Given RestAuthCallbackHandlerManager manager = @@ -361,6 +384,29 @@ public void shouldFillOtherCallbacksFromRequestWhenHttpCallbackHeaderIsPresent() assertEquals(nameCallback.getName(), "demo"); } + @Test + public void shouldRejectHttpCallbackNextToInputCallbackThatCannotBeFilled() throws RestAuthException { + + //Given + RestAuthCallbackHandlerManager manager = + new RestAuthCallbackHandlerManager(RestAuthCallbackHandlerFactory.getInstance()); + HttpServletRequest request = postRequest(); + HttpServletResponse response = mock(HttpServletResponse.class); + HttpCallback httpCallback = new HttpCallback("Authorization", "WWW-Authenticate:Negotiate", "401"); + PasswordCallback passwordCallback = new PasswordCallback("Password:", false); + given(request.getHeader("Authorization")).willReturn("Negotiate YIIB"); + + //When + try { + manager.handleCallbacks(request, response, new Callback[]{httpCallback, passwordCallback}); + fail("Expected a 400 for the PasswordCallback that cannot be filled from the request"); + } catch (RestAuthException e) { + //Then + assertEquals(e.getStatusCode(), 400); + assertNull(passwordCallback.getPassword()); + } + } + private HttpServletRequest postRequest() { HttpServletRequest request = mock(HttpServletRequest.class); given(request.getMethod()).willReturn("POST"); From 81cbe132771cff918b9eae6b60a184b67074c861 Mon Sep 17 00:00:00 2001 From: maximthomas Date: Tue, 13 Oct 2026 14:58:02 +0300 Subject: [PATCH 4/4] e2e: read the XUI authenticate body only on failure (#1170) After a successful login the XUI navigates away, and Chromium may drop the previous document's response bodies, so resp.text() could throw even though Kerberos SSO worked. --- e2e/kerberos/kerberos.spec.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/e2e/kerberos/kerberos.spec.mjs b/e2e/kerberos/kerberos.spec.mjs index 2dd226d4c1..96e47e4af9 100644 --- a/e2e/kerberos/kerberos.spec.mjs +++ b/e2e/kerberos/kerberos.spec.mjs @@ -214,7 +214,11 @@ test.describe("OpenAM WindowsDesktopSSO - Kerberos SSO", () => { await page.goto(`${OPENAM_BASE}/XUI/#login/&authIndexType=module` + `&authIndexValue=${encodeURIComponent(krb.module)}`); const resp = await login; - expect(resp.status(), await resp.text()).toBe(200); + // Read the body only on failure: after a successful login the XUI navigates away, and Chromium may drop + // the previous document's response bodies. + if (resp.status() !== 200) { + throw new Error(`/authenticate answered ${resp.status()}: ${await resp.text()}`); + } await expect.poll(async () => String(await idFromSession(page.request)).toLowerCase(), { timeout: 30_000 }) .toBe(krb.user.toLowerCase()); await expect(page).not.toHaveURL(/#login/, { timeout: 30_000 });