From 8c177d7ad096746488ffe569807b2251f7c150c5 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:10:43 +0200 Subject: [PATCH 01/24] refactor(bff): extract a mode-neutral SessionBinding seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec D7. Replace every direct SessionStore binding in the BFF foundation with one mode-neutral seam a stateless variant can also implement, so login, CSRF, step-up, scope enforcement and logout orchestration stay single-sourced across both session.mode values. - SessionBinding: bind / resolve / persist / destroy plus the two IdP-driven destruction forms and their SUPPORTED/UNSUPPORTED capability flag. The contract names no store and no opaque id; BoundSession carries the session plus the Set-Cookie headers the caller emits. - ServerSessionBinding: the server-mode adapter over the unchanged SessionStore and SessionCookieCodec, behaviour-preserving by construction and reporting IdpDestruction.SUPPORTED. - Migrate all seven consumers (SessionAuthenticationStage, TokenRefreshCoordinator, CallbackEndpoint, LogoutEndpoint, UserInfoEndpoint, LoginInitiationEndpoint, BackchannelLogoutReceiver) plus the BffRuntimeProducer assembly site in one clean break — no shim, no compatibility layer, per the pre-1.0 rules and this plan's breaking compatibility setting. - Single identity model: every binding populates SessionRecord.sessionId(), so TokenRefreshCoordinator keeps single-flight keyed on that component and the seam gains no identity accessor. The coordinator now threads the request Cookie header so its re-resolve-and-re-check under single-flight exclusion stays mode-neutral, and RefreshOutcome carries the re-bind's Set-Cookie headers, which the stage emits on the response. - Javadoc corrected where it had become factually wrong: runtime/package-info no longer claims the stage resolves an opaque session id, SessionRecord states the seam's identity model, and SessionStore is narrowed to the server-mode implementation detail behind the seam. Tests: new ServerSessionBindingTest pins the adapter's preservation of every landed store semantic (upsert-on-persist with no destroy window, lazy-TTL eviction on resolve, O(1) sid/sub destruction). The nine compile-breaking consumer test classes are migrated to SessionBinding doubles with every landed assertion intact. Co-Authored-By: Claude --- .../bff/logout/BackchannelLogoutReceiver.java | 41 ++-- .../bff/refresh/TokenRefreshCoordinator.java | 96 ++++---- .../bff/reserved/CallbackEndpoint.java | 37 ++- .../bff/reserved/LoginInitiationEndpoint.java | 26 +-- .../gateway/bff/reserved/LogoutEndpoint.java | 58 +++-- .../bff/reserved/UserInfoEndpoint.java | 25 +-- .../runtime/SessionAuthenticationStage.java | 73 +++--- .../gateway/bff/runtime/package-info.java | 16 +- .../bff/session/ServerSessionBinding.java | 114 ++++++++++ .../gateway/bff/session/SessionBinding.java | 161 +++++++++++++ .../gateway/bff/session/SessionRecord.java | 25 ++- .../gateway/bff/session/SessionStore.java | 11 +- .../gateway/bff/session/package-info.java | 34 +-- .../gateway/quarkus/BffRuntimeProducer.java | 35 +-- .../gateway/auth/AuthenticationStageTest.java | 6 +- .../refresh/TokenRefreshCoordinatorTest.java | 38 ++-- .../BackchannelLogoutEndpointTest.java | 5 +- .../bff/reserved/CallbackEndpointTest.java | 11 +- .../reserved/LoginInitiationEndpointTest.java | 4 +- .../bff/reserved/LogoutEndpointTest.java | 9 +- .../bff/reserved/UserInfoEndpointTest.java | 11 +- .../SessionAuthenticationStageTest.java | 57 ++--- .../bff/session/ServerSessionBindingTest.java | 212 ++++++++++++++++++ .../edge/GatewayEdgeRouteBffWiringTest.java | 57 +++-- 24 files changed, 860 insertions(+), 302 deletions(-) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/ServerSessionBinding.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionBinding.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/ServerSessionBindingTest.java 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..025ab64e 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,23 @@ import java.util.concurrent.ConcurrentMap; +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 +51,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 @@ -72,11 +77,11 @@ public final class TokenRefreshCoordinator { 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 +89,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 +127,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 +139,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,16 +155,16 @@ 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); + // 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.debug("Refreshed the mediated tokens for a require:session route (single-flight)"); - return RefreshOutcome.refreshed(rotated); + 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); + sessionBinding.destroy(latest); LOGGER.debug(refreshFailure, "Token refresh failed (IdP rejection or refresh-token reuse) — session destroyed"); return RefreshOutcome.failed(); @@ -228,17 +236,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 +271,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 +306,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/CallbackEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java index 9f702c11..bf1cb9c3 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 @@ -19,6 +19,7 @@ import java.security.MessageDigest; 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 +28,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 +74,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 @@ -97,28 +98,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"); } @@ -192,9 +189,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 +200,10 @@ private CallbackOutcome completeLogin(AuthorizationCodeFlow.AuthenticationResult .acr(claim(idToken, CLAIM_ACR)) .authTime(claimEpochSeconds(idToken, CLAIM_AUTH_TIME)) .build(); - sessionStore.create(session); + SessionBinding.BoundSession bound = sessionBinding.bind(session, now); - 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); } 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/SessionAuthenticationStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStage.java index b432993e..773c2b50 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,21 @@ *

