This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch 
UNOMI-972-credentials-profile-binding-privileged-rest
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit 3a0bb091a16730c8acf894408d81f9bfb96dce5d
Author: Serge Huber <[email protected]>
AuthorDate: Mon Aug 10 09:27:28 2026 +0200

    UNOMI-972: rewrite the login sample to demonstrate the trusted server-side 
pattern
    
    Coupled to reported issue 6. The sample shipped exampleLogin.json, which 
wired a public
    login event to mergeProfilesOnPropertyAction - exactly the pattern the 
documentation warns
    against, and the pattern the report cites. The gate added for issue 6 
refuses it, so the
    sample would otherwise ship broken as well as misleading.
    
    The browser now posts only to the sample's own /login/authenticate, which 
validates a demo
    password and then calls /cxs/context.json itself with trusted credentials, 
so the merge is
    performed by a caller the server can vouch for. The session id is 
server-generated and held
    on the container session rather than accepted from the request, and a 
same-origin check
    stands in for the per-session CSRF token a real integration would use.
    
    No demo password ships with the sample, for the same reason Unomi no longer 
ships a default
    admin password: a credential baked into published source is a credential 
everyone has.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
 manual/src/main/asciidoc/samples/login-sample.adoc | 203 +++++++++--
 samples/login-integration/pom.xml                  |  44 ++-
 samples/login-integration/setup.sh                 | 299 ++++++++++++++++
 .../unomi/samples/login/LoginSampleResources.java  |  37 ++
 .../apache/unomi/samples/login/LoginServlet.java   | 395 +++++++++++++++++++++
 .../src/main/resources/static/index.html           |  75 ++++
 .../resources/static/javascript/login-example.js   |  58 +++
 .../unomi/samples/login/LoginServletTest.java      | 353 ++++++++++++++++++
 8 files changed, 1430 insertions(+), 34 deletions(-)

diff --git a/manual/src/main/asciidoc/samples/login-sample.adoc 
b/manual/src/main/asciidoc/samples/login-sample.adoc
index 0a33e2377..8e2ae12fb 100644
--- a/manual/src/main/asciidoc/samples/login-sample.adoc
+++ b/manual/src/main/asciidoc/samples/login-sample.adoc
@@ -14,51 +14,206 @@
 [#_login_sample]
 === Login sample
 
-This sample is an example of what is involved in integrating a login with 
Apache Unomi.
+This sample shows how to integrate a **server-side** login with Apache Unomi 
so that
+`mergeProfilesOnPropertyAction` can merge profiles on email (or another merge 
key).
 
-==== Warning !
+==== Why server-side?
 
-The example code uses client-side Javascript code to send the login event. 
This is only
-done this way for the sake of samples simplicity but if should NEVER BE DONE 
THIS WAY in real cases.
+Under Unomi 3.1 hardening 
(https://issues.apache.org/jira/browse/UNOMI-972[UNOMI-972] /
+<<_client_facing_hardening_3_1,client-facing hardening>>), cross-profile merge 
requires a
+**trusted** caller (system administrator or tenant private key). A browser 
call with only a
+public API key is not trusted — merge is refused.
 
-The login event should always be sent from the server performing the actual 
login since it must
-only be sent if the user has authenticated properly, and only the 
authentication server can validate this.
+A real authentication server must validate the password, then call Unomi. This 
sample mimics
+that with a small DS servlet inside Unomi (`/login/authenticate`).
 
-==== Installing the samples
+==== How it works
 
-Login into the Unomi Karaf SSH shell using something like this :
+. Open http://localhost:8181/login/index.html and submit the form
+   (the password you set as `demoPassword` in the sample configuration).
+. The page posts to `/login/authenticate` (same host) — it never posts a login 
event to `/cxs/context.json` from JavaScript.
+. `LoginServlet` checks the demo password, then `POST`s a `login` event to 
`/cxs/context.json` using Basic auth
+   (`tenantId:privateKey` — a tenant private key is the only credential the 
sample accepts).
+. The session id is generated by the servlet and kept on the browser's own 
`HttpSession` — it is
+   deliberately *not* read from the form. See 
<<_never_forward_client_supplied_identifiers,Never forward client-supplied 
identifiers>>.
+. The bundled rule `exampleLogin` (`META-INF/cxs/rules/exampleLogin.json`) runs
+   `mergeProfilesOnPropertyAction` on `target.properties.email` plus 
`copyPropertiesAction`.
 
-[source]
+[plantuml]
 ----
-ssh -p 8102 karaf@localhost (default password is karaf) 
+@startuml
+title Login sample — trusted server-side merge (UNOMI-972)
+
+actor Browser
+participant "Login page\n/login/index.html" as Page
+participant "LoginServlet\n/login/authenticate" as Auth
+participant "Unomi REST\n/cxs/context.json" as Context
+participant "exampleLogin rule" as Rule
+
+Browser -> Page: Open /login/index.html
+Browser -> Page: Submit form (email, password, …)
+Page -> Auth: POST /login/authenticate\n(form fields only)
+
+alt Wrong demo password
+    Auth --> Page: 401 { error }
+    Page --> Browser: Show error
+else Password OK
+    Auth -> Auth: Resolve sessionId from HttpSession\n(never from the request)
+    Auth -> Auth: Build login ContextRequest\n(scope from config)
+    Auth -> Context: POST /cxs/context.json?sessionId=…\nBasic 
tenantId:privateKey
+    activate Context
+    Context -> Rule: login event
+    Rule -> Rule: mergeProfilesOnPropertyAction\n(on email) + 
copyPropertiesAction
+    Context --> Auth: context JSON + Set-Cookie
+    deactivate Context
+    Auth --> Page: Forward Unomi response\n(+ profile cookie)
+    Page --> Browser: Show profileId
+end
+
+note over Page
+  Browser never calls /cxs/context.json
+  for login — only the trusted servlet does.
+end note
+
+note over Context
+  Public API key alone is not trusted;
+  merge would be refused.
+end note
+
+@enduml
 ----
 
-Install the login samples using the following command:
+Source: `samples/login-integration/` (OSGi Declarative Services, no Blueprint).
 
-[source]
+[[_never_forward_client_supplied_identifiers]]
+==== Never forward client-supplied identifiers
+
+Moving the login event server-side is only half of the fix. A trusted caller 
is allowed to adopt
+whatever profile owns the `sessionId` it passes, and to merge profiles on 
whatever identifier the
+event carries. A proxy that takes those values from its own untrusted callers 
and replays them under
+trusted credentials hands that power straight back to the browser: anyone who 
guesses another
+visitor's session id could rebind or merge that visitor's profile.
+
+`LoginServlet` therefore derives the session id from state it controls — a 
UUID stored on the
+browser's container `HttpSession` — and never reads it from a request 
parameter. Apply the same rule
+in your own integration:
+
+* Derive the `sessionId` from your authenticated server-side session, not from 
the request body.
+* Derive the merge identifier (here, the email) from the account you just 
authenticated, not from a
+  form field the caller chose.
+* Treat every other Unomi identifier the same way: if the value came from the 
caller, it must not be
+  forwarded under credentials the caller does not hold.
+
+NOTE: The sample's same-origin check is a lightweight stand-in for CSRF 
protection, and
+`demoPassword` is a single shared secret standing in for a user directory. A 
real integration should
+use a per-session CSRF token and authenticate each user against your identity 
provider. The sample
+ships no default `demoPassword` for the same reason Unomi ships no default 
admin password: a
+credential published in source is a credential everyone has.
+
+==== Build
+
+From the Unomi source tree:
+
+[source,bash]
 ----
-bundle:install mvn:org.apache.unomi/login-integration-sample/${project.version}
+mvn -pl samples/login-integration -am install -DskipTests
 ----
 
-when the bundle is successfully install you will get an bundle ID back we will 
call it BUNDLE_ID. 
+==== Set up
 
-You can then do:
+Build the sample, then run the setup script on the Unomi host. It creates the 
tenant and scope,
+issues a tenant private key, generates a demo password, configures the bundle 
and starts it:
 
-[source]
+[source,bash]
 ----
-bundle:start BUNDLE_ID
+mvn -pl samples/login-integration -am install -DskipTests
+
+export UNOMI_ROOT_PASSWORD='your-admin-password'
+./samples/login-integration/setup.sh
 ----
 
-If all went well you can access the login samples HTML page here :
+`KARAF_HOME` must point at the Unomi install that is **actually running** and 
serving `UNOMI_URL` —
+the script writes into that install's `etc/` and `deploy/` directories, so 
configuring a different
+copy would have no effect. Set it explicitly unless you have exactly one built 
distribution in the
+source tree:
 
-[source]
+[source,bash]
 ----
-http://localhost:8181/login/index.html
+cd samples/login-integration
+KARAF_HOME=../../package/target/unomi-3.1.0-SNAPSHOT ./setup.sh
 ----
 
-You can fill in the form to test it. Note that the hardcoded password is:
+The script validates this before changing anything, and refuses to continue if 
the directory does not
+exist, does not look like a Unomi install, is not writable, or belongs to an 
instance that is stopped
+(it checks `karaf.pid` against the running process, so a stale pid file from a 
previous run is caught
+too). If you have more than one built distribution under `package/target`, it 
will not guess — set
+`KARAF_HOME`.
+
+The script waits until the sample page answers before reporting success, then 
prints the page URL and
+the **generated demo password** — copy it, it is not stored anywhere you can 
read it back. The private
+key is never printed. Re-running is safe: it reuses an existing tenant and 
scope and issues a fresh key.
+
+It needs `curl` and `jq`, and filesystem access to the Unomi install — it 
writes
+`etc/org.apache.unomi.samples.login.cfg` (mode `600`, since it holds the 
private key) and copies the
+bundle into `deploy/`, both of which Karaf picks up within a second. 
`KARAF_HOME` is auto-detected in
+the source tree; set it otherwise. No Karaf console, SSH or console credential 
is involved. Override
+`UNOMI_URL`, `UNOMI_TENANT_ID`, `UNOMI_SCOPE` or `DEMO_PASSWORD` if the 
defaults do not suit;
+`./setup.sh --help` lists them.
+
+NOTE: The script uses the system administrator credential because creating a 
tenant is an operator
+action. The servlet never sees it — it receives only the scoped tenant private 
key the script issues.
+A tenant private key is the **only** credential the sample accepts: a system 
administrator password
+would also satisfy the merge gate, but it grants far more than this sample 
needs and is not scoped to
+a single tenant, so the servlet deliberately refuses to use one.
+
+===== Setting it up by hand
+
+If you would rather not run the script, the equivalent steps are: create the 
tenant and a scope,
+`POST /cxs/tenants/<tenant>/apikeys?type=PRIVATE` and keep the returned 
`plainTextKey` (it is shown
+once), then from the Karaf console:
 
-[source]
+[source,bash]
 ----
-test1234
-----
\ No newline at end of file
+bundle:install mvn:org.apache.unomi/login-integration-sample/${project.version}
+config:edit org.apache.unomi.samples.login
+config:property-set tenantId default
+config:property-set scope default
+config:property-set privateKey <plainTextKey>
+config:property-set demoPassword <choose-a-password>
+config:update
+bundle:start <bundle-id>
+----
+
+Either way, the bundle reports on activation whether it is usable. A 
configured sample logs, at
+`INFO`:
+
+----
+Login sample ready - open http://localhost:8181/login/index.html 
(tenantId=default, scope=default)
+----
+
+A sample still missing something logs a `WARN` naming exactly what to set. 
Correct it and run
+`config:update` again — the servlet re-reads its configuration and reprints 
the status without a
+restart.
+
+Ensure the tenant allows login events from the servlet's source IP (typically 
`127.0.0.1` when
+calling localhost) via tenant authorized IPs.
+
+==== Test profile merge
+
+. Open http://localhost:8181/login/index.html
+. Log in with email `[email protected]` and your configured `demoPassword`. 
Note the `profileId` in the success message.
+. Clear the `context-profile-id` cookie for `localhost` (or use a private 
browser window) so Unomi would otherwise create a new anonymous profile.
+. Log in again with the **same** email. Expect the **same** master `profileId` 
(merge on email).
+. Optional: log in with a different email — expect a different profile.
+
+If you see `Unable to resolve a tenant`, create the tenant and ensure 
`tenantId` matches the tenant
+the private key belongs to — the key's tenant is what Unomi authenticates 
against.
+If schema validation rejects the scope, create the scope (see above).
+If you see a configuration error from `/login/authenticate`, the servlet has 
no trusted credentials
+yet — set `privateKey` as shown above and run `config:update`.
+
+==== Related
+
+* <<_client_facing_hardening_3_1,Client-facing hardening (3.1)>>
+* <<_how_profile_tracking_works,How profile tracking works>>
+* Rule JSON: 
https://github.com/apache/unomi/blob/master/samples/login-integration/src/main/resources/META-INF/cxs/rules/exampleLogin.json
diff --git a/samples/login-integration/pom.xml 
b/samples/login-integration/pom.xml
index 772e2aeab..6d10ebfa5 100644
--- a/samples/login-integration/pom.xml
+++ b/samples/login-integration/pom.xml
@@ -25,7 +25,7 @@
     </parent>
     <artifactId>login-integration-sample</artifactId>
     <name>Apache Unomi :: Samples :: External Login plugin</name>
