diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c81ca74587..6383f71179 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: @@ -32,13 +45,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: @@ -276,7 +288,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 +298,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..b1b7598118 --- /dev/null +++ b/e2e/xui/xui-httponly.spec.mjs @@ -0,0 +1,266 @@ +/* + * 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). + * + * 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. + * + * 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"; +import { OPENAM_BASE, USERNAME, PASSWORD, ADMIN_USER, ADMIN_PASS } from "../common/openam-commons.mjs"; + +// XUI / LESS-based OpenAM login form selectors +const SEL = { + usernameInput: "#idToken1", + passwordInput: "#idToken2", + // 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) +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(); +} + +/** 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 ────────────── + 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.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 }); + + // ── 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 on the logged-out/login route ──── + await page.goto(`${OPENAM_BASE}/XUI/#logout/`); + await page.waitForURL((url) => /^#(loggedOut|login)/.test(url.hash), { timeout: 30_000 }); + + // ── 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); + }); + + 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()); + }); + + 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 (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, "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); + expect(String(idAfter).toLowerCase()).toBe(USERNAME.toLowerCase()); + }); +}); + + + + + + + + + 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..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 @@ -11,13 +12,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 +34,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 +150,8 @@ private JsonValue authenticate(HttpServletRequest request, HttpServletResponse r AuthIndexType indexType = getAuthIndexType(authIndexType); + sessionUpgradeSSOTokenId = resolveSessionUpgradeTarget(request, sessionUpgradeSSOTokenId); + String authId = null; String sessionId = null; @@ -210,6 +211,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);
}
@@ -291,7 +326,20 @@ 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 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.
AuditRequestContext.putProperty(TOKEN_ID, tokenId);
} else {
jsonResponseObject.put("message", "Authentication Successful");
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 For an embedded OpenDJ the suffix is created from
+ * 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 (
+ * 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"
*
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/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 603ce1078b..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
@@ -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
+ *
+ * Normally a completion is detected by the presence of a 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.dc, o or
+ * ou).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, Listdc, 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 ListHttpOnly 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,75 @@ 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;
+}
+
+/**
+ * Whether the supplied /json/authenticate response represents a completed (successful)
+ * authentication.
+ * 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
+ // 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..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
@@ -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;
}
@@ -114,7 +115,7 @@ define([
});
}
- const isAuthenticated = requirements.hasOwnProperty("tokenId");
+ const isAuthenticated = SessionToken.isAuthenticated(requirements);
if (requirements.hasOwnProperty("authId")) {
requirementList.push(requirements);
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: {