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. + *

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

      - *
    1. resolves the opaque {@code __Host-} session cookie to a live {@link SessionRecord} through - * the {@link SessionStore} (an expired / unknown session is treated as unauthenticated);
    2. + *
    3. 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;
    4. *
    5. 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);
    6. + * 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; *
    7. 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);
    8. *
    9. 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.
    10. + * 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): *

      *
    1. stage 0 — response-header preparation + CORS preflight (short-circuits a preflight here);
    2. *
    3. 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: + *

        + *
      1. 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.
      2. + *
      3. 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.
      4. + *
      */ 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> 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); } /**