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 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-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..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 @@ -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,173 @@ public void shouldReturnAbsoluteRealmInSuccessfulAuthenticationResponse() throws assertThat(response).stringAt("realm").isEqualTo("REALM"); } + @Test + public void shouldNotEchoTokenIdInResponseBodyWhenCookieIsHttpOnly() throws Exception { + + // 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(); + + // 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 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 { + + // 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 { + + // 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 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-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-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, 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); } 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-server/pom.xml b/openam-server/pom.xml index 7603ea883b..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 @@ -166,13 +173,13 @@ org.seleniumhq.selenium selenium-java - 4.43.0 + 4.44.0 test org.testcontainers testcontainers - 2.0.4 + 2.0.5 test 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(); 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" * 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 + * 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,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. + *

+ * 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 + // 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: {