From 646208fcf1961accbcad386fd202b8518c615ade Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 19 Jun 2026 19:49:36 +0300 Subject: [PATCH 1/5] Enable HttpOnly session cookies by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the default of com.sun.identity.cookie.httponly from false to true so that OpenAM marks its SSO/session cookies HttpOnly out of the box. The XUI already supports this mode (relies on the auto-sent cookie instead of reading the token from document.cookie), so the previous "breaks XUI" rationale no longer applies. Core: - CookieUtils (openam-shared): default to true when the property is unset, via getAsBoolean(AM_COOKIE_HTTPONLY, true); update isCookieHttpOnly() javadoc. - serverdefaults.properties: ship com.sun.identity.cookie.httponly=true and rewrite the stale comment (document the allowTokenInBody opt-in / opt-out). IDP Discovery: - CookieUtils: default HttpOnly to true (null/empty -> on, explicit false -> off). - Configurator.jsp: default the "HTTP-Only Cookie" radio to True. CI / e2e: - build.yml: invert the Playwright phases — test the new HttpOnly=true default first (xui specs), then override to false via setenv.sh and run the full suite (oauth2/saml read tokenId from the response body, suppressed in HttpOnly mode). - saml-test.spec.mjs: fix the now-incorrect "HttpOnly breaks XUI" comment. - openam-commons.mjs: document that getAuthToken needs the token in the body. Tests: - RestAuthenticationHandlerTest: set the token-readable baseline (setCookieHttpOnly(false)) in @BeforeMethod so legacy assertions are independent of the production default and test order. Docs (asciidoc): update default to true and the serverinfo example in chap-securing, chap-deployments, chap-config-ref, chap-client-dev. --- .github/workflows/build.yml | 32 ++++++++++++------- e2e/common/openam-commons.mjs | 5 +++ e2e/saml/saml-test.spec.mjs | 7 ++-- .../authn/RestAuthenticationHandlerTest.java | 7 +++- .../asciidoc/admin-guide/chap-securing.adoc | 4 +-- .../deployment-planning/chap-deployments.adoc | 2 +- .../asciidoc/dev-guide/chap-client-dev.adoc | 2 +- .../asciidoc/reference/chap-config-ref.adoc | 2 +- .../src/main/webapp/Configurator.jsp | 5 +-- .../saml2/idpdiscovery/CookieUtils.java | 9 ++---- .../template/sms/serverdefaults.properties | 24 +++++++------- .../identity/shared/encode/CookieUtils.java | 14 ++++---- 12 files changed, 66 insertions(+), 47 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 94c8e0eb10..2e51c96229 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -302,38 +302,46 @@ jobs: with: sparse-checkout: e2e - - name: UI Smoke Tests (Playwright) - HttpOnly disabled + - name: UI Smoke Tests (Playwright) - HttpOnly enabled (default) + # OpenAM now ships HttpOnly session cookies by default, so a freshly + # configured server already reports cookieHttpOnly=true. This stage runs + # the (mode-agnostic) XUI specs against that default. env: - EXPECT_COOKIE_HTTPONLY: "false" + EXPECT_COOKIE_HTTPONLY: "true" run: | cd e2e npm init -y npm install @playwright/test npx playwright install chromium --with-deps - npx playwright test --reporter=list + echo "verifying the freshly configured server reports cookieHttpOnly=true (the new default)" + curl -sf "http://openam.example.org:8080/openam/json/serverinfo/*" | jq -e '.cookieHttpOnly == true' + npx playwright test xui --reporter=list - - name: Enable HttpOnly session cookie on OpenAM IDP and restart + - name: Disable 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). + # so we inject the non-default value 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" + echo "export CATALINA_OPTS=\"\$CATALINA_OPTS -Dcom.sun.identity.cookie.httponly=false\"" > "$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' + echo "verifying the server now reports cookieHttpOnly=false" + curl -sf "http://openam.example.org:8080/openam/json/serverinfo/*" | jq -e '.cookieHttpOnly == false' - - name: UI Smoke Tests (Playwright) - HttpOnly enabled + - name: UI Smoke Tests (Playwright) - HttpOnly disabled + # The full suite (oauth2/saml) runs in the non-HttpOnly mode because those + # specs read the SSO tokenId from the /json/authenticate response body, + # which is suppressed in the default HttpOnly mode. env: - EXPECT_COOKIE_HTTPONLY: "true" + EXPECT_COOKIE_HTTPONLY: "false" run: | cd e2e - npx playwright test xui --reporter=list + npx playwright test --reporter=list - name: Upload failure artifacts uses: actions/upload-artifact@v7 diff --git a/e2e/common/openam-commons.mjs b/e2e/common/openam-commons.mjs index 6f84272277..eb90570408 100644 --- a/e2e/common/openam-commons.mjs +++ b/e2e/common/openam-commons.mjs @@ -25,6 +25,11 @@ export async function getAdminToken(request) { return getAuthToken(request, ADMIN_USER, ADMIN_PASS) } +// Resolves the SSO tokenId from the /json/authenticate response body. Note this only works when the +// session cookie is NOT HttpOnly, or when org.openidentityplatform.openam.httponly.allowTokenInBody +// is enabled: in the default HttpOnly deployment the token is delivered solely via Set-Cookie and is +// not echoed in the body, so this helper returns undefined. Specs that rely on it must run against a +// server with HttpOnly disabled (see the CI matrix in .github/workflows/build.yml). export async function getAuthToken(request, username, password) { const resp = await request.post(`${OPENAM_BASE}/json/authenticate`, { headers: { diff --git a/e2e/saml/saml-test.spec.mjs b/e2e/saml/saml-test.spec.mjs index ddac492c8e..d3f9cd3bb8 100644 --- a/e2e/saml/saml-test.spec.mjs +++ b/e2e/saml/saml-test.spec.mjs @@ -115,9 +115,10 @@ test.describe("OpenAM XUI - Login flow", () => { // ── 7. Assert the SSO session cookie carries a SameSite attribute ─────── // GHSA-fpmh-vx4h-xc33: the iPlanetDirectoryPro SSO cookie ships with a SameSite attribute by - // default so it is not sent on cross-site requests. It is intentionally NOT HttpOnly: the XUI - // reads it from document.cookie (SessionToken.jsm / AMConfig.js / AuthNService.js) to track the - // session and set REST headers, so enabling HttpOnly by default would break XUI console login. + // default so it is not sent on cross-site requests. The check below only asserts the SameSite + // attribute; whether the cookie is HttpOnly is governed by com.sun.identity.cookie.httponly (on + // by default, and fully supported by the XUI). HttpOnly behaviour is covered by the xui-httponly + // spec. const cookies = await page.context().cookies(); const ssoCookie = cookies.find((c) => c.name === "iPlanetDirectoryPro"); expect(ssoCookie, "iPlanetDirectoryPro SSO cookie should be set").toBeTruthy(); 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 c3c344bbed..be5801c694 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 @@ -74,7 +74,12 @@ public class RestAuthenticationHandlerTest { private CoreServicesWrapper coreServicesWrapper; @BeforeMethod - public void setUp() { + public void setUp() throws Exception { + + // Establish the token-readable baseline (HttpOnly off) for every test, independent of the + // production default of com.sun.identity.cookie.httponly. Tests that exercise HttpOnly mode + // opt in explicitly via setCookieHttpOnly(true) and reset it afterwards. + setCookieHttpOnly(false); loginAuthenticator = mock(LoginAuthenticator.class); restAuthCallbackHandlerManager = mock(RestAuthCallbackHandlerManager.class); diff --git a/openam-documentation/openam-doc-source/src/main/asciidoc/admin-guide/chap-securing.adoc b/openam-documentation/openam-doc-source/src/main/asciidoc/admin-guide/chap-securing.adoc index 8b4fd5ad69..7b071245f4 100644 --- a/openam-documentation/openam-doc-source/src/main/asciidoc/admin-guide/chap-securing.adoc +++ b/openam-documentation/openam-doc-source/src/main/asciidoc/admin-guide/chap-securing.adoc @@ -110,11 +110,11 @@ To configure OpenAM server to use secure cookies, in the OpenAM console, navigat + HttpOnly cookies are meant to be transmitted only over HTTP and HTTPS, and not through non-HTTP methods, such as JavaScript functions. + -You can configure the OpenAM server to use HttpOnly cookies by navigating to Configure > Server Defaults > Advanced, and setting the `com.sun.identity.cookie.httponly` property's value to `true`. Save your changes. Both the classic UI and the XUI support HttpOnly session cookies: when HttpOnly is enabled, the XUI relies on the automatically sent cookie instead of reading the token from JavaScript, and the `/json/authenticate` response delivers the token only through the `Set-Cookie` header rather than echoing `tokenId` in the response body. To keep returning `tokenId` in the body as well (for example, for non-browser or raw-REST integrations), set `org.openidentityplatform.openam.httponly.allowTokenInBody` to `true`. Note that doing so re-exposes the token to scripts on the OpenAM origin, so leave it at its default of `false` unless an integration requires it. For both properties, see `com.sun.identity.cookie.httponly` and `org.openidentityplatform.openam.httponly.allowTokenInBody` in xref:../reference/chap-config-ref.adoc#chap-config-ref["Configuration Reference"] in the __Reference__. +OpenAM marks its cookies `HttpOnly` by default (`com.sun.identity.cookie.httponly=true`, under Configure > Server Defaults > Advanced). Both the classic UI and the XUI support HttpOnly session cookies: the XUI relies on the automatically sent cookie instead of reading the token from JavaScript, and the `/json/authenticate` response delivers the token only through the `Set-Cookie` header rather than echoing `tokenId` in the response body. To keep returning `tokenId` in the body as well (for example, for non-browser or raw-REST integrations), set `org.openidentityplatform.openam.httponly.allowTokenInBody` to `true`. Note that doing so re-exposes the token to scripts on the OpenAM origin, so leave it at its default of `false` unless an integration requires it. Set `com.sun.identity.cookie.httponly` to `false` to disable HttpOnly cookies entirely. For both properties, see `com.sun.identity.cookie.httponly` and `org.openidentityplatform.openam.httponly.allowTokenInBody` in xref:../reference/chap-config-ref.adoc#chap-config-ref["Configuration Reference"] in the __Reference__. + Both properties are read once when the server starts, so you must restart the OpenAM server for a change to either of them to take effect. + -`com.sun.identity.cookie.httponly` defaults to `false` to preserve the behaviour of existing integrations that read the session cookie, or the `tokenId` from the authentication response body, from script. Enabling HttpOnly is recommended for browser-facing deployments: an HttpOnly session cookie prevents a cross-site scripting flaw on the OpenAM origin from reading a replayable session token. +An HttpOnly session cookie prevents a cross-site scripting flaw on the OpenAM origin from reading a replayable session token, which is why it is enabled by default. + One known limitation applies: the OpenID Connect session management OP iframe (`/oauth2/connect/checkSession`) computes the browser state by reading the session cookie from JavaScript, so it cannot observe the session while `HttpOnly` is enabled. + diff --git a/openam-documentation/openam-doc-source/src/main/asciidoc/deployment-planning/chap-deployments.adoc b/openam-documentation/openam-doc-source/src/main/asciidoc/deployment-planning/chap-deployments.adoc index 060258029e..5e932b5532 100644 --- a/openam-documentation/openam-doc-source/src/main/asciidoc/deployment-planning/chap-deployments.adoc +++ b/openam-documentation/openam-doc-source/src/main/asciidoc/deployment-planning/chap-deployments.adoc @@ -226,7 +226,7 @@ When you first configure OpenAM, there are many options to evaluate, plus a numb * On a server that includes OpenAM Console, all the endpoints defined in the Web application descriptor, `WEB-INF/web.xml`, are available for use. -* To prevent cross-site scripting attacks, you can configure session cookies as HTTP Only by setting the property `com.sun.identity.cookie.httponly=true`. This property prevents third-party scripts from accessing the session cookie. Both the classic UI and the XUI support HttpOnly session cookies, so enabling it is recommended for browser-facing deployments; it defaults to `false` only to preserve the behaviour of existing integrations that read the session cookie, or the `tokenId` from the authentication response body, from script. Two consequences to plan for: the OpenID Connect session management OP iframe (`/oauth2/connect/checkSession`) reads the session cookie from JavaScript and so cannot observe the session while HttpOnly is enabled, and the property is read only at server startup. See xref:../admin-guide/chap-securing.adoc#secure-communications["Securing Communications"] in the __Administration Guide__. By default, OpenAM also sets `org.openidentityplatform.openam.cookie.samesite=Lax` to reduce cross-site request forgery (CSRF) exposure. +* To prevent cross-site scripting attacks, OpenAM marks session cookies as HTTP Only by default (`com.sun.identity.cookie.httponly=true`). This property prevents third-party scripts from accessing the session cookie. Both the classic UI and the XUI support HttpOnly session cookies out of the box; set the property to `false` only if you need the SSO token, or the `tokenId` from the authentication response body, to be readable from script. Two consequences to plan for: the OpenID Connect session management OP iframe (`/oauth2/connect/checkSession`) reads the session cookie from JavaScript and so cannot observe the session while HttpOnly is enabled, and the property is read only at server startup. See xref:../admin-guide/chap-securing.adoc#secure-communications["Securing Communications"] in the __Administration Guide__. By default, OpenAM also sets `org.openidentityplatform.openam.cookie.samesite=Lax` to reduce cross-site request forgery (CSRF) exposure. * You can deploy a reverse proxy within delimitarized zone (DMZ) firewalls to limit exposure of service URLs to the end user as well as block access to back end configuration and user data stores to unauthorized users. 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 f858f073fa..fe77a9abb7 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 @@ -944,7 +944,7 @@ $ curl https://openam.example.com:8443/openam/json/serverinfo/* "protectedUserAttributes": [], "cookieName": "iPlanetDirectoryPro", "secureCookie": false, - "cookieHttpOnly": false, + "cookieHttpOnly": true, "forgotPassword": "false", "forgotUsername": "false", "kbaEnabled": "false", diff --git a/openam-documentation/openam-doc-source/src/main/asciidoc/reference/chap-config-ref.adoc b/openam-documentation/openam-doc-source/src/main/asciidoc/reference/chap-config-ref.adoc index 7270569126..9c7df2e3f4 100644 --- a/openam-documentation/openam-doc-source/src/main/asciidoc/reference/chap-config-ref.adoc +++ b/openam-documentation/openam-doc-source/src/main/asciidoc/reference/chap-config-ref.adoc @@ -5643,7 +5643,7 @@ Both the classic UI and the XUI support HttpOnly session cookies. When HttpOnly Changes to this property do not take effect until you restart the OpenAM server. + -Default: `false` +Default: `true` `com.sun.identity.enableUniqueSSOTokenCookie`:: If `true`, then OpenAM is using protection against cookie hijacking. diff --git a/openam-federation/openam-idpdiscovery-war/src/main/webapp/Configurator.jsp b/openam-federation/openam-idpdiscovery-war/src/main/webapp/Configurator.jsp index 97d8304e6a..a042e32441 100644 --- a/openam-federation/openam-idpdiscovery-war/src/main/webapp/Configurator.jsp +++ b/openam-federation/openam-idpdiscovery-war/src/main/webapp/Configurator.jsp @@ -28,6 +28,7 @@ <%-- Portions Copyrighted 2012-2013 ForgeRock Inc Portions Copyrighted 2012 Open Source Solution Technology Corporation + Portions Copyrighted 2026 3A Systems, LLC --%> @@ -207,8 +208,8 @@ java.util.Properties" HTTP-Only Cookie: - True - False + True + False diff --git a/openam-federation/openam-idpdiscovery/src/main/java/com/sun/identity/saml2/idpdiscovery/CookieUtils.java b/openam-federation/openam-idpdiscovery/src/main/java/com/sun/identity/saml2/idpdiscovery/CookieUtils.java index b30bf58c5a..60776a5210 100644 --- a/openam-federation/openam-idpdiscovery/src/main/java/com/sun/identity/saml2/idpdiscovery/CookieUtils.java +++ b/openam-federation/openam-idpdiscovery/src/main/java/com/sun/identity/saml2/idpdiscovery/CookieUtils.java @@ -28,7 +28,7 @@ /** * Portions Copyrighted 2013 ForgeRock, Inc. - * Portions Copyrighted 2025-2026 3A Systems LLC. + * Portions Copyrighted 2021-2026 3A Systems LLC. */ package com.sun.identity.saml2.idpdiscovery; @@ -63,11 +63,8 @@ public class CookieUtils { SystemProperties.get(IDPDiscoveryConstants.AM_COOKIE_SECURE). equalsIgnoreCase("true")); - static boolean cookieHttpOnly = - (SystemProperties.get(IDPDiscoveryConstants.AM_COOKIE_HTTPONLY) - != null) && - (SystemProperties.get(IDPDiscoveryConstants.AM_COOKIE_HTTPONLY). - equalsIgnoreCase("true")); + static boolean cookieHttpOnly = !"false".equalsIgnoreCase( + SystemProperties.get(IDPDiscoveryConstants.AM_COOKIE_HTTPONLY)); static String cookieSameSite = SystemPropertiesManager.get( Constants.AM_COOKIE_SAMESITE); diff --git a/openam-server-only/src/main/webapp/WEB-INF/template/sms/serverdefaults.properties b/openam-server-only/src/main/webapp/WEB-INF/template/sms/serverdefaults.properties index f369bb0c32..82dbd0e61f 100644 --- a/openam-server-only/src/main/webapp/WEB-INF/template/sms/serverdefaults.properties +++ b/openam-server-only/src/main/webapp/WEB-INF/template/sms/serverdefaults.properties @@ -50,18 +50,18 @@ com.iplanet.am.profile.host=%SERVER_HOST% com.iplanet.am.profile.port=%SERVER_PORT% com.sun.identity.client.notification.url=%SERVER_PROTO%://%SERVER_HOST%:%SERVER_PORT%/%SERVER_URI%/notificationservice com.iplanet.am.daemons=securid -# NOTE: both the classic UI and the XUI work with HttpOnly session cookies: the -# XUI relies on the automatically sent cookie rather than reading it from -# document.cookie (see SessionToken.jsm), and in HttpOnly mode the token is -# delivered only via Set-Cookie, not echoed as tokenId in the /json/authenticate -# body (override with org.openidentityplatform.openam.httponly.allowTokenInBody). -# The default stays false only to preserve the behaviour of existing integrations -# that read the cookie, or the body tokenId, from script. Enabling it is -# recommended for browser-facing deployments. Two caveats: this value is read -# once at startup (a change needs a server restart), and the OIDC session -# management iframe (/oauth2/connect/checkSession) reads the cookie from -# JavaScript and so cannot observe the session while HttpOnly is enabled. -com.sun.identity.cookie.httponly=false +# The session/SSO cookies are marked HttpOnly by default so that scripts cannot +# read the SSO token from document.cookie. The XUI fully supports this mode: it +# relies on the auto-sent cookie instead of reading the token in JavaScript, and +# in HttpOnly mode the token is delivered only via the Set-Cookie header (it is +# not echoed in the /json/authenticate body). Non-browser/raw-REST integrations +# that need the token in the body can opt back in with +# org.openidentityplatform.openam.httponly.allowTokenInBody=true, or set this +# property to false to disable HttpOnly entirely. This value is read once at +# server startup (a change needs a restart), and the OIDC session management +# iframe (/oauth2/connect/checkSession) reads the cookie from JavaScript, so it +# cannot observe the session while HttpOnly is enabled. +com.sun.identity.cookie.httponly=true com.iplanet.am.cookie.name=iPlanetDirectoryPro com.iplanet.am.cookie.secure=@SECURE_COOKIE@ org.openidentityplatform.openam.cookie.samesite=Lax 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 0aa4711fd7..286d4c0e8a 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 @@ -63,10 +63,8 @@ public class CookieUtils { (SystemPropertiesManager.get(Constants.AM_COOKIE_SECURE). equalsIgnoreCase("true")); - static boolean cookieHttpOnly = - (SystemPropertiesManager.get(Constants.AM_COOKIE_HTTPONLY) != null) && - (SystemPropertiesManager.get(Constants.AM_COOKIE_HTTPONLY). - equalsIgnoreCase("true")); + static boolean cookieHttpOnly = + SystemPropertiesManager.getAsBoolean(Constants.AM_COOKIE_HTTPONLY, true); static boolean httpOnlyAllowTokenInBody = SystemPropertiesManager.getAsBoolean(Constants.AM_COOKIE_HTTPONLY_ALLOW_TOKEN_IN_BODY, false); @@ -156,8 +154,12 @@ public static boolean isCookieSecure() { } /** - * Returns property value of "com.sun.identity.cookie.httponly" - * + * Returns property value of "com.sun.identity.cookie.httponly". + *