-    <description>This is a simple Apache Unomi plugin.</description>
+    <description>Server-side login sample that calls Unomi with trusted 
credentials so profile merge works (UNOMI-972).</description>
     <packaging>bundle</packaging>
 
     <dependencyManagement>
@@ -42,16 +42,40 @@
 
     <dependencies>
         <dependency>
-            <groupId>org.apache.unomi</groupId>
-            <artifactId>unomi-api</artifactId>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.service.component.annotations</artifactId>
             <scope>provided</scope>
         </dependency>
         <dependency>
-            <groupId>javax.servlet.jsp</groupId>
-            <artifactId>jsp-api</artifactId>
-            <version>2.1</version>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.service.metatype.annotations</artifactId>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>javax.servlet</groupId>
+            <artifactId>javax.servlet-api</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <build>
@@ -62,10 +86,10 @@
                 <extensions>true</extensions>
                 <configuration>
                     <instructions>
-                        <_wab>src/main/webapp</_wab>
-                        
<Embed-Dependency>*;scope=compile|runtime</Embed-Dependency>
-                        <Embed-Directory>WEB-INF/lib</Embed-Directory>
-                        <Web-ContextPath>/login</Web-ContextPath>
+                        <_dsannotations>*</_dsannotations>
+                        <_metatypeannotations>*</_metatypeannotations>
+                        <Export-Package>!*</Export-Package>
+                        
<Private-Package>org.apache.unomi.samples.login.*</Private-Package>
                     </instructions>
                 </configuration>
             </plugin>
