From 94bd319ec0c7941a20a8a637f646084759036781 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 19 Jun 2026 19:49:36 +0300 Subject: [PATCH 1/3] 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 8a763faaa2..80b09476c3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -296,38 +296,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 4206397927..a7aad479cb 100644 --- a/e2e/saml/saml-test.spec.mjs +++ b/e2e/saml/saml-test.spec.mjs @@ -114,9 +114,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 aca1cc5ff4..f577387b2b 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 @@ -5640,7 +5640,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 21913da9db..d9397e55b8 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 13d6c97f75..2d8777f1b8 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 @@ -65,10 +65,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); @@ -169,8 +167,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 7cf380a49c9735e7821bd2afba512dfae31f87a6 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Sat, 20 Jun 2026 09:26:35 +0300 Subject: [PATCH 2/3] 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 80b09476c3..176d915f5b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -183,13 +183,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\" @@ -218,7 +219,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 @@ -275,13 +276,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\" @@ -391,13 +392,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\" @@ -441,13 +442,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\" @@ -489,12 +490,12 @@ 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 1700002fa260ca1f84b64fffd950bbbb9cbeddae Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 25 Sep 2026 10:32:43 +0300 Subject: [PATCH 3/3] 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);