From 6f98a137eae3925998e6ecb217f19bd91a655b8c Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Thu, 4 Jun 2026 14:21:54 +0300 Subject: [PATCH 01/16] feat(xui): support HttpOnly session cookie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow the XUI to work correctly when the OpenAM session cookie (iPlanetDirectoryPro) is issued with the HttpOnly flag, where the token cannot be read from JavaScript via document.cookie. Server - ServerInfoResource: expose the new "cookieHttpOnly" flag in /json/serverinfo/* so the XUI can detect the mode at runtime. XUI - SessionToken: detect HttpOnly mode via Configuration.globalData.cookieHttpOnly. When enabled, keep the token in memory for the page lifetime instead of reading/writing the cookie, return an HTTP_ONLY_SESSION_TOKEN sentinel from get(), and add isHttpOnly()/isResolvable() helpers. set()/remove() become no-ops on the cookie since it is managed server-side. - SessionService: omit the tokenId query param for getSessionInfo/logout when the token is not client-readable, so the server resolves the session from the automatically-sent HttpOnly cookie; suppress expected 400/401 for the no-token case. - AuthNService: only send sessionUpgradeSSOTokenId when the token is resolvable. - SiteConfigurationService: tolerate a missing/invalid session in checkForDifferences() and continue rendering instead of stalling. Tests / CI - e2e/xui/xui-httponly.spec.mjs: mode-agnostic Playwright spec that asserts the cookie HttpOnly attribute, JS visibility, login/idFromSession detection and logout match the server mode. - build.yml: run the UI smoke tests in both modes — default (HttpOnly disabled) and after restarting the IDP with -Dcom.sun.identity.cookie.httponly=true (HttpOnly enabled). Docs - dev-guide: document the new cookieHttpOnly field in the serverinfo example. --- .github/workflows/build.yml | 42 ++++++- e2e/xui/xui-httponly.spec.mjs | 110 ++++++++++++++++++ .../core/rest/server/ServerInfoResource.java | 3 +- .../asciidoc/dev-guide/chap-client-dev.adoc | 1 + .../services/SiteConfigurationService.js | 6 + .../ui/user/login/tokens/SessionToken.jsm | 56 +++++++++ .../openam/ui/user/services/AuthNService.js | 3 +- .../ui/user/services/SessionService.jsm | 24 +++- 8 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 e2e/xui/xui-httponly.spec.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c81ca74587..d197be3340 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,17 @@ -name: Build +# The contents of this file are subject to the terms of the Common Development and +# Distribution License (the License). You may not use this file except in compliance with the +# License. +# +# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the +# specific language governing permission and limitations under the License. +# +# When distributing Covered Software, include this CDDL Header Notice in each file and include +# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL +# Header, with the fields enclosed by brackets [] replaced by your own identifying +# information: "Portions copyright [year] [name of copyright owner]". +# +# Copyright 2021-2026 3A Systems, LLC. +name: Build on: push: @@ -276,7 +289,9 @@ jobs: with: sparse-checkout: e2e - - name: UI Smoke Tests (Playwright) + - name: UI Smoke Tests (Playwright) - HttpOnly disabled + env: + EXPECT_COOKIE_HTTPONLY: "false" run: | cd e2e npm init -y @@ -284,6 +299,29 @@ jobs: npx playwright install chromium --with-deps npx playwright test --reporter=list + - name: Enable HttpOnly session cookie on OpenAM IDP and restart + shell: bash + run: | + # com.sun.identity.cookie.httponly is read once at startup (static field + # in CookieUtils) and SystemProperties gives JVM -D properties priority, + # so we inject it via Tomcat setenv.sh and restart the same container + # (its configured data dir is preserved across a restart). + docker exec openam-idp bash -c ' + echo "export CATALINA_OPTS=\"\$CATALINA_OPTS -Dcom.sun.identity.cookie.httponly=true\"" > "$CATALINA_HOME/bin/setenv.sh" + chmod +x "$CATALINA_HOME/bin/setenv.sh"' + docker restart openam-idp + echo "waiting for OpenAM IDP to be alive again..." + timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" openam-idp | grep -q \"healthy\"; do sleep 10; done' + echo "verifying the server now reports cookieHttpOnly=true" + curl -sf "http://openam.example.org:8080/openam/json/serverinfo/*" | jq -e '.cookieHttpOnly == true' + + - name: UI Smoke Tests (Playwright) - HttpOnly enabled + env: + EXPECT_COOKIE_HTTPONLY: "true" + run: | + cd e2e + npx playwright test xui --reporter=list + - name: Upload failure artifacts uses: actions/upload-artifact@v7 if: ${{ failure() }} diff --git a/e2e/xui/xui-httponly.spec.mjs b/e2e/xui/xui-httponly.spec.mjs new file mode 100644 index 0000000000..0190a2c32b --- /dev/null +++ b/e2e/xui/xui-httponly.spec.mjs @@ -0,0 +1,110 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ + +/** + * OpenAM XUI - HttpOnly session cookie test + * + * Goal: prove that the XUI works correctly REGARDLESS of whether the session + * cookie (e.g. iPlanetDirectoryPro) is issued with the HttpOnly flag. + * + * The test is "mode-agnostic": it asks the server which mode it is running in + * (GET /json/serverinfo/*, field "cookieHttpOnly") and then asserts that the + * real browser cookie and the XUI behaviour match that mode. The very same spec + * therefore validates BOTH modes: + * - run it against a server started without the flag -> HttpOnly = false + * - run it against a server started with + * -Dcom.sun.identity.cookie.httponly=true -> HttpOnly = true + * + * Optionally set EXPECT_COOKIE_HTTPONLY=true|false to additionally assert that + * the server is in the expected mode (useful for the CI matrix). + */ + +import { test, expect } from "@playwright/test"; +import { OPENAM_BASE, USERNAME, PASSWORD } from "../common/openam-commons.mjs"; + +// XUI / LESS-based OpenAM login form selectors +const SEL = { + usernameInput: "#idToken1", + passwordInput: "#idToken2", + loginButton: "#loginButton", +}; + +// Optional hard expectation for the CI matrix ("true" | "false" | undefined) +const EXPECT_HTTPONLY = process.env.EXPECT_COOKIE_HTTPONLY; + +async function getServerInfo(request) { + const resp = await request.get(`${OPENAM_BASE}/json/serverinfo/*`, { + headers: { "Accept-API-Version": "protocol=1.0,resource=1.0" }, + }); + expect(resp.ok(), "GET /json/serverinfo/* should succeed").toBeTruthy(); + return resp.json(); +} + +test.describe("OpenAM XUI - HttpOnly session cookie", () => { + test("XUI login/session/logout work and cookie flag matches server mode", async ({ page, context }) => { + // ── 1. Discover the mode the server is actually running in ────────────── + const info = await getServerInfo(page.request); + const cookieName = info.cookieName ?? "iPlanetDirectoryPro"; + const httpOnly = info.cookieHttpOnly === true; + console.log(`Server reports cookieName=${cookieName}, cookieHttpOnly=${httpOnly}`); + + if (EXPECT_HTTPONLY !== undefined) { + expect(httpOnly, `server should run with cookieHttpOnly=${EXPECT_HTTPONLY}`) + .toBe(EXPECT_HTTPONLY === "true"); + } + + // ── 2. Log in through the XUI login form ──────────────────────────────── + await page.goto(`${OPENAM_BASE}/XUI/#login/`); + await expect(page.locator(SEL.usernameInput)).toBeVisible({ timeout: 20_000 }); + await page.fill(SEL.usernameInput, USERNAME); + await page.fill(SEL.passwordInput, PASSWORD); + await page.click(SEL.loginButton); + + // ── 3. XUI must consider the user logged in (leaves the #login route) ──── + await page.waitForURL((url) => !url.hash.startsWith("#login"), { timeout: 30_000 }); + + // ── 4. The session cookie must carry the expected HttpOnly attribute ──── + const cookies = await context.cookies(); + const session = cookies.find((c) => c.name === cookieName); + expect(session, `session cookie "${cookieName}" must be present`).toBeTruthy(); + expect(session.httpOnly, `cookie HttpOnly attribute must match server mode`).toBe(httpOnly); + + // ── 5. JS visibility of the cookie must match the mode ────────────────── + // With HttpOnly=true the token must NOT be readable from document.cookie; + // with HttpOnly=false it must be readable. XUI must keep working either way. + const visibleInJs = await page.evaluate((name) => document.cookie.includes(`${name}=`), cookieName); + expect(visibleInJs, "document.cookie visibility must be the inverse of HttpOnly").toBe(!httpOnly); + + // ── 6. Logged-in detection must work WITHOUT reading the cookie in JS ──── + // idFromSession resolves the session from the auto-sent (HttpOnly) cookie. + const idResp = await page.request.post( + `${OPENAM_BASE}/json/users?_action=idFromSession`, + { headers: { "Accept-API-Version": "protocol=1.0,resource=2.0" } } + ); + expect(idResp.ok(), "idFromSession should resolve the active session").toBeTruthy(); + const idJson = await idResp.json(); + expect(String(idJson.id).toLowerCase()).toBe(USERNAME.toLowerCase()); + + // ── 7. Logout through the XUI must end back on the login route ────────── + await page.goto(`${OPENAM_BASE}/XUI/#logout/`); + await page.waitForURL((url) => url.hash.startsWith("#login"), { timeout: 30_000 }); + + // ── 8. Session cookie must be gone after logout ───────────────────────── + const afterLogout = (await context.cookies()).find((c) => c.name === cookieName && c.value); + expect(afterLogout, "session cookie must be cleared after logout").toBeFalsy(); + }); +}); + diff --git a/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/server/ServerInfoResource.java b/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/server/ServerInfoResource.java index 52f2909f5a..6218bab685 100644 --- a/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/server/ServerInfoResource.java +++ b/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/server/ServerInfoResource.java @@ -12,7 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2013-2016 ForgeRock AS. - * Portions copyright 2025 3A Systems LLC. + * Portions copyright 2021-2026 3A Systems LLC. */ package org.forgerock.openam.core.rest.server; @@ -172,6 +172,7 @@ private Promise getAllServerInfo(Context co result.put("protectedUserAttributes", new ArrayList<>(protectedUserAttributes)); result.put("cookieName", SystemProperties.get(Constants.AM_COOKIE_NAME, "iPlanetDirectoryPro")); result.put("secureCookie", CookieUtils.isCookieSecure()); + result.put("cookieHttpOnly", CookieUtils.isCookieHttpOnly()); result.put("cookieSameSite", SystemProperties.get(Constants.AM_COOKIE_SAMESITE, null)); result.put("forgotPassword", String.valueOf(selfServiceInfo.isForgottenPasswordEnabled())); result.put("forgotUsername", String.valueOf(selfServiceInfo.isForgottenUsernameEnabled())); diff --git a/openam-documentation/openam-doc-source/src/main/asciidoc/dev-guide/chap-client-dev.adoc b/openam-documentation/openam-doc-source/src/main/asciidoc/dev-guide/chap-client-dev.adoc index 8a7241fad1..b3161f6eb6 100644 --- a/openam-documentation/openam-doc-source/src/main/asciidoc/dev-guide/chap-client-dev.adoc +++ b/openam-documentation/openam-doc-source/src/main/asciidoc/dev-guide/chap-client-dev.adoc @@ -938,6 +938,7 @@ $ curl https://openam.example.com:8443/openam/json/serverinfo/* "protectedUserAttributes": [], "cookieName": "iPlanetDirectoryPro", "secureCookie": false, + "cookieHttpOnly": false, "forgotPassword": "false", "forgotUsername": "false", "kbaEnabled": "false", diff --git a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/common/services/SiteConfigurationService.js b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/common/services/SiteConfigurationService.js index 77af5b5319..51fb90a354 100644 --- a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/common/services/SiteConfigurationService.js +++ b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/common/services/SiteConfigurationService.js @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Portions copyright 2014-2016 ForgeRock AS. + * Portions copyright 2026 3A Systems, LLC. */ define([ @@ -59,6 +60,11 @@ define([ if (isRealmChanged()) { location.href = "#confirmLogin/"; } + }, () => { + // No (valid) session - e.g. anonymous user, or an HttpOnly session cookie that + // cannot be read and turned out not to reference a live session. Continue without + // a realm change check rather than stalling page rendering. + return $.Deferred().resolve(); }); } else { return $.Deferred().resolve(); diff --git a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/login/tokens/SessionToken.jsm b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/login/tokens/SessionToken.jsm index 603ce1078b..8e19e91c75 100644 --- a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/login/tokens/SessionToken.jsm +++ b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/login/tokens/SessionToken.jsm @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. + * Portions copyright 2021-2026 3A Systems, LLC. */ /** @@ -22,6 +23,24 @@ import CookieHelper from "org/forgerock/commons/ui/common/util/CookieHelper"; import Configuration from "org/forgerock/commons/ui/common/main/Configuration"; +/** + * Sentinel value returned by {@link get} when the OpenAM session cookie is configured as + * HttpOnly and therefore cannot be read by JavaScript. In that case the real token + * value is held by the browser in the HttpOnly cookie and is sent automatically with every + * (same-origin, credentialed) request. The server resolves the token from the cookie/header when + * no tokenId is supplied, so this sentinel is enough for the XUI to know that a + * session may exist. + * @type {String} + */ +export const HTTP_ONLY_TOKEN = "HTTP_ONLY_SESSION_TOKEN"; + +/** + * Retains the token value during the lifetime of the page when the session cookie is HttpOnly. + * This allows JavaScript to use the real token immediately after authentication, even though it + * cannot be read back from the (HttpOnly) cookie. + */ +let inMemoryToken; + function cookieName () { return Configuration.globalData.auth.cookieName; } @@ -38,14 +57,51 @@ function cookieSameSite () { return Configuration.globalData.auth.cookieSameSite; } +/** + * Whether the OpenAM session cookie is configured to be HttpOnly. When true the session cookie + * cannot be read or written from JavaScript and is managed entirely by the server. + * @returns {Boolean} true if the session cookie is HttpOnly. + */ +export function isHttpOnly () { + const httpOnly = Configuration.globalData.cookieHttpOnly; + return httpOnly === true || httpOnly === "true"; +} + +/** + * Whether the supplied token is a real (JavaScript readable) token value, as opposed to the + * {@link HTTP_ONLY_TOKEN} sentinel used when the session cookie is HttpOnly. + * @param {String} token The token to test. + * @returns {Boolean} true if the token value is usable as a tokenId on the client side. + */ +export function isResolvable (token) { + return Boolean(token) && token !== HTTP_ONLY_TOKEN; +} + export function set (token) { + if (isHttpOnly()) { + // The server is responsible for setting the HttpOnly session cookie. JavaScript cannot + // write it, so we only retain the value in memory for the lifetime of the page. + inMemoryToken = token; + return token; + } return CookieHelper.setCookie(cookieName(), token, "", "/", cookieDomains(), secureCookie(), cookieSameSite()); } export function get () { + if (isHttpOnly()) { + // Return the token retained in memory if available (e.g. straight after authentication), + // otherwise a sentinel so that callers know a session may exist. The real value is held in + // the HttpOnly cookie and is resolved server-side from the request. + return inMemoryToken || HTTP_ONLY_TOKEN; + } return CookieHelper.getCookie(cookieName()); } export function remove () { + inMemoryToken = undefined; + if (isHttpOnly()) { + // An HttpOnly cookie cannot be removed from JavaScript; the server clears it on logout. + return undefined; + } return CookieHelper.deleteCookie(cookieName(), "/", cookieDomains()); } diff --git a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/services/AuthNService.js b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/services/AuthNService.js index 5ca3489179..e9de74c650 100644 --- a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/services/AuthNService.js +++ b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/services/AuthNService.js @@ -13,6 +13,7 @@ * * Portions copyright 2011-2016 ForgeRock AS. * Portions copyright 2019 Open Source Solution Technology Corporation + * Portions copyright 2026 3A Systems, LLC. */ define([ "jquery", @@ -45,7 +46,7 @@ define([ // In case user has logged in already update session const sessionToken = SessionToken.get(); - if (sessionToken) { + if (sessionToken && SessionToken.isResolvable(sessionToken)) { params.sessionUpgradeSSOTokenId = sessionToken; } diff --git a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/services/SessionService.jsm b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/services/SessionService.jsm index a09067be8f..c2e0a1e008 100644 --- a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/services/SessionService.jsm +++ b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/services/SessionService.jsm @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Portions copyright 2014-2016 ForgeRock AS. + * Portions copyright 2026 3A Systems, LLC. */ import _ from "lodash"; @@ -21,17 +22,31 @@ import AbstractDelegate from "org/forgerock/commons/ui/common/main/AbstractDeleg import Constants from "org/forgerock/commons/ui/common/util/Constants"; import store from "store/index"; import moment from "moment"; +import { isResolvable } from "org/forgerock/openam/ui/user/login/tokens/SessionToken"; const obj = new AbstractDelegate(`${Constants.host}/${Constants.context}/json/sessions`); const getSessionInfo = (token, options) => { + // When the session cookie is HttpOnly the token cannot be read by JavaScript. In that case + // we omit the tokenId so that the server resolves the session from the (automatically sent) + // HttpOnly cookie / Cookie header instead. + const resolvable = isResolvable(token); + const tokenIdParam = resolvable ? `&tokenId=${token}` : ""; + // Without a client-readable token we cannot know up front whether a session exists, so we let + // the call fail quietly (e.g. when anonymous) and let the caller's rejection handler decide. + const suppressMissingSession = resolvable ? {} : { + errorsHandlers: { + "Bad Request": { status: 400 }, + "Unauthorized": { status: 401 } + } + }; return obj.serviceCall(_.merge({ - url: `?_action=getSessionInfo&tokenId=${token}`, + url: `?_action=getSessionInfo${tokenIdParam}`, type: "POST", data: {}, headers: { "Accept-API-Version": "protocol=1.0,resource=2.0" } - }, options)); + }, suppressMissingSession, options)); }; export const getTimeLeft = (token) => { @@ -55,8 +70,11 @@ export const updateSessionInfo = (token, options) => { export const isSessionValid = (token) => getSessionInfo(token).then((response) => _.has(response, "username")); export const logout = (token) => { + // Omit tokenId when the token is not client-readable (HttpOnly cookie); the server resolves + // the session to invalidate from the request cookie instead. + const tokenIdParam = isResolvable(token) ? `&tokenId=${token}` : ""; return obj.serviceCall({ - url: `?_action=logout&tokenId=${token}`, + url: `?_action=logout${tokenIdParam}`, type: "POST", data: {}, headers: { From f522b5480ccbf2b3534188e1fa8b9fcac283cb50 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Thu, 4 Jun 2026 16:04:52 +0300 Subject: [PATCH 02/16] test(e2e): fix XUI login button selector in HttpOnly spec The HttpOnly spec waited for "#loginButton", which does not exist in this XUI build, so the login click hung until the global test timeout. - Match the submit button by type, like the working SAML spec, and keep the id as a fallback: "#loginButton, input[type=submit], button[type=submit]". - Click the first visible match to avoid a strict-mode violation when several elements match. --- e2e/xui/xui-httponly.spec.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/e2e/xui/xui-httponly.spec.mjs b/e2e/xui/xui-httponly.spec.mjs index 0190a2c32b..76a6137dc5 100644 --- a/e2e/xui/xui-httponly.spec.mjs +++ b/e2e/xui/xui-httponly.spec.mjs @@ -39,7 +39,9 @@ import { OPENAM_BASE, USERNAME, PASSWORD } from "../common/openam-commons.mjs"; const SEL = { usernameInput: "#idToken1", passwordInput: "#idToken2", - loginButton: "#loginButton", + // The submit button id varies between XUI builds (loginButton / loginButton_0 / none), + // so match by submit type as the working SAML spec does. + loginButton: "#loginButton, input[type=\"submit\"], button[type=\"submit\"]", }; // Optional hard expectation for the CI matrix ("true" | "false" | undefined) @@ -71,7 +73,7 @@ test.describe("OpenAM XUI - HttpOnly session cookie", () => { await expect(page.locator(SEL.usernameInput)).toBeVisible({ timeout: 20_000 }); await page.fill(SEL.usernameInput, USERNAME); await page.fill(SEL.passwordInput, PASSWORD); - await page.click(SEL.loginButton); + await page.locator(SEL.loginButton).first().click(); // ── 3. XUI must consider the user logged in (leaves the #login route) ──── await page.waitForURL((url) => !url.hash.startsWith("#login"), { timeout: 30_000 }); @@ -108,3 +110,5 @@ test.describe("OpenAM XUI - HttpOnly session cookie", () => { }); }); + + From 28cc65542de21a706263618cebfafd31b472c7f6 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Thu, 4 Jun 2026 17:12:55 +0300 Subject: [PATCH 03/16] Logout: XUI redirects to "#loggedOut/", not "#login/", so accept either route when waiting for the session to end. --- e2e/xui/xui-httponly.spec.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/e2e/xui/xui-httponly.spec.mjs b/e2e/xui/xui-httponly.spec.mjs index 76a6137dc5..38d804cd12 100644 --- a/e2e/xui/xui-httponly.spec.mjs +++ b/e2e/xui/xui-httponly.spec.mjs @@ -100,9 +100,9 @@ test.describe("OpenAM XUI - HttpOnly session cookie", () => { const idJson = await idResp.json(); expect(String(idJson.id).toLowerCase()).toBe(USERNAME.toLowerCase()); - // ── 7. Logout through the XUI must end back on the login route ────────── + // ── 7. Logout through the XUI must end on the logged-out/login route ──── await page.goto(`${OPENAM_BASE}/XUI/#logout/`); - await page.waitForURL((url) => url.hash.startsWith("#login"), { timeout: 30_000 }); + await page.waitForURL((url) => /^#(loggedOut|login)/.test(url.hash), { timeout: 30_000 }); // ── 8. Session cookie must be gone after logout ───────────────────────── const afterLogout = (await context.cookies()).find((c) => c.name === cookieName && c.value); @@ -112,3 +112,4 @@ test.describe("OpenAM XUI - HttpOnly session cookie", () => { + From 7dff22f20eb5b068dbfef205a38d08bd96800429 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Thu, 4 Jun 2026 19:13:05 +0300 Subject: [PATCH 04/16] test(e2e): make XUI HttpOnly spec robust in both cookie modes Stabilize the XUI HttpOnly Playwright spec so it passes against the real XUI build with the session cookie both with and without the HttpOnly flag. - Login: match the submit button by type with the id as a fallback ("#loginButton, input[type=submit], button[type=submit]") and click the first visible match, since "#loginButton" is absent in this build. - Logout: XUI redirects to "#loggedOut/" (not "#login/"), so accept either route when waiting for the session to end. - Logout assertion: verify the session is invalidated server-side via idFromSession instead of checking the browser cookie. In HttpOnly mode JS cannot clear the cookie and the REST logout may not emit a Set-Cookie, so a stale-but-dead cookie can linger; server-side invalidation holds in both modes. --- e2e/xui/xui-httponly.spec.mjs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/e2e/xui/xui-httponly.spec.mjs b/e2e/xui/xui-httponly.spec.mjs index 38d804cd12..28326b67a9 100644 --- a/e2e/xui/xui-httponly.spec.mjs +++ b/e2e/xui/xui-httponly.spec.mjs @@ -104,12 +104,22 @@ test.describe("OpenAM XUI - HttpOnly session cookie", () => { await page.goto(`${OPENAM_BASE}/XUI/#logout/`); await page.waitForURL((url) => /^#(loggedOut|login)/.test(url.hash), { timeout: 30_000 }); - // ── 8. Session cookie must be gone after logout ───────────────────────── - const afterLogout = (await context.cookies()).find((c) => c.name === cookieName && c.value); - expect(afterLogout, "session cookie must be cleared after logout").toBeFalsy(); + // ── 8. The session must be invalidated server-side after logout ───────── + // Checking the browser cookie is not reliable: in HttpOnly mode JavaScript + // cannot clear it and the REST logout may not emit a Set-Cookie, so a stale + // (but dead) cookie can linger. The meaningful guarantee is that the server + // no longer resolves the session, which holds in both modes. + const afterLogoutId = await page.request.post( + `${OPENAM_BASE}/json/users?_action=idFromSession`, + { headers: { "Accept-API-Version": "protocol=1.0,resource=2.0" } } + ); + const sessionStillValid = afterLogoutId.ok() && + String((await afterLogoutId.json()).id).toLowerCase() === USERNAME.toLowerCase(); + expect(sessionStillValid, "session must be invalid after logout").toBe(false); }); }); + From 5e0d7c501664d59ef4c9f3017b662e6ab9c10739 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Thu, 4 Jun 2026 22:36:22 +0300 Subject: [PATCH 05/16] revert: re-enable XUI when session cookie is HttpOnly Reverts the workaround from 3913a59 that disabled the XUI entirely when the session cookie is HttpOnly. The XUI now supports HttpOnly session cookies, so it must stay enabled in that mode. --- .../src/main/java/org/forgerock/openam/xui/XUIState.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openam-core/src/main/java/org/forgerock/openam/xui/XUIState.java b/openam-core/src/main/java/org/forgerock/openam/xui/XUIState.java index 860f7e7d57..e3390005a4 100644 --- a/openam-core/src/main/java/org/forgerock/openam/xui/XUIState.java +++ b/openam-core/src/main/java/org/forgerock/openam/xui/XUIState.java @@ -25,7 +25,6 @@ import com.sun.identity.security.AdminTokenAction; import com.sun.identity.shared.datastruct.CollectionHelper; import com.sun.identity.shared.debug.Debug; -import com.sun.identity.shared.encode.CookieUtils; import com.sun.identity.sm.SMSException; import com.sun.identity.sm.ServiceListener; import com.sun.identity.sm.ServiceSchema; @@ -62,7 +61,7 @@ private void detectMode(String service, String attribute) { try { ServiceSchema schema = schemaManager.getGlobalSchema(); Map defaults = schema.getAttributeDefaults(); - enabled = !CookieUtils.isCookieHttpOnly() && Boolean.parseBoolean(SystemProperties.get("XUI.enable", CollectionHelper.getMapAttr(defaults, attribute, ""))); + enabled = Boolean.parseBoolean(SystemProperties.get("XUI.enable", CollectionHelper.getMapAttr(defaults, attribute, ""))); if (listenerId == null) { listenerId = schemaManager.addListener(this); } From 8a90ec29194191c4a9bd500aef36a1a8c324e7a3 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Thu, 4 Jun 2026 22:36:34 +0300 Subject: [PATCH 06/16] test(e2e): verify admin stays logged in after page reload Add an e2e check that an admin remains authenticated in the console after a full browser page reload (the reload drops any in-memory token, so the session must be re-detected from the auto-sent cookie). Extract shared loginViaXui()/idFromSession() helpers and update the PR description. --- e2e/xui/xui-httponly.spec.mjs | 51 ++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/e2e/xui/xui-httponly.spec.mjs b/e2e/xui/xui-httponly.spec.mjs index 28326b67a9..f8ebcc5e8a 100644 --- a/e2e/xui/xui-httponly.spec.mjs +++ b/e2e/xui/xui-httponly.spec.mjs @@ -33,7 +33,7 @@ */ import { test, expect } from "@playwright/test"; -import { OPENAM_BASE, USERNAME, PASSWORD } from "../common/openam-commons.mjs"; +import { OPENAM_BASE, USERNAME, PASSWORD, ADMIN_USER, ADMIN_PASS } from "../common/openam-commons.mjs"; // XUI / LESS-based OpenAM login form selectors const SEL = { @@ -55,6 +55,27 @@ async function getServerInfo(request) { return resp.json(); } +/** Log in through the XUI login form and wait until the user leaves the #login route. */ +async function loginViaXui(page, user, pass) { + await page.goto(`${OPENAM_BASE}/XUI/#login/`); + await expect(page.locator(SEL.usernameInput)).toBeVisible({ timeout: 20_000 }); + await page.fill(SEL.usernameInput, user); + await page.fill(SEL.passwordInput, pass); + await page.locator(SEL.loginButton).first().click(); + await page.waitForURL((url) => !url.hash.startsWith("#login"), { timeout: 30_000 }); +} + +/** Resolve the username of the active session from the (auto-sent) session cookie. */ +async function idFromSession(request) { + const resp = await request.post(`${OPENAM_BASE}/json/users?_action=idFromSession`, { + headers: { "Accept-API-Version": "protocol=1.0,resource=2.0" }, + }); + if (!resp.ok()) { + return null; + } + return (await resp.json()).id; +} + test.describe("OpenAM XUI - HttpOnly session cookie", () => { test("XUI login/session/logout work and cookie flag matches server mode", async ({ page, context }) => { // ── 1. Discover the mode the server is actually running in ────────────── @@ -117,9 +138,37 @@ test.describe("OpenAM XUI - HttpOnly session cookie", () => { String((await afterLogoutId.json()).id).toLowerCase() === USERNAME.toLowerCase(); expect(sessionStillValid, "session must be invalid after logout").toBe(false); }); + + test("admin stays logged in to the console after a browser page reload", async ({ page }) => { + // Reloading re-bootstraps the XUI from scratch: any in-memory token is lost, so the + // session must be re-detected purely from the (auto-sent) session cookie. This is the + // critical path that the HttpOnly support has to keep working. + + // ── 1. Log in to the admin console ────────────────────────────────────── + await loginViaXui(page, ADMIN_USER, ADMIN_PASS); + expect(String(await idFromSession(page.request)).toLowerCase()).toBe(ADMIN_USER.toLowerCase()); + + // Land on the admin console (realms view) so the reload happens on a real console route. + await page.goto(`${OPENAM_BASE}/XUI/#realms/%2F`); + await page.waitForURL((url) => !url.hash.startsWith("#login"), { timeout: 30_000 }); + + // ── 2. Reload the page in the browser ─────────────────────────────────── + await page.reload({ waitUntil: "networkidle" }); + + // ── 3. The user must still be logged in (not bounced back to #login) ───── + await page.waitForLoadState("networkidle"); + expect(page.url(), "reload must not redirect to the login page").not.toContain("#login"); + await expect(page.locator(SEL.usernameInput), "login form must not be shown after reload") + .toHaveCount(0); + + // ── 4. The session is still resolvable after the reload ───────────────── + expect(String(await idFromSession(page.request)).toLowerCase()).toBe(ADMIN_USER.toLowerCase()); + }); }); + + From d742217241b4ab44c32bd7520741566f30cf0af6 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Tue, 9 Jun 2026 14:08:21 +0300 Subject: [PATCH 07/16] fix(authn): fall back to HttpOnly session cookie as step-up upgrade target When the session cookie is configured as HttpOnly, the XUI cannot read the tokenId from JavaScript and therefore cannot send the sessionUpgradeSSOTokenId query parameter on an agent-driven session upgrade (step-up), which is performed via a fresh page load with an empty in-memory token. Server-side that parameter was the only source for the session to upgrade, so the request fell through to a brand-new login: the existing session was orphaned, its properties/sessionHandle were lost, and composite-advice step-up could loop. RestAuthenticationHandler now resolves the upgrade target from the auto-sent HttpOnly session cookie when sessionUpgradeSSOTokenId is absent. The fallback is limited to the HttpOnly deployment mode (CookieUtils.isCookieHttpOnly()), so the behaviour of all other token-readable deployments is unchanged. Also clean up leftover merge-conflict markers in the file's license header. Changes: - RestAuthenticationHandler: add resolveSessionUpgradeTarget() and apply it in the authenticate flow before resolving the auth index. - RestAuthenticationHandlerTest: cover the cookie fallback in HttpOnly mode and the unchanged behaviour when a token is supplied / HttpOnly is off. - e2e/xui/xui-httponly.spec.mjs: add a step-up scenario asserting the existing session is recognised as the upgrade target (no fresh authId/callbacks) in HttpOnly mode; consolidated from the separate session-upgrade spec. --- e2e/xui/xui-httponly.spec.mjs | 62 ++++++++++ .../rest/authn/RestAuthenticationHandler.java | 44 ++++++- .../authn/RestAuthenticationHandlerTest.java | 117 +++++++++++++++++- 3 files changed, 213 insertions(+), 10 deletions(-) diff --git a/e2e/xui/xui-httponly.spec.mjs b/e2e/xui/xui-httponly.spec.mjs index f8ebcc5e8a..87afa503cc 100644 --- a/e2e/xui/xui-httponly.spec.mjs +++ b/e2e/xui/xui-httponly.spec.mjs @@ -30,6 +30,23 @@ * * Optionally set EXPECT_COOKIE_HTTPONLY=true|false to additionally assert that * the server is in the expected mode (useful for the CI matrix). + * + * This spec covers three scenarios: + * 1. login / session detection / logout, with the cookie HttpOnly flag matching the server mode; + * 2. the admin staying logged in to the console after a full browser page reload; + * 3. an agent-driven session upgrade (step-up) being recognised as an upgrade — not a brand-new + * login — after a fresh page load in HttpOnly mode. + * + * Step-up background / the bug it guards against: + * A step-up is triggered by a fresh page load (a redirect from the agent). After such a reload + * the XUI in-memory token is empty and, because the session cookie is HttpOnly, JavaScript cannot + * read the tokenId. As a result the XUI cannot send the "sessionUpgradeSSOTokenId" query param. + * Server-side that param used to be the ONLY source for the session to upgrade + * (LoginAuthenticator resolves it via getExistingValidSSOToken(new SessionID(getSSOTokenId())) and + * never reads the cookie). Without a fallback the request falls through to a brand-new login: the + * existing session is orphaned, its properties/sessionHandle are lost and composite-advice step-up + * can loop. The fix: when "sessionUpgradeSSOTokenId" is absent the REST authenticate flow falls + * back to the session carried by the (auto-sent) HttpOnly cookie as the upgrade target. */ import { test, expect } from "@playwright/test"; @@ -164,6 +181,49 @@ test.describe("OpenAM XUI - HttpOnly session cookie", () => { // ── 4. The session is still resolvable after the reload ───────────────── expect(String(await idFromSession(page.request)).toLowerCase()).toBe(ADMIN_USER.toLowerCase()); }); + + test("step-up after a fresh page load is recognised as a session upgrade, not a new login", + async ({ page }) => { + // ── 1. Discover the mode the server is actually running in ────────────── + const info = await getServerInfo(page.request); + const httpOnly = info.cookieHttpOnly === true; + console.log(`Server reports cookieHttpOnly=${httpOnly}`); + + // The cookie fallback is specific to HttpOnly mode; in token-readable mode the XUI sends + // the upgrade token itself and there is nothing to fall back to. + test.skip(!httpOnly, "Session-cookie upgrade fallback only applies in HttpOnly mode"); + + // ── 2. Log in -> establishes the HttpOnly session cookie in the browser ── + await loginViaXui(page, USERNAME, PASSWORD); + const idBefore = await idFromSession(page.request); + expect(String(idBefore).toLowerCase()).toBe(USERNAME.toLowerCase()); + + // ── 3. Simulate the step-up request issued right after the redirect ────── + // A fresh page load means the XUI in-memory token is empty and the HttpOnly cookie + // cannot be read, so NO sessionUpgradeSSOTokenId is sent. The HttpOnly session cookie + // is, however, auto-sent with this request. + const resp = await page.request.post(`${OPENAM_BASE}/json/authenticate`, { + headers: { + "Content-Type": "application/json", + "Accept-API-Version": "protocol=1.0,resource=2.1", + }, + data: "{}", + }); + expect(resp.ok(), "authenticate against the existing session should succeed").toBeTruthy(); + const body = await resp.json(); + + // ── 4. The existing session is recognised as the upgrade target ────────── + // With the cookie fallback the server resolves the session from the HttpOnly cookie and + // returns its tokenId. Without the fix it would start a brand-new login and answer with + // an authId + callbacks (a fresh login form) instead. + expect(body.authId, "must NOT start a brand-new login flow (no fresh authId)").toBeFalsy(); + expect(body.callbacks, "must NOT present a fresh login form (no callbacks)").toBeFalsy(); + expect(body.tokenId, "the existing session must be recognised (tokenId returned)").toBeTruthy(); + + // ── 5. The session is still the same user's session (not orphaned/replaced) ── + const idAfter = await idFromSession(page.request); + expect(String(idAfter).toLowerCase()).toBe(USERNAME.toLowerCase()); + }); }); @@ -172,3 +232,5 @@ test.describe("OpenAM XUI - HttpOnly session cookie", () => { + + diff --git a/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandler.java b/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandler.java index 24191347c5..671cf97a27 100644 --- a/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandler.java +++ b/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandler.java @@ -11,13 +11,9 @@ * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions copyright [year] [name of copyright owner]". * -<<<<<<< HEAD * Copyright 2013-2016 ForgeRock AS. -======= - * Copyright 2013-2015 ForgeRock AS. * Portions copyright 2019 Open Source Solution Technology Corporation ->>>>>>> cafd23ed69... Remove an input parameter included in exception message (#123) - * Portions copyright 2025 3A Systems LLC. + * Portions copyright 2018-2026 3A Systems LLC. */ package org.forgerock.openam.core.rest.authn; @@ -37,7 +33,9 @@ >>>>>>> cafd23ed69... Remove an input parameter included in exception message (# import com.sun.identity.authentication.spi.PagePropertiesCallback; import com.sun.identity.authentication.spi.RedirectCallback; import com.sun.identity.shared.debug.Debug; +import com.sun.identity.shared.encode.CookieUtils; import com.sun.identity.shared.locale.L10NMessageImpl; +import org.forgerock.openam.utils.StringUtils; import org.forgerock.json.JsonException; import org.forgerock.json.JsonValue; import org.forgerock.json.jose.exceptions.JwsSigningException; @@ -151,6 +149,8 @@ private JsonValue authenticate(HttpServletRequest request, HttpServletResponse r AuthIndexType indexType = getAuthIndexType(authIndexType); + sessionUpgradeSSOTokenId = resolveSessionUpgradeTarget(request, sessionUpgradeSSOTokenId); + String authId = null; String sessionId = null; @@ -210,6 +210,40 @@ private String getRealmDomainName(SignedJwt jwt) { return jwt.getClaimsSet().getClaim("realm", String.class); } + /** + * Resolves the SSO Token Id of the session to upgrade (step-up). + *

