From 283a4b341f899f34107721f87ebf03f62938c566 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:40:54 +0200 Subject: [PATCH 01/39] feat(bff): extend OIDC/session config surface for user-info and login folds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the two BFF operator-fold config keys to OidcConfig — a UserInfo block (path, allowedClaims allowlist, defaultView selector) and a Login block (path) — plus a server-mode maxSessions bound on the Session block. Add OIDC/session-section validation rules: user-info and login paths must be absolute gateway paths, default-view claims must lie within the operator allowlist (empty allowlist is the secure closed default), and max_sessions must be positive. Extend the value-object contract and validator test suites for the new records and rules. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Af6Ah5SR43Ug7eG9HN1Yyo --- .../gateway/config/model/OidcConfig.java | 72 ++++++++++- .../config/validation/ConfigValidator.java | 77 ++++++++++++ .../config/model/ConfigModelContractTest.java | 102 +++++++++++++++ .../validation/ConfigValidatorTest.java | 119 ++++++++++++++++++ 4 files changed, 364 insertions(+), 6 deletions(-) diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/OidcConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/OidcConfig.java index 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..fd7d8837 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,10 @@ 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"; + 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"; + 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 +129,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 +741,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/test/java/de/cuioss/sheriff/gateway/config/model/ConfigModelContractTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/model/ConfigModelContractTest.java index b56f1283..38eb5d0b 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/model/ConfigModelContractTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/model/ConfigModelContractTest.java @@ -146,8 +146,19 @@ private static OidcConfig oidcConfig() { .csrf(Optional.of(new OidcConfig.Csrf(List.of("https://app.example.com")))) .refresh(Optional.of(new OidcConfig.Refresh(Optional.of(true), Optional.of(60), Optional.of("reauthenticate")))) + .maxSessions(Optional.of(10000)) .build())) .stepUp(Optional.of(new OidcConfig.StepUp(Optional.of(true), Optional.of(false)))) + .userInfo(Optional.of(userInfo())) + .login(Optional.of(new OidcConfig.Login(Optional.of("/session/login")))) + .build(); + } + + private static OidcConfig.UserInfo userInfo() { + return OidcConfig.UserInfo.builder() + .path(Optional.of("/session/userinfo")) + .allowedClaims(List.of("sub", "name", "roles")) + .defaultView(List.of("sub", "name")) .build(); } @@ -321,6 +332,11 @@ static Stream valueObjects() { voCase("OidcConfig.StepUp", new OidcConfig.StepUp(Optional.of(true), Optional.of(false)), new OidcConfig.StepUp(Optional.of(true), Optional.of(false)), new OidcConfig.StepUp(Optional.of(false), Optional.of(true))), + voCase("OidcConfig.UserInfo", userInfo(), userInfo(), + new OidcConfig.UserInfo(Optional.of("/other"), List.of("sub"), List.of())), + voCase("OidcConfig.Login", new OidcConfig.Login(Optional.of("/session/login")), + new OidcConfig.Login(Optional.of("/session/login")), + new OidcConfig.Login(Optional.of("/other-login"))), voCase("UpstreamDefaultsConfig", new UpstreamDefaultsConfig(true, true), new UpstreamDefaultsConfig(true, true), new UpstreamDefaultsConfig(false, true)), voCase("EndpointConfig", endpointConfig(), endpointConfig(), EndpointConfig.builder() @@ -840,4 +856,90 @@ void defaultsEnableRetryAndNotModified() { assertEquals(new UpstreamDefaultsConfig(true, true), defaults); } } + + // --- BFF OIDC/session fold records (D1) -------------------------------- + + @Nested + @DisplayName("BFF OIDC/session fold records (D1)") + class BffFoldRecords { + + @Test + void userInfoExposesPathAllowlistAndDefaultView() { + OidcConfig.UserInfo cfg = userInfo(); + assertEquals(Optional.of("/session/userinfo"), cfg.path()); + assertEquals(List.of("sub", "name", "roles"), cfg.allowedClaims()); + assertEquals(List.of("sub", "name"), cfg.defaultView()); + } + + @Test + void userInfoBuilderMatchesConstructor() { + OidcConfig.UserInfo viaCtor = new OidcConfig.UserInfo(Optional.of("/u"), List.of("sub"), List.of("sub")); + OidcConfig.UserInfo viaBuilder = OidcConfig.UserInfo.builder() + .path(Optional.of("/u")).allowedClaims(List.of("sub")).defaultView(List.of("sub")).build(); + assertEquals(viaCtor, viaBuilder); + } + + @Test + void userInfoNormalizesAbsentComponents() { + OidcConfig.UserInfo cfg = new OidcConfig.UserInfo(null, null, null); + assertTrue(cfg.path().isEmpty()); + assertTrue(cfg.allowedClaims().isEmpty(), "an absent allowlist is the secure closed default (empty)"); + assertTrue(cfg.defaultView().isEmpty()); + } + + @Test + void userInfoClaimListsAreDefensivelyCopiedAndUnmodifiable() { + List allowedSource = new ArrayList<>(List.of("sub")); + List viewSource = new ArrayList<>(List.of("sub")); + OidcConfig.UserInfo cfg = OidcConfig.UserInfo.builder() + .path(Optional.of("/u")).allowedClaims(allowedSource).defaultView(viewSource).build(); + allowedSource.add("email"); + viewSource.add("email"); + assertEquals(List.of("sub"), cfg.allowedClaims(), + "mutating the source list after construction must not affect the record"); + assertEquals(List.of("sub"), cfg.defaultView()); + List allowed = cfg.allowedClaims(); + List view = cfg.defaultView(); + assertThrows(UnsupportedOperationException.class, () -> allowed.add("roles")); + assertThrows(UnsupportedOperationException.class, () -> view.add("roles")); + } + + @Test + void loginExposesPathAndNormalizesAbsent() { + assertEquals(Optional.of("/session/login"), new OidcConfig.Login(Optional.of("/session/login")).path()); + assertTrue(new OidcConfig.Login(null).path().isEmpty()); + } + + @Test + void loginBuilderMatchesConstructor() { + OidcConfig.Login viaCtor = new OidcConfig.Login(Optional.of("/login")); + OidcConfig.Login viaBuilder = OidcConfig.Login.builder().path(Optional.of("/login")).build(); + assertEquals(viaCtor, viaBuilder); + } + + @Test + void sessionExposesAndNormalizesMaxSessions() { + OidcConfig.Session withBound = OidcConfig.Session.builder().maxSessions(Optional.of(500)).build(); + assertEquals(Optional.of(500), withBound.maxSessions()); + OidcConfig.Session withoutBound = OidcConfig.Session.builder().build(); + assertTrue(withoutBound.maxSessions().isEmpty(), "an absent max_sessions normalizes to Optional.empty()"); + } + + @Test + void oidcToStringCarriesTheFoldFieldsAndStillRedactsTheSecret() { + String rendered = oidcConfig().toString(); + assertTrue(rendered.contains("userInfo="), "toString must surface the userInfo block"); + assertTrue(rendered.contains("login="), "toString must surface the login block"); + assertTrue(rendered.contains("***REDACTED***"), "the client secret must stay redacted"); + assertFalse(rendered.contains("${OIDC_SECRET}"), "the raw client-secret reference must never appear"); + } + + @Test + void sessionToStringCarriesMaxSessionsAndStillRedactsKeys() { + String rendered = oidcConfig().session().orElseThrow().toString(); + assertTrue(rendered.contains("maxSessions="), "Session toString must surface the max_sessions bound"); + assertTrue(rendered.contains("***REDACTED***"), "the encryption key must stay redacted"); + assertFalse(rendered.contains("${SESSION_KEY}"), "the raw encryption-key reference must never appear"); + } + } } diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidatorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidatorTest.java index b8329617..8c72f374 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidatorTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidatorTest.java @@ -1275,4 +1275,123 @@ void shouldRejectWebSocketBlockOnNonWebSocketRoute() { "declares a websocket block but its protocol is not 'websocket'"); } } + + @Nested + @DisplayName("The BFF OIDC/session fold rules (D1)") + class BffOidcFoldRules { + + private static GatewayConfig gatewayWithOidc(OidcConfig oidc) { + return validGateway().oidc(Optional.of(oidc)).build(); + } + + @Test + @DisplayName("Should accept a user_info block whose default_view claims all lie within the allowlist") + void shouldAcceptUserInfoWithDefaultViewWithinAllowlist() { + GatewayConfig gateway = gatewayWithOidc(OidcConfig.builder() + .userInfo(Optional.of(OidcConfig.UserInfo.builder() + .path(Optional.of("/session/userinfo")) + .allowedClaims(List.of("sub", "name", "roles")) + .defaultView(List.of("sub", "name")) + .build())) + .build()); + + List errors = validator.validate(gateway, List.of(), topologyWith()); + + assertTrue(errors.isEmpty(), () -> "expected no violations, got: " + errors); + } + + @Test + @DisplayName("Should accept a user_info block with an empty allowlist as the secure closed default") + void shouldAcceptUserInfoWithEmptyAllowlistSecureDefault() { + GatewayConfig gateway = gatewayWithOidc(OidcConfig.builder() + .userInfo(Optional.of(OidcConfig.UserInfo.builder() + .path(Optional.of("/session/userinfo")) + .build())) + .build()); + + List errors = validator.validate(gateway, List.of(), topologyWith()); + + assertTrue(errors.isEmpty(), + () -> "an empty allowlist is the secure closed default and must not be an error, got: " + errors); + } + + @Test + @DisplayName("Should reject a default_view claim that lies outside the operator allowlist") + void shouldRejectDefaultViewClaimOutsideAllowlist() { + GatewayConfig gateway = gatewayWithOidc(OidcConfig.builder() + .userInfo(Optional.of(OidcConfig.UserInfo.builder() + .path(Optional.of("/session/userinfo")) + .allowedClaims(List.of("sub", "name")) + .defaultView(List.of("sub", "email")) + .build())) + .build()); + + List errors = validator.validate(gateway, List.of(), topologyWith()); + + assertHasError(errors, "/oidc/user_info/default_view", "not in allowed_claims"); + } + + @ParameterizedTest(name = "user_info path \"{0}\" is rejected as non-absolute") + @ValueSource(strings = {"session/userinfo", "//evil.example.com", "https://evil.example.com"}) + @DisplayName("Should reject a malformed user_info path that is not an absolute gateway path") + void shouldRejectNonAbsoluteUserInfoPath(String path) { + GatewayConfig gateway = gatewayWithOidc(OidcConfig.builder() + .userInfo(Optional.of(OidcConfig.UserInfo.builder().path(Optional.of(path)).build())) + .build()); + + List errors = validator.validate(gateway, List.of(), topologyWith()); + + assertHasError(errors, "/oidc/user_info/path", "must be an absolute gateway path"); + } + + @ParameterizedTest(name = "off-path login value \"{0}\" is rejected") + @ValueSource(strings = {"login", "//evil.example.com", "https://evil.example.com"}) + @DisplayName("Should reject an off-path login value") + void shouldRejectOffPathLoginValue(String path) { + GatewayConfig gateway = gatewayWithOidc(OidcConfig.builder() + .login(Optional.of(OidcConfig.Login.builder().path(Optional.of(path)).build())) + .build()); + + List errors = validator.validate(gateway, List.of(), topologyWith()); + + assertHasError(errors, "/oidc/login/path", "must be an absolute gateway path"); + } + + @Test + @DisplayName("Should accept an absolute login path") + void shouldAcceptAbsoluteLoginPath() { + GatewayConfig gateway = gatewayWithOidc(OidcConfig.builder() + .login(Optional.of(OidcConfig.Login.builder().path(Optional.of("/session/login")).build())) + .build()); + + List errors = validator.validate(gateway, List.of(), topologyWith()); + + assertTrue(errors.isEmpty(), () -> "expected no violations, got: " + errors); + } + + @ParameterizedTest(name = "max_sessions = {0} is rejected") + @ValueSource(ints = {0, -1, -1000}) + @DisplayName("Should reject a non-positive session max_sessions bound") + void shouldRejectNonPositiveMaxSessions(int maxSessions) { + GatewayConfig gateway = gatewayWithOidc(OidcConfig.builder() + .session(Optional.of(OidcConfig.Session.builder().maxSessions(Optional.of(maxSessions)).build())) + .build()); + + List errors = validator.validate(gateway, List.of(), topologyWith()); + + assertHasError(errors, "/oidc/session/max_sessions", "must be a positive integer"); + } + + @Test + @DisplayName("Should accept a positive session max_sessions bound") + void shouldAcceptPositiveMaxSessions() { + GatewayConfig gateway = gatewayWithOidc(OidcConfig.builder() + .session(Optional.of(OidcConfig.Session.builder().maxSessions(Optional.of(10000)).build())) + .build()); + + List errors = validator.validate(gateway, List.of(), topologyWith()); + + assertTrue(errors.isEmpty(), () -> "expected no violations, got: " + errors); + } + } } From 375977ee75e3fba6f3591e6c873d8514ba9286fd Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:57:23 +0200 Subject: [PATCH 02/39] feat(bff): add pending-authorization record, store, and browser-binding cookie Introduce the D2b transient pre-login state under a new bff.pending package. PendingAuthorizationRecord wraps the engine's FlowContext (state/nonce/PKCE/ 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. PendingAuthorizationStore is the pluggable storage seam with a bounded, single-use, TTL-expiring in-memory implementation (oldest-eviction DoS guard). BindingCookieCodec sets and reads the short-lived __Host- browser-binding cookie so a callback replayed in a different browser is rejected even with a valid state. Unit tests cover single-use consumption, TTL expiry, bounded eviction, return-URL same-origin validation, and cross-browser rejection. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Af6Ah5SR43Ug7eG9HN1Yyo --- .../bff/pending/BindingCookieCodec.java | 102 ++++++++ .../pending/PendingAuthorizationRecord.java | 169 ++++++++++++ .../pending/PendingAuthorizationStore.java | 123 +++++++++ .../gateway/bff/pending/package-info.java | 43 ++++ .../bff/pending/BindingCookieCodecTest.java | 100 ++++++++ .../PendingAuthorizationStoreTest.java | 240 ++++++++++++++++++ 6 files changed, 777 insertions(+) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/BindingCookieCodec.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationRecord.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStore.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/package-info.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/BindingCookieCodecTest.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStoreTest.java 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..2310005d --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationRecord.java @@ -0,0 +1,169 @@ +/* + * 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 org.jspecify.annotations.Nullable; + +import de.cuioss.sheriff.token.client.flow.FlowContext; + +import lombok.Builder; + +/** + * 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 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; + } + if (returnUrl.startsWith("/")) { + return true; + } + try { + return sameOriginAbsolute(URI.create(returnUrl), URI.create(gatewayOrigin)); + } catch (IllegalArgumentException unparseable) { + 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..72ea0ab2 --- /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 record the record to store + */ + void store(PendingAuthorizationRecord record); + + /** + * 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 record) { + Objects.requireNonNull(record, "record"); + records.put(record.id(), record); + evictOldestBeyondCapacity(); + } + + @Override + public synchronized Optional consume(String recordId, Instant now) { + Objects.requireNonNull(recordId, "recordId"); + Objects.requireNonNull(now, "now"); + PendingAuthorizationRecord record = records.remove(recordId); + if (record == null || record.isExpired(now)) { + return Optional.empty(); + } + return Optional.of(record); + } + + /** + * @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/test/java/de/cuioss/sheriff/gateway/bff/pending/BindingCookieCodecTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/BindingCookieCodecTest.java new file mode 100644 index 00000000..e170b027 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/BindingCookieCodecTest.java @@ -0,0 +1,100 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.Optional; + +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 BindingCookieCodec}: the {@code __Host-} hardening of the emitted + * {@code Set-Cookie} header, the clearing form, and the request-header parse that reads back the + * bound record id (returning empty whenever the binding cookie is absent or empty-valued — the + * request-side half of the cross-browser callback rejection). + */ +class BindingCookieCodecTest { + + private final BindingCookieCodec codec = new BindingCookieCodec(Duration.ofMinutes(5)); + + @Nested + @DisplayName("Set-Cookie hardening") + class SetCookie { + + @Test + @DisplayName("Should emit a __Host- prefixed, Secure, HttpOnly, SameSite=Lax, Path=/ cookie") + void shouldEmitHardenedCookie() { + String header = codec.toSetCookieHeader("record-123"); + + assertTrue(header.startsWith("__Host-sheriff-binding=record-123"), header); + assertTrue(header.contains("; Path=/"), header); + assertTrue(header.contains("; Secure"), header); + assertTrue(header.contains("; HttpOnly"), header); + assertTrue(header.contains("; SameSite=Lax"), header); + assertTrue(header.contains("; Max-Age=300"), header); + } + + @Test + @DisplayName("Should clear the cookie with Max-Age=0 while keeping the __Host- attributes") + void shouldClearCookie() { + String header = codec.toClearingSetCookieHeader(); + + assertTrue(header.startsWith("__Host-sheriff-binding="), header); + assertTrue(header.contains("; Max-Age=0"), header); + assertTrue(header.contains("; Path=/"), header); + assertTrue(header.contains("; Secure"), header); + assertTrue(header.contains("; HttpOnly"), header); + } + } + + @Nested + @DisplayName("Reading the binding record id") + class ReadRecordId { + + @Test + @DisplayName("Should read the record id from a Cookie header carrying the binding cookie among others") + void shouldReadRecordIdFromCookieHeader() { + String cookieHeader = "other=1; __Host-sheriff-binding=abc123; last=z"; + assertEquals(Optional.of("abc123"), codec.readRecordId(cookieHeader)); + } + + @Test + @DisplayName("Should read the record id when the binding cookie is the sole cookie") + void shouldReadSoleBindingCookie() { + assertEquals(Optional.of("xyz"), codec.readRecordId("__Host-sheriff-binding=xyz")); + } + + @ParameterizedTest(name = "cookie header \"{0}\" yields no binding record id") + @ValueSource(strings = {"", " ", "session=abc", "__Host-sheriff-binding=", "notbinding=__Host-sheriff-binding"}) + @DisplayName("Should return empty when the binding cookie is absent or empty-valued") + void shouldReturnEmptyWhenAbsentOrEmpty(String cookieHeader) { + assertTrue(codec.readRecordId(cookieHeader).isEmpty()); + } + + @Test + @DisplayName("Should return empty for a null Cookie header (no binding cookie sent)") + void shouldReturnEmptyForNullHeader() { + assertTrue(codec.readRecordId(null).isEmpty()); + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStoreTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStoreTest.java new file mode 100644 index 00000000..2b63eeb9 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStoreTest.java @@ -0,0 +1,240 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +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; + +import de.cuioss.sheriff.token.client.flow.FlowContext; + +/** + * Tests for the D2b pending-authorization primitives: the bounded single-use + * {@link PendingAuthorizationStore.InMemory}, the {@link PendingAuthorizationRecord} contract + * (single-use enforcement, TTL expiry, same-origin return-URL validation), and the + * security-critical cross-browser callback rejection (a valid {@code state} presented in a + * browser without the matching binding cookie is refused). + */ +class PendingAuthorizationStoreTest { + + private static final Instant T0 = Instant.parse("2026-07-23T10:00:00Z"); + private static final String GATEWAY_ORIGIN = "https://gw.example.com"; + + private static FlowContext flow() { + return FlowContext.create(GATEWAY_ORIGIN + "/callback"); + } + + private static PendingAuthorizationRecord record(String returnUrl, Instant createdAt) { + return PendingAuthorizationRecord.create(flow(), returnUrl, createdAt); + } + + @Nested + @DisplayName("Bounded single-use in-memory store") + class InMemoryStore { + + @Test + @DisplayName("Should resolve a stored record exactly once (single-use consumption)") + void shouldConsumeStoredRecordExactlyOnce() { + PendingAuthorizationStore.InMemory store = new PendingAuthorizationStore.InMemory(16); + PendingAuthorizationRecord stored = record("/app", T0); + store.store(stored); + + Optional first = store.consume(stored.id(), T0); + Optional second = store.consume(stored.id(), T0); + + assertTrue(first.isPresent(), "the first consumption resolves the record"); + assertEquals(stored.id(), first.get().id()); + assertTrue(second.isEmpty(), "single-use: the second consumption is refused"); + } + + @Test + @DisplayName("Should refuse a record consumed after its TTL has elapsed") + void shouldRefuseExpiredRecord() { + PendingAuthorizationStore.InMemory store = new PendingAuthorizationStore.InMemory(16); + PendingAuthorizationRecord stored = record("/app", T0); + store.store(stored); + + Instant afterTtl = T0.plus(PendingAuthorizationRecord.FIXED_TTL).plusSeconds(1); + assertTrue(store.consume(stored.id(), afterTtl).isEmpty(), "an expired record is refused"); + } + + @Test + @DisplayName("Should resolve a record consumed within its TTL") + void shouldResolveRecordWithinTtl() { + PendingAuthorizationStore.InMemory store = new PendingAuthorizationStore.InMemory(16); + PendingAuthorizationRecord stored = record("/app", T0); + store.store(stored); + + assertTrue(store.consume(stored.id(), T0.plusSeconds(1)).isPresent()); + } + + @Test + @DisplayName("Should evict the oldest record once the capacity bound is exceeded") + void shouldEvictOldestBeyondCapacity() { + PendingAuthorizationStore.InMemory store = new PendingAuthorizationStore.InMemory(2); + PendingAuthorizationRecord first = record("/a", T0); + PendingAuthorizationRecord second = record("/b", T0); + PendingAuthorizationRecord third = record("/c", T0); + store.store(first); + store.store(second); + store.store(third); + + assertEquals(2, store.size(), "the store never exceeds its capacity bound"); + assertTrue(store.consume(first.id(), T0).isEmpty(), "the oldest record was evicted"); + assertTrue(store.consume(second.id(), T0).isPresent()); + assertTrue(store.consume(third.id(), T0).isPresent()); + } + + @Test + @DisplayName("Should return empty for an unknown record id") + void shouldReturnEmptyForUnknownId() { + PendingAuthorizationStore.InMemory store = new PendingAuthorizationStore.InMemory(4); + assertTrue(store.consume("nonexistent", T0).isEmpty()); + } + + @Test + @DisplayName("Should reject a non-positive capacity bound") + void shouldRejectNonPositiveCapacity() { + assertThrows(IllegalArgumentException.class, () -> new PendingAuthorizationStore.InMemory(0)); + assertThrows(IllegalArgumentException.class, () -> new PendingAuthorizationStore.InMemory(-1)); + } + } + + @Nested + @DisplayName("Cross-browser callback rejection (binding cookie required)") + class CrossBrowserRejection { + + @Test + @DisplayName("Should refuse a callback that carries no binding cookie even with a stored record") + void shouldRefuseCallbackWithoutBindingCookie() { + PendingAuthorizationStore.InMemory store = new PendingAuthorizationStore.InMemory(8); + BindingCookieCodec codec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL); + PendingAuthorizationRecord stored = record("/app", T0); + store.store(stored); + + // A different browser presents a valid 'state' but carries no binding cookie. + Optional recordId = codec.readRecordId(null); + assertTrue(recordId.isEmpty(), "no binding cookie -> no record id -> the callback resolves no record"); + + // The record is untouched — only the originating browser can consume it. + assertTrue(store.consume(stored.id(), T0).isPresent(), + "the cookie-less cross-browser callback never consumed the record"); + } + + @Test + @DisplayName("Should resolve the record for the originating browser that carries the binding cookie") + void shouldResolveForOriginatingBrowser() { + PendingAuthorizationStore.InMemory store = new PendingAuthorizationStore.InMemory(8); + BindingCookieCodec codec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL); + PendingAuthorizationRecord stored = record("/app", T0); + store.store(stored); + + String cookieHeader = codec.toSetCookieHeader(stored.id()).split(";", 2)[0]; + Optional recordId = codec.readRecordId(cookieHeader); + + assertTrue(recordId.isPresent()); + assertTrue(store.consume(recordId.get(), T0).isPresent(), + "the originating browser's binding cookie resolves the record"); + } + } + + @Nested + @DisplayName("Record contract") + class RecordContract { + + @Test + @DisplayName("Should generate a distinct, non-blank unguessable id per record") + void shouldGenerateDistinctIds() { + assertNotEquals(PendingAuthorizationRecord.newId(), PendingAuthorizationRecord.newId()); + assertFalse(PendingAuthorizationRecord.newId().isBlank()); + } + + @Test + @DisplayName("Should wrap the engine FlowContext and expose the gateway fields") + void shouldWrapFlowContextAndGatewayFields() { + FlowContext flow = flow(); + PendingAuthorizationRecord record = PendingAuthorizationRecord.create(flow, "/dashboard", T0); + + assertSame(flow, record.flowContext(), "the record wraps the engine DTO, never re-invents it"); + assertEquals("/dashboard", record.returnUrl()); + assertEquals(T0, record.createdAt()); + assertEquals(PendingAuthorizationRecord.FIXED_TTL, record.ttl()); + assertEquals(T0.plus(PendingAuthorizationRecord.FIXED_TTL), record.expiresAt()); + } + + @Test + @DisplayName("Should treat the TTL boundary as expired (inclusive)") + void shouldTreatTtlBoundaryAsExpired() { + PendingAuthorizationRecord record = PendingAuthorizationRecord.create(flow(), "/app", T0); + assertFalse(record.isExpired(record.expiresAt().minusNanos(1))); + assertTrue(record.isExpired(record.expiresAt()), "expiry is inclusive of the boundary"); + } + + @Test + @DisplayName("Should reject a null mandatory component") + void shouldRejectNullComponents() { + Duration ttl = Duration.ofMinutes(1); + FlowContext flow = flow(); + assertThrows(NullPointerException.class, + () -> new PendingAuthorizationRecord(null, flow, "/a", T0, ttl)); + assertThrows(NullPointerException.class, + () -> new PendingAuthorizationRecord("id", null, "/a", T0, ttl)); + assertThrows(NullPointerException.class, + () -> new PendingAuthorizationRecord("id", flow, null, T0, ttl)); + } + } + + @Nested + @DisplayName("Return-URL same-origin validation") + class ReturnUrlValidation { + + @ParameterizedTest(name = "same-origin return URL \"{0}\" is accepted") + @ValueSource(strings = {"/", "/dashboard", "/app/page?x=1", "https://gw.example.com/app", + "https://gw.example.com:443/app", "HTTPS://GW.EXAMPLE.COM/app"}) + @DisplayName("Should accept a gateway-relative path or a same-origin absolute URL") + void shouldAcceptSameOrigin(String returnUrl) { + assertTrue(PendingAuthorizationRecord.sameOrigin(returnUrl, GATEWAY_ORIGIN)); + } + + @ParameterizedTest(name = "off-origin return URL \"{0}\" is rejected") + @ValueSource(strings = {"//evil.example.com", "https://evil.example.com/app", + "https://gw.example.com:8443/app", "http://gw.example.com/app", "javascript:alert(1)"}) + @DisplayName("Should reject a schema-relative, cross-origin, or non-http(s) return URL") + void shouldRejectOffOrigin(String returnUrl) { + assertFalse(PendingAuthorizationRecord.sameOrigin(returnUrl, GATEWAY_ORIGIN)); + } + + @Test + @DisplayName("Should reject a null or blank return URL") + void shouldRejectNullOrBlank() { + assertFalse(PendingAuthorizationRecord.sameOrigin(null, GATEWAY_ORIGIN)); + assertFalse(PendingAuthorizationRecord.sameOrigin(" ", GATEWAY_ORIGIN)); + } + } +} From 14cd765086aee8c95f384a2796779c9d860e480b Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:08:51 +0200 Subject: [PATCH 03/39] feat(bff): add server-side session store and opaque session cookie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the D3 server-session package. SessionRecord holds the mediated access/refresh/ID tokens plus session metadata (expiry, acr, auth_time, sid, sub); token material never leaves the server and every credential — including the bearer session id — is redacted from toString(). SessionStore is the store contract; InMemorySessionStore is the only implementation, keyed by opaque id with secondary indexes by sid/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. SessionCookieCodec sets and reads the hardened __Host- session cookie carrying only the opaque id. Unit tests cover create/resolve/destroy, lazy and swept TTL eviction, O(1) back-channel lookup, the capacity bound, and cookie attribute assertions. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Af6Ah5SR43Ug7eG9HN1Yyo --- .../bff/session/InMemorySessionStore.java | 162 +++++++++++ .../bff/session/SessionCookieCodec.java | 109 ++++++++ .../gateway/bff/session/SessionRecord.java | 110 ++++++++ .../gateway/bff/session/SessionStore.java | 87 ++++++ .../gateway/bff/session/package-info.java | 42 +++ .../bff/session/InMemorySessionStoreTest.java | 255 ++++++++++++++++++ .../bff/session/SessionCookieCodecTest.java | 114 ++++++++ 7 files changed, 879 insertions(+) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/InMemorySessionStore.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionCookieCodec.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionRecord.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionStore.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/package-info.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/InMemorySessionStoreTest.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/SessionCookieCodecTest.java 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..b49e26b4 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/InMemorySessionStore.java @@ -0,0 +1,162 @@ +/* + * 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 record) { + Objects.requireNonNull(record, "record"); + if (byId.size() >= maxSessions) { + throw new IllegalStateException("session store is at its max-session bound of " + maxSessions); + } + byId.put(record.sessionId(), record); + index(bySub, record.sub(), record.sessionId()); + record.sid().ifPresent(sid -> index(bySid, sid, record.sessionId())); + } + + @Override + public synchronized Optional resolve(String sessionId, Instant now) { + Objects.requireNonNull(sessionId, "sessionId"); + Objects.requireNonNull(now, "now"); + SessionRecord record = byId.get(sessionId); + if (record == null) { + return Optional.empty(); + } + if (record.isExpired(now)) { + removeInternal(sessionId); + return Optional.empty(); + } + return Optional.of(record); + } + + @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(); + } + + 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 record = byId.remove(sessionId); + if (record == null) { + return; + } + deindex(bySub, record.sub(), sessionId); + record.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..adef17fe --- /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..2337f333 --- /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 record the session to store + * @throws IllegalStateException when the store is at its max-session capacity bound + */ + void create(SessionRecord record); + + /** + * 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/test/java/de/cuioss/sheriff/gateway/bff/session/InMemorySessionStoreTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/InMemorySessionStoreTest.java new file mode 100644 index 00000000..4375cd00 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/InMemorySessionStoreTest.java @@ -0,0 +1,255 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.session; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +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 org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Tests for the D3 server-side session store: {@link SessionRecord} (credential redaction, TTL, + * normalization) and {@link InMemorySessionStore} (create/resolve/destroy, lazy + swept TTL + * eviction, O(1) back-channel destruction by {@code sid}/{@code sub}, and the max-session bound). + */ +class InMemorySessionStoreTest { + + private static final Instant T0 = Instant.parse("2026-07-23T10:00:00Z"); + private static final Instant FUTURE = T0.plusSeconds(3600); + + private static SessionRecord session(String sessionId, String sub, Optional sid, Instant expiresAt) { + return SessionRecord.builder() + .sessionId(sessionId) + .accessToken("access-" + sessionId) + .refreshToken(Optional.of("refresh-" + sessionId)) + .idToken("id-" + sessionId) + .sub(sub) + .sid(sid) + .expiresAt(expiresAt) + .acr(Optional.of("urn:acr:silver")) + .authTime(Optional.of(T0)) + .build(); + } + + @Nested + @DisplayName("Create / resolve / destroy") + class Lifecycle { + + @Test + @DisplayName("Should resolve a created session and drop it after destroy-by-id") + void shouldCreateResolveDestroy() { + InMemorySessionStore store = new InMemorySessionStore(16); + store.create(session("s1", "sub1", Optional.of("sid1"), FUTURE)); + + assertTrue(store.resolve("s1", T0).isPresent()); + store.destroyById("s1"); + assertTrue(store.resolve("s1", T0).isEmpty(), "a destroyed session no longer resolves"); + } + + @Test + @DisplayName("Should return empty for an unknown session id") + void shouldReturnEmptyForUnknownId() { + InMemorySessionStore store = new InMemorySessionStore(16); + assertTrue(store.resolve("nope", T0).isEmpty()); + } + + @Test + @DisplayName("Should treat destroy-by-id of an absent session as a no-op") + void shouldNoOpDestroyAbsent() { + InMemorySessionStore store = new InMemorySessionStore(16); + store.destroyById("absent"); + assertEquals(0, store.size()); + } + } + + @Nested + @DisplayName("Absolute TTL eviction (lazy + swept)") + class TtlEviction { + + @Test + @DisplayName("Should evict an expired session lazily on resolve") + void shouldEvictExpiredLazily() { + InMemorySessionStore store = new InMemorySessionStore(16); + store.create(session("s1", "sub1", Optional.empty(), T0.plusSeconds(10))); + + assertTrue(store.resolve("s1", T0.plusSeconds(11)).isEmpty(), "an expired session is refused"); + assertEquals(0, store.size(), "the expired session was evicted lazily on resolve"); + } + + @Test + @DisplayName("Should treat the TTL boundary as expired (inclusive)") + void shouldTreatBoundaryAsExpired() { + InMemorySessionStore store = new InMemorySessionStore(16); + Instant expiry = T0.plusSeconds(10); + store.create(session("s1", "sub1", Optional.empty(), expiry)); + + assertTrue(store.resolve("s1", expiry).isEmpty(), "expiry is inclusive of the boundary"); + } + + @Test + @DisplayName("Should sweep every expired session and leave live ones untouched") + void shouldSweepExpired() { + InMemorySessionStore store = new InMemorySessionStore(16); + store.create(session("dead1", "sub1", Optional.empty(), T0.plusSeconds(5))); + store.create(session("dead2", "sub2", Optional.empty(), T0.plusSeconds(5))); + store.create(session("live", "sub3", Optional.empty(), FUTURE)); + + int swept = store.sweepExpired(T0.plusSeconds(10)); + + assertEquals(2, swept, "both expired sessions were swept"); + assertEquals(1, store.size()); + assertTrue(store.resolve("live", T0.plusSeconds(10)).isPresent()); + } + } + + @Nested + @DisplayName("O(1) back-channel destruction") + class BackChannel { + + @Test + @DisplayName("Should destroy every session carrying the given sid") + void shouldDestroyBySid() { + InMemorySessionStore store = new InMemorySessionStore(16); + store.create(session("s1", "sub1", Optional.of("A"), FUTURE)); + store.create(session("s2", "sub2", Optional.of("A"), FUTURE)); + store.create(session("s3", "sub3", Optional.of("B"), FUTURE)); + + assertEquals(2, store.destroyBySid("A")); + assertTrue(store.resolve("s1", T0).isEmpty()); + assertTrue(store.resolve("s2", T0).isEmpty()); + assertTrue(store.resolve("s3", T0).isPresent(), "an unrelated sid is untouched"); + } + + @Test + @DisplayName("Should destroy every session for the given subject") + void shouldDestroyBySub() { + InMemorySessionStore store = new InMemorySessionStore(16); + store.create(session("s1", "user-x", Optional.empty(), FUTURE)); + store.create(session("s2", "user-x", Optional.empty(), FUTURE)); + store.create(session("s3", "user-y", Optional.empty(), FUTURE)); + + assertEquals(2, store.destroyBySub("user-x")); + assertTrue(store.resolve("s3", T0).isPresent(), "an unrelated subject is untouched"); + } + + @Test + @DisplayName("Should report zero and clean the index when the sid is already gone") + void shouldReturnZeroForUnknownSidAndCleanIndex() { + InMemorySessionStore store = new InMemorySessionStore(16); + store.create(session("s1", "sub1", Optional.of("A"), FUTURE)); + + assertEquals(1, store.destroyBySid("A")); + assertEquals(0, store.destroyBySid("A"), "the secondary index was cleaned after the destroy"); + assertEquals(0, store.destroyBySid("never-seen")); + } + } + + @Nested + @DisplayName("Max-session bound") + class Capacity { + + @Test + @DisplayName("Should refuse a session created beyond the max-session bound") + void shouldEnforceMaxBound() { + InMemorySessionStore store = new InMemorySessionStore(2); + store.create(session("s1", "sub1", Optional.empty(), FUTURE)); + store.create(session("s2", "sub2", Optional.empty(), FUTURE)); + + SessionRecord overflow = session("s3", "sub3", Optional.empty(), FUTURE); + assertThrows(IllegalStateException.class, () -> store.create(overflow)); + } + + @Test + @DisplayName("Should free capacity once expired sessions are swept") + void shouldFreeCapacityAfterSweep() { + InMemorySessionStore store = new InMemorySessionStore(2); + store.create(session("s1", "sub1", Optional.empty(), T0.plusSeconds(5))); + store.create(session("s2", "sub2", Optional.empty(), FUTURE)); + + store.sweepExpired(T0.plusSeconds(10)); + store.create(session("s3", "sub3", Optional.empty(), FUTURE)); + + assertEquals(2, store.size()); + } + + @Test + @DisplayName("Should reject a non-positive capacity bound") + void shouldRejectNonPositiveCapacity() { + assertThrows(IllegalArgumentException.class, () -> new InMemorySessionStore(0)); + assertThrows(IllegalArgumentException.class, () -> new InMemorySessionStore(-1)); + } + } + + @Nested + @DisplayName("Session record contract") + class RecordContract { + + @Test + @DisplayName("Should generate distinct opaque session ids") + void shouldGenerateDistinctIds() { + assertNotEquals(SessionRecord.newSessionId(), SessionRecord.newSessionId()); + assertFalse(SessionRecord.newSessionId().isBlank()); + } + + @Test + @DisplayName("Should redact the session id and every token in toString, keeping sub visible") + void shouldRedactCredentialsInToString() { + SessionRecord record = SessionRecord.builder() + .sessionId("SID-SECRET") + .accessToken("AT-SECRET") + .refreshToken(Optional.of("RT-SECRET")) + .idToken("IT-SECRET") + .sub("user-123") + .sid(Optional.of("idp-sid")) + .expiresAt(FUTURE) + .build(); + + String rendered = record.toString(); + + assertFalse(rendered.contains("SID-SECRET"), "the bearer session id must be redacted"); + assertFalse(rendered.contains("AT-SECRET"), "the access token must be redacted"); + assertFalse(rendered.contains("RT-SECRET"), "the refresh token must be redacted"); + assertFalse(rendered.contains("IT-SECRET"), "the ID token must be redacted"); + assertTrue(rendered.contains("***REDACTED***")); + assertTrue(rendered.contains("user-123"), "the subject is non-secret metadata and stays visible"); + } + + @Test + @DisplayName("Should normalize absent optionals and reject null mandatory components") + void shouldNormalizeAndReject() { + SessionRecord sparse = new SessionRecord("s", "at", null, "it", "sub", null, FUTURE, null, null); + assertTrue(sparse.refreshToken().isEmpty()); + assertTrue(sparse.sid().isEmpty()); + assertTrue(sparse.acr().isEmpty()); + assertTrue(sparse.authTime().isEmpty()); + + assertThrows(NullPointerException.class, + () -> new SessionRecord(null, "at", Optional.empty(), "it", "sub", Optional.empty(), FUTURE, + Optional.empty(), Optional.empty())); + assertThrows(NullPointerException.class, + () -> new SessionRecord("s", "at", Optional.empty(), "it", null, Optional.empty(), FUTURE, + Optional.empty(), Optional.empty())); + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/SessionCookieCodecTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/SessionCookieCodecTest.java new file mode 100644 index 00000000..b01c497e --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/SessionCookieCodecTest.java @@ -0,0 +1,114 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.session; + +import 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.Duration; +import java.util.Optional; + +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 SessionCookieCodec}: the {@code __Host-} hardening of the opaque session + * cookie, its clearing form, the operator-configurable cookie name, and the request-header parse + * that reads back the opaque session id (never any token material). + */ +class SessionCookieCodecTest { + + private final SessionCookieCodec codec = + new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, Duration.ofHours(1)); + + @Nested + @DisplayName("Set-Cookie hardening") + class SetCookie { + + @Test + @DisplayName("Should emit a __Host- prefixed, Secure, HttpOnly, SameSite=Lax, Path=/ cookie carrying only the id") + void shouldEmitHardenedCookie() { + String header = codec.toSetCookieHeader("opaque-id"); + + assertTrue(header.startsWith("__Host-sheriff-session=opaque-id"), header); + assertTrue(header.contains("; Path=/"), header); + assertTrue(header.contains("; Secure"), header); + assertTrue(header.contains("; HttpOnly"), header); + assertTrue(header.contains("; SameSite=Lax"), header); + assertTrue(header.contains("; Max-Age=3600"), header); + } + + @Test + @DisplayName("Should clear the cookie with Max-Age=0 keeping the __Host- attributes") + void shouldClearCookie() { + String header = codec.toClearingSetCookieHeader(); + + assertTrue(header.startsWith("__Host-sheriff-session="), header); + assertTrue(header.contains("; Max-Age=0"), header); + assertTrue(header.contains("; Path=/"), header); + assertTrue(header.contains("; Secure"), header); + assertTrue(header.contains("; HttpOnly"), header); + } + + @Test + @DisplayName("Should honour an operator-configured cookie name") + void shouldHonourConfiguredName() { + SessionCookieCodec custom = new SessionCookieCodec("__Host-my-session", Duration.ofMinutes(30)); + assertTrue(custom.toSetCookieHeader("x").startsWith("__Host-my-session=x")); + } + } + + @Nested + @DisplayName("Reading the session id") + class ReadSessionId { + + @Test + @DisplayName("Should read the session id from a Cookie header carrying the session cookie among others") + void shouldReadSessionIdFromCookieHeader() { + String cookieHeader = "csrf=1; __Host-sheriff-session=opaque-id; last=z"; + assertEquals(Optional.of("opaque-id"), codec.readSessionId(cookieHeader)); + } + + @ParameterizedTest(name = "cookie header \"{0}\" yields no session id") + @ValueSource(strings = {"", " ", "other=abc", "__Host-sheriff-session="}) + @DisplayName("Should return empty when the session cookie is absent or empty-valued") + void shouldReturnEmptyWhenAbsentOrEmpty(String cookieHeader) { + assertTrue(codec.readSessionId(cookieHeader).isEmpty()); + } + + @Test + @DisplayName("Should return empty for a null Cookie header") + void shouldReturnEmptyForNullHeader() { + assertTrue(codec.readSessionId(null).isEmpty()); + } + } + + @Nested + @DisplayName("Construction") + class Construction { + + @Test + @DisplayName("Should reject a null or blank cookie name") + void shouldRejectBlankName() { + assertThrows(NullPointerException.class, () -> new SessionCookieCodec(null, Duration.ofHours(1))); + assertThrows(IllegalArgumentException.class, () -> new SessionCookieCodec(" ", Duration.ofHours(1))); + } + } +} From e1499d819447eefc97bf7eceab722fc54b7bd9be Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:40:24 +0200 Subject: [PATCH 04/39] feat(bff): add reserved-path registry and OIDC callback endpoint Carve the gateway's reserved OIDC paths out of the proxy route table (D2): ReservedPathRegistry does exact-match, OIDC-host-only resolution consulted ahead of route selection in GatewayEdgeRoute, so a proxy route such as path_prefix: /auth never swallows /auth/callback. CallbackEndpoint parses the raw query via CallbackParameters.parse (BFF-13 duplicate-parameter defence), resolves the browser-bound pending record via state + binding cookie, drives the engine code exchange through a CodeExchange seam, creates the server session, sets the opaque session cookie, clears the single-use binding cookie, and redirects to the recorded return URL. The seam keeps the endpoint decoupled from the deferred confidential-client producer wiring (session runtime) and unit-testable without a live token endpoint. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Af6Ah5SR43Ug7eG9HN1Yyo --- .../bff/reserved/CallbackEndpoint.java | 308 ++++++++++++++++++ .../bff/reserved/ReservedPathRegistry.java | 171 ++++++++++ .../gateway/bff/reserved/package-info.java | 42 +++ .../gateway/edge/GatewayEdgeRoute.java | 17 + .../bff/reserved/CallbackEndpointTest.java | 279 ++++++++++++++++ .../reserved/ReservedPathRegistryTest.java | 146 +++++++++ 6 files changed, 963 insertions(+) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistry.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/package-info.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistryTest.java 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..59197559 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java @@ -0,0 +1,308 @@ +/* + * 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 org.jspecify.annotations.Nullable; + +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; + +/** + * 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 query + * 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. + *

+ * Callback HPP defence (BFF-13). The endpoint parses the raw query 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. + *

+ * 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 rawQuery the raw callback query string (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 rawQuery, @Nullable String cookieHeader, Instant now) { + Objects.requireNonNull(rawQuery, "rawQuery"); + Objects.requireNonNull(now, "now"); + + CallbackParameters params; + try { + params = CallbackParameters.parse(rawQuery); + } 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 record = resolved.get(); + + if (!constantTimeEquals(record.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(record.flowContext(), params); + return completeLogin(result, record, 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 record, 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(record.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 notNumeric) { + return Optional.empty(); + } + }); + } + + private static boolean isBlank(@Nullable String value) { + return value == null || value.isBlank(); + } + + private static boolean constantTimeEquals(String expected, String actual) { + 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/ReservedPathRegistry.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistry.java new file mode 100644 index 00000000..66000ee5 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistry.java @@ -0,0 +1,171 @@ +/* + * 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 org.jspecify.annotations.Nullable; + +import de.cuioss.sheriff.gateway.config.model.OidcConfig; + +/** + * The exact-match registry of the gateway's reserved OIDC endpoints (D2). + *

+ * The BFF variants carve four 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, and the {@code oidc.logout.backchannel_path} + * receiver. 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 + } + + 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)); + 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 == null || 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 unparseable) { + 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/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/edge/GatewayEdgeRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java index d7a43136..b35d1866 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 @@ -44,6 +44,7 @@ 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.config.model.ForwardedConfig; import de.cuioss.sheriff.gateway.config.model.GatewayConfig; import de.cuioss.sheriff.gateway.config.model.HttpMethod; @@ -157,6 +158,7 @@ public class GatewayEdgeRoute { private final GatewayEventCounter gatewayEventCounter; private final UpstreamFailureMapper upstreamFailureMapper; private final SheriffMetrics sheriffMetrics; + private final ReservedPathRegistry reservedPathRegistry; private final SecurityHeadersStage securityHeadersStage; private final BasicChecksStage basicChecksStage; @@ -249,6 +251,12 @@ public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig, // security-filter counts surface as sheriff_security_events_total, completing the fixed // five-meter contract alongside the request/duration/error/upstream meters recorded above. sheriffMetrics.bindSecurityEventCounter(securityEventCounter); + + // 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. + this.reservedPathRegistry = ReservedPathRegistry.from(gatewayConfig.oidc()); } /** @@ -388,6 +396,15 @@ private void process(RoutingContext ctx) { return; } passthroughHostGuardStage.process(request); + // Reserved-path carve-out (D2): a reserved OIDC endpoint is resolved here, ahead of the + // route table, so a proxy route such as path_prefix: /auth can never swallow the exact + // /auth/callback. The reserved-endpoint handlers are wired by the session runtime; until + // then a matched reserved path is carved out of proxy dispatch (never proxied to an upstream). + if (reservedPathRegistry.isReserved(request.host(), requireCanonicalPath(request))) { + LOGGER.debug("Reserved gateway path carved out of proxy dispatch: %s", requireCanonicalPath(request)); + renderProblem(ctx, request, EventType.NO_ROUTE_MATCHED); + return; + } routeSelectionStage.process(request); verbGateStage.process(request); RouteRuntime route = requireSelectedRoute(request); diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java new file mode 100644 index 00000000..3be9703b --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java @@ -0,0 +1,279 @@ +/* + * 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 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.util.HashMap; +import java.util.Map; +import java.util.Optional; + +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 de.cuioss.sheriff.gateway.bff.pending.BindingCookieCodec; +import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord; +import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationStore; +import de.cuioss.sheriff.gateway.bff.reserved.CallbackEndpoint.CallbackOutcome; +import de.cuioss.sheriff.gateway.bff.reserved.CallbackEndpoint.CodeExchange; +import de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore; +import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec; +import de.cuioss.sheriff.gateway.bff.session.SessionRecord; +import de.cuioss.sheriff.token.client.flow.AuthorizationCodeFlow; +import de.cuioss.sheriff.token.client.flow.FlowContext; +import de.cuioss.sheriff.token.commons.error.ClientProtocolException; +import de.cuioss.sheriff.token.validation.domain.claim.ClaimName; +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; + +/** + * Tests for {@link CallbackEndpoint}: the OIDC auth-code callback orchestration — the BFF-13 + * duplicate-parameter rejection (via {@code parse(rawQuery)}), the browser-binding checks (D2b), + * and the success path that drives the exchange seam, creates the session, and redirects. + *

+ * The engine exchange is driven through the {@link CodeExchange} seam, so the success and + * exchange-failure paths are exercised with a hand-built {@link AuthorizationCodeFlow.AuthenticationResult} + * — no live token endpoint, no signed tokens, no test double framework. + */ +class CallbackEndpointTest { + + private static final Instant T0 = Instant.parse("2026-07-23T10:00:00Z"); + private static final Duration SESSION_TTL = Duration.ofHours(8); + private static final String RETURN_URL = "/dashboard"; + private static final String SUBJECT = "user-sub-1"; + private static final String RAW_ACCESS_TOKEN = "raw-access-token"; + private static final String RAW_ID_TOKEN = "raw-id-token"; + private static final String IDP_SID = "idp-sid-9"; + + private PendingAuthorizationStore.InMemory pendingStore; + private BindingCookieCodec bindingCodec; + private InMemorySessionStore sessionStore; + private SessionCookieCodec sessionCodec; + private CallbackEndpoint endpoint; + + private String state; + private String recordId; + private String bindingCookieHeader; + + @BeforeEach + void setUp() { + pendingStore = new PendingAuthorizationStore.InMemory(8); + bindingCodec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL); + sessionStore = new InMemorySessionStore(16); + sessionCodec = new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, SESSION_TTL); + endpoint = new CallbackEndpoint(successfulExchange(), pendingStore, bindingCodec, sessionStore, sessionCodec, + SESSION_TTL); + + FlowContext flow = FlowContext.create("https://gw.example.com/auth/callback"); + state = flow.state(); + PendingAuthorizationRecord record = PendingAuthorizationRecord.create(flow, RETURN_URL, T0); + pendingStore.store(record); + recordId = record.id(); + bindingCookieHeader = bindingCodec.toSetCookieHeader(recordId).split(";", 2)[0]; + } + + private static CodeExchange successfulExchange() { + Map accessClaims = new HashMap<>(); + accessClaims.put(ClaimName.SUBJECT.getName(), ClaimValue.forPlainString(SUBJECT)); + AccessTokenContent access = new AccessTokenContent(accessClaims, RAW_ACCESS_TOKEN); + + Map idClaims = new HashMap<>(); + idClaims.put(ClaimName.SUBJECT.getName(), ClaimValue.forPlainString(SUBJECT)); + idClaims.put("sid", ClaimValue.forPlainString(IDP_SID)); + idClaims.put("acr", ClaimValue.forPlainString("urn:mace:incommon:iap:silver")); + idClaims.put("auth_time", ClaimValue.forPlainString("1721730000")); + IdTokenContent id = new IdTokenContent(idClaims, RAW_ID_TOKEN); + + AuthorizationCodeFlow.AuthenticationResult result = new AuthorizationCodeFlow.AuthenticationResult(access, id); + return (context, params) -> result; + } + + @Nested + @DisplayName("BFF-13 duplicate-parameter rejection (raw parse)") + class DuplicateParameterRejection { + + @Test + @DisplayName("Should reject a duplicate-code callback 400 without consuming the pending record") + void shouldRejectDuplicateCode() { + CallbackOutcome outcome = endpoint.handle("code=first&code=second&state=" + state, bindingCookieHeader, T0); + + assertEquals(400, outcome.status(), "a duplicated code is rejected by parse(rawQuery)"); + assertFalse(outcome.isRedirect()); + assertTrue(pendingStore.consume(recordId, T0).isPresent(), + "the record is untouched — parse fails before binding resolution"); + } + + @Test + @DisplayName("Should reject a duplicate-state callback 400") + void shouldRejectDuplicateState() { + CallbackOutcome outcome = endpoint.handle("code=abc&state=" + state + "&state=other", bindingCookieHeader, + T0); + + assertEquals(400, outcome.status()); + } + } + + @Nested + @DisplayName("Browser-binding checks (D2b)") + class BrowserBinding { + + @Test + @DisplayName("Should reject a callback that carries no binding cookie 403 (cross-browser replay)") + void shouldRejectWithoutBindingCookie() { + CallbackOutcome outcome = endpoint.handle("code=abc&state=" + state, null, T0); + + assertEquals(403, outcome.status()); + assertTrue(pendingStore.consume(recordId, T0).isPresent(), "the record was never resolved"); + } + + @Test + @DisplayName("Should reject a binding cookie that resolves no live record 403") + void shouldRejectUnknownRecord() { + String foreignCookie = bindingCodec.toSetCookieHeader("nonexistent-id").split(";", 2)[0]; + + CallbackOutcome outcome = endpoint.handle("code=abc&state=" + state, foreignCookie, T0); + + assertEquals(403, outcome.status()); + } + + @Test + @DisplayName("Should reject a returned state that does not match the bound record 403") + void shouldRejectStateMismatch() { + CallbackOutcome outcome = endpoint.handle("code=abc&state=not-the-bound-state", bindingCookieHeader, T0); + + assertEquals(403, outcome.status()); + } + + @Test + @DisplayName("Should reject an expired pending record 403") + void shouldRejectExpiredRecord() { + Instant afterTtl = T0.plus(PendingAuthorizationRecord.FIXED_TTL).plusSeconds(1); + + CallbackOutcome outcome = endpoint.handle("code=abc&state=" + state, bindingCookieHeader, afterTtl); + + assertEquals(403, outcome.status()); + } + } + + @Nested + @DisplayName("IdP error and exchange failure") + class FailurePaths { + + @Test + @DisplayName("Should reject an IdP error response 400") + void shouldRejectIdpError() { + CallbackOutcome outcome = endpoint.handle("error=access_denied&error_description=nope&state=" + state, + bindingCookieHeader, T0); + + assertEquals(400, outcome.status()); + } + + @Test + @DisplayName("Should reject a missing state parameter 400") + void shouldRejectMissingState() { + CallbackOutcome outcome = endpoint.handle("code=abc", bindingCookieHeader, T0); + + assertEquals(400, outcome.status()); + } + + @Test + @DisplayName("Should map an engine exchange failure to 400") + void shouldMapExchangeFailure() { + CodeExchange failing = (context, params) -> { + throw new ClientProtocolException("token endpoint rejected the code"); + }; + CallbackEndpoint failingEndpoint = new CallbackEndpoint(failing, pendingStore, bindingCodec, sessionStore, + sessionCodec, SESSION_TTL); + + CallbackOutcome outcome = failingEndpoint.handle("code=abc&state=" + state, bindingCookieHeader, T0); + + assertEquals(400, outcome.status()); + } + } + + @Nested + @DisplayName("Successful login") + class SuccessfulLogin { + + @Test + @DisplayName("Should create a session, set the cookies, and redirect to the return URL") + void shouldCompleteLogin() { + CallbackOutcome outcome = endpoint.handle("code=auth-code&state=" + state, bindingCookieHeader, T0); + + assertTrue(outcome.isRedirect(), "a successful login is a 302 redirect"); + assertEquals(302, outcome.status()); + assertEquals(Optional.of(RETURN_URL), outcome.location()); + assertEquals(2, outcome.setCookieHeaders().size(), "one session cookie + one binding-clearing cookie"); + + String sessionSetCookie = outcome.setCookieHeaders().get(0); + assertTrue(sessionSetCookie.startsWith(SessionCookieCodec.DEFAULT_COOKIE_NAME + "="), sessionSetCookie); + String bindingClear = outcome.setCookieHeaders().get(1); + assertTrue(bindingClear.contains(BindingCookieCodec.COOKIE_NAME + "="), bindingClear); + assertTrue(bindingClear.contains("Max-Age=0"), "the single-use binding cookie is cleared"); + } + + @Test + @DisplayName("Should store the mediated token material and identity claims under the session id") + void shouldStoreSession() { + CallbackOutcome outcome = endpoint.handle("code=auth-code&state=" + state, bindingCookieHeader, T0); + + String sessionId = sessionCodec.readSessionId(outcome.setCookieHeaders().get(0)).orElseThrow(); + Optional session = sessionStore.resolve(sessionId, T0); + + assertTrue(session.isPresent(), "the session was created under the opaque id from the cookie"); + SessionRecord record = session.get(); + assertEquals(RAW_ACCESS_TOKEN, record.accessToken()); + assertEquals(RAW_ID_TOKEN, record.idToken()); + assertEquals(SUBJECT, record.sub()); + assertEquals(Optional.of(IDP_SID), record.sid()); + assertEquals(T0.plus(SESSION_TTL), record.expiresAt(), "the session TTL is absolute from login"); + } + + @Test + @DisplayName("Should consume the pending record exactly once (single-use)") + void shouldConsumePendingRecordOnce() { + endpoint.handle("code=auth-code&state=" + state, bindingCookieHeader, T0); + + assertTrue(pendingStore.consume(recordId, T0).isEmpty(), "the pending record was consumed by the callback"); + } + } + + @Nested + @DisplayName("Argument contract") + class ArgumentContract { + + @Test + @DisplayName("Should reject a null raw query") + void shouldRejectNullRawQuery() { + assertThrows(NullPointerException.class, () -> endpoint.handle(null, bindingCookieHeader, T0)); + } + + @Test + @DisplayName("Should reject a null reference instant") + void shouldRejectNullNow() { + assertThrows(NullPointerException.class, () -> endpoint.handle("code=abc&state=" + state, bindingCookieHeader, + null)); + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistryTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistryTest.java new file mode 100644 index 00000000..f48d1860 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistryTest.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.reserved; + +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.util.Optional; + +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; + +import de.cuioss.sheriff.gateway.bff.reserved.ReservedPathRegistry.ReservedEndpoint; +import de.cuioss.sheriff.gateway.config.model.OidcConfig; + +/** + * Tests for {@link ReservedPathRegistry}: the exact-match, OIDC-host-only carve-out (D2) that + * guarantees a proxy route such as {@code path_prefix: /auth} never swallows the exact + * {@code /auth/callback}, plus the empty registry when no OIDC callback is configured. + */ +class ReservedPathRegistryTest { + + private static final String OIDC_HOST = "gw.example.com"; + private static final String CALLBACK_PATH = "/auth/callback"; + private static final String LOGOUT_PATH = "/auth/logout"; + private static final String LOGOUT_RETURN_PATH = "/auth/logout/return"; + private static final String BACKCHANNEL_PATH = "/auth/backchannel"; + + private static ReservedPathRegistry fullyConfigured() { + OidcConfig.Logout logout = OidcConfig.Logout.builder() + .path(Optional.of(LOGOUT_PATH)) + .postLogoutRedirectUri(Optional.of("https://" + OIDC_HOST + LOGOUT_RETURN_PATH)) + .backchannelPath(Optional.of(BACKCHANNEL_PATH)) + .build(); + OidcConfig oidc = OidcConfig.builder() + .redirectUri(Optional.of("https://" + OIDC_HOST + CALLBACK_PATH)) + .logout(Optional.of(logout)) + .build(); + return ReservedPathRegistry.from(Optional.of(oidc)); + } + + @Nested + @DisplayName("Exact-match carve-out (a /auth proxy route never swallows /auth/callback)") + class ExactMatch { + + private final ReservedPathRegistry registry = fullyConfigured(); + + @Test + @DisplayName("Should resolve the exact callback path on the OIDC host") + void shouldResolveExactCallback() { + assertEquals(Optional.of(ReservedEndpoint.CALLBACK), registry.match(OIDC_HOST, CALLBACK_PATH)); + assertTrue(registry.isReserved(OIDC_HOST, CALLBACK_PATH)); + } + + @ParameterizedTest(name = "reserved path \"{0}\" is matched exactly") + @ValueSource(strings = {LOGOUT_PATH, LOGOUT_RETURN_PATH, BACKCHANNEL_PATH}) + @DisplayName("Should resolve each configured reserved path exactly") + void shouldResolveEachReservedPath(String path) { + assertTrue(registry.isReserved(OIDC_HOST, path), path); + } + + @ParameterizedTest(name = "prefix/sibling path \"{0}\" is not reserved") + @ValueSource(strings = {"/auth", "/auth/", "/auth/callbacks", "/auth/callback/extra", "/auth/other", + "/authcallback", "/"}) + @DisplayName("Should not match a prefix, sibling, or extended path — the exact carve-out") + void shouldNotMatchPrefixOrSibling(String path) { + assertFalse(registry.isReserved(OIDC_HOST, path), path); + assertTrue(registry.match(OIDC_HOST, path).isEmpty(), path); + } + } + + @Nested + @DisplayName("OIDC-host-only matching") + class HostScoping { + + private final ReservedPathRegistry registry = fullyConfigured(); + + @Test + @DisplayName("Should not match the reserved path on a foreign host") + void shouldNotMatchForeignHost() { + assertTrue(registry.match("app.example.com", CALLBACK_PATH).isEmpty()); + assertFalse(registry.isReserved("other.host", CALLBACK_PATH)); + } + + @Test + @DisplayName("Should match the OIDC host case-insensitively") + void shouldMatchHostCaseInsensitively() { + assertEquals(Optional.of(ReservedEndpoint.CALLBACK), registry.match("GW.EXAMPLE.COM", CALLBACK_PATH)); + } + + @Test + @DisplayName("Should not match when the request host or path is absent") + void shouldNotMatchNullHostOrPath() { + assertTrue(registry.match(null, CALLBACK_PATH).isEmpty()); + assertTrue(registry.match(OIDC_HOST, null).isEmpty()); + } + } + + @Nested + @DisplayName("Empty registry when no OIDC callback is configured") + class EmptyRegistry { + + @Test + @DisplayName("Should be empty and never match when no oidc block is present") + void shouldBeEmptyWithoutOidc() { + ReservedPathRegistry registry = ReservedPathRegistry.from(Optional.empty()); + assertTrue(registry.isEmpty()); + assertFalse(registry.isReserved(OIDC_HOST, CALLBACK_PATH)); + } + + @Test + @DisplayName("Should be empty when the oidc block declares no redirect_uri (no OIDC host to bind to)") + void shouldBeEmptyWithoutRedirectUri() { + OidcConfig oidc = OidcConfig.builder() + .logout(Optional.of(OidcConfig.Logout.builder().path(Optional.of(LOGOUT_PATH)).build())) + .build(); + ReservedPathRegistry registry = ReservedPathRegistry.from(Optional.of(oidc)); + assertTrue(registry.isEmpty(), "no redirect_uri -> no OIDC host -> no reserved paths"); + assertFalse(registry.isReserved(OIDC_HOST, LOGOUT_PATH)); + } + + @Test + @DisplayName("Should reject a null oidc argument") + void shouldRejectNullArgument() { + assertThrows(NullPointerException.class, () -> ReservedPathRegistry.from(null)); + } + } +} From 4f313c00ded89b430a51ed22005f16552590d0f4 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:47:32 +0200 Subject: [PATCH 05/39] feat(bff): add confidential-client login initiation flow LoginFlow orchestrates the browser-facing start of the OIDC auth-code flow (D1/D2/D2b): it drives the engine to build the authorization URL and transaction FlowContext (PKCE/state/nonce owned by the engine, no OAuth leg re-implemented), persists the context as a single-use pending-authorization record, sets the __Host- browser-binding cookie, and redirects to the IdP. The requested post-login return URL is same-origin-validated (falling back to / for a cross-origin or unparseable target) so the eventual redirect is never an open redirect. The engine authorization is reached through an AuthorizationInitiation seam, mirroring the callback endpoint, keeping the flow decoupled from the deferred discovery-metadata wiring and unit-testable without a live IdP. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Af6Ah5SR43Ug7eG9HN1Yyo --- .../sheriff/gateway/bff/login/LoginFlow.java | 141 +++++++++++++++++ .../gateway/bff/login/package-info.java | 37 +++++ .../gateway/bff/login/LoginFlowTest.java | 145 ++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/LoginFlow.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/package-info.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/login/LoginFlowTest.java 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..66388bc5 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/LoginFlow.java @@ -0,0 +1,141 @@ +/* + * 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 org.jspecify.annotations.Nullable; + +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; + +/** + * 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 = PendingAuthorizationRecord.sameOrigin(requestedReturnUrl, gatewayOrigin) + ? requestedReturnUrl : DEFAULT_RETURN_URL; + PendingAuthorizationRecord record = PendingAuthorizationRecord.create(redirect.context(), returnUrl, now); + pendingStore.store(record); + LOGGER.debug("Initiated OIDC login; pending record persisted, redirecting to the IdP"); + List setCookies = List.of(bindingCookieCodec.toSetCookieHeader(record.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/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..a1ab1099 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/login/LoginFlowTest.java @@ -0,0 +1,145 @@ +/* + * 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 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; + +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; + +/** + * 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 record = consumeBoundRecord(result); + assertSame(flowContext, record.flowContext(), "the record wraps the engine context, never re-invents it"); + assertEquals("/dashboard", record.returnUrl()); + assertEquals(T0, record.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)); + } + } +} From b563c32d3e479bfb7a15e0cd28500aeb639a506c Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:23:04 +0200 Subject: [PATCH 06/39] feat(gateway): implement require:session stage-4 runtime (D4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the SessionAuthenticationStage server-session runtime — the counterpart of the offline bearer validation — and remove the boot-time rejection that RouteRuntimeAssembler used to raise for require:session routes. - SessionAuthenticationStage resolves the opaque __Host- session cookie to a live session, offers it to the single-flight refresh seam (D9 hook), enforces required_scopes against the mediated token (403 SCOPE_MISSING), and records the mediated access token for automatic upstream Authorization: Bearer injection. Unauthenticated navigation is redirected 302 into the auth-code flow; anything else is challenged 401 application/problem+json. - AuthenticationStage dispatches require:session routes to the wired session runtime; an unwired route is a boot-configuration IllegalStateException. - RouteRuntimeAssembler compiles require:session routes like any other route. - ForwardPolicyStage renders the mediated bearer last so it wins over any allow-listed inbound Authorization; PipelineRequest carries the mediated bearer server-side. - Tests: new SessionAuthenticationStageTest; RouteRuntimeAssemblerTest, AuthenticationStageTest, and GatewayEdgeRouteTest migrated off the removed boot-time session rejection. --- .../gateway/auth/AuthenticationStage.java | 45 ++- .../runtime/SessionAuthenticationStage.java | 257 +++++++++++++++ .../gateway/bff/runtime/package-info.java | 37 +++ .../gateway/edge/RouteRuntimeAssembler.java | 20 +- .../gateway/forward/ForwardPolicyStage.java | 15 + .../gateway/pipeline/PipelineRequest.java | 22 ++ .../gateway/auth/AuthenticationStageTest.java | 83 ++++- .../SessionAuthenticationStageTest.java | 299 ++++++++++++++++++ .../gateway/edge/GatewayEdgeRouteTest.java | 34 +- .../edge/RouteRuntimeAssemblerTest.java | 34 +- 10 files changed, 791 insertions(+), 55 deletions(-) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStage.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/package-info.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStageTest.java 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/runtime/SessionAuthenticationStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStage.java new file mode 100644 index 00000000..3043f30f --- /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: + *

      + *
    1. resolves the opaque {@code __Host-} session cookie to a live {@link SessionRecord} through + * the {@link SessionStore} (an expired / unknown session is treated as unauthenticated);
    2. + *
    3. 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);
    4. + *
    5. 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);
    6. + *
    7. 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.
    8. + *
    + * 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(request, 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(PipelineRequest request, 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/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/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/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/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/runtime/SessionAuthenticationStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStageTest.java new file mode 100644 index 00000000..a85152bd --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStageTest.java @@ -0,0 +1,299 @@ +/* + * 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 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 java.util.Optional; + + +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.bff.session.SessionStore; +import de.cuioss.sheriff.gateway.config.model.AuthConfig; +import de.cuioss.sheriff.gateway.config.model.HttpMethod; +import de.cuioss.sheriff.gateway.events.EventType; +import de.cuioss.sheriff.gateway.events.GatewayException; +import de.cuioss.sheriff.gateway.pipeline.PipelineRequest; +import de.cuioss.sheriff.gateway.routing.RouteRuntime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("SessionAuthenticationStage — stage 4 require:session runtime") +class SessionAuthenticationStageTest { + + private static final Instant NOW = Instant.parse("2026-07-23T10:00:00Z"); + private static final Instant SESSION_EXPIRY = NOW.plusSeconds(3600); + 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"; + private static final String REFRESHED_TOKEN = "refreshed-access-token"; + private static final String REQUIRED_SCOPE = "orders:read"; + private static final String LOGIN_LOCATION = "https://idp.example/authorize?client_id=sheriff"; + private static final String BINDING_COOKIE = "__Host-sheriff-binding=binding-value; Path=/; Secure; HttpOnly; SameSite=Lax"; + + private static final SessionCookieCodec CODEC = + new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, Duration.ofHours(1)); + + @Nested + @DisplayName("Live session") + class LiveSession { + + @Test + @DisplayName("injects the mediated access token as the upstream bearer for a live session") + void injectsMediatedBearer() { + SessionStore store = storeWith(session(MEDIATED_TOKEN)); + SessionAuthenticationStage stage = stage(store, identityRefresh(), scopesGranted(), redirectLogin()); + PipelineRequest request = sessionRequest(authConfig(List.of()), navigationHeaders()); + + assertDoesNotThrow(() -> stage.process(request)); + + assertEquals(Optional.of(MEDIATED_TOKEN), request.mediatedBearer(), + "the live session's mediated token is recorded for automatic upstream injection"); + assertTrue(request.shortCircuitStatus().isEmpty(), "an authenticated request flows on, never short-circuits"); + } + + @Test + @DisplayName("injects the refreshed token when the single-flight refresh seam rotates it near expiry") + void injectsRefreshedTokenAfterRefresh() { + SessionStore store = storeWith(session(MEDIATED_TOKEN)); + SessionAuthenticationStage.TokenRefresh rotating = + (session, now) -> rebind(session, REFRESHED_TOKEN); + SessionAuthenticationStage stage = stage(store, rotating, scopesGranted(), redirectLogin()); + PipelineRequest request = sessionRequest(authConfig(List.of()), navigationHeaders()); + + stage.process(request); + + assertEquals(Optional.of(REFRESHED_TOKEN), request.mediatedBearer(), + "the token injected is the one the refresh seam returned, not the pre-refresh token"); + } + + @Test + @DisplayName("passes and injects the bearer when the mediated token grants every required scope") + void passesWhenRequiredScopesSatisfied() { + SessionStore store = storeWith(session(MEDIATED_TOKEN)); + SessionAuthenticationStage stage = stage(store, identityRefresh(), scopesGranted(), redirectLogin()); + PipelineRequest request = sessionRequest(authConfig(List.of(REQUIRED_SCOPE)), navigationHeaders()); + + assertDoesNotThrow(() -> stage.process(request)); + + assertEquals(Optional.of(MEDIATED_TOKEN), request.mediatedBearer(), + "a scope-satisfying session still mediates its bearer"); + } + } + + @Nested + @DisplayName("Scope enforcement") + class ScopeEnforcement { + + @Test + @DisplayName("rejects 403 SCOPE_MISSING when the mediated token lacks a required scope") + void rejectsMissingScopeWith403() { + SessionStore store = storeWith(session(MEDIATED_TOKEN)); + SessionAuthenticationStage stage = stage(store, identityRefresh(), scopesDenied(), redirectLogin()); + PipelineRequest request = sessionRequest(authConfig(List.of(REQUIRED_SCOPE)), navigationHeaders()); + + GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request)); + + assertEquals(EventType.SCOPE_MISSING, thrown.getEventType(), "a scope shortfall is a 403 SCOPE_MISSING"); + assertTrue(request.mediatedBearer().isEmpty(), "no bearer is mediated when a required scope is missing"); + } + } + + @Nested + @DisplayName("Unauthenticated request") + class Unauthenticated { + + @Test + @DisplayName("redirects a navigation request 302 into the auth-code flow") + void redirectsNavigationIntoLogin() { + SessionAuthenticationStage stage = stage(emptyStore(), identityRefresh(), scopesGranted(), redirectLogin()); + PipelineRequest request = sessionRequest(authConfig(List.of()), navigationHeaders()); + + assertDoesNotThrow(() -> stage.process(request)); + + assertEquals(Optional.of(302), request.shortCircuitStatus(), "an unauthenticated navigation is short-circuited 302"); + assertEquals(LOGIN_LOCATION, request.responseHeaders().get("Location"), + "the redirect targets the login-initiation location"); + assertEquals(BINDING_COOKIE, request.responseHeaders().get("Set-Cookie"), + "the browser-binding Set-Cookie is emitted with the redirect"); + assertTrue(request.mediatedBearer().isEmpty(), "an unauthenticated request mediates no bearer"); + } + + @Test + @DisplayName("challenges an XHR request 401 TOKEN_MISSING rather than redirecting") + void challengesXhrWith401() { + SessionAuthenticationStage stage = stage(emptyStore(), identityRefresh(), scopesGranted(), redirectLogin()); + PipelineRequest request = sessionRequest(authConfig(List.of()), xhrHeaders()); + + GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request)); + + assertEquals(EventType.TOKEN_MISSING, thrown.getEventType(), + "an unauthenticated non-navigation request is a 401 application/problem+json challenge"); + assertTrue(request.shortCircuitStatus().isEmpty(), "the XHR challenge does not short-circuit into a redirect"); + } + + @Test + @DisplayName("treats an expired session as unauthenticated") + void treatsExpiredSessionAsUnauthenticated() { + SessionStore store = storeWith(session(MEDIATED_TOKEN, NOW.minusSeconds(1))); + SessionAuthenticationStage stage = stage(store, identityRefresh(), scopesGranted(), redirectLogin()); + PipelineRequest request = sessionRequest(authConfig(List.of()), xhrHeaders()); + + GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request)); + + assertEquals(EventType.TOKEN_MISSING, thrown.getEventType(), "an expired session no longer authenticates"); + } + + @Test + @DisplayName("treats a request without a session cookie as unauthenticated") + void treatsAbsentCookieAsUnauthenticated() { + SessionAuthenticationStage stage = stage(storeWith(session(MEDIATED_TOKEN)), identityRefresh(), + scopesGranted(), redirectLogin()); + PipelineRequest request = sessionRequest(authConfig(List.of()), Map.of("accept", List.of("application/json"))); + + GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request)); + + assertEquals(EventType.TOKEN_MISSING, thrown.getEventType(), "a request carrying no session cookie is unauthenticated"); + } + } + + @Nested + @DisplayName("Preconditions") + class Preconditions { + + @Test + @DisplayName("rejects a request whose route was not selected at stage 2") + void rejectsUnselectedRoute() { + SessionAuthenticationStage stage = stage(emptyStore(), identityRefresh(), scopesGranted(), redirectLogin()); + PipelineRequest request = PipelineRequest.builder() + .method(HttpMethod.GET).requestPath("/app").queryParameters(Map.of()).headers(Map.of()).build(); + + assertThrows(IllegalStateException.class, () -> stage.process(request)); + } + + @Test + @DisplayName("rejects a null request") + void rejectsNullRequest() { + SessionAuthenticationStage stage = stage(emptyStore(), identityRefresh(), scopesGranted(), redirectLogin()); + + assertThrows(NullPointerException.class, () -> stage.process(null)); + } + } + + private static SessionAuthenticationStage stage(SessionStore store, SessionAuthenticationStage.TokenRefresh refresh, + SessionAuthenticationStage.GrantedScopes scopes, SessionAuthenticationStage.LoginInitiation login) { + return new SessionAuthenticationStage(store, CODEC, refresh, scopes, login, CLOCK); + } + + private static SessionAuthenticationStage.TokenRefresh identityRefresh() { + return (session, now) -> session; + } + + private static SessionAuthenticationStage.GrantedScopes scopesGranted() { + return (accessToken, requiredScopes) -> true; + } + + private static SessionAuthenticationStage.GrantedScopes scopesDenied() { + return (accessToken, requiredScopes) -> false; + } + + private static SessionAuthenticationStage.LoginInitiation redirectLogin() { + return (returnUrl, now) -> new LoginChallenge(LOGIN_LOCATION, List.of(BINDING_COOKIE)); + } + + private static SessionStore emptyStore() { + return new InMemorySessionStore(16); + } + + private static SessionStore storeWith(SessionRecord record) { + InMemorySessionStore store = new InMemorySessionStore(16); + store.create(record); + return store; + } + + private static SessionRecord session(String accessToken) { + return session(accessToken, SESSION_EXPIRY); + } + + private static SessionRecord session(String accessToken, Instant expiresAt) { + return SessionRecord.builder() + .sessionId(SESSION_ID) + .accessToken(accessToken) + .refreshToken(Optional.of("refresh-token")) + .idToken("id-token") + .sub("subject") + .sid(Optional.of("idp-sid")) + .expiresAt(expiresAt) + .build(); + } + + private static SessionRecord rebind(SessionRecord session, String accessToken) { + return SessionRecord.builder() + .sessionId(session.sessionId()) + .accessToken(accessToken) + .refreshToken(session.refreshToken()) + .idToken(session.idToken()) + .sub(session.sub()) + .sid(session.sid()) + .expiresAt(session.expiresAt()) + .acr(session.acr()) + .authTime(session.authTime()) + .build(); + } + + private static AuthConfig authConfig(List requiredScopes) { + return AuthConfig.builder().require("session").requiredScopes(requiredScopes).build(); + } + + private static Map> navigationHeaders() { + return Map.of("cookie", List.of(cookie()), "accept", List.of("text/html,application/xhtml+xml")); + } + + private static Map> xhrHeaders() { + return Map.of("cookie", List.of(cookie()), "accept", List.of("application/json")); + } + + private static String cookie() { + return SessionCookieCodec.DEFAULT_COOKIE_NAME + "=" + SESSION_ID; + } + + private static PipelineRequest sessionRequest(AuthConfig auth, Map> headers) { + PipelineRequest request = PipelineRequest.builder() + .method(HttpMethod.GET) + .requestPath("/app/orders") + .queryParameters(Map.of()) + .headers(headers) + .build(); + request.canonicalPath("/app/orders"); + request.selectedRoute(RouteRuntime.builder().id("orders").effectiveAuth(auth).build()); + return request; + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteTest.java index 793bf9a5..27936198 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteTest.java @@ -17,7 +17,6 @@ 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.assertTimeoutPreemptively; import java.lang.annotation.Annotation; @@ -37,8 +36,6 @@ import de.cuioss.sheriff.gateway.config.model.ResolvedRoute; import de.cuioss.sheriff.gateway.config.model.ResolvedUpstream; import de.cuioss.sheriff.gateway.config.model.RouteTable; -import de.cuioss.sheriff.gateway.events.EventType; -import de.cuioss.sheriff.gateway.events.GatewayException; import de.cuioss.sheriff.gateway.quarkus.SheriffMetrics; import de.cuioss.sheriff.token.validation.TokenValidator; import de.cuioss.sheriff.token.validation.test.generator.TestTokenGenerators; @@ -115,19 +112,17 @@ void registersCatchAllRoute() { } @Test - @DisplayName("fails boot fast when a route requires session authentication") - void failsBootForSessionAuth() { + @DisplayName("boots a require:session route now the boot-time rejection is removed (D4)") + void bootsSessionAuthRoute() { // Arrange RouteTable sessionTable = new RouteTable(List.of( route("s", Protocol.HTTP, "session"))); - // Act - GatewayException thrown = assertThrows(GatewayException.class, () -> newEdge(sessionTable), - "Session auth is not implemented and must fail boot"); - - // Assert - assertEquals(EventType.CONFIG_INVALID, thrown.getEventType(), - "A session-auth route is rejected as an invalid configuration"); + // Act + Assert — a require:session route now assembles at boot; its stage-4 runtime is the + // SessionAuthenticationStage (D4), which replaced the boot-time CONFIG_INVALID rejection. This + // edge wires no session runtime, so such a route is only rejected at request time, not at boot. + assertDoesNotThrow(() -> newEdge(sessionTable), + "A require:session route assembles at boot now the boot-time rejection is removed"); } @Test @@ -157,19 +152,16 @@ void bootsWebSocketProtocol() { } @Test - @DisplayName("fails boot fast for a session-auth WebSocket route (session unimplemented)") - void failsBootForSessionAuthWebSocket() { + @DisplayName("boots a session-auth WebSocket route now the boot-time rejection is removed (D4)") + void bootsSessionAuthWebSocketRoute() { // Arrange RouteTable webSocketTable = new RouteTable(List.of( route("w", Protocol.WEBSOCKET, "session"))); - // Act — session-auth WebSocket routes remain unimplemented until Plan 07 - GatewayException thrown = assertThrows(GatewayException.class, () -> newEdge(webSocketTable), - "Session-auth WebSocket routes are not yet implemented and must fail boot"); - - // Assert - assertEquals(EventType.CONFIG_INVALID, thrown.getEventType(), - "A session-auth WebSocket route is rejected as an invalid configuration"); + // Act + Assert — session auth no longer gates boot, so a session-auth WebSocket route + // assembles exactly like any other WebSocket route. + assertDoesNotThrow(() -> newEdge(webSocketTable), + "A session-auth WebSocket route assembles at boot now session auth no longer fails boot"); } @Test diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssemblerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssemblerTest.java index 1f151ad9..5b7a4fd0 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssemblerTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssemblerTest.java @@ -19,7 +19,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotSame; 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.nio.file.Path; @@ -44,8 +43,6 @@ import de.cuioss.sheriff.gateway.config.model.ResolvedUpstream; import de.cuioss.sheriff.gateway.config.model.RouteTable; import de.cuioss.sheriff.gateway.config.model.SecurityFilterConfig; -import de.cuioss.sheriff.gateway.events.EventType; -import de.cuioss.sheriff.gateway.events.GatewayException; import de.cuioss.sheriff.gateway.routing.ProtocolProcessorRegistry; import de.cuioss.sheriff.gateway.routing.RouteRuntime; @@ -152,25 +149,28 @@ void shouldPreserveRouteTableOrder() { } @Test - @DisplayName("Should fail boot for session auth and a session-auth WebSocket; gRPC and WebSocket assemble") - void shouldFailBootForSessionAndAssembleProtocolRoutes() { + @DisplayName("Should assemble a require:session route now the boot-time rejection is removed (D4)") + void shouldAssembleSessionRoutes() { + // A require:session route now assembles like any other route — its stage-4 runtime is the + // SessionAuthenticationStage (D4), which replaced the boot-time CONFIG_INVALID rejection. RouteTable sessionTable = new RouteTable(List.of( route("s", Protocol.HTTP, "session", Optional.empty(), upstream("a.example")))); - var session = assertThrows(GatewayException.class, + List sessionRuntimes = assertDoesNotThrow( () -> assembler.assemble(sessionTable, securityConfigFactory, clientFactory, guardFactory, assetSourceFactory), - "session auth must fail boot"); - RouteTable webSocketTable = new RouteTable(List.of( - route("sw", Protocol.WEBSOCKET, "session", Optional.empty(), upstream("a.example")))); - var sessionWebSocket = assertThrows(GatewayException.class, - () -> assembler.assemble(webSocketTable, securityConfigFactory, clientFactory, guardFactory, assetSourceFactory), - "session-auth WebSocket must fail boot"); + "a require:session route assembles now the boot-time rejection is removed"); + assertEquals("session", sessionRuntimes.getFirst().getEffectiveAuth().require(), + "the assembled route keeps its require:session posture for the stage-4 runtime to dispatch on"); - assertEquals(EventType.CONFIG_INVALID, session.getEventType(), "session rejection is a config failure"); - assertEquals(EventType.CONFIG_INVALID, sessionWebSocket.getEventType(), - "session-auth WebSocket rejection is a config failure"); + // A session-auth WebSocket route likewise assembles — session auth no longer gates boot, so + // it is treated exactly like any other WebSocket route. + RouteTable webSocketSessionTable = new RouteTable(List.of( + route("sw", Protocol.WEBSOCKET, "session", Optional.empty(), upstream("a.example")))); + assertDoesNotThrow( + () -> assembler.assemble(webSocketSessionTable, securityConfigFactory, clientFactory, guardFactory, assetSourceFactory), + "a session-auth WebSocket route assembles — session auth no longer fails boot"); - // A gRPC route now assembles cleanly (its boot rejection was removed when the gRPC processor - // was registered) — the forced-h2 upstream client is built by the injected client factory. + // A gRPC route with non-session auth assembles cleanly — the forced-h2 upstream client is + // built by the injected client factory. RouteTable grpcTable = new RouteTable(List.of( route("g", Protocol.GRPC, "none", Optional.empty(), upstream("a.example")))); assertDoesNotThrow( From 939fa4a7d926a6c70c86971e15cb901733530b77 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:11:52 +0200 Subject: [PATCH 07/39] feat(bff): add fixed CSRF defence for session routes Fixed, fail-closed CSRF defence for require:session routes (D7) with no disable knob: unsafe methods (everything except GET/HEAD/OPTIONS) must present a trusted Origin (exact full-origin match, scheme+host+port) or, when Origin is absent, a Sec-Fetch-Site: same-origin header; everything else is rejected 403 with a CSRF_REJECTED event. Parameterized tests cover the positive/negative matrix. Co-Authored-By: Claude --- .../sheriff/gateway/bff/csrf/CsrfDefence.java | 130 ++++++++++++++ .../gateway/bff/csrf/package-info.java | 34 ++++ .../gateway/bff/csrf/CsrfDefenceTest.java | 159 ++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/csrf/CsrfDefence.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/csrf/package-info.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/csrf/CsrfDefenceTest.java 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..ba7b1732 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/csrf/CsrfDefence.java @@ -0,0 +1,130 @@ +/* + * 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.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: + *

      + *
    1. 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
    2. + *
    3. when {@code Origin} is absent, a {@code Sec-Fetch-Site: same-origin} fetch-metadata header.
    4. + *
    + * 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 = Set.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/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..b551f025 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/csrf/CsrfDefenceTest.java @@ -0,0 +1,159 @@ +/* + * 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)); + } +} From 7c8006b4908a6ba38d9fc32121a0098530c448a3 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:04:35 +0200 Subject: [PATCH 08/39] feat(bff): add RP-initiated and back-channel logout (D8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement both OIDC logout channels for the BFF session mode: - RpInitiatedLogout orchestrates RP-initiated logout — best-effort RFC 7009 token revocation, session-bound state minted into the single-use __Host-sheriff-logout cookie, engine-owned end-session redirect (id_token_hint + exact-match post_logout_redirect_uri as the open-redirect defence), and constant-time state verification on the return leg before landing on final_redirect. - BackchannelLogoutReceiver + LogoutTokenValidator apply the full OIDC back-channel-logout-token claim residual (iss/aud, iat freshness replay guard, backchannel-logout event, nonce-absent, sub/sid present) on the engine-signature-verified token, then destroy affected sessions via the store's O(1) secondary index. - LogoutEndpoint and BackchannelLogoutEndpoint are the framework-agnostic request/response edges over the reserved logout paths. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Af6Ah5SR43Ug7eG9HN1Yyo --- .../bff/logout/BackchannelLogoutReceiver.java | 154 ++++++++++ .../bff/logout/LogoutTokenValidator.java | 178 +++++++++++ .../gateway/bff/logout/RpInitiatedLogout.java | 281 ++++++++++++++++++ .../gateway/bff/logout/package-info.java | 47 +++ .../reserved/BackchannelLogoutEndpoint.java | 158 ++++++++++ .../gateway/bff/reserved/LogoutEndpoint.java | 186 ++++++++++++ 6 files changed, 1004 insertions(+) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/BackchannelLogoutReceiver.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/LogoutTokenValidator.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/RpInitiatedLogout.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/package-info.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpoint.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/BackchannelLogoutReceiver.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/BackchannelLogoutReceiver.java 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..ca20a0c5 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/LogoutTokenValidator.java @@ -0,0 +1,178 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.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: + *

      + *
    1. {@code iss} equals the expected issuer,
    2. + *
    3. {@code aud} contains the expected audience (the confidential client id),
    4. + *
    5. {@code iat} is present and within a short freshness window (the replay guard, BFF-09),
    6. + *
    7. {@code events} contains the {@value #BACKCHANNEL_LOGOUT_EVENT} member,
    8. + *
    9. {@code nonce} is absent (a nonce is prohibited in a logout token),
    10. + *
    11. at least one of {@code sub} / {@code sid} is present.
    12. + *
    + * 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(events -> events.contains(BACKCHANNEL_LOGOUT_EVENT)).isEmpty()) { + LOGGER.debug("Back-channel logout token rejected — events claim missing the back-channel-logout event"); + 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)); + } + + 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/reserved/BackchannelLogoutEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpoint.java new file mode 100644 index 00000000..17497927 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpoint.java @@ -0,0 +1,158 @@ +/* + * 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; + } + if (LOGOUT_TOKEN_PARAM.equals(decode(pair.substring(0, equals)))) { + String value = decode(pair.substring(equals + 1)); + return value.isBlank() ? Optional.empty() : Optional.of(value); + } + } + return Optional.empty(); + } + + private static String decode(String value) { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } + + /** + * 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/LogoutEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java new file mode 100644 index 00000000..b4af838e --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java @@ -0,0 +1,186 @@ +/* + * 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 sessionId = sessionCookieCodec.readSessionId(cookieHeader); + Optional session = sessionId.flatMap(id -> sessionStore.resolve(id, now)); + if (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())); + } + + RpInitiatedLogout.LogoutRedirect redirect = rpInitiatedLogout.initiate(session.get()); + sessionStore.destroyById(sessionId.get()); + 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; + } + } +} From 019a1ca4d20de1985f4b5ae7fc860184a2d7610a Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:04:51 +0200 Subject: [PATCH 09/39] chore(quality): normalize import ordering in bff sources OpenRewrite import-order normalization applied by the pre-commit recipe during D8 verification, touching prior-deliverable bff sources and tests. No behavioral change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Af6Ah5SR43Ug7eG9HN1Yyo --- .../sheriff/gateway/bff/csrf/CsrfDefence.java | 4 +++- .../sheriff/gateway/bff/login/LoginFlow.java | 3 ++- .../pending/PendingAuthorizationRecord.java | 4 ++-- .../bff/reserved/CallbackEndpoint.java | 3 ++- .../bff/reserved/ReservedPathRegistry.java | 3 ++- .../gateway/bff/session/SessionRecord.java | 2 +- .../gateway/bff/csrf/CsrfDefenceTest.java | 3 ++- .../gateway/bff/login/LoginFlowTest.java | 13 ++++++----- .../PendingAuthorizationStoreTest.java | 5 ++-- .../bff/reserved/CallbackEndpointTest.java | 23 ++++++++++--------- .../reserved/ReservedPathRegistryTest.java | 7 +++--- 11 files changed, 40 insertions(+), 30 deletions(-) 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 index ba7b1732..aba3b93a 100644 --- 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 @@ -15,12 +15,14 @@ */ 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; @@ -64,7 +66,7 @@ public final class CsrfDefence { 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 = Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS); + 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"; 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 index 66388bc5..24d4f8cf 100644 --- 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 @@ -19,7 +19,6 @@ import java.util.List; import java.util.Objects; -import org.jspecify.annotations.Nullable; import de.cuioss.sheriff.gateway.bff.pending.BindingCookieCodec; import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord; @@ -27,6 +26,8 @@ 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}. 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 index 2310005d..b4f4d608 100644 --- 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 @@ -22,11 +22,11 @@ import java.util.Base64; import java.util.Objects; -import org.jspecify.annotations.Nullable; 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). @@ -56,7 +56,7 @@ */ @Builder public record PendingAuthorizationRecord(String id, FlowContext flowContext, String returnUrl, Instant createdAt, - Duration ttl) { +Duration ttl) { /** * The short fixed lifetime a pending-authorization record lives before it expires. Fixed diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java index 59197559..905299e3 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java @@ -23,7 +23,6 @@ import java.util.Objects; import java.util.Optional; -import org.jspecify.annotations.Nullable; import de.cuioss.sheriff.gateway.bff.pending.BindingCookieCodec; import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord; @@ -41,6 +40,8 @@ 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 query 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 index 66000ee5..d5471a2f 100644 --- 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 @@ -21,10 +21,11 @@ import java.util.Objects; import java.util.Optional; -import org.jspecify.annotations.Nullable; 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). *

    diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionRecord.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionRecord.java index adef17fe..f12d9a27 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionRecord.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionRecord.java @@ -51,7 +51,7 @@ */ @Builder public record SessionRecord(String sessionId, String accessToken, Optional refreshToken, String idToken, - String sub, Optional sid, Instant expiresAt, Optional acr, Optional authTime) { +String sub, Optional sid, Instant expiresAt, Optional acr, Optional authTime) { private static final String REDACTED = "***REDACTED***"; private static final SecureRandom SECURE_RANDOM = new SecureRandom(); 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 index b551f025..8bcab999 100644 --- 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 @@ -23,6 +23,7 @@ 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; @@ -147,7 +148,7 @@ void emptyTrustedSetIsFailClosedExceptSameOriginFetch() { 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")))), + Map.of(SEC_FETCH_SITE_HEADER, List.of("same-origin")))), "a same-origin fetch still passes with an empty trusted set"); } 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 index a1ab1099..45c14919 100644 --- 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 @@ -23,12 +23,6 @@ import java.time.Instant; import java.util.Optional; -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; import de.cuioss.sheriff.gateway.bff.login.LoginFlow.AuthorizationInitiation; import de.cuioss.sheriff.gateway.bff.login.LoginFlow.LoginRedirect; @@ -38,6 +32,13 @@ 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. diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStoreTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStoreTest.java index 2b63eeb9..e6bfd0f9 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStoreTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStoreTest.java @@ -26,14 +26,15 @@ import java.time.Instant; import java.util.Optional; + +import de.cuioss.sheriff.token.client.flow.FlowContext; + 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; -import de.cuioss.sheriff.token.client.flow.FlowContext; - /** * Tests for the D2b pending-authorization primitives: the bounded single-use * {@link PendingAuthorizationStore.InMemory}, the {@link PendingAuthorizationRecord} contract diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java index 3be9703b..4efc14f1 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java @@ -26,10 +26,6 @@ import java.util.Map; import java.util.Optional; -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 de.cuioss.sheriff.gateway.bff.pending.BindingCookieCodec; import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord; @@ -47,6 +43,11 @@ import de.cuioss.sheriff.token.validation.domain.token.AccessTokenContent; import de.cuioss.sheriff.token.validation.domain.token.IdTokenContent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + /** * Tests for {@link CallbackEndpoint}: the OIDC auth-code callback orchestration — the BFF-13 * duplicate-parameter rejection (via {@code parse(rawQuery)}), the browser-binding checks (D2b), @@ -98,11 +99,11 @@ private static CodeExchange successfulExchange() { accessClaims.put(ClaimName.SUBJECT.getName(), ClaimValue.forPlainString(SUBJECT)); AccessTokenContent access = new AccessTokenContent(accessClaims, RAW_ACCESS_TOKEN); - Map idClaims = new HashMap<>(); - idClaims.put(ClaimName.SUBJECT.getName(), ClaimValue.forPlainString(SUBJECT)); - idClaims.put("sid", ClaimValue.forPlainString(IDP_SID)); - idClaims.put("acr", ClaimValue.forPlainString("urn:mace:incommon:iap:silver")); - idClaims.put("auth_time", ClaimValue.forPlainString("1721730000")); + Map idClaims = new HashMap<>(Map.of( + ClaimName.SUBJECT.getName(), ClaimValue.forPlainString(SUBJECT), + "sid", ClaimValue.forPlainString(IDP_SID), + "acr", ClaimValue.forPlainString("urn:mace:incommon:iap:silver"), + "auth_time", ClaimValue.forPlainString("1721730000"))); IdTokenContent id = new IdTokenContent(idClaims, RAW_ID_TOKEN); AuthorizationCodeFlow.AuthenticationResult result = new AuthorizationCodeFlow.AuthenticationResult(access, id); @@ -226,7 +227,7 @@ void shouldCompleteLogin() { assertEquals(Optional.of(RETURN_URL), outcome.location()); assertEquals(2, outcome.setCookieHeaders().size(), "one session cookie + one binding-clearing cookie"); - String sessionSetCookie = outcome.setCookieHeaders().get(0); + String sessionSetCookie = outcome.setCookieHeaders().getFirst(); assertTrue(sessionSetCookie.startsWith(SessionCookieCodec.DEFAULT_COOKIE_NAME + "="), sessionSetCookie); String bindingClear = outcome.setCookieHeaders().get(1); assertTrue(bindingClear.contains(BindingCookieCodec.COOKIE_NAME + "="), bindingClear); @@ -238,7 +239,7 @@ void shouldCompleteLogin() { void shouldStoreSession() { CallbackOutcome outcome = endpoint.handle("code=auth-code&state=" + state, bindingCookieHeader, T0); - String sessionId = sessionCodec.readSessionId(outcome.setCookieHeaders().get(0)).orElseThrow(); + String sessionId = sessionCodec.readSessionId(outcome.setCookieHeaders().getFirst()).orElseThrow(); Optional session = sessionStore.resolve(sessionId, T0); assertTrue(session.isPresent(), "the session was created under the opaque id from the cookie"); diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistryTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistryTest.java index f48d1860..c290111a 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistryTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistryTest.java @@ -22,15 +22,16 @@ import java.util.Optional; + +import de.cuioss.sheriff.gateway.bff.reserved.ReservedPathRegistry.ReservedEndpoint; +import de.cuioss.sheriff.gateway.config.model.OidcConfig; + 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; -import de.cuioss.sheriff.gateway.bff.reserved.ReservedPathRegistry.ReservedEndpoint; -import de.cuioss.sheriff.gateway.config.model.OidcConfig; - /** * Tests for {@link ReservedPathRegistry}: the exact-match, OIDC-host-only carve-out (D2) that * guarantees a proxy route such as {@code path_prefix: /auth} never swallows the exact From fbae35d72f96042c4ab7bfed192e6cc0c7f51a29 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:20:16 +0200 Subject: [PATCH 10/39] test(bff): cover logout-token matrix and RP-initiated logout (D8) - LogoutTokenValidatorTest exercises the full fail-closed check matrix (iss/aud wrong, iat stale/future/absent, events missing or lacking the backchannel-logout event, nonce present, sub+sid absent) plus the sub-only / sid-only success shapes and plain-string vs array aud. - RpInitiatedLogoutTest drives the real engine EndSessionFlow + PostLogoutRedirectValidator: end-session redirect assembly, the single-use __Host-sheriff-logout state cookie, the state round-trip on the return leg, and the exact post_logout_redirect_uri open-redirect rejection. api-sheriff quality gate + full test suite green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Af6Ah5SR43Ug7eG9HN1Yyo --- .../bff/logout/LogoutTokenValidatorTest.java | 281 ++++++++++++++++++ .../bff/logout/RpInitiatedLogoutTest.java | 253 ++++++++++++++++ 2 files changed, 534 insertions(+) create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/logout/LogoutTokenValidatorTest.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/logout/RpInitiatedLogoutTest.java 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..6ede227f --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/logout/LogoutTokenValidatorTest.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 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() { + Map claims = 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))); + return claims; + } + + 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 claims = validClaims(); + claims.put("aud", ClaimValue.forList("aud", List.of("other-client", AUDIENCE))); + + assertTrue(validator.validate(token(claims), NOW).isPresent()); + } + } + + @Nested + @DisplayName("Rejected logout tokens (fail-closed matrix)") + class Rejected { + + @Test + @DisplayName("Should reject a token whose iss does not equal the expected issuer") + void shouldRejectWrongIssuer() { + Map claims = validClaims(); + claims.put("iss", ClaimValue.forPlainString("https://evil.example.com")); + + assertTrue(validator.validate(token(claims), NOW).isEmpty()); + } + + @Test + @DisplayName("Should reject a token whose aud does not contain the client id") + void shouldRejectWrongAudience() { + Map claims = validClaims(); + claims.put("aud", ClaimValue.forList("aud", List.of("some-other-client"))); + + assertTrue(validator.validate(token(claims), NOW).isEmpty()); + } + + @Test + @DisplayName("Should reject a token whose iat is older than the freshness window") + void shouldRejectStaleIat() { + Map claims = validClaims(); + Instant tooOld = NOW.minus(FRESHNESS).minusSeconds(1); + claims.put("iat", ClaimValue.forDateTime("iat", OffsetDateTime.ofInstant(tooOld, ZoneOffset.UTC))); + + assertTrue(validator.validate(token(claims), NOW).isEmpty()); + } + + @Test + @DisplayName("Should reject a token whose iat is further in the future than the freshness window") + void shouldRejectFutureIat() { + Map claims = validClaims(); + Instant tooNew = NOW.plus(FRESHNESS).plusSeconds(1); + claims.put("iat", ClaimValue.forDateTime("iat", OffsetDateTime.ofInstant(tooNew, ZoneOffset.UTC))); + + assertTrue(validator.validate(token(claims), NOW).isEmpty()); + } + + @Test + @DisplayName("Should reject a token that carries no iat") + void shouldRejectMissingIat() { + Map claims = validClaims(); + claims.remove("iat"); + + assertTrue(validator.validate(token(claims), NOW).isEmpty()); + } + + @Test + @DisplayName("Should reject a token whose events claim is absent") + void shouldRejectMissingEvents() { + Map claims = validClaims(); + claims.remove("events"); + + assertTrue(validator.validate(token(claims), NOW).isEmpty()); + } + + @Test + @DisplayName("Should reject a token whose events claim lacks the back-channel-logout event") + void shouldRejectEventsWithoutBackchannelEvent() { + Map claims = validClaims(); + claims.put("events", ClaimValue.forPlainString(OTHER_EVENT)); + + assertTrue(validator.validate(token(claims), NOW).isEmpty()); + } + + @Test + @DisplayName("Should reject a token that carries a nonce (prohibited in a logout token)") + void shouldRejectPresentNonce() { + Map claims = validClaims(); + claims.put("nonce", ClaimValue.forPlainString("n-0S6_WzA2Mj")); + + assertTrue(validator.validate(token(claims), NOW).isEmpty()); + } + + @Test + @DisplayName("Should reject a token that carries neither sub nor sid") + void shouldRejectMissingSubAndSid() { + Map claims = validClaims(); + claims.remove("sub"); + claims.remove("sid"); + + assertTrue(validator.validate(token(claims), NOW).isEmpty()); + } + + @Test + @DisplayName("Should reject a fresh valid token when now is far past its freshness window") + void shouldRejectWhenNowOutsideWindow() { + Instant farLater = NOW.plus(Duration.ofHours(1)); + + assertTrue(validator.validate(token(validClaims()), farLater).isEmpty(), + "a token minted at NOW is stale relative to a much later now"); + } + } + + @Nested + @DisplayName("Argument contract") + class ArgumentContract { + + @Test + @DisplayName("Should reject a null token") + void shouldRejectNullToken() { + assertThrows(NullPointerException.class, () -> validator.validate(null, NOW)); + } + + @Test + @DisplayName("Should reject a null reference instant") + void shouldRejectNullNow() { + assertThrows(NullPointerException.class, () -> validator.validate(token(validClaims()), null)); + } + + @Test + @DisplayName("Should reject null constructor arguments") + void shouldRejectNullConstructorArguments() { + assertAllReject(); + } + + private void assertAllReject() { + assertThrows(NullPointerException.class, () -> new LogoutTokenValidator(null, AUDIENCE, FRESHNESS)); + assertThrows(NullPointerException.class, () -> new LogoutTokenValidator(ISSUER, null, FRESHNESS)); + assertThrows(NullPointerException.class, () -> new LogoutTokenValidator(ISSUER, AUDIENCE, null)); + } + } + + @Nested + @DisplayName("LogoutSubject record") + class LogoutSubjectRecord { + + @Test + @DisplayName("Should normalize null components to Optional.empty") + void shouldNormalizeNullComponents() { + LogoutSubject subject = new LogoutSubject(null, null); + + assertFalse(subject.sub().isPresent()); + assertFalse(subject.sid().isPresent()); + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/logout/RpInitiatedLogoutTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/logout/RpInitiatedLogoutTest.java new file mode 100644 index 00000000..79317e28 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/logout/RpInitiatedLogoutTest.java @@ -0,0 +1,253 @@ +/* + * 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.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + + +import de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout.LogoutRedirect; +import de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout.LogoutReturn; +import de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout.TokenRevocation; +import de.cuioss.sheriff.gateway.bff.session.SessionRecord; +import de.cuioss.sheriff.token.client.logout.EndSessionFlow; +import de.cuioss.sheriff.token.client.logout.PostLogoutRedirectValidator; +import de.cuioss.sheriff.token.commons.error.ClientProtocolException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link RpInitiatedLogout}: the gateway-side RP-initiated logout orchestration exercised + * against the real engine {@link EndSessionFlow} (constructed with a + * {@link PostLogoutRedirectValidator}), so the exact-match {@code post_logout_redirect_uri} + * open-redirect defence and the constant-time {@code state} verification are the genuine engine code + * paths — not stubs. + *

    + * Covered: the initiate leg (best-effort revocation, engine end-session redirect carrying + * {@code id_token_hint}/{@code post_logout_redirect_uri}/{@code state}, and the single-use + * {@code __Host-sheriff-logout} cookie), the logout-state cookie round-trip through + * {@link RpInitiatedLogout#completeReturn}, and the open-redirect rejection of an unregistered + * {@code post_logout_redirect_uri}. + */ +class RpInitiatedLogoutTest { + + private static final String END_SESSION = "https://idp.example.com/protocol/openid-connect/logout"; + private static final String REGISTERED_RETURN = "https://gw.example.com/auth/logout/return"; + private static final String FINAL_REDIRECT = "/goodbye"; + private static final Duration STATE_TTL = Duration.ofSeconds(60); + private static final Instant NOW = Instant.parse("2026-07-23T10:00:00Z"); + private static final String RAW_ID_TOKEN = "raw-id-token-hint"; + + private AtomicReference revoked; + private EndSessionFlow endSessionFlow; + private RpInitiatedLogout logout; + private SessionRecord session; + + @BeforeEach + void setUp() { + revoked = new AtomicReference<>(); + TokenRevocation revocation = revoked::set; + endSessionFlow = new EndSessionFlow(new PostLogoutRedirectValidator(Set.of(REGISTERED_RETURN))); + logout = new RpInitiatedLogout(endSessionFlow, revocation, END_SESSION, REGISTERED_RETURN, FINAL_REDIRECT, + STATE_TTL); + session = SessionRecord.builder() + .sessionId(SessionRecord.newSessionId()) + .accessToken("mediated-access-token") + .idToken(RAW_ID_TOKEN) + .sub("user-sub-1") + .expiresAt(NOW.plus(Duration.ofHours(8))) + .build(); + } + + private static String cookieValue(String setCookieHeader) { + String firstPair = setCookieHeader.split(";", 2)[0]; + return firstPair.substring(firstPair.indexOf('=') + 1); + } + + @Nested + @DisplayName("Logout initiation") + class Initiation { + + @Test + @DisplayName("Should build an end-session redirect carrying id_token_hint, post_logout_redirect_uri, and state") + void shouldBuildEndSessionRedirect() { + LogoutRedirect redirect = logout.initiate(session); + + assertTrue(redirect.location().startsWith(END_SESSION), redirect.location()); + assertTrue(redirect.location().contains("id_token_hint="), "the id_token_hint is present"); + assertTrue(redirect.location().contains("post_logout_redirect_uri="), + "the exact post_logout_redirect_uri is present"); + String state = cookieValue(redirect.setCookieHeaders().getFirst()); + assertTrue(redirect.location().contains("state=" + state), + "the redirect state matches the minted cookie state"); + } + + @Test + @DisplayName("Should set the single-use __Host-sheriff-logout state cookie hardened and short-lived") + void shouldSetHardenedStateCookie() { + LogoutRedirect redirect = logout.initiate(session); + + assertEquals(1, redirect.setCookieHeaders().size()); + String cookie = redirect.setCookieHeaders().getFirst(); + assertTrue(cookie.startsWith(RpInitiatedLogout.LOGOUT_STATE_COOKIE_NAME + "="), cookie); + assertTrue(cookie.contains("Max-Age=" + STATE_TTL.toSeconds()), cookie); + assertTrue(cookie.contains("Path=/"), cookie); + assertTrue(cookie.contains("Secure"), cookie); + assertTrue(cookie.contains("HttpOnly"), cookie); + assertTrue(cookie.contains("SameSite=Lax"), cookie); + } + + @Test + @DisplayName("Should mint a fresh unpredictable state on every initiation") + void shouldMintFreshStatePerInitiation() { + String first = cookieValue(logout.initiate(session).setCookieHeaders().getFirst()); + String second = cookieValue(logout.initiate(session).setCookieHeaders().getFirst()); + + assertNotEquals(first, second, "each logout mints a distinct state"); + } + + @Test + @DisplayName("Should revoke the mediated tokens best-effort during initiation") + void shouldRevokeMediatedTokens() { + logout.initiate(session); + + assertEquals(session, revoked.get(), "the session's tokens are handed to the revocation seam"); + } + + @Test + @DisplayName("Should proceed with logout even when token revocation fails") + void shouldProceedWhenRevocationFails() { + TokenRevocation failing = ignored -> { + throw new IllegalStateException("revocation endpoint unreachable"); + }; + RpInitiatedLogout resilient = new RpInitiatedLogout(endSessionFlow, failing, END_SESSION, + REGISTERED_RETURN, FINAL_REDIRECT, STATE_TTL); + + LogoutRedirect redirect = resilient.initiate(session); + + assertTrue(redirect.location().startsWith(END_SESSION), + "a revocation failure never strands the browser half-logged-out"); + } + } + + @Nested + @DisplayName("Open-redirect defence (exact post_logout_redirect_uri match)") + class OpenRedirectDefence { + + @Test + @DisplayName("Should reject an unregistered post_logout_redirect_uri via the engine validator") + void shouldRejectUnregisteredReturnUri() { + RpInitiatedLogout evil = new RpInitiatedLogout(endSessionFlow, revoked::set, END_SESSION, + "https://evil.example.com/steal", FINAL_REDIRECT, STATE_TTL); + + assertThrows(ClientProtocolException.class, () -> evil.initiate(session), + "the engine PostLogoutRedirectValidator rejects a non-exact-match return URI"); + } + } + + @Nested + @DisplayName("Return leg (logout-state cookie round-trip)") + class ReturnLeg { + + @Test + @DisplayName("Should redirect to final_redirect and clear the state cookie on a matching state") + void shouldCompleteMatchingReturn() { + LogoutRedirect initiated = logout.initiate(session); + String state = cookieValue(initiated.setCookieHeaders().getFirst()); + String cookieHeader = RpInitiatedLogout.LOGOUT_STATE_COOKIE_NAME + "=" + state; + + LogoutReturn result = logout.completeReturn(state, cookieHeader); + + assertTrue(result.isRedirect(), "a matching state completes the logout"); + assertEquals(302, result.status()); + assertEquals(Optional.of(FINAL_REDIRECT), result.location()); + String clearing = result.setCookieHeaders().getFirst(); + assertTrue(clearing.startsWith(RpInitiatedLogout.LOGOUT_STATE_COOKIE_NAME + "="), clearing); + assertTrue(clearing.contains("Max-Age=0"), "the single-use state cookie is cleared"); + } + + @Test + @DisplayName("Should reject a returned state that does not match the cookie 400") + void shouldRejectMismatchedState() { + LogoutRedirect initiated = logout.initiate(session); + String state = cookieValue(initiated.setCookieHeaders().getFirst()); + String cookieHeader = RpInitiatedLogout.LOGOUT_STATE_COOKIE_NAME + "=" + state; + + LogoutReturn result = logout.completeReturn("not-the-state", cookieHeader); + + assertFalse(result.isRedirect()); + assertEquals(400, result.status()); + assertTrue(result.setCookieHeaders().isEmpty()); + } + + @Test + @DisplayName("Should reject a return that carries no logout-state cookie 400") + void shouldRejectMissingStateCookie() { + LogoutReturn result = logout.completeReturn("some-state", null); + + assertFalse(result.isRedirect()); + assertEquals(400, result.status()); + } + + @Test + @DisplayName("Should reject a return whose state parameter is absent 400") + void shouldRejectMissingStateParameter() { + LogoutRedirect initiated = logout.initiate(session); + String state = cookieValue(initiated.setCookieHeaders().getFirst()); + String cookieHeader = RpInitiatedLogout.LOGOUT_STATE_COOKIE_NAME + "=" + state; + + LogoutReturn result = logout.completeReturn(null, cookieHeader); + + assertEquals(400, result.status()); + } + } + + @Nested + @DisplayName("Accessors and argument contract") + class Contract { + + @Test + @DisplayName("Should expose the configured final_redirect landing") + void shouldExposeFinalRedirect() { + assertEquals(FINAL_REDIRECT, logout.finalRedirect()); + } + + @Test + @DisplayName("Should reject a null session on initiate") + void shouldRejectNullSession() { + assertThrows(NullPointerException.class, () -> logout.initiate(null)); + } + + @Test + @DisplayName("Should reject blank required constructor settings") + void shouldRejectBlankSettings() { + assertThrows(IllegalArgumentException.class, () -> new RpInitiatedLogout(endSessionFlow, revoked::set, + " ", REGISTERED_RETURN, FINAL_REDIRECT, STATE_TTL)); + } + } +} From 871e4cb784cd99d0663bdee192e9e56ba1554c01 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:51:15 +0200 Subject: [PATCH 11/39] feat(bff): add transparent token refresh and RFC 9470 step-up (D9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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; an IdP rejection or engine refresh-token-family reuse destroys the session so a revoked family can never be mediated from again. StepUpCoordinator parses an upstream RFC 9470 insufficient_user_authentication challenge, attempts silent satisfaction, and otherwise re-drives the auth-code flow with the challenge's elevated acr_values/max_age — persisting a single-use pending-authorization record with a same-origin-validated replay target and a browser-binding cookie. Both coordinators are framework-agnostic and reach the engine through functional-interface seams; no OAuth leg is re-implemented in gateway code. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Af6Ah5SR43Ug7eG9HN1Yyo --- .../bff/refresh/StepUpCoordinator.java | 271 +++++++++++++++ .../bff/refresh/TokenRefreshCoordinator.java | 304 +++++++++++++++++ .../gateway/bff/refresh/package-info.java | 49 +++ .../bff/refresh/StepUpCoordinatorTest.java | 221 ++++++++++++ .../refresh/TokenRefreshCoordinatorTest.java | 317 ++++++++++++++++++ 5 files changed, 1162 insertions(+) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/StepUpCoordinator.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/package-info.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/StepUpCoordinatorTest.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinatorTest.java 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..f965a99b --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/StepUpCoordinator.java @@ -0,0 +1,271 @@ +/* + * 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: + *

      + *
    1. 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.
    2. + *
    3. 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.
    4. + *
    + * 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 = PendingAuthorizationRecord.sameOrigin(replayUrl, gatewayOrigin) + ? replayUrl : DEFAULT_RETURN_URL; + PendingAuthorizationRecord record = PendingAuthorizationRecord.create(request.context(), returnUrl, now); + pendingStore.store(record); + LOGGER.debug("Step-up challenge requires re-authentication — re-driving the auth-code flow"); + List setCookies = List.of(bindingCookieCodec.toSetCookieHeader(record.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..2cc03bfd --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java @@ -0,0 +1,304 @@ +/* + * 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(); + if (latest.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 = latest.refreshToken().get(); + try { + RotationResult rotation = refreshExchange.exchange(presentedRefreshToken); + SessionRecord rotated = rotate(latest, rotation); + sessionStore.destroyById(sessionId); + 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/test/java/de/cuioss/sheriff/gateway/bff/refresh/StepUpCoordinatorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/StepUpCoordinatorTest.java new file mode 100644 index 00000000..d363548f --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/StepUpCoordinatorTest.java @@ -0,0 +1,221 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +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.refresh.StepUpCoordinator.SilentSatisfaction; +import de.cuioss.sheriff.gateway.bff.refresh.StepUpCoordinator.StepUpInitiation; +import de.cuioss.sheriff.gateway.bff.refresh.StepUpCoordinator.StepUpOutcome; +import de.cuioss.sheriff.gateway.bff.session.SessionRecord; +import de.cuioss.sheriff.token.client.flow.FlowContext; +import de.cuioss.sheriff.token.client.flow.StepUpChallengeParser.StepUpChallenge; +import de.cuioss.sheriff.token.client.flow.StepUpHandler; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link StepUpCoordinator}: RFC 9470 step-up orchestration — challenge detection, silent + * satisfaction, and the auth-code re-drive with a same-origin-validated replay target. + *

    + * The engine step-up authorization and the silent-satisfaction attempt are driven through the + * {@link StepUpInitiation} and {@link SilentSatisfaction} seams, so every path is exercised with + * hand-built engine objects and no test-double framework. The step-up challenge parsing is the + * engine's own ({@code StepUpChallengeParser}) and is exercised through {@code detectChallenge}. + */ +class StepUpCoordinatorTest { + + private static final Instant NOW = Instant.parse("2026-07-23T10:00:00Z"); + private static final String GATEWAY_ORIGIN = "https://gw.example.com"; + private static final String STEP_UP_URL = "https://idp.example.com/authorize?acr_values=urn:example:gold"; + private static final String REPLAY_URL = "/orders/42"; + private static final String ACR = "urn:example:gold"; + + private PendingAuthorizationStore.InMemory pendingStore; + private BindingCookieCodec bindingCodec; + + @BeforeEach + void setUp() { + pendingStore = new PendingAuthorizationStore.InMemory(8); + bindingCodec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL); + } + + private static SessionRecord session(String acr) { + return SessionRecord.builder() + .sessionId("session-1") + .accessToken("access-current") + .refreshToken(Optional.of("refresh-current")) + .idToken("id-current") + .sub("sub-1") + .sid(Optional.empty()) + .expiresAt(NOW.plusSeconds(28800)) + .acr(Optional.ofNullable(acr)) + .authTime(Optional.empty()) + .build(); + } + + private static StepUpChallenge challenge() { + return new StepUpChallenge(ACR, null); + } + + private StepUpCoordinator coordinator(SilentSatisfaction silent, StepUpInitiation initiation) { + return new StepUpCoordinator(silent, initiation, pendingStore, bindingCodec, GATEWAY_ORIGIN); + } + + private StepUpCoordinator reDrivingCoordinator() { + StepUpHandler.StepUpRequest request = new StepUpHandler.StepUpRequest(STEP_UP_URL, + FlowContext.create(GATEWAY_ORIGIN + "/auth/callback")); + return coordinator((s, c, now) -> Optional.empty(), c -> request); + } + + private String recordIdFrom(StepUpOutcome outcome) { + String cookieHeader = outcome.setCookieHeaders().getFirst().split(";", 2)[0]; + return bindingCodec.readRecordId(cookieHeader).orElseThrow(); + } + + @Nested + @DisplayName("Challenge detection") + class Detection { + + @Test + @DisplayName("Should detect an RFC 9470 insufficient_user_authentication challenge") + void shouldDetectChallenge() { + StepUpCoordinator coordinator = reDrivingCoordinator(); + + Optional detected = coordinator.detectChallenge( + "Bearer error=\"insufficient_user_authentication\", acr_values=\"" + ACR + "\""); + + assertTrue(detected.isPresent(), "the upstream step-up challenge is parsed"); + assertEquals(Optional.of(ACR), detected.get().getAcrValues()); + } + + @Test + @DisplayName("Should return empty for a null, blank, or non-step-up WWW-Authenticate header") + void shouldReturnEmptyForNonChallenge() { + StepUpCoordinator coordinator = reDrivingCoordinator(); + + assertTrue(coordinator.detectChallenge(null).isEmpty()); + assertTrue(coordinator.detectChallenge(" ").isEmpty()); + assertTrue(coordinator.detectChallenge("Bearer realm=\"api\"").isEmpty(), + "an ordinary bearer challenge is not a step-up challenge"); + } + } + + @Nested + @DisplayName("Silent satisfaction") + class Silent { + + @Test + @DisplayName("Should replay with the elevated session when the challenge is satisfied silently") + void shouldReplayOnSilentSatisfaction() { + SessionRecord elevated = session(ACR); + StepUpCoordinator coordinator = coordinator((s, c, now) -> Optional.of(elevated), + c -> { + throw new IllegalStateException("re-drive must not be attempted when silent satisfaction works"); + }); + + StepUpOutcome outcome = coordinator.coordinate(session("urn:example:silver"), challenge(), REPLAY_URL, NOW); + + assertTrue(outcome.isSatisfied()); + assertEquals(StepUpOutcome.Kind.SATISFIED, outcome.kind()); + assertSame(elevated, outcome.session().orElseThrow(), "the elevated session is used for the replay"); + assertTrue(outcome.setCookieHeaders().isEmpty(), "a silent satisfaction sets no browser-binding cookie"); + } + } + + @Nested + @DisplayName("Auth-code re-drive") + class ReDrive { + + @Test + @DisplayName("Should re-drive to the engine step-up URL and set the browser-binding cookie") + void shouldReDriveWhenSilentFails() { + StepUpCoordinator coordinator = reDrivingCoordinator(); + + StepUpOutcome outcome = coordinator.coordinate(session("urn:example:silver"), challenge(), REPLAY_URL, NOW); + + assertFalse(outcome.isSatisfied()); + assertEquals(StepUpOutcome.Kind.RE_DRIVE, outcome.kind()); + assertEquals(Optional.of(STEP_UP_URL), outcome.location()); + assertEquals(1, outcome.setCookieHeaders().size(), "the browser-binding cookie is minted for the re-drive"); + } + + @Test + @DisplayName("Should persist a single-use pending record carrying the same-origin replay URL") + void shouldRecordSameOriginReplayUrl() { + StepUpCoordinator coordinator = reDrivingCoordinator(); + + StepUpOutcome outcome = coordinator.coordinate(session("urn:example:silver"), challenge(), REPLAY_URL, NOW); + + PendingAuthorizationRecord record = pendingStore.consume(recordIdFrom(outcome), NOW).orElseThrow(); + assertEquals(REPLAY_URL, record.returnUrl(), "the validated replay URL is recorded as the return target"); + } + + @Test + @DisplayName("Should fall back to the default return URL for an off-origin replay target") + void shouldRejectOffOriginReplayUrl() { + StepUpCoordinator coordinator = reDrivingCoordinator(); + + StepUpOutcome outcome = coordinator.coordinate(session("urn:example:silver"), challenge(), + "https://evil.example.com/steal", NOW); + + PendingAuthorizationRecord record = pendingStore.consume(recordIdFrom(outcome), NOW).orElseThrow(); + assertEquals(StepUpCoordinator.DEFAULT_RETURN_URL, record.returnUrl(), + "an off-origin replay target is never an open redirect"); + } + } + + @Nested + @DisplayName("Argument and outcome contracts") + class Contracts { + + @Test + @DisplayName("Should reject null session, challenge, and instant") + void shouldRejectNullArguments() { + StepUpCoordinator coordinator = reDrivingCoordinator(); + + assertThrows(NullPointerException.class, () -> coordinator.coordinate(null, challenge(), REPLAY_URL, NOW)); + assertThrows(NullPointerException.class, + () -> coordinator.coordinate(session(ACR), null, REPLAY_URL, NOW)); + assertThrows(NullPointerException.class, + () -> coordinator.coordinate(session(ACR), challenge(), REPLAY_URL, null)); + } + + @Test + @DisplayName("Should defensively copy the re-drive cookies in the outcome") + void shouldExposeSatisfiedFactory() { + StepUpOutcome satisfied = StepUpOutcome.satisfied(session(ACR)); + + assertTrue(satisfied.isSatisfied()); + assertTrue(satisfied.location().isEmpty()); + assertTrue(satisfied.setCookieHeaders().isEmpty()); + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinatorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinatorTest.java new file mode 100644 index 00000000..ef612d23 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinatorTest.java @@ -0,0 +1,317 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + + +import de.cuioss.sheriff.gateway.bff.refresh.TokenRefreshCoordinator.AccessTokenExpiry; +import de.cuioss.sheriff.gateway.bff.refresh.TokenRefreshCoordinator.RefreshExchange; +import de.cuioss.sheriff.gateway.bff.refresh.TokenRefreshCoordinator.RefreshOutcome; +import de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore; +import de.cuioss.sheriff.gateway.bff.session.SessionRecord; +import de.cuioss.sheriff.token.client.token.RotationResult; +import de.cuioss.sheriff.token.commons.error.ClientProtocolException; +import de.cuioss.sheriff.token.validation.domain.claim.ClaimName; +import de.cuioss.sheriff.token.validation.domain.claim.ClaimValue; +import de.cuioss.sheriff.token.validation.domain.token.AccessTokenContent; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link TokenRefreshCoordinator}: the single-flight, per-session transparent refresh. + *

    + * The engine refresh is driven through the {@link RefreshExchange} seam and the near-expiry decision + * through the {@link AccessTokenExpiry} seam, so every path — not-needed, refreshed, failed, reuse, + * and the concurrent single-flight coalesce — is exercised with hand-built engine objects and no + * test-double framework. The failed-refresh outcome's downstream content negotiation + * (navigation → login, XHR → 401) belongs to the session runtime stage and is tested there; this + * suite covers the coordinator's own contract: which {@link RefreshOutcome} it returns and its + * session-store side effects. + */ +class TokenRefreshCoordinatorTest { + + private static final Instant NOW = Instant.parse("2026-07-23T10:00:00Z"); + private static final Duration LEEWAY = Duration.ofSeconds(60); + private static final Instant NOT_NEAR = NOW.plusSeconds(600); + private static final Instant NEAR = NOW.plusSeconds(30); + private static final Duration SESSION_TTL = Duration.ofHours(8); + private static final String SESSION_ID = "session-1"; + private static final String CURRENT_REFRESH = "refresh-current"; + private static final String ROTATED_ACCESS = "rotated-access-token"; + private static final String ROTATED_REFRESH = "rotated-refresh-token"; + private static final String ROTATED_ID = "rotated-id-token"; + + private InMemorySessionStore store; + + @BeforeEach + void setUp() { + store = new InMemorySessionStore(16); + } + + private static SessionRecord session(String refreshToken) { + return SessionRecord.builder() + .sessionId(SESSION_ID) + .accessToken("access-current") + .refreshToken(Optional.ofNullable(refreshToken)) + .idToken("id-current") + .sub("sub-1") + .sid(Optional.empty()) + .expiresAt(NOW.plus(SESSION_TTL)) + .acr(Optional.empty()) + .authTime(Optional.empty()) + .build(); + } + + private static RotationResult rotation() { + Map claims = new HashMap<>(); + claims.put(ClaimName.SUBJECT.getName(), ClaimValue.forPlainString("sub-1")); + AccessTokenContent rotatedAccess = new AccessTokenContent(claims, ROTATED_ACCESS); + return new RotationResult(rotatedAccess, ROTATED_REFRESH, ROTATED_ID, 300L, true); + } + + private TokenRefreshCoordinator coordinator(Instant accessExpiry, RefreshExchange exchange) { + return new TokenRefreshCoordinator(LEEWAY, unused -> accessExpiry, exchange, store); + } + + @Nested + @DisplayName("No refresh needed") + class NoRefresh { + + @Test + @DisplayName("Should return the current session unchanged when not within the expiry leeway") + void shouldReturnCurrentWhenNotNearExpiry() { + AtomicInteger calls = new AtomicInteger(); + SessionRecord live = session(CURRENT_REFRESH); + store.create(live); + TokenRefreshCoordinator coordinator = coordinator(NOT_NEAR, rt -> { + calls.incrementAndGet(); + return rotation(); + }); + + RefreshOutcome outcome = coordinator.refresh(live, NOW); + + assertEquals(RefreshOutcome.Kind.CURRENT, outcome.kind()); + assertSame(live, outcome.session().orElseThrow(), "the same session is returned unchanged"); + assertEquals(0, calls.get(), "no engine refresh is attempted when the token is not near expiry"); + } + + @Test + @DisplayName("Should return the current session when near expiry but the session carries no refresh token") + void shouldReturnCurrentWhenNoRefreshToken() { + AtomicInteger calls = new AtomicInteger(); + SessionRecord live = session(null); + store.create(live); + TokenRefreshCoordinator coordinator = coordinator(NEAR, rt -> { + calls.incrementAndGet(); + return rotation(); + }); + + RefreshOutcome outcome = coordinator.refresh(live, NOW); + + assertEquals(RefreshOutcome.Kind.CURRENT, outcome.kind()); + assertEquals(0, calls.get(), "a session without a refresh token cannot be refreshed"); + } + } + + @Nested + @DisplayName("Successful refresh") + class SuccessfulRefresh { + + @Test + @DisplayName("Should refresh through the engine and persist the rotated token material") + void shouldRefreshAndPersist() { + SessionRecord live = session(CURRENT_REFRESH); + store.create(live); + TokenRefreshCoordinator coordinator = coordinator(NEAR, rt -> rotation()); + + RefreshOutcome outcome = coordinator.refresh(live, NOW); + + assertEquals(RefreshOutcome.Kind.REFRESHED, outcome.kind()); + SessionRecord rotated = outcome.session().orElseThrow(); + assertEquals(ROTATED_ACCESS, rotated.accessToken()); + assertEquals(Optional.of(ROTATED_REFRESH), rotated.refreshToken()); + assertEquals(ROTATED_ID, rotated.idToken()); + assertEquals(live.expiresAt(), rotated.expiresAt(), "the absolute session cap is unchanged by a refresh"); + } + + @Test + @DisplayName("Should pass the session's current refresh token to the engine") + void shouldPresentCurrentRefreshToken() { + SessionRecord live = session(CURRENT_REFRESH); + store.create(live); + AtomicInteger calls = new AtomicInteger(); + TokenRefreshCoordinator coordinator = coordinator(NEAR, presented -> { + calls.incrementAndGet(); + assertEquals(CURRENT_REFRESH, presented, "the coordinator presents the stored refresh token"); + return rotation(); + }); + + coordinator.refresh(live, NOW); + + assertEquals(1, calls.get()); + SessionRecord persisted = store.resolve(SESSION_ID, NOW).orElseThrow(); + assertEquals(ROTATED_ACCESS, persisted.accessToken(), "the store now serves the rotated token"); + } + } + + @Nested + @DisplayName("Failure and reuse detection") + class Failure { + + @Test + @DisplayName("Should destroy the session and fail when the engine refresh is rejected") + void shouldFailAndDestroyOnEngineRejection() { + SessionRecord live = session(CURRENT_REFRESH); + store.create(live); + TokenRefreshCoordinator coordinator = coordinator(NEAR, rt -> { + throw new ClientProtocolException("token endpoint rejected the refresh grant"); + }); + + RefreshOutcome outcome = coordinator.refresh(live, NOW); + + assertTrue(outcome.isFailure()); + assertEquals(RefreshOutcome.Kind.FAILED, outcome.kind()); + assertTrue(outcome.session().isEmpty(), "a failed outcome carries no session"); + assertTrue(store.resolve(SESSION_ID, NOW).isEmpty(), "the session is destroyed on refresh failure"); + } + + @Test + @DisplayName("Should destroy the session when the engine reports refresh-token reuse (family revoked)") + void shouldFailOnReuseDetection() { + SessionRecord live = session(CURRENT_REFRESH); + store.create(live); + TokenRefreshCoordinator coordinator = coordinator(NEAR, rt -> { + throw new ClientProtocolException("refresh token family is revoked"); + }); + + RefreshOutcome outcome = coordinator.refresh(live, NOW); + + assertTrue(outcome.isFailure(), "a revoked refresh-token family fails the refresh"); + assertTrue(store.resolve(SESSION_ID, NOW).isEmpty(), "a reused-token session is destroyed"); + } + + @Test + @DisplayName("Should fail when the session was destroyed between the near-expiry check and the refresh") + void shouldFailWhenSessionGone() { + SessionRecord live = session(CURRENT_REFRESH); + // Deliberately NOT stored — models a session destroyed concurrently before the lead resolves it. + TokenRefreshCoordinator coordinator = coordinator(NEAR, rt -> rotation()); + + RefreshOutcome outcome = coordinator.refresh(live, NOW); + + assertTrue(outcome.isFailure(), "a session absent from the store cannot be refreshed"); + } + } + + @Nested + @DisplayName("Single-flight coalescing") + class SingleFlight { + + @Test + @DisplayName("Should coalesce concurrent refreshes on one session into a single engine call") + void shouldCoalesceConcurrentRefreshes() throws Exception { + SessionRecord live = session(CURRENT_REFRESH); + store.create(live); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch proceed = new CountDownLatch(1); + AtomicInteger calls = new AtomicInteger(); + TokenRefreshCoordinator coordinator = coordinator(NEAR, rt -> { + calls.incrementAndGet(); + entered.countDown(); + awaitUninterruptibly(proceed); + return rotation(); + }); + + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future leader = pool.submit(() -> coordinator.refresh(live, NOW)); + assertTrue(entered.await(2, TimeUnit.SECONDS), "the leader entered the engine refresh"); + Future follower = pool.submit(() -> coordinator.refresh(live, NOW)); + // Let the follower reach the in-flight join before the leader is released. + Thread.sleep(100); + proceed.countDown(); + + RefreshOutcome leaderOutcome = leader.get(2, TimeUnit.SECONDS); + RefreshOutcome followerOutcome = follower.get(2, TimeUnit.SECONDS); + + assertEquals(1, calls.get(), "concurrent requests on one session share a single engine refresh"); + assertFalse(leaderOutcome.isFailure()); + assertFalse(followerOutcome.isFailure(), "the coalesced follower shares the successful refresh"); + assertEquals(ROTATED_ACCESS, store.resolve(SESSION_ID, NOW).orElseThrow().accessToken()); + } finally { + pool.shutdownNow(); + } + } + + private static void awaitUninterruptibly(CountDownLatch latch) { + try { + latch.await(2, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + } + } + + @Nested + @DisplayName("Argument and outcome contracts") + class Contracts { + + @Test + @DisplayName("Should reject a null session and a null instant") + void shouldRejectNullArguments() { + TokenRefreshCoordinator coordinator = coordinator(NEAR, rt -> rotation()); + + assertThrows(NullPointerException.class, () -> coordinator.refresh(null, NOW)); + assertThrows(NullPointerException.class, () -> coordinator.refresh(session(CURRENT_REFRESH), null)); + } + + @Test + @DisplayName("Should reject constructing a non-failed outcome without a session") + void shouldRejectPresentContractViolation() { + assertThrows(IllegalArgumentException.class, + () -> new RefreshOutcome(RefreshOutcome.Kind.CURRENT, Optional.empty())); + } + + @Test + @DisplayName("Should expose an empty session on a failed outcome") + void failedOutcomeCarriesNoSession() { + RefreshOutcome failed = RefreshOutcome.failed(); + + assertTrue(failed.isFailure()); + assertTrue(failed.session().isEmpty()); + } + } +} From 1bf8f9e0d617e50d7920dc5630740b69110b06fe Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:14:05 +0200 Subject: [PATCH 12/39] feat(bff): add session metrics, LogRecords, and server-mode readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deliverable 10 — cross-cutting BFF observability for require:session routes. - BffLogMessages: DSL-style LogRecord catalogue for the session lifecycle, transparent refresh, CSRF, and logout events on the shared ApiSheriff prefix with disjoint ids 10-14 (INFO) / 110-112 (WARN); records only non-sensitive dispositions, never session ids, subjects, sids, or token material. - EventType: SESSION_CREATED / SESSION_DESTROYED / SESSION_REFRESH_FAILED / BACKCHANNEL_LOGOUT (informational) and LOGOUT_TOKEN_INVALID (400, AUTHENTICATION) for the back-channel logout receiver. - SheriffMetrics: sheriff_session_events_total counter keyed by the EventType name (bounded cardinality), bridging GatewayEventCounter to Micrometer. - GatewayReadinessCheck: mode:server issuer reachability, reusing the JWKS-backed validation health check (reachable / unreachable / unverified). - doc/LogMessages.adoc: documents every new BFF record. Co-Authored-By: Claude --- .../sheriff/gateway/bff/BffLogMessages.java | 140 ++++++++++++++++++ .../sheriff/gateway/bff/package-info.java | 36 +++++ .../sheriff/gateway/events/EventType.java | 20 +++ .../quarkus/GatewayReadinessCheck.java | 58 +++++++- .../gateway/quarkus/SheriffMetrics.java | 24 ++- .../sheriff/gateway/events/EventTypeTest.java | 4 +- .../gateway/quarkus/SheriffMetricsTest.java | 103 +++++++++++-- doc/LogMessages.adoc | 8 + 8 files changed, 378 insertions(+), 15 deletions(-) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/BffLogMessages.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/package-info.java diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/BffLogMessages.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/BffLogMessages.java 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/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/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/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/test/java/de/cuioss/sheriff/gateway/events/EventTypeTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/events/EventTypeTest.java index c822141a..bfe068e0 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/events/EventTypeTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/events/EventTypeTest.java @@ -36,7 +36,8 @@ class EventTypeTest { class SuccessEvents { @ParameterizedTest - @EnumSource(names = {"REQUEST_FORWARDED", "TOKEN_REFRESHED", "CONFIG_LOADED"}) + @EnumSource(names = {"REQUEST_FORWARDED", "TOKEN_REFRESHED", "CONFIG_LOADED", + "SESSION_CREATED", "SESSION_DESTROYED", "SESSION_REFRESH_FAILED", "BACKCHANNEL_LOGOUT"}) @DisplayName("Should carry no category and no HTTP mapping") void shouldCarryNoCategoryAndNoHttpMapping(EventType eventType) { assertAll("success event " + eventType, @@ -80,6 +81,7 @@ class FailureEvents { "TOKEN_INVALID, 401, AUTHENTICATION", "SCOPE_MISSING, 403, AUTHORIZATION", "CSRF_REJECTED, 403, AUTHORIZATION", + "LOGOUT_TOKEN_INVALID, 400, AUTHENTICATION", "UPSTREAM_ERROR, 502, UPSTREAM", "UPSTREAM_CIRCUIT_OPEN, 503, UPSTREAM", "UPSTREAM_TIMEOUT, 504, UPSTREAM" diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/SheriffMetricsTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/SheriffMetricsTest.java index fcaf0305..4c37a2b2 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/SheriffMetricsTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/SheriffMetricsTest.java @@ -17,6 +17,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.lang.annotation.Annotation; @@ -31,6 +33,7 @@ import de.cuioss.http.security.monitoring.SecurityEventCounter; 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.EventCategory; import de.cuioss.sheriff.gateway.events.EventType; @@ -47,8 +50,9 @@ /** * Verifies the D4/D5 metrics-and-readiness surface: {@link SheriffMetrics} registers the meter - * names named in {@code architecture.adoc} § Metrics, and {@link GatewayReadinessCheck} reflects - * configuration and JWKS status. + * names named in {@code architecture.adoc} § Metrics (including the BFF session-lifecycle counter), + * and {@link GatewayReadinessCheck} reflects configuration, JWKS status, and — for a BFF + * {@code mode: server} deployment — issuer reachability. */ class SheriffMetricsTest { @@ -67,6 +71,7 @@ void meterNameConstantsMatchArchitecture() { assertEquals("sheriff_errors_total", SheriffMetrics.ERRORS_TOTAL); assertEquals("sheriff_security_events_total", SheriffMetrics.SECURITY_EVENTS_TOTAL); assertEquals("sheriff_upstream_duration_seconds", SheriffMetrics.UPSTREAM_DURATION_SECONDS); + assertEquals("sheriff_session_events_total", SheriffMetrics.SESSION_EVENTS_TOTAL); } @Test @@ -102,6 +107,29 @@ void recordErrorCountsErrorsView() { assertEquals(1.0, counter.count()); } + @Test + @DisplayName("recordSessionEvent counts under sheriff_session_events_total{event} keyed by the EventType name") + void recordSessionEventCountsSessionLifecycle() { + metrics.recordSessionEvent(EventType.SESSION_CREATED); + metrics.recordSessionEvent(EventType.SESSION_CREATED); + metrics.recordSessionEvent(EventType.SESSION_DESTROYED); + metrics.recordSessionEvent(EventType.BACKCHANNEL_LOGOUT); + + var created = registry.find("sheriff_session_events_total").tags("event", "SESSION_CREATED").counter(); + var destroyed = registry.find("sheriff_session_events_total").tags("event", "SESSION_DESTROYED").counter(); + var backchannel = registry.find("sheriff_session_events_total").tags("event", "BACKCHANNEL_LOGOUT").counter(); + assertNotNull(created, "sheriff_session_events_total must be keyed by the EventType name"); + assertEquals(2.0, created.count(), "each recordSessionEvent call increments the event's series"); + assertEquals(1.0, destroyed.count()); + assertEquals(1.0, backchannel.count()); + } + + @Test + @DisplayName("recordSessionEvent rejects a null event type fail-closed") + void recordSessionEventRejectsNull() { + assertThrows(NullPointerException.class, () -> metrics.recordSessionEvent(null)); + } + @Test @DisplayName("bindSecurityEventCounter exposes the shared counter under sheriff_security_events_total{failure_type} and moves with it") void bindSecurityEventCounterExposesAndMovesWithCounter() { @@ -159,13 +187,13 @@ void statusFamilyBucketsByLeadingDigit() { } @Nested - @DisplayName("GatewayReadinessCheck reflects config and JWKS status") + @DisplayName("GatewayReadinessCheck reflects config, JWKS status, and server-mode issuer reachability") class Readiness { @Test @DisplayName("UP with jwks=not-applicable when no token_validation is configured") void upWhenNoTokenValidation() { - GatewayConfig config = configWith(Optional.empty(), Optional.empty()); + GatewayConfig config = configWith(Optional.empty(), Optional.empty(), Optional.empty()); GatewayReadinessCheck check = new GatewayReadinessCheck(config, FakeValidatorInstance.resolving()); HealthCheckResponse response = check.call(); @@ -174,13 +202,15 @@ void upWhenNoTokenValidation() { Map data = response.getData().orElseThrow(); assertEquals("loaded", data.get("config")); assertEquals("not-applicable", data.get("jwks")); + assertNull(data.get("oidc"), "a non-server-mode probe carries no oidc datum"); + assertNull(data.get("issuer_reachability"), "a non-server-mode probe carries no issuer_reachability datum"); } @Test @DisplayName("UP with jwks=ready when the gateway validator resolves") void upWhenValidatorResolves() { GatewayConfig config = configWith(Optional.empty(), - Optional.of(new TokenValidationConfig(List.of()))); + Optional.of(new TokenValidationConfig(List.of())), Optional.empty()); GatewayReadinessCheck check = new GatewayReadinessCheck(config, FakeValidatorInstance.resolving()); HealthCheckResponse response = check.call(); @@ -189,13 +219,14 @@ void upWhenValidatorResolves() { Map data = response.getData().orElseThrow(); assertEquals("ready", data.get("jwks")); assertEquals(0L, data.get("issuers")); + assertNull(data.get("oidc"), "a non-server-mode probe carries no oidc datum"); } @Test @DisplayName("DOWN with jwks=unavailable when the validator fails to resolve") void downWhenValidatorFails() { GatewayConfig config = configWith(Optional.empty(), - Optional.of(new TokenValidationConfig(List.of()))); + Optional.of(new TokenValidationConfig(List.of())), Optional.empty()); GatewayException failure = new GatewayException(EventType.CONFIG_INVALID, "no usable jwks source"); GatewayReadinessCheck check = new GatewayReadinessCheck(config, FakeValidatorInstance.failing(failure)); @@ -207,11 +238,59 @@ void downWhenValidatorFails() { assertTrue(String.valueOf(data.get("error")).contains("no usable jwks source")); } + @Test + @DisplayName("server mode UP reports oidc=server and issuer_reachability=reachable when the issuer JWKS resolves") + void serverModeUpReportsIssuerReachable() { + GatewayConfig config = configWith(Optional.empty(), + Optional.of(new TokenValidationConfig(List.of())), serverMode()); + GatewayReadinessCheck check = new GatewayReadinessCheck(config, FakeValidatorInstance.resolving()); + + HealthCheckResponse response = check.call(); + + assertEquals(HealthCheckResponse.Status.UP, response.getStatus()); + Map data = response.getData().orElseThrow(); + assertEquals("server", data.get("oidc")); + assertEquals("ready", data.get("jwks")); + assertEquals("reachable", data.get("issuer_reachability")); + } + + @Test + @DisplayName("server mode DOWN reports issuer_reachability=unreachable when the issuer JWKS is unreachable") + void serverModeDownReportsIssuerUnreachable() { + GatewayConfig config = configWith(Optional.empty(), + Optional.of(new TokenValidationConfig(List.of())), serverMode()); + GatewayException failure = new GatewayException(EventType.CONFIG_INVALID, "issuer JWKS unreachable"); + GatewayReadinessCheck check = new GatewayReadinessCheck(config, FakeValidatorInstance.failing(failure)); + + HealthCheckResponse response = check.call(); + + assertEquals(HealthCheckResponse.Status.DOWN, response.getStatus()); + Map data = response.getData().orElseThrow(); + assertEquals("server", data.get("oidc")); + assertEquals("unavailable", data.get("jwks")); + assertEquals("unreachable", data.get("issuer_reachability")); + } + + @Test + @DisplayName("server mode without token_validation reports issuer_reachability=unverified but stays UP") + void serverModeWithoutValidationReportsUnverified() { + GatewayConfig config = configWith(Optional.empty(), Optional.empty(), serverMode()); + GatewayReadinessCheck check = new GatewayReadinessCheck(config, FakeValidatorInstance.resolving()); + + HealthCheckResponse response = check.call(); + + assertEquals(HealthCheckResponse.Status.UP, response.getStatus()); + Map data = response.getData().orElseThrow(); + assertEquals("server", data.get("oidc")); + assertEquals("not-applicable", data.get("jwks")); + assertEquals("unverified", data.get("issuer_reachability")); + } + @Test @DisplayName("config_version is surfaced when metadata carries one") void configVersionSurfaced() { GatewayConfig config = configWith(Optional.of(new Metadata(Optional.of("2026-07-19"))), - Optional.empty()); + Optional.empty(), Optional.empty()); GatewayReadinessCheck check = new GatewayReadinessCheck(config, FakeValidatorInstance.resolving()); HealthCheckResponse response = check.call(); @@ -219,9 +298,15 @@ void configVersionSurfaced() { assertEquals("2026-07-19", response.getData().orElseThrow().get("config_version")); } - private GatewayConfig configWith(Optional metadata, Optional tokenValidation) { + private Optional serverMode() { + OidcConfig.Session session = OidcConfig.Session.builder().mode(Optional.of("server")).build(); + return Optional.of(OidcConfig.builder().session(Optional.of(session)).build()); + } + + private GatewayConfig configWith(Optional metadata, Optional tokenValidation, + Optional oidc) { return new GatewayConfig(1, metadata, Optional.empty(), Optional.empty(), Optional.empty(), - null, null, Optional.empty(), Optional.empty(), tokenValidation, Optional.empty()); + null, null, Optional.empty(), Optional.empty(), tokenValidation, oidc); } } diff --git a/doc/LogMessages.adoc b/doc/LogMessages.adoc index db408b94..07ce9762 100644 --- a/doc/LogMessages.adoc +++ b/doc/LogMessages.adoc @@ -40,6 +40,11 @@ diagnostics use the logger directly and are not catalogued. |ApiSheriff-5 |EDGE |WebSocket relay on route '%s' reclaimed after idle timeout (%s seconds) |Logged when an established WebSocket relay is closed because no frame travelled in either direction for the per-route websocket.idle_timeout_seconds window; ping/pong counts as activity, so only genuinely dead sockets are reaped |ApiSheriff-6 |EDGE |Passthrough relay established for SNI '%s' to upstream '%s' |Logged once per accepted connection whose SNI matched tls.passthrough_sni, when the opaque L4 relay to the resolved backend is established; the gateway never handshakes, so the backend certificate reaches the client end-to-end |ApiSheriff-7 |EDGE |Accept-time SNI front listener started on port %s (%s passthrough SNI mapping(s)) |Logged once at startup when the Vert.x front listener binds the public TLS port; emitted only when tls.passthrough_sni is non-empty (the front listener is not started for the default single-listener topology) +|ApiSheriff-10 |BFF |Server-side session established for a require:session route |Logged once when a `mode: server` BFF session is created after a successful IdP login; carries no session id, subject, or IdP `sid` +|ApiSheriff-11 |BFF |Server-side session destroyed (%s) |Logged when a server-side session is destroyed; `%s` is a bounded, non-sensitive disposition (`logout` / `backchannel-logout` / `expiry` / `refresh-failure`) — never the session id, subject, or `sid` +|ApiSheriff-12 |BFF |Mediated tokens refreshed for a require:session route (single-flight) |Logged when the transparent single-flight token refresh rotates a session's mediated tokens through the engine; token material is never logged +|ApiSheriff-13 |BFF |Back-channel logout accepted — %s session(s) destroyed |Logged when a signature- and claim-valid back-channel `logout_token` destroys the affected sessions; `%s` is the bounded destroyed-session count — never the subject or `sid` +|ApiSheriff-14 |BFF |RP-initiated logout completed for a require:session route |Logged when the RP-initiated logout return leg verifies its single-use logout-state cookie and redirects the browser to the configured final_redirect |=== == WARN Level (100-199) @@ -55,6 +60,9 @@ diagnostics use the logger directly and are not catalogued. |ApiSheriff-105 |EDGE |WebSocket upgrade rejected on route '%s': Origin '%s' is not in the allowed_origins allowlist |Logged when a bearer WebSocket handshake presents an Origin that does not exactly match the route's allowed_origins allowlist; the upgrade is refused before any upstream dial (fail-closed CSWSH defence, GW-09 / ADR-0015) |ApiSheriff-107 |EDGE |TLS ClientHello failed closed to terminated path: %s |Logged when an accept-time TLS ClientHello is failed closed to the terminated-strict path (GW-06) because it carried no usable SNI, was malformed, or exceeded the reassembly bound; records the disposition only — never the raw ClientHello bytes |ApiSheriff-108 |EDGE |Host-vs-SNI smuggle rejected before route selection: %s |Logged when a terminated request's Host header names a reserved passthrough SNI hostname and is rejected 404 before route selection; records a fixed disposition only — never the raw Host value +|ApiSheriff-110 |BFF |CSRF defence rejected an unsafe-method session request: %s |Logged when the fixed CSRF defence rejects an unsafe-method `require: session` request; `%s` is the non-sensitive rejection disposition (`untrusted-origin` / `no-origin-proof`) — the raw offending `Origin` value is never logged +|ApiSheriff-111 |BFF |Token refresh failed for a require:session route (%s) — session destroyed |Logged when a transparent token refresh fails (IdP rejection or engine-detected refresh-token reuse) and the session is destroyed; `%s` is a bounded, non-sensitive reason — never the presented refresh token or session id +|ApiSheriff-112 |BFF |Back-channel logout token rejected: %s |Logged when a back-channel `logout_token` fails signature or claim validation; `%s` is the non-sensitive rejection disposition (`signature` / `claims`) — the raw logout token is never logged |=== == ERROR Level (200-299) From 9574592fb7216d06bed49b4e9d9aa7c62457090d Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:26:32 +0200 Subject: [PATCH 13/39] feat(bff): add session/user-info reserved endpoint (fold) Add the D11 session/user-info reserved gateway endpoint above the session-resolution seam: - UserInfoEndpoint: exact-match reserved path serving a curated identity view (allowlisted claims via the operator default view) plus server-held session metadata (expires_at, auth_time, acr). Source is validated ID-token claims (via the ClaimSource seam) and session metadata only, never raw tokens. No live session yields 401 application/problem+json (never a redirect); 403 is reserved for CSRF / a disallowed-claim request; Cache-Control: no-store on every response. - ClaimAllowlistFilter: operator-owned claim allowlist with a secure closed default (empty allowlist discloses nothing); the curated default view is capped to the allowlist at construction. - gateway.schema.json: mirror the D1 OidcConfig additions (oidc.user_info, oidc.login, oidc.session.max_sessions) so downstream YAML binding validates cleanly under additionalProperties:false. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bff/reserved/ClaimAllowlistFilter.java | 120 +++++++ .../bff/reserved/UserInfoEndpoint.java | 300 ++++++++++++++++++ .../main/resources/schema/gateway.schema.json | 30 ++ 3 files changed, 450 insertions(+) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilter.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/UserInfoEndpoint.java 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..ce632c51 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilter.java @@ -0,0 +1,120 @@ +/* + * 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 + */ +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/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/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" } + } } } } From 8b1eda7c571a1693c252e7c4557983419cadf93f Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:33:49 +0200 Subject: [PATCH 14/39] test(bff): cover session/user-info reserved endpoint (fold) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the D11 test suite completing the session/user-info deliverable: - UserInfoEndpointTest: 401-not-redirect XHR probe (problem+json, no Location), curated default-view disclosure, allowlist enforcement (a non-allowlisted claim absent from the default view; an explicitly requested disallowed claim rejected 403), full-view capping, no raw token material in any response body, and Cache-Control: no-store on every outcome (200 / 401 / 403). Exercised with a real InMemorySessionStore, the real SessionCookieCodec, and a hand-built ClaimSource lambda — no container, no test double framework. - ClaimAllowlistFilterTest: secure-default-closed (empty allowlist discloses nothing), default-view capping to the allowlist, membership, order-preserving filtering, immutability, and the null-argument contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../reserved/ClaimAllowlistFilterTest.java | 173 ++++++++++++ .../bff/reserved/UserInfoEndpointTest.java | 257 ++++++++++++++++++ 2 files changed, 430 insertions(+) create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilterTest.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/UserInfoEndpointTest.java diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilterTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilterTest.java new file mode 100644 index 00000000..00f58ae3 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilterTest.java @@ -0,0 +1,173 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link ClaimAllowlistFilter}: the operator-owned claim allowlist capping what the + * session/user-info endpoint may disclose. The security invariants under test — an empty allowlist + * discloses nothing, a claim outside the allowlist can never appear, and the curated default view is + * itself capped to the allowlist — are pure, container-free, and framework-agnostic. + */ +class ClaimAllowlistFilterTest { + + private static final Map SOURCE = Map.of( + "sub", "user-sub-1", + "name", "Alice Example", + "roles", List.of("admin", "user"), + "email", "alice@example.com"); + + @Nested + @DisplayName("Secure default closed") + class SecureDefault { + + @Test + @DisplayName("An empty allowlist discloses nothing") + void shouldDiscloseNothingWhenAllowlistEmpty() { + ClaimAllowlistFilter filter = new ClaimAllowlistFilter(List.of(), List.of("sub", "name")); + + assertTrue(filter.isEmpty(), "an empty allowlist is the secure closed default"); + assertTrue(filter.allowedClaims().isEmpty()); + assertTrue(filter.defaultView().isEmpty(), "the default view is capped away to nothing"); + assertTrue(filter.filter(SOURCE, List.of("sub", "name", "roles")).isEmpty(), + "no claim is disclosed through an empty allowlist"); + } + + @Test + @DisplayName("The default view is capped to the allowlist at construction") + void shouldCapDefaultViewToAllowlist() { + ClaimAllowlistFilter filter = new ClaimAllowlistFilter(List.of("sub", "name"), + List.of("sub", "name", "email")); + + assertIterableEquals(List.of("sub", "name"), filter.defaultView(), + "the email default-view entry outside the allowlist is dropped"); + } + } + + @Nested + @DisplayName("Allowlist membership") + class Membership { + + private final ClaimAllowlistFilter filter = new ClaimAllowlistFilter(List.of("sub", "name", "roles"), + List.of("sub", "name")); + + @Test + @DisplayName("isAllowed reflects the operator allowlist") + void shouldReportMembership() { + assertTrue(filter.isAllowed("sub")); + assertTrue(filter.isAllowed("roles")); + assertFalse(filter.isAllowed("email"), "a claim outside the allowlist is not permitted"); + } + + @Test + @DisplayName("A non-empty allowlist is not the closed default") + void shouldNotBeEmpty() { + assertFalse(filter.isEmpty()); + } + } + + @Nested + @DisplayName("Filtering") + class Filtering { + + private final ClaimAllowlistFilter filter = new ClaimAllowlistFilter(List.of("sub", "name", "roles"), + List.of("sub", "name")); + + @Test + @DisplayName("Only allowlisted, present claims are disclosed, in selection order") + void shouldDiscloseAllowlistedPresentClaimsInOrder() { + Map disclosed = filter.filter(SOURCE, List.of("name", "sub")); + + assertIterableEquals(List.of("name", "sub"), disclosed.keySet(), + "selection order is preserved"); + assertEquals("Alice Example", disclosed.get("name")); + assertEquals("user-sub-1", disclosed.get("sub")); + } + + @Test + @DisplayName("A requested claim outside the allowlist is silently dropped from the filtered result") + void shouldDropDisallowedRequestedClaim() { + Map disclosed = filter.filter(SOURCE, List.of("sub", "email")); + + assertFalse(disclosed.containsKey("email"), "email is not on the allowlist"); + assertTrue(disclosed.containsKey("sub")); + } + + @Test + @DisplayName("A selected claim absent from the source contributes nothing") + void shouldSkipSelectedClaimAbsentFromSource() { + Map disclosed = filter.filter(SOURCE, List.of("sub", "roles")); + + assertTrue(disclosed.containsKey("roles"), "roles is allowlisted and present"); + assertEquals(2, disclosed.size()); + } + } + + @Nested + @DisplayName("Immutability and argument contract") + class Contract { + + private final ClaimAllowlistFilter filter = new ClaimAllowlistFilter(List.of("sub", "name"), + List.of("sub")); + + @Test + @DisplayName("allowedClaims is immutable") + void shouldExposeImmutableAllowedClaims() { + assertThrows(UnsupportedOperationException.class, () -> filter.allowedClaims().add("email")); + } + + @Test + @DisplayName("defaultView is immutable") + void shouldExposeImmutableDefaultView() { + assertThrows(UnsupportedOperationException.class, () -> filter.defaultView().add("email")); + } + + @Test + @DisplayName("The filtered map is immutable") + void shouldReturnImmutableFilteredMap() { + Map disclosed = filter.filter(SOURCE, List.of("sub")); + + assertThrows(UnsupportedOperationException.class, () -> disclosed.put("email", "x")); + } + + @Test + @DisplayName("Null constructor arguments are rejected") + void shouldRejectNullConstructorArguments() { + assertThrows(NullPointerException.class, () -> new ClaimAllowlistFilter(null, List.of())); + assertThrows(NullPointerException.class, () -> new ClaimAllowlistFilter(List.of(), null)); + } + + @Test + @DisplayName("Null filter arguments are rejected") + void shouldRejectNullFilterArguments() { + assertThrows(NullPointerException.class, () -> filter.filter(null, List.of())); + assertThrows(NullPointerException.class, () -> filter.filter(SOURCE, null)); + assertThrows(NullPointerException.class, () -> filter.isAllowed(null)); + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/UserInfoEndpointTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/UserInfoEndpointTest.java new file mode 100644 index 00000000..b8451867 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/UserInfoEndpointTest.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.reserved; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + + +import de.cuioss.sheriff.gateway.bff.reserved.UserInfoEndpoint.ClaimSource; +import de.cuioss.sheriff.gateway.bff.reserved.UserInfoEndpoint.UserInfoOutcome; +import de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore; +import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec; +import de.cuioss.sheriff.gateway.bff.session.SessionRecord; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link UserInfoEndpoint}: the session/user-info reserved endpoint. The security-relevant + * contracts under test are the 401-not-redirect XHR probe, the curated default-view disclosure, the + * operator-allowlist cap (a claim outside the allowlist is absent from a default view and rejected + * {@code 403} when explicitly requested), the absence of any raw token material in the response, and + * the {@code Cache-Control: no-store} header on every outcome. + *

    + * The endpoint is framework-agnostic, so it is exercised with a real {@link InMemorySessionStore}, + * the real {@link SessionCookieCodec}, and a hand-built {@link ClaimSource} lambda — no container, no + * live IdP, no test double framework. + */ +class UserInfoEndpointTest { + + private static final Instant T0 = Instant.parse("2026-07-23T10:00:00Z"); + private static final Duration TTL = Duration.ofHours(8); + private static final String SESSION_ID = "opaque-session-1"; + private static final String SUBJECT = "user-sub-1"; + private static final String NAME = "Alice Example"; + private static final String EMAIL = "alice@example.com"; + private static final String ACR = "urn:mace:incommon:iap:silver"; + private static final Instant AUTH_TIME = Instant.ofEpochSecond(1_721_730_000L); + private static final String RAW_ACCESS_TOKEN = "raw-access-token-SECRET-material"; + private static final String RAW_ID_TOKEN = "raw-id-token-SECRET-material"; + + private InMemorySessionStore sessionStore; + private SessionCookieCodec sessionCodec; + private ClaimAllowlistFilter claimFilter; + private UserInfoEndpoint endpoint; + private String cookieHeader; + + @BeforeEach + void setUp() { + sessionStore = new InMemorySessionStore(16); + sessionCodec = new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, TTL); + claimFilter = new ClaimAllowlistFilter(List.of("sub", "name", "roles"), List.of("sub", "name", "roles")); + endpoint = new UserInfoEndpoint(sessionStore, sessionCodec, claimFilter, validatedClaims()); + + SessionRecord session = SessionRecord.builder() + .sessionId(SESSION_ID) + .accessToken(RAW_ACCESS_TOKEN) + .idToken(RAW_ID_TOKEN) + .sub(SUBJECT) + .expiresAt(T0.plus(TTL)) + .acr(Optional.of(ACR)) + .authTime(Optional.of(AUTH_TIME)) + .build(); + sessionStore.create(session); + cookieHeader = sessionCodec.toSetCookieHeader(SESSION_ID).split(";", 2)[0]; + } + + /** A validated-ID-token claim map carrying an allowlisted subset plus a non-allowlisted email. */ + private static ClaimSource validatedClaims() { + Map claims = Map.of( + "sub", SUBJECT, + "name", NAME, + "roles", List.of("admin", "user"), + "email", EMAIL); + return session -> claims; + } + + @SuppressWarnings("unchecked") + private static Map claimsOf(UserInfoOutcome outcome) { + return (Map) outcome.body().get("claims"); + } + + @SuppressWarnings("unchecked") + private static Map sessionOf(UserInfoOutcome outcome) { + return (Map) outcome.body().get("session"); + } + + @Nested + @DisplayName("Unauthenticated probe (401, never a redirect)") + class Unauthenticated { + + @Test + @DisplayName("No session cookie yields 401 application/problem+json — never a 302 redirect") + void shouldChallenge401NotRedirect() { + UserInfoOutcome outcome = endpoint.handle(null, null, T0); + + assertEquals(401, outcome.status(), "an XHR probe with no session is challenged 401"); + assertNotEquals(302, outcome.status(), "the user-info probe is never a redirect"); + assertFalse(outcome.isDisclosed()); + assertEquals("application/problem+json", outcome.headers().get("Content-Type")); + assertFalse(outcome.headers().containsKey("Location"), "no redirect location is ever emitted"); + } + + @Test + @DisplayName("An expired session is treated as unauthenticated (401)") + void shouldChallengeExpiredSession() { + UserInfoOutcome outcome = endpoint.handle(cookieHeader, null, T0.plus(TTL).plusSeconds(1)); + + assertEquals(401, outcome.status()); + assertFalse(outcome.isDisclosed()); + } + } + + @Nested + @DisplayName("Default-view disclosure") + class DefaultView { + + @Test + @DisplayName("The default view discloses the curated allowlisted claim subset") + void shouldDiscloseCuratedDefaultView() { + UserInfoOutcome outcome = endpoint.handle(cookieHeader, null, T0); + + assertEquals(200, outcome.status()); + assertTrue(outcome.isDisclosed()); + Map claims = claimsOf(outcome); + assertIterableEquals(List.of("sub", "name", "roles"), claims.keySet()); + assertEquals(NAME, claims.get("name")); + } + + @Test + @DisplayName("Session metadata (expiry, auth_time, acr) accompanies the claims") + void shouldDiscloseSessionMetadata() { + UserInfoOutcome outcome = endpoint.handle(cookieHeader, null, T0); + + Map session = sessionOf(outcome); + assertEquals(T0.plus(TTL).toString(), session.get("expires_at")); + assertEquals(AUTH_TIME.toString(), session.get("auth_time")); + assertEquals(ACR, session.get("acr")); + } + } + + @Nested + @DisplayName("Allowlist enforcement") + class AllowlistEnforcement { + + @Test + @DisplayName("A claim present in the token but outside the allowlist is absent from the default view") + void shouldOmitNonAllowlistedClaim() { + UserInfoOutcome outcome = endpoint.handle(cookieHeader, null, T0); + + assertFalse(claimsOf(outcome).containsKey("email"), "email is not on the operator allowlist"); + } + + @Test + @DisplayName("An explicitly-requested disallowed claim is rejected 403 before any disclosure") + void shouldReject403DisallowedRequestedClaim() { + UserInfoOutcome outcome = endpoint.handle(cookieHeader, "email", T0); + + assertEquals(403, outcome.status(), "a disallowed-claim request is 403"); + assertFalse(outcome.isDisclosed()); + assertEquals("application/problem+json", outcome.headers().get("Content-Type")); + } + + @Test + @DisplayName("A specific allowlisted claim selection discloses only that claim") + void shouldDiscloseRequestedAllowlistedSubset() { + UserInfoOutcome outcome = endpoint.handle(cookieHeader, "name", T0); + + assertIterableEquals(List.of("name"), claimsOf(outcome).keySet()); + } + + @Test + @DisplayName("The full view discloses every allowlisted claim present, never a disallowed one") + void shouldDiscloseFullAllowlistedView() { + UserInfoOutcome outcome = endpoint.handle(cookieHeader, UserInfoEndpoint.FULL_VIEW, T0); + + Map claims = claimsOf(outcome); + assertTrue(claims.keySet().containsAll(List.of("sub", "name", "roles"))); + assertFalse(claims.containsKey("email"), "the full view is still capped to the allowlist"); + } + } + + @Nested + @DisplayName("No token material and uncacheability") + class SecurityInvariants { + + @Test + @DisplayName("No raw token material ever appears in the response body") + void shouldNeverDiscloseTokenMaterial() { + UserInfoOutcome outcome = endpoint.handle(cookieHeader, UserInfoEndpoint.FULL_VIEW, T0); + + String rendered = outcome.body().toString(); + assertFalse(rendered.contains(RAW_ACCESS_TOKEN), "the mediated access token never leaves the server"); + assertFalse(rendered.contains(RAW_ID_TOKEN), "the raw ID token never leaves the server"); + } + + @Test + @DisplayName("Cache-Control: no-store is set on a disclosed view") + void shouldSetNoStoreOnDisclosure() { + UserInfoOutcome outcome = endpoint.handle(cookieHeader, null, T0); + + assertEquals("no-store", outcome.headers().get("Cache-Control")); + } + + @Test + @DisplayName("Cache-Control: no-store is set on a 401 challenge") + void shouldSetNoStoreOnChallenge() { + UserInfoOutcome outcome = endpoint.handle(null, null, T0); + + assertEquals("no-store", outcome.headers().get("Cache-Control")); + } + + @Test + @DisplayName("Cache-Control: no-store is set on a 403 rejection") + void shouldSetNoStoreOnForbidden() { + UserInfoOutcome outcome = endpoint.handle(cookieHeader, "email", T0); + + assertEquals("no-store", outcome.headers().get("Cache-Control")); + } + } + + @Nested + @DisplayName("Argument contract") + class ArgumentContract { + + @Test + @DisplayName("A null reference instant is rejected") + void shouldRejectNullNow() { + assertThrows(NullPointerException.class, () -> endpoint.handle(cookieHeader, null, null)); + } + } +} From b2d8888c1765c602ff2d008fc891b1680315671b Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:48:36 +0200 Subject: [PATCH 15/39] feat(bff): add login-initiation reserved path with already-authenticated short-circuit The sixth reserved gateway path (oidc.login.path) folds onto the D5 LoginFlow and the D2b pending-authorization record: an unauthenticated caller is driven into the auth-code flow (binding cookie set) and returns to the same-origin- validated return URL; an already-authenticated caller short-circuits straight to the validated return URL without a fresh auth-code flow. The return URL is same-origin-validated on both paths, so the login-initiation URL is never an open redirect. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bff/reserved/LoginInitiationEndpoint.java | 173 ++++++++++++++ .../reserved/LoginInitiationEndpointTest.java | 216 ++++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpoint.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpointTest.java 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..20ce1c39 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpoint.java @@ -0,0 +1,173 @@ +/* + * 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 = 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/test/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpointTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpointTest.java new file mode 100644 index 00000000..dcd0b106 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpointTest.java @@ -0,0 +1,216 @@ +/* + * 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 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.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + + +import de.cuioss.sheriff.gateway.bff.login.LoginFlow; +import de.cuioss.sheriff.gateway.bff.login.LoginFlow.AuthorizationInitiation; +import de.cuioss.sheriff.gateway.bff.pending.BindingCookieCodec; +import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord; +import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationStore; +import de.cuioss.sheriff.gateway.bff.reserved.LoginInitiationEndpoint.LoginInitiationOutcome; +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.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 LoginInitiationEndpoint}: the login-initiation reserved endpoint. The + * security-relevant contracts under test are that an unauthenticated caller is driven into the D5 + * auth-code flow (binding cookie set, engine authorization reached) and returns to the same-origin- + * validated return URL; that an off-origin, schema-relative, or unparseable {@code returnUrl} can + * never become the redirect location (never an open redirect) on either path; and — the explicitly + * defined behaviour — that an already-authenticated caller short-circuits straight to the validated + * return URL without a fresh auth-code flow (no engine authorization, no pending record, no + * binding cookie). + *

    + * The endpoint is framework-agnostic, so it is exercised with the real {@link LoginFlow} over a + * hand-built {@link AuthorizationInitiation} seam (a counter proves whether the fresh flow ran), a + * real {@link InMemorySessionStore}, and the real {@link SessionCookieCodec} — no container, no live + * IdP, no test double framework. + */ +class LoginInitiationEndpointTest { + + private static final Instant T0 = Instant.parse("2026-07-23T10:00:00Z"); + private static final Duration SESSION_TTL = Duration.ofHours(8); + 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 static final String SESSION_ID = "opaque-session-1"; + private static final String SUBJECT = "user-sub-1"; + + private PendingAuthorizationStore.InMemory pendingStore; + private BindingCookieCodec bindingCodec; + private InMemorySessionStore sessionStore; + private SessionCookieCodec sessionCodec; + private AtomicInteger authorizeCalls; + private LoginInitiationEndpoint endpoint; + + @BeforeEach + void setUp() { + pendingStore = new PendingAuthorizationStore.InMemory(8); + bindingCodec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL); + FlowContext flowContext = FlowContext.create(GATEWAY_ORIGIN + "/auth/callback"); + AuthorizationCodeFlow.AuthorizationRedirect redirect = + new AuthorizationCodeFlow.AuthorizationRedirect(AUTHORIZATION_URL, flowContext); + authorizeCalls = new AtomicInteger(); + AuthorizationInitiation authorization = () -> { + authorizeCalls.incrementAndGet(); + return redirect; + }; + LoginFlow loginFlow = new LoginFlow(authorization, pendingStore, bindingCodec, GATEWAY_ORIGIN); + + sessionStore = new InMemorySessionStore(16); + sessionCodec = new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, SESSION_TTL); + endpoint = new LoginInitiationEndpoint(loginFlow, sessionStore, sessionCodec, GATEWAY_ORIGIN); + } + + /** Creates a live session and returns the request {@code Cookie} header that resolves it. */ + private String liveSessionCookie() { + SessionRecord session = SessionRecord.builder() + .sessionId(SESSION_ID) + .accessToken("raw-access-token") + .idToken("raw-id-token") + .sub(SUBJECT) + .expiresAt(T0.plus(SESSION_TTL)) + .acr(Optional.empty()) + .authTime(Optional.empty()) + .build(); + sessionStore.create(session); + return sessionCodec.toSetCookieHeader(SESSION_ID).split(";", 2)[0]; + } + + /** Consumes the pending record bound by the binding cookie the fresh flow set. */ + private PendingAuthorizationRecord consumeBoundRecord(LoginInitiationOutcome outcome) { + String cookie = outcome.setCookieHeaders().getFirst().split(";", 2)[0]; + Optional recordId = bindingCodec.readRecordId(cookie); + assertTrue(recordId.isPresent(), "a fresh login sets a binding cookie carrying the record id"); + return pendingStore.consume(recordId.get(), T0).orElseThrow(); + } + + @Nested + @DisplayName("Unauthenticated caller (drives the auth-code flow)") + class Unauthenticated { + + @Test + @DisplayName("Should redirect to the engine authorization URL and set the binding cookie") + void shouldDriveAuthCodeFlow() { + LoginInitiationOutcome outcome = endpoint.initiate("/dashboard", null, T0); + + assertEquals(302, outcome.status()); + assertTrue(outcome.isRedirect()); + assertEquals(AUTHORIZATION_URL, outcome.location(), "the URL comes from the engine, unchanged"); + assertEquals(1, authorizeCalls.get(), "the fresh auth-code flow was driven exactly once"); + assertEquals(1, outcome.setCookieHeaders().size()); + assertTrue(outcome.setCookieHeaders().getFirst().startsWith(BindingCookieCodec.COOKIE_NAME + "=")); + } + + @Test + @DisplayName("Should return to the same-origin-validated return URL after the callback") + void shouldRecordSameOriginReturnUrl() { + LoginInitiationOutcome outcome = endpoint.initiate("/dashboard", null, T0); + + assertEquals("/dashboard", consumeBoundRecord(outcome).returnUrl()); + } + + @ParameterizedTest(name = "off-origin returnUrl \"{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 an off-origin return URL (never an open redirect)") + void shouldRejectOffOriginReturnUrl(String returnUrl) { + LoginInitiationOutcome outcome = endpoint.initiate(returnUrl, null, T0); + + assertEquals(LoginFlow.DEFAULT_RETURN_URL, consumeBoundRecord(outcome).returnUrl()); + } + + @Test + @DisplayName("Should treat an expired session as unauthenticated and drive a fresh flow") + void shouldTreatExpiredSessionAsUnauthenticated() { + String cookie = liveSessionCookie(); + + LoginInitiationOutcome outcome = endpoint.initiate("/dashboard", cookie, T0.plus(SESSION_TTL).plusSeconds(1)); + + assertEquals(AUTHORIZATION_URL, outcome.location(), "an expired session cannot short-circuit"); + assertEquals(1, authorizeCalls.get()); + } + } + + @Nested + @DisplayName("Already-authenticated caller (short-circuit, no fresh flow)") + class AlreadyAuthenticated { + + @Test + @DisplayName("Should redirect straight to the validated return URL without a fresh auth-code flow") + void shouldShortCircuitToReturnUrl() { + String cookie = liveSessionCookie(); + + LoginInitiationOutcome outcome = endpoint.initiate("/dashboard", cookie, T0); + + assertEquals(302, outcome.status()); + assertEquals("/dashboard", outcome.location(), "a live session lands straight on the return URL"); + assertEquals(0, authorizeCalls.get(), "no fresh auth-code flow is driven for an authenticated caller"); + assertTrue(outcome.setCookieHeaders().isEmpty(), "no binding cookie — there is no new login transaction"); + } + + @ParameterizedTest(name = "off-origin returnUrl \"{0}\" is refused on the short-circuit too") + @ValueSource(strings = {"https://evil.example.com", "//evil.example.com", "not a uri"}) + @DisplayName("Should refuse an off-origin return URL on the short-circuit (never an open redirect)") + void shouldRejectOffOriginReturnUrlOnShortCircuit(String returnUrl) { + String cookie = liveSessionCookie(); + + LoginInitiationOutcome outcome = endpoint.initiate(returnUrl, cookie, T0); + + assertEquals(LoginFlow.DEFAULT_RETURN_URL, outcome.location(), "an off-origin target can never be the location"); + assertEquals(0, authorizeCalls.get()); + } + + @Test + @DisplayName("Should fall back to the default landing when no return URL is supplied") + void shouldFallBackForNullReturnUrl() { + String cookie = liveSessionCookie(); + + LoginInitiationOutcome outcome = endpoint.initiate(null, cookie, T0); + + assertEquals(LoginFlow.DEFAULT_RETURN_URL, outcome.location()); + } + } + + @Nested + @DisplayName("Argument contract") + class ArgumentContract { + + @Test + @DisplayName("Should reject a null reference instant") + void shouldRejectNullNow() { + assertThrows(NullPointerException.class, () -> endpoint.initiate("/dashboard", null, null)); + } + } +} From 7de0af972021e28b3ba93c6192c9ceacdd8ed9bb Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:59:39 +0200 Subject: [PATCH 16/39] feat(bff): wire server-mode BFF runtime into the live gateway edge Close the D13 gap: the D1-D12 BFF components were unit-green but never invoked at the live gateway edge. This deliverable (D16) makes them reachable. - ReservedPathRegistry.from now also registers the user_info (D11) and login (D12) reserved endpoints alongside the existing four. - GatewayEdgeRoute dispatches a matched reserved path to its handler (callback/logout/logout-return/back-channel/user-info/login) instead of rendering NO_ROUTE_MATCHED, runs require:session routes through the session-aware AuthenticationStage/SessionAuthenticationStage, and enforces the fixed CSRF defence on unsafe-method session requests. - New BffRuntime holds the wired session stage, CSRF defence, step-up coordinator, and reserved-endpoint handlers, and normalizes the heterogeneous handler outcomes for the edge to render. - New BffRuntimeProducer instantiates the server-mode runtime only when a global oidc block with session.mode=server + redirect_uri is configured (inert bearer-only path otherwise) and binds the token-sheriff-client engine seams (authorize/exchange/refresh/step-up) with lazy OIDC discovery so boot needs no live IdP. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bff/reserved/ReservedPathRegistry.java | 20 +- .../gateway/bff/runtime/BffRuntime.java | 315 ++++++++++++++ .../gateway/bff/runtime/JsonWriter.java | 116 +++++ .../gateway/edge/GatewayEdgeRoute.java | 114 ++++- .../gateway/quarkus/BffRuntimeProducer.java | 337 +++++++++++++++ .../gateway/edge/GatewayEdgePipelineTest.java | 3 +- .../edge/GatewayEdgeRouteBffWiringTest.java | 400 ++++++++++++++++++ .../gateway/edge/GatewayEdgeRouteTest.java | 3 +- .../gateway/edge/WebSocketRelayStageTest.java | 3 +- .../quarkus/BffRuntimeProducerTest.java | 225 ++++++++++ 10 files changed, 1521 insertions(+), 15 deletions(-) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/JsonWriter.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java 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 index d5471a2f..b03bcb7b 100644 --- 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 @@ -29,10 +29,12 @@ /** * The exact-match registry of the gateway's reserved OIDC endpoints (D2). *

    - * The BFF variants carve four gateway-owned paths out of the proxy route table — the + * 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, and the {@code oidc.logout.backchannel_path} - * receiver. Each is matched exactly (never by prefix) and only on the + * {@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 @@ -66,7 +68,13 @@ public enum ReservedEndpoint { LOGOUT_RETURN, /** The {@code oidc.logout.backchannel_path} back-channel logout receiver. */ - BACKCHANNEL_LOGOUT + 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; @@ -102,6 +110,10 @@ public static ReservedPathRegistry from(Optional oidc) { .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); } 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..1dce21e9 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java @@ -0,0 +1,315 @@ +/* + * 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: + *

      + *
    1. 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;
    2. + *
    3. {@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.
    4. + *
    + * 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; + + 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) + */ + 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(req.rawQuery(), 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)); + }; + } + + 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 + * — the 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 for back-channel + * logout, may be absent + * @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) { + + /** + * Canonical constructor normalizing an absent raw query to the empty string. + */ + public ReservedHttpRequest { + rawQuery = Objects.requireNonNullElse(rawQuery, ""); + } + } + + /** + * 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..a82a01b2 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/JsonWriter.java @@ -0,0 +1,116 @@ +/* + * 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 -> out.append(number); + case Map map -> writeObject(out, map); + case Collection collection -> writeArray(out, collection); + default -> writeString(out, String.valueOf(value)); + } + } + + 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/edge/GatewayEdgeRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java index b35d1866..e5414268 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,12 +19,14 @@ 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; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.Semaphore; @@ -45,6 +47,8 @@ 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; @@ -142,6 +146,13 @@ 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; @@ -159,6 +170,7 @@ public class GatewayEdgeRoute { private final UpstreamFailureMapper upstreamFailureMapper; private final SheriffMetrics sheriffMetrics; private final ReservedPathRegistry reservedPathRegistry; + private final BffRuntime bffRuntime; private final SecurityHeadersStage securityHeadersStage; private final BasicChecksStage basicChecksStage; @@ -196,15 +208,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(); @@ -240,7 +259,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(); @@ -396,13 +420,21 @@ private void process(RoutingContext ctx) { return; } passthroughHostGuardStage.process(request); - // Reserved-path carve-out (D2): a reserved OIDC endpoint is resolved here, ahead of the + // Reserved-path carve-out (D2/D16): a reserved OIDC endpoint is resolved here, ahead of the // route table, so a proxy route such as path_prefix: /auth can never swallow the exact - // /auth/callback. The reserved-endpoint handlers are wired by the session runtime; until - // then a matched reserved path is carved out of proxy dispatch (never proxied to an upstream). - if (reservedPathRegistry.isReserved(request.host(), requireCanonicalPath(request))) { - LOGGER.debug("Reserved gateway path carved out of proxy dispatch: %s", requireCanonicalPath(request)); - renderProblem(ctx, request, EventType.NO_ROUTE_MATCHED); + // /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 NO_ROUTE_MATCHED (the empty reserved registry never matches there anyway). + Optional reserved = + reservedPathRegistry.match(request.host(), requireCanonicalPath(request)); + if (reserved.isPresent()) { + 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; } routeSelectionStage.process(request); @@ -410,6 +442,12 @@ private void process(RoutingContext ctx) { 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); ForwardPolicyStage.Result forward = forwardPolicyStage.process(request, route.getEffectiveForward(), route.isNotModifiedEnabled()); @@ -445,6 +483,66 @@ private void process(RoutingContext ctx) { } } + /** + * 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 rawFormBody = kind == ReservedEndpoint.BACKCHANNEL_LOGOUT ? 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); + 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(); + } + + /** + * Reads the raw {@code application/x-www-form-urlencoded} back-channel logout body on the virtual + * thread. A read failure yields {@code null}, which the receiver rejects {@code 400} — the fail- + * closed default (a body the gateway could not read is not an accepted logout token). + */ + private static @Nullable String readFormBody(RoutingContext ctx) { + // The catch is a deliberate boundary: a body-read failure must degrade to a rejected logout + // rather than escape onto the request path, so the null return drives the receiver's 400. + // cui-rewrite:disable InvalidExceptionUsageRecipe + try { + Buffer body = ctx.request().body().toCompletionStage().toCompletableFuture().get(); + return body == null ? null : body.toString(StandardCharsets.UTF_8); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return null; + } catch (ExecutionException failure) { + LOGGER.debug(failure, "Back-channel logout body read failed: %s", failure.getMessage()); + return null; + } + } + 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/quarkus/BffRuntimeProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java new file mode 100644 index 00000000..2b72d2b7 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java @@ -0,0 +1,337 @@ +/* + * 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, + (record, now) -> refreshCoordinator.refresh(record, now).session().orElse(record), + (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( + (record, 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. + */ + private 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); + if (port != -1) { + origin.append(':').append(port); + } + return origin.toString(); + } + + /** + * 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/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgePipelineTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgePipelineTest.java index 030d5f30..d5e4aa8f 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgePipelineTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgePipelineTest.java @@ -34,6 +34,7 @@ import de.cuioss.http.security.config.SecurityConfiguration; +import de.cuioss.sheriff.gateway.bff.runtime.BffRuntime; import de.cuioss.sheriff.gateway.config.model.AuthConfig; import de.cuioss.sheriff.gateway.config.model.GatewayConfig; import de.cuioss.sheriff.gateway.config.model.HttpMethod; @@ -113,7 +114,7 @@ void setUp() throws Exception { GatewayEdgeRoute edge = new GatewayEdgeRoute(routeTable, gatewayConfig, new SingletonInstance<>(tokenValidator), vertx, virtualThreadExecutor, - new EdgeHardeningOptions(), new SheriffMetrics(new SimpleMeterRegistry())); + new EdgeHardeningOptions(), new SheriffMetrics(new SimpleMeterRegistry()), BffRuntime.inert()); Router router = Router.router(vertx); edge.registerRoutes(router); diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java new file mode 100644 index 00000000..85c0b675 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java @@ -0,0 +1,400 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.edge; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.annotation.Annotation; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + + +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.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.ReservedPathRegistry; +import de.cuioss.sheriff.gateway.bff.reserved.ReservedPathRegistry.ReservedEndpoint; +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.SessionRecord; +import de.cuioss.sheriff.gateway.bff.session.SessionStore; +import de.cuioss.sheriff.gateway.config.model.AuthConfig; +import de.cuioss.sheriff.gateway.config.model.GatewayConfig; +import de.cuioss.sheriff.gateway.config.model.HttpMethod; +import de.cuioss.sheriff.gateway.config.model.MatchConfig; +import de.cuioss.sheriff.gateway.config.model.OidcConfig; +import de.cuioss.sheriff.gateway.config.model.Protocol; +import de.cuioss.sheriff.gateway.config.model.ResolvedRoute; +import de.cuioss.sheriff.gateway.config.model.ResolvedUpstream; +import de.cuioss.sheriff.gateway.config.model.RouteTable; +import de.cuioss.sheriff.gateway.quarkus.SheriffMetrics; +import de.cuioss.sheriff.token.client.logout.EndSessionFlow; +import de.cuioss.sheriff.token.client.logout.PostLogoutRedirectValidator; +import de.cuioss.sheriff.token.validation.TokenValidator; +import de.cuioss.sheriff.token.validation.test.generator.TestTokenGenerators; +import de.cuioss.test.generator.junit.EnableGeneratorController; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.vertx.core.Vertx; +import io.vertx.ext.web.Router; +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.util.TypeLiteral; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Covers the D16 edge wiring of the server-mode BFF runtime: the {@link ReservedPathRegistry} now + * registers the {@code user_info} and {@code login} folds, the {@link GatewayEdgeRoute} assembles the + * session-aware authentication stage when an active runtime is wired, and {@link BffRuntime#dispatch} + * routes each reserved path to its handler rather than the pre-wiring {@code NO_ROUTE_MATCHED} 404. + * The live per-request serving over a Vert.x server (and the engine round-trips) is exercised by the + * Keycloak integration tests; these deterministic module tests stay container- and IdP-free. + */ +@EnableGeneratorController +@DisplayName("GatewayEdgeRoute — server-mode BFF runtime edge wiring (D16)") +class GatewayEdgeRouteBffWiringTest { + + private static final String OIDC_HOST = "gw.example.com"; + private static final String ORIGIN = "https://gw.example.com"; + private static final String CALLBACK_PATH = "/auth/callback"; + private static final String LOGOUT_PATH = "/auth/logout"; + private static final String LOGOUT_RETURN_PATH = "/auth/logout/return"; + private static final String BACKCHANNEL_PATH = "/auth/backchannel"; + private static final String USER_INFO_PATH = "/auth/userinfo"; + private static final String LOGIN_PATH = "/auth/login"; + + @Nested + @DisplayName("ReservedPathRegistry registers the user_info and login folds (D11/D12)") + class RegistryFolds { + + private final ReservedPathRegistry registry = ReservedPathRegistry.from(Optional.of(fullOidc())); + + @Test + @DisplayName("Should register the user_info fold path as USER_INFO") + void shouldRegisterUserInfo() { + assertEquals(Optional.of(ReservedEndpoint.USER_INFO), registry.match(OIDC_HOST, USER_INFO_PATH)); + } + + @Test + @DisplayName("Should register the login fold path as LOGIN") + void shouldRegisterLogin() { + assertEquals(Optional.of(ReservedEndpoint.LOGIN), registry.match(OIDC_HOST, LOGIN_PATH)); + } + + @Test + @DisplayName("Should keep the original four reserved paths alongside the two new folds") + void shouldKeepOriginalReserved() { + assertEquals(Optional.of(ReservedEndpoint.CALLBACK), registry.match(OIDC_HOST, CALLBACK_PATH)); + assertEquals(Optional.of(ReservedEndpoint.LOGOUT), registry.match(OIDC_HOST, LOGOUT_PATH)); + assertEquals(Optional.of(ReservedEndpoint.LOGOUT_RETURN), registry.match(OIDC_HOST, LOGOUT_RETURN_PATH)); + assertEquals(Optional.of(ReservedEndpoint.BACKCHANNEL_LOGOUT), registry.match(OIDC_HOST, BACKCHANNEL_PATH)); + } + } + + @Nested + @DisplayName("GatewayEdgeRoute assembles the session-aware stage when the runtime is active") + class EdgeAssembly { + + private Vertx vertx; + private ExecutorService virtualThreadExecutor; + private TokenValidator tokenValidator; + + @BeforeEach + void setUp() { + vertx = Vertx.vertx(); + virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor(); + tokenValidator = TokenValidator.builder() + .issuerConfig(TestTokenGenerators.accessTokens().next().getIssuerConfig()).build(); + } + + @AfterEach + void tearDown() { + virtualThreadExecutor.close(); + vertx.close(); + } + + @Test + @DisplayName("Should boot a require:session route through the session-aware AuthenticationStage") + void shouldBootSessionRouteWithActiveRuntime() { + RouteTable sessionTable = new RouteTable(List.of(sessionRoute())); + assertDoesNotThrow(() -> newEdge(sessionTable, activeRuntime(new InMemorySessionStore(16))), + "A require:session route assembles through the wired SessionAuthenticationStage"); + } + + @Test + @DisplayName("Should still register a single catch-all route with an active runtime") + void shouldRegisterCatchAll() { + GatewayEdgeRoute edge = newEdge(new RouteTable(List.of()), activeRuntime(new InMemorySessionStore(16))); + Router router = Router.router(vertx); + edge.registerRoutes(router); + assertEquals(1, router.getRoutes().size()); + } + + private GatewayEdgeRoute newEdge(RouteTable table, BffRuntime runtime) { + return new GatewayEdgeRoute(table, GatewayConfig.builder().version(1).build(), + new SingletonInstance<>(tokenValidator), vertx, virtualThreadExecutor, new EdgeHardeningOptions(), + new SheriffMetrics(new SimpleMeterRegistry()), runtime); + } + } + + @Nested + @DisplayName("BffRuntime.dispatch routes each reserved path to its handler (not NO_ROUTE_MATCHED)") + class ReservedDispatch { + + private final SessionStore store = new InMemorySessionStore(16); + private final SessionCookieCodec codec = new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, + Duration.ofHours(1)); + private final BffRuntime runtime = activeRuntime(store, codec); + private final Instant now = Instant.parse("2026-07-25T10:00:00Z"); + + @Test + @DisplayName("USER_INFO with no session cookie yields 401 from the user-info handler") + void shouldDispatchUserInfo() { + BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.USER_INFO, + request(null, null), now); + assertEquals(401, response.status()); + } + + @Test + @DisplayName("CALLBACK with a stateless query yields 400 from the callback handler") + void shouldDispatchCallback() { + BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.CALLBACK, + new BffRuntime.ReservedHttpRequest("", null, null, null, null, null), now); + assertEquals(400, response.status()); + } + + @Test + @DisplayName("LOGOUT without a live session redirects (302) to final_redirect") + void shouldDispatchLogout() { + BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.LOGOUT, + request(null, null), now); + assertEquals(302, response.status()); + } + + @Test + @DisplayName("LOGOUT_RETURN without a logout-state cookie yields 400") + void shouldDispatchLogoutReturn() { + BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.LOGOUT_RETURN, + request(null, null), now); + assertEquals(400, response.status()); + } + + @Test + @DisplayName("BACKCHANNEL_LOGOUT with no form body yields 400 and is uncacheable") + void shouldDispatchBackchannel() { + BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.BACKCHANNEL_LOGOUT, + new BffRuntime.ReservedHttpRequest("", null, null, null, null, null), now); + assertEquals(400, response.status()); + assertEquals("no-store", response.headers().get("Cache-Control")); + } + + @Test + @DisplayName("LOGIN with a live session short-circuits (302) to the validated return URL") + void shouldDispatchLogin() { + String sessionId = SessionRecord.newSessionId(); + store.create(SessionRecord.builder().sessionId(sessionId).accessToken("a").idToken("i").sub("sub") + .expiresAt(now.plus(Duration.ofHours(1))).build()); + String cookie = SessionCookieCodec.DEFAULT_COOKIE_NAME + "=" + sessionId; + BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.LOGIN, + new BffRuntime.ReservedHttpRequest("", cookie, null, "/home", null, null), now); + assertEquals(302, response.status()); + assertTrue(response.locationOptional().isPresent()); + } + + private BffRuntime.ReservedHttpRequest request(String cookie, String claims) { + return new BffRuntime.ReservedHttpRequest("", cookie, claims, null, null, null); + } + } + + private static OidcConfig fullOidc() { + OidcConfig.Logout logout = OidcConfig.Logout.builder() + .path(Optional.of(LOGOUT_PATH)) + .postLogoutRedirectUri(Optional.of(ORIGIN + LOGOUT_RETURN_PATH)) + .backchannelPath(Optional.of(BACKCHANNEL_PATH)) + .build(); + return OidcConfig.builder() + .redirectUri(Optional.of(ORIGIN + CALLBACK_PATH)) + .logout(Optional.of(logout)) + .userInfo(Optional.of(OidcConfig.UserInfo.builder().path(Optional.of(USER_INFO_PATH)).build())) + .login(Optional.of(OidcConfig.Login.builder().path(Optional.of(LOGIN_PATH)).build())) + .build(); + } + + private static BffRuntime activeRuntime(SessionStore store) { + return activeRuntime(store, new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, + Duration.ofHours(1))); + } + + /** + * Assembles a fully-wired active runtime with engine-free test seams — every reserved-endpoint + * handler is real, but the seams that would reach the confidential-client engine throw or no-op, + * so the paths these tests drive (no-session, no-input, live-session short-circuit) never touch a + * live IdP. + */ + private static BffRuntime activeRuntime(SessionStore store, SessionCookieCodec codec) { + BindingCookieCodec bindingCodec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL); + PendingAuthorizationStore pendingStore = new PendingAuthorizationStore.InMemory(16); + Duration ttl = Duration.ofHours(1); + + LoginFlow loginFlow = new LoginFlow(() -> { + throw new AssertionError("engine authorize must not be reached"); + }, pendingStore, bindingCodec, ORIGIN); + + SessionAuthenticationStage sessionStage = new SessionAuthenticationStage(store, codec, + (record, instant) -> record, + (token, scopes) -> true, + (returnUrl, instant) -> new SessionAuthenticationStage.LoginChallenge("/login", List.of()), + Clock.systemUTC()); + + CsrfDefence csrf = new CsrfDefence(Set.of(ORIGIN)); + + StepUpCoordinator stepUp = new StepUpCoordinator( + (record, challenge, instant) -> Optional.empty(), + challenge -> { + throw new AssertionError("engine step-up must not be reached"); + }, + pendingStore, bindingCodec, ORIGIN); + + CallbackEndpoint callback = new CallbackEndpoint((context, params) -> { + throw new AssertionError("engine exchange must not be reached"); + }, pendingStore, bindingCodec, store, codec, ttl); + + BackchannelLogoutEndpoint backchannel = new BackchannelLogoutEndpoint(new BackchannelLogoutReceiver( + rawToken -> { + throw new AssertionError("engine verify must not be reached"); + }, + new LogoutTokenValidator(ORIGIN, "client", Duration.ofMinutes(2)), store)); + + UserInfoEndpoint userInfo = new UserInfoEndpoint(store, codec, + new ClaimAllowlistFilter(List.of("sub"), List.of("sub")), + session -> Map.of("sub", session.sub())); + + LoginInitiationEndpoint login = new LoginInitiationEndpoint(loginFlow, store, codec, ORIGIN); + + return new BffRuntime(sessionStage, csrf, stepUp, callback, () -> logoutEndpoint(store, codec), backchannel, + userInfo, login); + } + + private static LogoutEndpoint logoutEndpoint(SessionStore store, SessionCookieCodec codec) { + EndSessionFlow endSessionFlow = new EndSessionFlow( + new PostLogoutRedirectValidator(Set.of(ORIGIN + LOGOUT_RETURN_PATH))); + RpInitiatedLogout rpInitiatedLogout = new RpInitiatedLogout(endSessionFlow, session -> { + }, "https://idp.example.com/logout", ORIGIN + LOGOUT_RETURN_PATH, "/", Duration.ofMinutes(1)); + return new LogoutEndpoint(rpInitiatedLogout, store, codec); + } + + private static ResolvedRoute sessionRoute() { + return ResolvedRoute.builder() + .id("s") + .protocol(Protocol.HTTP) + .match(MatchConfig.builder().pathPrefix("/s").build()) + .effectiveAuth(AuthConfig.builder().require("session").build()) + .effectiveAllowedMethods(List.of(HttpMethod.GET)) + .upstream(Optional.of(new ResolvedUpstream("https", "s.example", 443, ""))) + .build(); + } + + /** + * Minimal {@link Instance} test double resolving to a single supplied bean. The assembly tests + * exercise only construction; no require:session route validates a bearer token, so + * {@link #get()} is never called and the remaining accessors throw. + */ + private static final class SingletonInstance implements Instance { + + private final T value; + + SingletonInstance(T value) { + this.value = value; + } + + @Override + public T get() { + return value; + } + + @Override + public Instance select(Annotation... qualifiers) { + throw new UnsupportedOperationException(); + } + + @Override + public Instance select(Class subtype, Annotation... qualifiers) { + throw new UnsupportedOperationException(); + } + + @Override + public Instance select(TypeLiteral subtype, Annotation... qualifiers) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isUnsatisfied() { + return false; + } + + @Override + public boolean isAmbiguous() { + return false; + } + + @Override + public void destroy(T instance) { + // no-op: the test double owns no lifecycle + } + + @Override + public Handle getHandle() { + throw new UnsupportedOperationException(); + } + + @Override + public Iterable> handles() { + throw new UnsupportedOperationException(); + } + + @Override + public Iterator iterator() { + return List.of(value).iterator(); + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteTest.java index 27936198..6c4e47d7 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteTest.java @@ -28,6 +28,7 @@ import java.util.concurrent.Executors; +import de.cuioss.sheriff.gateway.bff.runtime.BffRuntime; import de.cuioss.sheriff.gateway.config.model.AuthConfig; import de.cuioss.sheriff.gateway.config.model.GatewayConfig; import de.cuioss.sheriff.gateway.config.model.HttpMethod; @@ -178,7 +179,7 @@ void drainsPromptlyWhenIdle() { private GatewayEdgeRoute newEdge(RouteTable table) { return new GatewayEdgeRoute(table, gatewayConfig, new SingletonInstance<>(tokenValidator), vertx, - virtualThreadExecutor, hardening, new SheriffMetrics(new SimpleMeterRegistry())); + virtualThreadExecutor, hardening, new SheriffMetrics(new SimpleMeterRegistry()), BffRuntime.inert()); } /** diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/WebSocketRelayStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/WebSocketRelayStageTest.java index b2ca21e1..51ff1d9c 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/WebSocketRelayStageTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/WebSocketRelayStageTest.java @@ -35,6 +35,7 @@ import java.util.concurrent.atomic.AtomicReference; +import de.cuioss.sheriff.gateway.bff.runtime.BffRuntime; import de.cuioss.sheriff.gateway.config.model.AuthConfig; import de.cuioss.sheriff.gateway.config.model.GatewayConfig; import de.cuioss.sheriff.gateway.config.model.HttpMethod; @@ -127,7 +128,7 @@ void setUp() throws Exception { .build(); GatewayEdgeRoute edge = new GatewayEdgeRoute(routeTable, gatewayConfig, new SingletonInstance<>(tokenValidator), vertx, virtualThreadExecutor, - new EdgeHardeningOptions(), new SheriffMetrics(new SimpleMeterRegistry())); + new EdgeHardeningOptions(), new SheriffMetrics(new SimpleMeterRegistry()), BffRuntime.inert()); Router router = Router.router(vertx); edge.registerRoutes(router); diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java new file mode 100644 index 00000000..f9e2f19d --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java @@ -0,0 +1,225 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.quarkus; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.annotation.Annotation; +import java.time.Instant; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; + + +import de.cuioss.sheriff.gateway.bff.reserved.ReservedPathRegistry.ReservedEndpoint; +import de.cuioss.sheriff.gateway.bff.runtime.BffRuntime; +import de.cuioss.sheriff.gateway.config.model.GatewayConfig; +import de.cuioss.sheriff.gateway.config.model.OidcConfig; +import de.cuioss.sheriff.token.validation.TokenValidator; +import de.cuioss.sheriff.token.validation.test.generator.TestTokenGenerators; +import de.cuioss.test.generator.junit.EnableGeneratorController; + +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.util.TypeLiteral; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Covers {@link BffRuntimeProducer}: the runtime is active (and its reserved handlers and session + * stage are wired) only when a global {@code oidc} block with {@code session.mode=server} and a + * {@code redirect_uri} is configured, and inert (bearer-only) otherwise. Assembly resolves no OIDC + * discovery (that is deferred to first engine use), so the producer builds a working runtime without + * a live IdP; the live engine round-trips are covered by the Keycloak integration tests. + */ +@EnableGeneratorController +@DisplayName("BffRuntimeProducer — server-mode activation and inert bearer-only default") +class BffRuntimeProducerTest { + + private static final String ORIGIN = "https://gw.example.com"; + private static final String REDIRECT_URI = ORIGIN + "/auth/callback"; + private static final String ISSUER = "https://idp.example.com"; + + private final TokenValidator tokenValidator = TokenValidator.builder() + .issuerConfig(TestTokenGenerators.accessTokens().next().getIssuerConfig()).build(); + + @Nested + @DisplayName("Active server-mode runtime") + class Active { + + private final BffRuntime runtime = producer(serverModeOidc()).bffRuntime(); + + @Test + @DisplayName("Should activate the runtime and expose the session stage and CSRF defence") + void shouldActivate() { + assertTrue(runtime.isActive()); + assertNotNull(runtime.sessionStage()); + assertNotNull(runtime.csrfDefence()); + assertNotNull(runtime.stepUpCoordinator()); + } + + @Test + @DisplayName("Should assemble without resolving OIDC discovery (no live IdP required)") + void shouldAssembleWithoutDiscovery() { + assertDoesNotThrow(() -> producer(serverModeOidc()).bffRuntime()); + } + + @Test + @DisplayName("Should wire the user-info fold reachably — no session yields 401") + void shouldWireUserInfo() { + BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.USER_INFO, + new BffRuntime.ReservedHttpRequest("", null, null, null, null, null), + Instant.parse("2026-07-25T10:00:00Z")); + assertEquals(401, response.status()); + } + } + + @Nested + @DisplayName("Inert bearer-only / cookie-mode runtime") + class Inert { + + @Test + @DisplayName("Should stay inert when no oidc block is configured") + void shouldBeInertWithoutOidc() { + BffRuntime runtime = producer(Optional.empty()).bffRuntime(); + assertFalse(runtime.isActive()); + } + + @Test + @DisplayName("Should stay inert for a cookie-mode session (server mode not selected)") + void shouldBeInertForCookieMode() { + OidcConfig oidc = OidcConfig.builder() + .issuer(Optional.of(ISSUER)) + .redirectUri(Optional.of(REDIRECT_URI)) + .session(Optional.of(OidcConfig.Session.builder().mode(Optional.of("cookie")).build())) + .build(); + assertFalse(producer(Optional.of(oidc)).bffRuntime().isActive()); + } + + @Test + @DisplayName("Should stay inert when server mode is set but no redirect_uri is configured") + void shouldBeInertWithoutRedirectUri() { + OidcConfig oidc = OidcConfig.builder() + .issuer(Optional.of(ISSUER)) + .session(Optional.of(OidcConfig.Session.builder().mode(Optional.of("server")).build())) + .build(); + assertFalse(producer(Optional.of(oidc)).bffRuntime().isActive()); + } + + @Test + @DisplayName("Should reject reserved dispatch and session-stage access on the inert runtime") + void shouldRejectUseOfInert() { + BffRuntime runtime = BffRuntime.inert(); + assertThrows(IllegalStateException.class, () -> runtime.sessionStage()); + assertThrows(IllegalStateException.class, () -> runtime.dispatch(ReservedEndpoint.USER_INFO, + new BffRuntime.ReservedHttpRequest("", null, null, null, null, null), Instant.now())); + } + } + + private BffRuntimeProducer producer(Optional oidc) { + GatewayConfig gatewayConfig = GatewayConfig.builder().version(1).oidc(oidc).build(); + return new BffRuntimeProducer(gatewayConfig, new SingletonInstance<>(tokenValidator)); + } + + private static Optional serverModeOidc() { + OidcConfig.Session session = OidcConfig.Session.builder() + .mode(Optional.of("server")) + .ttlSeconds(Optional.of(3600)) + .build(); + return Optional.of(OidcConfig.builder() + .issuer(Optional.of(ISSUER)) + .clientId(Optional.of("gateway-client")) + .clientSecret(Optional.of("secret")) + .scopes(List.of("openid")) + .redirectUri(Optional.of(REDIRECT_URI)) + .session(Optional.of(session)) + .userInfo(Optional.of(OidcConfig.UserInfo.builder() + .path(Optional.of("/auth/userinfo")) + .allowedClaims(List.of("sub", "name")) + .defaultView(List.of("sub")) + .build())) + .login(Optional.of(OidcConfig.Login.builder().path(Optional.of("/auth/login")).build())) + .build()); + } + + /** + * Minimal {@link Instance} test double resolving to a single supplied bean; the producer resolves + * the validator only on the active path via {@link #get()}. + */ + private static final class SingletonInstance implements Instance { + + private final T value; + + SingletonInstance(T value) { + this.value = value; + } + + @Override + public T get() { + return value; + } + + @Override + public Instance select(Annotation... qualifiers) { + throw new UnsupportedOperationException(); + } + + @Override + public Instance select(Class subtype, Annotation... qualifiers) { + throw new UnsupportedOperationException(); + } + + @Override + public Instance select(TypeLiteral subtype, Annotation... qualifiers) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isUnsatisfied() { + return false; + } + + @Override + public boolean isAmbiguous() { + return false; + } + + @Override + public void destroy(T instance) { + // no-op: the test double owns no lifecycle + } + + @Override + public Handle getHandle() { + throw new UnsupportedOperationException(); + } + + @Override + public Iterable> handles() { + throw new UnsupportedOperationException(); + } + + @Override + public Iterator iterator() { + return List.of(value).iterator(); + } + } +} From c50fb8727644fd8bea79878153b1a138566c6fd3 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:15:50 +0200 Subject: [PATCH 17/39] feat(benchmarks): add session-mediation benchmark and comparison headline Add the k6 session-mediated benchmark script (session_mediated.js) modelled on bearer_proxied.js: it establishes one server session through the gateway's login-initiation reserved path in setup() and replays the opaque session cookie for the whole run, measuring only the steady-state mediation hot path (cookie -> store lookup -> bearer injection -> upstream), never the IdP-bound login handshake. Make K6BenchmarkConverter prefer the session-mediation aspect as the report overview headline when present, so a BFF run leads with the mediation figure that is directly comparable against the bearer (offline-validation) and proxiedStatic (plain-proxy) baselines measured in the same run. Runs without the mediation aspect keep the prior first-benchmark headline unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../k6/benchmark/K6BenchmarkConverter.java | 35 +++- .../resources/k6-scripts/session_mediated.js | 153 ++++++++++++++++++ .../benchmark/K6BenchmarkConverterTest.java | 84 ++++++++++ 3 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 benchmarks/src/main/resources/k6-scripts/session_mediated.js diff --git a/benchmarks/src/main/java/de/cuioss/sheriff/gateway/k6/benchmark/K6BenchmarkConverter.java b/benchmarks/src/main/java/de/cuioss/sheriff/gateway/k6/benchmark/K6BenchmarkConverter.java index b049f73a..f2149269 100644 --- a/benchmarks/src/main/java/de/cuioss/sheriff/gateway/k6/benchmark/K6BenchmarkConverter.java +++ b/benchmarks/src/main/java/de/cuioss/sheriff/gateway/k6/benchmark/K6BenchmarkConverter.java @@ -95,6 +95,20 @@ public class K6BenchmarkConverter implements BenchmarkConverter { /** Key for the k6-reported request failure ratio in {@link BenchmarkData.Benchmark#getAdditionalData()}. */ static final String ERROR_RATE = "errorRate"; + /** + * The session-mediation benchmark name — the headline aspect of the server-session BFF variant. + *

    + * When a run includes this aspect it is chosen as the report overview's primary (see + * {@link #selectOverviewPrimary}), so the single figure the report leads with characterises the + * BFF mediation hot path — cookie → store lookup → bearer injection → upstream — rather than an + * arbitrary alphabetically-first aspect. That aspect is measured in the same run as, and is + * directly comparable against, the {@code bearerProxied} (offline-validation) and + * {@code proxiedStatic} (plain-proxy, no auth) baselines; the three together isolate the added + * cost of session mediation. A run without this aspect (every non-BFF benchmark run) is + * unaffected: the overview keeps leading with the first parsed benchmark. + */ + public static final String SESSION_MEDIATED_BENCHMARK = "sessionMediated"; + private static final String FIELD_BENCHMARK_NAME = "benchmark_name"; private static final String FIELD_GATEWAY_TARGET = "gateway_target"; private static final String FIELD_REQUESTS_PER_SECOND = "requests_per_second"; @@ -283,7 +297,7 @@ private BenchmarkData.Overview createOverview(List benc .build(); } - BenchmarkData.Benchmark primary = benchmarks.getFirst(); + BenchmarkData.Benchmark primary = selectOverviewPrimary(benchmarks); double throughput = primary.getRawScore(); double latencyMs = resolveLatencyMs(primary); // Delegate to MetricsComputer - the single home for score/grade computation @@ -303,6 +317,25 @@ private BenchmarkData.Overview createOverview(List benc .build(); } + /** + * Selects the benchmark whose figures headline the report overview. + *

    + * The session-mediation aspect ({@link #SESSION_MEDIATED_BENCHMARK}), when present, is preferred + * so a BFF run's headline characterises the mediation hot path it exists to measure — the aspect + * compared against the bearer and plain-proxy baselines. Any run without it (every non-BFF + * benchmark run) keeps the prior behaviour of leading with the first parsed benchmark, so + * existing reports are unchanged. + * + * @param benchmarks the parsed benchmarks, never empty + * @return the benchmark to headline the overview + */ + private BenchmarkData.Benchmark selectOverviewPrimary(List benchmarks) { + return benchmarks.stream() + .filter(benchmark -> SESSION_MEDIATED_BENCHMARK.equals(benchmark.getName())) + .findFirst() + .orElseGet(benchmarks::getFirst); + } + /** * Resolves the overview latency (ms) from measured data only: the measured P50 if present, * otherwise the k6-reported average latency. diff --git a/benchmarks/src/main/resources/k6-scripts/session_mediated.js b/benchmarks/src/main/resources/k6-scripts/session_mediated.js new file mode 100644 index 00000000..65100a73 --- /dev/null +++ b/benchmarks/src/main/resources/k6-scripts/session_mediated.js @@ -0,0 +1,153 @@ +/** + * @fileoverview Benchmark for the session-mediated proxied route (browser -> gateway -> upstream). + * + * Measures the steady-state cost of BFF session mediation on the hot path: every request carries a + * pre-established server-session cookie the gateway resolves to a stored session, injecting the + * server-held bearer token before forwarding upstream. The session is established exactly once in + * {@link setup} through the gateway's own login-initiation reserved path and replayed for the whole + * run, so the measured window is the mediation hot path alone -- cookie -> store lookup -> bearer + * injection -> upstream -- and never the login handshake. + * + * Login-flow throughput is deliberately NOT benchmarked. The auth-code round-trip is IdP-bound + * (Keycloak authorization, PKCE, callback) rather than a gateway hot path, so folding it into the + * measured window would report Keycloak's per-login cost as gateway overhead. This mirrors how + * {@link bearer_proxied.js} mints its token once in {@link setup} and measures only per-request + * validation, and it is the session-mediation peer of the `bearer` (offline validation) and + * `proxiedStatic` (plain proxy, no auth) baselines: the three together isolate mediation overhead. + * + * Anti-silent-wrong-result gate: `checks` carries an explicit HTTP-200 assertion, so a run whose + * session cookie is rejected (the gateway falls through to a 302 login redirect or a 401) scores a + * checks rate of 0 and fails the build instead of reporting the redirect/rejection path as an + * excellent result. {@link setup} is fail-loud for the same reason: a run that could not establish + * a session aborts here rather than measuring the unauthenticated fall-through. + */ +import http from 'k6/http'; +import { check, fail } from 'k6'; +import { buildSummary, duration, maxErrorRate, SUMMARY_TREND_STATS, vus } from './lib/summary.js'; +import { baseUrl, targetUrl } from './lib/target.js'; + +const BENCHMARK_NAME = 'sessionMediated'; + +// The protected upstream route guarded by `require: session`: a request without a resolvable +// session cookie is redirected to login, so a 200 here proves the mediation path ran end to end. +const TARGET_URL = __ENV.TARGET_URL || targetUrl('/secure/get'); + +// The gateway-owned login-initiation reserved path (oidc.login.path). Hitting it drives the +// auth-code flow that ends with the gateway setting the opaque session cookie. +const LOGIN_URL = __ENV.LOGIN_URL || targetUrl('/auth/login'); + +// The opaque server-session cookie the gateway sets on a successful callback. Only its value is +// replayed; no token material ever leaves the gateway. Matches SessionCookieCodec.DEFAULT_COOKIE_NAME. +const SESSION_COOKIE_NAME = __ENV.SESSION_COOKIE_NAME || '__Host-sheriff-session'; + +// Keycloak is reached by service name on the shared api-sheriff network, exactly as the bearer +// aspect reaches it: the benchmark realm import pins frontendUrl to https://keycloak:8443 so the +// gateway's benchmark-realm issuer validation matches. +const KEYCLOAK_USERNAME = __ENV.KEYCLOAK_USERNAME || 'benchmark-user'; +const KEYCLOAK_PASSWORD = __ENV.KEYCLOAK_PASSWORD || 'benchmark-password'; + +export const options = { + vus: vus(50), + duration: duration(), + summaryTrendStats: SUMMARY_TREND_STATS, + insecureSkipTLSVerify: true, + thresholds: { + http_req_failed: [`rate<=${maxErrorRate()}`], + checks: [`rate>=${1 - maxErrorRate()}`], + }, +}; + +/** + * Establishes one server session before the measured window opens and returns the opaque session + * cookie every VU iteration replays. + * + * The login is driven through the gateway's own login-initiation reserved path so the whole + * auth-code flow (gateway -> Keycloak authorization -> callback -> session cookie) runs exactly as a + * browser would drive it, k6 following each 302 and accumulating cookies in its jar. The measured + * default iteration then never pays this cost: it replays only the resolved cookie. + * + * A session that could not be established is fatal here rather than deferred to the run. Without the + * cookie every request would fall through to the login redirect, and a redirect-only run measures + * the unauthenticated path, not the mediation path this benchmark exists to measure. + * + * @returns {{sessionCookie: string}} the opaque session-cookie value replayed by every VU iteration + */ +export function setup() { + // The login-initiation path drives the auth-code flow; k6 follows the gateway->Keycloak->callback + // redirect chain and lands on the Keycloak login form, whose action carries the flow parameters. + const loginPage = http.get(LOGIN_URL, { redirects: 10 }); + if (loginPage.status !== 200) { + fail(`login initiation ${LOGIN_URL} did not reach a login form: HTTP ${loginPage.status}`); + } + + const formAction = extractFormAction(loginPage.body); + if (!formAction) { + fail(`login form at ${loginPage.url} carried no resolvable form action to post credentials to`); + } + + // Posting the credentials completes authentication at Keycloak, which 302s back to the gateway + // callback; the gateway exchanges the code, stores the session, and sets the session cookie. + const authenticated = http.post( + formAction, + { username: KEYCLOAK_USERNAME, password: KEYCLOAK_PASSWORD }, + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, redirects: 10 }, + ); + if (authenticated.status !== 200) { + fail(`credential post to ${formAction} did not complete the login: HTTP ${authenticated.status}`); + } + + const sessionCookie = resolveSessionCookie(); + if (!sessionCookie) { + fail(`login flow set no ${SESSION_COOKIE_NAME} cookie — the gateway did not establish a session`); + } + return { sessionCookie: sessionCookie }; +} + +/** + * Extracts the Keycloak login form's POST action from the returned HTML. + * + * @param {string} html the login-page body + * @returns {string|null} the form action URL, or {@code null} when no form is present + */ +function extractFormAction(html) { + if (typeof html !== 'string') { + return null; + } + const match = html.match(/]*\baction="([^"]+)"/i); + if (!match) { + return null; + } + // Keycloak renders the action HTML-escaped; unescape the ampersands so the query parameters + // (session_code / execution / tab_id) survive the credential post. + return match[1].replace(/&/g, '&'); +} + +/** + * Reads the opaque session-cookie value the gateway set on the callback out of the VU cookie jar. + * + * @returns {string|null} the session-cookie value, or {@code null} when it was never set + */ +function resolveSessionCookie() { + const cookies = http.cookieJar().cookiesForURL(baseUrl() + '/'); + const values = cookies[SESSION_COOKIE_NAME]; + return values && values.length > 0 ? values[0] : null; +} + +export default function (data) { + const response = http.get(TARGET_URL, { + headers: { + Cookie: `${SESSION_COOKIE_NAME}=${data.sessionCookie}`, + Accept: 'application/json', + }, + // A fresh redirect budget of 0: the mediated route must answer 200 directly. A 302 to login + // means the session was not resolved, and the check below scores it a failure rather than + // silently following the redirect and timing the login page. + redirects: 0, + tags: { benchmark: BENCHMARK_NAME }, + }); + check(response, { 'status is 200': (r) => r.status === 200 }); +} + +export function handleSummary(data) { + return buildSummary(BENCHMARK_NAME, data); +} diff --git a/benchmarks/src/test/java/de/cuioss/sheriff/gateway/k6/benchmark/K6BenchmarkConverterTest.java b/benchmarks/src/test/java/de/cuioss/sheriff/gateway/k6/benchmark/K6BenchmarkConverterTest.java index 9c0c2b47..6b5c2d92 100644 --- a/benchmarks/src/test/java/de/cuioss/sheriff/gateway/k6/benchmark/K6BenchmarkConverterTest.java +++ b/benchmarks/src/test/java/de/cuioss/sheriff/gateway/k6/benchmark/K6BenchmarkConverterTest.java @@ -369,6 +369,90 @@ void onlyMeasuredPercentilesAreEmitted() throws Exception { "throughput score (formatted from requests_per_second: 1500.0)")); } + @Test + void sessionMediationOverheadIsMeasuredAgainstBearerAndPlainProxyBaselines() throws Exception { + // Arrange — the three comparison-cohort aspects measured in one run at distinct throughputs + // so a mix-up between them is visible: plain proxy fastest, session mediation slowest. + writeSummary("proxiedStatic", 9000.0); + writeSummary("bearerProxied", 7000.0); + writeSummary(K6BenchmarkConverter.SESSION_MEDIATED_BENCHMARK, 5000.0); + + // Act + JsonObject json = processAndReadJson(); + + // Assert — all three cohort aspects survive into the same report so the session-mediation + // overhead is directly comparable against the bearer and plain-proxy baselines, each carrying + // its own measured throughput. + assertEquals(3, json.getAsJsonArray("benchmarks").size(), "all three cohort aspects present"); + assertAll("comparison cohort", + () -> assertEquals("9.0K ops/s", benchmarkNamed(json, "proxiedStatic").get("score").getAsString(), + "plain-proxy baseline throughput"), + () -> assertEquals("7.0K ops/s", benchmarkNamed(json, "bearerProxied").get("score").getAsString(), + "bearer baseline throughput"), + () -> assertEquals("5.0K ops/s", benchmarkNamed(json, "sessionMediated").get("score").getAsString(), + "session-mediation throughput, comparable against both baselines")); + } + + @Test + void sessionMediatedAspectHeadlinesTheOverviewWhenPresent() throws Exception { + // Arrange — bearerProxied sorts before sessionMediated alphabetically, so the pre-change + // getFirst() headline would be the bearer baseline; the mediation aspect must win instead. + writeSummary("proxiedStatic", 9000.0); + writeSummary("bearerProxied", 7000.0); + writeSummary(K6BenchmarkConverter.SESSION_MEDIATED_BENCHMARK, 5000.0); + + // Act + JsonObject json = processAndReadJson(); + + // Assert — the report leads with the mediation hot path, not the alphabetically-first aspect. + JsonObject overview = json.getAsJsonObject("overview"); + assertAll("mediation headline", + () -> assertEquals("sessionMediated", overview.get("throughputBenchmarkName").getAsString(), + "the session-mediation aspect headlines the overview when present"), + () -> assertEquals(5000.0, overview.get("throughputOpsPerSec").getAsDouble(), 0.001, + "the headline throughput is the mediation aspect's, not the bearer baseline's")); + } + + @Test + void overviewFallsBackToFirstAspectWithoutTheMediationBenchmark() throws Exception { + // Arrange — a non-BFF run with no mediation aspect keeps the prior getFirst() headline so + // existing reports are unchanged. bearerProxied sorts before proxiedStatic. + writeSummary("proxiedStatic", 9000.0); + writeSummary("bearerProxied", 7000.0); + + // Act + JsonObject json = processAndReadJson(); + + // Assert + JsonObject overview = json.getAsJsonObject("overview"); + assertEquals("bearerProxied", overview.get("throughputBenchmarkName").getAsString(), + "without the mediation aspect the overview leads with the first parsed benchmark"); + } + + /** + * Writes one k6 summary fixture into the temp {@code k6} directory, carrying the mandatory + * name/window/throughput fields the post-processor requires. + * + * @param benchmarkName the benchmark name (also the summary file stem) + * @param requestsPerSecond the measured throughput to record + * @throws IOException when the fixture cannot be written + */ + private void writeSummary(String benchmarkName, double requestsPerSecond) throws IOException { + Path k6Dir = tempDir.resolve(K6ResultPostProcessor.K6_RESULTS_SUBDIRECTORY); + Files.createDirectories(k6Dir); + Files.writeString(k6Dir.resolve(benchmarkName + "-summary.json"), """ + { + "benchmark_name": "%s", + "gateway_target": "api-sheriff", + "start_time": "2026-07-19T10:00:00Z", + "end_time": "2026-07-19T10:01:00Z", + "requests_per_second": %s, + "error_rate": 0.0, + "latency_ms": {"avg": 2.5, "p50": 2.0, "p75": 3.0, "p90": 5.0, "p99": 9.0} + } + """.formatted(benchmarkName, requestsPerSecond)); + } + /** * Verify that k6 generates the same comprehensive structure the wrk toolchain produced. */ From 8bfb18f8dfdeef6a9726208afda7728a777e6209 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:28:53 +0200 Subject: [PATCH 18/39] docs(bff): author three-layer server-session BFF documentation Grow the reserved-path list in configuration.adoc from four to six (add the oidc.user_info and oidc.login login-initiation fold paths) and document every new/changed OIDC/session key: oidc.login.path, oidc.user_info.path with its allowed_claims disclosure ceiling and default_view, and oidc.session.max_sessions. Record the server-session BFF structural additions in architecture.adoc's Authentication Layer (session-resolution seam, reserved-path registry + live-edge dispatch, engine boundary, and the BffRuntime / BffRuntimeProducer CDI wiring), and add the doc-first login-initiation path, pending-authorization binding cookie, and user-info endpoint to the Variant 2 design doc. Add the operator setup guide (doc/user/bff-session.adoc) and contributor guide (doc/development/bff-session.adoc), with append-only index lines in the user, development, and top-level documentation READMEs. Incidental: the pre-commit formatter normalized continuation-line indentation in BffRuntime.java (record component alignment); included so the tree stays clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../gateway/bff/runtime/BffRuntime.java | 12 +- doc/README.adoc | 5 + doc/architecture.adoc | 36 +++++ doc/configuration.adoc | 63 ++++++++- doc/development/README.adoc | 6 + doc/development/bff-session.adoc | 112 +++++++++++++++ doc/user/README.adoc | 6 + doc/user/bff-session.adoc | 130 ++++++++++++++++++ doc/variants/02-bff-session.adoc | 46 +++++++ 9 files changed, 403 insertions(+), 13 deletions(-) create mode 100644 doc/development/bff-session.adoc create mode 100644 doc/user/bff-session.adoc diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java index 1dce21e9..3b3ee228 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java @@ -255,12 +255,12 @@ private static T requireNonNull(@Nullable T value) { * @since 1.0 */ public record ReservedHttpRequest(String rawQuery, @Nullable - String cookieHeader, @Nullable - String claimsParam, + String cookieHeader, @Nullable + String claimsParam, @Nullable String returnUrlParam, @Nullable - String stateParam, @Nullable - String rawFormBody) { + String stateParam, @Nullable + String rawFormBody) { /** * Canonical constructor normalizing an absent raw query to the empty string. @@ -286,8 +286,8 @@ public record ReservedHttpRequest(String rawQuery, @Nullable * @since 1.0 */ public record ReservedHttpResponse(int status, @Nullable - String location, @Nullable - String jsonBody, + String location, @Nullable + String jsonBody, Map headers, List setCookieHeaders) { /** diff --git a/doc/README.adoc b/doc/README.adoc index 8d7b9a44..e7796e1e 100644 --- a/doc/README.adoc +++ b/doc/README.adoc @@ -100,6 +100,11 @@ configuration. Each has its own design section: | link:variants/03-bff-cookie.adoc[3. BFF -- Cookie-based (non-session)] | Backend-For-Frontend where the tokens travel only inside an AES-256-GCM encrypted `HttpOnly` cookie, decrypted per request. Stateless (manifest Variant A). + +| Variant 2 -- three-layer guides +| Task-oriented companions to the Variant 2 design section above: + link:user/bff-session.adoc[operator setup] (configure and run the server-session BFF) and + link:development/bff-session.adoc[contributor guide] (build, test, and extend it). |=== == Implementation Plans diff --git a/doc/architecture.adoc b/doc/architecture.adoc index d0edaa71..e8c8023e 100644 --- a/doc/architecture.adoc +++ b/doc/architecture.adoc @@ -171,6 +171,42 @@ the engine wires it internally. The engine's API surface is specified in `TokenSheriff/doc/client`. ==== +[[_bff_session_runtime]] +==== Server-session BFF runtime (edge wiring) + +The *server-session* variant realizes the BFF side of that seam as four structural pieces, all +framework-agnostic (raw request pieces in, a rendered response out -- no JAX-RS/Vert.x coupling) +so each is unit-testable without a container: + +* *Session-resolution seam.* The opaque session-id cookie is the only browser-held state. A + `SessionCookieCodec` reads the id out of the request `Cookie` header and a `SessionStore` + (the in-memory `InMemorySessionStore`, bounded by `oidc.session.max_sessions`) resolves it to + the server-held `SessionRecord` carrying the tokens. A `require: session` route is served by a + `SessionAuthenticationStage` injected into the session-aware authentication stage -- so the + route is *mediated* (the stored access token injected upstream) rather than rejected. + +* *Reserved-path registry + live-edge dispatch.* The `ReservedPathRegistry` classifies each of the + up-to-six gateway-owned OIDC paths (callback, logout, logout-return, back-channel logout, + user-info, login-initiation) by *exact* host+path match. The gateway edge consults it *before* + the proxy route table, so a matched reserved path is dispatched to its handler and a proxy route + such as `path_prefix: /auth` can never swallow `/auth/callback`. The + link:configuration.adoc#_oidc[`oidc`] block is the single source of the registered paths. + +* *Engine boundary.* The gateway re-implements no OAuth leg. Every reserved handler (login + initiation, callback code-exchange, RP-initiated + back-channel logout, transparent refresh, + RFC 9470 step-up) drives the `token-sheriff-client` engine across a small set of seams and adds + only the browser-facing concern on top (binding/session cookies, CSRF, curated user-info view, + RFC 9457 rendering). The BFF never holds token material outside the `SessionStore`. + +* *`BffRuntime` / `BffRuntimeProducer` CDI wiring.* `BffRuntime` is the single collaborator the + edge consults: it carries the `SessionAuthenticationStage` + fixed `CsrfDefence` and a + `dispatch(...)` fan-out that routes each matched reserved kind to its wired handler and + normalizes the outcomes into one response the edge renders verbatim. `BffRuntimeProducer` builds + it once at boot *only when a global `oidc` block with `session.mode=server` and a `redirect_uri` + is configured*; otherwise it produces the *inert* runtime, so a bearer-only gateway (or a + cookie-mode BFF) is unchanged and never touches the confidential-client engine. Provider-metadata + discovery is resolved lazily on first engine use, so a server-mode gateway boots without a live IdP. + === Forward Policy (Zero-Trust) Nothing crosses to the upstream unless it is explicitly allowed. The forward policy defines diff --git a/doc/configuration.adoc b/doc/configuration.adoc index 582b9af3..9900464e 100644 --- a/doc/configuration.adoc +++ b/doc/configuration.adoc @@ -242,6 +242,13 @@ oidc: # only used by the BFF variants (2 and 3) client_secret: ${SHERIFF_CLIENT_SECRET} scopes: [openid, profile] redirect_uri: https://gw.example.com/auth/callback + login: + path: /auth/login # gateway-owned login-initiation entry point (reserved path) + user_info: + path: /auth/userinfo # gateway-served curated identity view (reserved path) + allowed_claims: [sub, name, email] # operator allowlist; empty (default) discloses nothing + default_view: [sub, name] # returned when the caller requests no explicit claims; + # every entry must lie within allowed_claims logout: path: /logout # gateway-served; triggers RP-initiated logout at the IdP post_logout_redirect_uri: https://gw.example.com/logout/done # gateway-owned return leg, @@ -252,6 +259,8 @@ oidc: # only used by the BFF variants (2 and 3) # server-mode-only, the encryption keys cookie-mode-only mode: cookie # cookie (Variant 3) | server (Variant 2) store: memory # REQUIRED for server mode; memory is the only initial option + max_sessions: 10000 # server mode only: upper bound on concurrently stored + # sessions -- a DoS guard on the in-memory store cookie_name: __Host-sheriff encryption_key: ${SHERIFF_SESSION_KEY} # cookie mode only (AES-256-GCM) previous_key: ${SHERIFF_SESSION_KEY_PREVIOUS} # optional; decrypt-only, for key rotation @@ -518,13 +527,23 @@ live in two endpoint files and are disjoint by host. A request that matches no route is rejected with `404`. -*Reserved gateway paths.* The gateway's own OIDC endpoints -- the `oidc.redirect_uri` path, -`oidc.logout.path`, its return path, and `oidc.logout.backchannel_path` -- are matched -*exactly, before the route table*, but only on the *OIDC host* (the host of -`oidc.redirect_uri`). A proxy route such as `path_prefix: /auth` therefore never swallows the -`/auth/callback` callback, while other virtual hosts (e.g. machine-to-machine API hosts with -`bearer` routes) keep their full path namespace. Routes remain free to use neighbouring -prefixes; only the exact reserved paths on that one host are carved out. +*Reserved gateway paths.* The gateway's own OIDC endpoints are matched *exactly, before the +route table*, but only on the *OIDC host* (the host of `oidc.redirect_uri`). Up to *six* paths +are carved out: + +. the `oidc.redirect_uri` path -- the auth-code *callback*; +. `oidc.logout.path` -- RP-initiated *logout*; +. its return path (`oidc.logout.post_logout_redirect_uri`) -- the *logout-return* leg; +. `oidc.logout.backchannel_path` -- the *back-channel logout* receiver (`mode: server` only); +. `oidc.user_info.path` -- the session/*user-info* endpoint serving the curated identity view; +. `oidc.login.path` -- the gateway-owned *login-initiation* entry point that starts a login. + +Each is registered only when its configuration key is present, so a deployment that omits +`oidc.user_info` or `oidc.login` carves out fewer than six. A proxy route such as +`path_prefix: /auth` therefore never swallows the `/auth/callback` callback, while other virtual +hosts (e.g. machine-to-machine API hosts with `bearer` routes) keep their full path namespace. +Routes remain free to use neighbouring prefixes; only the exact reserved paths on that one host +are carved out. === Defaults for omitted blocks @@ -1086,6 +1105,30 @@ ignored when no route's effective auth is `require: session`. | The OIDC confidential-client identity. `client_secret` must be an `${ENV_VAR}` reference. `redirect_uri` is the gateway's own callback route, registered exactly at the IdP. +| `login.path` +| Gateway-owned *login-initiation* entry point -- the reserved path a browser navigates to in + order to *start* a login (the mirror of the `redirect_uri` callback that *lands* it). A caller + that already holds a live session is redirected straight to the same-origin-validated return + URL (no fresh auth-code flow); an unauthenticated caller is driven into the authorization-code + flow. Optional -- omit it and the deployment simply has no gateway-owned login-start URL. + Registered as a reserved gateway path when present. + +| `user_info.path` +| Gateway-served *user-info* endpoint returning the curated identity view assembled from the + session's tokens -- never a passthrough of the raw ID/userinfo claims. Registered as a + reserved gateway path when present; omit it and no user-info endpoint is exposed. + +| `user_info.allowed_claims` +| The operator claim *allowlist* bounding what `user_info.path` may ever disclose. The secure + default is *closed*: an empty (or omitted) allowlist discloses *nothing*, so the operator -- + never the browser client -- widens disclosure. A claim absent from this list is never returned, + regardless of what the caller requests. + +| `user_info.default_view` +| The claim selector returned when the caller requests no explicit claims. Every entry *must* lie + within `user_info.allowed_claims` (a default-view claim outside the allowlist fails the boot), + so the curated default can never exceed the disclosure ceiling. + | `logout.path` | Gateway-served logout endpoint (default `/logout`): revokes the held tokens (RFC 7009), destroys the local session/cookie, and redirects to the IdP `end_session_endpoint` with @@ -1136,6 +1179,12 @@ ignored when no route's effective auth is `require: session`. link:variants/02-bff-session.adoc[Variant 2]). There is no implicit default -- server mode must name its store explicitly. +| `session.max_sessions` +| *Server mode only.* Upper bound on the number of concurrently stored sessions -- a + denial-of-service guard on the in-memory store, so an unbounded stream of logins cannot exhaust + node memory. Unused in `cookie` mode (there is no server-side store to bound). Optional; omit it + to leave the store unbounded. + | `session.cookie_name` | Cookie name; the `+__Host-+` prefix (implying `Secure`, `Path=/`, no `Domain`) is the default and recommended. diff --git a/doc/development/README.adoc b/doc/development/README.adoc index a9c170c1..00f13d12 100644 --- a/doc/development/README.adoc +++ b/doc/development/README.adoc @@ -53,6 +53,12 @@ This tree is seeded here and grows as contributor-facing material lands. | The Vert.x `NetServer` front architecture, the accept-time L4/L7 split, the internal-handoff loopback relay and public/internal port ownership, the runtime Host-vs-SNI smuggle guard, and the fail-closed (GW-06) rationale with the Traefik / KrakenD CVEs it answers. + +| link:bff-session.adoc[Server-Session BFF -- Contributor Guide] +| Where the server-session BFF code lives (the `bff.*` packages and the framework-agnostic seam), + how to build and test it, the engine-boundary rule (drive the `token-sheriff-client` engine, never + re-implement an OAuth leg), the integration-test and benchmark entry points, and how to add a new + reserved endpoint. |=== == Scope of This Layer diff --git a/doc/development/bff-session.adoc b/doc/development/bff-session.adoc new file mode 100644 index 00000000..be1d9609 --- /dev/null +++ b/doc/development/bff-session.adoc @@ -0,0 +1,112 @@ += Server-Session BFF -- Contributor Guide +:toc: +:toclevels: 3 +:sectnums: + +A contributor-facing guide to the *server-session BFF* implementation (Variant 2): where its code +lives, how to build and test it, and the one architectural rule that keeps it correct. It describes +how to *work in* the code; the design rationale is in +link:../variants/02-bff-session.adoc[Variant 2 -- BFF, Session-based] and the canonical seam +statement in link:../architecture.adoc#_bff_session_runtime[Architecture -- Server-session BFF runtime]. + +== The engine boundary rule (read first) + +*The gateway re-implements no OAuth leg.* The confidential-client *engine* (`token-sheriff-client`) +owns retrieval and flow -- auth-code + PKCE, `state`/`nonce`/exact `redirect_uri`, mix-up defence, +refresh-with-rotation, RP-initiated logout, RFC 9470 step-up -- and the server-side token lifecycle. +The *BFF* (this code) owns only the browser-facing part: the binding/session cookies, CSRF, request +proxying, the curated user-info view, logout receivers, and RFC 9457 rendering for gateway-raised +errors. + +When you add or change a reserved handler, drive an engine seam -- never hand-roll an OAuth request, +a token exchange, or a JWKS fetch inside the gateway. If a capability seems to need a new OAuth leg, +that leg belongs upstream in the engine, not here. This boundary is enforced by review and by the +framework-agnostic package seam (below); keep it intact. + +== Where the code lives + +All BFF code is in the `api-sheriff` module under `de.cuioss.sheriff.gateway`: + +[cols="1,2"] +|=== +| Package | Responsibility + +| `bff.session` +| The session-resolution seam: `SessionRecord`, `SessionStore` (`InMemorySessionStore`), and + `SessionCookieCodec` (the opaque `__Host-` session cookie). + +| `bff.pending` +| The pre-session transaction: `PendingAuthorizationRecord`/`Store` and `BindingCookieCodec` -- the + browser-binding cookie that ties an in-flight login to one browser until the callback lands. + +| `bff.login` +| `LoginFlow` -- auth-code initiation over the engine's authorization seam. + +| `bff.reserved` +| The reserved-endpoint handlers and the `ReservedPathRegistry`: `CallbackEndpoint`, + `LoginInitiationEndpoint`, `UserInfoEndpoint` (+ `ClaimAllowlistFilter`), `LogoutEndpoint`, + `BackchannelLogoutEndpoint`. + +| `bff.csrf` / `bff.refresh` / `bff.logout` +| `CsrfDefence`; the `TokenRefreshCoordinator` / `StepUpCoordinator`; the logout token validator and + RP-initiated / back-channel logout logic. + +| `bff.runtime` +| `BffRuntime` (the single edge-facing collaborator) and `SessionAuthenticationStage`. + +| `quarkus.BffRuntimeProducer` +| The CDI producer that assembles the active runtime and binds the engine seams -- the only + framework-coupled BFF class. +|=== + +Every `bff.*` type is *framework-agnostic* (raw request pieces in, a rendered response out -- no +JAX-RS/Vert.x coupling), so it is unit-testable without a container. The framework wiring is confined +to `BffRuntimeProducer` and the edge (`gateway.edge`), which consults the `ReservedPathRegistry` +before the proxy route table and dispatches a matched reserved path through `BffRuntime.dispatch(...)`. +Keep new logic on the framework-agnostic side of that seam. + +== Build and test + +The canonical build commands are the ones in the repository `CLAUDE.md` (invoked through the build +executor, never a raw `mvn`). For BFF work: + +* *Unit and module tests* -- run the `api-sheriff` module tests; the `bff.*` suites cover each + handler and codec directly, without a container, following the project AAA + CUI-generator + conventions. +* *Quality gate + full verify* -- both must pass with zero findings before a commit (the + link:../development/README.adoc[contributor guide] and `CLAUDE.md` state the pre-commit process). + +Because the runtime is assembled by `BffRuntimeProducer` only when a global `oidc` block with +`session.mode=server` and a `redirect_uri` is configured, a bearer-only test fixture gets the +*inert* runtime and never touches the engine -- add a server-mode `oidc` fixture when a test needs +the active path. + +== Entry points for integration and benchmarks + +* *Integration tests* live in the `integration-tests` module and run against the native image on the + Docker compose stack (Keycloak + upstream), under the integration-tests Maven profile. The BFF ITs + exercise the full login -> mediated call -> logout round-trip through a real IdP; a + `require: session` route is declared in the sheriff-config the IT container consumes. + +* *Benchmark* -- `benchmarks/src/main/resources/k6-scripts/session_mediated.js` measures the + steady-state mediation hot path (a pre-established session cookie replayed against a + `require: session` route: cookie -> store lookup -> bearer injection -> upstream), the + session-mediation peer of the `bearer` and plain-proxy baselines. `K6BenchmarkConverter` parses + its result alongside those baselines so mediation overhead is comparable, and leads the report + overview with the mediation figure when present. The login handshake is deliberately *not* + benchmarked -- it is IdP-bound, not a gateway hot path. + +== How to extend + +* *A new reserved endpoint* -- add its kind to `ReservedPathRegistry.ReservedEndpoint`, register its + path in `ReservedPathRegistry.from(...)`, write a framework-agnostic handler in `bff.reserved`, + wire it in `BffRuntimeProducer`, and route its kind in `BffRuntime.dispatch(...)`. Document the new + key in link:../configuration.adoc#_oidc[Configuration -- `oidc`] and grow the reserved-path list. +* *A new session-runtime capability* -- keep it on the framework-agnostic side; drive any OAuth + interaction through an engine seam per the boundary rule above. + +== See also + +* link:../architecture.adoc#_bff_session_runtime[Architecture -- Server-session BFF runtime] -- the seam. +* link:../variants/02-bff-session.adoc[Variant 2 -- BFF, Session-based] -- the design. +* link:../user/bff-session.adoc[Operator setup -- server-session BFF] -- how it is configured and run. diff --git a/doc/user/README.adoc b/doc/user/README.adoc index 0e37018d..a900340d 100644 --- a/doc/user/README.adoc +++ b/doc/user/README.adoc @@ -38,6 +38,12 @@ layer. `tls.mtls` (require-and-verify a client certificate on terminated connections), plus the emergency trust-root-replacement blast radius every operator must understand before relying on mTLS. Links to the Configuration Reference for the field contract. + +| link:bff-session.adoc[Server-Session BFF -- Operator Setup] +| A task-oriented guide for standing up the server-session BFF (Variant 2): registering the OIDC + confidential client, protecting a route with `require: session`, the gateway-owned reserved + paths, serving a curated user-info view, CSRF for a cross-origin SPA, and operating the in-memory + session store. Links to the Configuration Reference for the `oidc` key contract. |=== == Scope of This Layer diff --git a/doc/user/bff-session.adoc b/doc/user/bff-session.adoc new file mode 100644 index 00000000..0b21dc48 --- /dev/null +++ b/doc/user/bff-session.adoc @@ -0,0 +1,130 @@ += Server-Session BFF -- Operator Setup +:toc: +:toclevels: 3 +:sectnums: + +A task-oriented guide for operators standing up the *server-session BFF* (Variant 2): API Sheriff +as an OIDC confidential client that holds tokens server-side and hands the browser only an opaque +session-id cookie. It tells you *how* to configure and run the variant; the field-by-field contract +lives in the link:../configuration.adoc#_oidc[Configuration Reference (`oidc`)] and the design +rationale in link:../variants/02-bff-session.adoc[Variant 2 -- BFF, Session-based]. + +== When to choose this variant + +Choose the server-session BFF when server-side-only token custody is a hard requirement: the token +never leaves the gateway, not even encrypted. It is *stateful* -- it keeps an in-memory session +store -- so it fits single-node or sticky-session deployments. A multi-node non-sticky deployment, +or one that must avoid server-side session state, is the +link:../variants/03-bff-cookie.adoc[cookie-based variant]'s job instead. + +== Prerequisites + +* An OIDC provider (IdP) where you can register a *confidential client* and obtain a + `client_id` / `client_secret`. +* A registered *redirect URI* pointing at the gateway's own callback path (`oidc.redirect_uri`), + matched exactly at the IdP. +* The client secret and any session-related secrets available as environment variables (never + written inline -- `client_secret` must be an `${ENV_VAR}` reference). + +== Configure the confidential client + +Add an `oidc` block to `gateway.yaml` and set at least one route's effective auth to +`require: session`. A minimal server-session configuration: + +[source,yaml] +---- +# gateway.yaml +oidc: + issuer: https://idp.example.com/realms/main + client_id: api-sheriff + client_secret: ${SHERIFF_CLIENT_SECRET} # must be an ${ENV_VAR} reference + redirect_uri: https://gw.example.com/auth/callback # registered exactly at the IdP + scopes: [openid, profile, orders.read] # include every scope a route requires + login: + path: /auth/login # optional: gateway-owned login-start URL + user_info: + path: /auth/userinfo # optional: curated identity view for the SPA + allowed_claims: [sub, name, email] # disclosure ceiling; empty discloses nothing + default_view: [sub, name] # subset of allowed_claims + session: + mode: server # server-side token store (this variant) + store: memory # REQUIRED for server mode; only initial option + max_sessions: 10000 # DoS guard on the in-memory store + cookie_name: __Host-sheriff + ttl_seconds: 3600 # absolute lifetime from login + csrf: + trusted_origins: [https://app.example.com] # SPA origins allowed on unsafe methods + refresh: { enabled: true, leeway_seconds: 30, on_failure: reauthenticate } +---- + +The gateway ignores the `oidc` block entirely unless a route's effective auth is `require: session`, +so a bearer-only deployment is unaffected by its presence. + +== Protect a route with a session + +Mark the endpoint (or an anchor) `require: session`; the mediated `Authorization: Bearer` is then +injected automatically from the stored token, and any inbound `Authorization` from the browser is +stripped: + +[source,yaml] +---- +# endpoints/app1.yaml +endpoint: + id: app1 + base_url: APP1 + auth: { require: session, required_scopes: [orders.read] } + routes: + - id: orders-api + match: { path_prefix: /api/orders } + upstream: { path: /orders } +---- + +== Gateway-owned reserved paths + +On the OIDC host (the host of `oidc.redirect_uri`) the gateway carves out its own exact paths +*before* the proxy route table, so a proxy prefix can never swallow them. Configure them so they do +not collide with a proxied namespace you need: + +[cols="1,2"] +|=== +| Path | Purpose + +| `oidc.redirect_uri` (path) | Auth-code callback that lands a login. +| `oidc.login.path` | Login-initiation entry point that starts a login (optional). +| `oidc.user_info.path` | Curated identity view served to the SPA (optional). +| `oidc.logout.path` | RP-initiated logout. +| `oidc.logout.post_logout_redirect_uri` (path) | Logout-return verification leg. +| `oidc.logout.backchannel_path` | Back-channel logout receiver (server mode only). +|=== + +== Serve a curated identity view to the SPA + +If the SPA needs profile fields, expose `oidc.user_info.path` rather than forwarding raw tokens. +Disclosure is *closed by default*: list exactly the claims you are willing to reveal in +`allowed_claims`, and set `default_view` (a subset) for the no-claims-requested case. A claim +absent from `allowed_claims` is never returned. + +== CSRF for a cross-origin SPA + +Every `require: session` route enforces origin verification on unsafe methods (non-GET/HEAD/OPTIONS) +-- this is fixed, not a knob. If your SPA is served from a different origin than the gateway, list +its origin in `oidc.session.csrf.trusted_origins` *and* enable `security_headers.cors` with +`allow_credentials: true`. This works only for *same-site* origins (same registrable domain); a +truly cross-site SPA never gets the `SameSite=Lax` cookie attached -- host it under the gateway's +site. See link:../variants/02-bff-session.adoc#_csrf_defence[CSRF Defence]. + +== Operate + +* *Sessions are in-memory.* A gateway restart drops all sessions and users re-authenticate. Size + `max_sessions` for expected concurrency; it caps memory against a login flood. +* *Absolute TTL.* `ttl_seconds` is counted from login and is not extended by refresh; on expiry the + user re-authenticates. +* *IdP reachability.* Provider metadata is discovered lazily on first use, so the gateway boots + without the IdP -- but logins fail until the issuer is reachable. Confirm egress to the issuer + (and its JWKS) is permitted. + +== See also + +* link:../configuration.adoc#_oidc[Configuration Reference -- `oidc`] -- the authoritative key contract. +* link:../variants/02-bff-session.adoc[Variant 2 -- BFF, Session-based] -- design and flow rationale. +* link:../development/bff-session.adoc[Contributor guide -- server-session BFF] -- building and extending it. diff --git a/doc/variants/02-bff-session.adoc b/doc/variants/02-bff-session.adoc index ca23b1a2..6ab46631 100644 --- a/doc/variants/02-bff-session.adoc +++ b/doc/variants/02-bff-session.adoc @@ -33,6 +33,28 @@ POST); the gateway -- driving the `token-sheriff-client` engine -- verifies `sta code server-to-server, validates the tokens offline, and -- in this variant -- *stores them server-side* and sets an opaque session-id cookie. +=== Login-initiation entry point + +A browser can start a login explicitly by navigating to the gateway-owned `oidc.login.path` +(a link:../configuration.adoc#_oidc[reserved gateway path], the mirror of the callback that +*lands* the login). A caller that already holds a live session is redirected straight to a +*same-origin-validated* return URL -- no fresh auth-code flow is run and no cookies are set -- +so re-entering the login URL while signed in is not an open redirect and not a redundant +handshake. An unauthenticated caller is driven into the authorization-code flow above. When +`oidc.login.path` is omitted the deployment simply has no gateway-owned login-start URL; the +callback and mediation paths are unaffected. + +=== Pending-authorization binding cookie + +Between the initial redirect and the callback there is not yet a session, so the in-flight +login is tied to *this* browser by a short-lived, single-use `+__Host-+`-prefixed *binding +cookie* carrying an opaque pending-authorization record id -- the pre-session analogue of the +session cookie. The gateway persists the transaction context (the engine-owned PKCE verifier, +`state`, `nonce`, and the same-origin-validated post-login return URL) as a `PendingAuthorization` +record keyed by that id; the callback resolves the record by the binding cookie before it will +exchange the code, so a callback replayed in a different browser (which carries no matching +binding cookie) is refused. The record is consumed on first use and expires on a short TTL. + image::../resources/diagrams/bff-login-sequence.svg[OIDC confidential-client login: redirect to IdP, code exchange, token validation, session binding, align=center] == Steady-State API Call @@ -156,6 +178,23 @@ attempts a silent refresh, and -- if that cannot satisfy the challenge -- re-dri authorization-code flow with the elevated parameters, then replays the request. This is a link:../features-analysis.adoc#_differentiators[differentiator] no competitor covers. +== User-Info Endpoint + +The gateway serves the browser a *curated identity view* at the gateway-owned `oidc.user_info.path` +(a link:../configuration.adoc#_oidc[reserved gateway path]), assembled from the session's tokens -- +never a passthrough of the raw ID/userinfo claims, and never a way for the browser to reach token +material. What it may disclose is bounded by an *operator-owned* claim allowlist whose secure +default is *closed*: + +* `oidc.user_info.allowed_claims` is the disclosure ceiling. Empty (the default) discloses + *nothing*, so the operator -- never the browser client -- widens what the endpoint returns; a + claim absent from the allowlist is never emitted regardless of what the caller asks for. +* `oidc.user_info.default_view` is the claim selector returned when the caller requests no explicit + claims. Every entry must lie within `allowed_claims` (a default-view claim outside the allowlist + fails the boot), so the curated default can never exceed the ceiling. + +When `oidc.user_info.path` is omitted no user-info endpoint is exposed. + == Configuration [source,yaml] @@ -167,9 +206,16 @@ oidc: client_secret: ${SHERIFF_CLIENT_SECRET} redirect_uri: https://gw.example.com/auth/callback scopes: [openid, profile, orders.read] # must include every scope a route requires + login: + path: /auth/login # gateway-owned login-initiation entry point (reserved path) + user_info: + path: /auth/userinfo # curated identity view (reserved path) + allowed_claims: [sub, name, email] # disclosure ceiling; empty (default) discloses nothing + default_view: [sub, name] # returned when no explicit claims requested; subset of allowed_claims session: mode: server # server-side token store (this variant) store: memory # the only initial option (single-node / sticky sessions) + max_sessions: 10000 # DoS guard: upper bound on concurrently stored sessions cookie_name: __Host-sheriff ttl_seconds: 3600 refresh: { enabled: true, leeway_seconds: 30, on_failure: reauthenticate } From 19dcac4a7bdee485bc384ea3af0b3d073ce865eb Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:47:22 +0200 Subject: [PATCH 19/39] test(bff): add Keycloak-backed server-mode BFF integration tests Wire the server-mode BFF into the mounted IT gateway config and add the six Bff*IT suites that drive a scripted, browser-less OIDC auth-code flow against the compose Keycloak integration realm (TASK-25, deliverable 13). Config (integration-tests/src/main/docker/sheriff-config): - gateway.yaml: add the global server-mode `oidc` block (session.mode=server, memory store) wiring the confidential integration-client/integration-secret against the integration realm; reserved OIDC paths under /auth on the oidc host (localhost); add the require:session `bff-session` anchor; add the integration-keycloak token_validation issuer so the @GatewayValidator built from this block validates the realm's session tokens. - endpoints/bff-session.yaml: the require:session protected route to the go-httpbin echo upstream (Authorization allow-listed, Cookie stripped) so the mediation ITs have a target. Tests (integration-tests/src/test/java/.../integration): - BffKeycloakLoginFlow: shared cookie-jar flow helper with container-network authority rewrite (keycloak:8443 -> localhost:1443) and disjoint gateway / Keycloak cookie jars. - BffSessionLoginIT, BffSessionMediationIT, BffCsrfIT, BffLogoutIT, BffUserInfoIT, BffLoginInitiationIT. Engine-seam caveats verified as authored: (a) RP-initiated logout asserts local session destruction (the IdP TokenRevocation seam is a best-effort no-op); (b) the back-channel receiver is asserted wired + fail-closed (a real Keycloak-issued logout token round-trip is left to the native+Docker IT run). Verification handoff: `verify -Pintegration-tests -pl integration-tests -am` is a native-image + Docker (Keycloak/go-httpbin) orchestrator-tier build not run in this leaf; `test-compile -pl integration-tests -am` passed. The orchestrator must run the native+Docker IT build to close TASK-25 verification. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sheriff-config/endpoints/bff-session.yaml | 25 +++ .../main/docker/sheriff-config/gateway.yaml | 68 +++++++ .../gateway/integration/BffCsrfIT.java | 75 +++++++ .../integration/BffKeycloakLoginFlow.java | 188 ++++++++++++++++++ .../integration/BffLoginInitiationIT.java | 78 ++++++++ .../gateway/integration/BffLogoutIT.java | 84 ++++++++ .../integration/BffSessionLoginIT.java | 59 ++++++ .../integration/BffSessionMediationIT.java | 119 +++++++++++ .../gateway/integration/BffUserInfoIT.java | 66 ++++++ 9 files changed, 762 insertions(+) create mode 100644 integration-tests/src/main/docker/sheriff-config/endpoints/bff-session.yaml create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCsrfIT.java create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffLoginInitiationIT.java create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffLogoutIT.java create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffSessionLoginIT.java create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffSessionMediationIT.java create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffUserInfoIT.java diff --git a/integration-tests/src/main/docker/sheriff-config/endpoints/bff-session.yaml b/integration-tests/src/main/docker/sheriff-config/endpoints/bff-session.yaml new file mode 100644 index 00000000..6300bf17 --- /dev/null +++ b/integration-tests/src/main/docker/sheriff-config/endpoints/bff-session.yaml @@ -0,0 +1,25 @@ +# yaml-language-server: $schema=../../../../../../api-sheriff/src/main/resources/schema/endpoint.schema.json +# The require:session protected upstream the server-mode BFF mediation suite targets. +# Anchored to the 'bff-session' anchor (type: bff, access: authenticated, +# auth: require: session) declared in gateway.yaml, so every route here inherits the +# session floor. It routes to the same go-httpbin echo backend the proxy suite uses +# (the UPSTREAM topology alias) so BffSessionMediationIT can observe exactly what the +# gateway forwarded: the mediated bearer the SessionAuthenticationStage injects from the +# stored session, and the ABSENCE of the browser session cookie upstream. +# +# The forward stage is deny-by-default (stage 5): only the allow-listed request headers +# cross to the upstream. Authorization is allow-listed so the injected mediated bearer is +# echoed back by go-httpbin; Cookie is deliberately NOT allow-listed, so the browser +# session cookie is stripped and never reaches the upstream — the two halves of the +# mediation proof. +endpoint: + id: bff-session + enabled: true + base_url: UPSTREAM + anchor: bff-session + routes: + - id: bff-session-mediated + match: + path_prefix: /bff-session + forward: + headers_allow: ["Authorization", "Content-Type"] diff --git a/integration-tests/src/main/docker/sheriff-config/gateway.yaml b/integration-tests/src/main/docker/sheriff-config/gateway.yaml index 356c3aa1..a2552535 100644 --- a/integration-tests/src/main/docker/sheriff-config/gateway.yaml +++ b/integration-tests/src/main/docker/sheriff-config/gateway.yaml @@ -46,6 +46,19 @@ anchors: path_prefix: /bff type: proxy access: public + # Server-mode BFF session namespace exercised by the Bff*IT suite. A require:session + # route lives here so an authenticated browser session mediates a bearer to the + # go-httpbin echo upstream (BffSessionMediationIT). type: bff forces access: + # authenticated (ADR-0013), and the session floor is backed by the global oidc block + # below (ConfigValidator rejects a session floor with no oidc block). The reserved + # OIDC endpoints themselves are carved out of the route table on the oidc host and + # live under /auth (see the oidc block), disjoint from this /bff-session proxy prefix. + bff-session: + path_prefix: /bff-session + type: bff + access: authenticated + auth: + require: session # Bearer-protected namespace exercised by BearerValidationIT. The gateway's own # token_validation issuer loads its key set from the mounted static JWKS file # (no Keycloak dependency), so the validator is ready offline at boot. The IT @@ -150,3 +163,58 @@ token_validation: # names a trust profile; it deliberately carries no store path and no password, so this # document stays portable and secret-free. The deployment binds the name. tls_profile: benchmark-idp + # The server-mode BFF (oidc block below) validates the id/access tokens the + # 'integration' realm mints for the browser session. The gateway's shared + # @GatewayValidator TokenValidator is built from THIS token_validation block (not + # the Quarkus %it issuer surface), so the integration issuer MUST be declared here + # or every mediated session token is rejected. Same container-internal iss the + # integration realm pins via frontendUrl https://keycloak:8443, reached over the + # shared api-sheriff network where 'keycloak' resolves by service name; the same + # narrow SSRF egress widening and self-signed trust profile the benchmark issuer uses. + - name: integration-keycloak + issuer: https://keycloak:8443/realms/integration + jwks: + source: http + url: https://keycloak:8443/realms/integration/protocol/openid-connect/certs + allowed_egress_hosts: ["keycloak"] + tls_profile: benchmark-idp +# --- Server-mode BFF confidential-client block (BFF-* integration suite) ----------- +# Activates the server-mode BffRuntime (BffRuntimeProducer builds the active runtime +# only when a global oidc block declares session.mode=server AND a redirect_uri). The +# reserved OIDC endpoints are carved out of the route table exactly on the oidc host — +# the host of redirect_uri, i.e. 'localhost' — and matched by exact path, so they live +# under /auth and never collide with the /bff-session proxy anchor above. Confidential +# client: integration-client / integration-secret against the compose 'integration' +# realm (standardFlowEnabled, redirectUris '*'). OIDC discovery is lazy (first engine +# use), so boot needs no live IdP; the browser reaches the gateway on the published +# host port 10443 and Keycloak on its published host port, while the gateway reaches +# Keycloak container-internally at keycloak:8443. +oidc: + issuer: https://keycloak:8443/realms/integration + client_id: integration-client + client_secret: integration-secret + scopes: ["openid", "profile", "email"] + # Full browser-facing callback URL. Its host ('localhost') is the oidc host every + # reserved path binds to; its origin ('https://localhost:10443') is the gateway + # origin used for same-origin return-URL validation and the default CSRF trusted origin. + redirect_uri: https://localhost:10443/auth/callback + logout: + path: /auth/logout + post_logout_redirect_uri: https://localhost:10443/auth/logout/return + final_redirect: / + backchannel_path: /auth/backchannel + session: + mode: server + store: memory + ttl_seconds: 3600 + refresh: + enabled: true + leeway_seconds: 30 + csrf: + trusted_origins: ["https://localhost:10443"] + user_info: + path: /auth/userinfo + allowed_claims: ["sub", "preferred_username", "email", "groups"] + default_view: ["sub", "preferred_username"] + login: + path: /auth/login diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCsrfIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCsrfIT.java new file mode 100644 index 00000000..58cf32f6 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffCsrfIT.java @@ -0,0 +1,75 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.integration; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Exercises the fixed CSRF defence on the require:session {@code /bff-session} route. The defence has no + * disable knob and runs before session resolution, so an unsafe-method request with a foreign + * {@code Origin} is rejected {@code 403} regardless of whether a session cookie is present — no login is + * required to prove the rejection. A trusted {@code Origin} passes the CSRF gate, after which the + * (missing) session is what shapes the response, proving the {@code 403} was specifically the CSRF gate + * and not the auth stage. + */ +class BffCsrfIT extends BaseIntegrationTest { + + @Test + @DisplayName("an unsafe method with a foreign Origin is rejected 403 by the CSRF defence") + void unsafeMethodForeignOriginRejected() { + var response = given() + .header("Origin", "https://evil.example.com") + .header("Accept", "application/json") + .when() + .post("/bff-session/post") + .then() + .statusCode(403) + .extract(); + + assertTrue(response.contentType().contains("application/problem+json"), + "a CSRF rejection must render RFC 9457 problem+json"); + } + + @Test + @DisplayName("an unsafe method with no Origin and no Sec-Fetch-Site proof is rejected 403") + void unsafeMethodNoOriginProofRejected() { + given() + .header("Accept", "application/json") + .when() + .post("/bff-session/post") + .then() + .statusCode(403); + } + + @Test + @DisplayName("a trusted Origin passes the CSRF gate and then requires a session (401, not 403)") + void trustedOriginPassesCsrfThenRequiresSession() { + // The CSRF gate accepts the trusted gateway origin, so control reaches the session stage; with no + // session cookie the non-navigation request is challenged 401 — distinguishing the CSRF 403 above + // from the downstream auth 401. + given() + .header("Origin", BffKeycloakLoginFlow.GATEWAY_ORIGIN) + .header("Accept", "application/json") + .when() + .post("/bff-session/post") + .then() + .statusCode(401); + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java new file mode 100644 index 00000000..d8383495 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java @@ -0,0 +1,188 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.integration; + +import static io.restassured.RestAssured.given; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; + +/** + * Drives a scripted, browser-less OIDC authorization-code flow against the compose Keycloak + * {@code integration} realm and the server-mode BFF gateway, following the {@code 302} chain with a + * cookie jar exactly as a browser would. + *

    + * Container-network rewrite. The {@code integration} realm pins + * {@code frontendUrl https://keycloak:8443}, so every authorization / login-form URL the gateway or + * Keycloak hands the "browser" carries the container-internal authority {@code keycloak:8443}. The + * host-driven test JVM cannot resolve that name, so this helper rewrites the authority to the + * host-published {@code localhost:1443} (compose maps {@code 1443 -> 8443}) before following each + * redirect. The gateway itself keeps reaching Keycloak container-internally at {@code keycloak:8443} + * over the shared {@code api-sheriff} network — only the browser leg is rewritten. + *

    + * Two disjoint cookie jars. RFC 6265 cookies are port-agnostic, so a single + * {@code localhost} jar would mix the gateway session cookie ({@code localhost:10443}) with the + * Keycloak {@code AUTH_SESSION_ID} ({@code localhost:1443}). The helper therefore keeps the gateway + * jar and the Keycloak jar separate and replays only the gateway jar on the returned session. + *

    + * This helper is a test-support class (no {@code *IT} suffix), so Failsafe does not run it as a + * suite; the six {@code Bff*IT} classes call {@link #login(String)} to establish a live session. + */ +final class BffKeycloakLoginFlow { + + /** Browser-facing gateway origin: published host port {@code 10443 -> } container {@code 8443}. */ + static final String GATEWAY_ORIGIN = "https://localhost:10443"; + + /** The container-internal Keycloak authority the {@code integration} realm frontendUrl pins. */ + static final String KEYCLOAK_INTERNAL_AUTHORITY = "keycloak:8443"; + + /** The host-published Keycloak authority the test JVM can actually reach (compose {@code 1443 -> 8443}). */ + static final String KEYCLOAK_HOST_AUTHORITY = "localhost:1443"; + + /** The seeded confidential-realm test user (see {@code integration-realm.json}). */ + static final String USERNAME = "integration-user"; + + /** The seeded test user's password. */ + static final String PASSWORD = "integration-password"; + + /** Matches the Keycloak username/password form's {@code login-actions/authenticate} action URL. */ + private static final Pattern FORM_ACTION = + Pattern.compile("action=\"([^\"]*login-actions/authenticate[^\"]*)\""); + + private BffKeycloakLoginFlow() { + // static helper + } + + /** + * The gateway session established by a completed login: the cookies to replay on subsequent + * protected requests (the session cookie plus any residual gateway cookies). + * + * @param gatewayCookies the gateway cookie jar carrying the live session cookie + */ + record Session(Map gatewayCookies) { + } + + /** + * Runs the full auth-code flow starting from a require:session navigation on {@code startPath} + * and returns the gateway session cookies established by the callback. + * + * @param startPath the gateway path to navigate to (a require:session route such as + * {@code /bff-session/get}); the unauthenticated navigation triggers the login + * redirect into the IdP + * @return the established gateway {@link Session} + */ + static Session login(String startPath) { + Map gatewayCookies = new HashMap<>(); + Map keycloakCookies = new HashMap<>(); + + // Step 1 — navigate onto the require:session route: the gateway sets the pending-auth binding + // cookie and 302s the browser to the IdP authorization endpoint. + Response initiation = gateway(gatewayCookies) + .header("Accept", "text/html") + .redirects().follow(false) + .when().get(startPath) + .then().statusCode(302).extract().response(); + gatewayCookies.putAll(initiation.getCookies()); + String authorizationUrl = rewriteToHost(location(initiation)); + + // Step 2 — GET the Keycloak login page and scrape the form action. + Response loginPage = keycloak(keycloakCookies) + .redirects().follow(false) + .when().get(authorizationUrl) + .then().statusCode(200).extract().response(); + keycloakCookies.putAll(loginPage.getCookies()); + String formAction = rewriteToHost(extractFormAction(loginPage.asString())); + + // Step 3 — POST the credentials; Keycloak 302s to the gateway callback with code + state. + Response formPost = keycloak(keycloakCookies) + .contentType("application/x-www-form-urlencoded") + .formParam("username", USERNAME) + .formParam("password", PASSWORD) + .redirects().follow(false) + .when().post(formAction) + .then().statusCode(302).extract().response(); + String callbackUrl = location(formPost); + + // Step 4 — follow the callback on the gateway: the code is exchanged for tokens, the server-side + // session is created, and the session cookie is set on a 302 back to the original path. + Response callback = gateway(gatewayCookies) + .redirects().follow(false) + .when().get(callbackUrl) + .then().statusCode(302).extract().response(); + gatewayCookies.putAll(callback.getCookies()); + + return new Session(gatewayCookies); + } + + /** + * A request spec bound to the gateway origin with relaxed HTTPS and the supplied cookie jar. + * + * @param cookies the gateway cookie jar + * @return the configured request specification + */ + static RequestSpecification gateway(Map cookies) { + return given().relaxedHTTPSValidation().baseUri(GATEWAY_ORIGIN).cookies(cookies); + } + + /** + * A request spec with relaxed HTTPS and the supplied Keycloak cookie jar; Keycloak calls always + * use absolute (host-rewritten) URLs, so no base URI is bound. + * + * @param cookies the Keycloak cookie jar + * @return the configured request specification + */ + static RequestSpecification keycloak(Map cookies) { + return given().relaxedHTTPSValidation().cookies(cookies); + } + + /** + * Rewrites the container-internal Keycloak authority to the host-published authority so the + * host-driven test JVM can follow a redirect the gateway/IdP emitted with the internal authority. + * + * @param url the URL to rewrite + * @return the URL with {@code keycloak:8443} replaced by {@code localhost:1443} + */ + static String rewriteToHost(String url) { + return url.replace(KEYCLOAK_INTERNAL_AUTHORITY, KEYCLOAK_HOST_AUTHORITY); + } + + /** + * Extracts the mandatory {@code Location} header from a redirect response. + * + * @param response the redirect response + * @return the {@code Location} value + */ + static String location(Response response) { + String value = response.getHeader("Location"); + if (value == null) { + throw new IllegalStateException("expected a Location header on a redirect response"); + } + return value; + } + + private static String extractFormAction(String html) { + Matcher matcher = FORM_ACTION.matcher(html); + if (!matcher.find()) { + throw new IllegalStateException("Keycloak login form action not found in login page"); + } + return matcher.group(1).replace("&", "&"); + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffLoginInitiationIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffLoginInitiationIT.java new file mode 100644 index 00000000..026392a9 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffLoginInitiationIT.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.integration; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import de.cuioss.sheriff.gateway.integration.BffKeycloakLoginFlow.Session; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Exercises the reserved {@code /auth/login} login-initiation fold end-to-end. + *

    + * Without a session the fold starts a fresh auth-code flow — a {@code 302} into the IdP authorization + * endpoint carrying the confidential {@code client_id}, {@code response_type=code}, and a PKCE + * {@code code_challenge}. With a live session it short-circuits {@code 302} to the same-origin-validated + * {@code return_to} target rather than re-driving the IdP, so an already-authenticated browser is not + * bounced through Keycloak again. + */ +class BffLoginInitiationIT extends BaseIntegrationTest { + + @Test + @DisplayName("login initiation without a session redirects 302 into the IdP with a PKCE auth-code request") + void loginInitiationStartsAuthCodeFlow() { + var response = given() + .redirects().follow(false) + .when() + .get("/auth/login") + .then() + .statusCode(302) + .extract(); + + String location = response.header("Location"); + assertNotNull(location, "login initiation must carry a Location redirect"); + assertTrue(location.contains("/protocol/openid-connect/auth"), + "login initiation must redirect into the OIDC authorization endpoint"); + assertTrue(location.contains("response_type=code"), "the flow must be an authorization-code request"); + assertTrue(location.contains("client_id=integration-client"), + "the flow must use the confidential integration client"); + assertTrue(location.contains("code_challenge="), "the flow must be PKCE-protected"); + } + + @Test + @DisplayName("login initiation with a live session short-circuits to the validated return target, not the IdP") + void loginInitiationShortCircuitsForLiveSession() { + Session session = BffKeycloakLoginFlow.login("/bff-session/get"); + + var response = BffKeycloakLoginFlow.gateway(session.gatewayCookies()) + .redirects().follow(false) + .when() + .get("/auth/login?return_to=/home") + .then() + .statusCode(302) + .extract(); + + String location = response.header("Location"); + assertNotNull(location, "a live-session login initiation must still redirect"); + assertFalse(location.contains("/protocol/openid-connect/auth"), + "an already-authenticated session must not be re-driven through the IdP"); + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffLogoutIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffLogoutIT.java new file mode 100644 index 00000000..67cd6d45 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffLogoutIT.java @@ -0,0 +1,84 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.integration; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import de.cuioss.sheriff.gateway.integration.BffKeycloakLoginFlow.Session; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Exercises the two server-mode BFF logout legs. + *

    + * RP-initiated logout (caveat a). The IdP {@code TokenRevocation} seam is bound as a + * best-effort no-op — {@code RpInitiatedLogout}'s contract makes local session destruction the + * authoritative logout. So this suite asserts the session is destroyed locally (the old cookie + * jar no longer authorizes the protected route), not that the IdP token was revoked. The + * {@code /auth/logout} entry redirects {@code 302} into the RP-initiated round-trip that ends at the + * configured {@code final_redirect}. + *

    + * Back-channel logout (caveat b). The back-channel {@code LogoutTokenVerifier} is + * bound to the id-token JWKS validation bridge. Minting a real Keycloak-issued logout token requires + * driving Keycloak's admin back-channel machinery, which is out of reach of a black-box suite; this + * suite instead proves the receiver is wired at the live edge (reachable, not {@code NO_ROUTE_MATCHED}) + * and fail-closed: an empty back-channel post is rejected {@code 400} and marked uncacheable. + */ +class BffLogoutIT extends BaseIntegrationTest { + + @Test + @DisplayName("RP-initiated logout redirects into the round-trip and destroys the local session") + void rpInitiatedLogoutDestroysLocalSession() { + Session session = BffKeycloakLoginFlow.login("/bff-session/get"); + + var logout = BffKeycloakLoginFlow.gateway(session.gatewayCookies()) + .redirects().follow(false) + .when() + .get("/auth/logout") + .then() + .statusCode(302) + .extract(); + assertNotNull(logout.header("Location"), + "RP-initiated logout must redirect into the logout round-trip"); + + // The authoritative effect is local session destruction: replaying the (now stale) cookie jar on + // the require:session route is challenged 401 — the session no longer resolves. + BffKeycloakLoginFlow.gateway(session.gatewayCookies()) + .header("Accept", "application/json") + .when() + .get("/bff-session/get") + .then() + .statusCode(401); + } + + @Test + @DisplayName("the back-channel logout receiver is wired and fail-closed: an empty post is rejected 400 no-store") + void backChannelReceiverIsWiredAndFailClosed() { + var response = given() + .contentType("application/x-www-form-urlencoded") + .when() + .post("/auth/backchannel") + .then() + .statusCode(400) + .extract(); + + assertEquals("no-store", response.header("Cache-Control"), + "a back-channel logout response must be uncacheable"); + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffSessionLoginIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffSessionLoginIT.java new file mode 100644 index 00000000..854ae05f --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffSessionLoginIT.java @@ -0,0 +1,59 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import de.cuioss.sheriff.gateway.integration.BffKeycloakLoginFlow.Session; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Exercises the full server-mode BFF authorization-code login through the reserved {@code /auth/callback} + * against the compose Keycloak {@code integration} realm. + *

    + * The scripted browser-less flow ({@link BffKeycloakLoginFlow}) navigates onto the require:session + * {@code /bff-session} route, follows the {@code 302} into Keycloak, submits the seeded + * {@code integration-user} credentials, and follows the callback back to the gateway. The observable + * proof that the server-side session was created is that the gateway set a session cookie on the + * callback response and that replaying that cookie jar on the protected route now serves the + * upstream (a fresh, cookieless request is challenged — covered by {@link BffSessionMediationIT}). + */ +class BffSessionLoginIT extends BaseIntegrationTest { + + @Test + @DisplayName("a completed auth-code login establishes a session cookie that authorizes the protected route") + void loginEstablishesUsableSession() { + Session session = BffKeycloakLoginFlow.login("/bff-session/get"); + + assertFalse(session.gatewayCookies().isEmpty(), + "the callback must set a session cookie establishing the server-side session"); + + // The established session authorizes the require:session route: the go-httpbin echo upstream + // is reached (method GET) only because the session mediated the request past stage 4. + var response = BffKeycloakLoginFlow.gateway(session.gatewayCookies()) + .when() + .get("/bff-session/get") + .then() + .statusCode(200) + .extract(); + + assertEquals("GET", response.path("method"), + "the authorized session request must reach the go-httpbin echo upstream"); + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffSessionMediationIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffSessionMediationIT.java new file mode 100644 index 00000000..131149df --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffSessionMediationIT.java @@ -0,0 +1,119 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.integration; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import de.cuioss.sheriff.gateway.integration.BffKeycloakLoginFlow.Session; + +import io.restassured.response.Response; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Exercises the server-mode BFF token-mediation semantics on the require:session {@code /bff-session} + * route: an authenticated session mediates a bearer to the upstream while the browser session cookie is + * stripped, an unauthenticated XHR is rejected {@code 401 application/problem+json}, and an + * unauthenticated navigation is redirected {@code 302} into the IdP. + *

    + * The mediated route proxies to the go-httpbin echo backend, so the forwarded request is fully + * observable: {@code headers.Authorization} carries the injected bearer (Authorization is allow-listed + * on the route) and {@code headers.Cookie} is absent (Cookie is deny-by-default and never forwarded). + */ +class BffSessionMediationIT extends BaseIntegrationTest { + + @Test + @DisplayName("an authenticated session injects the bearer upstream and never forwards the session cookie") + void mediatesBearerAndStripsSessionCookie() { + Session session = BffKeycloakLoginFlow.login("/bff-session/get"); + + Response response = BffKeycloakLoginFlow.gateway(session.gatewayCookies()) + .when() + .get("/bff-session/get") + .then() + .statusCode(200) + .extract().response(); + + Object authorization = response.path("headers.Authorization"); + assertNotNull(authorization, "the session must mediate a bearer to the upstream"); + assertTrue(authorization.toString().contains("Bearer"), + "the mediated upstream credential must be a bearer token"); + assertNull(response.path("headers.Cookie"), + "the browser session cookie must never be forwarded upstream"); + } + + @Test + @DisplayName("an unauthenticated XHR on a require:session route is rejected 401 problem+json") + void unauthenticatedXhrRejected() { + var response = given() + .header("Accept", "application/json") + .when() + .get("/bff-session/get") + .then() + .statusCode(401) + .extract(); + + assertTrue(response.contentType().contains("application/problem+json"), + "an unauthenticated non-navigation request must render RFC 9457 problem+json"); + assertNull(response.path("method"), "a challenged request must never reach the upstream"); + } + + @Test + @DisplayName("an unauthenticated navigation on a require:session route is redirected 302 into the IdP") + void unauthenticatedNavigationRedirectsToIdp() { + var response = given() + .header("Accept", "text/html") + .redirects().follow(false) + .when() + .get("/bff-session/get") + .then() + .statusCode(302) + .extract(); + + String location = response.header("Location"); + assertNotNull(location, "a navigation challenge must carry a Location redirect"); + assertTrue(location.contains("/protocol/openid-connect/auth"), + "the navigation challenge must redirect into the OIDC authorization endpoint"); + } + + @Test + @DisplayName("the mediated session stays usable across sequential requests (transparent refresh path)") + void mediatedSessionStaysUsableAcrossRequests() { + // Session continuity: two sequential mediated requests both reach the upstream through the same + // server-side session. The transparent near-expiry refresh (leeway_seconds: 30) only re-drives + // the RefreshFlow when the mediated token is within its leeway of expiry; the integration realm's + // 900s access-token lifespan keeps the token well inside its validity for both requests, so this + // asserts the always-available continuity path rather than forcing a refresh (a forced-refresh + // trigger needs a short access-token-lifespan realm client and is validated by the native+Docker + // IT run, not asserted with a wall-clock wait here). + Session session = BffKeycloakLoginFlow.login("/bff-session/get"); + + for (int request = 0; request < 2; request++) { + var response = BffKeycloakLoginFlow.gateway(session.gatewayCookies()) + .when() + .get("/bff-session/get") + .then() + .statusCode(200) + .extract(); + assertEquals("GET", response.path("method"), + "every mediated request in the live session must reach the upstream"); + } + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffUserInfoIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffUserInfoIT.java new file mode 100644 index 00000000..825e3d0d --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffUserInfoIT.java @@ -0,0 +1,66 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.integration; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import de.cuioss.sheriff.gateway.integration.BffKeycloakLoginFlow.Session; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Exercises the reserved {@code /auth/userinfo} session/user-info fold end-to-end. + *

    + * Without a session the fold is closed ({@code 401}); with a live session it serves the curated + * default view, capped by the operator claim allowlist. The mounted {@code oidc.user_info} config sets + * {@code default_view: [sub, preferred_username]}, so the response discloses exactly those claims — the + * seeded {@code integration-user}'s {@code preferred_username} is the observable identity proof. + */ +class BffUserInfoIT extends BaseIntegrationTest { + + @Test + @DisplayName("the user-info fold is closed without a session (401)") + void userInfoRequiresSession() { + given() + .header("Accept", "application/json") + .when() + .get("/auth/userinfo") + .then() + .statusCode(401); + } + + @Test + @DisplayName("the user-info fold serves the curated default view for a live session") + void userInfoServesCuratedDefaultView() { + Session session = BffKeycloakLoginFlow.login("/bff-session/get"); + + var response = BffKeycloakLoginFlow.gateway(session.gatewayCookies()) + .header("Accept", "application/json") + .when() + .get("/auth/userinfo") + .then() + .statusCode(200) + .extract(); + + assertEquals("integration-user", response.path("preferred_username"), + "the curated default view must disclose the session user's preferred_username"); + assertNull(response.path("client_secret"), + "the fold must never disclose a claim outside the operator allowlist"); + } +} From e9169dc879248a0e4af15a8c0719f6fd511cc90a Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:58:58 +0200 Subject: [PATCH 20/39] fix(native): initialize BFF SecureRandom-holding classes at runtime GraalVM initializes static state at image build time by default, which baked the three BFF records' static final SecureRandom into the image heap. This both broke the native build ("instance of Random/SplittableRandom in the image heap") and was a real security defect: a build-time-seeded SecureRandom yields predictable randomness from a cached seed. Force runtime initialization via per-class --initialize-at-run-time entries in quarkus.native.additional-build-args for: - de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord - de.cuioss.sheriff.gateway.bff.session.SessionRecord - de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout --- api-sheriff/src/main/resources/application.properties | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/api-sheriff/src/main/resources/application.properties b/api-sheriff/src/main/resources/application.properties index 1aa86925..7c00d814 100644 --- a/api-sheriff/src/main/resources/application.properties +++ b/api-sheriff/src/main/resources/application.properties @@ -38,7 +38,14 @@ 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). +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 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 From 0fb5bd08794cf45fcbd7d12a5a76aa6d9048af46 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:09:04 +0200 Subject: [PATCH 21/39] fix(native): initialize token-sheriff engine flow package at runtime --- api-sheriff/src/main/resources/application.properties | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/api-sheriff/src/main/resources/application.properties b/api-sheriff/src/main/resources/application.properties index 7c00d814..c1b7fea8 100644 --- a/api-sheriff/src/main/resources/application.properties +++ b/api-sheriff/src/main/resources/application.properties @@ -45,7 +45,14 @@ quarkus.tls.alpn=true # 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). -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 +# 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 From ecbe50a0345e2acbf83bdcee504bf4fc3268d4da Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:18:01 +0200 Subject: [PATCH 22/39] fix(it): resolve BFF oidc client_secret via env indirection The native+Docker integration stack refused to start with ApiSheriff-200: oidc.client_secret in gateway.yaml carried a literal value, but ConfigValidator requires a bare required ${VAR} reference. Change it to ${OIDC_CLIENT_SECRET} and inject the container-side value through the api-sheriff service environment so EnvSecretResolver resolves it at boot. --- integration-tests/docker-compose.yml | 3 +++ integration-tests/src/main/docker/sheriff-config/gateway.yaml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index a0b961b5..32d02932 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -183,6 +183,9 @@ services: # sources its routes and upstreams from the assembled RouteTable, and the # UPSTREAM topology alias resolves to the go-httpbin echo backend. - SHERIFF_CONFIG_DIR=/app/sheriff-config + # Container-side value the bare ${OIDC_CLIENT_SECRET} reference in + # gateway.yaml (oidc.client_secret) resolves to via EnvSecretResolver. + - OIDC_CLIENT_SECRET=integration-secret # Map form so grpc-echo can gate on service_healthy — the gateway must not start # until the gRPC echo upstream is accepting connections, otherwise the first diff --git a/integration-tests/src/main/docker/sheriff-config/gateway.yaml b/integration-tests/src/main/docker/sheriff-config/gateway.yaml index a2552535..42e720ee 100644 --- a/integration-tests/src/main/docker/sheriff-config/gateway.yaml +++ b/integration-tests/src/main/docker/sheriff-config/gateway.yaml @@ -192,7 +192,7 @@ token_validation: oidc: issuer: https://keycloak:8443/realms/integration client_id: integration-client - client_secret: integration-secret + client_secret: ${OIDC_CLIENT_SECRET} scopes: ["openid", "profile", "email"] # Full browser-facing callback URL. Its host ('localhost') is the oidc host every # reserved path binds to; its origin ('https://localhost:10443') is the gateway From 01ebc7545bab04c96eda2126c546af423c5b278e Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:48:37 +0200 Subject: [PATCH 23/39] fix(it): keep mtls gateway.yaml in lockstep with the shared BFF endpoint The api-sheriff-mtls container overlays only gateway.yaml over the shared sheriff-config dir, so it must resolve the shared endpoints/ (including the D13 bff-session endpoint) identically to the primary gateway.yaml. Mirror the three D13 additions the shared bff-session endpoint needs into the mtls gateway.yaml: the bff-session anchor, the global oidc block, and the integration-keycloak token_validation issuer. Add OIDC_CLIENT_SECRET to the api-sheriff-mtls service so the ${OIDC_CLIENT_SECRET} reference resolves, matching the primary service. The tls block (mtls) stays the one intentional difference. Co-Authored-By: Claude Opus 4.8 (1M context) --- integration-tests/docker-compose.yml | 5 ++ .../docker/sheriff-config-mtls/gateway.yaml | 68 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index 32d02932..506010ea 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -277,6 +277,11 @@ services: # not the listener's control and would leave TLS 1.3 offered. - QUARKUS_HTTP_SSL_PROTOCOLS=TLSv1.2 - SHERIFF_CONFIG_DIR=/app/sheriff-config + # Container-side value the bare ${OIDC_CLIENT_SECRET} reference in the overlaid + # mtls gateway.yaml (oidc.client_secret) resolves to via EnvSecretResolver — kept + # in lockstep with the primary api-sheriff service, whose gateway.yaml has the same + # oidc block through the shared bff-session endpoint. + - OIDC_CLIENT_SECRET=integration-secret depends_on: keycloak: condition: service_started diff --git a/integration-tests/src/main/docker/sheriff-config-mtls/gateway.yaml b/integration-tests/src/main/docker/sheriff-config-mtls/gateway.yaml index cc501cbe..3a7b62d1 100644 --- a/integration-tests/src/main/docker/sheriff-config-mtls/gateway.yaml +++ b/integration-tests/src/main/docker/sheriff-config-mtls/gateway.yaml @@ -38,6 +38,19 @@ anchors: path_prefix: /bff type: proxy access: public + # Server-mode BFF session namespace exercised by the Bff*IT suite. A require:session + # route lives here so an authenticated browser session mediates a bearer to the + # go-httpbin echo upstream (BffSessionMediationIT). type: bff forces access: + # authenticated (ADR-0013), and the session floor is backed by the global oidc block + # below (ConfigValidator rejects a session floor with no oidc block). The reserved + # OIDC endpoints themselves are carved out of the route table on the oidc host and + # live under /auth (see the oidc block), disjoint from this /bff-session proxy prefix. + bff-session: + path_prefix: /bff-session + type: bff + access: authenticated + auth: + require: session secure: path_prefix: /secure type: proxy @@ -90,3 +103,58 @@ token_validation: url: https://keycloak:8443/realms/benchmark/protocol/openid-connect/certs allowed_egress_hosts: ["keycloak"] tls_profile: benchmark-idp + # The server-mode BFF (oidc block below) validates the id/access tokens the + # 'integration' realm mints for the browser session. The gateway's shared + # @GatewayValidator TokenValidator is built from THIS token_validation block (not + # the Quarkus %it issuer surface), so the integration issuer MUST be declared here + # or every mediated session token is rejected. Same container-internal iss the + # integration realm pins via frontendUrl https://keycloak:8443, reached over the + # shared api-sheriff network where 'keycloak' resolves by service name; the same + # narrow SSRF egress widening and self-signed trust profile the benchmark issuer uses. + - name: integration-keycloak + issuer: https://keycloak:8443/realms/integration + jwks: + source: http + url: https://keycloak:8443/realms/integration/protocol/openid-connect/certs + allowed_egress_hosts: ["keycloak"] + tls_profile: benchmark-idp +# --- Server-mode BFF confidential-client block (BFF-* integration suite) ----------- +# Activates the server-mode BffRuntime (BffRuntimeProducer builds the active runtime +# only when a global oidc block declares session.mode=server AND a redirect_uri). The +# reserved OIDC endpoints are carved out of the route table exactly on the oidc host — +# the host of redirect_uri, i.e. 'localhost' — and matched by exact path, so they live +# under /auth and never collide with the /bff-session proxy anchor above. Confidential +# client: integration-client / integration-secret against the compose 'integration' +# realm (standardFlowEnabled, redirectUris '*'). OIDC discovery is lazy (first engine +# use), so boot needs no live IdP; the browser reaches the gateway on the published +# host port 10443 and Keycloak on its published host port, while the gateway reaches +# Keycloak container-internally at keycloak:8443. +oidc: + issuer: https://keycloak:8443/realms/integration + client_id: integration-client + client_secret: ${OIDC_CLIENT_SECRET} + scopes: ["openid", "profile", "email"] + # Full browser-facing callback URL. Its host ('localhost') is the oidc host every + # reserved path binds to; its origin ('https://localhost:10443') is the gateway + # origin used for same-origin return-URL validation and the default CSRF trusted origin. + redirect_uri: https://localhost:10443/auth/callback + logout: + path: /auth/logout + post_logout_redirect_uri: https://localhost:10443/auth/logout/return + final_redirect: / + backchannel_path: /auth/backchannel + session: + mode: server + store: memory + ttl_seconds: 3600 + refresh: + enabled: true + leeway_seconds: 30 + csrf: + trusted_origins: ["https://localhost:10443"] + user_info: + path: /auth/userinfo + allowed_claims: ["sub", "preferred_username", "email", "groups"] + default_view: ["sub", "preferred_username"] + login: + path: /auth/login From 85f5bd091d0f93c24d4a73df2d8997e226a22ac2 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:24:06 +0200 Subject: [PATCH 24/39] fix(it): trust the private-CA IdP via the JVM default truststore for OIDC discovery (token-sheriff-client#597) The token-sheriff-client OIDC engine performs its outbound OIDC discovery/token/refresh/logout calls with only the JVM default TrustManager and exposes no per-client TLS-trust seam (token-sheriff-client#597), so the per-issuer tls_profile that validates JWKS does not cover them. Against the self-signed IT Keycloak the BFF login ITs failed PKIX/500. Pass javax.net.ssl.trustStore* as runtime -D args on the native runner command line (a native runner does not honour JAVA_TOOL_OPTIONS) for both the api-sheriff and api-sheriff-mtls services, pointing the JVM default truststore at the already-mounted private-CA PKCS12. Document the mechanism in the operator guide. Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/user/bff-session.adoc | 20 ++++++++++++++++++++ integration-tests/docker-compose.yml | 22 ++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/doc/user/bff-session.adoc b/doc/user/bff-session.adoc index 0b21dc48..61ac66cd 100644 --- a/doc/user/bff-session.adoc +++ b/doc/user/bff-session.adoc @@ -123,6 +123,26 @@ site. See link:../variants/02-bff-session.adoc#_csrf_defence[CSRF Defence]. without the IdP -- but logins fail until the issuer is reachable. Confirm egress to the issuer (and its JWKS) is permitted. +== Private-CA / self-signed IdP + +If your IdP presents a certificate from a *private CA* (or a self-signed cert), you must add that CA +to the *JVM default truststore*. The confidential-client engine uses the JVM default truststore for +its outbound OIDC calls (discovery, token, refresh, logout); the per-issuer `tls_profile` that +validates JWKS does *not* cover these calls (token-sheriff-client#597). Without it, logins fail with +a PKIX/TLS trust error even though JWKS validation succeeds. + +Point the JVM default truststore at your CA store with system properties on the runner command line +(a native runner does not honour `JAVA_TOOL_OPTIONS` — pass `-D` on the command line): + +[source] +---- +-Djavax.net.ssl.trustStore=/app/certificates/idp-truststore.p12 +-Djavax.net.ssl.trustStorePassword= +-Djavax.net.ssl.trustStoreType=PKCS12 +---- + +A publicly-trusted IdP needs none of this — the JDK's built-in truststore already trusts it. + == See also * link:../configuration.adoc#_oidc[Configuration Reference -- `oidc`] -- the authoritative key contract. diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index 506010ea..8e80d40e 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -156,6 +156,20 @@ services: - quay.io/quarkus/quarkus-distroless-image:2.0 - quay.io/quarkus/ubi9-quarkus-micro-image:2.0 + # Point the JVM DEFAULT truststore at the mounted private-CA PKCS12. The confidential-client + # OIDC engine (token-sheriff-client) performs its outbound OIDC discovery/token/refresh/logout + # calls with ONLY the JVM default TrustManager (TrustManagerFactory.init((KeyStore) null)) — it + # exposes no per-client TLS-trust seam (token-sheriff-client#597) — so the per-issuer tls_profile + # that already validates JWKS does NOT cover these calls. Against the self-signed IT Keycloak the + # login flows otherwise fail PKIX/500. The Dockerfile's ENTRYPOINT is exec-form with no CMD, so + # this command list appends as runtime -D args on the native runner's command line; a + # GraalVM/Mandrel native executable honors runtime -Djavax.net.ssl.trustStore* for the default + # TrustManager (JAVA_TOOL_OPTIONS is NOT honored by a native runner — command-line -D is). + command: + - -Djavax.net.ssl.trustStore=/app/certificates/localhost-truststore.p12 + - -Djavax.net.ssl.trustStorePassword=localhost-trust + - -Djavax.net.ssl.trustStoreType=PKCS12 + ports: - "10443:8443" # External test port for integration tests - "19000:9000" # Management interface (health/metrics, plain HTTP) @@ -260,6 +274,14 @@ services: # miss; the mutual-auth security check itself is identical on 1.2 and 1.3. api-sheriff-mtls: image: "api-sheriff:distroless" + # Same JVM-default-truststore trust as the primary instance: this service overlays the same oidc + # block (lockstep gateway.yaml) so its confidential-client engine needs the private-CA trust for + # any OIDC discovery/token call — harmless if the mTLS suite never exercises a login. See the + # primary api-sheriff service's command comment for the mechanism (token-sheriff-client#597). + command: + - -Djavax.net.ssl.trustStore=/app/certificates/localhost-truststore.p12 + - -Djavax.net.ssl.trustStorePassword=localhost-trust + - -Djavax.net.ssl.trustStoreType=PKCS12 ports: - "10444:8443" # External test port for MtlsHandshakeIT (test.mtls.port) - "19001:9000" # Management interface (health/metrics, plain HTTP) From 58fd123e33540aad16735dff5e74f70705bfc0ce Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:20:36 +0200 Subject: [PATCH 25/39] fix(bff): register token-sheriff-client engine response DTOs for native DslJson deserialization The confidential-client engine parses every back-channel IdP response (OIDC discovery, token endpoint, PAR, userinfo) with a DSL-JSON instance. For each response type the DSL-JSON annotation processor ships a compile-time __DslJsonConverter implementing com.dslplatform.json.Configuration, which DSL-JSON's ExternalConverterAnalyzer discovers by reflectively loading the .__DslJsonConverter class, instantiating it, and calling configure(dslJson). That reflective loadClass+newInstance works on the JVM but fails in native (Unable to find reader for provided type ... fallback serialization is not registered) unless the converters are registered for reflection. Add TokenClientDslJsonReflection registering the four generated converters (ProviderMetadata, TokenResponse, ParResponse, UserInfoResponse) plus their DTOs for native reflection. Fixes native OIDC discovery against Keycloak. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../quarkus/TokenClientDslJsonReflection.java | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/TokenClientDslJsonReflection.java 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. + } +} From a29c13dd10d8d2a370fd69aa6a49be6adef5248a Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:46:20 +0200 Subject: [PATCH 26/39] =?UTF-8?q?fix(bff):=20address=20CodeRabbit=20review?= =?UTF-8?q?=20=E2=80=94=20events=20object=20claim,=20backslash=20open-redi?= =?UTF-8?q?rect,=20local=20logout=20on=20failure,=20CSRF=20port=20normaliz?= =?UTF-8?q?ation,=20bounded=20logout=20body,=20benchmark=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the valid CodeRabbit findings on PR #103: - LogoutTokenValidator: validate events structurally as a JSON object carrying the back-channel-logout member as a key (reject scalar/array/value-position occurrences). - PendingAuthorizationRecord.sameOrigin: reject backslash-authority return URLs (/\evil.com etc.) that browsers normalize into protocol-relative open redirects. - TokenRefreshCoordinator: drop the redundant pre-create destroyById on the success path (create upserts by id), closing the resolve() miss window. - BackchannelLogoutEndpoint: fail closed to 400 on malformed percent-encoding instead of surfacing a 500. - LogoutEndpoint: always destroy the local session and clear the cookie even when the IdP end-session redirect construction throws. - JsonWriter: normalize non-finite doubles/floats to null so the user-info body is always valid JSON. - GatewayEdgeRoute.readFormBody: bound the back-channel body read with a 5s deadline, fail-closed on timeout. - BffRuntimeProducer.originOf: drop default ports (443/80) so the derived gateway origin matches the browser Origin for CSRF/same-origin checks. - session_mediated.js: target /bff-session/get with an integration-realm user. - sheriff-config-mtls/gateway.yaml: point redirect_uri/post_logout/trusted_origins at this instance's port 10444. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bff/logout/LogoutTokenValidator.java | 37 ++++- .../pending/PendingAuthorizationRecord.java | 10 +- .../bff/refresh/TokenRefreshCoordinator.java | 4 +- .../reserved/BackchannelLogoutEndpoint.java | 18 ++- .../gateway/bff/reserved/LogoutEndpoint.java | 16 ++- .../gateway/bff/runtime/JsonWriter.java | 12 +- .../gateway/edge/GatewayEdgeRoute.java | 14 +- .../gateway/quarkus/BffRuntimeProducer.java | 18 ++- .../bff/logout/LogoutTokenValidatorTest.java | 42 ++++++ .../PendingAuthorizationStoreTest.java | 9 ++ .../BackchannelLogoutEndpointTest.java | 116 ++++++++++++++++ .../bff/reserved/LogoutEndpointTest.java | 126 ++++++++++++++++++ .../gateway/bff/runtime/JsonWriterTest.java | 70 ++++++++++ .../quarkus/BffRuntimeProducerTest.java | 36 +++++ .../resources/k6-scripts/session_mediated.js | 15 ++- .../docker/sheriff-config-mtls/gateway.yaml | 12 +- 16 files changed, 529 insertions(+), 26 deletions(-) create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpointTest.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpointTest.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/JsonWriterTest.java 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 index ca20a0c5..3fe0b526 100644 --- 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 @@ -108,8 +108,8 @@ public Optional validate(TokenContent logoutToken, Instant now) { LOGGER.debug("Back-channel logout token rejected — iat missing or outside the freshness window"); return Optional.empty(); } - if (claim(logoutToken, CLAIM_EVENTS).filter(events -> events.contains(BACKCHANNEL_LOGOUT_EVENT)).isEmpty()) { - LOGGER.debug("Back-channel logout token rejected — events claim missing the back-channel-logout event"); + 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()) { @@ -146,6 +146,39 @@ private boolean isFresh(TokenContent token, Instant now) { 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()) { 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 index b4f4d608..a209bacc 100644 --- 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 @@ -122,7 +122,8 @@ public boolean isExpired(Instant now) { /** * 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 cross-origin absolute + * {@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. * @@ -134,6 +135,13 @@ public static boolean sameOrigin(@Nullable String returnUrl, String gatewayOrigi 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; } diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java index 2cc03bfd..78afbbe6 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java @@ -146,7 +146,9 @@ private RefreshOutcome performRefresh(String sessionId, Instant now) { try { RotationResult rotation = refreshExchange.exchange(presentedRefreshToken); SessionRecord rotated = rotate(latest, rotation); - sessionStore.destroyById(sessionId); + // 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); diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpoint.java index 17497927..e411baff 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpoint.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpoint.java @@ -104,16 +104,24 @@ private static Optional extractLogoutToken(@Nullable String rawFormBody) if (equals <= 0) { continue; } - if (LOGOUT_TOKEN_PARAM.equals(decode(pair.substring(0, equals)))) { - String value = decode(pair.substring(equals + 1)); - return value.isBlank() ? Optional.empty() : Optional.of(value); + 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 String decode(String value) { - return URLDecoder.decode(value, StandardCharsets.UTF_8); + 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(); + } } /** diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java index b4af838e..1a2b8cfe 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java @@ -105,7 +105,21 @@ public LogoutOutcome logout(@Nullable String cookieHeader, Instant now) { List.of(sessionCookieCodec.toClearingSetCookieHeader())); } - RpInitiatedLogout.LogoutRedirect redirect = rpInitiatedLogout.initiate(session.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.get()); + 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.get()); List setCookies = new ArrayList<>(redirect.setCookieHeaders()); setCookies.add(sessionCookieCodec.toClearingSetCookieHeader()); 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 index a82a01b2..c5701920 100644 --- 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 @@ -55,13 +55,23 @@ private static void write(StringBuilder out, @Nullable Object value) { case null -> out.append("null"); case String string -> writeString(out, string); case Boolean bool -> out.append(bool.booleanValue()); - case Number number -> out.append(number); + 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; 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 e5414268..df9d4aba 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 @@ -31,6 +31,7 @@ import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -157,6 +158,10 @@ public class GatewayEdgeRoute { private static final int INTERNAL_ERROR = 500; private static final int BAD_GATEWAY = 502; private static final long DRAIN_POLL_INTERVAL_MILLIS = 50L; + // A small fixed deadline for reading the tiny back-channel logout form body (a single + // logout_token). Bounding the read prevents a slow/stalled chunked body from pinning the handler + // thread; a timeout is treated as fail-closed (rejected 400), consistent with the read-failure path. + private static final long BACKCHANNEL_BODY_READ_TIMEOUT_SECONDS = 5L; /** Per-request {@link RoutingContext} data key holding the resolved metrics route label. */ private static final String ROUTE_KEY = "sheriff.route"; @@ -532,7 +537,8 @@ private void renderReserved(RoutingContext ctx, PipelineRequest request, // rather than escape onto the request path, so the null return drives the receiver's 400. // cui-rewrite:disable InvalidExceptionUsageRecipe try { - Buffer body = ctx.request().body().toCompletionStage().toCompletableFuture().get(); + Buffer body = ctx.request().body().toCompletionStage().toCompletableFuture() + .get(BACKCHANNEL_BODY_READ_TIMEOUT_SECONDS, TimeUnit.SECONDS); return body == null ? null : body.toString(StandardCharsets.UTF_8); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); @@ -540,6 +546,12 @@ private void renderReserved(RoutingContext ctx, PipelineRequest request, } catch (ExecutionException failure) { LOGGER.debug(failure, "Back-channel logout body read failed: %s", failure.getMessage()); return null; + } catch (TimeoutException timeout) { + // A slow/stalled chunked body exceeded the read deadline — fail closed to a rejected 400 + // rather than pinning the handler thread waiting for a body that may never arrive. + LOGGER.debug(timeout, "Back-channel logout body read timed out after %s s — rejected", + BACKCHANNEL_BODY_READ_TIMEOUT_SECONDS); + return null; } } diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java index 2b72d2b7..6aa455c1 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java @@ -297,7 +297,7 @@ private static Map toClaimMap(Map claims) { * {@code redirect_uri}, used to same-origin-validate post-login return URLs and as the default * CSRF trusted origin. */ - private static String originOf(String redirectUri) { + static String originOf(String redirectUri) { URI uri = URI.create(redirectUri); String scheme = uri.getScheme(); String host = uri.getHost(); @@ -306,12 +306,26 @@ private static String originOf(String redirectUri) { } int port = uri.getPort(); StringBuilder origin = new StringBuilder(scheme).append("://").append(host); - if (port != -1) { + // 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 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 index 6ede227f..2afc7634 100644 --- 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 @@ -207,6 +207,48 @@ void shouldRejectEventsWithoutBackchannelEvent() { assertTrue(validator.validate(token(claims), NOW).isEmpty()); } + @Test + @DisplayName("Should reject a scalar events claim that merely equals the event URI (not a JSON object)") + void shouldRejectScalarEventsEqualToUri() { + Map claims = validClaims(); + claims.put("events", ClaimValue.forPlainString(LogoutTokenValidator.BACKCHANNEL_LOGOUT_EVENT)); + + assertTrue(validator.validate(token(claims), NOW).isEmpty(), + "a scalar events string that only contains the event URI must not destroy a session"); + } + + @Test + @DisplayName("Should reject an events claim that is a JSON array containing the event URI") + void shouldRejectEventsArray() { + Map claims = validClaims(); + claims.put("events", ClaimValue.forPlainString( + "[\"" + LogoutTokenValidator.BACKCHANNEL_LOGOUT_EVENT + "\"]")); + + assertTrue(validator.validate(token(claims), NOW).isEmpty(), "events must be a JSON object, not an array"); + } + + @Test + @DisplayName("Should reject an events object carrying the event URI only as a value, not a member key") + void shouldRejectEventsUriInValuePosition() { + Map claims = validClaims(); + claims.put("events", ClaimValue.forPlainString( + "{\"event\":\"" + LogoutTokenValidator.BACKCHANNEL_LOGOUT_EVENT + "\"}")); + + assertTrue(validator.validate(token(claims), NOW).isEmpty(), + "the back-channel-logout URI must be a member key, not a member value"); + } + + @Test + @DisplayName("Should accept an events object that carries the member key with surrounding whitespace") + void shouldAcceptEventsObjectWithWhitespace() { + Map claims = validClaims(); + claims.put("events", ClaimValue.forPlainString( + "{ \"" + LogoutTokenValidator.BACKCHANNEL_LOGOUT_EVENT + "\" : {} }")); + + assertTrue(validator.validate(token(claims), NOW).isPresent(), + "compact and pretty-printed object serializations are both accepted"); + } + @Test @DisplayName("Should reject a token that carries a nonce (prohibited in a logout token)") void shouldRejectPresentNonce() { diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStoreTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStoreTest.java index e6bfd0f9..439003e4 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStoreTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStoreTest.java @@ -231,6 +231,15 @@ void shouldRejectOffOrigin(String returnUrl) { assertFalse(PendingAuthorizationRecord.sameOrigin(returnUrl, GATEWAY_ORIGIN)); } + @ParameterizedTest(name = "backslash-authority return URL \"{0}\" is rejected") + @ValueSource(strings = {"/\\evil.example.com", "/\\/evil.example.com", "\\evil.example.com", + "\\\\evil.example.com", "/app\\x"}) + @DisplayName("Should reject a backslash-authority return URL (browsers normalize \\ to /)") + void shouldRejectBackslashAuthority(String returnUrl) { + assertFalse(PendingAuthorizationRecord.sameOrigin(returnUrl, GATEWAY_ORIGIN), + "a backslash the browser normalizes to / must never yield a protocol-relative open redirect"); + } + @Test @DisplayName("Should reject a null or blank return URL") void shouldRejectNullOrBlank() { diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpointTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpointTest.java new file mode 100644 index 00000000..5633b820 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpointTest.java @@ -0,0 +1,116 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import de.cuioss.sheriff.gateway.bff.logout.BackchannelLogoutReceiver; +import de.cuioss.sheriff.gateway.bff.logout.LogoutTokenValidator; +import de.cuioss.sheriff.gateway.bff.reserved.BackchannelLogoutEndpoint.BackchannelLogoutOutcome; +import de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore; +import de.cuioss.sheriff.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.Test; + +/** + * Tests for {@link BackchannelLogoutEndpoint}: the request/response edge over + * {@link BackchannelLogoutReceiver}. The focus here is the {@code application/x-www-form-urlencoded} + * body parsing — in particular that a malformed percent-encoded {@code logout_token} value fails + * closed to {@code 400} rather than surfacing a {@code 500} (the receiver's signature seam is bound + * to a hand-built token so the accepted path is exercised without a live IdP). + */ +class BackchannelLogoutEndpointTest { + + private static final String ISSUER = "https://idp.example.com"; + private static final String AUDIENCE = "bff-client"; + private static final Instant NOW = Instant.parse("2026-07-23T10:00:00Z"); + + private static TokenContent validLogoutToken() { + Map claims = 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("user-sub-1")); + return new IdTokenContent(claims, "raw-logout-token"); + } + + private BackchannelLogoutEndpoint endpoint(AtomicBoolean verifierInvoked) { + LogoutTokenValidator validator = new LogoutTokenValidator(ISSUER, AUDIENCE, Duration.ofMinutes(2)); + BackchannelLogoutReceiver receiver = new BackchannelLogoutReceiver(rawToken -> { + verifierInvoked.set(true); + return validLogoutToken(); + }, validator, new InMemorySessionStore(16)); + return new BackchannelLogoutEndpoint(receiver); + } + + @Test + @DisplayName("Should reject a logout_token carrying malformed percent-encoding 400, without invoking the receiver") + void shouldRejectMalformedPercentEncoding() { + AtomicBoolean verifierInvoked = new AtomicBoolean(false); + + BackchannelLogoutOutcome outcome = endpoint(verifierInvoked).receive("logout_token=%ZZ", NOW); + + assertEquals(400, outcome.status(), "malformed percent-encoding fails closed to 400, not 500"); + assertFalse(outcome.isAccepted()); + assertFalse(verifierInvoked.get(), "a body the endpoint could not decode never reaches the receiver"); + } + + @Test + @DisplayName("Should reject a malformed percent-encoded parameter name 400") + void shouldRejectMalformedParameterName() { + AtomicBoolean verifierInvoked = new AtomicBoolean(false); + + BackchannelLogoutOutcome outcome = endpoint(verifierInvoked).receive("logout%ZZtoken=abc", NOW); + + assertEquals(400, outcome.status()); + assertFalse(verifierInvoked.get()); + } + + @Test + @DisplayName("Should reject a request with no logout_token parameter 400") + void shouldRejectAbsentToken() { + BackchannelLogoutOutcome outcome = endpoint(new AtomicBoolean(false)).receive("other=value", NOW); + + assertEquals(400, outcome.status()); + } + + @Test + @DisplayName("Should accept a well-formed percent-encoded logout_token 200") + void shouldAcceptWellFormedToken() { + AtomicBoolean verifierInvoked = new AtomicBoolean(false); + + BackchannelLogoutOutcome outcome = endpoint(verifierInvoked).receive("logout_token=abc.def.ghi", NOW); + + assertEquals(200, outcome.status()); + assertTrue(outcome.isAccepted()); + assertTrue(verifierInvoked.get(), "a well-formed token reaches the signature-verification seam"); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpointTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpointTest.java new file mode 100644 index 00000000..5867d935 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpointTest.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.reserved; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.time.Instant; +import java.util.Set; + +import de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout; +import de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout.TokenRevocation; +import de.cuioss.sheriff.gateway.bff.reserved.LogoutEndpoint.LogoutOutcome; +import de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore; +import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec; +import de.cuioss.sheriff.gateway.bff.session.SessionRecord; +import de.cuioss.sheriff.token.client.logout.EndSessionFlow; +import de.cuioss.sheriff.token.client.logout.PostLogoutRedirectValidator; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link LogoutEndpoint}, focused on the invariant that local logout ALWAYS succeeds. When + * the IdP end-session redirect construction fails ({@link RpInitiatedLogout#initiate} throws, e.g. the + * engine {@link PostLogoutRedirectValidator} rejects an unregistered {@code post_logout_redirect_uri}), + * the endpoint must still destroy the server-side session and clear the session cookie before landing + * the browser on {@code final_redirect} — a redirect-construction failure must never leave the local + * session usable. + */ +class LogoutEndpointTest { + + private static final String END_SESSION = "https://idp.example.com/protocol/openid-connect/logout"; + private static final String REGISTERED_RETURN = "https://gw.example.com/auth/logout/return"; + private static final String FINAL_REDIRECT = "/goodbye"; + private static final Duration STATE_TTL = Duration.ofSeconds(60); + private static final Instant NOW = Instant.parse("2026-07-23T10:00:00Z"); + + private InMemorySessionStore store; + private SessionCookieCodec cookieCodec; + private EndSessionFlow endSessionFlow; + private String sessionId; + private String cookieHeader; + + @BeforeEach + void setUp() { + store = new InMemorySessionStore(16); + cookieCodec = new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, Duration.ofHours(8)); + endSessionFlow = new EndSessionFlow(new PostLogoutRedirectValidator(Set.of(REGISTERED_RETURN))); + sessionId = SessionRecord.newSessionId(); + SessionRecord session = SessionRecord.builder() + .sessionId(sessionId) + .accessToken("mediated-access-token") + .idToken("raw-id-token") + .sub("user-sub-1") + .expiresAt(NOW.plus(Duration.ofHours(8))) + .build(); + store.create(session); + cookieHeader = SessionCookieCodec.DEFAULT_COOKIE_NAME + "=" + sessionId; + } + + private LogoutEndpoint endpoint(String postLogoutRedirectUri) { + TokenRevocation revocation = ignored -> { + }; + RpInitiatedLogout logout = new RpInitiatedLogout(endSessionFlow, revocation, END_SESSION, + postLogoutRedirectUri, FINAL_REDIRECT, STATE_TTL); + return new LogoutEndpoint(logout, store, cookieCodec); + } + + @Test + @DisplayName("Should destroy the local session and clear the cookie when end-session redirect construction fails") + void shouldAlwaysLogOutLocallyOnInitiationFailure() { + // An unregistered post_logout_redirect_uri makes the engine validator throw inside initiate(). + LogoutEndpoint endpoint = endpoint("https://evil.example.com/steal"); + + LogoutOutcome outcome = endpoint.logout(cookieHeader, NOW); + + assertTrue(outcome.isRedirect(), "local logout must still land the browser on a safe redirect"); + assertEquals(FINAL_REDIRECT, outcome.location().orElseThrow(), + "a failed IdP redirect falls back to final_redirect"); + assertTrue(store.resolve(sessionId, NOW).isEmpty(), + "the server-side session is destroyed even when the IdP redirect could not be built"); + assertTrue(outcome.setCookieHeaders().stream().anyMatch(header -> header.contains("Max-Age=0")), + "the session cookie is cleared on the fallback path"); + } + + @Test + @DisplayName("Should redirect to the IdP end_session_endpoint and destroy the session on the happy path") + void shouldRedirectToIdpAndDestroyOnSuccess() { + LogoutEndpoint endpoint = endpoint(REGISTERED_RETURN); + + LogoutOutcome outcome = endpoint.logout(cookieHeader, NOW); + + assertTrue(outcome.isRedirect()); + assertTrue(outcome.location().orElseThrow().startsWith(END_SESSION), + "a registered return URI yields the IdP end-session redirect"); + assertTrue(store.resolve(sessionId, NOW).isEmpty(), "the server-side session is destroyed"); + } + + @Test + @DisplayName("Should land on final_redirect and clear the cookie when no live session is present") + void shouldLandOnFinalRedirectWithoutSession() { + LogoutEndpoint endpoint = endpoint(REGISTERED_RETURN); + + LogoutOutcome outcome = endpoint.logout(null, NOW); + + assertTrue(outcome.isRedirect()); + assertEquals(FINAL_REDIRECT, outcome.location().orElseThrow()); + assertTrue(outcome.setCookieHeaders().stream().anyMatch(header -> header.contains("Max-Age=0"))); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/JsonWriterTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/JsonWriterTest.java new file mode 100644 index 00000000..02cdb035 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/JsonWriterTest.java @@ -0,0 +1,70 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link JsonWriter}, the minimal dependency-free serializer for the curated user-info + * disclosure. The focus is that a non-finite floating-point leaf ({@link Double#NaN}, + * {@code ±Infinity}) is normalized to {@code null} so the gateway-controlled body is always valid + * JSON (RFC 8259 defines no {@code NaN} / {@code Infinity} token), alongside the ordinary leaf shapes. + */ +class JsonWriterTest { + + @Test + @DisplayName("Should normalize a non-finite double to null so the body stays valid JSON") + void shouldNormalizeNonFiniteDouble() { + assertEquals("null", JsonWriter.toJson(Double.NaN)); + assertEquals("null", JsonWriter.toJson(Double.POSITIVE_INFINITY)); + assertEquals("null", JsonWriter.toJson(Double.NEGATIVE_INFINITY)); + assertEquals("null", JsonWriter.toJson(Float.NaN)); + assertEquals("null", JsonWriter.toJson(Float.POSITIVE_INFINITY)); + } + + @Test + @DisplayName("Should serialize finite numbers, strings, booleans, and null unchanged") + void shouldSerializeScalars() { + assertEquals("1.5", JsonWriter.toJson(1.5d)); + assertEquals("42", JsonWriter.toJson(42)); + assertEquals("true", JsonWriter.toJson(Boolean.TRUE)); + assertEquals("null", JsonWriter.toJson(null)); + assertEquals("\"hi\"", JsonWriter.toJson("hi")); + } + + @Test + @DisplayName("Should normalize a non-finite double nested inside an object and array") + void shouldNormalizeNonFiniteNested() { + Map object = new LinkedHashMap<>(); + object.put("finite", 1); + object.put("broken", Double.NaN); + + assertEquals("{\"finite\":1,\"broken\":null}", JsonWriter.toJson(object)); + + List array = new ArrayList<>(); + array.add(1); + array.add(Double.POSITIVE_INFINITY); + assertEquals("[1,null]", JsonWriter.toJson(array)); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java index f9e2f19d..66d69f77 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java @@ -134,6 +134,42 @@ void shouldRejectUseOfInert() { } } + @Nested + @DisplayName("Gateway-origin derivation (default-port normalization)") + class OriginDerivation { + + @Test + @DisplayName("Should drop the default https port 443 so the origin matches a browser Origin header") + void shouldNormalizeHttpsDefaultPort() { + assertEquals("https://gw.example.com", + BffRuntimeProducer.originOf("https://gw.example.com:443/auth/callback")); + } + + @Test + @DisplayName("Should drop the default http port 80 so the origin matches a browser Origin header") + void shouldNormalizeHttpDefaultPort() { + assertEquals("http://gw.example.com", + BffRuntimeProducer.originOf("http://gw.example.com:80/auth/callback")); + } + + @Test + @DisplayName("Should preserve a non-default explicit port and a portless URL") + void shouldPreserveNonDefaultPort() { + assertEquals("https://gw.example.com:8443", + BffRuntimeProducer.originOf("https://gw.example.com:8443/auth/callback")); + assertEquals("https://gw.example.com", + BffRuntimeProducer.originOf("https://gw.example.com/auth/callback")); + assertEquals("http://gw.example.com:8080", + BffRuntimeProducer.originOf("http://gw.example.com:8080/auth/callback")); + } + + @Test + @DisplayName("Should reject a redirect_uri that is not an absolute URI") + void shouldRejectRelativeRedirectUri() { + assertThrows(IllegalStateException.class, () -> BffRuntimeProducer.originOf("/auth/callback")); + } + } + private BffRuntimeProducer producer(Optional oidc) { GatewayConfig gatewayConfig = GatewayConfig.builder().version(1).oidc(oidc).build(); return new BffRuntimeProducer(gatewayConfig, new SingletonInstance<>(tokenValidator)); diff --git a/benchmarks/src/main/resources/k6-scripts/session_mediated.js b/benchmarks/src/main/resources/k6-scripts/session_mediated.js index 65100a73..24645594 100644 --- a/benchmarks/src/main/resources/k6-scripts/session_mediated.js +++ b/benchmarks/src/main/resources/k6-scripts/session_mediated.js @@ -30,7 +30,9 @@ const BENCHMARK_NAME = 'sessionMediated'; // The protected upstream route guarded by `require: session`: a request without a resolvable // session cookie is redirected to login, so a 200 here proves the mediation path ran end to end. -const TARGET_URL = __ENV.TARGET_URL || targetUrl('/secure/get'); +// It targets the session-gated `/bff-session` anchor (NOT `/secure`, which is `require: bearer` and +// would reject a replayed session cookie with a 401). +const TARGET_URL = __ENV.TARGET_URL || targetUrl('/bff-session/get'); // The gateway-owned login-initiation reserved path (oidc.login.path). Hitting it drives the // auth-code flow that ends with the gateway setting the opaque session cookie. @@ -40,11 +42,12 @@ const LOGIN_URL = __ENV.LOGIN_URL || targetUrl('/auth/login'); // replayed; no token material ever leaves the gateway. Matches SessionCookieCodec.DEFAULT_COOKIE_NAME. const SESSION_COOKIE_NAME = __ENV.SESSION_COOKIE_NAME || '__Host-sheriff-session'; -// Keycloak is reached by service name on the shared api-sheriff network, exactly as the bearer -// aspect reaches it: the benchmark realm import pins frontendUrl to https://keycloak:8443 so the -// gateway's benchmark-realm issuer validation matches. -const KEYCLOAK_USERNAME = __ENV.KEYCLOAK_USERNAME || 'benchmark-user'; -const KEYCLOAK_PASSWORD = __ENV.KEYCLOAK_PASSWORD || 'benchmark-password'; +// Keycloak is reached by service name on the shared api-sheriff network. Server-mode BFF session +// mediation is wired (the gateway's oidc block) to the `integration` realm and the `integration-client` +// confidential client, so the session login must authenticate an integration-realm user — NOT the +// bearer aspect's benchmark-realm benchmark-user, whose realm the session route does not mediate. +const KEYCLOAK_USERNAME = __ENV.KEYCLOAK_USERNAME || 'integration-user'; +const KEYCLOAK_PASSWORD = __ENV.KEYCLOAK_PASSWORD || 'integration-password'; export const options = { vus: vus(50), diff --git a/integration-tests/src/main/docker/sheriff-config-mtls/gateway.yaml b/integration-tests/src/main/docker/sheriff-config-mtls/gateway.yaml index 3a7b62d1..bd2eedbd 100644 --- a/integration-tests/src/main/docker/sheriff-config-mtls/gateway.yaml +++ b/integration-tests/src/main/docker/sheriff-config-mtls/gateway.yaml @@ -127,20 +127,20 @@ token_validation: # client: integration-client / integration-secret against the compose 'integration' # realm (standardFlowEnabled, redirectUris '*'). OIDC discovery is lazy (first engine # use), so boot needs no live IdP; the browser reaches the gateway on the published -# host port 10443 and Keycloak on its published host port, while the gateway reaches -# Keycloak container-internally at keycloak:8443. +# host port 10444 (this mTLS instance's own published port) and Keycloak on its published +# host port, while the gateway reaches Keycloak container-internally at keycloak:8443. oidc: issuer: https://keycloak:8443/realms/integration client_id: integration-client client_secret: ${OIDC_CLIENT_SECRET} scopes: ["openid", "profile", "email"] # Full browser-facing callback URL. Its host ('localhost') is the oidc host every - # reserved path binds to; its origin ('https://localhost:10443') is the gateway + # reserved path binds to; its origin ('https://localhost:10444') is the gateway # origin used for same-origin return-URL validation and the default CSRF trusted origin. - redirect_uri: https://localhost:10443/auth/callback + redirect_uri: https://localhost:10444/auth/callback logout: path: /auth/logout - post_logout_redirect_uri: https://localhost:10443/auth/logout/return + post_logout_redirect_uri: https://localhost:10444/auth/logout/return final_redirect: / backchannel_path: /auth/backchannel session: @@ -151,7 +151,7 @@ oidc: enabled: true leeway_seconds: 30 csrf: - trusted_origins: ["https://localhost:10443"] + trusted_origins: ["https://localhost:10444"] user_info: path: /auth/userinfo allowed_claims: ["sub", "preferred_username", "email", "groups"] From a184a5323011bd68c65aa6c08bdaddd744f9d46c Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:56:50 +0200 Subject: [PATCH 27/39] fix(bff): honor session-stage short-circuit at the edge so unauthenticated require:session navigations redirect (302) instead of reaching the upstream Co-Authored-By: Claude Opus 4.8 (1M context) --- .../gateway/edge/GatewayEdgeRoute.java | 10 ++++ .../SessionAuthenticationStageTest.java | 47 +++++++++++++++++++ 2 files changed, 57 insertions(+) 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 df9d4aba..c2f5d0bc 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 @@ -454,6 +454,16 @@ private void process(RoutingContext ctx) { 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 diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStageTest.java index a85152bd..3a62405e 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStageTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStageTest.java @@ -185,6 +185,53 @@ void treatsAbsentCookieAsUnauthenticated() { } } + @Nested + @DisplayName("Edge short-circuit contract (honored by GatewayEdgeRoute; edge-render IT-covered)") + class EdgeShortCircuitContract { + + /** + * Pins the stage-side half of the edge fix in {@code GatewayEdgeRoute.handle}: after the + * authentication stage runs, an unauthenticated navigation must leave the request short-circuited + * (302) with a {@code Location}, and — critically — mediate NO upstream bearer. The edge honors + * that short-circuit and renders the 302 instead of proceeding to the forward stage, so nothing is + * forwarded to the upstream. The 302 actually written by the edge (rather than a 200 fall-through to + * the upstream) is asserted end-to-end by the native BffSessionLoginIT / BffSessionMediationIT ITs. + */ + @Test + @DisplayName("an unauthenticated navigation leaves a 302 short-circuit and no bearer, so the edge redirects instead of forwarding") + void navigationLeavesShortCircuitAndNoBearer() { + SessionAuthenticationStage stage = stage(emptyStore(), identityRefresh(), scopesGranted(), redirectLogin()); + PipelineRequest request = sessionRequest(authConfig(List.of()), navigationHeaders()); + + stage.process(request); + + assertEquals(Optional.of(302), request.shortCircuitStatus(), + "the short-circuit the edge must honor is present after the auth stage runs"); + assertEquals(LOGIN_LOCATION, request.responseHeaders().get("Location"), + "the redirect target the edge renders is set on the request"); + assertTrue(request.mediatedBearer().isEmpty(), + "no bearer is mediated, so there is nothing for the edge to forward to the upstream"); + } + + /** + * The non-navigation counterpart: an unauthenticated XHR raises the 401 problem path (no + * short-circuit), so the edge takes its rejection/problem branch rather than the redirect. + */ + @Test + @DisplayName("an unauthenticated XHR raises 401 TOKEN_MISSING with no short-circuit, so the edge takes the problem path not the redirect") + void xhrRaises401WithNoShortCircuit() { + SessionAuthenticationStage stage = stage(emptyStore(), identityRefresh(), scopesGranted(), redirectLogin()); + PipelineRequest request = sessionRequest(authConfig(List.of()), xhrHeaders()); + + GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request)); + + assertEquals(EventType.TOKEN_MISSING, thrown.getEventType(), + "an unauthenticated non-navigation request is the 401 application/problem+json path"); + assertTrue(request.shortCircuitStatus().isEmpty(), + "no short-circuit is set, so the edge renders the problem response rather than a redirect"); + } + } + @Nested @DisplayName("Preconditions") class Preconditions { From 606d98f65ce1fc6f39dcb43be7ccce70ad56297c Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:26:04 +0200 Subject: [PATCH 28/39] chore(sonar): drive PR-103 new-code findings to zero (2 reliability bugs + smells) Fix the SonarCloud gate blocker for PR #103: two java:S3655 reliability BUGs (TokenRefreshCoordinator, LogoutEndpoint) that accessed an Optional value without a guard, plus the 56 new-code code smells, taking new-code findings to zero. Mechanical fixes (restricted-identifier renames, unnamed patterns, single-throw assertThrows lambdas, JSpecify null-safety guards) where a fix is correct; in-code suppressions with rationale where a fix fights the design (S107 wiring holder, S6206 behavior class, S1075 config pointers, S2589 load-bearing Map.get guard, S2925 thread-ordering sleep). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sheriff/gateway/bff/login/LoginFlow.java | 9 +- .../pending/PendingAuthorizationRecord.java | 2 +- .../pending/PendingAuthorizationStore.java | 16 ++-- .../bff/refresh/StepUpCoordinator.java | 9 +- .../bff/refresh/TokenRefreshCoordinator.java | 5 +- .../bff/reserved/CallbackEndpoint.java | 19 +++-- .../bff/reserved/ClaimAllowlistFilter.java | 4 + .../bff/reserved/LoginInitiationEndpoint.java | 3 +- .../gateway/bff/reserved/LogoutEndpoint.java | 11 +-- .../bff/reserved/ReservedPathRegistry.java | 4 +- .../gateway/bff/runtime/BffRuntime.java | 2 + .../runtime/SessionAuthenticationStage.java | 4 +- .../bff/session/InMemorySessionStore.java | 29 ++++--- .../gateway/bff/session/SessionStore.java | 4 +- .../config/validation/ConfigValidator.java | 4 + .../gateway/edge/GatewayEdgeRoute.java | 84 ++++++++++++------- .../gateway/quarkus/BffRuntimeProducer.java | 4 +- .../gateway/bff/login/LoginFlowTest.java | 8 +- .../bff/logout/LogoutTokenValidatorTest.java | 6 +- .../PendingAuthorizationStoreTest.java | 36 ++++---- .../bff/refresh/StepUpCoordinatorTest.java | 17 ++-- .../refresh/TokenRefreshCoordinatorTest.java | 11 ++- .../BackchannelLogoutEndpointTest.java | 1 + .../bff/reserved/CallbackEndpointTest.java | 18 ++-- .../reserved/ClaimAllowlistFilterTest.java | 6 +- .../bff/reserved/LogoutEndpointTest.java | 1 + .../gateway/bff/runtime/JsonWriterTest.java | 6 +- .../SessionAuthenticationStageTest.java | 4 +- .../bff/session/InMemorySessionStoreTest.java | 4 +- .../bff/session/SessionCookieCodecTest.java | 5 +- .../edge/GatewayEdgeRouteBffWiringTest.java | 4 +- .../quarkus/BffRuntimeProducerTest.java | 9 +- 32 files changed, 203 insertions(+), 146 deletions(-) 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 index 24d4f8cf..bcabc429 100644 --- 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 @@ -90,12 +90,13 @@ public LoginFlow(AuthorizationInitiation authorization, PendingAuthorizationStor public LoginRedirect initiate(@Nullable String requestedReturnUrl, Instant now) { Objects.requireNonNull(now, "now"); AuthorizationCodeFlow.AuthorizationRedirect redirect = authorization.authorize(); - String returnUrl = PendingAuthorizationRecord.sameOrigin(requestedReturnUrl, gatewayOrigin) + String returnUrl = requestedReturnUrl != null + && PendingAuthorizationRecord.sameOrigin(requestedReturnUrl, gatewayOrigin) ? requestedReturnUrl : DEFAULT_RETURN_URL; - PendingAuthorizationRecord record = PendingAuthorizationRecord.create(redirect.context(), returnUrl, now); - pendingStore.store(record); + 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(record.id())); + List setCookies = List.of(bindingCookieCodec.toSetCookieHeader(pending.id())); return new LoginRedirect(redirect.authorizationUrl(), setCookies); } 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 index a209bacc..cea69b7d 100644 --- 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 @@ -147,7 +147,7 @@ public static boolean sameOrigin(@Nullable String returnUrl, String gatewayOrigi } try { return sameOriginAbsolute(URI.create(returnUrl), URI.create(gatewayOrigin)); - } catch (IllegalArgumentException unparseable) { + } catch (IllegalArgumentException _) { return false; } } 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 index 72ea0ab2..f0b57c30 100644 --- 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 @@ -44,9 +44,9 @@ public interface PendingAuthorizationStore { /** * Persists a pending-authorization record under its {@link PendingAuthorizationRecord#id()}. * - * @param record the record to store + * @param pending the record to store */ - void store(PendingAuthorizationRecord record); + void store(PendingAuthorizationRecord pending); /** * Atomically resolves and removes the record for {@code recordId} (single-use). Returns @@ -88,9 +88,9 @@ public InMemory(int maxEntries) { } @Override - public synchronized void store(PendingAuthorizationRecord record) { - Objects.requireNonNull(record, "record"); - records.put(record.id(), record); + public synchronized void store(PendingAuthorizationRecord pending) { + Objects.requireNonNull(pending, "pending"); + records.put(pending.id(), pending); evictOldestBeyondCapacity(); } @@ -98,11 +98,11 @@ public synchronized void store(PendingAuthorizationRecord record) { public synchronized Optional consume(String recordId, Instant now) { Objects.requireNonNull(recordId, "recordId"); Objects.requireNonNull(now, "now"); - PendingAuthorizationRecord record = records.remove(recordId); - if (record == null || record.isExpired(now)) { + PendingAuthorizationRecord pending = records.remove(recordId); + if (pending == null || pending.isExpired(now)) { return Optional.empty(); } - return Optional.of(record); + return Optional.of(pending); } /** 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 index f965a99b..f9df2386 100644 --- 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 @@ -145,12 +145,13 @@ public StepUpOutcome coordinate(SessionRecord session, StepUpChallenge challenge } StepUpHandler.StepUpRequest request = stepUpInitiation.initiate(challenge); - String returnUrl = PendingAuthorizationRecord.sameOrigin(replayUrl, gatewayOrigin) + String returnUrl = replayUrl != null + && PendingAuthorizationRecord.sameOrigin(replayUrl, gatewayOrigin) ? replayUrl : DEFAULT_RETURN_URL; - PendingAuthorizationRecord record = PendingAuthorizationRecord.create(request.context(), returnUrl, now); - pendingStore.store(record); + 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(record.id())); + List setCookies = List.of(bindingCookieCodec.toSetCookieHeader(pending.id())); return StepUpOutcome.reDrive(request.authorizationUrl(), setCookies); } diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java index 78afbbe6..c878f009 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java @@ -138,11 +138,12 @@ private RefreshOutcome performRefresh(String sessionId, Instant now) { return RefreshOutcome.failed(); } SessionRecord latest = resolved.get(); - if (latest.refreshToken().isEmpty() || !nearExpiry(latest, now)) { + 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 = latest.refreshToken().get(); + String presentedRefreshToken = refreshToken.get(); try { RotationResult rotation = refreshExchange.exchange(presentedRefreshToken); SessionRecord rotated = rotate(latest, rotation); diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java index 905299e3..c1227796 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java @@ -156,16 +156,16 @@ public CallbackOutcome handle(String rawQuery, @Nullable String cookieHeader, In LOGGER.debug("OIDC callback binding cookie resolved no live pending record — rejected"); return CallbackOutcome.error(FORBIDDEN); } - PendingAuthorizationRecord record = resolved.get(); + PendingAuthorizationRecord pending = resolved.get(); - if (!constantTimeEquals(record.flowContext().state(), params.state())) { + 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(record.flowContext(), params); - return completeLogin(result, record, now); + 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); @@ -173,7 +173,7 @@ public CallbackOutcome handle(String rawQuery, @Nullable String cookieHeader, In } private CallbackOutcome completeLogin(AuthorizationCodeFlow.AuthenticationResult result, - PendingAuthorizationRecord record, Instant now) { + PendingAuthorizationRecord pending, Instant now) { AccessTokenContent accessToken = result.accessToken(); IdTokenContent idToken = result.idToken(); Optional subject = idToken.getSubject().or(accessToken::getSubject); @@ -199,7 +199,7 @@ private CallbackOutcome completeLogin(AuthorizationCodeFlow.AuthenticationResult List setCookies = List.of( sessionCookieCodec.toSetCookieHeader(sessionId), bindingCookieCodec.toClearingSetCookieHeader()); - return CallbackOutcome.redirect(record.returnUrl(), setCookies); + return CallbackOutcome.redirect(pending.returnUrl(), setCookies); } private static Optional claim(TokenContent token, String name) { @@ -215,7 +215,7 @@ private static Optional claimEpochSeconds(TokenContent token, String na return claim(token, name).flatMap(raw -> { try { return Optional.of(Instant.ofEpochSecond(Long.parseLong(raw.trim()))); - } catch (NumberFormatException notNumeric) { + } catch (NumberFormatException _) { return Optional.empty(); } }); @@ -225,7 +225,10 @@ private static boolean isBlank(@Nullable String value) { return value == null || value.isBlank(); } - private static boolean constantTimeEquals(String expected, String actual) { + 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)); } 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 index ce632c51..ce65815d 100644 --- 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 @@ -42,6 +42,10 @@ * @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; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpoint.java index 20ce1c39..c974399b 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpoint.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpoint.java @@ -115,7 +115,8 @@ public LoginInitiationOutcome initiate(@Nullable String requestedReturnUrl, @Nul Optional session = sessionCookieCodec.readSessionId(cookieHeader) .flatMap(sessionId -> sessionStore.resolve(sessionId, now)); if (session.isPresent()) { - String returnUrl = PendingAuthorizationRecord.sameOrigin(requestedReturnUrl, gatewayOrigin) + 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"); diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java index 1a2b8cfe..786af580 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java @@ -97,13 +97,14 @@ public LogoutEndpoint(RpInitiatedLogout rpInitiatedLogout, SessionStore sessionS public LogoutOutcome logout(@Nullable String cookieHeader, Instant now) { Objects.requireNonNull(now, "now"); - Optional sessionId = sessionCookieCodec.readSessionId(cookieHeader); - Optional session = sessionId.flatMap(id -> sessionStore.resolve(id, now)); - if (session.isEmpty()) { + 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 @@ -114,13 +115,13 @@ public LogoutOutcome logout(@Nullable String cookieHeader, Instant now) { try { redirect = rpInitiatedLogout.initiate(session.get()); } catch (RuntimeException initiationFailure) { - sessionStore.destroyById(sessionId.get()); + 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.get()); + 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"); 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 index b03bcb7b..9ff82013 100644 --- 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 @@ -161,7 +161,7 @@ public boolean isEmpty() { * relative, or unparseable value contributes nothing. */ private static Optional toPath(String value) { - if (value == null || value.isBlank()) { + if (value.isBlank()) { return Optional.empty(); } if (value.contains("://")) { @@ -173,7 +173,7 @@ private static Optional toPath(String value) { private static Optional parseUri(String value) { try { return Optional.of(URI.create(value.trim())); - } catch (IllegalArgumentException unparseable) { + } catch (IllegalArgumentException _) { return Optional.empty(); } } diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java index 3b3ee228..88488ac7 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java @@ -80,6 +80,7 @@ public final class BffRuntime { 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, @@ -111,6 +112,7 @@ private BffRuntime(boolean active, @Nullable SessionAuthenticationStage sessionS * @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, diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStage.java index 3043f30f..b432993e 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStage.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStage.java @@ -122,7 +122,7 @@ public void process(PipelineRequest request) { } SessionRecord session = tokenRefresh.refreshIfNeeded(resolved.get(), now); - enforceScopes(request, route, session); + enforceScopes(route, session); request.mediatedBearer(session.accessToken()); } @@ -131,7 +131,7 @@ private Optional resolveSession(PipelineRequest request, Instant .flatMap(sessionId -> sessionStore.resolve(sessionId, now)); } - private void enforceScopes(PipelineRequest request, RouteRuntime route, SessionRecord session) { + 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, 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 index b49e26b4..146bdad1 100644 --- 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 @@ -64,29 +64,29 @@ public InMemorySessionStore(int maxSessions) { } @Override - public synchronized void create(SessionRecord record) { - Objects.requireNonNull(record, "record"); + 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(record.sessionId(), record); - index(bySub, record.sub(), record.sessionId()); - record.sid().ifPresent(sid -> index(bySid, sid, record.sessionId())); + 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 record = byId.get(sessionId); - if (record == null) { + SessionRecord session = byId.get(sessionId); + if (session == null) { return Optional.empty(); } - if (record.isExpired(now)) { + if (session.isExpired(now)) { removeInternal(sessionId); return Optional.empty(); } - return Optional.of(record); + return Optional.of(session); } @Override @@ -127,6 +127,9 @@ 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; @@ -137,12 +140,12 @@ private int removeAll(Set sessionIds) { } private void removeInternal(String sessionId) { - SessionRecord record = byId.remove(sessionId); - if (record == null) { + SessionRecord session = byId.remove(sessionId); + if (session == null) { return; } - deindex(bySub, record.sub(), sessionId); - record.sid().ifPresent(sid -> deindex(bySid, sid, sessionId)); + deindex(bySub, session.sub(), sessionId); + session.sid().ifPresent(sid -> deindex(bySid, sid, sessionId)); } private static void index(Map> map, String key, String sessionId) { diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionStore.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionStore.java index 2337f333..ad6036cc 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionStore.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionStore.java @@ -36,10 +36,10 @@ public interface SessionStore { /** * Stores a freshly created session. * - * @param record the session to store + * @param session the session to store * @throws IllegalStateException when the store is at its max-session capacity bound */ - void create(SessionRecord record); + void create(SessionRecord session); /** * Resolves a live session by its opaque id, enforcing the absolute TTL lazily: an expired 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 fd7d8837..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,8 +106,12 @@ 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"; 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 c2f5d0bc..dc36223b 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 @@ -425,21 +425,7 @@ private void process(RoutingContext ctx) { return; } passthroughHostGuardStage.process(request); - // Reserved-path carve-out (D2/D16): a reserved OIDC endpoint is resolved here, ahead of the - // route table, so a proxy route such as path_prefix: /auth can never swallow the exact - // /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 NO_ROUTE_MATCHED (the empty reserved registry never matches there anyway). - Optional reserved = - reservedPathRegistry.match(request.host(), requireCanonicalPath(request)); - if (reserved.isPresent()) { - 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); - } + if (handleReservedPath(ctx, request)) { return; } routeSelectionStage.process(request); @@ -477,27 +463,63 @@ 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 @@ -550,7 +572,7 @@ private void renderReserved(RoutingContext ctx, PipelineRequest request, Buffer body = ctx.request().body().toCompletionStage().toCompletableFuture() .get(BACKCHANNEL_BODY_READ_TIMEOUT_SECONDS, TimeUnit.SECONDS); return body == null ? null : body.toString(StandardCharsets.UTF_8); - } catch (InterruptedException interrupted) { + } catch (InterruptedException _) { Thread.currentThread().interrupt(); return null; } catch (ExecutionException failure) { diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java index 6aa455c1..dd3c9192 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java @@ -212,7 +212,7 @@ private BffRuntime build(OidcConfig oidc) { // D4 session stage-4 runtime — binds refresh, scope enforcement, and the login-redirect seam. SessionAuthenticationStage sessionStage = new SessionAuthenticationStage(sessionStore, sessionCookieCodec, - (record, now) -> refreshCoordinator.refresh(record, now).session().orElse(record), + (sessionRecord, now) -> refreshCoordinator.refresh(sessionRecord, now).session().orElse(sessionRecord), (accessToken, requiredScopes) -> tokenBridge.validateAccessToken(accessToken) .providesScopes(requiredScopes), (returnUrl, now) -> { @@ -226,7 +226,7 @@ private BffRuntime build(OidcConfig oidc) { // edge integration is exercised by the Keycloak integration tests. StepUpHandler stepUpHandler = new StepUpHandler(); StepUpCoordinator stepUpCoordinator = new StepUpCoordinator( - (record, challenge, now) -> Optional.empty(), + (sessionRecord, challenge, now) -> Optional.empty(), challenge -> stepUpHandler.initiate(clientConfiguration, metadata.get(), challenge), pendingStore, bindingCookieCodec, gatewayOrigin); 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 index 45c14919..433d7cde 100644 --- 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 @@ -95,10 +95,10 @@ void shouldRedirectToAuthorizationUrl() { void shouldPersistPendingRecord() { LoginRedirect result = loginFlow.initiate("/dashboard", T0); - PendingAuthorizationRecord record = consumeBoundRecord(result); - assertSame(flowContext, record.flowContext(), "the record wraps the engine context, never re-invents it"); - assertEquals("/dashboard", record.returnUrl()); - assertEquals(T0, record.createdAt()); + 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()); } } 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 index 2afc7634..c431468b 100644 --- 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 @@ -64,7 +64,7 @@ class LogoutTokenValidatorTest { private final LogoutTokenValidator validator = new LogoutTokenValidator(ISSUER, AUDIENCE, FRESHNESS); private static Map validClaims() { - Map claims = new HashMap<>(Map.of( + 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)), @@ -72,7 +72,6 @@ private static Map validClaims() { "{\"" + LogoutTokenValidator.BACKCHANNEL_LOGOUT_EVENT + "\":{}}"), "sub", ClaimValue.forPlainString(SUB), "sid", ClaimValue.forPlainString(SID))); - return claims; } private static TokenContent token(Map claims) { @@ -291,7 +290,8 @@ void shouldRejectNullToken() { @Test @DisplayName("Should reject a null reference instant") void shouldRejectNullNow() { - assertThrows(NullPointerException.class, () -> validator.validate(token(validClaims()), null)); + TokenContent token = token(validClaims()); + assertThrows(NullPointerException.class, () -> validator.validate(token, null)); } @Test diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStoreTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStoreTest.java index 439003e4..4677fc04 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStoreTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStoreTest.java @@ -51,7 +51,7 @@ private static FlowContext flow() { return FlowContext.create(GATEWAY_ORIGIN + "/callback"); } - private static PendingAuthorizationRecord record(String returnUrl, Instant createdAt) { + private static PendingAuthorizationRecord pendingRecord(String returnUrl, Instant createdAt) { return PendingAuthorizationRecord.create(flow(), returnUrl, createdAt); } @@ -63,7 +63,7 @@ class InMemoryStore { @DisplayName("Should resolve a stored record exactly once (single-use consumption)") void shouldConsumeStoredRecordExactlyOnce() { PendingAuthorizationStore.InMemory store = new PendingAuthorizationStore.InMemory(16); - PendingAuthorizationRecord stored = record("/app", T0); + PendingAuthorizationRecord stored = pendingRecord("/app", T0); store.store(stored); Optional first = store.consume(stored.id(), T0); @@ -78,7 +78,7 @@ void shouldConsumeStoredRecordExactlyOnce() { @DisplayName("Should refuse a record consumed after its TTL has elapsed") void shouldRefuseExpiredRecord() { PendingAuthorizationStore.InMemory store = new PendingAuthorizationStore.InMemory(16); - PendingAuthorizationRecord stored = record("/app", T0); + PendingAuthorizationRecord stored = pendingRecord("/app", T0); store.store(stored); Instant afterTtl = T0.plus(PendingAuthorizationRecord.FIXED_TTL).plusSeconds(1); @@ -89,7 +89,7 @@ void shouldRefuseExpiredRecord() { @DisplayName("Should resolve a record consumed within its TTL") void shouldResolveRecordWithinTtl() { PendingAuthorizationStore.InMemory store = new PendingAuthorizationStore.InMemory(16); - PendingAuthorizationRecord stored = record("/app", T0); + PendingAuthorizationRecord stored = pendingRecord("/app", T0); store.store(stored); assertTrue(store.consume(stored.id(), T0.plusSeconds(1)).isPresent()); @@ -99,9 +99,9 @@ void shouldResolveRecordWithinTtl() { @DisplayName("Should evict the oldest record once the capacity bound is exceeded") void shouldEvictOldestBeyondCapacity() { PendingAuthorizationStore.InMemory store = new PendingAuthorizationStore.InMemory(2); - PendingAuthorizationRecord first = record("/a", T0); - PendingAuthorizationRecord second = record("/b", T0); - PendingAuthorizationRecord third = record("/c", T0); + PendingAuthorizationRecord first = pendingRecord("/a", T0); + PendingAuthorizationRecord second = pendingRecord("/b", T0); + PendingAuthorizationRecord third = pendingRecord("/c", T0); store.store(first); store.store(second); store.store(third); @@ -136,7 +136,7 @@ class CrossBrowserRejection { void shouldRefuseCallbackWithoutBindingCookie() { PendingAuthorizationStore.InMemory store = new PendingAuthorizationStore.InMemory(8); BindingCookieCodec codec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL); - PendingAuthorizationRecord stored = record("/app", T0); + PendingAuthorizationRecord stored = pendingRecord("/app", T0); store.store(stored); // A different browser presents a valid 'state' but carries no binding cookie. @@ -153,7 +153,7 @@ void shouldRefuseCallbackWithoutBindingCookie() { void shouldResolveForOriginatingBrowser() { PendingAuthorizationStore.InMemory store = new PendingAuthorizationStore.InMemory(8); BindingCookieCodec codec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL); - PendingAuthorizationRecord stored = record("/app", T0); + PendingAuthorizationRecord stored = pendingRecord("/app", T0); store.store(stored); String cookieHeader = codec.toSetCookieHeader(stored.id()).split(";", 2)[0]; @@ -180,21 +180,21 @@ void shouldGenerateDistinctIds() { @DisplayName("Should wrap the engine FlowContext and expose the gateway fields") void shouldWrapFlowContextAndGatewayFields() { FlowContext flow = flow(); - PendingAuthorizationRecord record = PendingAuthorizationRecord.create(flow, "/dashboard", T0); + PendingAuthorizationRecord pending = PendingAuthorizationRecord.create(flow, "/dashboard", T0); - assertSame(flow, record.flowContext(), "the record wraps the engine DTO, never re-invents it"); - assertEquals("/dashboard", record.returnUrl()); - assertEquals(T0, record.createdAt()); - assertEquals(PendingAuthorizationRecord.FIXED_TTL, record.ttl()); - assertEquals(T0.plus(PendingAuthorizationRecord.FIXED_TTL), record.expiresAt()); + assertSame(flow, pending.flowContext(), "the record wraps the engine DTO, never re-invents it"); + assertEquals("/dashboard", pending.returnUrl()); + assertEquals(T0, pending.createdAt()); + assertEquals(PendingAuthorizationRecord.FIXED_TTL, pending.ttl()); + assertEquals(T0.plus(PendingAuthorizationRecord.FIXED_TTL), pending.expiresAt()); } @Test @DisplayName("Should treat the TTL boundary as expired (inclusive)") void shouldTreatTtlBoundaryAsExpired() { - PendingAuthorizationRecord record = PendingAuthorizationRecord.create(flow(), "/app", T0); - assertFalse(record.isExpired(record.expiresAt().minusNanos(1))); - assertTrue(record.isExpired(record.expiresAt()), "expiry is inclusive of the boundary"); + PendingAuthorizationRecord pending = PendingAuthorizationRecord.create(flow(), "/app", T0); + assertFalse(pending.isExpired(pending.expiresAt().minusNanos(1))); + assertTrue(pending.isExpired(pending.expiresAt()), "expiry is inclusive of the boundary"); } @Test diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/StepUpCoordinatorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/StepUpCoordinatorTest.java index d363548f..b3eb43b0 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/StepUpCoordinatorTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/StepUpCoordinatorTest.java @@ -174,8 +174,8 @@ void shouldRecordSameOriginReplayUrl() { StepUpOutcome outcome = coordinator.coordinate(session("urn:example:silver"), challenge(), REPLAY_URL, NOW); - PendingAuthorizationRecord record = pendingStore.consume(recordIdFrom(outcome), NOW).orElseThrow(); - assertEquals(REPLAY_URL, record.returnUrl(), "the validated replay URL is recorded as the return target"); + PendingAuthorizationRecord pending = pendingStore.consume(recordIdFrom(outcome), NOW).orElseThrow(); + assertEquals(REPLAY_URL, pending.returnUrl(), "the validated replay URL is recorded as the return target"); } @Test @@ -186,8 +186,8 @@ void shouldRejectOffOriginReplayUrl() { StepUpOutcome outcome = coordinator.coordinate(session("urn:example:silver"), challenge(), "https://evil.example.com/steal", NOW); - PendingAuthorizationRecord record = pendingStore.consume(recordIdFrom(outcome), NOW).orElseThrow(); - assertEquals(StepUpCoordinator.DEFAULT_RETURN_URL, record.returnUrl(), + PendingAuthorizationRecord pending = pendingStore.consume(recordIdFrom(outcome), NOW).orElseThrow(); + assertEquals(StepUpCoordinator.DEFAULT_RETURN_URL, pending.returnUrl(), "an off-origin replay target is never an open redirect"); } } @@ -200,12 +200,15 @@ class Contracts { @DisplayName("Should reject null session, challenge, and instant") void shouldRejectNullArguments() { StepUpCoordinator coordinator = reDrivingCoordinator(); + var validSession = session(ACR); + var validChallenge = challenge(); - assertThrows(NullPointerException.class, () -> coordinator.coordinate(null, challenge(), REPLAY_URL, NOW)); assertThrows(NullPointerException.class, - () -> coordinator.coordinate(session(ACR), null, REPLAY_URL, NOW)); + () -> coordinator.coordinate(null, validChallenge, REPLAY_URL, NOW)); assertThrows(NullPointerException.class, - () -> coordinator.coordinate(session(ACR), challenge(), REPLAY_URL, null)); + () -> coordinator.coordinate(validSession, null, REPLAY_URL, NOW)); + assertThrows(NullPointerException.class, + () -> coordinator.coordinate(validSession, validChallenge, REPLAY_URL, null)); } @Test diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinatorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinatorTest.java index ef612d23..6e6ad0e8 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinatorTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinatorTest.java @@ -260,8 +260,10 @@ void shouldCoalesceConcurrentRefreshes() throws Exception { Future leader = pool.submit(() -> coordinator.refresh(live, NOW)); assertTrue(entered.await(2, TimeUnit.SECONDS), "the leader entered the engine refresh"); Future follower = pool.submit(() -> coordinator.refresh(live, NOW)); - // Let the follower reach the in-flight join before the leader is released. - Thread.sleep(100); + // Let the follower reach the in-flight join before the leader is released. There is no + // observable hook for a thread reaching CompletableFuture#join, so this best-effort + // ordering sleep cannot be made deterministic without an added dependency. + Thread.sleep(100); // NOSONAR java:S2925 - no observable hook for the follower reaching the in-flight join proceed.countDown(); RefreshOutcome leaderOutcome = leader.get(2, TimeUnit.SECONDS); @@ -279,7 +281,7 @@ void shouldCoalesceConcurrentRefreshes() throws Exception { private static void awaitUninterruptibly(CountDownLatch latch) { try { latch.await(2, TimeUnit.SECONDS); - } catch (InterruptedException interrupted) { + } catch (InterruptedException _) { Thread.currentThread().interrupt(); } } @@ -294,8 +296,9 @@ class Contracts { void shouldRejectNullArguments() { TokenRefreshCoordinator coordinator = coordinator(NEAR, rt -> rotation()); + var session = session(CURRENT_REFRESH); assertThrows(NullPointerException.class, () -> coordinator.refresh(null, NOW)); - assertThrows(NullPointerException.class, () -> coordinator.refresh(session(CURRENT_REFRESH), null)); + assertThrows(NullPointerException.class, () -> coordinator.refresh(session, null)); } @Test diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpointTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpointTest.java index 5633b820..3bdd907a 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpointTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpointTest.java @@ -27,6 +27,7 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; + import de.cuioss.sheriff.gateway.bff.logout.BackchannelLogoutReceiver; import de.cuioss.sheriff.gateway.bff.logout.LogoutTokenValidator; import de.cuioss.sheriff.gateway.bff.reserved.BackchannelLogoutEndpoint.BackchannelLogoutOutcome; diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java index 4efc14f1..6d4d696a 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java @@ -88,9 +88,9 @@ void setUp() { FlowContext flow = FlowContext.create("https://gw.example.com/auth/callback"); state = flow.state(); - PendingAuthorizationRecord record = PendingAuthorizationRecord.create(flow, RETURN_URL, T0); - pendingStore.store(record); - recordId = record.id(); + PendingAuthorizationRecord pending = PendingAuthorizationRecord.create(flow, RETURN_URL, T0); + pendingStore.store(pending); + recordId = pending.id(); bindingCookieHeader = bindingCodec.toSetCookieHeader(recordId).split(";", 2)[0]; } @@ -243,12 +243,12 @@ void shouldStoreSession() { Optional session = sessionStore.resolve(sessionId, T0); assertTrue(session.isPresent(), "the session was created under the opaque id from the cookie"); - SessionRecord record = session.get(); - assertEquals(RAW_ACCESS_TOKEN, record.accessToken()); - assertEquals(RAW_ID_TOKEN, record.idToken()); - assertEquals(SUBJECT, record.sub()); - assertEquals(Optional.of(IDP_SID), record.sid()); - assertEquals(T0.plus(SESSION_TTL), record.expiresAt(), "the session TTL is absolute from login"); + SessionRecord sessionRecord = session.get(); + assertEquals(RAW_ACCESS_TOKEN, sessionRecord.accessToken()); + assertEquals(RAW_ID_TOKEN, sessionRecord.idToken()); + assertEquals(SUBJECT, sessionRecord.sub()); + assertEquals(Optional.of(IDP_SID), sessionRecord.sid()); + assertEquals(T0.plus(SESSION_TTL), sessionRecord.expiresAt(), "the session TTL is absolute from login"); } @Test diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilterTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilterTest.java index 00f58ae3..0993d874 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilterTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilterTest.java @@ -138,13 +138,15 @@ class Contract { @Test @DisplayName("allowedClaims is immutable") void shouldExposeImmutableAllowedClaims() { - assertThrows(UnsupportedOperationException.class, () -> filter.allowedClaims().add("email")); + var allowedClaims = filter.allowedClaims(); + assertThrows(UnsupportedOperationException.class, () -> allowedClaims.add("email")); } @Test @DisplayName("defaultView is immutable") void shouldExposeImmutableDefaultView() { - assertThrows(UnsupportedOperationException.class, () -> filter.defaultView().add("email")); + var defaultView = filter.defaultView(); + assertThrows(UnsupportedOperationException.class, () -> defaultView.add("email")); } @Test diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpointTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpointTest.java index 5867d935..375bf91a 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpointTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpointTest.java @@ -22,6 +22,7 @@ import java.time.Instant; import java.util.Set; + import de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout; import de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout.TokenRevocation; import de.cuioss.sheriff.gateway.bff.reserved.LogoutEndpoint.LogoutOutcome; diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/JsonWriterTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/JsonWriterTest.java index 02cdb035..8b837461 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/JsonWriterTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/JsonWriterTest.java @@ -62,9 +62,9 @@ void shouldNormalizeNonFiniteNested() { assertEquals("{\"finite\":1,\"broken\":null}", JsonWriter.toJson(object)); - List array = new ArrayList<>(); - array.add(1); - array.add(Double.POSITIVE_INFINITY); + List array = new ArrayList<>(List.of( + 1, + Double.POSITIVE_INFINITY)); assertEquals("[1,null]", JsonWriter.toJson(array)); } } diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStageTest.java index 3a62405e..b1e12594 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStageTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStageTest.java @@ -280,9 +280,9 @@ private static SessionStore emptyStore() { return new InMemorySessionStore(16); } - private static SessionStore storeWith(SessionRecord record) { + private static SessionStore storeWith(SessionRecord session) { InMemorySessionStore store = new InMemorySessionStore(16); - store.create(record); + store.create(session); return store; } diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/InMemorySessionStoreTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/InMemorySessionStoreTest.java index 4375cd00..bb0a27c4 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/InMemorySessionStoreTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/InMemorySessionStoreTest.java @@ -215,7 +215,7 @@ void shouldGenerateDistinctIds() { @Test @DisplayName("Should redact the session id and every token in toString, keeping sub visible") void shouldRedactCredentialsInToString() { - SessionRecord record = SessionRecord.builder() + SessionRecord session = SessionRecord.builder() .sessionId("SID-SECRET") .accessToken("AT-SECRET") .refreshToken(Optional.of("RT-SECRET")) @@ -225,7 +225,7 @@ void shouldRedactCredentialsInToString() { .expiresAt(FUTURE) .build(); - String rendered = record.toString(); + String rendered = session.toString(); assertFalse(rendered.contains("SID-SECRET"), "the bearer session id must be redacted"); assertFalse(rendered.contains("AT-SECRET"), "the access token must be redacted"); diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/SessionCookieCodecTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/SessionCookieCodecTest.java index b01c497e..75515b6a 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/SessionCookieCodecTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/SessionCookieCodecTest.java @@ -107,8 +107,9 @@ class Construction { @Test @DisplayName("Should reject a null or blank cookie name") void shouldRejectBlankName() { - assertThrows(NullPointerException.class, () -> new SessionCookieCodec(null, Duration.ofHours(1))); - assertThrows(IllegalArgumentException.class, () -> new SessionCookieCodec(" ", Duration.ofHours(1))); + Duration ttl = Duration.ofHours(1); + assertThrows(NullPointerException.class, () -> new SessionCookieCodec(null, ttl)); + assertThrows(IllegalArgumentException.class, () -> new SessionCookieCodec(" ", ttl)); } } } diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java index 85c0b675..dea3ef2e 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java @@ -281,7 +281,7 @@ private static BffRuntime activeRuntime(SessionStore store, SessionCookieCodec c }, pendingStore, bindingCodec, ORIGIN); SessionAuthenticationStage sessionStage = new SessionAuthenticationStage(store, codec, - (record, instant) -> record, + (session, instant) -> session, (token, scopes) -> true, (returnUrl, instant) -> new SessionAuthenticationStage.LoginChallenge("/login", List.of()), Clock.systemUTC()); @@ -289,7 +289,7 @@ private static BffRuntime activeRuntime(SessionStore store, SessionCookieCodec c CsrfDefence csrf = new CsrfDefence(Set.of(ORIGIN)); StepUpCoordinator stepUp = new StepUpCoordinator( - (record, challenge, instant) -> Optional.empty(), + (session, challenge, instant) -> Optional.empty(), challenge -> { throw new AssertionError("engine step-up must not be reached"); }, diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java index 66d69f77..f365d1a9 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java @@ -128,9 +128,12 @@ void shouldBeInertWithoutRedirectUri() { @DisplayName("Should reject reserved dispatch and session-stage access on the inert runtime") void shouldRejectUseOfInert() { BffRuntime runtime = BffRuntime.inert(); - assertThrows(IllegalStateException.class, () -> runtime.sessionStage()); - assertThrows(IllegalStateException.class, () -> runtime.dispatch(ReservedEndpoint.USER_INFO, - new BffRuntime.ReservedHttpRequest("", null, null, null, null, null), Instant.now())); + BffRuntime.ReservedHttpRequest request = + new BffRuntime.ReservedHttpRequest("", null, null, null, null, null); + Instant now = Instant.now(); + assertThrows(IllegalStateException.class, runtime::sessionStage); + assertThrows(IllegalStateException.class, + () -> runtime.dispatch(ReservedEndpoint.USER_INFO, request, now)); } } From da4d0b95aa43ba4f92622361e291f67deede3fd9 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:56:02 +0200 Subject: [PATCH 29/39] fix(bff): support OIDC form_post callback (POST /auth/callback, body-parsed code preserving BFF-13) and adapt the Keycloak IT flow The engine hardcodes response_mode=form_post in the authorization request, so Keycloak returns a 200 auto-submit form that POSTs the code/state to /auth/callback as an application/x-www-form-urlencoded body rather than a 302 with the code in the query. The reserved callback now accepts POST: the edge reads the form body via the existing bounded read and carries the HTTP method, and BffRuntime.dispatch parses the code/state from the body for a POST callback (the query for a GET), reusing the same CallbackParameters.parse so the BFF-13 duplicate-parameter rejection holds identically. The reserved dispatch already runs before route selection, so the cross-site form_post is never subject to the require:session CSRF guard. The Keycloak IT harness scrapes the 200 form_post action + hidden inputs (tolerant of attribute ordering and &) and replays them to the gateway callback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bff/reserved/CallbackEndpoint.java | 26 ++-- .../gateway/bff/runtime/BffRuntime.java | 54 ++++++-- .../gateway/edge/GatewayEdgeRoute.java | 10 +- .../edge/GatewayEdgeRouteBffWiringTest.java | 115 +++++++++++++++++- .../quarkus/BffRuntimeProducerTest.java | 4 +- .../integration/BffKeycloakLoginFlow.java | 103 ++++++++++++++-- 6 files changed, 277 insertions(+), 35 deletions(-) diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java index c1227796..9f702c11 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java @@ -44,14 +44,22 @@ /** * 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 query + * 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. *

    - * Callback HPP defence (BFF-13). The endpoint parses the raw query with - * {@link CallbackParameters#parse(String)} — never {@link CallbackParameters#of(java.util.Map)}: + * 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. + * 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 @@ -118,18 +126,20 @@ public CallbackEndpoint(CodeExchange codeExchange, PendingAuthorizationStore pen * Handles one OIDC callback: validates the binding, drives the engine exchange, creates the * session, and returns the browser response. * - * @param rawQuery the raw callback query string (never map-collapsed — BFF-13) + * @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 rawQuery, @Nullable String cookieHeader, Instant now) { - Objects.requireNonNull(rawQuery, "rawQuery"); + public CallbackOutcome handle(String rawParameters, @Nullable String cookieHeader, Instant now) { + Objects.requireNonNull(rawParameters, "rawParameters"); Objects.requireNonNull(now, "now"); CallbackParameters params; try { - params = CallbackParameters.parse(rawQuery); + 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. diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java index 88488ac7..617d4de7 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java @@ -197,7 +197,8 @@ public ReservedHttpResponse dispatch(ReservedEndpoint kind, ReservedHttpRequest throw new IllegalStateException("inert BFF runtime cannot dispatch a reserved path"); } return switch (kind) { - case CALLBACK -> render(requireNonNull(callbackEndpoint).handle(req.rawQuery(), req.cookieHeader(), now)); + 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())); @@ -209,6 +210,23 @@ public ReservedHttpResponse dispatch(ReservedEndpoint kind, ReservedHttpRequest }; } + /** + * 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) { + if (req.isFormPost()) { + return Objects.requireNonNullElse(req.rawFormBody(), ""); + } + return req.rawQuery(); + } + private static ReservedHttpResponse render(CallbackEndpoint.CallbackOutcome outcome) { return new ReservedHttpResponse(outcome.status(), outcome.location().orElse(null), null, Map.of(), outcome.setCookieHeaders()); @@ -245,30 +263,42 @@ private static T requireNonNull(@Nullable T value) { * 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 - * — the callback re-parses it to re-detect a duplicated {@code code}/{@code + * — 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 for back-channel - * logout, 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) { + 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. + * 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); } } 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 dc36223b..0ec2c626 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 @@ -528,10 +528,16 @@ private void handleGatewayRejection(RoutingContext ctx, @Nullable PipelineReques */ private void dispatchReserved(RoutingContext ctx, PipelineRequest request, ReservedEndpoint kind) { String cookieHeader = request.firstHeader(COOKIE_HEADER).orElse(null); - String rawFormBody = kind == ReservedEndpoint.BACKCHANNEL_LOGOUT ? readFormBody(ctx) : 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); + firstQueryParam(request, RETURN_TO_PARAM), firstQueryParam(request, STATE_PARAM), rawFormBody, method); BffRuntime.ReservedHttpResponse response = bffRuntime.dispatch(kind, reservedRequest, Instant.now()); renderReserved(ctx, request, response); } diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java index dea3ef2e..a75fc622 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java @@ -23,6 +23,7 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; +import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -65,9 +66,15 @@ import de.cuioss.sheriff.gateway.config.model.ResolvedUpstream; import de.cuioss.sheriff.gateway.config.model.RouteTable; import de.cuioss.sheriff.gateway.quarkus.SheriffMetrics; +import de.cuioss.sheriff.token.client.flow.AuthorizationCodeFlow; +import de.cuioss.sheriff.token.client.flow.FlowContext; import de.cuioss.sheriff.token.client.logout.EndSessionFlow; import de.cuioss.sheriff.token.client.logout.PostLogoutRedirectValidator; import de.cuioss.sheriff.token.validation.TokenValidator; +import de.cuioss.sheriff.token.validation.domain.claim.ClaimName; +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.test.generator.TestTokenGenerators; import de.cuioss.test.generator.junit.EnableGeneratorController; @@ -199,7 +206,7 @@ void shouldDispatchUserInfo() { @DisplayName("CALLBACK with a stateless query yields 400 from the callback handler") void shouldDispatchCallback() { BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.CALLBACK, - new BffRuntime.ReservedHttpRequest("", null, null, null, null, null), now); + new BffRuntime.ReservedHttpRequest("", null, null, null, null, null, "GET"), now); assertEquals(400, response.status()); } @@ -223,7 +230,7 @@ void shouldDispatchLogoutReturn() { @DisplayName("BACKCHANNEL_LOGOUT with no form body yields 400 and is uncacheable") void shouldDispatchBackchannel() { BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.BACKCHANNEL_LOGOUT, - new BffRuntime.ReservedHttpRequest("", null, null, null, null, null), now); + new BffRuntime.ReservedHttpRequest("", null, null, null, null, null, "POST"), now); assertEquals(400, response.status()); assertEquals("no-store", response.headers().get("Cache-Control")); } @@ -236,13 +243,113 @@ void shouldDispatchLogin() { .expiresAt(now.plus(Duration.ofHours(1))).build()); String cookie = SessionCookieCodec.DEFAULT_COOKIE_NAME + "=" + sessionId; BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.LOGIN, - new BffRuntime.ReservedHttpRequest("", cookie, null, "/home", null, null), now); + new BffRuntime.ReservedHttpRequest("", cookie, null, "/home", null, null, "GET"), now); assertEquals(302, response.status()); assertTrue(response.locationOptional().isPresent()); } private BffRuntime.ReservedHttpRequest request(String cookie, String claims) { - return new BffRuntime.ReservedHttpRequest("", cookie, claims, null, null, null); + return new BffRuntime.ReservedHttpRequest("", cookie, claims, null, null, null, "GET"); + } + } + + @Nested + @DisplayName("form_post callback dispatch (POST /auth/callback, body-parsed code/state)") + class FormPostCallbackDispatch { + + private static final String RETURN_URL = "/dashboard"; + private static final String RAW_ACCESS_TOKEN = "raw-access-token"; + private static final String RAW_ID_TOKEN = "raw-id-token"; + private static final String SUBJECT = "user-sub-1"; + private final Instant now = Instant.parse("2026-07-25T10:00:00Z"); + + private PendingAuthorizationStore.InMemory pendingStore; + private BindingCookieCodec bindingCodec; + private SessionStore sessionStore; + private SessionCookieCodec codec; + private BffRuntime runtime; + private String state; + private String bindingCookieHeader; + + @BeforeEach + void setUp() { + pendingStore = new PendingAuthorizationStore.InMemory(16); + bindingCodec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL); + sessionStore = new InMemorySessionStore(16); + codec = new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, Duration.ofHours(1)); + + FlowContext flow = FlowContext.create(ORIGIN + CALLBACK_PATH); + state = flow.state(); + PendingAuthorizationRecord pending = PendingAuthorizationRecord.create(flow, RETURN_URL, now); + pendingStore.store(pending); + bindingCookieHeader = bindingCodec.toSetCookieHeader(pending.id()).split(";", 2)[0]; + + runtime = formPostRuntime(); + } + + @Test + @DisplayName("POST body with code+state resolves the pending record and creates the session (302 + session cookie)") + void shouldCompleteFormPostLogin() { + BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.CALLBACK, + new BffRuntime.ReservedHttpRequest("", bindingCookieHeader, null, null, null, + "code=auth-code&state=" + state, "POST"), now); + + assertEquals(302, response.status(), "form_post code exchange completes the login"); + assertEquals(Optional.of(RETURN_URL), response.locationOptional()); + assertTrue(response.setCookieHeaders().stream() + .anyMatch(cookie -> cookie.startsWith(SessionCookieCodec.DEFAULT_COOKIE_NAME + "=")), + "the session cookie is set from the form_post callback"); + } + + @Test + @DisplayName("A duplicate code in the POST body is still rejected 400 (BFF-13 raw-parse defence)") + void shouldRejectDuplicateCodeInFormBody() { + BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.CALLBACK, + new BffRuntime.ReservedHttpRequest("", bindingCookieHeader, null, null, null, + "code=first&code=second&state=" + state, "POST"), now); + + assertEquals(400, response.status(), "a duplicated code in the form body is rejected by parse()"); + assertTrue(pendingStore.consume(bindingCodec.readRecordId(bindingCookieHeader).orElseThrow(), now) + .isPresent(), "the record is untouched — parse fails before binding resolution"); + } + + private BffRuntime formPostRuntime() { + CallbackEndpoint callback = new CallbackEndpoint((context, params) -> { + Map accessClaims = new HashMap<>(); + accessClaims.put(ClaimName.SUBJECT.getName(), ClaimValue.forPlainString(SUBJECT)); + AccessTokenContent access = new AccessTokenContent(accessClaims, RAW_ACCESS_TOKEN); + Map idClaims = new HashMap<>(); + idClaims.put(ClaimName.SUBJECT.getName(), ClaimValue.forPlainString(SUBJECT)); + IdTokenContent id = new IdTokenContent(idClaims, RAW_ID_TOKEN); + return new AuthorizationCodeFlow.AuthenticationResult(access, id); + }, pendingStore, bindingCodec, sessionStore, codec, Duration.ofHours(1)); + + SessionAuthenticationStage sessionStage = new SessionAuthenticationStage(sessionStore, codec, + (session, instant) -> session, + (token, scopes) -> true, + (returnUrl, instant) -> new SessionAuthenticationStage.LoginChallenge("/login", List.of()), + Clock.systemUTC()); + StepUpCoordinator stepUp = new StepUpCoordinator( + (session, challenge, instant) -> Optional.empty(), + challenge -> { + throw new AssertionError("engine step-up must not be reached"); + }, + pendingStore, bindingCodec, ORIGIN); + LoginFlow loginFlow = new LoginFlow(() -> { + throw new AssertionError("engine authorize must not be reached"); + }, pendingStore, bindingCodec, ORIGIN); + BackchannelLogoutEndpoint backchannel = new BackchannelLogoutEndpoint(new BackchannelLogoutReceiver( + rawToken -> { + throw new AssertionError("engine verify must not be reached"); + }, + new LogoutTokenValidator(ORIGIN, "client", Duration.ofMinutes(2)), sessionStore)); + UserInfoEndpoint userInfo = new UserInfoEndpoint(sessionStore, codec, + new ClaimAllowlistFilter(List.of("sub"), List.of("sub")), + session -> Map.of("sub", session.sub())); + LoginInitiationEndpoint login = new LoginInitiationEndpoint(loginFlow, sessionStore, codec, ORIGIN); + + return new BffRuntime(sessionStage, new CsrfDefence(Set.of(ORIGIN)), stepUp, callback, + () -> logoutEndpoint(sessionStore, codec), backchannel, userInfo, login); } } diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java index f365d1a9..7591662a 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java @@ -86,7 +86,7 @@ void shouldAssembleWithoutDiscovery() { @DisplayName("Should wire the user-info fold reachably — no session yields 401") void shouldWireUserInfo() { BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.USER_INFO, - new BffRuntime.ReservedHttpRequest("", null, null, null, null, null), + new BffRuntime.ReservedHttpRequest("", null, null, null, null, null, "GET"), Instant.parse("2026-07-25T10:00:00Z")); assertEquals(401, response.status()); } @@ -129,7 +129,7 @@ void shouldBeInertWithoutRedirectUri() { void shouldRejectUseOfInert() { BffRuntime runtime = BffRuntime.inert(); BffRuntime.ReservedHttpRequest request = - new BffRuntime.ReservedHttpRequest("", null, null, null, null, null); + new BffRuntime.ReservedHttpRequest("", null, null, null, null, null, "GET"); Instant now = Instant.now(); assertThrows(IllegalStateException.class, runtime::sessionStage); assertThrows(IllegalStateException.class, diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java index d8383495..2c2ad4fe 100644 --- a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java @@ -30,6 +30,13 @@ * {@code integration} realm and the server-mode BFF gateway, following the {@code 302} chain with a * cookie jar exactly as a browser would. *

    + * response_mode=form_post. The engine drives the authorization request with + * {@code response_mode=form_post}, so the final IdP leg is not a {@code 302} carrying the code in the + * query: after a successful credential POST Keycloak returns a {@code 200} auto-submit HTML form that + * POSTs the {@code code}/{@code state} to the gateway {@code redirect_uri}. This helper scrapes that + * form's action and hidden inputs and replays them as an {@code application/x-www-form-urlencoded} + * POST to the gateway callback — exactly the browser auto-submit — to complete the login. + *

    * Container-network rewrite. The {@code integration} realm pins * {@code frontendUrl https://keycloak:8443}, so every authorization / login-form URL the gateway or * Keycloak hands the "browser" carries the container-internal authority {@code keycloak:8443}. The @@ -67,6 +74,23 @@ final class BffKeycloakLoginFlow { private static final Pattern FORM_ACTION = Pattern.compile("action=\"([^\"]*login-actions/authenticate[^\"]*)\""); + /** + * Matches the {@code action} of the form_post auto-submit {@code

    } (the gateway + * redirect_uri). Tolerant of attribute ordering — {@code [^>]*} skips any attributes (e.g. + * {@code method="post"}) that precede {@code action} inside the opening tag. + */ + private static final Pattern FORM_POST_ACTION = + Pattern.compile("]*\\baction=\"([^\"]*)\"", Pattern.CASE_INSENSITIVE); + + /** Matches each {@code } tag of the form_post auto-submit form. */ + private static final Pattern INPUT_TAG = Pattern.compile("]*>", Pattern.CASE_INSENSITIVE); + + /** Extracts the {@code name} attribute from an {@code } tag, regardless of attribute order. */ + private static final Pattern INPUT_NAME = Pattern.compile("\\bname=\"([^\"]*)\"", Pattern.CASE_INSENSITIVE); + + /** Extracts the {@code value} attribute from an {@code } tag, regardless of attribute order. */ + private static final Pattern INPUT_VALUE = Pattern.compile("\\bvalue=\"([^\"]*)\"", Pattern.CASE_INSENSITIVE); + private BffKeycloakLoginFlow() { // static helper } @@ -111,21 +135,31 @@ static Session login(String startPath) { keycloakCookies.putAll(loginPage.getCookies()); String formAction = rewriteToHost(extractFormAction(loginPage.asString())); - // Step 3 — POST the credentials; Keycloak 302s to the gateway callback with code + state. + // Step 3 — POST the credentials. The engine drives the authorization request with + // response_mode=form_post, so Keycloak does NOT 302 with the code in the query: on a successful + // login it returns a 200 auto-submit HTML form whose action is the gateway redirect_uri + // (/auth/callback) and whose hidden inputs carry the authorization code + state (+ any others). Response formPost = keycloak(keycloakCookies) .contentType("application/x-www-form-urlencoded") .formParam("username", USERNAME) .formParam("password", PASSWORD) .redirects().follow(false) .when().post(formAction) - .then().statusCode(302).extract().response(); - String callbackUrl = location(formPost); + .then().statusCode(200).extract().response(); + String formPostHtml = formPost.asString(); + String callbackAction = rewriteToHost(extractFormPostAction(formPostHtml)); + Map callbackFields = extractHiddenInputs(formPostHtml); - // Step 4 — follow the callback on the gateway: the code is exchanged for tokens, the server-side - // session is created, and the session cookie is set on a 302 back to the original path. + // Step 4 — replay the form_post to the gateway callback exactly as the browser auto-submit would: + // an application/x-www-form-urlencoded POST carrying code + state (+ any other hidden fields). The + // gateway parses the code from the BODY, exchanges it for tokens, creates the server-side session, + // and sets the session cookie on a 302 back to the original path. The binding cookie from Step 1 + // rides the gateway jar. Response callback = gateway(gatewayCookies) + .contentType("application/x-www-form-urlencoded") + .formParams(callbackFields) .redirects().follow(false) - .when().get(callbackUrl) + .when().post(callbackAction) .then().statusCode(302).extract().response(); gatewayCookies.putAll(callback.getCookies()); @@ -183,6 +217,61 @@ private static String extractFormAction(String html) { if (!matcher.find()) { throw new IllegalStateException("Keycloak login form action not found in login page"); } - return matcher.group(1).replace("&", "&"); + return unescapeHtml(matcher.group(1)); + } + + /** + * Extracts the {@code action} of the form_post auto-submit form — the gateway redirect_uri the + * browser would POST the code/state to. + * + * @param html the 200 form_post document Keycloak returned after a successful credential POST + * @return the (HTML-unescaped) form action URL + */ + private static String extractFormPostAction(String html) { + Matcher matcher = FORM_POST_ACTION.matcher(html); + if (!matcher.find()) { + throw new IllegalStateException("form_post auto-submit form action not found in Keycloak response"); + } + return unescapeHtml(matcher.group(1)); + } + + /** + * Extracts every hidden input {@code name -> value} of the form_post auto-submit form. Parses each + * {@code } tag independently and reads {@code name}/{@code value} in either order, so the + * result is insensitive to Keycloak's attribute ordering; every value is HTML-unescaped. + * + * @param html the 200 form_post document + * @return the form fields to replay to the gateway callback (must include {@code code} and {@code state}) + * @throws IllegalStateException when {@code code} or {@code state} is absent (the login did not complete) + */ + private static Map extractHiddenInputs(String html) { + Map fields = new HashMap<>(); + Matcher tags = INPUT_TAG.matcher(html); + while (tags.find()) { + String tag = tags.group(); + Matcher name = INPUT_NAME.matcher(tag); + Matcher value = INPUT_VALUE.matcher(tag); + if (name.find() && value.find()) { + fields.put(unescapeHtml(name.group(1)), unescapeHtml(value.group(1))); + } + } + if (!fields.containsKey("code") || !fields.containsKey("state")) { + throw new IllegalStateException( + "form_post auto-submit form missing code/state hidden inputs; found " + fields.keySet()); + } + return fields; + } + + /** + * Decodes the small set of HTML entities Keycloak emits in form action URLs and hidden-input + * values (predominantly {@code &} in URL-encoded {@code code}/{@code state} values). + * + * @param value the raw attribute value + * @return the decoded value + */ + private static String unescapeHtml(String value) { + return value.replace("&", "&") + .replace("=", "=").replace("=", "=") + .replace(""", "\"").replace("<", "<").replace(">", ">"); } } From cdf353188f486691c03c40dca112a3abf57f0bd6 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:20:00 +0200 Subject: [PATCH 30/39] fix(bff): guard nullable rawQuery in callback selector (Sonar S2637); surface CI login page in IT form-scrape failure Co-Authored-By: Claude Opus 4.8 (1M context) --- .../de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java | 2 +- .../sheriff/gateway/integration/BffKeycloakLoginFlow.java | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java index 617d4de7..33809734 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java @@ -224,7 +224,7 @@ private static String callbackParameters(ReservedHttpRequest req) { if (req.isFormPost()) { return Objects.requireNonNullElse(req.rawFormBody(), ""); } - return req.rawQuery(); + return Objects.requireNonNullElse(req.rawQuery(), ""); } private static ReservedHttpResponse render(CallbackEndpoint.CallbackOutcome outcome) { diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java index 2c2ad4fe..25f9bd2f 100644 --- a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java @@ -215,7 +215,10 @@ static String location(Response response) { private static String extractFormAction(String html) { Matcher matcher = FORM_ACTION.matcher(html); if (!matcher.find()) { - throw new IllegalStateException("Keycloak login form action not found in login page"); + String body = html == null ? "" : html; + String snippet = body.substring(0, Math.min(body.length(), 1500)); + throw new IllegalStateException("Keycloak login form action not found in login page (length=" + + body.length() + "). Page head:\n" + snippet); } return unescapeHtml(matcher.group(1)); } From e5c09ae66a4f0999ed2889340b57958b8c97b700 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:42:38 +0200 Subject: [PATCH 31/39] fix(bff): send pre-encoded IT URLs verbatim and harden callbackParameters null-return Two follow-ups surfaced by CI: - IT harness: the login flow replays authorization/callback URLs that the gateway/IdP already percent-encoded. REST Assured's default urlEncodingEnabled=true re-encodes their literal '+' to %2B, so the scope "openid+profile+email" reached Keycloak as one literal scope -> invalid_scope (form_post error page, "login form action not found"). Disable re-encoding on the gateway() and keycloak() request specs so the URLs go out as-is. - BffRuntime.callbackParameters: replace Objects.requireNonNullElse with an explicit null check so Sonar's dataflow recognizes the non-null return (clears the S2637 @NullMarked new-code reliability bug). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sheriff/gateway/bff/runtime/BffRuntime.java | 7 ++++--- .../gateway/integration/BffKeycloakLoginFlow.java | 11 +++++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java index 33809734..08dc27f0 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java @@ -221,10 +221,11 @@ public ReservedHttpResponse dispatch(ReservedEndpoint kind, ReservedHttpRequest * {@code of(Map)} form is never taken). */ private static String callbackParameters(ReservedHttpRequest req) { - if (req.isFormPost()) { - return Objects.requireNonNullElse(req.rawFormBody(), ""); + final String raw = req.isFormPost() ? req.rawFormBody() : req.rawQuery(); + if (raw == null) { + return ""; } - return Objects.requireNonNullElse(req.rawQuery(), ""); + return raw; } private static ReservedHttpResponse render(CallbackEndpoint.CallbackOutcome outcome) { diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java index 25f9bd2f..4303c600 100644 --- a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java @@ -173,7 +173,10 @@ static Session login(String startPath) { * @return the configured request specification */ static RequestSpecification gateway(Map cookies) { - return given().relaxedHTTPSValidation().baseUri(GATEWAY_ORIGIN).cookies(cookies); + // urlEncodingEnabled(false): the callback/return URLs replayed here are already correctly + // percent-encoded (they come from the IdP form_post action / the gateway's own redirects), so + // REST Assured MUST NOT re-encode them — re-encoding a literal '+' to %2B corrupts them. + return given().relaxedHTTPSValidation().urlEncodingEnabled(false).baseUri(GATEWAY_ORIGIN).cookies(cookies); } /** @@ -184,7 +187,11 @@ static RequestSpecification gateway(Map cookies) { * @return the configured request specification */ static RequestSpecification keycloak(Map cookies) { - return given().relaxedHTTPSValidation().cookies(cookies); + // urlEncodingEnabled(false): the authorization URL (and the login-form action) are already + // percent-encoded by the gateway/Keycloak. REST Assured's default re-encoding rewrites the + // scope separator '+' to %2B, which Keycloak reads as a single literal scope + // "openid+profile+email" -> invalid_scope. Disabling it sends the URL verbatim. + return given().relaxedHTTPSValidation().urlEncodingEnabled(false).cookies(cookies); } /** From b38264df964d349cbfa64e8654297f30c4500e35 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:50:09 +0200 Subject: [PATCH 32/39] fix(bff): read the reserved POST form body by resuming the paused request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OIDC response_mode=form_post callback (and back-channel logout) POST their parameters as an x-www-form-urlencoded body. The edge pauses every request in handle() before dispatching to a virtual thread; readFormBody then called ctx.request().body() from that virtual thread. body() attaches its collector but does NOT re-arm an explicitly-paused stream, so the read stalled to the 5s deadline and returned null — the callback saw an empty body, reported "missing state", and rejected the login 400. Every full-login IT (BffSessionLoginIT, BffSessionMediationIT, BffUserInfoIT, BffLoginInitiationIT, BffLogoutIT) failed this way once the earlier scope-encoding blocker was cleared. Fix: capture the request's event-loop context in handle() before the pause, and in readFormBody marshal the body() collector + an explicit request.resume() back onto that context — mirroring what the proxy path's pipeTo(send) already does for a streamed upstream body. The buffered body is then delivered and drained. Verified end-to-end against the native stack + Keycloak: callback returns 302 (~18ms, was a 5s timeout) with the session cookie set, and a mediated require:session request with that cookie returns 200. Also revert the gateway() request spec to default URL encoding: it only issues a plain GET and the callback POST, whose code/state/iss body params must be percent-encoded by REST Assured (disabling it corrupted the body -> 400). Only keycloak() replays a pre-encoded URL and keeps urlEncodingEnabled(false). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../gateway/edge/GatewayEdgeRoute.java | 51 ++++++++++++++++--- .../integration/BffKeycloakLoginFlow.java | 10 ++-- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java index 0ec2c626..225c85ba 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 @@ -26,6 +26,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.RejectedExecutionException; @@ -84,6 +85,7 @@ import io.quarkus.runtime.ShutdownEvent; import io.quarkus.virtual.threads.VirtualThreads; import io.smallrye.faulttolerance.api.Guard; +import io.vertx.core.Context; import io.vertx.core.MultiMap; import io.vertx.core.Vertx; import io.vertx.core.buffer.Buffer; @@ -165,6 +167,10 @@ public class GatewayEdgeRoute { /** Per-request {@link RoutingContext} data key holding the resolved metrics route label. */ private static final String ROUTE_KEY = "sheriff.route"; + /** Stashes the request's event-loop {@link Context}, captured in {@link #handle} before the pause, + * so a reserved-path body read on the virtual thread can re-arm and drain the paused request stream + * on its own event loop (see {@link #readFormBody}). */ + private static final String REQUEST_CONTEXT_KEY = "sheriff.reqctx"; private final List routes; private final ExecutorService virtualThreadExecutor; @@ -333,6 +339,10 @@ private void handle(RoutingContext ctx) { releaseAdmission(admissionReleased); recordRequestMetrics(ctx, startNanos); }); + // Capture the request's event-loop context BEFORE pausing (handle() runs on that context). A + // reserved POST body read happens later on a virtual thread, where re-arming the paused request + // stream must be marshalled back onto this same event loop — see readFormBody(). + ctx.put(REQUEST_CONTEXT_KEY, ctx.vertx().getOrCreateContext()); ctx.request().pause(); try { virtualThreadExecutor.execute(() -> process(ctx)); @@ -566,28 +576,55 @@ private void renderReserved(RoutingContext ctx, PipelineRequest request, } /** - * Reads the raw {@code application/x-www-form-urlencoded} back-channel logout body on the virtual - * thread. A read failure yields {@code null}, which the receiver rejects {@code 400} — the fail- + * Reads 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) on the virtual thread. A read + * failure yields {@code null}, which the receiver rejects {@code 400} — the fail- * closed default (a body the gateway could not read is not an accepted logout token). */ private static @Nullable String readFormBody(RoutingContext ctx) { - // The catch is a deliberate boundary: a body-read failure must degrade to a rejected logout + // The inbound request was paused on its event loop in handle() before this virtual-thread + // dispatch. Vert.x's request().body() attaches its collector and resumes, but calling it from a + // virtual thread does NOT re-arm the explicitly-paused stream — the read then stalls until the + // deadline and the body is lost. So marshal the body().resume() back onto the request's own + // event-loop context (captured in handle()); the collector then drains the buffered body and + // completes the holder, which this virtual thread awaits under a bounded deadline. + HttpServerRequest request = ctx.request(); + Context requestContext = ctx.get(REQUEST_CONTEXT_KEY); + CompletableFuture<@Nullable Buffer> holder = new CompletableFuture<>(); + Runnable read = () -> { + request.body().onComplete(result -> { + if (result.succeeded()) { + holder.complete(result.result()); + } else { + holder.completeExceptionally(result.cause()); + } + }); + // body() attaches the collector but does NOT re-arm an explicitly-paused stream; resume it + // here (on the request's event loop) so the buffered body is delivered — exactly what the + // proxy path's pipeTo(send) does for a streamed upstream body. + request.resume(); + }; + if (requestContext != null) { + requestContext.runOnContext(v -> read.run()); + } else { + read.run(); + } + // The catch is a deliberate boundary: a body-read failure must degrade to a rejected request // rather than escape onto the request path, so the null return drives the receiver's 400. // cui-rewrite:disable InvalidExceptionUsageRecipe try { - Buffer body = ctx.request().body().toCompletionStage().toCompletableFuture() - .get(BACKCHANNEL_BODY_READ_TIMEOUT_SECONDS, TimeUnit.SECONDS); + Buffer body = holder.get(BACKCHANNEL_BODY_READ_TIMEOUT_SECONDS, TimeUnit.SECONDS); return body == null ? null : body.toString(StandardCharsets.UTF_8); } catch (InterruptedException _) { Thread.currentThread().interrupt(); return null; } catch (ExecutionException failure) { - LOGGER.debug(failure, "Back-channel logout body read failed: %s", failure.getMessage()); + LOGGER.debug(failure, "Reserved form body read failed: %s", failure.getMessage()); return null; } catch (TimeoutException timeout) { // A slow/stalled chunked body exceeded the read deadline — fail closed to a rejected 400 // rather than pinning the handler thread waiting for a body that may never arrive. - LOGGER.debug(timeout, "Back-channel logout body read timed out after %s s — rejected", + LOGGER.debug(timeout, "Reserved form body read timed out after %s s — rejected", BACKCHANNEL_BODY_READ_TIMEOUT_SECONDS); return null; } diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java index 4303c600..b1af2bf1 100644 --- a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java @@ -173,10 +173,12 @@ static Session login(String startPath) { * @return the configured request specification */ static RequestSpecification gateway(Map cookies) { - // urlEncodingEnabled(false): the callback/return URLs replayed here are already correctly - // percent-encoded (they come from the IdP form_post action / the gateway's own redirects), so - // REST Assured MUST NOT re-encode them — re-encoding a literal '+' to %2B corrupts them. - return given().relaxedHTTPSValidation().urlEncodingEnabled(false).baseUri(GATEWAY_ORIGIN).cookies(cookies); + // Default URL encoding stays ON here: the gateway spec only issues a plain require:session GET + // (Step 1, no query) and the callback POST (Step 4). The callback carries the authorization + // code/state/iss as x-www-form-urlencoded body params whose raw values (e.g. the issuer URL's + // ':' and '/') MUST be percent-encoded by REST Assured — disabling encoding here corrupts the + // body and the gateway rejects it with 400. Only keycloak() replays a pre-encoded URL. + return given().relaxedHTTPSValidation().baseUri(GATEWAY_ORIGIN).cookies(cookies); } /** From 7eb25018c14a6a7f490222505c1445db1b8363de Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:51:11 +0200 Subject: [PATCH 33/39] fix(bff): exempt reserved-path params from the url-parameter pipeline; fix userinfo IT assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reserved-path IT failures surfaced once the form_post callback fix let the login flow complete (these paths were never exercised before): 1. Login-initiation return_to (product fix). A GET /auth/login?return_to=/home was rejected 400 by BasicChecksStage: cui-http's url-parameter value pipeline rejects '/' (and ':') in every query-param value gateway-wide — a defense for params forwarded to an upstream. But reserved gateway paths (callback/login/logout/ userinfo/back-channel) are gateway-TERMINATED (never proxied) and self-validate their own params (return_to -> sameOrigin, code/state -> CallbackParameters.parse, claims -> operator allowlist), so any real same-origin return_to path was unreachable. BasicChecksStage now skips ONLY the url-parameter VALUE pipeline for a matched reserved path (via an injected (host,canonicalPath)->isReserved predicate keyed on the same reservedPathRegistry used for dispatch, on the canonical path). The param-count cap, url-path pipeline, and header pipeline still apply, and proxied traffic is unchanged (a proxy route still rejects '/' in a param value). 2. UserInfo IT (test fix). The curated user-info view is {"claims":{...},"session":{...}} by design (UserInfoEndpoint nests disclosed claims under "claims"); the IT asserted the top-level path. Corrected to claims.preferred_username / claims.client_secret. Verified end-to-end against the native stack + Keycloak: all six Bff*IT suites pass (login, mediation, user-info, login-initiation, logout, csrf); login?return_to=/home -> 302; a proxy route ?x=/home -> 400. New BasicChecksStage unit test covers the reserved-exempt / non-reserved-rejected split. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../gateway/edge/GatewayEdgeRoute.java | 16 +++++----- .../gateway/pipeline/BasicChecksStage.java | 28 ++++++++++++++---- .../pipeline/BasicChecksStageTest.java | Bin 5919 -> 7511 bytes .../gateway/integration/BffUserInfoIT.java | 4 +-- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java index 225c85ba..efd3c8a2 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 @@ -261,8 +261,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( @@ -286,12 +294,6 @@ public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig, // security-filter counts surface as sheriff_security_events_total, completing the fixed // five-meter contract alongside the request/duration/error/upstream meters recorded above. sheriffMetrics.bindSecurityEventCounter(securityEventCounter); - - // 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. - this.reservedPathRegistry = ReservedPathRegistry.from(gatewayConfig.oidc()); } /** 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/test/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStageTest.java index e7c4106e7c1bba43720ff36216b58a18b374da2f..95ec2f3abfeee9a8847f0e8af679fdbefc52c899 100644 GIT binary patch delta 1115 zcmaizUu)A)7{+l3#iCQwM2)-vxv#v1Ki_nDRd7t0&KIhE;yZ`IY#=YkP)v?g;A}J;Z^Ro?e z`)(aBf97YcZ9detciP}aLg_YSOh+DcdN5?MLL+LA*FSiA-OKZfT5Hq%u0NRJDJGd# zz#w5dh%f|+3gwvoSbZ2ukw7lvPR1llP@{w~i*vMB9x}~E+6JY09MgxNFwi_5fzT03 z<#>hvvEU(;W8Re7CnSAh1?yJvy%khuYinl)7vH{<7l}HT=|?TfVF8nJYL)l8d%zW> zLMAMZr{zpCJD36uU2~f83}c>Ry9%<~g+Z=q!iZ^{uxW?JCp@K_0(`bYt}q^UN=j6~ zDI_sTDHXDAt@!(m6!DsbWvk_Js>`$09=e485OJmDEtyjqDvRp^qSP+im z>6ksH?KF_ cTr*1`r&Fi!xqE|8bKU^+wceZmYdE+51GQ*vb^rhX delta 42 xcmca^HD7PTRi@1n%&{z!8`*6qUu8D|(mIm`M7Sn Date: Sun, 26 Jul 2026 10:43:27 +0200 Subject: [PATCH 34/39] fix(bff): read reserved POST body eagerly on the event loop (robust under constrained CPU) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The form_post callback / back-channel logout body read was flaky under constrained CPU (green locally on a fat machine, red in CI on 2 cores): the callback 400'd with "missing state" because the body read hit its 5s deadline. Reproduced deterministically by pinning the container to 1-2 CPUs. Root cause: the edge pauses every request before the virtual-thread hop, and the handler then drained the paused stream from the virtual thread — either blocking a virtual thread on body().get() while it competed for a contended event loop, or relying on a re-armed resume() marshalled across threads. Under CPU pressure the read never completed in time. Fix: for the two reserved POST paths (matched on the exact raw path against the reserved registry), do NOT pause. Read the small, gateway-terminated body fully asynchronously on its own event loop in handle() — register the body collector, resume() the stream, and complete via a bounded fail-closed timer — then dispatch the pipeline with the buffered body stashed. No virtual-thread .get() blocking, no cross-thread resume. readFormBody just returns the stashed buffer (null -> fail closed 400). Every other request pauses and streams exactly as before. Verified: the full Bff*IT suite passes 3x in a row with the container pinned to 1 CPU (and at 2/4 CPU); the reserved-POST body read completes in ~15ms at every CPU level (was a 5s timeout). Full IT suite green except MtlsHandshakeIT, which enforces mTLS only in CI (a documented local-only limitation, unrelated to this change). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../gateway/edge/GatewayEdgeRoute.java | 149 ++++++++++-------- 1 file changed, 85 insertions(+), 64 deletions(-) diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java index efd3c8a2..6b93f944 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 @@ -26,13 +26,10 @@ import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -85,7 +82,6 @@ import io.quarkus.runtime.ShutdownEvent; import io.quarkus.virtual.threads.VirtualThreads; import io.smallrye.faulttolerance.api.Guard; -import io.vertx.core.Context; import io.vertx.core.MultiMap; import io.vertx.core.Vertx; import io.vertx.core.buffer.Buffer; @@ -122,7 +118,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): *
      *
    1. stage 0 — response-header preparation + CORS preflight (short-circuits a preflight here);
    2. *
    3. stage 1 — baseline security filter (records the single canonical path), the canonical-path @@ -167,10 +165,12 @@ public class GatewayEdgeRoute { /** Per-request {@link RoutingContext} data key holding the resolved metrics route label. */ private static final String ROUTE_KEY = "sheriff.route"; - /** Stashes the request's event-loop {@link Context}, captured in {@link #handle} before the pause, - * so a reserved-path body read on the virtual thread can re-arm and drain the paused request stream - * on its own event loop (see {@link #readFormBody}). */ - private static final String REQUEST_CONTEXT_KEY = "sheriff.reqctx"; + /** 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; @@ -341,11 +341,75 @@ private void handle(RoutingContext ctx) { releaseAdmission(admissionReleased); recordRequestMetrics(ctx, startNanos); }); - // Capture the request's event-loop context BEFORE pausing (handle() runs on that context). A - // reserved POST body read happens later on a virtual thread, where re-arming the paused request - // stream must be marshalled back onto this same event loop — see readFormBody(). - ctx.put(REQUEST_CONTEXT_KEY, ctx.vertx().getOrCreateContext()); - 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 under a bounded deadline, stashes it (or the + * {@link #RESERVED_BODY_FAILED} sentinel on failure/timeout), then dispatches processing exactly + * once. The request is NOT paused: {@code body()} drains the stream on its own event loop. + */ + private void readReservedBodyThenDispatch(RoutingContext ctx, AtomicBoolean admissionReleased) { + AtomicBoolean bodyDone = new AtomicBoolean(); + long timer = ctx.vertx().setTimer(TimeUnit.SECONDS.toMillis(BACKCHANNEL_BODY_READ_TIMEOUT_SECONDS), id -> { + if (bodyDone.compareAndSet(false, true)) { + // The body did not arrive within the deadline — leave RESERVED_BODY_KEY unset so the + // receiver fails closed to 400 rather than pinning on a body that may never arrive. + LOGGER.debug("Reserved form body read timed out after %s s — failing closed", + BACKCHANNEL_BODY_READ_TIMEOUT_SECONDS); + dispatchProcessing(ctx, admissionReleased); + } + }); + HttpServerRequest request = ctx.request(); + request.body().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. Reading fully + // asynchronously here — with no virtual-thread .get() blocking on a contended event loop — + // is what makes this reliable under constrained CPU (the failure mode that flaked in CI). + 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) { @@ -578,58 +642,15 @@ private void renderReserved(RoutingContext ctx, PipelineRequest request, } /** - * Reads 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) on the virtual thread. A read - * failure yields {@code null}, which the receiver rejects {@code 400} — the fail- - * closed default (a body the gateway could not read is not an accepted logout token). + * 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) { - // The inbound request was paused on its event loop in handle() before this virtual-thread - // dispatch. Vert.x's request().body() attaches its collector and resumes, but calling it from a - // virtual thread does NOT re-arm the explicitly-paused stream — the read then stalls until the - // deadline and the body is lost. So marshal the body().resume() back onto the request's own - // event-loop context (captured in handle()); the collector then drains the buffered body and - // completes the holder, which this virtual thread awaits under a bounded deadline. - HttpServerRequest request = ctx.request(); - Context requestContext = ctx.get(REQUEST_CONTEXT_KEY); - CompletableFuture<@Nullable Buffer> holder = new CompletableFuture<>(); - Runnable read = () -> { - request.body().onComplete(result -> { - if (result.succeeded()) { - holder.complete(result.result()); - } else { - holder.completeExceptionally(result.cause()); - } - }); - // body() attaches the collector but does NOT re-arm an explicitly-paused stream; resume it - // here (on the request's event loop) so the buffered body is delivered — exactly what the - // proxy path's pipeTo(send) does for a streamed upstream body. - request.resume(); - }; - if (requestContext != null) { - requestContext.runOnContext(v -> read.run()); - } else { - read.run(); - } - // The catch is a deliberate boundary: a body-read failure must degrade to a rejected request - // rather than escape onto the request path, so the null return drives the receiver's 400. - // cui-rewrite:disable InvalidExceptionUsageRecipe - try { - Buffer body = holder.get(BACKCHANNEL_BODY_READ_TIMEOUT_SECONDS, TimeUnit.SECONDS); - return body == null ? null : body.toString(StandardCharsets.UTF_8); - } catch (InterruptedException _) { - Thread.currentThread().interrupt(); - return null; - } catch (ExecutionException failure) { - LOGGER.debug(failure, "Reserved form body read failed: %s", failure.getMessage()); - return null; - } catch (TimeoutException timeout) { - // A slow/stalled chunked body exceeded the read deadline — fail closed to a rejected 400 - // rather than pinning the handler thread waiting for a body that may never arrive. - LOGGER.debug(timeout, "Reserved form body read timed out after %s s — rejected", - BACKCHANNEL_BODY_READ_TIMEOUT_SECONDS); - return null; - } + 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, From 5ce0fe8ef532410163943a3c86f61ae8513e4f5d Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:59:30 +0200 Subject: [PATCH 35/39] =?UTF-8?q?docs(architecture):=20add=20threading-mod?= =?UTF-8?q?el=20rule=20=E2=80=94=20never=20drive=20inbound-socket=20I/O=20?= =?UTF-8?q?from=20the=20request=20virtual=20thread?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the lesson from the form_post callback body-read defect: the request's event loop owns the inbound socket; the virtual thread runs pipeline logic only. Reading the request body / pause / resume / response write / WebSocket upgrade must happen on the event loop (marshal via runOnContext, or read a consumed body on the event loop before the virtual-thread hop). Documents the forbidden anti-pattern (draining a paused body from a virtual thread), its CPU-contention failure signature, and the docker --cpus reproduction, so this class of latent threading bug is caught by design review rather than in CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/architecture.adoc | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/doc/architecture.adoc b/doc/architecture.adoc index e8c8023e..fd7d30ee 100644 --- a/doc/architecture.adoc +++ b/doc/architecture.adoc @@ -509,6 +509,34 @@ container/native runtime is the JDK 25 line). The CI build/test matrix additiona the next GA release (Java 26, then 27 at GA) as a forward-compatibility guard. Java 21 is not a target for this project. +[CAUTION] +.Never drive inbound-socket I/O from the request's virtual thread +==== +The virtual thread runs the *pipeline logic*; the request's own *event loop* owns the inbound +socket. Any operation on the inbound Vert.x connection -- reading the request body, `pause()` +/ `resume()`, writing the response, the WebSocket upgrade (`toWebSocket()`) -- MUST run on that +event loop, never inline on the per-request virtual thread. + +* *Writing the response* from the pipeline: marshal it with + `ctx.vertx().runOnContext(v -> ...)` (every renderer in `GatewayEdgeRoute` does this; the sole + exception, `reject(...)`, is called only from the event loop and so needs no hop). +* *Reading a body the gateway itself consumes* (the `response_mode=form_post` callback and + back-channel logout): read it on the event loop *before* the virtual-thread hop, then dispatch + with the buffered bytes -- see `GatewayEdgeRoute#readReservedBodyThenDispatch`. A proxied body + is never buffered; it streams on the client's event loop (ADR-0008). + +The anti-pattern that this rule forbids: pausing the inbound stream, hopping to the virtual +thread, then draining the paused body from that virtual thread -- e.g. blocking on +`request.body().toCompletionStage().get()` or re-arming the stream via a captured context and +`runOnContext`. It *appears* to work on a multi-core dev box but hits its read deadline under +CPU contention (a 1--2-core CI runner, or a 1--2 CPU cgroup limit), because the paused read is +re-armed across the event-loop/virtual-thread boundary instead of on the connection's own event +loop. The failure signature is a body-read timeout surfacing as a downstream 400 (a +form_post callback rejected "missing state"). This is a threading defect, not a test flake: +reproduce it locally by pinning the app container's CPU (`docker update --cpus 1 `) +and running the integration suite against the running stack. +==== + [[_performance_object_reuse]] == Performance: Boot-Time Assembly and Object Reuse From 8a4fa994ef1b5b97420195e969420a6540b3d98d Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:15:48 +0200 Subject: [PATCH 36/39] fix(bff): make the reserved-POST body-read deadline race-proof under CPU contention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eager event-loop body read still flaked in the merge-queue CI run (login callback 400) although it passed the PR-branch CI twice and locally down to a single shared core. Root cause is a race on the (contention-starved) event loop: the 5s deadline timer and the body's completion callback are both queued on the same event loop, and after a starvation window the timer callback can be drained first — falsely failing a body that has in fact already fully arrived. Two changes: - The deadline handler now inspects the read future: if the body already completed (result present), it honours it instead of rejecting — so a legitimate body delayed only by event-loop scheduling is never falsely 400'd. This removes the race deterministically. - The deadline is raised 5s -> 20s (renamed RESERVED_BODY_READ_TIMEOUT_SECONDS): it is only a fail-closed ceiling for a body that truly never completes, and the body is read on a shared event loop that can be scheduling-starved under CPU contention, so a tight 5s was too aggressive. Normal reads still complete in ~15ms; the ceiling only bounds a genuinely stalled body. The CI-only contention could not be reproduced locally (even pinning app+Keycloak to one shared core passes — quota/cpuset throttling still lets the event loop burst on free physical cores, unlike a fully-saturated 2-core CI runner), so this is a reasoned fix validated by the merge-queue re-run; no local regression. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../gateway/edge/GatewayEdgeRoute.java | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java index 6b93f944..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 @@ -82,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; @@ -158,10 +159,13 @@ public class GatewayEdgeRoute { private static final int INTERNAL_ERROR = 500; private static final int BAD_GATEWAY = 502; private static final long DRAIN_POLL_INTERVAL_MILLIS = 50L; - // A small fixed deadline for reading the tiny back-channel logout form body (a single - // logout_token). Bounding the read prevents a slow/stalled chunked body from pinning the handler - // thread; a timeout is treated as fail-closed (rejected 400), consistent with the read-failure path. - private static final long BACKCHANNEL_BODY_READ_TIMEOUT_SECONDS = 5L; + // 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"; @@ -370,23 +374,33 @@ private boolean needsReservedBodyRead(RoutingContext ctx) { } /** - * Reads the reserved-POST body on the event loop under a bounded deadline, stashes it (or the - * {@link #RESERVED_BODY_FAILED} sentinel on failure/timeout), then dispatches processing exactly - * once. The request is NOT paused: {@code body()} drains the stream on its own event loop. + * 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(); - long timer = ctx.vertx().setTimer(TimeUnit.SECONDS.toMillis(BACKCHANNEL_BODY_READ_TIMEOUT_SECONDS), id -> { + 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)) { - // The body did not arrive within the deadline — leave RESERVED_BODY_KEY unset so the - // receiver fails closed to 400 rather than pinning on a body that may never arrive. - LOGGER.debug("Reserved form body read timed out after %s s — failing closed", - BACKCHANNEL_BODY_READ_TIMEOUT_SECONDS); + 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); } }); - HttpServerRequest request = ctx.request(); - request.body().onComplete(result -> { + bodyFuture.onComplete(result -> { if (bodyDone.compareAndSet(false, true)) { ctx.vertx().cancelTimer(timer); if (result.succeeded() && result.result() != null) { @@ -399,9 +413,7 @@ private void readReservedBodyThenDispatch(RoutingContext ctx, AtomicBoolean admi } }); // 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. Reading fully - // asynchronously here — with no virtual-thread .get() blocking on a contended event loop — - // is what makes this reliable under constrained CPU (the failure mode that flaked in CI). + // resume() (on this event loop) re-arms it so the buffered body is delivered. request.resume(); } From 68d87b4a6e86ba27a68c595e68596897aa73f3b7 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:34:38 +0200 Subject: [PATCH 37/39] test(it): capture api-sheriff app logs on test failure + TEMP debug logging (diagnostic) The Bff callback 400 flakes only in CI (2-core runner) and its reason is logged at DEBUG, so it has been undiagnosable. This diagnostic round: - dump-keycloak-logs.sh now also dumps the api-sheriff (+mtls) container stdout into target/failsafe-reports/ (uploaded), so a TEST failure is diagnosable, not just a startup failure. (Keep.) - TEMP: enable DEBUG for the bff/edge/token-client packages in the IT container to surface the exact reserved-callback 400 reason. (Revert once diagnosed.) Co-Authored-By: Claude Opus 4.8 (1M context) --- integration-tests/docker-compose.yml | 8 ++++++++ integration-tests/scripts/dump-keycloak-logs.sh | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index 8e80d40e..e681fd27 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -169,6 +169,10 @@ services: - -Djavax.net.ssl.trustStore=/app/certificates/localhost-truststore.p12 - -Djavax.net.ssl.trustStorePassword=localhost-trust - -Djavax.net.ssl.trustStoreType=PKCS12 + # TEMP DIAGNOSTIC (revert): surface the reserved-callback 400 reason (logged at DEBUG). + - -Dquarkus.log.category."de.cuioss.sheriff.gateway.bff".level=DEBUG + - -Dquarkus.log.category."de.cuioss.sheriff.gateway.edge".level=DEBUG + - -Dquarkus.log.category."de.cuioss.sheriff.token.client".level=DEBUG ports: - "10443:8443" # External test port for integration tests @@ -282,6 +286,10 @@ services: - -Djavax.net.ssl.trustStore=/app/certificates/localhost-truststore.p12 - -Djavax.net.ssl.trustStorePassword=localhost-trust - -Djavax.net.ssl.trustStoreType=PKCS12 + # TEMP DIAGNOSTIC (revert): surface the reserved-callback 400 reason (logged at DEBUG). + - -Dquarkus.log.category."de.cuioss.sheriff.gateway.bff".level=DEBUG + - -Dquarkus.log.category."de.cuioss.sheriff.gateway.edge".level=DEBUG + - -Dquarkus.log.category."de.cuioss.sheriff.token.client".level=DEBUG ports: - "10444:8443" # External test port for MtlsHandshakeIT (test.mtls.port) - "19001:9000" # Management interface (health/metrics, plain HTTP) diff --git a/integration-tests/scripts/dump-keycloak-logs.sh b/integration-tests/scripts/dump-keycloak-logs.sh index 4b6f0ff9..44f98310 100755 --- a/integration-tests/scripts/dump-keycloak-logs.sh +++ b/integration-tests/scripts/dump-keycloak-logs.sh @@ -38,6 +38,18 @@ echo "🚀 Dumping Keycloak container logs..." echo "📦 Keycloak container: $KEYCLOAK_CONTAINER_NAME" echo "📝 Output file: $KEYCLOAK_LOG_FILE_PATH" +# Best-effort dump of the api-sheriff app containers into failsafe-reports (uploaded as a CI +# artifact) so a TEST failure — not just a startup failure — is diagnosable from the app's stdout. +# Never fail the build on a dump problem. +FAILSAFE_DIR="${TARGET_ABS_PATH}/failsafe-reports" +mkdir -p "$FAILSAFE_DIR" || true +for app in integration-tests-api-sheriff-1 integration-tests-api-sheriff-mtls-1; do + if docker ps -a --format "{{.Names}}" | grep -q "^${app}$"; then + echo "📥 Dumping app logs: ${app} -> ${FAILSAFE_DIR}/${app}.log" + docker logs "$app" > "${FAILSAFE_DIR}/${app}.log" 2>&1 || true + fi +done + # Check if container exists and is running if ! docker ps --format "{{.Names}}" | grep -q "^${KEYCLOAK_CONTAINER_NAME}$"; then if docker ps -a --format "{{.Names}}" | grep -q "^${KEYCLOAK_CONTAINER_NAME}$"; then From 0d692a90a9307fedc270474200caeea55fd7e34b Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:05:30 +0200 Subject: [PATCH 38/39] fix(it): start Keycloak before the gateway so the JWKS loader finds it ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the flaky Bff callback 400 (captured via the diagnostic app-log round): the gateway's token-validation JWKS loader for the Keycloak `integration` issuer runs at boot; docker compose started the gateway and Keycloak together (depends_on: service_started), so under CI's shared 2-core contention Keycloak was not yet ready when the loader's brief initial-retry window elapsed. The load failed with ConnectException and — with a 600s background refresh — the issuer stayed unhealthy for the whole run, so every mediated login's token validation failed with "No healthy issuer configuration found for issuer: https://keycloak:8443/realms/integration". (This is why the earlier body-read fixes did not help — the body read was never the cause.) Fix: the start script now brings up Keycloak first, waits for its /health/ready, then starts the gateway and the rest — so the gateway's initial JWKS load always finds Keycloak reachable. Keycloak-ready wait widened 60s -> 120s for CI headroom. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../scripts/start-integration-container.sh | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/integration-tests/scripts/start-integration-container.sh b/integration-tests/scripts/start-integration-container.sh index d43565cb..b3405607 100755 --- a/integration-tests/scripts/start-integration-container.sh +++ b/integration-tests/scripts/start-integration-container.sh @@ -86,26 +86,37 @@ mkdir -p "${LOG_TARGET_DIR}" chmod 0777 "${LOG_TARGET_DIR}" echo "📁 Quarkus logs will be written to: ${LOG_TARGET_DIR}/quarkus.log" -# Start with Docker Compose (includes Keycloak) -echo "🐳 Starting Docker containers (Quarkus $MODE + Keycloak)..." -(cd "${PROJECT_DIR}" && $COMPOSE_CMD up -d) +# Bring up Keycloak FIRST and wait until it is READY before starting the gateway. The api-sheriff +# native app eagerly loads the Keycloak issuers' JWKS at boot; if it starts before Keycloak can +# answer, that initial load fails (ConnectException) and — with a long background-refresh interval — +# the issuer stays unhealthy for the whole test run, so every mediated login's token validation +# fails with "No healthy issuer configuration found". Under CI's shared-CPU contention Keycloak is +# slower to answer than the gateway's brief initial-retry window, which made this flake. Gating the +# gateway start on a ready Keycloak removes the race. (docker compose up -d keycloak starts Keycloak +# and its own dependencies only; the gateway and remaining infra are started afterwards.) +echo "🐳 Starting Keycloak first (the Quarkus $MODE gateway starts only after Keycloak is ready)..." +(cd "${PROJECT_DIR}" && $COMPOSE_CMD up -d keycloak) # Wait for Keycloak to be ready first echo "⏳ Waiting for Keycloak to be ready..." -for i in {1..60}; do +for i in {1..120}; do if curl -k -s https://localhost:1090/health/ready > /dev/null 2>&1; then echo "✅ Keycloak is ready!" break fi - if [ $i -eq 60 ]; then - echo "❌ Keycloak failed to start within 60 seconds" + if [ $i -eq 120 ]; then + echo "❌ Keycloak failed to become ready within 120 seconds" echo "Check logs with: ${COMPOSE_BASE} logs keycloak" exit 1 fi - echo "⏳ Waiting for Keycloak... (attempt $i/60)" + echo "⏳ Waiting for Keycloak... (attempt $i/120)" sleep 1 done +# Keycloak is ready — now bring up the gateway and the remaining containers. +echo "🐳 Starting the gateway ($MODE) and remaining containers..." +(cd "${PROJECT_DIR}" && $COMPOSE_CMD up -d) + # Wait for the go-httpbin upstream backend (proxy target) to be ready echo "⏳ Waiting for go-httpbin upstream to be ready..." for i in {1..30}; do From 6d0a8dacc4d04d32b7cfe9eb839797b4c601d658 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:20:35 +0200 Subject: [PATCH 39/39] chore(it): remove the temporary diagnostic DEBUG logging The reserved-callback flake is diagnosed and fixed (Keycloak-before-gateway start ordering). Drop the temporary bff/edge/token-client DEBUG log categories added for the diagnostic round; the app-log capture on test failure is kept. Co-Authored-By: Claude Opus 4.8 (1M context) --- integration-tests/docker-compose.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index e681fd27..8e80d40e 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -169,10 +169,6 @@ services: - -Djavax.net.ssl.trustStore=/app/certificates/localhost-truststore.p12 - -Djavax.net.ssl.trustStorePassword=localhost-trust - -Djavax.net.ssl.trustStoreType=PKCS12 - # TEMP DIAGNOSTIC (revert): surface the reserved-callback 400 reason (logged at DEBUG). - - -Dquarkus.log.category."de.cuioss.sheriff.gateway.bff".level=DEBUG - - -Dquarkus.log.category."de.cuioss.sheriff.gateway.edge".level=DEBUG - - -Dquarkus.log.category."de.cuioss.sheriff.token.client".level=DEBUG ports: - "10443:8443" # External test port for integration tests @@ -286,10 +282,6 @@ services: - -Djavax.net.ssl.trustStore=/app/certificates/localhost-truststore.p12 - -Djavax.net.ssl.trustStorePassword=localhost-trust - -Djavax.net.ssl.trustStoreType=PKCS12 - # TEMP DIAGNOSTIC (revert): surface the reserved-callback 400 reason (logged at DEBUG). - - -Dquarkus.log.category."de.cuioss.sheriff.gateway.bff".level=DEBUG - - -Dquarkus.log.category."de.cuioss.sheriff.gateway.edge".level=DEBUG - - -Dquarkus.log.category."de.cuioss.sheriff.token.client".level=DEBUG ports: - "10444:8443" # External test port for MtlsHandshakeIT (test.mtls.port) - "19001:9000" # Management interface (health/metrics, plain HTTP)