Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
283a4b3
feat(bff): extend OIDC/session config surface for user-info and login…
cuioss-oliver Jul 23, 2026
375977e
feat(bff): add pending-authorization record, store, and browser-bindi…
cuioss-oliver Jul 23, 2026
14cd765
feat(bff): add server-side session store and opaque session cookie
cuioss-oliver Jul 23, 2026
e1499d8
feat(bff): add reserved-path registry and OIDC callback endpoint
cuioss-oliver Jul 23, 2026
4f313c0
feat(bff): add confidential-client login initiation flow
cuioss-oliver Jul 23, 2026
b563c32
feat(gateway): implement require:session stage-4 runtime (D4)
cuioss-oliver Jul 23, 2026
939fa4a
feat(bff): add fixed CSRF defence for session routes
cuioss-oliver Jul 24, 2026
7c8006b
feat(bff): add RP-initiated and back-channel logout (D8)
cuioss-oliver Jul 24, 2026
019a1ca
chore(quality): normalize import ordering in bff sources
cuioss-oliver Jul 24, 2026
fbae35d
test(bff): cover logout-token matrix and RP-initiated logout (D8)
cuioss-oliver Jul 24, 2026
871e4cb
feat(bff): add transparent token refresh and RFC 9470 step-up (D9)
cuioss-oliver Jul 24, 2026
1bf8f9e
feat(bff): add session metrics, LogRecords, and server-mode readiness
cuioss-oliver Jul 24, 2026
9574592
feat(bff): add session/user-info reserved endpoint (fold)
cuioss-oliver Jul 25, 2026
8b1eda7
test(bff): cover session/user-info reserved endpoint (fold)
cuioss-oliver Jul 25, 2026
b2d8888
feat(bff): add login-initiation reserved path with already-authentica…
cuioss-oliver Jul 25, 2026
7de0af9
feat(bff): wire server-mode BFF runtime into the live gateway edge
cuioss-oliver Jul 25, 2026
c50fb87
feat(benchmarks): add session-mediation benchmark and comparison head…
cuioss-oliver Jul 25, 2026
8bfb18f
docs(bff): author three-layer server-session BFF documentation
cuioss-oliver Jul 25, 2026
19dcac4
test(bff): add Keycloak-backed server-mode BFF integration tests
cuioss-oliver Jul 25, 2026
e9169dc
fix(native): initialize BFF SecureRandom-holding classes at runtime
cuioss-oliver Jul 25, 2026
0fb5bd0
fix(native): initialize token-sheriff engine flow package at runtime
cuioss-oliver Jul 25, 2026
ecbe50a
fix(it): resolve BFF oidc client_secret via env indirection
cuioss-oliver Jul 25, 2026
01ebc75
fix(it): keep mtls gateway.yaml in lockstep with the shared BFF endpoint
cuioss-oliver Jul 25, 2026
85f5bd0
fix(it): trust the private-CA IdP via the JVM default truststore for …
cuioss-oliver Jul 25, 2026
58fd123
fix(bff): register token-sheriff-client engine response DTOs for nati…
cuioss-oliver Jul 25, 2026
a29c13d
fix(bff): address CodeRabbit review — events object claim, backslash …
cuioss-oliver Jul 25, 2026
a184a53
fix(bff): honor session-stage short-circuit at the edge so unauthenti…
cuioss-oliver Jul 25, 2026
606d98f
chore(sonar): drive PR-103 new-code findings to zero (2 reliability b…
cuioss-oliver Jul 25, 2026
da4d0b9
fix(bff): support OIDC form_post callback (POST /auth/callback, body-…
cuioss-oliver Jul 25, 2026
cdf3531
fix(bff): guard nullable rawQuery in callback selector (Sonar S2637);…
cuioss-oliver Jul 25, 2026
e5c09ae
fix(bff): send pre-encoded IT URLs verbatim and harden callbackParame…
cuioss-oliver Jul 25, 2026
b38264d
fix(bff): read the reserved POST form body by resuming the paused req…
cuioss-oliver Jul 25, 2026
7eb2501
fix(bff): exempt reserved-path params from the url-parameter pipeline…
cuioss-oliver Jul 26, 2026
fc7be16
fix(bff): read reserved POST body eagerly on the event loop (robust u…
cuioss-oliver Jul 26, 2026
5ce0fe8
docs(architecture): add threading-model rule — never drive inbound-so…
cuioss-oliver Jul 26, 2026
8a4fa99
fix(bff): make the reserved-POST body-read deadline race-proof under …
cuioss-oliver Jul 26, 2026
68d87b4
test(it): capture api-sheriff app logs on test failure + TEMP debug l…
cuioss-oliver Jul 26, 2026
0d692a9
fix(it): start Keycloak before the gateway so the JWKS loader finds i…
cuioss-oliver Jul 26, 2026
6d0a8da
chore(it): remove the temporary diagnostic DEBUG logging
cuioss-oliver Jul 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -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};</li>
* <li>{@code require: session} — never reaches here; the boot-time {@code RouteRuntimeAssembler}
* already rejected such routes (session auth is not yet implemented).</li>
* <li>{@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.</li>
* </ul>
* The upstream is never contacted on any authentication or authorization rejection.
* <p>
* 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
Expand All @@ -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> 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
Expand All @@ -71,6 +87,19 @@ public final class AuthenticationStage {
*/
public AuthenticationStage(Provider<TokenValidator> 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> tokenValidator, SessionAuthenticationStage sessionStage) {
this.tokenValidator = Objects.requireNonNull(tokenValidator, "tokenValidator");
this.sessionStage = Objects.requireNonNull(sessionStage, "sessionStage");
}

/**
Expand All @@ -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"));
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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.
* <p>
* <strong>No sensitive data is logged.</strong> 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 <em>disposition</em> ({@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();
}
}
Original file line number Diff line number Diff line change
@@ -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 <strong>fixed
* behaviour with no disable knob</strong>: 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.
* <p>
* "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:
* <ol>
* <li>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</li>
* <li>when {@code Origin} is absent, a {@code Sec-Fetch-Site: same-origin} fetch-metadata header.</li>
* </ol>
* 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.
* <p>
* 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<HttpMethod> 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<String> 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<String> 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<String> 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);
}
}
Loading