+ * Defaults to {@code true} when the property is not set: OpenAM marks its cookies + * {@code HttpOnly} out of the box. Set the property to {@code false} to opt out (for example for + * integrations that read the SSO token from {@code document.cookie} in the browser). + * * @return the property value of "com.sun.identity.cookie.httponly" */ public static boolean isCookieHttpOnly() { From e6c9998317fccaadd919413e50974521465b7ea3 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Sat, 20 Jun 2026 09:26:35 +0300 Subject: [PATCH 2/5] ci: adapt e2e auth checks to default HttpOnly session cookies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With HttpOnly enabled by default, /json/authenticate no longer echoes the tokenId in the response body, so the Docker/e2e steps that scraped it broke. - Extract the admin SSO token from the iPlanetDirectoryPro Set-Cookie header (curl -D - -o /dev/null + sed) instead of jq .tokenId; pick the last non-empty value so a clearing (empty) Set-Cookie cannot win. - Verify successful logins via "successUrl" in the response body (present on every completed authentication, in both HttpOnly and token-readable modes) instead of grepping tokenId — robust against cookie-clearing Set-Cookie. Applies to the IDP demo user, SP, and the multi-server test-openam1/2/3 checks. --- .github/workflows/build.yml | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2e51c96229..1af98d65d2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -189,13 +189,14 @@ jobs: echo "Test IDP authentication" ADMIN_TOKEN=$(docker exec openam-idp bash -c \ - 'curl -sf \ + 'curl -sf -D - -o /dev/null \ --request POST \ --header "Content-Type: application/json" \ --header "X-OpenAM-Username: amadmin" \ --header "X-OpenAM-Password: ampassword" \ --data "{}" \ - http://openam.example.org:8080/openam/json/authenticate' | jq -r .tokenId) + http://openam.example.org:8080/openam/json/authenticate \ + | tr -d "\r" | sed -n "s/^[Ss]et-[Cc]ookie: *iPlanetDirectoryPro=\([^;]*\).*/\1/p" | grep . | tail -n1') docker inspect --format="{{json .State.Health.Status}}" openam-idp | grep -q \"healthy\" @@ -224,7 +225,7 @@ jobs: --header "X-OpenAM-Username: demo" \ --header "X-OpenAM-Password: changeit" \ --data "{}" \ - http://openam.example.org:8080/openam/json/authenticate' + http://openam.example.org:8080/openam/json/authenticate | grep -q successUrl' - name: Docker start with a dedicated OpenDJ container (SP) shell: bash @@ -281,13 +282,13 @@ jobs: echo "Test SP authentication" docker exec openam-sp bash -c \ - 'curl \ + 'curl -sf \ --request POST \ --header "Content-Type: application/json" \ --header "X-OpenAM-Username: amadmin" \ --header "X-OpenAM-Password: ampassword" \ --data "{}" \ - http://sp.mycompany.org:8080/openam/json/authenticate | grep tokenId' + http://sp.mycompany.org:8080/openam/json/authenticate | grep -q successUrl' docker inspect --format="{{json .State.Health.Status}}" openam-sp | grep -q \"healthy\" @@ -397,13 +398,13 @@ jobs: " > conf.file && java -jar openam-configurator-tool*.jar --file conf.file' docker exec test-openam1 bash -c \ - 'curl \ + 'curl -sf \ --request POST \ --header "Content-Type: application/json" \ --header "X-OpenAM-Username: amadmin" \ --header "X-OpenAM-Password: ampassword" \ --data "{}" \ - http://openam1.example.org:8080/openam/json/authenticate | grep tokenId' + http://openam1.example.org:8080/openam/json/authenticate | grep -q successUrl' docker inspect --format="{{json .State.Health.Status}}" test-openam1 | grep -q \"healthy\" @@ -447,13 +448,13 @@ jobs: " > conf.file && java -jar openam-configurator-tool*.jar --file conf.file' docker exec test-openam2 bash -c \ - 'curl \ + 'curl -sf \ --request POST \ --header "Content-Type: application/json" \ --header "X-OpenAM-Username: amadmin" \ --header "X-OpenAM-Password: ampassword" \ --data "{}" \ - http://openam2.example.org:8080/openam/json/authenticate | grep tokenId' + http://openam2.example.org:8080/openam/json/authenticate | grep -q successUrl' docker inspect --format="{{json .State.Health.Status}}" test-openam2 | grep -q \"healthy\" @@ -495,13 +496,13 @@ jobs: " > conf.file && java -jar openam-configurator-tool*.jar --file conf.file' docker exec test-openam3 bash -c \ - 'curl \ + 'curl -sf \ --request POST \ --header "Content-Type: application/json" \ --header "X-OpenAM-Username: amadmin" \ --header "X-OpenAM-Password: ampassword" \ --data "{}" \ - http://openam3.example.org:8080/openam/json/authenticate | grep tokenId' + http://openam3.example.org:8080/openam/json/authenticate | grep -q successUrl' docker inspect --format="{{json .State.Health.Status}}" test-openam3 | grep -q \"healthy\" From b6ebd2deefae3cd3541a8a9487d3088350ce91e7 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 25 Sep 2026 10:32:43 +0300 Subject: [PATCH 3/5] mcp-server: read the SSO token from the session cookie under HttpOnly With HttpOnly session cookies on by default, /json/authenticate no longer returns tokenId in the body, so the MCP server got a null token on a fresh OpenAM install. AuthInterceptor now takes tokenId from the body when present and otherwise the last non-empty Set-Cookie named like openam.tokenHeader (iPlanetDirectoryPro), failing with a clear error when neither is there. Covers both the username/password and the OAuth path; README notes that allowTokenInBody is not needed. --- openam-mcp-server/README.md | 3 + .../mcp/server/security/AuthInterceptor.java | 50 ++++++++-- .../server/security/AuthInterceptorTest.java | 96 +++++++++++++++++++ 3 files changed, 142 insertions(+), 7 deletions(-) diff --git a/openam-mcp-server/README.md b/openam-mcp-server/README.md index 79cc6426b0..236b0b081a 100644 --- a/openam-mcp-server/README.md +++ b/openam-mcp-server/README.md @@ -16,6 +16,9 @@ export OPENAM_ADMIN_USERNAME=amadmin export OPENAM_ADMIN_PASSWORD=passw0rd ``` +The server works with OpenAM's default HttpOnly session cookies (`com.sun.identity.cookie.httponly=true`): it takes the SSO token from the session cookie when `/json/authenticate` does not return `tokenId` in the body, so `org.openidentityplatform.openam.httponly.allowTokenInBody` does not need to be enabled. +If the OpenAM session cookie is not named `iPlanetDirectoryPro` (`com.iplanet.am.cookie.name`), set `OPENAM_TOKEN_HEADER` to that name. + Clone and run from source: ```bash diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptor.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptor.java index 8d3813bcb2..4d7acfd418 100644 --- a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptor.java +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptor.java @@ -25,9 +25,12 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import org.springframework.web.client.RestClient; import org.springframework.web.servlet.HandlerInterceptor; @@ -118,24 +121,57 @@ long tokenValidSeconds(String tokenId) { } String getUserNamePasswordToken() { - Map tokenResponse = openAMRestClient.post().uri("/json/authenticate") + ResponseEntity> tokenResponse = openAMRestClient.post().uri("/json/authenticate") .header("X-OpenAM-Username", openAMConfig.username()) .header("X-OpenAM-Password", openAMConfig.password()) .retrieve() - .body(new ParameterizedTypeReference<>() { + .toEntity(new ParameterizedTypeReference<>() { }); - return tokenResponse.get("tokenId"); + return extractSessionToken(tokenResponse); } - private String getTokenIdFromAccessToken(String accessToken) { - Map tokenResponse = openAMRestClient.post() + String getTokenIdFromAccessToken(String accessToken) { + ResponseEntity> tokenResponse = openAMRestClient.post() .uri("/json/authenticate?authIndexType=service&authIndexValue=".concat(openAMConfig.oidcAuthChain())) .header(openAMConfig.oidcAuthHeader(), accessToken) .body("{}") .accept(MediaType.APPLICATION_JSON) .retrieve() - .body(new ParameterizedTypeReference<>() {}); - return tokenResponse.get("tokenId"); + .toEntity(new ParameterizedTypeReference<>() {}); + return extractSessionToken(tokenResponse); + } + + /** + * Takes the SSO token out of a {@code /json/authenticate} response. OpenAM + * returns it as {@code tokenId} in the body only while the session cookie is not + * HttpOnly (or {@code org.openidentityplatform.openam.httponly.allowTokenInBody} + * is set); with HttpOnly on, the default, the token comes only as the session + * cookie, named like {@link OpenAMConfig#tokenHeader()}. Of several such + * cookies the last non-empty one wins, so a clearing (empty) cookie cannot + * replace the token. + */ + String extractSessionToken(ResponseEntity> response) { + Map body = response.getBody(); + if (body != null && StringUtils.hasText(body.get("tokenId"))) { + return body.get("tokenId"); + } + String cookieName = openAMConfig.tokenHeader(); + String token = null; + for (String setCookie : response.getHeaders().getOrEmpty(HttpHeaders.SET_COOKIE)) { + String pair = setCookie.split(";", 2)[0]; + int eq = pair.indexOf('='); + if (eq > 0 && pair.substring(0, eq).trim().equals(cookieName)) { + String value = pair.substring(eq + 1).trim(); + if (!value.isEmpty()) { + token = value; + } + } + } + if (token == null) { + throw new IllegalStateException("OpenAM authentication response carries neither a tokenId " + + "in the body nor a non-empty " + cookieName + " cookie"); + } + return token; } boolean preHandleUsernamePassword(HttpServletRequest request) { diff --git a/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptorTest.java b/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptorTest.java index ddd519b144..07064a60c7 100644 --- a/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptorTest.java +++ b/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptorTest.java @@ -28,8 +28,13 @@ import org.openidentityplatform.openam.mcp.server.config.OpenAMConfig; import org.slf4j.LoggerFactory; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; import java.util.List; @@ -38,6 +43,7 @@ import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.atMost; @@ -48,6 +54,10 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; @ExtendWith(MockitoExtension.class) class AuthInterceptorTest { @@ -336,6 +346,92 @@ void tokenValidSeconds_doesNotLogRawTokenId_whenOpenAMFails() { assertThat(messages).noneMatch(m -> m.contains(tokenId)); } + /** + * With HttpOnly session cookies (the OpenAM default) /json/authenticate does not + * echo the tokenId in the body; the token arrives only as the session cookie. + */ + @Test + void getUserNamePasswordToken_readsSessionCookie_whenBodyHasNoTokenId() { + when(openAMConfig.username()).thenReturn("amadmin"); + when(openAMConfig.password()).thenReturn("passw0rd"); + when(openAMConfig.tokenHeader()).thenReturn("iPlanetDirectoryPro"); + RestClient.Builder builder = RestClient.builder().baseUrl("http://openam.example.org/openam"); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo("http://openam.example.org/openam/json/authenticate")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("X-OpenAM-Username", "amadmin")) + .andRespond(withSuccess("{\"successUrl\":\"/openam/console\",\"realm\":\"/\"}", MediaType.APPLICATION_JSON) + .headers(setCookies( + "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-cookie-token; Path=/; HttpOnly", + "amlbcookie=01; Path=/"))); + + String token = new AuthInterceptor(builder.build(), openAMConfig, tokenCache).getUserNamePasswordToken(); + + assertThat(token).isEqualTo("AQIC5wM2LY4Sfczn-cookie-token"); + server.verify(); + } + + @Test + void getTokenIdFromAccessToken_readsSessionCookie_whenBodyHasNoTokenId() { + when(openAMConfig.oidcAuthChain()).thenReturn("oidc"); + when(openAMConfig.oidcAuthHeader()).thenReturn("oidc_id_token"); + when(openAMConfig.tokenHeader()).thenReturn("iPlanetDirectoryPro"); + RestClient.Builder builder = RestClient.builder().baseUrl("http://openam.example.org/openam"); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo("http://openam.example.org/openam/json/authenticate?authIndexType=service&authIndexValue=oidc")) + .andExpect(header("oidc_id_token", "f3c1a9e0-access-token-value")) + .andRespond(withSuccess("{\"successUrl\":\"/openam/console\",\"realm\":\"/\"}", MediaType.APPLICATION_JSON) + .headers(setCookies("iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-oauth-token; Path=/; HttpOnly"))); + + String token = new AuthInterceptor(builder.build(), openAMConfig, tokenCache) + .getTokenIdFromAccessToken("f3c1a9e0-access-token-value"); + + assertThat(token).isEqualTo("AQIC5wM2LY4Sfczn-oauth-token"); + server.verify(); + } + + @Test + void extractSessionToken_prefersTokenIdInBody() { + ResponseEntity> response = ResponseEntity.ok() + .headers(setCookies("iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-cookie-token; Path=/")) + .body(Map.of("tokenId", "AQIC5wM2LY4Sfczn-body-token")); + + assertThat(interceptor.extractSessionToken(response)).isEqualTo("AQIC5wM2LY4Sfczn-body-token"); + } + + @Test + void extractSessionToken_ignoresClearingCookie() { + when(openAMConfig.tokenHeader()).thenReturn("iPlanetDirectoryPro"); + ResponseEntity> response = ResponseEntity.ok() + .headers(setCookies( + "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-cookie-token; Path=/; HttpOnly", + "iPlanetDirectoryPro=; Expires=Thu, 01-Jan-1970 00:00:10 GMT; Path=/", + "iPlanetDirectoryProExtra=other; Path=/")) + .body(Map.of("successUrl", "/openam/console")); + + assertThat(interceptor.extractSessionToken(response)).isEqualTo("AQIC5wM2LY4Sfczn-cookie-token"); + } + + @Test + void extractSessionToken_failsClearly_whenNoTokenAnywhere() { + when(openAMConfig.tokenHeader()).thenReturn("iPlanetDirectoryPro"); + ResponseEntity> response = ResponseEntity.ok() + .headers(setCookies("iPlanetDirectoryPro=; Path=/")) + .body(Map.of("successUrl", "/openam/console")); + + assertThatThrownBy(() -> interceptor.extractSessionToken(response)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("iPlanetDirectoryPro"); + } + + private static HttpHeaders setCookies(String... cookies) { + HttpHeaders headers = new HttpHeaders(); + for (String cookie : cookies) { + headers.add(HttpHeaders.SET_COOKIE, cookie); + } + return headers; + } + private static List captureLogs(Runnable action) { ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(AuthInterceptor.class); From a00408e7a26ec68635c89ff23cf76b5f0d410c0b Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Mon, 28 Sep 2026 17:54:52 +0300 Subject: [PATCH 4/5] Address review round: HttpOnly-by-default consumers and test gaps - js-sdk: treat a response with successUrl as a completed login; the session travels in the HttpOnly cookie, tokenId is optional. - STS: keep the Set-Cookie headers in ConnectionResult and let AMTokenParser fall back to the last non-empty session cookie (named by AM_SESSION_COOKIE_NAME, URL-decoded when encoded) when the authN body has no tokenId; fixes REST STS validation and SOAP STS agent bootstrap. - Self-registration auto-login: with HttpOnly on, set the session cookie server-side and keep tokenId out of the response unless allowTokenInBody is set; XUI only stores a tokenId it received. - Read com.sun.identity.cookie.httponly with one rule in the shared and IDP discovery CookieUtils: only "false" disables it. - MCP server: URL-decode an encoded session cookie; pin body precedence and last-non-empty-cookie-wins with tests. - CI: run the IDP admin login pipeline with pipefail. - e2e: self-registration with auto-login keeps the new user logged in across a reload, in both cookie modes. --- .github/workflows/build.yml | 2 +- e2e/xui/xui-self-registration.spec.mjs | 190 ++++++++++++++++++ .../asciidoc/reference/chap-config-ref.adoc | 3 + .../saml2/idpdiscovery/CookieUtils.java | 14 +- .../saml2/idpdiscovery/CookieUtilsTest.java | 27 ++- .../mcp/server/security/AuthInterceptor.java | 8 +- .../server/security/AuthInterceptorTest.java | 29 +++ .../config/flows/AutoLoginStage.java | 71 ++++++- .../config/flows/AutoLoginStageTest.java | 105 ++++++++++ .../identity/shared/encode/CookieUtils.java | 15 +- .../shared/encode/CookieUtilsTest.java | 21 ++ .../openam/sts/HttpURLConnectionWrapper.java | 24 ++- .../sts/HttpURLConnectionWrapperFactory.java | 23 ++- .../openam/sts/token/AMTokenParser.java | 9 +- .../openam/sts/token/AMTokenParserImpl.java | 62 +++++- .../validator/AuthenticationHandlerImpl.java | 5 +- ...ficateAuthenticationRequestDispatcher.java | 8 +- .../TokenAuthenticationRequestDispatcher.java | 5 +- .../HttpURLConnectionWrapperFactoryTest.java | 65 ++++++ .../openam/sts/token/AMTokenParserTest.java | 61 +++++- ...onnectAuthenticationRequestDispatcher.java | 6 +- ...eTokenAuthenticationRequestDispatcher.java | 6 +- .../SoapSTSAccessTokenProviderImpl.java | 4 +- ...eTokenAuthenticationRequestDispatcher.java | 6 +- .../openam-ui-js-sdk/src/lib/Login.test.tsx | 52 +++++ openam-ui/openam-ui-js-sdk/src/lib/Login.tsx | 8 +- openam-ui/openam-ui-js-sdk/src/lib/types.ts | 4 +- .../anonymousProcess/SelfRegistrationView.js | 6 +- 28 files changed, 789 insertions(+), 50 deletions(-) create mode 100644 e2e/xui/xui-self-registration.spec.mjs create mode 100644 openam-selfservice/src/test/java/org/forgerock/openam/selfservice/config/flows/AutoLoginStageTest.java create mode 100644 openam-sts/openam-common-sts/src/test/java/org/forgerock/openam/sts/HttpURLConnectionWrapperFactoryTest.java create mode 100644 openam-ui/openam-ui-js-sdk/src/lib/Login.test.tsx diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1af98d65d2..9d17f5ca86 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -188,7 +188,7 @@ jobs: echo "Test IDP authentication" - ADMIN_TOKEN=$(docker exec openam-idp bash -c \ + ADMIN_TOKEN=$(docker exec openam-idp bash -o pipefail -c \ 'curl -sf -D - -o /dev/null \ --request POST \ --header "Content-Type: application/json" \ diff --git a/e2e/xui/xui-self-registration.spec.mjs b/e2e/xui/xui-self-registration.spec.mjs new file mode 100644 index 0000000000..cae73d9e9f --- /dev/null +++ b/e2e/xui/xui-self-registration.spec.mjs @@ -0,0 +1,190 @@ +/* + * 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 - self-registration with auto-login + * + * A user who registers through the XUI with "auto-login" as the registration destination must end up + * logged in, and stay logged in across a page reload, in both session cookie modes: + * - HttpOnly off: the registration response carries the tokenId and the XUI writes the cookie; + * - HttpOnly on (the default): JavaScript cannot write the cookie, so the server sets it on the + * registration response and keeps the tokenId out of the body. + * + * The spec enables self-registration in the top level realm (no captcha, no email verification, + * no security questions) and deletes the user it registers. + */ + +import { test, expect } from "@playwright/test"; +import { OPENAM_BASE, ADMIN_USER, ADMIN_PASS } from "../common/openam-commons.mjs"; + +const SERVICE_URL = `${OPENAM_BASE}/json/realms/root/realm-config/services/selfService`; +const SERVICE_API = "protocol=1.0,resource=1.0"; +const REGISTRATION = { + userRegistrationEnabled: true, + userRegisteredDestination: "auto-login", + userRegistrationCaptchaEnabled: false, + userRegistrationEmailVerificationEnabled: false, + userRegistrationKbaEnabled: false, +}; + +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(); +} + +/** + * Logs in the administrator and returns the SSO token: from the body when the server echoes it, + * otherwise from the last non-empty session cookie (HttpOnly mode). + */ +async function getAdminSession(request, cookieName) { + const resp = await request.post(`${OPENAM_BASE}/json/authenticate`, { + headers: { + "Content-Type": "application/json", + "X-OpenAM-Username": ADMIN_USER, + "X-OpenAM-Password": ADMIN_PASS, + "Accept-API-Version": "resource=2.0, protocol=1.0", + }, + }); + expect(resp.ok(), "administrator authentication should succeed").toBeTruthy(); + const body = await resp.json(); + if (body.tokenId) { + return body.tokenId; + } + const token = resp.headersArray() + .filter((h) => h.name.toLowerCase() === "set-cookie") + .map((h) => h.value.split(";", 1)[0]) + .filter((pair) => pair.startsWith(`${cookieName}=`)) + .map((pair) => pair.substring(cookieName.length + 1)) + .filter((value) => value.length > 0) + .pop(); + expect(token, "administrator session must be in the body or the session cookie").toBeTruthy(); + return token; +} + +/** + * Enables self-registration with auto-login, creating the realm's self-service config if needed. The + * config comes in sections (generalConfig, userRegistration, ...); the service needs its key aliases, + * which a fresh config leaves empty, so the default keystore's self-service keys are filled in. + */ +async function enableAutoLoginRegistration(request, adminHeaders) { + const current = await request.get(SERVICE_URL, { headers: adminHeaders }); + const exists = current.status() !== 404; + const source = exists ? current : await request.post(`${SERVICE_URL}?_action=template`, { headers: adminHeaders }); + expect(source.ok(), `reading the self-service config: ${await source.text()}`).toBeTruthy(); + const config = await source.json(); + delete config._id; + delete config._rev; + delete config._type; + config.generalConfig = { + ...config.generalConfig, + encryptionKeyPairAlias: config.generalConfig?.encryptionKeyPairAlias || "selfserviceenctest", + signingSecretKeyAlias: config.generalConfig?.signingSecretKeyAlias || "selfservicesigntest", + }; + config.userRegistration = { ...config.userRegistration, ...REGISTRATION }; + const saved = exists + ? await request.put(SERVICE_URL, { + headers: { ...adminHeaders, "Content-Type": "application/json" }, data: config }) + : await request.post(`${SERVICE_URL}?_action=create`, { + headers: { ...adminHeaders, "Content-Type": "application/json" }, data: config }); + expect(saved.ok(), `saving the self-service config: ${await saved.text()}`).toBeTruthy(); +} + +/** Resolves the username of the session the browser's (auto-sent) cookie carries. */ +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" }, + }); + return resp.ok() ? (await resp.json()).id : null; +} + +test.describe("OpenAM XUI - self-registration", () => { + test("a user registered with auto-login is logged in and stays logged in after a reload", + async ({ page, context, request }) => { + const info = await getServerInfo(request); + const cookieName = info.cookieName ?? "iPlanetDirectoryPro"; + const httpOnly = info.cookieHttpOnly === true; + console.log(`Server reports cookieName=${cookieName}, cookieHttpOnly=${httpOnly}`); + + const adminHeaders = { + [cookieName]: await getAdminSession(request, cookieName), + "Accept-API-Version": SERVICE_API, + }; + await enableAutoLoginRegistration(request, adminHeaders); + + const username = `selfreg${Date.now()}`; + const password = "Selfreg-Passw0rd"; + try { + // ── 1. Register through the XUI ───────────────────────────────────── + await page.goto(`${OPENAM_BASE}/XUI/#register/`); + await expect(page.locator("#input-username")).toBeVisible({ timeout: 30_000 }); + await page.fill("#input-username", username); + await page.fill("#input-givenName", "Self"); + await page.fill("#input-sn", "Registered"); + // The password validators run asynchronously on keyup, which fill() does not fire, and a + // stale result for a shorter prefix can land last; a final keyup revalidates the whole value + for (const field of ["#input-password", "#input-confirmPassword"]) { + await page.locator(field).pressSequentially(password, { delay: 20 }); + await page.waitForTimeout(500); + await page.locator(field).press("End"); + } + const submit = page.locator("input[type=\"submit\"]"); + await expect(submit, "the form must validate before it can be submitted").toBeEnabled(); + + const registered = page.waitForResponse((resp) => + resp.url().includes("selfservice/userRegistration") + && resp.url().includes("_action=submitRequirements") + && resp.status() === 200, { timeout: 30_000 }); + await submit.click(); + const registration = await (await registered).json(); + + // ── 2. The session travels as the server intends in this mode ─────── + expect(registration.type, "registration must end in the auto-login stage").toBe("autoLoginStage"); + expect(registration.tag).toBe("end"); + if (httpOnly) { + expect(registration.additions?.tokenId, + "the registration response must not expose the session in HttpOnly mode").toBeFalsy(); + } else { + expect(registration.additions?.tokenId, "the XUI needs the tokenId to write the cookie") + .toBeTruthy(); + } + + // ── 3. The browser holds the session cookie with the server's HttpOnly flag ── + await page.waitForURL((url) => !url.hash.startsWith("#register"), { timeout: 30_000 }); + await expect.poll(async () => (await context.cookies()).find((c) => c.name === cookieName), + { message: `session cookie "${cookieName}" must be set`, timeout: 15_000 }).toBeTruthy(); + const session = (await context.cookies()).find((c) => c.name === cookieName); + expect(session.httpOnly, "cookie HttpOnly attribute must match the server mode").toBe(httpOnly); + + // ── 4. The new user is logged in, before and after a reload ───────── + expect(String(await idFromSession(page.request)).toLowerCase()).toBe(username.toLowerCase()); + await page.reload({ waitUntil: "networkidle" }); + expect(page.url(), "reload must not redirect to the login page").not.toContain("#login"); + expect(String(await idFromSession(page.request)).toLowerCase()).toBe(username.toLowerCase()); + } finally { + try { + const deleted = await request.delete(`${OPENAM_BASE}/json/realms/root/users/${username}`, { + headers: { ...adminHeaders, "Accept-API-Version": "protocol=1.0,resource=2.0" }, + }); + console.log(`Deleting ${username}: HTTP ${deleted.status()}`); + } catch (e) { + console.log(`Could not delete ${username}: ${e.message}`); + } + } + }); +}); diff --git a/openam-documentation/openam-doc-source/src/main/asciidoc/reference/chap-config-ref.adoc b/openam-documentation/openam-doc-source/src/main/asciidoc/reference/chap-config-ref.adoc index 9c7df2e3f4..521cfe71a5 100644 --- a/openam-documentation/openam-doc-source/src/main/asciidoc/reference/chap-config-ref.adoc +++ b/openam-documentation/openam-doc-source/src/main/asciidoc/reference/chap-config-ref.adoc @@ -5639,6 +5639,9 @@ When set to `true`, mark cookies as HttpOnly to prevent scripts and third-party + Both the classic UI and the XUI support HttpOnly session cookies. When HttpOnly is enabled, the XUI relies on the automatically sent cookie instead of reading the token from JavaScript, and a successful `/json/authenticate` response delivers the token only through the `Set-Cookie` header (the `tokenId` is, by default, no longer returned in the response body). See `org.openidentityplatform.openam.httponly.allowTokenInBody` to control that behaviour. The OpenID Connect session management OP iframe (`/oauth2/connect/checkSession`) is a known exception: it reads the session cookie from JavaScript to compute the browser state, and so cannot observe the session while HttpOnly is enabled. ++ +Only the value `false` (in any letter case) disables HttpOnly cookies; an empty or any other value leaves them enabled. + + Changes to this property do not take effect until you restart the OpenAM server. diff --git a/openam-federation/openam-idpdiscovery/src/main/java/com/sun/identity/saml2/idpdiscovery/CookieUtils.java b/openam-federation/openam-idpdiscovery/src/main/java/com/sun/identity/saml2/idpdiscovery/CookieUtils.java index 60776a5210..369030e86e 100644 --- a/openam-federation/openam-idpdiscovery/src/main/java/com/sun/identity/saml2/idpdiscovery/CookieUtils.java +++ b/openam-federation/openam-idpdiscovery/src/main/java/com/sun/identity/saml2/idpdiscovery/CookieUtils.java @@ -63,7 +63,7 @@ public class CookieUtils { SystemProperties.get(IDPDiscoveryConstants.AM_COOKIE_SECURE). equalsIgnoreCase("true")); - static boolean cookieHttpOnly = !"false".equalsIgnoreCase( + static boolean cookieHttpOnly = isHttpOnlyEnabled( SystemProperties.get(IDPDiscoveryConstants.AM_COOKIE_HTTPONLY)); static String cookieSameSite = SystemPropertiesManager.get( @@ -120,6 +120,18 @@ public static boolean isCookieHttpOnly() { return cookieHttpOnly; } + /** + * Interprets a value of "com.sun.identity.cookie.httponly" with the same rule as + * {@code com.sun.identity.shared.encode.CookieUtils#isHttpOnlyEnabled(String)}: only + * {@code false} (in any case, surrounding whitespace ignored) turns {@code HttpOnly} off. + * + * @param value the property value, may be {@code null}. + * @return {@code false} only if the value is {@code false}. + */ + static boolean isHttpOnlyEnabled(String value) { + return value == null || !"false".equalsIgnoreCase(value.trim()); + } + /** * Returns property value of "org.openidentityplatform.openam.cookie.samesite" * diff --git a/openam-federation/openam-idpdiscovery/src/test/java/com/sun/identity/saml2/idpdiscovery/CookieUtilsTest.java b/openam-federation/openam-idpdiscovery/src/test/java/com/sun/identity/saml2/idpdiscovery/CookieUtilsTest.java index 6de8d18b69..c4cce92b7a 100644 --- a/openam-federation/openam-idpdiscovery/src/test/java/com/sun/identity/saml2/idpdiscovery/CookieUtilsTest.java +++ b/openam-federation/openam-idpdiscovery/src/test/java/com/sun/identity/saml2/idpdiscovery/CookieUtilsTest.java @@ -33,13 +33,16 @@ import org.apache.tomcat.util.http.Rfc6265CookieProcessor; import org.apache.tomcat.util.http.ServerCookies; import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; /** * Verifies that {@link CookieUtils#isRedirectUrlValid} blocks the open redirect * described in GHSA-2pf8-52jh-5x3m while still allowing legitimate same-origin - * and relative RelayState redirects, and that the preferred-IdP cookie gets - * through the container's RFC 6265 cookie processor on the way out and back in. + * and relative RelayState redirects, that the preferred-IdP cookie gets + * through the container's RFC 6265 cookie processor on the way out and back in, + * and that the HttpOnly property is read with the same rule as the shared + * {@code CookieUtils}. */ public class CookieUtilsTest { @@ -277,4 +280,24 @@ private static Cookie[] sentBackThroughTheContainer(Cookie cookie) { } return cookies; } + + @DataProvider + public Object[][] httpOnlyValues() { + return new Object[][] { + {null, true}, + {"", true}, + {"true", true}, + {"TRUE", true}, + {"yes", true}, + {"1", true}, + {"false", false}, + {"FALSE", false}, + {" false ", false}, + }; + } + + @Test(dataProvider = "httpOnlyValues") + public void onlyFalseTurnsHttpOnlyOff(String value, boolean expected) { + assertEquals(CookieUtils.isHttpOnlyEnabled(value), expected); + } } diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptor.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptor.java index 4d7acfd418..4c5a6ce560 100644 --- a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptor.java +++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptor.java @@ -35,6 +35,7 @@ import org.springframework.web.servlet.HandlerInterceptor; import java.io.IOException; +import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -148,7 +149,7 @@ String getTokenIdFromAccessToken(String accessToken) { * is set); with HttpOnly on, the default, the token comes only as the session * cookie, named like {@link OpenAMConfig#tokenHeader()}. Of several such * cookies the last non-empty one wins, so a clearing (empty) cookie cannot - * replace the token. + * replace the token. A URL-encoded cookie value is decoded. */ String extractSessionToken(ResponseEntity> response) { Map body = response.getBody(); @@ -162,6 +163,11 @@ String extractSessionToken(ResponseEntity> response) { int eq = pair.indexOf('='); if (eq > 0 && pair.substring(0, eq).trim().equals(cookieName)) { String value = pair.substring(eq + 1).trim(); + if (value.indexOf('%') >= 0) { + // com.iplanet.am.cookie.encode=true URL-encodes the cookie value; a raw session + // id never contains '%', so only an encoded value is decoded + value = URLDecoder.decode(value, StandardCharsets.UTF_8); + } if (!value.isEmpty()) { token = value; } diff --git a/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptorTest.java b/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptorTest.java index 07064a60c7..e5eec63f51 100644 --- a/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptorTest.java +++ b/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptorTest.java @@ -48,6 +48,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.atMost; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; @@ -392,6 +393,9 @@ void getTokenIdFromAccessToken_readsSessionCookie_whenBodyHasNoTokenId() { @Test void extractSessionToken_prefersTokenIdInBody() { + // lenient: the body wins, so a correct implementation never reads the cookie name; the stub + // is there to let a cookie-first implementation find the cookie and fail this test + lenient().when(openAMConfig.tokenHeader()).thenReturn("iPlanetDirectoryPro"); ResponseEntity> response = ResponseEntity.ok() .headers(setCookies("iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-cookie-token; Path=/")) .body(Map.of("tokenId", "AQIC5wM2LY4Sfczn-body-token")); @@ -412,6 +416,31 @@ void extractSessionToken_ignoresClearingCookie() { assertThat(interceptor.extractSessionToken(response)).isEqualTo("AQIC5wM2LY4Sfczn-cookie-token"); } + @Test + void extractSessionToken_lastNonEmptyCookieWins() { + when(openAMConfig.tokenHeader()).thenReturn("iPlanetDirectoryPro"); + ResponseEntity> response = ResponseEntity.ok() + .headers(setCookies( + "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-old-token; Path=/", + "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-new-token; Path=/; HttpOnly", + "iPlanetDirectoryPro=; Expires=Thu, 01-Jan-1970 00:00:10 GMT; Path=/")) + .body(Map.of("successUrl", "/openam/console")); + + assertThat(interceptor.extractSessionToken(response)).isEqualTo("AQIC5wM2LY4Sfczn-new-token"); + } + + @Test + void extractSessionToken_decodesUrlEncodedCookie() { + // com.iplanet.am.cookie.encode=true with c66Encode=false: the session id keeps its "=@#", + // which the cookie carries URL-encoded while the body would carry it raw + when(openAMConfig.tokenHeader()).thenReturn("iPlanetDirectoryPro"); + ResponseEntity> response = ResponseEntity.ok() + .headers(setCookies("iPlanetDirectoryPro=AQIC5wM2LY4Sfczn%3D%40AAJTSQACMDE%23; Path=/; HttpOnly")) + .body(Map.of("successUrl", "/openam/console")); + + assertThat(interceptor.extractSessionToken(response)).isEqualTo("AQIC5wM2LY4Sfczn=@AAJTSQACMDE#"); + } + @Test void extractSessionToken_failsClearly_whenNoTokenAnywhere() { when(openAMConfig.tokenHeader()).thenReturn("iPlanetDirectoryPro"); diff --git a/openam-selfservice/src/main/java/org/forgerock/openam/selfservice/config/flows/AutoLoginStage.java b/openam-selfservice/src/main/java/org/forgerock/openam/selfservice/config/flows/AutoLoginStage.java index 7ee0db7bd7..2ad41f50e0 100644 --- a/openam-selfservice/src/main/java/org/forgerock/openam/selfservice/config/flows/AutoLoginStage.java +++ b/openam-selfservice/src/main/java/org/forgerock/openam/selfservice/config/flows/AutoLoginStage.java @@ -12,32 +12,49 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. + * Portions copyright 2026 3A Systems, LLC. */ package org.forgerock.openam.selfservice.config.flows; import static org.forgerock.selfservice.stages.CommonStateFields.USER_FIELD; +import java.util.Collections; +import java.util.Map; +import java.util.Set; + import javax.security.auth.callback.Callback; import javax.security.auth.callback.NameCallback; import javax.security.auth.callback.PasswordCallback; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + import org.forgerock.json.JsonValue; import org.forgerock.json.resource.ResourceException; import org.forgerock.selfservice.core.ProcessContext; import org.forgerock.selfservice.core.ProgressStage; import org.forgerock.selfservice.core.StageResponse; import org.forgerock.selfservice.core.util.RequirementsBuilder; +import org.forgerock.services.context.AttributesContext; +import org.forgerock.services.context.Context; import org.forgerock.util.Reject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.sun.identity.authentication.AuthContext; import com.sun.identity.authentication.AuthContext.Status; +import com.sun.identity.authentication.client.AuthClientUtils; +import com.sun.identity.shared.encode.CookieUtils; /** * Auto logic stage attempts to authenticate the registered * user and submits the SSO token to the success additions. + *

+ * With an HttpOnly session cookie the browser cannot set the cookie from the SSO token, so the stage sets it + * on the response itself, as {@code /json/authenticate} does, and leaves the SSO token out of the success + * additions unless {@code org.openidentityplatform.openam.httponly.allowTokenInBody} is set. * * @since 13.5.0 */ @@ -45,6 +62,23 @@ final class AutoLoginStage implements ProgressStage { private static final Logger logger = LoggerFactory.getLogger(AutoLoginStage.class); + /** + * Sets the session cookie carrying an SSO token on the response to the request. + */ + interface SessionCookieSetter { + void setSessionCookie(Context requestContext, String ssoToken); + } + + private final SessionCookieSetter sessionCookieSetter; + + AutoLoginStage() { + this(AutoLoginStage::addSessionCookie); + } + + AutoLoginStage(SessionCookieSetter sessionCookieSetter) { + this.sessionCookieSetter = sessionCookieSetter; + } + @Override public JsonValue gatherInitialRequirements(ProcessContext context, AutoLoginStageConfig config) throws ResourceException { @@ -91,10 +125,45 @@ private void putAuthTokenInSuccessAdditions(ProcessContext context, String ssoToken = authContext.getSSOToken().getTokenID().toString(); String gotoUrl = authContext.getSuccessURL(); - context.putSuccessAddition("tokenId", ssoToken); + putSessionInSuccessAdditions(context, ssoToken, gotoUrl); + } + + void putSessionInSuccessAdditions(ProcessContext context, String ssoToken, String gotoUrl) { + if (CookieUtils.isCookieHttpOnly()) { + sessionCookieSetter.setSessionCookie(context.getRequestContext(), ssoToken); + if (CookieUtils.isHttpOnlyAllowTokenInBody()) { + context.putSuccessAddition("tokenId", ssoToken); + } + } else { + context.putSuccessAddition("tokenId", ssoToken); + } context.putSuccessAddition("successUrl", gotoUrl); } + /* + Sets the session cookie the way CoreServicesWrapper#setAuthCookie does for /json/authenticate: one cookie per + configured cookie domain matching the request, on the servlet response the request came in with. + */ + private static void addSessionCookie(Context requestContext, String ssoToken) { + Map attributes = requestContext.asContext(AttributesContext.class).getAttributes(); + HttpServletRequest request = (HttpServletRequest) attributes.get(HttpServletRequest.class.getName()); + HttpServletResponse response = (HttpServletResponse) attributes.get(HttpServletResponse.class.getName()); + if (request == null || response == null) { + throw new IllegalStateException("No HTTP request and response to set the session cookie on"); + } + Set domains = AuthClientUtils.getCookieDomainsForRequest(request); + if (domains.isEmpty()) { + domains = Collections.singleton(null); + } + for (String domain : domains) { + Cookie cookie = AuthClientUtils.createCookie(ssoToken, domain); + if (CookieUtils.isCookieSecure()) { + cookie.setSecure(true); + } + CookieUtils.addCookieToResponse(response, cookie); + } + } + private void handleCallbacks(Callback[] callbacks, JsonValue user) throws AutoLoginException { for (Callback callback : callbacks) { if (callback instanceof NameCallback) { diff --git a/openam-selfservice/src/test/java/org/forgerock/openam/selfservice/config/flows/AutoLoginStageTest.java b/openam-selfservice/src/test/java/org/forgerock/openam/selfservice/config/flows/AutoLoginStageTest.java new file mode 100644 index 0000000000..069a9f34f6 --- /dev/null +++ b/openam-selfservice/src/test/java/org/forgerock/openam/selfservice/config/flows/AutoLoginStageTest.java @@ -0,0 +1,105 @@ +/* + * 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. + */ +package org.forgerock.openam.selfservice.config.flows; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; + +import org.forgerock.selfservice.core.ProcessContext; +import org.forgerock.services.context.Context; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import com.sun.identity.shared.encode.CookieUtils; + +public class AutoLoginStageTest { + + private static final String SSO_TOKEN = "AQIC5wM2LY4Sfczn-registered-user"; + private static final String GOTO_URL = "/openam/console"; + + private boolean savedHttpOnly; + private boolean savedAllowTokenInBody; + private AutoLoginStage.SessionCookieSetter cookieSetter; + private Context requestContext; + private ProcessContext processContext; + private AutoLoginStage stage; + + @BeforeMethod + public void setUp() throws Exception { + savedHttpOnly = CookieUtils.isCookieHttpOnly(); + savedAllowTokenInBody = CookieUtils.isHttpOnlyAllowTokenInBody(); + cookieSetter = mock(AutoLoginStage.SessionCookieSetter.class); + requestContext = mock(Context.class); + processContext = mock(ProcessContext.class); + when(processContext.getRequestContext()).thenReturn(requestContext); + stage = new AutoLoginStage(cookieSetter); + } + + @AfterMethod + public void tearDown() throws Exception { + setCookieUtilsFlag("cookieHttpOnly", savedHttpOnly); + setCookieUtilsFlag("httpOnlyAllowTokenInBody", savedAllowTokenInBody); + } + + @Test + public void httpOnlySetsTheSessionCookieAndKeepsTheTokenOutOfTheResponse() throws Exception { + setCookieUtilsFlag("cookieHttpOnly", true); + setCookieUtilsFlag("httpOnlyAllowTokenInBody", false); + + stage.putSessionInSuccessAdditions(processContext, SSO_TOKEN, GOTO_URL); + + verify(cookieSetter).setSessionCookie(requestContext, SSO_TOKEN); + verify(processContext, never()).putSuccessAddition(eq("tokenId"), any()); + verify(processContext).putSuccessAddition("successUrl", GOTO_URL); + } + + @Test + public void httpOnlyWithAllowTokenInBodyAlsoReturnsTheToken() throws Exception { + setCookieUtilsFlag("cookieHttpOnly", true); + setCookieUtilsFlag("httpOnlyAllowTokenInBody", true); + + stage.putSessionInSuccessAdditions(processContext, SSO_TOKEN, GOTO_URL); + + verify(cookieSetter).setSessionCookie(requestContext, SSO_TOKEN); + verify(processContext).putSuccessAddition("tokenId", SSO_TOKEN); + verify(processContext).putSuccessAddition("successUrl", GOTO_URL); + } + + @Test + public void withoutHttpOnlyTheBrowserSetsTheCookieFromTheToken() throws Exception { + setCookieUtilsFlag("cookieHttpOnly", false); + + stage.putSessionInSuccessAdditions(processContext, SSO_TOKEN, GOTO_URL); + + verify(cookieSetter, never()).setSessionCookie(any(Context.class), anyString()); + verify(processContext).putSuccessAddition("tokenId", SSO_TOKEN); + verify(processContext).putSuccessAddition("successUrl", GOTO_URL); + } + + private static void setCookieUtilsFlag(String name, boolean value) throws Exception { + Field field = CookieUtils.class.getDeclaredField(name); + field.setAccessible(true); + field.setBoolean(null, value); + } +} 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 286d4c0e8a..f2140d7b17 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 @@ -64,7 +64,7 @@ public class CookieUtils { equalsIgnoreCase("true")); static boolean cookieHttpOnly = - SystemPropertiesManager.getAsBoolean(Constants.AM_COOKIE_HTTPONLY, true); + isHttpOnlyEnabled(SystemPropertiesManager.get(Constants.AM_COOKIE_HTTPONLY)); static boolean httpOnlyAllowTokenInBody = SystemPropertiesManager.getAsBoolean(Constants.AM_COOKIE_HTTPONLY_ALLOW_TOKEN_IN_BODY, false); @@ -166,6 +166,19 @@ public static boolean isCookieHttpOnly() { return cookieHttpOnly; } + /** + * Interprets a value of "com.sun.identity.cookie.httponly". Only {@code false} (in any case, + * surrounding whitespace ignored) turns {@code HttpOnly} off; an unset, empty or unrecognised + * value keeps it on. The IDP discovery {@code CookieUtils} applies the same rule, so the + * {@code _saml_idp} and session cookies agree on every value. + * + * @param value the property value, may be {@code null}. + * @return {@code false} only if the value is {@code false}. + */ + public static boolean isHttpOnlyEnabled(String value) { + return value == null || !"false".equalsIgnoreCase(value.trim()); + } + /** * Returns whether the SSO token id may be returned in the authenticate response body while the * session cookie is {@code HttpOnly}. diff --git a/openam-shared/src/test/java/com/sun/identity/shared/encode/CookieUtilsTest.java b/openam-shared/src/test/java/com/sun/identity/shared/encode/CookieUtilsTest.java index c63023b3a7..b7bd34c5c7 100644 --- a/openam-shared/src/test/java/com/sun/identity/shared/encode/CookieUtilsTest.java +++ b/openam-shared/src/test/java/com/sun/identity/shared/encode/CookieUtilsTest.java @@ -27,6 +27,7 @@ import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import com.sun.identity.shared.Constants; @@ -134,4 +135,24 @@ public void addCookieToResponseWritesTheHeaderItselfWhenSameSiteIsConfigured() { eq("iPlanetDirectoryPro=AQIC;path=/;secure;httponly;SameSite=Strict")); }); } + + @DataProvider + public Object[][] httpOnlyValues() { + return new Object[][] { + {null, true}, + {"", true}, + {"true", true}, + {"TRUE", true}, + {"yes", true}, + {"1", true}, + {"false", false}, + {"FALSE", false}, + {" false ", false}, + }; + } + + @Test(dataProvider = "httpOnlyValues") + public void onlyFalseTurnsHttpOnlyOff(String value, boolean expected) { + assertEquals(CookieUtils.isHttpOnlyEnabled(value), expected); + } } diff --git a/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/HttpURLConnectionWrapper.java b/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/HttpURLConnectionWrapper.java index e0671eda8f..7c1e8ba528 100644 --- a/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/HttpURLConnectionWrapper.java +++ b/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/HttpURLConnectionWrapper.java @@ -12,12 +12,15 @@ * information: "Portions Copyrighted [year] [name of copyright owner]". * * Copyright 2014-2015 ForgeRock AS. + * Portions Copyrighted 2026 3A Systems, LLC. */ package org.forgerock.openam.sts; import java.io.IOException; import java.net.ProtocolException; +import java.util.Collections; +import java.util.List; import java.util.Map; /** @@ -30,15 +33,27 @@ public interface HttpURLConnectionWrapper { /** * Class which encapsulates the result of the HttpURLConnection invocation. The status code will specify the Http response * code, and the result will contain the contents of the response, from either the input stream, or the error stream, - * depending upon whether the statusCode was expected. + * depending upon whether the statusCode was expected. The Set-Cookie response headers are kept as well, as the + * OpenAM session of a rest authN response with an HttpOnly session cookie travels only in that cookie. */ public static class ConnectionResult { private int statusCode; private String result; + private List setCookieHeaders; ConnectionResult(int statusCode, String result) { + this(statusCode, result, Collections.emptyList()); + } + + /** + * @param statusCode the Http response code. + * @param result the contents of the response. + * @param setCookieHeaders the values of the Set-Cookie response headers, in the order received. + */ + public ConnectionResult(int statusCode, String result, List setCookieHeaders) { this.statusCode = statusCode; this.result = result; + this.setCookieHeaders = Collections.unmodifiableList(setCookieHeaders); } public int getStatusCode() { @@ -48,6 +63,13 @@ public int getStatusCode() { public String getResult() { return result; } + + /** + * @return the values of the Set-Cookie response headers, in the order received. Never null. + */ + public List getSetCookieHeaders() { + return setCookieHeaders; + } } /** diff --git a/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/HttpURLConnectionWrapperFactory.java b/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/HttpURLConnectionWrapperFactory.java index 28ac69097b..028d5321b8 100644 --- a/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/HttpURLConnectionWrapperFactory.java +++ b/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/HttpURLConnectionWrapperFactory.java @@ -12,7 +12,7 @@ * information: "Portions Copyrighted [year] [name of copyright owner]". * * Copyright 2014-2015 ForgeRock AS. All rights reserved. - * Portions Copyrighted 2025 3A Systems, LLC. + * Portions Copyrighted 2025-2026 3A Systems, LLC. */ package org.forgerock.openam.sts; @@ -28,6 +28,8 @@ import java.net.HttpURLConnection; import java.net.ProtocolException; import java.net.URL; +import java.util.ArrayList; +import java.util.List; import java.util.Map; /** @@ -117,12 +119,27 @@ error stream should be obtained, drained, and then closed. This occurs when getE throw e; } if (responseCode == expectedResponseCode) { - return new ConnectionResult(responseCode, getSuccessMessage()); + return new ConnectionResult(responseCode, getSuccessMessage(), getSetCookieHeaders()); } else { - return new ConnectionResult(responseCode, getErrorMessage()); + return new ConnectionResult(responseCode, getErrorMessage(), getSetCookieHeaders()); } } + /* + Reads the headers by index rather than through getHeaderFields(), which returns the values of a repeated header + in reverse order. Note that HttpURLConnection hides HttpOnly cookies from these calls when a JVM-wide + CookieHandler is installed; OpenAM installs none. + */ + private List getSetCookieHeaders() { + List setCookieHeaders = new ArrayList<>(); + for (int i = 0; httpURLConnection.getHeaderField(i) != null; i++) { + if ("Set-Cookie".equalsIgnoreCase(httpURLConnection.getHeaderFieldKey(i))) { + setCookieHeaders.add(httpURLConnection.getHeaderField(i)); + } + } + return setCookieHeaders; + } + private String getSuccessMessage() throws IOException { return readInputStream(httpURLConnection.getInputStream()); } diff --git a/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/AMTokenParser.java b/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/AMTokenParser.java index 136e49545e..a26b282980 100644 --- a/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/AMTokenParser.java +++ b/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/AMTokenParser.java @@ -12,10 +12,12 @@ * information: "Portions Copyrighted [year] [name of copyright owner]". * * Copyright 2013-2014 ForgeRock AS. All rights reserved. + * Portions Copyrighted 2026 3A Systems, LLC. */ package org.forgerock.openam.sts.token; +import org.forgerock.openam.sts.HttpURLConnectionWrapper; import org.forgerock.openam.sts.TokenValidationException; /** @@ -24,10 +26,13 @@ */ public interface AMTokenParser { /** + * Takes the session id from the {@code tokenId} of the response body or, when the body has none because the + * session cookie is HttpOnly, from the last non-empty session cookie the response sets. * - * @param authNResponse The value returned by a successful invocation of the rest authN + * @param authNResponse The result of a successful invocation of the rest authN * @return Returns the string corresponding to the OpenAM session. * @throws TokenValidationException Thrown when authentication unsuccessful or the OpenAM session id could not be pulled from the response. */ - String getSessionFromAuthNResponse(String authNResponse) throws TokenValidationException; + String getSessionFromAuthNResponse(HttpURLConnectionWrapper.ConnectionResult authNResponse) + throws TokenValidationException; } diff --git a/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/AMTokenParserImpl.java b/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/AMTokenParserImpl.java index 2e0946b0bc..253da65e25 100644 --- a/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/AMTokenParserImpl.java +++ b/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/AMTokenParserImpl.java @@ -12,16 +12,22 @@ * information: "Portions Copyrighted [year] [name of copyright owner]". * * Copyright 2013-2015 ForgeRock AS. - * Portions Copyrighted 2025 3A Systems, LLC. + * Portions Copyrighted 2025-2026 3A Systems, LLC. */ package org.forgerock.openam.sts.token; import jakarta.inject.Inject; +import jakarta.inject.Named; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; import org.forgerock.json.JsonException; import org.forgerock.json.JsonValue; import org.forgerock.json.resource.ResourceException; +import org.forgerock.openam.sts.AMSTSConstants; +import org.forgerock.openam.sts.HttpURLConnectionWrapper; import org.forgerock.openam.sts.TokenValidationException; import org.forgerock.openam.utils.JsonValueBuilder; import org.slf4j.Logger; @@ -33,27 +39,67 @@ public class AMTokenParserImpl implements AMTokenParser { private static final String TOKEN_ID = "tokenId"; private final Logger logger; + private final String amSessionCookieName; @Inject - AMTokenParserImpl(Logger logger) { + AMTokenParserImpl(Logger logger, @Named(AMSTSConstants.AM_SESSION_COOKIE_NAME) String amSessionCookieName) { this.logger = logger; + this.amSessionCookieName = amSessionCookieName; } @Override - public String getSessionFromAuthNResponse(String authNResponse) throws TokenValidationException { + public String getSessionFromAuthNResponse(HttpURLConnectionWrapper.ConnectionResult authNResponse) + throws TokenValidationException { JsonValue responseJson; try { - responseJson = JsonValueBuilder.toJsonValue(authNResponse); + responseJson = JsonValueBuilder.toJsonValue(authNResponse.getResult()); } catch (JsonException e) { String message = "Exception caught getting the text of the json authN response: " + e; throw new TokenValidationException(ResourceException.INTERNAL_ERROR, message, e); } JsonValue sessionIdJsonValue = responseJson.get(TOKEN_ID); - if (!sessionIdJsonValue.isString()) { - String message = "REST authN response does not contain " + TOKEN_ID + " string entry. The obtained entry: " - + sessionIdJsonValue.toString() + "; The response: " + responseJson.toString(); + if (sessionIdJsonValue.isString()) { + return sessionIdJsonValue.asString(); + } + /* + With an HttpOnly session cookie (the default) OpenAM leaves the tokenId out of the body unless + org.openidentityplatform.openam.httponly.allowTokenInBody is set, and delivers the session only in the cookie. + */ + String sessionId = getSessionFromCookies(authNResponse); + if (sessionId == null) { + String message = "REST authN response contains neither a " + TOKEN_ID + " string entry nor a non-empty " + + amSessionCookieName + " cookie. The obtained entry: " + sessionIdJsonValue.toString() + + "; The response: " + responseJson.toString(); throw new TokenValidationException(ResourceException.INTERNAL_ERROR, message); } - return sessionIdJsonValue.asString(); + return sessionId; + } + + /* + Of several session cookies the last non-empty one wins, so a clearing (empty) cookie cannot replace the session. + A URL-encoded value (com.iplanet.am.cookie.encode=true) is decoded; a raw session id never contains '%'. + */ + private String getSessionFromCookies(HttpURLConnectionWrapper.ConnectionResult authNResponse) + throws TokenValidationException { + String sessionId = null; + for (String setCookie : authNResponse.getSetCookieHeaders()) { + String pair = setCookie.split(";", 2)[0]; + int eq = pair.indexOf('='); + if (eq > 0 && pair.substring(0, eq).trim().equals(amSessionCookieName)) { + String value = pair.substring(eq + 1).trim(); + if (value.indexOf('%') >= 0) { + try { + value = URLDecoder.decode(value, "UTF-8"); + } catch (UnsupportedEncodingException | IllegalArgumentException e) { + throw new TokenValidationException(ResourceException.INTERNAL_ERROR, + "Could not decode the " + amSessionCookieName + " cookie of the REST authN response", e); + } + } + if (!value.isEmpty()) { + sessionId = value; + } + } + } + return sessionId; } } diff --git a/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/validator/AuthenticationHandlerImpl.java b/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/validator/AuthenticationHandlerImpl.java index c4b4181ec9..1cae4195e8 100644 --- a/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/validator/AuthenticationHandlerImpl.java +++ b/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/validator/AuthenticationHandlerImpl.java @@ -12,11 +12,12 @@ * information: "Portions Copyrighted [year] [name of copyright owner]". * * Copyright 2013-2015 ForgeRock AS. - * Portions Copyrighted 2025 3A Systems, LLC. + * Portions Copyrighted 2025-2026 3A Systems, LLC. */ package org.forgerock.openam.sts.token.validator; +import org.forgerock.openam.sts.HttpURLConnectionWrapper; import org.forgerock.openam.sts.TokenTypeId; import org.forgerock.openam.sts.config.user.AuthTargetMapping; import org.forgerock.openam.sts.TokenValidationException; @@ -57,7 +58,7 @@ public AuthenticationHandlerImpl( public String authenticate(T token, TokenTypeId tokenTypeId) throws TokenValidationException { final URL authUrl = authenticationUrlProvider.authenticationUrl(tokenTypeId); logger.debug("STSAuthenticationHandler: The authUri: " + authUrl.toString()); - final String response = requestDispatcher.dispatch(authUrl, authTargetMapping.getAuthTargetMapping(tokenTypeId), token); + final HttpURLConnectionWrapper.ConnectionResult response = requestDispatcher.dispatch(authUrl, authTargetMapping.getAuthTargetMapping(tokenTypeId), token); return tokenParser.getSessionFromAuthNResponse(response); } } diff --git a/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/validator/disp/CertificateAuthenticationRequestDispatcher.java b/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/validator/disp/CertificateAuthenticationRequestDispatcher.java index 156bd28554..11e039f540 100644 --- a/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/validator/disp/CertificateAuthenticationRequestDispatcher.java +++ b/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/validator/disp/CertificateAuthenticationRequestDispatcher.java @@ -12,7 +12,7 @@ * information: "Portions Copyrighted [year] [name of copyright owner]". * * Copyright 2013-2015 ForgeRock AS. - * Portions Copyrighted 2025 3A Systems, LLC. + * Portions Copyrighted 2025-2026 3A Systems, LLC. */ package org.forgerock.openam.sts.token.validator.disp; @@ -56,7 +56,7 @@ public CertificateAuthenticationRequestDispatcher(@Named(AMSTSConstants.CREST_VE } @Override - public String dispatch(URL url, AuthTargetMapping.AuthTarget target, X509Certificate[] certificates) throws TokenValidationException { + public HttpURLConnectionWrapper.ConnectionResult dispatch(URL url, AuthTargetMapping.AuthTarget target, X509Certificate[] certificates) throws TokenValidationException { /* The common practice in the cxf-sts and wss4j is just to use the first element in the array, as this is the leaf cert, and all others correspond to CAs, which will be in the targeted destination's trust store. And this dispatcher @@ -72,7 +72,7 @@ public String dispatch(URL url, AuthTargetMapping.AuthTarget target, X509Certifi return postCertInHeader(url, certificates[0], target); } - private String postCertInHeader(URL url, X509Certificate certificate, AuthTargetMapping.AuthTarget target) + private HttpURLConnectionWrapper.ConnectionResult postCertInHeader(URL url, X509Certificate certificate, AuthTargetMapping.AuthTarget target) throws TokenValidationException { final String base64Certificate; @@ -111,7 +111,7 @@ private String postCertInHeader(URL url, X509Certificate certificate, AuthTarget throw new TokenValidationException(responseCode, "Non-200 response from posting x509 token " + "to rest authN: " + connectionResult.getResult()); } else { - return connectionResult.getResult(); + return connectionResult; } } catch (IOException e) { throw new TokenValidationException(org.forgerock.json.resource.ResourceException.INTERNAL_ERROR, diff --git a/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/validator/disp/TokenAuthenticationRequestDispatcher.java b/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/validator/disp/TokenAuthenticationRequestDispatcher.java index 18f2e35e3b..f713f4ba13 100644 --- a/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/validator/disp/TokenAuthenticationRequestDispatcher.java +++ b/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/token/validator/disp/TokenAuthenticationRequestDispatcher.java @@ -18,6 +18,7 @@ package org.forgerock.openam.sts.token.validator.disp; +import org.forgerock.openam.sts.HttpURLConnectionWrapper; import org.forgerock.openam.sts.config.user.AuthTargetMapping; import org.forgerock.openam.sts.TokenValidationException; @@ -34,9 +35,9 @@ public interface TokenAuthenticationRequestDispatcher { * parameters for the targeted authN module. This reference can be null. * @param token The token which will be dispatched to the OpenAM authN context. * @return The state corresponding to a successful invocation. Produced by the OpenAM rest-authN context. - * Includes the OpenAM session id. + * Includes the OpenAM session id, in the body or, with an HttpOnly session cookie, in a Set-Cookie header. * @throws org.forgerock.openam.sts.TokenValidationException if an error occurred in the invocation, or if a non-200 * result is returned. */ - String dispatch(URL url, AuthTargetMapping.AuthTarget authTarget, T token) throws TokenValidationException; + HttpURLConnectionWrapper.ConnectionResult dispatch(URL url, AuthTargetMapping.AuthTarget authTarget, T token) throws TokenValidationException; } diff --git a/openam-sts/openam-common-sts/src/test/java/org/forgerock/openam/sts/HttpURLConnectionWrapperFactoryTest.java b/openam-sts/openam-common-sts/src/test/java/org/forgerock/openam/sts/HttpURLConnectionWrapperFactoryTest.java new file mode 100644 index 0000000000..d2e1a10080 --- /dev/null +++ b/openam-sts/openam-common-sts/src/test/java/org/forgerock/openam/sts/HttpURLConnectionWrapperFactoryTest.java @@ -0,0 +1,65 @@ +/* + * 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. + */ + +package org.forgerock.openam.sts; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; + +import java.io.ByteArrayInputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import org.testng.annotations.Test; + +public class HttpURLConnectionWrapperFactoryTest { + + @Test + public void keepsSetCookieHeadersInWireOrder() throws Exception { + String[][] headers = { + {null, "HTTP/1.1 200 OK"}, + {"Set-Cookie", "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-old-token; Path=/"}, + {"Content-Type", "application/json"}, + {"set-cookie", "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-new-token; Path=/; HttpOnly"}, + {"Set-Cookie", "amlbcookie=01; Path=/"}, + }; + HttpURLConnection connection = mock(HttpURLConnection.class); + when(connection.getResponseCode()).thenReturn(HttpURLConnection.HTTP_OK); + when(connection.getInputStream()).thenReturn( + new ByteArrayInputStream("{\"successUrl\":\"/openam/console\"}".getBytes(StandardCharsets.UTF_8))); + for (int i = 0; i < headers.length; i++) { + when(connection.getHeaderFieldKey(i)).thenReturn(headers[i][0]); + when(connection.getHeaderField(i)).thenReturn(headers[i][1]); + } + HttpURLConnectionFactory connectionFactory = mock(HttpURLConnectionFactory.class); + when(connectionFactory.getHttpURLConnection(any(URL.class))).thenReturn(connection); + + HttpURLConnectionWrapper.ConnectionResult result = new HttpURLConnectionWrapperFactory(connectionFactory) + .httpURLConnectionWrapper(new URL("http://openam.example.org/openam/json/authenticate")) + .withoutAuditTransactionIdHeader() + .makeInvocation(); + + assertEquals(result.getResult().trim(), "{\"successUrl\":\"/openam/console\"}"); + assertEquals(result.getSetCookieHeaders(), Arrays.asList( + "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-old-token; Path=/", + "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-new-token; Path=/; HttpOnly", + "amlbcookie=01; Path=/")); + } +} diff --git a/openam-sts/openam-common-sts/src/test/java/org/forgerock/openam/sts/token/AMTokenParserTest.java b/openam-sts/openam-common-sts/src/test/java/org/forgerock/openam/sts/token/AMTokenParserTest.java index 8f81606b3a..4aea4fe9fd 100644 --- a/openam-sts/openam-common-sts/src/test/java/org/forgerock/openam/sts/token/AMTokenParserTest.java +++ b/openam-sts/openam-common-sts/src/test/java/org/forgerock/openam/sts/token/AMTokenParserTest.java @@ -12,29 +12,39 @@ * information: "Portions Copyrighted [year] [name of copyright owner]". * * Copyright 2013-2014 ForgeRock AS. All rights reserved. + * Portions Copyrighted 2026 3A Systems, LLC. */ package org.forgerock.openam.sts.token; import com.google.inject.AbstractModule; import com.google.inject.Guice; +import com.google.inject.name.Names; +import org.forgerock.openam.sts.AMSTSConstants; +import org.forgerock.openam.sts.HttpURLConnectionWrapper; import org.forgerock.openam.sts.TokenValidationException; import org.slf4j.Logger; import org.testng.annotations.BeforeTest; import org.testng.annotations.Test; -import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; import static org.mockito.Mockito.mock; +import static org.testng.Assert.assertEquals; public class AMTokenParserTest { + private static final String BODY_WITH_TOKEN = "{\"tokenId\":\"da_token_id\",\"successUrl\":\"/openam/console\"}"; + private static final String HTTP_ONLY_BODY = "{\"successUrl\":\"/openam/console\",\"realm\":\"/\"}"; + AMTokenParser tokenParser; - String authNResponse = "{\"tokenId\":\"da_token_id\",\"successUrl\":\"/openam/console\"}"; + static class MyModule extends AbstractModule { @Override protected void configure() { bind(AMTokenParser.class).to(AMTokenParserImpl.class); bind(Logger.class).toInstance(mock(Logger.class)); + bindConstant().annotatedWith(Names.named(AMSTSConstants.AM_SESSION_COOKIE_NAME)).to("iPlanetDirectoryPro"); } } @@ -43,8 +53,51 @@ public void initialize() { tokenParser = Guice.createInjector(new MyModule()).getInstance(AMTokenParser.class); } + private static HttpURLConnectionWrapper.ConnectionResult response(String body, String... setCookies) { + return new HttpURLConnectionWrapper.ConnectionResult(200, body, Arrays.asList(setCookies)); + } + + @Test + public void testParse() throws TokenValidationException { + assertEquals(tokenParser.getSessionFromAuthNResponse( + new HttpURLConnectionWrapper.ConnectionResult(200, BODY_WITH_TOKEN, Collections.emptyList())), + "da_token_id"); + } + + @Test + public void takesSessionFromCookieWhenBodyHasNoTokenId() throws TokenValidationException { + assertEquals(tokenParser.getSessionFromAuthNResponse(response(HTTP_ONLY_BODY, + "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-cookie-token; Path=/; HttpOnly")), + "AQIC5wM2LY4Sfczn-cookie-token"); + } + @Test - void testParse() throws TokenValidationException, IOException { - tokenParser.getSessionFromAuthNResponse(authNResponse).equals("da_token_id"); + public void prefersTokenIdInBody() throws TokenValidationException { + assertEquals(tokenParser.getSessionFromAuthNResponse(response(BODY_WITH_TOKEN, + "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-cookie-token; Path=/")), + "da_token_id"); + } + + @Test + public void lastNonEmptySessionCookieWins() throws TokenValidationException { + assertEquals(tokenParser.getSessionFromAuthNResponse(response(HTTP_ONLY_BODY, + "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-old-token; Path=/", + "iPlanetDirectoryProExtra=other; Path=/", + "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-new-token; Path=/; HttpOnly", + "iPlanetDirectoryPro=; Expires=Thu, 01-Jan-1970 00:00:10 GMT; Path=/")), + "AQIC5wM2LY4Sfczn-new-token"); + } + + @Test + public void decodesUrlEncodedSessionCookie() throws TokenValidationException { + assertEquals(tokenParser.getSessionFromAuthNResponse(response(HTTP_ONLY_BODY, + "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn%3D%40AAJTSQACMDE%23; Path=/; HttpOnly")), + "AQIC5wM2LY4Sfczn=@AAJTSQACMDE#"); + } + + @Test(expectedExceptions = TokenValidationException.class) + public void failsWhenNeitherBodyNorCookieCarriesTheSession() throws TokenValidationException { + tokenParser.getSessionFromAuthNResponse(response(HTTP_ONLY_BODY, + "iPlanetDirectoryPro=; Path=/", "amlbcookie=01; Path=/")); } } diff --git a/openam-sts/openam-rest-sts/src/main/java/org/forgerock/openam/sts/rest/token/validator/disp/OpenIdConnectAuthenticationRequestDispatcher.java b/openam-sts/openam-rest-sts/src/main/java/org/forgerock/openam/sts/rest/token/validator/disp/OpenIdConnectAuthenticationRequestDispatcher.java index 1863a474d0..d9c438ebca 100644 --- a/openam-sts/openam-rest-sts/src/main/java/org/forgerock/openam/sts/rest/token/validator/disp/OpenIdConnectAuthenticationRequestDispatcher.java +++ b/openam-sts/openam-rest-sts/src/main/java/org/forgerock/openam/sts/rest/token/validator/disp/OpenIdConnectAuthenticationRequestDispatcher.java @@ -12,7 +12,7 @@ * information: "Portions Copyrighted [year] [name of copyright owner]". * * Copyright 2014-2015 ForgeRock AS. - * Portions Copyrighted 2025 3A Systems LLC. + * Portions Copyrighted 2025-2026 3A Systems LLC. */ package org.forgerock.openam.sts.rest.token.validator.disp; @@ -50,7 +50,7 @@ public class OpenIdConnectAuthenticationRequestDispatcher implements TokenAuthen } @Override - public String dispatch(URL url, AuthTargetMapping.AuthTarget target, OpenIdConnectIdToken token) throws TokenValidationException { + public HttpURLConnectionWrapper.ConnectionResult dispatch(URL url, AuthTargetMapping.AuthTarget target, OpenIdConnectIdToken token) throws TokenValidationException { if (target == null) { throw new TokenValidationException(org.forgerock.json.resource.ResourceException.BAD_REQUEST, "When validatating OIDC tokens, an AuthTarget needs to be configured with a Map containing a String " + @@ -79,7 +79,7 @@ public String dispatch(URL url, AuthTargetMapping.AuthTarget target, OpenIdConne throw new TokenValidationException(responseCode, "Non-200 response from posting OIDC token " + "to rest authN: " + connectionResult.getResult()); } else { - return connectionResult.getResult(); + return connectionResult; } } catch (IOException e) { throw new TokenValidationException(org.forgerock.json.resource.ResourceException.INTERNAL_ERROR, diff --git a/openam-sts/openam-rest-sts/src/main/java/org/forgerock/openam/sts/rest/token/validator/disp/RestUsernameTokenAuthenticationRequestDispatcher.java b/openam-sts/openam-rest-sts/src/main/java/org/forgerock/openam/sts/rest/token/validator/disp/RestUsernameTokenAuthenticationRequestDispatcher.java index 8273a691ba..3d5f0a5e6f 100644 --- a/openam-sts/openam-rest-sts/src/main/java/org/forgerock/openam/sts/rest/token/validator/disp/RestUsernameTokenAuthenticationRequestDispatcher.java +++ b/openam-sts/openam-rest-sts/src/main/java/org/forgerock/openam/sts/rest/token/validator/disp/RestUsernameTokenAuthenticationRequestDispatcher.java @@ -12,7 +12,7 @@ * information: "Portions Copyrighted [year] [name of copyright owner]". * * Copyright 2015 ForgeRock AS. - * Portions Copyrighted 2025 3A Systems LLC. + * Portions Copyrighted 2025-2026 3A Systems LLC. */ package org.forgerock.openam.sts.rest.token.validator.disp; @@ -49,7 +49,7 @@ public class RestUsernameTokenAuthenticationRequestDispatcher implements TokenAu } @Override - public String dispatch(URL url, AuthTargetMapping.AuthTarget target, RestUsernameToken token) throws TokenValidationException { + public HttpURLConnectionWrapper.ConnectionResult dispatch(URL url, AuthTargetMapping.AuthTarget target, RestUsernameToken token) throws TokenValidationException { try { Map headerMap = new HashMap<>(); headerMap.put(AMSTSConstants.CONTENT_TYPE, AMSTSConstants.APPLICATION_JSON); @@ -66,7 +66,7 @@ public String dispatch(URL url, AuthTargetMapping.AuthTarget target, RestUsernam throw new TokenValidationException(responseCode, "Non-200 response from posting Username token " + "to rest authN: " + connectionResult.getResult()); } else { - return connectionResult.getResult(); + return connectionResult; } } catch (IOException e) { throw new TokenValidationException(ResourceException.INTERNAL_ERROR, diff --git a/openam-sts/openam-soap-sts/openam-soap-sts-server/src/main/java/org/forgerock/openam/sts/soap/bootstrap/SoapSTSAccessTokenProviderImpl.java b/openam-sts/openam-soap-sts/openam-soap-sts-server/src/main/java/org/forgerock/openam/sts/soap/bootstrap/SoapSTSAccessTokenProviderImpl.java index a06b25b3f2..8308b62f83 100644 --- a/openam-sts/openam-soap-sts/openam-soap-sts-server/src/main/java/org/forgerock/openam/sts/soap/bootstrap/SoapSTSAccessTokenProviderImpl.java +++ b/openam-sts/openam-soap-sts/openam-soap-sts-server/src/main/java/org/forgerock/openam/sts/soap/bootstrap/SoapSTSAccessTokenProviderImpl.java @@ -12,7 +12,7 @@ * information: "Portions Copyrighted [year] [name of copyright owner]". * * Copyright 2015-2016 ForgeRock AS. - * Portions Copyrighted 2025 3A Systems LLC. + * Portions Copyrighted 2025-2026 3A Systems LLC. */ package org.forgerock.openam.sts.soap.bootstrap; @@ -140,7 +140,7 @@ public String getAccessToken() throws ResourceException { } else { try { if (StringUtils.isEmpty(accessTokenRef.get())) { - accessTokenRef.set(amTokenParser.getSessionFromAuthNResponse(connectionResult.getResult())); + accessTokenRef.set(amTokenParser.getSessionFromAuthNResponse(connectionResult)); } return accessTokenRef.get(); } catch (TokenValidationException e) { diff --git a/openam-sts/openam-soap-sts/openam-soap-sts-server/src/main/java/org/forgerock/openam/sts/soap/token/validator/disp/SoapUsernameTokenAuthenticationRequestDispatcher.java b/openam-sts/openam-soap-sts/openam-soap-sts-server/src/main/java/org/forgerock/openam/sts/soap/token/validator/disp/SoapUsernameTokenAuthenticationRequestDispatcher.java index 594260a897..fc17acd13c 100644 --- a/openam-sts/openam-soap-sts/openam-soap-sts-server/src/main/java/org/forgerock/openam/sts/soap/token/validator/disp/SoapUsernameTokenAuthenticationRequestDispatcher.java +++ b/openam-sts/openam-soap-sts/openam-soap-sts-server/src/main/java/org/forgerock/openam/sts/soap/token/validator/disp/SoapUsernameTokenAuthenticationRequestDispatcher.java @@ -12,7 +12,7 @@ * information: "Portions Copyrighted [year] [name of copyright owner]". * * Copyright 2013-2015 ForgeRock AS. - * Portions Copyrighted 2025 3A-Systems LLC. + * Portions Copyrighted 2025-2026 3A-Systems LLC. */ package org.forgerock.openam.sts.soap.token.validator.disp; @@ -50,7 +50,7 @@ public class SoapUsernameTokenAuthenticationRequestDispatcher implements TokenAu } @Override - public String dispatch(URL url, AuthTargetMapping.AuthTarget target, UsernameToken token) throws TokenValidationException { + public HttpURLConnectionWrapper.ConnectionResult dispatch(URL url, AuthTargetMapping.AuthTarget target, UsernameToken token) throws TokenValidationException { try { Map headerMap = new HashMap<>(); headerMap.put(AMSTSConstants.CONTENT_TYPE, AMSTSConstants.APPLICATION_JSON); @@ -67,7 +67,7 @@ public String dispatch(URL url, AuthTargetMapping.AuthTarget target, UsernameTok throw new TokenValidationException(responseCode, "Non-200 response from posting Username token " + "to rest authN: " + connectionResult.getResult()); } else { - return connectionResult.getResult(); + return connectionResult; } } catch (IOException e) { throw new TokenValidationException(ResourceException.INTERNAL_ERROR, diff --git a/openam-ui/openam-ui-js-sdk/src/lib/Login.test.tsx b/openam-ui/openam-ui-js-sdk/src/lib/Login.test.tsx new file mode 100644 index 0000000000..aa83af7cbd --- /dev/null +++ b/openam-ui/openam-ui-js-sdk/src/lib/Login.test.tsx @@ -0,0 +1,52 @@ +/** + * 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. + */ + +import { render, waitFor } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import Login from './Login'; +import type { LoginService } from './loginService'; +import type { AuthResponse } from './types'; + +const mockNavigate = vi.fn(); + +vi.mock('react-router', async (importOriginal) => ({ + ...await importOriginal(), + useNavigate: () => mockNavigate, + useSearchParams: () => [new URLSearchParams()], +})); + +function loginServiceReturning(response: AuthResponse): LoginService { + return { init: vi.fn().mockResolvedValue(response) } as unknown as LoginService; +} + +describe('Login', () => { + beforeEach(() => { + mockNavigate.mockReset(); + }); + + it('completes a login whose response carries no tokenId (HttpOnly session cookie)', async () => { + render(); + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/')); + }); + + it('completes a login whose response carries a tokenId', async () => { + render(); + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/')); + }); +}); diff --git a/openam-ui/openam-ui-js-sdk/src/lib/Login.tsx b/openam-ui/openam-ui-js-sdk/src/lib/Login.tsx index 0fe4e7844b..b516a75151 100644 --- a/openam-ui/openam-ui-js-sdk/src/lib/Login.tsx +++ b/openam-ui/openam-ui-js-sdk/src/lib/Login.tsx @@ -11,7 +11,7 @@ * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions copyright [year] [name of copyright owner]". * - * Copyright 2025 3A Systems LLC. + * Copyright 2025-2026 3A Systems LLC. */ import { useEffect, useState } from "react"; @@ -44,9 +44,11 @@ const Login: React.FC = ({ loginService }) => { && 'callbacks' in response && Array.isArray(response.callbacks); } + // With an HttpOnly session cookie (the server default) a completed authentication carries no + // tokenId in the body: the session travels in the cookie, so successUrl alone marks success. function isSuccessfulAuth(response: unknown): response is SuccessfulAuth { - return typeof response === 'object' && response !== null && 'tokenId' in response - && 'successUrl' in response && typeof response.tokenId === 'string' && typeof response.successUrl === 'string'; + return typeof response === 'object' && response !== null + && 'successUrl' in response && typeof response.successUrl === 'string'; } const doRedirect = (url: string) => { diff --git a/openam-ui/openam-ui-js-sdk/src/lib/types.ts b/openam-ui/openam-ui-js-sdk/src/lib/types.ts index 9232ca60ac..b7daf8957c 100644 --- a/openam-ui/openam-ui-js-sdk/src/lib/types.ts +++ b/openam-ui/openam-ui-js-sdk/src/lib/types.ts @@ -11,7 +11,7 @@ * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions copyright [year] [name of copyright owner]". * - * Copyright 2025 3A Systems LLC. + * Copyright 2025-2026 3A Systems LLC. */ export interface CallbackOutput { @@ -40,7 +40,7 @@ export interface AuthData { } export interface SuccessfulAuth { - tokenId: string; + tokenId?: string; // absent when the session cookie is HttpOnly successUrl: string; realm: string; } diff --git a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/anonymousProcess/SelfRegistrationView.js b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/anonymousProcess/SelfRegistrationView.js index bbfaab09d0..67d23c9a0c 100644 --- a/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/anonymousProcess/SelfRegistrationView.js +++ b/openam-ui/openam-ui-ria/src/main/js/org/forgerock/openam/ui/user/anonymousProcess/SelfRegistrationView.js @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2015-2016 ForgeRock AS. + * Portions copyright 2026 3A Systems, LLC. */ define([ @@ -47,8 +48,11 @@ define([ const realm = _.get(Configuration, "globalData.realm", ""); if (shouldAutoLogin(response, destination)) { + // With an HttpOnly session cookie the server has already set the cookie and sends no tokenId const tokenId = _.get(response, "additions.tokenId"); - SessionToken.set(tokenId); + if (tokenId) { + SessionToken.set(tokenId); + } RESTLoginView.handleExistingSession(response.additions); } else if (shouldRouteToLoginView(response, destination)) { From 406fa4bd7b90cbaf257ef81e13753db54647c0f3 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Tue, 29 Sep 2026 10:33:48 +0300 Subject: [PATCH 5/5] sts: pin the Set-Cookie fallback against a real HttpURLConnection --- .../sts/HttpURLConnectionWrapperFactory.java | 4 +- .../HttpURLConnectionWrapperFactoryTest.java | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/HttpURLConnectionWrapperFactory.java b/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/HttpURLConnectionWrapperFactory.java index 028d5321b8..147c547e13 100644 --- a/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/HttpURLConnectionWrapperFactory.java +++ b/openam-sts/openam-common-sts/src/main/java/org/forgerock/openam/sts/HttpURLConnectionWrapperFactory.java @@ -127,8 +127,8 @@ error stream should be obtained, drained, and then closed. This occurs when getE /* Reads the headers by index rather than through getHeaderFields(), which returns the values of a repeated header - in reverse order. Note that HttpURLConnection hides HttpOnly cookies from these calls when a JVM-wide - CookieHandler is installed; OpenAM installs none. + in reverse order on JDK 11 and 17. Note that HttpURLConnection hides HttpOnly cookies from these calls when a + JVM-wide CookieHandler is installed; OpenAM installs none. */ private List getSetCookieHeaders() { List setCookieHeaders = new ArrayList<>(); diff --git a/openam-sts/openam-common-sts/src/test/java/org/forgerock/openam/sts/HttpURLConnectionWrapperFactoryTest.java b/openam-sts/openam-common-sts/src/test/java/org/forgerock/openam/sts/HttpURLConnectionWrapperFactoryTest.java index d2e1a10080..04fa1615e7 100644 --- a/openam-sts/openam-common-sts/src/test/java/org/forgerock/openam/sts/HttpURLConnectionWrapperFactoryTest.java +++ b/openam-sts/openam-common-sts/src/test/java/org/forgerock/openam/sts/HttpURLConnectionWrapperFactoryTest.java @@ -23,10 +23,13 @@ import java.io.ByteArrayInputStream; import java.net.HttpURLConnection; +import java.net.InetSocketAddress; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import com.sun.net.httpserver.HttpServer; + import org.testng.annotations.Test; public class HttpURLConnectionWrapperFactoryTest { @@ -62,4 +65,40 @@ public void keepsSetCookieHeadersInWireOrder() throws Exception { "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-new-token; Path=/; HttpOnly", "amlbcookie=01; Path=/")); } + + /* + Pins the two HttpURLConnection facts the index-based reader relies on against a real connection: repeated + Set-Cookie headers come back in wire order when read by index (getHeaderFields() reverses them on JDK 11 and + 17, and keys them by exact wire spelling), and an HttpOnly cookie's value comes back empty once a JVM-wide + CookieHandler is installed, so this test also fails if a fixture ever installs one. + */ + @Test + public void seesHttpOnlySetCookieOnARealConnectionInWireOrder() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/openam/json/authenticate", exchange -> { + exchange.getResponseHeaders().add("Set-Cookie", "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-old-token; Path=/"); + exchange.getResponseHeaders().add("Set-Cookie", + "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-new-token; Path=/; HttpOnly"); + byte[] body = "{\"successUrl\":\"/openam/console\"}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + try { + HttpURLConnectionWrapper.ConnectionResult result = + new HttpURLConnectionWrapperFactory(new DefaultHttpURLConnectionFactory()) + .httpURLConnectionWrapper(new URL("http://127.0.0.1:" + server.getAddress().getPort() + + "/openam/json/authenticate")) + .withoutAuditTransactionIdHeader() + .makeInvocation(); + + assertEquals(result.getResult().trim(), "{\"successUrl\":\"/openam/console\"}"); + assertEquals(result.getSetCookieHeaders(), Arrays.asList( + "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-old-token; Path=/", + "iPlanetDirectoryPro=AQIC5wM2LY4Sfczn-new-token; Path=/; HttpOnly")); + } finally { + server.stop(0); + } + } }