+ * Normally the client supplies this through the {@code sessionUpgradeSSOTokenId} query + * parameter. However, when the session cookie is configured as {@code HttpOnly} the XUI cannot + * read the {@code tokenId} from JavaScript, so it cannot send the parameter on an agent-driven + * session upgrade, which is performed via a fresh page load with an empty in-memory token. + *

+ * In that case we fall back to the session carried by the (auto-sent) {@code HttpOnly} cookie as + * the upgrade target, so that the existing session is upgraded instead of being orphaned by a + * brand new login (which would lose its properties/sessionHandle and could make composite-advice + * step-up loop). The fallback is limited to the {@code HttpOnly} deployment mode so that the + * behaviour of all other (token-readable) deployments is unchanged. + * + * @param request The HttpServletRequest. + * @param sessionUpgradeSSOTokenId The explicitly supplied upgrade token id, may be null/empty. + * @return The upgrade token id to use, possibly resolved from the session cookie. + */ + private String resolveSessionUpgradeTarget(HttpServletRequest request, String sessionUpgradeSSOTokenId) { + if (!StringUtils.isEmpty(sessionUpgradeSSOTokenId) || !CookieUtils.isCookieHttpOnly()) { + return sessionUpgradeSSOTokenId; + } + SSOToken existingToken = coreServicesWrapper.getExistingValidSSOToken( + coreServicesWrapper.getSessionIDFromRequest(request)); + if (existingToken != null) { + String tokenId = existingToken.getTokenID().toString(); + if (DEBUG.messageEnabled()) { + DEBUG.message("RestAuthenticationHandler :: resolved session upgrade target from HttpOnly cookie"); + } + return tokenId; + } + return sessionUpgradeSSOTokenId; + } + private String getAuthIndexValue(SignedJwt jwt) { return jwt.getClaimsSet().getClaim("authIndexValue", String.class); } diff --git a/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandlerTest.java b/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandlerTest.java index f50a5b5e18..30f5e27200 100644 --- a/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandlerTest.java +++ b/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandlerTest.java @@ -11,13 +11,9 @@ * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions copyright [year] [name of copyright owner]". * -<<<<<<< HEAD * Copyright 2013-2016 ForgeRock AS. -======= - * Copyright 2013-2015 ForgeRock AS. * Portions copyright 2019 Open Source Solution Technology Corporation ->>>>>>> cafd23ed69... Remove an input parameter included in exception message (#123) - * Portions copyright 2025-2026 3A Systems, LLC. + * Portions copyright 2018-2026 3A Systems, LLC. */ package org.forgerock.openam.core.rest.authn; @@ -39,8 +35,10 @@ >>>>>>> cafd23ed69... Remove an input parameter included in exception message (# import com.iplanet.sso.SSOToken; import com.iplanet.sso.SSOTokenID; +import com.iplanet.dpro.session.SessionID; import com.sun.identity.authentication.spi.AuthLoginException; import com.sun.identity.authentication.spi.PagePropertiesCallback; +import com.sun.identity.shared.encode.CookieUtils; import com.sun.identity.shared.locale.L10NMessageImpl; import org.forgerock.json.JsonValue; import org.forgerock.json.jose.jws.SignedJwt; @@ -458,6 +456,115 @@ public void shouldReturnAbsoluteRealmInSuccessfulAuthenticationResponse() throws assertThat(response).stringAt("realm").isEqualTo("REALM"); } + @Test + public void shouldFallBackToSessionCookieAsUpgradeTargetWhenHttpOnlyAndNoUpgradeTokenSupplied() + throws Exception { + + // Given - HttpOnly mode and no sessionUpgradeSSOTokenId supplied (XUI cannot read the cookie) + setCookieHttpOnly(true); + try { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse httpResponse = mock(HttpServletResponse.class); + + SessionID cookieSessionId = new SessionID("COOKIE_SSO_TOKEN_ID"); + given(coreServicesWrapper.getSessionIDFromRequest(request)).willReturn(cookieSessionId); + + SSOTokenID ssoTokenID = mock(SSOTokenID.class); + given(ssoTokenID.toString()).willReturn("COOKIE_SSO_TOKEN_ID"); + SSOToken existingToken = mock(SSOToken.class); + given(existingToken.getTokenID()).willReturn(ssoTokenID); + given(coreServicesWrapper.getExistingValidSSOToken(cookieSessionId)).willReturn(existingToken); + + LoginProcess loginProcess = completedLoginProcess(); + given(loginAuthenticator.getLoginProcess(ArgumentMatchers.anyObject())) + .willReturn(loginProcess); + + // When + restAuthenticationHandler.initiateAuthentication(request, httpResponse, + AuthIndexType.MODULE.toString(), "INDEX_VALUE", null); + + // Then - the session carried by the HttpOnly cookie is used as the upgrade target + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(LoginConfiguration.class); + verify(loginAuthenticator).getLoginProcess(argumentCaptor.capture()); + LoginConfiguration loginConfiguration = argumentCaptor.getValue(); + assertEquals(loginConfiguration.getSSOTokenId(), "COOKIE_SSO_TOKEN_ID"); + assertTrue(loginConfiguration.isSessionUpgradeRequest()); + } finally { + setCookieHttpOnly(false); + } + } + + @Test + public void shouldNotFallBackToSessionCookieWhenCookieIsNotHttpOnly() throws Exception { + + // Given - the cookie is readable by JS, so the XUI is responsible for supplying the token + setCookieHttpOnly(false); + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse httpResponse = mock(HttpServletResponse.class); + + LoginProcess loginProcess = completedLoginProcess(); + given(loginAuthenticator.getLoginProcess(ArgumentMatchers.anyObject())) + .willReturn(loginProcess); + + // When + restAuthenticationHandler.initiateAuthentication(request, httpResponse, + AuthIndexType.MODULE.toString(), "INDEX_VALUE", null); + + // Then - no cookie lookup is performed and no upgrade target is resolved + verify(coreServicesWrapper, never()).getExistingValidSSOToken(ArgumentMatchers.any()); + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(LoginConfiguration.class); + verify(loginAuthenticator).getLoginProcess(argumentCaptor.capture()); + assertEquals(argumentCaptor.getValue().getSSOTokenId(), ""); + } + + @Test + public void shouldPreferSuppliedUpgradeTokenOverSessionCookieInHttpOnlyMode() throws Exception { + + // Given - HttpOnly mode but an explicit upgrade token is supplied (e.g. straight after login) + setCookieHttpOnly(true); + try { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse httpResponse = mock(HttpServletResponse.class); + + LoginProcess loginProcess = completedLoginProcess(); + given(loginAuthenticator.getLoginProcess(ArgumentMatchers.anyObject())) + .willReturn(loginProcess); + + // When + restAuthenticationHandler.initiateAuthentication(request, httpResponse, + AuthIndexType.MODULE.toString(), "INDEX_VALUE", "SUPPLIED_SSO_TOKEN_ID"); + + // Then - the explicitly supplied token wins and the cookie is not consulted + verify(coreServicesWrapper, never()).getExistingValidSSOToken(ArgumentMatchers.any()); + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(LoginConfiguration.class); + verify(loginAuthenticator).getLoginProcess(argumentCaptor.capture()); + assertEquals(argumentCaptor.getValue().getSSOTokenId(), "SUPPLIED_SSO_TOKEN_ID"); + } finally { + setCookieHttpOnly(false); + } + } + + private LoginProcess completedLoginProcess() throws Exception { + SSOTokenID ssoTokenID = mock(SSOTokenID.class); + given(ssoTokenID.toString()).willReturn("NEW_SSO_TOKEN_ID"); + SSOToken ssoToken = mock(SSOToken.class); + given(ssoToken.getTokenID()).willReturn(ssoTokenID); + + AuthContextLocalWrapper authContextLocalWrapper = mock(AuthContextLocalWrapper.class); + LoginProcess loginProcess = mock(LoginProcess.class); + given(loginProcess.getSSOToken()).willReturn(ssoToken); + given(loginProcess.getLoginStage()).willReturn(LoginStage.COMPLETE); + given(loginProcess.isSuccessful()).willReturn(true); + given(loginProcess.getAuthContext()).willReturn(authContextLocalWrapper); + return loginProcess; + } + + private static void setCookieHttpOnly(boolean value) throws Exception { + java.lang.reflect.Field field = CookieUtils.class.getDeclaredField("cookieHttpOnly"); + field.setAccessible(true); + field.setBoolean(null, value); + } + private JsonValue performSuccessfulAuthentication() throws Exception { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse httpResponse = mock(HttpServletResponse.class); From 85eeff5e15084d025e6e2d8825cea859219069c0 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 12 Jun 2026 13:32:47 +0300 Subject: [PATCH 08/16] fix(authn): never echo the SSO token in the body in HttpOnly cookie mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the session cookie is configured as HttpOnly, the XUI cannot read the tokenId from JavaScript and relies on the auto-sent cookie (and idFromSession) to detect the session. Yet a successful /json/authenticate response still echoed the tokenId in the response body. That re-opened the very token-exfiltration path HttpOnly is meant to close: any XSS on the origin could read a full, replayable SSO token with a single fetch — including a freshly upgraded one returned by a step-up. Gating the cookie-based step-up fallback alone was not enough: an already- satisfied request (e.g. an auth-level upgrade the session already meets) still completes immediately and would echo the existing token, and a genuine upgrade returns a brand-new token in the body. Both cases leak. The robust fix is to suppress the token in the response body whenever the cookie is HttpOnly, regardless of whether it changed, because the browser receives the token only via the Set-Cookie header and the XUI never consumes body.tokenId in this mode. Server change (RestAuthenticationHandler.processAuthentication, COMPLETE stage): - Only put TOKEN_ID into the JSON response when CookieUtils.isCookieHttpOnly() is false. successUrl/realm/authId/callbacks are unchanged. - Keep AuditRequestContext.putProperty(TOKEN_ID, ...) — that is server-side audit only and is never sent to the client. The session-upgrade cookie fallback (resolveSessionUpgradeTarget) remains scoped to HttpOnly mode via isCookieHttpOnly(), so token-readable deployments keep their existing behaviour and the contract change is confined to HttpOnly mode. Tests: - RestAuthenticationHandlerTest: - shouldNotEchoTokenIdInResponseBodyWhenCookieIsHttpOnly — successful auth returns realm/successUrl but no tokenId. - shouldEchoTokenIdInResponseBodyWhenCookieIsNotHttpOnly — default mode still returns tokenId. - e2e/xui/xui-httponly.spec.mjs (step-up scenario): - Assert body.tokenId is absent in HttpOnly mode and that the existing session is recognised via the absence of a fresh login (no authId/callbacks), a successful completion (successUrl/realm) and idFromSession resolving to the same user — instead of reading a token from the body. - Header comment updated to document that the token never leaves the body in HttpOnly mode. BREAKING CHANGE: in HttpOnly cookie mode, POST /json/authenticate no longer returns tokenId in the response body; the session token is delivered only via the Set-Cookie header. Non-browser/raw-REST clients that previously read body.tokenId in HttpOnly deployments must obtain the token from the cookie. Token-readable (non-HttpOnly) deployments are unaffected. --- e2e/xui/xui-httponly.spec.mjs | 22 ++++++++++--- .../rest/authn/RestAuthenticationHandler.java | 11 ++++++- .../authn/RestAuthenticationHandlerTest.java | 31 +++++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/e2e/xui/xui-httponly.spec.mjs b/e2e/xui/xui-httponly.spec.mjs index 87afa503cc..3c0de320c6 100644 --- a/e2e/xui/xui-httponly.spec.mjs +++ b/e2e/xui/xui-httponly.spec.mjs @@ -47,6 +47,11 @@ * existing session is orphaned, its properties/sessionHandle are lost and composite-advice step-up * can loop. The fix: when "sessionUpgradeSSOTokenId" is absent the REST authenticate flow falls * back to the session carried by the (auto-sent) HttpOnly cookie as the upgrade target. + * + * Token never leaves the body in HttpOnly mode: a successful /json/authenticate response does NOT + * echo the tokenId when the cookie is HttpOnly (the token is delivered only via Set-Cookie). This + * prevents an XSS on the origin from reading a replayable SSO token via a single fetch. The XUI in + * HttpOnly mode does not consume body.tokenId — it relies on the auto-sent cookie / idFromSession. */ import { test, expect } from "@playwright/test"; @@ -212,13 +217,20 @@ test.describe("OpenAM XUI - HttpOnly session cookie", () => { expect(resp.ok(), "authenticate against the existing session should succeed").toBeTruthy(); const body = await resp.json(); - // ── 4. The existing session is recognised as the upgrade target ────────── - // With the cookie fallback the server resolves the session from the HttpOnly cookie and - // returns its tokenId. Without the fix it would start a brand-new login and answer with - // an authId + callbacks (a fresh login form) instead. + // ── 4. The existing session is recognised (no brand-new login) ────────── + // With the cookie fallback the server resolves the session from the auto-sent HttpOnly + // cookie and completes against it (successUrl/realm) instead of starting a brand-new login + // (which would answer with an authId + callbacks, i.e. a fresh login form). + // + // Note: in HttpOnly mode the server deliberately does NOT echo the tokenId in the body + // (it is delivered only via Set-Cookie), so recognition is asserted via the absence of a + // fresh login and a successful completion, and confirmed by idFromSession below — NOT by + // reading a token from the response body. expect(body.authId, "must NOT start a brand-new login flow (no fresh authId)").toBeFalsy(); expect(body.callbacks, "must NOT present a fresh login form (no callbacks)").toBeFalsy(); - expect(body.tokenId, "the existing session must be recognised (tokenId returned)").toBeTruthy(); + expect(body.tokenId, "tokenId must NOT be echoed in the body in HttpOnly mode").toBeFalsy(); + expect(body.successUrl ?? body.realm, "completion must reference the existing session") + .toBeTruthy(); // ── 5. The session is still the same user's session (not orphaned/replaced) ── const idAfter = await idFromSession(page.request); diff --git a/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandler.java b/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandler.java index 671cf97a27..f41d16a0f6 100644 --- a/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandler.java +++ b/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandler.java @@ -325,7 +325,16 @@ private JsonValue processAuthentication(HttpServletRequest request, SSOToken ssoToken = loginProcess.getSSOToken(); if (ssoToken != null) { String tokenId = ssoToken.getTokenID().toString(); - jsonResponseObject.put(TOKEN_ID, tokenId); + // In HttpOnly mode the session token is delivered to the browser ONLY via the + // Set-Cookie header (an HttpOnly cookie). Never echo it in the response body: + // the XUI in HttpOnly mode does not consume body.tokenId (it relies on the + // auto-sent cookie), and returning it would let XSS on the origin read a + // replayable SSO token - including a freshly upgraded one - which is exactly + // what HttpOnly is meant to prevent. + if (!CookieUtils.isCookieHttpOnly()) { + jsonResponseObject.put(TOKEN_ID, tokenId); + } + // Server-side audit only - not exposed to the client. AuditRequestContext.putProperty(TOKEN_ID, tokenId); } else { jsonResponseObject.put("message", "Authentication Successful"); diff --git a/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandlerTest.java b/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandlerTest.java index 30f5e27200..cec8bfba46 100644 --- a/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandlerTest.java +++ b/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandlerTest.java @@ -456,6 +456,37 @@ public void shouldReturnAbsoluteRealmInSuccessfulAuthenticationResponse() throws assertThat(response).stringAt("realm").isEqualTo("REALM"); } + @Test + public void shouldNotEchoTokenIdInResponseBodyWhenCookieIsHttpOnly() throws Exception { + + // Given - HttpOnly mode: the token must be delivered only via Set-Cookie, never in the body + setCookieHttpOnly(true); + try { + // When - a successful authentication completes + JsonValue response = performSuccessfulAuthentication(); + + // Then - the response is successful but carries NO tokenId (no token exfiltration path) + assertFalse(response.isDefined("tokenId"), "tokenId must not be echoed in HttpOnly mode"); + assertThat(response).stringAt("realm").isEqualTo("REALM"); + assertTrue(response.isDefined("successUrl")); + } finally { + setCookieHttpOnly(false); + } + } + + @Test + public void shouldEchoTokenIdInResponseBodyWhenCookieIsNotHttpOnly() throws Exception { + + // Given - token-readable mode (default): the XUI consumes body.tokenId to set the cookie + setCookieHttpOnly(false); + + // When + JsonValue response = performSuccessfulAuthentication(); + + // Then - the tokenId is returned in the body as before + assertEquals(response.get("tokenId").asString(), "SSO_TOKEN_ID"); + } + @Test public void shouldFallBackToSessionCookieAsUpgradeTargetWhenHttpOnlyAndNoUpgradeTokenSupplied() throws Exception { From bfc88a5d646e0af00982e0df1b1cefbf4d9d5646 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 12 Jun 2026 19:01:05 +0300 Subject: [PATCH 09/16] Update selenium to 4.44.0 --- openam-server/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openam-server/pom.xml b/openam-server/pom.xml index 7603ea883b..1fca487c94 100644 --- a/openam-server/pom.xml +++ b/openam-server/pom.xml @@ -166,7 +166,7 @@ org.seleniumhq.selenium selenium-java - 4.43.0 + 4.44.0 test From 5be276522bb457604bf79951d5f69a7d8599d02b Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Sat, 13 Jun 2026 10:04:19 +0300 Subject: [PATCH 10/16] Update testcontainers to 2.0.5 --- openam-server/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openam-server/pom.xml b/openam-server/pom.xml index 1fca487c94..b7580f83db 100644 --- a/openam-server/pom.xml +++ b/openam-server/pom.xml @@ -172,7 +172,7 @@ org.testcontainers testcontainers - 2.0.4 + 2.0.5 test From d3b474eb5c10990b7df18be9eebc6a673a8d3484 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Sat, 13 Jun 2026 11:06:31 +0300 Subject: [PATCH 11/16] remove MAVEN_VERIFY_STAGE=verify - always verify vs package --- .github/workflows/build.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c81ca74587..2b100547a6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,13 +32,12 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | echo "MAVEN_PROFILE_FLAG=-P integration-test" >> $GITHUB_OUTPUT - echo "MAVEN_VERIFY_STAGE=verify" >> $GITHUB_OUTPUT echo "127.0.0.1 openam.local" | sudo tee -a /etc/hosts id: maven-profile-flag - name: Build with Maven env: MAVEN_OPTS: -Dhttps.protocols=TLSv1.2 -Dmaven.wagon.httpconnectionManager.ttlSeconds=120 -Dmaven.wagon.http.retryHandler.requestSentEnabled=true -Dmaven.wagon.http.retryHandler.count=10 - run: mvn --batch-mode --errors --update-snapshots package ${{ steps.maven-profile-flag.outputs.MAVEN_VERIFY_STAGE }} --file pom.xml ${{ steps.maven-profile-flag.outputs.MAVEN_PROFILE_FLAG }} + run: mvn --batch-mode --errors --update-snapshots verify --file pom.xml ${{ steps.maven-profile-flag.outputs.MAVEN_PROFILE_FLAG }} - name: Upload artifacts uses: actions/upload-artifact@v6 with: From d4d7fb966cc92da3cd15fa5936a6ae02d7a21452 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Sat, 13 Jun 2026 17:52:13 +0300 Subject: [PATCH 12/16] Create base entry on external config store during setup Allow OpenAM to be installed against an external OpenDJ when the base DN (root suffix) has not been pre-created, removing the need for the OpenDJ "--addBaseEntry" / ADD_BASE_ENTRY option. - AMSetupDSConfig: add createBaseEntry() to create the root suffix when missing, deriving the objectClass from the RDN (dc/o/ou), with an existence check and ENTRY_ALREADY_EXISTS handling for idempotency. - AMSetupServlet: call createBaseEntry() before loading schema files for an external (dsSmsSchema) configuration store, mirroring the embedded behaviour that creates the suffix via openam_suffix.ldif. - ServicesDefaultValues: create the base entry instead of failing with configurator.invalidsuffix when the suffix does not yet exist. - Step3 wizard: treat a missing root suffix (NO_SUCH_OBJECT) as valid so the Next button is enabled; only real connection/auth failures block. - Add IT_SetupWithOpenDJ integration tests covering external OpenDJ both with and without a pre-created base DN; deploy a separate /am2 context, raise Tomcat heap to 2g and extend startup/install timeouts. --- .../com/sun/identity/config/wizard/Step3.java | 24 ++++-- .../sun/identity/setup/AMSetupDSConfig.java | 83 ++++++++++++++++++- .../sun/identity/setup/AMSetupServlet.java | 14 +++- .../identity/setup/ServicesDefaultValues.java | 19 ++++- openam-server/pom.xml | 9 +- .../test/integration/IT_SetupWithOpenDJ.java | 27 ++++-- 6 files changed, 159 insertions(+), 17 deletions(-) diff --git a/openam-core/src/main/java/com/sun/identity/config/wizard/Step3.java b/openam-core/src/main/java/com/sun/identity/config/wizard/Step3.java index caf0f3e495..e2efd62131 100644 --- a/openam-core/src/main/java/com/sun/identity/config/wizard/Step3.java +++ b/openam-core/src/main/java/com/sun/identity/config/wizard/Step3.java @@ -26,6 +26,7 @@ * * Portions Copyrighted 2010-2016 ForgeRock AS. * Portions Copyright 2016 Nomura Research Institute, Ltd. + * Portions Copyright 2025-2026 3A Systems, LLC */ package com.sun.identity.config.wizard; @@ -48,6 +49,7 @@ import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.EntryNotFoundException; import org.forgerock.opendj.ldap.LdapException; +import org.forgerock.opendj.ldap.ResultCode; /** * Step 3 is for selecting the embedded or external configuration store @@ -623,19 +625,31 @@ public boolean validateSMHost() { } /* - * Check if the provided root suffix exists and 'services' entry does not exist under the suffix in the existing - * SM host. If not, OpenAM denies setting up. + * Make sure the chosen config store does not already contain an OpenAM configuration (a 'services' entry under + * the root suffix). The root suffix itself does not need to exist yet: if it is missing OpenAM will create the + * base entry during installation. Only a real connection/authentication failure prevents proceeding. */ try (Connection conn = getConnection(host, port, bindDN, bindPwd.toCharArray(), 5, ssl)) { - conn.readEntry(DN.valueOf(rootSuffix)); + boolean servicesExist; try { conn.readEntry(DN.valueOf(rootSuffix).child("ou", "services")); - writeToResponse(getLocalizedString("config.data.already.exist")); + servicesExist = true; } catch (EntryNotFoundException enfe) { + // Either the suffix or the 'services' entry is missing - safe to set up. + servicesExist = false; + } + if (servicesExist) { + writeToResponse(getLocalizedString("config.data.already.exist")); + } else { writeToResponse("ok"); } } catch (LdapException lex) { - if (!writeErrorToResponse(lex.getResult().getResultCode())) { + ResultCode resultCode = lex.getResult().getResultCode(); + if (ResultCode.NO_SUCH_OBJECT.equals(resultCode)) { + // The root suffix (or its parent) does not exist yet - OpenAM will create the base entry during + // installation, so this is allowed. + writeToResponse("ok"); + } else if (!writeErrorToResponse(resultCode)) { writeToResponse(getLocalizedString("cannot.connect.to.SM.datastore")); } } catch (Exception e) { diff --git a/openam-core/src/main/java/com/sun/identity/setup/AMSetupDSConfig.java b/openam-core/src/main/java/com/sun/identity/setup/AMSetupDSConfig.java index 78eb4c8377..c1be80865d 100644 --- a/openam-core/src/main/java/com/sun/identity/setup/AMSetupDSConfig.java +++ b/openam-core/src/main/java/com/sun/identity/setup/AMSetupDSConfig.java @@ -25,7 +25,7 @@ * $Id: AMSetupDSConfig.java,v 1.20 2009/11/20 23:52:55 ww203982 Exp $ * * Portions Copyrighted 2011-2016 ForgeRock AS. - * Portions Copyrighted 2025 3A Systems LLC. + * Portions Copyrighted 2022-2026 3A Systems LLC. */ package com.sun.identity.setup; @@ -53,8 +53,10 @@ import org.forgerock.opendj.ldap.Filter; import org.forgerock.opendj.ldap.LDAPConnectionFactory; import org.forgerock.opendj.ldap.LdapException; +import org.forgerock.opendj.ldap.ResultCode; import org.forgerock.opendj.ldap.SSLContextBuilder; import org.forgerock.opendj.ldap.SearchScope; +import org.forgerock.opendj.ldap.requests.AddRequest; import org.forgerock.opendj.ldap.requests.SimpleBindRequest; import org.forgerock.opendj.ldif.ConnectionEntryReader; import org.forgerock.util.Options; @@ -215,6 +217,85 @@ public boolean connectDSwithDN(boolean ssl) { } } + /** + * Ensures that the base entry (root suffix) exists in an external directory + * server, creating it when it is missing. + * + *