diff --git a/samples/login-integration/setup.sh 
b/samples/login-integration/setup.sh
new file mode 100755
index 000000000..ea56b13eb
--- /dev/null
+++ b/samples/login-integration/setup.sh
@@ -0,0 +1,299 @@
+#!/bin/sh
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# Provisions and configures the Apache Unomi login sample on a LOCAL instance.
+#
+# This is a demo convenience, not a deployment tool. It uses the system 
administrator
+# credential because provisioning a tenant is an operator action - the sample 
servlet itself
+# only ever receives the scoped tenant private key this script creates for it.
+#
+# Idempotent: re-running reuses an existing tenant and scope, and issues a 
fresh private key.
+#
+# Usage:
+#   export UNOMI_ROOT_PASSWORD='your-admin-password'
+#   ./setup.sh [--version <sample-version>]
+#
+# Optional environment overrides:
+#   UNOMI_URL        base URL of the running Unomi   (default 
http://localhost:8181)
+#   UNOMI_TENANT_ID  tenant to create/use            (default default)
+#   UNOMI_SCOPE      scope to create/use             (default default)
+#   KARAF_HOME       Unomi install dir (auto-detected in the source tree)
+#   DEMO_PASSWORD    login-form password             (default: randomly 
generated)
+
+set -eu
+
+UNOMI_URL="${UNOMI_URL:-http://localhost:8181}";
+TENANT_ID="${UNOMI_TENANT_ID:-default}"
+SCOPE="${UNOMI_SCOPE:-default}"
+PID="org.apache.unomi.samples.login"
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+SAMPLE_VERSION=""
+
+usage() {
+    cat <<'USAGE'
+Provisions and configures the Apache Unomi login sample on a local instance.
+
+  export UNOMI_ROOT_PASSWORD='your-admin-password'
+  ./setup.sh [--version <sample-version>]
+
+Environment overrides: UNOMI_URL, UNOMI_TENANT_ID, UNOMI_SCOPE, KARAF_HOME, 
DEMO_PASSWORD.
+USAGE
+}
+
+fail() { echo "ERROR: $*" >&2; exit 1; }
+
+# The cfg is parsed as a Java .properties file, which treats backslash as an 
escape character and
+# strips whitespace between the separator and the value. Writing a value 
verbatim would therefore
+# store something different from what the operator typed - "p@ss\\word" 
silently becomes
+# "p@ssword" - and the resulting login failure gives no clue why. Escape 
backslashes, then escape a
+# leading space or tab so it survives.
+properties_escape() {
+    printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/^\([ \t]\)/\\\1/'
+}
+
+while [ $# -gt 0 ]; do
+    case "$1" in
+        --version) SAMPLE_VERSION="${2:?--version needs a value}"; shift 2 ;;
+        -h|--help) usage; exit 0 ;;
+        *) echo "Unknown argument: $1 (try --help)" >&2; exit 2 ;;
+    esac
+done
+
+for cmd in curl jq; do
+    command -v "$cmd" >/dev/null 2>&1 || fail "$cmd is required but not on 
PATH"
+done
+
+[ -n "${UNOMI_ROOT_PASSWORD:-}" ] || fail "UNOMI_ROOT_PASSWORD is not set. 
Export the administrator
+       password you started Unomi with, then run this script again."
+
+# Configuration and deployment go through the directories Karaf already watches
+# (felix.fileinstall polls etc/ for *.cfg and deploy/ for bundles), so this 
script needs no Karaf
+# console: no SSH, no host keys, and no console credential on the command line.
+#
+# That also means everything is written to a directory rather than to the 
running process, so the
+# directory has to be validated properly: pointing at a freshly built 
distribution while a different
+# one is actually serving UNOMI_URL would write the config into an install 
nobody is reading.
+if [ -n "${KARAF_HOME:-}" ]; then
+    KARAF_DIR="$KARAF_HOME"
+else
+    KARAF_DIR=""
+    for candidate in "${SCRIPT_DIR}"/../../package/target/unomi-*/; do
+        [ -f "${candidate}etc/config.properties" ] || continue
+        [ -z "$KARAF_DIR" ] || fail "Found more than one built distribution 
under package/target.
+       Set KARAF_HOME to the one that is running."
+        KARAF_DIR="$candidate"
+    done
+    [ -n "$KARAF_DIR" ] || fail "Could not find a Unomi install. Set 
KARAF_HOME to the directory of the
+       running instance, for example:
+         KARAF_HOME=../../package/target/unomi-3.1.0-SNAPSHOT ./setup.sh"
+fi
+
+[ -d "$KARAF_DIR" ] || fail "KARAF_HOME does not exist: ${KARAF_DIR}"
+KARAF_DIR=$(CDPATH= cd -- "$KARAF_DIR" && pwd)
+
+# Looks like a Karaf install at all?
+for marker in etc/config.properties bin/karaf deploy; do
+    [ -e "${KARAF_DIR}/${marker}" ] \
+        || fail "${KARAF_DIR} does not look like a Unomi install (missing 
${marker}). Set KARAF_HOME."
+done
+
+# Writable? Failing here beats a half-applied setup.
+for dir in etc deploy; do
+    [ -w "${KARAF_DIR}/${dir}" ] || fail "${KARAF_DIR}/${dir} is not writable 
by $(id -un)."
+done
+
+# Actually running? Karaf writes karaf.pid at startup; a stale file from a 
previous run is common,
+# so the process is checked rather than just the file. Without this the script 
would happily
+# configure a stopped install and only fail later, at the readiness poll, with 
a confusing message.
+KARAF_PID_FILE="${KARAF_DIR}/karaf.pid"
+[ -f "$KARAF_PID_FILE" ] || fail "No karaf.pid in ${KARAF_DIR} - that instance 
has never been started.
+       Start Unomi there, or point KARAF_HOME at the instance serving 
${UNOMI_URL}."
+KARAF_PID=$(cat "$KARAF_PID_FILE" 2>/dev/null || true)
+{ [ -n "$KARAF_PID" ] && kill -0 "$KARAF_PID" 2>/dev/null; } \
+    || fail "${KARAF_DIR} has a stale karaf.pid (process ${KARAF_PID:-unknown} 
is not running).
+       Start that instance, or point KARAF_HOME at the one serving 
${UNOMI_URL}."
+
+echo "==> Using Unomi install ${KARAF_DIR} (running, pid ${KARAF_PID})"
+
+# Credentials go into a mode-600 netrc rather than curl --user: --user places 
the password in the
+# process arguments, where any local user can read it from ps for the lifetime 
of the request.
+NETRC=$(mktemp) || fail "Could not create a temporary file"
+chmod 600 "$NETRC"
+trap 'rm -f "$NETRC"' EXIT INT TERM HUP
+UNOMI_HOST=$(printf '%s' "$UNOMI_URL" | sed -e 
's,^[A-Za-z][A-Za-z0-9+.-]*://,,' -e 's,[:/].*$,,')
+[ -n "$UNOMI_HOST" ] || fail "Could not parse a host out of 
UNOMI_URL='${UNOMI_URL}'"
+printf 'machine %s login karaf password %s\n' "$UNOMI_HOST" 
"$UNOMI_ROOT_PASSWORD" > "$NETRC"
+
+# curl exits 0 for any completed HTTP transaction, including 401/403/500, so a 
bare "curl || fail"
+# reports failed requests as successes. Every call therefore checks the status 
code explicitly and
+# surfaces it, rather than relying on curl's exit status.
+#   $1 = description used in the error message; remaining args go to curl; 
body goes to stdout.
+admin_request() {
+    _what="$1"; shift
+    _out=$(mktemp) || fail "Could not create a temporary file"
+    _code=$(curl -sS -o "$_out" -w '%{http_code}' --netrc-file "$NETRC" "$@" 
2>/dev/null) || _code="000"
+    case "$_code" in
+        2*)
+            cat "$_out"; rm -f "$_out"; return 0 ;;
+        000)
+            rm -f "$_out"
+            fail "${_what}: could not connect to ${UNOMI_URL}. Is Unomi 
running?" ;;
+        401|403)
+            rm -f "$_out"
+            fail "${_what}: HTTP ${_code}. Check that UNOMI_ROOT_PASSWORD is 
the administrator
+       password of the instance at ${UNOMI_URL}." ;;
+        *)
+            _body=$(head -c 400 "$_out" 2>/dev/null || true); rm -f "$_out"
+            fail "${_what}: HTTP ${_code}. ${_body}" ;;
+    esac
+}
+
+# Existence checks must NOT fail on a non-2xx: "not found" is the normal 
create-it path, and the
+# endpoints differ in how they say it (404, or an empty 2xx body). These two 
helpers therefore
+# report what came back instead of treating it as an error; a genuine 
permission problem still
+# surfaces loudly at the following create call, which goes through 
admin_request.
+admin_status() { curl -sS -o /dev/null -w '%{http_code}' --netrc-file "$NETRC" 
"$@" 2>/dev/null || echo "000"; }
+admin_body_or_empty() { curl -sS --netrc-file "$NETRC" "$@" 2>/dev/null || 
true; }
+
+echo "==> Checking Unomi at ${UNOMI_URL}"
+admin_request "Connecting to ${UNOMI_URL}" "${UNOMI_URL}/cxs/tenants" 
>/dev/null
+echo "    reachable, administrator credentials accepted"
+
+echo "==> Tenant '${TENANT_ID}'"
+if [ "$(admin_status "${UNOMI_URL}/cxs/tenants/${TENANT_ID}")" = "200" ]; then
+    echo "    already exists, reusing"
+else
+    admin_request "Creating tenant '${TENANT_ID}'" -X POST 
"${UNOMI_URL}/cxs/tenants" \
+        -H "Content-Type: application/json" \
+        -d "{\"requestedId\":\"${TENANT_ID}\",\"properties\":{\"name\":\"Login 
sample tenant\"}}" \
+        >/dev/null
+    echo "    created"
+fi
+
+echo "==> Scope '${SCOPE}'"
+existing_scope=$(admin_body_or_empty -H "X-Unomi-Tenant-Id: ${TENANT_ID}" 
"${UNOMI_URL}/cxs/scopes/${SCOPE}")
+if printf '%s' "$existing_scope" | jq -e '.itemId? // empty' >/dev/null 2>&1; 
then
+    echo "    already exists, reusing"
+else
+    admin_request "Creating scope '${SCOPE}'" -X POST 
"${UNOMI_URL}/cxs/scopes" \
+        -H "Content-Type: application/json" \
+        -H "X-Unomi-Tenant-Id: ${TENANT_ID}" \
+        -d 
"{\"itemId\":\"${SCOPE}\",\"metadata\":{\"id\":\"${SCOPE}\",\"name\":\"Login 
sample scope\"}}" \
+        >/dev/null
+    echo "    created"
+fi
+
+# The plaintext of a private key is returned once, at creation, so it is 
captured here and never
+# echoed. An empty value means the request failed; configuring the sample with 
it would leave the
+# servlet with a blank credential.
+echo "==> Issuing a tenant private key"
+PRIVATE_KEY=$(admin_request "Issuing a private key for '${TENANT_ID}'" \
+    -X POST "${UNOMI_URL}/cxs/tenants/${TENANT_ID}/apikeys?type=PRIVATE" \
+    | jq -r '.plainTextKey // empty')
+[ -n "$PRIVATE_KEY" ] || fail "No plainTextKey was returned. Check that tenant 
'${TENANT_ID}' exists
+       and that the administrator credentials are correct."
+echo "    issued (not printed)"
+
+# No demo password ships with the sample, for the same reason Unomi ships no 
default admin password.
+if [ -n "${DEMO_PASSWORD:-}" ]; then
+    case "$DEMO_PASSWORD" in
+        *"$(printf '\n')"*) fail "DEMO_PASSWORD must not contain a newline." ;;
+    esac
+    LOGIN_PASSWORD="$DEMO_PASSWORD"
+else
+    LOGIN_PASSWORD=$(LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 20) \
+        || fail "Could not generate a demo password"
+fi
+
+# Written before the bundle is deployed so the component sees its 
configuration on first activation.
+# Holds the private key and the demo password, hence the restrictive mode.
+echo "==> Writing ${KARAF_DIR}/etc/${PID}.cfg"
+CFG="${KARAF_DIR}/etc/${PID}.cfg"
+# Scoped so the restrictive mode applies to the cfg only: leaving umask 077 
set would also strip
+# group/other bits from the bundle jar copied into deploy/ further down.
+_previous_umask=$(umask)
+umask 077
+cat > "$CFG" <<EOF
+# Generated by samples/login-integration/setup.sh - safe to edit or delete.
+unomiBaseUrl=${UNOMI_URL}
+tenantId=${TENANT_ID}
+scope=${SCOPE}
+privateKey=$(properties_escape "$PRIVATE_KEY")
+demoPassword=$(properties_escape "$LOGIN_PASSWORD")
+EOF
+chmod 600 "$CFG"
+umask "$_previous_umask"
+echo "    written (mode 600)"
+
+echo "==> Deploying the sample bundle"
+SAMPLE_JAR=$(ls "${SCRIPT_DIR}"/target/login-integration-sample-*.jar 
2>/dev/null | head -1)
+[ -n "$SAMPLE_JAR" ] || fail "No built bundle in ${SCRIPT_DIR}/target.
+       Build it first: mvn -pl samples/login-integration -am install 
-DskipTests"
+cp "$SAMPLE_JAR" "${KARAF_DIR}/deploy/" || fail "Could not copy the bundle 
into ${KARAF_DIR}/deploy"
+echo "    copied $(basename "$SAMPLE_JAR") into deploy/"
+
+# Karaf polls deploy/ once a second, so confirm the sample really came up 
rather than reporting
+# success on the basis of having copied a file.
+#
+# Probe the servlet, not the static page: /login/index.html is published by 
LoginSampleResources,
+# a separate component with no configuration dependency, so it answers 200 as 
soon as the bundle
+# resolves - even while LoginServlet is still unconfigured and every login 
returns 503. That window
+# is real, not theoretical: the two components activate independently as 
fileinstall picks up the
+# cfg and the jar. Posting a deliberately wrong password distinguishes the 
states:
+#   401 = servlet up AND configured (it got as far as checking the password)
+#   503 = deployed but not configured yet
+#   404/000 = not deployed yet
+echo "==> Waiting for the sample to answer"
+DEPLOY_STATUS=""
+i=0
+while [ $i -lt 30 ]; do
+    DEPLOY_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
+        --data 
"[email protected]&password=setup-probe-wrong-password" \
+        "${UNOMI_URL}/login/authenticate" 2>/dev/null || echo "000")
+    [ "$DEPLOY_STATUS" = "401" ] && break
+    sleep 1
+    i=$((i + 1))
+done
+if [ "$DEPLOY_STATUS" != "401" ]; then
+    case "$DEPLOY_STATUS" in
+        503) fail "The sample deployed but never picked up its configuration 
(still HTTP 503 after ${i}s).
+       Check ${CFG} and ${KARAF_DIR}/data/log/karaf.log for a line starting 
'Login sample'." ;;
+        *)   fail "The sample did not come up at 
${UNOMI_URL}/login/authenticate after ${i}s (last status
+       ${DEPLOY_STATUS}). Check ${KARAF_DIR}/data/log/karaf.log for a line 
starting 'Login sample'." ;;
+    esac
+fi
+echo "    up and configured after ${i}s"
+
+cat <<EOF
+
+========================================================================
+ Login sample is ready.
+
+   Page:           ${UNOMI_URL}/login/index.html
+   Demo password:  ${LOGIN_PASSWORD}
+
+ Log in with any email address and the password above. The password was
+ generated for this run - it is not stored anywhere else, so copy it now.
+========================================================================
+
+To remove the sample, run:
+
+  rm -f "${KARAF_DIR}/deploy/$(basename "$SAMPLE_JAR")"
+  rm -f "${CFG}"
+
+Karaf uninstalls the bundle as soon as the jar disappears from deploy/.
+EOF
diff --git 
a/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginSampleResources.java
 