* 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; *
  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 @@ -76,27 +80,24 @@ public final class SessionAuthenticationStage { 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 +115,24 @@ 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); + SessionBinding.BoundSession refreshed = tokenRefresh.refreshIfNeeded(resolved.get(), cookieHeader, now); + emitSetCookies(request, refreshed.setCookieHeaders()); + SessionRecord session = refreshed.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)); + private static void emitSetCookies(PipelineRequest request, List setCookieHeaders) { + setCookieHeaders.stream().findFirst() + .ifPresent(cookie -> request.responseHeaders().put(SET_COOKIE_HEADER, cookie)); } private void enforceScopes(RouteRuntime route, SessionRecord session) { @@ -143,8 +147,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 +178,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 +190,15 @@ 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 */ - SessionRecord refreshIfNeeded(SessionRecord session, Instant now); + SessionBinding.BoundSession 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..869b67c3 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionBinding.java @@ -0,0 +1,161 @@ +/* + * 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 a stateful implementation is at its capacity bound + */ + 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..27e7d448 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,26 @@ 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. In server mode it is the opaque store key, + * which is also the session-cookie value; in a stateless mode it is a derived identity that is + * never emitted to the browser. It is redacted from {@link #toString()} either way, because in + * server mode it is itself a bearer credential. + *

+ * {@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 +80,8 @@ public record SessionRecord(String sessionId, String accessToken, Optionalserver-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/quarkus/BffRuntimeProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java index dd3c9192..8a344a1f 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 @@ -48,8 +48,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; @@ -189,7 +190,10 @@ private BffRuntime build(OidcConfig oidc) { 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. Server mode + // supplies the store-backed adapter; the cookie-mode binding plugs in at the same point. + SessionBinding sessionBinding = new ServerSessionBinding(new InMemorySessionStore(maxSessions), + sessionCookieCodec); PendingAuthorizationStore pendingStore = new PendingAuthorizationStore.InMemory(DEFAULT_MAX_PENDING); Clock clock = Clock.systemUTC(); @@ -201,18 +205,23 @@ 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); + return new SessionBinding.BoundSession(outcome.session().orElse(sessionRecord), + outcome.setCookieHeaders()); + }, (accessToken, requiredScopes) -> tokenBridge.validateAccessToken(accessToken) .providesScopes(requiredScopes), (returnUrl, now) -> { @@ -234,25 +243,25 @@ 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. BackchannelLogoutReceiver backchannelReceiver = new BackchannelLogoutReceiver( idBridge::validateRefreshedIdToken, new LogoutTokenValidator(issuer, clientId, BACKCHANNEL_FRESHNESS_WINDOW), - sessionStore); + sessionBinding); BackchannelLogoutEndpoint backchannelLogoutEndpoint = new BackchannelLogoutEndpoint(backchannelReceiver); // 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); @@ -262,7 +271,7 @@ private BffRuntime build(OidcConfig oidc) { } 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 +285,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/test/java/de/cuioss/sheriff/gateway/auth/AuthenticationStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/auth/AuthenticationStageTest.java index 321342c2..790175c7 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 @@ -31,6 +31,8 @@ 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 +193,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) -> 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/refresh/TokenRefreshCoordinatorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinatorTest.java index 6e6ad0e8..0783c856 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 @@ -24,6 +24,7 @@ import java.time.Duration; import java.time.Instant; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.CountDownLatch; @@ -38,6 +39,9 @@ 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 +78,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 +112,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 +130,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 +148,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 +166,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 +188,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 +209,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 +226,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 +239,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 +266,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. @@ -297,24 +306,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..af1608ab 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 @@ -32,6 +32,8 @@ 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.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; @@ -68,7 +70,8 @@ private BackchannelLogoutEndpoint endpoint(AtomicBoolean verifierInvoked) { BackchannelLogoutReceiver receiver = new BackchannelLogoutReceiver(rawToken -> { verifierInvoked.set(true); return validLogoutToken(); - }, validator, new InMemorySessionStore(16)); + }, validator, new ServerSessionBinding(new InMemorySessionStore(16), + new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, Duration.ofHours(8)))); return new BackchannelLogoutEndpoint(receiver); } 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..127fbba6 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 @@ -33,6 +33,8 @@ 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 +73,7 @@ class CallbackEndpointTest { private BindingCookieCodec bindingCodec; private InMemorySessionStore sessionStore; private SessionCookieCodec sessionCodec; + private SessionBinding sessionBinding; private CallbackEndpoint endpoint; private String state; @@ -83,8 +86,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(); @@ -204,8 +207,8 @@ 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); 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..8625a311 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; @@ -69,8 +70,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 +84,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) -> + 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); @@ -98,8 +100,8 @@ void injectsRefreshedTokenAfterRefresh() { @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 +118,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 +136,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)); @@ -150,7 +152,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 +165,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 +177,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"))); @@ -200,7 +202,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 +222,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 +241,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 +251,20 @@ 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) -> new SessionBinding.BoundSession(session, List.of()); } private static SessionAuthenticationStage.GrantedScopes scopesGranted() { @@ -276,14 +279,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..9339b214 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/ServerSessionBindingTest.java @@ -0,0 +1,212 @@ +/* + * 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/edge/GatewayEdgeRouteBffWiringTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java index 06fa8224..414af74c 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,10 @@ 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) -> new SessionBinding.BoundSession(session, List.of()), (token, scopes) -> true, (returnUrl, instant) -> new SessionAuthenticationStage.LoginChallenge("/login", List.of()), Clock.systemUTC()); @@ -342,14 +340,14 @@ private BffRuntime formPostRuntime() { rawToken -> { throw new AssertionError("engine verify must not be reached"); }, - new LogoutTokenValidator(ORIGIN, "client", Duration.ofMinutes(2)), sessionStore)); - UserInfoEndpoint userInfo = new UserInfoEndpoint(sessionStore, codec, + new LogoutTokenValidator(ORIGIN, "client", Duration.ofMinutes(2)), 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 +365,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 +377,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 +386,8 @@ 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) -> new SessionBinding.BoundSession(session, List.of()), (token, scopes) -> true, (returnUrl, instant) -> new SessionAuthenticationStage.LoginChallenge("/login", List.of()), Clock.systemUTC()); @@ -404,30 +403,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)); - 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() { From 6973ea3560aebd12b459a05e7689717e4cb29d49 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:32:48 +0200 Subject: [PATCH 02/24] feat(bff): add the AES-256-GCM sealed-cookie crypto core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec D1. Seal the mediated token set into an authenticated encrypted cookie and read it back per request, so session.mode=cookie holds no server-side state. - SealedSessionPayload: the sealed plaintext — token material, identity/session claims, and the absolute login instant that anchors the server-enforced TTL. Encoding is compact, explicit and dependency-free; every credential is redacted from toString(). - SealedSessionCookieCodec: version(1B) || key-id(1B) || nonce(12B) || ciphertext || tag(16B), base64url without padding. A fresh 96-bit SecureRandom nonce per seal — never derived, never counter-based, because GCM nonce reuse under one key is catastrophic. Version, key id and cookie name are bound into the GCM associated data, so a value cannot be replayed under a different format version, key generation, or cookie name. unseal selects the key deterministically by key id (never a try-every-key decrypt) and returns "no session" for any tag failure, malformed length, unknown version, or unknown key id — never a 500. A value over the ~4 KB budget fails the seal with a checked outcome and a logged warning rather than truncating silently. Max-Age carries the remaining absolute lifetime, so a re-seal never extends the session. - CookieSessionBinding: the seam implementation over the codec. It enforces the absolute TTL server-side from the sealed login instant, so an expired cookie a browser still holds is refused; reports IdpDestruction.UNSUPPORTED because a stateless gateway has no index to destroy another browser's session through; and populates SessionRecord.sessionId() with a salted digest over the login instant and sub — stable for the session's life, used only as the in-instance single-flight key, and never emitted to the browser. - BffRuntimeProducer: isServerModeBff becomes the mode-aware isBffMode, so session.mode=cookie activates the runtime. Only the binding differs between modes; every other collaborator is identical. The stale comment claiming a cookie-mode gateway never touches the confidential-client engine is corrected. Cookie mode fails closed at boot on a missing or non-AES-256 sealing key. - Six LogRecord constants (INFO 15-17, WARN 113-115) carrying only bounded, non-sensitive dispositions, documented in doc/LogMessages.adoc as the project logging standard requires. No new Maven dependency: the sealing uses the JDK's own javax.crypto AES-GCM. Tests: 47 new cases across the payload, codec and binding — round-trip fidelity, a distinct value per seal, tamper rejection with a flipped byte in each of ciphertext / nonce / tag, cookie-name and version and key-id mismatch rejection, oversize refusal, server-side TTL expiry, remaining-lifetime Max-Age on re-seal, and the absence of key or token material from every emitted header. The BffRuntimeProducer inert-for-cookie-mode test is migrated to the new specification, where cookie mode is an active mode. Co-Authored-By: Claude --- .../sheriff/gateway/bff/BffLogMessages.java | 69 +++- .../bff/cookie/CookieSessionBinding.java | 193 ++++++++++ .../bff/cookie/SealedSessionCookieCodec.java | 326 +++++++++++++++++ .../bff/cookie/SealedSessionPayload.java | 174 +++++++++ .../gateway/bff/cookie/package-info.java | 46 +++ .../gateway/bff/session/SessionBinding.java | 4 +- .../gateway/quarkus/BffRuntimeProducer.java | 112 +++++- .../bff/cookie/CookieSessionBindingTest.java | 282 +++++++++++++++ .../cookie/SealedSessionCookieCodecTest.java | 335 ++++++++++++++++++ .../bff/cookie/SealedSessionPayloadTest.java | 200 +++++++++++ .../quarkus/BffRuntimeProducerTest.java | 101 +++++- doc/LogMessages.adoc | 6 + 12 files changed, 1821 insertions(+), 27 deletions(-) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/CookieSessionBinding.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionCookieCodec.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionPayload.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/package-info.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/CookieSessionBindingTest.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionCookieCodecTest.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionPayloadTest.java 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..f3385974 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-115} (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,40 @@ 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 session sealed under the previous key was re-sealed under the current key on its next + * write, completing the rotation for that session. Records only the bounded disposition. + */ + public static final LogRecord COOKIE_RESEALED_WITH_CURRENT_KEY = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(17) + .template("Cookie-mode session re-sealed under the current key (%s)") + .build(); } /** - * Warn-level messages (WARN range 100-199; this catalogue owns 110-112). + * Warn-level messages (WARN range 100-199; this catalogue owns 110-115). */ @UtilityClass public static final class WARN { @@ -136,5 +166,38 @@ 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(); + + /** + * The OIDC back-channel logout endpoint is disabled because the active session binding + * cannot honour IdP-driven destruction (cookie mode holds no server-side index). Records + * only the non-sensitive reason. + */ + public static final LogRecord COOKIE_BACKCHANNEL_DISABLED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(115) + .template("Back-channel logout disabled for the active session binding (%s)") + .build(); } } 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..0563e4a8 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/CookieSessionBinding.java @@ -0,0 +1,193 @@ +/* + * 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 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 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. + *

+ * 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 String DIGEST_ALGORITHM = "SHA-256"; + private static final int IDENTITY_BYTES = 16; + + private final SealedSessionCookieCodec codec; + private final byte[] identitySalt; + + /** + * 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(payload -> !payload.isExpired(codec.sessionTtl(), now)) + .map(this::toSessionRecord); + } + + @Override + public BoundSession persist(SessionRecord rotated, Instant now) { + Objects.requireNonNull(rotated, "rotated"); + Objects.requireNonNull(now, "now"); + // The absolute deadline lives in expiresAt, which the login anchored; re-deriving the login + // instant from it keeps the re-seal from extending the session. + 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))); + } + + 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..e711ae35 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionCookieCodec.java @@ -0,0 +1,326 @@ +/* + * 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. + *

+ * Size budget. A sealed value larger than {@link #MAX_COOKIE_VALUE_BYTES} 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. + *

+ * 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; + + /** + * The sealed cookie-value size budget in bytes (~4 KB). Browsers are only required to accept + * 4096 bytes per cookie, so a larger value would be silently dropped by the browser. + */ + public static final int MAX_COOKIE_VALUE_BYTES = 4096; + + 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); + + 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 SecretKey currentKey; + private final byte currentKeyId; + + /** + * Assembles the codec for one cookie name, session lifetime, and sealing key. + * + * @param cookieName the session-cookie name (bound into the associated data) + * @param sessionTtl the absolute session lifetime from login + * @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, SecretKey currentKey, byte currentKeyId) { + this.cookieName = requireNonBlank(cookieName); + this.sessionTtl = Objects.requireNonNull(sessionTtl, "sessionTtl"); + this.currentKey = Objects.requireNonNull(currentKey, "currentKey"); + this.currentKeyId = currentKeyId; + } + + /** + * 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 + * {@link #MAX_COOKIE_VALUE_BYTES} + */ + 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() > MAX_COOKIE_VALUE_BYTES) { + LOGGER.warn(BffLogMessages.WARN.COOKIE_SIZE_BUDGET_EXCEEDED, encoded.length()); + throw new CookieSizeBudgetExceededException(encoded.length(), MAX_COOKIE_VALUE_BYTES); + } + 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; 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 notBase64) { + 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]; + SecretKey key = keyFor(keyId); + if (key == null) { + 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 tamperedOrMisconfigured) { + // 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; + } + + /** + * 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; + } + + /** + * Selects the key for a cookie's key id. Deterministic by design — never a try-both decrypt. + * + * @param keyId the key id read from the cookie header + * @return the matching key, or {@code null} when the id is unknown + */ + private @Nullable SecretKey keyFor(byte keyId) { + return keyId == currentKeyId ? currentKey : null; + } + + 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(); + } + + private static String requireNonBlank(String cookieName) { + Objects.requireNonNull(cookieName, "cookieName"); + if (cookieName.isBlank()) { + throw new IllegalArgumentException("cookieName must not be blank"); + } + return cookieName; + } + + /** + * 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..e4c357c7 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionPayload.java @@ -0,0 +1,174 @@ +/* + * 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.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 malformed) { + // Base64 or epoch-second parse failure on bytes that authenticated: a foreign payload + // format under the same key. Report "no session" rather than propagating. + return Optional.empty(); + } + } + + /** + * Whether the session has reached its absolute deadline at {@code now} — the deadline is + * inclusive, and is always computed from {@link #loginInstant()} so a re-seal cannot extend it. + * + * @param ttl the configured absolute session lifetime from login + * @param now the reference instant + * @return {@code true} when {@code now} is at or after the absolute deadline + */ + public boolean isExpired(Duration ttl, Instant now) { + Objects.requireNonNull(ttl, "ttl"); + Objects.requireNonNull(now, "now"); + return !now.isBefore(loginInstant.plus(ttl)); + } + + /** + * Overridden to redact every credential — the three tokens. The default record + * {@code toString()} would otherwise print the raw token material into any log line, exception + * message, or debugger view. + * + * @return a string representation with all credential-bearing fields redacted + */ + @Override + public String toString() { + return "SealedSessionPayload[accessToken=%s, refreshToken=%s, idToken=%s, sub=%s, sid=%s, acr=%s, authTime=%s, loginInstant=%s]" + .formatted(REDACTED, refreshToken.isPresent() ? "Optional[" + REDACTED + "]" : "Optional.empty", + REDACTED, sub, sid, acr, authTime, loginInstant); + } + + private static void appendField(StringBuilder target, String value) { + if (!target.isEmpty()) { + target.append(FIELD_SEPARATOR); + } + target.append(Base64.getUrlEncoder().withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8))); + } + + private static String decodeField(String field) { + return new String(Base64.getUrlDecoder().decode(field), StandardCharsets.UTF_8); + } + + private static Optional optionalField(String field) { + if (field.isEmpty()) { + return Optional.empty(); + } + String decoded = decodeField(field); + return decoded.isEmpty() ? Optional.empty() : Optional.of(decoded); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/package-info.java new file mode 100644 index 00000000..1cdc848b --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/package-info.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * The stateless cookie-mode session binding (D1, {@code session.mode: cookie}) — the second + * implementation of {@link de.cuioss.sheriff.gateway.bff.session.SessionBinding}, alongside the + * server-mode store adapter. + *

+ * The gateway holds no server-side session state in this mode: the mediated token + * set is sealed into the session cookie itself and read back per request. + *

    + *
  • {@link de.cuioss.sheriff.gateway.bff.cookie.SealedSessionPayload} is the record that gets + * sealed — the token material, the identity/session claims, and the absolute login instant + * that anchors the server-enforced TTL. Every credential-bearing component is redacted from + * {@code toString()}.
  • + *
  • {@link de.cuioss.sheriff.gateway.bff.cookie.SealedSessionCookieCodec} performs the + * AES-256-GCM sealing: a fresh 96-bit random nonce per seal, with the format version, the + * key id, and the cookie name bound into the GCM associated data. Any tampering, truncation, + * unknown version, or unknown key id unseals to "no session" — never a server error.
  • + *
  • {@link de.cuioss.sheriff.gateway.bff.cookie.CookieSessionBinding} is the seam + * implementation over the codec. It reports + * {@link de.cuioss.sheriff.gateway.bff.session.SessionBinding.IdpDestruction#UNSUPPORTED} + * because a stateless gateway holds no index to destroy another browser's session through.
  • + *
+ * The classes are framework-agnostic (no CDI, no JAX-RS/Vert.x coupling) and introduce no new + * dependency — the sealing uses the JDK's own {@code javax.crypto} AES-GCM provider. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.gateway.bff.cookie; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionBinding.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionBinding.java index 869b67c3..21d57605 100644 --- 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 @@ -58,7 +58,9 @@ public interface SessionBinding { * @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 a stateful implementation is at its capacity bound + * @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); 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 8a344a1f..48b9b5c7 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 @@ -16,8 +16,12 @@ package de.cuioss.sheriff.gateway.quarkus; import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Clock; import java.time.Duration; +import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -27,8 +31,13 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + import de.cuioss.sheriff.gateway.auth.GatewayValidator; +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; @@ -80,11 +89,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 @@ -104,6 +120,10 @@ public class BffRuntimeProducer { private static final CuiLogger LOGGER = new CuiLogger(BffRuntimeProducer.class); private static final String SESSION_MODE_SERVER = "server"; + private static final String SESSION_MODE_COOKIE = "cookie"; + private static final int AES_256_KEY_BYTES = 32; + private static final byte COOKIE_KEY_ID_CURRENT = 1; + private static final String IDENTITY_SALT_LABEL = "api-sheriff:cookie-session-identity"; 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; @@ -133,24 +153,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).flatMap(OidcConfig.Session::mode) + .filter(mode -> SESSION_MODE_SERVER.equalsIgnoreCase(mode) + || SESSION_MODE_COOKIE.equalsIgnoreCase(mode)) + .isPresent(); boolean hasRedirect = oidc.flatMap(OidcConfig::redirectUri).isPresent(); - return serverMode && hasRedirect; + return recognisedMode && hasRedirect; + } + + private static boolean isCookieMode(Optional session) { + return session.flatMap(OidcConfig.Session::mode).filter(SESSION_MODE_COOKIE::equalsIgnoreCase).isPresent(); } private BffRuntime build(OidcConfig oidc) { @@ -190,10 +222,11 @@ private BffRuntime build(OidcConfig oidc) { SessionCookieCodec sessionCookieCodec = new SessionCookieCodec(cookieName, sessionTtl); BindingCookieCodec bindingCookieCodec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL); - // D7 seam: the whole BFF foundation binds SessionBinding, never the store directly. Server mode - // supplies the store-backed adapter; the cookie-mode binding plugs in at the same point. - SessionBinding sessionBinding = new ServerSessionBinding(new InMemorySessionStore(maxSessions), - sessionCookieCodec); + // 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), sessionCookieCodec); PendingAuthorizationStore pendingStore = new PendingAuthorizationStore.InMemory(DEFAULT_MAX_PENDING); Clock clock = Clock.systemUTC(); @@ -270,6 +303,53 @@ private BffRuntime build(OidcConfig oidc) { backchannelLogoutEndpoint, userInfoEndpoint, loginInitiationEndpoint); } + /** + * Assembles the stateless cookie-mode binding: the AES-256-GCM sealed-cookie codec over the + * configured {@code session.encryption_key}, 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. + */ + private static SessionBinding cookieSessionBinding(Optional session, String cookieName, + Duration sessionTtl) { + byte[] keyBytes = decodeAesKey(session.flatMap(OidcConfig.Session::encryptionKey) + .orElseThrow(() -> new IllegalStateException( + "session.mode=cookie requires session.encryption_key"))); + SecretKey key = new SecretKeySpec(keyBytes, "AES"); + SealedSessionCookieCodec codec = new SealedSessionCookieCodec(cookieName, sessionTtl, key, + COOKIE_KEY_ID_CURRENT); + return new CookieSessionBinding(codec, deriveIdentitySalt(keyBytes)); + } + + /** + * Decodes the operator-supplied base64 AES-256 key. The value is an {@code ${ENV_VAR}} + * reference in config (ADR-0011), so the material itself never lives in a descriptor. + */ + private static byte[] decodeAesKey(String encoded) { + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(encoded.trim()); + } catch (IllegalArgumentException notBase64) { + throw new IllegalStateException("session.encryption_key is not valid base64", notBase64); + } + if (decoded.length != AES_256_KEY_BYTES) { + throw new IllegalStateException( + "session.encryption_key must decode to %d bytes (AES-256), but was %d" + .formatted(AES_256_KEY_BYTES, decoded.length)); + } + return decoded; + } + + private static byte[] deriveIdentitySalt(byte[] keyBytes) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(IDENTITY_SALT_LABEL.getBytes(StandardCharsets.UTF_8)); + return digest.digest(keyBytes); + } catch (NoSuchAlgorithmException unavailable) { + throw new IllegalStateException("SHA-256 is required to derive the cookie-mode session identity salt", + unavailable); + } + } + private static LogoutEndpoint buildLogoutEndpoint(OidcConfig oidc, String gatewayOrigin, ProviderMetadata metadata, SessionBinding sessionBinding) { Optional logout = oidc.logout(); 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..bf9e9aaf --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/CookieSessionBindingTest.java @@ -0,0 +1,282 @@ +/* + * 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.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 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 stable-but-never-emitted session identity, and the + * {@code UNSUPPORTED} IdP-driven destruction capability. + */ +class CookieSessionBindingTest { + + private static final String COOKIE_NAME = "__Host-sheriff-session"; + private static final Duration TTL = Duration.ofHours(8); + 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 SealedSessionCookieCodec codec; + private CookieSessionBinding binding; + + @BeforeEach + void setUp() { + byte[] keyMaterial = new byte[32]; + Arrays.fill(keyMaterial, (byte) 0x11); + SecretKey key = new SecretKeySpec(keyMaterial, "AES"); + codec = new SealedSessionCookieCodec(COOKIE_NAME, TTL, key, (byte) 1); + byte[] salt = new byte[32]; + Arrays.fill(salt, (byte) 0x22); + binding = new CookieSessionBinding(codec, salt); + } + + 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 record = resolved.get(); + assertEquals(ACCESS_TOKEN, record.accessToken()); + assertEquals(Optional.of(REFRESH_TOKEN), record.refreshToken()); + assertEquals(ID_TOKEN, record.idToken()); + assertEquals(SUB, record.sub()); + assertEquals(Optional.of(SID), record.sid()); + assertEquals(LOGIN.plus(TTL), record.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("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 record = session(ACCESS_TOKEN, LOGIN.plus(TTL)); + + binding.destroy(record); + + 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..6478461d --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionCookieCodecTest.java @@ -0,0 +1,335 @@ +/* + * 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 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; 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 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, 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 CookieSizeBudgetExceededException { + SealedSessionPayload original = payload(); + + String sealed = codec.seal(original); + Optional unsealed = codec.unseal(sealed); + + assertEquals(Optional.of(original), unsealed, "the payload survives the round trip intact"); + } + + @Test + @DisplayName("Should draw a fresh nonce per seal, so two seals of one payload differ") + void shouldUseAFreshNoncePerSeal() throws CookieSizeBudgetExceededException { + 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 CookieSizeBudgetExceededException { + 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 CookieSizeBudgetExceededException { + 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 CookieSizeBudgetExceededException { + 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 CookieSizeBudgetExceededException { + 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 CookieSizeBudgetExceededException { + String sealed = codec.seal(payload()); + SealedSessionCookieCodec otherName = new SealedSessionCookieCodec(OTHER_COOKIE_NAME, TTL, 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 CookieSizeBudgetExceededException { + 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 CookieSizeBudgetExceededException { + SealedSessionCookieCodec otherKeyId = new SealedSessionCookieCodec(COOKIE_NAME, TTL, 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 CookieSizeBudgetExceededException { + SealedSessionCookieCodec foreign = + new SealedSessionCookieCodec(COOKIE_NAME, TTL, 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("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(SealedSessionCookieCodec.MAX_COOKIE_VALUE_BYTES * 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(SealedSessionCookieCodec.MAX_COOKIE_VALUE_BYTES, 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 CookieSizeBudgetExceededException { + 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 CookieSizeBudgetExceededException { + 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 CookieSizeBudgetExceededException { + 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 CookieSizeBudgetExceededException { + 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(SealedSessionCookieCodec.MAX_COOKIE_VALUE_BYTES * 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..bc83dc23 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/SealedSessionPayloadTest.java @@ -0,0 +1,200 @@ +/* + * 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.Optional; + +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); + } + + @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"); + } + } + + @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/quarkus/BffRuntimeProducerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java index 7591662a..f07e9eeb 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; @@ -93,7 +95,59 @@ 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 refuse to boot cookie mode without a usable encryption key") + void shouldRefuseCookieModeWithoutKey() { + 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(); + + assertThrows(IllegalStateException.class, () -> producer(Optional.of(noKey)).bffRuntime(), + "a cookie-mode gateway with no sealing key must fail closed at boot, never serve unsealed"); + } + + @Test + @DisplayName("Should refuse an encryption key that is not a base64 AES-256 value") + void shouldRefuseMalformedKey() { + assertThrows(IllegalStateException.class, + () -> producer(cookieModeOidcWithKey("not-base64-~~~")).bffRuntime()); + assertThrows(IllegalStateException.class, + () -> producer(cookieModeOidcWithKey(Base64.getEncoder().encodeToString(new byte[16]))) + .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 +158,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 +258,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/doc/LogMessages.adoc b/doc/LogMessages.adoc index 07ce9762..78f7126d 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 session re-sealed under the current key (%s) |Logged when a session sealed under the decrypt-only `previous_key` is re-sealed under the current key on its next write, completing that session's rotation; `%s` is a bounded disposition — never key or token material |=== == WARN Level (100-199) @@ -63,6 +66,9 @@ diagnostics use the logger directly and are not catalogued. |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 +|ApiSheriff-115 |BFF |Back-channel logout disabled for the active session binding (%s) |Logged when the OIDC back-channel logout endpoint is disabled because the active session binding cannot honour IdP-driven `sid`/`sub` destruction (cookie mode holds no server-side index); `%s` is the non-sensitive reason |=== == ERROR Level (200-299) From 17b87d49afb4ae4b3fa8dac7a4a035880c5fed10 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:04:12 +0200 Subject: [PATCH 03/24] feat(bff): add decrypt-only previous_key rotation --- .../bff/cookie/CookieSessionBinding.java | 37 ++++++- .../bff/cookie/SealedSessionCookieCodec.java | 102 ++++++++++++++---- .../bff/cookie/CookieSessionBindingTest.java | 96 +++++++++++++++-- .../cookie/SealedSessionCookieCodecTest.java | 77 ++++++++++++- 4 files changed, 283 insertions(+), 29 deletions(-) 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 index 0563e4a8..e83e4698 100644 --- 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 @@ -24,9 +24,11 @@ import java.util.Objects; import java.util.Optional; +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; @@ -47,6 +49,15 @@ * 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 event is recorded once as INFO + * {@code COOKIE_RESEALED_WITH_CURRENT_KEY} carrying only a bounded disposition. 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 @@ -60,9 +71,14 @@ */ 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; @@ -92,8 +108,8 @@ public Optional resolve(@Nullable String cookieHeader, Instant no Objects.requireNonNull(now, "now"); return codec.readSealedValue(cookieHeader) .flatMap(codec::unseal) - .filter(payload -> !payload.isExpired(codec.sessionTtl(), now)) - .map(this::toSessionRecord); + .filter(unsealed -> !unsealed.payload().isExpired(codec.sessionTtl(), now)) + .map(this::markForReseal); } @Override @@ -150,6 +166,23 @@ private BoundSession seal(SealedSessionPayload payload, Instant now) { 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. + */ + private SessionRecord markForReseal(SealedSessionCookieCodec.Unsealed unsealed) { + if (unsealed.sealedWithPreviousKey()) { + LOGGER.info(BffLogMessages.INFO.COOKIE_RESEALED_WITH_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); 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 index e711ae35..26a5e31c 100644 --- 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 @@ -56,6 +56,15 @@ * {@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 {@link #MAX_COOKIE_VALUE_BYTES} 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 @@ -103,9 +112,13 @@ public final class SealedSessionCookieCodec { private final Duration sessionTtl; private final SecretKey currentKey; private final byte currentKeyId; + private final @Nullable SecretKey previousKey; + private final byte previousKeyId; /** - * Assembles the codec for one cookie name, session lifetime, and sealing key. + * 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 @@ -117,6 +130,35 @@ public SealedSessionCookieCodec(String cookieName, Duration sessionTtl, SecretKe this.sessionTtl = Objects.requireNonNull(sessionTtl, "sessionTtl"); 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 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, SecretKey currentKey, byte currentKeyId, + SecretKey previousKey, byte previousKeyId) { + this.cookieName = requireNonBlank(cookieName); + this.sessionTtl = Objects.requireNonNull(sessionTtl, "sessionTtl"); + 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; } /** @@ -159,10 +201,11 @@ public String seal(SealedSessionPayload payload) throws CookieSizeBudgetExceeded * 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; 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 + * @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) { + public Optional unseal(String cookieValue) { Objects.requireNonNull(cookieValue, "cookieValue"); byte[] raw; try { @@ -178,8 +221,16 @@ public Optional unseal(String cookieValue) { return reject(DISPOSITION_UNKNOWN_VERSION); } byte keyId = raw[1]; - SecretKey key = keyFor(keyId); - if (key == null) { + // 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); } @@ -203,7 +254,7 @@ public Optional unseal(String cookieValue) { if (payload.isEmpty()) { return reject(DISPOSITION_PAYLOAD); } - return payload; + return payload.map(decoded -> new Unsealed(decoded, sealedWithPreviousKey)); } /** @@ -261,22 +312,12 @@ public Duration sessionTtl() { return sessionTtl; } - /** - * Selects the key for a cookie's key id. Deterministic by design — never a try-both decrypt. - * - * @param keyId the key id read from the cookie header - * @return the matching key, or {@code null} when the id is unknown - */ - private @Nullable SecretKey keyFor(byte keyId) { - return keyId == currentKeyId ? currentKey : null; - } - 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) { + private static Optional reject(String disposition) { LOGGER.warn(BffLogMessages.WARN.COOKIE_UNSEAL_REJECTED, disposition); return Optional.empty(); } @@ -289,6 +330,31 @@ private static String requireNonBlank(String cookieName) { 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. 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 index bf9e9aaf..1ff22dc3 100644 --- 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 @@ -23,6 +23,7 @@ import java.time.Duration; import java.time.Instant; import java.util.Arrays; +import java.util.Base64; import java.util.Optional; import javax.crypto.SecretKey; @@ -41,7 +42,9 @@ * 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 stable-but-never-emitted session identity, and the + * 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. */ class CookieSessionBindingTest { @@ -54,19 +57,35 @@ class CookieSessionBindingTest { 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() { - byte[] keyMaterial = new byte[32]; - Arrays.fill(keyMaterial, (byte) 0x11); - SecretKey key = new SecretKeySpec(keyMaterial, "AES"); - codec = new SealedSessionCookieCodec(COOKIE_NAME, TTL, key, (byte) 1); + codec = new SealedSessionCookieCodec(COOKIE_NAME, TTL, 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); - binding = new CookieSessionBinding(codec, salt); + 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) { @@ -203,6 +222,71 @@ void shouldKeepDeadlineAnchoredAcrossReseal() { } } + @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, previousKey, PREVIOUS_KEY_ID), identitySalt()); + rotatingBinding = new CookieSessionBinding( + new SealedSessionCookieCodec(COOKIE_NAME, TTL, 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 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 { 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 index 6478461d..507ff715 100644 --- 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 @@ -32,6 +32,7 @@ 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; @@ -46,7 +47,9 @@ * 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; and the absence of key or token material from every emitted + * 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 { @@ -99,9 +102,10 @@ void shouldRoundTrip() throws CookieSizeBudgetExceededException { SealedSessionPayload original = payload(); String sealed = codec.seal(original); - Optional unsealed = codec.unseal(sealed); + Optional unsealed = codec.unseal(sealed); - assertEquals(Optional.of(original), unsealed, "the payload survives the round trip intact"); + assertEquals(Optional.of(new Unsealed(original, false)), unsealed, + "the payload survives the round trip intact, authenticated by the current key"); } @Test @@ -208,6 +212,73 @@ void shouldRejectMalformedValue() { } } + @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, previousKey, PREVIOUS_KEY_ID); + rotating = new SealedSessionCookieCodec(COOKIE_NAME, TTL, 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 CookieSizeBudgetExceededException { + 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 CookieSizeBudgetExceededException { + 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 CookieSizeBudgetExceededException { + 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 CookieSizeBudgetExceededException { + String sealedUnderPreviousKey = retiredCodec.seal(payload()); + SealedSessionCookieCodec currentKeyOnly = new SealedSessionCookieCodec(COOKIE_NAME, TTL, 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, key, KEY_ID, previousKey, KEY_ID)); + + assertTrue(thrown.getMessage().contains("previousKeyId"), thrown.getMessage()); + } + } + @Nested @DisplayName("Size budget") class SizeBudget { From 7bfd04419894978a64629d6ee1086e9a28444e6b Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:19:03 +0200 Subject: [PATCH 04/24] feat(bff): add two-mode cookie key material --- .../gateway/bff/cookie/CookieKeyMaterial.java | 263 ++++++++++++++++++ .../gateway/config/model/OidcConfig.java | 15 +- .../config/validation/ConfigValidator.java | 19 +- .../gateway/quarkus/BffRuntimeProducer.java | 72 ++--- .../bff/cookie/CookieKeyMaterialTest.java | 244 ++++++++++++++++ .../validation/ConfigValidatorTest.java | 46 ++- .../quarkus/BffRuntimeProducerTest.java | 30 +- doc/configuration.adoc | 32 ++- 8 files changed, 647 insertions(+), 74 deletions(-) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/CookieKeyMaterial.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/CookieKeyMaterialTest.java 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..1c6e9dc4 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/cookie/CookieKeyMaterial.java @@ -0,0 +1,263 @@ +/* + * 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)} 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 + * @return the codec sealing under the current key and, when rotating, unsealing under both + */ + public SealedSessionCookieCodec codec(String cookieName, Duration sessionTtl) { + SecretKey retired = previousKey; + if (retired == null) { + return new SealedSessionCookieCodec(cookieName, sessionTtl, currentKey, currentKeyId()); + } + return new SealedSessionCookieCodec(cookieName, sessionTtl, 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); + } + } + + /** + * 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/config/model/OidcConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/OidcConfig.java index 181584cf..1f3b1a1a 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 @@ -127,9 +127,18 @@ public record Logout(Optional path, Optional postLogoutRedirectU * when omitted * @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 diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidator.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidator.java index 9f8d8acc..78839390 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidator.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidator.java @@ -731,12 +731,25 @@ 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 ("cookie".equals(mode) && 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 ("server".equals(mode) && session.store().isEmpty()) { errors.add(new ConfigError(GATEWAY_FILE, "/oidc/session/store", 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 48b9b5c7..f6192842 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 @@ -16,12 +16,8 @@ package de.cuioss.sheriff.gateway.quarkus; import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.time.Clock; import java.time.Duration; -import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -31,13 +27,9 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; -import javax.crypto.SecretKey; -import javax.crypto.spec.SecretKeySpec; - - 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; @@ -121,9 +113,6 @@ public class BffRuntimeProducer { private static final String SESSION_MODE_SERVER = "server"; private static final String SESSION_MODE_COOKIE = "cookie"; - private static final int AES_256_KEY_BYTES = 32; - private static final byte COOKIE_KEY_ID_CURRENT = 1; - private static final String IDENTITY_SALT_LABEL = "api-sheriff:cookie-session-identity"; 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; @@ -304,50 +293,27 @@ private BffRuntime build(OidcConfig oidc) { } /** - * Assembles the stateless cookie-mode binding: the AES-256-GCM sealed-cookie codec over the - * configured {@code session.encryption_key}, 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. + * 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. */ private static SessionBinding cookieSessionBinding(Optional session, String cookieName, Duration sessionTtl) { - byte[] keyBytes = decodeAesKey(session.flatMap(OidcConfig.Session::encryptionKey) - .orElseThrow(() -> new IllegalStateException( - "session.mode=cookie requires session.encryption_key"))); - SecretKey key = new SecretKeySpec(keyBytes, "AES"); - SealedSessionCookieCodec codec = new SealedSessionCookieCodec(cookieName, sessionTtl, key, - COOKIE_KEY_ID_CURRENT); - return new CookieSessionBinding(codec, deriveIdentitySalt(keyBytes)); - } - - /** - * Decodes the operator-supplied base64 AES-256 key. The value is an {@code ${ENV_VAR}} - * reference in config (ADR-0011), so the material itself never lives in a descriptor. - */ - private static byte[] decodeAesKey(String encoded) { - byte[] decoded; - try { - decoded = Base64.getDecoder().decode(encoded.trim()); - } catch (IllegalArgumentException notBase64) { - throw new IllegalStateException("session.encryption_key is not valid base64", notBase64); - } - if (decoded.length != AES_256_KEY_BYTES) { - throw new IllegalStateException( - "session.encryption_key must decode to %d bytes (AES-256), but was %d" - .formatted(AES_256_KEY_BYTES, decoded.length)); - } - return decoded; - } - - private static byte[] deriveIdentitySalt(byte[] keyBytes) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - digest.update(IDENTITY_SALT_LABEL.getBytes(StandardCharsets.UTF_8)); - return digest.digest(keyBytes); - } catch (NoSuchAlgorithmException unavailable) { - throw new IllegalStateException("SHA-256 is required to derive the cookie-mode session identity salt", - unavailable); - } + CookieKeyMaterial keyMaterial = CookieKeyMaterial.resolve( + session.flatMap(OidcConfig.Session::encryptionKey), + session.flatMap(OidcConfig.Session::previousKey)); + LOGGER.debug("Cookie-mode key material resolved: mode=%s, rotating=%s", + keyMaterial.mode().diagnosticName(), keyMaterial.hasPreviousKey()); + return new CookieSessionBinding(keyMaterial.codec(cookieName, sessionTtl), keyMaterial.identitySalt()); } private static LogoutEndpoint buildLogoutEndpoint(OidcConfig oidc, String gatewayOrigin, ProviderMetadata metadata, 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..68d016c1 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/cookie/CookieKeyMaterialTest.java @@ -0,0 +1,244 @@ +/* + * 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 de.cuioss.sheriff.gateway.bff.cookie.SealedSessionCookieCodec.CookieSizeBudgetExceededException; + +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 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 CookieSizeBudgetExceededException { + // 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).seal(payload()); + + CookieKeyMaterial rotating = + CookieKeyMaterial.resolve(Optional.of(CURRENT_KEY_B64), Optional.of(PREVIOUS_KEY_B64)); + SealedSessionCookieCodec rotatingCodec = rotating.codec(COOKIE_NAME, TTL); + + 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 CookieSizeBudgetExceededException { + SealedSessionCookieCodec codec = CookieKeyMaterial + .resolve(Optional.of(CURRENT_KEY_B64), Optional.empty()) + .codec(COOKIE_NAME, TTL); + + 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 ~~~"; + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> CookieKeyMaterial.resolve(Optional.of(offending), Optional.empty())); + + 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]); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> CookieKeyMaterial.resolve(Optional.of(tooShort), Optional.empty())); + + 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() { + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> CookieKeyMaterial + .resolve(Optional.of(CURRENT_KEY_B64), Optional.of(Base64.getEncoder().encodeToString(new byte[8])))); + + 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() { + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> CookieKeyMaterial.resolve(Optional.of(CURRENT_KEY_B64), Optional.of(CURRENT_KEY_B64))); + + 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() { + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> CookieKeyMaterial.resolve(Optional.empty(), Optional.of(PREVIOUS_KEY_B64))); + + 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 CookieSizeBudgetExceededException { + SealedSessionCookieCodec firstBoot = + CookieKeyMaterial.resolve(Optional.empty(), Optional.empty()).codec(COOKIE_NAME, TTL); + SealedSessionCookieCodec secondBoot = + CookieKeyMaterial.resolve(Optional.empty(), Optional.empty()).codec(COOKIE_NAME, TTL); + + 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() { + byte[] salt = CookieKeyMaterial.resolve(Optional.empty(), Optional.empty()).identitySalt(); + byte[] otherSalt = CookieKeyMaterial.resolve(Optional.empty(), Optional.empty()).identitySalt(); + + assertEquals(32, salt.length); + 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/config/validation/ConfigValidatorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidatorTest.java index 8c72f374..cbc1b570 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 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 f07e9eeb..0eab6837 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 @@ -119,8 +119,8 @@ void shouldWireTheSameReservedEndpoints() { } @Test - @DisplayName("Should refuse to boot cookie mode without a usable encryption key") - void shouldRefuseCookieModeWithoutKey() { + @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")) @@ -130,8 +130,30 @@ void shouldRefuseCookieModeWithoutKey() { .session(Optional.of(OidcConfig.Session.builder().mode(Optional.of("cookie")).build())) .build(); - assertThrows(IllegalStateException.class, () -> producer(Optional.of(noKey)).bffRuntime(), - "a cookie-mode gateway with no sealing key must fail closed at boot, never serve unsealed"); + 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(); + + assertThrows(IllegalStateException.class, () -> producer(Optional.of(previousOnly)).bffRuntime(), + "a decrypt-only rotation key with no current key to roll onto is refused, never ignored"); } @Test diff --git a/doc/configuration.adoc b/doc/configuration.adoc index 9900464e..86d561dc 100644 --- a/doc/configuration.adoc +++ b/doc/configuration.adoc @@ -262,8 +262,10 @@ 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 ttl_seconds: 3600 # absolute session lifetime from login csrf: trusted_origins: [https://app.example.com] # app origins allowed on unsafe methods; @@ -1190,9 +1192,20 @@ 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.ttl_seconds` | *Absolute* session lifetime, counted from a login timestamp stored inside the session record @@ -1564,9 +1577,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 From 366d4425aac16ca36db7b2593dc3b64b5fe654b4 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:34:09 +0200 Subject: [PATCH 05/24] feat(bff): re-seal cookie sessions on token refresh --- .../bff/cookie/CookieSessionBinding.java | 15 +- .../bff/refresh/TokenRefreshCoordinator.java | 15 +- .../runtime/SessionAuthenticationStage.java | 29 +++- .../gateway/quarkus/BffRuntimeProducer.java | 10 +- .../gateway/auth/AuthenticationStageTest.java | 3 +- .../refresh/TokenRefreshCoordinatorTest.java | 143 ++++++++++++++++++ .../SessionAuthenticationStageTest.java | 74 ++++++++- .../edge/GatewayEdgeRouteBffWiringTest.java | 6 +- 8 files changed, 276 insertions(+), 19 deletions(-) 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 index e83e4698..7deac870 100644 --- 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 @@ -112,12 +112,23 @@ public Optional resolve(@Nullable String cookieHeader, Instant no .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"); - // The absolute deadline lives in expiresAt, which the login anchored; re-deriving the login - // instant from it keeps the re-seal from extending the session. Instant loginInstant = rotated.expiresAt().minus(codec.sessionTtl()); return seal(payloadOf(rotated, loginInstant), now); } 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 025ab64e..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 @@ -25,6 +25,7 @@ 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.token.client.token.RotationResult; @@ -74,6 +75,14 @@ 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; @@ -159,14 +168,14 @@ private RefreshOutcome performRefresh(@Nullable String cookieHeader, Instant now // 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.debug("Refreshed the mediated tokens for a require:session route (single-flight)"); + 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. sessionBinding.destroy(latest); - LOGGER.debug(refreshFailure, - "Token refresh failed (IdP rejection or refresh-token reuse) — session destroyed"); + // 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(); } } 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 773c2b50..27831518 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 @@ -47,7 +47,9 @@ *

  • 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) and emits any {@code Set-Cookie} the seam - * returns, so a binding that re-binds on refresh reaches the browser on the same response;
  • + * returns, so a binding that re-binds on refresh reaches the browser on the same response. A + * failed refresh destroyed the session, so the stage clears the browser's copy and + * re-drives the same unauthenticated negotiation rather than mediating a revoked token; *
  • enforces the route's {@code required_scopes} against the mediated token's granted * scopes through the {@link GrantedScopes} seam — a shortfall is {@code 403} * {@link EventType#SCOPE_MISSING} (the D2c residual);
  • @@ -123,9 +125,21 @@ public void process(PipelineRequest request) { return; } - SessionBinding.BoundSession refreshed = tokenRefresh.refreshIfNeeded(resolved.get(), cookieHeader, now); - emitSetCookies(request, refreshed.setCookieHeaders()); - SessionRecord session = refreshed.session(); + 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; on the navigation branch the login + // challenge replaces it with its own binding cookie, which supersedes it either way. + 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()); } @@ -196,9 +210,12 @@ public interface TokenRefresh { * 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 + * 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 */ - SessionBinding.BoundSession refreshIfNeeded(SessionRecord session, @Nullable String cookieHeader, Instant now); + Optional refreshIfNeeded(SessionRecord session, @Nullable String cookieHeader, + Instant now); } /** 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 f6192842..56320e31 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 @@ -241,8 +241,14 @@ private BffRuntime build(OidcConfig oidc) { (sessionRecord, cookieHeader, now) -> { TokenRefreshCoordinator.RefreshOutcome outcome = refreshCoordinator.refresh(sessionRecord, cookieHeader, now); - return new SessionBinding.BoundSession(outcome.session().orElse(sessionRecord), - outcome.setCookieHeaders()); + 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), 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 790175c7..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,6 +26,7 @@ import java.time.ZoneOffset; import java.util.List; import java.util.Map; +import java.util.Optional; import de.cuioss.sheriff.gateway.bff.runtime.SessionAuthenticationStage; @@ -194,7 +195,7 @@ private static SessionAuthenticationStage sessionStage() { .build()); SessionCookieCodec codec = new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, Duration.ofHours(1)); return new SessionAuthenticationStage(new ServerSessionBinding(store, codec), - (session, cookieHeader, now) -> new SessionBinding.BoundSession(session, List.of()), + (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/refresh/TokenRefreshCoordinatorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinatorTest.java index 0783c856..9996a4a0 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,6 +23,7 @@ import java.time.Duration; import java.time.Instant; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -35,6 +36,11 @@ 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; @@ -296,6 +302,143 @@ 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, 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 { 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 8625a311..085f3c2f 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 @@ -59,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)); @@ -86,8 +87,8 @@ void injectsMediatedBearer() { void injectsRefreshedTokenAfterRefresh() { SessionBinding binding = bindingWith(session(MEDIATED_TOKEN)); SessionAuthenticationStage.TokenRefresh rotating = - (session, cookieHeader, now) -> - new SessionBinding.BoundSession(rebind(session, REFRESHED_TOKEN), List.of()); + (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()); @@ -97,6 +98,24 @@ 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(RESEAL_COOKIE, request.responseHeaders().get("Set-Cookie"), + "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() { @@ -185,6 +204,49 @@ 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(binding.clearingSetCookieHeader(), request.responseHeaders().get("Set-Cookie"), + "the stale cookie is cleared so the browser stops presenting a destroyed session"); + } + + @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 @@ -264,7 +326,13 @@ private static SessionAuthenticationStage stage(SessionBinding binding, } private static SessionAuthenticationStage.TokenRefresh identityRefresh() { - return (session, cookieHeader, now) -> new SessionBinding.BoundSession(session, List.of()); + 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() { 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 414af74c..841610db 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 @@ -323,7 +323,8 @@ private BffRuntime formPostRuntime() { }, pendingStore, bindingCodec, sessionBinding, Duration.ofHours(1)); SessionAuthenticationStage sessionStage = new SessionAuthenticationStage(sessionBinding, - (session, cookieHeader, instant) -> new SessionBinding.BoundSession(session, List.of()), + (session, cookieHeader, instant) -> + Optional.of(new SessionBinding.BoundSession(session, List.of())), (token, scopes) -> true, (returnUrl, instant) -> new SessionAuthenticationStage.LoginChallenge("/login", List.of()), Clock.systemUTC()); @@ -387,7 +388,8 @@ private static BffRuntime activeRuntime(SessionBinding binding) { }, pendingStore, bindingCodec, ORIGIN); SessionAuthenticationStage sessionStage = new SessionAuthenticationStage(binding, - (session, cookieHeader, instant) -> new SessionBinding.BoundSession(session, List.of()), + (session, cookieHeader, instant) -> + Optional.of(new SessionBinding.BoundSession(session, List.of())), (token, scopes) -> true, (returnUrl, instant) -> new SessionAuthenticationStage.LoginChallenge("/login", List.of()), Clock.systemUTC()); From d652b59f670b8e2fd345c4344c3e429761f73f2c Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:55:10 +0200 Subject: [PATCH 06/24] feat(bff): gate the back-channel logout endpoint to 404 in cookie mode Drive the back-channel logout gate off the SessionBinding sid/sub destruction capability rather than a mode string: when idpDestruction() reports UNSUPPORTED the endpoint answers 404 before parsing the form body, so the logout_token is never read and the receiver is never reached. Each gated request records the catalogued WARN COOKIE_BACKCHANNEL_DISABLED with a bounded, non-sensitive reason and no token material. Server-mode behaviour (200 with the destroyed count, 400 on a missing or rejected token) is unchanged, and the reserved path stays registered in both modes so it returns a deliberate 404 instead of falling through to the proxy route table. The BffRuntime dispatch arm already renders every back-channel outcome uncacheable, so the 404 is served Cache-Control: no-store like the 200/400 outcomes. --- .../reserved/BackchannelLogoutEndpoint.java | 49 ++++++++-- .../gateway/bff/runtime/BffRuntime.java | 9 +- .../gateway/quarkus/BffRuntimeProducer.java | 6 +- .../BackchannelLogoutEndpointTest.java | 96 +++++++++++++++++-- .../edge/GatewayEdgeRouteBffWiringTest.java | 5 +- .../quarkus/BffRuntimeProducerTest.java | 25 +++++ 6 files changed, 170 insertions(+), 20 deletions(-) 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..97367be4 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 @@ -22,7 +22,9 @@ import java.util.Optional; +import de.cuioss.sheriff.gateway.bff.BffLogMessages; 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 +44,17 @@ * 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. Each rejected request records the catalogued WARN + * {@code COOKIE_BACKCHANNEL_DISABLED} carrying only a bounded, non-sensitive reason — no token + * material. 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 +71,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 +99,20 @@ 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. + LOGGER.warn(BffLogMessages.WARN.COOKIE_BACKCHANNEL_DISABLED, 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 +157,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 +182,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/runtime/BffRuntime.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java index 2c9de828..982beb8f 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()); } 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 56320e31..b3f0244f 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 @@ -279,11 +279,15 @@ private BffRuntime build(OidcConfig oidc) { 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), sessionBinding); - BackchannelLogoutEndpoint backchannelLogoutEndpoint = new BackchannelLogoutEndpoint(backchannelReceiver); + 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 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 af1608ab..62006d39 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,36 +23,50 @@ 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 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. */ 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( @@ -65,14 +79,34 @@ 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), 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 ServerSessionBinding(new InMemorySessionStore(16), - new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, Duration.ofHours(8)))); - return new BackchannelLogoutEndpoint(receiver); + }, validator, binding); + return new BackchannelLogoutEndpoint(receiver, binding); } @Test @@ -117,4 +151,52 @@ 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()); + } + } } 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 841610db..232f5521 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 @@ -341,7 +341,8 @@ private BffRuntime formPostRuntime() { rawToken -> { throw new AssertionError("engine verify must not be reached"); }, - new LogoutTokenValidator(ORIGIN, "client", Duration.ofMinutes(2)), sessionBinding)); + 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())); @@ -411,7 +412,7 @@ private static BffRuntime activeRuntime(SessionBinding binding) { rawToken -> { throw new AssertionError("engine verify must not be reached"); }, - new LogoutTokenValidator(ORIGIN, "client", Duration.ofMinutes(2)), binding)); + new LogoutTokenValidator(ORIGIN, "client", Duration.ofMinutes(2)), binding), binding); UserInfoEndpoint userInfo = new UserInfoEndpoint(binding, new ClaimAllowlistFilter(List.of("sub"), List.of("sub")), 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 0eab6837..cfdd3aab 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 @@ -84,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() { @@ -118,6 +129,20 @@ void shouldWireTheSameReservedEndpoints() { 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() { From 93aaffe227ec6c12f137fb3eb54dc29730339082 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:11:14 +0200 Subject: [PATCH 07/24] test(bff): add in-module cookie-mode boot fixture and generate-on-startup guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the config/cookieboot fixture (gateway.yaml, topology.properties, and one require: session endpoint) mirroring the landed config/testboot layout, plus CookieModeBootTest: a @QuarkusTest whose profile points sheriff.config.dir at the fixture and asserts the gateway boots active in cookie mode with NO encryption_key configured — the generate-on-startup key mode — and that the resulting binding reports the UNSUPPORTED sid/sub capability, observed at the runtime surface as the back-channel path answering an uncacheable 404. The fixture carries a token_validation block because an active BFF mode resolves the shared @GatewayValidator TokenValidator at boot, and an oidc.client_secret as a bare ${VAR} because the D4 secrets rule refuses a literal and the confidential-client engine refuses a blank secret; the surefire environment supplies that throwaway value. The test starts no container and reaches no network — OIDC discovery is lazy — so it runs in the ordinary surefire phase, before the Docker suite. --- api-sheriff/pom.xml | 16 ++++ .../gateway/quarkus/CookieModeBootTest.java | 88 +++++++++++++++++++ .../cookieboot/endpoints/bff-cookie.yaml | 16 ++++ .../resources/config/cookieboot/gateway.yaml | 68 ++++++++++++++ .../config/cookieboot/topology.properties | 7 ++ 5 files changed, 195 insertions(+) create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/CookieModeBootTest.java create mode 100644 api-sheriff/src/test/resources/config/cookieboot/endpoints/bff-cookie.yaml create mode 100644 api-sheriff/src/test/resources/config/cookieboot/gateway.yaml create mode 100644 api-sheriff/src/test/resources/config/cookieboot/topology.properties 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/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..975f4e0c --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/CookieModeBootTest.java @@ -0,0 +1,88 @@ +/* + * 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 From 1419cd1d1c27578088c3bf3b1e0590d97c593cbc Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:24:13 +0200 Subject: [PATCH 08/24] test(bff): add cookie-mode deployment descriptors and the activation wiring guard Add sheriff-config-cookie/gateway.yaml mirroring the sheriff-config-mtls overlay pattern (session.mode: cookie, encryption_key as a bare ${SESSION_ENCRYPTION_KEY} reference, no store) and exactly one additive api-sheriff-cookie compose service following the api-sheriff-mtls precedent: same image, shared sheriff-config mount plus the cookie gateway.yaml overlay, its own port 10445, and the session-key environment variable. No existing service, network, or volume definition is touched. Add BffCookieActivationWiringTest, the fast no-Docker surefire guard that parses the committed descriptors and asserts the variant is actually switched on: cookie session mode with no store, the sealing key supplied by env reference rather than generated per boot, the back-channel path still registered so its capability gate answers 404 instead of falling through, and the compose service publishing 10445 with the overlay mounted. Deliverable 8 is incomplete: the three Docker-backed cookie IT suites are blocked on two descriptor-level conflicts raised for a planning decision. --- integration-tests/docker-compose.yml | 84 +++++++ .../docker/sheriff-config-cookie/gateway.yaml | 156 +++++++++++++ .../BffCookieActivationWiringTest.java | 206 ++++++++++++++++++ 3 files changed, 446 insertions(+) create mode 100644 integration-tests/src/main/docker/sheriff-config-cookie/gateway.yaml create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieActivationWiringTest.java diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index 6c7c923c..adfb1933 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -361,6 +361,90 @@ 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 + prometheus: image: prom/prometheus:v3.6.0 ports: 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..a92197b5 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieActivationWiringTest.java @@ -0,0 +1,206 @@ +/* + * 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"; + private static final String COOKIE_OVERLAY_MOUNT = + "./src/main/docker/sheriff-config-cookie/gateway.yaml:/app/sheriff-config/gateway.yaml:ro"; + + @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("SESSION_ENCRYPTION_KEY=")); + + // 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 -> entry.equals("SESSION_ENCRYPTION_KEY=")), + "SESSION_ENCRYPTION_KEY must carry a value — a blank key cannot seal a session"); + } + + @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); + } + } +} From ae6bff3a59b53b1932de4eb83cd4a10f9d4d8fe8 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:38:18 +0200 Subject: [PATCH 09/24] test(bff): prove the sealed cookie session across two instances end to end Add the Variant 3 cookie-mode integration suites and the deployment wiring they need. BffCookieSessionIT drives the login, steady-state mediation, logout and tamper round trips against the dedicated cookie-mode gateway, asserting the __Host- prefixed Secure/HttpOnly/SameSite=Lax cookie and that its value carries no readable segment of the live mediated token. BffCookieStatelessnessIT lands the statelessness proof literally rather than by inference: a session sealed by api-sheriff-cookie (10445) is replayed against a second, separate gateway process, api-sheriff-cookie-2 (10446), that shares nothing with the first but the AES sealing key -- no session store, no volume, no sticky routing. It also pins that the peer refuses a cookie it cannot unseal, so being served is evidence about the key rather than about laxness. BffCookieBackchannelDisabledIT pins the capability gate on both bindings side by side: 404 no-store in cookie mode, the unchanged 400 contract in server mode. Two operator-approved widenings of the plan's write boundary, both additive: - docker-compose.yml gains a SECOND cookie-mode service, api-sheriff-cookie-2, a literal clone of api-sheriff-cookie on 10446/19003 using the same image, the same sheriff-config-cookie overlay and a byte-identical SESSION_ENCRYPTION_KEY. No new network, no new volume, no edit to any existing service, no change to the compose networking. - scripts/start-integration-container.sh gains the missing readiness waits for both cookie instances' published management ports (19002 and 19003), mirroring the existing 19001 mTLS block. This makes the compose comment true rather than deleting its claim, and stops the ITs racing either instance. Additive only; the script is not restructured. BffCookieActivationWiringTest is the fast no-Docker guard that the committed descriptors actually activate the variant, extended here to assert the peer instance exists, publishes 10446, mounts the same overlay, and carries the identical sealing key -- the descriptor precondition the statelessness proof rests on. --- integration-tests/docker-compose.yml | 84 +++++++ .../scripts/start-integration-container.sh | 30 +++ .../BffCookieActivationWiringTest.java | 63 ++++- .../BffCookieBackchannelDisabledIT.java | 89 +++++++ .../integration/BffCookieSessionIT.java | 223 ++++++++++++++++++ .../integration/BffCookieStatelessnessIT.java | 168 +++++++++++++ .../integration/BffKeycloakLoginFlow.java | 83 ++++++- 7 files changed, 728 insertions(+), 12 deletions(-) create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieBackchannelDisabledIT.java create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieSessionIT.java create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieStatelessnessIT.java diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index adfb1933..54f09433 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -445,6 +445,90 @@ services: - 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/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/test/java/de/cuioss/sheriff/gateway/integration/BffCookieActivationWiringTest.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieActivationWiringTest.java index a92197b5..30bdd610 100644 --- 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 @@ -62,9 +62,15 @@ class BffCookieActivationWiringTest { 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 { @@ -152,16 +158,69 @@ void composeSuppliesTheSealingKey() throws Exception { 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("SESSION_ENCRYPTION_KEY=")); + 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 -> entry.equals("SESSION_ENCRYPTION_KEY=")), + assertFalse(environment.stream().anyMatch(entry -> entry.equals(SEALING_KEY_VAR)), "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); 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..d8e94b51 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieSessionIT.java @@ -0,0 +1,223 @@ +/* + * 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 java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.restassured.http.Cookie; +import io.restassured.response.Response; + +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(); + String mediatedToken = compactJwtIn(String.valueOf(echo.path("headers.Authorization"))); + + // 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..acb8571c --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieStatelessnessIT.java @@ -0,0 +1,168 @@ +/* + * 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 java.util.HashMap; +import java.util.Map; + +import io.restassured.response.Response; + +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); } /** From fcff1702185f46dd620fba86071be133562e1f8d Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:40:40 +0200 Subject: [PATCH 10/24] docs(bff): record the accepted @Nullable record-component layout The ReservedHttpRequest and ReservedHttpResponse record headers carry an unusual component layout -- a line break between each @Nullable annotation and the component it annotates. That layout is what the formatter inherited from de.cuioss:cui-java-parent produces, and it reads as an accident that invites a well-meant hand-reflow, which the next format-check silently undoes. Name it instead: a short comment above each of the two record declarations records that the layout is formatter-produced, that it is accepted rather than fought because this project declares no formatter plugin of its own and adding one is out of scope, and that the header must therefore not be hand-reflowed. The two records are peers of the same structure, so both are annotated in the same change. Comments only -- no code, annotation, or component-ordering change, and no pom.xml or .editorconfig modification. --- .../cuioss/sheriff/gateway/bff/runtime/BffRuntime.java | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 982beb8f..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 @@ -285,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 @@ -328,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, From 9d7e1301ead640effab5bac3fd7ce496bad32ad1 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:46:24 +0200 Subject: [PATCH 11/24] style(bff): apply the quality gate's in-place normalizations The pre-commit quality gate rewrites sources in place. Land what it produced across this plan's files so the tree matches the gate's own output and the next run is a no-op: - import-block whitespace normalized to the inherited convention - test methods that declared a single checked exception now declare throws Exception, per the inherited JUnit recipe - one constant-first equals() comparison No behavioural change; the gate passes clean on the result. --- .../gateway/bff/cookie/CookieKeyMaterial.java | 2 +- .../bff/cookie/CookieSessionBinding.java | 1 + .../bff/cookie/SealedSessionCookieCodec.java | 2 +- .../gateway/quarkus/BffRuntimeProducer.java | 1 + .../bff/cookie/CookieKeyMaterialTest.java | 9 ++--- .../bff/cookie/CookieSessionBindingTest.java | 2 +- .../cookie/SealedSessionCookieCodecTest.java | 38 +++++++++---------- .../refresh/TokenRefreshCoordinatorTest.java | 3 +- .../BackchannelLogoutEndpointTest.java | 2 +- .../bff/session/ServerSessionBindingTest.java | 1 + .../edge/GatewayEdgeRouteBffWiringTest.java | 8 ++-- .../gateway/quarkus/CookieModeBootTest.java | 1 + .../BffCookieActivationWiringTest.java | 2 +- .../integration/BffCookieSessionIT.java | 5 +-- .../integration/BffCookieStatelessnessIT.java | 3 +- 15 files changed, 40 insertions(+), 40 deletions(-) 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 index 1c6e9dc4..d68fae32 100644 --- 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 @@ -23,11 +23,11 @@ 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; 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 index 7deac870..05ae6bfe 100644 --- 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 @@ -24,6 +24,7 @@ import java.util.Objects; import java.util.Optional; + import de.cuioss.sheriff.gateway.bff.BffLogMessages; import de.cuioss.sheriff.gateway.bff.cookie.SealedSessionCookieCodec.CookieSizeBudgetExceededException; import de.cuioss.sheriff.gateway.bff.session.SessionBinding; 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 index 26a5e31c..500abd97 100644 --- 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 @@ -24,11 +24,11 @@ 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; 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 b3f0244f..4e16dfc3 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 @@ -27,6 +27,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; + import de.cuioss.sheriff.gateway.auth.GatewayValidator; import de.cuioss.sheriff.gateway.bff.cookie.CookieKeyMaterial; import de.cuioss.sheriff.gateway.bff.cookie.CookieSessionBinding; 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 index 68d016c1..4f12c5df 100644 --- 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 @@ -27,7 +27,6 @@ import java.util.Base64; import java.util.Optional; -import de.cuioss.sheriff.gateway.bff.cookie.SealedSessionCookieCodec.CookieSizeBudgetExceededException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -81,7 +80,7 @@ void shouldResolvePassedKey() { @Test @DisplayName("Should carry a key sealed before the rotation over into the rotating material") - void shouldAcceptPreviousKey() throws CookieSizeBudgetExceededException { + 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()); @@ -93,7 +92,7 @@ void shouldAcceptPreviousKey() throws CookieSizeBudgetExceededException { assertTrue(rotating.hasPreviousKey()); assertEquals(Optional.of(payload()), rotatingCodec.unseal(sealedBeforeRotation) - .map(SealedSessionCookieCodec.Unsealed::payload), + .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"); @@ -105,7 +104,7 @@ void shouldAcceptPreviousKey() throws CookieSizeBudgetExceededException { @Test @DisplayName("Should survive a round trip through the codec it builds") - void shouldRoundTripThroughItsCodec() throws CookieSizeBudgetExceededException { + void shouldRoundTripThroughItsCodec() throws Exception { SealedSessionCookieCodec codec = CookieKeyMaterial .resolve(Optional.of(CURRENT_KEY_B64), Optional.empty()) .codec(COOKIE_NAME, TTL); @@ -198,7 +197,7 @@ void shouldSelectGeneratedMode() { @Test @DisplayName("Should generate a distinct key per boot, so a restart drops every session") - void shouldGenerateADistinctKeyPerBoot() throws CookieSizeBudgetExceededException { + void shouldGenerateADistinctKeyPerBoot() throws Exception { SealedSessionCookieCodec firstBoot = CookieKeyMaterial.resolve(Optional.empty(), Optional.empty()).codec(COOKIE_NAME, TTL); SealedSessionCookieCodec secondBoot = 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 index 1ff22dc3..851a3dc2 100644 --- 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 @@ -25,10 +25,10 @@ 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; 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 index 507ff715..96bdc013 100644 --- 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 @@ -27,10 +27,10 @@ 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; @@ -98,7 +98,7 @@ class RoundTrip { @Test @DisplayName("Should unseal a sealed payload back to an equal payload") - void shouldRoundTrip() throws CookieSizeBudgetExceededException { + void shouldRoundTrip() throws Exception { SealedSessionPayload original = payload(); String sealed = codec.seal(original); @@ -110,7 +110,7 @@ void shouldRoundTrip() throws CookieSizeBudgetExceededException { @Test @DisplayName("Should draw a fresh nonce per seal, so two seals of one payload differ") - void shouldUseAFreshNoncePerSeal() throws CookieSizeBudgetExceededException { + void shouldUseAFreshNoncePerSeal() throws Exception { SealedSessionPayload original = payload(); String first = codec.seal(original); @@ -123,7 +123,7 @@ void shouldUseAFreshNoncePerSeal() throws CookieSizeBudgetExceededException { @Test @DisplayName("Should carry the version and key id in the first two bytes of the value") - void shouldCarryVersionAndKeyIdHeader() throws CookieSizeBudgetExceededException { + void shouldCarryVersionAndKeyIdHeader() throws Exception { byte[] raw = Base64.getUrlDecoder().decode(codec.seal(payload())); assertEquals(SealedSessionCookieCodec.FORMAT_VERSION, raw[0]); @@ -137,7 +137,7 @@ class FailClosed { @Test @DisplayName("Should reject a flipped ciphertext byte as no session") - void shouldRejectFlippedCiphertextByte() throws CookieSizeBudgetExceededException { + 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); @@ -147,7 +147,7 @@ void shouldRejectFlippedCiphertextByte() throws CookieSizeBudgetExceededExceptio @Test @DisplayName("Should reject a flipped nonce byte as no session") - void shouldRejectFlippedNonceByte() throws CookieSizeBudgetExceededException { + void shouldRejectFlippedNonceByte() throws Exception { String sealed = codec.seal(payload()); assertTrue(codec.unseal(flipByteAt(sealed, 2)).isEmpty(), "a tampered nonce fails the tag check"); @@ -155,7 +155,7 @@ void shouldRejectFlippedNonceByte() throws CookieSizeBudgetExceededException { @Test @DisplayName("Should reject a flipped tag byte as no session") - void shouldRejectFlippedTagByte() throws CookieSizeBudgetExceededException { + void shouldRejectFlippedTagByte() throws Exception { String sealed = codec.seal(payload()); int lastByte = Base64.getUrlDecoder().decode(sealed).length - 1; @@ -164,7 +164,7 @@ void shouldRejectFlippedTagByte() throws CookieSizeBudgetExceededException { @Test @DisplayName("Should reject a value sealed for a different cookie name (AAD binding)") - void shouldRejectCookieNameMismatch() throws CookieSizeBudgetExceededException { + void shouldRejectCookieNameMismatch() throws Exception { String sealed = codec.seal(payload()); SealedSessionCookieCodec otherName = new SealedSessionCookieCodec(OTHER_COOKIE_NAME, TTL, key, KEY_ID); @@ -174,7 +174,7 @@ void shouldRejectCookieNameMismatch() throws CookieSizeBudgetExceededException { @Test @DisplayName("Should reject an unknown format version") - void shouldRejectUnknownVersion() throws CookieSizeBudgetExceededException { + void shouldRejectUnknownVersion() throws Exception { String sealed = codec.seal(payload()); byte[] raw = Base64.getUrlDecoder().decode(sealed); raw[0] = (byte) (SealedSessionCookieCodec.FORMAT_VERSION + 1); @@ -185,7 +185,7 @@ void shouldRejectUnknownVersion() throws CookieSizeBudgetExceededException { @Test @DisplayName("Should reject an unknown key id without trying the key it does hold") - void shouldRejectUnknownKeyId() throws CookieSizeBudgetExceededException { + void shouldRejectUnknownKeyId() throws Exception { SealedSessionCookieCodec otherKeyId = new SealedSessionCookieCodec(COOKIE_NAME, TTL, key, OTHER_KEY_ID); String sealedUnderOtherId = otherKeyId.seal(payload()); @@ -195,7 +195,7 @@ void shouldRejectUnknownKeyId() throws CookieSizeBudgetExceededException { @Test @DisplayName("Should reject a value sealed under a different key") - void shouldRejectForeignKey() throws CookieSizeBudgetExceededException { + void shouldRejectForeignKey() throws Exception { SealedSessionCookieCodec foreign = new SealedSessionCookieCodec(COOKIE_NAME, TTL, aesKey((byte) 0x22), KEY_ID); String sealedUnderForeignKey = foreign.seal(payload()); @@ -231,7 +231,7 @@ void setUpRotation() { @Test @DisplayName("Should accept a value sealed under the previous key and flag it for re-seal") - void shouldAcceptPreviousKeyValue() throws CookieSizeBudgetExceededException { + void shouldAcceptPreviousKeyValue() throws Exception { SealedSessionPayload original = payload(); String sealedUnderPreviousKey = retiredCodec.seal(original); @@ -243,7 +243,7 @@ void shouldAcceptPreviousKeyValue() throws CookieSizeBudgetExceededException { @Test @DisplayName("Should never seal under the previous key id, so a rollover is one-way") - void shouldNeverSealWithThePreviousKey() throws CookieSizeBudgetExceededException { + void shouldNeverSealWithThePreviousKey() throws Exception { byte[] raw = Base64.getUrlDecoder().decode(rotating.seal(payload())); assertEquals(KEY_ID, raw[1], "seal always stamps the current key id"); @@ -252,7 +252,7 @@ void shouldNeverSealWithThePreviousKey() throws CookieSizeBudgetExceededExceptio @Test @DisplayName("Should still flag a current-key value as not sealed with the previous key") - void shouldNotFlagCurrentKeyValue() throws CookieSizeBudgetExceededException { + void shouldNotFlagCurrentKeyValue() throws Exception { Optional unsealed = rotating.unseal(rotating.seal(payload())); assertTrue(unsealed.isPresent()); @@ -261,7 +261,7 @@ void shouldNotFlagCurrentKeyValue() throws CookieSizeBudgetExceededException { @Test @DisplayName("Should treat a previous-key value as no session once the previous key is withdrawn") - void shouldRejectPreviousKeyValueAfterWithdrawal() throws CookieSizeBudgetExceededException { + void shouldRejectPreviousKeyValueAfterWithdrawal() throws Exception { String sealedUnderPreviousKey = retiredCodec.seal(payload()); SealedSessionCookieCodec currentKeyOnly = new SealedSessionCookieCodec(COOKIE_NAME, TTL, key, KEY_ID); @@ -311,7 +311,7 @@ class SetCookieHeader { @Test @DisplayName("Should emit the landed hardening attributes") - void shouldEmitHardenedAttributes() throws CookieSizeBudgetExceededException { + void shouldEmitHardenedAttributes() throws Exception { String header = codec.toSetCookieHeader(codec.seal(payload()), LOGIN, LOGIN); assertTrue(header.startsWith(COOKIE_NAME + "="), header); @@ -323,7 +323,7 @@ void shouldEmitHardenedAttributes() throws CookieSizeBudgetExceededException { @Test @DisplayName("Should set Max-Age to the remaining lifetime, so a re-seal never extends the session") - void shouldSetMaxAgeToRemainingLifetime() throws CookieSizeBudgetExceededException { + void shouldSetMaxAgeToRemainingLifetime() throws Exception { String sealed = codec.seal(payload()); Instant halfway = LOGIN.plus(TTL.dividedBy(2)); @@ -337,7 +337,7 @@ void shouldSetMaxAgeToRemainingLifetime() throws CookieSizeBudgetExceededExcepti @Test @DisplayName("Should clamp Max-Age at zero past the absolute deadline") - void shouldClampMaxAgeAtZero() throws CookieSizeBudgetExceededException { + void shouldClampMaxAgeAtZero() throws Exception { String header = codec.toSetCookieHeader(codec.seal(payload()), LOGIN, LOGIN.plus(TTL).plusSeconds(60)); assertTrue(header.contains("Max-Age=0"), header); @@ -381,7 +381,7 @@ class NoSecretDisclosure { @Test @DisplayName("Should keep token material out of the emitted Set-Cookie header") - void shouldNotLeakTokensIntoTheHeader() throws CookieSizeBudgetExceededException { + 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"); 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 9996a4a0..9f7d7593 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 @@ -34,11 +34,10 @@ 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; 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 62006d39..dbaa2ed4 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 @@ -27,10 +27,10 @@ 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; 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 index 9339b214..ee90542e 100644 --- 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 @@ -24,6 +24,7 @@ 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; 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 232f5521..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 @@ -338,10 +338,10 @@ 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)), sessionBinding), + 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")), 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 index 975f4e0c..3586028c 100644 --- 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 @@ -22,6 +22,7 @@ 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; 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 index 30bdd610..14617f29 100644 --- 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 @@ -164,7 +164,7 @@ void composeSuppliesTheSealingKey() throws Exception { // 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 -> entry.equals(SEALING_KEY_VAR)), + assertFalse(environment.stream().anyMatch(entry -> SEALING_KEY_VAR.equals(entry)), "SESSION_ENCRYPTION_KEY must carry a value — a blank key cannot seal a session"); } 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 index d8e94b51..5ad009cf 100644 --- 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 @@ -21,15 +21,14 @@ 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 io.restassured.http.Cookie; -import io.restassured.response.Response; - import de.cuioss.sheriff.gateway.integration.BffKeycloakLoginFlow.Session; import org.junit.jupiter.api.DisplayName; 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 index acb8571c..b9260b76 100644 --- 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 @@ -20,11 +20,10 @@ 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 io.restassured.response.Response; - import de.cuioss.sheriff.gateway.integration.BffKeycloakLoginFlow.Session; import org.junit.jupiter.api.DisplayName; From 2ad42c5f5010272b678fb7d4219e7dc59f6cb699 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:19:15 +0200 Subject: [PATCH 12/24] fix(bff): drive the pre-route Cookie cap and the seal budget from one config key Cookie mode could not serve a single authenticated request: every request carrying a live sealed session cookie was rejected 400 at the edge before any BFF logic ran. Two independent constants encoded the same limit and had drifted apart -- SecurityConfiguration.defaults().maxHeaderValueLength (2048), hard-coded into the pre-route BasicChecksStage by GatewayEdgeRoute, versus SealedSessionCookieCodec.MAX_COOKIE_VALUE_BYTES (4096), enforced at seal time. CHOSEN DIRECTION (operator-selected, overriding the ranked options recorded on TASK-016): remove the root cause rather than the symptom. The task recommended a blanket Cookie-header exemption from the length check; that treats the symptom and leaves the two contradicting constants in place. Instead ONE declared number now drives BOTH ends of the round trip, so the contradiction is unrepresentable: * New config key oidc.session.max_cookie_size (default 4096), validated in ConfigValidator with the same rigour as its sibling session keys -- floor 40 (the encoded length of a structurally minimal sealed value, below which no cookie could ever be emitted) and ceiling 8192 (kept well below the 16 KiB inbound header-block limit, so a configured value can never exceed what the transport carries). * SealedSessionCookieCodec's seal-time budget is that configured value. MAX_COOKIE_VALUE_BYTES is REMOVED, not deprecated or kept as a fallback -- leaving a second independent constant behind is exactly the defect. * The pre-route cap derives from the same value (budget + 512 bytes headroom for the cookie name and co-resident cookies). THE LIFT IS SCOPED TWICE -- the narrowest relaxation that works: * By MODE: GatewayEdgeRoute no longer hard-codes SecurityConfiguration .defaults() for the pre-route stage; it derives the Cookie policy from gatewayConfig + bffRuntime, following the pattern already used a few lines above for forwarded()/oidc()/tls()/securityHeaders(). A bearer-only or server-mode gateway keeps 2048 on every header, unchanged. * By HEADER: within a cookie-mode gateway the raised cap applies to Cookie and Set-Cookie values ONLY. BasicChecksStage.validateHeaders already had the header name in hand at the loop head -- that is the seam. Every other header keeps 2048, and every non-length validator (null-byte, control-character, injection-pattern) still applies to the cookie value. RECORDED CONSTRAINT: per-anchor scoping is unreachable for this stage. It runs BEFORE route selection and GatewayEdgeRoute is one bean holding the whole route table, so no anchor exists at that point. The key lives on the session block but enforcement is necessarily gateway-wide. Stated in the code and in the reference docs so the key's placement is not mistaken for per-anchor enforcement. This remains a deliberate relaxation of an inbound hardening control on a security gateway. It is justified in code, held as narrow as the two scopings allow, and nothing beyond them is widened. Also in this change: * doc/configuration.adoc gains the reference-layer row for the new key (default, bounds, the cookie-mode-only + Cookie-header-only scoping, and the gateway-wide-enforcement caveat). A config key never merges undocumented. * dump-keycloak-logs.sh's failure-path app-log dump now names both cookie instances, so a CI-only recurrence is diagnosable from the uploaded artifact instead of requiring a local repro. * BffCookieSessionIT.sealedCookieValueCarriesNoReadableToken: a latent test defect the 400 had masked. Passing response.path(...) straight into String.valueOf() let the generic return type infer to char[], so the compiler inserted a cast the JSON-path proxy cannot satisfy. Bound to Object first, exactly as the sibling assertions in the same class already do. COVERAGE pinning the new boundary at module-test level: a Cookie header at the configured budget passes; one above the raised cap is rejected; an oversized NON-Cookie header still yields 400 on a cookie-mode gateway; and a server / bearer-only gateway still rejects an oversized Cookie header at 2048 (proving the lift did not leak across modes). Plus the ConfigValidator floor/ceiling bounds, on and outside the range. Verified: quality gate green, full verify green, and the full Docker integration profile green against a freshly rebuilt native image -- 75/75 ITs pass, with BffCookieSessionIT (5), BffCookieStatelessnessIT (3) and BffCookieBackchannelDisabledIT (2) all green and no pre-existing suite regressed. --- .../gateway/bff/cookie/CookieKeyMaterial.java | 14 ++- .../bff/cookie/SealedSessionCookieCodec.java | 110 +++++++++++++----- .../gateway/config/model/OidcConfig.java | 14 ++- .../config/validation/ConfigValidator.java | 28 +++++ .../gateway/edge/GatewayEdgeRoute.java | 60 +++++++++- .../gateway/pipeline/BasicChecksStage.java | 50 +++++++- .../gateway/quarkus/BffRuntimeProducer.java | 14 ++- .../bff/cookie/CookieKeyMaterialTest.java | 12 +- .../bff/cookie/CookieSessionBindingTest.java | 7 +- .../cookie/SealedSessionCookieCodecTest.java | 24 ++-- .../refresh/TokenRefreshCoordinatorTest.java | 3 +- .../BackchannelLogoutEndpointTest.java | 3 +- .../validation/ConfigValidatorTest.java | 32 +++++ .../pipeline/BasicChecksStageTest.java | Bin 7511 -> 11070 bytes doc/configuration.adoc | 35 ++++++ .../scripts/dump-keycloak-logs.sh | 11 +- .../integration/BffCookieSessionIT.java | 9 +- 17 files changed, 356 insertions(+), 70 deletions(-) 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 index d68fae32..4f29755d 100644 --- 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 @@ -138,17 +138,19 @@ public boolean hasPreviousKey() { * 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 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) { + public SealedSessionCookieCodec codec(String cookieName, Duration sessionTtl, int maxCookieValueBytes) { SecretKey retired = previousKey; if (retired == null) { - return new SealedSessionCookieCodec(cookieName, sessionTtl, currentKey, currentKeyId()); + return new SealedSessionCookieCodec(cookieName, sessionTtl, maxCookieValueBytes, currentKey, + currentKeyId()); } - return new SealedSessionCookieCodec(cookieName, sessionTtl, currentKey, currentKeyId(), retired, - keyIdOf(retired)); + return new SealedSessionCookieCodec(cookieName, sessionTtl, maxCookieValueBytes, currentKey, currentKeyId(), + retired, keyIdOf(retired)); } /** 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 index 500abd97..5cf0d874 100644 --- 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 @@ -65,10 +65,18 @@ * 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 {@link #MAX_COOKIE_VALUE_BYTES} 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. + * 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 @@ -89,18 +97,35 @@ public final class SealedSessionCookieCodec { /** The current sealed-cookie format version, bound into the GCM associated data. */ public static final byte FORMAT_VERSION = 1; - /** - * The sealed cookie-value size budget in bytes (~4 KB). Browsers are only required to accept - * 4096 bytes per cookie, so a larger value would be silently dropped by the browser. - */ - public static final int MAX_COOKIE_VALUE_BYTES = 4096; - 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"; @@ -110,6 +135,7 @@ public final class SealedSessionCookieCodec { 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; @@ -120,14 +146,18 @@ public final class SealedSessionCookieCodec { * 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 currentKey the AES-256 key new values are sealed under - * @param currentKeyId the id identifying {@code currentKey} in the cookie header + * @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, SecretKey currentKey, byte currentKeyId) { + 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; @@ -138,18 +168,21 @@ public SealedSessionCookieCodec(String cookieName, Duration sessionTtl, SecretKe * 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 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 + * @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, SecretKey currentKey, byte currentKeyId, - SecretKey previousKey, byte previousKeyId) { + 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"); @@ -166,8 +199,8 @@ public SealedSessionCookieCodec(String cookieName, Duration sessionTtl, SecretKe * * @param payload the session payload to seal * @return the base64url-encoded sealed cookie value - * @throws CookieSizeBudgetExceededException when the sealed value exceeds - * {@link #MAX_COOKIE_VALUE_BYTES} + * @throws CookieSizeBudgetExceededException when the sealed value exceeds the configured + * {@linkplain #maxCookieValueBytes() budget} */ public String seal(SealedSessionPayload payload) throws CookieSizeBudgetExceededException { Objects.requireNonNull(payload, "payload"); @@ -189,9 +222,9 @@ public String seal(SealedSessionPayload payload) throws CookieSizeBudgetExceeded 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() > MAX_COOKIE_VALUE_BYTES) { + if (encoded.length() > maxCookieValueBytes) { LOGGER.warn(BffLogMessages.WARN.COOKIE_SIZE_BUDGET_EXCEEDED, encoded.length()); - throw new CookieSizeBudgetExceededException(encoded.length(), MAX_COOKIE_VALUE_BYTES); + throw new CookieSizeBudgetExceededException(encoded.length(), maxCookieValueBytes); } LOGGER.info(BffLogMessages.INFO.COOKIE_SESSION_SEALED, encoded.length()); return encoded; @@ -312,6 +345,14 @@ 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(); @@ -322,6 +363,19 @@ private static Optional reject(String 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()) { 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 1f3b1a1a..236e02ed 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 @@ -146,13 +146,20 @@ 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) { /** * Canonical constructor normalizing absent components to {@link Optional#empty()}. @@ -167,6 +174,7 @@ 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), @@ -774,6 +777,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/GatewayEdgeRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java index 5dc3ce74..997499e6 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; @@ -148,6 +150,18 @@ public class GatewayEdgeRoute { private static final String PROBLEM_JSON = "application/problem+json"; private static final String DEFAULT_EMIT_MODE = "x-forwarded"; + + /** The {@code oidc.session.mode} value selecting the stateless sealed-cookie BFF variant. */ + private static final String SESSION_MODE_COOKIE = "cookie"; + + /** + * 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"; @@ -274,7 +288,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( @@ -999,6 +1014,49 @@ private static HttpClient clientFor(Vertx vertx, RouteRuntimeAssembler.UpstreamT * seeding the safe builder defaults and overriding only the limits the route declared, so an * undeclared dimension never falls below the gateway baseline. */ + /** + * 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); + boolean cookieMode = bffRuntime.isActive() && session.flatMap(OidcConfig.Session::mode) + .filter(SESSION_MODE_COOKIE::equalsIgnoreCase).isPresent(); + 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(); + } + private static SecurityConfiguration securityConfigurationFor(SecurityFilterConfig filter) { SecurityConfigurationBuilder builder = SecurityConfiguration.builder(); filter.maxBodyBytes().ifPresent(value -> builder.maxBodySize(value.longValue())); 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/quarkus/BffRuntimeProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java index 4e16dfc3..de020840 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 @@ -31,6 +31,7 @@ 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; @@ -316,15 +317,22 @@ private BffRuntime build(OidcConfig oidc) { * 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)); - LOGGER.debug("Cookie-mode key material resolved: mode=%s, rotating=%s", - keyMaterial.mode().diagnosticName(), keyMaterial.hasPreviousKey()); - return new CookieSessionBinding(keyMaterial.codec(cookieName, sessionTtl), keyMaterial.identitySalt()); + 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, 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 index 4f12c5df..a352c1cf 100644 --- 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 @@ -27,7 +27,6 @@ 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; @@ -45,6 +44,7 @@ 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); @@ -84,11 +84,11 @@ 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).seal(payload()); + 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); + SealedSessionCookieCodec rotatingCodec = rotating.codec(COOKIE_NAME, TTL, BUDGET); assertTrue(rotating.hasPreviousKey()); assertEquals(Optional.of(payload()), rotatingCodec.unseal(sealedBeforeRotation) @@ -107,7 +107,7 @@ void shouldAcceptPreviousKey() throws Exception { void shouldRoundTripThroughItsCodec() throws Exception { SealedSessionCookieCodec codec = CookieKeyMaterial .resolve(Optional.of(CURRENT_KEY_B64), Optional.empty()) - .codec(COOKIE_NAME, TTL); + .codec(COOKIE_NAME, TTL, BUDGET); assertEquals(Optional.of(payload()), codec.unseal(codec.seal(payload())) .map(SealedSessionCookieCodec.Unsealed::payload)); @@ -199,9 +199,9 @@ void shouldSelectGeneratedMode() { @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); + CookieKeyMaterial.resolve(Optional.empty(), Optional.empty()).codec(COOKIE_NAME, TTL, BUDGET); SealedSessionCookieCodec secondBoot = - CookieKeyMaterial.resolve(Optional.empty(), Optional.empty()).codec(COOKIE_NAME, TTL); + CookieKeyMaterial.resolve(Optional.empty(), Optional.empty()).codec(COOKIE_NAME, TTL, BUDGET); String sealedBeforeRestart = firstBoot.seal(payload()); 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 index 851a3dc2..334b4438 100644 --- 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 @@ -51,6 +51,7 @@ 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"; @@ -65,7 +66,7 @@ class CookieSessionBindingTest { @BeforeEach void setUp() { - codec = new SealedSessionCookieCodec(COOKIE_NAME, TTL, aesKey((byte) 0x11), CURRENT_KEY_ID); + codec = new SealedSessionCookieCodec(COOKIE_NAME, TTL, BUDGET, aesKey((byte) 0x11), CURRENT_KEY_ID); binding = new CookieSessionBinding(codec, identitySalt()); } @@ -233,9 +234,9 @@ class Rotation { void setUpRotation() { SecretKey previousKey = aesKey((byte) 0x33); retiredBinding = new CookieSessionBinding( - new SealedSessionCookieCodec(COOKIE_NAME, TTL, previousKey, PREVIOUS_KEY_ID), identitySalt()); + new SealedSessionCookieCodec(COOKIE_NAME, TTL, BUDGET, previousKey, PREVIOUS_KEY_ID), identitySalt()); rotatingBinding = new CookieSessionBinding( - new SealedSessionCookieCodec(COOKIE_NAME, TTL, aesKey((byte) 0x11), CURRENT_KEY_ID, + new SealedSessionCookieCodec(COOKIE_NAME, TTL, BUDGET, aesKey((byte) 0x11), CURRENT_KEY_ID, previousKey, PREVIOUS_KEY_ID), identitySalt()); } 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 index 96bdc013..a34149d4 100644 --- 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 @@ -57,6 +57,7 @@ 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; @@ -71,7 +72,7 @@ class SealedSessionCookieCodecTest { @BeforeEach void setUp() { key = aesKey((byte) 0x11); - codec = new SealedSessionCookieCodec(COOKIE_NAME, TTL, key, KEY_ID); + codec = new SealedSessionCookieCodec(COOKIE_NAME, TTL, BUDGET, key, KEY_ID); } private static SecretKey aesKey(byte fill) { @@ -166,7 +167,7 @@ void shouldRejectFlippedTagByte() throws Exception { @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, key, KEY_ID); + 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"); @@ -186,7 +187,7 @@ void shouldRejectUnknownVersion() throws Exception { @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, key, OTHER_KEY_ID); + SealedSessionCookieCodec otherKeyId = new SealedSessionCookieCodec(COOKIE_NAME, TTL, BUDGET, key, OTHER_KEY_ID); String sealedUnderOtherId = otherKeyId.seal(payload()); assertTrue(codec.unseal(sealedUnderOtherId).isEmpty(), @@ -197,7 +198,7 @@ void shouldRejectUnknownKeyId() throws Exception { @DisplayName("Should reject a value sealed under a different key") void shouldRejectForeignKey() throws Exception { SealedSessionCookieCodec foreign = - new SealedSessionCookieCodec(COOKIE_NAME, TTL, aesKey((byte) 0x22), KEY_ID); + 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"); @@ -225,8 +226,9 @@ class Rotation { @BeforeEach void setUpRotation() { previousKey = aesKey((byte) 0x33); - retiredCodec = new SealedSessionCookieCodec(COOKIE_NAME, TTL, previousKey, PREVIOUS_KEY_ID); - rotating = new SealedSessionCookieCodec(COOKIE_NAME, TTL, key, KEY_ID, previousKey, PREVIOUS_KEY_ID); + 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 @@ -263,7 +265,7 @@ void shouldNotFlagCurrentKeyValue() throws Exception { @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, key, KEY_ID); + 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"); @@ -273,7 +275,7 @@ void shouldRejectPreviousKeyValueAfterWithdrawal() throws Exception { @DisplayName("Should refuse a previous key that reuses the current key id") void shouldRefuseAmbiguousKeyIds() { IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, - () -> new SealedSessionCookieCodec(COOKIE_NAME, TTL, key, KEY_ID, previousKey, KEY_ID)); + () -> new SealedSessionCookieCodec(COOKIE_NAME, TTL, BUDGET, key, KEY_ID, previousKey, KEY_ID)); assertTrue(thrown.getMessage().contains("previousKeyId"), thrown.getMessage()); } @@ -286,14 +288,14 @@ class SizeBudget { @Test @DisplayName("Should refuse to seal a payload whose value exceeds the budget, never truncate") void shouldRefuseOversizedPayload() { - String huge = "x".repeat(SealedSessionCookieCodec.MAX_COOKIE_VALUE_BYTES * 2); + 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(SealedSessionCookieCodec.MAX_COOKIE_VALUE_BYTES, thrown.budget()); + assertEquals(BUDGET, thrown.budget()); assertTrue(thrown.sealedLength() > thrown.budget(), "the reported length is the value that would have been emitted"); } @@ -392,7 +394,7 @@ void shouldNotLeakTokensIntoTheHeader() throws Exception { @Test @DisplayName("Should keep key material out of the exception message on an oversized seal") void shouldNotLeakKeyMaterialIntoTheOverBudgetMessage() { - String huge = "x".repeat(SealedSessionCookieCodec.MAX_COOKIE_VALUE_BYTES * 2); + String huge = "x".repeat(BUDGET * 2); SealedSessionPayload oversized = new SealedSessionPayload(huge, Optional.empty(), ID_TOKEN, SUB, Optional.empty(), Optional.empty(), Optional.empty(), LOGIN); 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 9f7d7593..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 @@ -319,7 +319,8 @@ void setUpCookieMode() { byte[] salt = new byte[32]; Arrays.fill(salt, (byte) 0x22); cookieBinding = new CookieSessionBinding( - new SealedSessionCookieCodec(COOKIE_NAME, SESSION_TTL, key, (byte) 1), salt); + 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(); 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 dbaa2ed4..45189bdd 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 @@ -93,7 +93,8 @@ private static SessionBinding cookieBinding() { byte[] salt = new byte[32]; Arrays.fill(salt, (byte) 0x22); return new CookieSessionBinding( - new SealedSessionCookieCodec(COOKIE_NAME, Duration.ofHours(8), sealingKey, CURRENT_KEY_ID), salt); + new SealedSessionCookieCodec(COOKIE_NAME, Duration.ofHours(8), + SealedSessionCookieCodec.DEFAULT_COOKIE_VALUE_BUDGET, sealingKey, CURRENT_KEY_ID), salt); } private BackchannelLogoutEndpoint endpoint(AtomicBoolean verifierInvoked) { 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 cbc1b570..818f5cea 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 @@ -1433,5 +1433,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/pipeline/BasicChecksStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStageTest.java index 95ec2f3abfeee9a8847f0e8af679fdbefc52c899..f57d2436ecd152880d0376f875924552b5997bc6 100644 GIT binary patch delta 2132 zcmbVN&u<$=7!@Gd!7YiwjYuT7`s51L>-t9;rTj>mio~P@rNk&f5eHD1?9SRt);p`2 zStq7Kfxlr8XZR10a<6*ePA{lO5E5r@aH$aA%=KlOxF)C&0gKXF*95SmD7!-hUycgLVIoeuLb+6G@NAa06lN z=*zzX;&}pm#?2{<6(Z87Kq4=@flo2NtZ4g)SLtYNzy!$I1U3UKr(0>-XSGoK*9vqSriVNP@6<-!LAb# zxa!aR#aAs0A0l77`Vv@G-{kA6c&VVarV8re+$-uqe)hAv?cOL2uuI*92XfeEksovu zP9*BpJV=5NWvDvMJ~`erlI&?9cWKm>z3Mq`9DAbboE}bnUrc#9y)a*UNnI(IuN|4j~WrQhBfzy##Zah=On@bVP4|W6*!=1 zPimC*gJY1@9!|><5y0se)RoxU(!zpncyVFrZE$;p6I|t@scuhSRjtB>TegR4#X_mn zp|Zcf$%Jf(9uA%FH`4i9t)MF_q|)RxrbJe?Pt~XMW%Wm4M*TEjDx7ho{+XRurQ+2| zO@s`>Fvay>${f%ki@Ya{`*b08OoL)vh;?(iP2#Ep_mDdEs8n1Y3BqYej*a!e3 z+nw#qemxscDC9V0xF_|kM}3lnm^37QCN-U`_%@3yO*z|%dRThhAMxT6KA;T@JRP*c zWij4_5uwSkr&4>a0L6ksVEBY*mOZ327RFp@;Ni&YWJ@u&)%~KQu3XAp93O3UYx)iC zy+%gtCyh4F2kUG1ol%?B!037wr-CAW4lSGo!YOa)7=tth#u=Vy#`HR7$mg;fWgO}d zNWEtWE^7J4U8vc$F@RG#(0PkYhJlZyV3KHyg_Im(L~$mB0V*)CAWd)FjWVMWdZP}6 z(F<}s3)b)40ZY9uZe}}k1-=Po@2w}a1A2^ekGx>G4dUjXxAnKZzXdTi&=JlgknX3oWj+(k$o7L~wI@}F}Y%;%Uo;!~EVY;Nk*Irhg M%em8q^0gQK1&8L(rvLx| delta 40 ycmV+@0N4M%R@XYP{05U22@aD!2+NZX36`=8pb(S47A2Fg69uEt8w0Z(Dk1?&1rC1z diff --git a/doc/configuration.adoc b/doc/configuration.adoc index 86d561dc..a6c48e85 100644 --- a/doc/configuration.adoc +++ b/doc/configuration.adoc @@ -266,6 +266,9 @@ oidc: # only used by the BFF variants (2 and 3) # 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; @@ -1207,6 +1210,38 @@ ignored when no route's effective auth is `require: session`. 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 (server mode) or the encrypted cookie payload (cookie mode) -- enforcement is server-side, 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/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieSessionIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCookieSessionIT.java index 5ad009cf..eb5c53ce 100644 --- 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 @@ -116,7 +116,14 @@ void sealedCookieValueCarriesNoReadableToken() { Response echo = BffKeycloakLoginFlow.gateway(session.gatewayCookies(), COOKIE_ORIGIN) .when().get(SESSION_ROUTE) .then().statusCode(200).extract().response(); - String mediatedToken = compactJwtIn(String.valueOf(echo.path("headers.Authorization"))); + // 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("\\.")) { From 94eaffed08a601093a06d70b33317e2ba33422c7 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:53:03 +0200 Subject: [PATCH 13/24] chore(simplify): collapse accidental complexity in plan-07a-bff-cookie-crypto-core Inline the SessionCookieCodec construction into the server-mode branch of BffRuntimeProducer. It was built unconditionally ahead of the mode-selection ternary but consumed only in the server-mode branch, so every cookie-mode boot constructed and discarded it. Co-Authored-By: Claude --- .../de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 de020840..92ad4bd5 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 @@ -211,13 +211,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); // 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), sessionCookieCodec); + : new ServerSessionBinding(new InMemorySessionStore(maxSessions), + new SessionCookieCodec(cookieName, sessionTtl)); PendingAuthorizationStore pendingStore = new PendingAuthorizationStore.InMemory(DEFAULT_MAX_PENDING); Clock clock = Clock.systemUTC(); From a949748553ddc0e5c5dbde1965851c98fda932d9 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:31:18 +0200 Subject: [PATCH 14/24] fix(edge): cap the reserved-path request body (finding 0ced30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deliberate, operator-approved widening of this plan's scope onto a PRE-EXISTING PLAN-06 defect surfaced by the finalize security audit. GatewayEdgeRoute#readReservedBodyThenDispatch buffered the FULL request body of the two reserved, PRE-AUTHENTICATION POST paths (the OIDC response_mode=form_post callback and the back-channel logout receiver) under a 20-second wall-clock deadline and NO byte-size cap. That read happens before basicChecksStage / thoroughChecksStage run, so the SecurityConfiguration.maxBodySize cap that bounds ordinary proxied routes never applied to it, and EdgeHardeningOptions bounded only a single 16 KiB chunk rather than the cumulative body. On a 512M container that was an unauthenticated memory-exhaustion DoS vector. The read is now bounded by EdgeHardeningOptions.reservedBodyMaxBytes(), enforced twice (defence in depth): a) a Content-Length pre-check that refuses before a single body byte is buffered, and b) a streaming cumulative byte counter that aborts mid-read, closing the bypass a chunked / Content-Length-absent / lying-Content-Length body would otherwise have. The ceiling is DEFINED AS EdgeHardeningOptions.MAX_HEADER_SIZE_BYTES (16 KiB) rather than restated as a second literal, so the gateway's single inbound-unit bound cannot drift into two contradicting constants — the exact defect class this plan already fixed once for the cookie budget. Both payloads (a urlencoded code/state pair, a compact logout_token JWT) sit an order of magnitude below it, and the worst case at the 2048 admission cap is ~32 MiB. Either breach is rejected 413 (EventType.RESERVED_BODY_TOO_LARGE, the honest status for an over-large payload) through the surrounding RFC 9457 problem+json contract — never a 500, and never by silently truncating the body into the handler. The new WARN ApiSheriff-109 records the ceiling and a fixed disposition only, never the offending body, and is documented in doc/LogMessages.adoc. The 20-second deadline, the reserved-path dispatch shape, and the pipeline stages are untouched. Coverage: ReservedBodyCeilingTest drives a live Vert.x server and pins a body at the ceiling (accepted), one byte over (413), an over-ceiling declared Content-Length refused inside a bounded wait far below the body deadline (proving no buffering), and an over-ceiling chunked body with no declared length at all (proving the streaming counter). EdgeHardeningTest pins the derivation itself; EventTypeTest pins the new error-contract row. Also carries a whitespace-only reflow of BffRuntimeProducer emitted by the pre-commit formatter. --- .../gateway/ApiSheriffLogMessages.java | 14 +- .../gateway/edge/EdgeHardeningOptions.java | 37 +- .../gateway/edge/GatewayEdgeRoute.java | 111 ++++-- .../sheriff/gateway/events/EventType.java | 10 +- .../gateway/quarkus/BffRuntimeProducer.java | 2 +- .../gateway/edge/EdgeHardeningTest.java | 18 + .../gateway/edge/ReservedBodyCeilingTest.java | 338 ++++++++++++++++++ .../sheriff/gateway/events/EventTypeTest.java | 1 + doc/LogMessages.adoc | 1 + 9 files changed, 500 insertions(+), 32 deletions(-) create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/ReservedBodyCeilingTest.java 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/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 997499e6..9e1f83e1 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 @@ -84,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; @@ -123,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 @@ -389,23 +389,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. @@ -415,23 +440,56 @@ 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); + // 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); + } + /** * 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. @@ -671,9 +729,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); 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/quarkus/BffRuntimeProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java index 92ad4bd5..f69ab062 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 @@ -217,7 +217,7 @@ private BffRuntime build(OidcConfig oidc) { SessionBinding sessionBinding = isCookieMode(session) ? cookieSessionBinding(session, cookieName, sessionTtl) : new ServerSessionBinding(new InMemorySessionStore(maxSessions), - new SessionCookieCodec(cookieName, sessionTtl)); + new SessionCookieCodec(cookieName, sessionTtl)); PendingAuthorizationStore pendingStore = new PendingAuthorizationStore.InMemory(DEFAULT_MAX_PENDING); Clock clock = Clock.systemUTC(); 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/ReservedBodyCeilingTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/ReservedBodyCeilingTest.java new file mode 100644 index 00000000..7fed6cd7 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/ReservedBodyCeilingTest.java @@ -0,0 +1,338 @@ +/* + * 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.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); + } + + /** + * 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/doc/LogMessages.adoc b/doc/LogMessages.adoc index 78f7126d..b1cb6f65 100644 --- a/doc/LogMessages.adoc +++ b/doc/LogMessages.adoc @@ -63,6 +63,7 @@ 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 From 2872e0f642d4659559a4ee989463ff98d2aff1ad Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:27:40 +0200 Subject: [PATCH 15/24] fix(bff): decode out-of-range epoch seconds as no session Long.parseLong could succeed while Instant.ofEpochSecond threw DateTimeException, which is not an IllegalArgumentException and so escaped SealedSessionPayload.decode's catch and unseal() as an unhandled request error. Widen the catch and cover both the loginInstant and authTime fields with a regression test. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAAt7guDZEDA4S6nUTHcLW --- .../bff/cookie/SealedSessionPayload.java | 10 ++++-- .../bff/cookie/SealedSessionPayloadTest.java | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) 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 index e4c357c7..b52e0459 100644 --- 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 @@ -16,6 +16,7 @@ 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; @@ -117,9 +118,12 @@ public static Optional decode(byte[] encoded) { optionalField(fields[5]), optionalField(fields[6]).map(seconds -> Instant.ofEpochSecond(Long.parseLong(seconds))), Instant.ofEpochSecond(Long.parseLong(decodeField(fields[7]))))); - } catch (IllegalArgumentException malformed) { - // Base64 or epoch-second parse failure on bytes that authenticated: a foreign payload - // format under the same key. Report "no session" rather than propagating. + } 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(); } } 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 index bc83dc23..8b19f0ce 100644 --- 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 @@ -23,7 +23,10 @@ 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; @@ -55,6 +58,19 @@ private static SealedSessionPayload minimal() { 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 { @@ -106,6 +122,24 @@ void shouldDecodeNothingFromMalformedFields() { 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 From ff4d5ff956572828f970863697f99b462829d38c Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:31:14 +0200 Subject: [PATCH 16/24] fix(bff): log the back-channel capability gate at DEBUG, not WARN The gate fired a catalogued WARN before any signature or body verification on a reserved, unauthenticated path, so any caller who could reach the gateway could drive an unbounded WARN flood with no logout_token, session, or credential. Every sibling malformed-input path already logs DEBUG; match them and drop the now-unreferenced ApiSheriff-115 LogRecord and its catalogue entry. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAAt7guDZEDA4S6nUTHcLW --- .../sheriff/gateway/bff/BffLogMessages.java | 15 ++------------- .../reserved/BackchannelLogoutEndpoint.java | 14 +++++++++----- .../BackchannelLogoutEndpointTest.java | 19 ++++++++++++++++++- doc/LogMessages.adoc | 1 - 4 files changed, 29 insertions(+), 20 deletions(-) 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 f3385974..5c3d8d75 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-17} (INFO) and {@code 110-115} (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 @@ -130,7 +130,7 @@ public static final class INFO { } /** - * Warn-level messages (WARN range 100-199; this catalogue owns 110-115). + * Warn-level messages (WARN range 100-199; this catalogue owns 110-114). */ @UtilityClass public static final class WARN { @@ -188,16 +188,5 @@ public static final class WARN { .identifier(114) .template("Sealed session cookie exceeds the size budget (%s bytes) — seal refused") .build(); - - /** - * The OIDC back-channel logout endpoint is disabled because the active session binding - * cannot honour IdP-driven destruction (cookie mode holds no server-side index). Records - * only the non-sensitive reason. - */ - public static final LogRecord COOKIE_BACKCHANNEL_DISABLED = LogRecordModel.builder() - .prefix(PREFIX) - .identifier(115) - .template("Back-channel logout disabled for the active session binding (%s)") - .build(); } } 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 97367be4..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 @@ -22,7 +22,6 @@ import java.util.Optional; -import de.cuioss.sheriff.gateway.bff.BffLogMessages; import de.cuioss.sheriff.gateway.bff.logout.BackchannelLogoutReceiver; import de.cuioss.sheriff.gateway.bff.session.SessionBinding; import de.cuioss.tools.logging.CuiLogger; @@ -51,9 +50,11 @@ * {@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. Each rejected request records the catalogued WARN - * {@code COOKIE_BACKCHANNEL_DISABLED} carrying only a bounded, non-sensitive reason — no token - * material. Server-mode behaviour is unchanged. + * 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; @@ -109,7 +110,10 @@ public BackchannelLogoutOutcome receive(@Nullable String rawFormBody, Instant no 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. - LOGGER.warn(BffLogMessages.WARN.COOKIE_BACKCHANNEL_DISABLED, DISABLED_REASON); + // 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); } 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 45189bdd..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 @@ -43,6 +43,9 @@ 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; @@ -58,8 +61,10 @@ * {@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. + * 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"; @@ -199,5 +204,17 @@ void shouldGateMalformedBody() { 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/doc/LogMessages.adoc b/doc/LogMessages.adoc index b1cb6f65..e082244f 100644 --- a/doc/LogMessages.adoc +++ b/doc/LogMessages.adoc @@ -69,7 +69,6 @@ diagnostics use the logger directly and are not catalogued. |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 -|ApiSheriff-115 |BFF |Back-channel logout disabled for the active session binding (%s) |Logged when the OIDC back-channel logout endpoint is disabled because the active session binding cannot honour IdP-driven `sid`/`sub` destruction (cookie mode holds no server-side index); `%s` is the non-sensitive reason |=== == ERROR Level (200-299) From 45ff640176acfd28b67eddee0526fa55c26b2d1e Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:39:33 +0200 Subject: [PATCH 17/24] fix(bff): retain every Set-Cookie value on the response emitSetCookies wrote into the single-valued response-header map and took only findFirst(), so on the failed-refresh HTML navigation path the clearing cookie was overwritten by the login-challenge cookie and a revoked session cookie was left in the browser. Set-Cookie is legitimately multi-valued (RFC 6265 3), so PipelineRequest now carries a dedicated Set-Cookie accumulator that the edge renders as distinct header lines on every terminal path. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAAt7guDZEDA4S6nUTHcLW --- .../runtime/SessionAuthenticationStage.java | 17 +++++-- .../gateway/edge/GatewayEdgeRoute.java | 47 +++++++++++++++---- .../gateway/pipeline/PipelineRequest.java | 30 +++++++++++- .../SessionAuthenticationStageTest.java | 38 +++++++++++++-- 4 files changed, 114 insertions(+), 18 deletions(-) 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 27831518..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 @@ -78,7 +78,6 @@ 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; @@ -132,8 +131,10 @@ public void process(PipelineRequest request) { // 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; on the navigation branch the login - // challenge replaces it with its own binding cookie, which supersedes it either way. + // 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; @@ -144,9 +145,15 @@ public void process(PipelineRequest request) { request.mediatedBearer(session.accessToken()); } + /** + * 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.stream().findFirst() - .ifPresent(cookie -> request.responseHeaders().put(SET_COOKIE_HEADER, cookie)); + setCookieHeaders.forEach(request::addResponseSetCookie); } private void enforceScopes(RouteRuntime route, SessionRecord session) { 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 9e1f83e1..0fa96208 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 @@ -706,6 +706,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()) { @@ -713,6 +714,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). @@ -721,6 +723,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(); @@ -768,9 +780,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)); + }); } /** @@ -791,6 +806,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); } @@ -820,10 +836,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)); + }); } /** @@ -842,12 +861,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. @@ -878,6 +899,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()) { @@ -885,6 +907,7 @@ private void writeShortCircuit(RoutingContext ctx, PipelineRequest request) { } response.setStatusCode(status); responseHeaders.forEach(response::putHeader); + applyStageSetCookies(response, setCookies); response.end(); }); } @@ -899,7 +922,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); @@ -921,6 +948,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()) { @@ -928,6 +958,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); }); 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/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 085f3c2f..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 @@ -110,7 +110,7 @@ void emitsResealSetCookieOnRefresh() { stage.process(request); - assertEquals(RESEAL_COOKIE, request.responseHeaders().get("Set-Cookie"), + 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"); @@ -163,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"); } @@ -229,10 +229,42 @@ void clearsTheCookieOnFailedRefresh() { assertThrows(GatewayException.class, () -> stage.process(request)); - assertEquals(binding.clearingSetCookieHeader(), request.responseHeaders().get("Set-Cookie"), + 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() { From b0bbb52bd89751806b814439f4566454a91a1c7d Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:43:17 +0200 Subject: [PATCH 18/24] fix(bff): map an unbindable session to a clean 500 callback outcome SessionBinding.bind is documented to throw IllegalStateException when it cannot hold the session -- for cookie mode whenever the sealed value exceeds the cookie-size budget, a realistic outcome for a large ID token. handle() caught only TokenSheriffException, so that escaped as an unhandled exception. Also widen the auth_time claim parse, which had the same DateTimeException gap. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAAt7guDZEDA4S6nUTHcLW --- .../bff/reserved/CallbackEndpoint.java | 28 ++++++++-- .../bff/reserved/CallbackEndpointTest.java | 51 +++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) 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 bf1cb9c3..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,6 +17,7 @@ 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; @@ -89,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"; @@ -128,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"); @@ -200,7 +204,20 @@ private CallbackOutcome completeLogin(AuthorizationCodeFlow.AuthenticationResult .acr(claim(idToken, CLAIM_ACR)) .authTime(claimEpochSeconds(idToken, CLAIM_AUTH_TIME)) .build(); - SessionBinding.BoundSession bound = sessionBinding.bind(session, now); + 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 = new ArrayList<>(bound.setCookieHeaders()); setCookies.add(bindingCookieCodec.toClearingSetCookieHeader()); @@ -220,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(); } }); @@ -300,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/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 127fbba6..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,11 +23,15 @@ 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; @@ -113,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 { @@ -214,6 +248,23 @@ void shouldMapExchangeFailure() { 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 From 185b7875c09448289a1fe5ede07484b53ffcbefd Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:49:25 +0200 Subject: [PATCH 19/24] fix(config): canonicalize oidc.session.mode once, share one predicate ConfigValidator compared the mode case-sensitively while the edge cap, the runtime binding selection and the readiness check each compared it case-insensitively against their own private constant. A value like 'Cookie' therefore skipped both boot companion rules while still relaxing the pre-route Cookie header-value cap. OidcConfig.Session now canonicalizes the value at construction and owns the single isCookieMode/isServerMode predicate all four sites read; the four duplicated constants are gone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAAt7guDZEDA4S6nUTHcLW --- .../gateway/config/model/OidcConfig.java | 57 +++++++++++++++++-- .../config/validation/ConfigValidator.java | 29 +++++----- .../gateway/edge/GatewayEdgeRoute.java | 7 ++- .../gateway/quarkus/BffRuntimeProducer.java | 12 ++-- .../quarkus/GatewayReadinessCheck.java | 10 ++-- .../config/model/ConfigModelContractTest.java | 44 ++++++++++++++ .../validation/ConfigValidatorTest.java | 36 ++++++++++++ 7 files changed, 164 insertions(+), 31 deletions(-) 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 236e02ed..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,8 +124,12 @@ 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-256 sealing key ({@code ${ENV_VAR}} @@ -161,11 +166,28 @@ public record Session(Optional mode, Optional store, 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()); @@ -177,6 +199,33 @@ public record Session(Optional mode, Optional store, Optional securityHeaders, S * 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() - && 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 ("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")); + } + }); } /** 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 0fa96208..79b3e3c0 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 @@ -152,7 +152,6 @@ public class GatewayEdgeRoute { private static final String DEFAULT_EMIT_MODE = "x-forwarded"; /** The {@code oidc.session.mode} value selecting the stateless sealed-cookie BFF variant. */ - private static final String SESSION_MODE_COOKIE = "cookie"; /** * Headroom added to the configured sealed-cookie budget when deriving the pre-route @@ -1133,8 +1132,10 @@ private static HttpClient clientFor(Vertx vertx, RouteRuntimeAssembler.UpstreamT private static @Nullable SecurityConfiguration cookieHeaderConfigurationFor(GatewayConfig gatewayConfig, BffRuntime bffRuntime) { Optional session = gatewayConfig.oidc().flatMap(OidcConfig::session); - boolean cookieMode = bffRuntime.isActive() && session.flatMap(OidcConfig.Session::mode) - .filter(SESSION_MODE_COOKIE::equalsIgnoreCase).isPresent(); + // 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; } 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 f69ab062..54efd214 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 @@ -113,8 +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 String SESSION_MODE_COOKIE = "cookie"; 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; @@ -164,16 +162,16 @@ public BffRuntime bffRuntime() { * configured. An unrecognised or absent mode leaves the gateway bearer-only. */ private static boolean isBffMode(Optional oidc) { - boolean recognisedMode = oidc.flatMap(OidcConfig::session).flatMap(OidcConfig.Session::mode) - .filter(mode -> SESSION_MODE_SERVER.equalsIgnoreCase(mode) - || SESSION_MODE_COOKIE.equalsIgnoreCase(mode)) - .isPresent(); + boolean recognisedMode = oidc.flatMap(OidcConfig::session) + .map(OidcConfig.Session::isRecognisedMode).orElse(false); boolean hasRedirect = oidc.flatMap(OidcConfig::redirectUri).isPresent(); return recognisedMode && hasRedirect; } private static boolean isCookieMode(Optional session) { - return session.flatMap(OidcConfig.Session::mode).filter(SESSION_MODE_COOKIE::equalsIgnoreCase).isPresent(); + // 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) { 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/config/model/ConfigModelContractTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/model/ConfigModelContractTest.java index 38eb5d0b..7f58f90c 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 818f5cea..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 @@ -699,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() { From 0010b3aa46a31baac28087758abe5aeb8d055390 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:53:09 +0200 Subject: [PATCH 20/24] fix(edge): retire the connection after a reserved-body 413 Both 413 paths ended the response with the request body unconsumed, leaving pending bytes on a reused HTTP/1.1 keep-alive connection -- which desyncs the next request framed on it, or lets an attacker pin connections by repeatedly tripping the ceiling, undercutting the reserved-body DoS guard. The response now advertises Connection: close and the connection is closed once written. Also reattach the dangling Javadoc block to securityConfigurationFor (java:S8491). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAAt7guDZEDA4S6nUTHcLW --- .../gateway/edge/GatewayEdgeRoute.java | 39 ++++++++-- .../gateway/edge/ReservedBodyCeilingTest.java | 75 +++++++++++++++++++ 2 files changed, 107 insertions(+), 7 deletions(-) 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 79b3e3c0..fa5b5706 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 @@ -151,8 +151,6 @@ public class GatewayEdgeRoute { private static final String PROBLEM_JSON = "application/problem+json"; private static final String DEFAULT_EMIT_MODE = "x-forwarded"; - /** The {@code oidc.session.mode} value selecting the stateless sealed-cookie BFF variant. */ - /** * 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 @@ -165,6 +163,8 @@ public class GatewayEdgeRoute { 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"; @@ -484,11 +484,36 @@ private void rejectOversizedReservedBody(RoutingContext ctx, long ceiling, Strin 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. @@ -1098,11 +1123,6 @@ private static HttpClient clientFor(Vertx vertx, RouteRuntimeAssembler.UpstreamT return vertx.createHttpClient(options); } - /** - * 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 - * undeclared dimension never falls below the gateway baseline. - */ /** * 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. @@ -1148,6 +1168,11 @@ private static HttpClient clientFor(Vertx vertx, RouteRuntimeAssembler.UpstreamT .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 + * undeclared dimension never falls below the gateway baseline. + */ private static SecurityConfiguration securityConfigurationFor(SecurityFilterConfig filter) { SecurityConfigurationBuilder builder = SecurityConfiguration.builder(); filter.maxBodyBytes().ifPresent(value -> builder.maxBodySize(value.longValue())); 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 index 7fed6cd7..98831b02 100644 --- 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 @@ -22,6 +22,7 @@ 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; @@ -205,6 +206,80 @@ void rejectsOverCeilingChunkedBody() throws Exception { "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 From efec0789f6dee62503fb31841786932ca8301135 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:54:21 +0200 Subject: [PATCH 21/24] refactor(bff): use unnamed patterns for unread unseal catch parameters Both catch parameters in SealedSessionCookieCodec.unseal were declared but never referenced (java:S7467). The rejection class each branch covers now lives in an adjacent comment instead of an unused binding. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAAt7guDZEDA4S6nUTHcLW --- .../gateway/bff/cookie/SealedSessionCookieCodec.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 5cf0d874..40f4d604 100644 --- 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 @@ -243,7 +243,9 @@ public Optional unseal(String cookieValue) { byte[] raw; try { raw = Base64.getUrlDecoder().decode(cookieValue); - } catch (IllegalArgumentException notBase64) { + } 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) { @@ -278,7 +280,7 @@ public Optional unseal(String cookieValue) { cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, nonce)); cipher.updateAAD(associatedData(version, keyId)); plaintext = cipher.doFinal(sealed); - } catch (GeneralSecurityException tamperedOrMisconfigured) { + } 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); From 001581bb3639a1046eac074bc33b21f9988174bc Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:59:56 +0200 Subject: [PATCH 22/24] docs(bff): correct cookie-mode statements and bound the rollover INFO Four statements were wrong about cookie mode: the session-identity docs implied one model for both bindings (cookie mode actually discards the minted id and derives its own), the assembly diagnostic hard-coded 'Server-mode' on a path now reached by both modes, a Javadoc link named a signature that no longer exists, and the rotation INFO fired on every resolve of an unrotated cookie -- flooding for the whole rotation window and announcing a re-seal no write had performed. The rotation record is now latched once per process, the repeats are DEBUG, and the LogRecord is retemplated to say what it observes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAAt7guDZEDA4S6nUTHcLW --- .../sheriff/gateway/bff/BffLogMessages.java | 11 +++++--- .../gateway/bff/cookie/CookieKeyMaterial.java | 2 +- .../bff/cookie/CookieSessionBinding.java | 28 +++++++++++++++++-- .../gateway/bff/session/SessionRecord.java | 26 +++++++++++++---- .../gateway/quarkus/BffRuntimeProducer.java | 6 +++- .../bff/cookie/CookieSessionBindingTest.java | 21 +++++++++++++- doc/LogMessages.adoc | 2 +- 7 files changed, 79 insertions(+), 17 deletions(-) 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 5c3d8d75..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 @@ -119,13 +119,16 @@ public static final class INFO { .build(); /** - * A session sealed under the previous key was re-sealed under the current key on its next - * write, completing the rotation for that session. Records only the bounded disposition. + * 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_RESEALED_WITH_CURRENT_KEY = LogRecordModel.builder() + public static final LogRecord COOKIE_ROLLOVER_IN_PROGRESS = LogRecordModel.builder() .prefix(PREFIX) .identifier(17) - .template("Cookie-mode session re-sealed under the current key (%s)") + .template("Cookie-mode previous-key rotation in progress (%s) — affected sessions " + + "re-seal under the current key on their next write") .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 index 4f29755d..dca04032 100644 --- 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 @@ -53,7 +53,7 @@ * {@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)} and {@link #identitySalt()}, both of which consume it + * through {@link #codec(String, Duration, int)} and {@link #identitySalt()}, both of which consume it * without disclosing it. * * @author API Sheriff Team 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 index 05ae6bfe..cfbad951 100644 --- 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 @@ -23,6 +23,7 @@ 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; @@ -54,8 +55,10 @@ * 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 event is recorded once as INFO - * {@code COOKIE_RESEALED_WITH_CURRENT_KEY} carrying only a bounded disposition. Rotation composes + * 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. *

      @@ -83,6 +86,13 @@ public final class CookieSessionBinding implements SessionBinding { 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. * @@ -187,10 +197,22 @@ private BoundSession seal(SealedSessionPayload payload, Instant now) { * 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()) { - LOGGER.info(BffLogMessages.INFO.COOKIE_RESEALED_WITH_CURRENT_KEY, RESEAL_DISPOSITION); + 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()); } 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 27e7d448..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 @@ -35,10 +35,20 @@ *

      * 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. In server mode it is the opaque store key, - * which is also the session-cookie value; in a stateless mode it is a derived identity that is - * never emitted to the browser. It is redacted from {@link #toString()} either way, because in - * server mode it is itself a bearer credential. + * 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. @@ -80,8 +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/quarkus/BffRuntimeProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java index 54efd214..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 @@ -297,7 +297,11 @@ private BffRuntime build(OidcConfig oidc) { 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); } 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 index 334b4438..c28f0068 100644 --- 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 @@ -32,6 +32,9 @@ 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; @@ -45,8 +48,9 @@ * 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. + * {@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"; @@ -256,6 +260,21 @@ void shouldResealOnNextWrite() { .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() { diff --git a/doc/LogMessages.adoc b/doc/LogMessages.adoc index e082244f..480d2cb9 100644 --- a/doc/LogMessages.adoc +++ b/doc/LogMessages.adoc @@ -47,7 +47,7 @@ diagnostics use the logger directly and are not catalogued. |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 session re-sealed under the current key (%s) |Logged when a session sealed under the decrypt-only `previous_key` is re-sealed under the current key on its next write, completing that session's rotation; `%s` is a bounded disposition — never key or token material +|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) From 1f4035972e02313bb6cddf4bb54272528d1c8c18 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:03:11 +0200 Subject: [PATCH 23/24] test(bff): assert the real key length and clear S5778/S6213 findings shouldGenerateA256BitKey asserted the derived identity salt's length, which is a fixed-width SHA-256 digest and so would pass for a 128-bit key too -- the crypto-strength claim in its name was never checked. It now asserts the key length through a package-private accessor that exposes no material, with the salt-uniqueness claim split into its own test. Also hoist assertThrows lambda arguments into locals so exactly one call can throw (java:S5778), and rename the locals named 'record' (java:S6213). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAAt7guDZEDA4S6nUTHcLW --- .../gateway/bff/cookie/CookieKeyMaterial.java | 13 +++++++ .../bff/cookie/CookieKeyMaterialTest.java | 37 +++++++++++++++---- .../bff/cookie/CookieSessionBindingTest.java | 19 +++++----- .../quarkus/BffRuntimeProducerTest.java | 15 +++++--- 4 files changed, 62 insertions(+), 22 deletions(-) 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 index dca04032..197c0178 100644 --- 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 @@ -199,6 +199,19 @@ public byte[] identitySalt() { } } + /** + * 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. * 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 index a352c1cf..0bf04bb6 100644 --- 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 @@ -132,9 +132,11 @@ class BootRejection { @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(Optional.of(offending), Optional.empty())); + () -> CookieKeyMaterial.resolve(encryptionKey, noPreviousKey)); assertTrue(thrown.getMessage().contains("session.encryption_key"), thrown.getMessage()); assertFalse(thrown.getMessage().contains(offending), "the offending value is never echoed"); @@ -144,9 +146,11 @@ void shouldRejectNonBase64Key() { @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(Optional.of(tooShort), Optional.empty())); + () -> CookieKeyMaterial.resolve(encryptionKey, noPreviousKey)); assertTrue(thrown.getMessage().contains("32"), thrown.getMessage()); assertTrue(thrown.getMessage().contains("AES-256"), thrown.getMessage()); @@ -156,8 +160,11 @@ void shouldRejectWrongLengthKey() { @Test @DisplayName("Should reject a malformed previous key naming the previous-key field") void shouldRejectMalformedPreviousKey() { - IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> CookieKeyMaterial - .resolve(Optional.of(CURRENT_KEY_B64), Optional.of(Base64.getEncoder().encodeToString(new byte[8])))); + 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()); } @@ -165,8 +172,11 @@ void shouldRejectMalformedPreviousKey() { @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(Optional.of(CURRENT_KEY_B64), Optional.of(CURRENT_KEY_B64))); + () -> CookieKeyMaterial.resolve(encryptionKey, collidingPreviousKey)); assertTrue(thrown.getMessage().contains("same key id"), thrown.getMessage()); } @@ -174,8 +184,11 @@ void shouldRefuseCollidingKeyIds() { @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(Optional.empty(), Optional.of(PREVIOUS_KEY_B64))); + () -> CookieKeyMaterial.resolve(noEncryptionKey, previousKey)); assertTrue(thrown.getMessage().contains("session.previous_key"), thrown.getMessage()); assertTrue(thrown.getMessage().contains("session.encryption_key"), thrown.getMessage()); @@ -213,10 +226,20 @@ void shouldGenerateADistinctKeyPerBoot() throws Exception { @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); + 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"); } } 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 index c28f0068..503df1c1 100644 --- 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 @@ -122,13 +122,14 @@ void shouldRoundTripThroughTheCookie() { Optional resolved = binding.resolve(cookieHeaderOf(bound), LOGIN); assertTrue(resolved.isPresent()); - SessionRecord record = resolved.get(); - assertEquals(ACCESS_TOKEN, record.accessToken()); - assertEquals(Optional.of(REFRESH_TOKEN), record.refreshToken()); - assertEquals(ID_TOKEN, record.idToken()); - assertEquals(SUB, record.sub()); - assertEquals(Optional.of(SID), record.sid()); - assertEquals(LOGIN.plus(TTL), record.expiresAt(), "the deadline is derived from the sealed login instant"); + 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 @@ -375,9 +376,9 @@ void shouldClearThroughTheCookie() { @Test @DisplayName("Should leave destroy a local no-op — nothing is held server-side") void shouldTreatDestroyAsALocalNoOp() { - SessionRecord record = session(ACCESS_TOKEN, LOGIN.plus(TTL)); + SessionRecord liveSession = session(ACCESS_TOKEN, LOGIN.plus(TTL)); - binding.destroy(record); + 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/quarkus/BffRuntimeProducerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java index cfdd3aab..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 @@ -177,18 +177,21 @@ void shouldRefusePreviousKeyWithoutEncryptionKey() { .build())) .build(); - assertThrows(IllegalStateException.class, () -> producer(Optional.of(previousOnly)).bffRuntime(), + 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() { - assertThrows(IllegalStateException.class, - () -> producer(cookieModeOidcWithKey("not-base64-~~~")).bffRuntime()); - assertThrows(IllegalStateException.class, - () -> producer(cookieModeOidcWithKey(Base64.getEncoder().encodeToString(new byte[16]))) - .bffRuntime(), + 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"); } } From ba3edbc0f1f81e4ecbe492b429b4f25bba4bfbb4 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:07:40 +0200 Subject: [PATCH 24/24] style: apply formatter reflow from the quality gate Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAAt7guDZEDA4S6nUTHcLW --- .../java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java | 2 +- .../sheriff/gateway/bff/cookie/SealedSessionPayloadTest.java | 4 ++-- .../sheriff/gateway/config/model/ConfigModelContractTest.java | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) 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 fa5b5706..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 @@ -864,7 +864,7 @@ private void dispatchGrpc(RoutingContext ctx, PipelineRequest request, RouteRunt ctx.vertx().runOnContext(v -> { applyStageSetCookies(ctx.response(), stageSetCookies); responseStage.relayWithTrailers(upstream, ctx.response(), route.isNotModifiedEnabled(), - request.responseHeaders()) + request.responseHeaders()) .onFailure(failure -> failRelay(ctx, failure)); }); } 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 index 8b19f0ce..194b3211 100644 --- 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 @@ -131,13 +131,13 @@ void shouldDecodeNothingFromOutOfRangeEpochSecond() { String login = Long.toString(LOGIN.getEpochSecond()); assertTrue(SealedSessionPayload - .decode(wireForm(ACCESS_TOKEN, "", ID_TOKEN, SUB, "", "", "", beyondInstantMax)).isEmpty(), + .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(), + .decode(wireForm(ACCESS_TOKEN, "", ID_TOKEN, SUB, "", "", beyondInstantMax, login)).isEmpty(), "the optional authTime field carries the same overflow risk as the login instant"); } } 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 7f58f90c..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 @@ -869,7 +869,7 @@ private static OidcConfig.Session sessionWithMode(String mode) { } @ParameterizedTest(name = "mode ''{0}'' canonicalizes to cookie mode") - @ValueSource(strings ={"cookie", "Cookie", "COOKIE", " CoOkIe "}) + @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); @@ -882,7 +882,7 @@ void shouldCanonicalizeCookieMode(String declared) { } @ParameterizedTest(name = "mode ''{0}'' canonicalizes to server mode") - @ValueSource(strings ={"server", "Server", "SERVER", " sErVeR "}) + @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);