For an embedded OpenDJ the suffix is created from + * openam_suffix.ldif. When OpenAM is installed against an + * external directory server the suffix is assumed to already exist (e.g. + * created by the OpenDJ docker image with --addBaseEntry). + * This method makes OpenAM create the base entry itself so the installation + * succeeds even when the suffix has not been pre-created.

+ * + *

The method is idempotent: if the suffix already exists nothing is + * done. The object class of the created entry is derived from the naming + * attribute of the suffix (dc, o or + * ou).

+ * + * @param ssl true if the directory server is running on LDAPS. + * @throws ConfiguratorException if the base entry cannot be created. + */ + public void createBaseEntry(boolean ssl) throws ConfiguratorException { + if ((suffix == null) || (suffix.trim().length() == 0)) { + return; + } + // Suffix already present - nothing to do. + if (connectDSwithDN(ssl)) { + return; + } + + DN suffixDN = DN.valueOf(suffix); + String namingAttr = suffixDN.rdn().getFirstAVA().getAttributeType().getNameOrOID(); + String rdnValue = LDAPUtils.rdnValueFromDn(suffixDN); + + SetupProgress.reportStart("emb.creatingfamsuffix", null); + Connection conn = getLDAPConnection(ssl); + if (conn == null) { + SetupProgress.reportEnd("emb.creatingfamsuffix.failure", new Object[] { suffix }); + Debug.getInstance(SetupConstants.DEBUG_NAME).error( + "AMSetupDSConfig.createBaseEntry: unable to connect to directory server"); + throw new ConfiguratorException("configurator.dsconnnectfailure", null, locale); + } + try { + AddRequest addRequest = LDAPRequests.newAddRequest(suffixDN) + .addAttribute("objectClass", "top"); + + if ("dc".equalsIgnoreCase(namingAttr)) { + addRequest.addAttribute("objectClass", "domain") + .addAttribute("dc", rdnValue); + } else if ("o".equalsIgnoreCase(namingAttr)) { + addRequest.addAttribute("objectClass", "organization") + .addAttribute("o", rdnValue); + } else if ("ou".equalsIgnoreCase(namingAttr)) { + addRequest.addAttribute("objectClass", "organizationalUnit") + .addAttribute("ou", rdnValue); + } else { + // Fallback for any other naming attribute. + addRequest.addAttribute("objectClass", "extensibleObject") + .addAttribute(namingAttr, rdnValue); + } + + conn.add(addRequest); + SetupProgress.reportEnd("emb.creatingfamsuffix.success", null); + } catch (LdapException e) { + // Created concurrently or already present - acceptable. + if (e.getResult() != null && ResultCode.ENTRY_ALREADY_EXISTS.equals(e.getResult().getResultCode())) { + SetupProgress.reportEnd("emb.creatingfamsuffix.success", null); + return; + } + Object[] error = { suffix }; + SetupProgress.reportEnd("emb.creatingfamsuffix.failure", error); + Debug.getInstance(SetupConstants.DEBUG_NAME).error( + "AMSetupDSConfig.createBaseEntry: failed to create base entry " + suffix, e); + InstallLog.getInstance().write( + "AMSetupDSConfig.createBaseEntry: failed to create base entry " + suffix, e); + throw new ConfiguratorException("configurator.ldiferror", null, locale); + } finally { + conn.close(); + } + } + /** * Check if DS is loaded with OpenAM entries * diff --git a/openam-core/src/main/java/com/sun/identity/setup/AMSetupServlet.java b/openam-core/src/main/java/com/sun/identity/setup/AMSetupServlet.java index 42fe3107fa..ed460108cf 100644 --- a/openam-core/src/main/java/com/sun/identity/setup/AMSetupServlet.java +++ b/openam-core/src/main/java/com/sun/identity/setup/AMSetupServlet.java @@ -25,7 +25,7 @@ * $Id: AMSetupServlet.java,v 1.117 2010/01/20 17:01:35 veiming Exp $ * * Portions Copyrighted 2010-2016 ForgeRock AS. - * Portions Copyrighted 2017-2025 3A Systems, LLC. + * Portions Copyrighted 2017-2026 3A Systems, LLC. */ package com.sun.identity.setup; @@ -1474,6 +1474,18 @@ private static void writeSchemaFiles(String basedir, List schemaFiles, M SetupProgress.reportEnd("emb.success", null); AMSetupDSConfig dsConfig = AMSetupDSConfig.getInstance(); + + // For an external configuration store the root suffix (base entry) is + // assumed to already exist. Create it if it is missing so OpenAM can be + // installed against an external OpenDJ without pre-creating the base DN + // (e.g. without the OpenDJ "--addBaseEntry" option). The embedded store + // creates the suffix separately via openam_suffix.ldif. + if (SetupConstants.SMS_DS_DATASTORE.equals(dataStore)) { + String sslEnabled = (String) map.get(SetupConstants.CONFIG_VAR_DIRECTORY_SERVER_SSL); + boolean ssl = "SSL".equals(sslEnabled); + dsConfig.createBaseEntry(ssl); + } + dsConfig.loadSchemaFiles(schemaFiles); if (dataStore.equals(SetupConstants.SMS_EMBED_DATASTORE)) { diff --git a/openam-core/src/main/java/com/sun/identity/setup/ServicesDefaultValues.java b/openam-core/src/main/java/com/sun/identity/setup/ServicesDefaultValues.java index 023807bd46..4a5fdc245a 100644 --- a/openam-core/src/main/java/com/sun/identity/setup/ServicesDefaultValues.java +++ b/openam-core/src/main/java/com/sun/identity/setup/ServicesDefaultValues.java @@ -25,6 +25,7 @@ * $Id: ServicesDefaultValues.java,v 1.38 2009/01/28 05:35:02 ww203982 Exp $ * * Portions Copyrighted 2013-2016 ForgeRock AS. + * Portions Copyrighted 2022-2026 3A Systems LLC. */ package com.sun.identity.setup; @@ -155,13 +156,25 @@ public static void setServiceConfigValues( throw new ConfiguratorException( "configurator.dsconnnectfailure", null, locale); } - if ((!LDAPUtils.isDN((String) map.get( - SetupConstants.CONFIG_VAR_ROOT_SUFFIX))) || - (!dsConfig.connectDSwithDN(ssl))) { + if (!LDAPUtils.isDN((String) map.get( + SetupConstants.CONFIG_VAR_ROOT_SUFFIX))) { dsConfig = null; throw new ConfiguratorException("configurator.invalidsuffix", null, locale); } + if (!dsConfig.connectDSwithDN(ssl)) { + // The base DN (root suffix) does not exist yet on the external + // directory server. Create it (mirrors the embedded behaviour + // that loads openam_suffix.ldif) so OpenAM can be installed + // without pre-creating the base entry (e.g. without the OpenDJ + // "--addBaseEntry" option). + dsConfig.createBaseEntry(ssl); + if (!dsConfig.connectDSwithDN(ssl)) { + dsConfig = null; + throw new ConfiguratorException("configurator.invalidsuffix", + null, locale); + } + } map.put(SetupConstants.DIT_LOADED, dsConfig.isDITLoaded(ssl)); } diff --git a/openam-server/pom.xml b/openam-server/pom.xml index b7580f83db..e040e26562 100644 --- a/openam-server/pom.xml +++ b/openam-server/pom.xml @@ -126,12 +126,19 @@ http://localhost:8207/am + + war + + am2 + + http://localhost:8207/am2 + 8206 8207 - ${java.surefire.options} + ${java.surefire.options} -Xmx2g diff --git a/openam-server/src/test/java/org/openidentityplatform/openam/test/integration/IT_SetupWithOpenDJ.java b/openam-server/src/test/java/org/openidentityplatform/openam/test/integration/IT_SetupWithOpenDJ.java index 3ca09ce98e..e60c55bae5 100644 --- a/openam-server/src/test/java/org/openidentityplatform/openam/test/integration/IT_SetupWithOpenDJ.java +++ b/openam-server/src/test/java/org/openidentityplatform/openam/test/integration/IT_SetupWithOpenDJ.java @@ -38,23 +38,38 @@ public class IT_SetupWithOpenDJ extends BaseTest { @Test public void testSetupWithOpendj() throws Exception { + // External OpenDJ WITHOUT a pre-created base DN - OpenAM must create the base entry itself. + runSetup("http://openam.local:8207/am", false); + } + + @Test + public void testSetupWithOpendjAddBaseEntry() throws Exception { + // External OpenDJ WITH a pre-created base DN (OpenDJ "--addBaseEntry"). + // Uses a separate webapp context (/am2) because an OpenAM instance can only be configured once. + runSetup("http://openam.local:8207/am2", true); + } - final String openamUrl = "http://openam.local:8207/am"; + private void runSetup(String openamUrl, boolean addBaseEntry) throws Exception { if(!DockerClientFactory.instance().isDockerAvailable()) { throw new SkipException("docker is not available"); } - try(GenericContainer opendj = + GenericContainer opendj = new GenericContainer<>(DockerImageName.parse("openidentityplatform/opendj:latest")) .withExposedPorts(1389, 4444) - .waitingFor(Wait.forHealthcheck().withStartupTimeout(Duration.ofMinutes(5)))) { + .waitingFor(Wait.forHealthcheck().withStartupTimeout(Duration.ofMinutes(10))); + if (addBaseEntry) { + opendj.withEnv("ADD_BASE_ENTRY", "--addBaseEntry"); + } + + try (GenericContainer opendjContainer = opendj) { - opendj.start(); + opendjContainer.start(); System.out.println("containers started"); - Integer opendjPort = opendj.getMappedPort(1389); + Integer opendjPort = opendjContainer.getMappedPort(1389); testOpenAmInstallation(openamUrl, opendjPort); @@ -130,7 +145,7 @@ private void testOpenAmInstallation(String openamUrl, Integer opendjPort) throws wait.until(ExpectedConditions.elementToBeClickable(By.id("writeConfigButton"))).click(); - WebDriverWait waitComplete = new WebDriverWait(driver, Duration.ofSeconds(300)); + WebDriverWait waitComplete = new WebDriverWait(driver, Duration.ofSeconds(600)); try { WebElement proceedToConsole = waitComplete.until(visibilityOfAnyElement(By.cssSelector("#confComplete a"))); proceedToConsole.click(); From 797e8163a071a804975c0097114174b82bc446db Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Sat, 13 Jun 2026 19:48:07 +0300 Subject: [PATCH 13/16] fix(authn): don't echo SSO token in /json/authenticate body in HttpOnly cookie mode In HttpOnly session-cookie mode the browser cannot read the token from document.cookie, so the XUI relies on the auto-sent cookie. However, a successful /json/authenticate response still echoed the SSO tokenId in the JSON body. That re-opened a token-exfiltration path HttpOnly is meant to close: an XSS on the origin could fetch the endpoint and read a full, replayable SSO token (including a freshly upgraded one) with a single call. Stop writing tokenId into the success (LoginStage.COMPLETE) response body when the session cookie is HttpOnly. The token is delivered to the browser solely via the Set-Cookie header. The server-side audit property (AuditRequestContext) is unchanged and is never exposed to the client. Because some non-browser/raw-REST integrations need both the HttpOnly cookie and the token in the body, the suppression is configurable via a new property: org.openidentityplatform.openam.httponly.allowTokenInBody (default: false) Behaviour matrix (success response body): - httponly=false -> tokenId returned (legacy, unchanged) - httponly=true, allowTokenInBody=false-> tokenId NOT returned (default-secure) - httponly=true, allowTokenInBody=true -> tokenId returned (opt-in legacy) The step-up cookie fallback (resolveSessionUpgradeTarget) remains gated to HttpOnly mode and to real step-up requests only (upgrade index/advice or ForceAuth), so a plain "am I logged in" probe (POST {}) cannot trigger an immediate completion that echoes a token. BREAKING CHANGE: in HttpOnly cookie mode a successful /json/authenticate response no longer contains tokenId by default. Browser/XUI clients are unaffected (they use the auto-sent cookie). Raw-REST integrations should read the token from the Set-Cookie header, or opt back in by setting org.openidentityplatform.openam.httponly.allowTokenInBody=true. Deployments that do not use HttpOnly cookies are unaffected. Changes: - Constants: add AM_COOKIE_HTTPONLY_ALLOW_TOKEN_IN_BODY - CookieUtils: add httpOnlyAllowTokenInBody flag + isHttpOnlyAllowTokenInBody() - RestAuthenticationHandler: gate body tokenId on !isCookieHttpOnly() || isHttpOnlyAllowTokenInBody() - RestAuthenticationHandlerTest: cover both default-secure and opt-in cases - e2e/xui/xui-httponly.spec.mjs: assert no body.tokenId in default HttpOnly mode - docs/pr/xui-httponly-session-cookie.md: document property and behaviour matrix --- e2e/xui/xui-httponly.spec.mjs | 26 ++++++++++++++--- .../rest/authn/RestAuthenticationHandler.java | 19 +++++++----- .../authn/RestAuthenticationHandlerTest.java | 29 ++++++++++++++++++- .../com/sun/identity/shared/Constants.java | 15 +++++++++- .../identity/shared/encode/CookieUtils.java | 20 ++++++++++++- 5 files changed, 95 insertions(+), 14 deletions(-) diff --git a/e2e/xui/xui-httponly.spec.mjs b/e2e/xui/xui-httponly.spec.mjs index 3c0de320c6..b1b7598118 100644 --- a/e2e/xui/xui-httponly.spec.mjs +++ b/e2e/xui/xui-httponly.spec.mjs @@ -48,10 +48,28 @@ * can loop. The fix: when "sessionUpgradeSSOTokenId" is absent the REST authenticate flow falls * back to the session carried by the (auto-sent) HttpOnly cookie as the upgrade target. * - * Token never leaves the body in HttpOnly mode: a successful /json/authenticate response does NOT - * echo the tokenId when the cookie is HttpOnly (the token is delivered only via Set-Cookie). This - * prevents an XSS on the origin from reading a replayable SSO token via a single fetch. The XUI in - * HttpOnly mode does not consume body.tokenId — it relies on the auto-sent cookie / idFromSession. + * Token never leaves the body in HttpOnly mode (by default): a successful /json/authenticate + * response does NOT echo the tokenId when the cookie is HttpOnly (the token is delivered only via + * Set-Cookie). This prevents an XSS on the origin from reading a replayable SSO token via a single + * fetch. The XUI in HttpOnly mode does not consume body.tokenId — it relies on the auto-sent + * cookie / idFromSession. + * + * Response-body contract / configuration: + * The presence of body.tokenId in a successful /json/authenticate response depends on two server + * properties: + * + * com.sun.identity.cookie.httponly (cookie HttpOnly flag) + * org.openidentityplatform.openam.httponly.allowTokenInBody (default: false) + * + * Behaviour matrix (success response body): + * | httponly | allowTokenInBody | body.tokenId | + * |----------|------------------|--------------| + * | false | (ignored) | yes (legacy) | + * | true | false (default) | no | + * | true | true | yes (opt-in) | + * + * In all cases the session cookie is still set via Set-Cookie. This spec is mode-agnostic and, in + * the default HttpOnly deployment (allowTokenInBody=false), asserts that body.tokenId is absent. */ import { test, expect } from "@playwright/test"; diff --git a/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandler.java b/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandler.java index f41d16a0f6..610e4563a5 100644 --- a/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandler.java +++ b/openam-core-rest/src/main/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandler.java @@ -1,3 +1,4 @@ +/* /* * The contents of this file are subject to the terms of the Common Development and * Distribution License (the License). You may not use this file except in compliance with the @@ -325,13 +326,17 @@ private JsonValue processAuthentication(HttpServletRequest request, SSOToken ssoToken = loginProcess.getSSOToken(); if (ssoToken != null) { String tokenId = ssoToken.getTokenID().toString(); - // In HttpOnly mode the session token is delivered to the browser ONLY via the - // Set-Cookie header (an HttpOnly cookie). Never echo it in the response body: - // the XUI in HttpOnly mode does not consume body.tokenId (it relies on the - // auto-sent cookie), and returning it would let XSS on the origin read a - // replayable SSO token - including a freshly upgraded one - which is exactly - // what HttpOnly is meant to prevent. - if (!CookieUtils.isCookieHttpOnly()) { + // In HttpOnly mode the session token is delivered to the browser via the + // Set-Cookie header. By default it is NOT echoed in the response body, so XSS + // on the origin cannot read a replayable SSO token (incl. a freshly upgraded + // one) - which is what HttpOnly is meant to prevent; the XUI relies on the + // auto-sent cookie and does not consume body.tokenId in this mode. + // + // Deployments that genuinely need both the HttpOnly cookie AND the token in + // the body (e.g. non-browser/raw-REST integrations) can opt back in via + // org.openidentityplatform.openam.httponly.allowTokenInBody=true. When the + // cookie is not HttpOnly the token is always returned, as before. + if (!CookieUtils.isCookieHttpOnly() || CookieUtils.isHttpOnlyAllowTokenInBody()) { jsonResponseObject.put(TOKEN_ID, tokenId); } // Server-side audit only - not exposed to the client. diff --git a/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandlerTest.java b/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandlerTest.java index cec8bfba46..c3c344bbed 100644 --- a/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandlerTest.java +++ b/openam-core-rest/src/test/java/org/forgerock/openam/core/rest/authn/RestAuthenticationHandlerTest.java @@ -459,8 +459,10 @@ public void shouldReturnAbsoluteRealmInSuccessfulAuthenticationResponse() throws @Test public void shouldNotEchoTokenIdInResponseBodyWhenCookieIsHttpOnly() throws Exception { - // Given - HttpOnly mode: the token must be delivered only via Set-Cookie, never in the body + // Given - HttpOnly mode with the default policy (allowTokenInBody=false): the token must be + // delivered only via Set-Cookie, never in the body setCookieHttpOnly(true); + setHttpOnlyAllowTokenInBody(false); try { // When - a successful authentication completes JsonValue response = performSuccessfulAuthentication(); @@ -474,6 +476,25 @@ public void shouldNotEchoTokenIdInResponseBodyWhenCookieIsHttpOnly() throws Exce } } + @Test + public void shouldEchoTokenIdInResponseBodyWhenHttpOnlyAndAllowTokenInBodyEnabled() throws Exception { + + // Given - HttpOnly mode but the deployment explicitly opted in to also return the token in + // the body (org.openidentityplatform.openam.httponly.allowTokenInBody=true) + setCookieHttpOnly(true); + setHttpOnlyAllowTokenInBody(true); + try { + // When + JsonValue response = performSuccessfulAuthentication(); + + // Then - both the HttpOnly cookie (set elsewhere) and the body token are available + assertEquals(response.get("tokenId").asString(), "SSO_TOKEN_ID"); + } finally { + setHttpOnlyAllowTokenInBody(false); + setCookieHttpOnly(false); + } + } + @Test public void shouldEchoTokenIdInResponseBodyWhenCookieIsNotHttpOnly() throws Exception { @@ -596,6 +617,12 @@ private static void setCookieHttpOnly(boolean value) throws Exception { field.setBoolean(null, value); } + private static void setHttpOnlyAllowTokenInBody(boolean value) throws Exception { + java.lang.reflect.Field field = CookieUtils.class.getDeclaredField("httpOnlyAllowTokenInBody"); + field.setAccessible(true); + field.setBoolean(null, value); + } + private JsonValue performSuccessfulAuthentication() throws Exception { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse httpResponse = mock(HttpServletResponse.class); diff --git a/openam-shared/src/main/java/com/sun/identity/shared/Constants.java b/openam-shared/src/main/java/com/sun/identity/shared/Constants.java index c1d879337d..33fb50140b 100644 --- a/openam-shared/src/main/java/com/sun/identity/shared/Constants.java +++ b/openam-shared/src/main/java/com/sun/identity/shared/Constants.java @@ -26,7 +26,7 @@ * * Portions Copyrighted 2010-2016 ForgeRock AS. * - * Portions Copyrighted 2020 Open Identity Platform Community. + * Portions Copyrighted 2018-2026 3A Systems, LLC. */ package com.sun.identity.shared; @@ -207,6 +207,19 @@ public interface Constants { */ static final String AM_COOKIE_SAMESITE = "org.openidentityplatform.openam.cookie.samesite"; + /** + * Property string controlling whether the SSO token id may be returned in the + * {@code /json/authenticate} response body when the session cookie is {@code HttpOnly}. + *

+ * Only relevant when {@link #AM_COOKIE_HTTPONLY} is enabled. Defaults to {@code false}, meaning + * the token is delivered to the browser solely via the {@code Set-Cookie} header and is not + * echoed in the response body (so XSS on the origin cannot read a replayable token). Set to + * {@code true} to keep the legacy behaviour of also returning {@code tokenId} in the body for + * integrations that require both an {@code HttpOnly} cookie and the token in the body. + */ + static final String AM_COOKIE_HTTPONLY_ALLOW_TOKEN_IN_BODY = + "org.openidentityplatform.openam.httponly.allowTokenInBody"; + /** * Property string for cookie encoding. */ diff --git a/openam-shared/src/main/java/com/sun/identity/shared/encode/CookieUtils.java b/openam-shared/src/main/java/com/sun/identity/shared/encode/CookieUtils.java index 46841c9f85..ccbc28b8a6 100644 --- a/openam-shared/src/main/java/com/sun/identity/shared/encode/CookieUtils.java +++ b/openam-shared/src/main/java/com/sun/identity/shared/encode/CookieUtils.java @@ -25,7 +25,7 @@ * $Id: CookieUtils.java,v 1.6 2009/10/02 00:08:26 ericow Exp $ * * Portions Copyrighted 2014-2016 ForgeRock AS. - * Portions Copyrighted 2017-2025 3A Systems, LLC. + * Portions Copyrighted 2017-2026 3A Systems, LLC. */ package com.sun.identity.shared.encode; @@ -70,6 +70,9 @@ public class CookieUtils { (SystemPropertiesManager.get(Constants.AM_COOKIE_HTTPONLY). equalsIgnoreCase("true")); + static boolean httpOnlyAllowTokenInBody = + SystemPropertiesManager.getAsBoolean(Constants.AM_COOKIE_HTTPONLY_ALLOW_TOKEN_IN_BODY, false); + static String cookieSameSite = SystemPropertiesManager.get( Constants.AM_COOKIE_SAMESITE); @@ -174,6 +177,21 @@ public static boolean isCookieHttpOnly() { return cookieHttpOnly; } + /** + * Returns whether the SSO token id may be returned in the authenticate response body while the + * session cookie is {@code HttpOnly}. + *

+ * Controlled by {@code org.openidentityplatform.openam.httponly.allowTokenInBody} and only + * relevant when {@link #isCookieHttpOnly()} is {@code true}. Defaults to {@code false}: the token + * is delivered only via the {@code Set-Cookie} header and is not echoed in the body. Set to + * {@code true} for integrations that need both the {@code HttpOnly} cookie and the body token. + * + * @return {@code true} if the token may be returned in the body in HttpOnly mode. + */ + public static boolean isHttpOnlyAllowTokenInBody() { + return httpOnlyAllowTokenInBody; + } + /** * Returns property value of "org.openidentityplatform.openam.cookie.samesite" * From ddaaecdfbe6743cc9ab90d9d9959dd033fd0d78c Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Sat, 13 Jun 2026 21:40:34 +0300 Subject: [PATCH 14/16] Create user store base entry during setup when missing Allow OpenAM to be configured (including via ssoconfiguratortools) against an external user-store directory whose base DN (root suffix) has not been pre-created, removing the need for the OpenDJ "--addBaseEntry" option on the user store. The user store schema/initialisation LDIFs (e.g. opendj_userinit.ldif) add entries such as "ou=people"/"ou=groups" under the user store root suffix but assume the suffix itself already exists. When it did not, the configurator failed with HTTP 500 while "Loading Schema opendj_userinit.ldif" (NO_SUCH_OBJECT). UserIdRepo.loadSchema() now creates the user store root suffix before loading the schema files via a new createBaseEntry() helper that: - checks for the base entry (BASE_OBJECT search) and is a no-op when present; - derives the objectClass from the RDN (dc/o/ou, otherwise extensibleObject); - is idempotent, handling ENTRY_ALREADY_EXISTS. This mirrors the existing AMSetupDSConfig.createBaseEntry() behaviour added for the external configuration store. --- .../com/sun/identity/setup/UserIdRepo.java | 80 ++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/openam-core/src/main/java/com/sun/identity/setup/UserIdRepo.java b/openam-core/src/main/java/com/sun/identity/setup/UserIdRepo.java index 3bf19fe2c7..7e4cb62681 100644 --- a/openam-core/src/main/java/com/sun/identity/setup/UserIdRepo.java +++ b/openam-core/src/main/java/com/sun/identity/setup/UserIdRepo.java @@ -25,7 +25,7 @@ * $Id: UserIdRepo.java,v 1.21 2009/12/23 00:22:34 goodearth Exp $ * * Portions Copyrighted 2011-2016 ForgeRock AS. - * Portions Copyrighted 2025 3A Systems LLC. + * Portions Copyrighted 2024-2026 3A Systems LLC. */ package com.sun.identity.setup; @@ -74,10 +74,13 @@ import org.forgerock.opendj.ldap.Attribute; import org.forgerock.opendj.ldap.Connection; import org.forgerock.opendj.ldap.ConnectionFactory; +import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.LDAPConnectionFactory; import org.forgerock.opendj.ldap.LdapException; +import org.forgerock.opendj.ldap.ResultCode; import org.forgerock.opendj.ldap.SearchScope; import org.forgerock.opendj.ldap.SSLContextBuilder; +import org.forgerock.opendj.ldap.requests.AddRequest; import org.forgerock.opendj.ldap.requests.SimpleBindRequest; import org.forgerock.opendj.ldap.responses.SearchResultEntry; import org.forgerock.opendj.ldif.ConnectionEntryReader; @@ -276,6 +279,14 @@ private void loadSchema( String type ) throws Exception { try (Connection conn = getLDAPConnection(userRepo)){ + // The user store schema/initialisation LDIFs (e.g. opendj_userinit.ldif) + // add entries such as "ou=people," under the user store + // root suffix. The root suffix (base entry) itself is assumed to + // already exist. Create it if it is missing so OpenAM can be configured + // against an external directory whose base DN has not been pre-created + // (e.g. without the OpenDJ "--addBaseEntry" option). + createBaseEntry(conn, (String) userRepo.get( + SetupConstants.USER_STORE_ROOT_SUFFIX)); String dbName = getDBName(userRepo, conn); for (String file : writeSchemaFiles(basedir, dbName, servletCtx, strFiles, userRepo, type)) { Object[] params = {file}; @@ -287,7 +298,72 @@ private void loadSchema( } } } - + + /** + * Creates the user store root suffix (base entry) when it does not already + * exist. The object class of the created entry is derived from the naming + * attribute of the suffix (dc, o or + * ou). If the base entry is already present this is a no-op. + * + * @param conn an open connection to the user store directory server. + * @param suffix the user store root suffix DN. + * @throws LdapException if the base entry cannot be created. + */ + private void createBaseEntry(Connection conn, String suffix) throws LdapException { + if ((suffix == null) || (suffix.trim().length() == 0)) { + return; + } + DN suffixDN = DN.valueOf(suffix); + + // Base entry already present - nothing to do. + try { + ConnectionEntryReader reader = conn.search(LDAPRequests.newSearchRequest( + suffix, SearchScope.BASE_OBJECT, "(objectclass=*)")); + if (reader.hasNext()) { + return; + } + } catch (LdapException e) { + if ((e.getResult() == null) + || !ResultCode.NO_SUCH_OBJECT.equals(e.getResult().getResultCode())) { + throw e; + } + // NO_SUCH_OBJECT - the base entry is missing, fall through to create it. + } + + String namingAttr = suffixDN.rdn().getFirstAVA().getAttributeType().getNameOrOID(); + String rdnValue = LDAPUtils.rdnValueFromDn(suffixDN); + + Object[] params = {suffix}; + SetupProgress.reportStart("emb.creatingfamsuffix", params); + + AddRequest addRequest = LDAPRequests.newAddRequest(suffixDN) + .addAttribute("objectClass", "top"); + if ("dc".equalsIgnoreCase(namingAttr)) { + addRequest.addAttribute("objectClass", "domain").addAttribute("dc", rdnValue); + } else if ("o".equalsIgnoreCase(namingAttr)) { + addRequest.addAttribute("objectClass", "organization").addAttribute("o", rdnValue); + } else if ("ou".equalsIgnoreCase(namingAttr)) { + addRequest.addAttribute("objectClass", "organizationalUnit").addAttribute("ou", rdnValue); + } else { + // Fallback for any other naming attribute. + addRequest.addAttribute("objectClass", "extensibleObject").addAttribute(namingAttr, rdnValue); + } + + try { + conn.add(addRequest); + SetupProgress.reportEnd("emb.success", null); + } catch (LdapException e) { + // Created concurrently or already present - acceptable. + if ((e.getResult() != null) + && ResultCode.ENTRY_ALREADY_EXISTS.equals(e.getResult().getResultCode())) { + SetupProgress.reportEnd("emb.success", null); + return; + } + SetupProgress.reportEnd("emb.failed", null); + throw e; + } + } + private List writeSchemaFiles( String basedir, String dbName, From 3376a1931a62c06b62e0a0a61648b733cce31fa4 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Sun, 14 Jun 2026 11:09:44 +0300 Subject: [PATCH 15/16] fix(xui): detect successful login without body tokenId in HttpOnly cookie mode After the server stopped echoing the SSO tokenId in the /json/authenticate response body in HttpOnly cookie mode (default), the XUI no longer recognised a completed authentication: it detects success by the presence of `tokenId` in the response, so the login flow never left the #login route (the e2e specs timed out on waitForURL for login, page reload and step-up). Detect a completed authentication via a shared helper that also covers HttpOnly mode, where the token is delivered solely via Set-Cookie and is absent from the body. A response is considered authenticated when it has a `tokenId` (as before) or, in HttpOnly mode, when it has a `successUrl` with no further `authId`/callbacks to satisfy. In HttpOnly mode the SessionToken now falls back to its in-memory sentinel, and getLoggedUser/idFromSession resolve the session from the auto-sent cookie. Changes: - SessionToken.jsm: add isAuthenticated(response) helper (tokenId, or HttpOnly + successUrl without authId) - AuthNService.handleRequirements: use SessionToken.isAuthenticated(requirements) - RESTLoginHelper.login: use SessionToken.isAuthenticated(result) - RESTLoginView: import SessionToken and use isAuthenticated(reqs) for both the existing-session and newsession checks - docs/pr/xui-httponly-session-cookie.md: document the client-side success detection No behaviour change when the cookie is not HttpOnly (or when org.openidentityplatform.openam.httponly.allowTokenInBody=true): the tokenId path is taken exactly as before. The successUrl fallback only applies in HttpOnly mode. --- .../openam/ui/user/login/RESTLoginHelper.js | 3 ++- .../openam/ui/user/login/RESTLoginView.js | 8 ++++--- .../ui/user/login/tokens/SessionToken.jsm | 24 +++++++++++++++++++ .../openam/ui/user/services/AuthNService.js | 2 +- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/login/RESTLoginHelper.js b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/login/RESTLoginHelper.js index e7a735a1b5..6f6084e6ec 100644 --- a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/login/RESTLoginHelper.js +++ b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/login/RESTLoginHelper.js @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Portions copyright 2011-2016 ForgeRock AS. + * Portions copyright 2026 3A Systems, LLC */ define([ @@ -47,7 +48,7 @@ define([ }); AuthNService.submitRequirements(populatedRequirements, params).then(function (result) { - if (result.hasOwnProperty("tokenId")) { + if (SessionToken.isAuthenticated(result)) { obj.getLoggedUser(function (user) { Configuration.setProperty("loggedUser", user); self.setSuccessURL(result.tokenId, result.successUrl).then(function () { diff --git a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/login/RESTLoginView.js b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/login/RESTLoginView.js index ed756308dc..236f0b9d4f 100644 --- a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/login/RESTLoginView.js +++ b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/login/RESTLoginView.js @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Portions copyright 2011-2016 ForgeRock AS. + * Portions copyright 2021-2026 3A Systems, LLC */ define([ @@ -37,10 +38,11 @@ define([ "org/forgerock/openam/ui/user/login/logout", "org/forgerock/openam/ui/common/util/uri/query", "org/forgerock/openam/ui/user/login/gotoUrl", + "org/forgerock/openam/ui/user/login/tokens/SessionToken", "store/index" ], ($, _, AbstractView, AuthNService, BootstrapDialog, Configuration, Constants, CookieHelper, EventManager, Form2js, Handlebars, i18nManager, Messages, RESTLoginHelper, isRealmChanged, Router, SessionManager, UIUtils, - URIUtils, logout, query, gotoUrl, store) => { + URIUtils, logout, query, gotoUrl, SessionToken, store) => { isRealmChanged = isRealmChanged.default; function hasSsoRedirectOrPost (goto) { @@ -269,13 +271,13 @@ define([ AuthNService.getRequirements().then(_.bind(function (reqs) { // Clear out existing session if instructed - if (reqs.hasOwnProperty("tokenId") && params.arg === "newsession") { + if (SessionToken.isAuthenticated(reqs) && params.arg === "newsession") { logout.default(); } // If simply by asking for the requirements, we end up with a token, // then we must have already had a session - if (reqs.hasOwnProperty("tokenId")) { + if (SessionToken.isAuthenticated(reqs)) { this.handleExistingSession(reqs); } else { // We aren't logged in yet, so render a form... this.renderForm(reqs, params); diff --git a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/login/tokens/SessionToken.jsm b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/login/tokens/SessionToken.jsm index 8e19e91c75..7006798a51 100644 --- a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/login/tokens/SessionToken.jsm +++ b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/login/tokens/SessionToken.jsm @@ -77,6 +77,30 @@ export function isResolvable (token) { return Boolean(token) && token !== HTTP_ONLY_TOKEN; } +/** + * Whether the supplied /json/authenticate response represents a completed (successful) + * authentication. + *

+ * Normally a completion is detected by the presence of a tokenId in the response body. + * However, when the session cookie is HttpOnly the server delivers the token solely via + * the Set-Cookie header and does NOT echo tokenId in the body (so an XSS on + * the origin cannot read a replayable token). In that case a completion is instead indicated by the + * presence of a successUrl with no further callbacks (no authId) to satisfy. + * @param {Object} response The parsed authenticate response. + * @returns {Boolean} true if the response represents a successful authentication. + */ +export function isAuthenticated (response) { + if (!response) { + return false; + } + if (Object.prototype.hasOwnProperty.call(response, "tokenId")) { + return true; + } + return isHttpOnly() && + Object.prototype.hasOwnProperty.call(response, "successUrl") && + !Object.prototype.hasOwnProperty.call(response, "authId"); +} + export function set (token) { if (isHttpOnly()) { // The server is responsible for setting the HttpOnly session cookie. JavaScript cannot diff --git a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/services/AuthNService.js b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/services/AuthNService.js index e9de74c650..9cb8f11e5d 100644 --- a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/services/AuthNService.js +++ b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/services/AuthNService.js @@ -115,7 +115,7 @@ define([ }); } - const isAuthenticated = requirements.hasOwnProperty("tokenId"); + const isAuthenticated = SessionToken.isAuthenticated(requirements); if (requirements.hasOwnProperty("authId")) { requirementList.push(requirements); From 38de352c0df3931d675d7b5d35e78afc8a17470b Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Sun, 14 Jun 2026 16:55:02 +0300 Subject: [PATCH 16/16] docs: document XUI HttpOnly session cookie support and allowTokenInBody Update the AsciiDoc documentation to reflect that the XUI now supports HttpOnly session cookies and to document the new configuration property that controls whether the SSO token is returned in the /json/authenticate response body. - reference/chap-config-ref.adoc: - com.sun.identity.cookie.httponly: remove the outdated note that the option is "used only in non-XUI deployments" / "the XUI cannot set the HttpOnly name in a cookie"; explain that both the classic UI and the XUI support HttpOnly, and that in HttpOnly mode the token is delivered via Set-Cookie and is not echoed in the response body by default (fix HTTPOnly -> HttpOnly typo). - add org.openidentityplatform.openam.httponly.allowTokenInBody (default false): when the cookie is HttpOnly, controls whether tokenId is returned in the /json/authenticate response body; default omits it to prevent XSS token exfiltration, set true to keep the legacy body token for raw-REST integrations. - admin-guide/chap-securing.adoc (Securing Communications): - replace the stale "the XUI does not currently support HttpOnly cookies" note with an accurate description of XUI HttpOnly support, the response-body behaviour, and the allowTokenInBody opt-in, linking to the Configuration Reference. No functional changes; documentation only. --- .../asciidoc/admin-guide/chap-securing.adoc | 2 +- .../asciidoc/reference/chap-config-ref.adoc | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/openam-documentation/openam-doc-source/src/main/asciidoc/admin-guide/chap-securing.adoc b/openam-documentation/openam-doc-source/src/main/asciidoc/admin-guide/chap-securing.adoc index 70b72495aa..1652f30b16 100644 --- a/openam-documentation/openam-doc-source/src/main/asciidoc/admin-guide/chap-securing.adoc +++ b/openam-documentation/openam-doc-source/src/main/asciidoc/admin-guide/chap-securing.adoc @@ -110,7 +110,7 @@ To configure OpenAM server to use secure cookies, in the OpenAM console, navigat + HttpOnly cookies are meant to be transmitted only over HTTP and HTTPS, and not through non-HTTP methods, such as JavaScript functions. + -If you are using the classic UI, you can configure the OpenAM server to use HttpOnly cookies by navigating to Configure > Server Defaults > Advanced, and setting the `com.sun.identity.cookie.httponly` property's value to `true`. Save your changes. Note that the XUI does not currently support HttpOnly cookies. +You can configure the OpenAM server to use HttpOnly cookies by navigating to Configure > Server Defaults > Advanced, and setting the `com.sun.identity.cookie.httponly` property's value to `true`. Save your changes. Both the classic UI and the XUI support HttpOnly session cookies: when HttpOnly is enabled, the XUI relies on the automatically sent cookie instead of reading the token from JavaScript, and the `/json/authenticate` response delivers the token only through the `Set-Cookie` header rather than echoing `tokenId` in the response body. To keep returning `tokenId` in the body as well (for example, for non-browser or raw-REST integrations), set `org.openidentityplatform.openam.httponly.allowTokenInBody` to `true`. See xref:../reference/chap-config-ref.adoc#chap-config-ref["Configuration Reference"] in the __Reference__. * Where possible, use subdomain cookies, and control subdomains in a specific DNS master. diff --git a/openam-documentation/openam-doc-source/src/main/asciidoc/reference/chap-config-ref.adoc b/openam-documentation/openam-doc-source/src/main/asciidoc/reference/chap-config-ref.adoc index 252271168d..d83e9216f1 100644 --- a/openam-documentation/openam-doc-source/src/main/asciidoc/reference/chap-config-ref.adoc +++ b/openam-documentation/openam-doc-source/src/main/asciidoc/reference/chap-config-ref.adoc @@ -5577,10 +5577,10 @@ Number of threads in the shared system timer pool used to schedule operations su Default: `3` `com.sun.identity.cookie.httponly`:: -When set to `true`, mark cookies as HTTPOnly to prevent scripts and third-party programs from accessing the cookies. +When set to `true`, mark cookies as HttpOnly to prevent scripts and third-party programs from accessing the cookies. + -Note that this configuration option is used only in non-XUI deployments. The XUI cannot set the HttpOnly name in a cookie. +Both the classic UI and the XUI support HttpOnly session cookies. When HttpOnly is enabled, the XUI relies on the automatically sent cookie instead of reading the token from JavaScript, and a successful `/json/authenticate` response delivers the token only through the `Set-Cookie` header (the `tokenId` is, by default, no longer returned in the response body). See `org.openidentityplatform.openam.httponly.allowTokenInBody` to control that behaviour. + Default: `false` @@ -5979,6 +5979,21 @@ Default: None (do not time out) + For suggested settings, see xref:../admin-guide/chap-tuning.adoc#tuning-ldap-settings-cts["Tuning LDAP CTS and Configuration Store Settings"] in the __Administration Guide__. +`org.openidentityplatform.openam.httponly.allowTokenInBody`:: +Controls whether the SSO token id (`tokenId`) is returned in the `/json/authenticate` response body when the session cookie is HttpOnly. + ++ +This property is only relevant when the session cookie is HttpOnly (`com.sun.identity.cookie.httponly=true`). By default (`false`), the token is delivered to the browser solely through the `Set-Cookie` header and is not echoed in the response body, so an XSS on the origin cannot read a replayable SSO token (including a freshly upgraded one). The XUI relies on the auto-sent cookie and does not need the token in the body. + ++ +Set this property to `true` to keep the legacy behaviour of also returning `tokenId` in the response body, for example for non-browser or raw-REST integrations that require both the HttpOnly cookie and the token in the body. + ++ +When the session cookie is not HttpOnly, `tokenId` is always returned in the response body, regardless of this property. + ++ +Default: `false` + `securidHelper.ports`:: Port on which SecurID daemon listens.