b/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginSampleResources.java
new file mode 100644
index 000000000..550656d17
--- /dev/null
+++ 
b/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginSampleResources.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.samples.login;
+
+import org.osgi.service.component.annotations.Component;
+
+/**
+ * Publishes the sample HTML/JS under {@code /login/*} via the OSGi Http 
Whiteboard
+ * (files live in {@code /static} inside this bundle).
+ * <p>
+ * Static resources only: there is no directory index, so the page is reached 
at
+ * {@code /login/index.html} rather than {@code /login}.
+ */
+@Component(
+        service = Object.class,
+        immediate = true,
+        property = {
+                "osgi.http.whiteboard.resource.pattern=/login/*",
+                "osgi.http.whiteboard.resource.prefix=/static"
+        }
+)
+public class LoginSampleResources {
+}
diff --git 
a/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginServlet.java
 
b/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginServlet.java
new file mode 100644
index 000000000..e3a1585c2
--- /dev/null
+++ 
b/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginServlet.java
@@ -0,0 +1,395 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.samples.login;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Modified;
+import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.Designate;
+import org.osgi.service.metatype.annotations.ObjectClassDefinition;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.servlet.Servlet;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Demo "authentication server" for the login sample.
+ * <p>
+ * The browser posts the form here. This servlet checks a hardcoded demo 
password,
+ * then calls Unomi {@code /cxs/context.json} with <strong>trusted</strong> 
Basic
+ * credentials (a tenant private key) so {@code mergeProfilesOnPropertyAction}
+ * is allowed. The browser must not call Unomi for login events itself.
+ */
+@Component(
+        service = Servlet.class,
+        immediate = true,
+        configurationPid = "org.apache.unomi.samples.login",
+        property = {
+                "osgi.http.whiteboard.servlet.name=LoginSampleServlet",
+                "osgi.http.whiteboard.servlet.pattern=/login/authenticate"
+        }
+)
+@Designate(ocd = LoginServlet.Config.class)
+public class LoginServlet extends HttpServlet {
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(LoginServlet.class);
+    /** Must match the {@code configurationPid} above; quoted in the hint 
printed when config is missing. */
+    private static final String CONFIGURATION_PID = 
"org.apache.unomi.samples.login";
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+    /** Attribute holding the Unomi session id we generated for this browser's 
container session. */
+    private static final String UNOMI_SESSION_ID_ATTRIBUTE = 
"org.apache.unomi.samples.login.unomiSessionId";
+    /**
+     * Idle timeout applied to the container sessions this servlet creates. A 
login round trip takes
+     * seconds, so a few minutes is generous; see {@link #resolveSessionId} 
for why it is capped.
+     */
+    private static final int SESSION_MAX_INACTIVE_SECONDS = 300;
+
+    private String unomiBaseUrl = "http://localhost:8181";;
+    private String tenantId = "default";
+    private String scope = "default";
+    private String privateKey = "";
+    private String demoPassword = "";
+
+    @ObjectClassDefinition(
+            name = "Unomi login sample",
+            description = "Trusted credentials used by /login/authenticate to 
call Unomi (UNOMI-972)"
+    )
+    public @interface Config {
+
+        @AttributeDefinition(name = "Unomi base URL", description = "Base URL 
of this Unomi instance")
+        String unomiBaseUrl() default "http://localhost:8181";;
+
+        @AttributeDefinition(
+                name = "Tenant ID",
+                description = "Tenant the login events belong to. Sent as the 
Basic auth user name alongside privateKey."
+        )
+        String tenantId() default "default";
+
+        @AttributeDefinition(
+                name = "Scope",
+                description = "Event/source scope (must already exist for the 
tenant; systemscope is not a valid event scope)"
+        )
+        String scope() default "default";
+
+        @AttributeDefinition(
+                name = "Tenant private key",
+                description = "Required. Plain-text tenant private API key; 
authenticates as tenant "
+                        + "administrator, which is what allows the profile 
merge."
+        )
+        String privateKey() default "";
+
+        @AttributeDefinition(
+                name = "Demo login password",
+                description = "Password the sample login form accepts. 
Required; no default is shipped, "
+                        + "so choose one when configuring the sample. Stands 
in for the user directory "
+                        + "a real integration would authenticate against."
+        )
+        String demoPassword() default "";
+    }
+
+    @Activate
+    @Modified
+    public void activate(Config config) {
+        this.unomiBaseUrl = config.unomiBaseUrl();
+        this.tenantId = config.tenantId();
+        this.scope = config.scope() != null && !config.scope().isBlank() ? 
config.scope().trim() : "default";
+        this.privateKey = config.privateKey() != null ? 
config.privateKey().trim() : "";
+        this.demoPassword = config.demoPassword() != null ? 
config.demoPassword().trim() : "";
+        logConfigurationStatus();
+    }
+
+    /**
+     * Reports whether the sample is usable, so that starting the bundle after 
configuring it is a
+     * self-checking step. Re-runs on every configuration update because 
{@link Modified} is applied
+     * to {@link #activate}, so correcting a value and running {@code 
config:update} reprints this.
+     * <p>
+     * Never logs a credential, only whether one is present.
+     */
+    private void logConfigurationStatus() {
+        List<String> missing = new ArrayList<>();
+        if (demoPassword.isEmpty()) {
+            missing.add("demoPassword (the password the login form accepts)");
+        }
+        if (privateKey.isEmpty()) {
+            missing.add("privateKey (a tenant private API key)");
+        }
+
+        if (missing.isEmpty()) {
+            LOGGER.info("Login sample ready - open {}/login/index.html 
(tenantId={}, scope={})",
+                    unomiBaseUrl, tenantId, scope);
+            return;
+        }
+
+        LOGGER.warn("Login sample is NOT usable yet, missing configuration: 
{}.\n"
+                        + "Set it from the Karaf console, then start the 
bundle again:\n"
+                        + "  config:edit {}\n"
+                        + "  config:property-set demoPassword 
<choose-a-password>\n"
+                        + "  config:property-set privateKey 
<tenant-private-key>\n"
+                        + "  config:update",
+                String.join(", ", missing), CONFIGURATION_PID);
+    }
+
+    @Override
+    protected void doPost(HttpServletRequest req, HttpServletResponse resp) 
throws IOException {
+        // This endpoint is an unauthenticated state-changing POST that then 
calls Unomi with trusted
+        // credentials, so a hostile page could otherwise drive it from a 
victim's browser. A real
+        // integration must use a proper per-session CSRF token; this 
same-origin check is only the
+        // lightweight equivalent that fits a sample.
+        if (!isSameOrigin(req)) {
+            writeError(resp, HttpServletResponse.SC_FORBIDDEN, "Cross-origin 
request rejected");
+            return;
+        }
+
+        String email = trim(req.getParameter("email"));
+        String firstName = trim(req.getParameter("firstName"));
+        String lastName = trim(req.getParameter("lastName"));
+        String password = trim(req.getParameter("password"));
+
+        // No demo password ships with the sample, for the same reason Unomi 
itself no longer ships a
+        // default admin password: a credential baked into published source is 
a credential everyone has.
+        if (demoPassword.isEmpty()) {
+            writeError(resp, HttpServletResponse.SC_SERVICE_UNAVAILABLE,
+                    "demoPassword is not configured: run 'config:edit " + 
CONFIGURATION_PID + "', "
+                            + "'config:property-set demoPassword <password>', 
'config:update'");
+            return;
+        }
+        if (!demoPassword.equals(password)) {
+            writeError(resp, HttpServletResponse.SC_UNAUTHORIZED, "Invalid 
credentials");
+            return;
+        }
+        if (email.isEmpty()) {
+            writeError(resp, HttpServletResponse.SC_BAD_REQUEST, "email is 
required");
+            return;
+        }
+
+        // The session id must be derived from state this servlet controls, 
never from the request
+        // parameters. We call Unomi with trusted credentials, and a trusted 
caller is allowed to
+        // adopt whatever profile owns the session id it passes: forwarding a 
client-supplied id
+        // would launder untrusted client input across the trust boundary and 
let anyone who guesses
+        // another visitor's session id rebind or merge that victim's profile. 
Storing a generated
+        // id on the container's own HttpSession keeps it attacker-unreachable 
while staying stable
+        // across requests from the same browser, which is what lets Unomi 
recover the visitor's
+        // pre-login anonymous profile.
+        String sessionId = resolveSessionId(req);
+
+        // Only a tenant private key. A system administrator credential would 
also satisfy the merge
+        // gate, but it grants far more than this sample needs and is scoped 
to the whole instance
+        // rather than one tenant, so it is deliberately not accepted here.
+        if (privateKey.isEmpty()) {
+            writeError(resp, HttpServletResponse.SC_SERVICE_UNAVAILABLE,
+                    "privateKey is not configured: run 'config:edit " + 
CONFIGURATION_PID + "', "
+                            + "'config:property-set privateKey 
<tenant-private-key>', 'config:update'");
+            return;
+        }
+
+        ObjectNode contextRequest = MAPPER.createObjectNode();
+        ObjectNode source = contextRequest.putObject("source");
+        source.put("itemId", "/login");
+        source.put("itemType", "page");
+        source.put("scope", scope);
+
+        ArrayNode events = contextRequest.putArray("events");
+        ObjectNode loginEvent = events.addObject();
+        loginEvent.put("eventType", "login");
+        loginEvent.put("scope", scope);
+        ObjectNode target = loginEvent.putObject("target");
+        target.put("itemId", email);
+        target.put("itemType", "exampleUser");
+        ObjectNode targetProps = target.putObject("properties");
+        targetProps.put("email", email);
+        targetProps.put("firstName", firstName);
+        targetProps.put("lastName", lastName);
+
+        contextRequest.set("requiredProfileProperties", 
MAPPER.valueToTree(List.of("*")));
+        contextRequest.set("requiredSessionProperties", 
MAPPER.valueToTree(List.of("*")));
+
+        byte[] body = MAPPER.writeValueAsBytes(contextRequest);
+
+        int status;
+        JsonNode responseJson;
+        List<String> setCookieValues;
+        try {
+            URL url = new URL(unomiBaseUrl.replaceAll("/$", "") + 
"/cxs/context.json?sessionId="
+                    + URLEncoder.encode(sessionId, StandardCharsets.UTF_8));
+            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+            conn.setRequestMethod("POST");
+            conn.setDoOutput(true);
+            conn.setConnectTimeout(10000);
+            conn.setReadTimeout(30000);
+            conn.setRequestProperty("Content-Type", "application/json; 
charset=utf-8");
+            conn.setRequestProperty("Accept", "application/json");
+            // The tenant is the Basic auth user name: Unomi derives the 
tenant context from the key
+            // itself, so no X-Unomi-Tenant-Id header is needed.
+            String token = Base64.getEncoder()
+                    .encodeToString((tenantId + ":" + 
privateKey).getBytes(StandardCharsets.UTF_8));
+            conn.setRequestProperty("Authorization", "Basic " + token);
+
+            try (OutputStream os = conn.getOutputStream()) {
+                os.write(body);
+            }
+
+            status = conn.getResponseCode();
+            InputStream stream = status >= 400 ? conn.getErrorStream() : 
conn.getInputStream();
+            if (stream != null) {
+                responseJson = MAPPER.readTree(stream);
+            } else {
+                responseJson = MAPPER.createObjectNode();
+            }
+            // getHeaderField() only returns the first value: Unomi can set 
several cookies
+            // (profile id and session id), so every value has to be forwarded.
+            setCookieValues = headerValues(conn, "Set-Cookie");
+        } catch (IOException e) {
+            // Never let the container render a stack trace: it would disclose 
the Unomi endpoint
+            // and internal class names to an unauthenticated caller.
+            LOGGER.warn("Login sample could not complete the call to Unomi", 
e);
+            writeError(resp, HttpServletResponse.SC_BAD_GATEWAY, "Profile 
service unavailable, please try again later");
+            return;
+        }
+
+        if (setCookieValues != null) {
+            for (String setCookie : setCookieValues) {
+                if (setCookie != null) {
+                    resp.addHeader("Set-Cookie", setCookie);
+                }
+            }
+        }
+        resp.setStatus(status);
+        resp.setContentType("application/json; charset=utf-8");
+        MAPPER.writeValue(resp.getOutputStream(), responseJson);
+    }
+
+    /**
+     * Returns <em>all</em> values of a response header. Unlike {@code 
getHeaderFields().get(name)},
+     * this keeps the case-insensitive matching that {@code 
getHeaderField(name)} provided.
+     * <p>
+     * Package-private rather than private so {@code LoginServletTest} can 
cover it without
+     * reflection.
+     */
+    static List<String> headerValues(HttpURLConnection conn, String name) {
+        for (Map.Entry<String, List<String>> entry : 
conn.getHeaderFields().entrySet()) {
+            if (name.equalsIgnoreCase(entry.getKey())) {
+                return entry.getValue();
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Returns the Unomi session id bound to this browser's container session, 
generating one on
+     * first use. Deliberately not read from any request parameter or header — 
see the call site.
+     * <p>
+     * Package-private rather than private so {@code LoginServletTest} can 
exercise the trust
+     * boundary directly instead of going through reflection.
+     */
+    static String resolveSessionId(HttpServletRequest req) {
+        HttpSession httpSession = req.getSession(true);
+        // Guard against two concurrent first requests from the same browser 
generating two different
+        // ids. The session object is the conventional mutex here; do not lock 
on an interned session
+        // id, which shares a JVM-wide monitor with any other code that 
interns the same value.
+        synchronized (httpSession) {
+            Object existing = 
httpSession.getAttribute(UNOMI_SESSION_ID_ATTRIBUTE);
+            if (existing instanceof String && !((String) existing).isEmpty()) {
+                return (String) existing;
+            }
+            String generated = UUID.randomUUID().toString();
+            httpSession.setAttribute(UNOMI_SESSION_ID_ATTRIBUTE, generated);
+            // Bound the lifetime of the sessions this servlet creates. The 
demo password gate above
+            // is NOT authentication: it is a single shared demo password, so
+            // anyone can pass it repeatedly while discarding the session 
cookie each time. Every such
+            // POST would otherwise pin a container session in memory for the 
container's default
+            // timeout (commonly 30 minutes), which is a cheap 
memory-exhaustion path. Expiring these
+            // sessions after a few minutes keeps the id stable for a real 
browser's login round trip
+            // while letting the container reclaim the throwaway ones almost 
immediately.
+            httpSession.setMaxInactiveInterval(SESSION_MAX_INACTIVE_SECONDS);
+            return generated;
+        }
+    }
+
+    /**
+     * Lightweight CSRF defence: when the browser sends an {@code Origin} 
header it must match the
+     * origin this request was addressed to. A missing header (same-origin 
form posts on older
+     * browsers, curl) is tolerated; an unparsable or mismatching one is 
rejected.
+     * <p>
+     * Package-private rather than private so {@code LoginServletTest} can 
cover it without
+     * reflection.
+     */
+    static boolean isSameOrigin(HttpServletRequest req) {
+        String origin = trim(req.getHeader("Origin"));
+        if (origin.isEmpty()) {
+            return true;
+        }
+        URI originUri;
+        try {
+            originUri = new URI(origin);
+        } catch (URISyntaxException e) {
+            LOGGER.debug("Rejecting login request with unparsable Origin 
header", e);
+            return false;
+        }
+        String originScheme = originUri.getScheme();
+        String originHost = originUri.getHost();
+        if (originScheme == null || originHost == null) {
+            // Includes the opaque "null" origin sent by sandboxed frames.
+            return false;
+        }
+        return originScheme.equalsIgnoreCase(req.getScheme())
+                && originHost.equalsIgnoreCase(req.getServerName())
+                && defaultedPort(originScheme, originUri.getPort()) == 
req.getServerPort();
+    }
+
+    private static int defaultedPort(String scheme, int port) {
+        if (port != -1) {
+            return port;
+        }
+        return "https".equalsIgnoreCase(scheme) ? 443 : 80;
+    }
+
+    private static void writeError(HttpServletResponse resp, int status, 
String message) throws IOException {
+        Map<String, String> error = new LinkedHashMap<>();
+        error.put("error", message);
+        resp.setStatus(status);
+        resp.setContentType("application/json; charset=utf-8");
+        MAPPER.writeValue(resp.getOutputStream(), error);
+    }
+
+    private static String trim(String s) {
+        return s == null ? "" : s.trim();
+    }
+}
diff --git a/samples/login-integration/src/main/resources/static/index.html 
b/samples/login-integration/src/main/resources/static/index.html
new file mode 100644
index 000000000..7f2c995b4
--- /dev/null
+++ b/samples/login-integration/src/main/resources/static/index.html
@@ -0,0 +1,75 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Unomi login sample</title>
+    <link rel="stylesheet" 
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css";
+          
integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u"
 crossorigin="anonymous">
+    <script 
src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js";></script>
+    <script src="javascript/login-example.js"></script>
+</head>
+<body>
+<div class="container" style="max-width: 720px; margin-top: 2em;">
+    <h1>Login integration sample</h1>
+
+    <div id="alert_placeholder"></div>
+
+    <form id="loginForm">
+        <div class="form-group">
+            <label for="firstname">First name</label>
+            <input type="text" name="firstName" id="firstname" 
class="form-control" placeholder="First name"/>
+        </div>
+        <div class="form-group">
+            <label for="lastname">Last name</label>
+            <input type="text" name="lastName" id="lastname" 
class="form-control" placeholder="Last name"/>
+        </div>
+        <div class="form-group">
+            <label for="email">Email (merge key)</label>
+            <input type="text" name="email" id="email" class="form-control" 
placeholder="[email protected]" required/>
+        </div>
+        <div class="form-group">
+            <label for="password">Password</label>
+            <input type="password" name="password" id="password" 
class="form-control" placeholder="password printed by setup.sh" required/>
+        </div>
+        <button type="submit" class="btn btn-primary">Login</button>
+    </form>
+
+    <p class="help-block" style="margin-top: 1.5em;">
+        To test merge: login once, note <code>profileId</code>, clear the 
<code>context-profile-id</code> cookie
+        (or use a private window), login again with the <strong>same 
email</strong> — you should get the same master profile.
+    </p>
+
+    <div class="panel panel-info" style="margin-top: 1.5em;">
+        <div class="panel-heading"><strong>How this sample works</strong></div>
+        <div class="panel-body">
+            <ol>
+                <li>This page posts the form to a <strong>server-side 
servlet</strong>
+                    (<code>/login/authenticate</code>) — not to 
<code>/cxs/context.json</code>.</li>
+                <li>The servlet checks the demo password <code>setup.sh</code> 
generated and stored as
+                    <code>demoPassword</code>, then calls Unomi with 
<strong>trusted</strong> Basic
+                    credentials (a tenant private key).</li>
+                <li>The bundled <code>exampleLogin</code> rule merges on email
+                    <code>mergeProfilesOnPropertyAction</code> (trusted 
callers only, UNOMI-972).</li>
+            </ol>
+        </div>
+    </div>
+</div>
+</body>
+</html>
diff --git 
a/samples/login-integration/src/main/resources/static/javascript/login-example.js
 
b/samples/login-integration/src/main/resources/static/javascript/login-example.js
new file mode 100644
index 000000000..bbc82fac2
--- /dev/null
+++ 
b/samples/login-integration/src/main/resources/static/javascript/login-example.js
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function () {
+    // No session id is generated or sent from the browser. 
/login/authenticate calls Unomi with
+    // trusted credentials, and a trusted caller is allowed to adopt the 
profile that owns the
+    // session id it passes, so the id must come from server-side state only 
(the servlet derives
+    // it from its own HttpSession). Sending a client-chosen id here would let 
anyone rebind
+    // another visitor's profile.
+
+    function show(ok, message) {
+        var cls = ok ? "alert-success" : "alert-danger";
+        $("#alert_placeholder").html(
+            '<div class="alert ' + cls + '"><a class="close" 
data-dismiss="alert">×</a><span></span></div>'
+        );
+        $("#alert_placeholder .alert span").text(message);
+    }
+
+    $(function () {
+        $("#loginForm").on("submit", function (event) {
+            event.preventDefault();
+            $.ajax({
+                url: "/login/authenticate",
+                type: "POST",
+                data: {
+                    firstName: $("#firstname").val(),
+                    lastName: $("#lastname").val(),
+                    email: $("#email").val(),
+                    password: $("#password").val()
+                },
+                dataType: "json"
+            }).done(function (body) {
+                var email = body.profileProperties && 
body.profileProperties.email;
+                show(true, "OK — profileId=" + body.profileId
+                    + (email ? (", email=" + email) : "")
+                    + ". Clear context-profile-id and login again with the 
same email to verify merge.");
+            }).fail(function (xhr) {
+                var body = xhr.responseJSON || {};
+                var msg = body.error || body.errorMessage || xhr.responseText 
|| ("HTTP " + xhr.status);
+                show(false, msg);
+            });
+            return false;
+        });
+    });
+})();
diff --git 
a/samples/login-integration/src/test/java/org/apache/unomi/samples/login/LoginServletTest.java
 
b/samples/login-integration/src/test/java/org/apache/unomi/samples/login/LoginServletTest.java
new file mode 100644
index 000000000..c575e9395
--- /dev/null
+++ 
b/samples/login-integration/src/test/java/org/apache/unomi/samples/login/LoginServletTest.java
@@ -0,0 +1,353 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.samples.login;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpSession;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.atMostOnce;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for the security-relevant helpers of {@link LoginServlet}.
+ * <p>
+ * The methods under test are package-private (rather than private) purely so 
these tests can call
+ * them directly instead of reaching through reflection; they are not part of 
any public API.
+ */
+class LoginServletTest {
+
+    // 
------------------------------------------------------------------------------------------
+    // resolveSessionId — the trust boundary. A client-supplied session id 
must never be honoured.
+    // 
------------------------------------------------------------------------------------------
+
+    @Test
+    @DisplayName("regression guard: a client-supplied sessionId parameter is 
ignored entirely")
+    void clientSuppliedSessionIdParameterIsIgnored() {
+        String attackerSuppliedId = "victim-session-id-we-want-to-hijack";
+        HttpSession session = statefulSession();
+        HttpServletRequest req = requestWithSession(session);
+        // Simulate every channel an attacker controls: query/form parameters 
and headers.
+        when(req.getParameter(anyString())).thenReturn(attackerSuppliedId);
+        when(req.getHeader(anyString())).thenReturn(attackerSuppliedId);
+
+        String resolved = LoginServlet.resolveSessionId(req);
+
+        assertNotEquals(attackerSuppliedId, resolved,
+                "the servlet must not adopt a session id supplied by the 
caller");
+        // Stronger than comparing values: prove the request's 
attacker-controlled surface is never
+        // even consulted, so no future refactor can quietly reintroduce the 
vulnerability.
+        verify(req, never()).getParameter(anyString());
+        verify(req, never()).getParameterValues(anyString());
+        verify(req, never()).getHeader(anyString());
+        verify(req, never()).getCookies();
+    }
+
+    @Test
+    @DisplayName("the generated session id is a server-side random UUID stored 
on the container session")
+    void generatedSessionIdIsARandomUuidStoredOnTheSession() {
+        HttpSession session = statefulSession();
+
+        String resolved = 
LoginServlet.resolveSessionId(requestWithSession(session));
+
+        assertNotNull(resolved);
+        assertDoesNotThrow(() -> UUID.fromString(resolved), "expected a random 
UUID, got: " + resolved);
+        assertEquals(resolved, 
session.getAttribute("org.apache.unomi.samples.login.unomiSessionId"),
+                "the resolved id must be the one persisted on the container 
session");
+    }
+
+    @Test
+    @DisplayName("the same browser session yields a stable session id across 
calls")
+    void sameSessionYieldsStableSessionId() {
+        HttpSession session = statefulSession();
+
+        String first = 
LoginServlet.resolveSessionId(requestWithSession(session));
+        String second = 
LoginServlet.resolveSessionId(requestWithSession(session));
+        String third = 
LoginServlet.resolveSessionId(requestWithSession(session));
+
+        assertEquals(first, second);
+        assertEquals(first, third);
+    }
+
+    @Test
+    @DisplayName("two different browser sessions yield different session ids")
+    void differentSessionsYieldDifferentSessionIds() {
+        String first = 
LoginServlet.resolveSessionId(requestWithSession(statefulSession()));
+        String second = 
LoginServlet.resolveSessionId(requestWithSession(statefulSession()));
+
+        assertNotEquals(first, second);
+    }
+
+    @Test
+    @DisplayName("sessions created by this servlet get a short idle timeout so 
they cannot accumulate")
+    void createdSessionsAreGivenAShortIdleTimeout() {
+        HttpSession session = statefulSession();
+
+        LoginServlet.resolveSessionId(requestWithSession(session));
+
+        verify(session).setMaxInactiveInterval(intThatIsAShortTimeout());
+    }
+
+    @Test
+    @DisplayName("the idle timeout is applied only when the id is first 
created, not on every request")
+    void idleTimeoutIsAppliedOnlyOnFirstUse() {
+        HttpSession session = statefulSession();
+
+        LoginServlet.resolveSessionId(requestWithSession(session));
+        LoginServlet.resolveSessionId(requestWithSession(session));
+        LoginServlet.resolveSessionId(requestWithSession(session));
+
+        verify(session, atMostOnce()).setMaxInactiveInterval(anyInt());
+    }
+
+    @Test
+    @DisplayName("an existing container session is reused rather than 
replaced")
+    void existingSessionIsReused() {
+        HttpSession session = statefulSession();
+        HttpServletRequest req = requestWithSession(session);
+
+        LoginServlet.resolveSessionId(req);
+
+        // getSession(true) is correct: the servlet needs a session to exist. 
What must not happen is
+        // the servlet inventing a second identity source.
+        verify(req, times(1)).getSession(anyBoolean());
+    }
+
+    // 
------------------------------------------------------------------------------------------
+    // isSameOrigin — lightweight CSRF defence.
+    // 
------------------------------------------------------------------------------------------
+
+    @Test
+    @DisplayName("a matching origin is accepted")
+    void sameOriginIsAccepted() {
+        assertTrue(LoginServlet.isSameOrigin(
+                requestWithOrigin("http://example.com:8181";, "http", 
"example.com", 8181)));
+    }
+
+    @Test
+    @DisplayName("an http origin with no explicit port matches port 80")
+    void defaultHttpPortIsAccepted() {
+        assertTrue(LoginServlet.isSameOrigin(
+                requestWithOrigin("http://example.com";, "http", "example.com", 
80)));
+    }
+
+    @Test
+    @DisplayName("an https origin with no explicit port matches port 443")
+    void defaultHttpsPortIsAccepted() {
+        assertTrue(LoginServlet.isSameOrigin(
+                requestWithOrigin("https://example.com";, "https", 
"example.com", 443)));
+    }
+
+    @Test
+    @DisplayName("origin comparison is case-insensitive on scheme and host")
+    void originComparisonIsCaseInsensitive() {
+        assertTrue(LoginServlet.isSameOrigin(
+                requestWithOrigin("HTTP://Example.COM:8181", "http", 
"example.com", 8181)));
+    }
+
+    @Test
+    @DisplayName("a different host is rejected")
+    void differentHostIsRejected() {
+        assertFalse(LoginServlet.isSameOrigin(
+                requestWithOrigin("http://evil.example.net:8181";, "http", 
"example.com", 8181)));
+    }
+
+    @Test
+    @DisplayName("a different port is rejected")
+    void differentPortIsRejected() {
+        assertFalse(LoginServlet.isSameOrigin(
+                requestWithOrigin("http://example.com:9090";, "http", 
"example.com", 8181)));
+    }
+
+    @Test
+    @DisplayName("an implicit default port that does not match the served port 
is rejected")
+    void implicitDefaultPortMismatchIsRejected() {
+        assertFalse(LoginServlet.isSameOrigin(
+                requestWithOrigin("http://example.com";, "http", "example.com", 
8181)));
+    }
+
+    @Test
+    @DisplayName("a different scheme is rejected")
+    void differentSchemeIsRejected() {
+        assertFalse(LoginServlet.isSameOrigin(
+                requestWithOrigin("https://example.com:8181";, "http", 
"example.com", 8181)));
+    }
+
+    @Test
+    @DisplayName("a missing Origin header is tolerated")
+    void missingOriginIsTolerated() {
+        assertTrue(LoginServlet.isSameOrigin(
+                requestWithOrigin(null, "http", "example.com", 8181)));
+    }
+
+    @Test
+    @DisplayName("a blank Origin header is tolerated")
+    void blankOriginIsTolerated() {
+        assertTrue(LoginServlet.isSameOrigin(
+                requestWithOrigin("   ", "http", "example.com", 8181)));
+    }
+
+    @Test
+    @DisplayName("the opaque \"null\" origin sent by sandboxed frames is 
rejected")
+    void opaqueNullOriginIsRejected() {
+        assertFalse(LoginServlet.isSameOrigin(
+                requestWithOrigin("null", "http", "example.com", 8181)),
+                "the literal string \"null\" is an opaque origin, not a 
missing header");
+    }
+
+    @Test
+    @DisplayName("an unparsable Origin header is rejected")
+    void unparsableOriginIsRejected() {
+        assertFalse(LoginServlet.isSameOrigin(
+                requestWithOrigin("http://exa mple.com", "http", 
"example.com", 8181)));
+    }
+
+    @Test
+    @DisplayName("a syntactically valid but host-less Origin is rejected")
+    void hostlessOriginIsRejected() {
+        assertFalse(LoginServlet.isSameOrigin(
+                requestWithOrigin("file:///etc/passwd", "http", "example.com", 
8181)));
+    }
+
+    // 
------------------------------------------------------------------------------------------
+    // headerValues — multi-value, case-insensitive Set-Cookie forwarding.
+    // 
------------------------------------------------------------------------------------------
+
+    @Test
+    @DisplayName("headerValues returns every value of a repeated header")
+    void headerValuesReturnsAllValues() throws Exception {
+        Map<String, List<String>> headers = new LinkedHashMap<>();
+        headers.put("Set-Cookie", Arrays.asList("context-profile-id=p1; 
Path=/", "context-session-id=s1; Path=/"));
+
+        List<String> values = 
LoginServlet.headerValues(connectionWithHeaders(headers), "Set-Cookie");
+
+        assertEquals(Arrays.asList("context-profile-id=p1; Path=/", 
"context-session-id=s1; Path=/"), values);
+    }
+
+    @Test
+    @DisplayName("headerValues matches the header name case-insensitively")
+    void headerValuesMatchesNameCaseInsensitively() throws Exception {
+        Map<String, List<String>> headers = new LinkedHashMap<>();
+        // Servers are free to use any casing; HttpURLConnection preserves 
what came off the wire.
+        headers.put("set-cookie", new ArrayList<>(Arrays.asList("a=1", 
"b=2")));
+
+        List<String> values = 
LoginServlet.headerValues(connectionWithHeaders(headers), "Set-Cookie");
+
+        assertEquals(Arrays.asList("a=1", "b=2"), values);
+    }
+
+    @Test
+    @DisplayName("headerValues tolerates the null status-line key and returns 
null for an absent header")
+    void headerValuesReturnsNullWhenAbsent() throws Exception {
+        Map<String, List<String>> headers = new LinkedHashMap<>();
+        // HttpURLConnection.getHeaderFields() maps the HTTP status line under 
a null key.
+        headers.put(null, Arrays.asList("HTTP/1.1 200 OK"));
+        headers.put("Content-Type", Arrays.asList("application/json"));
+
+        assertNull(LoginServlet.headerValues(connectionWithHeaders(headers), 
"Set-Cookie"));
+    }
+
+    // 
------------------------------------------------------------------------------------------
+    // Fakes / helpers
+    // 
------------------------------------------------------------------------------------------
+
+    /** A mock {@link HttpSession} with real attribute storage, so id 
stability can be observed. */
+    private static HttpSession statefulSession() {
+        HttpSession session = mock(HttpSession.class);
+        Map<String, Object> attributes = new HashMap<>();
+        when(session.getAttribute(anyString())).thenAnswer(inv -> 
attributes.get(inv.<String>getArgument(0)));
+        doAnswer(inv -> {
+            attributes.put(inv.getArgument(0), inv.getArgument(1));
+            return null;
+        }).when(session).setAttribute(anyString(), any());
+        return session;
+    }
+
+    private static HttpServletRequest requestWithSession(HttpSession session) {
+        HttpServletRequest req = mock(HttpServletRequest.class);
+        when(req.getSession(anyBoolean())).thenReturn(session);
+        return req;
+    }
+
+    private static HttpServletRequest requestWithOrigin(String origin, String 
scheme, String serverName, int port) {
+        HttpServletRequest req = mock(HttpServletRequest.class);
+        when(req.getHeader("Origin")).thenReturn(origin);
+        when(req.getScheme()).thenReturn(scheme);
+        when(req.getServerName()).thenReturn(serverName);
+        when(req.getServerPort()).thenReturn(port);
+        return req;
+    }
+
+    private static HttpURLConnection connectionWithHeaders(Map<String, 
List<String>> headers) throws Exception {
+        return new HttpURLConnection(new 
URL("http://localhost:8181/cxs/context.json";)) {
+            @Override
+            public Map<String, List<String>> getHeaderFields() {
+                return headers;
+            }
+
+            @Override
+            public void connect() {
+                // never actually connects
+            }
+
+            @Override
+            public void disconnect() {
+                // nothing to release
+            }
+
+            @Override
+            public boolean usingProxy() {
+                return false;
+            }
+        };
+    }
+
+    /**
+     * Matches any timeout that is positive and no longer than ten minutes: 
the exact value is a
+     * tuning detail, but "short and bounded" is the security property we care 
about.
+     */
+    private static int intThatIsAShortTimeout() {
+        return org.mockito.ArgumentMatchers.intThat(seconds -> seconds > 0 && 
seconds <= 600);
+    }
+}

Reply via email to