diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/auth/AuthenticationStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/auth/AuthenticationStage.java
index 1bd5c8cc..6e7348b2 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/auth/AuthenticationStage.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/auth/AuthenticationStage.java
@@ -20,6 +20,7 @@
import java.util.Optional;
+import de.cuioss.sheriff.gateway.bff.runtime.SessionAuthenticationStage;
import de.cuioss.sheriff.gateway.config.model.AuthConfig;
import de.cuioss.sheriff.gateway.events.EventType;
import de.cuioss.sheriff.gateway.events.GatewayException;
@@ -31,6 +32,7 @@
import de.cuioss.sheriff.token.validation.exception.TokenValidationException;
import jakarta.inject.Provider;
+import org.jspecify.annotations.Nullable;
/**
* Stage 4 — offline bearer-token validation, run after the per-route thorough checks.
@@ -45,10 +47,19 @@
* invalid / expired / tampered token is 401 {@link EventType#TOKEN_INVALID}; both carry
* {@code WWW-Authenticate: Bearer}. A valid token lacking a required scope is 403
* {@link EventType#SCOPE_MISSING};
- *
{@code require: session} — never reaches here; the boot-time {@code RouteRuntimeAssembler}
- * already rejected such routes (session auth is not yet implemented).
+ * {@code require: session} — dispatched to the {@link SessionAuthenticationStage} stage-4
+ * runtime (D4): the opaque session cookie is resolved to a live session, the mediated token is
+ * injected as the upstream {@code Authorization: Bearer}, {@code required_scopes} are enforced
+ * against the mediated token (403 {@link EventType#SCOPE_MISSING}), and an unauthenticated
+ * request is redirected into the auth-code flow (navigation) or challenged 401
+ * {@code application/problem+json} (everything else). Bearer and session stay separate
+ * mechanisms — the bearer-validation logic above is untouched.
*
* The upstream is never contacted on any authentication or authorization rejection.
+ *
+ * The session runtime is an optional collaborator: it is wired only when the gateway serves a BFF
+ * variant. A {@code require: session} route reaching this stage without a wired session runtime is a
+ * boot-configuration error surfaced as an {@link IllegalStateException} rather than served.
*
* @author API Sheriff Team
* @since 1.0
@@ -57,11 +68,16 @@ public final class AuthenticationStage {
private static final String REQUIRE_NONE = "none";
private static final String REQUIRE_BEARER = "bearer";
+ private static final String REQUIRE_SESSION = "session";
private static final String BEARER_PREFIX = "Bearer ";
private final Provider tokenValidator;
+ private final @Nullable SessionAuthenticationStage sessionStage;
/**
+ * Assembles the stage for a gateway serving no BFF variant — {@code require: session} is
+ * unsupported (a session route reaching this stage is a boot-configuration error).
+ *
* @param tokenValidator a lazy provider of the single shared validator built from the
* {@code token_validation} config. The validator is resolved via
* {@link Provider#get()} only at the first actual bearer validation, so a
@@ -71,6 +87,19 @@ public final class AuthenticationStage {
*/
public AuthenticationStage(Provider tokenValidator) {
this.tokenValidator = Objects.requireNonNull(tokenValidator, "tokenValidator");
+ this.sessionStage = null;
+ }
+
+ /**
+ * Assembles the stage with the {@code require: session} stage-4 runtime (D4) wired for a gateway
+ * serving a BFF variant.
+ *
+ * @param tokenValidator the lazy bearer-validator provider (see the single-argument constructor)
+ * @param sessionStage the session runtime a {@code require: session} route is dispatched to
+ */
+ public AuthenticationStage(Provider tokenValidator, SessionAuthenticationStage sessionStage) {
+ this.tokenValidator = Objects.requireNonNull(tokenValidator, "tokenValidator");
+ this.sessionStage = Objects.requireNonNull(sessionStage, "sessionStage");
}
/**
@@ -91,10 +120,22 @@ public void process(PipelineRequest request) {
validateBearer(request, auth, route);
return;
}
+ if (REQUIRE_SESSION.equals(require)) {
+ requireSessionStage(route).process(request);
+ return;
+ }
throw new IllegalStateException(
"Route " + route.getId() + " reached authentication with unsupported require '" + require + "'");
}
+ private SessionAuthenticationStage requireSessionStage(RouteRuntime route) {
+ if (sessionStage == null) {
+ throw new IllegalStateException("Route " + route.getId()
+ + " requires session authentication but no session runtime is wired");
+ }
+ return sessionStage;
+ }
+
private void validateBearer(PipelineRequest request, AuthConfig auth, RouteRuntime route) {
String token = extractBearerToken(request)
.orElseThrow(() -> unauthorized(request, EventType.TOKEN_MISSING, "No bearer token presented"));
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
new file mode 100644
index 00000000..93e866a7
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/BffLogMessages.java
@@ -0,0 +1,140 @@
+/*
+ * 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;
+
+import de.cuioss.tools.logging.LogRecord;
+import de.cuioss.tools.logging.LogRecordModel;
+
+import lombok.experimental.UtilityClass;
+
+/**
+ * DSL-style {@link LogRecord} catalogue for the Backend-for-Frontend {@code require: session}
+ * surface — the session lifecycle, transparent token refresh, CSRF defence, and logout events.
+ *
+ * Structured {@code INFO} (1-99) and {@code WARN} (100-199) messages carry the shared
+ * {@code ApiSheriff} prefix and a stable numeric identifier, so they are greppable and assertable.
+ * This catalogue's identifier ranges are disjoint from the request-pipeline edge
+ * ({@link de.cuioss.sheriff.gateway.ApiSheriffLogMessages}: {@code 1} / {@code 4} / {@code 100} /
+ * {@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
+ * the others for a collision.
+ *
+ * No sensitive data is logged. Session subjects ({@code sub}), IdP session ids
+ * ({@code sid}), token material, and raw offending values never appear in a template: a rejection
+ * records only its non-sensitive disposition ({@code untrusted-origin} / {@code signature}
+ * / …), a lifecycle event records only a non-sensitive reason or a bounded count. Exception-bearing
+ * {@code WARN}s pass the throwable first, per the CUI logging contract; callers must not attach a
+ * raw, unsanitized IdP exception whose message could re-inject untrusted content. {@code DEBUG} /
+ * {@code TRACE} diagnostics use the logger directly and are not catalogued here.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@UtilityClass
+public final class BffLogMessages {
+
+ private static final String PREFIX = "ApiSheriff";
+
+ /**
+ * Info-level messages (INFO range 1-99; this catalogue owns 10-14).
+ */
+ @UtilityClass
+ public static final class INFO {
+
+ /** A server-side session was established after a successful IdP login (require: session). */
+ public static final LogRecord SESSION_CREATED = LogRecordModel.builder()
+ .prefix(PREFIX)
+ .identifier(10)
+ .template("Server-side session established for a require:session route")
+ .build();
+
+ /**
+ * A server-side session was destroyed. The reason is a bounded, non-sensitive disposition
+ * ({@code logout} / {@code backchannel-logout} / {@code expiry} / {@code refresh-failure}) —
+ * never the session id, subject, or IdP {@code sid}.
+ */
+ public static final LogRecord SESSION_DESTROYED = LogRecordModel.builder()
+ .prefix(PREFIX)
+ .identifier(11)
+ .template("Server-side session destroyed (%s)")
+ .build();
+
+ /** The mediated tokens were transparently refreshed for a require:session route. */
+ public static final LogRecord TOKEN_REFRESHED = LogRecordModel.builder()
+ .prefix(PREFIX)
+ .identifier(12)
+ .template("Mediated tokens refreshed for a require:session route (single-flight)")
+ .build();
+
+ /**
+ * A back-channel logout token was accepted and its affected sessions were destroyed. The
+ * template carries only the bounded destroyed-session count — never the subject or {@code sid}.
+ */
+ public static final LogRecord BACKCHANNEL_LOGOUT = LogRecordModel.builder()
+ .prefix(PREFIX)
+ .identifier(13)
+ .template("Back-channel logout accepted — %s session(s) destroyed")
+ .build();
+
+ /** An RP-initiated logout completed its return leg for a require:session route. */
+ public static final LogRecord RP_LOGOUT_COMPLETED = LogRecordModel.builder()
+ .prefix(PREFIX)
+ .identifier(14)
+ .template("RP-initiated logout completed for a require:session route")
+ .build();
+ }
+
+ /**
+ * Warn-level messages (WARN range 100-199; this catalogue owns 110-112).
+ */
+ @UtilityClass
+ public static final class WARN {
+
+ /**
+ * The fixed CSRF defence rejected an unsafe-method session request. Records the non-sensitive
+ * rejection disposition ({@code untrusted-origin} / {@code no-origin-proof}) only — never the
+ * raw offending {@code Origin} value.
+ */
+ public static final LogRecord CSRF_REJECTED = LogRecordModel.builder()
+ .prefix(PREFIX)
+ .identifier(110)
+ .template("CSRF defence rejected an unsafe-method session request: %s")
+ .build();
+
+ /**
+ * A transparent token refresh failed (IdP rejection or engine-detected refresh-token reuse)
+ * and its session was destroyed; the caller re-authenticates. Records only a bounded,
+ * non-sensitive reason — never the presented refresh token or session id.
+ */
+ public static final LogRecord SESSION_REFRESH_FAILED = LogRecordModel.builder()
+ .prefix(PREFIX)
+ .identifier(111)
+ .template("Token refresh failed for a require:session route (%s) — session destroyed")
+ .build();
+
+ /**
+ * A back-channel logout token was rejected. Records the non-sensitive rejection disposition
+ * ({@code signature} / {@code claims}) only — never the raw logout token.
+ */
+ public static final LogRecord LOGOUT_TOKEN_REJECTED = LogRecordModel.builder()
+ .prefix(PREFIX)
+ .identifier(112)
+ .template("Back-channel logout token rejected: %s")
+ .build();
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/csrf/CsrfDefence.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/csrf/CsrfDefence.java
new file mode 100644
index 00000000..aba3b93a
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/csrf/CsrfDefence.java
@@ -0,0 +1,132 @@
+/*
+ * 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.csrf;
+
+import java.util.EnumSet;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+
+import de.cuioss.sheriff.gateway.config.model.HttpMethod;
+import de.cuioss.sheriff.gateway.events.EventType;
+import de.cuioss.sheriff.gateway.events.GatewayException;
+import de.cuioss.sheriff.gateway.pipeline.PipelineRequest;
+import de.cuioss.tools.logging.CuiLogger;
+
+/**
+ * The fixed CSRF defence for {@code require: session} routes (D7). It is fixed
+ * behaviour with no disable knob: every unsafe-method request on a session route must
+ * prove same-origin provenance or it is rejected {@code 403} with a {@link EventType#CSRF_REJECTED}
+ * event. There is no configuration flag that turns the defence off.
+ *
+ * "Unsafe" means any method outside the CSRF-safe set {@code GET} / {@code HEAD} / {@code OPTIONS}
+ * (the methods a browser issues without a body-mutating intent). For an unsafe method the defence
+ * is fail-closed and accepts only two proofs of same-origin provenance:
+ *
+ * - an {@code Origin} header whose exact full-origin value (scheme + host + port) is a member of
+ * the trusted-origin set (configured via {@code session.csrf.trusted_origins}, defaulting to the
+ * origin of {@code redirect_uri}); or
+ * - when {@code Origin} is absent, a {@code Sec-Fetch-Site: same-origin} fetch-metadata header.
+ *
+ * Everything else — an untrusted {@code Origin}, an absent {@code Origin} without a
+ * {@code same-origin} {@code Sec-Fetch-Site}, or both signals absent — is rejected. The comparison is
+ * case-insensitive (the trusted origins are lower-cased once at construction and the inbound values
+ * are lower-cased on each check), matching the exact-match, fail-closed model of the WebSocket-upgrade
+ * Origin gate.
+ *
+ * The defence is framework-agnostic — it consumes the {@link PipelineRequest} carrier and carries no
+ * Vert.x / Quarkus types — and immutable, so a single instance is safely shared across threads.
+ * Security-relevant rejections are logged with the rejection disposition only, never the raw offending
+ * {@code Origin} value; the {@link EventType#CSRF_REJECTED} counter is the authoritative observability
+ * signal for a rejection.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class CsrfDefence {
+
+ private static final CuiLogger LOGGER = new CuiLogger(CsrfDefence.class);
+
+ private static final String ORIGIN_HEADER = "Origin";
+ private static final String SEC_FETCH_SITE_HEADER = "Sec-Fetch-Site";
+ private static final String SAME_ORIGIN = "same-origin";
+ private static final Set SAFE_METHODS = EnumSet.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS);
+
+ private static final String DISPOSITION_UNTRUSTED_ORIGIN = "untrusted-origin";
+ private static final String DISPOSITION_NO_ORIGIN_PROOF = "no-origin-proof";
+
+ private final Set trustedOrigins;
+
+ /**
+ * Assembles the defence with the effective trusted-origin allowlist.
+ *
+ * @param trustedOrigins the browser origins trusted on unsafe methods — full origin strings
+ * (scheme + host + port), already resolved to the configured
+ * {@code session.csrf.trusted_origins} or the {@code redirect_uri} default;
+ * lower-cased and defensively copied here. An empty set is the fail-closed
+ * extreme where only {@code Sec-Fetch-Site: same-origin} can pass.
+ */
+ public CsrfDefence(Set trustedOrigins) {
+ Objects.requireNonNull(trustedOrigins, "trustedOrigins");
+ this.trustedOrigins = trustedOrigins.stream()
+ .map(origin -> origin.toLowerCase(Locale.ROOT))
+ .collect(Collectors.toUnmodifiableSet());
+ }
+
+ /**
+ * Enforces the fixed CSRF defence for a request selected onto a {@code require: session} route.
+ * A CSRF-safe method returns immediately; an unsafe method must present a trusted {@code Origin}
+ * or a {@code Sec-Fetch-Site: same-origin} header.
+ *
+ * @param request the in-flight request carrier
+ * @throws GatewayException carrying {@link EventType#CSRF_REJECTED} ({@code 403}) when an
+ * unsafe-method request presents neither a trusted {@code Origin} nor a
+ * {@code same-origin} {@code Sec-Fetch-Site}
+ */
+ public void enforce(PipelineRequest request) {
+ Objects.requireNonNull(request, "request");
+ if (SAFE_METHODS.contains(request.method())) {
+ return;
+ }
+ Optional origin = request.firstHeader(ORIGIN_HEADER);
+ if (origin.isPresent()) {
+ if (trustedOrigins.contains(origin.get().toLowerCase(Locale.ROOT))) {
+ return;
+ }
+ throw rejected(DISPOSITION_UNTRUSTED_ORIGIN);
+ }
+ if (isSameOriginFetch(request)) {
+ return;
+ }
+ throw rejected(DISPOSITION_NO_ORIGIN_PROOF);
+ }
+
+ private static boolean isSameOriginFetch(PipelineRequest request) {
+ return request.firstHeader(SEC_FETCH_SITE_HEADER)
+ .map(value -> value.toLowerCase(Locale.ROOT))
+ .filter(SAME_ORIGIN::equals)
+ .isPresent();
+ }
+
+ private static GatewayException rejected(String disposition) {
+ LOGGER.debug("CSRF defence rejected an unsafe-method session request: %s", disposition);
+ return new GatewayException(EventType.CSRF_REJECTED,
+ "CSRF defence rejected an unsafe-method session request: " + disposition);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/csrf/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/csrf/package-info.java
new file mode 100644
index 00000000..32b0109f
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/csrf/package-info.java
@@ -0,0 +1,34 @@
+/*
+ * 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 fixed CSRF defence for {@code require: session} routes (D7).
+ *
+ * {@link de.cuioss.sheriff.gateway.bff.csrf.CsrfDefence} is fixed behaviour with no disable knob:
+ * on unsafe methods (everything except {@code GET} / {@code HEAD} / {@code OPTIONS}) it accepts only
+ * an {@code Origin} whose exact full origin (scheme + host + port) is in the trusted-origin set
+ * ({@code session.csrf.trusted_origins}, defaulting to the {@code redirect_uri} origin) or, when
+ * {@code Origin} is absent, a {@code Sec-Fetch-Site: same-origin} header; everything else is rejected
+ * {@code 403} with a {@link de.cuioss.sheriff.gateway.events.EventType#CSRF_REJECTED} event.
+ *
+ * The class is framework-agnostic and immutable; the session runtime wires it into the request edge.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.gateway.bff.csrf;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/LoginFlow.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/LoginFlow.java
new file mode 100644
index 00000000..bcabc429
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/LoginFlow.java
@@ -0,0 +1,143 @@
+/*
+ * 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.login;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+
+
+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.token.client.flow.AuthorizationCodeFlow;
+import de.cuioss.tools.logging.CuiLogger;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The confidential-client login initiation (D1/D2/D2b) — the browser-facing start of the OIDC
+ * auth-code flow, the mirror of {@link de.cuioss.sheriff.gateway.bff.reserved.CallbackEndpoint}.
+ *
+ * The gateway re-implements no OAuth leg: the engine owns PKCE ({@code S256}),
+ * {@code state}, {@code nonce}, and the authorization-URL construction. {@link LoginFlow} only
+ * orchestrates the gateway-side concerns — it asks the engine to {@linkplain AuthorizationInitiation
+ * authorize} (yielding the authorization URL and the transaction {@code FlowContext}), persists the
+ * context as a single-use {@link PendingAuthorizationRecord}, sets the short-lived {@code __Host-}
+ * browser-binding cookie carrying the record id, and redirects the browser to the IdP. The binding
+ * cookie ties the in-flight login to this browser so the later callback cannot be replayed in
+ * another (the pre-session analogue of the session cookie).
+ *
+ * The post-login return URL is same-origin-validated ({@link PendingAuthorizationRecord#sameOrigin})
+ * before it is recorded — a cross-origin or unparseable target falls back to {@link #DEFAULT_RETURN_URL},
+ * so the post-login redirect is never an open redirect. The engine authorization is reached through
+ * the {@link AuthorizationInitiation} seam, keeping the flow decoupled from the confidential-client
+ * wiring (discovery metadata) and unit-testable without a live IdP.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class LoginFlow {
+
+ private static final CuiLogger LOGGER = new CuiLogger(LoginFlow.class);
+
+ /** The safe default post-login landing when no valid same-origin return URL is supplied. */
+ public static final String DEFAULT_RETURN_URL = "/";
+
+ private final AuthorizationInitiation authorization;
+ private final PendingAuthorizationStore pendingStore;
+ private final BindingCookieCodec bindingCookieCodec;
+ private final String gatewayOrigin;
+
+ /**
+ * Assembles the login flow with the engine authorization seam and the gateway-side stores.
+ *
+ * @param authorization the engine authorization seam (bound to {@link AuthorizationCodeFlow#authorize})
+ * @param pendingStore the single-use pending-authorization store
+ * @param bindingCookieCodec the browser-binding cookie codec
+ * @param gatewayOrigin the gateway's own origin (the {@code redirect_uri} origin) used to
+ * same-origin-validate the post-login return URL
+ */
+ public LoginFlow(AuthorizationInitiation authorization, PendingAuthorizationStore pendingStore,
+ BindingCookieCodec bindingCookieCodec, String gatewayOrigin) {
+ this.authorization = Objects.requireNonNull(authorization, "authorization");
+ this.pendingStore = Objects.requireNonNull(pendingStore, "pendingStore");
+ this.bindingCookieCodec = Objects.requireNonNull(bindingCookieCodec, "bindingCookieCodec");
+ this.gatewayOrigin = Objects.requireNonNull(gatewayOrigin, "gatewayOrigin");
+ }
+
+ /**
+ * Initiates a login: drives the engine authorization, persists the transaction, sets the binding
+ * cookie, and redirects the browser to the IdP.
+ *
+ * @param requestedReturnUrl the post-login return target the browser asked for, may be absent
+ * @param now the reference instant (the pending record's TTL anchor)
+ * @return the redirect to the IdP authorization URL carrying the binding {@code Set-Cookie}
+ */
+ public LoginRedirect initiate(@Nullable String requestedReturnUrl, Instant now) {
+ Objects.requireNonNull(now, "now");
+ AuthorizationCodeFlow.AuthorizationRedirect redirect = authorization.authorize();
+ String returnUrl = requestedReturnUrl != null
+ && PendingAuthorizationRecord.sameOrigin(requestedReturnUrl, gatewayOrigin)
+ ? requestedReturnUrl : DEFAULT_RETURN_URL;
+ PendingAuthorizationRecord pending = PendingAuthorizationRecord.create(redirect.context(), returnUrl, now);
+ pendingStore.store(pending);
+ LOGGER.debug("Initiated OIDC login; pending record persisted, redirecting to the IdP");
+ List setCookies = List.of(bindingCookieCodec.toSetCookieHeader(pending.id()));
+ return new LoginRedirect(redirect.authorizationUrl(), setCookies);
+ }
+
+ /**
+ * The engine authorization seam. The session runtime binds it to the engine as
+ * {@code () -> authorizationCodeFlow.authorize(providerMetadata)}; a test binds it to a
+ * hand-built redirect. Keeping the discovery-metadata wiring behind the seam decouples the flow
+ * from it and makes the initiation path unit-testable without a live IdP.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @FunctionalInterface
+ public interface AuthorizationInitiation {
+
+ /**
+ * Builds the authorization URL and the transaction context (PKCE/state/nonce owned by the
+ * engine) for a fresh login.
+ *
+ * @return the engine's authorization redirect (URL + transaction {@code FlowContext})
+ */
+ AuthorizationCodeFlow.AuthorizationRedirect authorize();
+ }
+
+ /**
+ * The framework-agnostic result of a login initiation: a {@code 302} redirect to the IdP
+ * authorization URL, carrying the browser-binding {@code Set-Cookie} header.
+ *
+ * @param authorizationUrl the IdP authorization endpoint URL to redirect the browser to
+ * @param setCookieHeaders the {@code Set-Cookie} header values to emit (the binding cookie)
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record LoginRedirect(String authorizationUrl, List setCookieHeaders) {
+
+ /**
+ * Canonical constructor rejecting an absent URL and defensively copying the cookies.
+ */
+ public LoginRedirect {
+ Objects.requireNonNull(authorizationUrl, "authorizationUrl");
+ setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders);
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/package-info.java
new file mode 100644
index 00000000..8899d5fe
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/package-info.java
@@ -0,0 +1,37 @@
+/*
+ * 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 confidential-client login initiation for the BFF auth-code flow (D1/D2/D2b).
+ *
+ * {@link de.cuioss.sheriff.gateway.bff.login.LoginFlow} is the browser-facing start of the OIDC
+ * code flow and the mirror of the callback endpoint: it drives the engine to build the
+ * authorization URL and the transaction {@code FlowContext} (PKCE/state/nonce owned by the engine,
+ * never re-implemented), persists the context as a single-use pending-authorization record, sets
+ * the {@code __Host-} browser-binding cookie, and redirects the browser to the IdP. The post-login
+ * return URL is same-origin-validated before it is recorded, so the eventual redirect is never an
+ * open redirect.
+ *
+ * The class is framework-agnostic (no CDI, no JAX-RS/Vert.x coupling) and reaches the engine
+ * through a seam, so it is unit-testable without a live IdP; the session runtime wires it to the
+ * request/response edge.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.gateway.bff.login;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/BackchannelLogoutReceiver.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/BackchannelLogoutReceiver.java
new file mode 100644
index 00000000..f7e50516
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/BackchannelLogoutReceiver.java
@@ -0,0 +1,154 @@
+/*
+ * 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.logout;
+
+import java.time.Instant;
+import java.util.Objects;
+import java.util.Optional;
+
+
+import de.cuioss.sheriff.gateway.bff.session.SessionStore;
+import de.cuioss.sheriff.token.commons.error.TokenSheriffException;
+import de.cuioss.sheriff.token.validation.domain.token.TokenContent;
+import de.cuioss.tools.logging.CuiLogger;
+
+/**
+ * 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.
+ *
+ * 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},
+ * ADR-11); a test binds it to a hand-built token or a failing stub. Keeping the JWKS wiring behind
+ * the seam decouples the receiver from the confidential-client configuration and makes both the
+ * 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.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class BackchannelLogoutReceiver {
+
+ private static final CuiLogger LOGGER = new CuiLogger(BackchannelLogoutReceiver.class);
+
+ private final LogoutTokenVerifier verifier;
+ private final LogoutTokenValidator validator;
+ private final SessionStore sessionStore;
+
+ /**
+ * Assembles the receiver with the signature-verification seam, the claim residual, and the store.
+ *
+ * @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
+ */
+ public BackchannelLogoutReceiver(LogoutTokenVerifier verifier, LogoutTokenValidator validator,
+ SessionStore sessionStore) {
+ this.verifier = Objects.requireNonNull(verifier, "verifier");
+ this.validator = Objects.requireNonNull(validator, "validator");
+ this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore");
+ }
+
+ /**
+ * Receives one back-channel logout token: signature-verify, claim-validate, and destroy.
+ *
+ * @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
+ */
+ public BackchannelResult receive(String rawLogoutToken, Instant now) {
+ Objects.requireNonNull(rawLogoutToken, "rawLogoutToken");
+ Objects.requireNonNull(now, "now");
+
+ TokenContent token;
+ try {
+ token = verifier.verify(rawLogoutToken);
+ } catch (TokenSheriffException signatureFailure) {
+ LOGGER.debug(signatureFailure, "Back-channel logout token signature/validation failed — rejected");
+ return BackchannelResult.rejected();
+ }
+
+ Optional subject = validator.validate(token, now);
+ if (subject.isEmpty()) {
+ return BackchannelResult.rejected();
+ }
+
+ LogoutTokenValidator.LogoutSubject logoutSubject = subject.get();
+ int destroyed = logoutSubject.sid().map(sessionStore::destroyBySid)
+ .orElseGet(() -> logoutSubject.sub().map(sessionStore::destroyBySub).orElse(0));
+ LOGGER.debug("Back-channel logout accepted — destroyed %s session(s)", destroyed);
+ return BackchannelResult.accepted(destroyed);
+ }
+
+ /**
+ * The engine signature-verification seam. The session runtime binds it to the engine's JWKS
+ * token validation; a test binds it to a hand-built {@link TokenContent} or a stub that throws.
+ * Keeping the confidential-client/JWKS wiring behind the seam decouples the receiver from it and
+ * makes the signature-failure path unit-testable without a live IdP.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @FunctionalInterface
+ public interface LogoutTokenVerifier {
+
+ /**
+ * Signature-verifies (and structurally validates) a raw logout token via the JWKS infrastructure.
+ *
+ * @param rawLogoutToken the raw logout-token JWT
+ * @return the signature-verified token content
+ * @throws de.cuioss.sheriff.token.commons.error.TokenSheriffException when the signature or
+ * structural validation fails
+ */
+ TokenContent verify(String rawLogoutToken);
+ }
+
+ /**
+ * The framework-agnostic outcome of a back-channel logout: whether the token was accepted and, if
+ * so, how many server-side sessions were destroyed. A rejected result destroys nothing.
+ *
+ * @param accepted whether the logout token passed signature and claim validation
+ * @param destroyed the number of sessions destroyed, always {@code 0} for a rejected result
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record BackchannelResult(boolean accepted, int destroyed) {
+
+ /**
+ * An accepted result carrying the destroyed-session count.
+ *
+ * @param destroyed the number of sessions destroyed
+ * @return the accepted result
+ */
+ public static BackchannelResult accepted(int destroyed) {
+ return new BackchannelResult(true, destroyed);
+ }
+
+ /**
+ * A rejected result destroying nothing.
+ *
+ * @return the rejected result
+ */
+ public static BackchannelResult rejected() {
+ return new BackchannelResult(false, 0);
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/LogoutTokenValidator.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/LogoutTokenValidator.java
new file mode 100644
index 00000000..3fe0b526
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/LogoutTokenValidator.java
@@ -0,0 +1,211 @@
+/*
+ * 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.logout;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+
+import de.cuioss.sheriff.token.validation.domain.claim.ClaimValue;
+import de.cuioss.sheriff.token.validation.domain.claim.ClaimValueType;
+import de.cuioss.sheriff.token.validation.domain.token.TokenContent;
+import de.cuioss.tools.logging.CuiLogger;
+
+/**
+ * The pure OIDC back-channel-logout-token check pipeline (D2c residual, BFF-09).
+ *
+ * The engine's issuer/JWKS infrastructure verifies the logout token's signature before it
+ * reaches this validator (reuse of {@code token-sheriff-validation}, ADR-11); the library carries no
+ * back-channel-logout support, so the gateway-side claim residual lives here. The pipeline applies
+ * the full OpenID Connect
+ * Back-Channel Logout validation on the already-signature-verified token:
+ *
+ * - {@code iss} equals the expected issuer,
+ * - {@code aud} contains the expected audience (the confidential client id),
+ * - {@code iat} is present and within a short freshness window (the replay guard, BFF-09),
+ * - {@code events} contains the {@value #BACKCHANNEL_LOGOUT_EVENT} member,
+ * - {@code nonce} is absent (a nonce is prohibited in a logout token),
+ * - at least one of {@code sub} / {@code sid} is present.
+ *
+ * Any failure returns {@link Optional#empty()} fail-closed — the token is rejected and no session is
+ * touched. On success the resolved {@code sub}/{@code sid} back the O(1) secondary-index destruction.
+ * The validator is framework-agnostic and stateless, so every negative case is unit-testable without
+ * a live IdP.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class LogoutTokenValidator {
+
+ private static final CuiLogger LOGGER = new CuiLogger(LogoutTokenValidator.class);
+
+ /** The OIDC back-channel-logout event member the {@code events} claim must carry. */
+ public static final String BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
+
+ private static final String CLAIM_ISS = "iss";
+ private static final String CLAIM_SUB = "sub";
+ private static final String CLAIM_SID = "sid";
+ private static final String CLAIM_AUD = "aud";
+ private static final String CLAIM_IAT = "iat";
+ private static final String CLAIM_EVENTS = "events";
+ private static final String CLAIM_NONCE = "nonce";
+
+ private final String expectedIssuer;
+ private final String expectedAudience;
+ private final Duration freshnessWindow;
+
+ /**
+ * Creates a validator bound to the confidential client's issuer, audience, and replay-guard window.
+ *
+ * @param expectedIssuer the OIDC issuer the logout token's {@code iss} must equal
+ * @param expectedAudience the audience (the client id) the token's {@code aud} must contain
+ * @param freshnessWindow the symmetric {@code iat} freshness window — the token is rejected when
+ * {@code iat} is older than, or further in the future than, this window
+ */
+ public LogoutTokenValidator(String expectedIssuer, String expectedAudience, Duration freshnessWindow) {
+ this.expectedIssuer = Objects.requireNonNull(expectedIssuer, "expectedIssuer");
+ this.expectedAudience = Objects.requireNonNull(expectedAudience, "expectedAudience");
+ this.freshnessWindow = Objects.requireNonNull(freshnessWindow, "freshnessWindow");
+ }
+
+ /**
+ * Runs the full logout-token check pipeline over an already-signature-verified token.
+ *
+ * @param logoutToken the signature-verified logout token
+ * @param now the reference instant for the {@code iat} freshness check
+ * @return the resolved {@code sub}/{@code sid} to destroy by, or empty when any check fails
+ */
+ public Optional validate(TokenContent logoutToken, Instant now) {
+ Objects.requireNonNull(logoutToken, "logoutToken");
+ Objects.requireNonNull(now, "now");
+
+ Optional issuer = claim(logoutToken, CLAIM_ISS);
+ if (issuer.isEmpty() || !expectedIssuer.equals(issuer.get())) {
+ LOGGER.debug("Back-channel logout token rejected — issuer mismatch");
+ return Optional.empty();
+ }
+ if (!audience(logoutToken).contains(expectedAudience)) {
+ LOGGER.debug("Back-channel logout token rejected — audience does not contain the client id");
+ return Optional.empty();
+ }
+ if (!isFresh(logoutToken, now)) {
+ LOGGER.debug("Back-channel logout token rejected — iat missing or outside the freshness window");
+ return Optional.empty();
+ }
+ if (claim(logoutToken, CLAIM_EVENTS).filter(LogoutTokenValidator::hasBackchannelLogoutEventMember).isEmpty()) {
+ LOGGER.debug("Back-channel logout token rejected — events is not a JSON object carrying the back-channel-logout member");
+ return Optional.empty();
+ }
+ if (claim(logoutToken, CLAIM_NONCE).isPresent()) {
+ LOGGER.debug("Back-channel logout token rejected — a nonce is prohibited in a logout token");
+ return Optional.empty();
+ }
+ Optional sub = claim(logoutToken, CLAIM_SUB);
+ Optional sid = claim(logoutToken, CLAIM_SID);
+ if (sub.isEmpty() && sid.isEmpty()) {
+ LOGGER.debug("Back-channel logout token rejected — neither sub nor sid present");
+ return Optional.empty();
+ }
+ return Optional.of(new LogoutSubject(sub, sid));
+ }
+
+ private static List audience(TokenContent token) {
+ ClaimValue aud = token.getClaims().get(CLAIM_AUD);
+ if (aud == null || aud.isNotPresentForClaimValueType()) {
+ return List.of();
+ }
+ if (aud.getType() == ClaimValueType.STRING_LIST) {
+ return aud.getAsList();
+ }
+ String original = aud.getOriginalString();
+ return original == null || original.isBlank() ? List.of() : List.of(original);
+ }
+
+ private boolean isFresh(TokenContent token, Instant now) {
+ ClaimValue iat = token.getClaims().get(CLAIM_IAT);
+ if (iat == null || iat.isNotPresentForClaimValueType()) {
+ return false;
+ }
+ Instant issuedAt = iat.getDateTime().toInstant();
+ return !issuedAt.isBefore(now.minus(freshnessWindow)) && !issuedAt.isAfter(now.plus(freshnessWindow));
+ }
+
+ /**
+ * Whether the (string-extracted) {@code events} claim is a JSON object that carries the
+ * {@value #BACKCHANNEL_LOGOUT_EVENT} URI as a member key, per
+ * OpenID Connect
+ * Back-Channel Logout §2.4. The engine surfaces a JSON-object claim as its serialized text, so
+ * the structural check here rejects a scalar or array {@code events} value that merely
+ * contains the event URI — a malformed or same-name scalar claim must never destroy a
+ * session. The URI must appear as a quoted key immediately followed (modulo whitespace) by the
+ * {@code :} name-separator; a value-position occurrence ({@code "x":"…backchannel-logout"}) is not
+ * followed by {@code :} and is therefore rejected.
+ *
+ * @param events the events claim's original string (the object's serialized JSON text)
+ * @return {@code true} only when {@code events} is a JSON object with the back-channel-logout key
+ */
+ private static boolean hasBackchannelLogoutEventMember(String events) {
+ String trimmed = events.strip();
+ // events MUST be a JSON object; a scalar string or a JSON array is rejected outright.
+ if (trimmed.length() < 2 || trimmed.charAt(0) != '{' || trimmed.charAt(trimmed.length() - 1) != '}') {
+ return false;
+ }
+ String quotedKey = '"' + BACKCHANNEL_LOGOUT_EVENT + '"';
+ for (int at = trimmed.indexOf(quotedKey); at >= 0; at = trimmed.indexOf(quotedKey, at + 1)) {
+ int cursor = at + quotedKey.length();
+ while (cursor < trimmed.length() && Character.isWhitespace(trimmed.charAt(cursor))) {
+ cursor++;
+ }
+ if (cursor < trimmed.length() && trimmed.charAt(cursor) == ':') {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static Optional claim(TokenContent token, String name) {
+ ClaimValue value = token.getClaims().get(name);
+ if (value == null || value.isNotPresentForClaimValueType()) {
+ return Optional.empty();
+ }
+ String original = value.getOriginalString();
+ return original == null || original.isBlank() ? Optional.empty() : Optional.of(original);
+ }
+
+ /**
+ * The subject a validated logout token resolves to — at least one of {@code sub}/{@code sid} is
+ * present (the pipeline rejects a token carrying neither). {@code sid} drives the precise
+ * single-session destruction; {@code sub} the all-sessions-for-subject destruction.
+ *
+ * @param sub the subject claim, empty when the token carried only a {@code sid}
+ * @param sid the IdP session id claim, empty when the token carried only a {@code sub}
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record LogoutSubject(Optional sub, Optional sid) {
+
+ /**
+ * Canonical constructor normalizing absent components to {@link Optional#empty()}.
+ */
+ public LogoutSubject {
+ sub = Objects.requireNonNullElse(sub, Optional.empty());
+ sid = Objects.requireNonNullElse(sid, Optional.empty());
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/RpInitiatedLogout.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/RpInitiatedLogout.java
new file mode 100644
index 00000000..d3425189
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/RpInitiatedLogout.java
@@ -0,0 +1,281 @@
+/*
+ * 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.logout;
+
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+
+import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
+import de.cuioss.sheriff.token.client.logout.EndSessionFlow;
+import de.cuioss.tools.logging.CuiLogger;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The gateway-side RP-initiated logout logic (D5) — the mirror of the login flow for the logout
+ * direction. Framework-agnostic by construction; the {@link de.cuioss.sheriff.gateway.bff.reserved.LogoutEndpoint}
+ * owns the request/response edge and the session store.
+ *
+ * The gateway re-implements no OAuth leg: the engine's {@link EndSessionFlow} owns
+ * the {@code end_session_endpoint} redirect construction, the {@code id_token_hint}, and — via its
+ * {@code PostLogoutRedirectValidator} — the exact-match {@code post_logout_redirect_uri}
+ * that is the open-redirect defence. {@link RpInitiatedLogout} only orchestrates the gateway-side
+ * concerns: it {@linkplain TokenRevocation revokes} the mediated tokens (RFC 7009, best-effort), mints
+ * a session-bound {@code state}, and carries that {@code state} in the short-lived single-use
+ * {@value #LOGOUT_STATE_COOKIE_NAME} cookie. The engine-owned {@code post_logout_redirect_uri} is a
+ * gateway-owned reserved path (the return leg), so the browser never controls the redirect target.
+ *
+ * The {@linkplain #completeReturn return leg} verifies the returned {@code state} against the cookie
+ * (constant-time, engine-owned), clears the single-use cookie, then redirects to the configured
+ * {@code final_redirect}. A missing or mismatched {@code state} is rejected {@code 400} — a forged
+ * logout-return cannot land the browser anywhere.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class RpInitiatedLogout {
+
+ private static final CuiLogger LOGGER = new CuiLogger(RpInitiatedLogout.class);
+
+ /** The {@code __Host-}-prefixed single-use logout-state cookie name (CSRF defence for logout). */
+ public static final String LOGOUT_STATE_COOKIE_NAME = "__Host-sheriff-logout";
+
+ private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+ private static final int STATE_BYTES = 32;
+ private static final int BAD_REQUEST = 400;
+ private static final int FOUND = 302;
+
+ private final EndSessionFlow endSessionFlow;
+ private final TokenRevocation revocation;
+ private final String endSessionEndpoint;
+ private final String postLogoutRedirectUri;
+ private final String finalRedirect;
+ private final Duration stateCookieTtl;
+
+ /**
+ * Assembles the RP-initiated logout with the engine end-session flow and the gateway-side settings.
+ *
+ * @param endSessionFlow the engine end-session redirect builder (owns exact-match validation)
+ * @param revocation the RFC 7009 token-revocation seam (best-effort)
+ * @param endSessionEndpoint the IdP {@code end_session_endpoint} (from discovery)
+ * @param postLogoutRedirectUri the gateway-owned return-leg URI sent to the IdP (exact-match)
+ * @param finalRedirect the application landing URL after the return leg
+ * @param stateCookieTtl the short lifetime of the single-use logout-state cookie (e.g. 60s)
+ */
+ public RpInitiatedLogout(EndSessionFlow endSessionFlow, TokenRevocation revocation, String endSessionEndpoint,
+ String postLogoutRedirectUri, String finalRedirect, Duration stateCookieTtl) {
+ this.endSessionFlow = Objects.requireNonNull(endSessionFlow, "endSessionFlow");
+ this.revocation = Objects.requireNonNull(revocation, "revocation");
+ this.endSessionEndpoint = requireNonBlank(endSessionEndpoint, "endSessionEndpoint");
+ this.postLogoutRedirectUri = requireNonBlank(postLogoutRedirectUri, "postLogoutRedirectUri");
+ this.finalRedirect = requireNonBlank(finalRedirect, "finalRedirect");
+ this.stateCookieTtl = Objects.requireNonNull(stateCookieTtl, "stateCookieTtl");
+ }
+
+ /**
+ * The configured application landing after logout. The {@link de.cuioss.sheriff.gateway.bff.reserved.LogoutEndpoint}
+ * edge redirects an already-logged-out browser (a logout request that carries no live
+ * session, so there is no {@code id_token_hint} to send) straight here, bypassing the IdP round-trip.
+ *
+ * @return the {@code final_redirect} application landing URL
+ */
+ public String finalRedirect() {
+ return finalRedirect;
+ }
+
+ /**
+ * Initiates RP-initiated logout for a live session: revokes the mediated tokens (best-effort),
+ * mints the session-bound {@code state}, and builds the engine end-session redirect carrying the
+ * {@code id_token_hint}, the exact {@code post_logout_redirect_uri}, and the {@code state}.
+ *
+ * @param session the live session being logged out (its raw ID token is the {@code id_token_hint})
+ * @return the redirect to the IdP {@code end_session_endpoint} carrying the logout-state {@code Set-Cookie}
+ */
+ public LogoutRedirect initiate(SessionRecord session) {
+ Objects.requireNonNull(session, "session");
+ // Revocation at the IdP is best-effort: any runtime failure of the revocation seam must not
+ // strand the browser half-logged-out, so the catch is deliberately broad.
+ // cui-rewrite:disable InvalidExceptionUsageRecipe
+ try {
+ revocation.revoke(session);
+ } catch (RuntimeException revocationFailure) {
+ // Revocation at the IdP is best-effort: the authoritative, immediately-effective step is the
+ // local session destruction the caller performs. A revocation-endpoint failure must not strand
+ // the browser half-logged-out, so it is logged and the logout proceeds.
+ LOGGER.debug(revocationFailure, "Token revocation failed during RP-initiated logout — proceeding with local logout");
+ }
+ String state = newState();
+ String location = endSessionFlow.buildLogoutRedirect(endSessionEndpoint, session.idToken(),
+ postLogoutRedirectUri, state);
+ return new LogoutRedirect(location, List.of(stateSetCookie(state)));
+ }
+
+ /**
+ * Completes the RP-initiated logout return leg: verifies the returned {@code state} against the
+ * single-use logout-state cookie, clears the cookie, and redirects to {@code final_redirect}.
+ *
+ * @param stateParam the {@code state} returned by the IdP on the post-logout redirect, may be absent
+ * @param cookieHeader the raw request {@code Cookie} header value, may be absent
+ * @return the redirect to {@code final_redirect} on a matching state, or a {@code 400} on mismatch
+ */
+ public LogoutReturn completeReturn(@Nullable String stateParam, @Nullable String cookieHeader) {
+ Optional cookieState = readState(cookieHeader);
+ if (cookieState.isEmpty()) {
+ LOGGER.debug("Post-logout return without a logout-state cookie — rejected");
+ return LogoutReturn.error(BAD_REQUEST);
+ }
+ try {
+ endSessionFlow.verifyPostLogoutState(cookieState.get(), stateParam);
+ } catch (IllegalStateException stateMismatch) {
+ LOGGER.debug(stateMismatch, "Post-logout return state did not match the logout-state cookie — rejected");
+ return LogoutReturn.error(BAD_REQUEST);
+ }
+ return LogoutReturn.redirect(finalRedirect, List.of(clearingStateCookie()));
+ }
+
+ private String stateSetCookie(String state) {
+ return "%s=%s; Max-Age=%d; Path=/; Secure; HttpOnly; SameSite=Lax"
+ .formatted(LOGOUT_STATE_COOKIE_NAME, state, stateCookieTtl.toSeconds());
+ }
+
+ private static String clearingStateCookie() {
+ return LOGOUT_STATE_COOKIE_NAME + "=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax";
+ }
+
+ private static Optional readState(@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 && LOGOUT_STATE_COOKIE_NAME.equals(trimmed.substring(0, equals))) {
+ String value = trimmed.substring(equals + 1);
+ return value.isEmpty() ? Optional.empty() : Optional.of(value);
+ }
+ }
+ return Optional.empty();
+ }
+
+ private static String newState() {
+ byte[] bytes = new byte[STATE_BYTES];
+ SECURE_RANDOM.nextBytes(bytes);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ }
+
+ private static String requireNonBlank(String value, String name) {
+ Objects.requireNonNull(value, name);
+ if (value.isBlank()) {
+ throw new IllegalArgumentException(name + " must not be blank");
+ }
+ return value;
+ }
+
+ /**
+ * The RFC 7009 token-revocation seam. The session runtime binds it to the engine's revocation
+ * call against the {@code revocation_endpoint}; a test binds it to a no-op or a failing stub.
+ * Revocation is best-effort — the caller's local session destruction is the authoritative logout.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @FunctionalInterface
+ public interface TokenRevocation {
+
+ /**
+ * Revokes the mediated tokens held by the given session (RFC 7009).
+ *
+ * @param session the session whose access/refresh tokens are being revoked
+ */
+ void revoke(SessionRecord session);
+ }
+
+ /**
+ * The framework-agnostic result of a logout initiation: a {@code 302} redirect to the IdP
+ * {@code end_session_endpoint}, carrying the single-use logout-state {@code Set-Cookie}.
+ *
+ * @param location the IdP end-session redirect URL to send the browser to
+ * @param setCookieHeaders the {@code Set-Cookie} header values to emit (the logout-state cookie)
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record LogoutRedirect(String location, List setCookieHeaders) {
+
+ /**
+ * Canonical constructor rejecting an absent location and defensively copying the cookies.
+ */
+ public LogoutRedirect {
+ Objects.requireNonNull(location, "location");
+ setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders);
+ }
+ }
+
+ /**
+ * The framework-agnostic result of the logout return leg: either a {@code 302} redirect to
+ * {@code final_redirect} carrying the logout-state-clearing {@code Set-Cookie}, or a {@code 400}
+ * on a missing/mismatched state.
+ *
+ * @param status the HTTP status the edge returns
+ * @param location the redirect target, present only on a matching state
+ * @param setCookieHeaders the {@code Set-Cookie} header values to emit, empty on error
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record LogoutReturn(int status, Optional location, List setCookieHeaders) {
+
+ /**
+ * Canonical constructor normalizing an absent location and defensively copying the cookies.
+ */
+ public LogoutReturn {
+ location = Objects.requireNonNullElse(location, Optional.empty());
+ setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders);
+ }
+
+ /**
+ * A successful-return {@code 302} redirect to {@code final_redirect}.
+ *
+ * @param location the application landing URL
+ * @param setCookieHeaders the logout-state-clearing {@code Set-Cookie}
+ * @return the redirect outcome
+ */
+ public static LogoutReturn redirect(String location, List setCookieHeaders) {
+ Objects.requireNonNull(location, "location");
+ return new LogoutReturn(FOUND, Optional.of(location), setCookieHeaders);
+ }
+
+ /**
+ * An error outcome carrying no redirect and no cookies.
+ *
+ * @param status the {@code 4xx} status
+ * @return the error outcome
+ */
+ public static LogoutReturn error(int status) {
+ return new LogoutReturn(status, Optional.empty(), List.of());
+ }
+
+ /**
+ * @return {@code true} when this outcome is a successful-return redirect
+ */
+ public boolean isRedirect() {
+ return status == FOUND;
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/package-info.java
new file mode 100644
index 00000000..ef14d8bb
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/package-info.java
@@ -0,0 +1,47 @@
+/*
+ * 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 gateway-side logout logic for both OIDC logout channels (D5, BFF-09).
+ *
+ * The gateway re-implements no OAuth leg: the engine
+ * ({@code token-sheriff-client}) owns the {@code end_session_endpoint} redirect construction, the
+ * {@code id_token_hint}, and the exact-match {@code post_logout_redirect_uri} open-redirect defence.
+ * This package owns only the gateway-side concerns, framework-agnostically (no CDI, no JAX-RS/Vert.x
+ * coupling), so every class is unit-testable without a container or a live IdP:
+ *
+ * - {@link de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout} orchestrates RP-initiated
+ * logout — it revokes the mediated tokens (RFC 7009, best-effort), mints a session-bound
+ * {@code state}, carries it in the short-lived single-use {@code __Host-sheriff-logout} cookie,
+ * and on the return leg verifies the returned {@code state} against that cookie before landing
+ * the browser on {@code final_redirect}.
+ * - {@link de.cuioss.sheriff.gateway.bff.logout.BackchannelLogoutReceiver} receives an OIDC
+ * back-channel {@code logout_token}: it signature-verifies the token through the engine's
+ * issuer/JWKS infrastructure, runs the claim residual, then destroys the affected sessions
+ * through the store's O(1) secondary index.
+ * - {@link de.cuioss.sheriff.gateway.bff.logout.LogoutTokenValidator} is the pure OIDC
+ * back-channel-logout-token check pipeline the receiver applies after signature verification —
+ * the gateway-side residual the engine library does not carry.
+ *
+ * The {@code reserved} package's {@code LogoutEndpoint} and {@code BackchannelLogoutEndpoint} own the
+ * request/response edge and the session store; this package holds only the transport-free logic.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.gateway.bff.logout;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/package-info.java
new file mode 100644
index 00000000..a234dd04
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * 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 Backend-for-Frontend (BFF) surface for {@code require: session} routes and its
+ * cross-cutting observability vocabulary.
+ *
+ * The confidential-client login, session, refresh, CSRF, and logout mechanics live in the
+ * sub-packages ({@code bff.login}, {@code bff.session}, {@code bff.refresh}, {@code bff.csrf},
+ * {@code bff.logout}, {@code bff.pending}, {@code bff.reserved}, {@code bff.runtime}). This
+ * root package holds only the framework-agnostic
+ * {@link de.cuioss.sheriff.gateway.bff.BffLogMessages} catalogue — the DSL-style
+ * {@link de.cuioss.tools.logging.LogRecord} vocabulary for the session lifecycle, transparent
+ * refresh, CSRF, and logout events those sub-packages emit. Keeping the catalogue in the
+ * framework-agnostic {@code cui-tools} {@code LogRecord} abstraction carries no framework
+ * dependency (ADR-0005).
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.gateway.bff;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/BindingCookieCodec.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/BindingCookieCodec.java
new file mode 100644
index 00000000..76c3f8fa
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/BindingCookieCodec.java
@@ -0,0 +1,102 @@
+/*
+ * 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.pending;
+
+import java.time.Duration;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Encodes and reads the short-lived browser-binding cookie that ties a
+ * {@link PendingAuthorizationRecord} to the browser that started the login (D2b).
+ *
+ * The cookie carries only the unguessable record id (server mode). It is set at redirect time
+ * and consulted at callback time: a callback is valid only when both the returned {@code state}
+ * matches and this cookie resolves to the same record, so a callback replayed in a different
+ * browser (which carries no binding cookie) is rejected even with a valid {@code state} — the
+ * pre-session analogue of the session cookie.
+ *
+ * The cookie is hardened by construction: the {@code __Host-} prefix (which the browser only
+ * honours with {@code Secure} + {@code Path=/} + no {@code Domain}), plus {@code HttpOnly} (no
+ * script access) and {@code SameSite=Lax} (survives the top-level IdP redirect while blocking
+ * cross-site sends). The codec is framework-agnostic: it produces and parses raw header values,
+ * so it carries no JAX-RS/Vert.x coupling and is unit-testable without a container.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class BindingCookieCodec {
+
+ /** The {@code __Host-}-prefixed browser-binding cookie name. */
+ public static final String COOKIE_NAME = "__Host-sheriff-binding";
+
+ private final Duration maxAge;
+
+ /**
+ * Creates a codec whose set-cookie output carries the given lifetime — normally
+ * {@link PendingAuthorizationRecord#FIXED_TTL}, so the cookie and the record expire together.
+ *
+ * @param maxAge the cookie {@code Max-Age}
+ */
+ public BindingCookieCodec(Duration maxAge) {
+ this.maxAge = Objects.requireNonNull(maxAge, "maxAge");
+ }
+
+ /**
+ * Builds the {@code Set-Cookie} header value binding the browser to {@code recordId}.
+ *
+ * @param recordId the pending-authorization record id
+ * @return the hardened {@code Set-Cookie} header value
+ */
+ public String toSetCookieHeader(String recordId) {
+ Objects.requireNonNull(recordId, "recordId");
+ return "%s=%s; Max-Age=%d; Path=/; Secure; HttpOnly; SameSite=Lax"
+ .formatted(COOKIE_NAME, recordId, maxAge.toSeconds());
+ }
+
+ /**
+ * Builds the {@code Set-Cookie} header value that clears the binding cookie (single-use:
+ * expire it once the callback has consumed the record).
+ *
+ * @return the clearing {@code Set-Cookie} header value
+ */
+ public String toClearingSetCookieHeader() {
+ return COOKIE_NAME + "=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax";
+ }
+
+ /**
+ * Reads the binding record id out of a request {@code Cookie} header value.
+ *
+ * @param cookieHeader the raw {@code Cookie} header value (may be absent/blank)
+ * @return the record id when the binding cookie is present with a non-empty value; empty otherwise
+ */
+ public Optional readRecordId(@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 && COOKIE_NAME.equals(trimmed.substring(0, equals))) {
+ String value = trimmed.substring(equals + 1);
+ return value.isEmpty() ? Optional.empty() : Optional.of(value);
+ }
+ }
+ return Optional.empty();
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationRecord.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationRecord.java
new file mode 100644
index 00000000..cea69b7d
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationRecord.java
@@ -0,0 +1,177 @@
+/*
+ * 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.pending;
+
+import java.net.URI;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.Objects;
+
+
+import de.cuioss.sheriff.token.client.flow.FlowContext;
+
+import lombok.Builder;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The gateway-side transaction record for a browser's in-flight auth-code login (D2b).
+ *
+ * The engine's {@link FlowContext} is the OIDC transaction DTO — it owns the
+ * {@code state} (32-byte SecureRandom), {@code nonce}, PKCE verifier ({@code S256}), and
+ * {@code redirect_uri}, and verifies {@code state}/{@code nonce} constant-time on callback.
+ * What the engine deliberately does not provide — and what this record adds — is the
+ * gateway's job: persistence, a short fixed TTL, single-use
+ * enforcement (the engine's single-use is a caller contract, not enforced by the type),
+ * and a same-origin-validated post-login return URL. The record is therefore a thin
+ * wrapper that never re-invents any engine control.
+ *
+ * Single-use is enforced by {@link PendingAuthorizationStore} (which removes the record on
+ * consumption), so this record stays immutable and carries no mutable consumed flag. The
+ * unguessable {@link #id()} is the store key and the value carried by the browser-binding
+ * cookie ({@link BindingCookieCodec}); a callback is valid only when both the returned
+ * {@code state} matches and the binding cookie resolves to this same record.
+ *
+ * @param id the unguessable record id (store key and binding-cookie value)
+ * @param flowContext the engine transaction DTO owning {@code state}/{@code nonce}/PKCE
+ * @param returnUrl the same-origin-validated post-login redirect target
+ * @param createdAt the instant the record was created (TTL anchor)
+ * @param ttl the short fixed lifetime before the record expires
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+public record PendingAuthorizationRecord(String id, FlowContext flowContext, String returnUrl, Instant createdAt,
+Duration ttl) {
+
+ /**
+ * The short fixed lifetime a pending-authorization record lives before it expires. Fixed
+ * (not operator-configurable): an unauthenticated browser creates these, so the window is
+ * a security parameter, not a tuning knob.
+ */
+ public static final Duration FIXED_TTL = Duration.ofMinutes(5);
+
+ private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+ private static final int ID_BYTES = 32;
+
+ /**
+ * Canonical constructor rejecting any absent component — every field is mandatory.
+ */
+ public PendingAuthorizationRecord {
+ Objects.requireNonNull(id, "id");
+ Objects.requireNonNull(flowContext, "flowContext");
+ Objects.requireNonNull(returnUrl, "returnUrl");
+ Objects.requireNonNull(createdAt, "createdAt");
+ Objects.requireNonNull(ttl, "ttl");
+ }
+
+ /**
+ * Creates a record with a freshly generated unguessable id and the {@link #FIXED_TTL}.
+ *
+ * @param flowContext the engine transaction DTO
+ * @param returnUrl the already same-origin-validated post-login redirect target
+ * @param createdAt the creation instant (TTL anchor)
+ * @return a new pending-authorization record
+ */
+ public static PendingAuthorizationRecord create(FlowContext flowContext, String returnUrl, Instant createdAt) {
+ return new PendingAuthorizationRecord(newId(), flowContext, returnUrl, createdAt, FIXED_TTL);
+ }
+
+ /**
+ * Generates a 256-bit URL-safe unguessable record id.
+ *
+ * @return the base64url (unpadded) encoding of 32 secure-random bytes
+ */
+ public static String newId() {
+ byte[] bytes = new byte[ID_BYTES];
+ SECURE_RANDOM.nextBytes(bytes);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ }
+
+ /**
+ * @return the instant this record expires ({@code createdAt + ttl})
+ */
+ public Instant expiresAt() {
+ return createdAt.plus(ttl);
+ }
+
+ /**
+ * Whether this record has expired at {@code now} — expiry is inclusive of the boundary.
+ *
+ * @param now the reference instant
+ * @return {@code true} when {@code now} is at or after {@link #expiresAt()}
+ */
+ public boolean isExpired(Instant now) {
+ return !now.isBefore(expiresAt());
+ }
+
+ /**
+ * Whether {@code returnUrl} is safe to redirect a browser to after login: a gateway-relative
+ * path ({@code /...}), or an absolute URL whose origin (scheme + host + port) matches
+ * {@code gatewayOrigin}. A schema-relative ({@code //host}) value, a backslash-authority
+ * ({@code /\host}, which browsers normalize to {@code //host}) value, a cross-origin absolute
+ * URL, a blank value, or an unparseable value is rejected — the post-login redirect is never
+ * an open redirect.
+ *
+ * @param returnUrl the candidate post-login redirect target (may be absent/blank)
+ * @param gatewayOrigin the gateway's own origin (e.g. the {@code redirect_uri} origin)
+ * @return {@code true} only when the candidate is same-origin with the gateway
+ */
+ public static boolean sameOrigin(@Nullable String returnUrl, String gatewayOrigin) {
+ if (returnUrl == null || returnUrl.isBlank() || returnUrl.startsWith("//")) {
+ return false;
+ }
+ // Browsers normalize a backslash to a forward slash, so /\evil.com or \\evil.com can be
+ // coerced into a protocol-relative //evil.com open redirect. A legitimate return URL — a
+ // gateway-relative path or an absolute gateway URL — never carries a raw backslash, so any
+ // backslash is rejected outright (closes /\evil.com, /\/evil.com, \evil.com).
+ if (returnUrl.indexOf('\\') >= 0) {
+ return false;
+ }
+ if (returnUrl.startsWith("/")) {
+ return true;
+ }
+ try {
+ return sameOriginAbsolute(URI.create(returnUrl), URI.create(gatewayOrigin));
+ } catch (IllegalArgumentException _) {
+ return false;
+ }
+ }
+
+ private static boolean sameOriginAbsolute(URI candidate, URI reference) {
+ if (candidate.getScheme() == null || candidate.getHost() == null) {
+ return false;
+ }
+ return candidate.getScheme().equalsIgnoreCase(reference.getScheme())
+ && candidate.getHost().equalsIgnoreCase(reference.getHost())
+ && effectivePort(candidate) == effectivePort(reference);
+ }
+
+ private static int effectivePort(URI uri) {
+ if (uri.getPort() != -1) {
+ return uri.getPort();
+ }
+ String scheme = uri.getScheme();
+ if ("https".equalsIgnoreCase(scheme)) {
+ return 443;
+ }
+ if ("http".equalsIgnoreCase(scheme)) {
+ return 80;
+ }
+ return -1;
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStore.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStore.java
new file mode 100644
index 00000000..f0b57c30
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStore.java
@@ -0,0 +1,123 @@
+/*
+ * 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.pending;
+
+import java.time.Instant;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * The storage seam for {@link PendingAuthorizationRecord}s (D2b).
+ *
+ * The seam is deliberately pluggable: server mode stores the record here and carries only the
+ * record id in the browser-binding cookie ({@link #InMemory}), whereas the cookie variant
+ * (PLAN-08) can supply an implementation where the sealed record travels in the cookie itself
+ * and no server map exists. The two operations are the whole contract — persist a record, and
+ * consume it exactly once.
+ *
+ * Single-use is a store invariant, not a caller contract. {@link #consume}
+ * removes the record as it returns it, so a second consumption of the same id resolves to
+ * empty even if the record has not expired — this is the enforcement the engine's
+ * {@link de.cuioss.sheriff.token.client.flow.FlowContext} deliberately leaves open.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public interface PendingAuthorizationStore {
+
+ /**
+ * Persists a pending-authorization record under its {@link PendingAuthorizationRecord#id()}.
+ *
+ * @param pending the record to store
+ */
+ void store(PendingAuthorizationRecord pending);
+
+ /**
+ * Atomically resolves and removes the record for {@code recordId} (single-use). Returns
+ * empty when no record is stored under that id, when it has already been consumed, or when
+ * it has expired at {@code now}.
+ *
+ * @param recordId the record id (from the browser-binding cookie)
+ * @param now the reference instant for the TTL check
+ * @return the live record, removed from the store; empty when absent/consumed/expired
+ */
+ Optional consume(String recordId, Instant now);
+
+ /**
+ * The bounded, single-node in-memory store — the only implementation for server mode.
+ *
+ * Backed by an insertion-ordered map with a hard capacity: because unauthenticated browsers
+ * create these records, an unbounded map would be a denial-of-service amplifier, so the
+ * oldest record is evicted once the capacity is exceeded (a DoS guard, not a correctness
+ * feature — a legitimately in-flight record is never evicted before the bound is reached).
+ * TTL expiry is enforced lazily on {@link #consume}. Every operation is guarded by the
+ * instance monitor, so concurrent redirect/callback requests never corrupt the map.
+ */
+ final class InMemory implements PendingAuthorizationStore {
+
+ private final int maxEntries;
+ private final Map records = new LinkedHashMap<>();
+
+ /**
+ * Creates a store bounded to {@code maxEntries} live records.
+ *
+ * @param maxEntries the hard capacity (DoS guard); must be positive
+ * @throws IllegalArgumentException when {@code maxEntries} is not positive
+ */
+ public InMemory(int maxEntries) {
+ if (maxEntries <= 0) {
+ throw new IllegalArgumentException("maxEntries must be positive, but was " + maxEntries);
+ }
+ this.maxEntries = maxEntries;
+ }
+
+ @Override
+ public synchronized void store(PendingAuthorizationRecord pending) {
+ Objects.requireNonNull(pending, "pending");
+ records.put(pending.id(), pending);
+ evictOldestBeyondCapacity();
+ }
+
+ @Override
+ public synchronized Optional consume(String recordId, Instant now) {
+ Objects.requireNonNull(recordId, "recordId");
+ Objects.requireNonNull(now, "now");
+ PendingAuthorizationRecord pending = records.remove(recordId);
+ if (pending == null || pending.isExpired(now)) {
+ return Optional.empty();
+ }
+ return Optional.of(pending);
+ }
+
+ /**
+ * @return the current number of stored records
+ */
+ public synchronized int size() {
+ return records.size();
+ }
+
+ private void evictOldestBeyondCapacity() {
+ Iterator oldestFirst = records.keySet().iterator();
+ while (records.size() > maxEntries && oldestFirst.hasNext()) {
+ oldestFirst.next();
+ oldestFirst.remove();
+ }
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/package-info.java
new file mode 100644
index 00000000..3abf2679
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/package-info.java
@@ -0,0 +1,43 @@
+/*
+ * 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.
+ */
+/**
+ * Transient pre-login state for the BFF auth-code flow (D2b).
+ *
+ * Between the redirect to the IdP and the callback — before any session exists — the gateway
+ * must hold the OIDC transaction and tie it to the browser that started it. This package owns
+ * that concern without re-inventing any engine control:
+ *
+ * - {@link de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord} wraps the
+ * engine's {@code FlowContext} (which owns {@code state}/{@code nonce}/PKCE/{@code
+ * redirect_uri}) and adds the gateway-only fields the engine leaves open: a short fixed
+ * TTL, single-use semantics, and a same-origin-validated post-login return URL.
+ * - {@link de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationStore} is the pluggable
+ * storage seam — a bounded, single-use, TTL-expiring in-memory map for server mode, with
+ * room for the PLAN-08 cookie-sealed variant.
+ * - {@link de.cuioss.sheriff.gateway.bff.pending.BindingCookieCodec} sets and reads the
+ * short-lived {@code __Host-} browser-binding cookie carrying the record id, so a callback
+ * replayed in a different browser is rejected even with a valid {@code state}.
+ *
+ * The classes are framework-agnostic (no CDI, no JAX-RS/Vert.x coupling); the reserved-endpoint
+ * and login-flow packages wire them to the request/response edge.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.gateway.bff.pending;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/StepUpCoordinator.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/StepUpCoordinator.java
new file mode 100644
index 00000000..f9df2386
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/StepUpCoordinator.java
@@ -0,0 +1,272 @@
+/*
+ * 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.refresh;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+
+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.SessionRecord;
+import de.cuioss.sheriff.token.client.flow.StepUpChallengeParser;
+import de.cuioss.sheriff.token.client.flow.StepUpChallengeParser.StepUpChallenge;
+import de.cuioss.sheriff.token.client.flow.StepUpHandler;
+import de.cuioss.tools.logging.CuiLogger;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * RFC 9470 step-up orchestration for a {@code require: session} route (D7) — the gateway-side
+ * handling of an upstream {@code insufficient_user_authentication} challenge.
+ *
+ * When a mediated upstream answers {@code 401} with a
+ * {@code WWW-Authenticate: Bearer error="insufficient_user_authentication"} challenge (RFC 9470),
+ * the gateway must obtain a token that satisfies the demanded authentication context
+ * ({@code acr_values} / {@code max_age}) and replay the original request. The gateway re-implements
+ * no OAuth leg: the engine ({@code token-sheriff-client}) owns the RFC 9470
+ * challenge grammar ({@link StepUpChallengeParser}) and the step-up authorization-request
+ * construction ({@link StepUpHandler} — carrying the elevated {@code acr_values} / {@code max_age}
+ * into the auth-code flow). This coordinator only orchestrates the gateway-side concerns.
+ *
+ * The two-step orchestration:
+ *
+ * - Silent satisfaction first. The {@link SilentSatisfaction} seam attempts to
+ * satisfy the challenge without a user-interactive redirect (the current session may already
+ * meet the demanded context, or the IdP may elevate silently). When it succeeds the original
+ * request is {@linkplain StepUpOutcome#satisfied(SessionRecord) replayed} straight away with
+ * the elevated session.
+ * - Re-drive otherwise. When silent satisfaction is not possible the engine
+ * builds a step-up authorization request carrying the challenge's elevated parameters; the
+ * coordinator persists its transaction as a single-use {@link PendingAuthorizationRecord}
+ * (whose same-origin-validated return URL is the URL of the request being replayed), mints the
+ * browser-binding cookie, and {@linkplain StepUpOutcome#reDrive(String, List) re-drives} the
+ * browser through the auth-code flow — exactly the {@code LoginFlow} shape, only with the
+ * elevated parameters. The recorded return URL is same-origin-validated so the post-step-up
+ * redirect is never an open redirect.
+ *
+ * The class is framework-agnostic — it consumes a raw {@code WWW-Authenticate} header value and
+ * returns a {@link StepUpOutcome} the edge renders, so it is unit-testable without a container or a
+ * live IdP. It is exercised only when {@code session.step_up.honor_upstream_challenge} is enabled;
+ * the enablement gate is the runtime's concern.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class StepUpCoordinator {
+
+ private static final CuiLogger LOGGER = new CuiLogger(StepUpCoordinator.class);
+
+ /** The safe default replay target when no valid same-origin replay URL is supplied. */
+ public static final String DEFAULT_RETURN_URL = "/";
+
+ private final StepUpChallengeParser challengeParser;
+ private final SilentSatisfaction silentSatisfaction;
+ private final StepUpInitiation stepUpInitiation;
+ private final PendingAuthorizationStore pendingStore;
+ private final BindingCookieCodec bindingCookieCodec;
+ private final String gatewayOrigin;
+
+ /**
+ * Assembles the coordinator with the engine step-up seams and the gateway-side stores.
+ *
+ * @param silentSatisfaction the silent-step-up seam (bound to the engine's silent elevation; a
+ * test binds a fixed present/absent result)
+ * @param stepUpInitiation the engine step-up authorization seam (bound to
+ * {@code StepUpHandler#initiate}; a test binds a hand-built request)
+ * @param pendingStore the single-use pending-authorization store the re-drive transaction
+ * is persisted to
+ * @param bindingCookieCodec the browser-binding cookie codec
+ * @param gatewayOrigin the gateway's own origin (the {@code redirect_uri} origin) used to
+ * same-origin-validate the replay return URL
+ */
+ public StepUpCoordinator(SilentSatisfaction silentSatisfaction, StepUpInitiation stepUpInitiation,
+ PendingAuthorizationStore pendingStore, BindingCookieCodec bindingCookieCodec, String gatewayOrigin) {
+ this.challengeParser = new StepUpChallengeParser();
+ this.silentSatisfaction = Objects.requireNonNull(silentSatisfaction, "silentSatisfaction");
+ this.stepUpInitiation = Objects.requireNonNull(stepUpInitiation, "stepUpInitiation");
+ this.pendingStore = Objects.requireNonNull(pendingStore, "pendingStore");
+ this.bindingCookieCodec = Objects.requireNonNull(bindingCookieCodec, "bindingCookieCodec");
+ this.gatewayOrigin = Objects.requireNonNull(gatewayOrigin, "gatewayOrigin");
+ }
+
+ /**
+ * Parses an upstream {@code WWW-Authenticate} header for an RFC 9470
+ * {@code insufficient_user_authentication} challenge (the engine owns the grammar).
+ *
+ * @param wwwAuthenticate the upstream response's raw {@code WWW-Authenticate} header value, may be absent
+ * @return the parsed step-up challenge, or empty when the header carries no step-up challenge
+ */
+ public Optional detectChallenge(@Nullable String wwwAuthenticate) {
+ if (wwwAuthenticate == null || wwwAuthenticate.isBlank()) {
+ return Optional.empty();
+ }
+ return challengeParser.parse(wwwAuthenticate);
+ }
+
+ /**
+ * Coordinates a detected step-up challenge: attempts silent satisfaction, else re-drives the
+ * auth-code flow with the challenge's elevated parameters and records the replay target.
+ *
+ * @param session the live session whose upstream call was challenged
+ * @param challenge the parsed RFC 9470 challenge (from {@link #detectChallenge})
+ * @param replayUrl the URL of the request to replay after step-up (same-origin-validated)
+ * @param now the reference instant (the re-drive pending record's TTL anchor)
+ * @return {@link StepUpOutcome#satisfied(SessionRecord) satisfied} when silent elevation worked,
+ * otherwise {@link StepUpOutcome#reDrive(String, List) reDrive} carrying the redirect and
+ * the browser-binding {@code Set-Cookie}
+ */
+ public StepUpOutcome coordinate(SessionRecord session, StepUpChallenge challenge, @Nullable String replayUrl,
+ Instant now) {
+ Objects.requireNonNull(session, "session");
+ Objects.requireNonNull(challenge, "challenge");
+ Objects.requireNonNull(now, "now");
+
+ Optional elevated = silentSatisfaction.attempt(session, challenge, now);
+ if (elevated.isPresent()) {
+ LOGGER.debug("Step-up challenge satisfied silently — replaying the original request");
+ return StepUpOutcome.satisfied(elevated.get());
+ }
+
+ StepUpHandler.StepUpRequest request = stepUpInitiation.initiate(challenge);
+ String returnUrl = replayUrl != null
+ && PendingAuthorizationRecord.sameOrigin(replayUrl, gatewayOrigin)
+ ? replayUrl : DEFAULT_RETURN_URL;
+ PendingAuthorizationRecord pending = PendingAuthorizationRecord.create(request.context(), returnUrl, now);
+ pendingStore.store(pending);
+ LOGGER.debug("Step-up challenge requires re-authentication — re-driving the auth-code flow");
+ List setCookies = List.of(bindingCookieCodec.toSetCookieHeader(pending.id()));
+ return StepUpOutcome.reDrive(request.authorizationUrl(), setCookies);
+ }
+
+ /**
+ * The silent-step-up seam. The session runtime binds it to the engine's attempt to elevate the
+ * authentication context without a user-interactive redirect (the current session may already
+ * satisfy the demanded {@code acr}, or the IdP may elevate silently); a test binds a fixed
+ * present/absent result. An empty result means a full user-interactive re-drive is required.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @FunctionalInterface
+ public interface SilentSatisfaction {
+
+ /**
+ * Attempts to satisfy the step-up challenge without a browser redirect.
+ *
+ * @param session the live session being elevated
+ * @param challenge the RFC 9470 challenge demanding a stronger authentication context
+ * @param now the reference instant
+ * @return the elevated session when satisfied silently, empty when a re-drive is required
+ */
+ Optional attempt(SessionRecord session, StepUpChallenge challenge, Instant now);
+ }
+
+ /**
+ * The engine step-up authorization seam. The session runtime binds it to the engine as
+ * {@code challenge -> stepUpHandler.initiate(clientConfiguration, providerMetadata, challenge)};
+ * a test binds a hand-built request. The engine carries the challenge's {@code acr_values} /
+ * {@code max_age} into the authorization request; keeping the confidential-client wiring behind
+ * the seam decouples the coordinator from it.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @FunctionalInterface
+ public interface StepUpInitiation {
+
+ /**
+ * Builds the step-up authorization request (URL + transaction context) for the challenge.
+ *
+ * @param challenge the RFC 9470 challenge whose elevated parameters drive the request
+ * @return the engine step-up request (authorization URL + transaction {@code FlowContext})
+ */
+ StepUpHandler.StepUpRequest initiate(StepUpChallenge challenge);
+ }
+
+ /**
+ * The framework-agnostic result of step-up coordination: either the challenge was
+ * {@link Kind#SATISFIED} silently (replay the original request with the elevated session) or the
+ * browser must be {@link Kind#RE_DRIVE re-driven} through the auth-code flow carrying the
+ * browser-binding {@code Set-Cookie}. Token material never appears here.
+ *
+ * @param kind which of the two step-up outcomes occurred
+ * @param session the elevated session, present only for {@link Kind#SATISFIED}
+ * @param location the step-up redirect target, present only for {@link Kind#RE_DRIVE}
+ * @param setCookieHeaders the browser-binding {@code Set-Cookie} header values, empty when satisfied
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record StepUpOutcome(Kind kind, Optional session, Optional location,
+ List setCookieHeaders) {
+
+ /**
+ * The two terminal states of a step-up coordination.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public enum Kind {
+ /** The challenge was satisfied silently — the original request is replayed. */
+ SATISFIED,
+ /** The browser is re-driven through the auth-code flow with the elevated parameters. */
+ RE_DRIVE
+ }
+
+ /**
+ * Canonical constructor normalizing absent optionals and defensively copying the cookies.
+ */
+ public StepUpOutcome {
+ Objects.requireNonNull(kind, "kind");
+ session = Objects.requireNonNullElse(session, Optional.empty());
+ location = Objects.requireNonNullElse(location, Optional.empty());
+ setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders);
+ }
+
+ /**
+ * A silently-satisfied outcome carrying the elevated session for the replay.
+ *
+ * @param session the session whose authentication context now satisfies the challenge
+ * @return the satisfied outcome
+ */
+ public static StepUpOutcome satisfied(SessionRecord session) {
+ Objects.requireNonNull(session, "session");
+ return new StepUpOutcome(Kind.SATISFIED, Optional.of(session), Optional.empty(), List.of());
+ }
+
+ /**
+ * A re-drive outcome carrying the step-up redirect and the browser-binding {@code Set-Cookie}.
+ *
+ * @param location the step-up authorization URL to redirect the browser to
+ * @param setCookieHeaders the browser-binding {@code Set-Cookie} header values
+ * @return the re-drive outcome
+ */
+ public static StepUpOutcome reDrive(String location, List setCookieHeaders) {
+ Objects.requireNonNull(location, "location");
+ return new StepUpOutcome(Kind.RE_DRIVE, Optional.empty(), Optional.of(location), setCookieHeaders);
+ }
+
+ /**
+ * @return {@code true} when the challenge was satisfied silently (the request is replayed)
+ */
+ public boolean isSatisfied() {
+ return kind == Kind.SATISFIED;
+ }
+ }
+}
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
new file mode 100644
index 00000000..c878f009
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java
@@ -0,0 +1,307 @@
+/*
+ * 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.refresh;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+
+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;
+
+/**
+ * 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
+ * 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
+ * surfaces that as a {@link TokenSheriffException}; the coordinator's residual is to
+ * destroy the session on any refresh failure so a revoked family can never be mediated
+ * from again.
+ *
+ * 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.
+ *
+ * On-failure semantics. A {@link RefreshOutcome#failed() failed} outcome means the
+ * session has already been destroyed and the caller MUST treat the request as
+ * unauthenticated, running the same content negotiation as {@code require: session}: a
+ * navigation request (its {@code Accept} offers {@code text/html}) is re-driven through login,
+ * anything else gets {@code 401 application/problem+json}. This class is framework-agnostic — it
+ * carries no request/response coupling and is unit-testable without a container or a live IdP; the
+ * session runtime performs the negotiation and binds the engine seams.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class TokenRefreshCoordinator {
+
+ private static final CuiLogger LOGGER = new CuiLogger(TokenRefreshCoordinator.class);
+
+ private final Duration leeway;
+ private final AccessTokenExpiry accessTokenExpiry;
+ private final RefreshExchange refreshExchange;
+ private final SessionStore sessionStore;
+ private final ConcurrentMap> inFlight = new ConcurrentHashMap<>();
+
+ /**
+ * Assembles the coordinator with the refresh leeway, the engine seams, and the session store.
+ *
+ * @param leeway how long before access-token expiry a refresh is triggered
+ * ({@code session.refresh.leeway_seconds})
+ * @param accessTokenExpiry the mediated-access-token expiry seam (bound to the engine token
+ * 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
+ */
+ public TokenRefreshCoordinator(Duration leeway, AccessTokenExpiry accessTokenExpiry,
+ RefreshExchange refreshExchange, SessionStore sessionStore) {
+ this.leeway = Objects.requireNonNull(leeway, "leeway");
+ this.accessTokenExpiry = Objects.requireNonNull(accessTokenExpiry, "accessTokenExpiry");
+ this.refreshExchange = Objects.requireNonNull(refreshExchange, "refreshExchange");
+ this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore");
+ }
+
+ /**
+ * 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
+ * @return {@link RefreshOutcome#current(SessionRecord) current} when no refresh was needed,
+ * {@link RefreshOutcome#refreshed(SessionRecord) refreshed} carrying the rotated
+ * session, or {@link RefreshOutcome#failed() failed} when the session was destroyed
+ */
+ public RefreshOutcome refresh(SessionRecord session, Instant now) {
+ Objects.requireNonNull(session, "session");
+ Objects.requireNonNull(now, "now");
+ if (session.refreshToken().isEmpty() || !nearExpiry(session, now)) {
+ return RefreshOutcome.current(session);
+ }
+ String sessionId = session.sessionId();
+ CompletableFuture leader = new CompletableFuture<>();
+ CompletableFuture existing = inFlight.putIfAbsent(sessionId, leader);
+ if (existing != null) {
+ // A concurrent request already leads the refresh for this session — share its result.
+ return existing.join();
+ }
+ try {
+ RefreshOutcome outcome = performRefresh(sessionId, now);
+ leader.complete(outcome);
+ return outcome;
+ } finally {
+ // Guarantee coalesced waiters never hang: a no-op when the leader already completed
+ // above, but a deterministic FAILED when performRefresh threw before completing it (the
+ // unexpected exception still propagates out of this method for the leader's own caller).
+ leader.complete(RefreshOutcome.failed());
+ inFlight.remove(sessionId, leader);
+ }
+ }
+
+ private RefreshOutcome performRefresh(String sessionId, Instant now) {
+ Optional resolved = sessionStore.resolve(sessionId, now);
+ if (resolved.isEmpty()) {
+ // Destroyed or expired between the near-expiry check and acquiring the lead — unauthenticated.
+ return RefreshOutcome.failed();
+ }
+ SessionRecord latest = resolved.get();
+ Optional refreshToken = latest.refreshToken();
+ if (refreshToken.isEmpty() || !nearExpiry(latest, now)) {
+ // A coalesced leader already rotated this session — share the current token, no engine call.
+ return RefreshOutcome.current(latest);
+ }
+ String presentedRefreshToken = refreshToken.get();
+ try {
+ RotationResult rotation = refreshExchange.exchange(presentedRefreshToken);
+ SessionRecord rotated = rotate(latest, rotation);
+ // create() upserts by session id (SessionStore contract; InMemorySessionStore is a keyed
+ // map put), so no pre-create destroy is needed on the success path. Destroying first would
+ // open a window where a concurrent resolve() misses the rotating session.
+ sessionStore.create(rotated);
+ LOGGER.debug("Refreshed the mediated tokens for a require:session route (single-flight)");
+ return RefreshOutcome.refreshed(rotated);
+ } catch (TokenSheriffException refreshFailure) {
+ // Engine failure OR refresh-token reuse (the engine revoked the family) — the session can no
+ // longer be sustained. Destroy it so the caller re-drives login / returns 401.
+ sessionStore.destroyById(sessionId);
+ LOGGER.debug(refreshFailure,
+ "Token refresh failed (IdP rejection or refresh-token reuse) — session destroyed");
+ return RefreshOutcome.failed();
+ }
+ }
+
+ private boolean nearExpiry(SessionRecord session, Instant now) {
+ Instant expiry = accessTokenExpiry.expiryOf(session);
+ return !now.isBefore(expiry.minus(leeway));
+ }
+
+ private static SessionRecord rotate(SessionRecord previous, RotationResult rotation) {
+ String rotatedIdToken = rotation.idToken();
+ return SessionRecord.builder()
+ .sessionId(previous.sessionId())
+ .accessToken(rotation.accessToken().getRawToken())
+ .refreshToken(Optional.of(rotation.refreshToken()))
+ .idToken(rotatedIdToken == null || rotatedIdToken.isBlank() ? previous.idToken() : rotatedIdToken)
+ .sub(previous.sub())
+ .sid(previous.sid())
+ .expiresAt(previous.expiresAt())
+ .acr(previous.acr())
+ .authTime(previous.authTime())
+ .build();
+ }
+
+ /**
+ * The mediated-access-token expiry seam. The session runtime binds it to the engine token
+ * parsing (the access token's {@code exp}); a test binds a fixed instant. Keeping the token
+ * parsing behind the seam decouples the coordinator from the engine and keeps it unit-testable,
+ * and lets the near-expiry decision use the access-token lifetime rather than the absolute
+ * session cap ({@link SessionRecord#expiresAt()}).
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @FunctionalInterface
+ public interface AccessTokenExpiry {
+
+ /**
+ * @param session the live session whose mediated access token is inspected
+ * @return the instant the session's mediated access token expires
+ */
+ Instant expiryOf(SessionRecord session);
+ }
+
+ /**
+ * The engine refresh seam. The session runtime binds it to the engine as
+ * {@code refreshToken -> refreshFlow.refresh(providerMetadata, refreshToken)}; a test binds a
+ * stubbed rotation or a throwing stub. The engine owns the refresh grant, refresh-token
+ * rotation, and reuse detection; keeping the confidential-client wiring (provider metadata,
+ * client authentication) behind the seam decouples the coordinator from it.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @FunctionalInterface
+ public interface RefreshExchange {
+
+ /**
+ * Refreshes the mediated tokens using the current refresh token.
+ *
+ * @param refreshToken the session's current refresh token
+ * @return the engine rotation result (new access/refresh/ID token + access-token lifetime)
+ * @throws de.cuioss.sheriff.token.commons.error.TokenSheriffException when the refresh fails
+ * (IdP rejection) or the engine detects refresh-token reuse and revokes the family
+ */
+ RotationResult exchange(String refreshToken);
+ }
+
+ /**
+ * 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.
+ *
+ * @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}
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record RefreshOutcome(Kind kind, Optional session) {
+
+ /**
+ * The three terminal states of a refresh attempt.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public enum Kind {
+ /** No refresh was needed — the mediated token is not yet within {@code leeway} of expiry. */
+ CURRENT,
+ /** The mediated token was rotated through the engine and persisted. */
+ REFRESHED,
+ /** The refresh failed and the session was destroyed; the caller treats it as unauthenticated. */
+ FAILED
+ }
+
+ /**
+ * Canonical constructor normalizing an absent optional and enforcing the presence contract.
+ */
+ public RefreshOutcome {
+ Objects.requireNonNull(kind, "kind");
+ session = Objects.requireNonNullElse(session, Optional.empty());
+ 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.
+ *
+ * @param session the live session, unchanged
+ * @return the current outcome
+ */
+ public static RefreshOutcome current(SessionRecord session) {
+ return new RefreshOutcome(Kind.CURRENT, Optional.of(session));
+ }
+
+ /**
+ * A successful-refresh outcome carrying the rotated session.
+ *
+ * @param session the session with the rotated token material
+ * @return the refreshed outcome
+ */
+ public static RefreshOutcome refreshed(SessionRecord session) {
+ return new RefreshOutcome(Kind.REFRESHED, Optional.of(session));
+ }
+
+ /**
+ * A failed-refresh outcome carrying no session — the session has been destroyed and the
+ * caller must treat the request as unauthenticated (navigation re-driven through login, any
+ * other request answered {@code 401 application/problem+json}).
+ *
+ * @return the failed outcome
+ */
+ public static RefreshOutcome failed() {
+ return new RefreshOutcome(Kind.FAILED, Optional.empty());
+ }
+
+ /**
+ * @return {@code true} when the refresh failed and the session was destroyed
+ */
+ public boolean isFailure() {
+ return kind == Kind.FAILED;
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/package-info.java
new file mode 100644
index 00000000..f9164a1b
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/package-info.java
@@ -0,0 +1,49 @@
+/*
+ * 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.
+ */
+/**
+ * Transparent token refresh and RFC 9470 step-up orchestration for {@code require: session}
+ * routes (D7).
+ *
+ * The gateway re-implements no OAuth leg: the engine
+ * ({@code token-sheriff-client}) owns the refresh grant with refresh-token rotation and reuse
+ * detection, and owns the RFC 9470 challenge grammar and the step-up authorization-request
+ * construction. This package holds only the gateway-side orchestration, framework-agnostically (no
+ * CDI, no JAX-RS/Vert.x coupling), so every class is unit-testable without a container or a live
+ * IdP:
+ *
+ * - {@link de.cuioss.sheriff.gateway.bff.refresh.TokenRefreshCoordinator} refreshes the mediated
+ * token within its expiry leeway through the engine, single-flighted per session so
+ * concurrent requests on one session share one refresh; a refresh failure (IdP rejection or
+ * engine-detected refresh-token reuse revoking the family) destroys the session so the caller
+ * treats the request as unauthenticated (navigation re-driven through login, any other request
+ * answered {@code 401 application/problem+json}).
+ * - {@link de.cuioss.sheriff.gateway.bff.refresh.StepUpCoordinator} parses an upstream
+ * {@code insufficient_user_authentication} challenge, attempts silent satisfaction, and
+ * otherwise re-drives the auth-code flow with the challenge's elevated {@code acr_values} /
+ * {@code max_age} — persisting the re-drive transaction as a single-use pending-authorization
+ * record with a same-origin-validated replay target and a browser-binding cookie.
+ *
+ * Both coordinators reach the engine through functional-interface seams and return framework-agnostic
+ * outcome records; the session runtime binds the seams (confidential-client wiring) and performs the
+ * request/response edge (the content negotiation, the redirect, the request replay).
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.gateway.bff.refresh;
+
+import org.jspecify.annotations.NullMarked;
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
new file mode 100644
index 00000000..e411baff
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpoint.java
@@ -0,0 +1,166 @@
+/*
+ * 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.reserved;
+
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Objects;
+import java.util.Optional;
+
+
+import de.cuioss.sheriff.gateway.bff.logout.BackchannelLogoutReceiver;
+import de.cuioss.tools.logging.CuiLogger;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The OIDC back-channel logout endpoint ({@code oidc.logout.backchannel_path}) — the request/response
+ * edge over {@link BackchannelLogoutReceiver} (D2c, BFF-09). It owns the reserved
+ * {@link ReservedPathRegistry.ReservedEndpoint#BACKCHANNEL_LOGOUT} path; the signature verification,
+ * the claim residual, and the secondary-index session destruction live in the receiver.
+ *
+ * The OpenID Connect
+ * Back-Channel Logout request is an {@code application/x-www-form-urlencoded} {@code POST} carrying
+ * a single {@code logout_token} parameter. The endpoint extracts that parameter from the raw
+ * form body (URL-decoding the value) and hands it to {@link BackchannelLogoutReceiver#receive}: an
+ * accepted token yields {@code 200} (with the destroyed-session count for observability), a
+ * missing/absent {@code logout_token} or a signature/claim rejection yields {@code 400}, and nothing
+ * 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.
+ *
+ * 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.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class BackchannelLogoutEndpoint {
+
+ private static final CuiLogger LOGGER = new CuiLogger(BackchannelLogoutEndpoint.class);
+
+ /** The single form parameter the OIDC back-channel logout request carries. */
+ public static final String LOGOUT_TOKEN_PARAM = "logout_token";
+
+ private static final int OK = 200;
+ private static final int BAD_REQUEST = 400;
+
+ private final BackchannelLogoutReceiver receiver;
+
+ /**
+ * Assembles the endpoint with the back-channel logout receiver.
+ *
+ * @param receiver the transport-free back-channel logout receiver (verify, validate, destroy)
+ */
+ public BackchannelLogoutEndpoint(BackchannelLogoutReceiver receiver) {
+ this.receiver = Objects.requireNonNull(receiver, "receiver");
+ }
+
+ /**
+ * Handles one back-channel logout request: extracts the {@code logout_token} from the raw form
+ * body and drives the 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}
+ * when {@code logout_token} is absent or the token is rejected
+ */
+ public BackchannelLogoutOutcome receive(@Nullable String rawFormBody, Instant now) {
+ Objects.requireNonNull(now, "now");
+
+ Optional logoutToken = extractLogoutToken(rawFormBody);
+ if (logoutToken.isEmpty()) {
+ LOGGER.debug("Back-channel logout request missing the logout_token form parameter — rejected");
+ return BackchannelLogoutOutcome.error(BAD_REQUEST);
+ }
+
+ BackchannelLogoutReceiver.BackchannelResult result = receiver.receive(logoutToken.get(), now);
+ if (!result.accepted()) {
+ return BackchannelLogoutOutcome.error(BAD_REQUEST);
+ }
+ return BackchannelLogoutOutcome.accepted(result.destroyed());
+ }
+
+ private static Optional extractLogoutToken(@Nullable String rawFormBody) {
+ if (rawFormBody == null || rawFormBody.isBlank()) {
+ return Optional.empty();
+ }
+ for (String pair : rawFormBody.split("&")) {
+ int equals = pair.indexOf('=');
+ if (equals <= 0) {
+ continue;
+ }
+ Optional name = decode(pair.substring(0, equals));
+ if (name.isEmpty() || !LOGOUT_TOKEN_PARAM.equals(name.get())) {
+ continue;
+ }
+ return decode(pair.substring(equals + 1)).filter(value -> !value.isBlank());
+ }
+ return Optional.empty();
+ }
+
+ private static Optional decode(String value) {
+ // Malformed percent-encoding (URLDecoder.decode throws IllegalArgumentException) is treated as
+ // an absent parameter so the endpoint fails closed to 400 rather than surfacing a 500.
+ try {
+ return Optional.of(URLDecoder.decode(value, StandardCharsets.UTF_8));
+ } catch (IllegalArgumentException malformed) {
+ LOGGER.debug(malformed, "Back-channel logout form value carried malformed percent-encoding — treated as absent");
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * 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.
+ *
+ * @param status the HTTP status the edge returns ({@code 200} accepted, {@code 400} rejected)
+ * @param destroyed the number of sessions destroyed, always {@code 0} for a rejected outcome
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record BackchannelLogoutOutcome(int status, int destroyed) {
+
+ /**
+ * An accepted {@code 200} outcome carrying the destroyed-session count.
+ *
+ * @param destroyed the number of sessions destroyed
+ * @return the accepted outcome
+ */
+ public static BackchannelLogoutOutcome accepted(int destroyed) {
+ return new BackchannelLogoutOutcome(OK, destroyed);
+ }
+
+ /**
+ * An error outcome carrying the given status and destroying nothing.
+ *
+ * @param status the {@code 4xx} status
+ * @return the error outcome
+ */
+ public static BackchannelLogoutOutcome error(int status) {
+ return new BackchannelLogoutOutcome(status, 0);
+ }
+
+ /**
+ * @return {@code true} when the back-channel logout token was accepted
+ */
+ public boolean isAccepted() {
+ return status == OK;
+ }
+ }
+}
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
new file mode 100644
index 00000000..9f702c11
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java
@@ -0,0 +1,322 @@
+/*
+ * 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.reserved;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+
+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.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;
+import de.cuioss.sheriff.token.commons.error.TokenSheriffException;
+import de.cuioss.sheriff.token.validation.domain.claim.ClaimValue;
+import de.cuioss.sheriff.token.validation.domain.token.AccessTokenContent;
+import de.cuioss.sheriff.token.validation.domain.token.IdTokenContent;
+import de.cuioss.sheriff.token.validation.domain.token.TokenContent;
+import de.cuioss.tools.logging.CuiLogger;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The OIDC auth-code callback endpoint ({@code oidc.redirect_uri}) — the browser-facing landing
+ * of the code flow (D2/D2b/D2c). Framework-agnostic by construction (it consumes a raw parameter
+ * string and a raw {@code Cookie} header and returns a {@link CallbackOutcome} the edge renders),
+ * so it carries no JAX-RS/Vert.x coupling and is unit-testable without a container.
+ *
+ * form_post callback. The engine drives the authorization request with
+ * {@code response_mode=form_post}, so after a successful login Keycloak returns a 200 auto-submit
+ * form that POSTs the {@code code}/{@code state} to the {@code redirect_uri} as an
+ * {@code application/x-www-form-urlencoded} body — not a 302 with the code in the query. The edge
+ * therefore hands this endpoint the raw form body for a POST callback and the raw query for a GET
+ * callback; both are the same urlencoded parameter shape, so a single {@code parse} handles either.
+ *
+ * Callback HPP defence (BFF-13). The endpoint parses the raw parameter
+ * string with {@link CallbackParameters#parse(String)} — never {@link CallbackParameters#of(java.util.Map)}:
+ * only the raw parse lets the engine re-detect a duplicated {@code code}/{@code state} (the
+ * Keycloak CVE-2026-9689 class), which a collapsed map cannot. A form_post body is parsed by the
+ * same {@code parse}, so the duplicate-parameter defence holds identically for the POST body.
+ *
+ * Browser binding (D2b). The pending-authorization record is resolved by the
+ * unguessable id carried in the {@link BindingCookieCodec browser-binding cookie}, not by the
+ * returned {@code state} alone: a callback replayed in a different browser (which carries no
+ * binding cookie) is rejected {@code 403} even with a valid {@code state}. The returned
+ * {@code state} must additionally match the resolved record's {@code state} (constant-time), so a
+ * callback is honoured only when both the binding cookie resolves the record and
+ * the {@code state} matches — the pre-session analogue of the session cookie.
+ *
+ * Engine-driven exchange. The endpoint hands the record's engine
+ * {@code FlowContext} and the parsed parameters to the {@link CodeExchange} seam — the session
+ * 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.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class CallbackEndpoint {
+
+ private static final CuiLogger LOGGER = new CuiLogger(CallbackEndpoint.class);
+
+ private static final int BAD_REQUEST = 400;
+ private static final int FORBIDDEN = 403;
+ private static final int FOUND = 302;
+
+ private static final String CLAIM_SID = "sid";
+ private static final String CLAIM_ACR = "acr";
+ private static final String CLAIM_AUTH_TIME = "auth_time";
+
+ private final CodeExchange codeExchange;
+ private final PendingAuthorizationStore pendingStore;
+ private final BindingCookieCodec bindingCookieCodec;
+ private final SessionStore sessionStore;
+ private final SessionCookieCodec sessionCookieCodec;
+ private final Duration sessionTtl;
+
+ /**
+ * Assembles the callback endpoint with the exchange seam and the gateway-side stores 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 sessionTtl the absolute session lifetime from login
+ */
+ public CallbackEndpoint(CodeExchange codeExchange, PendingAuthorizationStore pendingStore,
+ BindingCookieCodec bindingCookieCodec, SessionStore sessionStore, SessionCookieCodec sessionCookieCodec,
+ 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.sessionTtl = Objects.requireNonNull(sessionTtl, "sessionTtl");
+ }
+
+ /**
+ * Handles one OIDC callback: validates the binding, drives the engine exchange, creates the
+ * session, and returns the browser response.
+ *
+ * @param rawParameters the raw callback parameter string — the query for a GET callback or the
+ * {@code application/x-www-form-urlencoded} body for a {@code response_mode=form_post}
+ * 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
+ */
+ public CallbackOutcome handle(String rawParameters, @Nullable String cookieHeader, Instant now) {
+ Objects.requireNonNull(rawParameters, "rawParameters");
+ Objects.requireNonNull(now, "now");
+
+ CallbackParameters params;
+ try {
+ params = CallbackParameters.parse(rawParameters);
+ } catch (IllegalArgumentException duplicateInjection) {
+ // BFF-13: parse() rejects a duplicated code/state (RFC 9700 §4.7.3 parameter injection —
+ // the Keycloak CVE-2026-9689 class). Only the raw parse can detect this; of(Map) cannot.
+ LOGGER.debug(duplicateInjection, "OIDC callback rejected — duplicate query parameter (BFF-13)");
+ return CallbackOutcome.error(BAD_REQUEST);
+ }
+ if (params.hasError()) {
+ LOGGER.debug("OIDC callback carried an IdP error response: %s", params.error());
+ return CallbackOutcome.error(BAD_REQUEST);
+ }
+ if (isBlank(params.state())) {
+ LOGGER.debug("OIDC callback missing state parameter");
+ return CallbackOutcome.error(BAD_REQUEST);
+ }
+
+ Optional recordId = bindingCookieCodec.readRecordId(cookieHeader);
+ if (recordId.isEmpty()) {
+ LOGGER.debug("OIDC callback without a browser-binding cookie — rejected");
+ return CallbackOutcome.error(FORBIDDEN);
+ }
+
+ Optional resolved = pendingStore.consume(recordId.get(), now);
+ if (resolved.isEmpty()) {
+ LOGGER.debug("OIDC callback binding cookie resolved no live pending record — rejected");
+ return CallbackOutcome.error(FORBIDDEN);
+ }
+ PendingAuthorizationRecord pending = resolved.get();
+
+ if (!constantTimeEquals(pending.flowContext().state(), params.state())) {
+ LOGGER.debug("OIDC callback state did not match the bound pending record — rejected");
+ return CallbackOutcome.error(FORBIDDEN);
+ }
+
+ try {
+ AuthorizationCodeFlow.AuthenticationResult result = codeExchange.exchange(pending.flowContext(), params);
+ return completeLogin(result, pending, now);
+ } catch (TokenSheriffException engineFailure) {
+ LOGGER.debug(engineFailure, "OIDC callback code exchange / token validation failed");
+ return CallbackOutcome.error(BAD_REQUEST);
+ }
+ }
+
+ private CallbackOutcome completeLogin(AuthorizationCodeFlow.AuthenticationResult result,
+ PendingAuthorizationRecord pending, Instant now) {
+ AccessTokenContent accessToken = result.accessToken();
+ IdTokenContent idToken = result.idToken();
+ Optional subject = idToken.getSubject().or(accessToken::getSubject);
+ if (subject.isEmpty()) {
+ LOGGER.debug("OIDC callback validated tokens carried no subject — rejected");
+ return CallbackOutcome.error(BAD_REQUEST);
+ }
+
+ String sessionId = SessionRecord.newSessionId();
+ SessionRecord session = SessionRecord.builder()
+ .sessionId(sessionId)
+ .accessToken(accessToken.getRawToken())
+ .refreshToken(Optional.empty())
+ .idToken(idToken.getRawToken())
+ .sub(subject.get())
+ .sid(claim(idToken, CLAIM_SID))
+ .expiresAt(now.plus(sessionTtl))
+ .acr(claim(idToken, CLAIM_ACR))
+ .authTime(claimEpochSeconds(idToken, CLAIM_AUTH_TIME))
+ .build();
+ sessionStore.create(session);
+
+ List setCookies = List.of(
+ sessionCookieCodec.toSetCookieHeader(sessionId),
+ bindingCookieCodec.toClearingSetCookieHeader());
+ return CallbackOutcome.redirect(pending.returnUrl(), setCookies);
+ }
+
+ private static Optional claim(TokenContent token, String name) {
+ ClaimValue value = token.getClaims().get(name);
+ if (value == null) {
+ return Optional.empty();
+ }
+ String original = value.getOriginalString();
+ return original == null || original.isBlank() ? Optional.empty() : Optional.of(original);
+ }
+
+ private static Optional claimEpochSeconds(TokenContent token, String name) {
+ return claim(token, name).flatMap(raw -> {
+ try {
+ return Optional.of(Instant.ofEpochSecond(Long.parseLong(raw.trim())));
+ } catch (NumberFormatException _) {
+ return Optional.empty();
+ }
+ });
+ }
+
+ private static boolean isBlank(@Nullable String value) {
+ return value == null || value.isBlank();
+ }
+
+ private static boolean constantTimeEquals(@Nullable String expected, @Nullable String actual) {
+ if (expected == null || actual == null) {
+ return false;
+ }
+ return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), actual.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * The engine code-exchange seam. The session runtime binds it to the engine as
+ * {@code (context, params) -> authorizationCodeFlow.exchange(providerMetadata, context, params,
+ * clientAuthentication)}; a test binds it to a hand-built result. Keeping the confidential-client
+ * wiring (provider metadata, client authentication) behind the seam decouples the endpoint from
+ * it and makes the success and exchange-failure paths unit-testable without a live token endpoint.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @FunctionalInterface
+ public interface CodeExchange {
+
+ /**
+ * Exchanges the callback for validated tokens: the engine performs the code exchange and the
+ * {@code state}/{@code nonce}/{@code iss} + token validation, fail-closed.
+ *
+ * @param context the pending record's engine transaction context (owns state/nonce/PKCE)
+ * @param params the parsed callback parameters (from the raw query — BFF-13)
+ * @return the validated access + ID token result
+ * @throws de.cuioss.sheriff.token.commons.error.TokenSheriffException when the exchange or
+ * token validation fails (invalid state/nonce, IdP error, signature/claim failure)
+ */
+ AuthorizationCodeFlow.AuthenticationResult exchange(FlowContext context, CallbackParameters params);
+ }
+
+ /**
+ * The framework-agnostic result of a callback: either a {@code 302} redirect carrying the
+ * post-login {@code Set-Cookie} headers, or an error status with no body semantics for the edge
+ * to render. Token material never appears here — only the opaque cookie headers and the
+ * same-origin return location.
+ *
+ * @param status the HTTP status the edge returns
+ * @param location the redirect target, present only for a successful login
+ * @param setCookieHeaders the {@code Set-Cookie} header values to emit, empty for an error
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record CallbackOutcome(int status, Optional location, List setCookieHeaders) {
+
+ /**
+ * Canonical constructor normalizing an absent location and defensively copying the cookies.
+ */
+ public CallbackOutcome {
+ location = Objects.requireNonNullElse(location, Optional.empty());
+ setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders);
+ }
+
+ /**
+ * A successful-login {@code 302} redirect.
+ *
+ * @param location the same-origin return URL
+ * @param setCookieHeaders the session {@code Set-Cookie} and the binding-clearing {@code Set-Cookie}
+ * @return the redirect outcome
+ */
+ public static CallbackOutcome redirect(String location, List setCookieHeaders) {
+ Objects.requireNonNull(location, "location");
+ return new CallbackOutcome(FOUND, Optional.of(location), setCookieHeaders);
+ }
+
+ /**
+ * An error outcome carrying no redirect and no cookies.
+ *
+ * @param status the {@code 4xx} status
+ * @return the error outcome
+ */
+ public static CallbackOutcome error(int status) {
+ return new CallbackOutcome(status, Optional.empty(), List.of());
+ }
+
+ /**
+ * @return {@code true} when this outcome is a successful-login redirect
+ */
+ public boolean isRedirect() {
+ return status == FOUND;
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilter.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilter.java
new file mode 100644
index 00000000..ce65815d
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilter.java
@@ -0,0 +1,124 @@
+/*
+ * 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.reserved;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * The operator-owned claim allowlist that caps what the session/user-info endpoint (D11) may
+ * disclose to the browser.
+ *
+ * Secure default closed. The allowlist is the sole authority over which identity
+ * claims leave the gateway — an empty allowlist discloses nothing, so the
+ * operator (never the browser client) is the only party that widens disclosure. Every response the
+ * endpoint serves is filtered through {@link #filter(Map, List)}, so a claim that is not on the
+ * allowlist can never appear in the output even when it is present in the validated ID token.
+ *
+ * The {@linkplain #defaultView() curated default view} — returned when the caller requests no
+ * explicit claims — is itself capped to the allowlist at construction: any {@code default_view}
+ * entry outside {@code allowed_claims} is dropped, so the default can never widen disclosure beyond
+ * the operator cap. The filter is immutable and framework-agnostic, so a single instance is safely
+ * shared across threads.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+// java:S6206 — not a transparent data carrier: the constructor performs security-critical
+// normalization (defensive Set.copyOf, defaultView capped to the allowlist) and the class exposes
+// behavior (isAllowed/isEmpty/filter), so a record's transparent component contract does not fit.
+@SuppressWarnings("java:S6206")
+public final class ClaimAllowlistFilter {
+
+ private final Set allowedClaims;
+ private final List defaultView;
+
+ /**
+ * Assembles the filter from the operator allowlist and the curated default view.
+ *
+ * @param allowedClaims the operator claim allowlist; an empty collection is the secure closed
+ * default that discloses nothing
+ * @param defaultView the curated default-view selector returned when the caller requests no
+ * explicit claims; entries outside {@code allowedClaims} are dropped so the
+ * default can never disclose beyond the allowlist
+ */
+ public ClaimAllowlistFilter(Collection allowedClaims, Collection defaultView) {
+ Objects.requireNonNull(allowedClaims, "allowedClaims");
+ Objects.requireNonNull(defaultView, "defaultView");
+ this.allowedClaims = Set.copyOf(allowedClaims);
+ this.defaultView = defaultView.stream()
+ .distinct()
+ .filter(this.allowedClaims::contains)
+ .toList();
+ }
+
+ /**
+ * @param claim the claim name to test
+ * @return {@code true} when the claim is on the operator allowlist and may be disclosed
+ */
+ public boolean isAllowed(String claim) {
+ Objects.requireNonNull(claim, "claim");
+ return allowedClaims.contains(claim);
+ }
+
+ /**
+ * @return {@code true} when the allowlist is empty — the secure closed default that discloses nothing
+ */
+ public boolean isEmpty() {
+ return allowedClaims.isEmpty();
+ }
+
+ /**
+ * @return the operator claim allowlist (immutable)
+ */
+ public Set allowedClaims() {
+ return allowedClaims;
+ }
+
+ /**
+ * @return the curated default-view selection, already capped to the allowlist (immutable)
+ */
+ public List defaultView() {
+ return defaultView;
+ }
+
+ /**
+ * Filters a source claim map to the allowlisted subset of the requested selection, preserving the
+ * selection order. Only keys that are both on the allowlist and present in {@code source} appear
+ * in the result; a requested claim outside the allowlist is silently dropped (the endpoint rejects
+ * an explicitly-requested disallowed claim {@code 403} before calling this).
+ *
+ * @param source the candidate claim values (validated ID-token claims)
+ * @param selection the ordered claim names the caller selected
+ * @return an ordered, unmodifiable map of the disclosed claims
+ */
+ public Map filter(Map source, List selection) {
+ Objects.requireNonNull(source, "source");
+ Objects.requireNonNull(selection, "selection");
+ Map filtered = new LinkedHashMap<>();
+ for (String claim : selection) {
+ if (allowedClaims.contains(claim) && source.containsKey(claim)) {
+ filtered.put(claim, source.get(claim));
+ }
+ }
+ return Collections.unmodifiableMap(filtered);
+ }
+}
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
new file mode 100644
index 00000000..c974399b
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpoint.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.reserved;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+
+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.SessionRecord;
+import de.cuioss.sheriff.gateway.bff.session.SessionStore;
+import de.cuioss.tools.logging.CuiLogger;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The login-initiation reserved endpoint ({@code oidc.login.path}) — the sixth reserved gateway
+ * path (D12). It is registered exactly like the other reserved endpoints (exact match, on the OIDC
+ * host, resolved by the {@link ReservedPathRegistry} before the proxy route table), and
+ * gives the browser an explicit, gateway-owned URL to start a login from — the mirror
+ * entry point to the {@link CallbackEndpoint} that lands it.
+ *
+ * No new subsystem. The endpoint is a thin edge over the D5 {@link LoginFlow}: it
+ * reuses the same-origin-validated post-login return URL and the browser-binding cookie carried by
+ * the D2b {@link PendingAuthorizationRecord}, and delegates the whole auth-code initiation (engine
+ * authorization, PKCE/{@code state}/{@code nonce}, pending-record persistence, binding-cookie
+ * minting) to {@link LoginFlow#initiate}. It adds exactly one gateway-side concern on top: the
+ * already-authenticated short-circuit.
+ *
+ * Return-URL safety (never an open redirect). The {@code returnUrl} parameter is
+ * same-origin-validated exactly as D2b requires
+ * ({@link PendingAuthorizationRecord#sameOrigin(String, String)}). On the unauthenticated path the
+ * validation is {@link LoginFlow}'s (a cross-origin or unparseable target falls back to
+ * {@link LoginFlow#DEFAULT_RETURN_URL}); on the already-authenticated path this endpoint applies the
+ * identical validation before it redirects. Either way an off-origin, schema-relative
+ * ({@code //host}), blank, or unparseable target can never become the redirect location — the
+ * login-initiation URL is not an open redirect.
+ *
+ * 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
+ * {@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.
+ *
+ * The endpoint is framework-agnostic (a raw {@code returnUrl} parameter and a raw {@code Cookie}
+ * header in, a {@link LoginInitiationOutcome} the edge renders out — no JAX-RS/Vert.x coupling), so
+ * it is unit-testable without a container; the session runtime wires it to the request/response edge.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class LoginInitiationEndpoint {
+
+ private static final CuiLogger LOGGER = new CuiLogger(LoginInitiationEndpoint.class);
+
+ private static final int FOUND = 302;
+
+ private final LoginFlow loginFlow;
+ private final SessionStore sessionStore;
+ private final SessionCookieCodec sessionCookieCodec;
+ 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
+ */
+ public LoginInitiationEndpoint(LoginFlow loginFlow, SessionStore sessionStore,
+ SessionCookieCodec sessionCookieCodec, String gatewayOrigin) {
+ this.loginFlow = Objects.requireNonNull(loginFlow, "loginFlow");
+ this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore");
+ this.sessionCookieCodec = Objects.requireNonNull(sessionCookieCodec, "sessionCookieCodec");
+ this.gatewayOrigin = Objects.requireNonNull(gatewayOrigin, "gatewayOrigin");
+ }
+
+ /**
+ * Initiates a login: an already-authenticated caller is redirected straight to the same-origin-
+ * validated return URL (no fresh auth-code flow); an unauthenticated caller is driven into the D5
+ * auth-code flow, which returns to the same validated return URL after the callback.
+ *
+ * @param requestedReturnUrl the post-login return target the browser asked for, may be absent
+ * @param cookieHeader the raw request {@code Cookie} header value, may be absent
+ * @param now the reference instant (session-resolution TTL anchor and, on the
+ * unauthenticated path, the pending record's TTL anchor)
+ * @return a {@code 302} redirect — straight to the validated return URL (no cookies) for a live
+ * session, or to the IdP authorization URL carrying the binding {@code Set-Cookie} for a
+ * fresh login
+ */
+ public LoginInitiationOutcome initiate(@Nullable String requestedReturnUrl, @Nullable String cookieHeader,
+ Instant now) {
+ Objects.requireNonNull(now, "now");
+
+ Optional session = sessionCookieCodec.readSessionId(cookieHeader)
+ .flatMap(sessionId -> sessionStore.resolve(sessionId, now));
+ if (session.isPresent()) {
+ String returnUrl = requestedReturnUrl != null
+ && PendingAuthorizationRecord.sameOrigin(requestedReturnUrl, gatewayOrigin)
+ ? requestedReturnUrl : LoginFlow.DEFAULT_RETURN_URL;
+ LOGGER.debug("Login initiation with a live session — short-circuiting to the validated "
+ + "return URL, no fresh auth-code flow");
+ return LoginInitiationOutcome.redirect(returnUrl, List.of());
+ }
+
+ LoginFlow.LoginRedirect redirect = loginFlow.initiate(requestedReturnUrl, now);
+ LOGGER.debug("Login initiation without a live session — starting the OIDC auth-code flow");
+ return LoginInitiationOutcome.redirect(redirect.authorizationUrl(), redirect.setCookieHeaders());
+ }
+
+ /**
+ * The framework-agnostic result of a login initiation: always a {@code 302} redirect — straight
+ * to the same-origin-validated return URL (no cookies) on the already-authenticated short-circuit,
+ * or to the IdP authorization URL carrying the browser-binding {@code Set-Cookie} on a fresh login.
+ * No token material ever appears here — only the opaque binding-cookie header and the redirect
+ * location.
+ *
+ * @param status the HTTP status the edge returns (always {@code 302})
+ * @param location the redirect target (the validated return URL or the IdP authorization URL)
+ * @param setCookieHeaders the {@code Set-Cookie} header values to emit — the binding cookie on a
+ * fresh login, empty on the already-authenticated short-circuit
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record LoginInitiationOutcome(int status, String location, List setCookieHeaders) {
+
+ /**
+ * Canonical constructor rejecting an absent location and defensively copying the cookies.
+ */
+ public LoginInitiationOutcome {
+ Objects.requireNonNull(location, "location");
+ setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders);
+ }
+
+ /**
+ * A {@code 302} redirect carrying the {@code Set-Cookie} headers.
+ *
+ * @param location the redirect target
+ * @param setCookieHeaders the {@code Set-Cookie} header values to emit
+ * @return the redirect outcome
+ */
+ public static LoginInitiationOutcome redirect(String location, List setCookieHeaders) {
+ Objects.requireNonNull(location, "location");
+ return new LoginInitiationOutcome(FOUND, location, setCookieHeaders);
+ }
+
+ /**
+ * @return {@code true} when this outcome is a redirect (always, by construction)
+ */
+ public boolean isRedirect() {
+ return status == FOUND;
+ }
+ }
+}
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
new file mode 100644
index 00000000..786af580
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java
@@ -0,0 +1,201 @@
+/*
+ * 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.reserved;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+
+import de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout;
+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.tools.logging.CuiLogger;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * 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
+ * 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
+ * {@link RpInitiatedLogout#finalRedirect()} directly, bypassing the IdP round-trip (there is no
+ * {@code id_token_hint} to send).
+ *
+ * Return leg. {@link #completeReturn(String, String)} delegates to
+ * {@link RpInitiatedLogout#completeReturn} — the returned {@code state} is verified (constant-time,
+ * engine-owned) against the single-use logout-state cookie, the cookie is cleared, and the browser is
+ * redirected to {@code final_redirect}; a missing/mismatched {@code state} is rejected {@code 400}, so
+ * a forged logout-return cannot land the browser anywhere.
+ *
+ * The endpoint is framework-agnostic (raw {@code Cookie} header in, a {@link LogoutOutcome} the edge
+ * renders out — no JAX-RS/Vert.x coupling), so it is unit-testable without a container; the session
+ * runtime wires it to the request/response edge.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+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;
+
+ /**
+ * Assembles the logout endpoint with the RP-initiated logout logic and the gateway-side stores.
+ *
+ * @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
+ */
+ public LogoutEndpoint(RpInitiatedLogout rpInitiatedLogout, SessionStore sessionStore,
+ SessionCookieCodec sessionCookieCodec) {
+ this.rpInitiatedLogout = Objects.requireNonNull(rpInitiatedLogout, "rpInitiatedLogout");
+ this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore");
+ this.sessionCookieCodec = Objects.requireNonNull(sessionCookieCodec, "sessionCookieCodec");
+ }
+
+ /**
+ * Handles the RP-initiated logout leg: resolves the live session, drives the engine end-session
+ * redirect, destroys the server-side session, and clears the session cookie.
+ *
+ * @param cookieHeader the raw request {@code Cookie} header value, may be absent
+ * @param now the reference instant (the session-resolution TTL anchor)
+ * @return a {@code 302} redirect to the IdP {@code end_session_endpoint} (session-cleared and
+ * logout-state {@code Set-Cookie} headers), or — when no live session — a {@code 302}
+ * straight to {@code final_redirect} clearing the session cookie
+ */
+ 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()) {
+ 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()));
+ }
+ String sessionId = sessionIdOpt.get();
+
+ RpInitiatedLogout.LogoutRedirect redirect;
+ // Local logout is the authoritative, immediately-effective step and must ALWAYS succeed: if the
+ // IdP end-session redirect cannot be built, still destroy the server-side session and clear the
+ // session cookie, then land the browser on final_redirect. The catch is deliberately broad so no
+ // redirect-construction failure can leave the local session usable.
+ // cui-rewrite:disable InvalidExceptionUsageRecipe
+ try {
+ redirect = rpInitiatedLogout.initiate(session.get());
+ } catch (RuntimeException initiationFailure) {
+ sessionStore.destroyById(sessionId);
+ 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()));
+ }
+ sessionStore.destroyById(sessionId);
+ List setCookies = new ArrayList<>(redirect.setCookieHeaders());
+ setCookies.add(sessionCookieCodec.toClearingSetCookieHeader());
+ LOGGER.debug("RP-initiated logout — session destroyed, redirecting to the IdP end_session_endpoint");
+ return LogoutOutcome.redirect(redirect.location(), setCookies);
+ }
+
+ /**
+ * Handles the RP-initiated logout return leg: verifies the returned {@code state} against the
+ * single-use logout-state cookie, clears the cookie, and redirects to {@code final_redirect}.
+ *
+ * @param stateParam the {@code state} returned by the IdP on the post-logout redirect, may be absent
+ * @param cookieHeader the raw request {@code Cookie} header value, may be absent
+ * @return a {@code 302} redirect to {@code final_redirect} on a matching state, or a {@code 400}
+ * on a missing/mismatched state
+ */
+ public LogoutOutcome completeReturn(@Nullable String stateParam, @Nullable String cookieHeader) {
+ RpInitiatedLogout.LogoutReturn result = rpInitiatedLogout.completeReturn(stateParam, cookieHeader);
+ if (!result.isRedirect()) {
+ return LogoutOutcome.error(result.status());
+ }
+ return LogoutOutcome.redirect(result.location().orElseThrow(), result.setCookieHeaders());
+ }
+
+ /**
+ * The framework-agnostic result of a logout leg: either a {@code 302} redirect (to the IdP
+ * {@code end_session_endpoint}, or to {@code final_redirect} on the return / already-logged-out
+ * paths) carrying the {@code Set-Cookie} headers to emit, or a {@code 4xx} error with no redirect.
+ * Token material never appears here — only the opaque cookie headers and the redirect location.
+ *
+ * @param status the HTTP status the edge returns
+ * @param location the redirect target, present only on a redirect outcome
+ * @param setCookieHeaders the {@code Set-Cookie} header values to emit, empty on an error
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record LogoutOutcome(int status, Optional location, List setCookieHeaders) {
+
+ private static final int FOUND = 302;
+
+ /**
+ * Canonical constructor normalizing an absent location and defensively copying the cookies.
+ */
+ public LogoutOutcome {
+ location = Objects.requireNonNullElse(location, Optional.empty());
+ setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders);
+ }
+
+ /**
+ * A {@code 302} redirect carrying the {@code Set-Cookie} headers.
+ *
+ * @param location the redirect target
+ * @param setCookieHeaders the {@code Set-Cookie} header values to emit
+ * @return the redirect outcome
+ */
+ public static LogoutOutcome redirect(String location, List setCookieHeaders) {
+ Objects.requireNonNull(location, "location");
+ return new LogoutOutcome(FOUND, Optional.of(location), setCookieHeaders);
+ }
+
+ /**
+ * An error outcome carrying no redirect and no cookies.
+ *
+ * @param status the {@code 4xx} status
+ * @return the error outcome
+ */
+ public static LogoutOutcome error(int status) {
+ return new LogoutOutcome(status, Optional.empty(), List.of());
+ }
+
+ /**
+ * @return {@code true} when this outcome is a redirect
+ */
+ public boolean isRedirect() {
+ return status == FOUND;
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistry.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistry.java
new file mode 100644
index 00000000..9ff82013
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistry.java
@@ -0,0 +1,184 @@
+/*
+ * 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.reserved;
+
+import java.net.URI;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+
+import de.cuioss.sheriff.gateway.config.model.OidcConfig;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The exact-match registry of the gateway's reserved OIDC endpoints (D2).
+ *
+ * The BFF variants carve up to six gateway-owned paths out of the proxy route table — the
+ * {@code oidc.redirect_uri} callback, the RP-initiated {@code oidc.logout.path}, its
+ * {@code post_logout_redirect_uri} return leg, the {@code oidc.logout.backchannel_path}
+ * receiver, the {@code oidc.user_info.path} session/user-info fold (D11), and the
+ * {@code oidc.login.path} login-initiation fold (D12). Each is matched exactly
+ * (never by prefix) and only on the
+ * OIDC host (the host of {@code oidc.redirect_uri}). The gateway edge consults this
+ * registry before the route table, so a proxy route such as {@code path_prefix: /auth}
+ * can never swallow the exact {@code /auth/callback}: the reserved path is resolved here first
+ * and the prefix route never sees it.
+ *
+ * The registry is built once at boot from the frozen {@link OidcConfig}. When no {@code oidc}
+ * block (or no {@code redirect_uri}) is configured the registry is {@linkplain #isEmpty() empty}
+ * and {@link #match(String, String)} never matches — the pure-proxy gateway is unchanged. The
+ * type is framework-agnostic (raw host/path strings only, no JAX-RS/Vert.x coupling), so it is
+ * unit-testable without a container.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class ReservedPathRegistry {
+
+ /**
+ * The kind of reserved gateway endpoint an exact host+path match resolves to. The runtime
+ * handler for each kind is wired by the session runtime; this registry only classifies the
+ * carve-out.
+ */
+ public enum ReservedEndpoint {
+
+ /** The {@code oidc.redirect_uri} OIDC auth-code callback. */
+ CALLBACK,
+
+ /** The RP-initiated {@code oidc.logout.path}. */
+ LOGOUT,
+
+ /** The {@code post_logout_redirect_uri} return leg of RP-initiated logout. */
+ LOGOUT_RETURN,
+
+ /** The {@code oidc.logout.backchannel_path} back-channel logout receiver. */
+ BACKCHANNEL_LOGOUT,
+
+ /** The {@code oidc.user_info.path} session/user-info fold endpoint (D11). */
+ USER_INFO,
+
+ /** The {@code oidc.login.path} login-initiation fold endpoint (D12). */
+ LOGIN
+ }
+
+ private final @Nullable String oidcHost;
+ private final Map endpointsByPath;
+
+ private ReservedPathRegistry(@Nullable String oidcHost, Map endpointsByPath) {
+ this.oidcHost = oidcHost;
+ this.endpointsByPath = Map.copyOf(endpointsByPath);
+ }
+
+ /**
+ * Builds the registry from the global {@code oidc} block. The OIDC host and the callback path
+ * are derived from {@code oidc.redirect_uri}; without a {@code redirect_uri} there is no OIDC
+ * host to bind reserved paths to, so the registry is empty.
+ *
+ * @param oidc the global OIDC configuration, empty when the gateway serves no BFF variant
+ * @return the reserved-path registry — {@linkplain #isEmpty() empty} when no OIDC callback is configured
+ */
+ public static ReservedPathRegistry from(Optional oidc) {
+ Objects.requireNonNull(oidc, "oidc");
+ Optional redirect = oidc.flatMap(OidcConfig::redirectUri).flatMap(ReservedPathRegistry::parseUri);
+ String host = redirect.map(URI::getHost).orElse(null);
+ if (host == null) {
+ return new ReservedPathRegistry(null, Map.of());
+ }
+ Map paths = new LinkedHashMap<>();
+ redirect.map(URI::getPath).filter(ReservedPathRegistry::isAbsolutePath)
+ .ifPresent(path -> paths.put(path, ReservedEndpoint.CALLBACK));
+ Optional logout = oidc.flatMap(OidcConfig::logout);
+ logout.flatMap(OidcConfig.Logout::path).flatMap(ReservedPathRegistry::toPath)
+ .ifPresent(path -> paths.putIfAbsent(path, ReservedEndpoint.LOGOUT));
+ logout.flatMap(OidcConfig.Logout::postLogoutRedirectUri).flatMap(ReservedPathRegistry::toPath)
+ .ifPresent(path -> paths.putIfAbsent(path, ReservedEndpoint.LOGOUT_RETURN));
+ logout.flatMap(OidcConfig.Logout::backchannelPath).flatMap(ReservedPathRegistry::toPath)
+ .ifPresent(path -> paths.putIfAbsent(path, ReservedEndpoint.BACKCHANNEL_LOGOUT));
+ oidc.flatMap(OidcConfig::userInfo).flatMap(OidcConfig.UserInfo::path).flatMap(ReservedPathRegistry::toPath)
+ .ifPresent(path -> paths.putIfAbsent(path, ReservedEndpoint.USER_INFO));
+ oidc.flatMap(OidcConfig::login).flatMap(OidcConfig.Login::path).flatMap(ReservedPathRegistry::toPath)
+ .ifPresent(path -> paths.putIfAbsent(path, ReservedEndpoint.LOGIN));
+ return new ReservedPathRegistry(host, paths);
+ }
+
+ /**
+ * Resolves the reserved endpoint for a request, matching the path exactly and
+ * only when the request host is the OIDC host. A prefix that merely contains a
+ * reserved path (e.g. {@code /auth} against the reserved {@code /auth/callback}) never matches —
+ * that is precisely the carve-out this registry guarantees.
+ *
+ * @param host the request host authority (without port), may be {@code null}
+ * @param path the single canonical request path, may be {@code null} before canonicalization
+ * @return the reserved endpoint when the host and path match exactly; empty otherwise
+ */
+ public Optional match(@Nullable String host, @Nullable String path) {
+ if (oidcHost == null || host == null || path == null) {
+ return Optional.empty();
+ }
+ if (!oidcHost.equalsIgnoreCase(host)) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(endpointsByPath.get(path));
+ }
+
+ /**
+ * Convenience predicate over {@link #match(String, String)} for the edge carve-out check.
+ *
+ * @param host the request host authority (without port), may be {@code null}
+ * @param path the single canonical request path, may be {@code null}
+ * @return {@code true} when the request targets a reserved gateway endpoint
+ */
+ public boolean isReserved(@Nullable String host, @Nullable String path) {
+ return match(host, path).isPresent();
+ }
+
+ /**
+ * @return {@code true} when no reserved endpoint is registered (no OIDC callback configured)
+ */
+ public boolean isEmpty() {
+ return endpointsByPath.isEmpty();
+ }
+
+ /**
+ * Normalizes a configured value to its absolute path component: a full URI ({@code https://…})
+ * contributes its path, a bare absolute path ({@code /auth/logout}) is taken verbatim. A blank,
+ * relative, or unparseable value contributes nothing.
+ */
+ private static Optional toPath(String value) {
+ if (value.isBlank()) {
+ return Optional.empty();
+ }
+ if (value.contains("://")) {
+ return parseUri(value).map(URI::getPath).filter(ReservedPathRegistry::isAbsolutePath);
+ }
+ return isAbsolutePath(value) ? Optional.of(value) : Optional.empty();
+ }
+
+ private static Optional parseUri(String value) {
+ try {
+ return Optional.of(URI.create(value.trim()));
+ } catch (IllegalArgumentException _) {
+ return Optional.empty();
+ }
+ }
+
+ private static boolean isAbsolutePath(@Nullable String path) {
+ return path != null && path.startsWith("/");
+ }
+}
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
new file mode 100644
index 00000000..89d94662
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/UserInfoEndpoint.java
@@ -0,0 +1,300 @@
+/*
+ * 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.reserved;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+
+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.tools.logging.CuiLogger;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The session/user-info reserved endpoint ({@code oidc.user_info.path}) — a sixth-minus-one
+ * reserved gateway path (D11). It is registered exactly like the other reserved endpoints (exact
+ * match, on the OIDC host, resolved by the {@link ReservedPathRegistry} before the proxy
+ * route table), and serves the browser a curated view of the caller's own identity and session
+ * metadata. It is built above the session-resolution seam so a server-mode BFF
+ * inherits it without additional wiring.
+ *
+ * What it discloses. The default response is a curated identity subset — the
+ * operator's {@linkplain ClaimAllowlistFilter#defaultView() default view} (typically {@code sub},
+ * {@code name}, {@code roles}) — plus session metadata (the session {@code expires_at}, the IdP
+ * {@code auth_time}, and the authentication-context {@code acr}). A {@code claims} parameter selects
+ * specific claims or, with {@link #FULL_VIEW}, the full allowlisted view. The claim source is the
+ * validated ID-token claims (resolved through the {@link ClaimSource} seam) and the
+ * server-held session metadata only — raw access / refresh / ID tokens never appear
+ * in any view.
+ *
+ * Allowlist cap (secure default closed). Every response is filtered through the
+ * operator-owned {@link ClaimAllowlistFilter}: a claim outside {@code allowed_claims} can never be
+ * disclosed even when it is present in the validated ID token, and an explicitly-requested
+ * disallowed claim is rejected {@code 403} before any disclosure. The operator — never the browser
+ * client — is the sole party that widens disclosure; an empty allowlist discloses nothing.
+ *
+ * Response semantics. No live session yields {@code 401}
+ * {@code application/problem+json} — never a redirect: the endpoint is an XHR probe that
+ * composes with the D4 content negotiation (a navigation redirect is the browser's concern, not this
+ * probe's). {@code 403} is reserved for CSRF / a disallowed-claim request. Every response — success
+ * or error — carries {@code Cache-Control: no-store}, so an identity view is never cached by any
+ * intermediary or the browser.
+ *
+ * The endpoint is framework-agnostic (a raw {@code Cookie} header and the raw {@code claims}
+ * parameter in, a {@link UserInfoOutcome} the edge renders out — no JAX-RS/Vert.x coupling), so it is
+ * unit-testable without a container; the session runtime wires it to the request/response edge.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class UserInfoEndpoint {
+
+ private static final CuiLogger LOGGER = new CuiLogger(UserInfoEndpoint.class);
+
+ /**
+ * The {@code claims} parameter value that selects the full allowlisted view rather than a
+ * specific claim list or the curated default view.
+ */
+ public static final String FULL_VIEW = "*";
+
+ private static final int OK = 200;
+ private static final int UNAUTHORIZED = 401;
+ private static final int FORBIDDEN = 403;
+
+ private static final String CACHE_CONTROL = "Cache-Control";
+ private static final String NO_STORE = "no-store";
+ private static final String CONTENT_TYPE = "Content-Type";
+ private static final String APPLICATION_JSON = "application/json";
+ private static final String PROBLEM_JSON = "application/problem+json";
+
+ private static final String CLAIMS_MEMBER = "claims";
+ private static final String SESSION_MEMBER = "session";
+ private static final String EXPIRES_AT = "expires_at";
+ private static final String AUTH_TIME = "auth_time";
+ private static final String ACR = "acr";
+
+ private final SessionStore sessionStore;
+ private final SessionCookieCodec sessionCookieCodec;
+ private final ClaimAllowlistFilter claimFilter;
+ private final ClaimSource claimSource;
+
+ /**
+ * Assembles the user-info endpoint with the session stores, 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)
+ */
+ public UserInfoEndpoint(SessionStore sessionStore, SessionCookieCodec sessionCookieCodec,
+ ClaimAllowlistFilter claimFilter, ClaimSource claimSource) {
+ this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore");
+ this.sessionCookieCodec = Objects.requireNonNull(sessionCookieCodec, "sessionCookieCodec");
+ this.claimFilter = Objects.requireNonNull(claimFilter, "claimFilter");
+ this.claimSource = Objects.requireNonNull(claimSource, "claimSource");
+ }
+
+ /**
+ * Serves one user-info request: resolves the live session, selects the disclosed claims (capped
+ * by the operator allowlist), and returns the curated identity view plus session metadata.
+ *
+ * @param cookieHeader the raw request {@code Cookie} header value, may be absent
+ * @param claimsParam the raw {@code claims} selector: absent selects the curated default view,
+ * {@link #FULL_VIEW} selects the full allowlisted view, otherwise a
+ * comma-separated list of specific claim names; may be absent
+ * @param now the reference instant (the session-resolution TTL anchor)
+ * @return the {@code 200} identity view, a {@code 401} {@code application/problem+json} when no
+ * live session exists (never a redirect), or a {@code 403} when an explicitly-requested
+ * claim is outside the operator allowlist — every outcome carries {@code Cache-Control:
+ * no-store}
+ */
+ 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));
+ if (session.isEmpty()) {
+ LOGGER.debug("user-info request without a live session — 401 problem+json (never a redirect)");
+ return UserInfoOutcome.unauthenticated();
+ }
+
+ List selection;
+ if (isBlank(claimsParam)) {
+ selection = claimFilter.defaultView();
+ } else if (FULL_VIEW.equals(claimsParam.trim())) {
+ selection = claimFilter.allowedClaims().stream().sorted().toList();
+ } else {
+ List requested = parseSelection(claimsParam);
+ for (String claim : requested) {
+ if (!claimFilter.isAllowed(claim)) {
+ LOGGER.debug("user-info request named a claim outside the operator allowlist — rejected 403");
+ return UserInfoOutcome.forbidden();
+ }
+ }
+ selection = requested.isEmpty() ? claimFilter.defaultView() : requested;
+ }
+
+ Map disclosed = claimFilter.filter(claimSource.claims(session.get()), selection);
+
+ Map body = new LinkedHashMap<>();
+ body.put(CLAIMS_MEMBER, disclosed);
+ body.put(SESSION_MEMBER, sessionMetadata(session.get()));
+ return UserInfoOutcome.identity(body);
+ }
+
+ /**
+ * Projects the server-held session metadata — never raw token material — into the disclosed view.
+ * The {@code sub} identity anchor and any identity claims are disclosed through the allowlisted
+ * {@code claims} member; this member carries only the gateway's own session bookkeeping the
+ * session owner is entitled to see.
+ */
+ private static Map sessionMetadata(SessionRecord session) {
+ Map metadata = new LinkedHashMap<>();
+ metadata.put(EXPIRES_AT, session.expiresAt().toString());
+ session.authTime().ifPresent(authTime -> metadata.put(AUTH_TIME, authTime.toString()));
+ session.acr().ifPresent(acr -> metadata.put(ACR, acr));
+ return Collections.unmodifiableMap(metadata);
+ }
+
+ private static List parseSelection(String claimsParam) {
+ return Arrays.stream(claimsParam.split(","))
+ .map(String::trim)
+ .filter(claim -> !claim.isEmpty())
+ .distinct()
+ .toList();
+ }
+
+ private static boolean isBlank(@Nullable String value) {
+ return value == null || value.isBlank();
+ }
+
+ /**
+ * The validated-claim resolution seam. The session runtime binds it to the engine's ID-token
+ * parsing so the endpoint discloses validated ID-token claims; a test binds it to a fixed
+ * claim map. Keeping the token parsing behind the seam both decouples the endpoint from the engine
+ * and enforces the invariant that the endpoint never touches raw token material — it receives only
+ * an already-validated, already-parsed claim map.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @FunctionalInterface
+ public interface ClaimSource {
+
+ /**
+ * Resolves the validated ID-token claims backing the given session's disclosure.
+ *
+ * @param session the resolved live session
+ * @return the validated ID-token claims, keyed by claim name (never raw token material)
+ */
+ Map claims(SessionRecord session);
+ }
+
+ /**
+ * The framework-agnostic result of a user-info request: the HTTP status the edge returns, the JSON
+ * response body, and the fixed response headers ({@code Cache-Control: no-store} on every outcome,
+ * plus the content type — {@code application/json} for a disclosed view, {@code
+ * application/problem+json} for a {@code 401} / {@code 403} error). No token material ever appears
+ * in the body — only allowlisted claims and server-held session metadata.
+ *
+ * @param status the HTTP status the edge returns
+ * @param body the JSON response body (the identity view, or the RFC 9457 problem detail)
+ * @param headers the fixed response headers the edge emits verbatim
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record UserInfoOutcome(int status, Map body, Map headers) {
+
+ /**
+ * Canonical constructor defensively copying the body and headers into immutable, insertion-
+ * ordered maps.
+ */
+ public UserInfoOutcome {
+ body = body == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(body));
+ headers = headers == null ? Map.of() : Map.copyOf(headers);
+ }
+
+ /**
+ * A {@code 200} disclosed identity view served {@code application/json}, uncacheable.
+ *
+ * @param body the curated identity view (allowlisted claims plus session metadata)
+ * @return the disclosure outcome
+ */
+ public static UserInfoOutcome identity(Map body) {
+ Objects.requireNonNull(body, "body");
+ return new UserInfoOutcome(OK, body, jsonHeaders());
+ }
+
+ /**
+ * A {@code 401} {@code application/problem+json} outcome — the no-live-session response. It is
+ * never a redirect: the endpoint is an XHR probe.
+ *
+ * @return the unauthenticated outcome
+ */
+ public static UserInfoOutcome unauthenticated() {
+ return new UserInfoOutcome(UNAUTHORIZED, problem(UNAUTHORIZED, "No live session"), problemHeaders());
+ }
+
+ /**
+ * A {@code 403} {@code application/problem+json} outcome — reserved for CSRF / a request that
+ * names a claim outside the operator allowlist.
+ *
+ * @return the forbidden outcome
+ */
+ public static UserInfoOutcome forbidden() {
+ return new UserInfoOutcome(FORBIDDEN, problem(FORBIDDEN, "Claim not permitted"), problemHeaders());
+ }
+
+ /**
+ * @return {@code true} when this outcome discloses an identity view
+ */
+ public boolean isDisclosed() {
+ return status == OK;
+ }
+
+ private static Map problem(int status, String title) {
+ Map problem = new LinkedHashMap<>();
+ problem.put("type", "about:blank");
+ problem.put("title", title);
+ problem.put("status", status);
+ return Collections.unmodifiableMap(problem);
+ }
+
+ private static Map jsonHeaders() {
+ Map headers = new LinkedHashMap<>();
+ headers.put(CACHE_CONTROL, NO_STORE);
+ headers.put(CONTENT_TYPE, APPLICATION_JSON);
+ return headers;
+ }
+
+ private static Map problemHeaders() {
+ Map headers = new LinkedHashMap<>();
+ headers.put(CACHE_CONTROL, NO_STORE);
+ headers.put(CONTENT_TYPE, PROBLEM_JSON);
+ return headers;
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/package-info.java
new file mode 100644
index 00000000..e17674c3
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/package-info.java
@@ -0,0 +1,42 @@
+/*
+ * 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 gateway's reserved OIDC endpoints, carved out of the proxy route table (D2/D2c).
+ *
+ * The BFF variants reserve four gateway-owned paths — the {@code oidc.redirect_uri} callback, the
+ * RP-initiated logout path, its return leg, and the back-channel logout receiver — matched
+ * exactly and only on the OIDC host. This package owns the carve-out and the callback landing:
+ *
+ * - {@link de.cuioss.sheriff.gateway.bff.reserved.ReservedPathRegistry} classifies an exact
+ * host+path match to a reserved endpoint. The edge consults it before the route
+ * table, so a proxy route such as {@code path_prefix: /auth} never swallows the exact
+ * {@code /auth/callback}.
+ * - {@link de.cuioss.sheriff.gateway.bff.reserved.CallbackEndpoint} handles the OIDC
+ * auth-code callback: it parses the raw query (BFF-13 duplicate-parameter
+ * defence), resolves the browser-bound pending-authorization record, drives the engine's
+ * code exchange and token validation, creates the server-side session, and redirects to the
+ * recorded return URL.
+ *
+ * The classes are framework-agnostic (no CDI, no JAX-RS/Vert.x coupling), so they are
+ * unit-testable without a container; the session runtime wires them to the request/response edge.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.gateway.bff.reserved;
+
+import org.jspecify.annotations.NullMarked;
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
new file mode 100644
index 00000000..08dc27f0
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java
@@ -0,0 +1,348 @@
+/*
+ * 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.runtime;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Supplier;
+
+
+import de.cuioss.sheriff.gateway.bff.csrf.CsrfDefence;
+import de.cuioss.sheriff.gateway.bff.refresh.StepUpCoordinator;
+import de.cuioss.sheriff.gateway.bff.reserved.BackchannelLogoutEndpoint;
+import de.cuioss.sheriff.gateway.bff.reserved.CallbackEndpoint;
+import de.cuioss.sheriff.gateway.bff.reserved.LoginInitiationEndpoint;
+import de.cuioss.sheriff.gateway.bff.reserved.LogoutEndpoint;
+import de.cuioss.sheriff.gateway.bff.reserved.ReservedPathRegistry.ReservedEndpoint;
+import de.cuioss.sheriff.gateway.bff.reserved.UserInfoEndpoint;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The assembled server-mode BFF runtime (D16 edge wiring) — the single collaborator the gateway edge
+ * consults to serve the {@code require: session} stage-4 runtime and to dispatch a matched reserved
+ * OIDC path to its handler.
+ *
+ * The runtime is built once at boot by {@code BffRuntimeProducer} only when a global
+ * {@code oidc} block with {@code session.mode=server} is configured; a gateway without such
+ * a block gets the {@linkplain #inert() inert} instance, which reports {@link #isActive()}
+ * {@code false} and dispatches nothing (the bearer-only proxy path is unchanged, and the
+ * {@code ReservedPathRegistry} carries no reserved paths to dispatch anyway).
+ *
+ * It carries two edge-facing capabilities:
+ *
+ * - the {@link SessionAuthenticationStage} the edge injects into the session-aware
+ * {@code AuthenticationStage} constructor so a {@code require: session} route is served rather
+ * than rejected, plus the fixed {@link CsrfDefence} the edge enforces on unsafe-method session
+ * requests;
+ * - {@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.
+ *
+ * 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
+ * collaborators are supplied by construction (the producer binds the {@code token-sheriff-client}
+ * engine seams); the {@link #logoutEndpoint} is a {@link Supplier} so its discovery-dependent
+ * {@code end_session_endpoint} is resolved lazily on first logout rather than at boot.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class BffRuntime {
+
+ private static final String CACHE_CONTROL = "Cache-Control";
+ private static final String NO_STORE = "no-store";
+
+ private final boolean active;
+ private final @Nullable SessionAuthenticationStage sessionStage;
+ private final @Nullable CsrfDefence csrfDefence;
+ private final @Nullable StepUpCoordinator stepUpCoordinator;
+ private final @Nullable CallbackEndpoint callbackEndpoint;
+ private final @Nullable Supplier logoutEndpoint;
+ private final @Nullable BackchannelLogoutEndpoint backchannelLogoutEndpoint;
+ private final @Nullable UserInfoEndpoint userInfoEndpoint;
+ private final @Nullable LoginInitiationEndpoint loginInitiationEndpoint;
+
+ @SuppressWarnings("java:S107") // wiring holder assembled once by BffRuntimeProducer
+ private BffRuntime(boolean active, @Nullable SessionAuthenticationStage sessionStage,
+ @Nullable CsrfDefence csrfDefence, @Nullable StepUpCoordinator stepUpCoordinator,
+ @Nullable CallbackEndpoint callbackEndpoint,
+ @Nullable Supplier logoutEndpoint,
+ @Nullable BackchannelLogoutEndpoint backchannelLogoutEndpoint,
+ @Nullable UserInfoEndpoint userInfoEndpoint, @Nullable LoginInitiationEndpoint loginInitiationEndpoint) {
+ this.active = active;
+ this.sessionStage = sessionStage;
+ this.csrfDefence = csrfDefence;
+ this.stepUpCoordinator = stepUpCoordinator;
+ this.callbackEndpoint = callbackEndpoint;
+ this.logoutEndpoint = logoutEndpoint;
+ this.backchannelLogoutEndpoint = backchannelLogoutEndpoint;
+ this.userInfoEndpoint = userInfoEndpoint;
+ this.loginInitiationEndpoint = loginInitiationEndpoint;
+ }
+
+ /**
+ * Assembles an active server-mode runtime with every reserved-endpoint handler and the session
+ * stage-4 runtime wired.
+ *
+ * @param sessionStage the {@code require: session} stage-4 runtime
+ * @param csrfDefence the fixed CSRF defence for unsafe-method session requests
+ * @param stepUpCoordinator the RFC 9470 step-up coordinator (D7) for upstream challenges
+ * @param callbackEndpoint the OIDC auth-code callback handler
+ * @param logoutEndpoint the lazy RP-initiated logout handler (resolves the
+ * discovery-dependent {@code end_session_endpoint} on first use)
+ * @param backchannelLogoutEndpoint the OIDC back-channel logout receiver
+ * @param userInfoEndpoint the session/user-info fold handler (D11)
+ * @param loginInitiationEndpoint the login-initiation fold handler (D12)
+ */
+ @SuppressWarnings("java:S107") // wiring holder assembled once by BffRuntimeProducer
+ public BffRuntime(SessionAuthenticationStage sessionStage, CsrfDefence csrfDefence,
+ StepUpCoordinator stepUpCoordinator, CallbackEndpoint callbackEndpoint,
+ Supplier logoutEndpoint, BackchannelLogoutEndpoint backchannelLogoutEndpoint,
+ UserInfoEndpoint userInfoEndpoint, LoginInitiationEndpoint loginInitiationEndpoint) {
+ this(true,
+ Objects.requireNonNull(sessionStage, "sessionStage"),
+ Objects.requireNonNull(csrfDefence, "csrfDefence"),
+ Objects.requireNonNull(stepUpCoordinator, "stepUpCoordinator"),
+ Objects.requireNonNull(callbackEndpoint, "callbackEndpoint"),
+ Objects.requireNonNull(logoutEndpoint, "logoutEndpoint"),
+ Objects.requireNonNull(backchannelLogoutEndpoint, "backchannelLogoutEndpoint"),
+ Objects.requireNonNull(userInfoEndpoint, "userInfoEndpoint"),
+ Objects.requireNonNull(loginInitiationEndpoint, "loginInitiationEndpoint"));
+ }
+
+ /**
+ * @return the inert runtime — a gateway serving no server-mode BFF variant. It reports
+ * {@link #isActive()} {@code false}, exposes no session stage, and dispatches nothing.
+ */
+ public static BffRuntime inert() {
+ return new BffRuntime(false, null, null, null, null, null, null, null, null);
+ }
+
+ /**
+ * @return the RFC 9470 step-up coordinator (D7) that handles an upstream
+ * {@code insufficient_user_authentication} challenge for a {@code require: session} route
+ * @throws IllegalStateException when this runtime is inert (no server-mode BFF variant)
+ */
+ public StepUpCoordinator stepUpCoordinator() {
+ if (stepUpCoordinator == null) {
+ throw new IllegalStateException("inert BFF runtime exposes no step-up coordinator");
+ }
+ return stepUpCoordinator;
+ }
+
+ /**
+ * @return {@code true} when the gateway serves a server-mode BFF variant (an {@code oidc} block
+ * with {@code session.mode=server} was configured); {@code false} for a bearer-only gateway
+ */
+ public boolean isActive() {
+ return active;
+ }
+
+ /**
+ * @return the {@code require: session} stage-4 runtime the edge wires into the session-aware
+ * {@code AuthenticationStage}
+ * @throws IllegalStateException when this runtime is inert (no server-mode BFF variant)
+ */
+ public SessionAuthenticationStage sessionStage() {
+ if (sessionStage == null) {
+ throw new IllegalStateException("inert BFF runtime exposes no session stage");
+ }
+ return sessionStage;
+ }
+
+ /**
+ * @return the fixed CSRF defence the edge enforces on unsafe-method {@code require: session}
+ * requests
+ * @throws IllegalStateException when this runtime is inert (no server-mode BFF variant)
+ */
+ public CsrfDefence csrfDefence() {
+ if (csrfDefence == null) {
+ throw new IllegalStateException("inert BFF runtime exposes no CSRF defence");
+ }
+ return csrfDefence;
+ }
+
+ /**
+ * Dispatches a matched reserved OIDC path to its handler and normalizes the outcome for the edge.
+ *
+ * @param kind the reserved endpoint the {@code ReservedPathRegistry} resolved for the request
+ * @param req the framework-agnostic request pieces the handlers consume
+ * @param now the reference instant (TTL anchor for session / pending resolution)
+ * @return the normalized response the edge renders verbatim
+ * @throws IllegalStateException when this runtime is inert (no reserved handler is wired)
+ */
+ public ReservedHttpResponse dispatch(ReservedEndpoint kind, ReservedHttpRequest req, Instant now) {
+ Objects.requireNonNull(kind, "kind");
+ Objects.requireNonNull(req, "req");
+ Objects.requireNonNull(now, "now");
+ if (!active) {
+ throw new IllegalStateException("inert BFF runtime cannot dispatch a reserved path");
+ }
+ return switch (kind) {
+ case CALLBACK ->
+ render(requireNonNull(callbackEndpoint).handle(callbackParameters(req), req.cookieHeader(), now));
+ case LOGOUT -> render(requireNonNull(logoutEndpoint).get().logout(req.cookieHeader(), now));
+ case LOGOUT_RETURN ->
+ render(requireNonNull(logoutEndpoint).get().completeReturn(req.stateParam(), req.cookieHeader()));
+ case BACKCHANNEL_LOGOUT ->
+ render(requireNonNull(backchannelLogoutEndpoint).receive(req.rawFormBody(), now));
+ case USER_INFO -> render(requireNonNull(userInfoEndpoint).handle(req.cookieHeader(), req.claimsParam(), now));
+ case LOGIN ->
+ render(requireNonNull(loginInitiationEndpoint).initiate(req.returnUrlParam(), req.cookieHeader(), now));
+ };
+ }
+
+ /**
+ * Selects the raw parameter string the callback parses. Keycloak drives the OIDC auth-code
+ * callback with {@code response_mode=form_post}: after a successful login it returns a 200
+ * auto-submit form whose {@code code}/{@code state} are POSTed to the {@code redirect_uri} as an
+ * {@code application/x-www-form-urlencoded} body — never a 302 with the code in the query. A POST
+ * callback therefore parses the raw form body; a GET callback parses the raw query. Both are the
+ * same urlencoded parameter shape, so the single {@code CallbackParameters.parse} the callback
+ * uses handles either source and preserves the BFF-13 duplicate-parameter rejection (the collapsed
+ * {@code of(Map)} form is never taken).
+ */
+ private static String callbackParameters(ReservedHttpRequest req) {
+ final String raw = req.isFormPost() ? req.rawFormBody() : req.rawQuery();
+ if (raw == null) {
+ return "";
+ }
+ return raw;
+ }
+
+ private static ReservedHttpResponse render(CallbackEndpoint.CallbackOutcome outcome) {
+ return new ReservedHttpResponse(outcome.status(), outcome.location().orElse(null), null, Map.of(),
+ outcome.setCookieHeaders());
+ }
+
+ private static ReservedHttpResponse render(LogoutEndpoint.LogoutOutcome outcome) {
+ return new ReservedHttpResponse(outcome.status(), outcome.location().orElse(null), null, Map.of(),
+ outcome.setCookieHeaders());
+ }
+
+ private static ReservedHttpResponse render(BackchannelLogoutEndpoint.BackchannelLogoutOutcome outcome) {
+ // The OIDC back-channel logout response — success and error alike — must be served uncacheable.
+ return new ReservedHttpResponse(outcome.status(), null, null, Map.of(CACHE_CONTROL, NO_STORE), List.of());
+ }
+
+ private static ReservedHttpResponse render(UserInfoEndpoint.UserInfoOutcome outcome) {
+ return new ReservedHttpResponse(outcome.status(), null, JsonWriter.toJson(outcome.body()), outcome.headers(),
+ List.of());
+ }
+
+ private static ReservedHttpResponse render(LoginInitiationEndpoint.LoginInitiationOutcome outcome) {
+ return new ReservedHttpResponse(outcome.status(), outcome.location(), null, Map.of(),
+ outcome.setCookieHeaders());
+ }
+
+ private static T requireNonNull(@Nullable T value) {
+ return Objects.requireNonNull(value, "active BFF runtime handler must be wired");
+ }
+
+ /**
+ * The framework-agnostic request pieces a reserved endpoint consumes, extracted by the edge from
+ * the raw request. Every field is optional at this level — each handler reads only the pieces it
+ * needs (the callback reads {@link #rawQuery} and {@link #cookieHeader}, the back-channel receiver
+ * reads {@link #rawFormBody}, the user-info fold reads {@link #claimsParam}, and so on).
+ *
+ * @param rawQuery the raw query string (without the leading {@code ?}), never map-collapsed
+ * — a GET callback re-parses it to re-detect a duplicated {@code code}/{@code
+ * state} (BFF-13)
+ * @param cookieHeader the raw request {@code Cookie} header value, may be absent
+ * @param claimsParam the raw {@code claims} selector for the user-info fold, may be absent
+ * @param returnUrlParam the raw post-login {@code return_to} target for the login fold, may be absent
+ * @param stateParam the {@code state} the IdP returned on a logout-return leg, may be absent
+ * @param rawFormBody the raw {@code application/x-www-form-urlencoded} body — carried for
+ * back-channel logout and for a {@code response_mode=form_post} callback POST
+ * (the {@code code}/{@code state} arrive here, not in {@link #rawQuery}), may
+ * be absent
+ * @param httpMethod the request HTTP method; a {@code POST} to the callback is a
+ * {@code response_mode=form_post} submission. Absent normalizes to
+ * {@code GET} (the CSRF-safe default)
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record ReservedHttpRequest(String rawQuery, @Nullable String cookieHeader,
+ @Nullable String claimsParam, @Nullable String returnUrlParam, @Nullable String stateParam,
+ @Nullable String rawFormBody, String httpMethod) {
+
+ /**
+ * Canonical constructor normalizing an absent raw query to the empty string and an absent HTTP
+ * method to {@code GET} (the CSRF-safe default).
+ */
+ public ReservedHttpRequest {
+ rawQuery = Objects.requireNonNullElse(rawQuery, "");
+ httpMethod = Objects.requireNonNullElse(httpMethod, "GET");
+ }
+
+ /**
+ * @return {@code true} when this is a {@code POST} — an OIDC {@code response_mode=form_post}
+ * callback, whose {@code code}/{@code state} arrive in {@link #rawFormBody} rather than
+ * {@link #rawQuery}
+ */
+ public boolean isFormPost() {
+ return "POST".equalsIgnoreCase(httpMethod);
+ }
+ }
+
+ /**
+ * The normalized response the edge renders for a dispatched reserved path: the HTTP status, an
+ * optional redirect {@code Location}, an optional already-serialized JSON body (the user-info
+ * fold), the fixed response headers, and the {@code Set-Cookie} header values to emit. Token
+ * material never appears here — only opaque cookie headers, the redirect location, and allowlisted
+ * disclosure.
+ *
+ * @param status the HTTP status the edge returns
+ * @param location the redirect target, present only for a redirect outcome
+ * @param jsonBody the already-serialized JSON body, present only for the user-info fold
+ * @param headers the fixed response headers the edge emits verbatim
+ * @param setCookieHeaders the {@code Set-Cookie} header values to emit
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record ReservedHttpResponse(int status, @Nullable
+ String location, @Nullable
+ String jsonBody,
+ Map headers, List setCookieHeaders) {
+
+ /**
+ * Canonical constructor defensively copying the header and cookie collections.
+ */
+ public ReservedHttpResponse {
+ headers = headers == null ? Map.of() : Map.copyOf(headers);
+ setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders);
+ }
+
+ /**
+ * @return the optional redirect {@code Location}
+ */
+ public Optional locationOptional() {
+ return Optional.ofNullable(location);
+ }
+
+ /**
+ * @return the optional serialized JSON body
+ */
+ public Optional jsonBodyOptional() {
+ return Optional.ofNullable(jsonBody);
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/JsonWriter.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/JsonWriter.java
new file mode 100644
index 00000000..c5701920
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/JsonWriter.java
@@ -0,0 +1,126 @@
+/*
+ * 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.runtime;
+
+import java.util.Collection;
+import java.util.Map;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * A minimal, dependency-free JSON serializer for the curated user-info disclosure the
+ * {@link BffRuntime} renders (D11). It serializes only the value shapes that disclosure produces —
+ * an insertion-ordered {@link Map} of allowlisted claims and session metadata whose leaves are
+ * {@link String}, {@link Number}, {@link Boolean}, {@link Collection}, nested {@link Map}, or
+ * {@code null} — with RFC 8259 string escaping. It is deliberately not a general-purpose JSON
+ * library: the response body is a small, gateway-controlled structure, and keeping the writer here
+ * avoids a runtime JSON dependency on the data-plane edge.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+final class JsonWriter {
+
+ private JsonWriter() {
+ }
+
+ /**
+ * Serializes a gateway-controlled value graph to a compact JSON string.
+ *
+ * @param value the value to serialize (a {@link Map}, {@link Collection}, {@link String},
+ * {@link Number}, {@link Boolean}, or {@code null})
+ * @return the compact JSON rendering
+ */
+ static String toJson(@Nullable Object value) {
+ StringBuilder out = new StringBuilder();
+ write(out, value);
+ return out.toString();
+ }
+
+ private static void write(StringBuilder out, @Nullable Object value) {
+ switch (value) {
+ case null -> out.append("null");
+ case String string -> writeString(out, string);
+ case Boolean bool -> out.append(bool.booleanValue());
+ case Number number -> writeNumber(out, number);
+ case Map, ?> map -> writeObject(out, map);
+ case Collection> collection -> writeArray(out, collection);
+ default -> writeString(out, String.valueOf(value));
+ }
+ }
+
+ private static void writeNumber(StringBuilder out, Number number) {
+ // RFC 8259 defines no NaN / Infinity token, so a non-finite floating-point value would emit an
+ // invalid JSON literal. Normalize it to null so the gateway-controlled body is always valid JSON.
+ if ((number instanceof Double || number instanceof Float) && !Double.isFinite(number.doubleValue())) {
+ out.append("null");
+ return;
+ }
+ out.append(number);
+ }
+
+ private static void writeObject(StringBuilder out, Map, ?> map) {
+ out.append('{');
+ boolean first = true;
+ for (Map.Entry, ?> entry : map.entrySet()) {
+ if (!first) {
+ out.append(',');
+ }
+ writeString(out, String.valueOf(entry.getKey()));
+ out.append(':');
+ write(out, entry.getValue());
+ first = false;
+ }
+ out.append('}');
+ }
+
+ private static void writeArray(StringBuilder out, Collection> collection) {
+ out.append('[');
+ boolean first = true;
+ for (Object element : collection) {
+ if (!first) {
+ out.append(',');
+ }
+ write(out, element);
+ first = false;
+ }
+ out.append(']');
+ }
+
+ private static void writeString(StringBuilder out, String value) {
+ out.append('"');
+ for (int index = 0; index < value.length(); index++) {
+ char character = value.charAt(index);
+ switch (character) {
+ case '"' -> out.append("\\\"");
+ case '\\' -> out.append("\\\\");
+ case '\n' -> out.append("\\n");
+ case '\r' -> out.append("\\r");
+ case '\t' -> out.append("\\t");
+ case '\b' -> out.append("\\b");
+ case '\f' -> out.append("\\f");
+ default -> {
+ if (character < 0x20) {
+ out.append("\\u%04x".formatted((int) character));
+ } else {
+ out.append(character);
+ }
+ }
+ }
+ }
+ out.append('"');
+ }
+}
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
new file mode 100644
index 00000000..b432993e
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStage.java
@@ -0,0 +1,257 @@
+/*
+ * 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.runtime;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Optional;
+
+
+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.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;
+
+/**
+ * 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
+ * {@code RouteRuntimeAssembler} used to raise for session routes.
+ *
+ * For a request selected onto a {@code require: session} route the stage:
+ *
+ * - resolves the opaque {@code __Host-} session cookie to a live {@link SessionRecord} through
+ * the {@link SessionStore} (an expired / unknown session is treated as unauthenticated);
+ * - 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);
+ * - enforces the route's {@code required_scopes} against the mediated token's granted
+ * scopes through the {@link GrantedScopes} seam — a shortfall is {@code 403}
+ * {@link EventType#SCOPE_MISSING} (the D2c residual);
+ * - records the mediated access token on the request for automatic upstream injection as
+ * {@code Authorization: Bearer} ({@link PipelineRequest#mediatedBearer(String)} — never an
+ * operator-configured header). The token material never leaves the server up to this point;
+ * the forward stage renders the bearer and the opaque session cookie never crosses.
+ *
+ * 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
+ * the {@link LoginInitiation} seam (short-circuiting the pipeline); anything else (an XHR / API call)
+ * gets {@code 401} {@code application/problem+json} via {@link EventType#TOKEN_MISSING}.
+ *
+ * The stage is framework-agnostic and driven entirely through its collaborators and seams, so it is
+ * unit-testable without a container or a live IdP. The engine-side and edge-side wiring (the refresh
+ * coordinator, the login initiation binding, and the reserved-endpoint plumbing) is supplied by the
+ * session runtime; the seams keep this stage decoupled from that wiring.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class SessionAuthenticationStage {
+
+ private static final CuiLogger LOGGER = new CuiLogger(SessionAuthenticationStage.class);
+
+ private static final String COOKIE_HEADER = "Cookie";
+ private static final String ACCEPT_HEADER = "Accept";
+ private static final String LOCATION_HEADER = "Location";
+ private static final String SET_COOKIE_HEADER = "Set-Cookie";
+ private static final String TEXT_HTML = "text/html";
+ private static final int FOUND = 302;
+
+ private final SessionStore sessionStore;
+ private final SessionCookieCodec sessionCookieCodec;
+ private final 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.
+ *
+ * @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)
+ */
+ 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");
+ this.tokenRefresh = Objects.requireNonNull(tokenRefresh, "tokenRefresh");
+ this.grantedScopes = Objects.requireNonNull(grantedScopes, "grantedScopes");
+ this.loginInitiation = Objects.requireNonNull(loginInitiation, "loginInitiation");
+ this.clock = Objects.requireNonNull(clock, "clock");
+ }
+
+ /**
+ * Runs the session runtime for the selected {@code require: session} route.
+ *
+ * @param request the in-flight request; its route must be selected (stage 2)
+ * @throws GatewayException {@code 401} when an unauthenticated non-navigation request is
+ * challenged, or {@code 403} when the mediated token lacks a required scope
+ */
+ public void process(PipelineRequest request) {
+ Objects.requireNonNull(request, "request");
+ RouteRuntime route = requireSelectedRoute(request);
+ Instant now = clock.instant();
+
+ Optional resolved = resolveSession(request, now);
+ if (resolved.isEmpty()) {
+ challengeUnauthenticated(request, route, now);
+ return;
+ }
+
+ SessionRecord session = tokenRefresh.refreshIfNeeded(resolved.get(), now);
+ 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 void enforceScopes(RouteRuntime route, SessionRecord session) {
+ List requiredScopes = route.getEffectiveAuth().requiredScopes();
+ if (!requiredScopes.isEmpty() && !grantedScopes.provides(session.accessToken(), requiredScopes)) {
+ throw new GatewayException(EventType.SCOPE_MISSING,
+ "Mediated token missing a required scope for route " + route.getId());
+ }
+ }
+
+ private void challengeUnauthenticated(PipelineRequest request, RouteRuntime route, Instant now) {
+ 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));
+ request.shortCircuit(FOUND);
+ LOGGER.debug("Unauthenticated navigation on require:session route %s — redirecting into login",
+ route.getId());
+ return;
+ }
+ throw new GatewayException(EventType.TOKEN_MISSING,
+ "No live session for require:session route " + route.getId());
+ }
+
+ private static boolean acceptsHtml(PipelineRequest request) {
+ return request.headerValues(ACCEPT_HEADER).stream()
+ .anyMatch(value -> value.toLowerCase(Locale.ROOT).contains(TEXT_HTML));
+ }
+
+ private static String returnUrl(PipelineRequest request) {
+ String canonicalPath = request.canonicalPath();
+ return canonicalPath != null ? canonicalPath : request.requestPath();
+ }
+
+ private static RouteRuntime requireSelectedRoute(PipelineRequest request) {
+ RouteRuntime route = request.selectedRoute();
+ if (route == null) {
+ throw new IllegalStateException("Session authentication requires the route selected at stage 2");
+ }
+ return route;
+ }
+
+ /**
+ * 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.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @FunctionalInterface
+ 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
+ */
+ SessionRecord refreshIfNeeded(SessionRecord session, Instant now);
+ }
+
+ /**
+ * The mediated-token scope-membership seam. The session runtime binds it to the engine token
+ * parsing so {@code required_scopes} is enforced against the mediated access token's granted
+ * scopes; a test binds it to a fixed predicate. Keeping the token parsing behind the seam
+ * decouples this stage from the engine and keeps it unit-testable.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @FunctionalInterface
+ public interface GrantedScopes {
+
+ /**
+ * @param accessToken the mediated access token
+ * @param requiredScopes the route's non-empty {@code required_scopes}
+ * @return {@code true} when the token grants every required scope
+ */
+ boolean provides(String accessToken, List requiredScopes);
+ }
+
+ /**
+ * The auth-code-flow initiation seam for a navigation redirect. The session runtime binds it to
+ * the login flow (which drives the engine authorization, persists the pending record, and mints
+ * the browser-binding cookie); a test binds it to a hand-built challenge.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @FunctionalInterface
+ public interface LoginInitiation {
+
+ /**
+ * Initiates a fresh login for an unauthenticated navigation request.
+ *
+ * @param returnUrl the post-login return target (the path the browser was navigating to)
+ * @param now the reference instant (the pending record's TTL anchor)
+ * @return the redirect target and the browser-binding {@code Set-Cookie}
+ */
+ LoginChallenge initiate(String returnUrl, Instant now);
+ }
+
+ /**
+ * The framework-agnostic result of a login initiation: the {@code 302} redirect target and the
+ * browser-binding {@code Set-Cookie} header(s) to emit. Token material never appears here.
+ *
+ * @param location the IdP authorization URL to redirect the browser to
+ * @param setCookieHeaders the browser-binding {@code Set-Cookie} header values (the single binding cookie)
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record LoginChallenge(String location, List setCookieHeaders) {
+
+ /**
+ * Canonical constructor rejecting an absent location and defensively copying the cookies.
+ */
+ public LoginChallenge {
+ Objects.requireNonNull(location, "location");
+ setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders);
+ }
+ }
+}
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
new file mode 100644
index 00000000..de59a3f3
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/package-info.java
@@ -0,0 +1,37 @@
+/*
+ * 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 {@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}.
+ *
+ * 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
+ * the seams to the engine and the request/response edge.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.gateway.bff.runtime;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/InMemorySessionStore.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/InMemorySessionStore.java
new file mode 100644
index 00000000..146bdad1
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/InMemorySessionStore.java
@@ -0,0 +1,165 @@
+/*
+ * 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.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * The single-node in-memory {@link SessionStore} — the only supported store for
+ * {@code mode: server} (D3).
+ *
+ * Sessions are keyed by their opaque id in a primary map. Two secondary indexes — by IdP
+ * {@code sid} and by {@code sub} — give O(1) back-channel logout destruction without scanning
+ * the primary map. Every removal path (direct destroy, back-channel destroy, lazy TTL eviction,
+ * periodic sweep) keeps the indexes consistent through a single {@link #removeInternal} seam.
+ *
+ * The absolute TTL is enforced two ways with no per-session timer threads:
+ * lazily on {@link #resolve} (an expired session is evicted as it is looked up) and by an
+ * operator/scheduler-driven {@link #sweepExpired}. A documented {@code maxSessions} bound caps
+ * the live-session count (a capacity ceiling for the operator's memory math); creating a session
+ * beyond the bound is refused fail-closed. Every operation is guarded by the instance monitor.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class InMemorySessionStore implements SessionStore {
+
+ private final int maxSessions;
+ private final Map byId = new HashMap<>();
+ private final Map> bySid = new HashMap<>();
+ private final Map> bySub = new HashMap<>();
+
+ /**
+ * Creates a store bounded to {@code maxSessions} live sessions.
+ *
+ * @param maxSessions the hard capacity bound; must be positive
+ * @throws IllegalArgumentException when {@code maxSessions} is not positive
+ */
+ public InMemorySessionStore(int maxSessions) {
+ if (maxSessions <= 0) {
+ throw new IllegalArgumentException("maxSessions must be positive, but was " + maxSessions);
+ }
+ this.maxSessions = maxSessions;
+ }
+
+ @Override
+ public synchronized void create(SessionRecord session) {
+ Objects.requireNonNull(session, "session");
+ if (byId.size() >= maxSessions) {
+ throw new IllegalStateException("session store is at its max-session bound of " + maxSessions);
+ }
+ byId.put(session.sessionId(), session);
+ index(bySub, session.sub(), session.sessionId());
+ session.sid().ifPresent(sid -> index(bySid, sid, session.sessionId()));
+ }
+
+ @Override
+ public synchronized Optional resolve(String sessionId, Instant now) {
+ Objects.requireNonNull(sessionId, "sessionId");
+ Objects.requireNonNull(now, "now");
+ SessionRecord session = byId.get(sessionId);
+ if (session == null) {
+ return Optional.empty();
+ }
+ if (session.isExpired(now)) {
+ removeInternal(sessionId);
+ return Optional.empty();
+ }
+ return Optional.of(session);
+ }
+
+ @Override
+ public synchronized void destroyById(String sessionId) {
+ Objects.requireNonNull(sessionId, "sessionId");
+ removeInternal(sessionId);
+ }
+
+ @Override
+ public synchronized int destroyBySid(String sid) {
+ Objects.requireNonNull(sid, "sid");
+ return removeAll(bySid.get(sid));
+ }
+
+ @Override
+ public synchronized int destroyBySub(String sub) {
+ Objects.requireNonNull(sub, "sub");
+ return removeAll(bySub.get(sub));
+ }
+
+ @Override
+ public synchronized int sweepExpired(Instant now) {
+ Objects.requireNonNull(now, "now");
+ List expired = new ArrayList<>();
+ for (Map.Entry entry : byId.entrySet()) {
+ if (entry.getValue().isExpired(now)) {
+ expired.add(entry.getKey());
+ }
+ }
+ expired.forEach(this::removeInternal);
+ return expired.size();
+ }
+
+ /**
+ * @return the current number of live sessions
+ */
+ public synchronized int size() {
+ return byId.size();
+ }
+
+ // java:S2589 — bySub/bySid.get() returns null for an unknown sub/sid, so the null guard is
+ // load-bearing (removeAll is called with the raw Map.get result); the analyzer misjudges it.
+ @SuppressWarnings("java:S2589")
+ private int removeAll(Set sessionIds) {
+ if (sessionIds == null || sessionIds.isEmpty()) {
+ return 0;
+ }
+ List snapshot = new ArrayList<>(sessionIds);
+ snapshot.forEach(this::removeInternal);
+ return snapshot.size();
+ }
+
+ private void removeInternal(String sessionId) {
+ SessionRecord session = byId.remove(sessionId);
+ if (session == null) {
+ return;
+ }
+ deindex(bySub, session.sub(), sessionId);
+ session.sid().ifPresent(sid -> deindex(bySid, sid, sessionId));
+ }
+
+ private static void index(Map> map, String key, String sessionId) {
+ map.computeIfAbsent(key, unused -> new HashSet<>()).add(sessionId);
+ }
+
+ private static void deindex(Map> map, String key, String sessionId) {
+ Set sessionIds = map.get(key);
+ if (sessionIds == null) {
+ return;
+ }
+ sessionIds.remove(sessionId);
+ if (sessionIds.isEmpty()) {
+ map.remove(key);
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionCookieCodec.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionCookieCodec.java
new file mode 100644
index 00000000..a6840aea
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionCookieCodec.java
@@ -0,0 +1,109 @@
+/*
+ * 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.Duration;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Encodes and reads the opaque server-session cookie (D3).
+ *
+ * The cookie carries only the opaque {@link SessionRecord#sessionId()} — never
+ * any token material, which stays server-side. It is hardened by construction: the
+ * {@code __Host-} prefix (browser-honoured only with {@code Secure} + {@code Path=/} + no
+ * {@code Domain}), {@code HttpOnly} (no script access), and {@code SameSite=Lax} (survives a
+ * top-level navigation while blocking cross-site sends). The cookie name is operator-configurable
+ * ({@code session.cookie_name}); {@link #DEFAULT_COOKIE_NAME} is the {@code __Host-} default.
+ *
+ * The codec is framework-agnostic: it produces and parses raw header values with no JAX-RS/Vert.x
+ * coupling, so it is unit-testable without a container.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class SessionCookieCodec {
+
+ /** The default {@code __Host-}-prefixed session-cookie name. */
+ public static final String DEFAULT_COOKIE_NAME = "__Host-sheriff-session";
+
+ private final String cookieName;
+ private final Duration maxAge;
+
+ /**
+ * Creates a codec for the given cookie name and lifetime.
+ *
+ * @param cookieName the cookie name (normally {@link #DEFAULT_COOKIE_NAME} or the configured
+ * {@code session.cookie_name})
+ * @param maxAge the cookie {@code Max-Age}
+ */
+ public SessionCookieCodec(String cookieName, Duration maxAge) {
+ this.cookieName = requireNonBlank(cookieName);
+ this.maxAge = Objects.requireNonNull(maxAge, "maxAge");
+ }
+
+ /**
+ * Builds the {@code Set-Cookie} header value carrying the opaque {@code sessionId}.
+ *
+ * @param sessionId the opaque session id
+ * @return the hardened {@code Set-Cookie} header value
+ */
+ public String toSetCookieHeader(String sessionId) {
+ Objects.requireNonNull(sessionId, "sessionId");
+ return "%s=%s; Max-Age=%d; Path=/; Secure; HttpOnly; SameSite=Lax"
+ .formatted(cookieName, sessionId, maxAge.toSeconds());
+ }
+
+ /**
+ * Builds the {@code Set-Cookie} header value that clears the session cookie (logout).
+ *
+ * @return the clearing {@code Set-Cookie} header value
+ */
+ public String toClearingSetCookieHeader() {
+ return cookieName + "=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax";
+ }
+
+ /**
+ * Reads the opaque session id out of a request {@code Cookie} header value.
+ *
+ * @param cookieHeader the raw {@code Cookie} header value (may be absent/blank)
+ * @return the session id when the session cookie is present with a non-empty value; empty otherwise
+ */
+ public Optional readSessionId(@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();
+ }
+
+ private static String requireNonBlank(String cookieName) {
+ Objects.requireNonNull(cookieName, "cookieName");
+ if (cookieName.isBlank()) {
+ throw new IllegalArgumentException("cookieName must not be blank");
+ }
+ return cookieName;
+ }
+}
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
new file mode 100644
index 00000000..f12d9a27
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionRecord.java
@@ -0,0 +1,110 @@
+/*
+ * 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.security.SecureRandom;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.Objects;
+import java.util.Optional;
+
+import lombok.Builder;
+
+/**
+ * A single server-side session (D3, {@code mode: server}).
+ *
+ * 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.
+ *
+ * {@link #sid()} and {@link #sub()} back the store's secondary index for O(1) back-channel
+ * logout destruction.
+ *
+ * @param sessionId the opaque session id (store key and session-cookie value)
+ * @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 (back-channel destroy-by-sub key)
+ * @param sid the IdP session id claim, empty when absent (back-channel destroy-by-sid key)
+ * @param expiresAt the absolute session expiry (from login), independent of activity
+ * @param acr the authentication context class, empty when absent
+ * @param authTime the IdP authentication instant, empty when absent
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+public record SessionRecord(String sessionId, String accessToken, Optional refreshToken, String idToken,
+String sub, Optional sid, Instant expiresAt, Optional acr, Optional authTime) {
+
+ private static final String REDACTED = "***REDACTED***";
+ private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+ private static final int SESSION_ID_BYTES = 32;
+
+ /**
+ * Canonical constructor rejecting absent mandatory components and normalizing absent
+ * optionals to {@link Optional#empty()}.
+ */
+ public SessionRecord {
+ Objects.requireNonNull(sessionId, "sessionId");
+ Objects.requireNonNull(accessToken, "accessToken");
+ Objects.requireNonNull(idToken, "idToken");
+ Objects.requireNonNull(sub, "sub");
+ Objects.requireNonNull(expiresAt, "expiresAt");
+ refreshToken = Objects.requireNonNullElse(refreshToken, Optional.empty());
+ sid = Objects.requireNonNullElse(sid, Optional.empty());
+ acr = Objects.requireNonNullElse(acr, Optional.empty());
+ authTime = Objects.requireNonNullElse(authTime, Optional.empty());
+ }
+
+ /**
+ * Generates a 256-bit URL-safe opaque session id.
+ *
+ * @return the base64url (unpadded) encoding of 32 secure-random bytes
+ */
+ public static String newSessionId() {
+ byte[] bytes = new byte[SESSION_ID_BYTES];
+ SECURE_RANDOM.nextBytes(bytes);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ }
+
+ /**
+ * Whether this session has expired at {@code now} — expiry is inclusive of the boundary.
+ *
+ * @param now the reference instant
+ * @return {@code true} when {@code now} is at or after {@link #expiresAt()}
+ */
+ public boolean isExpired(Instant now) {
+ return !now.isBefore(expiresAt);
+ }
+
+ /**
+ * Overridden to redact every credential — the session id and all three tokens. The default
+ * record {@code toString()} would otherwise print the bearer session id and 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 "SessionRecord[sessionId=%s, accessToken=%s, refreshToken=%s, idToken=%s, sub=%s, sid=%s, expiresAt=%s, acr=%s, authTime=%s]"
+ .formatted(REDACTED, REDACTED, refreshToken.isPresent() ? "Optional[" + REDACTED + "]" : "Optional.empty",
+ REDACTED, sub, sid, expiresAt, acr, authTime);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionStore.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionStore.java
new file mode 100644
index 00000000..ad6036cc
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionStore.java
@@ -0,0 +1,87 @@
+/*
+ * 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.Optional;
+
+/**
+ * The server-side session store contract (D3).
+ *
+ * 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
+ * {@code sid}/{@code sub} on a back-channel logout. The {@code sid}/{@code sub} destruction is
+ * O(1) through the implementation's secondary index — a back-channel logout must not scan the
+ * whole store. {@code memory} is the only implementation ({@link InMemorySessionStore}); a
+ * shared/external store is deliberately unsupported (single-node / sticky-session deployments).
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public interface SessionStore {
+
+ /**
+ * Stores a freshly created session.
+ *
+ * @param session the session to store
+ * @throws IllegalStateException when the store is at its max-session capacity bound
+ */
+ void create(SessionRecord session);
+
+ /**
+ * Resolves a live session by its opaque id, enforcing the absolute TTL lazily: an expired
+ * session is evicted and reported as absent.
+ *
+ * @param sessionId the opaque session id (from the session cookie)
+ * @param now the reference instant for the TTL check
+ * @return the live session; empty when unknown or expired
+ */
+ Optional resolve(String sessionId, Instant now);
+
+ /**
+ * Destroys the session with the given opaque id (RP-initiated logout). A no-op when absent.
+ *
+ * @param sessionId the opaque session id
+ */
+ void destroyById(String sessionId);
+
+ /**
+ * Destroys every session carrying the given IdP {@code sid} (back-channel logout), O(1) via
+ * the secondary index.
+ *
+ * @param sid the IdP session id claim
+ * @return the number of sessions destroyed
+ */
+ int destroyBySid(String sid);
+
+ /**
+ * Destroys every session for the given subject (back-channel logout without a {@code sid}),
+ * O(1) via the secondary index.
+ *
+ * @param sub the subject claim
+ * @return the number of sessions destroyed
+ */
+ int destroyBySub(String sub);
+
+ /**
+ * Removes every session expired at {@code now}. Invoked by a periodic sweep so eviction does
+ * not rely on access alone — there are no per-session timer threads.
+ *
+ * @param now the reference instant
+ * @return the number of sessions swept
+ */
+ int sweepExpired(Instant now);
+}
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
new file mode 100644
index 00000000..f79818ec
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/package-info.java
@@ -0,0 +1,42 @@
+/*
+ * 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 server-side session store and its opaque cookie (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:
+ *
+ * - {@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.
+ *
+ * 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.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.gateway.bff.session;
+
+import org.jspecify.annotations.NullMarked;
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 6fb81c34..181584cf 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
@@ -35,13 +35,17 @@
* @param logout the logout settings, empty when omitted
* @param session the session settings, empty when omitted
* @param stepUp the step-up authentication settings, empty when omitted
+ * @param userInfo the session/user-info reserved-endpoint settings, empty
+ * when omitted
+ * @param login the login-initiation reserved-path settings, empty when
+ * omitted
* @author API Sheriff Team
* @since 1.0
*/
@Builder
public record OidcConfig(Optional issuer, Optional clientId, Optional clientSecret,
List scopes, Optional redirectUri, Optional logout, Optional session,
-Optional stepUp) {
+Optional stepUp, Optional userInfo, Optional login) {
/**
* Canonical constructor defensively copying {@code scopes} and normalizing
@@ -56,6 +60,8 @@ public record OidcConfig(Optional issuer, Optional clientId, Opt
logout = Objects.requireNonNullElse(logout, Optional.empty());
session = Objects.requireNonNullElse(session, Optional.empty());
stepUp = Objects.requireNonNullElse(stepUp, Optional.empty());
+ userInfo = Objects.requireNonNullElse(userInfo, Optional.empty());
+ login = Objects.requireNonNullElse(login, Optional.empty());
}
/**
@@ -70,8 +76,9 @@ public record OidcConfig(Optional issuer, Optional clientId, Opt
*/
@Override
public String toString() {
- return "OidcConfig[issuer=%s, clientId=%s, clientSecret=%s, scopes=%s, redirectUri=%s, logout=%s, session=%s, stepUp=%s]"
- .formatted(issuer, clientId, redact(clientSecret), scopes, redirectUri, logout, session, stepUp);
+ return "OidcConfig[issuer=%s, clientId=%s, clientSecret=%s, scopes=%s, redirectUri=%s, logout=%s, session=%s, stepUp=%s, userInfo=%s, login=%s]"
+ .formatted(issuer, clientId, redact(clientSecret), scopes, redirectUri, logout, session, stepUp, userInfo,
+ login);
}
/**
@@ -127,13 +134,16 @@ public record Logout(Optional path, Optional postLogoutRedirectU
* omitted
* @param csrf the CSRF settings, empty when omitted
* @param refresh the token-refresh settings, empty when omitted
+ * @param maxSessions the server-mode upper bound on concurrently stored
+ * sessions — a DoS guard on the in-memory store, empty
+ * when omitted
* @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 csrf, Optional refresh, Optional maxSessions) {
/**
* Canonical constructor normalizing absent components to {@link Optional#empty()}.
@@ -147,6 +157,7 @@ public record Session(Optional mode, Optional store, Optional mode, Optional store, Optional enabled, Optional honorUpstreamC
honorUpstreamChallenge = Objects.requireNonNullElse(honorUpstreamChallenge, Optional.empty());
}
}
+
+ /**
+ * Session/user-info reserved-endpoint settings (fold). Configures the curated
+ * identity view the gateway serves to the browser, capped by an operator-owned
+ * claim allowlist whose secure default is closed: an empty {@code allowedClaims}
+ * discloses nothing, so the operator — never the browser client — widens
+ * disclosure.
+ *
+ * @param path the gateway-served user-info path, empty when omitted
+ * @param allowedClaims the operator claim allowlist; empty is the secure closed
+ * default that discloses nothing
+ * @param defaultView the curated default-view claim selector returned when the
+ * caller requests no explicit claims; every entry must lie
+ * within {@code allowedClaims}
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @Builder
+ public record UserInfo(Optional path, List allowedClaims, List defaultView) {
+
+ /**
+ * Canonical constructor defensively copying the claim lists and normalizing an
+ * absent path to {@link Optional#empty()}.
+ */
+ public UserInfo {
+ path = Objects.requireNonNullElse(path, Optional.empty());
+ allowedClaims = allowedClaims == null ? List.of() : List.copyOf(allowedClaims);
+ defaultView = defaultView == null ? List.of() : List.copyOf(defaultView);
+ }
+ }
+
+ /**
+ * Login-initiation reserved-path settings (fold). Mirrors the {@link Logout}
+ * shape so the login-initiation endpoint reads as {@code oidc.login.path}.
+ *
+ * @param path the gateway-served login-initiation path, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @Builder
+ public record Login(Optional path) {
+
+ /**
+ * Canonical constructor normalizing an absent path to {@link Optional#empty()}.
+ */
+ public Login {
+ path = Objects.requireNonNullElse(path, Optional.empty());
+ }
+ }
}
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 1f639cc2..9f8d8acc 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
@@ -106,6 +106,14 @@ public final class ConfigValidator {
private static final String REQUIRE_NONE = "none";
private static final String REQUIRE_BEARER = "bearer";
private static final String REQUIRE_SESSION = "session";
+ // java:S1075 — a fixed JSON-pointer into the config document (schema key), not a customizable URI/filesystem path.
+ @SuppressWarnings("java:S1075")
+ private static final String OIDC_USER_INFO_PATH_POINTER = "/oidc/user_info/path";
+ private static final String OIDC_USER_INFO_DEFAULT_VIEW_POINTER = "/oidc/user_info/default_view";
+ // java:S1075 — a fixed JSON-pointer into the config document (schema key), not a customizable URI/filesystem path.
+ @SuppressWarnings("java:S1075")
+ private static final String OIDC_LOGIN_PATH_POINTER = "/oidc/login/path";
+ private static final String OIDC_SESSION_MAX_SESSIONS_POINTER = "/oidc/session/max_sessions";
private static final List DEFAULT_RULES = List.of(
(gateway, endpoints, topology, errors) -> validateVersion(gateway, errors),
@@ -125,6 +133,9 @@ public final class ConfigValidator {
(gateway, endpoints, topology, errors) -> validateForwardedTrust(gateway, errors),
(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) -> validateUserInfo(gateway, errors),
+ (gateway, endpoints, topology, errors) -> validateLoginPath(gateway, errors),
(gateway, endpoints, topology, errors) -> validatePassthroughHostCollision(gateway, endpoints, errors),
(gateway, endpoints, topology, errors) -> validatePassthroughAliasResolvable(gateway, topology, errors),
(gateway, endpoints, topology, errors) -> validateWebSocketConfig(gateway, endpoints, errors));
@@ -734,6 +745,76 @@ private static void validateSessionMode(GatewayConfig gateway, List
}));
}
+ /**
+ * Rule: the server-mode session store bound (D1/D3). When
+ * {@code oidc.session.max_sessions} is present it must be a positive integer —
+ * the bound is a DoS guard on the in-memory store, so a non-positive value is a
+ * misconfiguration. A no-op when the bound is omitted (the store applies its own
+ * documented default bound).
+ */
+ private static void validateSessionMaxSessions(GatewayConfig gateway, List errors) {
+ gateway.oidc().flatMap(OidcConfig::session).flatMap(OidcConfig.Session::maxSessions).ifPresent(max -> {
+ if (max <= 0) {
+ errors.add(new ConfigError(GATEWAY_FILE, OIDC_SESSION_MAX_SESSIONS_POINTER,
+ "oidc session max_sessions must be a positive integer, but was %d".formatted(max)));
+ }
+ });
+ }
+
+ /**
+ * 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
+ * gateway path, and every {@code default_view} claim must lie within the
+ * {@code allowed_claims} allowlist — the operator-owned allowlist caps
+ * disclosure and the default view can never exceed it. An empty allowlist is the
+ * secure closed default (nothing disclosed) and is not itself an error. Every
+ * violation collects into the shared list; the rule never fails fast.
+ */
+ private static void validateUserInfo(GatewayConfig gateway, List errors) {
+ gateway.oidc().flatMap(OidcConfig::userInfo).ifPresent(userInfo -> {
+ userInfo.path().ifPresent(path -> {
+ if (!isAbsoluteGatewayPath(path)) {
+ errors.add(new ConfigError(GATEWAY_FILE, OIDC_USER_INFO_PATH_POINTER,
+ "oidc user_info path '%s' must be an absolute gateway path starting with a single '/'"
+ .formatted(path)));
+ }
+ });
+ Set allowed = new HashSet<>(userInfo.allowedClaims());
+ for (String claim : userInfo.defaultView()) {
+ if (!allowed.contains(claim)) {
+ errors.add(new ConfigError(GATEWAY_FILE, OIDC_USER_INFO_DEFAULT_VIEW_POINTER,
+ "oidc user_info default_view claim '%s' is not in allowed_claims; the default view cannot disclose a claim outside the operator allowlist"
+ .formatted(claim)));
+ }
+ }
+ });
+ }
+
+ /**
+ * Rule: the login-initiation reserved path (fold, D1). When
+ * {@code oidc.login.path} is present it must be an absolute gateway path — a
+ * schema-relative ({@code //host}) or off-path value is rejected as an
+ * open-redirect hazard.
+ */
+ private static void validateLoginPath(GatewayConfig gateway, List errors) {
+ gateway.oidc().flatMap(OidcConfig::login).flatMap(OidcConfig.Login::path).ifPresent(path -> {
+ if (!isAbsoluteGatewayPath(path)) {
+ errors.add(new ConfigError(GATEWAY_FILE, OIDC_LOGIN_PATH_POINTER,
+ "oidc login path '%s' must be an absolute gateway path starting with a single '/'"
+ .formatted(path)));
+ }
+ });
+ }
+
+ /**
+ * Whether {@code path} is an absolute gateway path: it starts with a single
+ * {@code /} and is not a schema-relative {@code //host} URL (which would be an
+ * open-redirect vector for a reserved path).
+ */
+ private static boolean isAbsoluteGatewayPath(String path) {
+ return path.startsWith("/") && !path.startsWith("//");
+ }
+
private static void validatePassthroughHostCollision(GatewayConfig gateway, List endpoints,
List errors) {
Map passthrough = passthroughSni(gateway);
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 d7a43136..5dc3ce74 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
@@ -19,6 +19,7 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
+import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.List;
@@ -44,6 +45,9 @@
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.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;
@@ -78,6 +82,7 @@
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;
@@ -114,7 +119,9 @@
* last} so management / health routes keep working. Each request is admitted under a bounded
* {@linkplain EdgeHardeningOptions#admissionCap() admission cap} before a virtual thread is
* 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:
+ * 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):
*
* - stage 0 — response-header preparation + CORS preflight (short-circuits a preflight here);
* - stage 1 — baseline security filter (records the single canonical path), the canonical-path
@@ -141,13 +148,33 @@ public class GatewayEdgeRoute {
private static final String PROBLEM_JSON = "application/problem+json";
private static final String DEFAULT_EMIT_MODE = "x-forwarded";
+ private static final String REQUIRE_SESSION = "session";
+ private static final String COOKIE_HEADER = "Cookie";
+ private static final String LOCATION_HEADER = "Location";
+ private static final String SET_COOKIE_HEADER = "Set-Cookie";
+ private static final String CLAIMS_PARAM = "claims";
+ private static final String RETURN_TO_PARAM = "return_to";
+ private static final String STATE_PARAM = "state";
private static final int SERVICE_UNAVAILABLE = 503;
private static final int INTERNAL_ERROR = 500;
private static final int BAD_GATEWAY = 502;
private static final long DRAIN_POLL_INTERVAL_MILLIS = 50L;
+ // Fail-closed deadline for reading a tiny reserved-POST form body (the form_post callback's
+ // code/state, or a back-channel logout_token). It bounds a genuinely slow/stalled body so it cannot
+ // pin admission indefinitely; it is deliberately generous because the body is read on a shared event
+ // loop that can be scheduling-starved under CPU contention, and the deadline handler still honours a
+ // body that has already fully arrived (see readReservedBodyThenDispatch), so a legitimate body is
+ // never falsely rejected — the deadline only fires for a body that truly never completes.
+ private static final long RESERVED_BODY_READ_TIMEOUT_SECONDS = 20L;
/** Per-request {@link RoutingContext} data key holding the resolved metrics route label. */
private static final String ROUTE_KEY = "sheriff.route";
+ /** Holds the fully-read {@code application/x-www-form-urlencoded} body of a reserved POST path
+ * (form_post callback / back-channel logout), buffered on the event loop in {@link #handle} before
+ * the virtual-thread dispatch so the handler never has to re-arm a paused stream. Left unset on a
+ * read failure or timeout, so {@link #readFormBody} reads {@code null} and the receiver fails closed
+ * to {@code 400}. */
+ private static final String RESERVED_BODY_KEY = "sheriff.reservedbody";
private final List routes;
private final ExecutorService virtualThreadExecutor;
@@ -157,6 +184,8 @@ public class GatewayEdgeRoute {
private final GatewayEventCounter gatewayEventCounter;
private final UpstreamFailureMapper upstreamFailureMapper;
private final SheriffMetrics sheriffMetrics;
+ private final ReservedPathRegistry reservedPathRegistry;
+ private final BffRuntime bffRuntime;
private final SecurityHeadersStage securityHeadersStage;
private final BasicChecksStage basicChecksStage;
@@ -194,15 +223,22 @@ public class GatewayEdgeRoute {
* @param hardening the edge transport / admission bounds
* @param sheriffMetrics the Micrometer adapter the request/error/upstream signals are
* recorded through
+ * @param bffRuntime the server-mode BFF runtime (D16); its {@code require: session}
+ * stage-4 runtime is injected into the session-aware
+ * {@link AuthenticationStage} and its reserved-endpoint handlers serve
+ * the carved-out OIDC paths. The {@linkplain BffRuntime#inert() inert}
+ * runtime (a bearer-only or cookie-mode gateway) leaves the bearer
+ * path and the empty reserved registry unchanged.
*/
@Inject
public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig,
@GatewayValidator Instance tokenValidator, Vertx vertx,
@VirtualThreads ExecutorService virtualThreadExecutor, EdgeHardeningOptions hardening,
- SheriffMetrics sheriffMetrics) {
+ SheriffMetrics sheriffMetrics, BffRuntime bffRuntime) {
this.virtualThreadExecutor = virtualThreadExecutor;
this.hardening = hardening;
this.sheriffMetrics = sheriffMetrics;
+ this.bffRuntime = bffRuntime;
this.admission = new Semaphore(hardening.admissionCap());
SecurityEventCounter securityEventCounter = new SecurityEventCounter();
@@ -229,8 +265,16 @@ public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig,
GatewayEdgeRoute::assetSourceFor);
LOGGER.info(ApiSheriffLogMessages.INFO.ROUTE_TABLE_COMPILED, routes.size());
+ // Reserved OIDC endpoints (D2) are carved out of the proxy route table: the registry is
+ // consulted in process() ahead of route selection, so a proxy route such as
+ // path_prefix: /auth never swallows the exact /auth/callback. Empty (and inert) unless the
+ // global oidc block declares a redirect_uri. Built before basicChecksStage so the latter can
+ // reuse the same match to exempt gateway-terminated reserved params from the url-parameter
+ // pipeline (see BasicChecksStage — reserved handlers self-validate their own params).
+ this.reservedPathRegistry = ReservedPathRegistry.from(gatewayConfig.oidc());
this.securityHeadersStage = new SecurityHeadersStage(gatewayConfig.securityHeaders());
- this.basicChecksStage = new BasicChecksStage(defaultConfiguration, securityEventCounter);
+ this.basicChecksStage = new BasicChecksStage(defaultConfiguration, securityEventCounter,
+ (host, canonicalPath) -> reservedPathRegistry.match(host, canonicalPath).isPresent());
this.canonicalPathGuard = new CanonicalPathGuard();
this.framingGate = new FramingGate();
this.passthroughHostGuardStage = new PassthroughHostGuardStage(
@@ -238,7 +282,12 @@ public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig,
this.routeSelectionStage = new RouteSelectionStage(routes);
this.verbGateStage = new VerbGateStage();
this.thoroughChecksStage = new ThoroughChecksStage(defaultConfiguration, securityEventCounter);
- this.authenticationStage = new AuthenticationStage(tokenValidator);
+ // A server-mode BFF wires the session-aware AuthenticationStage so a require:session route is
+ // served through the SessionAuthenticationStage (D4) rather than failing at request time; a
+ // bearer-only gateway keeps the no-session constructor unchanged.
+ this.authenticationStage = bffRuntime.isActive()
+ ? new AuthenticationStage(tokenValidator, bffRuntime.sessionStage())
+ : new AuthenticationStage(tokenValidator);
this.forwardPolicyStage = new ForwardPolicyStage(resolver, peerGate, emitMode);
this.responseStage = new ResponseStage();
this.originValidationStage = new OriginValidationStage();
@@ -296,7 +345,83 @@ private void handle(RoutingContext ctx) {
releaseAdmission(admissionReleased);
recordRequestMetrics(ctx, startNanos);
});
- ctx.request().pause();
+ if (needsReservedBodyRead(ctx)) {
+ // A reserved POST (form_post callback / back-channel logout) is dispatched on a virtual
+ // thread that cannot reliably re-arm a paused request stream. Read the small, gateway-
+ // terminated body here on its own event loop — the natural Vert.x path — under a bounded
+ // deadline, stash it, then dispatch. This avoids any paused-stream / cross-thread resume.
+ readReservedBodyThenDispatch(ctx, admissionReleased);
+ } else {
+ ctx.request().pause();
+ dispatchProcessing(ctx, admissionReleased);
+ }
+ }
+
+ /**
+ * @return {@code true} when the request is a reserved POST path whose {@code x-www-form-urlencoded}
+ * body a handler consumes (the form_post callback and back-channel logout). Matched on the
+ * raw path against the reserved registry's exact-match set, so only an exact clean reserved
+ * path (raw == canonical) triggers the eager body read; every other request pauses as before.
+ */
+ private boolean needsReservedBodyRead(RoutingContext ctx) {
+ if (!"POST".equalsIgnoreCase(ctx.request().method().name())) {
+ return false;
+ }
+ String host = ctx.request().authority() != null ? ctx.request().authority().host() : ctx.request().host();
+ return reservedPathRegistry.match(host, ctx.request().path())
+ .filter(kind -> kind == ReservedEndpoint.CALLBACK || kind == ReservedEndpoint.BACKCHANNEL_LOGOUT)
+ .isPresent();
+ }
+
+ /**
+ * 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.
+ */
+ private void readReservedBodyThenDispatch(RoutingContext ctx, AtomicBoolean admissionReleased) {
+ AtomicBoolean bodyDone = new AtomicBoolean();
+ HttpServerRequest request = ctx.request();
+ Future bodyFuture = request.body();
+ 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());
+ } 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.
+ LOGGER.debug("Reserved form body read did not complete within %s s — failing closed",
+ RESERVED_BODY_READ_TIMEOUT_SECONDS);
+ }
+ dispatchProcessing(ctx, admissionReleased);
+ }
+ });
+ bodyFuture.onComplete(result -> {
+ 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");
+ }
+ 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.
+ request.resume();
+ }
+
+ /**
+ * 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.
+ */
+ private void dispatchProcessing(RoutingContext ctx, AtomicBoolean admissionReleased) {
try {
virtualThreadExecutor.execute(() -> process(ctx));
} catch (RejectedExecutionException rejected) {
@@ -388,12 +513,31 @@ private void process(RoutingContext ctx) {
return;
}
passthroughHostGuardStage.process(request);
+ if (handleReservedPath(ctx, request)) {
+ return;
+ }
routeSelectionStage.process(request);
verbGateStage.process(request);
RouteRuntime route = requireSelectedRoute(request);
ctx.put(ROUTE_KEY, route.getId());
thoroughChecksStage.process(request, route.getEffectiveAllowedPaths());
+ // Fixed CSRF defence (D7): every unsafe-method require:session request must prove same-origin
+ // provenance before the session runtime resolves it. A bearer-only gateway has no session
+ // routes and never reaches this guard.
+ if (bffRuntime.isActive() && REQUIRE_SESSION.equals(route.getEffectiveAuth().require())) {
+ bffRuntime.csrfDefence().enforce(request);
+ }
authenticationStage.process(request);
+ // Honor an auth-stage short-circuit before forwarding: the BFF SessionAuthenticationStage
+ // challenges an unauthenticated require:session navigation by setting shortCircuit(302) plus a
+ // Location header that redirects the browser into the auth-code flow. Without this gate the
+ // request would fall through to the upstream (200) instead of being challenged — both a broken
+ // login redirect and a security defect (an unauthenticated require:session request reaching the
+ // upstream). Mirrors the post-framing short-circuit gate above.
+ if (request.shortCircuitStatus().isPresent()) {
+ writeShortCircuit(ctx, request);
+ return;
+ }
ForwardPolicyStage.Result forward = forwardPolicyStage.process(request,
route.getEffectiveForward(), route.isNotModifiedEnabled());
// Protocol-dispatch seam: a WebSocket route validates its handshake Origin and hands the
@@ -407,27 +551,120 @@ private void process(RoutingContext ctx) {
dispatchAndRelay(ctx, request, route, forward);
}
} catch (GatewayException rejected) {
- // Upstream failures are already metered inside UpstreamFailureMapper; meter the rest here.
- if (rejected.getEventType().category() != EventCategory.UPSTREAM) {
- gatewayEventCounter.increment(rejected.getEventType());
- }
- if (rejected.getEventType() == EventType.SECURITY_FILTER_VIOLATION) {
- // Security-relevant WARN (D4): the failure-type detail only, never the raw payload —
- // rejected.getMessage() already carries a sanitized description (see GatewayException).
- LOGGER.warn(ApiSheriffLogMessages.WARN.SECURITY_FILTER_VIOLATION, routeLabel(ctx), rejected.getMessage());
- } else if (rejected.getEventType() == EventType.PASSTHROUGH_HOST_SMUGGLED) {
- // Security-relevant WARN: a terminated Host named a reserved passthrough SNI. The
- // message is a fixed disposition (never the raw Host value).
- LOGGER.warn(ApiSheriffLogMessages.WARN.PASSTHROUGH_HOST_SMUGGLED, rejected.getMessage());
- }
- recordError(ctx, rejected.getEventType());
- renderRejection(ctx, request, rejected.getEventType());
+ handleGatewayRejection(ctx, request, rejected);
} catch (RuntimeException unexpected) {
LOGGER.debug(unexpected, "Unexpected edge failure: %s", unexpected.getMessage());
renderProblem(ctx, request, null);
}
}
+ /**
+ * Resolves and dispatches a reserved OIDC carve-out (D2/D16): a reserved endpoint is matched here,
+ * ahead of the route table, so a proxy route such as {@code path_prefix: /auth} can never swallow
+ * the exact {@code /auth/callback}. When the server-mode BFF runtime is wired the matched path is
+ * DISPATCHED to its handler; a bearer-only / cookie-mode gateway carves it out of proxy dispatch
+ * and renders {@code NO_ROUTE_MATCHED} (the empty reserved registry never matches there anyway).
+ *
+ * @return {@code true} when the request was a reserved path and has been handled (the caller must
+ * stop processing); {@code false} when no reserved path matched and normal routing continues
+ */
+ private boolean handleReservedPath(RoutingContext ctx, PipelineRequest request) {
+ Optional reserved =
+ reservedPathRegistry.match(request.host(), requireCanonicalPath(request));
+ if (reserved.isEmpty()) {
+ return false;
+ }
+ if (bffRuntime.isActive()) {
+ dispatchReserved(ctx, request, reserved.get());
+ } else {
+ LOGGER.debug("Reserved gateway path carved out of proxy dispatch: %s",
+ requireCanonicalPath(request));
+ renderProblem(ctx, request, EventType.NO_ROUTE_MATCHED);
+ }
+ return true;
+ }
+
+ /**
+ * Meters and renders a categorized {@link GatewayException} rejection: increments the event counter
+ * (except for upstream failures already metered inside {@code UpstreamFailureMapper}), emits the
+ * security-relevant WARN for filter violations and smuggled passthrough hosts, records the error
+ * metric, and renders the rejection.
+ */
+ private void handleGatewayRejection(RoutingContext ctx, @Nullable PipelineRequest request, GatewayException rejected) {
+ // Upstream failures are already metered inside UpstreamFailureMapper; meter the rest here.
+ if (rejected.getEventType().category() != EventCategory.UPSTREAM) {
+ gatewayEventCounter.increment(rejected.getEventType());
+ }
+ if (rejected.getEventType() == EventType.SECURITY_FILTER_VIOLATION) {
+ // Security-relevant WARN (D4): the failure-type detail only, never the raw payload —
+ // rejected.getMessage() already carries a sanitized description (see GatewayException).
+ LOGGER.warn(ApiSheriffLogMessages.WARN.SECURITY_FILTER_VIOLATION, routeLabel(ctx), rejected.getMessage());
+ } else if (rejected.getEventType() == EventType.PASSTHROUGH_HOST_SMUGGLED) {
+ // Security-relevant WARN: a terminated Host named a reserved passthrough SNI. The
+ // message is a fixed disposition (never the raw Host value).
+ LOGGER.warn(ApiSheriffLogMessages.WARN.PASSTHROUGH_HOST_SMUGGLED, rejected.getMessage());
+ }
+ recordError(ctx, rejected.getEventType());
+ renderRejection(ctx, request, rejected.getEventType());
+ }
+
+ /**
+ * Dispatches a matched reserved OIDC path (D16): extracts the raw request pieces each handler
+ * consumes, drives the {@link BffRuntime#dispatch reserved dispatch}, and renders the normalized
+ * response. Runs on the virtual thread (the handler may reach the confidential-client engine), then
+ * hops the response mutation back onto the event loop like every other terminal path.
+ */
+ private void dispatchReserved(RoutingContext ctx, PipelineRequest request, ReservedEndpoint kind) {
+ String cookieHeader = request.firstHeader(COOKIE_HEADER).orElse(null);
+ String method = ctx.request().method().name();
+ // The reserved form body is read for two POST reserved paths: back-channel logout, and an OIDC
+ // response_mode=form_post callback (Keycloak POSTs the code/state to redirect_uri as an
+ // urlencoded body rather than returning a 302 with the code in the query). Both reuse the same
+ // bounded read; every other reserved path (and a GET callback) carries no body.
+ boolean callbackFormPost = kind == ReservedEndpoint.CALLBACK && "POST".equalsIgnoreCase(method);
+ String rawFormBody = kind == ReservedEndpoint.BACKCHANNEL_LOGOUT || callbackFormPost ? readFormBody(ctx) : null;
+ BffRuntime.ReservedHttpRequest reservedRequest = new BffRuntime.ReservedHttpRequest(
+ ctx.request().query(), cookieHeader, firstQueryParam(request, CLAIMS_PARAM),
+ firstQueryParam(request, RETURN_TO_PARAM), firstQueryParam(request, STATE_PARAM), rawFormBody, method);
+ BffRuntime.ReservedHttpResponse response = bffRuntime.dispatch(kind, reservedRequest, Instant.now());
+ renderReserved(ctx, request, response);
+ }
+
+ private void renderReserved(RoutingContext ctx, PipelineRequest request,
+ BffRuntime.ReservedHttpResponse response) {
+ Map stageHeaders = Map.copyOf(request.responseHeaders());
+ ctx.vertx().runOnContext(v -> {
+ HttpServerResponse httpResponse = ctx.response();
+ if (httpResponse.ended()) {
+ return;
+ }
+ httpResponse.setStatusCode(response.status());
+ stageHeaders.forEach(httpResponse::putHeader);
+ 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).
+ response.setCookieHeaders().forEach(cookie -> httpResponse.headers().add(SET_COOKIE_HEADER, cookie));
+ response.jsonBodyOptional().ifPresentOrElse(httpResponse::end, httpResponse::end);
+ });
+ }
+
+ private static @Nullable String firstQueryParam(PipelineRequest request, String name) {
+ List values = request.queryParameters().get(name);
+ return values == null || values.isEmpty() ? null : values.getFirst();
+ }
+
+ /**
+ * 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).
+ */
+ private static @Nullable String readFormBody(RoutingContext ctx) {
+ Buffer body = ctx.get(RESERVED_BODY_KEY);
+ return body == null ? null : body.toString(StandardCharsets.UTF_8);
+ }
+
private void dispatchAndRelay(RoutingContext ctx, PipelineRequest request, RouteRuntime route,
ForwardPolicyStage.Result forward) {
String prefix = stripTrailingSlash(route.getMatcher().pathPrefix());
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java
index cf7a311c..50efb2a4 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java
@@ -56,17 +56,17 @@
* resilience shape}.
*
* The heavy objects are produced by the injected factories (so tests supply fakes and the
- * production wiring supplies the real Vert.x / SmallRye instances). A route requesting
- * {@code session} auth, or a {@code GRPC} / {@code WEBSOCKET} protocol, fails boot with a
- * {@link GatewayException} carrying {@link EventType#CONFIG_INVALID}.
+ * production wiring supplies the real Vert.x / SmallRye instances). An unsupported protocol fails
+ * boot through the {@link ProtocolProcessorRegistry}. {@code require: session} routes are compiled
+ * like any other route — their stage-4 runtime is the
+ * {@code de.cuioss.sheriff.gateway.bff.runtime.SessionAuthenticationStage} (D4), which replaces the
+ * boot-time rejection this assembler used to raise.
*
* @author API Sheriff Team
* @since 1.0
*/
public final class RouteRuntimeAssembler {
- private static final String SESSION_REQUIRE = "session";
-
private final ProtocolProcessorRegistry protocolRegistry;
/**
@@ -88,7 +88,7 @@ public RouteRuntimeAssembler(ProtocolProcessorRegistry protocolRegistry) {
* @param assetSourceFactory builds the live {@link AssetSource} for an asset route's
* terminal action
* @return the assembled runtimes, in the table's longest-prefix-first order
- * @throws GatewayException when a route requests {@code session} auth or an unsupported protocol
+ * @throws GatewayException when a route declares an unsupported protocol
*/
public List assemble(RouteTable table, SecurityConfigurationFactory securityConfigFactory,
UpstreamClientFactory clientFactory, ResilienceGuardFactory guardFactory,
@@ -105,7 +105,6 @@ public List assemble(RouteTable table, SecurityConfigurationFactor
List runtimes = new ArrayList<>();
for (ResolvedRoute route : table.routes()) {
- rejectUnsupportedAuth(route);
ProtocolProcessor processor = protocolRegistry.require(route.protocol(), route.id());
Optional securityConfiguration = route.effectiveSecurityFilter()
@@ -156,13 +155,6 @@ public List assemble(RouteTable table, SecurityConfigurationFactor
return List.copyOf(runtimes);
}
- private static void rejectUnsupportedAuth(ResolvedRoute route) {
- if (SESSION_REQUIRE.equals(route.effectiveAuth().require())) {
- throw new GatewayException(EventType.CONFIG_INVALID,
- "Route '" + route.id() + "' requires session authentication which is not yet implemented");
- }
- }
-
private static Set toMethodSet(List methods) {
return methods.isEmpty() ? EnumSet.noneOf(HttpMethod.class) : EnumSet.copyOf(methods);
}
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 aa45045a..91997d93 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
@@ -93,6 +93,26 @@ public enum EventType {
/** The upstream call exceeded its configured timeout. */
UPSTREAM_TIMEOUT(EventCategory.UPSTREAM, 504),
+ // --- BFF server-session lifecycle (informational; no category, no HTTP mapping) ---
+
+ /** A server-side session was established after a successful IdP login ({@code require: session}). */
+ SESSION_CREATED(null, 0),
+ /** A server-side session was destroyed (RP-initiated logout, back-channel logout, TTL eviction, or refresh failure). */
+ SESSION_DESTROYED(null, 0),
+ /** A transparent token refresh failed (IdP rejection or refresh-token reuse) and its session was destroyed. */
+ SESSION_REFRESH_FAILED(null, 0),
+ /** A back-channel logout token was accepted and its affected sessions were destroyed. */
+ BACKCHANNEL_LOGOUT(null, 0),
+
+ // --- BFF back-channel logout rejection (400) ---
+
+ /**
+ * A back-channel {@code logout_token} failed signature or claim validation; the back-channel
+ * logout receiver rejects the request {@code 400} (OpenID Connect Back-Channel Logout), so it
+ * carries an HTTP mapping despite being an authentication-class failure.
+ */
+ LOGOUT_TOKEN_INVALID(EventCategory.AUTHENTICATION, 400),
+
// --- WebSocket (403 on the handshake; 1001 "Going Away" close on the established relay) ---
/**
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/forward/ForwardPolicyStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/forward/ForwardPolicyStage.java
index e1fa5a84..ef9fa414 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/forward/ForwardPolicyStage.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/forward/ForwardPolicyStage.java
@@ -44,6 +44,10 @@
* {@linkplain TcpPeerGate#isTrustedPeer(String) trusted proxy}, inbound forwarding headers are
* ignored (a spoofed chain from an untrusted peer never influences the regenerated set).
* - Static set headers. {@code set_headers} are appended verbatim.
+ * - Mediated session bearer. A {@code require: session} route's stage-4
+ * runtime records the mediated access token on the request; it is rendered here as the
+ * outbound {@code Authorization: Bearer} last, so it wins over any allow-listed
+ * inbound {@code Authorization}. The upstream therefore sees only the mediated bearer.
* - Conditional requests. {@code If-None-Match} / {@code If-Modified-Since}
* cross only when the route enables {@code not_modified}; otherwise they are dropped here.
*
@@ -95,10 +99,21 @@ public Result process(PipelineRequest request, ForwardConfig forwardConfig, bool
headers.putAll(forwardConfig.setHeaders());
applyConditionalHeaders(request, notModifiedEnabled, headers);
applyRegeneratedForwarding(request, headers);
+ applyMediatedBearer(request, headers);
return new Result(Map.copyOf(headers), copyAllowedQuery(request, forwardConfig));
}
+ /**
+ * Applies the {@code require: session} stage-4 mediated bearer as the outbound
+ * {@code Authorization} header, last, so it deterministically wins over any inbound
+ * {@code Authorization} an allowlist happened to forward. A pure-proxy or {@code require: bearer}
+ * route resolves no mediated bearer, so this is a no-op there.
+ */
+ private static void applyMediatedBearer(PipelineRequest request, Map headers) {
+ request.mediatedBearer().ifPresent(token -> headers.put("Authorization", "Bearer " + token));
+ }
+
private static void copyAllowedHeaders(PipelineRequest request, ForwardConfig forwardConfig,
Map headers) {
for (String name : forwardConfig.headersAllow()) {
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 14dc6570..44ea98fd 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
@@ -18,6 +18,7 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.function.BiPredicate;
import de.cuioss.http.security.config.SecurityConfiguration;
@@ -47,15 +48,25 @@ public final class BasicChecksStage {
private final SecurityConfiguration configuration;
private final PipelineFactory.PipelineSet pipelines;
+ private final BiPredicate reservedPathMatcher;
/**
- * @param configuration the gateway's default inbound validation policy
- * @param eventCounter the shared cui-http security event counter (never a local instance)
+ * @param configuration the gateway's default inbound validation policy
+ * @param eventCounter the shared cui-http security event counter (never a local instance)
+ * @param reservedPathMatcher predicate {@code (host, canonicalPath) -> isReserved}: a matched
+ * reserved gateway path is terminated at the gateway (never proxied) and
+ * its query params are validated by the reserved handler itself, so the
+ * url-parameter value pipeline — an anti-injection defense for params
+ * 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)
*/
- public BasicChecksStage(SecurityConfiguration configuration, SecurityEventCounter eventCounter) {
+ public BasicChecksStage(SecurityConfiguration configuration, SecurityEventCounter eventCounter,
+ BiPredicate reservedPathMatcher) {
this.configuration = Objects.requireNonNull(configuration, "configuration");
this.pipelines = PipelineFactory.createCommonPipelines(configuration,
Objects.requireNonNull(eventCounter, "eventCounter"));
+ this.reservedPathMatcher = Objects.requireNonNull(reservedPathMatcher, "reservedPathMatcher");
}
/**
@@ -70,9 +81,16 @@ public void process(PipelineRequest request) {
Objects.requireNonNull(request, "request");
enforceCollectionLimits(request);
String canonical = validatePath(request.requestPath());
- validateParameters(request.queryParameters());
- validateHeaders(request.headers());
request.canonicalPath(canonical);
+ // A gateway-terminated reserved path (callback/login/logout/userinfo/back-channel) self-validates
+ // its own params in its handler; the generic url-parameter value pipeline (an anti-injection
+ // defense for upstream-forwarded params) is not applied to it, so a legitimate same-origin
+ // return_to path carrying '/' is not rejected. The param-count cap, path pipeline, and header
+ // pipeline above/below still apply.
+ if (!reservedPathMatcher.test(request.host(), canonical)) {
+ validateParameters(request.queryParameters());
+ }
+ validateHeaders(request.headers());
}
private void enforceCollectionLimits(PipelineRequest request) {
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 d07829b3..fe2cd518 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
@@ -63,6 +63,7 @@ public final class PipelineRequest {
private @Nullable String canonicalPath;
private @Nullable RouteRuntime selectedRoute;
private @Nullable Integer shortCircuitStatus;
+ private @Nullable String mediatedBearer;
private PipelineRequest(Builder builder) {
this.method = Objects.requireNonNull(builder.method, "method");
@@ -252,6 +253,27 @@ public void shortCircuit(int status) {
this.shortCircuitStatus = status;
}
+ /**
+ * @return the mediated access token the {@code require: session} stage-4 runtime resolved for
+ * automatic upstream injection, or empty when no session mediation applied. The forward
+ * policy stage renders it as the outbound {@code Authorization: Bearer} header so the
+ * upstream sees only the mediated bearer, never the opaque session cookie.
+ */
+ public Optional mediatedBearer() {
+ return Optional.ofNullable(mediatedBearer);
+ }
+
+ /**
+ * Records the mediated access token the session stage-4 runtime injects automatically as the
+ * upstream {@code Authorization: Bearer} (never operator-configured). The token material stays
+ * server-side up to this point; the forward stage is its sole consumer.
+ *
+ * @param mediatedBearer the raw mediated access token
+ */
+ public void mediatedBearer(String mediatedBearer) {
+ this.mediatedBearer = Objects.requireNonNull(mediatedBearer, "mediatedBearer");
+ }
+
/**
* Builder collecting the immutable inbound components of a {@link PipelineRequest}.
*/
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
new file mode 100644
index 00000000..dd3c9192
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java
@@ -0,0 +1,351 @@
+/*
+ * 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 java.net.URI;
+import java.time.Clock;
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+
+
+import de.cuioss.sheriff.gateway.auth.GatewayValidator;
+import de.cuioss.sheriff.gateway.bff.csrf.CsrfDefence;
+import de.cuioss.sheriff.gateway.bff.login.LoginFlow;
+import de.cuioss.sheriff.gateway.bff.logout.BackchannelLogoutReceiver;
+import de.cuioss.sheriff.gateway.bff.logout.LogoutTokenValidator;
+import de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout;
+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.refresh.StepUpCoordinator;
+import de.cuioss.sheriff.gateway.bff.refresh.TokenRefreshCoordinator;
+import de.cuioss.sheriff.gateway.bff.reserved.BackchannelLogoutEndpoint;
+import de.cuioss.sheriff.gateway.bff.reserved.CallbackEndpoint;
+import de.cuioss.sheriff.gateway.bff.reserved.ClaimAllowlistFilter;
+import de.cuioss.sheriff.gateway.bff.reserved.LoginInitiationEndpoint;
+import de.cuioss.sheriff.gateway.bff.reserved.LogoutEndpoint;
+import de.cuioss.sheriff.gateway.bff.reserved.UserInfoEndpoint;
+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.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;
+import de.cuioss.sheriff.token.client.auth.ClientSecretBasicAuth;
+import de.cuioss.sheriff.token.client.config.ClientAuthMethod;
+import de.cuioss.sheriff.token.client.config.ClientConfiguration;
+import de.cuioss.sheriff.token.client.discovery.DiscoveryResolver;
+import de.cuioss.sheriff.token.client.discovery.ProviderMetadata;
+import de.cuioss.sheriff.token.client.flow.AuthorizationCodeFlow;
+import de.cuioss.sheriff.token.client.flow.RefreshFlow;
+import de.cuioss.sheriff.token.client.flow.StepUpHandler;
+import de.cuioss.sheriff.token.client.flow.TokenEndpointClient;
+import de.cuioss.sheriff.token.client.logout.EndSessionFlow;
+import de.cuioss.sheriff.token.client.logout.PostLogoutRedirectValidator;
+import de.cuioss.sheriff.token.client.token.IdTokenValidationBridge;
+import de.cuioss.sheriff.token.client.token.TokenValidationBridge;
+import de.cuioss.sheriff.token.validation.TokenValidator;
+import de.cuioss.sheriff.token.validation.domain.claim.ClaimValue;
+import de.cuioss.tools.logging.CuiLogger;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Instance;
+import jakarta.enterprise.inject.Produces;
+import jakarta.inject.Singleton;
+
+/**
+ * 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
+ * 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
+ * {@code StepUpHandler#initiate} for RFC 9470 re-drive — so the engine is reached at runtime.
+ *
+ * Lazy discovery. The OIDC provider metadata is resolved through a memoized supplier
+ * on first engine use, not at boot: a server-mode gateway therefore boots (and is unit-testable)
+ * without a live IdP, and the discovery-dependent {@code end_session_endpoint} the logout leg needs is
+ * materialized only when the first logout arrives.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@ApplicationScoped
+public class BffRuntimeProducer {
+
+ private static final CuiLogger LOGGER = new CuiLogger(BffRuntimeProducer.class);
+
+ private static final String SESSION_MODE_SERVER = "server";
+ private static final int DEFAULT_SESSION_TTL_SECONDS = 3600;
+ private static final int DEFAULT_MAX_SESSIONS = 10_000;
+ private static final int DEFAULT_MAX_PENDING = 10_000;
+ private static final int DEFAULT_REFRESH_LEEWAY_SECONDS = 30;
+ private static final Duration BACKCHANNEL_FRESHNESS_WINDOW = Duration.ofMinutes(2);
+ private static final Duration LOGOUT_STATE_TTL = Duration.ofMinutes(1);
+ private static final String DEFAULT_FINAL_REDIRECT = "/";
+
+ private final GatewayConfig gatewayConfig;
+ private final Instance tokenValidator;
+
+ /**
+ * @param gatewayConfig the bound global gateway document carrying the {@code oidc} block
+ * @param tokenValidator a lazy handle to the gateway's shared offline validator, resolved only on
+ * the active server-mode path (a bearer-only gateway never triggers it)
+ */
+ public BffRuntimeProducer(GatewayConfig gatewayConfig,
+ @GatewayValidator Instance tokenValidator) {
+ this.gatewayConfig = Objects.requireNonNull(gatewayConfig, "gatewayConfig");
+ this.tokenValidator = Objects.requireNonNull(tokenValidator, "tokenValidator");
+ }
+
+ /**
+ * Produces the BFF runtime.
+ *
+ * {@link Singleton} (a pseudo-scope, no client proxy) because {@link BffRuntime} is a {@code final}
+ * 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
+ */
+ @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)");
+ 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();
+ boolean hasRedirect = oidc.flatMap(OidcConfig::redirectUri).isPresent();
+ return serverMode && hasRedirect;
+ }
+
+ private BffRuntime build(OidcConfig oidc) {
+ String redirectUri = oidc.redirectUri().orElseThrow();
+ String gatewayOrigin = originOf(redirectUri);
+ String issuer = oidc.issuer().orElse(gatewayOrigin);
+ String clientId = oidc.clientId().orElse("");
+ String clientSecret = oidc.clientSecret().orElse("");
+
+ Optional session = oidc.session();
+ Duration sessionTtl = Duration.ofSeconds(
+ session.flatMap(OidcConfig.Session::ttlSeconds).orElse(DEFAULT_SESSION_TTL_SECONDS));
+ String cookieName = session.flatMap(OidcConfig.Session::cookieName)
+ .orElse(SessionCookieCodec.DEFAULT_COOKIE_NAME);
+ int maxSessions = session.flatMap(OidcConfig.Session::maxSessions).orElse(DEFAULT_MAX_SESSIONS);
+ Duration refreshLeeway = Duration.ofSeconds(session.flatMap(OidcConfig.Session::refresh)
+ .flatMap(OidcConfig.Refresh::leewaySeconds).orElse(DEFAULT_REFRESH_LEEWAY_SECONDS));
+ Set trustedOrigins = Set.copyOf(session.flatMap(OidcConfig.Session::csrf)
+ .map(OidcConfig.Csrf::trustedOrigins).filter(list -> !list.isEmpty())
+ .orElse(List.of(gatewayOrigin)));
+
+ ClientConfiguration clientConfiguration = ClientConfiguration.builder()
+ .issuer(issuer).clientId(clientId).clientSecret(clientSecret)
+ .authMethod(ClientAuthMethod.CLIENT_SECRET_BASIC)
+ .scopes(oidc.scopes()).redirectUri(redirectUri).build();
+ ClientAuthentication clientAuthentication = new ClientSecretBasicAuth(clientId, clientSecret);
+ Supplier metadata = memoize(() -> new DiscoveryResolver(clientConfiguration).resolve());
+
+ TokenValidator validator = tokenValidator.get();
+ TokenValidationBridge tokenBridge = new TokenValidationBridge(validator);
+ IdTokenValidationBridge idBridge = new IdTokenValidationBridge(validator);
+ TokenEndpointClient tokenEndpointClient = new TokenEndpointClient(clientConfiguration);
+ AuthorizationCodeFlow authorizationCodeFlow = new AuthorizationCodeFlow(clientConfiguration,
+ tokenEndpointClient, tokenBridge, idBridge);
+ RefreshFlow refreshFlow = new RefreshFlow(clientConfiguration, tokenEndpointClient, tokenBridge,
+ clientAuthentication);
+
+ SessionCookieCodec sessionCookieCodec = new SessionCookieCodec(cookieName, sessionTtl);
+ BindingCookieCodec bindingCookieCodec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL);
+ SessionStore sessionStore = new InMemorySessionStore(maxSessions);
+ PendingAuthorizationStore pendingStore = new PendingAuthorizationStore.InMemory(DEFAULT_MAX_PENDING);
+ Clock clock = Clock.systemUTC();
+
+ // D5 login flow — the AuthorizationInitiation seam reaches the engine at runtime.
+ LoginFlow loginFlow = new LoginFlow(() -> authorizationCodeFlow.authorize(metadata.get()),
+ pendingStore, bindingCookieCodec, gatewayOrigin);
+
+ // D2 callback — the CodeExchange seam reaches the engine's code exchange + token validation.
+ CallbackEndpoint callbackEndpoint = new CallbackEndpoint(
+ (context, params) -> authorizationCodeFlow.exchange(metadata.get(), context, params,
+ clientAuthentication),
+ pendingStore, bindingCookieCodec, sessionStore, sessionCookieCodec, 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);
+
+ // 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),
+ (accessToken, requiredScopes) -> tokenBridge.validateAccessToken(accessToken)
+ .providesScopes(requiredScopes),
+ (returnUrl, now) -> {
+ LoginFlow.LoginRedirect redirect = loginFlow.initiate(returnUrl, now);
+ return new SessionAuthenticationStage.LoginChallenge(redirect.authorizationUrl(),
+ redirect.setCookieHeaders());
+ },
+ clock);
+
+ // D7 RFC 9470 step-up — instantiated with the engine StepUpHandler seam; the upstream-challenge
+ // edge integration is exercised by the Keycloak integration tests.
+ StepUpHandler stepUpHandler = new StepUpHandler();
+ StepUpCoordinator stepUpCoordinator = new StepUpCoordinator(
+ (sessionRecord, challenge, now) -> Optional.empty(),
+ challenge -> stepUpHandler.initiate(clientConfiguration, metadata.get(), challenge),
+ pendingStore, bindingCookieCodec, gatewayOrigin);
+
+ // D11 user-info fold — validated ID-token claims through the engine, capped by the allowlist.
+ 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,
+ 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);
+
+ // 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);
+ 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));
+
+ CsrfDefence csrfDefence = new CsrfDefence(trustedOrigins);
+
+ LOGGER.debug("Server-mode BFF runtime assembled for origin %s (issuer %s)", gatewayOrigin, issuer);
+ return new BffRuntime(sessionStage, csrfDefence, stepUpCoordinator, callbackEndpoint, logoutEndpoint,
+ backchannelLogoutEndpoint, userInfoEndpoint, loginInitiationEndpoint);
+ }
+
+ private static LogoutEndpoint buildLogoutEndpoint(OidcConfig oidc, String gatewayOrigin, ProviderMetadata metadata,
+ SessionStore sessionStore, SessionCookieCodec sessionCookieCodec) {
+ Optional logout = oidc.logout();
+ String postLogoutRedirectUri = logout.flatMap(OidcConfig.Logout::postLogoutRedirectUri)
+ .orElse(gatewayOrigin + "/");
+ String finalRedirect = logout.flatMap(OidcConfig.Logout::finalRedirect).orElse(DEFAULT_FINAL_REDIRECT);
+ String endSessionEndpoint = metadata.getEndSessionEndpoint()
+ .orElseThrow(() -> new IllegalStateException(
+ "OIDC provider metadata declares no end_session_endpoint — RP-initiated logout unavailable"));
+ EndSessionFlow endSessionFlow = new EndSessionFlow(new PostLogoutRedirectValidator(Set.of(postLogoutRedirectUri)));
+ RpInitiatedLogout rpInitiatedLogout = new RpInitiatedLogout(endSessionFlow,
+ sessionRecord -> {
+ // Best-effort by design: the authoritative logout is the local session destruction.
+ },
+ endSessionEndpoint, postLogoutRedirectUri, finalRedirect, LOGOUT_STATE_TTL);
+ return new LogoutEndpoint(rpInitiatedLogout, sessionStore, sessionCookieCodec);
+ }
+
+ private static Map toClaimMap(Map claims) {
+ Map converted = new LinkedHashMap<>();
+ claims.forEach((name, value) -> {
+ if (value != null && !value.isNotPresentForClaimValueType()) {
+ String original = value.getOriginalString();
+ if (original != null && !original.isBlank()) {
+ converted.put(name, original);
+ }
+ }
+ });
+ return converted;
+ }
+
+ /**
+ * Derives the gateway's own origin (scheme + host + optional non-default port) from the configured
+ * {@code redirect_uri}, used to same-origin-validate post-login return URLs and as the default
+ * CSRF trusted origin.
+ */
+ static String originOf(String redirectUri) {
+ URI uri = URI.create(redirectUri);
+ String scheme = uri.getScheme();
+ String host = uri.getHost();
+ if (scheme == null || host == null) {
+ throw new IllegalStateException("oidc.redirect_uri is not an absolute URI: " + redirectUri);
+ }
+ int port = uri.getPort();
+ StringBuilder origin = new StringBuilder(scheme).append("://").append(host);
+ // Browsers send the Origin without the scheme's default port, so https://gw:443 and
+ // http://gw:80 must reduce to https://gw / http://gw. Emitting the default port here would make
+ // the derived origin (same-origin return-URL check AND the default CSRF trusted-origins entry)
+ // reject a genuine same-origin unsafe request whose Origin omits the default port.
+ if (port != -1 && port != defaultPortFor(scheme)) {
+ origin.append(':').append(port);
+ }
+ return origin.toString();
+ }
+
+ private static int defaultPortFor(String scheme) {
+ if ("https".equalsIgnoreCase(scheme)) {
+ return 443;
+ }
+ if ("http".equalsIgnoreCase(scheme)) {
+ return 80;
+ }
+ return -1;
+ }
+
+ /**
+ * Wraps {@code delegate} in a thread-safe memoizing supplier: the delegate runs at most once, on
+ * first {@link Supplier#get()}, and every later call returns the cached value. Used to defer OIDC
+ * discovery (and the discovery-dependent logout endpoint) to first request rather than boot.
+ */
+ private static Supplier memoize(Supplier delegate) {
+ AtomicReference cache = new AtomicReference<>();
+ return () -> {
+ T existing = cache.get();
+ if (existing != null) {
+ return existing;
+ }
+ synchronized (cache) {
+ T current = cache.get();
+ if (current == null) {
+ current = Objects.requireNonNull(delegate.get(), "memoized supplier produced null");
+ cache.set(current);
+ }
+ return current;
+ }
+ };
+ }
+}
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 b0ee79eb..9de3f231 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
@@ -21,6 +21,7 @@
import de.cuioss.sheriff.gateway.auth.GatewayValidator;
import de.cuioss.sheriff.gateway.config.model.GatewayConfig;
import de.cuioss.sheriff.gateway.config.model.Metadata;
+import de.cuioss.sheriff.gateway.config.model.OidcConfig;
import de.cuioss.sheriff.gateway.config.model.TokenValidationConfig;
import de.cuioss.sheriff.gateway.events.GatewayException;
import de.cuioss.sheriff.token.validation.TokenValidator;
@@ -50,6 +51,17 @@
* resolution failure ({@link GatewayException}) marks the probe {@code DOWN} with the cause.
* A gateway with no {@code token_validation} block needs no bearer validation, so JWKS is
* reported {@code not-applicable} and does not gate readiness.
+ * - Issuer reachability (mode: server) — when the gateway runs a BFF
+ * {@code oidc.session.mode: server} deployment, the OIDC issuer must be reachable for the
+ * gateway to mediate and validate the confidential-client tokens. This reuses the same
+ * JWKS-backed validation health check above (resolving the {@link GatewayValidator}
+ * {@link TokenValidator} reaches every configured issuer's JWKS): a server-mode probe adds
+ * an {@code oidc=server} datum and reports the issuer as {@code reachable} /
+ * {@code unreachable} alongside the JWKS status, so a server-mode probe surfaces issuer
+ * reachability explicitly. A server-mode deployment that configures no
+ * {@code token_validation} block has no validation health check to reuse, so issuer
+ * reachability is reported {@code unverified} — the confidential-client engine reaches the
+ * issuer lazily on the first login and does not gate boot readiness.
*
* The validator is resolved lazily through an {@link Instance} so a misconfigured JWKS source
* yields a clean {@code DOWN} response rather than failing this probe's own construction.
@@ -67,6 +79,13 @@ public class GatewayReadinessCheck implements HealthCheck {
private static final String DATA_JWKS = "jwks";
private static final String DATA_ISSUERS = "issuers";
private static final String DATA_ERROR = "error";
+ private static final String DATA_OIDC = "oidc";
+ private static final String DATA_ISSUER_REACHABILITY = "issuer_reachability";
+
+ private static final String MODE_SERVER = "server";
+ private static final String ISSUER_REACHABLE = "reachable";
+ private static final String ISSUER_UNREACHABLE = "unreachable";
+ private static final String ISSUER_UNVERIFIED = "unverified";
private final GatewayConfig gatewayConfig;
private final Instance gatewayValidator;
@@ -95,8 +114,20 @@ public HealthCheckResponse call() {
gatewayConfig.metadata().flatMap(Metadata::configVersion)
.ifPresent(version -> builder.withData(DATA_CONFIG_VERSION, version));
+ boolean serverMode = isServerSessionMode();
+ if (serverMode) {
+ builder.withData(DATA_OIDC, MODE_SERVER);
+ }
+
Optional tokenValidation = gatewayConfig.tokenValidation();
if (tokenValidation.isEmpty()) {
+ // No bearer validation configured, so there is no JWKS-backed validation health check to
+ // reuse. A server-mode deployment reaches its issuer lazily through the confidential-client
+ // engine on the first login, which does not gate boot readiness — so issuer reachability is
+ // reported unverified rather than gating the probe DOWN.
+ if (serverMode) {
+ builder.withData(DATA_ISSUER_REACHABILITY, ISSUER_UNVERIFIED);
+ }
return builder.withData(DATA_JWKS, "not-applicable").up().build();
}
@@ -104,11 +135,30 @@ public HealthCheckResponse call() {
builder.withData(DATA_ISSUERS, issuerCount);
try {
gatewayValidator.get();
- return builder.withData(DATA_JWKS, "ready").up().build();
+ builder.withData(DATA_JWKS, "ready");
+ if (serverMode) {
+ builder.withData(DATA_ISSUER_REACHABILITY, ISSUER_REACHABLE);
+ }
+ return builder.up().build();
} catch (GatewayException | CreationException failure) {
- return builder.withData(DATA_JWKS, "unavailable")
- .withData(DATA_ERROR, String.valueOf(failure.getMessage()))
- .down().build();
+ builder.withData(DATA_JWKS, "unavailable")
+ .withData(DATA_ERROR, String.valueOf(failure.getMessage()));
+ if (serverMode) {
+ builder.withData(DATA_ISSUER_REACHABILITY, ISSUER_UNREACHABLE);
+ }
+ return builder.down().build();
}
}
+
+ /**
+ * @return {@code true} when the gateway runs a BFF {@code oidc.session.mode: server} deployment,
+ * so issuer reachability is reported as part of readiness
+ */
+ private boolean isServerSessionMode() {
+ return gatewayConfig.oidc()
+ .flatMap(OidcConfig::session)
+ .flatMap(OidcConfig.Session::mode)
+ .filter(MODE_SERVER::equalsIgnoreCase)
+ .isPresent();
+ }
}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/SheriffMetrics.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/SheriffMetrics.java
index 8c798d30..0e85b672 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/SheriffMetrics.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/SheriffMetrics.java
@@ -22,6 +22,7 @@
import de.cuioss.http.security.core.UrlSecurityFailureType;
import de.cuioss.http.security.monitoring.SecurityEventCounter;
import de.cuioss.sheriff.gateway.events.EventCategory;
+import de.cuioss.sheriff.gateway.events.EventType;
import io.micrometer.core.instrument.FunctionCounter;
import io.micrometer.core.instrument.MeterRegistry;
@@ -42,7 +43,10 @@
* - {@value #SECURITY_EVENTS_TOTAL}{@code {failure_type}} — the {@code cui-http}
* security-filter counts;
* - {@value #UPSTREAM_DURATION_SECONDS}{@code {route}} — downstream-call time, separated
- * from gateway overhead.
+ * from gateway overhead;
+ * - {@value #SESSION_EVENTS_TOTAL}{@code {event}} — the BFF {@code require: session} lifecycle
+ * counts (session create/destroy, transparent refresh, CSRF, and logout events), keyed by
+ * {@link EventType} name.
*
* Route cardinality is bounded (route id is a config-fixed label; unmatched requests share the
* fixed {@value #NO_ROUTE} value), so every meter is safe to keep always on. Each record call
@@ -65,6 +69,8 @@ public class SheriffMetrics {
public static final String SECURITY_EVENTS_TOTAL = "sheriff_security_events_total";
/** Per-route downstream-call time (timer). */
public static final String UPSTREAM_DURATION_SECONDS = "sheriff_upstream_duration_seconds";
+ /** Counter of BFF {@code require: session} lifecycle events, keyed by {@link EventType} name. */
+ public static final String SESSION_EVENTS_TOTAL = "sheriff_session_events_total";
/** The bounded label value shared by requests that matched no route. */
public static final String NO_ROUTE = "";
@@ -74,6 +80,7 @@ public class SheriffMetrics {
private static final String TAG_STATUS_FAMILY = "status_family";
private static final String TAG_CATEGORY = "category";
private static final String TAG_FAILURE_TYPE = "failure_type";
+ private static final String TAG_EVENT = "event";
private final MeterRegistry registry;
@@ -118,6 +125,21 @@ public void recordError(String route, EventCategory category) {
registry.counter(ERRORS_TOTAL, TAG_ROUTE, route, TAG_CATEGORY, category.slug()).increment();
}
+ /**
+ * Counts one BFF {@code require: session} lifecycle event against {@link #SESSION_EVENTS_TOTAL},
+ * keyed by the {@link EventType} name. The counter surfaces the session create/destroy,
+ * transparent-refresh, CSRF, and logout events the confidential-client surface emits — the same
+ * catalogue {@link de.cuioss.sheriff.gateway.events.GatewayEventCounter} counts in-process,
+ * bridged here to a Micrometer meter. The {@code event} label cardinality is fixed at the
+ * {@link EventType} enum (never operator-controlled input), so the meter is safe to keep always on.
+ *
+ * @param eventType the session lifecycle event to count (e.g. {@link EventType#SESSION_CREATED})
+ */
+ public void recordSessionEvent(EventType eventType) {
+ Objects.requireNonNull(eventType, "eventType");
+ registry.counter(SESSION_EVENTS_TOTAL, TAG_EVENT, eventType.name()).increment();
+ }
+
/**
* Binds the boot-shared {@code cui-http} {@link SecurityEventCounter} to the registry, exposing its
* per-{@link UrlSecurityFailureType} violation counts as the {@link #SECURITY_EVENTS_TOTAL} meter.
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/TokenClientDslJsonReflection.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/TokenClientDslJsonReflection.java
new file mode 100644
index 00000000..b8af7e46
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/TokenClientDslJsonReflection.java
@@ -0,0 +1,78 @@
+/*
+ * 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 de.cuioss.sheriff.token.client.discovery.ProviderMetadata;
+import de.cuioss.sheriff.token.client.discovery._ProviderMetadata_DslJsonConverter;
+import de.cuioss.sheriff.token.client.flow.ParResponse;
+import de.cuioss.sheriff.token.client.flow._ParResponse_DslJsonConverter;
+import de.cuioss.sheriff.token.client.token.TokenResponse;
+import de.cuioss.sheriff.token.client.token.UserInfoResponse;
+import de.cuioss.sheriff.token.client.token._TokenResponse_DslJsonConverter;
+import de.cuioss.sheriff.token.client.token._UserInfoResponse_DslJsonConverter;
+
+import io.quarkus.runtime.annotations.RegisterForReflection;
+
+/**
+ * Registers the {@code token-sheriff-client} confidential-client engine response
+ * DTOs and their generated DSL-JSON converters for reflection so the engine can
+ * deserialize IdP responses in a GraalVM native image.
+ *
+ * The engine parses every back-channel IdP response (OIDC discovery, the token
+ * endpoint, PAR, and userinfo) with a DSL-JSON instance built by
+ * {@code de.cuioss.sheriff.token.commons.transport.ParserConfig}. For each response
+ * type the DSL-JSON annotation processor emits a compile-time
+ * {@code __DslJsonConverter} that implements
+ * {@code com.dslplatform.json.Configuration}. DSL-JSON discovers these converters at
+ * runtime through {@code com.dslplatform.json.ExternalConverterAnalyzer}, which
+ * derives the converter class name by the {@code .__DslJsonConverter}
+ * naming convention, loads it via {@link ClassLoader#loadClass(String)}, instantiates
+ * it with the no-arg constructor, and calls {@code configure(dslJson)} to register the
+ * reader/writer bindings.
+ *
+ * That reflective {@code loadClass} + {@code newInstance} step works on the JVM but
+ * fails in a native image unless the converter classes are registered for reflection —
+ * the engine then throws {@code Unable to find reader for provided type ... and
+ * fallback serialization is not registered}. Registering the converter classes here is
+ * the load-bearing fix: it makes DSL-JSON's reflective lookup resolve in native. The
+ * DTO records are registered alongside as defence in depth; the converters instantiate
+ * their nested {@code $ObjectFormatConverter} readers via a direct {@code new} and are
+ * therefore reached by the native-image static analysis without an explicit entry.
+ *
+ * The class has no runtime behavior; it exists solely to carry the
+ * {@link RegisterForReflection} targets. Referencing the generated converter classes by
+ * symbol (rather than by string) makes any future rename in the client engine fail the
+ * build loudly instead of silently regressing native deserialization.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@RegisterForReflection(targets = {
+ _ProviderMetadata_DslJsonConverter.class,
+ _ParResponse_DslJsonConverter.class,
+ _TokenResponse_DslJsonConverter.class,
+ _UserInfoResponse_DslJsonConverter.class,
+ ProviderMetadata.class,
+ ParResponse.class,
+ TokenResponse.class,
+ UserInfoResponse.class
+})
+public final class TokenClientDslJsonReflection {
+
+ private TokenClientDslJsonReflection() {
+ // Reflection-registration holder; not instantiable.
+ }
+}
diff --git a/api-sheriff/src/main/resources/application.properties b/api-sheriff/src/main/resources/application.properties
index 1aa86925..c1b7fea8 100644
--- a/api-sheriff/src/main/resources/application.properties
+++ b/api-sheriff/src/main/resources/application.properties
@@ -38,7 +38,21 @@ quarkus.tls.cipher-suites=TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256,TL
quarkus.tls.alpn=true
# Native Image Configuration
-quarkus.native.additional-build-args=--enable-url-protocols=https,--enable-http,--enable-https,-O2
+# The three BFF records below each hold a `static final SecureRandom`. GraalVM initializes static
+# state at BUILD time by default, which would bake a seeded SecureRandom into the image heap
+# (a build failure — "instance of Random/SplittableRandom in the image heap" — AND a real security
+# defect: predictable randomness from a cached seed). Force runtime initialization for each so the
+# SecureRandom is seeded from the OS entropy source at process start. The list is comma-separated,
+# so each class needs its own --initialize-at-run-time entry (a comma-joined class list inside a
+# single flag would be split by the outer comma).
+# The token-sheriff-client engine flow classes (PkceChallenge, FlowContext, and sibling
+# refresh/step-up/end-session flows) likewise hold static SecureRandom fields. Deliverable 16 wired
+# the engine into the live edge (BffRuntimeProducer -> AuthorizationCodeFlow -> FlowContext.create),
+# so those static fields are now reachable in the image heap and hit the same build failure. A single
+# package-prefix --initialize-at-run-time=de.cuioss.sheriff.token.client.flow covers the whole flow
+# package (GraalVM treats a bare package name as a prefix over that package and its classes), so
+# every current and future sibling flow class is forced to runtime initialization in one entry.
+quarkus.native.additional-build-args=--enable-url-protocols=https,--enable-http,--enable-https,-O2,--initialize-at-run-time=de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord,--initialize-at-run-time=de.cuioss.sheriff.gateway.bff.session.SessionRecord,--initialize-at-run-time=de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout,--initialize-at-run-time=de.cuioss.sheriff.token.client.flow
quarkus.native.resources.includes=**/*.p12,**/*.crt,**/*.key,schema/*.json
quarkus.native.monitoring=jfr
# Java 25 Mandrel builder image for JEP 491 (virtual threads without pinning) — see ADR-0001
diff --git a/api-sheriff/src/main/resources/schema/gateway.schema.json b/api-sheriff/src/main/resources/schema/gateway.schema.json
index 6e8d3011..a7fb8c40 100644
--- a/api-sheriff/src/main/resources/schema/gateway.schema.json
+++ b/api-sheriff/src/main/resources/schema/gateway.schema.json
@@ -252,6 +252,10 @@
"leeway_seconds": { "type": "integer" },
"on_failure": { "type": "string", "enum": ["reauthenticate", "reject"] }
}
+ },
+ "max_sessions": {
+ "type": "integer",
+ "description": "Server-mode upper bound on concurrently stored sessions — a DoS guard on the in-memory store. Creating a session beyond the bound is refused fail-closed."
}
}
},
@@ -262,6 +266,32 @@
"enabled": { "type": "boolean" },
"honor_upstream_challenge": { "type": "boolean" }
}
+ },
+ "user_info": {
+ "type": "object",
+ "additionalProperties": false,
+ "description": "Session/user-info reserved-endpoint settings (fold). The curated identity view the gateway serves to the browser, capped by an operator-owned claim allowlist whose secure default is closed: an empty allowed_claims discloses nothing.",
+ "properties": {
+ "path": { "type": "string" },
+ "allowed_claims": {
+ "type": "array",
+ "description": "Operator claim allowlist; empty is the secure closed default that discloses nothing. The operator — never the browser client — widens disclosure.",
+ "items": { "type": "string" }
+ },
+ "default_view": {
+ "type": "array",
+ "description": "Curated default-view claim selector returned when the caller requests no explicit claims; every entry must lie within allowed_claims.",
+ "items": { "type": "string" }
+ }
+ }
+ },
+ "login": {
+ "type": "object",
+ "additionalProperties": false,
+ "description": "Login-initiation reserved-path settings (fold). Mirrors the logout shape so the login-initiation endpoint reads as oidc.login.path.",
+ "properties": {
+ "path": { "type": "string" }
+ }
}
}
}
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 2712f145..321342c2 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
@@ -18,11 +18,21 @@
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
+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.SessionCookieCodec;
+import de.cuioss.sheriff.gateway.bff.session.SessionRecord;
import de.cuioss.sheriff.gateway.config.model.AuthConfig;
import de.cuioss.sheriff.gateway.config.model.HttpMethod;
import de.cuioss.sheriff.gateway.events.EventType;
@@ -34,14 +44,19 @@
import de.cuioss.sheriff.token.validation.test.generator.TestTokenGenerators;
import de.cuioss.test.generator.junit.EnableGeneratorController;
+import jakarta.inject.Provider;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@EnableGeneratorController
-@DisplayName("AuthenticationStage — stage 4 offline bearer-token validation")
+@DisplayName("AuthenticationStage — stage 4 auth dispatch (offline bearer validation and session dispatch)")
class AuthenticationStageTest {
private static final String ABSENT_SCOPE = "gateway:definitely-absent-scope-xyz";
+ private static final Instant NOW = Instant.parse("2026-07-23T10:00:00Z");
+ private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
+ private static final String SESSION_ID = "opaque-session-id";
+ private static final String MEDIATED_TOKEN = "mediated-access-token";
@Test
@DisplayName("passes a require:none route without inspecting any token")
@@ -125,11 +140,77 @@ void rejectsMissingScope() {
assertEquals(EventType.SCOPE_MISSING, thrown.getEventType());
}
+ @Test
+ @DisplayName("dispatches a require:session route to the wired session stage-4 runtime")
+ void dispatchesSessionRouteToWiredSessionStage() {
+ // Arrange — a session stage wired with a live session; a require:session request carrying the
+ // session cookie must be dispatched here and complete, recording the mediated bearer.
+ AuthenticationStage stage = new AuthenticationStage(failingValidatorProvider(), sessionStage());
+ PipelineRequest request = sessionRequest(authConfig("session", List.of()));
+
+ // Act + Assert
+ assertDoesNotThrow(() -> stage.process(request));
+ assertTrue(request.mediatedBearer().isPresent(),
+ "dispatch reached the session stage, which mediated the session's bearer");
+ assertEquals(MEDIATED_TOKEN, request.mediatedBearer().orElseThrow());
+ }
+
+ @Test
+ @DisplayName("rejects a require:session route when no session runtime is wired")
+ void rejectsSessionRouteWithoutWiredSessionRuntime() {
+ // Arrange — a stage built without a session runtime (non-BFF gateway).
+ AuthenticationStage stage = stageFor(TestTokenGenerators.accessTokens().next());
+ PipelineRequest request = sessionRequest(authConfig("session", List.of()));
+
+ // Act
+ IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> stage.process(request));
+
+ // Assert
+ assertTrue(thrown.getMessage().contains("no session runtime is wired"),
+ "the unwired session route is a boot-configuration error, not a served request");
+ }
+
private static AuthenticationStage stageFor(TestTokenHolder holder) {
TokenValidator validator = TokenValidator.builder().issuerConfig(holder.getIssuerConfig()).build();
return new AuthenticationStage(() -> validator);
}
+ private static Provider failingValidatorProvider() {
+ return () -> {
+ throw new AssertionError("a require:session route must not resolve the bearer validator");
+ };
+ }
+
+ private static SessionAuthenticationStage sessionStage() {
+ InMemorySessionStore store = new InMemorySessionStore(16);
+ store.create(SessionRecord.builder()
+ .sessionId(SESSION_ID)
+ .accessToken(MEDIATED_TOKEN)
+ .idToken("id-token")
+ .sub("subject")
+ .expiresAt(NOW.plusSeconds(3600))
+ .build());
+ SessionCookieCodec codec = new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, Duration.ofHours(1));
+ return new SessionAuthenticationStage(store, codec,
+ (session, now) -> session,
+ (accessToken, requiredScopes) -> true,
+ (returnUrl, now) -> new LoginChallenge("https://idp.example/authorize", List.of()),
+ CLOCK);
+ }
+
+ private static PipelineRequest sessionRequest(AuthConfig auth) {
+ PipelineRequest request = PipelineRequest.builder()
+ .method(HttpMethod.GET)
+ .requestPath("/app/orders")
+ .queryParameters(Map.of())
+ .headers(Map.of("cookie", List.of(SessionCookieCodec.DEFAULT_COOKIE_NAME + "=" + SESSION_ID),
+ "accept", List.of("application/json")))
+ .build();
+ request.canonicalPath("/app/orders");
+ request.selectedRoute(RouteRuntime.builder().id("orders").effectiveAuth(auth).build());
+ return request;
+ }
+
private static AuthConfig authConfig(String require, List requiredScopes) {
return AuthConfig.builder().require(require).requiredScopes(requiredScopes).build();
}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/csrf/CsrfDefenceTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/csrf/CsrfDefenceTest.java
new file mode 100644
index 00000000..8bcab999
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/csrf/CsrfDefenceTest.java
@@ -0,0 +1,160 @@
+/*
+ * 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.csrf;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+
+import de.cuioss.sheriff.gateway.config.model.HttpMethod;
+import de.cuioss.sheriff.gateway.events.EventType;
+import de.cuioss.sheriff.gateway.events.GatewayException;
+import de.cuioss.sheriff.gateway.pipeline.PipelineRequest;
+import de.cuioss.test.juli.junit5.EnableTestLogger;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+/**
+ * Contract of the fixed CSRF defence for {@code require: session} routes (D7). The defence is
+ * fail-closed on unsafe methods: it accepts only a trusted {@code Origin} (exact full-origin match,
+ * case-insensitive) or, when {@code Origin} is absent, a {@code Sec-Fetch-Site: same-origin} header —
+ * everything else is rejected with {@link EventType#CSRF_REJECTED}. Safe methods
+ * ({@code GET}/{@code HEAD}/{@code OPTIONS}) are never checked.
+ */
+@EnableTestLogger
+@DisplayName("CsrfDefence — fixed, fail-closed CSRF defence for session routes")
+class CsrfDefenceTest {
+
+ private static final String ORIGIN_HEADER = "Origin";
+ private static final String SEC_FETCH_SITE_HEADER = "Sec-Fetch-Site";
+ private static final String TRUSTED_ORIGIN = "https://app.example.com";
+ private static final String UNTRUSTED_ORIGIN = "https://evil.example.com";
+
+ private final CsrfDefence defence = new CsrfDefence(Set.of(TRUSTED_ORIGIN));
+
+ private static PipelineRequest request(HttpMethod method, Map> headers) {
+ return PipelineRequest.builder()
+ .method(method)
+ .requestPath("/api/orders")
+ .headers(headers)
+ .build();
+ }
+
+ private static void assertRejected(CsrfDefence defence, PipelineRequest request, String reason) {
+ GatewayException thrown = assertThrows(GatewayException.class, () -> defence.enforce(request), reason);
+ assertEquals(EventType.CSRF_REJECTED, thrown.getEventType(),
+ "a CSRF rejection must carry the CSRF_REJECTED event");
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = HttpMethod.class, names = {"GET", "HEAD", "OPTIONS"})
+ @DisplayName("a CSRF-safe method bypasses the defence even with no Origin proof")
+ void safeMethodsBypass(HttpMethod method) {
+ PipelineRequest request = request(method, Map.of());
+
+ assertDoesNotThrow(() -> defence.enforce(request),
+ "safe methods carry no CSRF risk and are never checked");
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = HttpMethod.class, names = {"POST", "PUT", "PATCH", "DELETE"})
+ @DisplayName("an unsafe method with a trusted Origin is accepted")
+ void unsafeMethodTrustedOriginAccepted(HttpMethod method) {
+ PipelineRequest request = request(method, Map.of(ORIGIN_HEADER, List.of(TRUSTED_ORIGIN)));
+
+ assertDoesNotThrow(() -> defence.enforce(request));
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = HttpMethod.class, names = {"POST", "PUT", "PATCH", "DELETE"})
+ @DisplayName("an unsafe method with an untrusted Origin is rejected")
+ void unsafeMethodUntrustedOriginRejected(HttpMethod method) {
+ PipelineRequest request = request(method, Map.of(ORIGIN_HEADER, List.of(UNTRUSTED_ORIGIN)));
+
+ assertRejected(defence, request, "an untrusted Origin fails the exact-match check");
+ }
+
+ @Test
+ @DisplayName("the trusted-Origin match is case-insensitive")
+ void trustedOriginMatchIsCaseInsensitive() {
+ PipelineRequest request = request(HttpMethod.POST, Map.of(ORIGIN_HEADER, List.of("HTTPS://App.Example.COM")));
+
+ assertDoesNotThrow(() -> defence.enforce(request),
+ "the inbound Origin is lower-cased before comparison against the pre-lower-cased trusted set");
+ }
+
+ @Test
+ @DisplayName("an absent Origin with Sec-Fetch-Site: same-origin is accepted")
+ void absentOriginSameOriginFetchAccepted() {
+ PipelineRequest request = request(HttpMethod.POST, Map.of(SEC_FETCH_SITE_HEADER, List.of("same-origin")));
+
+ assertDoesNotThrow(() -> defence.enforce(request),
+ "with no Origin, a same-origin fetch-metadata header is the accepted proof");
+ }
+
+ @Test
+ @DisplayName("an absent Origin with Sec-Fetch-Site: cross-site is rejected")
+ void absentOriginCrossSiteFetchRejected() {
+ PipelineRequest request = request(HttpMethod.POST, Map.of(SEC_FETCH_SITE_HEADER, List.of("cross-site")));
+
+ assertRejected(defence, request, "a cross-site fetch is not same-origin provenance");
+ }
+
+ @Test
+ @DisplayName("an unsafe method with neither Origin nor Sec-Fetch-Site is rejected")
+ void bothHeadersAbsentRejected() {
+ PipelineRequest request = request(HttpMethod.POST, Map.of());
+
+ assertRejected(defence, request, "no proof of same-origin provenance is fail-closed");
+ }
+
+ @Test
+ @DisplayName("a present untrusted Origin is rejected even when Sec-Fetch-Site is same-origin")
+ void presentOriginTakesPrecedenceOverFetchMetadata() {
+ PipelineRequest request = request(HttpMethod.POST, Map.of(
+ ORIGIN_HEADER, List.of(UNTRUSTED_ORIGIN),
+ SEC_FETCH_SITE_HEADER, List.of("same-origin")));
+
+ assertRejected(defence, request,
+ "a present Origin is authoritative — an untrusted Origin is not rescued by fetch metadata");
+ }
+
+ @Test
+ @DisplayName("with an empty trusted set only a same-origin fetch can pass an unsafe method")
+ void emptyTrustedSetIsFailClosedExceptSameOriginFetch() {
+ CsrfDefence closed = new CsrfDefence(Set.of());
+
+ assertRejected(closed, request(HttpMethod.POST, Map.of(ORIGIN_HEADER, List.of(TRUSTED_ORIGIN))),
+ "with no trusted origins configured, every Origin is untrusted");
+ assertDoesNotThrow(() -> closed.enforce(request(HttpMethod.POST,
+ Map.of(SEC_FETCH_SITE_HEADER, List.of("same-origin")))),
+ "a same-origin fetch still passes with an empty trusted set");
+ }
+
+ @Test
+ @DisplayName("the constructor rejects a null trusted-origin set")
+ void constructorRejectsNullTrustedOrigins() {
+ assertThrows(NullPointerException.class, () -> new CsrfDefence(null));
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/login/LoginFlowTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/login/LoginFlowTest.java
new file mode 100644
index 00000000..433d7cde
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/login/LoginFlowTest.java
@@ -0,0 +1,146 @@
+/*
+ * 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.login;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Instant;
+import java.util.Optional;
+
+
+import de.cuioss.sheriff.gateway.bff.login.LoginFlow.AuthorizationInitiation;
+import de.cuioss.sheriff.gateway.bff.login.LoginFlow.LoginRedirect;
+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.token.client.flow.AuthorizationCodeFlow;
+import de.cuioss.sheriff.token.client.flow.FlowContext;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * Tests for {@link LoginFlow}: the login-initiation orchestration — engine-driven authorization,
+ * pending-record persistence, the browser-binding cookie, and the same-origin return-URL guard.
+ *
+ * The engine authorization is driven through the {@link AuthorizationInitiation} seam with a
+ * hand-built {@link AuthorizationCodeFlow.AuthorizationRedirect}, so the flow is exercised without a
+ * live IdP or discovery round-trip.
+ */
+class LoginFlowTest {
+
+ private static final Instant T0 = Instant.parse("2026-07-23T10:00:00Z");
+ private static final String GATEWAY_ORIGIN = "https://gw.example.com";
+ private static final String AUTHORIZATION_URL = "https://idp.example.com/authorize?client_id=api-sheriff";
+
+ private PendingAuthorizationStore.InMemory pendingStore;
+ private BindingCookieCodec bindingCodec;
+ private FlowContext flowContext;
+ private LoginFlow loginFlow;
+
+ @BeforeEach
+ void setUp() {
+ pendingStore = new PendingAuthorizationStore.InMemory(8);
+ bindingCodec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL);
+ flowContext = FlowContext.create(GATEWAY_ORIGIN + "/auth/callback");
+ AuthorizationCodeFlow.AuthorizationRedirect redirect =
+ new AuthorizationCodeFlow.AuthorizationRedirect(AUTHORIZATION_URL, flowContext);
+ AuthorizationInitiation authorization = () -> redirect;
+ loginFlow = new LoginFlow(authorization, pendingStore, bindingCodec, GATEWAY_ORIGIN);
+ }
+
+ private PendingAuthorizationRecord consumeBoundRecord(LoginRedirect result) {
+ String cookieHeader = result.setCookieHeaders().getFirst().split(";", 2)[0];
+ Optional recordId = bindingCodec.readRecordId(cookieHeader);
+ assertTrue(recordId.isPresent(), "the login sets a binding cookie carrying the record id");
+ return pendingStore.consume(recordId.get(), T0).orElseThrow();
+ }
+
+ @Nested
+ @DisplayName("Engine-driven redirect")
+ class Redirect {
+
+ @Test
+ @DisplayName("Should redirect to the engine authorization URL and set the binding cookie")
+ void shouldRedirectToAuthorizationUrl() {
+ LoginRedirect result = loginFlow.initiate("/dashboard", T0);
+
+ assertEquals(AUTHORIZATION_URL, result.authorizationUrl(), "the URL comes from the engine, unchanged");
+ assertEquals(1, result.setCookieHeaders().size());
+ assertTrue(result.setCookieHeaders().getFirst().startsWith(BindingCookieCodec.COOKIE_NAME + "="));
+ }
+
+ @Test
+ @DisplayName("Should persist the engine FlowContext as a single-use pending record bound to the browser")
+ void shouldPersistPendingRecord() {
+ LoginRedirect result = loginFlow.initiate("/dashboard", T0);
+
+ PendingAuthorizationRecord pending = consumeBoundRecord(result);
+ assertSame(flowContext, pending.flowContext(), "the record wraps the engine context, never re-invents it");
+ assertEquals("/dashboard", pending.returnUrl());
+ assertEquals(T0, pending.createdAt());
+ }
+ }
+
+ @Nested
+ @DisplayName("Same-origin return-URL guard (never an open redirect)")
+ class ReturnUrlGuard {
+
+ @ParameterizedTest(name = "same-origin return URL \"{0}\" is recorded verbatim")
+ @ValueSource(strings = {"/dashboard", "/app/page?x=1", "https://gw.example.com/app"})
+ @DisplayName("Should record a same-origin return URL verbatim")
+ void shouldRecordSameOriginReturnUrl(String returnUrl) {
+ LoginRedirect result = loginFlow.initiate(returnUrl, T0);
+
+ assertEquals(returnUrl, consumeBoundRecord(result).returnUrl());
+ }
+
+ @ParameterizedTest(name = "off-origin return URL \"{0}\" falls back to the default landing")
+ @ValueSource(strings = {"https://evil.example.com/app", "//evil.example.com", "javascript:alert(1)"})
+ @DisplayName("Should fall back to the default landing for a cross-origin or unparseable return URL")
+ void shouldFallBackForOffOrigin(String returnUrl) {
+ LoginRedirect result = loginFlow.initiate(returnUrl, T0);
+
+ assertEquals(LoginFlow.DEFAULT_RETURN_URL, consumeBoundRecord(result).returnUrl());
+ }
+
+ @Test
+ @DisplayName("Should fall back to the default landing when no return URL is supplied")
+ void shouldFallBackForNull() {
+ LoginRedirect result = loginFlow.initiate(null, T0);
+
+ assertEquals(LoginFlow.DEFAULT_RETURN_URL, consumeBoundRecord(result).returnUrl());
+ }
+ }
+
+ @Nested
+ @DisplayName("Argument contract")
+ class ArgumentContract {
+
+ @Test
+ @DisplayName("Should reject a null reference instant")
+ void shouldRejectNullNow() {
+ assertThrows(NullPointerException.class, () -> loginFlow.initiate("/dashboard", null));
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/logout/LogoutTokenValidatorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/logout/LogoutTokenValidatorTest.java
new file mode 100644
index 00000000..c431468b
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/logout/LogoutTokenValidatorTest.java
@@ -0,0 +1,323 @@
+/*
+ * 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.logout;
+
+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.time.Duration;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+
+import de.cuioss.sheriff.gateway.bff.logout.LogoutTokenValidator.LogoutSubject;
+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 LogoutTokenValidator}: the pure OIDC back-channel-logout-token claim residual
+ * applied after the engine has signature-verified the token. Every negative case in the check
+ * matrix — {@code iss} wrong, {@code aud} wrong, {@code iat} outside the freshness window (both
+ * directions) or absent, {@code events} missing the back-channel event, {@code nonce} present, and
+ * both {@code sub}/{@code sid} absent — must fail-closed to {@link Optional#empty()}, and the two
+ * single-subject success shapes ({@code sub}-only, {@code sid}-only) must resolve.
+ *
+ * The token is hand-built as an {@link IdTokenContent} claim carrier over a {@link ClaimValue} map,
+ * so every case is exercised without a live IdP or a signed token.
+ */
+class LogoutTokenValidatorTest {
+
+ private static final String ISSUER = "https://idp.example.com";
+ private static final String AUDIENCE = "bff-client";
+ private static final Duration FRESHNESS = Duration.ofMinutes(2);
+ private static final Instant NOW = Instant.parse("2026-07-23T10:00:00Z");
+ private static final String SUB = "user-sub-1";
+ private static final String SID = "idp-sid-9";
+ private static final String RAW = "raw-logout-token";
+ private static final String OTHER_EVENT = "{\"http://schemas.openid.net/event/token-revoked\":{}}";
+
+ private final LogoutTokenValidator validator = new LogoutTokenValidator(ISSUER, AUDIENCE, FRESHNESS);
+
+ private static Map validClaims() {
+ return new HashMap<>(Map.of(
+ "iss", ClaimValue.forPlainString(ISSUER),
+ "aud", ClaimValue.forList("aud", List.of(AUDIENCE)),
+ "iat", ClaimValue.forDateTime("iat", OffsetDateTime.ofInstant(NOW, ZoneOffset.UTC)),
+ "events", ClaimValue.forPlainString(
+ "{\"" + LogoutTokenValidator.BACKCHANNEL_LOGOUT_EVENT + "\":{}}"),
+ "sub", ClaimValue.forPlainString(SUB),
+ "sid", ClaimValue.forPlainString(SID)));
+ }
+
+ private static TokenContent token(Map claims) {
+ return new IdTokenContent(claims, RAW);
+ }
+
+ @Nested
+ @DisplayName("Accepted logout tokens")
+ class Accepted {
+
+ @Test
+ @DisplayName("Should accept a fully valid logout token and resolve sub + sid")
+ void shouldAcceptValidToken() {
+ Optional result = validator.validate(token(validClaims()), NOW);
+
+ assertTrue(result.isPresent(), "a fully valid logout token is accepted");
+ assertEquals(Optional.of(SUB), result.get().sub());
+ assertEquals(Optional.of(SID), result.get().sid());
+ }
+
+ @Test
+ @DisplayName("Should accept a token carrying only sub (sid absent)")
+ void shouldAcceptSubOnly() {
+ Map claims = validClaims();
+ claims.remove("sid");
+
+ Optional result = validator.validate(token(claims), NOW);
+
+ assertTrue(result.isPresent());
+ assertEquals(Optional.of(SUB), result.get().sub());
+ assertTrue(result.get().sid().isEmpty(), "sid is absent");
+ }
+
+ @Test
+ @DisplayName("Should accept a token carrying only sid (sub absent)")
+ void shouldAcceptSidOnly() {
+ Map claims = validClaims();
+ claims.remove("sub");
+
+ Optional result = validator.validate(token(claims), NOW);
+
+ assertTrue(result.isPresent());
+ assertTrue(result.get().sub().isEmpty(), "sub is absent");
+ assertEquals(Optional.of(SID), result.get().sid());
+ }
+
+ @Test
+ @DisplayName("Should accept a plain-string aud equal to the client id")
+ void shouldAcceptPlainStringAudience() {
+ Map claims = validClaims();
+ claims.put("aud", ClaimValue.forPlainString(AUDIENCE));
+
+ assertTrue(validator.validate(token(claims), NOW).isPresent());
+ }
+
+ @Test
+ @DisplayName("Should accept an aud array that contains the client id among others")
+ void shouldAcceptAudienceListContainingClient() {
+ Map