diff --git a/api-sheriff/pom.xml b/api-sheriff/pom.xml
index d6800bc0..37360dda 100644
--- a/api-sheriff/pom.xml
+++ b/api-sheriff/pom.xml
@@ -265,6 +265,22 @@
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+
+ cookie-boot-test-secret
+
+
+
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/ApiSheriffLogMessages.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/ApiSheriffLogMessages.java
index 681bbcbb..f4b2bb33 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/ApiSheriffLogMessages.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/ApiSheriffLogMessages.java
@@ -28,7 +28,7 @@
* assertable. This catalogue's identifier ranges are disjoint from
* {@link de.cuioss.sheriff.gateway.config.ConfigLogMessages}'s (the boot-time configuration
* subsystem catalogue), which shares the same {@code ApiSheriff} prefix: {@code 1} / {@code 4} /
- * {@code 6-7} / {@code 100} / {@code 103-108} here vs {@code 2-3} / {@code 101-102} / {@code 200-201}
+ * {@code 6-7} / {@code 100} / {@code 103-109} here vs {@code 2-3} / {@code 101-102} / {@code 200-201}
* there — never renumber one catalogue without checking the other for a collision.
* Security-relevant {@code WARN}s record only the failure type and route id —
* never the raw offending payload. {@code DEBUG} / {@code TRACE} diagnostics use the logger
@@ -146,5 +146,17 @@ public static final class WARN {
.identifier(108)
.template("Host-vs-SNI smuggle rejected before route selection: %s")
.build();
+
+ /**
+ * A gateway-terminated reserved POST path exceeded the edge's reserved-body byte ceiling and
+ * was rejected {@code 413}. Records the ceiling and a fixed disposition
+ * ({@code declared-content-length} or {@code streamed-body}) only — never the offending body,
+ * which is precisely the unauthenticated payload this bound exists to refuse.
+ */
+ public static final LogRecord RESERVED_BODY_TOO_LARGE = LogRecordModel.builder()
+ .prefix(PREFIX)
+ .identifier(109)
+ .template("Reserved-path request body exceeded the %s byte ceiling (%s) — rejected 413")
+ .build();
}
}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/BffLogMessages.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/BffLogMessages.java
index 93e866a7..852093e7 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/BffLogMessages.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/BffLogMessages.java
@@ -31,7 +31,7 @@
* {@code 103-106}) and the configuration subsystem
* ({@link de.cuioss.sheriff.gateway.config.ConfigLogMessages}: {@code 2-3} / {@code 101-102} /
* {@code 200-201}), which share the same {@code ApiSheriff} prefix — this BFF catalogue owns
- * {@code 10-14} (INFO) and {@code 110-112} (WARN). Never renumber one catalogue without checking
+ * {@code 10-17} (INFO) and {@code 110-114} (WARN). Never renumber one catalogue without checking
* the others for a collision.
*
* No sensitive data is logged. Session subjects ({@code sub}), IdP session ids
@@ -51,7 +51,7 @@ public final class BffLogMessages {
private static final String PREFIX = "ApiSheriff";
/**
- * Info-level messages (INFO range 1-99; this catalogue owns 10-14).
+ * Info-level messages (INFO range 1-99; this catalogue owns 10-17).
*/
@UtilityClass
public static final class INFO {
@@ -97,10 +97,43 @@ public static final class INFO {
.identifier(14)
.template("RP-initiated logout completed for a require:session route")
.build();
+
+ /**
+ * A cookie-mode session was sealed into its {@code Set-Cookie}. The template carries only
+ * the bounded sealed-value length — never the sealed value, the key, or any token material.
+ */
+ public static final LogRecord COOKIE_SESSION_SEALED = LogRecordModel.builder()
+ .prefix(PREFIX)
+ .identifier(15)
+ .template("Cookie-mode session sealed (%s bytes)")
+ .build();
+
+ /**
+ * No {@code encryption_key} was configured, so a cookie-mode sealing key was generated at
+ * startup. Records only the non-sensitive fact and the affected scope — never key material.
+ */
+ public static final LogRecord COOKIE_KEY_GENERATED = LogRecordModel.builder()
+ .prefix(PREFIX)
+ .identifier(16)
+ .template("Cookie-mode sealing key generated at startup (%s) — sessions do not survive a restart")
+ .build();
+
+ /**
+ * A cookie still sealed under the decrypt-only {@code previous_key} was observed, so a key
+ * rotation is in progress: each such session is re-sealed under the current key on its next
+ * write. Recorded once per process — the condition is gateway-wide, and the detection recurs
+ * on every request for an unrotated session. Records only the bounded disposition.
+ */
+ public static final LogRecord COOKIE_ROLLOVER_IN_PROGRESS = LogRecordModel.builder()
+ .prefix(PREFIX)
+ .identifier(17)
+ .template("Cookie-mode previous-key rotation in progress (%s) — affected sessions "
+ + "re-seal under the current key on their next write")
+ .build();
}
/**
- * Warn-level messages (WARN range 100-199; this catalogue owns 110-112).
+ * Warn-level messages (WARN range 100-199; this catalogue owns 110-114).
*/
@UtilityClass
public static final class WARN {
@@ -136,5 +169,27 @@ public static final class WARN {
.identifier(112)
.template("Back-channel logout token rejected: %s")
.build();
+
+ /**
+ * A sealed session cookie failed to unseal and was treated as "no session". Records the
+ * non-sensitive rejection disposition ({@code malformed} / {@code unknown-version} /
+ * {@code unknown-key-id} / {@code authentication-tag} / {@code payload-format}) only —
+ * never the offending cookie value or any key material.
+ */
+ public static final LogRecord COOKIE_UNSEAL_REJECTED = LogRecordModel.builder()
+ .prefix(PREFIX)
+ .identifier(113)
+ .template("Sealed session cookie rejected: %s")
+ .build();
+
+ /**
+ * A sealed session cookie exceeded the browser-safe size budget, so the seal failed rather
+ * than emitting a value the browser would silently drop. Records only the bounded length.
+ */
+ public static final LogRecord COOKIE_SIZE_BUDGET_EXCEEDED = LogRecordModel.builder()
+ .prefix(PREFIX)
+ .identifier(114)
+ .template("Sealed session cookie exceeds the size budget (%s bytes) — seal refused")
+ .build();
}
}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/CookieKeyMaterial.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/CookieKeyMaterial.java
new file mode 100644
index 00000000..197c0178
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/CookieKeyMaterial.java
@@ -0,0 +1,278 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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 de.cuioss.sheriff.gateway.bff.cookie;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.Objects;
+import java.util.Optional;
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+
+import de.cuioss.sheriff.gateway.bff.BffLogMessages;
+import de.cuioss.tools.logging.CuiLogger;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The cookie-mode sealing key material (D2b, {@code session.mode: cookie}), resolved at boot into
+ * one of two first-class, fully supported production modes.
+ *
+ * (a) {@link Mode#PASSED}. The operator supplied {@code oidc.session.encryption_key}
+ * — and optionally the decrypt-only {@code oidc.session.previous_key} — as {@code ${ENV_VAR}}
+ * references, so no key material ever lives in a descriptor (ADR-0011). Each value must decode to a
+ * 256-bit key; anything else is rejected at boot with a message naming the defect but never the
+ * value. Rotation composes with this mode only.
+ *
+ * (b) {@link Mode#GENERATED}. Cookie mode is configured with no
+ * {@code encryption_key}, so a fresh AES-256 key is generated from {@link SecureRandom} at boot and
+ * INFO {@code COOKIE_KEY_GENERATED} records the fact — the mode only, never the material. This mode
+ * carries two caveats the operator must accept: every session is dropped on restart, and
+ * the key cannot be shared across replicas, so a multi-replica deployment must pass a key.
+ * There is by construction no previous key in this mode, so a {@code previous_key} without an
+ * {@code encryption_key} is refused rather than silently ignored — the same companion rule
+ * {@code ConfigValidator} enforces at configuration-validation time.
+ *
+ * The type never logs, serialises, or {@link #toString()}s key bytes; the material is reachable only
+ * through {@link #codec(String, Duration, int)} and {@link #identitySalt()}, both of which consume it
+ * without disclosing it.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class CookieKeyMaterial {
+
+ private static final CuiLogger LOGGER = new CuiLogger(CookieKeyMaterial.class);
+
+ private static final String ALGORITHM = "AES";
+ private static final int AES_256_KEY_BYTES = 32;
+ private static final int AES_256_KEY_BITS = AES_256_KEY_BYTES * 8;
+ private static final String DIGEST_ALGORITHM = "SHA-256";
+ private static final String IDENTITY_SALT_LABEL = "api-sheriff:cookie-session-identity:v1";
+ private static final String KEY_ID_LABEL = "api-sheriff:cookie-key-id:v1";
+ private static final String ENCRYPTION_KEY_FIELD = "session.encryption_key";
+ private static final String PREVIOUS_KEY_FIELD = "session.previous_key";
+
+ private final Mode mode;
+ private final SecretKey currentKey;
+ private final @Nullable SecretKey previousKey;
+
+ private CookieKeyMaterial(Mode mode, SecretKey currentKey, @Nullable SecretKey previousKey) {
+ this.mode = mode;
+ this.currentKey = currentKey;
+ this.previousKey = previousKey;
+ }
+
+ /**
+ * Resolves the configured key references into the active key material.
+ *
+ * @param encryptionKey the already-substituted {@code session.encryption_key} value, empty to
+ * select {@link Mode#GENERATED}
+ * @param previousKey the already-substituted {@code session.previous_key} value, empty when no
+ * rotation is in progress
+ * @return the resolved material
+ * @throws IllegalStateException when a supplied value is not a base64 AES-256 key, or when a
+ * {@code previous_key} is supplied without an {@code encryption_key}
+ */
+ public static CookieKeyMaterial resolve(Optional encryptionKey, Optional previousKey) {
+ Objects.requireNonNull(encryptionKey, "encryptionKey");
+ Objects.requireNonNull(previousKey, "previousKey");
+ if (encryptionKey.isEmpty()) {
+ if (previousKey.isPresent()) {
+ // A decrypt-only rotation key with no current key to roll onto is semantically
+ // nonsensical, and rotation composes with the passed-key mode only.
+ throw new IllegalStateException(
+ PREVIOUS_KEY_FIELD + " requires " + ENCRYPTION_KEY_FIELD + " — rotation composes with the "
+ + "passed-key mode only, and the generate-on-startup mode has no previous key");
+ }
+ SecretKey generated = generateKey();
+ LOGGER.info(BffLogMessages.INFO.COOKIE_KEY_GENERATED, Mode.GENERATED.diagnosticName());
+ return new CookieKeyMaterial(Mode.GENERATED, generated, null);
+ }
+ SecretKey current = decodeKey(encryptionKey.get(), ENCRYPTION_KEY_FIELD);
+ SecretKey previous = previousKey.map(value -> decodeKey(value, PREVIOUS_KEY_FIELD)).orElse(null);
+ if (previous != null && keyIdOf(current) == keyIdOf(previous)) {
+ // The 1-byte wire key id selects the generation deterministically, so two keys that
+ // derive the same id cannot be told apart. Fail the boot rather than roll over into a
+ // state where the retired cookies decrypt under the wrong key.
+ throw new IllegalStateException(ENCRYPTION_KEY_FIELD + " and " + PREVIOUS_KEY_FIELD
+ + " derive the same key id — rotate to a different encryption_key");
+ }
+ return new CookieKeyMaterial(Mode.PASSED, current, previous);
+ }
+
+ /**
+ * @return the active key-material mode, for the startup diagnostic and the producer's reporting
+ */
+ public Mode mode() {
+ return mode;
+ }
+
+ /**
+ * @return {@code true} when a decrypt-only rotation key is active
+ */
+ public boolean hasPreviousKey() {
+ return previousKey != null;
+ }
+
+ /**
+ * Builds the sealed-cookie codec over this material, wiring the decrypt-only previous key only
+ * when one is active.
+ *
+ * @param cookieName the session-cookie name bound into the associated data
+ * @param sessionTtl the absolute session lifetime from login
+ * @param maxCookieValueBytes the configured sealed cookie-value size budget
+ * @return the codec sealing under the current key and, when rotating, unsealing under both
+ */
+ public SealedSessionCookieCodec codec(String cookieName, Duration sessionTtl, int maxCookieValueBytes) {
+ SecretKey retired = previousKey;
+ if (retired == null) {
+ return new SealedSessionCookieCodec(cookieName, sessionTtl, maxCookieValueBytes, currentKey,
+ currentKeyId());
+ }
+ return new SealedSessionCookieCodec(cookieName, sessionTtl, maxCookieValueBytes, currentKey, currentKeyId(),
+ retired, keyIdOf(retired));
+ }
+
+ /**
+ * @return the key id every value sealed under the current key is stamped with
+ */
+ public byte currentKeyId() {
+ return keyIdOf(currentKey);
+ }
+
+ /**
+ * Derives a key's wire id from the key itself, so a given key carries the same id in every
+ * deployment that holds it.
+ *
+ * This is what makes rotation work across a restart: the key that was current before
+ * the rotation keeps its id when it becomes the previous key, so the cookies it sealed
+ * still select it. A positional id (say, "current is always 1") would instead hand the retired
+ * cookies to the new key and silently drop every session the rotation was meant to preserve.
+ * The 1-byte field means two keys can collide; {@link #resolve} fails the boot in that case.
+ */
+ private static byte keyIdOf(SecretKey key) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance(DIGEST_ALGORITHM);
+ digest.update(KEY_ID_LABEL.getBytes(StandardCharsets.UTF_8));
+ return digest.digest(key.getEncoded())[0];
+ } catch (NoSuchAlgorithmException unavailable) {
+ throw new IllegalStateException(DIGEST_ALGORITHM + " is required to derive the cookie-mode key id",
+ unavailable);
+ }
+ }
+
+ /**
+ * Derives the per-gateway salt keying the cookie-mode session identity. It is bound to the
+ * current sealing key, so it needs no operator input and cannot be recomputed off-gateway; the
+ * key bytes never leave this type.
+ *
+ * @return a fresh copy of the derived salt
+ */
+ public byte[] identitySalt() {
+ try {
+ MessageDigest digest = MessageDigest.getInstance(DIGEST_ALGORITHM);
+ digest.update(IDENTITY_SALT_LABEL.getBytes(StandardCharsets.UTF_8));
+ return digest.digest(currentKey.getEncoded());
+ } catch (NoSuchAlgorithmException unavailable) {
+ throw new IllegalStateException(
+ DIGEST_ALGORITHM + " is required to derive the cookie-mode session identity salt", unavailable);
+ }
+ }
+
+ /**
+ * The sealing key's length in bytes — the crypto-strength fact, carrying none of the material.
+ *
+ * Package-private on purpose: it exists so a test can assert the AES-256 key length
+ * directly. The derived {@link #identitySalt()} cannot stand in for that: it is a fixed-width
+ * SHA-256 digest, so it measures 32 bytes for a 128-bit key exactly as for a 256-bit one.
+ *
+ * @return the current sealing key's encoded length in bytes, always {@value #AES_256_KEY_BYTES}
+ */
+ int currentKeyLengthBytes() {
+ return currentKey.getEncoded().length;
+ }
+
+ /**
+ * Overridden to expose only the non-sensitive mode and rotation state — never key material.
+ *
+ * @return the redacted description
+ */
+ @Override
+ public String toString() {
+ return "CookieKeyMaterial[mode=%s, rotating=%s]".formatted(mode.diagnosticName(), hasPreviousKey());
+ }
+
+ private static SecretKey generateKey() {
+ try {
+ KeyGenerator generator = KeyGenerator.getInstance(ALGORITHM);
+ generator.init(AES_256_KEY_BITS, new SecureRandom());
+ return generator.generateKey();
+ } catch (NoSuchAlgorithmException unavailable) {
+ throw new IllegalStateException(ALGORITHM + " is required to generate the cookie-mode sealing key",
+ unavailable);
+ }
+ }
+
+ private static SecretKey decodeKey(String encoded, String field) {
+ byte[] decoded;
+ try {
+ decoded = Base64.getDecoder().decode(encoded.trim());
+ } catch (IllegalArgumentException notBase64) {
+ // The offending value is key material — name the field, never echo the value.
+ throw new IllegalStateException(field + " is not valid base64", notBase64);
+ }
+ if (decoded.length != AES_256_KEY_BYTES) {
+ throw new IllegalStateException("%s must decode to %d bytes (AES-256), but was %d"
+ .formatted(field, AES_256_KEY_BYTES, decoded.length));
+ }
+ return new SecretKeySpec(decoded, ALGORITHM);
+ }
+
+ /**
+ * The two first-class cookie key-material modes.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public enum Mode {
+
+ /** The operator passed {@code session.encryption_key}; rotation composes with this mode. */
+ PASSED("passed key"),
+
+ /** No key was configured, so one was generated at startup and is lost on restart. */
+ GENERATED("generated on startup");
+
+ private final String diagnosticName;
+
+ Mode(String diagnosticName) {
+ this.diagnosticName = diagnosticName;
+ }
+
+ /**
+ * @return the bounded, non-sensitive name this mode is reported under in diagnostics
+ */
+ public String diagnosticName() {
+ return diagnosticName;
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/CookieSessionBinding.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/CookieSessionBinding.java
new file mode 100644
index 00000000..cfbad951
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/CookieSessionBinding.java
@@ -0,0 +1,260 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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 de.cuioss.sheriff.gateway.bff.cookie;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+
+import de.cuioss.sheriff.gateway.bff.BffLogMessages;
+import de.cuioss.sheriff.gateway.bff.cookie.SealedSessionCookieCodec.CookieSizeBudgetExceededException;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding;
+import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
+import de.cuioss.tools.logging.CuiLogger;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The stateless cookie-mode {@link SessionBinding} ({@code session.mode: cookie}) — the sealed
+ * cookie is the session.
+ *
+ * {@link #bind} seals the {@link SessionRecord} into a hardened {@code Set-Cookie};
+ * {@link #resolve} unseals the request cookie, enforces the absolute TTL server-side
+ * against the sealed login instant (a browser that keeps an expired cookie past its {@code Max-Age}
+ * still gets "no session"), and reconstructs the record; {@link #persist} re-seals the rotated
+ * material; {@link #destroy} is a no-op locally — the browser's copy is cleared through
+ * {@link #clearingSetCookieHeader()}, which the logout edge emits.
+ *
+ * No IdP-driven destruction. A stateless gateway holds no index and cannot reach
+ * another browser's cookie, so {@link #idpDestruction()} reports
+ * {@link IdpDestruction#UNSUPPORTED} and both {@code destroyBySid} / {@code destroyBySub} report
+ * zero destroyed. That capability signal is what gates the back-channel logout endpoint off in this
+ * mode rather than letting it claim a destruction that never happened.
+ *
+ * Key rotation (D2). When the codec carries a decrypt-only {@code previous_key}, a
+ * cookie still sealed under it resolves normally and is marked for re-seal: because
+ * {@link SealedSessionCookieCodec#seal} is unconditionally current-key, the session's next write —
+ * {@link #bind} on a fresh login or {@link #persist} on a refresh — emits a cookie stamped with the
+ * current key id, completing the rollover. The rotation is recorded once per process as INFO
+ * {@code COOKIE_ROLLOVER_IN_PROGRESS} carrying only a bounded disposition; the per-request
+ * re-detections that follow are DEBUG, since an unrotated session is re-detected on every one of its
+ * requests until its next write. Rotation composes
+ * with the passed-key mode only: when the key material is generated on startup there is by
+ * construction no previous key, so no cookie can carry a retired key id and this path never fires.
+ *
+ * Session identity. Per the seam's single identity model, the reconstructed
+ * {@link SessionRecord#sessionId()} is a keyed digest over the sealed payload's login instant and
+ * {@code sub}. It is stable for the life of the session — so the refresh coordinator's single-flight
+ * coalescing behaves exactly as in server mode — and is never emitted to the browser:
+ * only the sealed value crosses. Single-flight is necessarily per instance here; a
+ * cross-instance duplicate refresh cannot be prevented without shared state and is this variant's
+ * documented, accepted trade-off.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class CookieSessionBinding implements SessionBinding {
+
+ private static final CuiLogger LOGGER = new CuiLogger(CookieSessionBinding.class);
+
+ private static final String DIGEST_ALGORITHM = "SHA-256";
+ private static final int IDENTITY_BYTES = 16;
+
+ /** The bounded, non-sensitive disposition recorded when a retired-key session is rolled over. */
+ private static final String RESEAL_DISPOSITION = "previous-key rollover";
+
+ private final SealedSessionCookieCodec codec;
+ private final byte[] identitySalt;
+
+ /**
+ * Latches the catalogued rollover INFO to the first retired-key detection. The rollover is a
+ * gateway-wide condition, not a per-session one, so one record per process is the whole signal —
+ * and the latch holds no session state, preserving the binding's statelessness.
+ */
+ private final AtomicBoolean rolloverRecorded = new AtomicBoolean();
+
+ /**
+ * Assembles the cookie-mode binding over its sealing codec.
+ *
+ * @param codec the AES-256-GCM sealed-cookie codec
+ * @param identitySalt the per-gateway salt keying the derived session identity, so the identity
+ * cannot be recomputed from the payload alone by anything outside this
+ * gateway. Never emitted to the browser.
+ */
+ public CookieSessionBinding(SealedSessionCookieCodec codec, byte[] identitySalt) {
+ this.codec = Objects.requireNonNull(codec, "codec");
+ this.identitySalt = Objects.requireNonNull(identitySalt, "identitySalt").clone();
+ }
+
+ @Override
+ public BoundSession bind(SessionRecord session, Instant now) {
+ Objects.requireNonNull(session, "session");
+ Objects.requireNonNull(now, "now");
+ // A fresh login anchors the absolute lifetime at this instant.
+ return seal(payloadOf(session, now), now);
+ }
+
+ @Override
+ public Optional resolve(@Nullable String cookieHeader, Instant now) {
+ Objects.requireNonNull(now, "now");
+ return codec.readSealedValue(cookieHeader)
+ .flatMap(codec::unseal)
+ .filter(unsealed -> !unsealed.payload().isExpired(codec.sessionTtl(), now))
+ .map(this::markForReseal);
+ }
+
+ /**
+ * Re-seals the rotated material into a fresh {@code Set-Cookie} without extending the
+ * session — this is the cookie-mode persistence target of a transparent token refresh.
+ *
+ * There is nothing to write server-side, so the whole of "persisting" a refresh here is emitting
+ * the new sealed value. The original login instant is re-derived from the record's absolute
+ * deadline rather than taken from {@code now}, so an endlessly-refreshed session still dies at
+ * its original absolute deadline: a refresh rotates the tokens, never the session's lifetime.
+ *
+ * @param rotated the session carrying the rotated token material
+ * @param now the reference instant, used only for the cookie's remaining {@code Max-Age}
+ * @return the re-sealed session and its single {@code Set-Cookie}
+ */
+ @Override
+ public BoundSession persist(SessionRecord rotated, Instant now) {
+ Objects.requireNonNull(rotated, "rotated");
+ Objects.requireNonNull(now, "now");
+ Instant loginInstant = rotated.expiresAt().minus(codec.sessionTtl());
+ return seal(payloadOf(rotated, loginInstant), now);
+ }
+
+ @Override
+ public void destroy(SessionRecord session) {
+ Objects.requireNonNull(session, "session");
+ // Nothing is held server-side. The browser's copy is cleared by the caller emitting
+ // clearingSetCookieHeader() — an expired-by-Max-Age cookie the browser keeps anyway is
+ // still refused by resolve()'s server-side TTL check.
+ }
+
+ @Override
+ public int destroyBySid(String sid) {
+ Objects.requireNonNull(sid, "sid");
+ return 0;
+ }
+
+ @Override
+ public int destroyBySub(String sub) {
+ Objects.requireNonNull(sub, "sub");
+ return 0;
+ }
+
+ @Override
+ public IdpDestruction idpDestruction() {
+ return IdpDestruction.UNSUPPORTED;
+ }
+
+ @Override
+ public String clearingSetCookieHeader() {
+ return codec.toClearingSetCookieHeader();
+ }
+
+ private BoundSession seal(SealedSessionPayload payload, Instant now) {
+ String sealedValue;
+ try {
+ sealedValue = codec.seal(payload);
+ } catch (CookieSizeBudgetExceededException overBudget) {
+ // The browser would silently drop a cookie this large, so the session could never be
+ // resolved again — fail loudly rather than hand back a binding that cannot work.
+ throw new IllegalStateException("cookie-mode session exceeds the cookie size budget", overBudget);
+ }
+ SessionRecord bound = toSessionRecord(payload);
+ return new BoundSession(bound,
+ List.of(codec.toSetCookieHeader(sealedValue, payload.loginInstant(), now)));
+ }
+
+ /**
+ * Reconstructs the session record and records the {@code previous_key} rollover when the value
+ * was authenticated by the retired key.
+ *
+ * Nothing is held to make the re-seal happen: {@link SealedSessionCookieCodec#seal} is
+ * unconditionally current-key, so every write this binding performs — {@link #bind} on a fresh
+ * login and {@link #persist} on a refresh — necessarily emits a cookie stamped with the current
+ * key id. The marking is therefore purely the audit record of the rollover, kept stateless so
+ * the binding holds no server-side session index.
+ *
+ * This runs on EVERY resolve. A cookie still sealed under the retired key is
+ * re-detected on every single request for that session until its next write actually re-seals it,
+ * so an unconditional INFO here would flood for the whole rotation window on a busy long-TTL
+ * deployment. The catalogued INFO is therefore latched to the FIRST detection in this process —
+ * the "recorded once" the class documentation promises — and every subsequent detection is a
+ * DEBUG diagnostic.
+ */
+ private SessionRecord markForReseal(SealedSessionCookieCodec.Unsealed unsealed) {
+ if (unsealed.sealedWithPreviousKey()) {
+ if (rolloverRecorded.compareAndSet(false, true)) {
+ LOGGER.info(BffLogMessages.INFO.COOKIE_ROLLOVER_IN_PROGRESS, RESEAL_DISPOSITION);
+ } else {
+ LOGGER.debug("Cookie-mode session still sealed under the previous key (%s) — "
+ + "its next write re-seals it under the current key", RESEAL_DISPOSITION);
+ }
+ }
+ return toSessionRecord(unsealed.payload());
+ }
+
+ private static SealedSessionPayload payloadOf(SessionRecord session, Instant loginInstant) {
+ return new SealedSessionPayload(session.accessToken(), session.refreshToken(), session.idToken(),
+ session.sub(), session.sid(), session.acr(), session.authTime(), loginInstant);
+ }
+
+ private SessionRecord toSessionRecord(SealedSessionPayload payload) {
+ return SessionRecord.builder()
+ .sessionId(derivedIdentity(payload))
+ .accessToken(payload.accessToken())
+ .refreshToken(payload.refreshToken())
+ .idToken(payload.idToken())
+ .sub(payload.sub())
+ .sid(payload.sid())
+ .expiresAt(payload.loginInstant().plus(codec.sessionTtl()))
+ .acr(payload.acr())
+ .authTime(payload.authTime())
+ .build();
+ }
+
+ /**
+ * Derives this binding's stable per-session identity: a salted digest over the sealed payload's
+ * login instant and {@code sub}. Both inputs are fixed for the life of the session, so the
+ * identity is stable across re-seals; the salt keeps it un-recomputable outside this gateway;
+ * and it is never emitted to the browser.
+ */
+ private String derivedIdentity(SealedSessionPayload payload) {
+ MessageDigest digest;
+ try {
+ digest = MessageDigest.getInstance(DIGEST_ALGORITHM);
+ } catch (NoSuchAlgorithmException unavailable) {
+ throw new IllegalStateException(DIGEST_ALGORITHM + " is required for the cookie-mode session identity",
+ unavailable);
+ }
+ digest.update(identitySalt);
+ digest.update(Long.toString(payload.loginInstant().getEpochSecond()).getBytes(StandardCharsets.UTF_8));
+ digest.update(payload.sub().getBytes(StandardCharsets.UTF_8));
+ byte[] identity = new byte[IDENTITY_BYTES];
+ System.arraycopy(digest.digest(), 0, identity, 0, IDENTITY_BYTES);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(identity);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionCookieCodec.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionCookieCodec.java
new file mode 100644
index 00000000..40f4d604
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionCookieCodec.java
@@ -0,0 +1,448 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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 de.cuioss.sheriff.gateway.bff.cookie;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.Objects;
+import java.util.Optional;
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.GCMParameterSpec;
+
+
+import de.cuioss.sheriff.gateway.bff.BffLogMessages;
+import de.cuioss.tools.logging.CuiLogger;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The AES-256-GCM sealed-session cookie codec (D1, {@code session.mode: cookie}) — the
+ * cryptographic core of the stateless BFF variant.
+ *
+ * Cookie value layout. {@code version(1B) || key-id(1B) || nonce(12B) ||
+ * ciphertext || tag(16B)}, base64url-encoded without padding. The {@code version}, {@code key-id},
+ * and the cookie name are bound into the GCM associated data, so a sealed value
+ * cannot be replayed under a different format version, a different key generation, or a different
+ * cookie name — the tag check fails and the value unseals to "no session".
+ *
+ * Nonce discipline. {@link #seal} draws a fresh 96-bit nonce from
+ * {@link SecureRandom} on every call. The nonce is never derived from the payload and
+ * never counter-based: GCM nonce reuse under one key is catastrophic (it leaks the authentication
+ * subkey and the XOR of the two plaintexts), so the only safe construction here is a fresh random
+ * nonce per seal.
+ *
+ * Fail-closed unsealing. {@link #unseal} reads the {@code version} and
+ * {@code key-id} and selects the key deterministically — never a try-every-key decrypt.
+ * Any authentication-tag failure, malformed length, unknown version, or unknown key id returns
+ * {@link Optional#empty()}: a tampered cookie is "no session", never a {@code 500}. The rejection
+ * is logged with its non-sensitive disposition only.
+ *
+ * Key rotation (D2) — the previous key is decrypt-only. The codec holds a current
+ * key and, optionally, one previous key. {@link #seal} always uses the current key and
+ * stamps its key id; the previous key is never selected for a seal, so a rollover is strictly
+ * one-way and cannot be walked backwards. {@link #unseal} selects between the two by the stamped
+ * key id and reports which one authenticated the value through
+ * {@link Unsealed#sealedWithPreviousKey()}, so the binding can complete the rollover on the
+ * session's next write. Withdrawing the previous key makes every value still sealed under it
+ * unauthenticated — an unknown key id, hence "no session", never an error.
+ *
+ * Size budget. A sealed value larger than the configured
+ * {@linkplain #maxCookieValueBytes() budget} fails the seal with
+ * {@link CookieSizeBudgetExceededException} and a logged warning — never a silent truncation. Cookie
+ * splitting across multiple {@code Set-Cookie} headers is a deliberate non-goal; an operator whose
+ * token set does not fit is expected to reduce it or run server mode.
+ *
+ * The budget is one declared number ({@code oidc.session.max_cookie_size},
+ * defaulting to {@link #DEFAULT_COOKIE_VALUE_BUDGET}) that drives BOTH ends of the round trip: the
+ * seal-time budget enforced here, and the gateway's pre-route {@code Cookie} header-value cap in
+ * {@code GatewayEdgeRoute}. Encoding the same limit as two independent constants is what made
+ * cookie mode unusable before — every request carrying a live sealed cookie was rejected {@code 400}
+ * at the edge by a 2048-character header-value cap the 4096-byte seal budget contradicted.
+ *
+ * Absolute lifetime. {@link #toSetCookieHeader} sets {@code Max-Age} to the
+ * remaining lifetime computed from the payload's login instant, so a re-seal after a token
+ * refresh never extends the session. The header reuses the landed hardening: the {@code __Host-}
+ * prefix, {@code Secure}, {@code HttpOnly}, {@code SameSite=Lax}, and {@code Path=/}.
+ *
+ * The codec is framework-agnostic and safe for concurrent use — it holds only immutable
+ * configuration and a thread-safe {@link SecureRandom}, and creates a fresh {@link Cipher} per
+ * operation.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class SealedSessionCookieCodec {
+
+ private static final CuiLogger LOGGER = new CuiLogger(SealedSessionCookieCodec.class);
+
+ /** The current sealed-cookie format version, bound into the GCM associated data. */
+ public static final byte FORMAT_VERSION = 1;
+
+ private static final String TRANSFORMATION = "AES/GCM/NoPadding";
+ private static final int NONCE_BYTES = 12;
+ private static final int TAG_BITS = 128;
+ private static final int HEADER_BYTES = 2 + NONCE_BYTES;
+ private static final int MIN_SEALED_BYTES = HEADER_BYTES + (TAG_BITS / 8);
+
+ /**
+ * The default sealed cookie-value size budget in bytes (~4 KB). Browsers are only required to
+ * accept 4096 bytes per cookie, so a larger value risks being silently dropped by the browser —
+ * which is why the default sits exactly there rather than at the gateway's transport ceiling.
+ */
+ public static final int DEFAULT_COOKIE_VALUE_BUDGET = 4096;
+
+ /**
+ * The smallest configurable budget: the encoded length of a sealed value carrying an
+ * empty plaintext ({@code version || key-id || nonce || tag}, base64url without
+ * padding). A budget below this could not admit even a structurally minimal sealed value, so it
+ * is refused at boot rather than failing every seal at runtime.
+ */
+ public static final int COOKIE_VALUE_BUDGET_FLOOR = (4 * MIN_SEALED_BYTES + 2) / 3;
+
+ /**
+ * The largest configurable budget (8 KiB), deliberately kept well below the gateway's 16 KiB
+ * inbound request-header-block limit ({@code EdgeHardeningOptions}). The {@code Cookie} header
+ * shares that block with every other inbound header, so a budget at or above the transport
+ * ceiling would produce a value the seal accepts but the transport rejects with {@code 431}.
+ */
+ public static final int COOKIE_VALUE_BUDGET_CEILING = 8192;
+
+ private static final String DISPOSITION_MALFORMED = "malformed";
+ private static final String DISPOSITION_UNKNOWN_VERSION = "unknown-version";
+ private static final String DISPOSITION_UNKNOWN_KEY_ID = "unknown-key-id";
+ private static final String DISPOSITION_TAG = "authentication-tag";
+ private static final String DISPOSITION_PAYLOAD = "payload-format";
+
+ private final SecureRandom secureRandom = new SecureRandom();
+ private final String cookieName;
+ private final Duration sessionTtl;
+ private final int maxCookieValueBytes;
+ private final SecretKey currentKey;
+ private final byte currentKeyId;
+ private final @Nullable SecretKey previousKey;
+ private final byte previousKeyId;
+
+ /**
+ * Assembles the codec without a rotation key — every value is sealed and unsealed under the one
+ * current key. This is the shape the generate-on-startup key mode takes, where there is by
+ * construction no previous key.
+ *
+ * @param cookieName the session-cookie name (bound into the associated data)
+ * @param sessionTtl the absolute session lifetime from login
+ * @param maxCookieValueBytes the sealed cookie-value size budget, the ONE declared number that
+ * also drives the gateway's pre-route {@code Cookie} header-value cap
+ * @param currentKey the AES-256 key new values are sealed under
+ * @param currentKeyId the id identifying {@code currentKey} in the cookie header
+ */
+ public SealedSessionCookieCodec(String cookieName, Duration sessionTtl, int maxCookieValueBytes,
+ SecretKey currentKey, byte currentKeyId) {
+ this.cookieName = requireNonBlank(cookieName);
+ this.sessionTtl = Objects.requireNonNull(sessionTtl, "sessionTtl");
+ this.maxCookieValueBytes = requireViableBudget(maxCookieValueBytes);
+ this.currentKey = Objects.requireNonNull(currentKey, "currentKey");
+ this.currentKeyId = currentKeyId;
+ this.previousKey = null;
+ this.previousKeyId = currentKeyId;
+ }
+
+ /**
+ * Assembles the codec with a decrypt-only rotation key. Values already sealed under
+ * {@code previousKey} keep unsealing, but nothing is ever sealed under it again.
+ *
+ * @param cookieName the session-cookie name (bound into the associated data)
+ * @param sessionTtl the absolute session lifetime from login
+ * @param maxCookieValueBytes the sealed cookie-value size budget, the ONE declared number that
+ * also drives the gateway's pre-route {@code Cookie} header-value cap
+ * @param currentKey the AES-256 key new values are sealed under
+ * @param currentKeyId the id identifying {@code currentKey} in the cookie header
+ * @param previousKey the AES-256 key retired values are still accepted under, decrypt-only
+ * @param previousKeyId the id identifying {@code previousKey}; must differ from
+ * {@code currentKeyId}, otherwise the stamped id could not select a key
+ */
+ public SealedSessionCookieCodec(String cookieName, Duration sessionTtl, int maxCookieValueBytes,
+ SecretKey currentKey, byte currentKeyId, SecretKey previousKey, byte previousKeyId) {
+ this.cookieName = requireNonBlank(cookieName);
+ this.sessionTtl = Objects.requireNonNull(sessionTtl, "sessionTtl");
+ this.maxCookieValueBytes = requireViableBudget(maxCookieValueBytes);
+ this.currentKey = Objects.requireNonNull(currentKey, "currentKey");
+ this.currentKeyId = currentKeyId;
+ this.previousKey = Objects.requireNonNull(previousKey, "previousKey");
+ if (previousKeyId == currentKeyId) {
+ // Unsealing selects the key by the stamped id alone; a shared id would make that
+ // selection ambiguous and silently turn the rotation into a try-both decrypt.
+ throw new IllegalArgumentException("previousKeyId must differ from currentKeyId");
+ }
+ this.previousKeyId = previousKeyId;
+ }
+
+ /**
+ * Seals a payload into the cookie value.
+ *
+ * @param payload the session payload to seal
+ * @return the base64url-encoded sealed cookie value
+ * @throws CookieSizeBudgetExceededException when the sealed value exceeds the configured
+ * {@linkplain #maxCookieValueBytes() budget}
+ */
+ public String seal(SealedSessionPayload payload) throws CookieSizeBudgetExceededException {
+ Objects.requireNonNull(payload, "payload");
+ byte[] nonce = new byte[NONCE_BYTES];
+ secureRandom.nextBytes(nonce);
+
+ byte[] sealed;
+ try {
+ Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+ cipher.init(Cipher.ENCRYPT_MODE, currentKey, new GCMParameterSpec(TAG_BITS, nonce));
+ cipher.updateAAD(associatedData(FORMAT_VERSION, currentKeyId));
+ sealed = cipher.doFinal(payload.encode());
+ } catch (GeneralSecurityException sealingFailure) {
+ // A misconfigured key (wrong algorithm or length) is an operator error the gateway
+ // cannot serve around — unlike unsealing, sealing has no "no session" fallback.
+ throw new IllegalStateException("cookie-mode session sealing failed", sealingFailure);
+ }
+
+ byte[] value = ByteBuffer.allocate(HEADER_BYTES + sealed.length)
+ .put(FORMAT_VERSION).put(currentKeyId).put(nonce).put(sealed).array();
+ String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(value);
+ if (encoded.length() > maxCookieValueBytes) {
+ LOGGER.warn(BffLogMessages.WARN.COOKIE_SIZE_BUDGET_EXCEEDED, encoded.length());
+ throw new CookieSizeBudgetExceededException(encoded.length(), maxCookieValueBytes);
+ }
+ LOGGER.info(BffLogMessages.INFO.COOKIE_SESSION_SEALED, encoded.length());
+ return encoded;
+ }
+
+ /**
+ * Unseals a cookie value back into its payload, fail-closed.
+ *
+ * @param cookieValue the base64url-encoded sealed value read from the request cookie
+ * @return the payload together with the key generation that authenticated it; empty when the
+ * value is malformed, carries an unknown version or key id, or fails its authentication
+ * tag — every rejection is "no session", never an error
+ */
+ public Optional unseal(String cookieValue) {
+ Objects.requireNonNull(cookieValue, "cookieValue");
+ byte[] raw;
+ try {
+ raw = Base64.getUrlDecoder().decode(cookieValue);
+ } catch (IllegalArgumentException _) {
+ // Covers a cookie value that is not valid base64url at all (truncated, re-encoded by an
+ // intermediary, or simply foreign) — "no session", never an error.
+ return reject(DISPOSITION_MALFORMED);
+ }
+ if (raw.length < MIN_SEALED_BYTES) {
+ return reject(DISPOSITION_MALFORMED);
+ }
+ byte version = raw[0];
+ if (version != FORMAT_VERSION) {
+ return reject(DISPOSITION_UNKNOWN_VERSION);
+ }
+ byte keyId = raw[1];
+ // Deterministic selection by the stamped id — never a try-both decrypt.
+ SecretKey key;
+ boolean sealedWithPreviousKey;
+ if (keyId == currentKeyId) {
+ key = currentKey;
+ sealedWithPreviousKey = false;
+ } else if (previousKey != null && keyId == previousKeyId) {
+ key = previousKey;
+ sealedWithPreviousKey = true;
+ } else {
+ return reject(DISPOSITION_UNKNOWN_KEY_ID);
+ }
+
+ byte[] nonce = new byte[NONCE_BYTES];
+ System.arraycopy(raw, 2, nonce, 0, NONCE_BYTES);
+ byte[] sealed = new byte[raw.length - HEADER_BYTES];
+ System.arraycopy(raw, HEADER_BYTES, sealed, 0, sealed.length);
+
+ byte[] plaintext;
+ try {
+ Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+ cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, nonce));
+ cipher.updateAAD(associatedData(version, keyId));
+ plaintext = cipher.doFinal(sealed);
+ } catch (GeneralSecurityException _) {
+ // Covers the tag mismatch (tampered ciphertext / nonce / tag, or a value replayed under
+ // a different cookie name or key generation) — all are "no session", never an error.
+ return reject(DISPOSITION_TAG);
+ }
+ Optional payload = SealedSessionPayload.decode(plaintext);
+ if (payload.isEmpty()) {
+ return reject(DISPOSITION_PAYLOAD);
+ }
+ return payload.map(decoded -> new Unsealed(decoded, sealedWithPreviousKey));
+ }
+
+ /**
+ * Builds the hardened {@code Set-Cookie} header carrying the sealed value, with {@code Max-Age}
+ * set to the session's remaining absolute lifetime so a re-seal never extends it.
+ *
+ * @param sealedValue the sealed cookie value from {@link #seal}
+ * @param loginInstant the payload's absolute login instant
+ * @param now the reference instant
+ * @return the hardened {@code Set-Cookie} header value
+ */
+ public String toSetCookieHeader(String sealedValue, Instant loginInstant, Instant now) {
+ Objects.requireNonNull(sealedValue, "sealedValue");
+ Objects.requireNonNull(loginInstant, "loginInstant");
+ Objects.requireNonNull(now, "now");
+ long remaining = Math.max(0L, Duration.between(now, loginInstant.plus(sessionTtl)).toSeconds());
+ return "%s=%s; Max-Age=%d; Path=/; Secure; HttpOnly; SameSite=Lax"
+ .formatted(cookieName, sealedValue, remaining);
+ }
+
+ /**
+ * Builds the {@code Set-Cookie} header value that clears the sealed session cookie.
+ *
+ * @return the clearing {@code Set-Cookie} header value
+ */
+ public String toClearingSetCookieHeader() {
+ return cookieName + "=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax";
+ }
+
+ /**
+ * Reads the sealed value out of a request {@code Cookie} header.
+ *
+ * @param cookieHeader the raw {@code Cookie} header value (may be absent/blank)
+ * @return the sealed value when the session cookie is present and non-empty; empty otherwise
+ */
+ public Optional readSealedValue(@Nullable String cookieHeader) {
+ if (cookieHeader == null || cookieHeader.isBlank()) {
+ return Optional.empty();
+ }
+ for (String pair : cookieHeader.split(";")) {
+ String trimmed = pair.trim();
+ int equals = trimmed.indexOf('=');
+ if (equals > 0 && cookieName.equals(trimmed.substring(0, equals))) {
+ String value = trimmed.substring(equals + 1);
+ return value.isEmpty() ? Optional.empty() : Optional.of(value);
+ }
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * @return the configured absolute session lifetime from login
+ */
+ public Duration sessionTtl() {
+ return sessionTtl;
+ }
+
+ /**
+ * @return the configured sealed cookie-value size budget in bytes — the same declared number the
+ * gateway derives its pre-route {@code Cookie} header-value cap from
+ */
+ public int maxCookieValueBytes() {
+ return maxCookieValueBytes;
+ }
+
+ private byte[] associatedData(byte version, byte keyId) {
+ byte[] name = cookieName.getBytes(StandardCharsets.UTF_8);
+ return ByteBuffer.allocate(name.length + 2).put(name).put(version).put(keyId).array();
+ }
+
+ private static Optional reject(String disposition) {
+ LOGGER.warn(BffLogMessages.WARN.COOKIE_UNSEAL_REJECTED, disposition);
+ return Optional.empty();
+ }
+
+ /**
+ * Refuses a budget that could not admit even a structurally minimal sealed value. The
+ * operator-facing bounds check (floor AND ceiling, with a config-pointer message) lives in
+ * {@code ConfigValidator}; this is the codec's own structural guard for programmatic callers.
+ */
+ private static int requireViableBudget(int maxCookieValueBytes) {
+ if (maxCookieValueBytes < COOKIE_VALUE_BUDGET_FLOOR) {
+ throw new IllegalArgumentException("maxCookieValueBytes must be at least %d, but was %d"
+ .formatted(COOKIE_VALUE_BUDGET_FLOOR, maxCookieValueBytes));
+ }
+ return maxCookieValueBytes;
+ }
+
+ private static String requireNonBlank(String cookieName) {
+ Objects.requireNonNull(cookieName, "cookieName");
+ if (cookieName.isBlank()) {
+ throw new IllegalArgumentException("cookieName must not be blank");
+ }
+ return cookieName;
+ }
+
+ /**
+ * The successful outcome of {@link #unseal(String)}: the authenticated payload plus which key
+ * generation authenticated it.
+ *
+ * {@code sealedWithPreviousKey} is the rotation signal {@link CookieSessionBinding} consumes —
+ * it is not a rejection reason. A value sealed under the previous key is a fully valid
+ * session; the flag only says the session still sits on the retired generation and should be
+ * rolled onto the current key by its next write.
+ *
+ * @param payload the authenticated session payload
+ * @param sealedWithPreviousKey {@code true} when the decrypt-only previous key authenticated the
+ * value, {@code false} when the current key did
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record Unsealed(SealedSessionPayload payload, boolean sealedWithPreviousKey) {
+
+ /**
+ * Canonical constructor rejecting an absent payload.
+ */
+ public Unsealed {
+ Objects.requireNonNull(payload, "payload");
+ }
+ }
+
+ /**
+ * Raised when a sealed cookie value exceeds the browser-safe size budget. Checked by design:
+ * the caller must decide what to do rather than silently emit a value the browser will drop.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public static final class CookieSizeBudgetExceededException extends Exception {
+
+ private static final long serialVersionUID = 1L;
+
+ private final transient int sealedLength;
+ private final transient int budget;
+
+ CookieSizeBudgetExceededException(int sealedLength, int budget) {
+ super("sealed session cookie is %d bytes, over the %d byte budget".formatted(sealedLength, budget));
+ this.sealedLength = sealedLength;
+ this.budget = budget;
+ }
+
+ /**
+ * @return the length the sealed value would have had
+ */
+ public int sealedLength() {
+ return sealedLength;
+ }
+
+ /**
+ * @return the configured budget the value exceeded
+ */
+ public int budget() {
+ return budget;
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionPayload.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionPayload.java
new file mode 100644
index 00000000..b52e0459
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionPayload.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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 de.cuioss.sheriff.gateway.bff.cookie;
+
+import java.nio.charset.StandardCharsets;
+import java.time.DateTimeException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * The plaintext that {@link SealedSessionCookieCodec} seals into the cookie-mode session cookie
+ * (D1, {@code session.mode: cookie}).
+ *
+ * It carries the same material a server-mode session would hold in the store: the mediated access
+ * token, the optional refresh token, and the raw ID token — the last is retained because a
+ * stateless gateway has nowhere else to keep the {@code id_token_hint} RP-initiated logout needs —
+ * plus the identity/session claims and the absolute {@link #loginInstant()} that anchors the
+ * server-enforced TTL. The login instant is the anchor rather than an expiry so a re-seal can never
+ * extend the session: the deadline is always recomputed from the original login.
+ *
+ * Wire format. {@link #encode()} produces a compact, explicit, dependency-free
+ * encoding: newline-separated fields in declaration order, each value URL-safe base64 of its UTF-8
+ * bytes (so no field value can contain the separator), with an empty field standing for an absent
+ * optional. It is an internal representation read only by {@link #decode(byte[])} after the GCM tag
+ * has already authenticated the bytes — it is never parsed from unauthenticated input.
+ *
+ * The plaintext never leaves the server unsealed. {@link #toString()} redacts every
+ * credential-bearing component, mirroring {@code SessionRecord}.
+ *
+ * @param accessToken the mediated access token injected as the upstream bearer
+ * @param refreshToken the refresh token, empty when the IdP granted none
+ * @param idToken the raw ID token retained for the logout {@code id_token_hint}
+ * @param sub the subject claim
+ * @param sid the IdP session id claim, empty when absent
+ * @param acr the authentication context class, empty when absent
+ * @param authTime the IdP authentication instant, empty when absent
+ * @param loginInstant the absolute login instant anchoring the server-enforced session TTL
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public record SealedSessionPayload(String accessToken, Optional refreshToken, String idToken,
+String sub, Optional sid, Optional acr, Optional authTime, Instant loginInstant) {
+
+ private static final String REDACTED = "***REDACTED***";
+ private static final char FIELD_SEPARATOR = '\n';
+ private static final int FIELD_COUNT = 8;
+
+ /**
+ * Canonical constructor rejecting absent mandatory components and normalizing absent optionals
+ * to {@link Optional#empty()}.
+ */
+ public SealedSessionPayload {
+ Objects.requireNonNull(accessToken, "accessToken");
+ Objects.requireNonNull(idToken, "idToken");
+ Objects.requireNonNull(sub, "sub");
+ Objects.requireNonNull(loginInstant, "loginInstant");
+ refreshToken = Objects.requireNonNullElse(refreshToken, Optional.empty());
+ sid = Objects.requireNonNullElse(sid, Optional.empty());
+ acr = Objects.requireNonNullElse(acr, Optional.empty());
+ authTime = Objects.requireNonNullElse(authTime, Optional.empty());
+ }
+
+ /**
+ * Serializes this payload into the compact wire form the codec seals.
+ *
+ * @return the UTF-8 bytes of the encoded payload
+ */
+ public byte[] encode() {
+ StringBuilder encoded = new StringBuilder();
+ appendField(encoded, accessToken);
+ appendField(encoded, refreshToken.orElse(""));
+ appendField(encoded, idToken);
+ appendField(encoded, sub);
+ appendField(encoded, sid.orElse(""));
+ appendField(encoded, acr.orElse(""));
+ appendField(encoded, authTime.map(instant -> Long.toString(instant.getEpochSecond())).orElse(""));
+ appendField(encoded, Long.toString(loginInstant.getEpochSecond()));
+ return encoded.toString().getBytes(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Reads a payload back from the wire form. The input MUST already have been authenticated by the
+ * codec's GCM tag — this method is not a parser for untrusted input.
+ *
+ * @param encoded the UTF-8 bytes produced by {@link #encode()}
+ * @return the decoded payload; empty when the bytes do not carry the expected field shape
+ * (a defensive guard against a key that authenticates a foreign format)
+ */
+ public static Optional decode(byte[] encoded) {
+ Objects.requireNonNull(encoded, "encoded");
+ String[] fields = new String(encoded, StandardCharsets.UTF_8).split(String.valueOf(FIELD_SEPARATOR), -1);
+ if (fields.length != FIELD_COUNT) {
+ return Optional.empty();
+ }
+ try {
+ return Optional.of(new SealedSessionPayload(
+ decodeField(fields[0]),
+ optionalField(fields[1]),
+ decodeField(fields[2]),
+ decodeField(fields[3]),
+ optionalField(fields[4]),
+ optionalField(fields[5]),
+ optionalField(fields[6]).map(seconds -> Instant.ofEpochSecond(Long.parseLong(seconds))),
+ Instant.ofEpochSecond(Long.parseLong(decodeField(fields[7])))));
+ } catch (IllegalArgumentException | DateTimeException _) {
+ // Base64 decode failure, epoch-second parse failure, or an epoch second that parses as a
+ // long but lies outside Instant's supported range, on bytes that authenticated: a
+ // foreign payload format under the same key. DateTimeException is NOT an
+ // IllegalArgumentException, so it must be caught explicitly or it escapes unseal() as an
+ // unhandled request error. Report "no session" rather than propagating.
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Whether the session has reached its absolute deadline at {@code now} — the deadline is
+ * inclusive, and is always computed from {@link #loginInstant()} so a re-seal cannot extend it.
+ *
+ * @param ttl the configured absolute session lifetime from login
+ * @param now the reference instant
+ * @return {@code true} when {@code now} is at or after the absolute deadline
+ */
+ public boolean isExpired(Duration ttl, Instant now) {
+ Objects.requireNonNull(ttl, "ttl");
+ Objects.requireNonNull(now, "now");
+ return !now.isBefore(loginInstant.plus(ttl));
+ }
+
+ /**
+ * Overridden to redact every credential — the three tokens. The default record
+ * {@code toString()} would otherwise print the raw token material into any log line, exception
+ * message, or debugger view.
+ *
+ * @return a string representation with all credential-bearing fields redacted
+ */
+ @Override
+ public String toString() {
+ return "SealedSessionPayload[accessToken=%s, refreshToken=%s, idToken=%s, sub=%s, sid=%s, acr=%s, authTime=%s, loginInstant=%s]"
+ .formatted(REDACTED, refreshToken.isPresent() ? "Optional[" + REDACTED + "]" : "Optional.empty",
+ REDACTED, sub, sid, acr, authTime, loginInstant);
+ }
+
+ private static void appendField(StringBuilder target, String value) {
+ if (!target.isEmpty()) {
+ target.append(FIELD_SEPARATOR);
+ }
+ target.append(Base64.getUrlEncoder().withoutPadding()
+ .encodeToString(value.getBytes(StandardCharsets.UTF_8)));
+ }
+
+ private static String decodeField(String field) {
+ return new String(Base64.getUrlDecoder().decode(field), StandardCharsets.UTF_8);
+ }
+
+ private static Optional optionalField(String field) {
+ if (field.isEmpty()) {
+ return Optional.empty();
+ }
+ String decoded = decodeField(field);
+ return decoded.isEmpty() ? Optional.empty() : Optional.of(decoded);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/package-info.java
new file mode 100644
index 00000000..1cdc848b
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/package-info.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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.
+ */
+/**
+ * The stateless cookie-mode session binding (D1, {@code session.mode: cookie}) — the second
+ * implementation of {@link de.cuioss.sheriff.gateway.bff.session.SessionBinding}, alongside the
+ * server-mode store adapter.
+ *
+ * The gateway holds no server-side session state in this mode: the mediated token
+ * set is sealed into the session cookie itself and read back per request.
+ *
+ * - {@link de.cuioss.sheriff.gateway.bff.cookie.SealedSessionPayload} is the record that gets
+ * sealed — the token material, the identity/session claims, and the absolute login instant
+ * that anchors the server-enforced TTL. Every credential-bearing component is redacted from
+ * {@code toString()}.
+ * - {@link de.cuioss.sheriff.gateway.bff.cookie.SealedSessionCookieCodec} performs the
+ * AES-256-GCM sealing: a fresh 96-bit random nonce per seal, with the format version, the
+ * key id, and the cookie name bound into the GCM associated data. Any tampering, truncation,
+ * unknown version, or unknown key id unseals to "no session" — never a server error.
+ * - {@link de.cuioss.sheriff.gateway.bff.cookie.CookieSessionBinding} is the seam
+ * implementation over the codec. It reports
+ * {@link de.cuioss.sheriff.gateway.bff.session.SessionBinding.IdpDestruction#UNSUPPORTED}
+ * because a stateless gateway holds no index to destroy another browser's session through.
+ *
+ * The classes are framework-agnostic (no CDI, no JAX-RS/Vert.x coupling) and introduce no new
+ * dependency — the sealing uses the JDK's own {@code javax.crypto} AES-GCM provider.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.gateway.bff.cookie;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/BackchannelLogoutReceiver.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/BackchannelLogoutReceiver.java
index f7e50516..e9bf3007 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/BackchannelLogoutReceiver.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/BackchannelLogoutReceiver.java
@@ -20,7 +20,7 @@
import java.util.Optional;
-import de.cuioss.sheriff.gateway.bff.session.SessionStore;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding;
import de.cuioss.sheriff.token.commons.error.TokenSheriffException;
import de.cuioss.sheriff.token.validation.domain.token.TokenContent;
import de.cuioss.tools.logging.CuiLogger;
@@ -28,7 +28,7 @@
/**
* The gateway-side back-channel logout receiver (D2c): signature-verify the logout token via the
* engine's issuer/JWKS infrastructure, run the {@link LogoutTokenValidator claim residual}, then
- * destroy the affected sessions through the store's O(1) secondary index.
+ * destroy the affected sessions through the mode-neutral {@link SessionBinding} seam.
*
* Signature verification is reached through the {@link LogoutTokenVerifier} seam — the session
* runtime binds it to the engine's token validation (reuse of {@code token-sheriff-validation},
@@ -37,10 +37,12 @@
* signature-failure and the claim-rejection paths unit-testable without a live IdP.
*
* Destruction is fail-closed and precise: a token carrying a {@code sid} destroys exactly that IdP
- * session ({@link SessionStore#destroyBySid}); a token carrying only a {@code sub} destroys every
- * session for the subject ({@link SessionStore#destroyBySub}). A signature or claim failure destroys
- * nothing. The receiver is framework-agnostic; the {@link de.cuioss.sheriff.gateway.bff.reserved.BackchannelLogoutEndpoint}
- * owns the HTTP concern.
+ * session ({@link SessionBinding#destroyBySid}); a token carrying only a {@code sub} destroys every
+ * session for the subject ({@link SessionBinding#destroyBySub}). A signature or claim failure
+ * destroys nothing. A binding that reports {@link SessionBinding.IdpDestruction#UNSUPPORTED} holds
+ * no server-side index and cannot honour either form, so the receiver rejects the token rather than
+ * reporting a destruction that never happened. The receiver is framework-agnostic; the
+ * {@link de.cuioss.sheriff.gateway.bff.reserved.BackchannelLogoutEndpoint} owns the HTTP concern.
*
* @author API Sheriff Team
* @since 1.0
@@ -51,20 +53,20 @@ public final class BackchannelLogoutReceiver {
private final LogoutTokenVerifier verifier;
private final LogoutTokenValidator validator;
- private final SessionStore sessionStore;
+ private final SessionBinding sessionBinding;
/**
- * Assembles the receiver with the signature-verification seam, the claim residual, and the store.
+ * Assembles the receiver with the signature-verification seam, the claim residual, and the binding.
*
- * @param verifier the engine signature-verification seam (bound to the JWKS token validation)
- * @param validator the pure logout-token claim pipeline
- * @param sessionStore the server-side session store destroyed through its secondary index
+ * @param verifier the engine signature-verification seam (bound to the JWKS token validation)
+ * @param validator the pure logout-token claim pipeline
+ * @param sessionBinding the mode-neutral session binding the IdP-driven destruction runs through
*/
public BackchannelLogoutReceiver(LogoutTokenVerifier verifier, LogoutTokenValidator validator,
- SessionStore sessionStore) {
+ SessionBinding sessionBinding) {
this.verifier = Objects.requireNonNull(verifier, "verifier");
this.validator = Objects.requireNonNull(validator, "validator");
- this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore");
+ this.sessionBinding = Objects.requireNonNull(sessionBinding, "sessionBinding");
}
/**
@@ -72,12 +74,19 @@ public BackchannelLogoutReceiver(LogoutTokenVerifier verifier, LogoutTokenValida
*
* @param rawLogoutToken the raw {@code logout_token} JWT from the back-channel request
* @param now the reference instant for the {@code iat} freshness check
- * @return an accepted result carrying the destroyed-session count, or a rejected result
+ * @return an accepted result carrying the destroyed-session count, or a rejected result — the
+ * latter also when the active binding cannot honour IdP-driven destruction
*/
public BackchannelResult receive(String rawLogoutToken, Instant now) {
Objects.requireNonNull(rawLogoutToken, "rawLogoutToken");
Objects.requireNonNull(now, "now");
+ if (sessionBinding.idpDestruction() == SessionBinding.IdpDestruction.UNSUPPORTED) {
+ LOGGER.debug("Back-channel logout rejected — the active session binding cannot honour "
+ + "IdP-driven sid/sub destruction");
+ return BackchannelResult.rejected();
+ }
+
TokenContent token;
try {
token = verifier.verify(rawLogoutToken);
@@ -92,8 +101,8 @@ public BackchannelResult receive(String rawLogoutToken, Instant now) {
}
LogoutTokenValidator.LogoutSubject logoutSubject = subject.get();
- int destroyed = logoutSubject.sid().map(sessionStore::destroyBySid)
- .orElseGet(() -> logoutSubject.sub().map(sessionStore::destroyBySub).orElse(0));
+ int destroyed = logoutSubject.sid().map(sessionBinding::destroyBySid)
+ .orElseGet(() -> logoutSubject.sub().map(sessionBinding::destroyBySub).orElse(0));
LOGGER.debug("Back-channel logout accepted — destroyed %s session(s)", destroyed);
return BackchannelResult.accepted(destroyed);
}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java
index c878f009..2b0ee01b 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java
@@ -17,6 +17,7 @@
import java.time.Duration;
import java.time.Instant;
+import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
@@ -24,20 +25,24 @@
import java.util.concurrent.ConcurrentMap;
+import de.cuioss.sheriff.gateway.bff.BffLogMessages;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding;
import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
-import de.cuioss.sheriff.gateway.bff.session.SessionStore;
import de.cuioss.sheriff.token.client.token.RotationResult;
import de.cuioss.sheriff.token.commons.error.TokenSheriffException;
import de.cuioss.tools.logging.CuiLogger;
+import org.jspecify.annotations.Nullable;
+
/**
* Transparent, single-flight token refresh for a {@code require: session} route (D7/D9) — the
* refresh coordinator bound to the {@code SessionAuthenticationStage} refresh seam (the D9 hook).
*
* When the mediated access token is within {@code leeway} of expiry the coordinator refreshes it
* through the engine ({@code token-sheriff-client}'s {@code RefreshFlow}, reached
- * via the {@link RefreshExchange} seam) and persists the rotated token material to the session
- * store, so the browser never sees a token and never drives a leg. The gateway re-implements
+ * via the {@link RefreshExchange} seam) and persists the rotated token material through the
+ * mode-neutral {@link SessionBinding} seam — a store write in server mode, a re-bound cookie in a
+ * stateless mode — so the browser never sees a token and never drives a leg. The gateway re-implements
* no OAuth leg: the engine owns the refresh grant, refresh-token rotation, and —
* inside its own refresh-token-family primitive — the reuse detection that revokes a
* family when a superseded refresh token is replayed (a stolen-token signal, RFC 9700). The engine
@@ -47,12 +52,13 @@
*
* Single-flight per session. Concurrent requests on one session share one
* refresh: the first request to observe near-expiry becomes the in-flight leader (registered
- * atomically in {@link #inFlight} — the check-then-act TOCTOU window is closed by the map's
- * atomic {@code putIfAbsent}, the per-session mutual-exclusion primitive); every concurrent
- * request on the same session joins the leader's result instead of launching its own refresh. The
- * leader re-resolves the session from the store under this exclusion and re-checks near-expiry, so
- * a request that arrives just after a refresh completed observes the already-rotated token and
- * makes no engine call.
+ * atomically in {@link #inFlight}, keyed on {@link SessionRecord#sessionId()} — the stable
+ * per-session identity every binding populates — so the check-then-act TOCTOU window is closed by
+ * the map's atomic {@code putIfAbsent}, the per-session mutual-exclusion primitive); every
+ * concurrent request on the same session joins the leader's result instead of launching its own
+ * refresh. The leader re-resolves the session through the binding under this exclusion and
+ * re-checks near-expiry, so a request that arrives just after a refresh completed observes the
+ * already-rotated token and makes no engine call.
*
* On-failure semantics. A {@link RefreshOutcome#failed() failed} outcome means the
* session has already been destroyed and the caller MUST treat the request as
@@ -69,14 +75,22 @@ public final class TokenRefreshCoordinator {
private static final CuiLogger LOGGER = new CuiLogger(TokenRefreshCoordinator.class);
+ /**
+ * The bounded, non-sensitive reason recorded on a refresh failure. Engine rejection and
+ * engine-detected refresh-token reuse are deliberately reported as one disposition: both end the
+ * session identically, and distinguishing them in a log line would tell an attacker whether a
+ * replayed token was recognised as reuse.
+ */
+ private static final String REFRESH_FAILURE_REASON = "engine rejection or refresh-token reuse";
+
private final Duration leeway;
private final AccessTokenExpiry accessTokenExpiry;
private final RefreshExchange refreshExchange;
- private final SessionStore sessionStore;
+ private final SessionBinding sessionBinding;
private final ConcurrentMap> inFlight = new ConcurrentHashMap<>();
/**
- * Assembles the coordinator with the refresh leeway, the engine seams, and the session store.
+ * Assembles the coordinator with the refresh leeway, the engine seams, and the session binding.
*
* @param leeway how long before access-token expiry a refresh is triggered
* ({@code session.refresh.leeway_seconds})
@@ -84,28 +98,31 @@ public final class TokenRefreshCoordinator {
* parsing; a test binds a fixed instant)
* @param refreshExchange the engine refresh seam (bound to {@code RefreshFlow#refresh}; a test
* binds a stubbed rotation or a throwing stub)
- * @param sessionStore the server-side session store the rotated record is persisted to and
- * the failed session is destroyed from
+ * @param sessionBinding the mode-neutral session binding the rotated record is persisted
+ * through and the failed session is destroyed through
*/
public TokenRefreshCoordinator(Duration leeway, AccessTokenExpiry accessTokenExpiry,
- RefreshExchange refreshExchange, SessionStore sessionStore) {
+ RefreshExchange refreshExchange, SessionBinding sessionBinding) {
this.leeway = Objects.requireNonNull(leeway, "leeway");
this.accessTokenExpiry = Objects.requireNonNull(accessTokenExpiry, "accessTokenExpiry");
this.refreshExchange = Objects.requireNonNull(refreshExchange, "refreshExchange");
- this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore");
+ this.sessionBinding = Objects.requireNonNull(sessionBinding, "sessionBinding");
}
/**
* Returns the session to mediate from, refreshing its mediated token when within {@code leeway}
* of expiry. Concurrent calls for the same session share one refresh.
*
- * @param session the resolved live session
- * @param now the reference instant
+ * @param session the resolved live session
+ * @param cookieHeader the raw request {@code Cookie} header value the session was resolved
+ * from — the leader re-resolves through it under single-flight exclusion;
+ * may be absent
+ * @param now the reference instant
* @return {@link RefreshOutcome#current(SessionRecord) current} when no refresh was needed,
- * {@link RefreshOutcome#refreshed(SessionRecord) refreshed} carrying the rotated
+ * {@link RefreshOutcome#refreshed(SessionRecord, List) refreshed} carrying the rotated
* session, or {@link RefreshOutcome#failed() failed} when the session was destroyed
*/
- public RefreshOutcome refresh(SessionRecord session, Instant now) {
+ public RefreshOutcome refresh(SessionRecord session, @Nullable String cookieHeader, Instant now) {
Objects.requireNonNull(session, "session");
Objects.requireNonNull(now, "now");
if (session.refreshToken().isEmpty() || !nearExpiry(session, now)) {
@@ -119,7 +136,7 @@ public RefreshOutcome refresh(SessionRecord session, Instant now) {
return existing.join();
}
try {
- RefreshOutcome outcome = performRefresh(sessionId, now);
+ RefreshOutcome outcome = performRefresh(cookieHeader, now);
leader.complete(outcome);
return outcome;
} finally {
@@ -131,8 +148,8 @@ public RefreshOutcome refresh(SessionRecord session, Instant now) {
}
}
- private RefreshOutcome performRefresh(String sessionId, Instant now) {
- Optional resolved = sessionStore.resolve(sessionId, now);
+ private RefreshOutcome performRefresh(@Nullable String cookieHeader, Instant now) {
+ Optional resolved = sessionBinding.resolve(cookieHeader, now);
if (resolved.isEmpty()) {
// Destroyed or expired between the near-expiry check and acquiring the lead — unauthenticated.
return RefreshOutcome.failed();
@@ -147,18 +164,18 @@ private RefreshOutcome performRefresh(String sessionId, Instant now) {
try {
RotationResult rotation = refreshExchange.exchange(presentedRefreshToken);
SessionRecord rotated = rotate(latest, rotation);
- // create() upserts by session id (SessionStore contract; InMemorySessionStore is a keyed
- // map put), so no pre-create destroy is needed on the success path. Destroying first would
- // open a window where a concurrent resolve() misses the rotating session.
- sessionStore.create(rotated);
- LOGGER.debug("Refreshed the mediated tokens for a require:session route (single-flight)");
- return RefreshOutcome.refreshed(rotated);
+ // persist() re-binds in place (an upsert in server mode, a re-seal in a stateless mode), so
+ // no pre-persist destroy is needed on the success path. Destroying first would open a
+ // window where a concurrent resolve() misses the rotating session.
+ SessionBinding.BoundSession bound = sessionBinding.persist(rotated, now);
+ LOGGER.info(BffLogMessages.INFO.TOKEN_REFRESHED);
+ return RefreshOutcome.refreshed(bound.session(), bound.setCookieHeaders());
} catch (TokenSheriffException refreshFailure) {
// Engine failure OR refresh-token reuse (the engine revoked the family) — the session can no
// longer be sustained. Destroy it so the caller re-drives login / returns 401.
- sessionStore.destroyById(sessionId);
- LOGGER.debug(refreshFailure,
- "Token refresh failed (IdP rejection or refresh-token reuse) — session destroyed");
+ sessionBinding.destroy(latest);
+ // Bounded, non-sensitive reason only — never the presented refresh token or session id.
+ LOGGER.warn(refreshFailure, BffLogMessages.WARN.SESSION_REFRESH_FAILED, REFRESH_FAILURE_REASON);
return RefreshOutcome.failed();
}
}
@@ -228,17 +245,19 @@ public interface RefreshExchange {
}
/**
- * The framework-agnostic result of a refresh attempt. Token material never crosses to the
- * browser — the rotated {@link SessionRecord} stays server-side and only its opaque session
- * cookie is carried.
+ * The framework-agnostic result of a refresh attempt. Token material is never disclosed to the
+ * browser — the rotated {@link SessionRecord} crosses only inside the binding's own cookie
+ * representation, which is opaque in server mode and authenticated-encrypted in a stateless mode.
*
- * @param kind which of the three refresh outcomes occurred
- * @param session the session to mediate from, present for {@link Kind#CURRENT} and
- * {@link Kind#REFRESHED}, empty for {@link Kind#FAILED}
+ * @param kind which of the three refresh outcomes occurred
+ * @param session the session to mediate from, present for {@link Kind#CURRENT} and
+ * {@link Kind#REFRESHED}, empty for {@link Kind#FAILED}
+ * @param setCookieHeaders the {@code Set-Cookie} header values the re-bind produced, empty when
+ * the binding needs no new cookie
* @author API Sheriff Team
* @since 1.0
*/
- public record RefreshOutcome(Kind kind, Optional session) {
+ public record RefreshOutcome(Kind kind, Optional session, List setCookieHeaders) {
/**
* The three terminal states of a refresh attempt.
@@ -261,29 +280,31 @@ public enum Kind {
public RefreshOutcome {
Objects.requireNonNull(kind, "kind");
session = Objects.requireNonNullElse(session, Optional.empty());
+ setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders);
if (kind != Kind.FAILED && session.isEmpty()) {
throw new IllegalArgumentException("a " + kind + " outcome must carry a session");
}
}
/**
- * A no-refresh-needed outcome carrying the unchanged session.
+ * A no-refresh-needed outcome carrying the unchanged session and no new cookie.
*
* @param session the live session, unchanged
* @return the current outcome
*/
public static RefreshOutcome current(SessionRecord session) {
- return new RefreshOutcome(Kind.CURRENT, Optional.of(session));
+ return new RefreshOutcome(Kind.CURRENT, Optional.of(session), List.of());
}
/**
- * A successful-refresh outcome carrying the rotated session.
+ * A successful-refresh outcome carrying the rotated session and the re-bind's cookies.
*
- * @param session the session with the rotated token material
+ * @param session the session with the rotated token material
+ * @param setCookieHeaders the {@code Set-Cookie} header values the re-bind produced
* @return the refreshed outcome
*/
- public static RefreshOutcome refreshed(SessionRecord session) {
- return new RefreshOutcome(Kind.REFRESHED, Optional.of(session));
+ public static RefreshOutcome refreshed(SessionRecord session, List setCookieHeaders) {
+ return new RefreshOutcome(Kind.REFRESHED, Optional.of(session), setCookieHeaders);
}
/**
@@ -294,7 +315,7 @@ public static RefreshOutcome refreshed(SessionRecord session) {
* @return the failed outcome
*/
public static RefreshOutcome failed() {
- return new RefreshOutcome(Kind.FAILED, Optional.empty());
+ return new RefreshOutcome(Kind.FAILED, Optional.empty(), List.of());
}
/**
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpoint.java
index e411baff..35d8f824 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpoint.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpoint.java
@@ -23,6 +23,7 @@
import de.cuioss.sheriff.gateway.bff.logout.BackchannelLogoutReceiver;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding;
import de.cuioss.tools.logging.CuiLogger;
import org.jspecify.annotations.Nullable;
@@ -42,6 +43,19 @@
* is destroyed on rejection. Per the spec both the success and error responses must be served
* uncacheable ({@code Cache-Control: no-store}); the framework edge renders that header.
*
+ * Capability gate (D4). The endpoint is gated on the active
+ * {@link SessionBinding}'s {@linkplain SessionBinding#idpDestruction() IdP-destruction capability},
+ * never on a mode string. A binding reporting {@link SessionBinding.IdpDestruction#UNSUPPORTED} — the
+ * stateless cookie-mode binding, which holds no server-side index — cannot honour an IdP-driven
+ * {@code sid}/{@code sub} destruction, so the endpoint answers {@code 404} before the form
+ * body is parsed: the {@code logout_token} is never read and the receiver is never reached. That is
+ * strictly better than accepting a post the gateway could only answer with a destruction that never
+ * happened. The gated rejection is recorded at {@code DEBUG} carrying only a bounded, non-sensitive
+ * reason — no token material — because this is a reserved, unauthenticated path: any caller who can
+ * reach the gateway could otherwise drive an unbounded WARN flood by posting to it, without a
+ * {@code logout_token}, a session, or any credential. Every other malformed-input rejection on this
+ * endpoint logs at {@code DEBUG} for the same reason. Server-mode behaviour is unchanged.
+ *
* The endpoint is framework-agnostic (raw form body in, a {@link BackchannelLogoutOutcome} the edge
* renders out — no JAX-RS/Vert.x coupling), so it is unit-testable without a container or a live IdP;
* the session runtime wires it to the request/response edge.
@@ -58,16 +72,26 @@ public final class BackchannelLogoutEndpoint {
private static final int OK = 200;
private static final int BAD_REQUEST = 400;
+ private static final int NOT_FOUND = 404;
+
+ /** The bounded, non-sensitive reason recorded when the capability gate refuses a request. */
+ private static final String DISABLED_REASON = "no-idp-destruction-capability";
private final BackchannelLogoutReceiver receiver;
+ private final SessionBinding sessionBinding;
/**
- * Assembles the endpoint with the back-channel logout receiver.
+ * Assembles the endpoint with the back-channel logout receiver and the active session binding
+ * whose IdP-destruction capability gates the endpoint.
*
- * @param receiver the transport-free back-channel logout receiver (verify, validate, destroy)
+ * @param receiver the transport-free back-channel logout receiver (verify, validate, destroy)
+ * @param sessionBinding the active session binding; a binding reporting
+ * {@link SessionBinding.IdpDestruction#UNSUPPORTED} gates the endpoint to
+ * {@code 404}
*/
- public BackchannelLogoutEndpoint(BackchannelLogoutReceiver receiver) {
+ public BackchannelLogoutEndpoint(BackchannelLogoutReceiver receiver, SessionBinding sessionBinding) {
this.receiver = Objects.requireNonNull(receiver, "receiver");
+ this.sessionBinding = Objects.requireNonNull(sessionBinding, "sessionBinding");
}
/**
@@ -76,12 +100,23 @@ public BackchannelLogoutEndpoint(BackchannelLogoutReceiver receiver) {
*
* @param rawFormBody the raw {@code application/x-www-form-urlencoded} request body, may be absent
* @param now the reference instant for the {@code iat} freshness check
- * @return {@code 200} carrying the destroyed-session count on an accepted token, or {@code 400}
+ * @return {@code 404} when the active session binding cannot honour IdP-driven destruction,
+ * {@code 200} carrying the destroyed-session count on an accepted token, or {@code 400}
* when {@code logout_token} is absent or the token is rejected
*/
public BackchannelLogoutOutcome receive(@Nullable String rawFormBody, Instant now) {
Objects.requireNonNull(now, "now");
+ if (sessionBinding.idpDestruction() == SessionBinding.IdpDestruction.UNSUPPORTED) {
+ // Fail closed before the body is touched: the logout_token is never read and the receiver
+ // is never reached, so the gateway cannot report a destruction it could not perform.
+ // DEBUG, not WARN: this path is reserved and unauthenticated, so a WARN here is an
+ // attacker-triggerable log-flood vector — see the capability-gate note on the type.
+ LOGGER.debug("Back-channel logout gated off for the active session binding (%s) — rejected 404",
+ DISABLED_REASON);
+ return BackchannelLogoutOutcome.error(NOT_FOUND);
+ }
+
Optional logoutToken = extractLogoutToken(rawFormBody);
if (logoutToken.isEmpty()) {
LOGGER.debug("Back-channel logout request missing the logout_token form parameter — rejected");
@@ -126,10 +161,12 @@ private static Optional decode(String value) {
/**
* The framework-agnostic result of a back-channel logout: the HTTP status the edge returns and,
- * on acceptance, how many server-side sessions were destroyed. A rejected outcome destroys nothing.
- * Both outcomes must be served uncacheable ({@code Cache-Control: no-store}) by the edge.
+ * on acceptance, how many server-side sessions were destroyed. A rejected or gated outcome
+ * destroys nothing. Every outcome must be served uncacheable ({@code Cache-Control: no-store}) by
+ * the edge.
*
- * @param status the HTTP status the edge returns ({@code 200} accepted, {@code 400} rejected)
+ * @param status the HTTP status the edge returns ({@code 200} accepted, {@code 400} rejected,
+ * {@code 404} gated off for a binding without IdP-destruction capability)
* @param destroyed the number of sessions destroyed, always {@code 0} for a rejected outcome
* @author API Sheriff Team
* @since 1.0
@@ -149,7 +186,7 @@ public static BackchannelLogoutOutcome accepted(int destroyed) {
/**
* An error outcome carrying the given status and destroying nothing.
*
- * @param status the {@code 4xx} status
+ * @param status the {@code 4xx} status ({@code 400} rejected, {@code 404} gated off)
* @return the error outcome
*/
public static BackchannelLogoutOutcome error(int status) {
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java
index 9f702c11..9eaa6d65 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java
@@ -17,8 +17,10 @@
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
+import java.time.DateTimeException;
import java.time.Duration;
import java.time.Instant;
+import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@@ -27,9 +29,8 @@
import de.cuioss.sheriff.gateway.bff.pending.BindingCookieCodec;
import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord;
import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationStore;
-import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding;
import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
-import de.cuioss.sheriff.gateway.bff.session.SessionStore;
import de.cuioss.sheriff.token.client.flow.AuthorizationCodeFlow;
import de.cuioss.sheriff.token.client.flow.CallbackParameters;
import de.cuioss.sheriff.token.client.flow.FlowContext;
@@ -74,10 +75,11 @@
* runtime binds it to {@link AuthorizationCodeFlow#exchange}, so the engine owns the code
* exchange, PKCE, and {@code state}/{@code nonce}/{@code iss} validation (fail-closed). The seam
* keeps the endpoint decoupled from the confidential-client wiring (discovery metadata, client
- * authentication) and unit-testable without a live token endpoint. On success the endpoint creates
- * the server-side {@link SessionRecord}, sets the opaque {@code __Host-} session cookie, clears the
- * now-consumed binding cookie (single-use), and redirects the browser to the record's
- * same-origin-validated return URL.
+ * authentication) and unit-testable without a live token endpoint. On success the endpoint builds
+ * the {@link SessionRecord} and binds it to the browser through the mode-neutral
+ * {@link SessionBinding} seam — emitting whatever {@code Set-Cookie} that binding produces rather
+ * than building one itself — clears the now-consumed binding cookie (single-use), and redirects the
+ * browser to the record's same-origin-validated return URL.
*
* @author API Sheriff Team
* @since 1.0
@@ -88,6 +90,7 @@ public final class CallbackEndpoint {
private static final int BAD_REQUEST = 400;
private static final int FORBIDDEN = 403;
+ private static final int INTERNAL_ERROR = 500;
private static final int FOUND = 302;
private static final String CLAIM_SID = "sid";
@@ -97,28 +100,24 @@ public final class CallbackEndpoint {
private final CodeExchange codeExchange;
private final PendingAuthorizationStore pendingStore;
private final BindingCookieCodec bindingCookieCodec;
- private final SessionStore sessionStore;
- private final SessionCookieCodec sessionCookieCodec;
+ private final SessionBinding sessionBinding;
private final Duration sessionTtl;
/**
- * Assembles the callback endpoint with the exchange seam and the gateway-side stores it drives.
+ * Assembles the callback endpoint with the exchange seam and the gateway-side collaborators it drives.
*
* @param codeExchange the engine code-exchange seam (bound to {@link AuthorizationCodeFlow#exchange})
* @param pendingStore the single-use pending-authorization store
* @param bindingCookieCodec the browser-binding cookie codec
- * @param sessionStore the server-side session store
- * @param sessionCookieCodec the opaque session-cookie codec
+ * @param sessionBinding the mode-neutral session binding the new session is bound through
* @param sessionTtl the absolute session lifetime from login
*/
public CallbackEndpoint(CodeExchange codeExchange, PendingAuthorizationStore pendingStore,
- BindingCookieCodec bindingCookieCodec, SessionStore sessionStore, SessionCookieCodec sessionCookieCodec,
- Duration sessionTtl) {
+ BindingCookieCodec bindingCookieCodec, SessionBinding sessionBinding, Duration sessionTtl) {
this.codeExchange = Objects.requireNonNull(codeExchange, "codeExchange");
this.pendingStore = Objects.requireNonNull(pendingStore, "pendingStore");
this.bindingCookieCodec = Objects.requireNonNull(bindingCookieCodec, "bindingCookieCodec");
- this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore");
- this.sessionCookieCodec = Objects.requireNonNull(sessionCookieCodec, "sessionCookieCodec");
+ this.sessionBinding = Objects.requireNonNull(sessionBinding, "sessionBinding");
this.sessionTtl = Objects.requireNonNull(sessionTtl, "sessionTtl");
}
@@ -131,7 +130,9 @@ public CallbackEndpoint(CodeExchange codeExchange, PendingAuthorizationStore pen
* POST callback (never map-collapsed — BFF-13)
* @param cookieHeader the raw request {@code Cookie} header value, may be absent
* @param now the reference instant (TTL anchor for pending resolution and session expiry)
- * @return the redirect outcome on success, or a {@code 400}/{@code 403} error outcome
+ * @return the redirect outcome on success, a {@code 400}/{@code 403} error outcome when the
+ * callback is rejected, or a {@code 500} error outcome when the validated session could
+ * not be bound (e.g. the sealed cookie-mode value exceeds the cookie-size budget)
*/
public CallbackOutcome handle(String rawParameters, @Nullable String cookieHeader, Instant now) {
Objects.requireNonNull(rawParameters, "rawParameters");
@@ -192,9 +193,8 @@ private CallbackOutcome completeLogin(AuthorizationCodeFlow.AuthenticationResult
return CallbackOutcome.error(BAD_REQUEST);
}
- String sessionId = SessionRecord.newSessionId();
SessionRecord session = SessionRecord.builder()
- .sessionId(sessionId)
+ .sessionId(SessionRecord.newSessionId())
.accessToken(accessToken.getRawToken())
.refreshToken(Optional.empty())
.idToken(idToken.getRawToken())
@@ -204,11 +204,23 @@ private CallbackOutcome completeLogin(AuthorizationCodeFlow.AuthenticationResult
.acr(claim(idToken, CLAIM_ACR))
.authTime(claimEpochSeconds(idToken, CLAIM_AUTH_TIME))
.build();
- sessionStore.create(session);
+ SessionBinding.BoundSession bound;
+ try {
+ bound = sessionBinding.bind(session, now);
+ } catch (IllegalStateException bindFailure) {
+ // bind() is documented to throw when the binding cannot hold the session: the stateless
+ // cookie binding refuses a sealed value beyond the browser-safe cookie-size budget (a
+ // realistic outcome for a large ID token or claim set), and the server binding refuses at
+ // store capacity. The exchange itself succeeded and the caller did nothing wrong, so this
+ // is an honest gateway-side 500 rather than a 4xx — and it must not escape handle() as an
+ // unhandled exception. Only the binding's own bounded reason is logged; the session's
+ // token material never reaches the log or the response.
+ LOGGER.debug(bindFailure, "OIDC callback could not bind the new session — login not completed");
+ return CallbackOutcome.error(INTERNAL_ERROR);
+ }
- List setCookies = List.of(
- sessionCookieCodec.toSetCookieHeader(sessionId),
- bindingCookieCodec.toClearingSetCookieHeader());
+ List setCookies = new ArrayList<>(bound.setCookieHeaders());
+ setCookies.add(bindingCookieCodec.toClearingSetCookieHeader());
return CallbackOutcome.redirect(pending.returnUrl(), setCookies);
}
@@ -225,7 +237,10 @@ private static Optional claimEpochSeconds(TokenContent token, String na
return claim(token, name).flatMap(raw -> {
try {
return Optional.of(Instant.ofEpochSecond(Long.parseLong(raw.trim())));
- } catch (NumberFormatException _) {
+ } catch (NumberFormatException | DateTimeException _) {
+ // An IdP-supplied auth_time is external input: it may not parse as a long, and a value
+ // that does parse can still exceed Instant's range (DateTimeException is NOT a
+ // NumberFormatException). Either way the claim is simply absent, never a 500.
return Optional.empty();
}
});
@@ -305,7 +320,7 @@ public static CallbackOutcome redirect(String location, List setCookieHe
/**
* An error outcome carrying no redirect and no cookies.
*
- * @param status the {@code 4xx} status
+ * @param status the error status ({@code 400} / {@code 403} rejected, {@code 500} unbindable)
* @return the error outcome
*/
public static CallbackOutcome error(int status) {
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpoint.java
index c974399b..6a3ae6e8 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpoint.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpoint.java
@@ -23,9 +23,8 @@
import de.cuioss.sheriff.gateway.bff.login.LoginFlow;
import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord;
-import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding;
import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
-import de.cuioss.sheriff.gateway.bff.session.SessionStore;
import de.cuioss.tools.logging.CuiLogger;
import org.jspecify.annotations.Nullable;
@@ -56,7 +55,7 @@
* Already-authenticated behaviour (explicitly defined). A caller that already
* holds a live session MUST NOT be silently re-driven through a fresh auth-code flow: the endpoint
* short-circuits straight to the same-origin-validated return URL ({@link #initiate} resolves the
- * live session through the {@link SessionCookieCodec}/{@link SessionStore} seam, exactly as
+ * live session through the mode-neutral {@link SessionBinding} seam, exactly as
* {@link UserInfoEndpoint} and {@link LogoutEndpoint} do). No pending record is created and no
* binding cookie is set on this path — there is no new login transaction to bind.
*
@@ -74,24 +73,20 @@ public final class LoginInitiationEndpoint {
private static final int FOUND = 302;
private final LoginFlow loginFlow;
- private final SessionStore sessionStore;
- private final SessionCookieCodec sessionCookieCodec;
+ private final SessionBinding sessionBinding;
private final String gatewayOrigin;
/**
* Assembles the login-initiation endpoint with the D5 login flow and the session-resolution seam.
*
- * @param loginFlow the D5 auth-code login initiation reused on the unauthenticated path
- * @param sessionStore the server-side session store resolving the opaque session id
- * @param sessionCookieCodec the opaque session-cookie codec reading the request cookie
- * @param gatewayOrigin the gateway's own origin (the {@code redirect_uri} origin) used to
- * same-origin-validate the return URL on the already-authenticated path
+ * @param loginFlow the D5 auth-code login initiation reused on the unauthenticated path
+ * @param sessionBinding the mode-neutral session binding resolving the request's live session
+ * @param gatewayOrigin the gateway's own origin (the {@code redirect_uri} origin) used to
+ * same-origin-validate the return URL on the already-authenticated path
*/
- public LoginInitiationEndpoint(LoginFlow loginFlow, SessionStore sessionStore,
- SessionCookieCodec sessionCookieCodec, String gatewayOrigin) {
+ public LoginInitiationEndpoint(LoginFlow loginFlow, SessionBinding sessionBinding, String gatewayOrigin) {
this.loginFlow = Objects.requireNonNull(loginFlow, "loginFlow");
- this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore");
- this.sessionCookieCodec = Objects.requireNonNull(sessionCookieCodec, "sessionCookieCodec");
+ this.sessionBinding = Objects.requireNonNull(sessionBinding, "sessionBinding");
this.gatewayOrigin = Objects.requireNonNull(gatewayOrigin, "gatewayOrigin");
}
@@ -112,8 +107,7 @@ public LoginInitiationOutcome initiate(@Nullable String requestedReturnUrl, @Nul
Instant now) {
Objects.requireNonNull(now, "now");
- Optional session = sessionCookieCodec.readSessionId(cookieHeader)
- .flatMap(sessionId -> sessionStore.resolve(sessionId, now));
+ Optional session = sessionBinding.resolve(cookieHeader, now);
if (session.isPresent()) {
String returnUrl = requestedReturnUrl != null
&& PendingAuthorizationRecord.sameOrigin(requestedReturnUrl, gatewayOrigin)
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java
index 786af580..e8f4e588 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java
@@ -23,9 +23,8 @@
import de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout;
-import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding;
import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
-import de.cuioss.sheriff.gateway.bff.session.SessionStore;
import de.cuioss.tools.logging.CuiLogger;
import org.jspecify.annotations.Nullable;
@@ -34,18 +33,19 @@
* The RP-initiated logout endpoint — the request/response edge over {@link RpInitiatedLogout}, the
* mirror of {@link CallbackEndpoint} for the logout direction (D5). It owns the two reserved logout
* legs ({@link ReservedPathRegistry.ReservedEndpoint#LOGOUT} and
- * {@link ReservedPathRegistry.ReservedEndpoint#LOGOUT_RETURN}) and the session store; the
+ * {@link ReservedPathRegistry.ReservedEndpoint#LOGOUT_RETURN}) and the session binding; the
* transport-free logic — token revocation, {@code state} minting, the engine end-session redirect,
* and the return-leg {@code state} verification — lives in {@link RpInitiatedLogout}.
*
- * Logout leg. {@link #logout(String, Instant)} resolves the opaque {@code __Host-}
- * session cookie to a live {@link SessionRecord}, drives {@link RpInitiatedLogout#initiate} (which
- * revokes the mediated tokens and builds the {@code end_session_endpoint} redirect carrying the
- * {@code id_token_hint}, the exact {@code post_logout_redirect_uri}, and the single-use logout-state
- * cookie), then destroys the server-side session ({@link SessionStore#destroyById}) and clears the
- * session cookie. The local session destruction is the authoritative, immediately-effective logout;
- * the IdP round-trip is layered on top. A logout request that carries no live session is
- * already logged out — the endpoint clears any stale session cookie and lands the browser on
+ * Logout leg. {@link #logout(String, Instant)} resolves the request's live
+ * {@link SessionRecord} through the mode-neutral {@link SessionBinding} seam, drives
+ * {@link RpInitiatedLogout#initiate} (which revokes the mediated tokens and builds the
+ * {@code end_session_endpoint} redirect carrying the {@code id_token_hint}, the exact
+ * {@code post_logout_redirect_uri}, and the single-use logout-state cookie), then destroys the
+ * session ({@link SessionBinding#destroy}) and clears the session cookie. The local session
+ * destruction is the authoritative, immediately-effective logout; the IdP round-trip is layered on
+ * top. A logout request that carries no live session is already logged out — the endpoint
+ * clears any stale session cookie and lands the browser on
* {@link RpInitiatedLogout#finalRedirect()} directly, bypassing the IdP round-trip (there is no
* {@code id_token_hint} to send).
*
@@ -67,21 +67,18 @@ public final class LogoutEndpoint {
private static final CuiLogger LOGGER = new CuiLogger(LogoutEndpoint.class);
private final RpInitiatedLogout rpInitiatedLogout;
- private final SessionStore sessionStore;
- private final SessionCookieCodec sessionCookieCodec;
+ private final SessionBinding sessionBinding;
/**
- * Assembles the logout endpoint with the RP-initiated logout logic and the gateway-side stores.
+ * Assembles the logout endpoint with the RP-initiated logout logic and the session binding.
*
- * @param rpInitiatedLogout the transport-free RP-initiated logout orchestration
- * @param sessionStore the server-side session store the session is destroyed in
- * @param sessionCookieCodec the opaque session-cookie codec reading and clearing the session cookie
+ * @param rpInitiatedLogout the transport-free RP-initiated logout orchestration
+ * @param sessionBinding the mode-neutral session binding the session is resolved, destroyed,
+ * and cleared through
*/
- public LogoutEndpoint(RpInitiatedLogout rpInitiatedLogout, SessionStore sessionStore,
- SessionCookieCodec sessionCookieCodec) {
+ public LogoutEndpoint(RpInitiatedLogout rpInitiatedLogout, SessionBinding sessionBinding) {
this.rpInitiatedLogout = Objects.requireNonNull(rpInitiatedLogout, "rpInitiatedLogout");
- this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore");
- this.sessionCookieCodec = Objects.requireNonNull(sessionCookieCodec, "sessionCookieCodec");
+ this.sessionBinding = Objects.requireNonNull(sessionBinding, "sessionBinding");
}
/**
@@ -97,14 +94,13 @@ public LogoutEndpoint(RpInitiatedLogout rpInitiatedLogout, SessionStore sessionS
public LogoutOutcome logout(@Nullable String cookieHeader, Instant now) {
Objects.requireNonNull(now, "now");
- Optional sessionIdOpt = sessionCookieCodec.readSessionId(cookieHeader);
- Optional session = sessionIdOpt.flatMap(id -> sessionStore.resolve(id, now));
- if (sessionIdOpt.isEmpty() || session.isEmpty()) {
+ Optional resolved = sessionBinding.resolve(cookieHeader, now);
+ if (resolved.isEmpty()) {
LOGGER.debug("RP-initiated logout without a live session — already logged out, landing on final_redirect");
return LogoutOutcome.redirect(rpInitiatedLogout.finalRedirect(),
- List.of(sessionCookieCodec.toClearingSetCookieHeader()));
+ List.of(sessionBinding.clearingSetCookieHeader()));
}
- String sessionId = sessionIdOpt.get();
+ SessionRecord session = resolved.get();
RpInitiatedLogout.LogoutRedirect redirect;
// Local logout is the authoritative, immediately-effective step and must ALWAYS succeed: if the
@@ -113,17 +109,17 @@ public LogoutOutcome logout(@Nullable String cookieHeader, Instant now) {
// redirect-construction failure can leave the local session usable.
// cui-rewrite:disable InvalidExceptionUsageRecipe
try {
- redirect = rpInitiatedLogout.initiate(session.get());
+ redirect = rpInitiatedLogout.initiate(session);
} catch (RuntimeException initiationFailure) {
- sessionStore.destroyById(sessionId);
+ sessionBinding.destroy(session);
LOGGER.debug(initiationFailure,
"RP-initiated logout — end-session redirect construction failed; local session destroyed, landing on final_redirect");
return LogoutOutcome.redirect(rpInitiatedLogout.finalRedirect(),
- List.of(sessionCookieCodec.toClearingSetCookieHeader()));
+ List.of(sessionBinding.clearingSetCookieHeader()));
}
- sessionStore.destroyById(sessionId);
+ sessionBinding.destroy(session);
List setCookies = new ArrayList<>(redirect.setCookieHeaders());
- setCookies.add(sessionCookieCodec.toClearingSetCookieHeader());
+ setCookies.add(sessionBinding.clearingSetCookieHeader());
LOGGER.debug("RP-initiated logout — session destroyed, redirecting to the IdP end_session_endpoint");
return LogoutOutcome.redirect(redirect.location(), setCookies);
}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/UserInfoEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/UserInfoEndpoint.java
index 89d94662..0c321194 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/UserInfoEndpoint.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/UserInfoEndpoint.java
@@ -25,9 +25,8 @@
import java.util.Optional;
-import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding;
import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
-import de.cuioss.sheriff.gateway.bff.session.SessionStore;
import de.cuioss.tools.logging.CuiLogger;
import org.jspecify.annotations.Nullable;
@@ -95,24 +94,21 @@ public final class UserInfoEndpoint {
private static final String AUTH_TIME = "auth_time";
private static final String ACR = "acr";
- private final SessionStore sessionStore;
- private final SessionCookieCodec sessionCookieCodec;
+ private final SessionBinding sessionBinding;
private final ClaimAllowlistFilter claimFilter;
private final ClaimSource claimSource;
/**
- * Assembles the user-info endpoint with the session stores, the operator claim allowlist, and the
+ * Assembles the user-info endpoint with the session binding, the operator claim allowlist, and the
* validated-claim resolution seam.
*
- * @param sessionStore the server-side session store resolving the opaque session id
- * @param sessionCookieCodec the opaque session-cookie codec reading the request cookie
- * @param claimFilter the operator-owned claim allowlist capping every disclosure
- * @param claimSource the validated ID-token claim-resolution seam (never raw tokens)
+ * @param sessionBinding the mode-neutral session binding resolving the request's live session
+ * @param claimFilter the operator-owned claim allowlist capping every disclosure
+ * @param claimSource the validated ID-token claim-resolution seam (never raw tokens)
*/
- public UserInfoEndpoint(SessionStore sessionStore, SessionCookieCodec sessionCookieCodec,
- ClaimAllowlistFilter claimFilter, ClaimSource claimSource) {
- this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore");
- this.sessionCookieCodec = Objects.requireNonNull(sessionCookieCodec, "sessionCookieCodec");
+ public UserInfoEndpoint(SessionBinding sessionBinding, ClaimAllowlistFilter claimFilter,
+ ClaimSource claimSource) {
+ this.sessionBinding = Objects.requireNonNull(sessionBinding, "sessionBinding");
this.claimFilter = Objects.requireNonNull(claimFilter, "claimFilter");
this.claimSource = Objects.requireNonNull(claimSource, "claimSource");
}
@@ -134,8 +130,7 @@ public UserInfoEndpoint(SessionStore sessionStore, SessionCookieCodec sessionCoo
public UserInfoOutcome handle(@Nullable String cookieHeader, @Nullable String claimsParam, Instant now) {
Objects.requireNonNull(now, "now");
- Optional session = sessionCookieCodec.readSessionId(cookieHeader)
- .flatMap(sessionId -> sessionStore.resolve(sessionId, now));
+ Optional session = sessionBinding.resolve(cookieHeader, now);
if (session.isEmpty()) {
LOGGER.debug("user-info request without a live session — 401 problem+json (never a redirect)");
return UserInfoOutcome.unauthenticated();
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java
index 2c9de828..5a74494b 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java
@@ -54,7 +54,10 @@
* {@link #dispatch(ReservedEndpoint, ReservedHttpRequest, Instant)} — the framework-agnostic
* fan-out that routes each matched reserved kind (callback, logout, logout-return, back-channel
* logout, user-info, login) to its already-wired handler and normalizes the heterogeneous
- * handler outcomes into one {@link ReservedHttpResponse} the edge renders verbatim.
+ * handler outcomes into one {@link ReservedHttpResponse} the edge renders verbatim. The
+ * back-channel arm stays wired in both session modes — the endpoint's own capability gate
+ * answers {@code 404} where IdP-driven destruction is unsupported, so the reserved path never
+ * falls through to the proxy route table.
*
* The runtime is framework-agnostic (raw request pieces in, a {@link ReservedHttpResponse} out — no
* JAX-RS / Vert.x coupling), so it is unit-testable without a container. The engine-dependent
@@ -239,7 +242,9 @@ private static ReservedHttpResponse render(LogoutEndpoint.LogoutOutcome outcome)
}
private static ReservedHttpResponse render(BackchannelLogoutEndpoint.BackchannelLogoutOutcome outcome) {
- // The OIDC back-channel logout response — success and error alike — must be served uncacheable.
+ // The OIDC back-channel logout response is served uncacheable whatever its status: the 200
+ // accepted and 400 rejected outcomes per the spec, and the 404 the endpoint's capability gate
+ // returns for a session binding that cannot honour IdP-driven destruction.
return new ReservedHttpResponse(outcome.status(), null, null, Map.of(CACHE_CONTROL, NO_STORE), List.of());
}
@@ -280,6 +285,11 @@ private static T requireNonNull(@Nullable T value) {
* @author API Sheriff Team
* @since 1.0
*/
+ // The component layout below — the line breaks between each @Nullable annotation and its
+ // component — is what the formatter inherited from de.cuioss:cui-java-parent produces. It is
+ // deliberately accepted rather than fought: this project declares no formatter plugin of its own,
+ // and adding one to change this is out of scope. Do NOT hand-reflow the header; the next
+ // format-check would simply undo it. ReservedHttpResponse below carries the identical note.
public record ReservedHttpRequest(String rawQuery, @Nullable
String cookieHeader,
@Nullable
@@ -323,6 +333,11 @@ public boolean isFormPost() {
* @author API Sheriff Team
* @since 1.0
*/
+ // The component layout below — the line breaks between each @Nullable annotation and its
+ // component — is what the formatter inherited from de.cuioss:cui-java-parent produces. It is
+ // deliberately accepted rather than fought: this project declares no formatter plugin of its own,
+ // and adding one to change this is out of scope. Do NOT hand-reflow the header; the next
+ // format-check would simply undo it. ReservedHttpRequest above carries the identical note.
public record ReservedHttpResponse(int status, @Nullable
String location, @Nullable
String jsonBody,
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStage.java
index b432993e..02a26c62 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStage.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStage.java
@@ -23,15 +23,16 @@
import java.util.Optional;
-import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding;
import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
-import de.cuioss.sheriff.gateway.bff.session.SessionStore;
import de.cuioss.sheriff.gateway.events.EventType;
import de.cuioss.sheriff.gateway.events.GatewayException;
import de.cuioss.sheriff.gateway.pipeline.PipelineRequest;
import de.cuioss.sheriff.gateway.routing.RouteRuntime;
import de.cuioss.tools.logging.CuiLogger;
+import org.jspecify.annotations.Nullable;
+
/**
* Stage 4 — the {@code require: session} runtime (D4), the server-session counterpart of the
* offline bearer validation in {@code AuthenticationStage}. It replaces the boot-time rejection the
@@ -39,18 +40,23 @@
*
* For a request selected onto a {@code require: session} route the stage:
*
- * - resolves the opaque {@code __Host-} session cookie to a live {@link SessionRecord} through
- * the {@link SessionStore} (an expired / unknown session is treated as unauthenticated);
+ * - resolves the request's live {@link SessionRecord} through the mode-neutral
+ * {@link SessionBinding} seam (an expired / unknown / unreadable session is treated as
+ * unauthenticated) — no opaque session id appears in this stage's contract, so the stage is
+ * identical for a server-side store and for a stateless binding;
* - on a live session, offers it to the single-flight {@link TokenRefresh} refresh seam (the D9
* hook — the seam owns the near-expiry decision, single-flight coalescing, and rotation; the
- * unwired binding returns the session unchanged);
+ * unwired binding returns the session unchanged) and emits any {@code Set-Cookie} the seam
+ * returns, so a binding that re-binds on refresh reaches the browser on the same response. A
+ * failed refresh destroyed the session, so the stage clears the browser's copy and
+ * re-drives the same unauthenticated negotiation rather than mediating a revoked token;
* - enforces the route's {@code required_scopes} against the mediated token's granted
* scopes through the {@link GrantedScopes} seam — a shortfall is {@code 403}
* {@link EventType#SCOPE_MISSING} (the D2c residual);
* - records the mediated access token on the request for automatic upstream injection as
* {@code Authorization: Bearer} ({@link PipelineRequest#mediatedBearer(String)} — never an
- * operator-configured header). The token material never leaves the server up to this point;
- * the forward stage renders the bearer and the opaque session cookie never crosses.
+ * operator-configured header). The token material is never disclosed to the browser up to
+ * this point; the forward stage renders the bearer and the session cookie never crosses.
*
* An unauthenticated request is content-negotiated: a navigation request
* (its {@code Accept} offers {@code text/html}) is redirected {@code 302} into the auth-code flow via
@@ -72,31 +78,27 @@ public final class SessionAuthenticationStage {
private static final String COOKIE_HEADER = "Cookie";
private static final String ACCEPT_HEADER = "Accept";
private static final String LOCATION_HEADER = "Location";
- private static final String SET_COOKIE_HEADER = "Set-Cookie";
private static final String TEXT_HTML = "text/html";
private static final int FOUND = 302;
- private final SessionStore sessionStore;
- private final SessionCookieCodec sessionCookieCodec;
+ private final SessionBinding sessionBinding;
private final TokenRefresh tokenRefresh;
private final GrantedScopes grantedScopes;
private final LoginInitiation loginInitiation;
private final Clock clock;
/**
- * Assembles the stage with the session stores and the engine / edge seams.
+ * Assembles the stage with the session binding and the engine / edge seams.
*
- * @param sessionStore the server-side session store resolving the opaque session id
- * @param sessionCookieCodec the opaque session-cookie codec reading the request cookie
- * @param tokenRefresh the single-flight near-expiry refresh seam (the D9 hook)
- * @param grantedScopes the mediated-token scope-membership seam backing {@code required_scopes}
- * @param loginInitiation the auth-code-flow initiation seam for a navigation redirect
- * @param clock the reference clock (TTL anchor for session resolution and refresh)
+ * @param sessionBinding the mode-neutral session binding resolving the request's live session
+ * @param tokenRefresh the single-flight near-expiry refresh seam (the D9 hook)
+ * @param grantedScopes the mediated-token scope-membership seam backing {@code required_scopes}
+ * @param loginInitiation the auth-code-flow initiation seam for a navigation redirect
+ * @param clock the reference clock (TTL anchor for session resolution and refresh)
*/
- public SessionAuthenticationStage(SessionStore sessionStore, SessionCookieCodec sessionCookieCodec,
- TokenRefresh tokenRefresh, GrantedScopes grantedScopes, LoginInitiation loginInitiation, Clock clock) {
- this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore");
- this.sessionCookieCodec = Objects.requireNonNull(sessionCookieCodec, "sessionCookieCodec");
+ public SessionAuthenticationStage(SessionBinding sessionBinding, TokenRefresh tokenRefresh,
+ GrantedScopes grantedScopes, LoginInitiation loginInitiation, Clock clock) {
+ this.sessionBinding = Objects.requireNonNull(sessionBinding, "sessionBinding");
this.tokenRefresh = Objects.requireNonNull(tokenRefresh, "tokenRefresh");
this.grantedScopes = Objects.requireNonNull(grantedScopes, "grantedScopes");
this.loginInitiation = Objects.requireNonNull(loginInitiation, "loginInitiation");
@@ -114,21 +116,44 @@ public void process(PipelineRequest request) {
Objects.requireNonNull(request, "request");
RouteRuntime route = requireSelectedRoute(request);
Instant now = clock.instant();
+ String cookieHeader = request.firstHeader(COOKIE_HEADER).orElse(null);
- Optional resolved = resolveSession(request, now);
+ Optional resolved = sessionBinding.resolve(cookieHeader, now);
if (resolved.isEmpty()) {
challengeUnauthenticated(request, route, now);
return;
}
- SessionRecord session = tokenRefresh.refreshIfNeeded(resolved.get(), now);
+ Optional refreshed =
+ tokenRefresh.refreshIfNeeded(resolved.get(), cookieHeader, now);
+ if (refreshed.isEmpty()) {
+ // The refresh failed and the seam already destroyed the session (an IdP rejection, or
+ // engine-detected refresh-token reuse that revoked the whole family). Mediating the
+ // pre-refresh token here would keep serving a session the gateway just revoked, so the
+ // request re-drives the SAME unauthenticated negotiation as a missing session. The
+ // clearing cookie drops the browser's stale copy of the revoked session; on the
+ // navigation branch the login challenge adds its own binding cookie for a DIFFERENT
+ // cookie name, so both must reach the browser on this one response — hence the
+ // multi-valued Set-Cookie accumulator rather than a single-valued header slot.
+ emitSetCookies(request, List.of(sessionBinding.clearingSetCookieHeader()));
+ challengeUnauthenticated(request, route, now);
+ return;
+ }
+ emitSetCookies(request, refreshed.get().setCookieHeaders());
+ SessionRecord session = refreshed.get().session();
enforceScopes(route, session);
request.mediatedBearer(session.accessToken());
}
- private Optional resolveSession(PipelineRequest request, Instant now) {
- return sessionCookieCodec.readSessionId(request.firstHeader(COOKIE_HEADER).orElse(null))
- .flatMap(sessionId -> sessionStore.resolve(sessionId, now));
+ /**
+ * Appends every supplied {@code Set-Cookie} value to the request's multi-valued Set-Cookie
+ * accumulator. Appending — never a single-valued put, never a {@code findFirst()} truncation —
+ * is what keeps BOTH the clearing cookie and the login-challenge cookie alive on the
+ * failed-refresh navigation path: the clearing cookie is what drops the browser's copy of a
+ * session the gateway just revoked, so losing it would leave a revoked session cookie in place.
+ */
+ private static void emitSetCookies(PipelineRequest request, List setCookieHeaders) {
+ setCookieHeaders.forEach(request::addResponseSetCookie);
}
private void enforceScopes(RouteRuntime route, SessionRecord session) {
@@ -143,8 +168,7 @@ private void challengeUnauthenticated(PipelineRequest request, RouteRuntime rout
if (acceptsHtml(request)) {
LoginChallenge challenge = loginInitiation.initiate(returnUrl(request), now);
request.responseHeaders().put(LOCATION_HEADER, challenge.location());
- challenge.setCookieHeaders().stream().findFirst()
- .ifPresent(cookie -> request.responseHeaders().put(SET_COOKIE_HEADER, cookie));
+ emitSetCookies(request, challenge.setCookieHeaders());
request.shortCircuit(FOUND);
LOGGER.debug("Unauthenticated navigation on require:session route %s — redirecting into login",
route.getId());
@@ -175,8 +199,8 @@ private static RouteRuntime requireSelectedRoute(PipelineRequest request) {
/**
* The single-flight near-expiry refresh seam (the D9 hook). The session runtime binds it to the
* refresh coordinator, which owns the near-expiry decision, single-flight coalescing per session,
- * and refresh-token rotation. The unwired binding returns the session unchanged, so a gateway
- * without the refresh coordinator injects the current mediated token verbatim.
+ * and refresh-token rotation. The unwired binding returns the session unchanged with no cookies,
+ * so a gateway without the refresh coordinator injects the current mediated token verbatim.
*
* @author API Sheriff Team
* @since 1.0
@@ -187,11 +211,18 @@ public interface TokenRefresh {
/**
* Returns the session to mediate from, refreshing its mediated token when near expiry.
*
- * @param session the resolved live session
- * @param now the reference instant
- * @return the same session, or a refreshed copy carrying the rotated token material
+ * @param session the resolved live session
+ * @param cookieHeader the raw request {@code Cookie} header value the session was resolved
+ * from, so the coordinator can re-resolve it under single-flight
+ * exclusion; may be absent
+ * @param now the reference instant
+ * @return the session to mediate from — the same one, or a refreshed copy carrying the
+ * rotated token material — plus any {@code Set-Cookie} the re-bind produced; or
+ * {@link Optional#empty()} when the refresh failed and the seam destroyed the
+ * session, which the stage treats as unauthenticated
*/
- SessionRecord refreshIfNeeded(SessionRecord session, Instant now);
+ Optional refreshIfNeeded(SessionRecord session, @Nullable String cookieHeader,
+ Instant now);
}
/**
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/package-info.java
index de59a3f3..2d470533 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/package-info.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/package-info.java
@@ -17,12 +17,16 @@
* The {@code require: session} stage-4 runtime (D4) — the server-session counterpart of the offline
* bearer validation.
*
- * {@link de.cuioss.sheriff.gateway.bff.runtime.SessionAuthenticationStage} resolves the opaque
- * session cookie to a live session, offers it to the single-flight refresh seam (the D9 hook),
- * enforces {@code required_scopes} against the mediated token's granted scopes ({@code 403}), and
- * records the mediated access token for automatic upstream {@code Authorization: Bearer} injection.
- * An unauthenticated request is content-negotiated: a navigation request is redirected into the
- * auth-code flow, everything else is challenged {@code 401} {@code application/problem+json}.
+ * {@link de.cuioss.sheriff.gateway.bff.runtime.SessionAuthenticationStage} resolves the request's
+ * live session through the mode-neutral
+ * {@link de.cuioss.sheriff.gateway.bff.session.SessionBinding} seam — no opaque session id appears
+ * in the stage's contract, so the stage is identical for a server-side store and for a stateless
+ * binding. It then offers the session to the single-flight refresh seam (the D9 hook), emits any
+ * {@code Set-Cookie} the binding returns, enforces {@code required_scopes} against the mediated
+ * token's granted scopes ({@code 403}), and records the mediated access token for automatic
+ * upstream {@code Authorization: Bearer} injection. An unauthenticated request is
+ * content-negotiated: a navigation request is redirected into the auth-code flow, everything else
+ * is challenged {@code 401} {@code application/problem+json}.
*
* The stage is framework-agnostic and driven through seams (refresh, scope membership, login
* initiation), so it is unit-testable without a container or a live IdP; the session runtime binds
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/ServerSessionBinding.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/ServerSessionBinding.java
new file mode 100644
index 00000000..24f1740d
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/ServerSessionBinding.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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 de.cuioss.sheriff.gateway.bff.session;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The server-mode {@link SessionBinding} ({@code session.mode: server}) — a thin adapter over the
+ * unchanged {@link SessionStore} and {@link SessionCookieCodec}.
+ *
+ * The token material stays server-side in the store and the browser carries only the opaque
+ * {@link SessionRecord#sessionId()} in the hardened {@code __Host-} session cookie, exactly as
+ * before the seam was extracted: {@link #bind} is a store {@code create} plus the opaque
+ * {@code Set-Cookie}, {@link #resolve} reads the cookie and looks the session up (the store's lazy
+ * TTL eviction applies), {@link #persist} is the store's documented upsert-by-id {@code create}
+ * (no pre-destroy, so a concurrent resolve never misses a rotating session), and {@link #destroy}
+ * is {@code destroyById}. The store's O(1) secondary indexes back the IdP-driven destruction, so
+ * this binding reports {@link IdpDestruction#SUPPORTED}.
+ *
+ * The adapter is behaviour-preserving by construction — it adds no policy of its own and holds no
+ * state beyond its two collaborators.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class ServerSessionBinding implements SessionBinding {
+
+ private final SessionStore sessionStore;
+ private final SessionCookieCodec sessionCookieCodec;
+
+ /**
+ * Assembles the server-mode binding over the session store and its opaque-cookie codec.
+ *
+ * @param sessionStore the server-side session store holding the token material
+ * @param sessionCookieCodec the opaque session-cookie codec reading and writing the handle
+ */
+ public ServerSessionBinding(SessionStore sessionStore, SessionCookieCodec sessionCookieCodec) {
+ this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore");
+ this.sessionCookieCodec = Objects.requireNonNull(sessionCookieCodec, "sessionCookieCodec");
+ }
+
+ @Override
+ public BoundSession bind(SessionRecord session, Instant now) {
+ Objects.requireNonNull(session, "session");
+ Objects.requireNonNull(now, "now");
+ sessionStore.create(session);
+ return new BoundSession(session, List.of(sessionCookieCodec.toSetCookieHeader(session.sessionId())));
+ }
+
+ @Override
+ public Optional resolve(@Nullable String cookieHeader, Instant now) {
+ Objects.requireNonNull(now, "now");
+ return sessionCookieCodec.readSessionId(cookieHeader)
+ .flatMap(sessionId -> sessionStore.resolve(sessionId, now));
+ }
+
+ @Override
+ public BoundSession persist(SessionRecord rotated, Instant now) {
+ Objects.requireNonNull(rotated, "rotated");
+ Objects.requireNonNull(now, "now");
+ // create() upserts by session id (SessionStore contract; InMemorySessionStore is a keyed map
+ // put), so no pre-destroy is needed. Destroying first would open a window where a concurrent
+ // resolve() misses the rotating session. The opaque handle is unchanged, so the browser needs
+ // no new Set-Cookie.
+ sessionStore.create(rotated);
+ return new BoundSession(rotated, List.of());
+ }
+
+ @Override
+ public void destroy(SessionRecord session) {
+ Objects.requireNonNull(session, "session");
+ sessionStore.destroyById(session.sessionId());
+ }
+
+ @Override
+ public int destroyBySid(String sid) {
+ Objects.requireNonNull(sid, "sid");
+ return sessionStore.destroyBySid(sid);
+ }
+
+ @Override
+ public int destroyBySub(String sub) {
+ Objects.requireNonNull(sub, "sub");
+ return sessionStore.destroyBySub(sub);
+ }
+
+ @Override
+ public IdpDestruction idpDestruction() {
+ return IdpDestruction.SUPPORTED;
+ }
+
+ @Override
+ public String clearingSetCookieHeader() {
+ return sessionCookieCodec.toClearingSetCookieHeader();
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionBinding.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionBinding.java
new file mode 100644
index 00000000..21d57605
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionBinding.java
@@ -0,0 +1,163 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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 de.cuioss.sheriff.gateway.bff.session;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The mode-neutral session-binding seam (D7) — the single session-state contract the whole BFF
+ * foundation binds.
+ *
+ * The contract deliberately mentions no store and no opaque id: it describes only
+ * how a {@link SessionRecord} is bound to the browser, read back from a request, re-bound after a
+ * rotation, and destroyed. That makes a stateless variant representable — a server-mode
+ * implementation keeps the record in a {@link SessionStore} and hands the browser an opaque handle,
+ * while a stateless implementation seals the record into the cookie itself. Login, CSRF, step-up,
+ * scope enforcement, and logout orchestration stay single-sourced above this seam.
+ *
+ * Session identity. Every implementation populates {@link SessionRecord#sessionId()}
+ * with a stable per-session identity, so callers that need to key per-session work — notably the
+ * single-flight coalescing in the refresh coordinator — use that component directly. The seam
+ * therefore carries no identity accessor.
+ *
+ * IdP-driven destruction. {@link #destroyBySid(String)} and
+ * {@link #destroyBySub(String)} serve OIDC back-channel logout. A stateless implementation holds no
+ * server-side index and cannot honour them; it declares that by reporting
+ * {@link IdpDestruction#UNSUPPORTED} from {@link #idpDestruction()} so callers fail closed rather
+ * than silently reporting a destruction that never happened.
+ *
+ * Implementations are framework-agnostic (raw {@code Cookie} header values in, {@code Set-Cookie}
+ * header values out — no JAX-RS/Vert.x coupling) and must be safe for concurrent use.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public interface SessionBinding {
+
+ /**
+ * Binds a freshly created session to the browser (post-login).
+ *
+ * @param session the session to bind
+ * @param now the reference instant
+ * @return the bound session and the {@code Set-Cookie} header value(s) the caller emits
+ * @throws IllegalStateException when the binding cannot bind the session at all — a stateful
+ * implementation at its capacity bound, or a stateless one whose sealed representation
+ * exceeds the browser-safe cookie size budget
+ */
+ BoundSession bind(SessionRecord session, Instant now);
+
+ /**
+ * Resolves the live session a request carries, enforcing the absolute TTL: an expired or
+ * unreadable binding is reported as absent.
+ *
+ * @param cookieHeader the raw request {@code Cookie} header value, may be absent
+ * @param now the reference instant for the TTL check
+ * @return the live session; empty when the request carries none, or it is unreadable or expired
+ */
+ Optional resolve(@Nullable String cookieHeader, Instant now);
+
+ /**
+ * Re-binds a session whose token material was rotated, without extending its absolute lifetime.
+ *
+ * @param rotated the session carrying the rotated token material
+ * @param now the reference instant
+ * @return the re-bound session and the {@code Set-Cookie} header value(s) the caller emits
+ * (empty for an implementation whose re-bind is invisible to the browser)
+ */
+ BoundSession persist(SessionRecord rotated, Instant now);
+
+ /**
+ * Destroys the given session (RP-initiated logout or a failed refresh). A no-op when the
+ * session is already gone.
+ *
+ * @param session the session to destroy
+ */
+ void destroy(SessionRecord session);
+
+ /**
+ * Destroys every session carrying the given IdP {@code sid} (back-channel logout).
+ *
+ * @param sid the IdP session id claim
+ * @return the number of sessions destroyed; always {@code 0} when {@link #idpDestruction()}
+ * reports {@link IdpDestruction#UNSUPPORTED}
+ */
+ int destroyBySid(String sid);
+
+ /**
+ * Destroys every session for the given subject (back-channel logout without a {@code sid}).
+ *
+ * @param sub the subject claim
+ * @return the number of sessions destroyed; always {@code 0} when {@link #idpDestruction()}
+ * reports {@link IdpDestruction#UNSUPPORTED}
+ */
+ int destroyBySub(String sub);
+
+ /**
+ * @return whether this binding can honour the IdP-driven {@code sid}/{@code sub} destruction
+ */
+ IdpDestruction idpDestruction();
+
+ /**
+ * Builds the {@code Set-Cookie} header value that clears the browser's session binding. Needed
+ * on logout even when no live session resolved, so a stale binding cookie is cleared too.
+ *
+ * @return the clearing {@code Set-Cookie} header value
+ */
+ String clearingSetCookieHeader();
+
+ /**
+ * Whether a binding can honour the IdP-driven {@code sid}/{@code sub} destruction of OIDC
+ * back-channel logout.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ enum IdpDestruction {
+
+ /** The binding holds a server-side index and destroys the named sessions. */
+ SUPPORTED,
+
+ /** The binding is stateless — it holds no index and cannot reach another browser's cookie. */
+ UNSUPPORTED
+ }
+
+ /**
+ * The result of binding or re-binding a session: the session as bound, plus the
+ * {@code Set-Cookie} header values the caller emits so the browser carries the new binding.
+ * Token material never appears in the headers — a server-mode binding emits an opaque handle
+ * and a stateless binding emits an authenticated-encrypted value.
+ *
+ * @param session the session as bound
+ * @param setCookieHeaders the {@code Set-Cookie} header values to emit, possibly empty
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ record BoundSession(SessionRecord session, List setCookieHeaders) {
+
+ /**
+ * Canonical constructor rejecting an absent session and defensively copying the cookies.
+ */
+ public BoundSession {
+ Objects.requireNonNull(session, "session");
+ setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders);
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionRecord.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionRecord.java
index f12d9a27..1a22c4ca 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionRecord.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionRecord.java
@@ -24,20 +24,36 @@
import lombok.Builder;
/**
- * A single server-side session (D3, {@code mode: server}).
+ * A single authenticated session, as held by whichever {@link SessionBinding} is in force.
*
* The record holds the mediated token material — the access token injected as
* {@code Authorization: Bearer} on proxied requests, the optional refresh token, and the raw
* ID token retained as the {@code id_token_hint} at logout — plus the session metadata
- * ({@code expiry}, {@code acr}, {@code auth_time}, {@code sid}, {@code sub}). The token material
- * never leaves the server: the browser only ever carries the opaque
- * {@link #sessionId()} in the session cookie, so {@link #toString()} redacts every credential
- * (the session id itself is a bearer credential) to keep tokens out of logs and stack traces.
+ * ({@code expiry}, {@code acr}, {@code auth_time}, {@code sid}, {@code sub}). The token material is
+ * never disclosed in the clear, so {@link #toString()} redacts every credential to
+ * keep tokens out of logs and stack traces.
*
- * {@link #sid()} and {@link #sub()} back the store's secondary index for O(1) back-channel
- * logout destruction.
+ * Session identity. {@link #sessionId()} is the one identity model the seam
+ * defines: a stable per-session identity every {@link SessionBinding} populates, and the key the
+ * refresh coordinator uses for single-flight coalescing. Login always mints one with
+ * {@link #newSessionId()} before the mode-neutral {@link SessionBinding#bind}, but only server mode
+ * keeps it:
+ *
+ * - server mode: the minted id becomes the opaque store key, and the session
+ * cookie carries it in the clear — the cookie value IS this identity, which is why it
+ * is itself a bearer credential;
+ * - cookie mode: the minted id is discarded — it is not among the
+ * fields sealed into the cookie. The binding replaces it with an identity derived
+ * from the sealed payload (a salted digest over the login instant and {@code sub}), so it is
+ * stable across re-seals, un-recomputable outside this gateway, and never emitted to the
+ * browser.
+ *
+ * It is redacted from {@link #toString()} either way.
+ *
+ * {@link #sid()} and {@link #sub()} back a server-mode store's secondary index for O(1)
+ * back-channel logout destruction.
*
- * @param sessionId the opaque session id (store key and session-cookie value)
+ * @param sessionId the stable per-session identity (see the identity model above)
* @param accessToken the mediated access token injected as the upstream bearer
* @param refreshToken the refresh token, empty when the IdP granted none
* @param idToken the raw ID token retained for the logout {@code id_token_hint}
@@ -74,7 +90,12 @@ public record SessionRecord(String sessionId, String accessToken, Optionalserver-mode session
+ * identity, which is also the value carried in that mode's session cookie.
+ *
+ * Login calls this before the mode-neutral {@link SessionBinding#bind}, so a cookie-mode login
+ * mints one too — but that binding discards it and derives its own identity from the sealed
+ * payload instead. Nothing downstream may assume the minted value survives {@code bind}.
*
* @return the base64url (unpadded) encoding of 32 secure-random bytes
*/
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionStore.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionStore.java
index ad6036cc..a54c1ab0 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionStore.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionStore.java
@@ -19,7 +19,13 @@
import java.util.Optional;
/**
- * The server-side session store contract (D3).
+ * The server-mode implementation detail behind {@link SessionBinding} (D3) — the
+ * keyed store {@link ServerSessionBinding} delegates to.
+ *
+ * No BFF collaborator binds this contract directly any more: the stage, the refresh coordinator,
+ * and every reserved endpoint bind the mode-neutral {@link SessionBinding} seam, so a stateless
+ * variant that keeps no server-side session state is representable. This interface therefore
+ * describes only the server-mode storage semantics.
*
* A session is created after a successful IdP login, resolved by its opaque id on every
* subsequent request, and destroyed either directly (RP-initiated logout) or via the IdP's
@@ -34,7 +40,8 @@
public interface SessionStore {
/**
- * Stores a freshly created session.
+ * Stores a session, upserting by its opaque id — re-creating an existing id replaces the record
+ * in place, which is how a rotated session is persisted without a destroy-then-create window.
*
* @param session the session to store
* @throws IllegalStateException when the store is at its max-session capacity bound
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/package-info.java
index f79818ec..4f6b96cb 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/package-info.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/package-info.java
@@ -14,21 +14,29 @@
* limitations under the License.
*/
/**
- * The server-side session store and its opaque cookie (D3, {@code mode: server}).
+ * The session-binding seam (D7) and its server-mode implementation (D3, {@code mode: server}).
*
- * After a successful IdP login the gateway holds the mediated tokens server-side and hands the
- * browser only an opaque handle:
+ * The package is split into the mode-neutral contract the rest of the BFF binds
+ * and the server-mode implementation detail behind it:
*
- * - {@link de.cuioss.sheriff.gateway.bff.session.SessionRecord} holds the access, refresh,
- * and raw ID tokens plus session metadata; token material never leaves the server and is
- * redacted from {@code toString()}.
- * - {@link de.cuioss.sheriff.gateway.bff.session.SessionStore} is the store contract, and
- * {@link de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore} its only
- * implementation — keyed by opaque id, with secondary indexes by {@code sid}/{@code sub}
- * for O(1) back-channel destruction, an absolute TTL enforced lazily plus by a periodic
- * sweep (no per-session timer threads), and a documented max-session bound.
- * - {@link de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec} sets and reads the
- * hardened {@code __Host-} session cookie carrying only the opaque id.
+ * - Seam. {@link de.cuioss.sheriff.gateway.bff.session.SessionBinding} is the
+ * single session-state contract the stage, the refresh coordinator, and every reserved
+ * endpoint bind — bind / resolve / persist / destroy plus the two IdP-driven destruction
+ * forms and their {@code SUPPORTED}/{@code UNSUPPORTED} capability flag. It names no store
+ * and no opaque id, so a stateless variant is representable.
+ * - Record. {@link de.cuioss.sheriff.gateway.bff.session.SessionRecord} holds
+ * the access, refresh, and raw ID tokens plus session metadata; every credential is redacted
+ * from {@code toString()}. Its {@code sessionId} is the one identity model — a stable
+ * per-session identity every binding populates.
+ * - Server-mode implementation.
+ * {@link de.cuioss.sheriff.gateway.bff.session.ServerSessionBinding} is a thin adapter over
+ * {@link de.cuioss.sheriff.gateway.bff.session.SessionStore} (implemented only by
+ * {@link de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore} — keyed by opaque id,
+ * with secondary indexes by {@code sid}/{@code sub} for O(1) back-channel destruction, an
+ * absolute TTL enforced lazily plus by a periodic sweep, and a documented max-session bound)
+ * and {@link de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec}, which sets and reads
+ * the hardened {@code __Host-} session cookie carrying only the opaque id. In this mode the
+ * token material never leaves the server.
*
* The classes are framework-agnostic (no CDI, no JAX-RS/Vert.x coupling); the runtime and
* reserved-endpoint packages wire them to the request/response edge.
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/OidcConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/OidcConfig.java
index 181584cf..00e929f8 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/OidcConfig.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/OidcConfig.java
@@ -16,6 +16,7 @@
package de.cuioss.sheriff.gateway.config.model;
import java.util.List;
+import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
@@ -123,13 +124,26 @@ public record Logout(Optional path, Optional postLogoutRedirectU
* Session settings. The exhibited fields span both modes: {@code store} is
* server-mode only, the encryption keys are cookie-mode only.
*
- * @param mode the session mode ({@code cookie} / {@code server}), empty
- * when omitted
+ * @param mode the session mode, canonicalized to lower case by the
+ * canonical constructor ({@link Session#MODE_COOKIE} /
+ * {@link Session#MODE_SERVER}), empty when omitted. Compare it
+ * through {@link Session#isCookieMode()} /
+ * {@link Session#isServerMode()} — never against a
+ * locally-declared constant
* @param store the server-mode store, empty when omitted
* @param cookieName the session cookie name, empty when omitted
- * @param encryptionKey the cookie-mode AES key ({@code ${ENV_VAR}} reference),
- * empty when omitted
- * @param previousKey the optional decrypt-only rotation key, empty when omitted
+ * @param encryptionKey the cookie-mode AES-256 sealing key ({@code ${ENV_VAR}}
+ * reference). Present selects the passed-key mode;
+ * empty selects generate-on-startup, which is a
+ * fully supported production mode whose key is fresh per
+ * boot — so every session is dropped on restart and the key
+ * cannot be shared across replicas
+ * @param previousKey the optional decrypt-only rotation key ({@code ${ENV_VAR}}
+ * reference), empty when no rotation is in progress. Values
+ * sealed under it still unseal, but nothing is ever sealed
+ * under it again. It composes with the passed-key mode only:
+ * supplying it without an {@code encryptionKey} is a
+ * configuration error the validator rejects
* @param ttlSeconds the absolute session lifetime in seconds, empty when
* omitted
* @param csrf the CSRF settings, empty when omitted
@@ -137,19 +151,43 @@ public record Logout(Optional path, Optional postLogoutRedirectU
* @param maxSessions the server-mode upper bound on concurrently stored
* sessions — a DoS guard on the in-memory store, empty
* when omitted
+ * @param maxCookieSize the cookie-mode sealed cookie-value size budget in
+ * bytes, empty when omitted (the codec's
+ * {@code DEFAULT_COOKIE_VALUE_BUDGET} then applies). It is
+ * the single declared number driving BOTH the seal-time
+ * budget and the gateway's pre-route {@code Cookie}
+ * header-value cap
* @author API Sheriff Team
* @since 1.0
*/
@Builder
public record Session(Optional mode, Optional store, Optional cookieName,
Optional encryptionKey, Optional previousKey, Optional ttlSeconds,
- Optional csrf, Optional refresh, Optional maxSessions) {
+ Optional csrf, Optional refresh, Optional maxSessions,
+ Optional maxCookieSize) {
+
+ /** The stateless cookie session mode, in its one canonical spelling. */
+ public static final String MODE_COOKIE = "cookie";
+
+ /** The server-side store session mode, in its one canonical spelling. */
+ public static final String MODE_SERVER = "server";
/**
- * Canonical constructor normalizing absent components to {@link Optional#empty()}.
+ * Canonical constructor normalizing absent components to {@link Optional#empty()} and
+ * canonicalizing {@link #mode()} to its lower-case, trimmed spelling.
+ *
+ * The mode is canonicalized here, once, so every downstream consumer compares the
+ * same spelling. Leaving it raw let a value like {@code Cookie} be read case-sensitively by
+ * boot validation (which then silently skipped both the cookie and server companion rules)
+ * and case-insensitively by the edge (which relaxed the pre-route {@code Cookie}
+ * header-value cap) — a validated-but-wrong runtime mode with a weakened inbound control.
+ * Consumers MUST use {@link #isCookieMode()} / {@link #isServerMode()} rather than
+ * re-deriving a comparison against a locally-declared constant.
*/
public Session {
- mode = Objects.requireNonNullElse(mode, Optional.empty());
+ mode = Objects.requireNonNullElse(mode, Optional.empty())
+ .map(value -> value.trim().toLowerCase(Locale.ROOT))
+ .filter(value -> !value.isEmpty());
store = Objects.requireNonNullElse(store, Optional.empty());
cookieName = Objects.requireNonNullElse(cookieName, Optional.empty());
encryptionKey = Objects.requireNonNullElse(encryptionKey, Optional.empty());
@@ -158,6 +196,34 @@ public record Session(Optional mode, Optional store, Optional mode, Optional store, Optional DEFAULT_RULES = List.of(
(gateway, endpoints, topology, errors) -> validateVersion(gateway, errors),
@@ -134,6 +136,7 @@ public final class ConfigValidator {
(gateway, endpoints, topology, errors) -> validateCors(gateway, errors),
(gateway, endpoints, topology, errors) -> validateSessionMode(gateway, errors),
(gateway, endpoints, topology, errors) -> validateSessionMaxSessions(gateway, errors),
+ (gateway, endpoints, topology, errors) -> validateSessionMaxCookieSize(gateway, errors),
(gateway, endpoints, topology, errors) -> validateUserInfo(gateway, errors),
(gateway, endpoints, topology, errors) -> validateLoginPath(gateway, errors),
(gateway, endpoints, topology, errors) -> validatePassthroughHostCollision(gateway, endpoints, errors),
@@ -731,18 +734,34 @@ private static void checkCors(Optional securityHeaders, S
});
}
+ /**
+ * Rule: the session mode's mandatory companions (D1/D2b).
+ *
+ * {@code server} mode requires a {@code store}. {@code cookie} mode does not require an
+ * {@code encryption_key} — omitting it selects the generate-on-startup key mode, a first-class
+ * production option (at the cost of dropping every session on restart and being unshareable
+ * across replicas). Its companion rule survives the relaxation: a {@code previous_key} present
+ * without an {@code encryption_key} is still invalid, because a decrypt-only rotation
+ * key with no current key to roll onto is semantically nonsensical and rotation composes with
+ * the passed-key mode only.
+ */
private static void validateSessionMode(GatewayConfig gateway, List errors) {
- gateway.oidc().flatMap(OidcConfig::session).ifPresent(session ->
- session.mode().ifPresent(mode -> {
- if ("cookie".equals(mode) && session.encryptionKey().isEmpty()) {
- errors.add(new ConfigError(GATEWAY_FILE, "/oidc/session/encryption_key",
- "cookie session mode requires an encryption_key"));
- }
- if ("server".equals(mode) && session.store().isEmpty()) {
- errors.add(new ConfigError(GATEWAY_FILE, "/oidc/session/store",
- "server session mode requires a store"));
- }
- }));
+ // isCookieMode() / isServerMode() are the SHARED predicates over the mode value the
+ // OidcConfig.Session canonical constructor already canonicalized. Comparing against a
+ // literal here is what previously let 'Cookie' skip both rules while the edge still read it
+ // as active cookie mode and relaxed the pre-route Cookie header-value cap.
+ gateway.oidc().flatMap(OidcConfig::session).ifPresent(session -> {
+ if (session.isCookieMode() && session.encryptionKey().isEmpty()
+ && session.previousKey().isPresent()) {
+ errors.add(new ConfigError(GATEWAY_FILE, "/oidc/session/previous_key",
+ "cookie session mode with a previous_key requires an encryption_key — "
+ + "the decrypt-only rotation key composes with the passed-key mode only"));
+ }
+ if (session.isServerMode() && session.store().isEmpty()) {
+ errors.add(new ConfigError(GATEWAY_FILE, "/oidc/session/store",
+ "server session mode requires a store"));
+ }
+ });
}
/**
@@ -761,6 +780,31 @@ private static void validateSessionMaxSessions(GatewayConfig gateway, List
+ * The floor is the encoded length of a structurally minimal sealed value — below it no sealed
+ * cookie could ever be emitted, so the boot fails with a clear message rather than every seal
+ * failing at runtime. The ceiling keeps the budget below what the transport will carry: the same
+ * number also raises the gateway's pre-route {@code Cookie} header-value cap, and a budget at or
+ * above the inbound header-block limit would produce a value the seal accepts but the transport
+ * rejects with {@code 431}. A no-op when the key is omitted (the codec default applies).
+ */
+ private static void validateSessionMaxCookieSize(GatewayConfig gateway, List errors) {
+ gateway.oidc().flatMap(OidcConfig::session).flatMap(OidcConfig.Session::maxCookieSize).ifPresent(size -> {
+ if (size < SealedSessionCookieCodec.COOKIE_VALUE_BUDGET_FLOOR
+ || size > SealedSessionCookieCodec.COOKIE_VALUE_BUDGET_CEILING) {
+ errors.add(new ConfigError(GATEWAY_FILE, OIDC_SESSION_MAX_COOKIE_SIZE_POINTER,
+ "oidc session max_cookie_size must be between %d and %d bytes, but was %d"
+ .formatted(SealedSessionCookieCodec.COOKIE_VALUE_BUDGET_FLOOR,
+ SealedSessionCookieCodec.COOKIE_VALUE_BUDGET_CEILING, size)));
+ }
+ });
+ }
+
/**
* Rule: the session/user-info reserved endpoint (fold, D1). When an
* {@code oidc.user_info} block is present, its {@code path} must be an absolute
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/EdgeHardeningOptions.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/EdgeHardeningOptions.java
index 00f4e64b..30b35f7b 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/EdgeHardeningOptions.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/EdgeHardeningOptions.java
@@ -29,12 +29,16 @@
* The transport bounds fail fast on abusive framing before a request is ever admitted to the
* pipeline: an over-long request line, an over-large header block, or an over-large chunk is
* rejected by the Vert.x codec, and an idle connection is reaped so a slow-loris / h2 abuse load
- * cannot pin connection slots. Two further limits are consumed by {@link GatewayEdgeRoute} rather
+ * cannot pin connection slots. Three further limits are consumed by {@link GatewayEdgeRoute} rather
* than the transport: the {@linkplain #admissionCap() admission cap} bounds the number of requests
* that may be in flight at once (rejected with {@code 503} before a virtual thread is
- * dispatched, so a flood cannot spawn unbounded virtual threads), and the
+ * dispatched, so a flood cannot spawn unbounded virtual threads), the
* {@linkplain #drainTimeoutMillis() drain timeout} bounds the graceful-shutdown wait for in-flight
- * requests to complete on {@code SIGTERM}.
+ * requests to complete on {@code SIGTERM}, and the
+ * {@linkplain #reservedBodyMaxBytes() reserved-body ceiling} bounds the cumulative bytes the edge
+ * will buffer for a gateway-terminated reserved POST path (rejected with {@code 413}), so an
+ * unauthenticated caller cannot exhaust heap through the pre-authentication callback / back-channel
+ * logout paths that never reach the per-route body cap.
*
* The values are deliberate secure defaults chosen to keep the abuse surface bounded while
* comfortably serving ordinary API traffic; the drain timeout is kept below the Quarkus default
@@ -61,6 +65,23 @@ public class EdgeHardeningOptions implements HttpServerOptionsCustomizer {
/** Maximum number of requests permitted in flight at once before a virtual thread is dispatched. */
private static final int ADMISSION_CAP = 2048;
+ /**
+ * Ceiling in bytes for a gateway-terminated reserved-path request body (the OIDC
+ * {@code response_mode=form_post} callback and the back-channel logout receiver).
+ *
+ * Derivation — deliberately not a second independent number. Both payloads are
+ * small, gateway-terminated inbound units of exactly the same class as the request header block
+ * this file already bounds: a form_post callback carries a urlencoded {@code code}/{@code state}
+ * pair (hundreds of bytes in practice) and a back-channel logout carries a single compact
+ * {@code logout_token} JWT (low single-digit KiB even with a large signing certificate chain).
+ * The ceiling is therefore defined as {@link #MAX_HEADER_SIZE_BYTES} rather than
+ * restated as its own literal, so the gateway's single 16 KiB inbound-unit bound cannot drift
+ * into two contradicting constants. It leaves roughly an order of magnitude of headroom over a
+ * real payload while bounding the worst case at the {@link #ADMISSION_CAP admission cap} to
+ * ~32 MiB — comfortably inside the deployed container memory limit.
+ */
+ private static final int RESERVED_BODY_MAX_BYTES = MAX_HEADER_SIZE_BYTES;
+
/** Bounded graceful-drain wait for in-flight requests on shutdown (below the Quarkus default). */
private static final long DRAIN_TIMEOUT_MILLIS = 25_000L;
@@ -96,4 +117,14 @@ public int admissionCap() {
public long drainTimeoutMillis() {
return DRAIN_TIMEOUT_MILLIS;
}
+
+ /**
+ * @return the byte ceiling the edge enforces on a gateway-terminated reserved-path request body
+ * (form_post callback / back-channel logout). A request declaring more, or actually
+ * streaming more, is rejected {@code 413} — see {@link #RESERVED_BODY_MAX_BYTES} for the
+ * derivation
+ */
+ public long reservedBodyMaxBytes() {
+ return RESERVED_BODY_MAX_BYTES;
+ }
}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java
index 5dc3ce74..9c28fbc2 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java
@@ -45,12 +45,14 @@
import de.cuioss.sheriff.gateway.asset.UpstreamAssetSource;
import de.cuioss.sheriff.gateway.auth.AuthenticationStage;
import de.cuioss.sheriff.gateway.auth.GatewayValidator;
+import de.cuioss.sheriff.gateway.bff.cookie.SealedSessionCookieCodec;
import de.cuioss.sheriff.gateway.bff.reserved.ReservedPathRegistry;
import de.cuioss.sheriff.gateway.bff.reserved.ReservedPathRegistry.ReservedEndpoint;
import de.cuioss.sheriff.gateway.bff.runtime.BffRuntime;
import de.cuioss.sheriff.gateway.config.model.ForwardedConfig;
import de.cuioss.sheriff.gateway.config.model.GatewayConfig;
import de.cuioss.sheriff.gateway.config.model.HttpMethod;
+import de.cuioss.sheriff.gateway.config.model.OidcConfig;
import de.cuioss.sheriff.gateway.config.model.Protocol;
import de.cuioss.sheriff.gateway.config.model.ResolvedAsset;
import de.cuioss.sheriff.gateway.config.model.ResolvedUpstream;
@@ -82,7 +84,6 @@
import io.quarkus.runtime.ShutdownEvent;
import io.quarkus.virtual.threads.VirtualThreads;
import io.smallrye.faulttolerance.api.Guard;
-import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.Vertx;
import io.vertx.core.buffer.Buffer;
@@ -121,7 +122,8 @@
* dispatched (a flood is rejected {@code 503} rather than spawning unbounded virtual threads), then
* the request stream is paused and the whole pipeline runs on a virtual thread (a reserved POST path —
* the form_post callback and back-channel logout — instead has its small body read on the event loop
- * first, then dispatches, so a handler never has to drain a paused stream from a virtual thread):
+ * first, under the {@linkplain EdgeHardeningOptions#reservedBodyMaxBytes() reserved-body byte
+ * ceiling}, then dispatches, so a handler never has to drain a paused stream from a virtual thread):
*
* - stage 0 — response-header preparation + CORS preflight (short-circuits a preflight here);
* - stage 1 — baseline security filter (records the single canonical path), the canonical-path
@@ -148,10 +150,21 @@ public class GatewayEdgeRoute {
private static final String PROBLEM_JSON = "application/problem+json";
private static final String DEFAULT_EMIT_MODE = "x-forwarded";
+
+ /**
+ * Headroom added to the configured sealed-cookie budget when deriving the pre-route
+ * {@code Cookie} header-value cap. The header value carries {@code =} and may carry
+ * co-resident cookies (the short-lived binding cookie) alongside the session cookie, so the cap
+ * cannot be the bare value budget. The sum stays far below the gateway's 16 KiB inbound
+ * header-block limit ({@link EdgeHardeningOptions}) even at the configurable budget ceiling.
+ */
+ private static final int COOKIE_HEADER_OVERHEAD_BYTES = 512;
private static final String REQUIRE_SESSION = "session";
private static final String COOKIE_HEADER = "Cookie";
private static final String LOCATION_HEADER = "Location";
private static final String SET_COOKIE_HEADER = "Set-Cookie";
+ private static final String CONNECTION_HEADER = "Connection";
+ private static final String CONNECTION_CLOSE = "close";
private static final String CLAIMS_PARAM = "claims";
private static final String RETURN_TO_PARAM = "return_to";
private static final String STATE_PARAM = "state";
@@ -274,7 +287,8 @@ public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig,
this.reservedPathRegistry = ReservedPathRegistry.from(gatewayConfig.oidc());
this.securityHeadersStage = new SecurityHeadersStage(gatewayConfig.securityHeaders());
this.basicChecksStage = new BasicChecksStage(defaultConfiguration, securityEventCounter,
- (host, canonicalPath) -> reservedPathRegistry.match(host, canonicalPath).isPresent());
+ (host, canonicalPath) -> reservedPathRegistry.match(host, canonicalPath).isPresent(),
+ cookieHeaderConfigurationFor(gatewayConfig, bffRuntime));
this.canonicalPathGuard = new CanonicalPathGuard();
this.framingGate = new FramingGate();
this.passthroughHostGuardStage = new PassthroughHostGuardStage(
@@ -374,23 +388,48 @@ private boolean needsReservedBodyRead(RoutingContext ctx) {
}
/**
- * Reads the reserved-POST body on the event loop, stashes it under {@link #RESERVED_BODY_KEY}, then
- * dispatches processing exactly once. The request is NOT paused: {@code body()} drains the stream on
- * its own event loop, fully asynchronously — no virtual-thread {@code .get()} blocks on a contended
- * event loop. A bounded deadline guards a body that truly never completes, but the deadline handler
- * still honours a body that has already fully arrived (it inspects the read future), so a legitimate
- * body delayed only by event-loop scheduling under CPU contention is never falsely rejected.
+ * Reads the reserved-POST body on the event loop under a byte ceiling, stashes it under
+ * {@link #RESERVED_BODY_KEY}, then dispatches processing exactly once. The request is NOT paused:
+ * the stream drains on its own event loop, fully asynchronously — no virtual-thread {@code .get()}
+ * blocks on a contended event loop.
+ *
+ * Two bounds, both mandatory. These two reserved paths are read
+ * pre-authentication and before {@code basicChecksStage} /
+ * {@code thoroughChecksStage} run, so the per-route {@link SecurityConfiguration#maxBodySize()} cap
+ * that bounds every ordinary proxied route can never apply here, and the transport's
+ * {@link EdgeHardeningOptions} chunk bound caps only one chunk, not the cumulative body:
+ *
+ * - Byte ceiling ({@link EdgeHardeningOptions#reservedBodyMaxBytes()}),
+ * enforced twice — a {@code Content-Length} pre-check that refuses before a single body byte
+ * is buffered, and a streaming cumulative counter that aborts mid-read. The pre-check alone
+ * is not sufficient: {@code Content-Length} is attacker-controlled and is absent entirely
+ * under chunked transfer-encoding. Either breach is rejected {@code 413}
+ * ({@link EventType#RESERVED_BODY_TOO_LARGE}) — never a {@code 500}, and never by silently
+ * truncating the body into the handler.
+ * - Wall-clock deadline ({@value #RESERVED_BODY_READ_TIMEOUT_SECONDS}s),
+ * guarding a body that truly never completes. The deadline handler still honours a body that
+ * has already fully arrived (it inspects {@link HttpServerRequest#isEnded()}), so a
+ * legitimate body delayed only by event-loop scheduling under CPU contention is never falsely
+ * rejected.
+ *
*/
private void readReservedBodyThenDispatch(RoutingContext ctx, AtomicBoolean admissionReleased) {
- AtomicBoolean bodyDone = new AtomicBoolean();
HttpServerRequest request = ctx.request();
- Future bodyFuture = request.body();
+ long ceiling = hardening.reservedBodyMaxBytes();
+ // Bound 1a — declared size. Refuse before the stream is ever resumed, so an oversized declared
+ // body costs the gateway zero heap.
+ if (parseContentLength(request) > ceiling) {
+ rejectOversizedReservedBody(ctx, ceiling, "declared-content-length");
+ return;
+ }
+ AtomicBoolean bodyDone = new AtomicBoolean();
+ Buffer accumulated = Buffer.buffer();
long timer = ctx.vertx().setTimer(TimeUnit.SECONDS.toMillis(RESERVED_BODY_READ_TIMEOUT_SECONDS), id -> {
if (bodyDone.compareAndSet(false, true)) {
- if (bodyFuture.succeeded() && bodyFuture.result() != null) {
- // The body actually arrived; only its completion callback had not yet drained from
- // this (contention-starved) event loop's queue. Honour it rather than falsely reject.
- ctx.put(RESERVED_BODY_KEY, bodyFuture.result());
+ if (request.isEnded()) {
+ // The body actually arrived; only the end callback had not yet drained from this
+ // (contention-starved) event loop's queue. Honour it rather than falsely reject.
+ ctx.put(RESERVED_BODY_KEY, accumulated);
} else {
// A body that truly never completed within the deadline — leave RESERVED_BODY_KEY
// unset so the receiver fails closed to 400 rather than pinning on it indefinitely.
@@ -400,23 +439,81 @@ private void readReservedBodyThenDispatch(RoutingContext ctx, AtomicBoolean admi
dispatchProcessing(ctx, admissionReleased);
}
});
- bodyFuture.onComplete(result -> {
+ // Bound 1b — actual size. Count cumulatively as the chunks land and abort the moment the next
+ // chunk would cross the ceiling, so a chunked (or lying-Content-Length) body is bounded by what
+ // it really sends rather than by what it claimed.
+ request.handler(chunk -> {
+ if (bodyDone.get()) {
+ return;
+ }
+ if (accumulated.length() + (long) chunk.length() > ceiling) {
+ bodyDone.set(true);
+ ctx.vertx().cancelTimer(timer);
+ rejectOversizedReservedBody(ctx, ceiling, "streamed-body");
+ return;
+ }
+ accumulated.appendBuffer(chunk);
+ });
+ request.exceptionHandler(cause -> {
+ if (bodyDone.compareAndSet(false, true)) {
+ ctx.vertx().cancelTimer(timer);
+ // Read failure: leave RESERVED_BODY_KEY unset (fail closed to 400).
+ LOGGER.debug(cause, "Reserved form body read failed — failing closed");
+ dispatchProcessing(ctx, admissionReleased);
+ }
+ });
+ request.endHandler(end -> {
if (bodyDone.compareAndSet(false, true)) {
ctx.vertx().cancelTimer(timer);
- if (result.succeeded() && result.result() != null) {
- ctx.put(RESERVED_BODY_KEY, result.result());
- } else {
- // Read failure: leave RESERVED_BODY_KEY unset (fail closed to 400).
- LOGGER.debug(result.cause(), "Reserved form body read failed — failing closed");
- }
+ ctx.put(RESERVED_BODY_KEY, accumulated);
dispatchProcessing(ctx, admissionReleased);
}
});
- // body() registers the collector but the inbound request stream arrives in fetch/paused mode;
- // resume() (on this event loop) re-arms it so the buffered body is delivered.
+ // The inbound request stream arrives in fetch/paused mode; resume() (on this event loop)
+ // re-arms it so the body is delivered to the handlers registered above.
request.resume();
}
+ /**
+ * Fails an over-ceiling reserved-path body closed with {@code 413}, the honest status for a payload
+ * the gateway refuses on size. The pipeline never runs for such a request, so this meters and
+ * renders directly rather than raising a {@link GatewayException}; the WARN records the ceiling and
+ * a fixed disposition only — never the offending body.
+ */
+ private void rejectOversizedReservedBody(RoutingContext ctx, long ceiling, String disposition) {
+ LOGGER.warn(ApiSheriffLogMessages.WARN.RESERVED_BODY_TOO_LARGE, ceiling, disposition);
+ gatewayEventCounter.increment(EventType.RESERVED_BODY_TOO_LARGE);
+ recordError(ctx, EventType.RESERVED_BODY_TOO_LARGE);
+ closeAfterOversizedReservedBody(ctx);
+ // Ending the response releases the admission permit through the end handler registered in
+ // handle(), exactly like every other terminal path.
+ renderProblem(ctx, null, EventType.RESERVED_BODY_TOO_LARGE);
+ }
+
+ /**
+ * Retires the connection a {@code 413} reserved-body rejection was served on, on BOTH rejection
+ * paths (the pre-read {@code Content-Length} refusal and the mid-stream ceiling abort).
+ *
+ * The request body is deliberately left unconsumed — reading an attacker-supplied
+ * oversized body to completion is exactly the work the ceiling exists to avoid, so draining it is
+ * not an option. But unread bytes left pending on a reused HTTP/1.1 keep-alive connection desync
+ * the next request framed on that connection, or pin the connection until the idle timeout — which
+ * would leave the reserved-body DoS guard only half-effective, since an attacker could still tie up
+ * connections by repeatedly tripping the {@code 413}. Retiring the connection closes that gap: the
+ * response advertises {@code Connection: close} (HTTP/1.x only — the header is forbidden in
+ * HTTP/2), and the connection is closed once the response has been written.
+ */
+ private static void closeAfterOversizedReservedBody(RoutingContext ctx) {
+ HttpServerResponse response = ctx.response();
+ HttpVersion version = ctx.request().version();
+ if (!response.headWritten() && (version == HttpVersion.HTTP_1_0 || version == HttpVersion.HTTP_1_1)) {
+ response.putHeader(CONNECTION_HEADER, CONNECTION_CLOSE);
+ }
+ // addEndHandler is additive, so this composes with the admission-release handler handle()
+ // registered rather than replacing it.
+ ctx.addEndHandler(result -> ctx.request().connection().close());
+ }
+
/**
* Hands the request to the virtual-thread pipeline, rolling admission back and failing {@code 503}
* if the executor refuses the dispatch (a shutdown race) so the response always ends.
@@ -633,6 +730,7 @@ private void dispatchReserved(RoutingContext ctx, PipelineRequest request, Reser
private void renderReserved(RoutingContext ctx, PipelineRequest request,
BffRuntime.ReservedHttpResponse response) {
Map stageHeaders = Map.copyOf(request.responseHeaders());
+ List stageSetCookies = request.responseSetCookies();
ctx.vertx().runOnContext(v -> {
HttpServerResponse httpResponse = ctx.response();
if (httpResponse.ended()) {
@@ -640,6 +738,7 @@ private void renderReserved(RoutingContext ctx, PipelineRequest request,
}
httpResponse.setStatusCode(response.status());
stageHeaders.forEach(httpResponse::putHeader);
+ applyStageSetCookies(httpResponse, stageSetCookies);
response.headers().forEach(httpResponse::putHeader);
response.locationOptional().ifPresent(location -> httpResponse.putHeader(LOCATION_HEADER, location));
// Multiple Set-Cookie headers must each be a distinct header line (never a comma-joined value).
@@ -648,6 +747,16 @@ private void renderReserved(RoutingContext ctx, PipelineRequest request,
});
}
+ /**
+ * Writes the pipeline's accumulated {@code Set-Cookie} values as distinct header lines.
+ * {@code Set-Cookie} is legitimately multi-valued (RFC 6265 §3) — the session runtime can emit a
+ * clearing cookie and a login-binding cookie on the same response — so each value gets its own
+ * line and is never comma-joined into, or overwritten in, the single-valued stage-header map.
+ */
+ private static void applyStageSetCookies(HttpServerResponse response, List setCookieHeaders) {
+ setCookieHeaders.forEach(cookie -> response.headers().add(SET_COOKIE_HEADER, cookie));
+ }
+
private static @Nullable String firstQueryParam(PipelineRequest request, String name) {
List values = request.queryParameters().get(name);
return values == null || values.isEmpty() ? null : values.getFirst();
@@ -656,9 +765,10 @@ private void renderReserved(RoutingContext ctx, PipelineRequest request,
/**
* Returns the raw {@code application/x-www-form-urlencoded} body of a reserved POST path (the OIDC
* {@code response_mode=form_post} callback and back-channel logout), buffered on the event loop in
- * {@link #handle} before this virtual-thread dispatch. A read failure or timeout stashed the
- * {@link #RESERVED_BODY_FAILED} sentinel, so this returns {@code null} — the fail-closed default a
- * receiver rejects {@code 400} (a body the gateway could not read is not an accepted token).
+ * {@link #handle} before this virtual-thread dispatch. A read failure or timeout leaves
+ * {@link #RESERVED_BODY_KEY} unset, so this returns {@code null} — the fail-closed default a
+ * receiver rejects {@code 400} (a body the gateway could not read is not an accepted token). An
+ * over-ceiling body never reaches here at all: it is refused {@code 413} at the read itself.
*/
private static @Nullable String readFormBody(RoutingContext ctx) {
Buffer body = ctx.get(RESERVED_BODY_KEY);
@@ -694,9 +804,12 @@ private void dispatchAndRelay(RoutingContext ctx, PipelineRequest request, Route
// virtual thread. Hop back onto the event loop exactly like every other terminal path
// (renderProblem / writeShortCircuit / failRelay); doing the relay off-loop races the
// response object and corrupts / truncates the streamed body.
- ctx.vertx().runOnContext(v ->
- responseStage.relay(upstream, ctx.response(), route.isNotModifiedEnabled(), request.responseHeaders())
- .onFailure(failure -> failRelay(ctx, failure)));
+ List stageSetCookies = request.responseSetCookies();
+ ctx.vertx().runOnContext(v -> {
+ applyStageSetCookies(ctx.response(), stageSetCookies);
+ responseStage.relay(upstream, ctx.response(), route.isNotModifiedEnabled(), request.responseHeaders())
+ .onFailure(failure -> failRelay(ctx, failure));
+ });
}
/**
@@ -717,6 +830,7 @@ private void dispatchWebSocket(RoutingContext ctx, PipelineRequest request, Rout
ResolvedUpstream upstreamTarget = route.getUpstream()
.orElseThrow(() -> new IllegalStateException("WebSocket dispatch requires a resolved upstream"));
String uri = DispatchStage.upstreamRequestUri(upstreamTarget, remainder, query);
+ applyStageSetCookies(ctx.response(), request.responseSetCookies());
webSocketRelayStage.relay(ctx, route, forward.headers(), request.responseHeaders(), uri);
}
@@ -746,10 +860,13 @@ private void dispatchGrpc(RoutingContext ctx, PipelineRequest request, RouteRunt
gatewayEventCounter.increment(EventType.REQUEST_FORWARDED);
// The trailer relay mutates the event-loop-bound response; hop back onto the event loop, exactly
// like the HTTP relay path.
- ctx.vertx().runOnContext(v ->
- responseStage.relayWithTrailers(upstream, ctx.response(), route.isNotModifiedEnabled(),
- request.responseHeaders())
- .onFailure(failure -> failRelay(ctx, failure)));
+ List stageSetCookies = request.responseSetCookies();
+ ctx.vertx().runOnContext(v -> {
+ applyStageSetCookies(ctx.response(), stageSetCookies);
+ responseStage.relayWithTrailers(upstream, ctx.response(), route.isNotModifiedEnabled(),
+ request.responseHeaders())
+ .onFailure(failure -> failRelay(ctx, failure));
+ });
}
/**
@@ -768,12 +885,14 @@ private void serveAsset(RoutingContext ctx, PipelineRequest request, RouteRuntim
private void writeBufferedAsset(RoutingContext ctx, PipelineRequest request, AssetSource.Served served) {
Map stageHeaders = Map.copyOf(request.responseHeaders());
+ List stageSetCookies = request.responseSetCookies();
ctx.vertx().runOnContext(v -> {
HttpServerResponse response = ctx.response();
if (response.ended()) {
return;
}
response.setStatusCode(served.status());
+ applyStageSetCookies(response, stageSetCookies);
// The stage-0 security headers (HSTS, frame options, …) apply to every response; the
// envelope's governed headers (fixed Content-Type, nosniff, no-store) are written last
// so they win any name collision.
@@ -804,6 +923,7 @@ private void failRelay(RoutingContext ctx, Throwable failure) {
private void writeShortCircuit(RoutingContext ctx, PipelineRequest request) {
int status = request.shortCircuitStatus().orElse(204);
Map responseHeaders = Map.copyOf(request.responseHeaders());
+ List setCookies = request.responseSetCookies();
ctx.vertx().runOnContext(v -> {
HttpServerResponse response = ctx.response();
if (response.ended()) {
@@ -811,6 +931,7 @@ private void writeShortCircuit(RoutingContext ctx, PipelineRequest request) {
}
response.setStatusCode(status);
responseHeaders.forEach(response::putHeader);
+ applyStageSetCookies(response, setCookies);
response.end();
});
}
@@ -825,7 +946,11 @@ private void renderRejection(RoutingContext ctx, @Nullable PipelineRequest reque
RouteRuntime selected = request != null ? request.selectedRoute() : null;
if (selected != null && selected.getProtocol() == Protocol.GRPC) {
Map responseHeaders = Map.copyOf(request.responseHeaders());
- ctx.vertx().runOnContext(v -> grpcStatusMapper.renderRejection(ctx.response(), eventType, responseHeaders));
+ List setCookies = request.responseSetCookies();
+ ctx.vertx().runOnContext(v -> {
+ applyStageSetCookies(ctx.response(), setCookies);
+ grpcStatusMapper.renderRejection(ctx.response(), eventType, responseHeaders);
+ });
return;
}
renderProblem(ctx, request, eventType);
@@ -847,6 +972,9 @@ private void renderProblem(RoutingContext ctx, @Nullable PipelineRequest request
}
String body = "{\"type\":\"" + type + "\",\"title\":\"" + title + "\",\"status\":" + status + "}";
Map responseHeaders = request != null ? Map.copyOf(request.responseHeaders()) : Map.of();
+ // A rejection still carries the stage's Set-Cookie values: an XHR whose refresh failed is a
+ // 401 problem response, and the clearing cookie that drops the revoked session rides on it.
+ List setCookies = request != null ? request.responseSetCookies() : List.of();
ctx.vertx().runOnContext(v -> {
HttpServerResponse response = ctx.response();
if (response.ended()) {
@@ -854,6 +982,7 @@ private void renderProblem(RoutingContext ctx, @Nullable PipelineRequest request
}
response.setStatusCode(status);
responseHeaders.forEach(response::putHeader);
+ applyStageSetCookies(response, setCookies);
response.putHeader("Content-Type", PROBLEM_JSON);
response.end(body);
});
@@ -994,6 +1123,51 @@ private static HttpClient clientFor(Vertx vertx, RouteRuntimeAssembler.UpstreamT
return vertx.createHttpClient(options);
}
+ /**
+ * Derives the pre-route {@code Cookie} header-value policy from the gateway document, returning
+ * {@code null} for every gateway that is not an active cookie-mode BFF.
+ *
+ * Why this exists. Stage 1 validates every inbound header value against the
+ * cui-http default {@code maxHeaderValueLength} of 2048 characters, while a cookie-mode BFF's
+ * sealed session cookie is designed to a multi-kilobyte budget. Encoded as two independent
+ * constants, the two limits contradicted each other inside the same product: every request
+ * carrying a live sealed cookie was rejected {@code 400} at the edge before any BFF logic ran.
+ * The single declared budget ({@code oidc.session.max_cookie_size}, defaulting to
+ * {@link SealedSessionCookieCodec#DEFAULT_COOKIE_VALUE_BUDGET}) now drives BOTH ends of the round
+ * trip — the codec's seal-time budget and this pre-route cap.
+ *
+ * The relaxation is scoped twice. By MODE: a bearer-only or server-mode gateway
+ * gets {@code null} and keeps the 2048 default on every header, exactly as before. By HEADER:
+ * within a cookie-mode gateway the raised cap applies to the {@code Cookie} / {@code Set-Cookie}
+ * values only — {@link BasicChecksStage} keeps the default policy for every other header, and
+ * every non-length validator (null-byte, control-character, injection-pattern) still applies to
+ * the cookie value. It remains a deliberate relaxation of an inbound hardening control, held as
+ * narrow as the pre-route position allows.
+ *
+ * Recorded constraint — enforcement is gateway-wide, not per-anchor. Stage 1
+ * runs BEFORE route selection and this bean holds the whole route table, so no anchor exists at
+ * that point. The configuration key lives on the global {@code oidc.session} block for exactly
+ * that reason; its placement must not be read as per-anchor enforcement.
+ */
+ private static @Nullable SecurityConfiguration cookieHeaderConfigurationFor(GatewayConfig gatewayConfig,
+ BffRuntime bffRuntime) {
+ Optional session = gatewayConfig.oidc().flatMap(OidcConfig::session);
+ // The SHARED cookie-mode predicate on the config model — never a locally-declared constant
+ // compared here. A private constant plus a local comparison is what let this cap relax for a
+ // mode spelling boot validation had already skipped.
+ boolean cookieMode = bffRuntime.isActive() && session.map(OidcConfig.Session::isCookieMode).orElse(false);
+ if (!cookieMode) {
+ return null;
+ }
+ int budget = session.flatMap(OidcConfig.Session::maxCookieSize)
+ .orElse(SealedSessionCookieCodec.DEFAULT_COOKIE_VALUE_BUDGET);
+ // SecurityConfiguration.builder() with no overrides IS SecurityConfiguration.defaults(), so
+ // this differs from the gateway default in maxHeaderValueLength and nothing else.
+ return SecurityConfiguration.builder()
+ .maxHeaderValueLength(budget + COOKIE_HEADER_OVERHEAD_BYTES)
+ .build();
+ }
+
/**
* Maps a route's {@code security_filter} block to a cui-http {@link SecurityConfiguration},
* seeding the safe builder defaults and overriding only the limits the route declared, so an
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/events/EventType.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/events/EventType.java
index 91997d93..87cb9ef3 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/events/EventType.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/events/EventType.java
@@ -50,7 +50,7 @@ public enum EventType {
/** A route weakens an authentication default via an explicit override. */
AUTH_WEAKENED(EventCategory.CONFIGURATION, 0),
- // --- Input validation (400 / 404 / 405) ---
+ // --- Input validation (400 / 404 / 405 / 413) ---
/** A cui-http security filter rejected the request. */
SECURITY_FILTER_VIOLATION(EventCategory.INPUT_VALIDATION, 400),
@@ -69,6 +69,14 @@ public enum EventType {
PASSTHROUGH_HOST_SMUGGLED(EventCategory.INPUT_VALIDATION, 404),
/** The request method is outside the route's effective {@code allowed_methods}. */
METHOD_NOT_ALLOWED(EventCategory.INPUT_VALIDATION, 405),
+ /**
+ * A gateway-terminated reserved POST path (the OIDC {@code response_mode=form_post} callback or
+ * the back-channel logout receiver) declared or streamed a body beyond the edge's
+ * {@code reserved_body_max_bytes} ceiling. These paths are read before the pipeline's per-route
+ * body cap can apply, so the ceiling is enforced at the read itself and the request is rejected
+ * {@code 413} without the oversized body ever being buffered.
+ */
+ RESERVED_BODY_TOO_LARGE(EventCategory.INPUT_VALIDATION, 413),
// --- Authentication (401) ---
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStage.java
index 44ea98fd..d7b7bc05 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStage.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStage.java
@@ -22,12 +22,15 @@
import de.cuioss.http.security.config.SecurityConfiguration;
+import de.cuioss.http.security.core.HttpSecurityValidator;
import de.cuioss.http.security.exceptions.UrlSecurityException;
import de.cuioss.http.security.monitoring.SecurityEventCounter;
import de.cuioss.http.security.pipeline.PipelineFactory;
import de.cuioss.sheriff.gateway.events.EventType;
import de.cuioss.sheriff.gateway.events.GatewayException;
+import org.jspecify.annotations.Nullable;
+
/**
* Stage 1 — the baseline cui-http security filter plus collection-limit fast-reject, run for
* every request before route selection.
@@ -40,14 +43,32 @@
* {@link EventType#SECURITY_FILTER_VIOLATION} (400); a parameter- or header-count overflow beyond
* the configured caps becomes a {@link EventType#PARAMETER_LIMIT_EXCEEDED} (400) — both without ever
* echoing the offending value.
+ *
+ * The cookie-mode header-value carve-out. A cookie-mode BFF's sealed session cookie
+ * is designed to a multi-kilobyte budget, which the default 2048-character header-value cap would
+ * reject at the edge on every authenticated request. The stage therefore accepts an OPTIONAL
+ * {@code cookieHeaderConfiguration} whose only difference from the default policy is a raised
+ * {@code maxHeaderValueLength}, and applies it to the {@code Cookie} / {@code Set-Cookie} header
+ * values ONLY — every other header keeps the default cap, and every other validator in the pipeline
+ * (null-byte, control-character, injection-pattern) applies to the cookie header unchanged. A
+ * bearer-only or server-mode gateway passes {@code null} and is byte-for-byte unaffected.
+ *
+ * The relaxation is necessarily gateway-wide, not per-anchor: this stage runs
+ * BEFORE route selection, so no anchor is resolved yet. The configuration key that supplies the
+ * budget ({@code oidc.session.max_cookie_size}) sits on the global session block for exactly that
+ * reason — its placement must not be read as per-anchor enforcement.
*
* @author API Sheriff Team
* @since 1.0
*/
public final class BasicChecksStage {
+ private static final String COOKIE_HEADER = "cookie";
+ private static final String SET_COOKIE_HEADER = "set-cookie";
+
private final SecurityConfiguration configuration;
private final PipelineFactory.PipelineSet pipelines;
+ private final HttpSecurityValidator cookieHeaderValuePipeline;
private final BiPredicate reservedPathMatcher;
/**
@@ -60,12 +81,21 @@ public final class BasicChecksStage {
* forwarded to an upstream — is not applied to it (a same-origin
* {@code return_to} path legitimately carries {@code /}, which the
* pipeline would otherwise reject)
+ * @param cookieHeaderConfiguration the policy applied to {@code Cookie} / {@code Set-Cookie}
+ * header VALUES only, or {@code null} to validate them under
+ * {@code configuration} like every other header. Supplied only by a
+ * cookie-mode BFF gateway, whose sealed session cookie exceeds the
+ * default header-value cap
*/
public BasicChecksStage(SecurityConfiguration configuration, SecurityEventCounter eventCounter,
- BiPredicate reservedPathMatcher) {
+ BiPredicate reservedPathMatcher,
+ @Nullable SecurityConfiguration cookieHeaderConfiguration) {
this.configuration = Objects.requireNonNull(configuration, "configuration");
- this.pipelines = PipelineFactory.createCommonPipelines(configuration,
- Objects.requireNonNull(eventCounter, "eventCounter"));
+ SecurityEventCounter counter = Objects.requireNonNull(eventCounter, "eventCounter");
+ this.pipelines = PipelineFactory.createCommonPipelines(configuration, counter);
+ this.cookieHeaderValuePipeline = cookieHeaderConfiguration == null
+ ? this.pipelines.headerValuePipeline()
+ : PipelineFactory.createHeaderValuePipeline(cookieHeaderConfiguration, counter);
this.reservedPathMatcher = Objects.requireNonNull(reservedPathMatcher, "reservedPathMatcher");
}
@@ -134,9 +164,15 @@ private void validateParameters(Map> parameters) {
private void validateHeaders(Map> headers) {
try {
for (Map.Entry> header : headers.entrySet()) {
- pipelines.headerNamePipeline().validate(header.getKey());
+ String name = header.getKey();
+ pipelines.headerNamePipeline().validate(name);
+ // The header NAME is already in hand here — that is the seam the cookie-mode
+ // carve-out hangs off. Only Cookie / Set-Cookie values may use the raised cap.
+ HttpSecurityValidator valuePipeline = isCookieHeader(name)
+ ? cookieHeaderValuePipeline
+ : pipelines.headerValuePipeline();
for (String value : header.getValue()) {
- pipelines.headerValuePipeline().validate(value);
+ valuePipeline.validate(value);
}
}
} catch (UrlSecurityException violation) {
@@ -144,6 +180,10 @@ private void validateHeaders(Map> headers) {
}
}
+ private static boolean isCookieHeader(String name) {
+ return COOKIE_HEADER.equalsIgnoreCase(name) || SET_COOKIE_HEADER.equalsIgnoreCase(name);
+ }
+
private static GatewayException rejected(UrlSecurityException violation) {
return new GatewayException(EventType.SECURITY_FILTER_VIOLATION,
"Security filter rejected %s at %s".formatted(violation.getFailureType(), violation.getValidationType()),
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/PipelineRequest.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/PipelineRequest.java
index fe2cd518..104721fb 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/PipelineRequest.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/PipelineRequest.java
@@ -39,8 +39,9 @@
* populated by the stages as the request flows: the {@linkplain #canonicalPath()
* single canonical path} (stage 1), the {@linkplain #selectedRoute() selected route} (stage 2),
* the accumulating {@linkplain #responseHeaders() response-header map} (stage 0 onward, applied
- * to every response including rejections), and an optional {@linkplain #shortCircuitStatus()
- * short-circuit status} (e.g. a CORS preflight answered at stage 0 before auth).
+ * to every response including rejections), the multi-valued {@linkplain #responseSetCookies()
+ * Set-Cookie accumulator}, and an optional {@linkplain #shortCircuitStatus() short-circuit status}
+ * (e.g. a CORS preflight answered at stage 0 before auth).
*
* Header lookups are case-insensitive: header names are normalized to lower case when the
* instance is built, matching RFC 7230 field-name semantics.
@@ -60,6 +61,7 @@ public final class PipelineRequest {
private final boolean bodyPresent;
private final Map responseHeaders = new LinkedHashMap<>();
+ private final List responseSetCookies = new ArrayList<>();
private @Nullable String canonicalPath;
private @Nullable RouteRuntime selectedRoute;
private @Nullable Integer shortCircuitStatus;
@@ -235,6 +237,30 @@ public Map responseHeaders() {
return responseHeaders;
}
+ /**
+ * The accumulated {@code Set-Cookie} values, in emission order. {@code Set-Cookie} is the one
+ * legitimately multi-valued response header (RFC 6265 §3), so it is carried here rather than in
+ * the single-valued {@link #responseHeaders() header map}: the session runtime can emit a
+ * clearing cookie and a login-binding cookie on the very same response, and a single-valued slot
+ * would silently drop one of them — leaving a revoked session cookie in the browser. The edge
+ * writes each value as its own header line; it never comma-joins them.
+ *
+ * @return an immutable snapshot of the accumulated {@code Set-Cookie} values, empty when none
+ */
+ public List responseSetCookies() {
+ return List.copyOf(responseSetCookies);
+ }
+
+ /**
+ * Appends one {@code Set-Cookie} value to the response. Every appended value reaches the client
+ * as its own header line — appending never replaces a previously appended value.
+ *
+ * @param setCookieHeader the fully-rendered {@code Set-Cookie} header value
+ */
+ public void addResponseSetCookie(String setCookieHeader) {
+ responseSetCookies.add(Objects.requireNonNull(setCookieHeader, "setCookieHeader"));
+ }
+
/**
* @return the short-circuit status the edge must return immediately (e.g. a CORS preflight
* answered at stage 0), or empty when the request should flow through the full pipeline
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java
index dd3c9192..63dfc047 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java
@@ -29,6 +29,9 @@
import de.cuioss.sheriff.gateway.auth.GatewayValidator;
+import de.cuioss.sheriff.gateway.bff.cookie.CookieKeyMaterial;
+import de.cuioss.sheriff.gateway.bff.cookie.CookieSessionBinding;
+import de.cuioss.sheriff.gateway.bff.cookie.SealedSessionCookieCodec;
import de.cuioss.sheriff.gateway.bff.csrf.CsrfDefence;
import de.cuioss.sheriff.gateway.bff.login.LoginFlow;
import de.cuioss.sheriff.gateway.bff.logout.BackchannelLogoutReceiver;
@@ -48,8 +51,9 @@
import de.cuioss.sheriff.gateway.bff.runtime.BffRuntime;
import de.cuioss.sheriff.gateway.bff.runtime.SessionAuthenticationStage;
import de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore;
+import de.cuioss.sheriff.gateway.bff.session.ServerSessionBinding;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding;
import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec;
-import de.cuioss.sheriff.gateway.bff.session.SessionStore;
import de.cuioss.sheriff.gateway.config.model.GatewayConfig;
import de.cuioss.sheriff.gateway.config.model.OidcConfig;
import de.cuioss.sheriff.token.client.auth.ClientAuthentication;
@@ -79,11 +83,18 @@
* CDI producer of the server-mode {@link BffRuntime} — the D16 edge wiring that makes the D1–D12 BFF
* components reachable at the live gateway edge.
*
- * The producer builds the active runtime only when a global {@code oidc} block with
- * {@code session.mode=server} and a {@code redirect_uri} is configured; otherwise it produces
- * the {@linkplain BffRuntime#inert() inert} runtime, so a bearer-only gateway (or a cookie-mode BFF)
- * is unchanged and never touches the confidential-client engine. On the active path it assembles the
- * server-side stores, cookie codecs, the CSRF defence, the token-refresh / step-up coordinators, the
+ * The producer builds the active runtime only when a global {@code oidc} block with a
+ * {@code redirect_uri} and a recognised {@code session.mode} — {@code server} or {@code cookie} —
+ * is configured; otherwise it produces the {@linkplain BffRuntime#inert() inert} runtime,
+ * so a bearer-only gateway is unchanged and never touches the confidential-client engine.
+ *
+ * Both modes drive the same wiring. The only thing the mode selects is which
+ * {@link SessionBinding} is assembled — the store-backed {@link ServerSessionBinding} or the
+ * stateless {@link CookieSessionBinding} over the AES-256-GCM sealed-cookie codec. Every other
+ * collaborator (login flow, CSRF defence, step-up, refresh, and all reserved endpoints) is
+ * identical, and cookie mode reaches the confidential-client engine exactly as server mode does.
+ * On the active path the producer assembles the session binding, the cookie codecs, the CSRF
+ * defence, the token-refresh / step-up coordinators, the
* reserved-endpoint handlers, and the {@code require: session} stage-4 runtime, and binds the
* {@code token-sheriff-client} engine seams — {@code AuthorizationCodeFlow#authorize} /
* {@code #exchange} for login and callback, {@code RefreshFlow#refresh} for transparent refresh, and
@@ -102,7 +113,6 @@ public class BffRuntimeProducer {
private static final CuiLogger LOGGER = new CuiLogger(BffRuntimeProducer.class);
- private static final String SESSION_MODE_SERVER = "server";
private static final int DEFAULT_SESSION_TTL_SECONDS = 3600;
private static final int DEFAULT_MAX_SESSIONS = 10_000;
private static final int DEFAULT_MAX_PENDING = 10_000;
@@ -132,24 +142,36 @@ public BffRuntimeProducer(GatewayConfig gatewayConfig,
* class ArC cannot subclass to build a normal-scope proxy. The runtime is immutable and assembled
* once at boot, so a single instance is exact.
*
- * @return the active server-mode runtime, or the inert runtime for a bearer-only / cookie-mode gateway
+ * @return the active runtime for a recognised {@code session.mode}, or the inert runtime for a
+ * bearer-only gateway
*/
@Produces
@Singleton
public BffRuntime bffRuntime() {
Optional oidc = gatewayConfig.oidc();
- if (!isServerModeBff(oidc)) {
- LOGGER.debug("No server-mode oidc block — BFF runtime inert (bearer-only proxy path unchanged)");
+ if (!isBffMode(oidc)) {
+ LOGGER.debug("No BFF-mode oidc block — BFF runtime inert (bearer-only proxy path unchanged)");
return BffRuntime.inert();
}
return build(oidc.orElseThrow());
}
- private static boolean isServerModeBff(Optional oidc) {
- boolean serverMode = oidc.flatMap(OidcConfig::session).flatMap(OidcConfig.Session::mode)
- .filter(SESSION_MODE_SERVER::equalsIgnoreCase).isPresent();
+ /**
+ * The mode-aware activation predicate: a BFF runtime is built for either recognised
+ * {@code session.mode} — {@code server} or {@code cookie} — provided a {@code redirect_uri} is
+ * configured. An unrecognised or absent mode leaves the gateway bearer-only.
+ */
+ private static boolean isBffMode(Optional oidc) {
+ boolean recognisedMode = oidc.flatMap(OidcConfig::session)
+ .map(OidcConfig.Session::isRecognisedMode).orElse(false);
boolean hasRedirect = oidc.flatMap(OidcConfig::redirectUri).isPresent();
- return serverMode && hasRedirect;
+ return recognisedMode && hasRedirect;
+ }
+
+ private static boolean isCookieMode(Optional session) {
+ // The SHARED predicate on the config model, identical to the one boot validation and the
+ // edge's pre-route Cookie header-value cap read — never a locally-declared constant.
+ return session.map(OidcConfig.Session::isCookieMode).orElse(false);
}
private BffRuntime build(OidcConfig oidc) {
@@ -187,9 +209,13 @@ private BffRuntime build(OidcConfig oidc) {
RefreshFlow refreshFlow = new RefreshFlow(clientConfiguration, tokenEndpointClient, tokenBridge,
clientAuthentication);
- SessionCookieCodec sessionCookieCodec = new SessionCookieCodec(cookieName, sessionTtl);
BindingCookieCodec bindingCookieCodec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL);
- SessionStore sessionStore = new InMemorySessionStore(maxSessions);
+ // D7 seam: the whole BFF foundation binds SessionBinding, never the store directly. The mode
+ // selects only which implementation is assembled — everything below is mode-independent.
+ SessionBinding sessionBinding = isCookieMode(session)
+ ? cookieSessionBinding(session, cookieName, sessionTtl)
+ : new ServerSessionBinding(new InMemorySessionStore(maxSessions),
+ new SessionCookieCodec(cookieName, sessionTtl));
PendingAuthorizationStore pendingStore = new PendingAuthorizationStore.InMemory(DEFAULT_MAX_PENDING);
Clock clock = Clock.systemUTC();
@@ -201,18 +227,29 @@ private BffRuntime build(OidcConfig oidc) {
CallbackEndpoint callbackEndpoint = new CallbackEndpoint(
(context, params) -> authorizationCodeFlow.exchange(metadata.get(), context, params,
clientAuthentication),
- pendingStore, bindingCookieCodec, sessionStore, sessionCookieCodec, sessionTtl);
+ pendingStore, bindingCookieCodec, sessionBinding, sessionTtl);
// D7/D9 transparent refresh — near-expiry decision + engine RefreshFlow, session persistence.
TokenRefreshCoordinator refreshCoordinator = new TokenRefreshCoordinator(refreshLeeway,
sessionRecord -> tokenBridge.validateAccessToken(sessionRecord.accessToken())
.getExpirationDateTime().toInstant(),
refreshToken -> refreshFlow.refresh(metadata.get(), refreshToken),
- sessionStore);
+ sessionBinding);
// D4 session stage-4 runtime — binds refresh, scope enforcement, and the login-redirect seam.
- SessionAuthenticationStage sessionStage = new SessionAuthenticationStage(sessionStore, sessionCookieCodec,
- (sessionRecord, now) -> refreshCoordinator.refresh(sessionRecord, now).session().orElse(sessionRecord),
+ SessionAuthenticationStage sessionStage = new SessionAuthenticationStage(sessionBinding,
+ (sessionRecord, cookieHeader, now) -> {
+ TokenRefreshCoordinator.RefreshOutcome outcome =
+ refreshCoordinator.refresh(sessionRecord, cookieHeader, now);
+ if (outcome.isFailure()) {
+ // The coordinator already destroyed the session — signal it so the stage
+ // re-drives the unauthenticated negotiation instead of mediating the
+ // pre-refresh token of a session the gateway just revoked.
+ return Optional.empty();
+ }
+ return Optional.of(new SessionBinding.BoundSession(outcome.session().orElse(sessionRecord),
+ outcome.setCookieHeaders()));
+ },
(accessToken, requiredScopes) -> tokenBridge.validateAccessToken(accessToken)
.providesScopes(requiredScopes),
(returnUrl, now) -> {
@@ -234,35 +271,74 @@ private BffRuntime build(OidcConfig oidc) {
ClaimAllowlistFilter claimFilter = new ClaimAllowlistFilter(
oidc.userInfo().map(OidcConfig.UserInfo::allowedClaims).orElse(List.of()),
oidc.userInfo().map(OidcConfig.UserInfo::defaultView).orElse(List.of()));
- UserInfoEndpoint userInfoEndpoint = new UserInfoEndpoint(sessionStore, sessionCookieCodec, claimFilter,
+ UserInfoEndpoint userInfoEndpoint = new UserInfoEndpoint(sessionBinding, claimFilter,
sessionRecord -> toClaimMap(idBridge.validateRefreshedIdToken(sessionRecord.idToken()).getClaims()));
// D12 login-initiation fold — the browser-facing start mirror of the callback.
- LoginInitiationEndpoint loginInitiationEndpoint = new LoginInitiationEndpoint(loginFlow, sessionStore,
- sessionCookieCodec, gatewayOrigin);
+ LoginInitiationEndpoint loginInitiationEndpoint = new LoginInitiationEndpoint(loginFlow, sessionBinding,
+ gatewayOrigin);
// D2c back-channel logout — JWKS signature verification through the engine, then the claim residual.
+ // The endpoint stays wired in both modes: it is gated on the binding's IdP-destruction
+ // capability, so a stateless binding answers a deliberate 404 on the reserved path rather than
+ // letting that path fall through to the proxy route table.
BackchannelLogoutReceiver backchannelReceiver = new BackchannelLogoutReceiver(
idBridge::validateRefreshedIdToken,
new LogoutTokenValidator(issuer, clientId, BACKCHANNEL_FRESHNESS_WINDOW),
- sessionStore);
- BackchannelLogoutEndpoint backchannelLogoutEndpoint = new BackchannelLogoutEndpoint(backchannelReceiver);
+ sessionBinding);
+ BackchannelLogoutEndpoint backchannelLogoutEndpoint =
+ new BackchannelLogoutEndpoint(backchannelReceiver, sessionBinding);
// D5 RP-initiated logout — lazy so the discovery-sourced end_session_endpoint is resolved on
// first logout, not at boot. Revocation at the IdP is best-effort; the authoritative logout is
// the local session destruction the LogoutEndpoint performs.
Supplier logoutEndpoint = memoize(() -> buildLogoutEndpoint(oidc, gatewayOrigin,
- metadata.get(), sessionStore, sessionCookieCodec));
+ metadata.get(), sessionBinding));
CsrfDefence csrfDefence = new CsrfDefence(trustedOrigins);
- LOGGER.debug("Server-mode BFF runtime assembled for origin %s (issuer %s)", gatewayOrigin, issuer);
+ // build(...) is reached for BOTH modes, so the diagnostic must name the mode that was actually
+ // resolved — this is the line an operator greps to confirm which binding came up.
+ LOGGER.debug("%s-mode BFF runtime assembled for origin %s (issuer %s)",
+ isCookieMode(session) ? OidcConfig.Session.MODE_COOKIE : OidcConfig.Session.MODE_SERVER,
+ gatewayOrigin, issuer);
return new BffRuntime(sessionStage, csrfDefence, stepUpCoordinator, callbackEndpoint, logoutEndpoint,
backchannelLogoutEndpoint, userInfoEndpoint, loginInitiationEndpoint);
}
+ /**
+ * Assembles the stateless cookie-mode binding from the resolved {@link CookieKeyMaterial}: the
+ * AES-256-GCM sealed-cookie codec (with the decrypt-only {@code previous_key} wired in when a
+ * rotation is in progress), plus the per-gateway salt that keys the derived, never-emitted
+ * session identity. The salt is derived from the sealing key rather than configured separately,
+ * so it needs no operator input and cannot be recomputed off-gateway.
+ *
+ * Per ADR-0011 the configuration stays neutral — the keys are {@code ${ENV_VAR}} references
+ * carrying no material — so the concrete runtime choice is named by a startup diagnostic
+ * reporting the active key mode and rotation state, never any key bytes. The
+ * generate-on-startup mode additionally raises the catalogued INFO
+ * {@code COOKIE_KEY_GENERATED} from {@link CookieKeyMaterial}, because its
+ * sessions-die-on-restart consequence is operationally notable rather than merely diagnostic.
+ *
+ * The codec's seal-time size budget comes from {@code oidc.session.max_cookie_size} — the single
+ * declared number that also drives the edge's pre-route {@code Cookie} header-value cap, so the
+ * two ends of the round trip cannot drift apart.
+ */
+ private static SessionBinding cookieSessionBinding(Optional session, String cookieName,
+ Duration sessionTtl) {
+ CookieKeyMaterial keyMaterial = CookieKeyMaterial.resolve(
+ session.flatMap(OidcConfig.Session::encryptionKey),
+ session.flatMap(OidcConfig.Session::previousKey));
+ int maxCookieSize = session.flatMap(OidcConfig.Session::maxCookieSize)
+ .orElse(SealedSessionCookieCodec.DEFAULT_COOKIE_VALUE_BUDGET);
+ LOGGER.debug("Cookie-mode key material resolved: mode=%s, rotating=%s, maxCookieSize=%s",
+ keyMaterial.mode().diagnosticName(), keyMaterial.hasPreviousKey(), maxCookieSize);
+ return new CookieSessionBinding(keyMaterial.codec(cookieName, sessionTtl, maxCookieSize),
+ keyMaterial.identitySalt());
+ }
+
private static LogoutEndpoint buildLogoutEndpoint(OidcConfig oidc, String gatewayOrigin, ProviderMetadata metadata,
- SessionStore sessionStore, SessionCookieCodec sessionCookieCodec) {
+ SessionBinding sessionBinding) {
Optional logout = oidc.logout();
String postLogoutRedirectUri = logout.flatMap(OidcConfig.Logout::postLogoutRedirectUri)
.orElse(gatewayOrigin + "/");
@@ -276,7 +352,7 @@ private static LogoutEndpoint buildLogoutEndpoint(OidcConfig oidc, String gatewa
// Best-effort by design: the authoritative logout is the local session destruction.
},
endSessionEndpoint, postLogoutRedirectUri, finalRedirect, LOGOUT_STATE_TTL);
- return new LogoutEndpoint(rpInitiatedLogout, sessionStore, sessionCookieCodec);
+ return new LogoutEndpoint(rpInitiatedLogout, sessionBinding);
}
private static Map toClaimMap(Map claims) {
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/GatewayReadinessCheck.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/GatewayReadinessCheck.java
index 9de3f231..013ea0ff 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/GatewayReadinessCheck.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/GatewayReadinessCheck.java
@@ -82,7 +82,8 @@ public class GatewayReadinessCheck implements HealthCheck {
private static final String DATA_OIDC = "oidc";
private static final String DATA_ISSUER_REACHABILITY = "issuer_reachability";
- private static final String MODE_SERVER = "server";
+ /** The readiness payload's mode label — the same canonical spelling the config model owns. */
+ private static final String MODE_SERVER = OidcConfig.Session.MODE_SERVER;
private static final String ISSUER_REACHABLE = "reachable";
private static final String ISSUER_UNREACHABLE = "unreachable";
private static final String ISSUER_UNVERIFIED = "unverified";
@@ -155,10 +156,11 @@ public HealthCheckResponse call() {
* so issuer reachability is reported as part of readiness
*/
private boolean isServerSessionMode() {
+ // The SHARED predicate on the config model, identical to the one boot validation, the edge
+ // cap and the runtime binding selection read — never a locally-declared constant.
return gatewayConfig.oidc()
.flatMap(OidcConfig::session)
- .flatMap(OidcConfig.Session::mode)
- .filter(MODE_SERVER::equalsIgnoreCase)
- .isPresent();
+ .map(OidcConfig.Session::isServerMode)
+ .orElse(false);
}
}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/auth/AuthenticationStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/auth/AuthenticationStageTest.java
index 321342c2..cc4af8d1 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/auth/AuthenticationStageTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/auth/AuthenticationStageTest.java
@@ -26,11 +26,14 @@
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import de.cuioss.sheriff.gateway.bff.runtime.SessionAuthenticationStage;
import de.cuioss.sheriff.gateway.bff.runtime.SessionAuthenticationStage.LoginChallenge;
import de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore;
+import de.cuioss.sheriff.gateway.bff.session.ServerSessionBinding;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding;
import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec;
import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
import de.cuioss.sheriff.gateway.config.model.AuthConfig;
@@ -191,8 +194,8 @@ private static SessionAuthenticationStage sessionStage() {
.expiresAt(NOW.plusSeconds(3600))
.build());
SessionCookieCodec codec = new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, Duration.ofHours(1));
- return new SessionAuthenticationStage(store, codec,
- (session, now) -> session,
+ return new SessionAuthenticationStage(new ServerSessionBinding(store, codec),
+ (session, cookieHeader, now) -> Optional.of(new SessionBinding.BoundSession(session, List.of())),
(accessToken, requiredScopes) -> true,
(returnUrl, now) -> new LoginChallenge("https://idp.example/authorize", List.of()),
CLOCK);
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/CookieKeyMaterialTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/CookieKeyMaterialTest.java
new file mode 100644
index 00000000..0bf04bb6
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/CookieKeyMaterialTest.java
@@ -0,0 +1,266 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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 de.cuioss.sheriff.gateway.bff.cookie;
+
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Optional;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link CookieKeyMaterial} — the two first-class cookie key-material modes (D2b).
+ *
+ * The contracts under test are: passed-key acceptance including the decrypt-only rotation key; the
+ * boot rejection of a non-base64 or wrong-length key with a message that names the field but never
+ * echoes the value; generate-on-startup producing a distinct 256-bit key per boot; the companion
+ * rule refusing a {@code previous_key} without an {@code encryption_key} rather than silently
+ * ignoring it; and the absence of any key byte from {@link CookieKeyMaterial#toString()}.
+ */
+class CookieKeyMaterialTest {
+
+ private static final String COOKIE_NAME = "__Host-sheriff-session";
+ private static final Duration TTL = Duration.ofHours(8);
+ private static final int BUDGET = SealedSessionCookieCodec.DEFAULT_COOKIE_VALUE_BUDGET;
+ private static final Instant LOGIN = Instant.parse("2026-07-27T10:00:00Z");
+ private static final String CURRENT_KEY_B64 = base64Key((byte) 0x11);
+ private static final String PREVIOUS_KEY_B64 = base64Key((byte) 0x33);
+
+ private static String base64Key(byte fill) {
+ byte[] material = new byte[32];
+ Arrays.fill(material, fill);
+ return Base64.getEncoder().encodeToString(material);
+ }
+
+ private static SealedSessionPayload payload() {
+ return new SealedSessionPayload("access", Optional.empty(), "id-token", "user-sub-1",
+ Optional.empty(), Optional.empty(), Optional.empty(), LOGIN);
+ }
+
+ /** Reads the key-id byte a sealed value is stamped with (value layout: version, key-id, …). */
+ private static byte keyIdOf(String sealedValue) {
+ return Base64.getUrlDecoder().decode(sealedValue)[1];
+ }
+
+ @Nested
+ @DisplayName("Passed-key mode")
+ class PassedKey {
+
+ @Test
+ @DisplayName("Should resolve a supplied key into the passed mode without a rotation key")
+ void shouldResolvePassedKey() {
+ CookieKeyMaterial material = CookieKeyMaterial.resolve(Optional.of(CURRENT_KEY_B64), Optional.empty());
+
+ assertEquals(CookieKeyMaterial.Mode.PASSED, material.mode());
+ assertFalse(material.hasPreviousKey(), "no rotation is in progress");
+ }
+
+ @Test
+ @DisplayName("Should carry a key sealed before the rotation over into the rotating material")
+ void shouldAcceptPreviousKey() throws Exception {
+ // Before the rotation the retired key was THE key, so it sealed under its own id.
+ CookieKeyMaterial beforeRotation =
+ CookieKeyMaterial.resolve(Optional.of(PREVIOUS_KEY_B64), Optional.empty());
+ String sealedBeforeRotation = beforeRotation.codec(COOKIE_NAME, TTL, BUDGET).seal(payload());
+
+ CookieKeyMaterial rotating =
+ CookieKeyMaterial.resolve(Optional.of(CURRENT_KEY_B64), Optional.of(PREVIOUS_KEY_B64));
+ SealedSessionCookieCodec rotatingCodec = rotating.codec(COOKIE_NAME, TTL, BUDGET);
+
+ assertTrue(rotating.hasPreviousKey());
+ assertEquals(Optional.of(payload()), rotatingCodec.unseal(sealedBeforeRotation)
+ .map(SealedSessionCookieCodec.Unsealed::payload),
+ "the pre-rotation cookie still unseals — the key id follows the key, not its position");
+ assertTrue(rotatingCodec.unseal(sealedBeforeRotation).orElseThrow().sealedWithPreviousKey(),
+ "and is flagged as the retired generation, so its next write rolls it over");
+ assertEquals(rotating.currentKeyId(), keyIdOf(rotatingCodec.seal(payload())),
+ "new values are sealed under the current key id, never the retired one");
+ assertNotEquals(beforeRotation.currentKeyId(), rotating.currentKeyId(),
+ "the two generations are distinguishable on the wire");
+ }
+
+ @Test
+ @DisplayName("Should survive a round trip through the codec it builds")
+ void shouldRoundTripThroughItsCodec() throws Exception {
+ SealedSessionCookieCodec codec = CookieKeyMaterial
+ .resolve(Optional.of(CURRENT_KEY_B64), Optional.empty())
+ .codec(COOKIE_NAME, TTL, BUDGET);
+
+ assertEquals(Optional.of(payload()), codec.unseal(codec.seal(payload()))
+ .map(SealedSessionCookieCodec.Unsealed::payload));
+ }
+
+ @Test
+ @DisplayName("Should derive a salt bound to the key, so two different keys salt differently")
+ void shouldDeriveKeyBoundIdentitySalt() {
+ byte[] first = CookieKeyMaterial.resolve(Optional.of(CURRENT_KEY_B64), Optional.empty()).identitySalt();
+ byte[] second = CookieKeyMaterial.resolve(Optional.of(PREVIOUS_KEY_B64), Optional.empty()).identitySalt();
+
+ assertEquals(32, first.length, "the salt is a full SHA-256 digest");
+ assertFalse(Arrays.equals(first, second), "the salt is bound to the sealing key");
+ }
+ }
+
+ @Nested
+ @DisplayName("Boot rejection of malformed key material")
+ class BootRejection {
+
+ @Test
+ @DisplayName("Should reject a key that is not base64, naming the field but never the value")
+ void shouldRejectNonBase64Key() {
+ String offending = "not base64 ~~~";
+ Optional encryptionKey = Optional.of(offending);
+ Optional noPreviousKey = Optional.empty();
+
+ IllegalStateException thrown = assertThrows(IllegalStateException.class,
+ () -> CookieKeyMaterial.resolve(encryptionKey, noPreviousKey));
+
+ assertTrue(thrown.getMessage().contains("session.encryption_key"), thrown.getMessage());
+ assertFalse(thrown.getMessage().contains(offending), "the offending value is never echoed");
+ }
+
+ @Test
+ @DisplayName("Should reject a key of the wrong length with a message naming the expected size")
+ void shouldRejectWrongLengthKey() {
+ String tooShort = Base64.getEncoder().encodeToString(new byte[16]);
+ Optional encryptionKey = Optional.of(tooShort);
+ Optional noPreviousKey = Optional.empty();
+
+ IllegalStateException thrown = assertThrows(IllegalStateException.class,
+ () -> CookieKeyMaterial.resolve(encryptionKey, noPreviousKey));
+
+ assertTrue(thrown.getMessage().contains("32"), thrown.getMessage());
+ assertTrue(thrown.getMessage().contains("AES-256"), thrown.getMessage());
+ assertFalse(thrown.getMessage().contains(tooShort), "the offending value is never echoed");
+ }
+
+ @Test
+ @DisplayName("Should reject a malformed previous key naming the previous-key field")
+ void shouldRejectMalformedPreviousKey() {
+ Optional encryptionKey = Optional.of(CURRENT_KEY_B64);
+ Optional malformedPreviousKey = Optional.of(Base64.getEncoder().encodeToString(new byte[8]));
+
+ IllegalStateException thrown = assertThrows(IllegalStateException.class,
+ () -> CookieKeyMaterial.resolve(encryptionKey, malformedPreviousKey));
+
+ assertTrue(thrown.getMessage().contains("session.previous_key"), thrown.getMessage());
+ }
+
+ @Test
+ @DisplayName("Should refuse a previous key that derives the same wire id as the current key")
+ void shouldRefuseCollidingKeyIds() {
+ Optional encryptionKey = Optional.of(CURRENT_KEY_B64);
+ Optional collidingPreviousKey = Optional.of(CURRENT_KEY_B64);
+
+ IllegalStateException thrown = assertThrows(IllegalStateException.class,
+ () -> CookieKeyMaterial.resolve(encryptionKey, collidingPreviousKey));
+
+ assertTrue(thrown.getMessage().contains("same key id"), thrown.getMessage());
+ }
+
+ @Test
+ @DisplayName("Should refuse a previous key without an encryption key rather than ignore it")
+ void shouldRefusePreviousKeyAlone() {
+ Optional noEncryptionKey = Optional.empty();
+ Optional previousKey = Optional.of(PREVIOUS_KEY_B64);
+
+ IllegalStateException thrown = assertThrows(IllegalStateException.class,
+ () -> CookieKeyMaterial.resolve(noEncryptionKey, previousKey));
+
+ assertTrue(thrown.getMessage().contains("session.previous_key"), thrown.getMessage());
+ assertTrue(thrown.getMessage().contains("session.encryption_key"), thrown.getMessage());
+ }
+ }
+
+ @Nested
+ @DisplayName("Generate-on-startup mode")
+ class GenerateOnStartup {
+
+ @Test
+ @DisplayName("Should select the generated mode when no encryption key is configured")
+ void shouldSelectGeneratedMode() {
+ CookieKeyMaterial material = CookieKeyMaterial.resolve(Optional.empty(), Optional.empty());
+
+ assertEquals(CookieKeyMaterial.Mode.GENERATED, material.mode());
+ assertFalse(material.hasPreviousKey(), "the generated mode has no previous key by construction");
+ }
+
+ @Test
+ @DisplayName("Should generate a distinct key per boot, so a restart drops every session")
+ void shouldGenerateADistinctKeyPerBoot() throws Exception {
+ SealedSessionCookieCodec firstBoot =
+ CookieKeyMaterial.resolve(Optional.empty(), Optional.empty()).codec(COOKIE_NAME, TTL, BUDGET);
+ SealedSessionCookieCodec secondBoot =
+ CookieKeyMaterial.resolve(Optional.empty(), Optional.empty()).codec(COOKIE_NAME, TTL, BUDGET);
+
+ String sealedBeforeRestart = firstBoot.seal(payload());
+
+ assertTrue(firstBoot.unseal(sealedBeforeRestart).isPresent(), "the sealing boot still reads its own value");
+ assertTrue(secondBoot.unseal(sealedBeforeRestart).isEmpty(),
+ "a fresh key per boot means sessions do not survive a restart");
+ }
+
+ @Test
+ @DisplayName("Should generate a 256-bit key, so the codec it builds is AES-256-GCM")
+ void shouldGenerateA256BitKey() {
+ CookieKeyMaterial generated = CookieKeyMaterial.resolve(Optional.empty(), Optional.empty());
+
+ assertEquals(32, generated.currentKeyLengthBytes(),
+ "the generated key must be 256-bit — asserting the derived salt's length instead would "
+ + "pass for a 128-bit key too, since the salt is a fixed-width SHA-256 digest");
+ }
+
+ @Test
+ @DisplayName("Should derive a distinct identity salt per boot, following the generated key")
+ void shouldDeriveADistinctSaltPerBoot() {
+ byte[] salt = CookieKeyMaterial.resolve(Optional.empty(), Optional.empty()).identitySalt();
+ byte[] otherSalt = CookieKeyMaterial.resolve(Optional.empty(), Optional.empty()).identitySalt();
+
+ assertEquals(32, salt.length, "the salt is a full SHA-256 digest");
+ assertFalse(Arrays.equals(salt, otherSalt), "each boot's salt follows its own generated key");
+ }
+ }
+
+ @Nested
+ @DisplayName("No secret disclosure")
+ class NoSecretDisclosure {
+
+ @Test
+ @DisplayName("Should keep key material out of toString in both modes")
+ void shouldNotLeakKeyMaterialIntoToString() {
+ String passed = CookieKeyMaterial
+ .resolve(Optional.of(CURRENT_KEY_B64), Optional.of(PREVIOUS_KEY_B64)).toString();
+ String generated = CookieKeyMaterial.resolve(Optional.empty(), Optional.empty()).toString();
+
+ assertFalse(passed.contains(CURRENT_KEY_B64), "the current key never appears in toString()");
+ assertFalse(passed.contains(PREVIOUS_KEY_B64), "the previous key never appears in toString()");
+ assertTrue(passed.contains("passed key"), passed);
+ assertTrue(passed.contains("rotating=true"), passed);
+ assertTrue(generated.contains("generated on startup"), generated);
+ assertNotEquals(passed, generated);
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/CookieSessionBindingTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/CookieSessionBindingTest.java
new file mode 100644
index 00000000..503df1c1
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/CookieSessionBindingTest.java
@@ -0,0 +1,387 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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 de.cuioss.sheriff.gateway.bff.cookie;
+
+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.assertTrue;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Optional;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding.BoundSession;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding.IdpDestruction;
+import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
+import de.cuioss.test.juli.LogAsserts;
+import de.cuioss.test.juli.TestLogLevel;
+import de.cuioss.test.juli.junit5.EnableTestLogger;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link CookieSessionBinding} — the stateless seam implementation. Covers the
+ * bind/resolve/persist/destroy round trip, the server-side absolute-TTL enforcement (an expired
+ * cookie the browser still holds is refused), the {@code Max-Age} reflecting the remaining rather
+ * than the reset lifetime on a re-seal, the {@code previous_key} rotation (a retired-key cookie
+ * resolves and its next write is sealed under the current key, while withdrawing the retired key
+ * makes it no session), the stable-but-never-emitted session identity, and the
+ * {@code UNSUPPORTED} IdP-driven destruction capability, and the once-per-process rotation record.
+ */
+@EnableTestLogger
+class CookieSessionBindingTest {
+
+ private static final String COOKIE_NAME = "__Host-sheriff-session";
+ private static final Duration TTL = Duration.ofHours(8);
+ private static final int BUDGET = SealedSessionCookieCodec.DEFAULT_COOKIE_VALUE_BUDGET;
+ private static final Instant LOGIN = Instant.parse("2026-07-27T10:00:00Z");
+ private static final String ACCESS_TOKEN = "raw-access-token-SECRET-material";
+ private static final String REFRESH_TOKEN = "raw-refresh-token-SECRET-material";
+ private static final String ID_TOKEN = "raw-id-token-SECRET-material";
+ private static final String SUB = "user-sub-1";
+ private static final String SID = "idp-sid-9";
+ private static final byte CURRENT_KEY_ID = 1;
+ private static final byte PREVIOUS_KEY_ID = 7;
+
+ private SealedSessionCookieCodec codec;
+ private CookieSessionBinding binding;
+
+ @BeforeEach
+ void setUp() {
+ codec = new SealedSessionCookieCodec(COOKIE_NAME, TTL, BUDGET, aesKey((byte) 0x11), CURRENT_KEY_ID);
+ binding = new CookieSessionBinding(codec, identitySalt());
+ }
+
+ private static SecretKey aesKey(byte fill) {
+ byte[] material = new byte[32];
+ Arrays.fill(material, fill);
+ return new SecretKeySpec(material, "AES");
+ }
+
+ private static byte[] identitySalt() {
+ byte[] salt = new byte[32];
+ Arrays.fill(salt, (byte) 0x22);
+ return salt;
+ }
+
+ /** Reads the key-id byte the emitted cookie value is stamped with (value layout: version, key-id, …). */
+ private static byte keyIdOf(BoundSession bound) {
+ String cookie = cookieHeaderOf(bound);
+ String value = cookie.substring(cookie.indexOf('=') + 1);
+ return Base64.getUrlDecoder().decode(value)[1];
+ }
+
+ private static SessionRecord session(String accessToken, Instant expiresAt) {
+ return SessionRecord.builder()
+ .sessionId("ignored-on-bind")
+ .accessToken(accessToken)
+ .refreshToken(Optional.of(REFRESH_TOKEN))
+ .idToken(ID_TOKEN)
+ .sub(SUB)
+ .sid(Optional.of(SID))
+ .expiresAt(expiresAt)
+ .build();
+ }
+
+ private static String cookieHeaderOf(BoundSession bound) {
+ String setCookie = bound.setCookieHeaders().getFirst();
+ return setCookie.substring(0, setCookie.indexOf(';'));
+ }
+
+ @Nested
+ @DisplayName("Bind and resolve")
+ class BindAndResolve {
+
+ @Test
+ @DisplayName("Should seal the session into a Set-Cookie and read it back intact")
+ void shouldRoundTripThroughTheCookie() {
+ BoundSession bound = binding.bind(session(ACCESS_TOKEN, LOGIN.plus(TTL)), LOGIN);
+
+ Optional resolved = binding.resolve(cookieHeaderOf(bound), LOGIN);
+
+ assertTrue(resolved.isPresent());
+ SessionRecord resolvedSession = resolved.get();
+ assertEquals(ACCESS_TOKEN, resolvedSession.accessToken());
+ assertEquals(Optional.of(REFRESH_TOKEN), resolvedSession.refreshToken());
+ assertEquals(ID_TOKEN, resolvedSession.idToken());
+ assertEquals(SUB, resolvedSession.sub());
+ assertEquals(Optional.of(SID), resolvedSession.sid());
+ assertEquals(LOGIN.plus(TTL), resolvedSession.expiresAt(),
+ "the deadline is derived from the sealed login instant");
+ }
+
+ @Test
+ @DisplayName("Should emit exactly one hardened Set-Cookie carrying no token material")
+ void shouldEmitOneHardenedCookie() {
+ BoundSession bound = binding.bind(session(ACCESS_TOKEN, LOGIN.plus(TTL)), LOGIN);
+
+ assertEquals(1, bound.setCookieHeaders().size());
+ String setCookie = bound.setCookieHeaders().getFirst();
+ assertTrue(setCookie.startsWith(COOKIE_NAME + "="), setCookie);
+ assertTrue(setCookie.contains("Secure"), setCookie);
+ assertTrue(setCookie.contains("HttpOnly"), setCookie);
+ assertTrue(setCookie.contains("SameSite=Lax"), setCookie);
+ assertFalse(setCookie.contains(ACCESS_TOKEN), "token material is sealed, never emitted in the clear");
+ assertFalse(setCookie.contains(REFRESH_TOKEN), "token material is sealed, never emitted in the clear");
+ }
+
+ @Test
+ @DisplayName("Should resolve nothing without a session cookie or from a tampered one")
+ void shouldResolveNothingWithoutAValidCookie() {
+ BoundSession bound = binding.bind(session(ACCESS_TOKEN, LOGIN.plus(TTL)), LOGIN);
+ String tampered = cookieHeaderOf(bound).replace(COOKIE_NAME + "=", COOKIE_NAME + "=A");
+
+ assertTrue(binding.resolve(null, LOGIN).isEmpty());
+ assertTrue(binding.resolve("other=1", LOGIN).isEmpty());
+ assertTrue(binding.resolve(tampered, LOGIN).isEmpty(), "a tampered cookie is no session, never an error");
+ }
+ }
+
+ @Nested
+ @DisplayName("Server-side absolute TTL")
+ class AbsoluteTtl {
+
+ @Test
+ @DisplayName("Should refuse a cookie past its absolute deadline even though the browser still sent it")
+ void shouldRefuseExpiredCookie() {
+ BoundSession bound = binding.bind(session(ACCESS_TOKEN, LOGIN.plus(TTL)), LOGIN);
+ String cookieHeader = cookieHeaderOf(bound);
+
+ assertTrue(binding.resolve(cookieHeader, LOGIN.plus(TTL).minusSeconds(1)).isPresent(),
+ "still live one second before the deadline");
+ assertTrue(binding.resolve(cookieHeader, LOGIN.plus(TTL)).isEmpty(),
+ "the TTL is enforced server-side from the sealed login instant, not by the browser's Max-Age");
+ }
+ }
+
+ @Nested
+ @DisplayName("Persist (re-seal)")
+ class Persist {
+
+ @Test
+ @DisplayName("Should re-seal the rotated material into a new cookie")
+ void shouldResealRotatedMaterial() {
+ BoundSession bound = binding.bind(session(ACCESS_TOKEN, LOGIN.plus(TTL)), LOGIN);
+ SessionRecord resolved = binding.resolve(cookieHeaderOf(bound), LOGIN).orElseThrow();
+ SessionRecord rotated = SessionRecord.builder()
+ .sessionId(resolved.sessionId())
+ .accessToken("rotated-access-token")
+ .refreshToken(Optional.of("rotated-refresh-token"))
+ .idToken(resolved.idToken())
+ .sub(resolved.sub())
+ .sid(resolved.sid())
+ .expiresAt(resolved.expiresAt())
+ .build();
+
+ BoundSession reBound = binding.persist(rotated, LOGIN.plusSeconds(60));
+
+ SessionRecord reResolved = binding.resolve(cookieHeaderOf(reBound), LOGIN.plusSeconds(60)).orElseThrow();
+ assertEquals("rotated-access-token", reResolved.accessToken());
+ assertEquals(Optional.of("rotated-refresh-token"), reResolved.refreshToken());
+ }
+
+ @Test
+ @DisplayName("Should carry the remaining, not the reset, lifetime in Max-Age on a re-seal")
+ void shouldNotExtendTheSessionOnReseal() {
+ Instant halfway = LOGIN.plus(TTL.dividedBy(2));
+ SessionRecord rotated = session("rotated-access-token", LOGIN.plus(TTL));
+
+ BoundSession reBound = binding.persist(rotated, halfway);
+
+ String setCookie = reBound.setCookieHeaders().getFirst();
+ assertTrue(setCookie.contains("Max-Age=" + TTL.dividedBy(2).toSeconds()),
+ "a re-seal halfway through carries only the remaining lifetime: " + setCookie);
+ }
+
+ @Test
+ @DisplayName("Should keep the absolute deadline anchored across a re-seal")
+ void shouldKeepDeadlineAnchoredAcrossReseal() {
+ Instant halfway = LOGIN.plus(TTL.dividedBy(2));
+ BoundSession reBound = binding.persist(session("rotated-access-token", LOGIN.plus(TTL)), halfway);
+ String cookieHeader = cookieHeaderOf(reBound);
+
+ assertTrue(binding.resolve(cookieHeader, LOGIN.plus(TTL).minusSeconds(1)).isPresent());
+ assertTrue(binding.resolve(cookieHeader, LOGIN.plus(TTL)).isEmpty(),
+ "the original deadline still applies after the re-seal");
+ }
+ }
+
+ @Nested
+ @DisplayName("previous_key rotation")
+ class Rotation {
+
+ private CookieSessionBinding retiredBinding;
+ private CookieSessionBinding rotatingBinding;
+
+ @BeforeEach
+ void setUpRotation() {
+ SecretKey previousKey = aesKey((byte) 0x33);
+ retiredBinding = new CookieSessionBinding(
+ new SealedSessionCookieCodec(COOKIE_NAME, TTL, BUDGET, previousKey, PREVIOUS_KEY_ID), identitySalt());
+ rotatingBinding = new CookieSessionBinding(
+ new SealedSessionCookieCodec(COOKIE_NAME, TTL, BUDGET, aesKey((byte) 0x11), CURRENT_KEY_ID,
+ previousKey, PREVIOUS_KEY_ID),
+ identitySalt());
+ }
+
+ @Test
+ @DisplayName("Should resolve a previous-key cookie and re-seal its next write with the current key")
+ void shouldResealOnNextWrite() {
+ BoundSession sealedBeforeRotation = retiredBinding.bind(session(ACCESS_TOKEN, LOGIN.plus(TTL)), LOGIN);
+ assertEquals(PREVIOUS_KEY_ID, keyIdOf(sealedBeforeRotation), "the old cookie carries the retired key id");
+
+ SessionRecord resolved = rotatingBinding.resolve(cookieHeaderOf(sealedBeforeRotation), LOGIN).orElseThrow();
+ BoundSession nextWrite = rotatingBinding.persist(resolved, LOGIN.plusSeconds(60));
+
+ assertEquals(CURRENT_KEY_ID, keyIdOf(nextWrite),
+ "the session survives the rotation and its next write is sealed under the current key");
+ assertEquals(ACCESS_TOKEN, rotatingBinding.resolve(cookieHeaderOf(nextWrite), LOGIN.plusSeconds(60))
+ .orElseThrow().accessToken(), "the re-sealed cookie still carries the session material");
+ }
+
+ @Test
+ @DisplayName("Should record the rotation once, not on every resolve of an unrotated cookie")
+ void shouldRecordTheRotationOnlyOnce() {
+ BoundSession sealedBeforeRotation = retiredBinding.bind(session(ACCESS_TOKEN, LOGIN.plus(TTL)), LOGIN);
+ String cookieHeader = cookieHeaderOf(sealedBeforeRotation);
+
+ for (int request = 0; request < 5; request++) {
+ assertTrue(rotatingBinding.resolve(cookieHeader, LOGIN).isPresent(),
+ "a previous-key cookie keeps resolving for the whole rotation window");
+ }
+
+ LogAsserts.assertSingleLogMessagePresentContaining(TestLogLevel.INFO,
+ "Cookie-mode previous-key rotation in progress");
+ }
+
+ @Test
+ @DisplayName("Should keep the absolute deadline anchored across the rotation re-seal")
+ void shouldNotExtendTheSessionOnRotation() {
+ BoundSession sealedBeforeRotation = retiredBinding.bind(session(ACCESS_TOKEN, LOGIN.plus(TTL)), LOGIN);
+ Instant halfway = LOGIN.plus(TTL.dividedBy(2));
+ SessionRecord resolved = rotatingBinding.resolve(cookieHeaderOf(sealedBeforeRotation), halfway)
+ .orElseThrow();
+
+ BoundSession reSealed = rotatingBinding.persist(resolved, halfway);
+
+ assertTrue(rotatingBinding.resolve(cookieHeaderOf(reSealed), LOGIN.plus(TTL)).isEmpty(),
+ "rotating the key does not restart the absolute lifetime");
+ }
+
+ @Test
+ @DisplayName("Should treat a previous-key cookie as no session once the previous key is withdrawn")
+ void shouldRefusePreviousKeyCookieAfterWithdrawal() {
+ BoundSession sealedBeforeRotation = retiredBinding.bind(session(ACCESS_TOKEN, LOGIN.plus(TTL)), LOGIN);
+
+ assertTrue(binding.resolve(cookieHeaderOf(sealedBeforeRotation), LOGIN).isEmpty(),
+ "the current-key-only binding refuses the retired cookie as no session, never an error");
+ }
+
+ @Test
+ @DisplayName("Should seal a fresh cookie-mode login under the current key while rotation is active")
+ void shouldBindFreshLoginsUnderTheCurrentKey() {
+ BoundSession fresh = rotatingBinding.bind(session(ACCESS_TOKEN, LOGIN.plus(TTL)), LOGIN);
+
+ assertEquals(CURRENT_KEY_ID, keyIdOf(fresh), "the previous key is decrypt-only and never seals");
+ }
+ }
+
+ @Nested
+ @DisplayName("Session identity")
+ class Identity {
+
+ @Test
+ @DisplayName("Should derive a stable identity across a re-seal, so single-flight coalescing keys the same")
+ void shouldDeriveStableIdentity() {
+ BoundSession bound = binding.bind(session(ACCESS_TOKEN, LOGIN.plus(TTL)), LOGIN);
+ SessionRecord first = binding.resolve(cookieHeaderOf(bound), LOGIN).orElseThrow();
+
+ BoundSession reBound = binding.persist(session("rotated-access-token", LOGIN.plus(TTL)),
+ LOGIN.plusSeconds(60));
+ SessionRecord second = binding.resolve(cookieHeaderOf(reBound), LOGIN.plusSeconds(60)).orElseThrow();
+
+ assertEquals(first.sessionId(), second.sessionId(),
+ "the identity is derived from the login instant and sub, both fixed for the session's life");
+ }
+
+ @Test
+ @DisplayName("Should never emit the derived identity to the browser")
+ void shouldNeverEmitTheIdentity() {
+ BoundSession bound = binding.bind(session(ACCESS_TOKEN, LOGIN.plus(TTL)), LOGIN);
+ String identity = binding.resolve(cookieHeaderOf(bound), LOGIN).orElseThrow().sessionId();
+
+ assertFalse(bound.setCookieHeaders().getFirst().contains(identity),
+ "the derived identity is an in-instance key only — the browser sees only the sealed value");
+ }
+
+ @Test
+ @DisplayName("Should derive different identities for different subjects")
+ void shouldSeparateIdentitiesBySubject() {
+ SessionRecord other = SessionRecord.builder()
+ .sessionId("ignored").accessToken(ACCESS_TOKEN).idToken(ID_TOKEN).sub("user-sub-2")
+ .expiresAt(LOGIN.plus(TTL)).build();
+
+ String first = binding.resolve(cookieHeaderOf(binding.bind(session(ACCESS_TOKEN, LOGIN.plus(TTL)), LOGIN)),
+ LOGIN).orElseThrow().sessionId();
+ String second = binding.resolve(cookieHeaderOf(binding.bind(other, LOGIN)), LOGIN)
+ .orElseThrow().sessionId();
+
+ assertNotEquals(first, second);
+ }
+ }
+
+ @Nested
+ @DisplayName("Destruction")
+ class Destruction {
+
+ @Test
+ @DisplayName("Should report UNSUPPORTED IdP-driven destruction — there is no index to scan")
+ void shouldReportUnsupportedIdpDestruction() {
+ assertEquals(IdpDestruction.UNSUPPORTED, binding.idpDestruction());
+ assertEquals(0, binding.destroyBySid(SID), "a stateless binding destroys nothing by sid");
+ assertEquals(0, binding.destroyBySub(SUB), "a stateless binding destroys nothing by sub");
+ }
+
+ @Test
+ @DisplayName("Should clear the browser's copy through the clearing Set-Cookie")
+ void shouldClearThroughTheCookie() {
+ String clearing = binding.clearingSetCookieHeader();
+
+ assertTrue(clearing.startsWith(COOKIE_NAME + "=;"), clearing);
+ assertTrue(clearing.contains("Max-Age=0"), clearing);
+ }
+
+ @Test
+ @DisplayName("Should leave destroy a local no-op — nothing is held server-side")
+ void shouldTreatDestroyAsALocalNoOp() {
+ SessionRecord liveSession = session(ACCESS_TOKEN, LOGIN.plus(TTL));
+
+ binding.destroy(liveSession);
+
+ assertEquals(codec.toClearingSetCookieHeader(), binding.clearingSetCookieHeader(),
+ "destruction is expressed through the clearing cookie the caller emits");
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionCookieCodecTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionCookieCodecTest.java
new file mode 100644
index 00000000..a34149d4
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionCookieCodecTest.java
@@ -0,0 +1,408 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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 de.cuioss.sheriff.gateway.bff.cookie;
+
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Optional;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+
+import de.cuioss.sheriff.gateway.bff.cookie.SealedSessionCookieCodec.CookieSizeBudgetExceededException;
+import de.cuioss.sheriff.gateway.bff.cookie.SealedSessionCookieCodec.Unsealed;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link SealedSessionCookieCodec} — the AES-256-GCM crypto core of the stateless
+ * cookie-mode BFF variant.
+ *
+ * The security-relevant contracts under test are: round-trip fidelity; a fresh nonce per seal (two
+ * seals of one payload never produce the same value); fail-closed unsealing for a flipped byte in
+ * each of ciphertext / nonce / tag; the associated-data binding, so a value cannot be
+ * replayed under a different cookie name, format version, or key id; the ~4 KB size budget failing
+ * the seal rather than truncating; the decrypt-only {@code previous_key} rotation, where a retired
+ * key still unseals but is never selected for a seal and withdrawing it makes the old value
+ * unauthenticated rather than an error; and the absence of key or token material from every emitted
+ * header and {@code toString()}.
+ */
+class SealedSessionCookieCodecTest {
+
+ private static final String COOKIE_NAME = "__Host-sheriff-session";
+ private static final String OTHER_COOKIE_NAME = "__Host-other-session";
+ private static final Duration TTL = Duration.ofHours(8);
+ private static final int BUDGET = SealedSessionCookieCodec.DEFAULT_COOKIE_VALUE_BUDGET;
+ private static final Instant LOGIN = Instant.parse("2026-07-27T10:00:00Z");
+ private static final byte KEY_ID = 1;
+ private static final byte OTHER_KEY_ID = 2;
+ private static final String ACCESS_TOKEN = "raw-access-token-SECRET-material";
+ private static final String REFRESH_TOKEN = "raw-refresh-token-SECRET-material";
+ private static final String ID_TOKEN = "raw-id-token-SECRET-material";
+ private static final String SUB = "user-sub-1";
+
+ private SecretKey key;
+ private SealedSessionCookieCodec codec;
+
+ @BeforeEach
+ void setUp() {
+ key = aesKey((byte) 0x11);
+ codec = new SealedSessionCookieCodec(COOKIE_NAME, TTL, BUDGET, key, KEY_ID);
+ }
+
+ private static SecretKey aesKey(byte fill) {
+ byte[] material = new byte[32];
+ Arrays.fill(material, fill);
+ return new SecretKeySpec(material, "AES");
+ }
+
+ private static SealedSessionPayload payload() {
+ return new SealedSessionPayload(ACCESS_TOKEN, Optional.of(REFRESH_TOKEN), ID_TOKEN, SUB,
+ Optional.of("idp-sid-9"), Optional.of("urn:acr:silver"),
+ Optional.of(Instant.parse("2026-07-27T09:59:00Z")), LOGIN);
+ }
+
+ private static String flipByteAt(String sealedValue, int index) {
+ byte[] raw = Base64.getUrlDecoder().decode(sealedValue);
+ raw[index] ^= (byte) 0xFF;
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
+ }
+
+ @Nested
+ @DisplayName("Round trip")
+ class RoundTrip {
+
+ @Test
+ @DisplayName("Should unseal a sealed payload back to an equal payload")
+ void shouldRoundTrip() throws Exception {
+ SealedSessionPayload original = payload();
+
+ String sealed = codec.seal(original);
+ Optional unsealed = codec.unseal(sealed);
+
+ assertEquals(Optional.of(new Unsealed(original, false)), unsealed,
+ "the payload survives the round trip intact, authenticated by the current key");
+ }
+
+ @Test
+ @DisplayName("Should draw a fresh nonce per seal, so two seals of one payload differ")
+ void shouldUseAFreshNoncePerSeal() throws Exception {
+ SealedSessionPayload original = payload();
+
+ String first = codec.seal(original);
+ String second = codec.seal(original);
+
+ assertNotEquals(first, second,
+ "a fresh random nonce per seal is mandatory — GCM nonce reuse under one key is catastrophic");
+ assertEquals(codec.unseal(first), codec.unseal(second), "both seals still unseal to the same payload");
+ }
+
+ @Test
+ @DisplayName("Should carry the version and key id in the first two bytes of the value")
+ void shouldCarryVersionAndKeyIdHeader() throws Exception {
+ byte[] raw = Base64.getUrlDecoder().decode(codec.seal(payload()));
+
+ assertEquals(SealedSessionCookieCodec.FORMAT_VERSION, raw[0]);
+ assertEquals(KEY_ID, raw[1]);
+ }
+ }
+
+ @Nested
+ @DisplayName("Fail-closed unsealing")
+ class FailClosed {
+
+ @Test
+ @DisplayName("Should reject a flipped ciphertext byte as no session")
+ void shouldRejectFlippedCiphertextByte() throws Exception {
+ String sealed = codec.seal(payload());
+ // Byte 14 is the first ciphertext byte: version(1) + key-id(1) + nonce(12).
+ String tampered = flipByteAt(sealed, 14);
+
+ assertTrue(codec.unseal(tampered).isEmpty(), "a tampered ciphertext is no session, never an error");
+ }
+
+ @Test
+ @DisplayName("Should reject a flipped nonce byte as no session")
+ void shouldRejectFlippedNonceByte() throws Exception {
+ String sealed = codec.seal(payload());
+
+ assertTrue(codec.unseal(flipByteAt(sealed, 2)).isEmpty(), "a tampered nonce fails the tag check");
+ }
+
+ @Test
+ @DisplayName("Should reject a flipped tag byte as no session")
+ void shouldRejectFlippedTagByte() throws Exception {
+ String sealed = codec.seal(payload());
+ int lastByte = Base64.getUrlDecoder().decode(sealed).length - 1;
+
+ assertTrue(codec.unseal(flipByteAt(sealed, lastByte)).isEmpty(), "a tampered tag fails the tag check");
+ }
+
+ @Test
+ @DisplayName("Should reject a value sealed for a different cookie name (AAD binding)")
+ void shouldRejectCookieNameMismatch() throws Exception {
+ String sealed = codec.seal(payload());
+ SealedSessionCookieCodec otherName = new SealedSessionCookieCodec(OTHER_COOKIE_NAME, TTL, BUDGET, key, KEY_ID);
+
+ assertTrue(otherName.unseal(sealed).isEmpty(),
+ "the cookie name is bound into the associated data, so a value cannot be replayed under another name");
+ }
+
+ @Test
+ @DisplayName("Should reject an unknown format version")
+ void shouldRejectUnknownVersion() throws Exception {
+ String sealed = codec.seal(payload());
+ byte[] raw = Base64.getUrlDecoder().decode(sealed);
+ raw[0] = (byte) (SealedSessionCookieCodec.FORMAT_VERSION + 1);
+
+ assertTrue(codec.unseal(Base64.getUrlEncoder().withoutPadding().encodeToString(raw)).isEmpty(),
+ "an unknown format version is refused before any decrypt attempt");
+ }
+
+ @Test
+ @DisplayName("Should reject an unknown key id without trying the key it does hold")
+ void shouldRejectUnknownKeyId() throws Exception {
+ SealedSessionCookieCodec otherKeyId = new SealedSessionCookieCodec(COOKIE_NAME, TTL, BUDGET, key, OTHER_KEY_ID);
+ String sealedUnderOtherId = otherKeyId.seal(payload());
+
+ assertTrue(codec.unseal(sealedUnderOtherId).isEmpty(),
+ "key selection is deterministic by key id — never a try-every-key decrypt");
+ }
+
+ @Test
+ @DisplayName("Should reject a value sealed under a different key")
+ void shouldRejectForeignKey() throws Exception {
+ SealedSessionCookieCodec foreign =
+ new SealedSessionCookieCodec(COOKIE_NAME, TTL, BUDGET, aesKey((byte) 0x22), KEY_ID);
+ String sealedUnderForeignKey = foreign.seal(payload());
+
+ assertTrue(codec.unseal(sealedUnderForeignKey).isEmpty(), "a foreign key fails the tag check");
+ }
+
+ @Test
+ @DisplayName("Should reject a value that is not base64 or is too short")
+ void shouldRejectMalformedValue() {
+ assertTrue(codec.unseal("not base64 ~~~").isEmpty());
+ assertTrue(codec.unseal("AAAA").isEmpty(), "a value shorter than the header plus tag is malformed");
+ assertTrue(codec.unseal("").isEmpty());
+ }
+ }
+
+ @Nested
+ @DisplayName("previous_key rotation (decrypt-only)")
+ class Rotation {
+
+ private static final byte PREVIOUS_KEY_ID = 7;
+
+ private SecretKey previousKey;
+ private SealedSessionCookieCodec retiredCodec;
+ private SealedSessionCookieCodec rotating;
+
+ @BeforeEach
+ void setUpRotation() {
+ previousKey = aesKey((byte) 0x33);
+ retiredCodec = new SealedSessionCookieCodec(COOKIE_NAME, TTL, BUDGET, previousKey, PREVIOUS_KEY_ID);
+ rotating = new SealedSessionCookieCodec(COOKIE_NAME, TTL, BUDGET, key, KEY_ID, previousKey,
+ PREVIOUS_KEY_ID);
+ }
+
+ @Test
+ @DisplayName("Should accept a value sealed under the previous key and flag it for re-seal")
+ void shouldAcceptPreviousKeyValue() throws Exception {
+ SealedSessionPayload original = payload();
+ String sealedUnderPreviousKey = retiredCodec.seal(original);
+
+ Optional unsealed = rotating.unseal(sealedUnderPreviousKey);
+
+ assertEquals(Optional.of(new Unsealed(original, true)), unsealed,
+ "the retired key still authenticates the value, flagged as the previous generation");
+ }
+
+ @Test
+ @DisplayName("Should never seal under the previous key id, so a rollover is one-way")
+ void shouldNeverSealWithThePreviousKey() throws Exception {
+ byte[] raw = Base64.getUrlDecoder().decode(rotating.seal(payload()));
+
+ assertEquals(KEY_ID, raw[1], "seal always stamps the current key id");
+ assertNotEquals(PREVIOUS_KEY_ID, raw[1], "the previous key is decrypt-only and never selected for a seal");
+ }
+
+ @Test
+ @DisplayName("Should still flag a current-key value as not sealed with the previous key")
+ void shouldNotFlagCurrentKeyValue() throws Exception {
+ Optional unsealed = rotating.unseal(rotating.seal(payload()));
+
+ assertTrue(unsealed.isPresent());
+ assertFalse(unsealed.get().sealedWithPreviousKey(), "a freshly sealed value is already on the current key");
+ }
+
+ @Test
+ @DisplayName("Should treat a previous-key value as no session once the previous key is withdrawn")
+ void shouldRejectPreviousKeyValueAfterWithdrawal() throws Exception {
+ String sealedUnderPreviousKey = retiredCodec.seal(payload());
+ SealedSessionCookieCodec currentKeyOnly = new SealedSessionCookieCodec(COOKIE_NAME, TTL, BUDGET, key, KEY_ID);
+
+ assertTrue(currentKeyOnly.unseal(sealedUnderPreviousKey).isEmpty(),
+ "withdrawing the previous key makes the old cookie unauthenticated, never an error");
+ }
+
+ @Test
+ @DisplayName("Should refuse a previous key that reuses the current key id")
+ void shouldRefuseAmbiguousKeyIds() {
+ IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class,
+ () -> new SealedSessionCookieCodec(COOKIE_NAME, TTL, BUDGET, key, KEY_ID, previousKey, KEY_ID));
+
+ assertTrue(thrown.getMessage().contains("previousKeyId"), thrown.getMessage());
+ }
+ }
+
+ @Nested
+ @DisplayName("Size budget")
+ class SizeBudget {
+
+ @Test
+ @DisplayName("Should refuse to seal a payload whose value exceeds the budget, never truncate")
+ void shouldRefuseOversizedPayload() {
+ String huge = "x".repeat(BUDGET * 2);
+ SealedSessionPayload oversized = new SealedSessionPayload(huge, Optional.empty(), ID_TOKEN, SUB,
+ Optional.empty(), Optional.empty(), Optional.empty(), LOGIN);
+
+ CookieSizeBudgetExceededException thrown =
+ assertThrows(CookieSizeBudgetExceededException.class, () -> codec.seal(oversized));
+
+ assertEquals(BUDGET, thrown.budget());
+ assertTrue(thrown.sealedLength() > thrown.budget(),
+ "the reported length is the value that would have been emitted");
+ }
+
+ @Test
+ @DisplayName("Should seal a realistic payload well inside the budget")
+ void shouldSealRealisticPayload() {
+ assertDoesNotThrow(() -> codec.seal(payload()));
+ }
+ }
+
+ @Nested
+ @DisplayName("Set-Cookie hardening and lifetime")
+ class SetCookieHeader {
+
+ @Test
+ @DisplayName("Should emit the landed hardening attributes")
+ void shouldEmitHardenedAttributes() throws Exception {
+ String header = codec.toSetCookieHeader(codec.seal(payload()), LOGIN, LOGIN);
+
+ assertTrue(header.startsWith(COOKIE_NAME + "="), header);
+ assertTrue(header.contains("Path=/"), header);
+ assertTrue(header.contains("Secure"), header);
+ assertTrue(header.contains("HttpOnly"), header);
+ assertTrue(header.contains("SameSite=Lax"), header);
+ }
+
+ @Test
+ @DisplayName("Should set Max-Age to the remaining lifetime, so a re-seal never extends the session")
+ void shouldSetMaxAgeToRemainingLifetime() throws Exception {
+ String sealed = codec.seal(payload());
+ Instant halfway = LOGIN.plus(TTL.dividedBy(2));
+
+ String atLogin = codec.toSetCookieHeader(sealed, LOGIN, LOGIN);
+ String atHalfway = codec.toSetCookieHeader(sealed, LOGIN, halfway);
+
+ assertTrue(atLogin.contains("Max-Age=" + TTL.toSeconds()), atLogin);
+ assertTrue(atHalfway.contains("Max-Age=" + TTL.dividedBy(2).toSeconds()),
+ "a re-seal halfway through the session carries only the remaining lifetime: " + atHalfway);
+ }
+
+ @Test
+ @DisplayName("Should clamp Max-Age at zero past the absolute deadline")
+ void shouldClampMaxAgeAtZero() throws Exception {
+ String header = codec.toSetCookieHeader(codec.seal(payload()), LOGIN, LOGIN.plus(TTL).plusSeconds(60));
+
+ assertTrue(header.contains("Max-Age=0"), header);
+ }
+
+ @Test
+ @DisplayName("Should clear the cookie with an immediate expiry")
+ void shouldClearCookie() {
+ String header = codec.toClearingSetCookieHeader();
+
+ assertTrue(header.startsWith(COOKIE_NAME + "=;"), header);
+ assertTrue(header.contains("Max-Age=0"), header);
+ }
+ }
+
+ @Nested
+ @DisplayName("Cookie header reading")
+ class Reading {
+
+ @Test
+ @DisplayName("Should read the sealed value out of a Cookie header among other cookies")
+ void shouldReadSealedValue() {
+ String header = "other=1; " + COOKIE_NAME + "=sealed-value; another=2";
+
+ assertEquals(Optional.of("sealed-value"), codec.readSealedValue(header));
+ }
+
+ @Test
+ @DisplayName("Should read nothing from an absent, blank, empty-valued, or unrelated Cookie header")
+ void shouldReadNothingWithoutTheCookie() {
+ assertTrue(codec.readSealedValue(null).isEmpty());
+ assertTrue(codec.readSealedValue(" ").isEmpty());
+ assertTrue(codec.readSealedValue("other=1").isEmpty());
+ assertTrue(codec.readSealedValue(COOKIE_NAME + "=").isEmpty());
+ }
+ }
+
+ @Nested
+ @DisplayName("No secret disclosure")
+ class NoSecretDisclosure {
+
+ @Test
+ @DisplayName("Should keep token material out of the emitted Set-Cookie header")
+ void shouldNotLeakTokensIntoTheHeader() throws Exception {
+ String header = codec.toSetCookieHeader(codec.seal(payload()), LOGIN, LOGIN);
+
+ assertFalse(header.contains(ACCESS_TOKEN), "the access token is sealed, never emitted in the clear");
+ assertFalse(header.contains(REFRESH_TOKEN), "the refresh token is sealed, never emitted in the clear");
+ assertFalse(header.contains(ID_TOKEN), "the ID token is sealed, never emitted in the clear");
+ }
+
+ @Test
+ @DisplayName("Should keep key material out of the exception message on an oversized seal")
+ void shouldNotLeakKeyMaterialIntoTheOverBudgetMessage() {
+ String huge = "x".repeat(BUDGET * 2);
+ SealedSessionPayload oversized = new SealedSessionPayload(huge, Optional.empty(), ID_TOKEN, SUB,
+ Optional.empty(), Optional.empty(), Optional.empty(), LOGIN);
+
+ CookieSizeBudgetExceededException thrown =
+ assertThrows(CookieSizeBudgetExceededException.class, () -> codec.seal(oversized));
+
+ assertFalse(thrown.getMessage().contains(huge), "the offending payload is never echoed");
+ assertFalse(thrown.getMessage().contains(ID_TOKEN), "no token material appears in the message");
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionPayloadTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionPayloadTest.java
new file mode 100644
index 00000000..194b3211
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionPayloadTest.java
@@ -0,0 +1,234 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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 de.cuioss.sheriff.gateway.bff.cookie;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link SealedSessionPayload} — the plaintext the cookie-mode codec seals. Covers the
+ * encoding round trip (including absent optionals and values that contain the field separator), the
+ * absolute-TTL check anchored on the login instant, the mandatory-component contract, and the
+ * redaction of every credential from {@code toString()}.
+ */
+class SealedSessionPayloadTest {
+
+ private static final Instant LOGIN = Instant.parse("2026-07-27T10:00:00Z");
+ private static final Duration TTL = Duration.ofHours(8);
+ private static final String ACCESS_TOKEN = "raw-access-token-SECRET-material";
+ private static final String REFRESH_TOKEN = "raw-refresh-token-SECRET-material";
+ private static final String ID_TOKEN = "raw-id-token-SECRET-material";
+ private static final String SUB = "user-sub-1";
+
+ private static SealedSessionPayload full() {
+ return new SealedSessionPayload(ACCESS_TOKEN, Optional.of(REFRESH_TOKEN), ID_TOKEN, SUB,
+ Optional.of("idp-sid-9"), Optional.of("urn:acr:silver"),
+ Optional.of(Instant.parse("2026-07-27T09:59:00Z")), LOGIN);
+ }
+
+ private static SealedSessionPayload minimal() {
+ return new SealedSessionPayload(ACCESS_TOKEN, Optional.empty(), ID_TOKEN, SUB,
+ Optional.empty(), Optional.empty(), Optional.empty(), LOGIN);
+ }
+
+ /**
+ * Builds the wire form {@code decode()} reads directly from raw field values, so a test can
+ * express a payload shape {@code encode()} could never produce — such as an epoch second outside
+ * {@link Instant}'s supported range.
+ */
+ private static byte[] wireForm(String... fields) {
+ Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
+ return Arrays.stream(fields)
+ .map(field -> encoder.encodeToString(field.getBytes(StandardCharsets.UTF_8)))
+ .collect(Collectors.joining("\n"))
+ .getBytes(StandardCharsets.UTF_8);
+ }
+
+ @Nested
+ @DisplayName("Encoding round trip")
+ class RoundTrip {
+
+ @Test
+ @DisplayName("Should decode a fully-populated payload back to an equal record")
+ void shouldRoundTripFullPayload() {
+ SealedSessionPayload original = full();
+
+ assertEquals(Optional.of(original), SealedSessionPayload.decode(original.encode()));
+ }
+
+ @Test
+ @DisplayName("Should preserve absent optionals across the round trip")
+ void shouldRoundTripAbsentOptionals() {
+ SealedSessionPayload original = minimal();
+
+ SealedSessionPayload decoded = SealedSessionPayload.decode(original.encode()).orElseThrow();
+
+ assertEquals(original, decoded);
+ assertTrue(decoded.refreshToken().isEmpty());
+ assertTrue(decoded.sid().isEmpty());
+ assertTrue(decoded.acr().isEmpty());
+ assertTrue(decoded.authTime().isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should round-trip values containing the field separator and other structural characters")
+ void shouldRoundTripSeparatorBearingValues() {
+ SealedSessionPayload original = new SealedSessionPayload("token\nwith\nnewlines", Optional.of("a=b;c"),
+ ID_TOKEN, "sub\nwith\nnewline", Optional.of("sid\n1"), Optional.empty(), Optional.empty(), LOGIN);
+
+ assertEquals(Optional.of(original), SealedSessionPayload.decode(original.encode()),
+ "each field is base64-encoded, so no value can collide with the separator");
+ }
+
+ @Test
+ @DisplayName("Should decode nothing from bytes carrying a foreign field shape")
+ void shouldDecodeNothingFromForeignShape() {
+ assertTrue(SealedSessionPayload.decode("not-the-expected-shape".getBytes(StandardCharsets.UTF_8)).isEmpty());
+ assertTrue(SealedSessionPayload.decode(new byte[0]).isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should decode nothing when a field is not valid base64")
+ void shouldDecodeNothingFromMalformedFields() {
+ byte[] malformed = "~~~\n~~~\n~~~\n~~~\n\n\n\n~~~".getBytes(StandardCharsets.UTF_8);
+
+ assertTrue(SealedSessionPayload.decode(malformed).isEmpty(),
+ "a base64 failure on authenticated-but-foreign bytes is no session, never an exception");
+ }
+
+ @Test
+ @DisplayName("Should decode nothing when an epoch second parses as a long but exceeds Instant's range")
+ void shouldDecodeNothingFromOutOfRangeEpochSecond() {
+ String beyondInstantMax = "999999999999999999";
+ String beyondInstantMin = "-999999999999999999";
+ String login = Long.toString(LOGIN.getEpochSecond());
+
+ assertTrue(SealedSessionPayload
+ .decode(wireForm(ACCESS_TOKEN, "", ID_TOKEN, SUB, "", "", "", beyondInstantMax)).isEmpty(),
+ "DateTimeException is not an IllegalArgumentException, so an out-of-range login instant "
+ + "must still decode to no session rather than escaping unseal()");
+ assertTrue(SealedSessionPayload
+ .decode(wireForm(ACCESS_TOKEN, "", ID_TOKEN, SUB, "", "", "", beyondInstantMin)).isEmpty());
+ assertTrue(SealedSessionPayload
+ .decode(wireForm(ACCESS_TOKEN, "", ID_TOKEN, SUB, "", "", beyondInstantMax, login)).isEmpty(),
+ "the optional authTime field carries the same overflow risk as the login instant");
+ }
+ }
+
+ @Nested
+ @DisplayName("Absolute lifetime")
+ class AbsoluteLifetime {
+
+ @Test
+ @DisplayName("Should not be expired before the deadline")
+ void shouldNotBeExpiredBeforeDeadline() {
+ assertFalse(full().isExpired(TTL, LOGIN.plus(TTL).minusSeconds(1)));
+ }
+
+ @Test
+ @DisplayName("Should be expired at and after the deadline (inclusive boundary)")
+ void shouldBeExpiredAtAndAfterDeadline() {
+ assertTrue(full().isExpired(TTL, LOGIN.plus(TTL)), "the boundary is inclusive");
+ assertTrue(full().isExpired(TTL, LOGIN.plus(TTL).plusSeconds(1)));
+ }
+
+ @Test
+ @DisplayName("Should anchor the deadline on the login instant, so it cannot drift")
+ void shouldAnchorDeadlineOnLoginInstant() {
+ SealedSessionPayload resealed = new SealedSessionPayload("rotated-access", Optional.of("rotated-refresh"),
+ ID_TOKEN, SUB, Optional.empty(), Optional.empty(), Optional.empty(), LOGIN);
+
+ assertTrue(resealed.isExpired(TTL, LOGIN.plus(TTL)),
+ "re-sealing rotated material does not move the absolute deadline");
+ }
+ }
+
+ @Nested
+ @DisplayName("Contract")
+ class Contract {
+
+ @Test
+ @DisplayName("Should reject an absent mandatory component")
+ void shouldRejectAbsentMandatoryComponents() {
+ assertThrows(NullPointerException.class, () -> new SealedSessionPayload(null, Optional.empty(), ID_TOKEN,
+ SUB, Optional.empty(), Optional.empty(), Optional.empty(), LOGIN));
+ assertThrows(NullPointerException.class, () -> new SealedSessionPayload(ACCESS_TOKEN, Optional.empty(),
+ null, SUB, Optional.empty(), Optional.empty(), Optional.empty(), LOGIN));
+ assertThrows(NullPointerException.class, () -> new SealedSessionPayload(ACCESS_TOKEN, Optional.empty(),
+ ID_TOKEN, null, Optional.empty(), Optional.empty(), Optional.empty(), LOGIN));
+ assertThrows(NullPointerException.class, () -> new SealedSessionPayload(ACCESS_TOKEN, Optional.empty(),
+ ID_TOKEN, SUB, Optional.empty(), Optional.empty(), Optional.empty(), null));
+ }
+
+ @Test
+ @DisplayName("Should normalize a null optional to empty")
+ void shouldNormalizeNullOptionals() {
+ SealedSessionPayload payload = new SealedSessionPayload(ACCESS_TOKEN, null, ID_TOKEN, SUB,
+ null, null, null, LOGIN);
+
+ assertTrue(payload.refreshToken().isEmpty());
+ assertTrue(payload.sid().isEmpty());
+ assertTrue(payload.acr().isEmpty());
+ assertTrue(payload.authTime().isEmpty());
+ }
+ }
+
+ @Nested
+ @DisplayName("Redaction")
+ class Redaction {
+
+ @Test
+ @DisplayName("Should redact every credential from toString()")
+ void shouldRedactCredentials() {
+ String rendered = full().toString();
+
+ assertFalse(rendered.contains(ACCESS_TOKEN), rendered);
+ assertFalse(rendered.contains(REFRESH_TOKEN), rendered);
+ assertFalse(rendered.contains(ID_TOKEN), rendered);
+ assertTrue(rendered.contains("***REDACTED***"), rendered);
+ }
+
+ @Test
+ @DisplayName("Should distinguish an absent refresh token from a redacted present one")
+ void shouldDistinguishAbsentRefreshToken() {
+ assertTrue(minimal().toString().contains("refreshToken=Optional.empty"), minimal().toString());
+ assertTrue(full().toString().contains("refreshToken=Optional[***REDACTED***]"), full().toString());
+ }
+
+ @Test
+ @DisplayName("Should still render the non-credential session metadata for diagnosis")
+ void shouldRenderNonCredentialMetadata() {
+ String rendered = full().toString();
+
+ assertTrue(rendered.contains(SUB), "the subject is an identity anchor, not a credential");
+ assertTrue(rendered.contains(LOGIN.toString()), rendered);
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinatorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinatorTest.java
index 6e6ad0e8..63d76e69 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinatorTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinatorTest.java
@@ -23,7 +23,9 @@
import java.time.Duration;
import java.time.Instant;
+import java.util.Arrays;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
@@ -32,12 +34,19 @@
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import de.cuioss.sheriff.gateway.bff.cookie.CookieSessionBinding;
+import de.cuioss.sheriff.gateway.bff.cookie.SealedSessionCookieCodec;
import de.cuioss.sheriff.gateway.bff.refresh.TokenRefreshCoordinator.AccessTokenExpiry;
import de.cuioss.sheriff.gateway.bff.refresh.TokenRefreshCoordinator.RefreshExchange;
import de.cuioss.sheriff.gateway.bff.refresh.TokenRefreshCoordinator.RefreshOutcome;
import de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore;
+import de.cuioss.sheriff.gateway.bff.session.ServerSessionBinding;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding;
+import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec;
import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
import de.cuioss.sheriff.token.client.token.RotationResult;
import de.cuioss.sheriff.token.commons.error.ClientProtocolException;
@@ -74,11 +83,16 @@ class TokenRefreshCoordinatorTest {
private static final String ROTATED_REFRESH = "rotated-refresh-token";
private static final String ROTATED_ID = "rotated-id-token";
+ private static final String COOKIE_HEADER = SessionCookieCodec.DEFAULT_COOKIE_NAME + "=" + SESSION_ID;
+
private InMemorySessionStore store;
+ private SessionBinding binding;
@BeforeEach
void setUp() {
store = new InMemorySessionStore(16);
+ binding = new ServerSessionBinding(store,
+ new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, SESSION_TTL));
}
private static SessionRecord session(String refreshToken) {
@@ -103,7 +117,7 @@ private static RotationResult rotation() {
}
private TokenRefreshCoordinator coordinator(Instant accessExpiry, RefreshExchange exchange) {
- return new TokenRefreshCoordinator(LEEWAY, unused -> accessExpiry, exchange, store);
+ return new TokenRefreshCoordinator(LEEWAY, unused -> accessExpiry, exchange, binding);
}
@Nested
@@ -121,7 +135,7 @@ void shouldReturnCurrentWhenNotNearExpiry() {
return rotation();
});
- RefreshOutcome outcome = coordinator.refresh(live, NOW);
+ RefreshOutcome outcome = coordinator.refresh(live, COOKIE_HEADER, NOW);
assertEquals(RefreshOutcome.Kind.CURRENT, outcome.kind());
assertSame(live, outcome.session().orElseThrow(), "the same session is returned unchanged");
@@ -139,7 +153,7 @@ void shouldReturnCurrentWhenNoRefreshToken() {
return rotation();
});
- RefreshOutcome outcome = coordinator.refresh(live, NOW);
+ RefreshOutcome outcome = coordinator.refresh(live, COOKIE_HEADER, NOW);
assertEquals(RefreshOutcome.Kind.CURRENT, outcome.kind());
assertEquals(0, calls.get(), "a session without a refresh token cannot be refreshed");
@@ -157,7 +171,7 @@ void shouldRefreshAndPersist() {
store.create(live);
TokenRefreshCoordinator coordinator = coordinator(NEAR, rt -> rotation());
- RefreshOutcome outcome = coordinator.refresh(live, NOW);
+ RefreshOutcome outcome = coordinator.refresh(live, COOKIE_HEADER, NOW);
assertEquals(RefreshOutcome.Kind.REFRESHED, outcome.kind());
SessionRecord rotated = outcome.session().orElseThrow();
@@ -179,7 +193,7 @@ void shouldPresentCurrentRefreshToken() {
return rotation();
});
- coordinator.refresh(live, NOW);
+ coordinator.refresh(live, COOKIE_HEADER, NOW);
assertEquals(1, calls.get());
SessionRecord persisted = store.resolve(SESSION_ID, NOW).orElseThrow();
@@ -200,7 +214,7 @@ void shouldFailAndDestroyOnEngineRejection() {
throw new ClientProtocolException("token endpoint rejected the refresh grant");
});
- RefreshOutcome outcome = coordinator.refresh(live, NOW);
+ RefreshOutcome outcome = coordinator.refresh(live, COOKIE_HEADER, NOW);
assertTrue(outcome.isFailure());
assertEquals(RefreshOutcome.Kind.FAILED, outcome.kind());
@@ -217,7 +231,7 @@ void shouldFailOnReuseDetection() {
throw new ClientProtocolException("refresh token family is revoked");
});
- RefreshOutcome outcome = coordinator.refresh(live, NOW);
+ RefreshOutcome outcome = coordinator.refresh(live, COOKIE_HEADER, NOW);
assertTrue(outcome.isFailure(), "a revoked refresh-token family fails the refresh");
assertTrue(store.resolve(SESSION_ID, NOW).isEmpty(), "a reused-token session is destroyed");
@@ -230,7 +244,7 @@ void shouldFailWhenSessionGone() {
// Deliberately NOT stored — models a session destroyed concurrently before the lead resolves it.
TokenRefreshCoordinator coordinator = coordinator(NEAR, rt -> rotation());
- RefreshOutcome outcome = coordinator.refresh(live, NOW);
+ RefreshOutcome outcome = coordinator.refresh(live, COOKIE_HEADER, NOW);
assertTrue(outcome.isFailure(), "a session absent from the store cannot be refreshed");
}
@@ -257,9 +271,9 @@ void shouldCoalesceConcurrentRefreshes() throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
- Future leader = pool.submit(() -> coordinator.refresh(live, NOW));
+ Future leader = pool.submit(() -> coordinator.refresh(live, COOKIE_HEADER, NOW));
assertTrue(entered.await(2, TimeUnit.SECONDS), "the leader entered the engine refresh");
- Future follower = pool.submit(() -> coordinator.refresh(live, NOW));
+ Future follower = pool.submit(() -> coordinator.refresh(live, COOKIE_HEADER, NOW));
// Let the follower reach the in-flight join before the leader is released. There is no
// observable hook for a thread reaching CompletableFuture#join, so this best-effort
// ordering sleep cannot be made deterministic without an added dependency.
@@ -287,6 +301,144 @@ private static void awaitUninterruptibly(CountDownLatch latch) {
}
}
+ @Nested
+ @DisplayName("Cookie-mode refresh (stateless binding)")
+ class CookieMode {
+
+ private static final String COOKIE_NAME = "__Host-sheriff-session";
+
+ private CookieSessionBinding cookieBinding;
+ private String sealedCookieHeader;
+ private SessionRecord cookieSession;
+
+ @BeforeEach
+ void setUpCookieMode() {
+ byte[] keyMaterial = new byte[32];
+ Arrays.fill(keyMaterial, (byte) 0x11);
+ SecretKey key = new SecretKeySpec(keyMaterial, "AES");
+ byte[] salt = new byte[32];
+ Arrays.fill(salt, (byte) 0x22);
+ cookieBinding = new CookieSessionBinding(
+ new SealedSessionCookieCodec(COOKIE_NAME, SESSION_TTL,
+ SealedSessionCookieCodec.DEFAULT_COOKIE_VALUE_BUDGET, key, (byte) 1), salt);
+
+ SessionBinding.BoundSession bound = cookieBinding.bind(session(CURRENT_REFRESH), NOW);
+ String setCookie = bound.setCookieHeaders().getFirst();
+ sealedCookieHeader = setCookie.substring(0, setCookie.indexOf(';'));
+ // The sealed cookie IS the session, so the record to refresh is the resolved one — its
+ // derived sessionId is what single-flight keys on.
+ cookieSession = cookieBinding.resolve(sealedCookieHeader, NOW).orElseThrow();
+ }
+
+ private TokenRefreshCoordinator cookieCoordinator(RefreshExchange exchange) {
+ return new TokenRefreshCoordinator(LEEWAY, unused -> NEAR, exchange, cookieBinding);
+ }
+
+ @Test
+ @DisplayName("Should re-seal the rotated material into a new Set-Cookie rather than a store write")
+ void shouldResealIntoANewCookie() {
+ TokenRefreshCoordinator coordinator = cookieCoordinator(rt -> rotation());
+
+ RefreshOutcome outcome = coordinator.refresh(cookieSession, sealedCookieHeader, NOW);
+
+ assertEquals(RefreshOutcome.Kind.REFRESHED, outcome.kind());
+ assertEquals(ROTATED_ACCESS, outcome.session().orElseThrow().accessToken());
+ assertEquals(1, outcome.setCookieHeaders().size(),
+ "a stateless refresh persists by emitting exactly one re-sealed cookie");
+ String reSealed = outcome.setCookieHeaders().getFirst();
+ assertTrue(reSealed.startsWith(COOKIE_NAME + "="), reSealed);
+ assertFalse(reSealed.contains(ROTATED_ACCESS), "the rotated token is sealed, never emitted in the clear");
+ assertFalse(reSealed.contains(ROTATED_REFRESH), "the rotated refresh token is sealed, never emitted");
+ }
+
+ @Test
+ @DisplayName("Should carry the rotated material in the re-sealed cookie the next request presents")
+ void shouldServeTheRotatedMaterialFromTheReSealedCookie() {
+ TokenRefreshCoordinator coordinator = cookieCoordinator(rt -> rotation());
+
+ RefreshOutcome outcome = coordinator.refresh(cookieSession, sealedCookieHeader, NOW);
+
+ String reSealed = outcome.setCookieHeaders().getFirst();
+ String nextRequestCookie = reSealed.substring(0, reSealed.indexOf(';'));
+ SessionRecord nextRequest = cookieBinding.resolve(nextRequestCookie, NOW).orElseThrow();
+ assertEquals(ROTATED_ACCESS, nextRequest.accessToken());
+ assertEquals(Optional.of(ROTATED_REFRESH), nextRequest.refreshToken());
+ assertEquals(cookieSession.expiresAt(), nextRequest.expiresAt(),
+ "the re-seal preserves the original absolute deadline — a refresh never extends the session");
+ }
+
+ @Test
+ @DisplayName("Should keep the derived identity stable across the re-seal, so single-flight keys the same")
+ void shouldKeepTheSingleFlightKeyStable() {
+ TokenRefreshCoordinator coordinator = cookieCoordinator(rt -> rotation());
+
+ RefreshOutcome outcome = coordinator.refresh(cookieSession, sealedCookieHeader, NOW);
+
+ assertEquals(cookieSession.sessionId(), outcome.session().orElseThrow().sessionId(),
+ "the single-flight key expression is unchanged in cookie mode");
+ }
+
+ @Test
+ @DisplayName("Should coalesce two concurrent cookie-mode refreshes into exactly one engine exchange")
+ void shouldCoalesceConcurrentCookieRefreshes() throws Exception {
+ CountDownLatch entered = new CountDownLatch(1);
+ CountDownLatch proceed = new CountDownLatch(1);
+ AtomicInteger calls = new AtomicInteger();
+ TokenRefreshCoordinator coordinator = cookieCoordinator(rt -> {
+ calls.incrementAndGet();
+ entered.countDown();
+ awaitUninterruptibly(proceed);
+ return rotation();
+ });
+
+ ExecutorService pool = Executors.newFixedThreadPool(2);
+ try {
+ Future leader =
+ pool.submit(() -> coordinator.refresh(cookieSession, sealedCookieHeader, NOW));
+ assertTrue(entered.await(2, TimeUnit.SECONDS), "the leader entered the engine refresh");
+ Future follower =
+ pool.submit(() -> coordinator.refresh(cookieSession, sealedCookieHeader, NOW));
+ // Best-effort ordering: there is no observable hook for a thread reaching
+ // CompletableFuture#join, so the follower's arrival cannot be awaited deterministically.
+ Thread.sleep(100); // NOSONAR java:S2925 - no observable hook for the follower reaching the in-flight join
+ proceed.countDown();
+
+ RefreshOutcome leaderOutcome = leader.get(2, TimeUnit.SECONDS);
+ RefreshOutcome followerOutcome = follower.get(2, TimeUnit.SECONDS);
+
+ assertEquals(1, calls.get(),
+ "two threads on the same cookie produce exactly one engine exchange — single-flight is "
+ + "per instance in cookie mode, which is the documented accepted trade-off");
+ assertFalse(leaderOutcome.isFailure());
+ assertFalse(followerOutcome.isFailure(), "the coalesced follower shares the successful refresh");
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ @Test
+ @DisplayName("Should fail and destroy the session when the engine rejects the cookie-mode refresh")
+ void shouldFailOnEngineRejection() {
+ TokenRefreshCoordinator coordinator = cookieCoordinator(rt -> {
+ throw new ClientProtocolException("invalid_grant");
+ });
+
+ RefreshOutcome outcome = coordinator.refresh(cookieSession, sealedCookieHeader, NOW);
+
+ assertTrue(outcome.isFailure(),
+ "reuse-as-failure is identical in cookie mode — the stage clears the cookie and re-negotiates");
+ assertTrue(outcome.setCookieHeaders().isEmpty(), "a failed refresh emits no re-seal");
+ }
+
+ private static void awaitUninterruptibly(CountDownLatch latch) {
+ try {
+ latch.await(2, TimeUnit.SECONDS);
+ } catch (InterruptedException _) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
@Nested
@DisplayName("Argument and outcome contracts")
class Contracts {
@@ -297,24 +449,25 @@ void shouldRejectNullArguments() {
TokenRefreshCoordinator coordinator = coordinator(NEAR, rt -> rotation());
var session = session(CURRENT_REFRESH);
- assertThrows(NullPointerException.class, () -> coordinator.refresh(null, NOW));
- assertThrows(NullPointerException.class, () -> coordinator.refresh(session, null));
+ assertThrows(NullPointerException.class, () -> coordinator.refresh(null, COOKIE_HEADER, NOW));
+ assertThrows(NullPointerException.class, () -> coordinator.refresh(session, COOKIE_HEADER, null));
}
@Test
@DisplayName("Should reject constructing a non-failed outcome without a session")
void shouldRejectPresentContractViolation() {
assertThrows(IllegalArgumentException.class,
- () -> new RefreshOutcome(RefreshOutcome.Kind.CURRENT, Optional.empty()));
+ () -> new RefreshOutcome(RefreshOutcome.Kind.CURRENT, Optional.empty(), List.of()));
}
@Test
- @DisplayName("Should expose an empty session on a failed outcome")
+ @DisplayName("Should expose an empty session and no cookies on a failed outcome")
void failedOutcomeCarriesNoSession() {
RefreshOutcome failed = RefreshOutcome.failed();
assertTrue(failed.isFailure());
assertTrue(failed.session().isEmpty());
+ assertTrue(failed.setCookieHeaders().isEmpty());
}
}
}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpointTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpointTest.java
index 3bdd907a..101e6d2c 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpointTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpointTest.java
@@ -23,34 +23,55 @@
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
+import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import de.cuioss.sheriff.gateway.bff.cookie.CookieSessionBinding;
+import de.cuioss.sheriff.gateway.bff.cookie.SealedSessionCookieCodec;
import de.cuioss.sheriff.gateway.bff.logout.BackchannelLogoutReceiver;
import de.cuioss.sheriff.gateway.bff.logout.LogoutTokenValidator;
import de.cuioss.sheriff.gateway.bff.reserved.BackchannelLogoutEndpoint.BackchannelLogoutOutcome;
import de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore;
+import de.cuioss.sheriff.gateway.bff.session.ServerSessionBinding;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding;
+import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec;
import de.cuioss.sheriff.token.validation.domain.claim.ClaimValue;
import de.cuioss.sheriff.token.validation.domain.token.IdTokenContent;
import de.cuioss.sheriff.token.validation.domain.token.TokenContent;
+import de.cuioss.test.juli.LogAsserts;
+import de.cuioss.test.juli.TestLogLevel;
+import de.cuioss.test.juli.junit5.EnableTestLogger;
import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Tests for {@link BackchannelLogoutEndpoint}: the request/response edge over
- * {@link BackchannelLogoutReceiver}. The focus here is the {@code application/x-www-form-urlencoded}
- * body parsing — in particular that a malformed percent-encoded {@code logout_token} value fails
- * closed to {@code 400} rather than surfacing a {@code 500} (the receiver's signature seam is bound
- * to a hand-built token so the accepted path is exercised without a live IdP).
+ * {@link BackchannelLogoutReceiver}.
+ *
+ * Two concerns are covered. In server mode the focus is the
+ * {@code application/x-www-form-urlencoded} body parsing — in particular that a malformed
+ * percent-encoded {@code logout_token} value fails closed to {@code 400} rather than surfacing a
+ * {@code 500} (the receiver's signature seam is bound to a hand-built token so the accepted path is
+ * exercised without a live IdP). In cookie mode the focus is the capability gate:
+ * a binding reporting {@link SessionBinding.IdpDestruction#UNSUPPORTED} answers {@code 404} for every
+ * request without ever parsing the body or reaching the receiver — and does so without emitting a
+ * {@code WARN}, since the path is reserved and unauthenticated.
*/
+@EnableTestLogger
class BackchannelLogoutEndpointTest {
private static final String ISSUER = "https://idp.example.com";
private static final String AUDIENCE = "bff-client";
private static final Instant NOW = Instant.parse("2026-07-23T10:00:00Z");
+ private static final String COOKIE_NAME = "__Host-sheriff-session";
+ private static final byte CURRENT_KEY_ID = 1;
private static TokenContent validLogoutToken() {
Map claims = Map.of(
@@ -63,13 +84,35 @@ private static TokenContent validLogoutToken() {
return new IdTokenContent(claims, "raw-logout-token");
}
+ /** The store-backed binding — {@code SUPPORTED} IdP destruction, so the gate stays open. */
+ private static SessionBinding serverBinding() {
+ return new ServerSessionBinding(new InMemorySessionStore(16),
+ new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, Duration.ofHours(8)));
+ }
+
+ /** The stateless binding — {@code UNSUPPORTED} IdP destruction, so the gate closes the endpoint. */
+ private static SessionBinding cookieBinding() {
+ byte[] key = new byte[32];
+ Arrays.fill(key, (byte) 0x11);
+ SecretKey sealingKey = new SecretKeySpec(key, "AES");
+ byte[] salt = new byte[32];
+ Arrays.fill(salt, (byte) 0x22);
+ return new CookieSessionBinding(
+ new SealedSessionCookieCodec(COOKIE_NAME, Duration.ofHours(8),
+ SealedSessionCookieCodec.DEFAULT_COOKIE_VALUE_BUDGET, sealingKey, CURRENT_KEY_ID), salt);
+ }
+
private BackchannelLogoutEndpoint endpoint(AtomicBoolean verifierInvoked) {
+ return endpoint(verifierInvoked, serverBinding());
+ }
+
+ private BackchannelLogoutEndpoint endpoint(AtomicBoolean verifierInvoked, SessionBinding binding) {
LogoutTokenValidator validator = new LogoutTokenValidator(ISSUER, AUDIENCE, Duration.ofMinutes(2));
BackchannelLogoutReceiver receiver = new BackchannelLogoutReceiver(rawToken -> {
verifierInvoked.set(true);
return validLogoutToken();
- }, validator, new InMemorySessionStore(16));
- return new BackchannelLogoutEndpoint(receiver);
+ }, validator, binding);
+ return new BackchannelLogoutEndpoint(receiver, binding);
}
@Test
@@ -114,4 +157,64 @@ void shouldAcceptWellFormedToken() {
assertTrue(outcome.isAccepted());
assertTrue(verifierInvoked.get(), "a well-formed token reaches the signature-verification seam");
}
+
+ /**
+ * The cookie-mode capability gate: the stateless binding reports
+ * {@link SessionBinding.IdpDestruction#UNSUPPORTED}, so every back-channel request is answered
+ * {@code 404} before the body is parsed — the gateway never claims a destruction it cannot perform.
+ */
+ @Nested
+ @DisplayName("Cookie mode — capability-gated to 404")
+ class CookieModeGate {
+
+ @Test
+ @DisplayName("Should answer 404 for a well-formed logout_token without ever parsing it")
+ void shouldGateWellFormedToken() {
+ AtomicBoolean verifierInvoked = new AtomicBoolean(false);
+
+ BackchannelLogoutOutcome outcome =
+ endpoint(verifierInvoked, cookieBinding()).receive("logout_token=abc.def.ghi", NOW);
+
+ assertEquals(404, outcome.status(), "a binding without IdP destruction gates the endpoint off");
+ assertFalse(outcome.isAccepted());
+ assertEquals(0, outcome.destroyed(), "a gated request destroys nothing");
+ assertFalse(verifierInvoked.get(),
+ "the gate fires before the body is parsed — the logout_token is never read");
+ }
+
+ @Test
+ @DisplayName("Should answer 404 for an absent body rather than the server-mode 400")
+ void shouldGateAbsentBody() {
+ AtomicBoolean verifierInvoked = new AtomicBoolean(false);
+
+ BackchannelLogoutOutcome outcome = endpoint(verifierInvoked, cookieBinding()).receive(null, NOW);
+
+ assertEquals(404, outcome.status(), "the gate precedes the missing-token 400 path");
+ assertFalse(verifierInvoked.get());
+ }
+
+ @Test
+ @DisplayName("Should answer 404 for a malformed body, never the server-mode 400")
+ void shouldGateMalformedBody() {
+ AtomicBoolean verifierInvoked = new AtomicBoolean(false);
+
+ BackchannelLogoutOutcome outcome =
+ endpoint(verifierInvoked, cookieBinding()).receive("logout_token=%ZZ", NOW);
+
+ assertEquals(404, outcome.status());
+ assertFalse(verifierInvoked.get());
+ }
+
+ @Test
+ @DisplayName("Should not emit a WARN for the unauthenticated capability-gate rejection")
+ void shouldNotWarnOnCapabilityGate() {
+ BackchannelLogoutEndpoint endpoint = endpoint(new AtomicBoolean(false), cookieBinding());
+
+ for (int request = 0; request < 5; request++) {
+ assertEquals(404, endpoint.receive("logout_token=abc.def.ghi", NOW).status());
+ }
+
+ LogAsserts.assertNoLogMessagePresent(TestLogLevel.WARN, BackchannelLogoutEndpoint.class);
+ }
+ }
}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java
index 6d4d696a..13b64eeb 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java
@@ -15,6 +15,7 @@
*/
package de.cuioss.sheriff.gateway.bff.reserved;
+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.assertThrows;
@@ -22,17 +23,23 @@
import java.time.Duration;
import java.time.Instant;
+import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
+import javax.crypto.spec.SecretKeySpec;
+import de.cuioss.sheriff.gateway.bff.cookie.CookieSessionBinding;
+import de.cuioss.sheriff.gateway.bff.cookie.SealedSessionCookieCodec;
import de.cuioss.sheriff.gateway.bff.pending.BindingCookieCodec;
import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord;
import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationStore;
import de.cuioss.sheriff.gateway.bff.reserved.CallbackEndpoint.CallbackOutcome;
import de.cuioss.sheriff.gateway.bff.reserved.CallbackEndpoint.CodeExchange;
import de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore;
+import de.cuioss.sheriff.gateway.bff.session.ServerSessionBinding;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding;
import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec;
import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
import de.cuioss.sheriff.token.client.flow.AuthorizationCodeFlow;
@@ -71,6 +78,7 @@ class CallbackEndpointTest {
private BindingCookieCodec bindingCodec;
private InMemorySessionStore sessionStore;
private SessionCookieCodec sessionCodec;
+ private SessionBinding sessionBinding;
private CallbackEndpoint endpoint;
private String state;
@@ -83,8 +91,8 @@ void setUp() {
bindingCodec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL);
sessionStore = new InMemorySessionStore(16);
sessionCodec = new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, SESSION_TTL);
- endpoint = new CallbackEndpoint(successfulExchange(), pendingStore, bindingCodec, sessionStore, sessionCodec,
- SESSION_TTL);
+ sessionBinding = new ServerSessionBinding(sessionStore, sessionCodec);
+ endpoint = new CallbackEndpoint(successfulExchange(), pendingStore, bindingCodec, sessionBinding, SESSION_TTL);
FlowContext flow = FlowContext.create("https://gw.example.com/auth/callback");
state = flow.state();
@@ -110,6 +118,35 @@ private static CodeExchange successfulExchange() {
return (context, params) -> result;
}
+ /**
+ * An exchange whose validated ID token is far larger than the browser-safe cookie budget — the
+ * realistic driver of the documented {@code bind()} failure in stateless cookie mode.
+ */
+ private static CodeExchange oversizedExchange() {
+ Map accessClaims = new HashMap<>();
+ accessClaims.put(ClaimName.SUBJECT.getName(), ClaimValue.forPlainString(SUBJECT));
+ AccessTokenContent access = new AccessTokenContent(accessClaims, RAW_ACCESS_TOKEN);
+
+ Map idClaims = new HashMap<>();
+ idClaims.put(ClaimName.SUBJECT.getName(), ClaimValue.forPlainString(SUBJECT));
+ IdTokenContent id = new IdTokenContent(idClaims, "x".repeat(16_384));
+
+ AuthorizationCodeFlow.AuthenticationResult result = new AuthorizationCodeFlow.AuthenticationResult(access, id);
+ return (context, params) -> result;
+ }
+
+ /** The stateless cookie binding — {@code bind()} refuses a session past the cookie-size budget. */
+ private static SessionBinding cookieBinding() {
+ byte[] key = new byte[32];
+ Arrays.fill(key, (byte) 0x11);
+ byte[] salt = new byte[32];
+ Arrays.fill(salt, (byte) 0x22);
+ return new CookieSessionBinding(
+ new SealedSessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, SESSION_TTL,
+ SealedSessionCookieCodec.DEFAULT_COOKIE_VALUE_BUDGET, new SecretKeySpec(key, "AES"), (byte) 1),
+ salt);
+ }
+
@Nested
@DisplayName("BFF-13 duplicate-parameter rejection (raw parse)")
class DuplicateParameterRejection {
@@ -204,13 +241,30 @@ void shouldMapExchangeFailure() {
CodeExchange failing = (context, params) -> {
throw new ClientProtocolException("token endpoint rejected the code");
};
- CallbackEndpoint failingEndpoint = new CallbackEndpoint(failing, pendingStore, bindingCodec, sessionStore,
- sessionCodec, SESSION_TTL);
+ CallbackEndpoint failingEndpoint = new CallbackEndpoint(failing, pendingStore, bindingCodec, sessionBinding,
+ SESSION_TTL);
CallbackOutcome outcome = failingEndpoint.handle("code=abc&state=" + state, bindingCookieHeader, T0);
assertEquals(400, outcome.status());
}
+
+ @Test
+ @DisplayName("Should map a binding failure to 500 rather than letting IllegalStateException escape handle()")
+ void shouldMapBindFailureTo500() {
+ CallbackEndpoint bindFailingEndpoint = new CallbackEndpoint(oversizedExchange(), pendingStore, bindingCodec,
+ cookieBinding(), SESSION_TTL);
+
+ CallbackOutcome outcome = assertDoesNotThrow(
+ () -> bindFailingEndpoint.handle("code=auth-code&state=" + state, bindingCookieHeader, T0),
+ "a session the binding cannot hold is a clean outcome, never an escaping IllegalStateException");
+
+ assertEquals(500, outcome.status(),
+ "the exchange succeeded and the caller did nothing wrong — an unbindable session is a "
+ + "gateway-side 500, not a 4xx");
+ assertFalse(outcome.isRedirect());
+ assertTrue(outcome.setCookieHeaders().isEmpty(), "a failed binding emits no session cookie");
+ }
}
@Nested
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpointTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpointTest.java
index dcd0b106..d38f8df7 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpointTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpointTest.java
@@ -32,6 +32,7 @@
import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationStore;
import de.cuioss.sheriff.gateway.bff.reserved.LoginInitiationEndpoint.LoginInitiationOutcome;
import de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore;
+import de.cuioss.sheriff.gateway.bff.session.ServerSessionBinding;
import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec;
import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
import de.cuioss.sheriff.token.client.flow.AuthorizationCodeFlow;
@@ -91,7 +92,8 @@ void setUp() {
sessionStore = new InMemorySessionStore(16);
sessionCodec = new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, SESSION_TTL);
- endpoint = new LoginInitiationEndpoint(loginFlow, sessionStore, sessionCodec, GATEWAY_ORIGIN);
+ endpoint = new LoginInitiationEndpoint(loginFlow, new ServerSessionBinding(sessionStore, sessionCodec),
+ GATEWAY_ORIGIN);
}
/** Creates a live session and returns the request {@code Cookie} header that resolves it. */
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpointTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpointTest.java
index 375bf91a..825e2cad 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpointTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpointTest.java
@@ -27,6 +27,8 @@
import de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout.TokenRevocation;
import de.cuioss.sheriff.gateway.bff.reserved.LogoutEndpoint.LogoutOutcome;
import de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore;
+import de.cuioss.sheriff.gateway.bff.session.ServerSessionBinding;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding;
import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec;
import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
import de.cuioss.sheriff.token.client.logout.EndSessionFlow;
@@ -53,7 +55,7 @@ class LogoutEndpointTest {
private static final Instant NOW = Instant.parse("2026-07-23T10:00:00Z");
private InMemorySessionStore store;
- private SessionCookieCodec cookieCodec;
+ private SessionBinding binding;
private EndSessionFlow endSessionFlow;
private String sessionId;
private String cookieHeader;
@@ -61,7 +63,8 @@ class LogoutEndpointTest {
@BeforeEach
void setUp() {
store = new InMemorySessionStore(16);
- cookieCodec = new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, Duration.ofHours(8));
+ binding = new ServerSessionBinding(store,
+ new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, Duration.ofHours(8)));
endSessionFlow = new EndSessionFlow(new PostLogoutRedirectValidator(Set.of(REGISTERED_RETURN)));
sessionId = SessionRecord.newSessionId();
SessionRecord session = SessionRecord.builder()
@@ -80,7 +83,7 @@ private LogoutEndpoint endpoint(String postLogoutRedirectUri) {
};
RpInitiatedLogout logout = new RpInitiatedLogout(endSessionFlow, revocation, END_SESSION,
postLogoutRedirectUri, FINAL_REDIRECT, STATE_TTL);
- return new LogoutEndpoint(logout, store, cookieCodec);
+ return new LogoutEndpoint(logout, binding);
}
@Test
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/UserInfoEndpointTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/UserInfoEndpointTest.java
index b8451867..036d73c5 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/UserInfoEndpointTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/UserInfoEndpointTest.java
@@ -32,6 +32,7 @@
import de.cuioss.sheriff.gateway.bff.reserved.UserInfoEndpoint.ClaimSource;
import de.cuioss.sheriff.gateway.bff.reserved.UserInfoEndpoint.UserInfoOutcome;
import de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore;
+import de.cuioss.sheriff.gateway.bff.session.ServerSessionBinding;
import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec;
import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
@@ -47,9 +48,10 @@
* {@code 403} when explicitly requested), the absence of any raw token material in the response, and
* the {@code Cache-Control: no-store} header on every outcome.
*
- * The endpoint is framework-agnostic, so it is exercised with a real {@link InMemorySessionStore},
- * the real {@link SessionCookieCodec}, and a hand-built {@link ClaimSource} lambda — no container, no
- * live IdP, no test double framework.
+ * The endpoint is framework-agnostic, so it is exercised with a real
+ * {@link de.cuioss.sheriff.gateway.bff.session.ServerSessionBinding} over an
+ * {@link InMemorySessionStore} and the real {@link SessionCookieCodec}, plus a hand-built
+ * {@link ClaimSource} lambda — no container, no live IdP, no test double framework.
*/
class UserInfoEndpointTest {
@@ -75,7 +77,8 @@ void setUp() {
sessionStore = new InMemorySessionStore(16);
sessionCodec = new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, TTL);
claimFilter = new ClaimAllowlistFilter(List.of("sub", "name", "roles"), List.of("sub", "name", "roles"));
- endpoint = new UserInfoEndpoint(sessionStore, sessionCodec, claimFilter, validatedClaims());
+ endpoint = new UserInfoEndpoint(new ServerSessionBinding(sessionStore, sessionCodec), claimFilter,
+ validatedClaims());
SessionRecord session = SessionRecord.builder()
.sessionId(SESSION_ID)
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStageTest.java
index b1e12594..7b523193 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStageTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStageTest.java
@@ -31,9 +31,10 @@
import de.cuioss.sheriff.gateway.bff.runtime.SessionAuthenticationStage.LoginChallenge;
import de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore;
+import de.cuioss.sheriff.gateway.bff.session.ServerSessionBinding;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding;
import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec;
import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
-import de.cuioss.sheriff.gateway.bff.session.SessionStore;
import de.cuioss.sheriff.gateway.config.model.AuthConfig;
import de.cuioss.sheriff.gateway.config.model.HttpMethod;
import de.cuioss.sheriff.gateway.events.EventType;
@@ -58,6 +59,7 @@ class SessionAuthenticationStageTest {
private static final String REQUIRED_SCOPE = "orders:read";
private static final String LOGIN_LOCATION = "https://idp.example/authorize?client_id=sheriff";
private static final String BINDING_COOKIE = "__Host-sheriff-binding=binding-value; Path=/; Secure; HttpOnly; SameSite=Lax";
+ private static final String RESEAL_COOKIE = "__Host-sheriff-session=re-sealed-value; Path=/; Secure; HttpOnly; SameSite=Lax";
private static final SessionCookieCodec CODEC =
new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, Duration.ofHours(1));
@@ -69,8 +71,8 @@ class LiveSession {
@Test
@DisplayName("injects the mediated access token as the upstream bearer for a live session")
void injectsMediatedBearer() {
- SessionStore store = storeWith(session(MEDIATED_TOKEN));
- SessionAuthenticationStage stage = stage(store, identityRefresh(), scopesGranted(), redirectLogin());
+ SessionBinding binding = bindingWith(session(MEDIATED_TOKEN));
+ SessionAuthenticationStage stage = stage(binding, identityRefresh(), scopesGranted(), redirectLogin());
PipelineRequest request = sessionRequest(authConfig(List.of()), navigationHeaders());
assertDoesNotThrow(() -> stage.process(request));
@@ -83,10 +85,11 @@ void injectsMediatedBearer() {
@Test
@DisplayName("injects the refreshed token when the single-flight refresh seam rotates it near expiry")
void injectsRefreshedTokenAfterRefresh() {
- SessionStore store = storeWith(session(MEDIATED_TOKEN));
+ SessionBinding binding = bindingWith(session(MEDIATED_TOKEN));
SessionAuthenticationStage.TokenRefresh rotating =
- (session, now) -> rebind(session, REFRESHED_TOKEN);
- SessionAuthenticationStage stage = stage(store, rotating, scopesGranted(), redirectLogin());
+ (session, cookieHeader, now) -> Optional.of(
+ new SessionBinding.BoundSession(rebind(session, REFRESHED_TOKEN), List.of()));
+ SessionAuthenticationStage stage = stage(binding, rotating, scopesGranted(), redirectLogin());
PipelineRequest request = sessionRequest(authConfig(List.of()), navigationHeaders());
stage.process(request);
@@ -95,11 +98,29 @@ void injectsRefreshedTokenAfterRefresh() {
"the token injected is the one the refresh seam returned, not the pre-refresh token");
}
+ @Test
+ @DisplayName("writes the re-seal Set-Cookie to the response when the refresh re-binds the session")
+ void emitsResealSetCookieOnRefresh() {
+ SessionBinding binding = bindingWith(session(MEDIATED_TOKEN));
+ SessionAuthenticationStage.TokenRefresh resealing =
+ (session, cookieHeader, now) -> Optional.of(new SessionBinding.BoundSession(
+ rebind(session, REFRESHED_TOKEN), List.of(RESEAL_COOKIE)));
+ SessionAuthenticationStage stage = stage(binding, resealing, scopesGranted(), redirectLogin());
+ PipelineRequest request = sessionRequest(authConfig(List.of()), navigationHeaders());
+
+ stage.process(request);
+
+ assertEquals(List.of(RESEAL_COOKIE), request.responseSetCookies(),
+ "a cookie-mode re-seal must reach the browser on the very response it was produced for");
+ assertEquals(Optional.of(REFRESHED_TOKEN), request.mediatedBearer(),
+ "the re-seal is emitted before the mediated bearer is injected");
+ }
+
@Test
@DisplayName("passes and injects the bearer when the mediated token grants every required scope")
void passesWhenRequiredScopesSatisfied() {
- SessionStore store = storeWith(session(MEDIATED_TOKEN));
- SessionAuthenticationStage stage = stage(store, identityRefresh(), scopesGranted(), redirectLogin());
+ SessionBinding binding = bindingWith(session(MEDIATED_TOKEN));
+ SessionAuthenticationStage stage = stage(binding, identityRefresh(), scopesGranted(), redirectLogin());
PipelineRequest request = sessionRequest(authConfig(List.of(REQUIRED_SCOPE)), navigationHeaders());
assertDoesNotThrow(() -> stage.process(request));
@@ -116,8 +137,8 @@ class ScopeEnforcement {
@Test
@DisplayName("rejects 403 SCOPE_MISSING when the mediated token lacks a required scope")
void rejectsMissingScopeWith403() {
- SessionStore store = storeWith(session(MEDIATED_TOKEN));
- SessionAuthenticationStage stage = stage(store, identityRefresh(), scopesDenied(), redirectLogin());
+ SessionBinding binding = bindingWith(session(MEDIATED_TOKEN));
+ SessionAuthenticationStage stage = stage(binding, identityRefresh(), scopesDenied(), redirectLogin());
PipelineRequest request = sessionRequest(authConfig(List.of(REQUIRED_SCOPE)), navigationHeaders());
GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request));
@@ -134,7 +155,7 @@ class Unauthenticated {
@Test
@DisplayName("redirects a navigation request 302 into the auth-code flow")
void redirectsNavigationIntoLogin() {
- SessionAuthenticationStage stage = stage(emptyStore(), identityRefresh(), scopesGranted(), redirectLogin());
+ SessionAuthenticationStage stage = stage(emptyBinding(), identityRefresh(), scopesGranted(), redirectLogin());
PipelineRequest request = sessionRequest(authConfig(List.of()), navigationHeaders());
assertDoesNotThrow(() -> stage.process(request));
@@ -142,7 +163,7 @@ void redirectsNavigationIntoLogin() {
assertEquals(Optional.of(302), request.shortCircuitStatus(), "an unauthenticated navigation is short-circuited 302");
assertEquals(LOGIN_LOCATION, request.responseHeaders().get("Location"),
"the redirect targets the login-initiation location");
- assertEquals(BINDING_COOKIE, request.responseHeaders().get("Set-Cookie"),
+ assertEquals(List.of(BINDING_COOKIE), request.responseSetCookies(),
"the browser-binding Set-Cookie is emitted with the redirect");
assertTrue(request.mediatedBearer().isEmpty(), "an unauthenticated request mediates no bearer");
}
@@ -150,7 +171,7 @@ void redirectsNavigationIntoLogin() {
@Test
@DisplayName("challenges an XHR request 401 TOKEN_MISSING rather than redirecting")
void challengesXhrWith401() {
- SessionAuthenticationStage stage = stage(emptyStore(), identityRefresh(), scopesGranted(), redirectLogin());
+ SessionAuthenticationStage stage = stage(emptyBinding(), identityRefresh(), scopesGranted(), redirectLogin());
PipelineRequest request = sessionRequest(authConfig(List.of()), xhrHeaders());
GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request));
@@ -163,8 +184,8 @@ void challengesXhrWith401() {
@Test
@DisplayName("treats an expired session as unauthenticated")
void treatsExpiredSessionAsUnauthenticated() {
- SessionStore store = storeWith(session(MEDIATED_TOKEN, NOW.minusSeconds(1)));
- SessionAuthenticationStage stage = stage(store, identityRefresh(), scopesGranted(), redirectLogin());
+ SessionBinding binding = bindingWith(session(MEDIATED_TOKEN, NOW.minusSeconds(1)));
+ SessionAuthenticationStage stage = stage(binding, identityRefresh(), scopesGranted(), redirectLogin());
PipelineRequest request = sessionRequest(authConfig(List.of()), xhrHeaders());
GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request));
@@ -175,7 +196,7 @@ void treatsExpiredSessionAsUnauthenticated() {
@Test
@DisplayName("treats a request without a session cookie as unauthenticated")
void treatsAbsentCookieAsUnauthenticated() {
- SessionAuthenticationStage stage = stage(storeWith(session(MEDIATED_TOKEN)), identityRefresh(),
+ SessionAuthenticationStage stage = stage(bindingWith(session(MEDIATED_TOKEN)), identityRefresh(),
scopesGranted(), redirectLogin());
PipelineRequest request = sessionRequest(authConfig(List.of()), Map.of("accept", List.of("application/json")));
@@ -183,6 +204,81 @@ void treatsAbsentCookieAsUnauthenticated() {
assertEquals(EventType.TOKEN_MISSING, thrown.getEventType(), "a request carrying no session cookie is unauthenticated");
}
+
+ @Test
+ @DisplayName("treats a failed refresh as unauthenticated rather than mediating the pre-refresh token")
+ void treatsFailedRefreshAsUnauthenticated() {
+ SessionBinding binding = bindingWith(session(MEDIATED_TOKEN));
+ SessionAuthenticationStage stage = stage(binding, failedRefresh(), scopesGranted(), redirectLogin());
+ PipelineRequest request = sessionRequest(authConfig(List.of()), xhrHeaders());
+
+ GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request));
+
+ assertEquals(EventType.TOKEN_MISSING, thrown.getEventType(),
+ "a refresh failure destroyed the session — the request gets the same 401 as a missing session");
+ assertTrue(request.mediatedBearer().isEmpty(),
+ "the revoked session's pre-refresh token is never injected upstream");
+ }
+
+ @Test
+ @DisplayName("clears the browser's session cookie when a refresh failure destroys the session")
+ void clearsTheCookieOnFailedRefresh() {
+ SessionBinding binding = bindingWith(session(MEDIATED_TOKEN));
+ SessionAuthenticationStage stage = stage(binding, failedRefresh(), scopesGranted(), redirectLogin());
+ PipelineRequest request = sessionRequest(authConfig(List.of()), xhrHeaders());
+
+ assertThrows(GatewayException.class, () -> stage.process(request));
+
+ assertEquals(List.of(binding.clearingSetCookieHeader()), request.responseSetCookies(),
+ "the stale cookie is cleared so the browser stops presenting a destroyed session");
+ }
+
+ @Test
+ @DisplayName("emits BOTH the clearing cookie and the login-challenge cookie when a refresh fails on an HTML navigation")
+ void retainsBothCookiesOnFailedRefreshNavigation() {
+ SessionBinding binding = bindingWith(session(MEDIATED_TOKEN));
+ SessionAuthenticationStage stage = stage(binding, failedRefresh(), scopesGranted(), redirectLogin());
+ PipelineRequest request = sessionRequest(authConfig(List.of()), navigationHeaders());
+
+ stage.process(request);
+
+ assertEquals(List.of(binding.clearingSetCookieHeader(), BINDING_COOKIE), request.responseSetCookies(),
+ "both Set-Cookie values must reach the browser: the clearing cookie drops the revoked "
+ + "session's cookie, the binding cookie carries the new login — they name different "
+ + "cookies, so neither may overwrite or truncate the other");
+ }
+
+ @Test
+ @DisplayName("retains every Set-Cookie a binding returns, never only the first")
+ void retainsEveryBindingSetCookie() {
+ String secondCookie = "__Host-sheriff-extra=second-value; Path=/; Secure; HttpOnly; SameSite=Lax";
+ SessionBinding binding = bindingWith(session(MEDIATED_TOKEN));
+ SessionAuthenticationStage.TokenRefresh multiCookieRefresh =
+ (session, cookieHeader, now) -> Optional.of(new SessionBinding.BoundSession(
+ rebind(session, REFRESHED_TOKEN), List.of(RESEAL_COOKIE, secondCookie)));
+ SessionAuthenticationStage stage = stage(binding, multiCookieRefresh, scopesGranted(), redirectLogin());
+ PipelineRequest request = sessionRequest(authConfig(List.of()), navigationHeaders());
+
+ stage.process(request);
+
+ assertEquals(List.of(RESEAL_COOKIE, secondCookie), request.responseSetCookies(),
+ "Set-Cookie is multi-valued — a binding returning two cookies must not be truncated to one");
+ }
+
+ @Test
+ @DisplayName("re-drives a navigation request through login when the refresh fails")
+ void redrivesNavigationOnFailedRefresh() {
+ SessionBinding binding = bindingWith(session(MEDIATED_TOKEN));
+ SessionAuthenticationStage stage = stage(binding, failedRefresh(), scopesGranted(), redirectLogin());
+ PipelineRequest request = sessionRequest(authConfig(List.of()), navigationHeaders());
+
+ assertDoesNotThrow(() -> stage.process(request));
+
+ assertEquals(Optional.of(302), request.shortCircuitStatus(),
+ "a navigation whose refresh failed runs the same negotiation as a missing session");
+ assertEquals(LOGIN_LOCATION, request.responseHeaders().get("Location"));
+ assertTrue(request.mediatedBearer().isEmpty(), "no bearer is mediated from the destroyed session");
+ }
}
@Nested
@@ -200,7 +296,7 @@ class EdgeShortCircuitContract {
@Test
@DisplayName("an unauthenticated navigation leaves a 302 short-circuit and no bearer, so the edge redirects instead of forwarding")
void navigationLeavesShortCircuitAndNoBearer() {
- SessionAuthenticationStage stage = stage(emptyStore(), identityRefresh(), scopesGranted(), redirectLogin());
+ SessionAuthenticationStage stage = stage(emptyBinding(), identityRefresh(), scopesGranted(), redirectLogin());
PipelineRequest request = sessionRequest(authConfig(List.of()), navigationHeaders());
stage.process(request);
@@ -220,7 +316,7 @@ void navigationLeavesShortCircuitAndNoBearer() {
@Test
@DisplayName("an unauthenticated XHR raises 401 TOKEN_MISSING with no short-circuit, so the edge takes the problem path not the redirect")
void xhrRaises401WithNoShortCircuit() {
- SessionAuthenticationStage stage = stage(emptyStore(), identityRefresh(), scopesGranted(), redirectLogin());
+ SessionAuthenticationStage stage = stage(emptyBinding(), identityRefresh(), scopesGranted(), redirectLogin());
PipelineRequest request = sessionRequest(authConfig(List.of()), xhrHeaders());
GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request));
@@ -239,7 +335,7 @@ class Preconditions {
@Test
@DisplayName("rejects a request whose route was not selected at stage 2")
void rejectsUnselectedRoute() {
- SessionAuthenticationStage stage = stage(emptyStore(), identityRefresh(), scopesGranted(), redirectLogin());
+ SessionAuthenticationStage stage = stage(emptyBinding(), identityRefresh(), scopesGranted(), redirectLogin());
PipelineRequest request = PipelineRequest.builder()
.method(HttpMethod.GET).requestPath("/app").queryParameters(Map.of()).headers(Map.of()).build();
@@ -249,19 +345,26 @@ void rejectsUnselectedRoute() {
@Test
@DisplayName("rejects a null request")
void rejectsNullRequest() {
- SessionAuthenticationStage stage = stage(emptyStore(), identityRefresh(), scopesGranted(), redirectLogin());
+ SessionAuthenticationStage stage = stage(emptyBinding(), identityRefresh(), scopesGranted(), redirectLogin());
assertThrows(NullPointerException.class, () -> stage.process(null));
}
}
- private static SessionAuthenticationStage stage(SessionStore store, SessionAuthenticationStage.TokenRefresh refresh,
- SessionAuthenticationStage.GrantedScopes scopes, SessionAuthenticationStage.LoginInitiation login) {
- return new SessionAuthenticationStage(store, CODEC, refresh, scopes, login, CLOCK);
+ private static SessionAuthenticationStage stage(SessionBinding binding,
+ SessionAuthenticationStage.TokenRefresh refresh, SessionAuthenticationStage.GrantedScopes scopes,
+ SessionAuthenticationStage.LoginInitiation login) {
+ return new SessionAuthenticationStage(binding, refresh, scopes, login, CLOCK);
}
private static SessionAuthenticationStage.TokenRefresh identityRefresh() {
- return (session, now) -> session;
+ return (session, cookieHeader, now) ->
+ Optional.of(new SessionBinding.BoundSession(session, List.of()));
+ }
+
+ /** A refresh seam that failed and destroyed the session — the empty outcome the stage negotiates on. */
+ private static SessionAuthenticationStage.TokenRefresh failedRefresh() {
+ return (session, cookieHeader, now) -> Optional.empty();
}
private static SessionAuthenticationStage.GrantedScopes scopesGranted() {
@@ -276,14 +379,14 @@ private static SessionAuthenticationStage.LoginInitiation redirectLogin() {
return (returnUrl, now) -> new LoginChallenge(LOGIN_LOCATION, List.of(BINDING_COOKIE));
}
- private static SessionStore emptyStore() {
- return new InMemorySessionStore(16);
+ private static SessionBinding emptyBinding() {
+ return new ServerSessionBinding(new InMemorySessionStore(16), CODEC);
}
- private static SessionStore storeWith(SessionRecord session) {
+ private static SessionBinding bindingWith(SessionRecord session) {
InMemorySessionStore store = new InMemorySessionStore(16);
store.create(session);
- return store;
+ return new ServerSessionBinding(store, CODEC);
}
private static SessionRecord session(String accessToken) {
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/ServerSessionBindingTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/ServerSessionBindingTest.java
new file mode 100644
index 00000000..ee90542e
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/ServerSessionBindingTest.java
@@ -0,0 +1,213 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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 de.cuioss.sheriff.gateway.bff.session;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Optional;
+
+
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding.BoundSession;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding.IdpDestruction;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link ServerSessionBinding} — the regression net proving the seam adapter preserves
+ * every landed {@link SessionStore} semantic byte for byte: the opaque-handle cookie on bind, the
+ * upsert-in-place on persist (no destroy-then-create window), the lazy TTL eviction on resolve, and
+ * the O(1) {@code sid}/{@code sub} destruction with its {@code SUPPORTED} capability.
+ */
+class ServerSessionBindingTest {
+
+ private static final Instant NOW = Instant.parse("2026-07-27T10:00:00Z");
+ private static final Duration SESSION_TTL = Duration.ofHours(8);
+ private static final String SUB = "user-sub-1";
+ private static final String SID = "idp-session-1";
+
+ private InMemorySessionStore store;
+ private SessionCookieCodec cookieCodec;
+ private ServerSessionBinding binding;
+
+ @BeforeEach
+ void setUp() {
+ store = new InMemorySessionStore(16);
+ cookieCodec = new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, SESSION_TTL);
+ binding = new ServerSessionBinding(store, cookieCodec);
+ }
+
+ private static SessionRecord session(String sessionId, String accessToken) {
+ return SessionRecord.builder()
+ .sessionId(sessionId)
+ .accessToken(accessToken)
+ .idToken("raw-id-token")
+ .sub(SUB)
+ .sid(Optional.of(SID))
+ .expiresAt(NOW.plus(SESSION_TTL))
+ .build();
+ }
+
+ private static String cookieHeaderFor(String sessionId) {
+ return SessionCookieCodec.DEFAULT_COOKIE_NAME + "=" + sessionId;
+ }
+
+ @Test
+ @DisplayName("Should store the session and hand the browser only the opaque handle on bind")
+ void shouldBindSessionAndEmitOpaqueCookie() {
+ // Arrange
+ SessionRecord session = session(SessionRecord.newSessionId(), "access-token-1");
+
+ // Act
+ BoundSession bound = binding.bind(session, NOW);
+
+ // Assert
+ assertSame(session, bound.session(), "bind returns the session it stored");
+ assertEquals(1, bound.setCookieHeaders().size(), "server mode emits exactly one session cookie");
+ String setCookie = bound.setCookieHeaders().getFirst();
+ assertEquals(cookieCodec.toSetCookieHeader(session.sessionId()), setCookie,
+ "the cookie is the landed hardened opaque-handle header, unchanged");
+ assertFalse(setCookie.contains("access-token-1"), "no token material ever reaches the browser");
+ assertEquals(Optional.of(session), store.resolve(session.sessionId(), NOW),
+ "the record is held server-side in the store");
+ }
+
+ @Test
+ @DisplayName("Should read the session cookie back to the stored session on resolve")
+ void shouldResolveSessionFromCookieHeader() {
+ SessionRecord session = session(SessionRecord.newSessionId(), "access-token-1");
+ binding.bind(session, NOW);
+
+ Optional resolved = binding.resolve(cookieHeaderFor(session.sessionId()), NOW);
+
+ assertEquals(Optional.of(session), resolved);
+ }
+
+ @Test
+ @DisplayName("Should resolve empty when the request carries no session cookie")
+ void shouldResolveEmptyWithoutCookie() {
+ assertTrue(binding.resolve(null, NOW).isEmpty(), "an absent Cookie header carries no session");
+ assertTrue(binding.resolve("other=value", NOW).isEmpty(), "an unrelated cookie carries no session");
+ }
+
+ @Test
+ @DisplayName("Should resolve empty for a session id the store does not know")
+ void shouldResolveEmptyForUnknownSessionId() {
+ assertTrue(binding.resolve(cookieHeaderFor(SessionRecord.newSessionId()), NOW).isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should evict and report absent an expired session on resolve (lazy TTL)")
+ void shouldEvictExpiredSessionOnResolve() {
+ SessionRecord session = session(SessionRecord.newSessionId(), "access-token-1");
+ binding.bind(session, NOW);
+ Instant afterExpiry = session.expiresAt().plusSeconds(1);
+
+ Optional resolved = binding.resolve(cookieHeaderFor(session.sessionId()), afterExpiry);
+
+ assertTrue(resolved.isEmpty(), "an expired session resolves as absent");
+ assertTrue(store.resolve(session.sessionId(), NOW).isEmpty(),
+ "the expired session was evicted from the store, not merely hidden");
+ }
+
+ @Test
+ @DisplayName("Should upsert the rotated record in place on persist, with no new cookie")
+ void shouldUpsertOnPersistWithoutNewCookie() {
+ SessionRecord session = session(SessionRecord.newSessionId(), "access-token-1");
+ binding.bind(session, NOW);
+ SessionRecord rotated = session(session.sessionId(), "access-token-2");
+
+ BoundSession bound = binding.persist(rotated, NOW);
+
+ assertSame(rotated, bound.session());
+ assertTrue(bound.setCookieHeaders().isEmpty(),
+ "the opaque handle is unchanged, so the browser needs no new Set-Cookie");
+ assertEquals(Optional.of(rotated), store.resolve(session.sessionId(), NOW),
+ "the rotated record replaced the previous one under the same id");
+ }
+
+ @Test
+ @DisplayName("Should keep the session resolvable throughout a persist (no destroy-then-create window)")
+ void shouldNotDropTheSessionWhilePersisting() {
+ SessionRecord session = session(SessionRecord.newSessionId(), "access-token-1");
+ binding.bind(session, NOW);
+
+ binding.persist(session(session.sessionId(), "access-token-2"), NOW);
+
+ assertTrue(binding.resolve(cookieHeaderFor(session.sessionId()), NOW).isPresent(),
+ "a concurrent resolve must never miss a rotating session");
+ }
+
+ @Test
+ @DisplayName("Should destroy the session by its identity")
+ void shouldDestroySession() {
+ SessionRecord session = session(SessionRecord.newSessionId(), "access-token-1");
+ binding.bind(session, NOW);
+
+ binding.destroy(session);
+
+ assertTrue(store.resolve(session.sessionId(), NOW).isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should tolerate destroying a session that is already gone")
+ void shouldTolerateDestroyingAnAbsentSession() {
+ SessionRecord session = session(SessionRecord.newSessionId(), "access-token-1");
+
+ binding.destroy(session);
+
+ assertTrue(store.resolve(session.sessionId(), NOW).isEmpty(), "destroy of an absent session is a no-op");
+ }
+
+ @Test
+ @DisplayName("Should destroy every session carrying the IdP sid")
+ void shouldDestroyBySid() {
+ binding.bind(session(SessionRecord.newSessionId(), "access-token-1"), NOW);
+ binding.bind(session(SessionRecord.newSessionId(), "access-token-2"), NOW);
+
+ assertEquals(2, binding.destroyBySid(SID), "both sessions share the IdP sid");
+ assertEquals(0, binding.destroyBySid(SID), "a repeated back-channel logout destroys nothing more");
+ }
+
+ @Test
+ @DisplayName("Should destroy every session for the subject")
+ void shouldDestroyBySub() {
+ binding.bind(session(SessionRecord.newSessionId(), "access-token-1"), NOW);
+ binding.bind(session(SessionRecord.newSessionId(), "access-token-2"), NOW);
+
+ assertEquals(2, binding.destroyBySub(SUB));
+ assertEquals(0, binding.destroyBySub(SUB));
+ }
+
+ @Test
+ @DisplayName("Should report SUPPORTED IdP-driven destruction — the store holds the secondary index")
+ void shouldReportSupportedIdpDestruction() {
+ assertEquals(IdpDestruction.SUPPORTED, binding.idpDestruction());
+ }
+
+ @Test
+ @DisplayName("Should clear the session cookie through the landed clearing header")
+ void shouldClearSessionCookie() {
+ assertEquals(cookieCodec.toClearingSetCookieHeader(), binding.clearingSetCookieHeader());
+ assertTrue(binding.clearingSetCookieHeader().contains("Max-Age=0"));
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/model/ConfigModelContractTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/model/ConfigModelContractTest.java
index 38eb5d0b..29b65e0f 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/model/ConfigModelContractTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/model/ConfigModelContractTest.java
@@ -37,6 +37,7 @@
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
/**
* Value-object contract test suite for the immutable configuration model records
@@ -859,6 +860,49 @@ void defaultsEnableRetryAndNotModified() {
// --- BFF OIDC/session fold records (D1) --------------------------------
+ @Nested
+ @DisplayName("Session mode canonicalization — one spelling, one predicate")
+ class SessionModeCanonicalization {
+
+ private static OidcConfig.Session sessionWithMode(String mode) {
+ return OidcConfig.Session.builder().mode(Optional.of(mode)).build();
+ }
+
+ @ParameterizedTest(name = "mode ''{0}'' canonicalizes to cookie mode")
+ @ValueSource(strings = {"cookie", "Cookie", "COOKIE", " CoOkIe "})
+ @DisplayName("Should read every spelling of the cookie mode as cookie mode")
+ void shouldCanonicalizeCookieMode(String declared) {
+ OidcConfig.Session session = sessionWithMode(declared);
+
+ assertEquals(Optional.of(OidcConfig.Session.MODE_COOKIE), session.mode(),
+ "the mode is canonicalized once, at construction");
+ assertTrue(session.isCookieMode());
+ assertFalse(session.isServerMode());
+ assertTrue(session.isRecognisedMode());
+ }
+
+ @ParameterizedTest(name = "mode ''{0}'' canonicalizes to server mode")
+ @ValueSource(strings = {"server", "Server", "SERVER", " sErVeR "})
+ @DisplayName("Should read every spelling of the server mode as server mode")
+ void shouldCanonicalizeServerMode(String declared) {
+ OidcConfig.Session session = sessionWithMode(declared);
+
+ assertEquals(Optional.of(OidcConfig.Session.MODE_SERVER), session.mode());
+ assertTrue(session.isServerMode());
+ assertFalse(session.isCookieMode());
+ assertTrue(session.isRecognisedMode());
+ }
+
+ @Test
+ @DisplayName("Should treat an absent, blank or unrecognised mode as no recognised mode")
+ void shouldRejectUnrecognisedModes() {
+ assertTrue(sessionWithMode(" ").mode().isEmpty(), "a blank mode is an absent mode");
+ assertFalse(sessionWithMode(" ").isRecognisedMode());
+ assertFalse(sessionWithMode("stateless").isRecognisedMode());
+ assertFalse(OidcConfig.Session.builder().build().isRecognisedMode());
+ }
+ }
+
@Nested
@DisplayName("BFF OIDC/session fold records (D1)")
class BffFoldRecords {
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidatorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidatorTest.java
index 8c72f374..fb0ecd5b 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidatorTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidatorTest.java
@@ -626,8 +626,8 @@ void shouldRejectWildcardOriginWithCredentials() {
}
@Test
- @DisplayName("Should reject cookie session mode without an encryption_key")
- void shouldRejectCookieSessionWithoutEncryptionKey() {
+ @DisplayName("Should accept cookie session mode without an encryption_key (generate-on-startup)")
+ void shouldAcceptCookieSessionWithoutEncryptionKey() {
GatewayConfig gateway = validGateway()
.oidc(Optional.of(OidcConfig.builder()
.session(Optional.of(OidcConfig.Session.builder()
@@ -639,7 +639,47 @@ void shouldRejectCookieSessionWithoutEncryptionKey() {
List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS"));
- assertHasError(errors, "/oidc/session/encryption_key", "cookie session mode requires an encryption_key");
+ assertTrue(errors.stream().noneMatch(e -> e.pointer().contains("/oidc/session/")),
+ () -> "omitting encryption_key selects the generate-on-startup key mode, got: " + errors);
+ }
+
+ @Test
+ @DisplayName("Should reject previous_key without encryption_key")
+ void shouldRejectPreviousKeyWithoutEncryptionKey() {
+ GatewayConfig gateway = validGateway()
+ .oidc(Optional.of(OidcConfig.builder()
+ .session(Optional.of(OidcConfig.Session.builder()
+ .mode(Optional.of("cookie"))
+ .previousKey(Optional.of("${SHERIFF_SESSION_KEY_PREVIOUS}"))
+ .build()))
+ .build()))
+ .build();
+ EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(), route("r", HttpMethod.GET));
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS"));
+
+ assertHasError(errors, "/oidc/session/previous_key",
+ "cookie session mode with a previous_key requires an encryption_key");
+ }
+
+ @Test
+ @DisplayName("Should accept cookie session mode with both an encryption_key and a previous_key")
+ void shouldAcceptCookieSessionWithRotationKeys() {
+ GatewayConfig gateway = validGateway()
+ .oidc(Optional.of(OidcConfig.builder()
+ .session(Optional.of(OidcConfig.Session.builder()
+ .mode(Optional.of("cookie"))
+ .encryptionKey(Optional.of("${SHERIFF_SESSION_KEY}"))
+ .previousKey(Optional.of("${SHERIFF_SESSION_KEY_PREVIOUS}"))
+ .build()))
+ .build()))
+ .build();
+ EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(), route("r", HttpMethod.GET));
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS"));
+
+ assertTrue(errors.stream().noneMatch(e -> e.pointer().contains("/oidc/session/")),
+ () -> "rotation composes with the passed-key mode, got: " + errors);
}
@Test
@@ -659,6 +699,42 @@ void shouldRejectServerSessionWithoutStore() {
assertHasError(errors, "/oidc/session/store", "server session mode requires a store");
}
+ @Test
+ @DisplayName("Should apply the cookie-mode companion rule to a mixed-case mode value")
+ void shouldApplyCookieRuleToMixedCaseMode() {
+ GatewayConfig gateway = validGateway()
+ .oidc(Optional.of(OidcConfig.builder()
+ .session(Optional.of(OidcConfig.Session.builder()
+ .mode(Optional.of(" CoOkIe "))
+ .previousKey(Optional.of("${SHERIFF_SESSION_KEY_PREVIOUS}"))
+ .build()))
+ .build()))
+ .build();
+ EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(), route("r", HttpMethod.GET));
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS"));
+
+ assertHasError(errors, "/oidc/session/previous_key",
+ "cookie session mode with a previous_key requires an encryption_key");
+ }
+
+ @Test
+ @DisplayName("Should apply the server-mode companion rule to a mixed-case mode value")
+ void shouldApplyServerRuleToMixedCaseMode() {
+ GatewayConfig gateway = validGateway()
+ .oidc(Optional.of(OidcConfig.builder()
+ .session(Optional.of(OidcConfig.Session.builder()
+ .mode(Optional.of("SERVER"))
+ .build()))
+ .build()))
+ .build();
+ EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(), route("r", HttpMethod.GET));
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS"));
+
+ assertHasError(errors, "/oidc/session/store", "server session mode requires a store");
+ }
+
@Test
@DisplayName("Should accept effective auth 'bearer' when a token_validation issuer is present")
void shouldAcceptBearerWithIssuer() {
@@ -1393,5 +1469,37 @@ void shouldAcceptPositiveMaxSessions() {
assertTrue(errors.isEmpty(), () -> "expected no violations, got: " + errors);
}
+
+ @ParameterizedTest(name = "max_cookie_size = {0} is rejected")
+ @ValueSource(ints = {0, -1, 39, 8193, 65536})
+ @DisplayName("Should reject a session max_cookie_size outside the viable budget bounds")
+ void shouldRejectOutOfBoundsMaxCookieSize(int maxCookieSize) {
+ // Arrange — below the floor no sealed value could ever be emitted; above the ceiling the
+ // derived pre-route Cookie cap would exceed what the transport carries.
+ GatewayConfig gateway = gatewayWithOidc(OidcConfig.builder()
+ .session(Optional.of(OidcConfig.Session.builder()
+ .maxCookieSize(Optional.of(maxCookieSize)).build()))
+ .build());
+
+ // Act
+ List errors = validator.validate(gateway, List.of(), topologyWith());
+
+ // Assert
+ assertHasError(errors, "/oidc/session/max_cookie_size", "must be between");
+ }
+
+ @ParameterizedTest(name = "max_cookie_size = {0} is accepted")
+ @ValueSource(ints = {40, 4096, 8192})
+ @DisplayName("Should accept a session max_cookie_size on and inside the viable budget bounds")
+ void shouldAcceptInBoundsMaxCookieSize(int maxCookieSize) {
+ GatewayConfig gateway = gatewayWithOidc(OidcConfig.builder()
+ .session(Optional.of(OidcConfig.Session.builder()
+ .maxCookieSize(Optional.of(maxCookieSize)).build()))
+ .build());
+
+ List errors = validator.validate(gateway, List.of(), topologyWith());
+
+ assertTrue(errors.isEmpty(), () -> "expected no violations, got: " + errors);
+ }
}
}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/EdgeHardeningTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/EdgeHardeningTest.java
index f0b6c09f..222d3433 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/EdgeHardeningTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/EdgeHardeningTest.java
@@ -109,6 +109,24 @@ void exposesPositiveAdmissionCap() {
assertTrue(cap > 0, "The admission cap must be a positive bound");
}
+ @Test
+ @DisplayName("bounds a reserved-path body at the same 16 KiB inbound unit as the header block")
+ void exposesReservedBodyCeilingDerivedFromHeaderBound() {
+ // Arrange
+ EdgeHardeningOptions hardening = new EdgeHardeningOptions();
+ HttpServerOptions options = new HttpServerOptions();
+ hardening.customizeHttpServer(options);
+
+ // Act
+ long reservedBodyMax = hardening.reservedBodyMaxBytes();
+
+ // Assert — the ceiling is DERIVED from the header-block bound rather than restated as its own
+ // literal, so the gateway's single 16 KiB inbound-unit bound cannot drift into two constants.
+ assertEquals(options.getMaxHeaderSize(), reservedBodyMax,
+ "The reserved-body ceiling is derived from the 16 KiB header-block bound, not a second literal");
+ assertTrue(reservedBodyMax > 0L, "The reserved-body ceiling must be a positive bound");
+ }
+
@Test
@DisplayName("bounds the graceful-drain wait below the Quarkus shutdown window")
void exposesBoundedDrainTimeout() {
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java
index 06fa8224..4677ab49 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java
@@ -53,6 +53,8 @@
import de.cuioss.sheriff.gateway.bff.runtime.BffRuntime;
import de.cuioss.sheriff.gateway.bff.runtime.SessionAuthenticationStage;
import de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore;
+import de.cuioss.sheriff.gateway.bff.session.ServerSessionBinding;
+import de.cuioss.sheriff.gateway.bff.session.SessionBinding;
import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec;
import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
import de.cuioss.sheriff.gateway.bff.session.SessionStore;
@@ -164,14 +166,14 @@ void tearDown() {
@DisplayName("Should boot a require:session route through the session-aware AuthenticationStage")
void shouldBootSessionRouteWithActiveRuntime() {
RouteTable sessionTable = new RouteTable(List.of(sessionRoute()));
- assertDoesNotThrow(() -> newEdge(sessionTable, activeRuntime(new InMemorySessionStore(16))),
+ assertDoesNotThrow(() -> newEdge(sessionTable, activeRuntime(serverBinding(new InMemorySessionStore(16)))),
"A require:session route assembles through the wired SessionAuthenticationStage");
}
@Test
@DisplayName("Should still register a single catch-all route with an active runtime")
void shouldRegisterCatchAll() {
- GatewayEdgeRoute edge = newEdge(new RouteTable(List.of()), activeRuntime(new InMemorySessionStore(16)));
+ GatewayEdgeRoute edge = newEdge(new RouteTable(List.of()), activeRuntime(serverBinding(new InMemorySessionStore(16))));
Router router = Router.router(vertx);
edge.registerRoutes(router);
assertEquals(1, router.getRoutes().size());
@@ -189,9 +191,7 @@ private GatewayEdgeRoute newEdge(RouteTable table, BffRuntime runtime) {
class ReservedDispatch {
private final SessionStore store = new InMemorySessionStore(16);
- private final SessionCookieCodec codec = new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME,
- Duration.ofHours(1));
- private final BffRuntime runtime = activeRuntime(store, codec);
+ private final BffRuntime runtime = activeRuntime(serverBinding(store));
private final Instant now = Instant.parse("2026-07-25T10:00:00Z");
@Test
@@ -265,8 +265,7 @@ class FormPostCallbackDispatch {
private PendingAuthorizationStore.InMemory pendingStore;
private BindingCookieCodec bindingCodec;
- private SessionStore sessionStore;
- private SessionCookieCodec codec;
+ private SessionBinding sessionBinding;
private BffRuntime runtime;
private String state;
private String bindingCookieHeader;
@@ -275,8 +274,7 @@ class FormPostCallbackDispatch {
void setUp() {
pendingStore = new PendingAuthorizationStore.InMemory(16);
bindingCodec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL);
- sessionStore = new InMemorySessionStore(16);
- codec = new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, Duration.ofHours(1));
+ sessionBinding = serverBinding(new InMemorySessionStore(16));
FlowContext flow = FlowContext.create(ORIGIN + CALLBACK_PATH);
state = flow.state();
@@ -322,10 +320,11 @@ private BffRuntime formPostRuntime() {
idClaims.put(ClaimName.SUBJECT.getName(), ClaimValue.forPlainString(SUBJECT));
IdTokenContent id = new IdTokenContent(idClaims, RAW_ID_TOKEN);
return new AuthorizationCodeFlow.AuthenticationResult(access, id);
- }, pendingStore, bindingCodec, sessionStore, codec, Duration.ofHours(1));
+ }, pendingStore, bindingCodec, sessionBinding, Duration.ofHours(1));
- SessionAuthenticationStage sessionStage = new SessionAuthenticationStage(sessionStore, codec,
- (session, instant) -> session,
+ SessionAuthenticationStage sessionStage = new SessionAuthenticationStage(sessionBinding,
+ (session, cookieHeader, instant) ->
+ Optional.of(new SessionBinding.BoundSession(session, List.of())),
(token, scopes) -> true,
(returnUrl, instant) -> new SessionAuthenticationStage.LoginChallenge("/login", List.of()),
Clock.systemUTC());
@@ -339,17 +338,18 @@ private BffRuntime formPostRuntime() {
throw new AssertionError("engine authorize must not be reached");
}, pendingStore, bindingCodec, ORIGIN);
BackchannelLogoutEndpoint backchannel = new BackchannelLogoutEndpoint(new BackchannelLogoutReceiver(
- rawToken -> {
- throw new AssertionError("engine verify must not be reached");
- },
- new LogoutTokenValidator(ORIGIN, "client", Duration.ofMinutes(2)), sessionStore));
- UserInfoEndpoint userInfo = new UserInfoEndpoint(sessionStore, codec,
+ rawToken -> {
+ throw new AssertionError("engine verify must not be reached");
+ },
+ new LogoutTokenValidator(ORIGIN, "client", Duration.ofMinutes(2)), sessionBinding),
+ sessionBinding);
+ UserInfoEndpoint userInfo = new UserInfoEndpoint(sessionBinding,
new ClaimAllowlistFilter(List.of("sub"), List.of("sub")),
session -> Map.of("sub", session.sub()));
- LoginInitiationEndpoint login = new LoginInitiationEndpoint(loginFlow, sessionStore, codec, ORIGIN);
+ LoginInitiationEndpoint login = new LoginInitiationEndpoint(loginFlow, sessionBinding, ORIGIN);
return new BffRuntime(sessionStage, new CsrfDefence(Set.of(ORIGIN)), stepUp, callback,
- () -> logoutEndpoint(sessionStore, codec), backchannel, userInfo, login);
+ () -> logoutEndpoint(sessionBinding), backchannel, userInfo, login);
}
}
@@ -367,9 +367,10 @@ private static OidcConfig fullOidc() {
.build();
}
- private static BffRuntime activeRuntime(SessionStore store) {
- return activeRuntime(store, new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME,
- Duration.ofHours(1)));
+ /** The server-mode seam over an in-memory store, standing in for whichever binding is in force. */
+ private static SessionBinding serverBinding(SessionStore store) {
+ return new ServerSessionBinding(store,
+ new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, Duration.ofHours(1)));
}
/**
@@ -378,7 +379,7 @@ private static BffRuntime activeRuntime(SessionStore store) {
* so the paths these tests drive (no-session, no-input, live-session short-circuit) never touch a
* live IdP.
*/
- private static BffRuntime activeRuntime(SessionStore store, SessionCookieCodec codec) {
+ private static BffRuntime activeRuntime(SessionBinding binding) {
BindingCookieCodec bindingCodec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL);
PendingAuthorizationStore pendingStore = new PendingAuthorizationStore.InMemory(16);
Duration ttl = Duration.ofHours(1);
@@ -387,8 +388,9 @@ private static BffRuntime activeRuntime(SessionStore store, SessionCookieCodec c
throw new AssertionError("engine authorize must not be reached");
}, pendingStore, bindingCodec, ORIGIN);
- SessionAuthenticationStage sessionStage = new SessionAuthenticationStage(store, codec,
- (session, instant) -> session,
+ SessionAuthenticationStage sessionStage = new SessionAuthenticationStage(binding,
+ (session, cookieHeader, instant) ->
+ Optional.of(new SessionBinding.BoundSession(session, List.of())),
(token, scopes) -> true,
(returnUrl, instant) -> new SessionAuthenticationStage.LoginChallenge("/login", List.of()),
Clock.systemUTC());
@@ -404,30 +406,30 @@ private static BffRuntime activeRuntime(SessionStore store, SessionCookieCodec c
CallbackEndpoint callback = new CallbackEndpoint((context, params) -> {
throw new AssertionError("engine exchange must not be reached");
- }, pendingStore, bindingCodec, store, codec, ttl);
+ }, pendingStore, bindingCodec, binding, ttl);
BackchannelLogoutEndpoint backchannel = new BackchannelLogoutEndpoint(new BackchannelLogoutReceiver(
rawToken -> {
throw new AssertionError("engine verify must not be reached");
},
- new LogoutTokenValidator(ORIGIN, "client", Duration.ofMinutes(2)), store));
+ new LogoutTokenValidator(ORIGIN, "client", Duration.ofMinutes(2)), binding), binding);
- UserInfoEndpoint userInfo = new UserInfoEndpoint(store, codec,
+ UserInfoEndpoint userInfo = new UserInfoEndpoint(binding,
new ClaimAllowlistFilter(List.of("sub"), List.of("sub")),
session -> Map.of("sub", session.sub()));
- LoginInitiationEndpoint login = new LoginInitiationEndpoint(loginFlow, store, codec, ORIGIN);
+ LoginInitiationEndpoint login = new LoginInitiationEndpoint(loginFlow, binding, ORIGIN);
- return new BffRuntime(sessionStage, csrf, stepUp, callback, () -> logoutEndpoint(store, codec), backchannel,
+ return new BffRuntime(sessionStage, csrf, stepUp, callback, () -> logoutEndpoint(binding), backchannel,
userInfo, login);
}
- private static LogoutEndpoint logoutEndpoint(SessionStore store, SessionCookieCodec codec) {
+ private static LogoutEndpoint logoutEndpoint(SessionBinding binding) {
EndSessionFlow endSessionFlow = new EndSessionFlow(
new PostLogoutRedirectValidator(Set.of(ORIGIN + LOGOUT_RETURN_PATH)));
RpInitiatedLogout rpInitiatedLogout = new RpInitiatedLogout(endSessionFlow, session -> {
}, "https://idp.example.com/logout", ORIGIN + LOGOUT_RETURN_PATH, "/", Duration.ofMinutes(1));
- return new LogoutEndpoint(rpInitiatedLogout, store, codec);
+ return new LogoutEndpoint(rpInitiatedLogout, binding);
}
private static ResolvedRoute sessionRoute() {
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/ReservedBodyCeilingTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/ReservedBodyCeilingTest.java
new file mode 100644
index 00000000..98831b02
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/ReservedBodyCeilingTest.java
@@ -0,0 +1,413 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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 de.cuioss.sheriff.gateway.edge;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.annotation.Annotation;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+
+import de.cuioss.sheriff.gateway.bff.runtime.BffRuntime;
+import de.cuioss.sheriff.gateway.config.model.GatewayConfig;
+import de.cuioss.sheriff.gateway.config.model.OidcConfig;
+import de.cuioss.sheriff.gateway.config.model.RouteTable;
+import de.cuioss.sheriff.gateway.quarkus.SheriffMetrics;
+import de.cuioss.sheriff.token.validation.TokenValidator;
+import de.cuioss.sheriff.token.validation.test.generator.TestTokenGenerators;
+import de.cuioss.test.generator.Generators;
+import de.cuioss.test.generator.junit.EnableGeneratorController;
+
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import io.vertx.core.Vertx;
+import io.vertx.core.buffer.Buffer;
+import io.vertx.core.http.HttpClient;
+import io.vertx.core.http.HttpMethod;
+import io.vertx.core.http.HttpServer;
+import io.vertx.core.http.RequestOptions;
+import io.vertx.core.net.NetClient;
+import io.vertx.core.net.NetSocket;
+import io.vertx.ext.web.Router;
+import jakarta.enterprise.inject.Instance;
+import jakarta.enterprise.util.TypeLiteral;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Pins the byte ceiling the edge enforces on a gateway-terminated reserved POST body (the OIDC
+ * {@code response_mode=form_post} callback and the back-channel logout receiver), driven over a live
+ * Vert.x HTTP server — no Docker, no Quarkus.
+ *
+ * Those two paths are read pre-authentication and before the pipeline's per-route
+ * {@code maxBodySize} cap can apply, so without a ceiling at the read itself an unauthenticated caller
+ * could buffer an arbitrarily large body straight into the gateway heap. The ceiling is enforced twice
+ * — a {@code Content-Length} pre-check and a streaming cumulative counter — and both arms are pinned
+ * here, because {@code Content-Length} is attacker-controlled and absent entirely under chunked
+ * transfer-encoding.
+ *
+ * The gateway under test wires the {@linkplain BffRuntime#inert() inert} BFF runtime, so a reserved
+ * path that passes the ceiling is carved out of proxy dispatch and rendered {@code 404}.
+ * {@code 404} is therefore the observable "the body was accepted and the pipeline ran" outcome, and
+ * {@code 413} the "the read itself refused it" outcome — the two are unambiguous.
+ */
+@EnableGeneratorController
+@DisplayName("GatewayEdgeRoute — reserved-path request-body byte ceiling")
+class ReservedBodyCeilingTest {
+
+ private static final String CALLBACK_PATH = "/auth/callback";
+ private static final String CRLF = "\r\n";
+ /** Bounded wait proving the pre-check refused BEFORE buffering: far below the 20s body deadline. */
+ private static final long NO_BUFFERING_TIMEOUT_SECONDS = 5L;
+
+ private Vertx vertx;
+ private ExecutorService virtualThreadExecutor;
+ private HttpServer frontServer;
+ private HttpClient client;
+ private NetClient netClient;
+ private int frontPort;
+ private long ceiling;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ vertx = Vertx.vertx();
+ virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor();
+
+ TokenValidator tokenValidator = TokenValidator.builder()
+ .issuerConfig(TestTokenGenerators.accessTokens().next().getIssuerConfig()).build();
+ GatewayConfig gatewayConfig = GatewayConfig.builder()
+ .version(1)
+ .oidc(Optional.of(OidcConfig.builder()
+ .redirectUri(Optional.of("https://localhost" + CALLBACK_PATH))
+ .build()))
+ .build();
+ EdgeHardeningOptions hardening = new EdgeHardeningOptions();
+ ceiling = hardening.reservedBodyMaxBytes();
+
+ GatewayEdgeRoute edge = new GatewayEdgeRoute(new RouteTable(List.of()), gatewayConfig,
+ new SingletonInstance<>(tokenValidator), vertx, virtualThreadExecutor, hardening,
+ new SheriffMetrics(new SimpleMeterRegistry()), BffRuntime.inert());
+
+ Router router = Router.router(vertx);
+ edge.registerRoutes(router);
+ frontServer = vertx.createHttpServer().requestHandler(router)
+ .listen(0).toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS);
+ frontPort = frontServer.actualPort();
+
+ client = vertx.createHttpClient();
+ netClient = vertx.createNetClient();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ netClient.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ client.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ frontServer.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ virtualThreadExecutor.close();
+ vertx.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ }
+
+ @Test
+ @DisplayName("accepts a reserved POST body exactly at the ceiling")
+ void acceptsBodyExactlyAtTheCeiling() throws Exception {
+ String body = formBody(ceiling);
+
+ int status = post(CALLBACK_PATH, body);
+
+ assertEquals(ceiling, body.length(),
+ "The arranged body must sit exactly on the boundary for this to pin the ceiling");
+ assertEquals(404, status,
+ "A body at the ceiling is read and the pipeline runs to the reserved carve-out (404), never 413");
+ }
+
+ @Test
+ @DisplayName("rejects a reserved POST body one byte over the ceiling with 413")
+ void rejectsBodyOverTheCeiling() throws Exception {
+ String body = formBody(ceiling + 1);
+
+ int status = post(CALLBACK_PATH, body);
+
+ assertEquals(413, status,
+ "A body beyond the ceiling is refused 413 — the honest status for an over-large payload");
+ }
+
+ @Test
+ @DisplayName("rejects an over-ceiling declared Content-Length without buffering any body byte")
+ void rejectsOverCeilingContentLengthWithoutBuffering() {
+ // A raw request head declaring far more than the ceiling, with ZERO body bytes ever sent. A
+ // gateway that buffered first would still be blocked on its 20-second body deadline; only a
+ // gateway that refuses on the declared length alone can answer inside the bounded wait below.
+ String head = "POST " + CALLBACK_PATH + " HTTP/1.1" + CRLF
+ + "Host: localhost:" + frontPort + CRLF
+ + "Content-Length: " + (ceiling * 64) + CRLF
+ + "Connection: close" + CRLF + CRLF;
+
+ CompletableFuture responded = sendRaw(head);
+
+ String statusLine = assertDoesNotThrow(
+ () -> responded.get(NO_BUFFERING_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "An over-ceiling Content-Length must be refused before any body byte is buffered");
+ assertTrue(statusLine.startsWith("HTTP/1.1 413"),
+ "The declared-length pre-check refuses 413: " + statusLine);
+ }
+
+ @Test
+ @DisplayName("rejects an over-ceiling chunked body whose declared length is absent")
+ void rejectsOverCeilingChunkedBody() throws Exception {
+ // Chunked transfer-encoding carries NO Content-Length, so the pre-check is structurally blind
+ // to it — exactly the bypass the streaming cumulative counter exists to close. Each chunk stays
+ // under the transport's per-chunk bound; only their SUM crosses the ceiling.
+ //
+ // This IS the "declared length understates the actual body" case at the protocol level: over
+ // HTTP/1.1 a body cannot outrun an honest Content-Length (the codec stops the body at the
+ // declared octet and reads the surplus as a new pipelined message), so the only framing in
+ // which the real size can exceed what the pre-check can see is a length that is absent
+ // altogether. That is what is exercised here.
+ int chunkSize = 1024;
+ long chunks = ceiling / chunkSize + 2;
+ StringBuilder raw = new StringBuilder("POST " + CALLBACK_PATH + " HTTP/1.1" + CRLF
+ + "Host: localhost:" + frontPort + CRLF
+ + "Transfer-Encoding: chunked" + CRLF
+ + "Connection: close" + CRLF + CRLF);
+ String chunk = "x".repeat(chunkSize);
+ for (long i = 0; i < chunks; i++) {
+ raw.append(Integer.toHexString(chunkSize)).append(CRLF).append(chunk).append(CRLF);
+ }
+ raw.append('0').append(CRLF).append(CRLF);
+
+ String statusLine = sendRaw(raw.toString()).get(15, TimeUnit.SECONDS);
+
+ assertTrue(chunks * chunkSize > ceiling,
+ "The arranged chunked body must exceed the ceiling for this to pin the streaming counter");
+ assertTrue(statusLine.startsWith("HTTP/1.1 413"),
+ "The streaming cumulative counter refuses 413 with no declared length present: " + statusLine);
+ }
+
+ @Test
+ @DisplayName("retires the keep-alive connection after a declared-length 413")
+ void retiresConnectionAfterDeclaredLengthRejection() {
+ // A keep-alive request (no Connection: close from the client) declaring far more than the
+ // ceiling, whose body is never sent. The gateway refuses on the declared length alone and must
+ // NOT hand the connection back for reuse: the request body was never consumed, so pending bytes
+ // would desync the next request framed on it — or an attacker could pin connections by
+ // repeatedly tripping the 413, which is exactly the DoS this ceiling exists to bound.
+ String head = "POST " + CALLBACK_PATH + " HTTP/1.1" + CRLF
+ + "Host: localhost:" + frontPort + CRLF
+ + "Content-Length: " + (ceiling * 64) + CRLF + CRLF;
+
+ String response = assertDoesNotThrow(
+ () -> sendRawAwaitingClose(head).get(NO_BUFFERING_TIMEOUT_SECONDS, TimeUnit.SECONDS),
+ "the gateway must close the connection itself, without the client asking");
+
+ assertTrue(response.startsWith("HTTP/1.1 413"), response);
+ assertTrue(response.toLowerCase(Locale.ROOT).contains("connection: close"),
+ "the 413 must advertise that the connection is being retired: " + response);
+ }
+
+ @Test
+ @DisplayName("retires the keep-alive connection after a mid-stream 413")
+ void retiresConnectionAfterStreamedRejection() {
+ // The chunked counterpart: the ceiling trips mid-read, leaving the remaining chunks unread. The
+ // same retirement applies — this path left the most unread bytes of the two.
+ int chunkSize = 1024;
+ long chunks = ceiling / chunkSize + 2;
+ StringBuilder raw = new StringBuilder("POST " + CALLBACK_PATH + " HTTP/1.1" + CRLF
+ + "Host: localhost:" + frontPort + CRLF
+ + "Transfer-Encoding: chunked" + CRLF + CRLF);
+ String chunk = "x".repeat(chunkSize);
+ for (long i = 0; i < chunks; i++) {
+ raw.append(Integer.toHexString(chunkSize)).append(CRLF).append(chunk).append(CRLF);
+ }
+ raw.append('0').append(CRLF).append(CRLF);
+
+ String response = assertDoesNotThrow(() -> sendRawAwaitingClose(raw.toString()).get(15, TimeUnit.SECONDS),
+ "the gateway must close the connection itself after aborting the read");
+
+ assertTrue(response.startsWith("HTTP/1.1 413"), response);
+ assertTrue(response.toLowerCase(Locale.ROOT).contains("connection: close"),
+ "the 413 must advertise that the connection is being retired: " + response);
+ }
+
+ /**
+ * Writes a verbatim HTTP/1.1 message and completes with everything received once the SERVER closes
+ * the connection. Completion is itself the assertion that matters: the client never closes and never
+ * asks for {@code Connection: close}, so the future only settles when the gateway retires the
+ * connection on its own.
+ */
+ private CompletableFuture sendRawAwaitingClose(String rawRequest) {
+ CompletableFuture closed = new CompletableFuture<>();
+ netClient.connect(frontPort, "localhost")
+ .onFailure(closed::completeExceptionally)
+ .onSuccess(socket -> {
+ Buffer received = Buffer.buffer();
+ socket.handler(received::appendBuffer);
+ socket.endHandler(end -> closed.complete(received.toString()));
+ socket.exceptionHandler(cause -> {
+ if (!closed.isDone()) {
+ closed.completeExceptionally(cause);
+ }
+ });
+ // A mid-write reset is the expected outcome once the gateway has answered and closed.
+ socket.write(rawRequest).onFailure(cause -> {
+ if (!closed.isDone() && received.length() == 0) {
+ closed.completeExceptionally(cause);
+ }
+ });
+ });
+ return closed;
+ }
+
+ /**
+ * Builds a urlencoded form body of exactly {@code length} characters, shaped like the
+ * {@code state=…} a real form_post callback carries so the payload is representative rather than
+ * arbitrary filler.
+ */
+ private static String formBody(long length) {
+ String prefix = "state=" + Generators.letterStrings(8, 16).next() + "&code=";
+ return prefix + "a".repeat((int) length - prefix.length());
+ }
+
+ /** POSTs {@code body} to {@code path} over a real HTTP client and returns the response status. */
+ private int post(String path, String body) throws Exception {
+ RequestOptions options = new RequestOptions()
+ .setHost("localhost").setPort(frontPort).setMethod(HttpMethod.POST).setURI(path);
+ return client.request(options)
+ .compose(request -> request.send(Buffer.buffer(body)))
+ .map(response -> {
+ int status = response.statusCode();
+ response.body();
+ return status;
+ })
+ .toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS);
+ }
+
+ /**
+ * Writes a verbatim HTTP/1.1 message over a raw socket and completes with the response status line.
+ * The raw socket is what makes the two framing shapes above expressible at all — an HTTP client
+ * would never emit a declared length it does not honour, nor let the test choose chunk boundaries.
+ */
+ private CompletableFuture sendRaw(String rawRequest) {
+ CompletableFuture statusLine = new CompletableFuture<>();
+ netClient.connect(frontPort, "localhost")
+ .onFailure(statusLine::completeExceptionally)
+ .onSuccess(socket -> {
+ readStatusLine(socket, statusLine);
+ socket.write(rawRequest).onFailure(cause -> {
+ // A mid-write reset is expected once the gateway has already answered 413 and
+ // closed; only fail the wait when no status line ever arrived.
+ if (!statusLine.isDone()) {
+ statusLine.completeExceptionally(cause);
+ }
+ });
+ });
+ return statusLine;
+ }
+
+ private static void readStatusLine(NetSocket socket, CompletableFuture statusLine) {
+ Buffer received = Buffer.buffer();
+ socket.handler(chunk -> {
+ received.appendBuffer(chunk);
+ int eol = received.toString().indexOf(CRLF);
+ if (eol >= 0 && !statusLine.isDone()) {
+ statusLine.complete(received.toString().substring(0, eol));
+ socket.close();
+ }
+ });
+ socket.exceptionHandler(cause -> {
+ if (!statusLine.isDone()) {
+ statusLine.completeExceptionally(cause);
+ }
+ });
+ socket.endHandler(end -> {
+ if (!statusLine.isDone()) {
+ statusLine.completeExceptionally(new IllegalStateException("connection closed with no response"));
+ }
+ });
+ }
+
+ /**
+ * Minimal {@link Instance} test double resolving to a single supplied validator; no reserved-path
+ * test reaches a {@code require: bearer} route, so the remaining CDI accessors are unused and throw.
+ */
+ private static final class SingletonInstance implements Instance {
+
+ private final T value;
+
+ SingletonInstance(T value) {
+ this.value = value;
+ }
+
+ @Override
+ public T get() {
+ return value;
+ }
+
+ @Override
+ public Instance select(Annotation... qualifiers) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Instance select(Class subtype, Annotation... qualifiers) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Instance select(TypeLiteral subtype, Annotation... qualifiers) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isUnsatisfied() {
+ return false;
+ }
+
+ @Override
+ public boolean isAmbiguous() {
+ return false;
+ }
+
+ @Override
+ public void destroy(T instance) {
+ // no-op: the test double owns no lifecycle
+ }
+
+ @Override
+ public Handle getHandle() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Iterable extends Handle> handles() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Iterator iterator() {
+ return List.of(value).iterator();
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/events/EventTypeTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/events/EventTypeTest.java
index bfe068e0..285ef897 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/events/EventTypeTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/events/EventTypeTest.java
@@ -77,6 +77,7 @@ class FailureEvents {
"NO_ROUTE_MATCHED, 404, INPUT_VALIDATION",
"PASSTHROUGH_HOST_SMUGGLED, 404, INPUT_VALIDATION",
"METHOD_NOT_ALLOWED, 405, INPUT_VALIDATION",
+ "RESERVED_BODY_TOO_LARGE, 413, INPUT_VALIDATION",
"TOKEN_MISSING, 401, AUTHENTICATION",
"TOKEN_INVALID, 401, AUTHENTICATION",
"SCOPE_MISSING, 403, AUTHORIZATION",
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStageTest.java
index 95ec2f3a..f57d2436 100644
Binary files a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStageTest.java and b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStageTest.java differ
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java
index 7591662a..ba35e9e2 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java
@@ -24,6 +24,8 @@
import java.lang.annotation.Annotation;
import java.time.Instant;
+import java.util.Arrays;
+import java.util.Base64;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
@@ -82,6 +84,17 @@ void shouldAssembleWithoutDiscovery() {
assertDoesNotThrow(() -> producer(serverModeOidc()).bffRuntime());
}
+ @Test
+ @DisplayName("Should keep the back-channel path un-gated — an absent logout_token yields the 400 contract")
+ void shouldNotGateBackchannelInServerMode() {
+ BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.BACKCHANNEL_LOGOUT,
+ new BffRuntime.ReservedHttpRequest("", null, null, null, null, "other=value", "POST"),
+ Instant.parse("2026-07-25T10:00:00Z"));
+
+ assertEquals(400, response.status(),
+ "the store-backed binding supports IdP destruction, so the endpoint stays open");
+ }
+
@Test
@DisplayName("Should wire the user-info fold reachably — no session yields 401")
void shouldWireUserInfo() {
@@ -93,7 +106,98 @@ void shouldWireUserInfo() {
}
@Nested
- @DisplayName("Inert bearer-only / cookie-mode runtime")
+ @DisplayName("Active cookie-mode runtime")
+ class ActiveCookieMode {
+
+ private final BffRuntime runtime = producer(cookieModeOidc()).bffRuntime();
+
+ @Test
+ @DisplayName("Should activate the runtime for session.mode=cookie, exactly as for server mode")
+ void shouldActivateForCookieMode() {
+ assertTrue(runtime.isActive(), "cookie mode is a recognised BFF mode, not a bearer-only gateway");
+ assertNotNull(runtime.sessionStage());
+ assertNotNull(runtime.csrfDefence());
+ assertNotNull(runtime.stepUpCoordinator());
+ }
+
+ @Test
+ @DisplayName("Should wire the same reserved endpoints — no session yields 401 from the user-info fold")
+ void shouldWireTheSameReservedEndpoints() {
+ BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.USER_INFO,
+ new BffRuntime.ReservedHttpRequest("", null, null, null, null, null, "GET"),
+ Instant.parse("2026-07-25T10:00:00Z"));
+ assertEquals(401, response.status(), "both modes drive identical wiring above the session binding");
+ }
+
+ @Test
+ @DisplayName("Should still register the back-channel path, answering a deliberate uncacheable 404")
+ void shouldRegisterBackchannelPathGatedTo404() {
+ BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.BACKCHANNEL_LOGOUT,
+ new BffRuntime.ReservedHttpRequest("", null, null, null, null,
+ "logout_token=abc.def.ghi", "POST"),
+ Instant.parse("2026-07-25T10:00:00Z"));
+
+ assertEquals(404, response.status(),
+ "the reserved path stays registered and returns a deliberate 404, never falling through");
+ assertEquals("no-store", response.headers().get("Cache-Control"),
+ "the gated outcome is served uncacheable exactly as the 200/400 outcomes are");
+ }
+
+ @Test
+ @DisplayName("Should boot cookie mode without an encryption key, generating one on startup")
+ void shouldBootCookieModeWithoutKey() {
+ OidcConfig noKey = OidcConfig.builder()
+ .issuer(Optional.of(ISSUER))
+ .clientId(Optional.of("gateway-client"))
+ .clientSecret(Optional.of("secret"))
+ .scopes(List.of("openid"))
+ .redirectUri(Optional.of(REDIRECT_URI))
+ .session(Optional.of(OidcConfig.Session.builder().mode(Optional.of("cookie")).build()))
+ .build();
+
+ BffRuntime generated = producer(Optional.of(noKey)).bffRuntime();
+
+ assertTrue(generated.isActive(),
+ "omitting the key selects generate-on-startup, a supported production mode — not a boot failure");
+ assertNotNull(generated.sessionStage());
+ }
+
+ @Test
+ @DisplayName("Should refuse to boot a previous_key without an encryption_key")
+ void shouldRefusePreviousKeyWithoutEncryptionKey() {
+ OidcConfig previousOnly = OidcConfig.builder()
+ .issuer(Optional.of(ISSUER))
+ .clientId(Optional.of("gateway-client"))
+ .clientSecret(Optional.of("secret"))
+ .scopes(List.of("openid"))
+ .redirectUri(Optional.of(REDIRECT_URI))
+ .session(Optional.of(OidcConfig.Session.builder()
+ .mode(Optional.of("cookie"))
+ .previousKey(Optional.of(Base64.getEncoder().encodeToString(new byte[32])))
+ .build()))
+ .build();
+
+ BffRuntimeProducer producer = producer(Optional.of(previousOnly));
+
+ assertThrows(IllegalStateException.class, producer::bffRuntime,
+ "a decrypt-only rotation key with no current key to roll onto is refused, never ignored");
+ }
+
+ @Test
+ @DisplayName("Should refuse an encryption key that is not a base64 AES-256 value")
+ void shouldRefuseMalformedKey() {
+ BffRuntimeProducer nonBase64 = producer(cookieModeOidcWithKey("not-base64-~~~"));
+ BffRuntimeProducer aes128 =
+ producer(cookieModeOidcWithKey(Base64.getEncoder().encodeToString(new byte[16])));
+
+ assertThrows(IllegalStateException.class, nonBase64::bffRuntime);
+ assertThrows(IllegalStateException.class, aes128::bffRuntime,
+ "an AES-128 key is refused — the codec is specified as AES-256-GCM");
+ }
+ }
+
+ @Nested
+ @DisplayName("Inert bearer-only runtime")
class Inert {
@Test
@@ -104,24 +208,29 @@ void shouldBeInertWithoutOidc() {
}
@Test
- @DisplayName("Should stay inert for a cookie-mode session (server mode not selected)")
- void shouldBeInertForCookieMode() {
+ @DisplayName("Should stay inert for an unrecognised session mode")
+ void shouldBeInertForUnrecognisedMode() {
OidcConfig oidc = OidcConfig.builder()
.issuer(Optional.of(ISSUER))
.redirectUri(Optional.of(REDIRECT_URI))
- .session(Optional.of(OidcConfig.Session.builder().mode(Optional.of("cookie")).build()))
+ .session(Optional.of(OidcConfig.Session.builder().mode(Optional.of("stateless")).build()))
.build();
assertFalse(producer(Optional.of(oidc)).bffRuntime().isActive());
}
@Test
- @DisplayName("Should stay inert when server mode is set but no redirect_uri is configured")
+ @DisplayName("Should stay inert when a mode is set but no redirect_uri is configured")
void shouldBeInertWithoutRedirectUri() {
- OidcConfig oidc = OidcConfig.builder()
+ OidcConfig serverNoRedirect = OidcConfig.builder()
.issuer(Optional.of(ISSUER))
.session(Optional.of(OidcConfig.Session.builder().mode(Optional.of("server")).build()))
.build();
- assertFalse(producer(Optional.of(oidc)).bffRuntime().isActive());
+ OidcConfig cookieNoRedirect = OidcConfig.builder()
+ .issuer(Optional.of(ISSUER))
+ .session(Optional.of(OidcConfig.Session.builder().mode(Optional.of("cookie")).build()))
+ .build();
+ assertFalse(producer(Optional.of(serverNoRedirect)).bffRuntime().isActive());
+ assertFalse(producer(Optional.of(cookieNoRedirect)).bffRuntime().isActive());
}
@Test
@@ -199,6 +308,34 @@ private static Optional serverModeOidc() {
.build());
}
+ private static Optional cookieModeOidc() {
+ byte[] key = new byte[32];
+ Arrays.fill(key, (byte) 0x11);
+ return cookieModeOidcWithKey(Base64.getEncoder().encodeToString(key));
+ }
+
+ private static Optional cookieModeOidcWithKey(String encryptionKey) {
+ OidcConfig.Session session = OidcConfig.Session.builder()
+ .mode(Optional.of("cookie"))
+ .ttlSeconds(Optional.of(3600))
+ .encryptionKey(Optional.of(encryptionKey))
+ .build();
+ return Optional.of(OidcConfig.builder()
+ .issuer(Optional.of(ISSUER))
+ .clientId(Optional.of("gateway-client"))
+ .clientSecret(Optional.of("secret"))
+ .scopes(List.of("openid"))
+ .redirectUri(Optional.of(REDIRECT_URI))
+ .session(Optional.of(session))
+ .userInfo(Optional.of(OidcConfig.UserInfo.builder()
+ .path(Optional.of("/auth/userinfo"))
+ .allowedClaims(List.of("sub", "name"))
+ .defaultView(List.of("sub"))
+ .build()))
+ .login(Optional.of(OidcConfig.Login.builder().path(Optional.of("/auth/login")).build()))
+ .build());
+ }
+
/**
* Minimal {@link Instance} test double resolving to a single supplied bean; the producer resolves
* the validator only on the active path via {@link #get()}.
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/CookieModeBootTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/CookieModeBootTest.java
new file mode 100644
index 00000000..3586028c
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/CookieModeBootTest.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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 de.cuioss.sheriff.gateway.quarkus;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Instant;
+import java.util.Map;
+
+
+import de.cuioss.sheriff.gateway.bff.reserved.ReservedPathRegistry.ReservedEndpoint;
+import de.cuioss.sheriff.gateway.bff.runtime.BffRuntime;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.QuarkusTestProfile;
+import io.quarkus.test.junit.TestProfile;
+import jakarta.inject.Inject;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Boots the gateway in cookie mode with no {@code session.encryption_key} configured
+ * and asserts it comes up active (D6).
+ *
+ * Omitting the key selects the generate-on-startup key mode — a fully supported production mode whose
+ * key is fresh per boot, so sessions do not survive a restart — and the point of this test is that the
+ * gateway boots in it rather than refusing to start. The fixture under
+ * {@code config/cookieboot} carries one {@code require: session} route so the session floor is real.
+ *
+ * OIDC discovery is lazy (resolved on first engine use), so this starts no Docker container and
+ * reaches no network — it runs in the ordinary surefire phase, before the Docker integration suite.
+ */
+@QuarkusTest
+@TestProfile(CookieModeBootTest.CookieBootProfile.class)
+@DisplayName("Cookie-mode boot — generate-on-startup key and the UNSUPPORTED destruction capability")
+class CookieModeBootTest {
+
+ @Inject
+ BffRuntime runtime;
+
+ /** Points the gateway at the cookie-mode boot fixture instead of the default testboot config. */
+ public static final class CookieBootProfile implements QuarkusTestProfile {
+
+ @Override
+ public Map getConfigOverrides() {
+ return Map.of("sheriff.config.dir", "target/test-classes/config/cookieboot");
+ }
+ }
+
+ @Test
+ @DisplayName("Should boot an active cookie-mode runtime with no encryption_key configured")
+ void shouldBootActiveWithGeneratedKey() {
+ assertTrue(runtime.isActive(),
+ "cookie mode with no encryption_key selects generate-on-startup — a boot, not a failure");
+ assertNotNull(runtime.sessionStage(), "the require: session stage-4 runtime is wired in cookie mode");
+ assertNotNull(runtime.csrfDefence());
+ }
+
+ @Test
+ @DisplayName("Should report the UNSUPPORTED sid/sub capability — the back-channel path answers 404")
+ void shouldReportUnsupportedIdpDestruction() {
+ // The stateless binding holds no server-side index, so it reports IdpDestruction.UNSUPPORTED.
+ // The observable consequence of that capability at the runtime's own surface is the
+ // back-channel logout gate: the reserved path stays registered and answers a deliberate 404.
+ BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.BACKCHANNEL_LOGOUT,
+ new BffRuntime.ReservedHttpRequest("", null, null, null, null, "logout_token=abc.def.ghi", "POST"),
+ Instant.parse("2026-07-27T10:00:00Z"));
+
+ assertEquals(404, response.status(),
+ "a binding reporting UNSUPPORTED gates the back-channel endpoint off");
+ assertEquals("no-store", response.headers().get("Cache-Control"),
+ "the gated outcome is served uncacheable, exactly as the 200/400 outcomes are");
+ }
+}
diff --git a/api-sheriff/src/test/resources/config/cookieboot/endpoints/bff-cookie.yaml b/api-sheriff/src/test/resources/config/cookieboot/endpoints/bff-cookie.yaml
new file mode 100644
index 00000000..041f19e1
--- /dev/null
+++ b/api-sheriff/src/test/resources/config/cookieboot/endpoints/bff-cookie.yaml
@@ -0,0 +1,16 @@
+# yaml-language-server: $schema=../../../../../main/resources/schema/endpoint.schema.json
+# The single require: session endpoint of the cookie-mode boot configuration.
+#
+# It sits inside the 'bff-session' anchor namespace and inherits that anchor's
+# session floor, so the gateway boots with a real session route rather than an
+# oidc block no route uses. The upstream is the UPSTREAM topology alias; the
+# route is never driven by CookieModeBootTest, which asserts the assembled
+# BffRuntime rather than a proxied request.
+endpoint:
+ id: bff-cookie
+ base_url: UPSTREAM
+ anchor: bff-session
+ routes:
+ - id: bff-cookie-all
+ match:
+ path_prefix: /bff-session
diff --git a/api-sheriff/src/test/resources/config/cookieboot/gateway.yaml b/api-sheriff/src/test/resources/config/cookieboot/gateway.yaml
new file mode 100644
index 00000000..01960ab1
--- /dev/null
+++ b/api-sheriff/src/test/resources/config/cookieboot/gateway.yaml
@@ -0,0 +1,68 @@
+# yaml-language-server: $schema=../../../../main/resources/schema/gateway.schema.json
+# Cookie-mode boot configuration for CookieModeBootTest (D6).
+#
+# Deliberately declares NO session.encryption_key: that selects the
+# generate-on-startup key mode, which is a fully supported production mode (the
+# key is fresh per boot, so sessions do not survive a restart). This fixture is
+# what proves the gateway BOOTS in that mode rather than refusing to start.
+#
+# The sibling endpoints/bff-cookie.yaml + topology.properties add the single
+# require: session route under the 'bff-session' anchor, so the session floor is
+# real; ConfigValidator rejects a session floor that has no global oidc block.
+# Nothing here is ever dialled: OIDC discovery is lazy (first engine use), so the
+# boot needs no live IdP, no container, and no network.
+version: 1
+metadata:
+ config_version: "cookie-boot"
+# The BFF runtime resolves the gateway's shared @GatewayValidator TokenValidator at
+# boot, and that validator is built from THIS block — so an active BFF mode needs it
+# declared or startup aborts. The offline static JWKS file needs no IdP and no egress;
+# the relative path mirrors the test certificates application.properties already
+# reaches for. Nothing in this test validates a token; the issuer only has to assemble.
+token_validation:
+ issuers:
+ - name: cookie-boot-static
+ issuer: https://idp.example.com/realms/cookie
+ jwks:
+ source: file
+ file: ../integration-tests/src/main/docker/certificates/test-jwks.json
+anchors:
+ # type: bff forces access: authenticated (ADR-0013). The session floor lives on
+ # the anchor so the endpoint below inherits it.
+ bff-session:
+ path_prefix: /bff-session
+ type: bff
+ access: authenticated
+ auth:
+ require: session
+oidc:
+ issuer: https://idp.example.com/realms/cookie
+ client_id: cookie-client
+ # A secret-classified field must be a bare ${VAR} reference (the D4 secrets rule
+ # refuses a literal), and the confidential-client engine refuses a blank secret, so
+ # the boot needs one. The variable is supplied by the surefire environment in
+ # api-sheriff/pom.xml and authenticates to nothing — this fixture reaches no IdP.
+ client_secret: ${COOKIE_BOOT_CLIENT_SECRET}
+ scopes: ["openid"]
+ # The host of redirect_uri is the oidc host every reserved path binds to; its
+ # origin is the gateway origin used for same-origin return-URL validation and
+ # as the default CSRF trusted origin.
+ redirect_uri: https://localhost:8443/auth/callback
+ logout:
+ path: /auth/logout
+ post_logout_redirect_uri: https://localhost:8443/auth/logout/return
+ final_redirect: /
+ # Registered in cookie mode too — the endpoint's capability gate answers a
+ # deliberate 404 there, so the reserved path never falls through to the
+ # proxy route table.
+ backchannel_path: /auth/backchannel
+ session:
+ mode: cookie
+ ttl_seconds: 3600
+ # NO encryption_key on purpose — this is the generate-on-startup path.
+ user_info:
+ path: /auth/userinfo
+ allowed_claims: ["sub"]
+ default_view: ["sub"]
+ login:
+ path: /auth/login
diff --git a/api-sheriff/src/test/resources/config/cookieboot/topology.properties b/api-sheriff/src/test/resources/config/cookieboot/topology.properties
new file mode 100644
index 00000000..04c553db
--- /dev/null
+++ b/api-sheriff/src/test/resources/config/cookieboot/topology.properties
@@ -0,0 +1,7 @@
+# Topology aliases for the cookie-mode boot configuration (CookieModeBootTest).
+#
+# UPSTREAM binds the single require: session route's alias to a concrete URL so
+# the RouteTable assembles. Nothing ever connects to it: the test asserts the
+# assembled BffRuntime, it drives no proxied request, so no listener is needed
+# on this port.
+UPSTREAM=http://localhost:19292
diff --git a/doc/LogMessages.adoc b/doc/LogMessages.adoc
index 07ce9762..480d2cb9 100644
--- a/doc/LogMessages.adoc
+++ b/doc/LogMessages.adoc
@@ -45,6 +45,9 @@ diagnostics use the logger directly and are not catalogued.
|ApiSheriff-12 |BFF |Mediated tokens refreshed for a require:session route (single-flight) |Logged when the transparent single-flight token refresh rotates a session's mediated tokens through the engine; token material is never logged
|ApiSheriff-13 |BFF |Back-channel logout accepted — %s session(s) destroyed |Logged when a signature- and claim-valid back-channel `logout_token` destroys the affected sessions; `%s` is the bounded destroyed-session count — never the subject or `sid`
|ApiSheriff-14 |BFF |RP-initiated logout completed for a require:session route |Logged when the RP-initiated logout return leg verifies its single-use logout-state cookie and redirects the browser to the configured final_redirect
+|ApiSheriff-15 |BFF |Cookie-mode session sealed (%s bytes) |Logged when a `mode: cookie` session is sealed into its `Set-Cookie`; `%s` is the bounded sealed-value length — never the sealed value, the sealing key, or any token material
+|ApiSheriff-16 |BFF |Cookie-mode sealing key generated at startup (%s) — sessions do not survive a restart |Logged once at startup when no `session.encryption_key` is configured and a cookie-mode sealing key is generated in memory; `%s` is the non-sensitive affected scope — key material is never logged
+|ApiSheriff-17 |BFF |Cookie-mode previous-key rotation in progress (%s) — affected sessions re-seal under the current key on their next write |Logged once per process, on the first request carrying a cookie still sealed under the decrypt-only `previous_key`; each such session is re-sealed under the current key on its next write. The per-request re-detections that follow are DEBUG, since an unrotated session is re-detected on every one of its requests. `%s` is a bounded disposition — never key or token material
|===
== WARN Level (100-199)
@@ -60,9 +63,12 @@ diagnostics use the logger directly and are not catalogued.
|ApiSheriff-105 |EDGE |WebSocket upgrade rejected on route '%s': Origin '%s' is not in the allowed_origins allowlist |Logged when a bearer WebSocket handshake presents an Origin that does not exactly match the route's allowed_origins allowlist; the upgrade is refused before any upstream dial (fail-closed CSWSH defence, GW-09 / ADR-0015)
|ApiSheriff-107 |EDGE |TLS ClientHello failed closed to terminated path: %s |Logged when an accept-time TLS ClientHello is failed closed to the terminated-strict path (GW-06) because it carried no usable SNI, was malformed, or exceeded the reassembly bound; records the disposition only — never the raw ClientHello bytes
|ApiSheriff-108 |EDGE |Host-vs-SNI smuggle rejected before route selection: %s |Logged when a terminated request's Host header names a reserved passthrough SNI hostname and is rejected 404 before route selection; records a fixed disposition only — never the raw Host value
+|ApiSheriff-109 |EDGE |Reserved-path request body exceeded the %s byte ceiling (%s) — rejected 413 |Logged when a gateway-terminated reserved POST path (the OIDC `response_mode=form_post` callback or the back-channel logout receiver) declares or streams a body beyond the edge's reserved-body byte ceiling; these paths are read pre-authentication, before the per-route body cap can apply, so the ceiling is enforced at the read itself. The first `%s` is the ceiling in bytes and the second a fixed disposition (`declared-content-length` / `streamed-body`) — the offending body is never logged
|ApiSheriff-110 |BFF |CSRF defence rejected an unsafe-method session request: %s |Logged when the fixed CSRF defence rejects an unsafe-method `require: session` request; `%s` is the non-sensitive rejection disposition (`untrusted-origin` / `no-origin-proof`) — the raw offending `Origin` value is never logged
|ApiSheriff-111 |BFF |Token refresh failed for a require:session route (%s) — session destroyed |Logged when a transparent token refresh fails (IdP rejection or engine-detected refresh-token reuse) and the session is destroyed; `%s` is a bounded, non-sensitive reason — never the presented refresh token or session id
|ApiSheriff-112 |BFF |Back-channel logout token rejected: %s |Logged when a back-channel `logout_token` fails signature or claim validation; `%s` is the non-sensitive rejection disposition (`signature` / `claims`) — the raw logout token is never logged
+|ApiSheriff-113 |BFF |Sealed session cookie rejected: %s |Logged when a `mode: cookie` session cookie fails to unseal and is treated as "no session"; `%s` is the non-sensitive disposition (`malformed` / `unknown-version` / `unknown-key-id` / `authentication-tag` / `payload-format`) — the offending cookie value and all key material are never logged
+|ApiSheriff-114 |BFF |Sealed session cookie exceeds the size budget (%s bytes) — seal refused |Logged when a sealed session cookie would exceed the browser-safe ~4 KB budget; the seal is refused rather than emitting a value the browser would silently drop, and `%s` is the bounded length only
|===
== ERROR Level (200-299)
diff --git a/doc/configuration.adoc b/doc/configuration.adoc
index 9900464e..a6c48e85 100644
--- a/doc/configuration.adoc
+++ b/doc/configuration.adoc
@@ -262,8 +262,13 @@ oidc: # only used by the BFF variants (2 and 3)
max_sessions: 10000 # server mode only: upper bound on concurrently stored
# sessions -- a DoS guard on the in-memory store
cookie_name: __Host-sheriff
- encryption_key: ${SHERIFF_SESSION_KEY} # cookie mode only (AES-256-GCM)
- previous_key: ${SHERIFF_SESSION_KEY_PREVIOUS} # optional; decrypt-only, for key rotation
+ encryption_key: ${SHERIFF_SESSION_KEY} # cookie mode only (AES-256-GCM); OPTIONAL -- omit to
+ # generate a key on startup (sessions die on restart)
+ previous_key: ${SHERIFF_SESSION_KEY_PREVIOUS} # optional; decrypt-only, for key rotation;
+ # requires encryption_key
+ max_cookie_size: 4096 # cookie mode only: sealed cookie-value size budget in bytes.
+ # 40..8192, default 4096. Drives BOTH the seal budget and the
+ # gateway's pre-route Cookie header-value cap
ttl_seconds: 3600 # absolute session lifetime from login
csrf:
trusted_origins: [https://app.example.com] # app origins allowed on unsafe methods;
@@ -1190,9 +1195,52 @@ ignored when no route's effective auth is `require: session`.
and recommended.
| `session.encryption_key` / `session.previous_key`
-| `cookie` mode only. AES-256-GCM key as an `${ENV_VAR}` reference. `previous_key` is optional
- and *decrypt-only*: during key rotation, cookies sealed with the old key are still accepted
- (and re-sealed with the new key on next write) for a grace window.
+| `cookie` mode only. AES-256-GCM key as an `${ENV_VAR}` reference. Both key-material modes are
+ fully supported production options:
+
+ * *Passed key* -- `encryption_key` is set. Sessions survive a restart and the key can be shared
+ across replicas. This is the mode a multi-replica deployment must use.
+ * *Generate on startup* -- `encryption_key` is omitted. A fresh AES-256 key is generated at boot.
+ Two consequences the operator must accept: *all sessions are dropped on every restart*, and
+ *the key cannot be shared across replicas*, so a load-balanced deployment would reject every
+ cookie sealed by another instance.
+
+ `previous_key` is optional and *decrypt-only*: during key rotation, cookies sealed with the old
+ key are still accepted (and re-sealed with the current key on next write) for a grace window; it
+ is never used to seal, so a rollover is one-way. It composes with the passed-key mode only, and
+ supplying it without an `encryption_key` is rejected at boot.
+
+| `session.max_cookie_size`
+| *Cookie mode only.* The sealed cookie-value size budget in bytes. Optional; range `40`--`8192`,
+ default `4096` (browsers are only required to accept 4096 bytes per cookie, so the default sits
+ there rather than at the gateway's transport ceiling). A value outside the range fails the boot
+ with a message naming this key: below the floor no structurally valid sealed value could ever be
+ emitted, and above the ceiling the derived header cap would exceed what the inbound
+ request-header block (16 KiB, shared with every other header) will carry.
+
+ This is *one declared number driving two limits*. It is both the seal-time budget -- a sealed
+ value over it fails the seal with a logged warning, never a silent truncation -- *and* the source
+ of the gateway's pre-route `Cookie` header-value cap (the budget plus a fixed 512-byte headroom
+ for the cookie name and any co-resident cookies). Encoding the two limits independently is what
+ previously made cookie mode unusable: the pre-route filter rejected every request carrying a live
+ sealed cookie with `400` before any BFF logic ran.
+
+ The raised cap is scoped twice, and is *not* a general widening of inbound header validation:
+
+ * *By mode* -- only a gateway that resolves an active cookie-mode BFF raises it. A bearer-only or
+ `session.mode: server` gateway keeps the 2048-character default on every header, unchanged.
+ * *By header* -- within a cookie-mode gateway only `Cookie` and `Set-Cookie` header *values* use
+ the raised cap. Every other header keeps the default, and every non-length validator
+ (null-byte, control-character, injection-pattern) still applies to the cookie value.
+
+ [IMPORTANT]
+ ====
+ *Enforcement is gateway-wide, not per-anchor.* The key sits on the global `oidc.session` block,
+ but the cap it drives is applied by the pre-route stage that runs *before* route selection --
+ at which point no anchor is resolved. Do not read the key's placement as per-anchor scoping;
+ a cookie-mode gateway raises the `Cookie` cap for every route it serves. The per-anchor
+ `security_filter.max_header_value_length` knob is a different control, applied post-route.
+ ====
| `session.ttl_seconds`
| *Absolute* session lifetime, counted from a login timestamp stored inside the session record
@@ -1564,9 +1612,12 @@ topology leak RFC 7239 §8.2/§8.3 warn about more simply than the obfuscation t
restriction, which existed only for the `cui-http` `HttpHandler` builder).
* Secrets in `oidc` (`client_secret`, the session keys) must be `${ENV_VAR}` references, not
literals.
-* `session.mode: cookie` requires `session.encryption_key`; `previous_key` is optional and
- decrypt-only. `session.mode: server` requires an explicit `session.store` (only `memory` is
- valid) and uses neither encryption key.
+* `session.mode: cookie` does *not* require `session.encryption_key` -- omitting it selects the
+ generate-on-startup key mode (sessions are dropped on restart and the key is not shareable
+ across replicas). `previous_key` is optional and decrypt-only, and *requires* an
+ `encryption_key`: `previous_key` without `encryption_key` is rejected, because a decrypt-only
+ rotation key with no current key to roll onto is nonsensical. `session.mode: server` requires an
+ explicit `session.store` (only `memory` is valid) and uses neither encryption key.
* `security_headers.cors` with a wildcard origin and `allow_credentials: true` is rejected.
* A `protocol: websocket` route whose *effective* auth is `require: bearer` must declare a
*non-empty* link:#_allowed_origins[`allowed_origins`] list; an absent or empty allowlist *fails
diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml
index 6c7c923c..54f09433 100644
--- a/integration-tests/docker-compose.yml
+++ b/integration-tests/docker-compose.yml
@@ -361,6 +361,174 @@ services:
- api-sheriff
restart: unless-stopped
+ # --- Variant 3: the dedicated cookie-mode (stateless sealed-cookie session) gateway instance -----
+ # The session mode is a property of the whole gateway, so it cannot coexist on the primary instance
+ # (10443) whose server-mode binding serves the landed Bff*IT suite. This instance reuses the SAME
+ # native image, overlays a cookie-mode gateway.yaml (session.mode: cookie, no store, no passthrough
+ # → Quarkus terminates directly on the public port), and is published on 10445.
+ #
+ # SESSION_ENCRYPTION_KEY supplies the AES-256 sealing key the overlaid gateway.yaml references as a
+ # bare ${SESSION_ENCRYPTION_KEY}. Passing the key explicitly — rather than letting the gateway
+ # generate one per boot — is what makes the sealed cookie portable across instances that share
+ # nothing but this value, which is the property BffCookieStatelessnessIT proves.
+ api-sheriff-cookie:
+ image: "api-sheriff:distroless"
+ # Same JVM-default-truststore trust as the primary instance: this service overlays the same oidc
+ # block (lockstep gateway.yaml) so its confidential-client engine needs the private-CA trust for
+ # the OIDC discovery/token calls the cookie login flow makes. See the primary api-sheriff
+ # service's command comment for the mechanism (token-sheriff-client#597).
+ command:
+ - -Djavax.net.ssl.trustStore=/app/certificates/localhost-truststore.p12
+ - -Djavax.net.ssl.trustStorePassword=localhost-trust
+ - -Djavax.net.ssl.trustStoreType=PKCS12
+ ports:
+ - "10445:8443" # External test port for the Bff*Cookie*IT suite (test.cookie.port)
+ - "19002:9000" # Management interface (health/metrics, HTTPS — single port, see below)
+ environment:
+ - QUARKUS_PROFILE=it
+ - LOG_FILE_PATH=/logs/quarkus-cookie.log
+ # Terminate directly on the public port (no passthrough front on this instance).
+ - QUARKUS_HTTP_SSL_CERTIFICATE_FILES=/app/certificates/localhost.crt
+ - QUARKUS_HTTP_SSL_CERTIFICATE_KEY_FILES=/app/certificates/localhost.key
+ # Management-interface certificate, kept in lockstep with the primary api-sheriff service —
+ # see that service's comment for the single-port mechanism. Without it this instance's
+ # management port 19002 stays plain HTTP while the primary's is HTTPS, and the host-side
+ # readiness wait in start-integration-container.sh (https + -k for all) would hang.
+ - QUARKUS_MANAGEMENT_SSL_CERTIFICATE_FILES=/app/certificates/localhost.crt
+ - QUARKUS_MANAGEMENT_SSL_CERTIFICATE_KEY_FILES=/app/certificates/localhost.key
+ - QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PATH=/app/certificates/localhost-truststore.p12
+ - QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PASSWORD=localhost-trust
+ - SHERIFF_CONFIG_DIR=/app/sheriff-config
+ # Container-side value the bare ${OIDC_CLIENT_SECRET} reference in the overlaid cookie
+ # gateway.yaml (oidc.client_secret) resolves to via EnvSecretResolver — kept in lockstep
+ # with the primary api-sheriff service.
+ - OIDC_CLIENT_SECRET=integration-secret
+ # The AES-256-GCM sealing key (base64 of 32 bytes) the overlaid cookie gateway.yaml references
+ # as ${SESSION_ENCRYPTION_KEY}. A fixed test value: it seals only throwaway integration-realm
+ # sessions and is never a production key.
+ - SESSION_ENCRYPTION_KEY=YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=
+ depends_on:
+ keycloak:
+ condition: service_started
+ go-httpbin:
+ condition: service_started
+ asset-origin:
+ condition: service_started
+ grpc-echo:
+ condition: service_healthy
+ volumes:
+ - ./src/main/docker/certificates:/app/certificates:ro
+ # Shared config dir, then overlay ONLY gateway.yaml with the cookie-mode variant.
+ - ./src/main/docker/sheriff-config:/app/sheriff-config:ro
+ - ./src/main/docker/sheriff-config-cookie/gateway.yaml:/app/sheriff-config/gateway.yaml:ro
+ - ./src/main/docker/assets:/app/assets:ro
+ - ${LOG_TARGET_DIR:-./target/quarkus-logs}:/logs:rw
+ security_opt:
+ - no-new-privileges:true
+ cap_drop:
+ - ALL
+ read_only: true
+ tmpfs:
+ - /tmp:rw,noexec,nosuid,size=100m
+ deploy:
+ resources:
+ limits:
+ memory: 512M
+ cpus: '4.0'
+ reservations:
+ memory: 256M
+ cpus: '1.0'
+ # No in-container healthcheck: the distroless image ships neither a shell nor curl. Readiness is
+ # gated host-side by start-integration-container.sh probing the published management port 19002,
+ # exactly as the primary instance is gated on 19000 and the mTLS instance on 19001.
+ networks:
+ - api-sheriff
+ restart: unless-stopped
+
+ # --- Variant 3, second instance: the peer that shares NOTHING but the sealing key ----------------
+ # A literal clone of api-sheriff-cookie above — same image, same overlaid cookie gateway.yaml, and
+ # the SAME SESSION_ENCRYPTION_KEY value — published on 10446 with its own management port 19003.
+ #
+ # It exists solely so BffCookieStatelessnessIT can land the proof as an actual two-instance fact
+ # rather than an inference: a session sealed by the instance on 10445 is replayed, with no sticky
+ # routing and no shared store, against this separate process on 10446 and is served. The two
+ # containers share the compose network and the mounted read-only config, and nothing else — no
+ # session store, no volume, no state — so the only thing that can make the replay succeed is the
+ # shared key unsealing a self-contained cookie.
+ #
+ # The overlay is mounted verbatim, so this instance's redirect_uri and CSRF trusted origin still
+ # name 10445. That is deliberate and correct: no login is ever driven against this instance. It
+ # only serves an already-sealed cookie on a steady-state GET, which is precisely the portability
+ # property under test. Giving it its own overlay would introduce a second cookie-mode descriptor
+ # to keep in lockstep for no gain.
+ api-sheriff-cookie-2:
+ image: "api-sheriff:distroless"
+ # Same JVM-default-truststore trust as every other instance — see the primary api-sheriff
+ # service's command comment for the mechanism (token-sheriff-client#597).
+ command:
+ - -Djavax.net.ssl.trustStore=/app/certificates/localhost-truststore.p12
+ - -Djavax.net.ssl.trustStorePassword=localhost-trust
+ - -Djavax.net.ssl.trustStoreType=PKCS12
+ ports:
+ - "10446:8443" # External test port for BffCookieStatelessnessIT's peer instance
+ - "19003:9000" # Management interface (health/metrics, HTTPS — single port, see below)
+ environment:
+ - QUARKUS_PROFILE=it
+ - LOG_FILE_PATH=/logs/quarkus-cookie-2.log
+ # Terminate directly on the public port (no passthrough front on this instance).
+ - QUARKUS_HTTP_SSL_CERTIFICATE_FILES=/app/certificates/localhost.crt
+ - QUARKUS_HTTP_SSL_CERTIFICATE_KEY_FILES=/app/certificates/localhost.key
+ # Management-interface certificate, kept in lockstep with every other instance — see the
+ # primary api-sheriff service's comment for the single-port mechanism. Without it this
+ # instance's management port 19003 stays plain HTTP while the others are HTTPS, and the
+ # host-side readiness wait in start-integration-container.sh (https + -k for all) would hang.
+ - QUARKUS_MANAGEMENT_SSL_CERTIFICATE_FILES=/app/certificates/localhost.crt
+ - QUARKUS_MANAGEMENT_SSL_CERTIFICATE_KEY_FILES=/app/certificates/localhost.key
+ - QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PATH=/app/certificates/localhost-truststore.p12
+ - QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PASSWORD=localhost-trust
+ - SHERIFF_CONFIG_DIR=/app/sheriff-config
+ - OIDC_CLIENT_SECRET=integration-secret
+ # Byte-identical to api-sheriff-cookie's key. This equality IS the experiment: it is the only
+ # state the two instances share, so BffCookieActivationWiringTest asserts the two values match
+ # rather than merely that both are present.
+ - SESSION_ENCRYPTION_KEY=YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=
+ depends_on:
+ keycloak:
+ condition: service_started
+ go-httpbin:
+ condition: service_started
+ asset-origin:
+ condition: service_started
+ grpc-echo:
+ condition: service_healthy
+ volumes:
+ - ./src/main/docker/certificates:/app/certificates:ro
+ # Shared config dir, then the SAME cookie-mode gateway.yaml overlay as api-sheriff-cookie.
+ - ./src/main/docker/sheriff-config:/app/sheriff-config:ro
+ - ./src/main/docker/sheriff-config-cookie/gateway.yaml:/app/sheriff-config/gateway.yaml:ro
+ - ./src/main/docker/assets:/app/assets:ro
+ - ${LOG_TARGET_DIR:-./target/quarkus-logs}:/logs:rw
+ security_opt:
+ - no-new-privileges:true
+ cap_drop:
+ - ALL
+ read_only: true
+ tmpfs:
+ - /tmp:rw,noexec,nosuid,size=100m
+ deploy:
+ resources:
+ limits:
+ memory: 512M
+ cpus: '4.0'
+ reservations:
+ memory: 256M
+ cpus: '1.0'
+ # No in-container healthcheck: the distroless image ships neither a shell nor curl. Readiness is
+ # gated host-side by start-integration-container.sh probing the published management port 19003.
+ networks:
+ - api-sheriff
+ restart: unless-stopped
+
prometheus:
image: prom/prometheus:v3.6.0
ports:
diff --git a/integration-tests/scripts/dump-keycloak-logs.sh b/integration-tests/scripts/dump-keycloak-logs.sh
index 44f98310..ebe566bd 100755
--- a/integration-tests/scripts/dump-keycloak-logs.sh
+++ b/integration-tests/scripts/dump-keycloak-logs.sh
@@ -41,9 +41,18 @@ echo "📝 Output file: $KEYCLOAK_LOG_FILE_PATH"
# Best-effort dump of the api-sheriff app containers into failsafe-reports (uploaded as a CI
# artifact) so a TEST failure — not just a startup failure — is diagnosable from the app's stdout.
# Never fail the build on a dump problem.
+#
+# The list MUST name every gateway instance docker-compose.yml starts, not just the primary and the
+# mTLS peer: the Bff*Cookie*IT suites drive the two dedicated cookie-mode instances, and a CI-only
+# failure on those instances previously produced NO uploaded log at all, forcing a local repro to
+# see the gateway's own rejection reason. Keep this list in lockstep with the api-sheriff* services
+# in integration-tests/docker-compose.yml.
FAILSAFE_DIR="${TARGET_ABS_PATH}/failsafe-reports"
mkdir -p "$FAILSAFE_DIR" || true
-for app in integration-tests-api-sheriff-1 integration-tests-api-sheriff-mtls-1; do
+for app in integration-tests-api-sheriff-1 \
+ integration-tests-api-sheriff-mtls-1 \
+ integration-tests-api-sheriff-cookie-1 \
+ integration-tests-api-sheriff-cookie-2-1; do
if docker ps -a --format "{{.Names}}" | grep -q "^${app}$"; then
echo "📥 Dumping app logs: ${app} -> ${FAILSAFE_DIR}/${app}.log"
docker logs "$app" > "${FAILSAFE_DIR}/${app}.log" 2>&1 || true
diff --git a/integration-tests/scripts/start-integration-container.sh b/integration-tests/scripts/start-integration-container.sh
index 02ddcbe1..f47020e9 100755
--- a/integration-tests/scripts/start-integration-container.sh
+++ b/integration-tests/scripts/start-integration-container.sh
@@ -205,6 +205,36 @@ for i in {1..30}; do
sleep 1
done
+# Wait for the two dedicated cookie-mode gateway instances (api-sheriff-cookie on 10445 /
+# management 19002, and its key-sharing peer api-sheriff-cookie-2 on 10446 / management 19003).
+# Both reuse the same native image and reach readiness offline (static-file JWKS). The Bff*Cookie*IT
+# suites drive their TLS ports directly — and BffCookieStatelessnessIT drives BOTH in one test — so
+# an unwaited instance is a race that surfaces as a connection refusal in the IT phase, not as a
+# start-up failure here. Mirrors the mTLS block above exactly.
+for cookie_instance in "api-sheriff-cookie:19002" "api-sheriff-cookie-2:19003"; do
+ COOKIE_SERVICE="${cookie_instance%%:*}"
+ COOKIE_MGMT_PORT="${cookie_instance##*:}"
+ echo "⏳ Waiting for the ${COOKIE_SERVICE} gateway instance to be ready..."
+ for i in {1..30}; do
+ if curl -skf "https://localhost:${COOKIE_MGMT_PORT}/q/health/live" > /dev/null 2>&1; then
+ echo "✅ ${COOKIE_SERVICE} gateway instance is ready!"
+ break
+ fi
+ if [ $i -eq 30 ]; then
+ echo "❌ ${COOKIE_SERVICE} gateway instance failed to start within 30 seconds"
+ DIAG_DIR="target/failsafe-reports"
+ mkdir -p "$DIAG_DIR"
+ echo "----- $COMPOSE_BASE logs ${COOKIE_SERVICE} -----"
+ $COMPOSE_BASE logs --no-color "${COOKIE_SERVICE}" 2>&1 | tee "$DIAG_DIR/${COOKIE_SERVICE}-app.log"
+ curl -sk "https://localhost:${COOKIE_MGMT_PORT}/q/health" 2>&1 | tee "$DIAG_DIR/${COOKIE_SERVICE}-health.json"
+ echo ""
+ exit 1
+ fi
+ echo "⏳ Waiting for ${COOKIE_SERVICE} gateway... (attempt $i/30)"
+ sleep 1
+ done
+done
+
# Extract native startup time from logs
NATIVE_STARTUP=$($COMPOSE_BASE logs api-sheriff 2>/dev/null | grep "started in" | sed -n 's/.*started in \([0-9.]*\)s.*/\1/p' | tail -1)
if [ ! -z "$NATIVE_STARTUP" ]; then
diff --git a/integration-tests/src/main/docker/sheriff-config-cookie/gateway.yaml b/integration-tests/src/main/docker/sheriff-config-cookie/gateway.yaml
new file mode 100644
index 00000000..4ea8b042
--- /dev/null
+++ b/integration-tests/src/main/docker/sheriff-config-cookie/gateway.yaml
@@ -0,0 +1,156 @@
+# yaml-language-server: $schema=../../../../../api-sheriff/src/main/resources/schema/gateway.schema.json
+# Global gateway document for the DEDICATED cookie-mode integration-test instance
+# (api-sheriff-cookie in docker-compose.yml). It is overlay-mounted on top of the shared
+# sheriff-config directory so it replaces ONLY gateway.yaml — the endpoints/ subdirectory and
+# topology.properties are shared with the primary instance, keeping the documents in lockstep
+# except for their oidc.session block. This mirrors the sheriff-config-mtls precedent exactly.
+#
+# This instance exists because the session mode is a property of the whole gateway: flipping the
+# primary instance to session.mode=cookie would change the binding under every landed server-mode
+# BFF IT. So Variant 3 (the stateless sealed-cookie session) is proven on this separate instance,
+# published on host port 10445, while the primary instance (10443) stays server-mode for the rest
+# of the suite — and BffCookieBackchannelDisabledIT asserts both contracts side by side.
+#
+# The anchors and token_validation block are a faithful copy of the primary gateway.yaml so the
+# shared endpoints/ resolve identically and the offline it-static JWKS issuer reaches readiness
+# without Keycloak. The ONLY intentional difference is the oidc.session block: mode cookie, an
+# encryption_key ${ENV_VAR} reference, and NO store (a stateless binding holds no server-side index).
+version: 1
+metadata:
+ config_version: "integration-test-cookie"
+allowed_methods: ["GET", "POST", "PUT", "DELETE"]
+tls:
+ # No passthrough_sni on this instance: the SNI front never starts, so Quarkus terminates
+ # directly on the public port — the same simplification the mTLS instance makes.
+ alpn: ["h2", "http/1.1"]
+anchors:
+ api:
+ path_prefix: /proxy
+ type: proxy
+ access: public
+ bff:
+ path_prefix: /bff
+ type: proxy
+ access: public
+ # BFF session namespace exercised by the Bff*Cookie*IT suite. A require:session route lives here
+ # so an authenticated browser session mediates a bearer to the go-httpbin echo upstream. type: bff
+ # forces access: authenticated (ADR-0013), and the session floor is backed by the global oidc
+ # block below (ConfigValidator rejects a session floor with no oidc block). The reserved OIDC
+ # endpoints are carved out of the route table on the oidc host and live under /auth, disjoint
+ # from this /bff-session proxy prefix.
+ bff-session:
+ path_prefix: /bff-session
+ type: bff
+ access: authenticated
+ auth:
+ require: session
+ secure:
+ path_prefix: /secure
+ type: proxy
+ access: authenticated
+ auth:
+ require: bearer
+ graphql:
+ path_prefix: /graphql
+ type: proxy
+ access: public
+ allowed_methods: ["POST"]
+ upload:
+ path_prefix: /upload
+ type: proxy
+ access: public
+ allowed_methods: ["POST"]
+ security_filter:
+ max_body_bytes: 67108864
+ ws:
+ path_prefix: /ws
+ type: proxy
+ access: public
+ grpc:
+ path_prefix: /grpc
+ type: proxy
+ access: public
+ assets-public:
+ path_prefix: /assets
+ type: asset
+ access: public
+ allowed_methods: ["GET", "HEAD"]
+ assets-secure:
+ path_prefix: /secure-assets
+ type: asset
+ access: authenticated
+ allowed_methods: ["GET", "HEAD"]
+ auth:
+ require: bearer
+token_validation:
+ issuers:
+ - name: it-static
+ issuer: https://api-sheriff.test/it
+ jwks:
+ source: file
+ file: /app/certificates/test-jwks.json
+ - name: benchmark-keycloak
+ issuer: https://keycloak:8443/realms/benchmark
+ jwks:
+ source: http
+ url: https://keycloak:8443/realms/benchmark/protocol/openid-connect/certs
+ allowed_egress_hosts: ["keycloak"]
+ tls_profile: benchmark-idp
+ # The cookie-mode BFF (oidc block below) validates the id/access tokens the 'integration' realm
+ # mints for the browser session. The gateway's shared @GatewayValidator TokenValidator is built
+ # from THIS token_validation block, so the integration issuer MUST be declared here or every
+ # mediated session token is rejected. Same container-internal iss the integration realm pins via
+ # frontendUrl https://keycloak:8443, reached over the shared api-sheriff network; the same narrow
+ # SSRF egress widening and self-signed trust profile the benchmark issuer uses.
+ - name: integration-keycloak
+ issuer: https://keycloak:8443/realms/integration
+ jwks:
+ source: http
+ url: https://keycloak:8443/realms/integration/protocol/openid-connect/certs
+ allowed_egress_hosts: ["keycloak"]
+ tls_profile: benchmark-idp
+# --- Cookie-mode BFF confidential-client block (Variant 3, BFF-Cookie-* integration suite) --------
+# Activates the cookie-mode BffRuntime (BffRuntimeProducer builds the active runtime for either
+# recognised session.mode — server or cookie — provided a redirect_uri is configured). Confidential
+# client: integration-client / integration-secret against the compose 'integration' realm, exactly
+# as the primary instance. OIDC discovery is lazy (first engine use), so boot needs no live IdP; the
+# browser reaches this instance on the published host port 10445, while the gateway reaches Keycloak
+# container-internally at keycloak:8443.
+oidc:
+ issuer: https://keycloak:8443/realms/integration
+ client_id: integration-client
+ client_secret: ${OIDC_CLIENT_SECRET}
+ scopes: ["openid", "profile", "email"]
+ # Full browser-facing callback URL. Its host ('localhost') is the oidc host every reserved path
+ # binds to; its origin ('https://localhost:10445') is the gateway origin used for same-origin
+ # return-URL validation and the default CSRF trusted origin.
+ redirect_uri: https://localhost:10445/auth/callback
+ logout:
+ path: /auth/logout
+ post_logout_redirect_uri: https://localhost:10445/auth/logout/return
+ final_redirect: /
+ # Registered in cookie mode too: the endpoint's capability gate answers a deliberate 404 here
+ # (the stateless binding cannot honour IdP-driven sid/sub destruction), so the reserved path
+ # never falls through to the proxy route table. BffCookieBackchannelDisabledIT asserts that.
+ backchannel_path: /auth/backchannel
+ session:
+ # Variant 3: the sealed cookie IS the session. No store — a stateless binding holds no
+ # server-side index, which is exactly what makes the back-channel logout endpoint report
+ # UNSUPPORTED and answer 404.
+ mode: cookie
+ # The AES-256 sealing key as a bare ${ENV_VAR} reference (the D4 secrets rule refuses a
+ # literal). Supplying it — rather than letting the gateway generate one at startup — is what
+ # lets a second instance unseal a cookie this one sealed: the instances share only this key.
+ encryption_key: ${SESSION_ENCRYPTION_KEY}
+ ttl_seconds: 3600
+ refresh:
+ enabled: true
+ leeway_seconds: 30
+ csrf:
+ trusted_origins: ["https://localhost:10445"]
+ user_info:
+ path: /auth/userinfo
+ allowed_claims: ["sub", "preferred_username", "email", "groups"]
+ default_view: ["sub", "preferred_username"]
+ login:
+ path: /auth/login
diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieActivationWiringTest.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieActivationWiringTest.java
new file mode 100644
index 00000000..14617f29
--- /dev/null
+++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieActivationWiringTest.java
@@ -0,0 +1,265 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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 de.cuioss.sheriff.gateway.integration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+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 java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import org.yaml.snakeyaml.Yaml;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Fast, no-Docker surefire guard that the committed integration-test deployment descriptors
+ * actually activate the Variant 3 cookie-mode session — the exact wiring whose
+ * absence would let the cookie ITs ({@code BffCookieSessionIT}, {@code BffCookieStatelessnessIT},
+ * {@code BffCookieBackchannelDisabledIT}) pass their black-box setup while silently exercising the
+ * landed server-mode binding instead.
+ *
+ * The cookie-mode components ({@code bff/cookie/CookieSessionBinding},
+ * {@code bff/cookie/SealedSessionCookieCodec}, {@code bff/cookie/CookieKeyMaterial}) are each
+ * unit-covered and correct in isolation; the whole variant is nonetheless opt-in, so a
+ * mounted {@code gateway.yaml} that keeps {@code session.mode: server}, a compose stack that never
+ * defines the cookie instance, or an overlay that is never mounted leaves every component inert. A
+ * per-component unit test structurally cannot see that gap; this descriptor assertion is the
+ * lowest-level regression guard that can.
+ *
+ * It parses the committed descriptors only (YAML text) and asserts the activation is present — it
+ * starts no container and reaches no network. Modelled on {@code TlsEdgeActivationWiringTest}.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+class BffCookieActivationWiringTest {
+
+ /** The module base directory (surefire runs with the module root as the working directory). */
+ private static final Path MODULE = Path.of(System.getProperty("user.dir"));
+ private static final Path DOCKER = MODULE.resolve("src/main/docker");
+ private static final Path COOKIE_GATEWAY = DOCKER.resolve("sheriff-config-cookie/gateway.yaml");
+
+ private static final String COOKIE_SERVICE = "api-sheriff-cookie";
+
+ /** The key-sharing peer instance {@code BffCookieStatelessnessIT} replays a sealed cookie against. */
+ private static final String COOKIE_PEER_SERVICE = "api-sheriff-cookie-2";
+
+ private static final String COOKIE_OVERLAY_MOUNT =
+ "./src/main/docker/sheriff-config-cookie/gateway.yaml:/app/sheriff-config/gateway.yaml:ro";
+
+ private static final String SEALING_KEY_VAR = "SESSION_ENCRYPTION_KEY=";
+
+ @Test
+ @DisplayName("the cookie instance gateway.yaml declares session.mode cookie with no store")
+ void cookieGatewayDeclaresCookieSessionMode() throws Exception {
+ // Arrange
+ Map session = sessionBlock();
+
+ // Assert — BffRuntimeProducer selects the stateless CookieSessionBinding only on
+ // session.mode=cookie; a 'store' here would be a server-mode leftover contradicting the
+ // stateless binding, which by construction holds no server-side session index.
+ assertEquals("cookie", session.get("mode"),
+ "oidc.session.mode must be 'cookie' to activate the stateless sealed-cookie binding");
+ assertNull(session.get("store"),
+ "a stateless binding holds no server-side index — oidc.session.store must be absent");
+ }
+
+ @Test
+ @DisplayName("the cookie instance supplies the sealing key as a bare ${ENV_VAR} reference")
+ void cookieGatewayReferencesTheSealingKeyByEnvVar() throws Exception {
+ // Arrange
+ Map session = sessionBlock();
+
+ // Act
+ Object encryptionKey = session.get("encryption_key");
+
+ // Assert — the D4 secrets rule refuses a literal, and an explicitly passed key (rather than
+ // the generate-on-startup mode) is what makes a sealed cookie portable across instances that
+ // share nothing else — the property BffCookieStatelessnessIT proves.
+ assertNotNull(encryptionKey,
+ "oidc.session.encryption_key must be configured so the key is shared, not generated per boot");
+ String reference = String.valueOf(encryptionKey);
+ assertTrue(reference.startsWith("${") && reference.endsWith("}"),
+ "the sealing key must be a bare ${ENV_VAR} reference, was: " + reference);
+ }
+
+ @Test
+ @DisplayName("the cookie instance still registers the back-channel logout reserved path")
+ void cookieGatewayStillRegistersTheBackchannelPath() throws Exception {
+ // Arrange
+ Map oidc = oidcBlock();
+ Object logout = oidc.get("logout");
+ assertInstanceOf(Map.class, logout, "the oidc.logout block must be a mapping");
+ @SuppressWarnings("unchecked")
+ Map logoutMap = (Map) logout;
+
+ // Assert — the endpoint's own capability gate answers a deliberate 404 in cookie mode, so the
+ // reserved path must stay registered; dropping it here would let the path fall through to the
+ // proxy route table instead, which is exactly what BffCookieBackchannelDisabledIT rejects.
+ assertNotNull(logoutMap.get("backchannel_path"),
+ "oidc.logout.backchannel_path must stay declared so the gate answers 404, not a route fall-through");
+ }
+
+ @Test
+ @DisplayName("docker-compose defines the cookie gateway service mounting the cookie overlay")
+ void composeDefinesTheCookieInstance() throws Exception {
+ // Arrange
+ Map services = composeServices();
+
+ // Act
+ Object cookieService = services.get(COOKIE_SERVICE);
+
+ // Assert — without a dedicated instance the cookie ITs would drive the primary server-mode
+ // gateway and pass against the wrong binding.
+ assertNotNull(cookieService, "a dedicated " + COOKIE_SERVICE + " gateway instance must be defined");
+ @SuppressWarnings("unchecked")
+ Map cookie = (Map) cookieService;
+
+ Object ports = cookie.get("ports");
+ assertInstanceOf(List.class, ports, "the cookie instance must publish ports");
+ boolean publishesCookiePort = ((List>) ports).stream()
+ .map(String::valueOf)
+ .anyMatch(p -> p.startsWith("10445:"));
+ assertTrue(publishesCookiePort, "the cookie instance must publish host port 10445 for the cookie ITs");
+
+ Object volumes = cookie.get("volumes");
+ assertInstanceOf(List.class, volumes, "the cookie instance must declare volumes");
+ List mounts = ((List>) volumes).stream().map(String::valueOf).toList();
+ assertTrue(mounts.contains(COOKIE_OVERLAY_MOUNT),
+ "the cookie instance must overlay ONLY gateway.yaml with the cookie variant, was: " + mounts);
+ }
+
+ @Test
+ @DisplayName("the cookie gateway service supplies the sealing key environment variable")
+ void composeSuppliesTheSealingKey() throws Exception {
+ // Arrange
+ List environment = environment(composeServices(), COOKIE_SERVICE);
+
+ // Act — the variable the overlaid gateway.yaml's bare ${SESSION_ENCRYPTION_KEY} resolves to.
+ boolean suppliesKey = environment.stream().anyMatch(entry -> entry.startsWith(SEALING_KEY_VAR));
+
+ // Assert — a bare reference naming an undefined variable aborts the boot, so an absent value
+ // here is a hard startup failure rather than a silent fallback.
+ assertTrue(suppliesKey,
+ "the cookie instance must supply SESSION_ENCRYPTION_KEY for the gateway.yaml reference");
+ assertFalse(environment.stream().anyMatch(entry -> SEALING_KEY_VAR.equals(entry)),
+ "SESSION_ENCRYPTION_KEY must carry a value — a blank key cannot seal a session");
+ }
+
+ @Test
+ @DisplayName("docker-compose defines the key-sharing peer instance backing the statelessness proof")
+ void composeDefinesThePeerInstance() throws Exception {
+ // Arrange
+ Map services = composeServices();
+ Object peerService = services.get(COOKIE_PEER_SERVICE);
+
+ // Assert — BffCookieStatelessnessIT replays a sealed cookie against a genuinely separate
+ // process on 10446; without the peer defined that suite would have no second instance and its
+ // proof would silently collapse back into a single-instance round trip.
+ assertNotNull(peerService, "the " + COOKIE_PEER_SERVICE + " peer instance must be defined");
+ @SuppressWarnings("unchecked")
+ Map peer = (Map) peerService;
+
+ Object ports = peer.get("ports");
+ assertInstanceOf(List.class, ports, "the peer instance must publish ports");
+ assertTrue(((List>) ports).stream().map(String::valueOf).anyMatch(p -> p.startsWith("10446:")),
+ "the peer instance must publish host port 10446 so it is addressed without sticky routing");
+
+ Object volumes = peer.get("volumes");
+ assertInstanceOf(List.class, volumes, "the peer instance must declare volumes");
+ List mounts = ((List>) volumes).stream().map(String::valueOf).toList();
+ assertTrue(mounts.contains(COOKIE_OVERLAY_MOUNT),
+ "the peer must overlay the SAME cookie gateway.yaml as the sealing instance, was: " + mounts);
+ }
+
+ @Test
+ @DisplayName("the two cookie instances share the identical sealing key and nothing else stateful")
+ void thePeerSharesTheIdenticalSealingKey() throws Exception {
+ // Arrange
+ Map services = composeServices();
+
+ // Act
+ String sealingKey = sealingKey(services, COOKIE_SERVICE);
+ String peerSealingKey = sealingKey(services, COOKIE_PEER_SERVICE);
+
+ // Assert — the shared key IS the experiment: it is the only state the two processes have in
+ // common, so a drifted value would turn the statelessness proof into an unexplained failure,
+ // and a shared session store would turn its success into a false pass.
+ assertEquals(sealingKey, peerSealingKey,
+ "both cookie instances must carry a byte-identical SESSION_ENCRYPTION_KEY");
+ assertFalse(sealingKey.isEmpty(), "the shared sealing key must carry a value");
+ }
+
+ private static String sealingKey(Map services, String service) {
+ return environment(services, service).stream()
+ .filter(entry -> entry.startsWith(SEALING_KEY_VAR))
+ .map(entry -> entry.substring(SEALING_KEY_VAR.length()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError(
+ "the '" + service + "' service must supply " + SEALING_KEY_VAR));
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map oidcBlock() throws IOException {
+ Map doc = loadYaml(COOKIE_GATEWAY);
+ Object oidc = doc.get("oidc");
+ assertNotNull(oidc, "the cookie gateway.yaml must declare an oidc block");
+ assertInstanceOf(Map.class, oidc, "the oidc block must be a mapping");
+ return (Map) oidc;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map sessionBlock() throws IOException {
+ Object session = oidcBlock().get("session");
+ assertNotNull(session, "the cookie gateway.yaml must declare an oidc.session block");
+ assertInstanceOf(Map.class, session, "the oidc.session block must be a mapping");
+ return (Map) session;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map composeServices() throws IOException {
+ Map doc = loadYaml(MODULE.resolve("docker-compose.yml"));
+ Object services = doc.get("services");
+ assertInstanceOf(Map.class, services, "docker-compose.yml must declare services");
+ return (Map) services;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List environment(Map services, String service) {
+ Object node = services.get(service);
+ assertNotNull(node, "docker-compose.yml must declare the '" + service + "' service");
+ Map serviceMap = (Map) node;
+ Object env = serviceMap.get("environment");
+ assertInstanceOf(List.class, env, "the '" + service + "' service environment must be a list");
+ return ((List>) env).stream().map(String::valueOf).toList();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map loadYaml(Path path) throws IOException {
+ try (InputStream in = Files.newInputStream(path)) {
+ return new Yaml().loadAs(in, Map.class);
+ }
+ }
+}
diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieBackchannelDisabledIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieBackchannelDisabledIT.java
new file mode 100644
index 00000000..fb8a53a0
--- /dev/null
+++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieBackchannelDisabledIT.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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 de.cuioss.sheriff.gateway.integration;
+
+import static io.restassured.RestAssured.given;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import io.restassured.response.Response;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Proves the back-channel logout capability gate at the live edge, on both bindings side by side: the
+ * cookie-mode instance ({@code api-sheriff-cookie}, {@code 10445}) answers {@code 404}, while the
+ * landed server-mode instance ({@code 10443}) still answers its unchanged {@code 400} contract for
+ * the same request.
+ *
+ * Why {@code 404} and not {@code 501}. A stateless binding holds no server-side
+ * session index, so it cannot honour an IdP-driven {@code sid}/{@code sub} destruction. Rather than
+ * accept a post it could only answer with a destruction that never happened, the endpoint refuses
+ * before the form body is parsed — the {@code logout_token} is never read. Reporting the reserved
+ * path as simply absent is the honest answer to a relying party probing for the capability.
+ *
+ * {@code Cache-Control: no-store} is the load-bearing assertion, not decoration. It
+ * is what distinguishes the reserved endpoint deliberately answering {@code 404} from the
+ * path having fallen through to the proxy route table and produced an incidental
+ * {@code NO_ROUTE_MATCHED} {@code 404}. Those two are indistinguishable by status alone, and only
+ * the first is the contract this suite exists to pin.
+ *
+ * Both requests bind their instance origin explicitly rather than relying on the
+ * {@code BaseIntegrationTest} defaults, because the whole point of the suite is the contrast between
+ * two instances.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+class BffCookieBackchannelDisabledIT {
+
+ /** The reserved back-channel logout path both instances register (oidc.logout.backchannel_path). */
+ private static final String BACKCHANNEL_PATH = "/auth/backchannel";
+
+ private static final String FORM_CONTENT_TYPE = "application/x-www-form-urlencoded";
+
+ @Test
+ @DisplayName("the cookie-mode gateway gates the back-channel endpoint off with 404 no-store")
+ void cookieModeBackchannelIsGatedOff() {
+ // Act
+ Response response = given().relaxedHTTPSValidation()
+ .baseUri(BffKeycloakLoginFlow.COOKIE_GATEWAY_ORIGIN)
+ .contentType(FORM_CONTENT_TYPE)
+ .when().post(BACKCHANNEL_PATH)
+ .then().statusCode(404).extract().response();
+
+ // Assert — no-store proves this is the reserved endpoint refusing, not a route-table miss.
+ assertEquals("no-store", response.header("Cache-Control"),
+ "the gated back-channel response must be the reserved endpoint's, served uncacheable");
+ }
+
+ @Test
+ @DisplayName("the server-mode gateway still answers the same back-channel post with 400 no-store")
+ void serverModeBackchannelContractIsUnchanged() {
+ // Act — the identical request against the primary instance, whose store-backed binding does
+ // support IdP-driven destruction and therefore reaches the fail-closed token validation.
+ Response response = given().relaxedHTTPSValidation()
+ .baseUri(BffKeycloakLoginFlow.GATEWAY_ORIGIN)
+ .contentType(FORM_CONTENT_TYPE)
+ .when().post(BACKCHANNEL_PATH)
+ .then().statusCode(400).extract().response();
+
+ // Assert — the gate is per-binding, so activating cookie mode on its own instance must leave
+ // the landed server-mode contract untouched.
+ assertEquals("no-store", response.header("Cache-Control"),
+ "a back-channel logout response must be uncacheable in every outcome");
+ }
+}
diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieSessionIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieSessionIT.java
new file mode 100644
index 00000000..eb5c53ce
--- /dev/null
+++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieSessionIT.java
@@ -0,0 +1,229 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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 de.cuioss.sheriff.gateway.integration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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 io.restassured.http.Cookie;
+import io.restassured.response.Response;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import de.cuioss.sheriff.gateway.integration.BffKeycloakLoginFlow.Session;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Exercises the Variant 3 stateless sealed-cookie session end to end against the dedicated
+ * cookie-mode gateway instance ({@code api-sheriff-cookie}, published on {@code 10445}): the
+ * Keycloak login round trip, steady-state token mediation, session continuity across requests, and
+ * RP-initiated logout — plus the two properties that distinguish the sealed cookie from the landed
+ * server-mode opaque handle.
+ *
+ * Hardening. The session cookie is {@code __Host-}-prefixed and carries
+ * {@code Secure}, {@code HttpOnly}, {@code SameSite=Lax}, {@code Path=/} and no {@code Domain} — the
+ * browser only honours the {@code __Host-} prefix under exactly that combination, so the prefix and
+ * the attributes are asserted together rather than as independent facts.
+ *
+ * Opacity. In this mode the cookie is the session, so the token material
+ * genuinely crosses to the browser — sealed. The opacity assertion is therefore made against the
+ * real mediated access token (echoed back by the go-httpbin upstream) rather than a marker string:
+ * no segment of the live JWT may appear in the cookie value. A heuristic marker such as
+ * {@code "eyJ"} would match a random position of a multi-kilobyte base64 ciphertext often enough to
+ * be flaky, and would prove nothing when it did not.
+ *
+ * Logout is the stateless trade-off, asserted honestly. RP-initiated logout clears
+ * the browser's copy of the cookie; it cannot invalidate a copy an attacker already holds, because a
+ * stateless gateway keeps no server-side index to revoke against. So this suite asserts the clearing
+ * {@code Set-Cookie} — the authoritative local effect in this mode — and deliberately does
+ * not assert that a replayed stale jar is rejected, which is a server-mode-only property
+ * ({@code BffLogoutIT}). The sealed cookie's absolute TTL, enforced server-side against the sealed
+ * login instant, is what bounds a retained copy.
+ *
+ * Every request binds the cookie-instance origin explicitly rather than relying on the
+ * {@code BaseIntegrationTest} defaults, which point at the primary server-mode instance ({@code
+ * 10443}) — a default-bound request here would silently exercise the wrong session binding and pass.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+class BffCookieSessionIT {
+
+ /** The dedicated cookie-mode gateway instance every request in this suite drives. */
+ private static final String COOKIE_ORIGIN = BffKeycloakLoginFlow.COOKIE_GATEWAY_ORIGIN;
+
+ /** The {@code __Host-}-prefixed default session-cookie name (the cookie gateway.yaml sets none). */
+ private static final String SESSION_COOKIE = "__Host-sheriff-session";
+
+ /** The require:session route mediating a bearer to the go-httpbin echo upstream. */
+ private static final String SESSION_ROUTE = "/bff-session/get";
+
+ /** Matches the compact JWT the gateway mediates upstream, whatever surrounds it in the echo. */
+ private static final Pattern COMPACT_JWT =
+ Pattern.compile("[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}");
+
+ @Test
+ @DisplayName("the cookie-mode login sets a __Host- prefixed Secure HttpOnly SameSite=Lax session cookie")
+ void sealedSessionCookieIsHardened() {
+ // Arrange
+ Session session = BffKeycloakLoginFlow.login(SESSION_ROUTE, COOKIE_ORIGIN);
+
+ // Act
+ Cookie sessionCookie = session.callbackCookies().get(SESSION_COOKIE);
+
+ // Assert — the __Host- prefix is a browser contract, not a naming convention: it is honoured
+ // only together with Secure, Path=/ and no Domain, so all four are one assertion group.
+ assertNotNull(sessionCookie, "the cookie-mode callback must set the sealed " + SESSION_COOKIE + " cookie");
+ assertTrue(sessionCookie.isSecured(), "the __Host- prefix requires Secure");
+ assertEquals("/", sessionCookie.getPath(), "the __Host- prefix requires Path=/");
+ assertNull(sessionCookie.getDomain(), "the __Host- prefix requires no Domain attribute");
+ assertTrue(sessionCookie.isHttpOnly(), "the sealed session must be unreadable from script");
+ assertEquals("Lax", sessionCookie.getSameSite(),
+ "SameSite=Lax survives a top-level navigation while blocking cross-site sends");
+ }
+
+ @Test
+ @DisplayName("the sealed cookie value is opaque ciphertext carrying no readable token substring")
+ void sealedCookieValueCarriesNoReadableToken() {
+ // Arrange
+ Session session = BffKeycloakLoginFlow.login(SESSION_ROUTE, COOKIE_ORIGIN);
+ String sealed = session.gatewayCookies().get(SESSION_COOKIE);
+ assertNotNull(sealed, "the login must establish a sealed session cookie");
+
+ // Act — the upstream echo hands back the exact bearer the session mediated, which is the very
+ // token material the cookie carries; asserting against it makes "no readable token" precise.
+ Response echo = BffKeycloakLoginFlow.gateway(session.gatewayCookies(), COOKIE_ORIGIN)
+ .when().get(SESSION_ROUTE)
+ .then().statusCode(200).extract().response();
+ // Bind the echo to Object first, exactly as the sibling assertions below do. Passing
+ // response.path(...) straight into String.valueOf() lets the generic return type infer to
+ // char[] (the String.valueOf(char[]) overload wins), so the compiler inserts a cast the
+ // JSON-path proxy cannot satisfy — a ClassCastException that has nothing to do with the
+ // assertion under test.
+ Object authorizationEcho = echo.path("headers.Authorization");
+ assertNotNull(authorizationEcho, "the sealed session must mediate a bearer to the upstream");
+ String mediatedToken = compactJwtIn(authorizationEcho.toString());
+
+ // Assert — no segment of the live access token may be readable in the cookie the browser holds.
+ for (String segment : mediatedToken.split("\\.")) {
+ assertFalse(sealed.contains(segment),
+ "the sealed cookie must carry no readable segment of the mediated access token");
+ }
+ byte[] raw = Base64.getUrlDecoder().decode(sealed);
+ assertEquals((byte) 1, raw[0],
+ "the cookie value must be the version-1 sealed layout, not an opaque server-side handle");
+ }
+
+ @Test
+ @DisplayName("the sealed session mediates a bearer upstream and stays usable across requests")
+ void sealedSessionMediatesAndStaysUsable() {
+ // Arrange
+ Session session = BffKeycloakLoginFlow.login(SESSION_ROUTE, COOKIE_ORIGIN);
+
+ // Act + Assert — two sequential mediated requests both reach the upstream through the same
+ // sealed session. The transparent near-expiry refresh (leeway_seconds: 30) re-seals only when
+ // the mediated token is within its leeway of expiry; the integration realm's 900s access-token
+ // lifespan keeps it well inside validity here, so this asserts the always-available continuity
+ // path rather than forcing a refresh with a wall-clock wait.
+ for (int request = 0; request < 2; request++) {
+ Response response = BffKeycloakLoginFlow.gateway(session.gatewayCookies(), COOKIE_ORIGIN)
+ .when().get(SESSION_ROUTE)
+ .then().statusCode(200).extract().response();
+
+ assertEquals("GET", response.path("method"),
+ "every mediated request in the live sealed session must reach the upstream");
+ Object authorization = response.path("headers.Authorization");
+ assertNotNull(authorization, "the sealed session must mediate a bearer to the upstream");
+ assertTrue(authorization.toString().contains("Bearer"),
+ "the mediated upstream credential must be a bearer token");
+ assertNull(response.path("headers.Cookie"),
+ "the sealed session cookie must never be forwarded upstream");
+ }
+ }
+
+ @Test
+ @DisplayName("RP-initiated logout redirects into the round trip and clears the sealed cookie")
+ void rpInitiatedLogoutClearsTheSealedCookie() {
+ // Arrange
+ Session session = BffKeycloakLoginFlow.login(SESSION_ROUTE, COOKIE_ORIGIN);
+
+ // Act
+ Response logout = BffKeycloakLoginFlow.gateway(session.gatewayCookies(), COOKIE_ORIGIN)
+ .redirects().follow(false)
+ .when().get("/auth/logout")
+ .then().statusCode(302).extract().response();
+
+ // Assert — a stateless binding holds nothing to destroy, so the authoritative local effect of
+ // logout is the clearing Set-Cookie that removes the browser's copy.
+ assertNotNull(logout.header("Location"), "RP-initiated logout must redirect into the logout round trip");
+ Cookie cleared = logout.getDetailedCookies().get(SESSION_COOKIE);
+ assertNotNull(cleared, "logout must emit the clearing Set-Cookie for the sealed session");
+ assertEquals(0, cleared.getMaxAge(), "the clearing cookie must expire immediately");
+ assertTrue(cleared.getValue() == null || cleared.getValue().isEmpty(),
+ "the clearing cookie must carry no sealed value");
+ }
+
+ @Test
+ @DisplayName("a tampered sealed cookie yields an unauthenticated response, never a 500")
+ void tamperedCookieIsUnauthenticatedNeverServerError() {
+ // Arrange
+ Session session = BffKeycloakLoginFlow.login(SESSION_ROUTE, COOKIE_ORIGIN);
+ Map tamperedJar = new HashMap<>(session.gatewayCookies());
+ tamperedJar.put(SESSION_COOKIE, tamper(session.gatewayCookies().get(SESSION_COOKIE)));
+
+ // Act + Assert — unsealing is fail-closed: whether the flipped character breaks the base64
+ // decode or the GCM authentication tag, the outcome is "no session", so the XHR is challenged
+ // 401. Asserting the exact status is what rules out the 500 a throwing unseal would produce.
+ BffKeycloakLoginFlow.gateway(tamperedJar, COOKIE_ORIGIN)
+ .header("Accept", "application/json")
+ .when().get(SESSION_ROUTE)
+ .then().statusCode(401);
+ }
+
+ /**
+ * Flips the final character of the sealed value, which sits inside the authentication tag.
+ *
+ * @param sealed the sealed cookie value
+ * @return the tampered value, still in the base64url alphabet
+ */
+ private static String tamper(String sealed) {
+ assertNotNull(sealed, "the login must establish a sealed session cookie");
+ char last = sealed.charAt(sealed.length() - 1);
+ return sealed.substring(0, sealed.length() - 1) + (last == 'A' ? 'B' : 'A');
+ }
+
+ /**
+ * Extracts the compact JWT out of the upstream echo's {@code Authorization} rendering, which may
+ * be a bare string or a single-element list depending on how go-httpbin serialises the header.
+ *
+ * @param authorizationEcho the echoed header value
+ * @return the compact JWT the gateway mediated
+ */
+ private static String compactJwtIn(String authorizationEcho) {
+ Matcher matcher = COMPACT_JWT.matcher(authorizationEcho);
+ assertTrue(matcher.find(),
+ "the mediated upstream credential must be a compact JWT, was: " + authorizationEcho);
+ return matcher.group();
+ }
+}
diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieStatelessnessIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieStatelessnessIT.java
new file mode 100644
index 00000000..b9260b76
--- /dev/null
+++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieStatelessnessIT.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * Licensed 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 de.cuioss.sheriff.gateway.integration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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 io.restassured.response.Response;
+import java.util.HashMap;
+import java.util.Map;
+
+import de.cuioss.sheriff.gateway.integration.BffKeycloakLoginFlow.Session;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Proves the defining property of the Variant 3 sealed-cookie session — statelessness — as a
+ * literal two-instance fact: a session created against one cookie-mode gateway is served by a
+ * second, separate gateway process that shares nothing with the first but the AES
+ * sealing key.
+ *
+ * Why two real instances and not one. Everything a single-instance suite can observe
+ * about a sealed cookie — that it is opaque, that it round-trips, that it survives a restart — is also
+ * true of an implementation that quietly kept server-side state. The only observation that rules that
+ * out is another process, which never saw the login, serving the very same cookie. The compose stack
+ * therefore publishes {@code api-sheriff-cookie} on {@code 10445} and its key-sharing peer
+ * {@code api-sheriff-cookie-2} on {@code 10446}: same image, same overlaid cookie {@code gateway.yaml},
+ * identical {@code SESSION_ENCRYPTION_KEY}, and no shared session store, volume, or state.
+ *
+ * No sticky routing. Each test addresses the two instances by their own published
+ * host ports, so there is no load balancer and no affinity mechanism in the path that a passing result
+ * could be attributed to — the request either reaches the peer directly or it does not reach it at all.
+ *
+ * The peer is never logged into. The login round trip runs exclusively against the
+ * first instance; the peer only ever receives an already-sealed cookie on a steady-state {@code GET}.
+ * That is deliberate: the peer's mounted descriptor names {@code 10445} as its {@code redirect_uri}
+ * and CSRF trusted origin — it is the same overlay, verbatim — and portability of an existing session,
+ * not a second login surface, is the property under test.
+ *
+ * {@link #thePeerRefusesACookieItCannotUnseal()} is what gives the two preceding assertions their
+ * force: it establishes that the peer genuinely verifies the sealed material rather than admitting any
+ * cookie-shaped value, so being served by the peer is evidence about the key, not about laxness.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+class BffCookieStatelessnessIT {
+
+ /** The cookie-mode instance the session is created against ({@code api-sheriff-cookie}, 10445). */
+ private static final String ORIGIN = BffKeycloakLoginFlow.COOKIE_GATEWAY_ORIGIN;
+
+ /** The key-sharing peer that never saw the login ({@code api-sheriff-cookie-2}, 10446). */
+ private static final String PEER_ORIGIN = BffKeycloakLoginFlow.COOKIE_GATEWAY_PEER_ORIGIN;
+
+ /** The {@code __Host-}-prefixed default session-cookie name (the cookie gateway.yaml sets none). */
+ private static final String SESSION_COOKIE = "__Host-sheriff-session";
+
+ /** The require:session route mediating a bearer to the go-httpbin echo upstream. */
+ private static final String SESSION_ROUTE = "/bff-session/get";
+
+ /** The reserved session/user-info fold, whose curated view carries the session's identity. */
+ private static final String USER_INFO = "/auth/userinfo";
+
+ @Test
+ @DisplayName("a session sealed by one instance is served by a second instance sharing only the key")
+ void aSessionSealedByOneInstanceIsServedByThePeer() {
+ // Arrange — the login round trip touches ONLY the first instance.
+ Session session = BffKeycloakLoginFlow.login(SESSION_ROUTE, ORIGIN);
+ assertNotNull(session.gatewayCookies().get(SESSION_COOKIE),
+ "the login must establish a sealed session cookie on the first instance");
+
+ // Act — replay the sealed jar against the peer, addressed by its own published port.
+ Response peerResponse = BffKeycloakLoginFlow.gateway(session.gatewayCookies(), PEER_ORIGIN)
+ .when().get(SESSION_ROUTE)
+ .then().statusCode(200).extract().response();
+
+ // Assert — the peer authenticated the request and mediated a bearer upstream, having held no
+ // server-side record of this session at any point. Only the shared key can account for that.
+ assertEquals("GET", peerResponse.path("method"),
+ "the peer must serve the mediated request for a session it never created");
+ Object authorization = peerResponse.path("headers.Authorization");
+ assertNotNull(authorization, "the peer must mediate a bearer unsealed from the portable cookie");
+ assertTrue(authorization.toString().contains("Bearer"),
+ "the mediated upstream credential must be a bearer token");
+ assertNull(peerResponse.path("headers.Cookie"),
+ "the sealed session cookie must never be forwarded upstream");
+ }
+
+ @Test
+ @DisplayName("both instances resolve one sealed cookie to the same authenticated identity")
+ void bothInstancesResolveTheSameIdentity() {
+ // Arrange
+ Session session = BffKeycloakLoginFlow.login(SESSION_ROUTE, ORIGIN);
+
+ // Act — the curated user-info view from each instance for the SAME cookie.
+ String identityOnOrigin = userInfoIdentity(session.gatewayCookies(), ORIGIN);
+ String identityOnPeer = userInfoIdentity(session.gatewayCookies(), PEER_ORIGIN);
+
+ // Assert — this is what rules out "the peer minted something of its own": a fresh or anonymous
+ // session could not reproduce the seeded login identity, and an unauthenticated one would have
+ // been refused 401 by the fold before any claim was disclosed.
+ assertEquals("integration-user", identityOnOrigin,
+ "the sealing instance must resolve the cookie to the seeded login identity");
+ assertEquals(identityOnOrigin, identityOnPeer,
+ "the peer must unseal the very same session, not establish an identity of its own");
+ }
+
+ @Test
+ @DisplayName("the peer refuses a cookie it cannot unseal, so being served is evidence about the key")
+ void thePeerRefusesACookieItCannotUnseal() {
+ // Arrange — the same sealed jar, with the authentication tag broken.
+ Session session = BffKeycloakLoginFlow.login(SESSION_ROUTE, ORIGIN);
+ Map tamperedJar = new HashMap<>(session.gatewayCookies());
+ tamperedJar.put(SESSION_COOKIE, tamper(session.gatewayCookies().get(SESSION_COOKIE)));
+
+ // Act + Assert — the peer verifies rather than trusts: a cookie whose GCM tag no longer
+ // authenticates is refused fail-closed. Without this, "the peer served it" would be equally
+ // consistent with a peer that admits anything cookie-shaped.
+ BffKeycloakLoginFlow.gateway(tamperedJar, PEER_ORIGIN)
+ .header("Accept", "application/json")
+ .when().get(SESSION_ROUTE)
+ .then().statusCode(401);
+ }
+
+ /**
+ * Reads the session's {@code preferred_username} out of the curated user-info view served by the
+ * given instance for the supplied cookie jar.
+ *
+ * @param cookies the sealed gateway cookie jar
+ * @param origin the gateway instance to address
+ * @return the authenticated identity the instance resolved the sealed cookie to
+ */
+ private static String userInfoIdentity(Map cookies, String origin) {
+ return BffKeycloakLoginFlow.gateway(cookies, origin)
+ .header("Accept", "application/json")
+ .when().get(USER_INFO)
+ .then().statusCode(200).extract().path("claims.preferred_username");
+ }
+
+ /**
+ * Flips the final character of the sealed value, which sits inside the authentication tag.
+ *
+ * @param sealed the sealed cookie value
+ * @return the tampered value, still in the base64url alphabet
+ */
+ private static String tamper(String sealed) {
+ assertNotNull(sealed, "the login must establish a sealed session cookie");
+ char last = sealed.charAt(sealed.length() - 1);
+ return sealed.substring(0, sealed.length() - 1) + (last == 'A' ? 'B' : 'A');
+ }
+}
diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java
index 95ce86e1..52d61c8d 100644
--- a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java
+++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java
@@ -17,6 +17,7 @@
import static io.restassured.RestAssured.given;
+import io.restassured.http.Cookies;
import io.restassured.response.Response;
import io.restassured.specification.RequestSpecification;
import java.util.HashMap;
@@ -49,14 +50,42 @@
* Keycloak {@code AUTH_SESSION_ID} ({@code localhost:1443}). The helper therefore keeps the gateway
* jar and the Keycloak jar separate and replays only the gateway jar on the returned session.
*
+ * Origin-parameterized. The flow is identical for every gateway instance, so the
+ * browser-facing origin is a parameter: {@link #login(String)} drives the primary server-mode
+ * instance ({@link #GATEWAY_ORIGIN}) and {@link #login(String, String)} drives any other — notably
+ * the dedicated cookie-mode instance ({@link #COOKIE_GATEWAY_ORIGIN}), since the session mode is a
+ * property of the whole gateway and cannot be flipped per request.
+ *
* This helper is a test-support class (no {@code *IT} suffix), so Failsafe does not run it as a
- * suite; the six {@code Bff*IT} classes call {@link #login(String)} to establish a live session.
+ * suite; the {@code Bff*IT} classes call {@link #login(String)} / {@link #login(String, String)} to
+ * establish a live session.
*/
final class BffKeycloakLoginFlow {
/** Browser-facing gateway origin: published host port {@code 10443 -> } container {@code 8443}. */
static final String GATEWAY_ORIGIN = "https://localhost:10443";
+ /**
+ * Browser-facing origin of the dedicated cookie-mode gateway instance
+ * ({@code api-sheriff-cookie}, published host port {@code 10445 -> } container {@code 8443}).
+ *
+ * The session mode is a property of the whole gateway, so Variant 3 cannot share the primary
+ * instance: {@link #GATEWAY_ORIGIN} stays server-mode for the landed {@code Bff*IT} suite while
+ * the {@code Bff*Cookie*IT} suite drives this origin.
+ */
+ static final String COOKIE_GATEWAY_ORIGIN = "https://localhost:10445";
+
+ /**
+ * Browser-facing origin of the second cookie-mode gateway instance
+ * ({@code api-sheriff-cookie-2}, published host port {@code 10446 -> } container {@code 8443}).
+ *
+ * A separate process sharing nothing with {@link #COOKIE_GATEWAY_ORIGIN} but the AES sealing key
+ * — no session store, no volume, no sticky routing. It is never logged into: it exists so
+ * {@code BffCookieStatelessnessIT} can replay a cookie sealed by the first instance against it
+ * and prove portability as an observed two-instance fact rather than an inference.
+ */
+ static final String COOKIE_GATEWAY_PEER_ORIGIN = "https://localhost:10446";
+
/** The container-internal Keycloak authority the {@code integration} realm frontendUrl pins. */
static final String KEYCLOAK_INTERNAL_AUTHORITY = "keycloak:8443";
@@ -96,16 +125,20 @@ private BffKeycloakLoginFlow() {
/**
* The gateway session established by a completed login: the cookies to replay on subsequent
- * protected requests (the session cookie plus any residual gateway cookies).
+ * protected requests (the session cookie plus any residual gateway cookies), plus the callback
+ * response's cookies with their attributes intact.
*
* @param gatewayCookies the gateway cookie jar carrying the live session cookie
+ * @param callbackCookies the cookies the callback response set, with {@code Secure} /
+ * {@code HttpOnly} / {@code SameSite} / {@code Path} / {@code Domain}
+ * attributes preserved — the flat {@code gatewayCookies} map drops them,
+ * and the sealed-cookie hardening contract is asserted on these
*/
- record Session(Map gatewayCookies) {
+ record Session(Map gatewayCookies, Cookies callbackCookies) {
}
/**
- * Runs the full auth-code flow starting from a require:session navigation on {@code startPath}
- * and returns the gateway session cookies established by the callback.
+ * Runs the full auth-code flow against the primary (server-mode) gateway origin.
*
* @param startPath the gateway path to navigate to (a require:session route such as
* {@code /bff-session/get}); the unauthenticated navigation triggers the login
@@ -113,12 +146,31 @@ record Session(Map gatewayCookies) {
* @return the established gateway {@link Session}
*/
static Session login(String startPath) {
+ return login(startPath, GATEWAY_ORIGIN);
+ }
+
+ /**
+ * Runs the full auth-code flow starting from a require:session navigation on {@code startPath}
+ * against the given gateway origin, and returns the gateway session cookies established by the
+ * callback.
+ *
+ * The origin is a parameter because the session mode is a property of the whole gateway: the
+ * cookie-mode variant runs on its own instance ({@link #COOKIE_GATEWAY_ORIGIN}), and the flow
+ * itself — the {@code 302} chain, the {@code response_mode=form_post} scrape, and the callback
+ * replay — is identical for both. Only the browser-facing origin differs; the callback action
+ * is an absolute URL taken from the auto-submit form, so it already targets the right instance.
+ *
+ * @param startPath the gateway path to navigate to (a require:session route)
+ * @param gatewayOrigin the browser-facing gateway origin to drive
+ * @return the established gateway {@link Session}
+ */
+ static Session login(String startPath, String gatewayOrigin) {
Map gatewayCookies = new HashMap<>();
Map keycloakCookies = new HashMap<>();
// Step 1 — navigate onto the require:session route: the gateway sets the pending-auth binding
// cookie and 302s the browser to the IdP authorization endpoint.
- Response initiation = gateway(gatewayCookies)
+ Response initiation = gateway(gatewayCookies, gatewayOrigin)
.header("Accept", "text/html")
.redirects().follow(false)
.when().get(startPath)
@@ -154,7 +206,7 @@ static Session login(String startPath) {
// gateway parses the code from the BODY, exchanges it for tokens, creates the server-side session,
// and sets the session cookie on a 302 back to the original path. The binding cookie from Step 1
// rides the gateway jar.
- Response callback = gateway(gatewayCookies)
+ Response callback = gateway(gatewayCookies, gatewayOrigin)
.contentType("application/x-www-form-urlencoded")
.formParams(callbackFields)
.redirects().follow(false)
@@ -162,22 +214,33 @@ static Session login(String startPath) {
.then().statusCode(302).extract().response();
gatewayCookies.putAll(callback.getCookies());
- return new Session(gatewayCookies);
+ return new Session(gatewayCookies, callback.getDetailedCookies());
}
/**
- * A request spec bound to the gateway origin with relaxed HTTPS and the supplied cookie jar.
+ * A request spec bound to the primary (server-mode) gateway origin.
*
* @param cookies the gateway cookie jar
* @return the configured request specification
*/
static RequestSpecification gateway(Map cookies) {
+ return gateway(cookies, GATEWAY_ORIGIN);
+ }
+
+ /**
+ * A request spec bound to the given gateway origin with relaxed HTTPS and the supplied cookie jar.
+ *
+ * @param cookies the gateway cookie jar
+ * @param gatewayOrigin the browser-facing gateway origin to bind
+ * @return the configured request specification
+ */
+ static RequestSpecification gateway(Map cookies, String gatewayOrigin) {
// Default URL encoding stays ON here: the gateway spec only issues a plain require:session GET
// (Step 1, no query) and the callback POST (Step 4). The callback carries the authorization
// code/state/iss as x-www-form-urlencoded body params whose raw values (e.g. the issuer URL's
// ':' and '/') MUST be percent-encoded by REST Assured — disabling encoding here corrupts the
// body and the gateway rejects it with 400. Only keycloak() replays a pre-encoded URL.
- return given().relaxedHTTPSValidation().baseUri(GATEWAY_ORIGIN).cookies(cookies);
+ return given().relaxedHTTPSValidation().baseUri(gatewayOrigin).cookies(cookies);
}
/**