diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/auth/AuthenticationStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/auth/AuthenticationStage.java index 1bd5c8cc..6e7348b2 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/auth/AuthenticationStage.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/auth/AuthenticationStage.java @@ -20,6 +20,7 @@ import java.util.Optional; +import de.cuioss.sheriff.gateway.bff.runtime.SessionAuthenticationStage; import de.cuioss.sheriff.gateway.config.model.AuthConfig; import de.cuioss.sheriff.gateway.events.EventType; import de.cuioss.sheriff.gateway.events.GatewayException; @@ -31,6 +32,7 @@ import de.cuioss.sheriff.token.validation.exception.TokenValidationException; import jakarta.inject.Provider; +import org.jspecify.annotations.Nullable; /** * Stage 4 — offline bearer-token validation, run after the per-route thorough checks. @@ -45,10 +47,19 @@ * invalid / expired / tampered token is 401 {@link EventType#TOKEN_INVALID}; both carry * {@code WWW-Authenticate: Bearer}. A valid token lacking a required scope is 403 * {@link EventType#SCOPE_MISSING}; - *
  • {@code require: session} — never reaches here; the boot-time {@code RouteRuntimeAssembler} - * already rejected such routes (session auth is not yet implemented).
  • + *
  • {@code require: session} — dispatched to the {@link SessionAuthenticationStage} stage-4 + * runtime (D4): the opaque session cookie is resolved to a live session, the mediated token is + * injected as the upstream {@code Authorization: Bearer}, {@code required_scopes} are enforced + * against the mediated token (403 {@link EventType#SCOPE_MISSING}), and an unauthenticated + * request is redirected into the auth-code flow (navigation) or challenged 401 + * {@code application/problem+json} (everything else). Bearer and session stay separate + * mechanisms — the bearer-validation logic above is untouched.
  • * * The upstream is never contacted on any authentication or authorization rejection. + *

    + * The session runtime is an optional collaborator: it is wired only when the gateway serves a BFF + * variant. A {@code require: session} route reaching this stage without a wired session runtime is a + * boot-configuration error surfaced as an {@link IllegalStateException} rather than served. * * @author API Sheriff Team * @since 1.0 @@ -57,11 +68,16 @@ public final class AuthenticationStage { private static final String REQUIRE_NONE = "none"; private static final String REQUIRE_BEARER = "bearer"; + private static final String REQUIRE_SESSION = "session"; private static final String BEARER_PREFIX = "Bearer "; private final Provider tokenValidator; + private final @Nullable SessionAuthenticationStage sessionStage; /** + * Assembles the stage for a gateway serving no BFF variant — {@code require: session} is + * unsupported (a session route reaching this stage is a boot-configuration error). + * * @param tokenValidator a lazy provider of the single shared validator built from the * {@code token_validation} config. The validator is resolved via * {@link Provider#get()} only at the first actual bearer validation, so a @@ -71,6 +87,19 @@ public final class AuthenticationStage { */ public AuthenticationStage(Provider tokenValidator) { this.tokenValidator = Objects.requireNonNull(tokenValidator, "tokenValidator"); + this.sessionStage = null; + } + + /** + * Assembles the stage with the {@code require: session} stage-4 runtime (D4) wired for a gateway + * serving a BFF variant. + * + * @param tokenValidator the lazy bearer-validator provider (see the single-argument constructor) + * @param sessionStage the session runtime a {@code require: session} route is dispatched to + */ + public AuthenticationStage(Provider tokenValidator, SessionAuthenticationStage sessionStage) { + this.tokenValidator = Objects.requireNonNull(tokenValidator, "tokenValidator"); + this.sessionStage = Objects.requireNonNull(sessionStage, "sessionStage"); } /** @@ -91,10 +120,22 @@ public void process(PipelineRequest request) { validateBearer(request, auth, route); return; } + if (REQUIRE_SESSION.equals(require)) { + requireSessionStage(route).process(request); + return; + } throw new IllegalStateException( "Route " + route.getId() + " reached authentication with unsupported require '" + require + "'"); } + private SessionAuthenticationStage requireSessionStage(RouteRuntime route) { + if (sessionStage == null) { + throw new IllegalStateException("Route " + route.getId() + + " requires session authentication but no session runtime is wired"); + } + return sessionStage; + } + private void validateBearer(PipelineRequest request, AuthConfig auth, RouteRuntime route) { String token = extractBearerToken(request) .orElseThrow(() -> unauthorized(request, EventType.TOKEN_MISSING, "No bearer token presented")); diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/BffLogMessages.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/BffLogMessages.java new file mode 100644 index 00000000..93e866a7 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/BffLogMessages.java @@ -0,0 +1,140 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff; + +import de.cuioss.tools.logging.LogRecord; +import de.cuioss.tools.logging.LogRecordModel; + +import lombok.experimental.UtilityClass; + +/** + * DSL-style {@link LogRecord} catalogue for the Backend-for-Frontend {@code require: session} + * surface — the session lifecycle, transparent token refresh, CSRF defence, and logout events. + *

    + * Structured {@code INFO} (1-99) and {@code WARN} (100-199) messages carry the shared + * {@code ApiSheriff} prefix and a stable numeric identifier, so they are greppable and assertable. + * This catalogue's identifier ranges are disjoint from the request-pipeline edge + * ({@link de.cuioss.sheriff.gateway.ApiSheriffLogMessages}: {@code 1} / {@code 4} / {@code 100} / + * {@code 103-106}) and the configuration subsystem + * ({@link de.cuioss.sheriff.gateway.config.ConfigLogMessages}: {@code 2-3} / {@code 101-102} / + * {@code 200-201}), which share the same {@code ApiSheriff} prefix — this BFF catalogue owns + * {@code 10-14} (INFO) and {@code 110-112} (WARN). Never renumber one catalogue without checking + * the others for a collision. + *

    + * No sensitive data is logged. Session subjects ({@code sub}), IdP session ids + * ({@code sid}), token material, and raw offending values never appear in a template: a rejection + * records only its non-sensitive disposition ({@code untrusted-origin} / {@code signature} + * / …), a lifecycle event records only a non-sensitive reason or a bounded count. Exception-bearing + * {@code WARN}s pass the throwable first, per the CUI logging contract; callers must not attach a + * raw, unsanitized IdP exception whose message could re-inject untrusted content. {@code DEBUG} / + * {@code TRACE} diagnostics use the logger directly and are not catalogued here. + * + * @author API Sheriff Team + * @since 1.0 + */ +@UtilityClass +public final class BffLogMessages { + + private static final String PREFIX = "ApiSheriff"; + + /** + * Info-level messages (INFO range 1-99; this catalogue owns 10-14). + */ + @UtilityClass + public static final class INFO { + + /** A server-side session was established after a successful IdP login (require: session). */ + public static final LogRecord SESSION_CREATED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(10) + .template("Server-side session established for a require:session route") + .build(); + + /** + * A server-side session was destroyed. The reason is a bounded, non-sensitive disposition + * ({@code logout} / {@code backchannel-logout} / {@code expiry} / {@code refresh-failure}) — + * never the session id, subject, or IdP {@code sid}. + */ + public static final LogRecord SESSION_DESTROYED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(11) + .template("Server-side session destroyed (%s)") + .build(); + + /** The mediated tokens were transparently refreshed for a require:session route. */ + public static final LogRecord TOKEN_REFRESHED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(12) + .template("Mediated tokens refreshed for a require:session route (single-flight)") + .build(); + + /** + * A back-channel logout token was accepted and its affected sessions were destroyed. The + * template carries only the bounded destroyed-session count — never the subject or {@code sid}. + */ + public static final LogRecord BACKCHANNEL_LOGOUT = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(13) + .template("Back-channel logout accepted — %s session(s) destroyed") + .build(); + + /** An RP-initiated logout completed its return leg for a require:session route. */ + public static final LogRecord RP_LOGOUT_COMPLETED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(14) + .template("RP-initiated logout completed for a require:session route") + .build(); + } + + /** + * Warn-level messages (WARN range 100-199; this catalogue owns 110-112). + */ + @UtilityClass + public static final class WARN { + + /** + * The fixed CSRF defence rejected an unsafe-method session request. Records the non-sensitive + * rejection disposition ({@code untrusted-origin} / {@code no-origin-proof}) only — never the + * raw offending {@code Origin} value. + */ + public static final LogRecord CSRF_REJECTED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(110) + .template("CSRF defence rejected an unsafe-method session request: %s") + .build(); + + /** + * A transparent token refresh failed (IdP rejection or engine-detected refresh-token reuse) + * and its session was destroyed; the caller re-authenticates. Records only a bounded, + * non-sensitive reason — never the presented refresh token or session id. + */ + public static final LogRecord SESSION_REFRESH_FAILED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(111) + .template("Token refresh failed for a require:session route (%s) — session destroyed") + .build(); + + /** + * A back-channel logout token was rejected. Records the non-sensitive rejection disposition + * ({@code signature} / {@code claims}) only — never the raw logout token. + */ + public static final LogRecord LOGOUT_TOKEN_REJECTED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(112) + .template("Back-channel logout token rejected: %s") + .build(); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/csrf/CsrfDefence.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/csrf/CsrfDefence.java new file mode 100644 index 00000000..aba3b93a --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/csrf/CsrfDefence.java @@ -0,0 +1,132 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.csrf; + +import java.util.EnumSet; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + + +import de.cuioss.sheriff.gateway.config.model.HttpMethod; +import de.cuioss.sheriff.gateway.events.EventType; +import de.cuioss.sheriff.gateway.events.GatewayException; +import de.cuioss.sheriff.gateway.pipeline.PipelineRequest; +import de.cuioss.tools.logging.CuiLogger; + +/** + * The fixed CSRF defence for {@code require: session} routes (D7). It is fixed + * behaviour with no disable knob: every unsafe-method request on a session route must + * prove same-origin provenance or it is rejected {@code 403} with a {@link EventType#CSRF_REJECTED} + * event. There is no configuration flag that turns the defence off. + *

    + * "Unsafe" means any method outside the CSRF-safe set {@code GET} / {@code HEAD} / {@code OPTIONS} + * (the methods a browser issues without a body-mutating intent). For an unsafe method the defence + * is fail-closed and accepts only two proofs of same-origin provenance: + *

      + *
    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 = EnumSet.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS); + + private static final String DISPOSITION_UNTRUSTED_ORIGIN = "untrusted-origin"; + private static final String DISPOSITION_NO_ORIGIN_PROOF = "no-origin-proof"; + + private final Set trustedOrigins; + + /** + * Assembles the defence with the effective trusted-origin allowlist. + * + * @param trustedOrigins the browser origins trusted on unsafe methods — full origin strings + * (scheme + host + port), already resolved to the configured + * {@code session.csrf.trusted_origins} or the {@code redirect_uri} default; + * lower-cased and defensively copied here. An empty set is the fail-closed + * extreme where only {@code Sec-Fetch-Site: same-origin} can pass. + */ + public CsrfDefence(Set trustedOrigins) { + Objects.requireNonNull(trustedOrigins, "trustedOrigins"); + this.trustedOrigins = trustedOrigins.stream() + .map(origin -> origin.toLowerCase(Locale.ROOT)) + .collect(Collectors.toUnmodifiableSet()); + } + + /** + * Enforces the fixed CSRF defence for a request selected onto a {@code require: session} route. + * A CSRF-safe method returns immediately; an unsafe method must present a trusted {@code Origin} + * or a {@code Sec-Fetch-Site: same-origin} header. + * + * @param request the in-flight request carrier + * @throws GatewayException carrying {@link EventType#CSRF_REJECTED} ({@code 403}) when an + * unsafe-method request presents neither a trusted {@code Origin} nor a + * {@code same-origin} {@code Sec-Fetch-Site} + */ + public void enforce(PipelineRequest request) { + Objects.requireNonNull(request, "request"); + if (SAFE_METHODS.contains(request.method())) { + return; + } + Optional origin = request.firstHeader(ORIGIN_HEADER); + if (origin.isPresent()) { + if (trustedOrigins.contains(origin.get().toLowerCase(Locale.ROOT))) { + return; + } + throw rejected(DISPOSITION_UNTRUSTED_ORIGIN); + } + if (isSameOriginFetch(request)) { + return; + } + throw rejected(DISPOSITION_NO_ORIGIN_PROOF); + } + + private static boolean isSameOriginFetch(PipelineRequest request) { + return request.firstHeader(SEC_FETCH_SITE_HEADER) + .map(value -> value.toLowerCase(Locale.ROOT)) + .filter(SAME_ORIGIN::equals) + .isPresent(); + } + + private static GatewayException rejected(String disposition) { + LOGGER.debug("CSRF defence rejected an unsafe-method session request: %s", disposition); + return new GatewayException(EventType.CSRF_REJECTED, + "CSRF defence rejected an unsafe-method session request: " + disposition); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/csrf/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/csrf/package-info.java new file mode 100644 index 00000000..32b0109f --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/csrf/package-info.java @@ -0,0 +1,34 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * The fixed CSRF defence for {@code require: session} routes (D7). + *

    + * {@link de.cuioss.sheriff.gateway.bff.csrf.CsrfDefence} is fixed behaviour with no disable knob: + * on unsafe methods (everything except {@code GET} / {@code HEAD} / {@code OPTIONS}) it accepts only + * an {@code Origin} whose exact full origin (scheme + host + port) is in the trusted-origin set + * ({@code session.csrf.trusted_origins}, defaulting to the {@code redirect_uri} origin) or, when + * {@code Origin} is absent, a {@code Sec-Fetch-Site: same-origin} header; everything else is rejected + * {@code 403} with a {@link de.cuioss.sheriff.gateway.events.EventType#CSRF_REJECTED} event. + *

    + * The class is framework-agnostic and immutable; the session runtime wires it into the request edge. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.gateway.bff.csrf; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/LoginFlow.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/LoginFlow.java new file mode 100644 index 00000000..bcabc429 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/LoginFlow.java @@ -0,0 +1,143 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.login; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + + +import de.cuioss.sheriff.gateway.bff.pending.BindingCookieCodec; +import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord; +import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationStore; +import de.cuioss.sheriff.token.client.flow.AuthorizationCodeFlow; +import de.cuioss.tools.logging.CuiLogger; + +import org.jspecify.annotations.Nullable; + +/** + * The confidential-client login initiation (D1/D2/D2b) — the browser-facing start of the OIDC + * auth-code flow, the mirror of {@link de.cuioss.sheriff.gateway.bff.reserved.CallbackEndpoint}. + *

    + * The gateway re-implements no OAuth leg: the engine owns PKCE ({@code S256}), + * {@code state}, {@code nonce}, and the authorization-URL construction. {@link LoginFlow} only + * orchestrates the gateway-side concerns — it asks the engine to {@linkplain AuthorizationInitiation + * authorize} (yielding the authorization URL and the transaction {@code FlowContext}), persists the + * context as a single-use {@link PendingAuthorizationRecord}, sets the short-lived {@code __Host-} + * browser-binding cookie carrying the record id, and redirects the browser to the IdP. The binding + * cookie ties the in-flight login to this browser so the later callback cannot be replayed in + * another (the pre-session analogue of the session cookie). + *

    + * The post-login return URL is same-origin-validated ({@link PendingAuthorizationRecord#sameOrigin}) + * before it is recorded — a cross-origin or unparseable target falls back to {@link #DEFAULT_RETURN_URL}, + * so the post-login redirect is never an open redirect. The engine authorization is reached through + * the {@link AuthorizationInitiation} seam, keeping the flow decoupled from the confidential-client + * wiring (discovery metadata) and unit-testable without a live IdP. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class LoginFlow { + + private static final CuiLogger LOGGER = new CuiLogger(LoginFlow.class); + + /** The safe default post-login landing when no valid same-origin return URL is supplied. */ + public static final String DEFAULT_RETURN_URL = "/"; + + private final AuthorizationInitiation authorization; + private final PendingAuthorizationStore pendingStore; + private final BindingCookieCodec bindingCookieCodec; + private final String gatewayOrigin; + + /** + * Assembles the login flow with the engine authorization seam and the gateway-side stores. + * + * @param authorization the engine authorization seam (bound to {@link AuthorizationCodeFlow#authorize}) + * @param pendingStore the single-use pending-authorization store + * @param bindingCookieCodec the browser-binding cookie codec + * @param gatewayOrigin the gateway's own origin (the {@code redirect_uri} origin) used to + * same-origin-validate the post-login return URL + */ + public LoginFlow(AuthorizationInitiation authorization, PendingAuthorizationStore pendingStore, + BindingCookieCodec bindingCookieCodec, String gatewayOrigin) { + this.authorization = Objects.requireNonNull(authorization, "authorization"); + this.pendingStore = Objects.requireNonNull(pendingStore, "pendingStore"); + this.bindingCookieCodec = Objects.requireNonNull(bindingCookieCodec, "bindingCookieCodec"); + this.gatewayOrigin = Objects.requireNonNull(gatewayOrigin, "gatewayOrigin"); + } + + /** + * Initiates a login: drives the engine authorization, persists the transaction, sets the binding + * cookie, and redirects the browser to the IdP. + * + * @param requestedReturnUrl the post-login return target the browser asked for, may be absent + * @param now the reference instant (the pending record's TTL anchor) + * @return the redirect to the IdP authorization URL carrying the binding {@code Set-Cookie} + */ + public LoginRedirect initiate(@Nullable String requestedReturnUrl, Instant now) { + Objects.requireNonNull(now, "now"); + AuthorizationCodeFlow.AuthorizationRedirect redirect = authorization.authorize(); + String returnUrl = requestedReturnUrl != null + && PendingAuthorizationRecord.sameOrigin(requestedReturnUrl, gatewayOrigin) + ? requestedReturnUrl : DEFAULT_RETURN_URL; + PendingAuthorizationRecord pending = PendingAuthorizationRecord.create(redirect.context(), returnUrl, now); + pendingStore.store(pending); + LOGGER.debug("Initiated OIDC login; pending record persisted, redirecting to the IdP"); + List setCookies = List.of(bindingCookieCodec.toSetCookieHeader(pending.id())); + return new LoginRedirect(redirect.authorizationUrl(), setCookies); + } + + /** + * The engine authorization seam. The session runtime binds it to the engine as + * {@code () -> authorizationCodeFlow.authorize(providerMetadata)}; a test binds it to a + * hand-built redirect. Keeping the discovery-metadata wiring behind the seam decouples the flow + * from it and makes the initiation path unit-testable without a live IdP. + * + * @author API Sheriff Team + * @since 1.0 + */ + @FunctionalInterface + public interface AuthorizationInitiation { + + /** + * Builds the authorization URL and the transaction context (PKCE/state/nonce owned by the + * engine) for a fresh login. + * + * @return the engine's authorization redirect (URL + transaction {@code FlowContext}) + */ + AuthorizationCodeFlow.AuthorizationRedirect authorize(); + } + + /** + * The framework-agnostic result of a login initiation: a {@code 302} redirect to the IdP + * authorization URL, carrying the browser-binding {@code Set-Cookie} header. + * + * @param authorizationUrl the IdP authorization endpoint URL to redirect the browser to + * @param setCookieHeaders the {@code Set-Cookie} header values to emit (the binding cookie) + * @author API Sheriff Team + * @since 1.0 + */ + public record LoginRedirect(String authorizationUrl, List setCookieHeaders) { + + /** + * Canonical constructor rejecting an absent URL and defensively copying the cookies. + */ + public LoginRedirect { + Objects.requireNonNull(authorizationUrl, "authorizationUrl"); + setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/package-info.java new file mode 100644 index 00000000..8899d5fe --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/package-info.java @@ -0,0 +1,37 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * The confidential-client login initiation for the BFF auth-code flow (D1/D2/D2b). + *

    + * {@link de.cuioss.sheriff.gateway.bff.login.LoginFlow} is the browser-facing start of the OIDC + * code flow and the mirror of the callback endpoint: it drives the engine to build the + * authorization URL and the transaction {@code FlowContext} (PKCE/state/nonce owned by the engine, + * never re-implemented), persists the context as a single-use pending-authorization record, sets + * the {@code __Host-} browser-binding cookie, and redirects the browser to the IdP. The post-login + * return URL is same-origin-validated before it is recorded, so the eventual redirect is never an + * open redirect. + *

    + * The class is framework-agnostic (no CDI, no JAX-RS/Vert.x coupling) and reaches the engine + * through a seam, so it is unit-testable without a live IdP; the session runtime wires it to the + * request/response edge. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.gateway.bff.login; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/BackchannelLogoutReceiver.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/BackchannelLogoutReceiver.java new file mode 100644 index 00000000..f7e50516 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/BackchannelLogoutReceiver.java @@ -0,0 +1,154 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.logout; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + + +import de.cuioss.sheriff.gateway.bff.session.SessionStore; +import de.cuioss.sheriff.token.commons.error.TokenSheriffException; +import de.cuioss.sheriff.token.validation.domain.token.TokenContent; +import de.cuioss.tools.logging.CuiLogger; + +/** + * The gateway-side back-channel logout receiver (D2c): signature-verify the logout token via the + * engine's issuer/JWKS infrastructure, run the {@link LogoutTokenValidator claim residual}, then + * destroy the affected sessions through the store's O(1) secondary index. + *

    + * Signature verification is reached through the {@link LogoutTokenVerifier} seam — the session + * runtime binds it to the engine's token validation (reuse of {@code token-sheriff-validation}, + * ADR-11); a test binds it to a hand-built token or a failing stub. Keeping the JWKS wiring behind + * the seam decouples the receiver from the confidential-client configuration and makes both the + * signature-failure and the claim-rejection paths unit-testable without a live IdP. + *

    + * Destruction is fail-closed and precise: a token carrying a {@code sid} destroys exactly that IdP + * session ({@link SessionStore#destroyBySid}); a token carrying only a {@code sub} destroys every + * session for the subject ({@link SessionStore#destroyBySub}). A signature or claim failure destroys + * nothing. The receiver is framework-agnostic; the {@link de.cuioss.sheriff.gateway.bff.reserved.BackchannelLogoutEndpoint} + * owns the HTTP concern. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class BackchannelLogoutReceiver { + + private static final CuiLogger LOGGER = new CuiLogger(BackchannelLogoutReceiver.class); + + private final LogoutTokenVerifier verifier; + private final LogoutTokenValidator validator; + private final SessionStore sessionStore; + + /** + * Assembles the receiver with the signature-verification seam, the claim residual, and the store. + * + * @param verifier the engine signature-verification seam (bound to the JWKS token validation) + * @param validator the pure logout-token claim pipeline + * @param sessionStore the server-side session store destroyed through its secondary index + */ + public BackchannelLogoutReceiver(LogoutTokenVerifier verifier, LogoutTokenValidator validator, + SessionStore sessionStore) { + this.verifier = Objects.requireNonNull(verifier, "verifier"); + this.validator = Objects.requireNonNull(validator, "validator"); + this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore"); + } + + /** + * Receives one back-channel logout token: signature-verify, claim-validate, and destroy. + * + * @param rawLogoutToken the raw {@code logout_token} JWT from the back-channel request + * @param now the reference instant for the {@code iat} freshness check + * @return an accepted result carrying the destroyed-session count, or a rejected result + */ + public BackchannelResult receive(String rawLogoutToken, Instant now) { + Objects.requireNonNull(rawLogoutToken, "rawLogoutToken"); + Objects.requireNonNull(now, "now"); + + TokenContent token; + try { + token = verifier.verify(rawLogoutToken); + } catch (TokenSheriffException signatureFailure) { + LOGGER.debug(signatureFailure, "Back-channel logout token signature/validation failed — rejected"); + return BackchannelResult.rejected(); + } + + Optional subject = validator.validate(token, now); + if (subject.isEmpty()) { + return BackchannelResult.rejected(); + } + + LogoutTokenValidator.LogoutSubject logoutSubject = subject.get(); + int destroyed = logoutSubject.sid().map(sessionStore::destroyBySid) + .orElseGet(() -> logoutSubject.sub().map(sessionStore::destroyBySub).orElse(0)); + LOGGER.debug("Back-channel logout accepted — destroyed %s session(s)", destroyed); + return BackchannelResult.accepted(destroyed); + } + + /** + * The engine signature-verification seam. The session runtime binds it to the engine's JWKS + * token validation; a test binds it to a hand-built {@link TokenContent} or a stub that throws. + * Keeping the confidential-client/JWKS wiring behind the seam decouples the receiver from it and + * makes the signature-failure path unit-testable without a live IdP. + * + * @author API Sheriff Team + * @since 1.0 + */ + @FunctionalInterface + public interface LogoutTokenVerifier { + + /** + * Signature-verifies (and structurally validates) a raw logout token via the JWKS infrastructure. + * + * @param rawLogoutToken the raw logout-token JWT + * @return the signature-verified token content + * @throws de.cuioss.sheriff.token.commons.error.TokenSheriffException when the signature or + * structural validation fails + */ + TokenContent verify(String rawLogoutToken); + } + + /** + * The framework-agnostic outcome of a back-channel logout: whether the token was accepted and, if + * so, how many server-side sessions were destroyed. A rejected result destroys nothing. + * + * @param accepted whether the logout token passed signature and claim validation + * @param destroyed the number of sessions destroyed, always {@code 0} for a rejected result + * @author API Sheriff Team + * @since 1.0 + */ + public record BackchannelResult(boolean accepted, int destroyed) { + + /** + * An accepted result carrying the destroyed-session count. + * + * @param destroyed the number of sessions destroyed + * @return the accepted result + */ + public static BackchannelResult accepted(int destroyed) { + return new BackchannelResult(true, destroyed); + } + + /** + * A rejected result destroying nothing. + * + * @return the rejected result + */ + public static BackchannelResult rejected() { + return new BackchannelResult(false, 0); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/LogoutTokenValidator.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/LogoutTokenValidator.java new file mode 100644 index 00000000..3fe0b526 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/LogoutTokenValidator.java @@ -0,0 +1,211 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.logout; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + + +import de.cuioss.sheriff.token.validation.domain.claim.ClaimValue; +import de.cuioss.sheriff.token.validation.domain.claim.ClaimValueType; +import de.cuioss.sheriff.token.validation.domain.token.TokenContent; +import de.cuioss.tools.logging.CuiLogger; + +/** + * The pure OIDC back-channel-logout-token check pipeline (D2c residual, BFF-09). + *

    + * The engine's issuer/JWKS infrastructure verifies the logout token's signature before it + * reaches this validator (reuse of {@code token-sheriff-validation}, ADR-11); the library carries no + * back-channel-logout support, so the gateway-side claim residual lives here. The pipeline applies + * the full OpenID Connect + * Back-Channel Logout validation on the already-signature-verified token: + *

      + *
    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(LogoutTokenValidator::hasBackchannelLogoutEventMember).isEmpty()) { + LOGGER.debug("Back-channel logout token rejected — events is not a JSON object carrying the back-channel-logout member"); + return Optional.empty(); + } + if (claim(logoutToken, CLAIM_NONCE).isPresent()) { + LOGGER.debug("Back-channel logout token rejected — a nonce is prohibited in a logout token"); + return Optional.empty(); + } + Optional sub = claim(logoutToken, CLAIM_SUB); + Optional sid = claim(logoutToken, CLAIM_SID); + if (sub.isEmpty() && sid.isEmpty()) { + LOGGER.debug("Back-channel logout token rejected — neither sub nor sid present"); + return Optional.empty(); + } + return Optional.of(new LogoutSubject(sub, sid)); + } + + private static List audience(TokenContent token) { + ClaimValue aud = token.getClaims().get(CLAIM_AUD); + if (aud == null || aud.isNotPresentForClaimValueType()) { + return List.of(); + } + if (aud.getType() == ClaimValueType.STRING_LIST) { + return aud.getAsList(); + } + String original = aud.getOriginalString(); + return original == null || original.isBlank() ? List.of() : List.of(original); + } + + private boolean isFresh(TokenContent token, Instant now) { + ClaimValue iat = token.getClaims().get(CLAIM_IAT); + if (iat == null || iat.isNotPresentForClaimValueType()) { + return false; + } + Instant issuedAt = iat.getDateTime().toInstant(); + return !issuedAt.isBefore(now.minus(freshnessWindow)) && !issuedAt.isAfter(now.plus(freshnessWindow)); + } + + /** + * Whether the (string-extracted) {@code events} claim is a JSON object that carries the + * {@value #BACKCHANNEL_LOGOUT_EVENT} URI as a member key, per + * OpenID Connect + * Back-Channel Logout §2.4. The engine surfaces a JSON-object claim as its serialized text, so + * the structural check here rejects a scalar or array {@code events} value that merely + * contains the event URI — a malformed or same-name scalar claim must never destroy a + * session. The URI must appear as a quoted key immediately followed (modulo whitespace) by the + * {@code :} name-separator; a value-position occurrence ({@code "x":"…backchannel-logout"}) is not + * followed by {@code :} and is therefore rejected. + * + * @param events the events claim's original string (the object's serialized JSON text) + * @return {@code true} only when {@code events} is a JSON object with the back-channel-logout key + */ + private static boolean hasBackchannelLogoutEventMember(String events) { + String trimmed = events.strip(); + // events MUST be a JSON object; a scalar string or a JSON array is rejected outright. + if (trimmed.length() < 2 || trimmed.charAt(0) != '{' || trimmed.charAt(trimmed.length() - 1) != '}') { + return false; + } + String quotedKey = '"' + BACKCHANNEL_LOGOUT_EVENT + '"'; + for (int at = trimmed.indexOf(quotedKey); at >= 0; at = trimmed.indexOf(quotedKey, at + 1)) { + int cursor = at + quotedKey.length(); + while (cursor < trimmed.length() && Character.isWhitespace(trimmed.charAt(cursor))) { + cursor++; + } + if (cursor < trimmed.length() && trimmed.charAt(cursor) == ':') { + return true; + } + } + return false; + } + + private static Optional claim(TokenContent token, String name) { + ClaimValue value = token.getClaims().get(name); + if (value == null || value.isNotPresentForClaimValueType()) { + return Optional.empty(); + } + String original = value.getOriginalString(); + return original == null || original.isBlank() ? Optional.empty() : Optional.of(original); + } + + /** + * The subject a validated logout token resolves to — at least one of {@code sub}/{@code sid} is + * present (the pipeline rejects a token carrying neither). {@code sid} drives the precise + * single-session destruction; {@code sub} the all-sessions-for-subject destruction. + * + * @param sub the subject claim, empty when the token carried only a {@code sid} + * @param sid the IdP session id claim, empty when the token carried only a {@code sub} + * @author API Sheriff Team + * @since 1.0 + */ + public record LogoutSubject(Optional sub, Optional sid) { + + /** + * Canonical constructor normalizing absent components to {@link Optional#empty()}. + */ + public LogoutSubject { + sub = Objects.requireNonNullElse(sub, Optional.empty()); + sid = Objects.requireNonNullElse(sid, Optional.empty()); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/RpInitiatedLogout.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/RpInitiatedLogout.java new file mode 100644 index 00000000..d3425189 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/RpInitiatedLogout.java @@ -0,0 +1,281 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.logout; + +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + + +import de.cuioss.sheriff.gateway.bff.session.SessionRecord; +import de.cuioss.sheriff.token.client.logout.EndSessionFlow; +import de.cuioss.tools.logging.CuiLogger; + +import org.jspecify.annotations.Nullable; + +/** + * The gateway-side RP-initiated logout logic (D5) — the mirror of the login flow for the logout + * direction. Framework-agnostic by construction; the {@link de.cuioss.sheriff.gateway.bff.reserved.LogoutEndpoint} + * owns the request/response edge and the session store. + *

    + * The gateway re-implements no OAuth leg: the engine's {@link EndSessionFlow} owns + * the {@code end_session_endpoint} redirect construction, the {@code id_token_hint}, and — via its + * {@code PostLogoutRedirectValidator} — the exact-match {@code post_logout_redirect_uri} + * that is the open-redirect defence. {@link RpInitiatedLogout} only orchestrates the gateway-side + * concerns: it {@linkplain TokenRevocation revokes} the mediated tokens (RFC 7009, best-effort), mints + * a session-bound {@code state}, and carries that {@code state} in the short-lived single-use + * {@value #LOGOUT_STATE_COOKIE_NAME} cookie. The engine-owned {@code post_logout_redirect_uri} is a + * gateway-owned reserved path (the return leg), so the browser never controls the redirect target. + *

    + * The {@linkplain #completeReturn return leg} verifies the returned {@code state} against the cookie + * (constant-time, engine-owned), clears the single-use cookie, then redirects to the configured + * {@code final_redirect}. A missing or mismatched {@code state} is rejected {@code 400} — a forged + * logout-return cannot land the browser anywhere. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class RpInitiatedLogout { + + private static final CuiLogger LOGGER = new CuiLogger(RpInitiatedLogout.class); + + /** The {@code __Host-}-prefixed single-use logout-state cookie name (CSRF defence for logout). */ + public static final String LOGOUT_STATE_COOKIE_NAME = "__Host-sheriff-logout"; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final int STATE_BYTES = 32; + private static final int BAD_REQUEST = 400; + private static final int FOUND = 302; + + private final EndSessionFlow endSessionFlow; + private final TokenRevocation revocation; + private final String endSessionEndpoint; + private final String postLogoutRedirectUri; + private final String finalRedirect; + private final Duration stateCookieTtl; + + /** + * Assembles the RP-initiated logout with the engine end-session flow and the gateway-side settings. + * + * @param endSessionFlow the engine end-session redirect builder (owns exact-match validation) + * @param revocation the RFC 7009 token-revocation seam (best-effort) + * @param endSessionEndpoint the IdP {@code end_session_endpoint} (from discovery) + * @param postLogoutRedirectUri the gateway-owned return-leg URI sent to the IdP (exact-match) + * @param finalRedirect the application landing URL after the return leg + * @param stateCookieTtl the short lifetime of the single-use logout-state cookie (e.g. 60s) + */ + public RpInitiatedLogout(EndSessionFlow endSessionFlow, TokenRevocation revocation, String endSessionEndpoint, + String postLogoutRedirectUri, String finalRedirect, Duration stateCookieTtl) { + this.endSessionFlow = Objects.requireNonNull(endSessionFlow, "endSessionFlow"); + this.revocation = Objects.requireNonNull(revocation, "revocation"); + this.endSessionEndpoint = requireNonBlank(endSessionEndpoint, "endSessionEndpoint"); + this.postLogoutRedirectUri = requireNonBlank(postLogoutRedirectUri, "postLogoutRedirectUri"); + this.finalRedirect = requireNonBlank(finalRedirect, "finalRedirect"); + this.stateCookieTtl = Objects.requireNonNull(stateCookieTtl, "stateCookieTtl"); + } + + /** + * The configured application landing after logout. The {@link de.cuioss.sheriff.gateway.bff.reserved.LogoutEndpoint} + * edge redirects an already-logged-out browser (a logout request that carries no live + * session, so there is no {@code id_token_hint} to send) straight here, bypassing the IdP round-trip. + * + * @return the {@code final_redirect} application landing URL + */ + public String finalRedirect() { + return finalRedirect; + } + + /** + * Initiates RP-initiated logout for a live session: revokes the mediated tokens (best-effort), + * mints the session-bound {@code state}, and builds the engine end-session redirect carrying the + * {@code id_token_hint}, the exact {@code post_logout_redirect_uri}, and the {@code state}. + * + * @param session the live session being logged out (its raw ID token is the {@code id_token_hint}) + * @return the redirect to the IdP {@code end_session_endpoint} carrying the logout-state {@code Set-Cookie} + */ + public LogoutRedirect initiate(SessionRecord session) { + Objects.requireNonNull(session, "session"); + // Revocation at the IdP is best-effort: any runtime failure of the revocation seam must not + // strand the browser half-logged-out, so the catch is deliberately broad. + // cui-rewrite:disable InvalidExceptionUsageRecipe + try { + revocation.revoke(session); + } catch (RuntimeException revocationFailure) { + // Revocation at the IdP is best-effort: the authoritative, immediately-effective step is the + // local session destruction the caller performs. A revocation-endpoint failure must not strand + // the browser half-logged-out, so it is logged and the logout proceeds. + LOGGER.debug(revocationFailure, "Token revocation failed during RP-initiated logout — proceeding with local logout"); + } + String state = newState(); + String location = endSessionFlow.buildLogoutRedirect(endSessionEndpoint, session.idToken(), + postLogoutRedirectUri, state); + return new LogoutRedirect(location, List.of(stateSetCookie(state))); + } + + /** + * Completes the RP-initiated logout return leg: verifies the returned {@code state} against the + * single-use logout-state cookie, clears the cookie, and redirects to {@code final_redirect}. + * + * @param stateParam the {@code state} returned by the IdP on the post-logout redirect, may be absent + * @param cookieHeader the raw request {@code Cookie} header value, may be absent + * @return the redirect to {@code final_redirect} on a matching state, or a {@code 400} on mismatch + */ + public LogoutReturn completeReturn(@Nullable String stateParam, @Nullable String cookieHeader) { + Optional cookieState = readState(cookieHeader); + if (cookieState.isEmpty()) { + LOGGER.debug("Post-logout return without a logout-state cookie — rejected"); + return LogoutReturn.error(BAD_REQUEST); + } + try { + endSessionFlow.verifyPostLogoutState(cookieState.get(), stateParam); + } catch (IllegalStateException stateMismatch) { + LOGGER.debug(stateMismatch, "Post-logout return state did not match the logout-state cookie — rejected"); + return LogoutReturn.error(BAD_REQUEST); + } + return LogoutReturn.redirect(finalRedirect, List.of(clearingStateCookie())); + } + + private String stateSetCookie(String state) { + return "%s=%s; Max-Age=%d; Path=/; Secure; HttpOnly; SameSite=Lax" + .formatted(LOGOUT_STATE_COOKIE_NAME, state, stateCookieTtl.toSeconds()); + } + + private static String clearingStateCookie() { + return LOGOUT_STATE_COOKIE_NAME + "=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax"; + } + + private static Optional readState(@Nullable String cookieHeader) { + if (cookieHeader == null || cookieHeader.isBlank()) { + return Optional.empty(); + } + for (String pair : cookieHeader.split(";")) { + String trimmed = pair.trim(); + int equals = trimmed.indexOf('='); + if (equals > 0 && LOGOUT_STATE_COOKIE_NAME.equals(trimmed.substring(0, equals))) { + String value = trimmed.substring(equals + 1); + return value.isEmpty() ? Optional.empty() : Optional.of(value); + } + } + return Optional.empty(); + } + + private static String newState() { + byte[] bytes = new byte[STATE_BYTES]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static String requireNonBlank(String value, String name) { + Objects.requireNonNull(value, name); + if (value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } + + /** + * The RFC 7009 token-revocation seam. The session runtime binds it to the engine's revocation + * call against the {@code revocation_endpoint}; a test binds it to a no-op or a failing stub. + * Revocation is best-effort — the caller's local session destruction is the authoritative logout. + * + * @author API Sheriff Team + * @since 1.0 + */ + @FunctionalInterface + public interface TokenRevocation { + + /** + * Revokes the mediated tokens held by the given session (RFC 7009). + * + * @param session the session whose access/refresh tokens are being revoked + */ + void revoke(SessionRecord session); + } + + /** + * The framework-agnostic result of a logout initiation: a {@code 302} redirect to the IdP + * {@code end_session_endpoint}, carrying the single-use logout-state {@code Set-Cookie}. + * + * @param location the IdP end-session redirect URL to send the browser to + * @param setCookieHeaders the {@code Set-Cookie} header values to emit (the logout-state cookie) + * @author API Sheriff Team + * @since 1.0 + */ + public record LogoutRedirect(String location, List setCookieHeaders) { + + /** + * Canonical constructor rejecting an absent location and defensively copying the cookies. + */ + public LogoutRedirect { + Objects.requireNonNull(location, "location"); + setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders); + } + } + + /** + * The framework-agnostic result of the logout return leg: either a {@code 302} redirect to + * {@code final_redirect} carrying the logout-state-clearing {@code Set-Cookie}, or a {@code 400} + * on a missing/mismatched state. + * + * @param status the HTTP status the edge returns + * @param location the redirect target, present only on a matching state + * @param setCookieHeaders the {@code Set-Cookie} header values to emit, empty on error + * @author API Sheriff Team + * @since 1.0 + */ + public record LogoutReturn(int status, Optional location, List setCookieHeaders) { + + /** + * Canonical constructor normalizing an absent location and defensively copying the cookies. + */ + public LogoutReturn { + location = Objects.requireNonNullElse(location, Optional.empty()); + setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders); + } + + /** + * A successful-return {@code 302} redirect to {@code final_redirect}. + * + * @param location the application landing URL + * @param setCookieHeaders the logout-state-clearing {@code Set-Cookie} + * @return the redirect outcome + */ + public static LogoutReturn redirect(String location, List setCookieHeaders) { + Objects.requireNonNull(location, "location"); + return new LogoutReturn(FOUND, Optional.of(location), setCookieHeaders); + } + + /** + * An error outcome carrying no redirect and no cookies. + * + * @param status the {@code 4xx} status + * @return the error outcome + */ + public static LogoutReturn error(int status) { + return new LogoutReturn(status, Optional.empty(), List.of()); + } + + /** + * @return {@code true} when this outcome is a successful-return redirect + */ + public boolean isRedirect() { + return status == FOUND; + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/package-info.java new file mode 100644 index 00000000..ef14d8bb --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/logout/package-info.java @@ -0,0 +1,47 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * The gateway-side logout logic for both OIDC logout channels (D5, BFF-09). + *

    + * The gateway re-implements no OAuth leg: the engine + * ({@code token-sheriff-client}) owns the {@code end_session_endpoint} redirect construction, the + * {@code id_token_hint}, and the exact-match {@code post_logout_redirect_uri} open-redirect defence. + * This package owns only the gateway-side concerns, framework-agnostically (no CDI, no JAX-RS/Vert.x + * coupling), so every class is unit-testable without a container or a live IdP: + *

      + *
    • {@link de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout} orchestrates RP-initiated + * logout — it revokes the mediated tokens (RFC 7009, best-effort), mints a session-bound + * {@code state}, carries it in the short-lived single-use {@code __Host-sheriff-logout} cookie, + * and on the return leg verifies the returned {@code state} against that cookie before landing + * the browser on {@code final_redirect}.
    • + *
    • {@link de.cuioss.sheriff.gateway.bff.logout.BackchannelLogoutReceiver} receives an OIDC + * back-channel {@code logout_token}: it signature-verifies the token through the engine's + * issuer/JWKS infrastructure, runs the claim residual, then destroys the affected sessions + * through the store's O(1) secondary index.
    • + *
    • {@link de.cuioss.sheriff.gateway.bff.logout.LogoutTokenValidator} is the pure OIDC + * back-channel-logout-token check pipeline the receiver applies after signature verification — + * the gateway-side residual the engine library does not carry.
    • + *
    + * The {@code reserved} package's {@code LogoutEndpoint} and {@code BackchannelLogoutEndpoint} own the + * request/response edge and the session store; this package holds only the transport-free logic. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.gateway.bff.logout; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/package-info.java new file mode 100644 index 00000000..a234dd04 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/package-info.java @@ -0,0 +1,36 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * The Backend-for-Frontend (BFF) surface for {@code require: session} routes and its + * cross-cutting observability vocabulary. + *

    + * The confidential-client login, session, refresh, CSRF, and logout mechanics live in the + * sub-packages ({@code bff.login}, {@code bff.session}, {@code bff.refresh}, {@code bff.csrf}, + * {@code bff.logout}, {@code bff.pending}, {@code bff.reserved}, {@code bff.runtime}). This + * root package holds only the framework-agnostic + * {@link de.cuioss.sheriff.gateway.bff.BffLogMessages} catalogue — the DSL-style + * {@link de.cuioss.tools.logging.LogRecord} vocabulary for the session lifecycle, transparent + * refresh, CSRF, and logout events those sub-packages emit. Keeping the catalogue in the + * framework-agnostic {@code cui-tools} {@code LogRecord} abstraction carries no framework + * dependency (ADR-0005). + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.gateway.bff; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/BindingCookieCodec.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/BindingCookieCodec.java new file mode 100644 index 00000000..76c3f8fa --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/BindingCookieCodec.java @@ -0,0 +1,102 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.pending; + +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +import org.jspecify.annotations.Nullable; + +/** + * Encodes and reads the short-lived browser-binding cookie that ties a + * {@link PendingAuthorizationRecord} to the browser that started the login (D2b). + *

    + * The cookie carries only the unguessable record id (server mode). It is set at redirect time + * and consulted at callback time: a callback is valid only when both the returned {@code state} + * matches and this cookie resolves to the same record, so a callback replayed in a different + * browser (which carries no binding cookie) is rejected even with a valid {@code state} — the + * pre-session analogue of the session cookie. + *

    + * The cookie is hardened by construction: the {@code __Host-} prefix (which the browser only + * honours with {@code Secure} + {@code Path=/} + no {@code Domain}), plus {@code HttpOnly} (no + * script access) and {@code SameSite=Lax} (survives the top-level IdP redirect while blocking + * cross-site sends). The codec is framework-agnostic: it produces and parses raw header values, + * so it carries no JAX-RS/Vert.x coupling and is unit-testable without a container. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class BindingCookieCodec { + + /** The {@code __Host-}-prefixed browser-binding cookie name. */ + public static final String COOKIE_NAME = "__Host-sheriff-binding"; + + private final Duration maxAge; + + /** + * Creates a codec whose set-cookie output carries the given lifetime — normally + * {@link PendingAuthorizationRecord#FIXED_TTL}, so the cookie and the record expire together. + * + * @param maxAge the cookie {@code Max-Age} + */ + public BindingCookieCodec(Duration maxAge) { + this.maxAge = Objects.requireNonNull(maxAge, "maxAge"); + } + + /** + * Builds the {@code Set-Cookie} header value binding the browser to {@code recordId}. + * + * @param recordId the pending-authorization record id + * @return the hardened {@code Set-Cookie} header value + */ + public String toSetCookieHeader(String recordId) { + Objects.requireNonNull(recordId, "recordId"); + return "%s=%s; Max-Age=%d; Path=/; Secure; HttpOnly; SameSite=Lax" + .formatted(COOKIE_NAME, recordId, maxAge.toSeconds()); + } + + /** + * Builds the {@code Set-Cookie} header value that clears the binding cookie (single-use: + * expire it once the callback has consumed the record). + * + * @return the clearing {@code Set-Cookie} header value + */ + public String toClearingSetCookieHeader() { + return COOKIE_NAME + "=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax"; + } + + /** + * Reads the binding record id out of a request {@code Cookie} header value. + * + * @param cookieHeader the raw {@code Cookie} header value (may be absent/blank) + * @return the record id when the binding cookie is present with a non-empty value; empty otherwise + */ + public Optional readRecordId(@Nullable String cookieHeader) { + if (cookieHeader == null || cookieHeader.isBlank()) { + return Optional.empty(); + } + for (String pair : cookieHeader.split(";")) { + String trimmed = pair.trim(); + int equals = trimmed.indexOf('='); + if (equals > 0 && COOKIE_NAME.equals(trimmed.substring(0, equals))) { + String value = trimmed.substring(equals + 1); + return value.isEmpty() ? Optional.empty() : Optional.of(value); + } + } + return Optional.empty(); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationRecord.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationRecord.java new file mode 100644 index 00000000..cea69b7d --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationRecord.java @@ -0,0 +1,177 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.pending; + +import java.net.URI; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Objects; + + +import de.cuioss.sheriff.token.client.flow.FlowContext; + +import lombok.Builder; +import org.jspecify.annotations.Nullable; + +/** + * The gateway-side transaction record for a browser's in-flight auth-code login (D2b). + *

    + * The engine's {@link FlowContext} is the OIDC transaction DTO — it owns the + * {@code state} (32-byte SecureRandom), {@code nonce}, PKCE verifier ({@code S256}), and + * {@code redirect_uri}, and verifies {@code state}/{@code nonce} constant-time on callback. + * What the engine deliberately does not provide — and what this record adds — is the + * gateway's job: persistence, a short fixed TTL, single-use + * enforcement (the engine's single-use is a caller contract, not enforced by the type), + * and a same-origin-validated post-login return URL. The record is therefore a thin + * wrapper that never re-invents any engine control. + *

    + * Single-use is enforced by {@link PendingAuthorizationStore} (which removes the record on + * consumption), so this record stays immutable and carries no mutable consumed flag. The + * unguessable {@link #id()} is the store key and the value carried by the browser-binding + * cookie ({@link BindingCookieCodec}); a callback is valid only when both the returned + * {@code state} matches and the binding cookie resolves to this same record. + * + * @param id the unguessable record id (store key and binding-cookie value) + * @param flowContext the engine transaction DTO owning {@code state}/{@code nonce}/PKCE + * @param returnUrl the same-origin-validated post-login redirect target + * @param createdAt the instant the record was created (TTL anchor) + * @param ttl the short fixed lifetime before the record expires + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record PendingAuthorizationRecord(String id, FlowContext flowContext, String returnUrl, Instant createdAt, +Duration ttl) { + + /** + * The short fixed lifetime a pending-authorization record lives before it expires. Fixed + * (not operator-configurable): an unauthenticated browser creates these, so the window is + * a security parameter, not a tuning knob. + */ + public static final Duration FIXED_TTL = Duration.ofMinutes(5); + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final int ID_BYTES = 32; + + /** + * Canonical constructor rejecting any absent component — every field is mandatory. + */ + public PendingAuthorizationRecord { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(flowContext, "flowContext"); + Objects.requireNonNull(returnUrl, "returnUrl"); + Objects.requireNonNull(createdAt, "createdAt"); + Objects.requireNonNull(ttl, "ttl"); + } + + /** + * Creates a record with a freshly generated unguessable id and the {@link #FIXED_TTL}. + * + * @param flowContext the engine transaction DTO + * @param returnUrl the already same-origin-validated post-login redirect target + * @param createdAt the creation instant (TTL anchor) + * @return a new pending-authorization record + */ + public static PendingAuthorizationRecord create(FlowContext flowContext, String returnUrl, Instant createdAt) { + return new PendingAuthorizationRecord(newId(), flowContext, returnUrl, createdAt, FIXED_TTL); + } + + /** + * Generates a 256-bit URL-safe unguessable record id. + * + * @return the base64url (unpadded) encoding of 32 secure-random bytes + */ + public static String newId() { + byte[] bytes = new byte[ID_BYTES]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** + * @return the instant this record expires ({@code createdAt + ttl}) + */ + public Instant expiresAt() { + return createdAt.plus(ttl); + } + + /** + * Whether this record has expired at {@code now} — expiry is inclusive of the boundary. + * + * @param now the reference instant + * @return {@code true} when {@code now} is at or after {@link #expiresAt()} + */ + public boolean isExpired(Instant now) { + return !now.isBefore(expiresAt()); + } + + /** + * Whether {@code returnUrl} is safe to redirect a browser to after login: a gateway-relative + * path ({@code /...}), or an absolute URL whose origin (scheme + host + port) matches + * {@code gatewayOrigin}. A schema-relative ({@code //host}) value, a backslash-authority + * ({@code /\host}, which browsers normalize to {@code //host}) value, a cross-origin absolute + * URL, a blank value, or an unparseable value is rejected — the post-login redirect is never + * an open redirect. + * + * @param returnUrl the candidate post-login redirect target (may be absent/blank) + * @param gatewayOrigin the gateway's own origin (e.g. the {@code redirect_uri} origin) + * @return {@code true} only when the candidate is same-origin with the gateway + */ + public static boolean sameOrigin(@Nullable String returnUrl, String gatewayOrigin) { + if (returnUrl == null || returnUrl.isBlank() || returnUrl.startsWith("//")) { + return false; + } + // Browsers normalize a backslash to a forward slash, so /\evil.com or \\evil.com can be + // coerced into a protocol-relative //evil.com open redirect. A legitimate return URL — a + // gateway-relative path or an absolute gateway URL — never carries a raw backslash, so any + // backslash is rejected outright (closes /\evil.com, /\/evil.com, \evil.com). + if (returnUrl.indexOf('\\') >= 0) { + return false; + } + if (returnUrl.startsWith("/")) { + return true; + } + try { + return sameOriginAbsolute(URI.create(returnUrl), URI.create(gatewayOrigin)); + } catch (IllegalArgumentException _) { + return false; + } + } + + private static boolean sameOriginAbsolute(URI candidate, URI reference) { + if (candidate.getScheme() == null || candidate.getHost() == null) { + return false; + } + return candidate.getScheme().equalsIgnoreCase(reference.getScheme()) + && candidate.getHost().equalsIgnoreCase(reference.getHost()) + && effectivePort(candidate) == effectivePort(reference); + } + + private static int effectivePort(URI uri) { + if (uri.getPort() != -1) { + return uri.getPort(); + } + String scheme = uri.getScheme(); + if ("https".equalsIgnoreCase(scheme)) { + return 443; + } + if ("http".equalsIgnoreCase(scheme)) { + return 80; + } + return -1; + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStore.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStore.java new file mode 100644 index 00000000..f0b57c30 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStore.java @@ -0,0 +1,123 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.pending; + +import java.time.Instant; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * The storage seam for {@link PendingAuthorizationRecord}s (D2b). + *

    + * The seam is deliberately pluggable: server mode stores the record here and carries only the + * record id in the browser-binding cookie ({@link #InMemory}), whereas the cookie variant + * (PLAN-08) can supply an implementation where the sealed record travels in the cookie itself + * and no server map exists. The two operations are the whole contract — persist a record, and + * consume it exactly once. + *

    + * Single-use is a store invariant, not a caller contract. {@link #consume} + * removes the record as it returns it, so a second consumption of the same id resolves to + * empty even if the record has not expired — this is the enforcement the engine's + * {@link de.cuioss.sheriff.token.client.flow.FlowContext} deliberately leaves open. + * + * @author API Sheriff Team + * @since 1.0 + */ +public interface PendingAuthorizationStore { + + /** + * Persists a pending-authorization record under its {@link PendingAuthorizationRecord#id()}. + * + * @param pending the record to store + */ + void store(PendingAuthorizationRecord pending); + + /** + * Atomically resolves and removes the record for {@code recordId} (single-use). Returns + * empty when no record is stored under that id, when it has already been consumed, or when + * it has expired at {@code now}. + * + * @param recordId the record id (from the browser-binding cookie) + * @param now the reference instant for the TTL check + * @return the live record, removed from the store; empty when absent/consumed/expired + */ + Optional consume(String recordId, Instant now); + + /** + * The bounded, single-node in-memory store — the only implementation for server mode. + *

    + * Backed by an insertion-ordered map with a hard capacity: because unauthenticated browsers + * create these records, an unbounded map would be a denial-of-service amplifier, so the + * oldest record is evicted once the capacity is exceeded (a DoS guard, not a correctness + * feature — a legitimately in-flight record is never evicted before the bound is reached). + * TTL expiry is enforced lazily on {@link #consume}. Every operation is guarded by the + * instance monitor, so concurrent redirect/callback requests never corrupt the map. + */ + final class InMemory implements PendingAuthorizationStore { + + private final int maxEntries; + private final Map records = new LinkedHashMap<>(); + + /** + * Creates a store bounded to {@code maxEntries} live records. + * + * @param maxEntries the hard capacity (DoS guard); must be positive + * @throws IllegalArgumentException when {@code maxEntries} is not positive + */ + public InMemory(int maxEntries) { + if (maxEntries <= 0) { + throw new IllegalArgumentException("maxEntries must be positive, but was " + maxEntries); + } + this.maxEntries = maxEntries; + } + + @Override + public synchronized void store(PendingAuthorizationRecord pending) { + Objects.requireNonNull(pending, "pending"); + records.put(pending.id(), pending); + evictOldestBeyondCapacity(); + } + + @Override + public synchronized Optional consume(String recordId, Instant now) { + Objects.requireNonNull(recordId, "recordId"); + Objects.requireNonNull(now, "now"); + PendingAuthorizationRecord pending = records.remove(recordId); + if (pending == null || pending.isExpired(now)) { + return Optional.empty(); + } + return Optional.of(pending); + } + + /** + * @return the current number of stored records + */ + public synchronized int size() { + return records.size(); + } + + private void evictOldestBeyondCapacity() { + Iterator oldestFirst = records.keySet().iterator(); + while (records.size() > maxEntries && oldestFirst.hasNext()) { + oldestFirst.next(); + oldestFirst.remove(); + } + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/package-info.java new file mode 100644 index 00000000..3abf2679 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/pending/package-info.java @@ -0,0 +1,43 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Transient pre-login state for the BFF auth-code flow (D2b). + *

    + * Between the redirect to the IdP and the callback — before any session exists — the gateway + * must hold the OIDC transaction and tie it to the browser that started it. This package owns + * that concern without re-inventing any engine control: + *

      + *
    • {@link de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord} wraps the + * engine's {@code FlowContext} (which owns {@code state}/{@code nonce}/PKCE/{@code + * redirect_uri}) and adds the gateway-only fields the engine leaves open: a short fixed + * TTL, single-use semantics, and a same-origin-validated post-login return URL.
    • + *
    • {@link de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationStore} is the pluggable + * storage seam — a bounded, single-use, TTL-expiring in-memory map for server mode, with + * room for the PLAN-08 cookie-sealed variant.
    • + *
    • {@link de.cuioss.sheriff.gateway.bff.pending.BindingCookieCodec} sets and reads the + * short-lived {@code __Host-} browser-binding cookie carrying the record id, so a callback + * replayed in a different browser is rejected even with a valid {@code state}.
    • + *
    + * The classes are framework-agnostic (no CDI, no JAX-RS/Vert.x coupling); the reserved-endpoint + * and login-flow packages wire them to the request/response edge. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.gateway.bff.pending; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/StepUpCoordinator.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/StepUpCoordinator.java new file mode 100644 index 00000000..f9df2386 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/StepUpCoordinator.java @@ -0,0 +1,272 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.refresh; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + + +import de.cuioss.sheriff.gateway.bff.pending.BindingCookieCodec; +import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord; +import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationStore; +import de.cuioss.sheriff.gateway.bff.session.SessionRecord; +import de.cuioss.sheriff.token.client.flow.StepUpChallengeParser; +import de.cuioss.sheriff.token.client.flow.StepUpChallengeParser.StepUpChallenge; +import de.cuioss.sheriff.token.client.flow.StepUpHandler; +import de.cuioss.tools.logging.CuiLogger; + +import org.jspecify.annotations.Nullable; + +/** + * RFC 9470 step-up orchestration for a {@code require: session} route (D7) — the gateway-side + * handling of an upstream {@code insufficient_user_authentication} challenge. + *

    + * When a mediated upstream answers {@code 401} with a + * {@code WWW-Authenticate: Bearer error="insufficient_user_authentication"} challenge (RFC 9470), + * the gateway must obtain a token that satisfies the demanded authentication context + * ({@code acr_values} / {@code max_age}) and replay the original request. The gateway re-implements + * no OAuth leg: the engine ({@code token-sheriff-client}) owns the RFC 9470 + * challenge grammar ({@link StepUpChallengeParser}) and the step-up authorization-request + * construction ({@link StepUpHandler} — carrying the elevated {@code acr_values} / {@code max_age} + * into the auth-code flow). This coordinator only orchestrates the gateway-side concerns. + *

    + * The two-step orchestration: + *

      + *
    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 = replayUrl != null + && PendingAuthorizationRecord.sameOrigin(replayUrl, gatewayOrigin) + ? replayUrl : DEFAULT_RETURN_URL; + PendingAuthorizationRecord pending = PendingAuthorizationRecord.create(request.context(), returnUrl, now); + pendingStore.store(pending); + LOGGER.debug("Step-up challenge requires re-authentication — re-driving the auth-code flow"); + List setCookies = List.of(bindingCookieCodec.toSetCookieHeader(pending.id())); + return StepUpOutcome.reDrive(request.authorizationUrl(), setCookies); + } + + /** + * The silent-step-up seam. The session runtime binds it to the engine's attempt to elevate the + * authentication context without a user-interactive redirect (the current session may already + * satisfy the demanded {@code acr}, or the IdP may elevate silently); a test binds a fixed + * present/absent result. An empty result means a full user-interactive re-drive is required. + * + * @author API Sheriff Team + * @since 1.0 + */ + @FunctionalInterface + public interface SilentSatisfaction { + + /** + * Attempts to satisfy the step-up challenge without a browser redirect. + * + * @param session the live session being elevated + * @param challenge the RFC 9470 challenge demanding a stronger authentication context + * @param now the reference instant + * @return the elevated session when satisfied silently, empty when a re-drive is required + */ + Optional attempt(SessionRecord session, StepUpChallenge challenge, Instant now); + } + + /** + * The engine step-up authorization seam. The session runtime binds it to the engine as + * {@code challenge -> stepUpHandler.initiate(clientConfiguration, providerMetadata, challenge)}; + * a test binds a hand-built request. The engine carries the challenge's {@code acr_values} / + * {@code max_age} into the authorization request; keeping the confidential-client wiring behind + * the seam decouples the coordinator from it. + * + * @author API Sheriff Team + * @since 1.0 + */ + @FunctionalInterface + public interface StepUpInitiation { + + /** + * Builds the step-up authorization request (URL + transaction context) for the challenge. + * + * @param challenge the RFC 9470 challenge whose elevated parameters drive the request + * @return the engine step-up request (authorization URL + transaction {@code FlowContext}) + */ + StepUpHandler.StepUpRequest initiate(StepUpChallenge challenge); + } + + /** + * The framework-agnostic result of step-up coordination: either the challenge was + * {@link Kind#SATISFIED} silently (replay the original request with the elevated session) or the + * browser must be {@link Kind#RE_DRIVE re-driven} through the auth-code flow carrying the + * browser-binding {@code Set-Cookie}. Token material never appears here. + * + * @param kind which of the two step-up outcomes occurred + * @param session the elevated session, present only for {@link Kind#SATISFIED} + * @param location the step-up redirect target, present only for {@link Kind#RE_DRIVE} + * @param setCookieHeaders the browser-binding {@code Set-Cookie} header values, empty when satisfied + * @author API Sheriff Team + * @since 1.0 + */ + public record StepUpOutcome(Kind kind, Optional session, Optional location, + List setCookieHeaders) { + + /** + * The two terminal states of a step-up coordination. + * + * @author API Sheriff Team + * @since 1.0 + */ + public enum Kind { + /** The challenge was satisfied silently — the original request is replayed. */ + SATISFIED, + /** The browser is re-driven through the auth-code flow with the elevated parameters. */ + RE_DRIVE + } + + /** + * Canonical constructor normalizing absent optionals and defensively copying the cookies. + */ + public StepUpOutcome { + Objects.requireNonNull(kind, "kind"); + session = Objects.requireNonNullElse(session, Optional.empty()); + location = Objects.requireNonNullElse(location, Optional.empty()); + setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders); + } + + /** + * A silently-satisfied outcome carrying the elevated session for the replay. + * + * @param session the session whose authentication context now satisfies the challenge + * @return the satisfied outcome + */ + public static StepUpOutcome satisfied(SessionRecord session) { + Objects.requireNonNull(session, "session"); + return new StepUpOutcome(Kind.SATISFIED, Optional.of(session), Optional.empty(), List.of()); + } + + /** + * A re-drive outcome carrying the step-up redirect and the browser-binding {@code Set-Cookie}. + * + * @param location the step-up authorization URL to redirect the browser to + * @param setCookieHeaders the browser-binding {@code Set-Cookie} header values + * @return the re-drive outcome + */ + public static StepUpOutcome reDrive(String location, List setCookieHeaders) { + Objects.requireNonNull(location, "location"); + return new StepUpOutcome(Kind.RE_DRIVE, Optional.empty(), Optional.of(location), setCookieHeaders); + } + + /** + * @return {@code true} when the challenge was satisfied silently (the request is replayed) + */ + public boolean isSatisfied() { + return kind == Kind.SATISFIED; + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java new file mode 100644 index 00000000..c878f009 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinator.java @@ -0,0 +1,307 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.refresh; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + + +import de.cuioss.sheriff.gateway.bff.session.SessionRecord; +import de.cuioss.sheriff.gateway.bff.session.SessionStore; +import de.cuioss.sheriff.token.client.token.RotationResult; +import de.cuioss.sheriff.token.commons.error.TokenSheriffException; +import de.cuioss.tools.logging.CuiLogger; + +/** + * Transparent, single-flight token refresh for a {@code require: session} route (D7/D9) — the + * refresh coordinator bound to the {@code SessionAuthenticationStage} refresh seam (the D9 hook). + *

    + * When the mediated access token is within {@code leeway} of expiry the coordinator refreshes it + * through the engine ({@code token-sheriff-client}'s {@code RefreshFlow}, reached + * via the {@link RefreshExchange} seam) and persists the rotated token material to the session + * store, so the browser never sees a token and never drives a leg. The gateway re-implements + * no OAuth leg: the engine owns the refresh grant, refresh-token rotation, and — + * inside its own refresh-token-family primitive — the reuse detection that revokes a + * family when a superseded refresh token is replayed (a stolen-token signal, RFC 9700). The engine + * surfaces that as a {@link TokenSheriffException}; the coordinator's residual is to + * destroy the session on any refresh failure so a revoked family can never be mediated + * from again. + *

    + * Single-flight per session. Concurrent requests on one session share one + * refresh: the first request to observe near-expiry becomes the in-flight leader (registered + * atomically in {@link #inFlight} — the check-then-act TOCTOU window is closed by the map's + * atomic {@code putIfAbsent}, the per-session mutual-exclusion primitive); every concurrent + * request on the same session joins the leader's result instead of launching its own refresh. The + * leader re-resolves the session from the store under this exclusion and re-checks near-expiry, so + * a request that arrives just after a refresh completed observes the already-rotated token and + * makes no engine call. + *

    + * On-failure semantics. A {@link RefreshOutcome#failed() failed} outcome means the + * session has already been destroyed and the caller MUST treat the request as + * unauthenticated, running the same content negotiation as {@code require: session}: a + * navigation request (its {@code Accept} offers {@code text/html}) is re-driven through login, + * anything else gets {@code 401 application/problem+json}. This class is framework-agnostic — it + * carries no request/response coupling and is unit-testable without a container or a live IdP; the + * session runtime performs the negotiation and binds the engine seams. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class TokenRefreshCoordinator { + + private static final CuiLogger LOGGER = new CuiLogger(TokenRefreshCoordinator.class); + + private final Duration leeway; + private final AccessTokenExpiry accessTokenExpiry; + private final RefreshExchange refreshExchange; + private final SessionStore sessionStore; + private final ConcurrentMap> inFlight = new ConcurrentHashMap<>(); + + /** + * Assembles the coordinator with the refresh leeway, the engine seams, and the session store. + * + * @param leeway how long before access-token expiry a refresh is triggered + * ({@code session.refresh.leeway_seconds}) + * @param accessTokenExpiry the mediated-access-token expiry seam (bound to the engine token + * parsing; a test binds a fixed instant) + * @param refreshExchange the engine refresh seam (bound to {@code RefreshFlow#refresh}; a test + * binds a stubbed rotation or a throwing stub) + * @param sessionStore the server-side session store the rotated record is persisted to and + * the failed session is destroyed from + */ + public TokenRefreshCoordinator(Duration leeway, AccessTokenExpiry accessTokenExpiry, + RefreshExchange refreshExchange, SessionStore sessionStore) { + this.leeway = Objects.requireNonNull(leeway, "leeway"); + this.accessTokenExpiry = Objects.requireNonNull(accessTokenExpiry, "accessTokenExpiry"); + this.refreshExchange = Objects.requireNonNull(refreshExchange, "refreshExchange"); + this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore"); + } + + /** + * Returns the session to mediate from, refreshing its mediated token when within {@code leeway} + * of expiry. Concurrent calls for the same session share one refresh. + * + * @param session the resolved live session + * @param now the reference instant + * @return {@link RefreshOutcome#current(SessionRecord) current} when no refresh was needed, + * {@link RefreshOutcome#refreshed(SessionRecord) refreshed} carrying the rotated + * session, or {@link RefreshOutcome#failed() failed} when the session was destroyed + */ + public RefreshOutcome refresh(SessionRecord session, Instant now) { + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(now, "now"); + if (session.refreshToken().isEmpty() || !nearExpiry(session, now)) { + return RefreshOutcome.current(session); + } + String sessionId = session.sessionId(); + CompletableFuture leader = new CompletableFuture<>(); + CompletableFuture existing = inFlight.putIfAbsent(sessionId, leader); + if (existing != null) { + // A concurrent request already leads the refresh for this session — share its result. + return existing.join(); + } + try { + RefreshOutcome outcome = performRefresh(sessionId, now); + leader.complete(outcome); + return outcome; + } finally { + // Guarantee coalesced waiters never hang: a no-op when the leader already completed + // above, but a deterministic FAILED when performRefresh threw before completing it (the + // unexpected exception still propagates out of this method for the leader's own caller). + leader.complete(RefreshOutcome.failed()); + inFlight.remove(sessionId, leader); + } + } + + private RefreshOutcome performRefresh(String sessionId, Instant now) { + Optional resolved = sessionStore.resolve(sessionId, now); + if (resolved.isEmpty()) { + // Destroyed or expired between the near-expiry check and acquiring the lead — unauthenticated. + return RefreshOutcome.failed(); + } + SessionRecord latest = resolved.get(); + Optional refreshToken = latest.refreshToken(); + if (refreshToken.isEmpty() || !nearExpiry(latest, now)) { + // A coalesced leader already rotated this session — share the current token, no engine call. + return RefreshOutcome.current(latest); + } + String presentedRefreshToken = refreshToken.get(); + try { + RotationResult rotation = refreshExchange.exchange(presentedRefreshToken); + SessionRecord rotated = rotate(latest, rotation); + // create() upserts by session id (SessionStore contract; InMemorySessionStore is a keyed + // map put), so no pre-create destroy is needed on the success path. Destroying first would + // open a window where a concurrent resolve() misses the rotating session. + sessionStore.create(rotated); + LOGGER.debug("Refreshed the mediated tokens for a require:session route (single-flight)"); + return RefreshOutcome.refreshed(rotated); + } catch (TokenSheriffException refreshFailure) { + // Engine failure OR refresh-token reuse (the engine revoked the family) — the session can no + // longer be sustained. Destroy it so the caller re-drives login / returns 401. + sessionStore.destroyById(sessionId); + LOGGER.debug(refreshFailure, + "Token refresh failed (IdP rejection or refresh-token reuse) — session destroyed"); + return RefreshOutcome.failed(); + } + } + + private boolean nearExpiry(SessionRecord session, Instant now) { + Instant expiry = accessTokenExpiry.expiryOf(session); + return !now.isBefore(expiry.minus(leeway)); + } + + private static SessionRecord rotate(SessionRecord previous, RotationResult rotation) { + String rotatedIdToken = rotation.idToken(); + return SessionRecord.builder() + .sessionId(previous.sessionId()) + .accessToken(rotation.accessToken().getRawToken()) + .refreshToken(Optional.of(rotation.refreshToken())) + .idToken(rotatedIdToken == null || rotatedIdToken.isBlank() ? previous.idToken() : rotatedIdToken) + .sub(previous.sub()) + .sid(previous.sid()) + .expiresAt(previous.expiresAt()) + .acr(previous.acr()) + .authTime(previous.authTime()) + .build(); + } + + /** + * The mediated-access-token expiry seam. The session runtime binds it to the engine token + * parsing (the access token's {@code exp}); a test binds a fixed instant. Keeping the token + * parsing behind the seam decouples the coordinator from the engine and keeps it unit-testable, + * and lets the near-expiry decision use the access-token lifetime rather than the absolute + * session cap ({@link SessionRecord#expiresAt()}). + * + * @author API Sheriff Team + * @since 1.0 + */ + @FunctionalInterface + public interface AccessTokenExpiry { + + /** + * @param session the live session whose mediated access token is inspected + * @return the instant the session's mediated access token expires + */ + Instant expiryOf(SessionRecord session); + } + + /** + * The engine refresh seam. The session runtime binds it to the engine as + * {@code refreshToken -> refreshFlow.refresh(providerMetadata, refreshToken)}; a test binds a + * stubbed rotation or a throwing stub. The engine owns the refresh grant, refresh-token + * rotation, and reuse detection; keeping the confidential-client wiring (provider metadata, + * client authentication) behind the seam decouples the coordinator from it. + * + * @author API Sheriff Team + * @since 1.0 + */ + @FunctionalInterface + public interface RefreshExchange { + + /** + * Refreshes the mediated tokens using the current refresh token. + * + * @param refreshToken the session's current refresh token + * @return the engine rotation result (new access/refresh/ID token + access-token lifetime) + * @throws de.cuioss.sheriff.token.commons.error.TokenSheriffException when the refresh fails + * (IdP rejection) or the engine detects refresh-token reuse and revokes the family + */ + RotationResult exchange(String refreshToken); + } + + /** + * The framework-agnostic result of a refresh attempt. Token material never crosses to the + * browser — the rotated {@link SessionRecord} stays server-side and only its opaque session + * cookie is carried. + * + * @param kind which of the three refresh outcomes occurred + * @param session the session to mediate from, present for {@link Kind#CURRENT} and + * {@link Kind#REFRESHED}, empty for {@link Kind#FAILED} + * @author API Sheriff Team + * @since 1.0 + */ + public record RefreshOutcome(Kind kind, Optional session) { + + /** + * The three terminal states of a refresh attempt. + * + * @author API Sheriff Team + * @since 1.0 + */ + public enum Kind { + /** No refresh was needed — the mediated token is not yet within {@code leeway} of expiry. */ + CURRENT, + /** The mediated token was rotated through the engine and persisted. */ + REFRESHED, + /** The refresh failed and the session was destroyed; the caller treats it as unauthenticated. */ + FAILED + } + + /** + * Canonical constructor normalizing an absent optional and enforcing the presence contract. + */ + public RefreshOutcome { + Objects.requireNonNull(kind, "kind"); + session = Objects.requireNonNullElse(session, Optional.empty()); + if (kind != Kind.FAILED && session.isEmpty()) { + throw new IllegalArgumentException("a " + kind + " outcome must carry a session"); + } + } + + /** + * A no-refresh-needed outcome carrying the unchanged session. + * + * @param session the live session, unchanged + * @return the current outcome + */ + public static RefreshOutcome current(SessionRecord session) { + return new RefreshOutcome(Kind.CURRENT, Optional.of(session)); + } + + /** + * A successful-refresh outcome carrying the rotated session. + * + * @param session the session with the rotated token material + * @return the refreshed outcome + */ + public static RefreshOutcome refreshed(SessionRecord session) { + return new RefreshOutcome(Kind.REFRESHED, Optional.of(session)); + } + + /** + * A failed-refresh outcome carrying no session — the session has been destroyed and the + * caller must treat the request as unauthenticated (navigation re-driven through login, any + * other request answered {@code 401 application/problem+json}). + * + * @return the failed outcome + */ + public static RefreshOutcome failed() { + return new RefreshOutcome(Kind.FAILED, Optional.empty()); + } + + /** + * @return {@code true} when the refresh failed and the session was destroyed + */ + public boolean isFailure() { + return kind == Kind.FAILED; + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/package-info.java new file mode 100644 index 00000000..f9164a1b --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/refresh/package-info.java @@ -0,0 +1,49 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Transparent token refresh and RFC 9470 step-up orchestration for {@code require: session} + * routes (D7). + *

    + * The gateway re-implements no OAuth leg: the engine + * ({@code token-sheriff-client}) owns the refresh grant with refresh-token rotation and reuse + * detection, and owns the RFC 9470 challenge grammar and the step-up authorization-request + * construction. This package holds only the gateway-side orchestration, framework-agnostically (no + * CDI, no JAX-RS/Vert.x coupling), so every class is unit-testable without a container or a live + * IdP: + *

      + *
    • {@link de.cuioss.sheriff.gateway.bff.refresh.TokenRefreshCoordinator} refreshes the mediated + * token within its expiry leeway through the engine, single-flighted per session so + * concurrent requests on one session share one refresh; a refresh failure (IdP rejection or + * engine-detected refresh-token reuse revoking the family) destroys the session so the caller + * treats the request as unauthenticated (navigation re-driven through login, any other request + * answered {@code 401 application/problem+json}).
    • + *
    • {@link de.cuioss.sheriff.gateway.bff.refresh.StepUpCoordinator} parses an upstream + * {@code insufficient_user_authentication} challenge, attempts silent satisfaction, and + * otherwise re-drives the auth-code flow with the challenge's elevated {@code acr_values} / + * {@code max_age} — persisting the re-drive transaction as a single-use pending-authorization + * record with a same-origin-validated replay target and a browser-binding cookie.
    • + *
    + * Both coordinators reach the engine through functional-interface seams and return framework-agnostic + * outcome records; the session runtime binds the seams (confidential-client wiring) and performs the + * request/response edge (the content negotiation, the redirect, the request replay). + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.gateway.bff.refresh; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpoint.java new file mode 100644 index 00000000..e411baff --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpoint.java @@ -0,0 +1,166 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.reserved; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + + +import de.cuioss.sheriff.gateway.bff.logout.BackchannelLogoutReceiver; +import de.cuioss.tools.logging.CuiLogger; + +import org.jspecify.annotations.Nullable; + +/** + * The OIDC back-channel logout endpoint ({@code oidc.logout.backchannel_path}) — the request/response + * edge over {@link BackchannelLogoutReceiver} (D2c, BFF-09). It owns the reserved + * {@link ReservedPathRegistry.ReservedEndpoint#BACKCHANNEL_LOGOUT} path; the signature verification, + * the claim residual, and the secondary-index session destruction live in the receiver. + *

    + * The OpenID Connect + * Back-Channel Logout request is an {@code application/x-www-form-urlencoded} {@code POST} carrying + * a single {@code logout_token} parameter. The endpoint extracts that parameter from the raw + * form body (URL-decoding the value) and hands it to {@link BackchannelLogoutReceiver#receive}: an + * accepted token yields {@code 200} (with the destroyed-session count for observability), a + * missing/absent {@code logout_token} or a signature/claim rejection yields {@code 400}, and nothing + * is destroyed on rejection. Per the spec both the success and error responses must be served + * uncacheable ({@code Cache-Control: no-store}); the framework edge renders that header. + *

    + * The endpoint is framework-agnostic (raw form body in, a {@link BackchannelLogoutOutcome} the edge + * renders out — no JAX-RS/Vert.x coupling), so it is unit-testable without a container or a live IdP; + * the session runtime wires it to the request/response edge. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class BackchannelLogoutEndpoint { + + private static final CuiLogger LOGGER = new CuiLogger(BackchannelLogoutEndpoint.class); + + /** The single form parameter the OIDC back-channel logout request carries. */ + public static final String LOGOUT_TOKEN_PARAM = "logout_token"; + + private static final int OK = 200; + private static final int BAD_REQUEST = 400; + + private final BackchannelLogoutReceiver receiver; + + /** + * Assembles the endpoint with the back-channel logout receiver. + * + * @param receiver the transport-free back-channel logout receiver (verify, validate, destroy) + */ + public BackchannelLogoutEndpoint(BackchannelLogoutReceiver receiver) { + this.receiver = Objects.requireNonNull(receiver, "receiver"); + } + + /** + * Handles one back-channel logout request: extracts the {@code logout_token} from the raw form + * body and drives the receiver. + * + * @param rawFormBody the raw {@code application/x-www-form-urlencoded} request body, may be absent + * @param now the reference instant for the {@code iat} freshness check + * @return {@code 200} carrying the destroyed-session count on an accepted token, or {@code 400} + * when {@code logout_token} is absent or the token is rejected + */ + public BackchannelLogoutOutcome receive(@Nullable String rawFormBody, Instant now) { + Objects.requireNonNull(now, "now"); + + Optional logoutToken = extractLogoutToken(rawFormBody); + if (logoutToken.isEmpty()) { + LOGGER.debug("Back-channel logout request missing the logout_token form parameter — rejected"); + return BackchannelLogoutOutcome.error(BAD_REQUEST); + } + + BackchannelLogoutReceiver.BackchannelResult result = receiver.receive(logoutToken.get(), now); + if (!result.accepted()) { + return BackchannelLogoutOutcome.error(BAD_REQUEST); + } + return BackchannelLogoutOutcome.accepted(result.destroyed()); + } + + private static Optional extractLogoutToken(@Nullable String rawFormBody) { + if (rawFormBody == null || rawFormBody.isBlank()) { + return Optional.empty(); + } + for (String pair : rawFormBody.split("&")) { + int equals = pair.indexOf('='); + if (equals <= 0) { + continue; + } + Optional name = decode(pair.substring(0, equals)); + if (name.isEmpty() || !LOGOUT_TOKEN_PARAM.equals(name.get())) { + continue; + } + return decode(pair.substring(equals + 1)).filter(value -> !value.isBlank()); + } + return Optional.empty(); + } + + private static Optional decode(String value) { + // Malformed percent-encoding (URLDecoder.decode throws IllegalArgumentException) is treated as + // an absent parameter so the endpoint fails closed to 400 rather than surfacing a 500. + try { + return Optional.of(URLDecoder.decode(value, StandardCharsets.UTF_8)); + } catch (IllegalArgumentException malformed) { + LOGGER.debug(malformed, "Back-channel logout form value carried malformed percent-encoding — treated as absent"); + return Optional.empty(); + } + } + + /** + * The framework-agnostic result of a back-channel logout: the HTTP status the edge returns and, + * on acceptance, how many server-side sessions were destroyed. A rejected outcome destroys nothing. + * Both outcomes must be served uncacheable ({@code Cache-Control: no-store}) by the edge. + * + * @param status the HTTP status the edge returns ({@code 200} accepted, {@code 400} rejected) + * @param destroyed the number of sessions destroyed, always {@code 0} for a rejected outcome + * @author API Sheriff Team + * @since 1.0 + */ + public record BackchannelLogoutOutcome(int status, int destroyed) { + + /** + * An accepted {@code 200} outcome carrying the destroyed-session count. + * + * @param destroyed the number of sessions destroyed + * @return the accepted outcome + */ + public static BackchannelLogoutOutcome accepted(int destroyed) { + return new BackchannelLogoutOutcome(OK, destroyed); + } + + /** + * An error outcome carrying the given status and destroying nothing. + * + * @param status the {@code 4xx} status + * @return the error outcome + */ + public static BackchannelLogoutOutcome error(int status) { + return new BackchannelLogoutOutcome(status, 0); + } + + /** + * @return {@code true} when the back-channel logout token was accepted + */ + public boolean isAccepted() { + return status == OK; + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java new file mode 100644 index 00000000..9f702c11 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java @@ -0,0 +1,322 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.reserved; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + + +import de.cuioss.sheriff.gateway.bff.pending.BindingCookieCodec; +import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord; +import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationStore; +import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec; +import de.cuioss.sheriff.gateway.bff.session.SessionRecord; +import de.cuioss.sheriff.gateway.bff.session.SessionStore; +import de.cuioss.sheriff.token.client.flow.AuthorizationCodeFlow; +import de.cuioss.sheriff.token.client.flow.CallbackParameters; +import de.cuioss.sheriff.token.client.flow.FlowContext; +import de.cuioss.sheriff.token.commons.error.TokenSheriffException; +import de.cuioss.sheriff.token.validation.domain.claim.ClaimValue; +import de.cuioss.sheriff.token.validation.domain.token.AccessTokenContent; +import de.cuioss.sheriff.token.validation.domain.token.IdTokenContent; +import de.cuioss.sheriff.token.validation.domain.token.TokenContent; +import de.cuioss.tools.logging.CuiLogger; + +import org.jspecify.annotations.Nullable; + +/** + * The OIDC auth-code callback endpoint ({@code oidc.redirect_uri}) — the browser-facing landing + * of the code flow (D2/D2b/D2c). Framework-agnostic by construction (it consumes a raw parameter + * string and a raw {@code Cookie} header and returns a {@link CallbackOutcome} the edge renders), + * so it carries no JAX-RS/Vert.x coupling and is unit-testable without a container. + *

    + * form_post callback. The engine drives the authorization request with + * {@code response_mode=form_post}, so after a successful login Keycloak returns a 200 auto-submit + * form that POSTs the {@code code}/{@code state} to the {@code redirect_uri} as an + * {@code application/x-www-form-urlencoded} body — not a 302 with the code in the query. The edge + * therefore hands this endpoint the raw form body for a POST callback and the raw query for a GET + * callback; both are the same urlencoded parameter shape, so a single {@code parse} handles either. + *

    + * Callback HPP defence (BFF-13). The endpoint parses the raw parameter + * string with {@link CallbackParameters#parse(String)} — never {@link CallbackParameters#of(java.util.Map)}: + * only the raw parse lets the engine re-detect a duplicated {@code code}/{@code state} (the + * Keycloak CVE-2026-9689 class), which a collapsed map cannot. A form_post body is parsed by the + * same {@code parse}, so the duplicate-parameter defence holds identically for the POST body. + *

    + * Browser binding (D2b). The pending-authorization record is resolved by the + * unguessable id carried in the {@link BindingCookieCodec browser-binding cookie}, not by the + * returned {@code state} alone: a callback replayed in a different browser (which carries no + * binding cookie) is rejected {@code 403} even with a valid {@code state}. The returned + * {@code state} must additionally match the resolved record's {@code state} (constant-time), so a + * callback is honoured only when both the binding cookie resolves the record and + * the {@code state} matches — the pre-session analogue of the session cookie. + *

    + * Engine-driven exchange. The endpoint hands the record's engine + * {@code FlowContext} and the parsed parameters to the {@link CodeExchange} seam — the session + * runtime binds it to {@link AuthorizationCodeFlow#exchange}, so the engine owns the code + * exchange, PKCE, and {@code state}/{@code nonce}/{@code iss} validation (fail-closed). The seam + * keeps the endpoint decoupled from the confidential-client wiring (discovery metadata, client + * authentication) and unit-testable without a live token endpoint. On success the endpoint creates + * the server-side {@link SessionRecord}, sets the opaque {@code __Host-} session cookie, clears the + * now-consumed binding cookie (single-use), and redirects the browser to the record's + * same-origin-validated return URL. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class CallbackEndpoint { + + private static final CuiLogger LOGGER = new CuiLogger(CallbackEndpoint.class); + + private static final int BAD_REQUEST = 400; + private static final int FORBIDDEN = 403; + private static final int FOUND = 302; + + private static final String CLAIM_SID = "sid"; + private static final String CLAIM_ACR = "acr"; + private static final String CLAIM_AUTH_TIME = "auth_time"; + + private final CodeExchange codeExchange; + private final PendingAuthorizationStore pendingStore; + private final BindingCookieCodec bindingCookieCodec; + private final SessionStore sessionStore; + private final SessionCookieCodec sessionCookieCodec; + private final Duration sessionTtl; + + /** + * Assembles the callback endpoint with the exchange seam and the gateway-side stores it drives. + * + * @param codeExchange the engine code-exchange seam (bound to {@link AuthorizationCodeFlow#exchange}) + * @param pendingStore the single-use pending-authorization store + * @param bindingCookieCodec the browser-binding cookie codec + * @param sessionStore the server-side session store + * @param sessionCookieCodec the opaque session-cookie codec + * @param sessionTtl the absolute session lifetime from login + */ + public CallbackEndpoint(CodeExchange codeExchange, PendingAuthorizationStore pendingStore, + BindingCookieCodec bindingCookieCodec, SessionStore sessionStore, SessionCookieCodec sessionCookieCodec, + Duration sessionTtl) { + this.codeExchange = Objects.requireNonNull(codeExchange, "codeExchange"); + this.pendingStore = Objects.requireNonNull(pendingStore, "pendingStore"); + this.bindingCookieCodec = Objects.requireNonNull(bindingCookieCodec, "bindingCookieCodec"); + this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore"); + this.sessionCookieCodec = Objects.requireNonNull(sessionCookieCodec, "sessionCookieCodec"); + this.sessionTtl = Objects.requireNonNull(sessionTtl, "sessionTtl"); + } + + /** + * Handles one OIDC callback: validates the binding, drives the engine exchange, creates the + * session, and returns the browser response. + * + * @param rawParameters the raw callback parameter string — the query for a GET callback or the + * {@code application/x-www-form-urlencoded} body for a {@code response_mode=form_post} + * POST callback (never map-collapsed — BFF-13) + * @param cookieHeader the raw request {@code Cookie} header value, may be absent + * @param now the reference instant (TTL anchor for pending resolution and session expiry) + * @return the redirect outcome on success, or a {@code 400}/{@code 403} error outcome + */ + public CallbackOutcome handle(String rawParameters, @Nullable String cookieHeader, Instant now) { + Objects.requireNonNull(rawParameters, "rawParameters"); + Objects.requireNonNull(now, "now"); + + CallbackParameters params; + try { + params = CallbackParameters.parse(rawParameters); + } catch (IllegalArgumentException duplicateInjection) { + // BFF-13: parse() rejects a duplicated code/state (RFC 9700 §4.7.3 parameter injection — + // the Keycloak CVE-2026-9689 class). Only the raw parse can detect this; of(Map) cannot. + LOGGER.debug(duplicateInjection, "OIDC callback rejected — duplicate query parameter (BFF-13)"); + return CallbackOutcome.error(BAD_REQUEST); + } + if (params.hasError()) { + LOGGER.debug("OIDC callback carried an IdP error response: %s", params.error()); + return CallbackOutcome.error(BAD_REQUEST); + } + if (isBlank(params.state())) { + LOGGER.debug("OIDC callback missing state parameter"); + return CallbackOutcome.error(BAD_REQUEST); + } + + Optional recordId = bindingCookieCodec.readRecordId(cookieHeader); + if (recordId.isEmpty()) { + LOGGER.debug("OIDC callback without a browser-binding cookie — rejected"); + return CallbackOutcome.error(FORBIDDEN); + } + + Optional resolved = pendingStore.consume(recordId.get(), now); + if (resolved.isEmpty()) { + LOGGER.debug("OIDC callback binding cookie resolved no live pending record — rejected"); + return CallbackOutcome.error(FORBIDDEN); + } + PendingAuthorizationRecord pending = resolved.get(); + + if (!constantTimeEquals(pending.flowContext().state(), params.state())) { + LOGGER.debug("OIDC callback state did not match the bound pending record — rejected"); + return CallbackOutcome.error(FORBIDDEN); + } + + try { + AuthorizationCodeFlow.AuthenticationResult result = codeExchange.exchange(pending.flowContext(), params); + return completeLogin(result, pending, now); + } catch (TokenSheriffException engineFailure) { + LOGGER.debug(engineFailure, "OIDC callback code exchange / token validation failed"); + return CallbackOutcome.error(BAD_REQUEST); + } + } + + private CallbackOutcome completeLogin(AuthorizationCodeFlow.AuthenticationResult result, + PendingAuthorizationRecord pending, Instant now) { + AccessTokenContent accessToken = result.accessToken(); + IdTokenContent idToken = result.idToken(); + Optional subject = idToken.getSubject().or(accessToken::getSubject); + if (subject.isEmpty()) { + LOGGER.debug("OIDC callback validated tokens carried no subject — rejected"); + return CallbackOutcome.error(BAD_REQUEST); + } + + String sessionId = SessionRecord.newSessionId(); + SessionRecord session = SessionRecord.builder() + .sessionId(sessionId) + .accessToken(accessToken.getRawToken()) + .refreshToken(Optional.empty()) + .idToken(idToken.getRawToken()) + .sub(subject.get()) + .sid(claim(idToken, CLAIM_SID)) + .expiresAt(now.plus(sessionTtl)) + .acr(claim(idToken, CLAIM_ACR)) + .authTime(claimEpochSeconds(idToken, CLAIM_AUTH_TIME)) + .build(); + sessionStore.create(session); + + List setCookies = List.of( + sessionCookieCodec.toSetCookieHeader(sessionId), + bindingCookieCodec.toClearingSetCookieHeader()); + return CallbackOutcome.redirect(pending.returnUrl(), setCookies); + } + + private static Optional claim(TokenContent token, String name) { + ClaimValue value = token.getClaims().get(name); + if (value == null) { + return Optional.empty(); + } + String original = value.getOriginalString(); + return original == null || original.isBlank() ? Optional.empty() : Optional.of(original); + } + + private static Optional claimEpochSeconds(TokenContent token, String name) { + return claim(token, name).flatMap(raw -> { + try { + return Optional.of(Instant.ofEpochSecond(Long.parseLong(raw.trim()))); + } catch (NumberFormatException _) { + return Optional.empty(); + } + }); + } + + private static boolean isBlank(@Nullable String value) { + return value == null || value.isBlank(); + } + + private static boolean constantTimeEquals(@Nullable String expected, @Nullable String actual) { + if (expected == null || actual == null) { + return false; + } + return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), actual.getBytes(StandardCharsets.UTF_8)); + } + + /** + * The engine code-exchange seam. The session runtime binds it to the engine as + * {@code (context, params) -> authorizationCodeFlow.exchange(providerMetadata, context, params, + * clientAuthentication)}; a test binds it to a hand-built result. Keeping the confidential-client + * wiring (provider metadata, client authentication) behind the seam decouples the endpoint from + * it and makes the success and exchange-failure paths unit-testable without a live token endpoint. + * + * @author API Sheriff Team + * @since 1.0 + */ + @FunctionalInterface + public interface CodeExchange { + + /** + * Exchanges the callback for validated tokens: the engine performs the code exchange and the + * {@code state}/{@code nonce}/{@code iss} + token validation, fail-closed. + * + * @param context the pending record's engine transaction context (owns state/nonce/PKCE) + * @param params the parsed callback parameters (from the raw query — BFF-13) + * @return the validated access + ID token result + * @throws de.cuioss.sheriff.token.commons.error.TokenSheriffException when the exchange or + * token validation fails (invalid state/nonce, IdP error, signature/claim failure) + */ + AuthorizationCodeFlow.AuthenticationResult exchange(FlowContext context, CallbackParameters params); + } + + /** + * The framework-agnostic result of a callback: either a {@code 302} redirect carrying the + * post-login {@code Set-Cookie} headers, or an error status with no body semantics for the edge + * to render. Token material never appears here — only the opaque cookie headers and the + * same-origin return location. + * + * @param status the HTTP status the edge returns + * @param location the redirect target, present only for a successful login + * @param setCookieHeaders the {@code Set-Cookie} header values to emit, empty for an error + * @author API Sheriff Team + * @since 1.0 + */ + public record CallbackOutcome(int status, Optional location, List setCookieHeaders) { + + /** + * Canonical constructor normalizing an absent location and defensively copying the cookies. + */ + public CallbackOutcome { + location = Objects.requireNonNullElse(location, Optional.empty()); + setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders); + } + + /** + * A successful-login {@code 302} redirect. + * + * @param location the same-origin return URL + * @param setCookieHeaders the session {@code Set-Cookie} and the binding-clearing {@code Set-Cookie} + * @return the redirect outcome + */ + public static CallbackOutcome redirect(String location, List setCookieHeaders) { + Objects.requireNonNull(location, "location"); + return new CallbackOutcome(FOUND, Optional.of(location), setCookieHeaders); + } + + /** + * An error outcome carrying no redirect and no cookies. + * + * @param status the {@code 4xx} status + * @return the error outcome + */ + public static CallbackOutcome error(int status) { + return new CallbackOutcome(status, Optional.empty(), List.of()); + } + + /** + * @return {@code true} when this outcome is a successful-login redirect + */ + public boolean isRedirect() { + return status == FOUND; + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilter.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilter.java new file mode 100644 index 00000000..ce65815d --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilter.java @@ -0,0 +1,124 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.reserved; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * The operator-owned claim allowlist that caps what the session/user-info endpoint (D11) may + * disclose to the browser. + *

    + * Secure default closed. The allowlist is the sole authority over which identity + * claims leave the gateway — an empty allowlist discloses nothing, so the + * operator (never the browser client) is the only party that widens disclosure. Every response the + * endpoint serves is filtered through {@link #filter(Map, List)}, so a claim that is not on the + * allowlist can never appear in the output even when it is present in the validated ID token. + *

    + * The {@linkplain #defaultView() curated default view} — returned when the caller requests no + * explicit claims — is itself capped to the allowlist at construction: any {@code default_view} + * entry outside {@code allowed_claims} is dropped, so the default can never widen disclosure beyond + * the operator cap. The filter is immutable and framework-agnostic, so a single instance is safely + * shared across threads. + * + * @author API Sheriff Team + * @since 1.0 + */ +// java:S6206 — not a transparent data carrier: the constructor performs security-critical +// normalization (defensive Set.copyOf, defaultView capped to the allowlist) and the class exposes +// behavior (isAllowed/isEmpty/filter), so a record's transparent component contract does not fit. +@SuppressWarnings("java:S6206") +public final class ClaimAllowlistFilter { + + private final Set allowedClaims; + private final List defaultView; + + /** + * Assembles the filter from the operator allowlist and the curated default view. + * + * @param allowedClaims the operator claim allowlist; an empty collection is the secure closed + * default that discloses nothing + * @param defaultView the curated default-view selector returned when the caller requests no + * explicit claims; entries outside {@code allowedClaims} are dropped so the + * default can never disclose beyond the allowlist + */ + public ClaimAllowlistFilter(Collection allowedClaims, Collection defaultView) { + Objects.requireNonNull(allowedClaims, "allowedClaims"); + Objects.requireNonNull(defaultView, "defaultView"); + this.allowedClaims = Set.copyOf(allowedClaims); + this.defaultView = defaultView.stream() + .distinct() + .filter(this.allowedClaims::contains) + .toList(); + } + + /** + * @param claim the claim name to test + * @return {@code true} when the claim is on the operator allowlist and may be disclosed + */ + public boolean isAllowed(String claim) { + Objects.requireNonNull(claim, "claim"); + return allowedClaims.contains(claim); + } + + /** + * @return {@code true} when the allowlist is empty — the secure closed default that discloses nothing + */ + public boolean isEmpty() { + return allowedClaims.isEmpty(); + } + + /** + * @return the operator claim allowlist (immutable) + */ + public Set allowedClaims() { + return allowedClaims; + } + + /** + * @return the curated default-view selection, already capped to the allowlist (immutable) + */ + public List defaultView() { + return defaultView; + } + + /** + * Filters a source claim map to the allowlisted subset of the requested selection, preserving the + * selection order. Only keys that are both on the allowlist and present in {@code source} appear + * in the result; a requested claim outside the allowlist is silently dropped (the endpoint rejects + * an explicitly-requested disallowed claim {@code 403} before calling this). + * + * @param source the candidate claim values (validated ID-token claims) + * @param selection the ordered claim names the caller selected + * @return an ordered, unmodifiable map of the disclosed claims + */ + public Map filter(Map source, List selection) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(selection, "selection"); + Map filtered = new LinkedHashMap<>(); + for (String claim : selection) { + if (allowedClaims.contains(claim) && source.containsKey(claim)) { + filtered.put(claim, source.get(claim)); + } + } + return Collections.unmodifiableMap(filtered); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpoint.java new file mode 100644 index 00000000..c974399b --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LoginInitiationEndpoint.java @@ -0,0 +1,174 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.reserved; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + + +import de.cuioss.sheriff.gateway.bff.login.LoginFlow; +import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord; +import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec; +import de.cuioss.sheriff.gateway.bff.session.SessionRecord; +import de.cuioss.sheriff.gateway.bff.session.SessionStore; +import de.cuioss.tools.logging.CuiLogger; + +import org.jspecify.annotations.Nullable; + +/** + * The login-initiation reserved endpoint ({@code oidc.login.path}) — the sixth reserved gateway + * path (D12). It is registered exactly like the other reserved endpoints (exact match, on the OIDC + * host, resolved by the {@link ReservedPathRegistry} before the proxy route table), and + * gives the browser an explicit, gateway-owned URL to start a login from — the mirror + * entry point to the {@link CallbackEndpoint} that lands it. + *

    + * No new subsystem. The endpoint is a thin edge over the D5 {@link LoginFlow}: it + * reuses the same-origin-validated post-login return URL and the browser-binding cookie carried by + * the D2b {@link PendingAuthorizationRecord}, and delegates the whole auth-code initiation (engine + * authorization, PKCE/{@code state}/{@code nonce}, pending-record persistence, binding-cookie + * minting) to {@link LoginFlow#initiate}. It adds exactly one gateway-side concern on top: the + * already-authenticated short-circuit. + *

    + * Return-URL safety (never an open redirect). The {@code returnUrl} parameter is + * same-origin-validated exactly as D2b requires + * ({@link PendingAuthorizationRecord#sameOrigin(String, String)}). On the unauthenticated path the + * validation is {@link LoginFlow}'s (a cross-origin or unparseable target falls back to + * {@link LoginFlow#DEFAULT_RETURN_URL}); on the already-authenticated path this endpoint applies the + * identical validation before it redirects. Either way an off-origin, schema-relative + * ({@code //host}), blank, or unparseable target can never become the redirect location — the + * login-initiation URL is not an open redirect. + *

    + * Already-authenticated behaviour (explicitly defined). A caller that already + * holds a live session MUST NOT be silently re-driven through a fresh auth-code flow: the endpoint + * short-circuits straight to the same-origin-validated return URL ({@link #initiate} resolves the + * live session through the {@link SessionCookieCodec}/{@link SessionStore} seam, exactly as + * {@link UserInfoEndpoint} and {@link LogoutEndpoint} do). No pending record is created and no + * binding cookie is set on this path — there is no new login transaction to bind. + *

    + * The endpoint is framework-agnostic (a raw {@code returnUrl} parameter and a raw {@code Cookie} + * header in, a {@link LoginInitiationOutcome} the edge renders out — no JAX-RS/Vert.x coupling), so + * it is unit-testable without a container; the session runtime wires it to the request/response edge. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class LoginInitiationEndpoint { + + private static final CuiLogger LOGGER = new CuiLogger(LoginInitiationEndpoint.class); + + private static final int FOUND = 302; + + private final LoginFlow loginFlow; + private final SessionStore sessionStore; + private final SessionCookieCodec sessionCookieCodec; + private final String gatewayOrigin; + + /** + * Assembles the login-initiation endpoint with the D5 login flow and the session-resolution seam. + * + * @param loginFlow the D5 auth-code login initiation reused on the unauthenticated path + * @param sessionStore the server-side session store resolving the opaque session id + * @param sessionCookieCodec the opaque session-cookie codec reading the request cookie + * @param gatewayOrigin the gateway's own origin (the {@code redirect_uri} origin) used to + * same-origin-validate the return URL on the already-authenticated path + */ + public LoginInitiationEndpoint(LoginFlow loginFlow, SessionStore sessionStore, + SessionCookieCodec sessionCookieCodec, String gatewayOrigin) { + this.loginFlow = Objects.requireNonNull(loginFlow, "loginFlow"); + this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore"); + this.sessionCookieCodec = Objects.requireNonNull(sessionCookieCodec, "sessionCookieCodec"); + this.gatewayOrigin = Objects.requireNonNull(gatewayOrigin, "gatewayOrigin"); + } + + /** + * Initiates a login: an already-authenticated caller is redirected straight to the same-origin- + * validated return URL (no fresh auth-code flow); an unauthenticated caller is driven into the D5 + * auth-code flow, which returns to the same validated return URL after the callback. + * + * @param requestedReturnUrl the post-login return target the browser asked for, may be absent + * @param cookieHeader the raw request {@code Cookie} header value, may be absent + * @param now the reference instant (session-resolution TTL anchor and, on the + * unauthenticated path, the pending record's TTL anchor) + * @return a {@code 302} redirect — straight to the validated return URL (no cookies) for a live + * session, or to the IdP authorization URL carrying the binding {@code Set-Cookie} for a + * fresh login + */ + public LoginInitiationOutcome initiate(@Nullable String requestedReturnUrl, @Nullable String cookieHeader, + Instant now) { + Objects.requireNonNull(now, "now"); + + Optional session = sessionCookieCodec.readSessionId(cookieHeader) + .flatMap(sessionId -> sessionStore.resolve(sessionId, now)); + if (session.isPresent()) { + String returnUrl = requestedReturnUrl != null + && PendingAuthorizationRecord.sameOrigin(requestedReturnUrl, gatewayOrigin) + ? requestedReturnUrl : LoginFlow.DEFAULT_RETURN_URL; + LOGGER.debug("Login initiation with a live session — short-circuiting to the validated " + + "return URL, no fresh auth-code flow"); + return LoginInitiationOutcome.redirect(returnUrl, List.of()); + } + + LoginFlow.LoginRedirect redirect = loginFlow.initiate(requestedReturnUrl, now); + LOGGER.debug("Login initiation without a live session — starting the OIDC auth-code flow"); + return LoginInitiationOutcome.redirect(redirect.authorizationUrl(), redirect.setCookieHeaders()); + } + + /** + * The framework-agnostic result of a login initiation: always a {@code 302} redirect — straight + * to the same-origin-validated return URL (no cookies) on the already-authenticated short-circuit, + * or to the IdP authorization URL carrying the browser-binding {@code Set-Cookie} on a fresh login. + * No token material ever appears here — only the opaque binding-cookie header and the redirect + * location. + * + * @param status the HTTP status the edge returns (always {@code 302}) + * @param location the redirect target (the validated return URL or the IdP authorization URL) + * @param setCookieHeaders the {@code Set-Cookie} header values to emit — the binding cookie on a + * fresh login, empty on the already-authenticated short-circuit + * @author API Sheriff Team + * @since 1.0 + */ + public record LoginInitiationOutcome(int status, String location, List setCookieHeaders) { + + /** + * Canonical constructor rejecting an absent location and defensively copying the cookies. + */ + public LoginInitiationOutcome { + Objects.requireNonNull(location, "location"); + setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders); + } + + /** + * A {@code 302} redirect carrying the {@code Set-Cookie} headers. + * + * @param location the redirect target + * @param setCookieHeaders the {@code Set-Cookie} header values to emit + * @return the redirect outcome + */ + public static LoginInitiationOutcome redirect(String location, List setCookieHeaders) { + Objects.requireNonNull(location, "location"); + return new LoginInitiationOutcome(FOUND, location, setCookieHeaders); + } + + /** + * @return {@code true} when this outcome is a redirect (always, by construction) + */ + public boolean isRedirect() { + return status == FOUND; + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java new file mode 100644 index 00000000..786af580 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpoint.java @@ -0,0 +1,201 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.reserved; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + + +import de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout; +import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec; +import de.cuioss.sheriff.gateway.bff.session.SessionRecord; +import de.cuioss.sheriff.gateway.bff.session.SessionStore; +import de.cuioss.tools.logging.CuiLogger; + +import org.jspecify.annotations.Nullable; + +/** + * The RP-initiated logout endpoint — the request/response edge over {@link RpInitiatedLogout}, the + * mirror of {@link CallbackEndpoint} for the logout direction (D5). It owns the two reserved logout + * legs ({@link ReservedPathRegistry.ReservedEndpoint#LOGOUT} and + * {@link ReservedPathRegistry.ReservedEndpoint#LOGOUT_RETURN}) and the session store; the + * transport-free logic — token revocation, {@code state} minting, the engine end-session redirect, + * and the return-leg {@code state} verification — lives in {@link RpInitiatedLogout}. + *

    + * Logout leg. {@link #logout(String, Instant)} resolves the opaque {@code __Host-} + * session cookie to a live {@link SessionRecord}, drives {@link RpInitiatedLogout#initiate} (which + * revokes the mediated tokens and builds the {@code end_session_endpoint} redirect carrying the + * {@code id_token_hint}, the exact {@code post_logout_redirect_uri}, and the single-use logout-state + * cookie), then destroys the server-side session ({@link SessionStore#destroyById}) and clears the + * session cookie. The local session destruction is the authoritative, immediately-effective logout; + * the IdP round-trip is layered on top. A logout request that carries no live session is + * already logged out — the endpoint clears any stale session cookie and lands the browser on + * {@link RpInitiatedLogout#finalRedirect()} directly, bypassing the IdP round-trip (there is no + * {@code id_token_hint} to send). + *

    + * Return leg. {@link #completeReturn(String, String)} delegates to + * {@link RpInitiatedLogout#completeReturn} — the returned {@code state} is verified (constant-time, + * engine-owned) against the single-use logout-state cookie, the cookie is cleared, and the browser is + * redirected to {@code final_redirect}; a missing/mismatched {@code state} is rejected {@code 400}, so + * a forged logout-return cannot land the browser anywhere. + *

    + * The endpoint is framework-agnostic (raw {@code Cookie} header in, a {@link LogoutOutcome} the edge + * renders out — no JAX-RS/Vert.x coupling), so it is unit-testable without a container; the session + * runtime wires it to the request/response edge. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class LogoutEndpoint { + + private static final CuiLogger LOGGER = new CuiLogger(LogoutEndpoint.class); + + private final RpInitiatedLogout rpInitiatedLogout; + private final SessionStore sessionStore; + private final SessionCookieCodec sessionCookieCodec; + + /** + * Assembles the logout endpoint with the RP-initiated logout logic and the gateway-side stores. + * + * @param rpInitiatedLogout the transport-free RP-initiated logout orchestration + * @param sessionStore the server-side session store the session is destroyed in + * @param sessionCookieCodec the opaque session-cookie codec reading and clearing the session cookie + */ + public LogoutEndpoint(RpInitiatedLogout rpInitiatedLogout, SessionStore sessionStore, + SessionCookieCodec sessionCookieCodec) { + this.rpInitiatedLogout = Objects.requireNonNull(rpInitiatedLogout, "rpInitiatedLogout"); + this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore"); + this.sessionCookieCodec = Objects.requireNonNull(sessionCookieCodec, "sessionCookieCodec"); + } + + /** + * Handles the RP-initiated logout leg: resolves the live session, drives the engine end-session + * redirect, destroys the server-side session, and clears the session cookie. + * + * @param cookieHeader the raw request {@code Cookie} header value, may be absent + * @param now the reference instant (the session-resolution TTL anchor) + * @return a {@code 302} redirect to the IdP {@code end_session_endpoint} (session-cleared and + * logout-state {@code Set-Cookie} headers), or — when no live session — a {@code 302} + * straight to {@code final_redirect} clearing the session cookie + */ + public LogoutOutcome logout(@Nullable String cookieHeader, Instant now) { + Objects.requireNonNull(now, "now"); + + Optional sessionIdOpt = sessionCookieCodec.readSessionId(cookieHeader); + Optional session = sessionIdOpt.flatMap(id -> sessionStore.resolve(id, now)); + if (sessionIdOpt.isEmpty() || session.isEmpty()) { + LOGGER.debug("RP-initiated logout without a live session — already logged out, landing on final_redirect"); + return LogoutOutcome.redirect(rpInitiatedLogout.finalRedirect(), + List.of(sessionCookieCodec.toClearingSetCookieHeader())); + } + String sessionId = sessionIdOpt.get(); + + RpInitiatedLogout.LogoutRedirect redirect; + // Local logout is the authoritative, immediately-effective step and must ALWAYS succeed: if the + // IdP end-session redirect cannot be built, still destroy the server-side session and clear the + // session cookie, then land the browser on final_redirect. The catch is deliberately broad so no + // redirect-construction failure can leave the local session usable. + // cui-rewrite:disable InvalidExceptionUsageRecipe + try { + redirect = rpInitiatedLogout.initiate(session.get()); + } catch (RuntimeException initiationFailure) { + sessionStore.destroyById(sessionId); + LOGGER.debug(initiationFailure, + "RP-initiated logout — end-session redirect construction failed; local session destroyed, landing on final_redirect"); + return LogoutOutcome.redirect(rpInitiatedLogout.finalRedirect(), + List.of(sessionCookieCodec.toClearingSetCookieHeader())); + } + sessionStore.destroyById(sessionId); + List setCookies = new ArrayList<>(redirect.setCookieHeaders()); + setCookies.add(sessionCookieCodec.toClearingSetCookieHeader()); + LOGGER.debug("RP-initiated logout — session destroyed, redirecting to the IdP end_session_endpoint"); + return LogoutOutcome.redirect(redirect.location(), setCookies); + } + + /** + * Handles the RP-initiated logout return leg: verifies the returned {@code state} against the + * single-use logout-state cookie, clears the cookie, and redirects to {@code final_redirect}. + * + * @param stateParam the {@code state} returned by the IdP on the post-logout redirect, may be absent + * @param cookieHeader the raw request {@code Cookie} header value, may be absent + * @return a {@code 302} redirect to {@code final_redirect} on a matching state, or a {@code 400} + * on a missing/mismatched state + */ + public LogoutOutcome completeReturn(@Nullable String stateParam, @Nullable String cookieHeader) { + RpInitiatedLogout.LogoutReturn result = rpInitiatedLogout.completeReturn(stateParam, cookieHeader); + if (!result.isRedirect()) { + return LogoutOutcome.error(result.status()); + } + return LogoutOutcome.redirect(result.location().orElseThrow(), result.setCookieHeaders()); + } + + /** + * The framework-agnostic result of a logout leg: either a {@code 302} redirect (to the IdP + * {@code end_session_endpoint}, or to {@code final_redirect} on the return / already-logged-out + * paths) carrying the {@code Set-Cookie} headers to emit, or a {@code 4xx} error with no redirect. + * Token material never appears here — only the opaque cookie headers and the redirect location. + * + * @param status the HTTP status the edge returns + * @param location the redirect target, present only on a redirect outcome + * @param setCookieHeaders the {@code Set-Cookie} header values to emit, empty on an error + * @author API Sheriff Team + * @since 1.0 + */ + public record LogoutOutcome(int status, Optional location, List setCookieHeaders) { + + private static final int FOUND = 302; + + /** + * Canonical constructor normalizing an absent location and defensively copying the cookies. + */ + public LogoutOutcome { + location = Objects.requireNonNullElse(location, Optional.empty()); + setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders); + } + + /** + * A {@code 302} redirect carrying the {@code Set-Cookie} headers. + * + * @param location the redirect target + * @param setCookieHeaders the {@code Set-Cookie} header values to emit + * @return the redirect outcome + */ + public static LogoutOutcome redirect(String location, List setCookieHeaders) { + Objects.requireNonNull(location, "location"); + return new LogoutOutcome(FOUND, Optional.of(location), setCookieHeaders); + } + + /** + * An error outcome carrying no redirect and no cookies. + * + * @param status the {@code 4xx} status + * @return the error outcome + */ + public static LogoutOutcome error(int status) { + return new LogoutOutcome(status, Optional.empty(), List.of()); + } + + /** + * @return {@code true} when this outcome is a redirect + */ + public boolean isRedirect() { + return status == FOUND; + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistry.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistry.java new file mode 100644 index 00000000..9ff82013 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistry.java @@ -0,0 +1,184 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.reserved; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + + +import de.cuioss.sheriff.gateway.config.model.OidcConfig; + +import org.jspecify.annotations.Nullable; + +/** + * The exact-match registry of the gateway's reserved OIDC endpoints (D2). + *

    + * The BFF variants carve up to six gateway-owned paths out of the proxy route table — the + * {@code oidc.redirect_uri} callback, the RP-initiated {@code oidc.logout.path}, its + * {@code post_logout_redirect_uri} return leg, the {@code oidc.logout.backchannel_path} + * receiver, the {@code oidc.user_info.path} session/user-info fold (D11), and the + * {@code oidc.login.path} login-initiation fold (D12). Each is matched exactly + * (never by prefix) and only on the + * OIDC host (the host of {@code oidc.redirect_uri}). The gateway edge consults this + * registry before the route table, so a proxy route such as {@code path_prefix: /auth} + * can never swallow the exact {@code /auth/callback}: the reserved path is resolved here first + * and the prefix route never sees it. + *

    + * The registry is built once at boot from the frozen {@link OidcConfig}. When no {@code oidc} + * block (or no {@code redirect_uri}) is configured the registry is {@linkplain #isEmpty() empty} + * and {@link #match(String, String)} never matches — the pure-proxy gateway is unchanged. The + * type is framework-agnostic (raw host/path strings only, no JAX-RS/Vert.x coupling), so it is + * unit-testable without a container. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class ReservedPathRegistry { + + /** + * The kind of reserved gateway endpoint an exact host+path match resolves to. The runtime + * handler for each kind is wired by the session runtime; this registry only classifies the + * carve-out. + */ + public enum ReservedEndpoint { + + /** The {@code oidc.redirect_uri} OIDC auth-code callback. */ + CALLBACK, + + /** The RP-initiated {@code oidc.logout.path}. */ + LOGOUT, + + /** The {@code post_logout_redirect_uri} return leg of RP-initiated logout. */ + LOGOUT_RETURN, + + /** The {@code oidc.logout.backchannel_path} back-channel logout receiver. */ + BACKCHANNEL_LOGOUT, + + /** The {@code oidc.user_info.path} session/user-info fold endpoint (D11). */ + USER_INFO, + + /** The {@code oidc.login.path} login-initiation fold endpoint (D12). */ + LOGIN + } + + private final @Nullable String oidcHost; + private final Map endpointsByPath; + + private ReservedPathRegistry(@Nullable String oidcHost, Map endpointsByPath) { + this.oidcHost = oidcHost; + this.endpointsByPath = Map.copyOf(endpointsByPath); + } + + /** + * Builds the registry from the global {@code oidc} block. The OIDC host and the callback path + * are derived from {@code oidc.redirect_uri}; without a {@code redirect_uri} there is no OIDC + * host to bind reserved paths to, so the registry is empty. + * + * @param oidc the global OIDC configuration, empty when the gateway serves no BFF variant + * @return the reserved-path registry — {@linkplain #isEmpty() empty} when no OIDC callback is configured + */ + public static ReservedPathRegistry from(Optional oidc) { + Objects.requireNonNull(oidc, "oidc"); + Optional redirect = oidc.flatMap(OidcConfig::redirectUri).flatMap(ReservedPathRegistry::parseUri); + String host = redirect.map(URI::getHost).orElse(null); + if (host == null) { + return new ReservedPathRegistry(null, Map.of()); + } + Map paths = new LinkedHashMap<>(); + redirect.map(URI::getPath).filter(ReservedPathRegistry::isAbsolutePath) + .ifPresent(path -> paths.put(path, ReservedEndpoint.CALLBACK)); + Optional logout = oidc.flatMap(OidcConfig::logout); + logout.flatMap(OidcConfig.Logout::path).flatMap(ReservedPathRegistry::toPath) + .ifPresent(path -> paths.putIfAbsent(path, ReservedEndpoint.LOGOUT)); + logout.flatMap(OidcConfig.Logout::postLogoutRedirectUri).flatMap(ReservedPathRegistry::toPath) + .ifPresent(path -> paths.putIfAbsent(path, ReservedEndpoint.LOGOUT_RETURN)); + logout.flatMap(OidcConfig.Logout::backchannelPath).flatMap(ReservedPathRegistry::toPath) + .ifPresent(path -> paths.putIfAbsent(path, ReservedEndpoint.BACKCHANNEL_LOGOUT)); + oidc.flatMap(OidcConfig::userInfo).flatMap(OidcConfig.UserInfo::path).flatMap(ReservedPathRegistry::toPath) + .ifPresent(path -> paths.putIfAbsent(path, ReservedEndpoint.USER_INFO)); + oidc.flatMap(OidcConfig::login).flatMap(OidcConfig.Login::path).flatMap(ReservedPathRegistry::toPath) + .ifPresent(path -> paths.putIfAbsent(path, ReservedEndpoint.LOGIN)); + return new ReservedPathRegistry(host, paths); + } + + /** + * Resolves the reserved endpoint for a request, matching the path exactly and + * only when the request host is the OIDC host. A prefix that merely contains a + * reserved path (e.g. {@code /auth} against the reserved {@code /auth/callback}) never matches — + * that is precisely the carve-out this registry guarantees. + * + * @param host the request host authority (without port), may be {@code null} + * @param path the single canonical request path, may be {@code null} before canonicalization + * @return the reserved endpoint when the host and path match exactly; empty otherwise + */ + public Optional match(@Nullable String host, @Nullable String path) { + if (oidcHost == null || host == null || path == null) { + return Optional.empty(); + } + if (!oidcHost.equalsIgnoreCase(host)) { + return Optional.empty(); + } + return Optional.ofNullable(endpointsByPath.get(path)); + } + + /** + * Convenience predicate over {@link #match(String, String)} for the edge carve-out check. + * + * @param host the request host authority (without port), may be {@code null} + * @param path the single canonical request path, may be {@code null} + * @return {@code true} when the request targets a reserved gateway endpoint + */ + public boolean isReserved(@Nullable String host, @Nullable String path) { + return match(host, path).isPresent(); + } + + /** + * @return {@code true} when no reserved endpoint is registered (no OIDC callback configured) + */ + public boolean isEmpty() { + return endpointsByPath.isEmpty(); + } + + /** + * Normalizes a configured value to its absolute path component: a full URI ({@code https://…}) + * contributes its path, a bare absolute path ({@code /auth/logout}) is taken verbatim. A blank, + * relative, or unparseable value contributes nothing. + */ + private static Optional toPath(String value) { + if (value.isBlank()) { + return Optional.empty(); + } + if (value.contains("://")) { + return parseUri(value).map(URI::getPath).filter(ReservedPathRegistry::isAbsolutePath); + } + return isAbsolutePath(value) ? Optional.of(value) : Optional.empty(); + } + + private static Optional parseUri(String value) { + try { + return Optional.of(URI.create(value.trim())); + } catch (IllegalArgumentException _) { + return Optional.empty(); + } + } + + private static boolean isAbsolutePath(@Nullable String path) { + return path != null && path.startsWith("/"); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/UserInfoEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/UserInfoEndpoint.java new file mode 100644 index 00000000..89d94662 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/UserInfoEndpoint.java @@ -0,0 +1,300 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.reserved; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + + +import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec; +import de.cuioss.sheriff.gateway.bff.session.SessionRecord; +import de.cuioss.sheriff.gateway.bff.session.SessionStore; +import de.cuioss.tools.logging.CuiLogger; + +import org.jspecify.annotations.Nullable; + +/** + * The session/user-info reserved endpoint ({@code oidc.user_info.path}) — a sixth-minus-one + * reserved gateway path (D11). It is registered exactly like the other reserved endpoints (exact + * match, on the OIDC host, resolved by the {@link ReservedPathRegistry} before the proxy + * route table), and serves the browser a curated view of the caller's own identity and session + * metadata. It is built above the session-resolution seam so a server-mode BFF + * inherits it without additional wiring. + *

    + * What it discloses. The default response is a curated identity subset — the + * operator's {@linkplain ClaimAllowlistFilter#defaultView() default view} (typically {@code sub}, + * {@code name}, {@code roles}) — plus session metadata (the session {@code expires_at}, the IdP + * {@code auth_time}, and the authentication-context {@code acr}). A {@code claims} parameter selects + * specific claims or, with {@link #FULL_VIEW}, the full allowlisted view. The claim source is the + * validated ID-token claims (resolved through the {@link ClaimSource} seam) and the + * server-held session metadata only — raw access / refresh / ID tokens never appear + * in any view. + *

    + * Allowlist cap (secure default closed). Every response is filtered through the + * operator-owned {@link ClaimAllowlistFilter}: a claim outside {@code allowed_claims} can never be + * disclosed even when it is present in the validated ID token, and an explicitly-requested + * disallowed claim is rejected {@code 403} before any disclosure. The operator — never the browser + * client — is the sole party that widens disclosure; an empty allowlist discloses nothing. + *

    + * Response semantics. No live session yields {@code 401} + * {@code application/problem+json} — never a redirect: the endpoint is an XHR probe that + * composes with the D4 content negotiation (a navigation redirect is the browser's concern, not this + * probe's). {@code 403} is reserved for CSRF / a disallowed-claim request. Every response — success + * or error — carries {@code Cache-Control: no-store}, so an identity view is never cached by any + * intermediary or the browser. + *

    + * The endpoint is framework-agnostic (a raw {@code Cookie} header and the raw {@code claims} + * parameter in, a {@link UserInfoOutcome} the edge renders out — no JAX-RS/Vert.x coupling), so it is + * unit-testable without a container; the session runtime wires it to the request/response edge. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class UserInfoEndpoint { + + private static final CuiLogger LOGGER = new CuiLogger(UserInfoEndpoint.class); + + /** + * The {@code claims} parameter value that selects the full allowlisted view rather than a + * specific claim list or the curated default view. + */ + public static final String FULL_VIEW = "*"; + + private static final int OK = 200; + private static final int UNAUTHORIZED = 401; + private static final int FORBIDDEN = 403; + + private static final String CACHE_CONTROL = "Cache-Control"; + private static final String NO_STORE = "no-store"; + private static final String CONTENT_TYPE = "Content-Type"; + private static final String APPLICATION_JSON = "application/json"; + private static final String PROBLEM_JSON = "application/problem+json"; + + private static final String CLAIMS_MEMBER = "claims"; + private static final String SESSION_MEMBER = "session"; + private static final String EXPIRES_AT = "expires_at"; + private static final String AUTH_TIME = "auth_time"; + private static final String ACR = "acr"; + + private final SessionStore sessionStore; + private final SessionCookieCodec sessionCookieCodec; + private final ClaimAllowlistFilter claimFilter; + private final ClaimSource claimSource; + + /** + * Assembles the user-info endpoint with the session stores, the operator claim allowlist, and the + * validated-claim resolution seam. + * + * @param sessionStore the server-side session store resolving the opaque session id + * @param sessionCookieCodec the opaque session-cookie codec reading the request cookie + * @param claimFilter the operator-owned claim allowlist capping every disclosure + * @param claimSource the validated ID-token claim-resolution seam (never raw tokens) + */ + public UserInfoEndpoint(SessionStore sessionStore, SessionCookieCodec sessionCookieCodec, + ClaimAllowlistFilter claimFilter, ClaimSource claimSource) { + this.sessionStore = Objects.requireNonNull(sessionStore, "sessionStore"); + this.sessionCookieCodec = Objects.requireNonNull(sessionCookieCodec, "sessionCookieCodec"); + this.claimFilter = Objects.requireNonNull(claimFilter, "claimFilter"); + this.claimSource = Objects.requireNonNull(claimSource, "claimSource"); + } + + /** + * Serves one user-info request: resolves the live session, selects the disclosed claims (capped + * by the operator allowlist), and returns the curated identity view plus session metadata. + * + * @param cookieHeader the raw request {@code Cookie} header value, may be absent + * @param claimsParam the raw {@code claims} selector: absent selects the curated default view, + * {@link #FULL_VIEW} selects the full allowlisted view, otherwise a + * comma-separated list of specific claim names; may be absent + * @param now the reference instant (the session-resolution TTL anchor) + * @return the {@code 200} identity view, a {@code 401} {@code application/problem+json} when no + * live session exists (never a redirect), or a {@code 403} when an explicitly-requested + * claim is outside the operator allowlist — every outcome carries {@code Cache-Control: + * no-store} + */ + public UserInfoOutcome handle(@Nullable String cookieHeader, @Nullable String claimsParam, Instant now) { + Objects.requireNonNull(now, "now"); + + Optional session = sessionCookieCodec.readSessionId(cookieHeader) + .flatMap(sessionId -> sessionStore.resolve(sessionId, now)); + if (session.isEmpty()) { + LOGGER.debug("user-info request without a live session — 401 problem+json (never a redirect)"); + return UserInfoOutcome.unauthenticated(); + } + + List selection; + if (isBlank(claimsParam)) { + selection = claimFilter.defaultView(); + } else if (FULL_VIEW.equals(claimsParam.trim())) { + selection = claimFilter.allowedClaims().stream().sorted().toList(); + } else { + List requested = parseSelection(claimsParam); + for (String claim : requested) { + if (!claimFilter.isAllowed(claim)) { + LOGGER.debug("user-info request named a claim outside the operator allowlist — rejected 403"); + return UserInfoOutcome.forbidden(); + } + } + selection = requested.isEmpty() ? claimFilter.defaultView() : requested; + } + + Map disclosed = claimFilter.filter(claimSource.claims(session.get()), selection); + + Map body = new LinkedHashMap<>(); + body.put(CLAIMS_MEMBER, disclosed); + body.put(SESSION_MEMBER, sessionMetadata(session.get())); + return UserInfoOutcome.identity(body); + } + + /** + * Projects the server-held session metadata — never raw token material — into the disclosed view. + * The {@code sub} identity anchor and any identity claims are disclosed through the allowlisted + * {@code claims} member; this member carries only the gateway's own session bookkeeping the + * session owner is entitled to see. + */ + private static Map sessionMetadata(SessionRecord session) { + Map metadata = new LinkedHashMap<>(); + metadata.put(EXPIRES_AT, session.expiresAt().toString()); + session.authTime().ifPresent(authTime -> metadata.put(AUTH_TIME, authTime.toString())); + session.acr().ifPresent(acr -> metadata.put(ACR, acr)); + return Collections.unmodifiableMap(metadata); + } + + private static List parseSelection(String claimsParam) { + return Arrays.stream(claimsParam.split(",")) + .map(String::trim) + .filter(claim -> !claim.isEmpty()) + .distinct() + .toList(); + } + + private static boolean isBlank(@Nullable String value) { + return value == null || value.isBlank(); + } + + /** + * The validated-claim resolution seam. The session runtime binds it to the engine's ID-token + * parsing so the endpoint discloses validated ID-token claims; a test binds it to a fixed + * claim map. Keeping the token parsing behind the seam both decouples the endpoint from the engine + * and enforces the invariant that the endpoint never touches raw token material — it receives only + * an already-validated, already-parsed claim map. + * + * @author API Sheriff Team + * @since 1.0 + */ + @FunctionalInterface + public interface ClaimSource { + + /** + * Resolves the validated ID-token claims backing the given session's disclosure. + * + * @param session the resolved live session + * @return the validated ID-token claims, keyed by claim name (never raw token material) + */ + Map claims(SessionRecord session); + } + + /** + * The framework-agnostic result of a user-info request: the HTTP status the edge returns, the JSON + * response body, and the fixed response headers ({@code Cache-Control: no-store} on every outcome, + * plus the content type — {@code application/json} for a disclosed view, {@code + * application/problem+json} for a {@code 401} / {@code 403} error). No token material ever appears + * in the body — only allowlisted claims and server-held session metadata. + * + * @param status the HTTP status the edge returns + * @param body the JSON response body (the identity view, or the RFC 9457 problem detail) + * @param headers the fixed response headers the edge emits verbatim + * @author API Sheriff Team + * @since 1.0 + */ + public record UserInfoOutcome(int status, Map body, Map headers) { + + /** + * Canonical constructor defensively copying the body and headers into immutable, insertion- + * ordered maps. + */ + public UserInfoOutcome { + body = body == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(body)); + headers = headers == null ? Map.of() : Map.copyOf(headers); + } + + /** + * A {@code 200} disclosed identity view served {@code application/json}, uncacheable. + * + * @param body the curated identity view (allowlisted claims plus session metadata) + * @return the disclosure outcome + */ + public static UserInfoOutcome identity(Map body) { + Objects.requireNonNull(body, "body"); + return new UserInfoOutcome(OK, body, jsonHeaders()); + } + + /** + * A {@code 401} {@code application/problem+json} outcome — the no-live-session response. It is + * never a redirect: the endpoint is an XHR probe. + * + * @return the unauthenticated outcome + */ + public static UserInfoOutcome unauthenticated() { + return new UserInfoOutcome(UNAUTHORIZED, problem(UNAUTHORIZED, "No live session"), problemHeaders()); + } + + /** + * A {@code 403} {@code application/problem+json} outcome — reserved for CSRF / a request that + * names a claim outside the operator allowlist. + * + * @return the forbidden outcome + */ + public static UserInfoOutcome forbidden() { + return new UserInfoOutcome(FORBIDDEN, problem(FORBIDDEN, "Claim not permitted"), problemHeaders()); + } + + /** + * @return {@code true} when this outcome discloses an identity view + */ + public boolean isDisclosed() { + return status == OK; + } + + private static Map problem(int status, String title) { + Map problem = new LinkedHashMap<>(); + problem.put("type", "about:blank"); + problem.put("title", title); + problem.put("status", status); + return Collections.unmodifiableMap(problem); + } + + private static Map jsonHeaders() { + Map headers = new LinkedHashMap<>(); + headers.put(CACHE_CONTROL, NO_STORE); + headers.put(CONTENT_TYPE, APPLICATION_JSON); + return headers; + } + + private static Map problemHeaders() { + Map headers = new LinkedHashMap<>(); + headers.put(CACHE_CONTROL, NO_STORE); + headers.put(CONTENT_TYPE, PROBLEM_JSON); + return headers; + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/package-info.java new file mode 100644 index 00000000..e17674c3 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/package-info.java @@ -0,0 +1,42 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * The gateway's reserved OIDC endpoints, carved out of the proxy route table (D2/D2c). + *

    + * The BFF variants reserve four gateway-owned paths — the {@code oidc.redirect_uri} callback, the + * RP-initiated logout path, its return leg, and the back-channel logout receiver — matched + * exactly and only on the OIDC host. This package owns the carve-out and the callback landing: + *

      + *
    • {@link de.cuioss.sheriff.gateway.bff.reserved.ReservedPathRegistry} classifies an exact + * host+path match to a reserved endpoint. The edge consults it before the route + * table, so a proxy route such as {@code path_prefix: /auth} never swallows the exact + * {@code /auth/callback}.
    • + *
    • {@link de.cuioss.sheriff.gateway.bff.reserved.CallbackEndpoint} handles the OIDC + * auth-code callback: it parses the raw query (BFF-13 duplicate-parameter + * defence), resolves the browser-bound pending-authorization record, drives the engine's + * code exchange and token validation, creates the server-side session, and redirects to the + * recorded return URL.
    • + *
    + * The classes are framework-agnostic (no CDI, no JAX-RS/Vert.x coupling), so they are + * unit-testable without a container; the session runtime wires them to the request/response edge. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.gateway.bff.reserved; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java new file mode 100644 index 00000000..08dc27f0 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java @@ -0,0 +1,348 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.runtime; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; + + +import de.cuioss.sheriff.gateway.bff.csrf.CsrfDefence; +import de.cuioss.sheriff.gateway.bff.refresh.StepUpCoordinator; +import de.cuioss.sheriff.gateway.bff.reserved.BackchannelLogoutEndpoint; +import de.cuioss.sheriff.gateway.bff.reserved.CallbackEndpoint; +import de.cuioss.sheriff.gateway.bff.reserved.LoginInitiationEndpoint; +import de.cuioss.sheriff.gateway.bff.reserved.LogoutEndpoint; +import de.cuioss.sheriff.gateway.bff.reserved.ReservedPathRegistry.ReservedEndpoint; +import de.cuioss.sheriff.gateway.bff.reserved.UserInfoEndpoint; + +import org.jspecify.annotations.Nullable; + +/** + * The assembled server-mode BFF runtime (D16 edge wiring) — the single collaborator the gateway edge + * consults to serve the {@code require: session} stage-4 runtime and to dispatch a matched reserved + * OIDC path to its handler. + *

    + * The runtime is built once at boot by {@code BffRuntimeProducer} only when a global + * {@code oidc} block with {@code session.mode=server} is configured; a gateway without such + * a block gets the {@linkplain #inert() inert} instance, which reports {@link #isActive()} + * {@code false} and dispatches nothing (the bearer-only proxy path is unchanged, and the + * {@code ReservedPathRegistry} carries no reserved paths to dispatch anyway). + *

    + * It carries two edge-facing capabilities: + *

      + *
    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; + + @SuppressWarnings("java:S107") // wiring holder assembled once by BffRuntimeProducer + private BffRuntime(boolean active, @Nullable SessionAuthenticationStage sessionStage, + @Nullable CsrfDefence csrfDefence, @Nullable StepUpCoordinator stepUpCoordinator, + @Nullable CallbackEndpoint callbackEndpoint, + @Nullable Supplier logoutEndpoint, + @Nullable BackchannelLogoutEndpoint backchannelLogoutEndpoint, + @Nullable UserInfoEndpoint userInfoEndpoint, @Nullable LoginInitiationEndpoint loginInitiationEndpoint) { + this.active = active; + this.sessionStage = sessionStage; + this.csrfDefence = csrfDefence; + this.stepUpCoordinator = stepUpCoordinator; + this.callbackEndpoint = callbackEndpoint; + this.logoutEndpoint = logoutEndpoint; + this.backchannelLogoutEndpoint = backchannelLogoutEndpoint; + this.userInfoEndpoint = userInfoEndpoint; + this.loginInitiationEndpoint = loginInitiationEndpoint; + } + + /** + * Assembles an active server-mode runtime with every reserved-endpoint handler and the session + * stage-4 runtime wired. + * + * @param sessionStage the {@code require: session} stage-4 runtime + * @param csrfDefence the fixed CSRF defence for unsafe-method session requests + * @param stepUpCoordinator the RFC 9470 step-up coordinator (D7) for upstream challenges + * @param callbackEndpoint the OIDC auth-code callback handler + * @param logoutEndpoint the lazy RP-initiated logout handler (resolves the + * discovery-dependent {@code end_session_endpoint} on first use) + * @param backchannelLogoutEndpoint the OIDC back-channel logout receiver + * @param userInfoEndpoint the session/user-info fold handler (D11) + * @param loginInitiationEndpoint the login-initiation fold handler (D12) + */ + @SuppressWarnings("java:S107") // wiring holder assembled once by BffRuntimeProducer + public BffRuntime(SessionAuthenticationStage sessionStage, CsrfDefence csrfDefence, + StepUpCoordinator stepUpCoordinator, CallbackEndpoint callbackEndpoint, + Supplier logoutEndpoint, BackchannelLogoutEndpoint backchannelLogoutEndpoint, + UserInfoEndpoint userInfoEndpoint, LoginInitiationEndpoint loginInitiationEndpoint) { + this(true, + Objects.requireNonNull(sessionStage, "sessionStage"), + Objects.requireNonNull(csrfDefence, "csrfDefence"), + Objects.requireNonNull(stepUpCoordinator, "stepUpCoordinator"), + Objects.requireNonNull(callbackEndpoint, "callbackEndpoint"), + Objects.requireNonNull(logoutEndpoint, "logoutEndpoint"), + Objects.requireNonNull(backchannelLogoutEndpoint, "backchannelLogoutEndpoint"), + Objects.requireNonNull(userInfoEndpoint, "userInfoEndpoint"), + Objects.requireNonNull(loginInitiationEndpoint, "loginInitiationEndpoint")); + } + + /** + * @return the inert runtime — a gateway serving no server-mode BFF variant. It reports + * {@link #isActive()} {@code false}, exposes no session stage, and dispatches nothing. + */ + public static BffRuntime inert() { + return new BffRuntime(false, null, null, null, null, null, null, null, null); + } + + /** + * @return the RFC 9470 step-up coordinator (D7) that handles an upstream + * {@code insufficient_user_authentication} challenge for a {@code require: session} route + * @throws IllegalStateException when this runtime is inert (no server-mode BFF variant) + */ + public StepUpCoordinator stepUpCoordinator() { + if (stepUpCoordinator == null) { + throw new IllegalStateException("inert BFF runtime exposes no step-up coordinator"); + } + return stepUpCoordinator; + } + + /** + * @return {@code true} when the gateway serves a server-mode BFF variant (an {@code oidc} block + * with {@code session.mode=server} was configured); {@code false} for a bearer-only gateway + */ + public boolean isActive() { + return active; + } + + /** + * @return the {@code require: session} stage-4 runtime the edge wires into the session-aware + * {@code AuthenticationStage} + * @throws IllegalStateException when this runtime is inert (no server-mode BFF variant) + */ + public SessionAuthenticationStage sessionStage() { + if (sessionStage == null) { + throw new IllegalStateException("inert BFF runtime exposes no session stage"); + } + return sessionStage; + } + + /** + * @return the fixed CSRF defence the edge enforces on unsafe-method {@code require: session} + * requests + * @throws IllegalStateException when this runtime is inert (no server-mode BFF variant) + */ + public CsrfDefence csrfDefence() { + if (csrfDefence == null) { + throw new IllegalStateException("inert BFF runtime exposes no CSRF defence"); + } + return csrfDefence; + } + + /** + * Dispatches a matched reserved OIDC path to its handler and normalizes the outcome for the edge. + * + * @param kind the reserved endpoint the {@code ReservedPathRegistry} resolved for the request + * @param req the framework-agnostic request pieces the handlers consume + * @param now the reference instant (TTL anchor for session / pending resolution) + * @return the normalized response the edge renders verbatim + * @throws IllegalStateException when this runtime is inert (no reserved handler is wired) + */ + public ReservedHttpResponse dispatch(ReservedEndpoint kind, ReservedHttpRequest req, Instant now) { + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(req, "req"); + Objects.requireNonNull(now, "now"); + if (!active) { + throw new IllegalStateException("inert BFF runtime cannot dispatch a reserved path"); + } + return switch (kind) { + case CALLBACK -> + render(requireNonNull(callbackEndpoint).handle(callbackParameters(req), req.cookieHeader(), now)); + case LOGOUT -> render(requireNonNull(logoutEndpoint).get().logout(req.cookieHeader(), now)); + case LOGOUT_RETURN -> + render(requireNonNull(logoutEndpoint).get().completeReturn(req.stateParam(), req.cookieHeader())); + case BACKCHANNEL_LOGOUT -> + render(requireNonNull(backchannelLogoutEndpoint).receive(req.rawFormBody(), now)); + case USER_INFO -> render(requireNonNull(userInfoEndpoint).handle(req.cookieHeader(), req.claimsParam(), now)); + case LOGIN -> + render(requireNonNull(loginInitiationEndpoint).initiate(req.returnUrlParam(), req.cookieHeader(), now)); + }; + } + + /** + * Selects the raw parameter string the callback parses. Keycloak drives the OIDC auth-code + * callback with {@code response_mode=form_post}: after a successful login it returns a 200 + * auto-submit form whose {@code code}/{@code state} are POSTed to the {@code redirect_uri} as an + * {@code application/x-www-form-urlencoded} body — never a 302 with the code in the query. A POST + * callback therefore parses the raw form body; a GET callback parses the raw query. Both are the + * same urlencoded parameter shape, so the single {@code CallbackParameters.parse} the callback + * uses handles either source and preserves the BFF-13 duplicate-parameter rejection (the collapsed + * {@code of(Map)} form is never taken). + */ + private static String callbackParameters(ReservedHttpRequest req) { + final String raw = req.isFormPost() ? req.rawFormBody() : req.rawQuery(); + if (raw == null) { + return ""; + } + return raw; + } + + private static ReservedHttpResponse render(CallbackEndpoint.CallbackOutcome outcome) { + return new ReservedHttpResponse(outcome.status(), outcome.location().orElse(null), null, Map.of(), + outcome.setCookieHeaders()); + } + + private static ReservedHttpResponse render(LogoutEndpoint.LogoutOutcome outcome) { + return new ReservedHttpResponse(outcome.status(), outcome.location().orElse(null), null, Map.of(), + outcome.setCookieHeaders()); + } + + private static ReservedHttpResponse render(BackchannelLogoutEndpoint.BackchannelLogoutOutcome outcome) { + // The OIDC back-channel logout response — success and error alike — must be served uncacheable. + return new ReservedHttpResponse(outcome.status(), null, null, Map.of(CACHE_CONTROL, NO_STORE), List.of()); + } + + private static ReservedHttpResponse render(UserInfoEndpoint.UserInfoOutcome outcome) { + return new ReservedHttpResponse(outcome.status(), null, JsonWriter.toJson(outcome.body()), outcome.headers(), + List.of()); + } + + private static ReservedHttpResponse render(LoginInitiationEndpoint.LoginInitiationOutcome outcome) { + return new ReservedHttpResponse(outcome.status(), outcome.location(), null, Map.of(), + outcome.setCookieHeaders()); + } + + private static T requireNonNull(@Nullable T value) { + return Objects.requireNonNull(value, "active BFF runtime handler must be wired"); + } + + /** + * The framework-agnostic request pieces a reserved endpoint consumes, extracted by the edge from + * the raw request. Every field is optional at this level — each handler reads only the pieces it + * needs (the callback reads {@link #rawQuery} and {@link #cookieHeader}, the back-channel receiver + * reads {@link #rawFormBody}, the user-info fold reads {@link #claimsParam}, and so on). + * + * @param rawQuery the raw query string (without the leading {@code ?}), never map-collapsed + * — a GET callback re-parses it to re-detect a duplicated {@code code}/{@code + * state} (BFF-13) + * @param cookieHeader the raw request {@code Cookie} header value, may be absent + * @param claimsParam the raw {@code claims} selector for the user-info fold, may be absent + * @param returnUrlParam the raw post-login {@code return_to} target for the login fold, may be absent + * @param stateParam the {@code state} the IdP returned on a logout-return leg, may be absent + * @param rawFormBody the raw {@code application/x-www-form-urlencoded} body — carried for + * back-channel logout and for a {@code response_mode=form_post} callback POST + * (the {@code code}/{@code state} arrive here, not in {@link #rawQuery}), may + * be absent + * @param httpMethod the request HTTP method; a {@code POST} to the callback is a + * {@code response_mode=form_post} submission. Absent normalizes to + * {@code GET} (the CSRF-safe default) + * @author API Sheriff Team + * @since 1.0 + */ + public record ReservedHttpRequest(String rawQuery, @Nullable String cookieHeader, + @Nullable String claimsParam, @Nullable String returnUrlParam, @Nullable String stateParam, + @Nullable String rawFormBody, String httpMethod) { + + /** + * Canonical constructor normalizing an absent raw query to the empty string and an absent HTTP + * method to {@code GET} (the CSRF-safe default). + */ + public ReservedHttpRequest { + rawQuery = Objects.requireNonNullElse(rawQuery, ""); + httpMethod = Objects.requireNonNullElse(httpMethod, "GET"); + } + + /** + * @return {@code true} when this is a {@code POST} — an OIDC {@code response_mode=form_post} + * callback, whose {@code code}/{@code state} arrive in {@link #rawFormBody} rather than + * {@link #rawQuery} + */ + public boolean isFormPost() { + return "POST".equalsIgnoreCase(httpMethod); + } + } + + /** + * The normalized response the edge renders for a dispatched reserved path: the HTTP status, an + * optional redirect {@code Location}, an optional already-serialized JSON body (the user-info + * fold), the fixed response headers, and the {@code Set-Cookie} header values to emit. Token + * material never appears here — only opaque cookie headers, the redirect location, and allowlisted + * disclosure. + * + * @param status the HTTP status the edge returns + * @param location the redirect target, present only for a redirect outcome + * @param jsonBody the already-serialized JSON body, present only for the user-info fold + * @param headers the fixed response headers the edge emits verbatim + * @param setCookieHeaders the {@code Set-Cookie} header values to emit + * @author API Sheriff Team + * @since 1.0 + */ + public record ReservedHttpResponse(int status, @Nullable + String location, @Nullable + String jsonBody, + Map headers, List setCookieHeaders) { + + /** + * Canonical constructor defensively copying the header and cookie collections. + */ + public ReservedHttpResponse { + headers = headers == null ? Map.of() : Map.copyOf(headers); + setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders); + } + + /** + * @return the optional redirect {@code Location} + */ + public Optional locationOptional() { + return Optional.ofNullable(location); + } + + /** + * @return the optional serialized JSON body + */ + public Optional jsonBodyOptional() { + return Optional.ofNullable(jsonBody); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/JsonWriter.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/JsonWriter.java new file mode 100644 index 00000000..c5701920 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/JsonWriter.java @@ -0,0 +1,126 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.runtime; + +import java.util.Collection; +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +/** + * A minimal, dependency-free JSON serializer for the curated user-info disclosure the + * {@link BffRuntime} renders (D11). It serializes only the value shapes that disclosure produces — + * an insertion-ordered {@link Map} of allowlisted claims and session metadata whose leaves are + * {@link String}, {@link Number}, {@link Boolean}, {@link Collection}, nested {@link Map}, or + * {@code null} — with RFC 8259 string escaping. It is deliberately not a general-purpose JSON + * library: the response body is a small, gateway-controlled structure, and keeping the writer here + * avoids a runtime JSON dependency on the data-plane edge. + * + * @author API Sheriff Team + * @since 1.0 + */ +final class JsonWriter { + + private JsonWriter() { + } + + /** + * Serializes a gateway-controlled value graph to a compact JSON string. + * + * @param value the value to serialize (a {@link Map}, {@link Collection}, {@link String}, + * {@link Number}, {@link Boolean}, or {@code null}) + * @return the compact JSON rendering + */ + static String toJson(@Nullable Object value) { + StringBuilder out = new StringBuilder(); + write(out, value); + return out.toString(); + } + + private static void write(StringBuilder out, @Nullable Object value) { + switch (value) { + case null -> out.append("null"); + case String string -> writeString(out, string); + case Boolean bool -> out.append(bool.booleanValue()); + case Number number -> writeNumber(out, number); + case Map map -> writeObject(out, map); + case Collection collection -> writeArray(out, collection); + default -> writeString(out, String.valueOf(value)); + } + } + + private static void writeNumber(StringBuilder out, Number number) { + // RFC 8259 defines no NaN / Infinity token, so a non-finite floating-point value would emit an + // invalid JSON literal. Normalize it to null so the gateway-controlled body is always valid JSON. + if ((number instanceof Double || number instanceof Float) && !Double.isFinite(number.doubleValue())) { + out.append("null"); + return; + } + out.append(number); + } + + private static void writeObject(StringBuilder out, Map map) { + out.append('{'); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) { + out.append(','); + } + writeString(out, String.valueOf(entry.getKey())); + out.append(':'); + write(out, entry.getValue()); + first = false; + } + out.append('}'); + } + + private static void writeArray(StringBuilder out, Collection collection) { + out.append('['); + boolean first = true; + for (Object element : collection) { + if (!first) { + out.append(','); + } + write(out, element); + first = false; + } + out.append(']'); + } + + private static void writeString(StringBuilder out, String value) { + out.append('"'); + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + switch (character) { + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + case '\b' -> out.append("\\b"); + case '\f' -> out.append("\\f"); + default -> { + if (character < 0x20) { + out.append("\\u%04x".formatted((int) character)); + } else { + out.append(character); + } + } + } + } + out.append('"'); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStage.java new file mode 100644 index 00000000..b432993e --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStage.java @@ -0,0 +1,257 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.runtime; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + + +import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec; +import de.cuioss.sheriff.gateway.bff.session.SessionRecord; +import de.cuioss.sheriff.gateway.bff.session.SessionStore; +import de.cuioss.sheriff.gateway.events.EventType; +import de.cuioss.sheriff.gateway.events.GatewayException; +import de.cuioss.sheriff.gateway.pipeline.PipelineRequest; +import de.cuioss.sheriff.gateway.routing.RouteRuntime; +import de.cuioss.tools.logging.CuiLogger; + +/** + * Stage 4 — the {@code require: session} runtime (D4), the server-session counterpart of the + * offline bearer validation in {@code AuthenticationStage}. It replaces the boot-time rejection the + * {@code RouteRuntimeAssembler} used to raise for session routes. + *

    + * For a request selected onto a {@code require: session} route the stage: + *

      + *
    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(route, session); + request.mediatedBearer(session.accessToken()); + } + + private Optional resolveSession(PipelineRequest request, Instant now) { + return sessionCookieCodec.readSessionId(request.firstHeader(COOKIE_HEADER).orElse(null)) + .flatMap(sessionId -> sessionStore.resolve(sessionId, now)); + } + + private void enforceScopes(RouteRuntime route, SessionRecord session) { + List requiredScopes = route.getEffectiveAuth().requiredScopes(); + if (!requiredScopes.isEmpty() && !grantedScopes.provides(session.accessToken(), requiredScopes)) { + throw new GatewayException(EventType.SCOPE_MISSING, + "Mediated token missing a required scope for route " + route.getId()); + } + } + + private void challengeUnauthenticated(PipelineRequest request, RouteRuntime route, Instant now) { + if (acceptsHtml(request)) { + LoginChallenge challenge = loginInitiation.initiate(returnUrl(request), now); + request.responseHeaders().put(LOCATION_HEADER, challenge.location()); + challenge.setCookieHeaders().stream().findFirst() + .ifPresent(cookie -> request.responseHeaders().put(SET_COOKIE_HEADER, cookie)); + request.shortCircuit(FOUND); + LOGGER.debug("Unauthenticated navigation on require:session route %s — redirecting into login", + route.getId()); + return; + } + throw new GatewayException(EventType.TOKEN_MISSING, + "No live session for require:session route " + route.getId()); + } + + private static boolean acceptsHtml(PipelineRequest request) { + return request.headerValues(ACCEPT_HEADER).stream() + .anyMatch(value -> value.toLowerCase(Locale.ROOT).contains(TEXT_HTML)); + } + + private static String returnUrl(PipelineRequest request) { + String canonicalPath = request.canonicalPath(); + return canonicalPath != null ? canonicalPath : request.requestPath(); + } + + private static RouteRuntime requireSelectedRoute(PipelineRequest request) { + RouteRuntime route = request.selectedRoute(); + if (route == null) { + throw new IllegalStateException("Session authentication requires the route selected at stage 2"); + } + return route; + } + + /** + * The single-flight near-expiry refresh seam (the D9 hook). The session runtime binds it to the + * refresh coordinator, which owns the near-expiry decision, single-flight coalescing per session, + * and refresh-token rotation. The unwired binding returns the session unchanged, so a gateway + * without the refresh coordinator injects the current mediated token verbatim. + * + * @author API Sheriff Team + * @since 1.0 + */ + @FunctionalInterface + public interface TokenRefresh { + + /** + * Returns the session to mediate from, refreshing its mediated token when near expiry. + * + * @param session the resolved live session + * @param now the reference instant + * @return the same session, or a refreshed copy carrying the rotated token material + */ + SessionRecord refreshIfNeeded(SessionRecord session, Instant now); + } + + /** + * The mediated-token scope-membership seam. The session runtime binds it to the engine token + * parsing so {@code required_scopes} is enforced against the mediated access token's granted + * scopes; a test binds it to a fixed predicate. Keeping the token parsing behind the seam + * decouples this stage from the engine and keeps it unit-testable. + * + * @author API Sheriff Team + * @since 1.0 + */ + @FunctionalInterface + public interface GrantedScopes { + + /** + * @param accessToken the mediated access token + * @param requiredScopes the route's non-empty {@code required_scopes} + * @return {@code true} when the token grants every required scope + */ + boolean provides(String accessToken, List requiredScopes); + } + + /** + * The auth-code-flow initiation seam for a navigation redirect. The session runtime binds it to + * the login flow (which drives the engine authorization, persists the pending record, and mints + * the browser-binding cookie); a test binds it to a hand-built challenge. + * + * @author API Sheriff Team + * @since 1.0 + */ + @FunctionalInterface + public interface LoginInitiation { + + /** + * Initiates a fresh login for an unauthenticated navigation request. + * + * @param returnUrl the post-login return target (the path the browser was navigating to) + * @param now the reference instant (the pending record's TTL anchor) + * @return the redirect target and the browser-binding {@code Set-Cookie} + */ + LoginChallenge initiate(String returnUrl, Instant now); + } + + /** + * The framework-agnostic result of a login initiation: the {@code 302} redirect target and the + * browser-binding {@code Set-Cookie} header(s) to emit. Token material never appears here. + * + * @param location the IdP authorization URL to redirect the browser to + * @param setCookieHeaders the browser-binding {@code Set-Cookie} header values (the single binding cookie) + * @author API Sheriff Team + * @since 1.0 + */ + public record LoginChallenge(String location, List setCookieHeaders) { + + /** + * Canonical constructor rejecting an absent location and defensively copying the cookies. + */ + public LoginChallenge { + Objects.requireNonNull(location, "location"); + setCookieHeaders = setCookieHeaders == null ? List.of() : List.copyOf(setCookieHeaders); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/package-info.java new file mode 100644 index 00000000..de59a3f3 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/package-info.java @@ -0,0 +1,37 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * The {@code require: session} stage-4 runtime (D4) — the server-session counterpart of the offline + * bearer validation. + *

    + * {@link de.cuioss.sheriff.gateway.bff.runtime.SessionAuthenticationStage} resolves the opaque + * session cookie to a live session, offers it to the single-flight refresh seam (the D9 hook), + * enforces {@code required_scopes} against the mediated token's granted scopes ({@code 403}), and + * records the mediated access token for automatic upstream {@code Authorization: Bearer} injection. + * An unauthenticated request is content-negotiated: a navigation request is redirected into the + * auth-code flow, everything else is challenged {@code 401} {@code application/problem+json}. + *

    + * The stage is framework-agnostic and driven through seams (refresh, scope membership, login + * initiation), so it is unit-testable without a container or a live IdP; the session runtime binds + * the seams to the engine and the request/response edge. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.gateway.bff.runtime; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/InMemorySessionStore.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/InMemorySessionStore.java new file mode 100644 index 00000000..146bdad1 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/InMemorySessionStore.java @@ -0,0 +1,165 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.session; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * The single-node in-memory {@link SessionStore} — the only supported store for + * {@code mode: server} (D3). + *

    + * Sessions are keyed by their opaque id in a primary map. Two secondary indexes — by IdP + * {@code sid} and by {@code sub} — give O(1) back-channel logout destruction without scanning + * the primary map. Every removal path (direct destroy, back-channel destroy, lazy TTL eviction, + * periodic sweep) keeps the indexes consistent through a single {@link #removeInternal} seam. + *

    + * The absolute TTL is enforced two ways with no per-session timer threads: + * lazily on {@link #resolve} (an expired session is evicted as it is looked up) and by an + * operator/scheduler-driven {@link #sweepExpired}. A documented {@code maxSessions} bound caps + * the live-session count (a capacity ceiling for the operator's memory math); creating a session + * beyond the bound is refused fail-closed. Every operation is guarded by the instance monitor. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class InMemorySessionStore implements SessionStore { + + private final int maxSessions; + private final Map byId = new HashMap<>(); + private final Map> bySid = new HashMap<>(); + private final Map> bySub = new HashMap<>(); + + /** + * Creates a store bounded to {@code maxSessions} live sessions. + * + * @param maxSessions the hard capacity bound; must be positive + * @throws IllegalArgumentException when {@code maxSessions} is not positive + */ + public InMemorySessionStore(int maxSessions) { + if (maxSessions <= 0) { + throw new IllegalArgumentException("maxSessions must be positive, but was " + maxSessions); + } + this.maxSessions = maxSessions; + } + + @Override + public synchronized void create(SessionRecord session) { + Objects.requireNonNull(session, "session"); + if (byId.size() >= maxSessions) { + throw new IllegalStateException("session store is at its max-session bound of " + maxSessions); + } + byId.put(session.sessionId(), session); + index(bySub, session.sub(), session.sessionId()); + session.sid().ifPresent(sid -> index(bySid, sid, session.sessionId())); + } + + @Override + public synchronized Optional resolve(String sessionId, Instant now) { + Objects.requireNonNull(sessionId, "sessionId"); + Objects.requireNonNull(now, "now"); + SessionRecord session = byId.get(sessionId); + if (session == null) { + return Optional.empty(); + } + if (session.isExpired(now)) { + removeInternal(sessionId); + return Optional.empty(); + } + return Optional.of(session); + } + + @Override + public synchronized void destroyById(String sessionId) { + Objects.requireNonNull(sessionId, "sessionId"); + removeInternal(sessionId); + } + + @Override + public synchronized int destroyBySid(String sid) { + Objects.requireNonNull(sid, "sid"); + return removeAll(bySid.get(sid)); + } + + @Override + public synchronized int destroyBySub(String sub) { + Objects.requireNonNull(sub, "sub"); + return removeAll(bySub.get(sub)); + } + + @Override + public synchronized int sweepExpired(Instant now) { + Objects.requireNonNull(now, "now"); + List expired = new ArrayList<>(); + for (Map.Entry entry : byId.entrySet()) { + if (entry.getValue().isExpired(now)) { + expired.add(entry.getKey()); + } + } + expired.forEach(this::removeInternal); + return expired.size(); + } + + /** + * @return the current number of live sessions + */ + public synchronized int size() { + return byId.size(); + } + + // java:S2589 — bySub/bySid.get() returns null for an unknown sub/sid, so the null guard is + // load-bearing (removeAll is called with the raw Map.get result); the analyzer misjudges it. + @SuppressWarnings("java:S2589") + private int removeAll(Set sessionIds) { + if (sessionIds == null || sessionIds.isEmpty()) { + return 0; + } + List snapshot = new ArrayList<>(sessionIds); + snapshot.forEach(this::removeInternal); + return snapshot.size(); + } + + private void removeInternal(String sessionId) { + SessionRecord session = byId.remove(sessionId); + if (session == null) { + return; + } + deindex(bySub, session.sub(), sessionId); + session.sid().ifPresent(sid -> deindex(bySid, sid, sessionId)); + } + + private static void index(Map> map, String key, String sessionId) { + map.computeIfAbsent(key, unused -> new HashSet<>()).add(sessionId); + } + + private static void deindex(Map> map, String key, String sessionId) { + Set sessionIds = map.get(key); + if (sessionIds == null) { + return; + } + sessionIds.remove(sessionId); + if (sessionIds.isEmpty()) { + map.remove(key); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionCookieCodec.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionCookieCodec.java new file mode 100644 index 00000000..a6840aea --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionCookieCodec.java @@ -0,0 +1,109 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.session; + +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +import org.jspecify.annotations.Nullable; + +/** + * Encodes and reads the opaque server-session cookie (D3). + *

    + * The cookie carries only the opaque {@link SessionRecord#sessionId()} — never + * any token material, which stays server-side. It is hardened by construction: the + * {@code __Host-} prefix (browser-honoured only with {@code Secure} + {@code Path=/} + no + * {@code Domain}), {@code HttpOnly} (no script access), and {@code SameSite=Lax} (survives a + * top-level navigation while blocking cross-site sends). The cookie name is operator-configurable + * ({@code session.cookie_name}); {@link #DEFAULT_COOKIE_NAME} is the {@code __Host-} default. + *

    + * The codec is framework-agnostic: it produces and parses raw header values with no JAX-RS/Vert.x + * coupling, so it is unit-testable without a container. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class SessionCookieCodec { + + /** The default {@code __Host-}-prefixed session-cookie name. */ + public static final String DEFAULT_COOKIE_NAME = "__Host-sheriff-session"; + + private final String cookieName; + private final Duration maxAge; + + /** + * Creates a codec for the given cookie name and lifetime. + * + * @param cookieName the cookie name (normally {@link #DEFAULT_COOKIE_NAME} or the configured + * {@code session.cookie_name}) + * @param maxAge the cookie {@code Max-Age} + */ + public SessionCookieCodec(String cookieName, Duration maxAge) { + this.cookieName = requireNonBlank(cookieName); + this.maxAge = Objects.requireNonNull(maxAge, "maxAge"); + } + + /** + * Builds the {@code Set-Cookie} header value carrying the opaque {@code sessionId}. + * + * @param sessionId the opaque session id + * @return the hardened {@code Set-Cookie} header value + */ + public String toSetCookieHeader(String sessionId) { + Objects.requireNonNull(sessionId, "sessionId"); + return "%s=%s; Max-Age=%d; Path=/; Secure; HttpOnly; SameSite=Lax" + .formatted(cookieName, sessionId, maxAge.toSeconds()); + } + + /** + * Builds the {@code Set-Cookie} header value that clears the session cookie (logout). + * + * @return the clearing {@code Set-Cookie} header value + */ + public String toClearingSetCookieHeader() { + return cookieName + "=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax"; + } + + /** + * Reads the opaque session id out of a request {@code Cookie} header value. + * + * @param cookieHeader the raw {@code Cookie} header value (may be absent/blank) + * @return the session id when the session cookie is present with a non-empty value; empty otherwise + */ + public Optional readSessionId(@Nullable String cookieHeader) { + if (cookieHeader == null || cookieHeader.isBlank()) { + return Optional.empty(); + } + for (String pair : cookieHeader.split(";")) { + String trimmed = pair.trim(); + int equals = trimmed.indexOf('='); + if (equals > 0 && cookieName.equals(trimmed.substring(0, equals))) { + String value = trimmed.substring(equals + 1); + return value.isEmpty() ? Optional.empty() : Optional.of(value); + } + } + return Optional.empty(); + } + + private static String requireNonBlank(String cookieName) { + Objects.requireNonNull(cookieName, "cookieName"); + if (cookieName.isBlank()) { + throw new IllegalArgumentException("cookieName must not be blank"); + } + return cookieName; + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionRecord.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionRecord.java new file mode 100644 index 00000000..f12d9a27 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionRecord.java @@ -0,0 +1,110 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.session; + +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Base64; +import java.util.Objects; +import java.util.Optional; + +import lombok.Builder; + +/** + * A single server-side session (D3, {@code mode: server}). + *

    + * The record holds the mediated token material — the access token injected as + * {@code Authorization: Bearer} on proxied requests, the optional refresh token, and the raw + * ID token retained as the {@code id_token_hint} at logout — plus the session metadata + * ({@code expiry}, {@code acr}, {@code auth_time}, {@code sid}, {@code sub}). The token material + * never leaves the server: the browser only ever carries the opaque + * {@link #sessionId()} in the session cookie, so {@link #toString()} redacts every credential + * (the session id itself is a bearer credential) to keep tokens out of logs and stack traces. + *

    + * {@link #sid()} and {@link #sub()} back the store's secondary index for O(1) back-channel + * logout destruction. + * + * @param sessionId the opaque session id (store key and session-cookie value) + * @param accessToken the mediated access token injected as the upstream bearer + * @param refreshToken the refresh token, empty when the IdP granted none + * @param idToken the raw ID token retained for the logout {@code id_token_hint} + * @param sub the subject claim (back-channel destroy-by-sub key) + * @param sid the IdP session id claim, empty when absent (back-channel destroy-by-sid key) + * @param expiresAt the absolute session expiry (from login), independent of activity + * @param acr the authentication context class, empty when absent + * @param authTime the IdP authentication instant, empty when absent + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record SessionRecord(String sessionId, String accessToken, Optional refreshToken, String idToken, +String sub, Optional sid, Instant expiresAt, Optional acr, Optional authTime) { + + private static final String REDACTED = "***REDACTED***"; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final int SESSION_ID_BYTES = 32; + + /** + * Canonical constructor rejecting absent mandatory components and normalizing absent + * optionals to {@link Optional#empty()}. + */ + public SessionRecord { + Objects.requireNonNull(sessionId, "sessionId"); + Objects.requireNonNull(accessToken, "accessToken"); + Objects.requireNonNull(idToken, "idToken"); + Objects.requireNonNull(sub, "sub"); + Objects.requireNonNull(expiresAt, "expiresAt"); + refreshToken = Objects.requireNonNullElse(refreshToken, Optional.empty()); + sid = Objects.requireNonNullElse(sid, Optional.empty()); + acr = Objects.requireNonNullElse(acr, Optional.empty()); + authTime = Objects.requireNonNullElse(authTime, Optional.empty()); + } + + /** + * Generates a 256-bit URL-safe opaque session id. + * + * @return the base64url (unpadded) encoding of 32 secure-random bytes + */ + public static String newSessionId() { + byte[] bytes = new byte[SESSION_ID_BYTES]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** + * Whether this session has expired at {@code now} — expiry is inclusive of the boundary. + * + * @param now the reference instant + * @return {@code true} when {@code now} is at or after {@link #expiresAt()} + */ + public boolean isExpired(Instant now) { + return !now.isBefore(expiresAt); + } + + /** + * Overridden to redact every credential — the session id and all three tokens. The default + * record {@code toString()} would otherwise print the bearer session id and the raw token + * material into any log line, exception message, or debugger view. + * + * @return a string representation with all credential-bearing fields redacted + */ + @Override + public String toString() { + return "SessionRecord[sessionId=%s, accessToken=%s, refreshToken=%s, idToken=%s, sub=%s, sid=%s, expiresAt=%s, acr=%s, authTime=%s]" + .formatted(REDACTED, REDACTED, refreshToken.isPresent() ? "Optional[" + REDACTED + "]" : "Optional.empty", + REDACTED, sub, sid, expiresAt, acr, authTime); + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionStore.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionStore.java new file mode 100644 index 00000000..ad6036cc --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/SessionStore.java @@ -0,0 +1,87 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.session; + +import java.time.Instant; +import java.util.Optional; + +/** + * The server-side session store contract (D3). + *

    + * A session is created after a successful IdP login, resolved by its opaque id on every + * subsequent request, and destroyed either directly (RP-initiated logout) or via the IdP's + * {@code sid}/{@code sub} on a back-channel logout. The {@code sid}/{@code sub} destruction is + * O(1) through the implementation's secondary index — a back-channel logout must not scan the + * whole store. {@code memory} is the only implementation ({@link InMemorySessionStore}); a + * shared/external store is deliberately unsupported (single-node / sticky-session deployments). + * + * @author API Sheriff Team + * @since 1.0 + */ +public interface SessionStore { + + /** + * Stores a freshly created session. + * + * @param session the session to store + * @throws IllegalStateException when the store is at its max-session capacity bound + */ + void create(SessionRecord session); + + /** + * Resolves a live session by its opaque id, enforcing the absolute TTL lazily: an expired + * session is evicted and reported as absent. + * + * @param sessionId the opaque session id (from the session cookie) + * @param now the reference instant for the TTL check + * @return the live session; empty when unknown or expired + */ + Optional resolve(String sessionId, Instant now); + + /** + * Destroys the session with the given opaque id (RP-initiated logout). A no-op when absent. + * + * @param sessionId the opaque session id + */ + void destroyById(String sessionId); + + /** + * Destroys every session carrying the given IdP {@code sid} (back-channel logout), O(1) via + * the secondary index. + * + * @param sid the IdP session id claim + * @return the number of sessions destroyed + */ + int destroyBySid(String sid); + + /** + * Destroys every session for the given subject (back-channel logout without a {@code sid}), + * O(1) via the secondary index. + * + * @param sub the subject claim + * @return the number of sessions destroyed + */ + int destroyBySub(String sub); + + /** + * Removes every session expired at {@code now}. Invoked by a periodic sweep so eviction does + * not rely on access alone — there are no per-session timer threads. + * + * @param now the reference instant + * @return the number of sessions swept + */ + int sweepExpired(Instant now); +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/package-info.java new file mode 100644 index 00000000..f79818ec --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/session/package-info.java @@ -0,0 +1,42 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * The server-side session store and its opaque cookie (D3, {@code mode: server}). + *

    + * After a successful IdP login the gateway holds the mediated tokens server-side and hands the + * browser only an opaque handle: + *

      + *
    • {@link de.cuioss.sheriff.gateway.bff.session.SessionRecord} holds the access, refresh, + * and raw ID tokens plus session metadata; token material never leaves the server and is + * redacted from {@code toString()}.
    • + *
    • {@link de.cuioss.sheriff.gateway.bff.session.SessionStore} is the store contract, and + * {@link de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore} its only + * implementation — keyed by opaque id, with secondary indexes by {@code sid}/{@code sub} + * for O(1) back-channel destruction, an absolute TTL enforced lazily plus by a periodic + * sweep (no per-session timer threads), and a documented max-session bound.
    • + *
    • {@link de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec} sets and reads the + * hardened {@code __Host-} session cookie carrying only the opaque id.
    • + *
    + * The classes are framework-agnostic (no CDI, no JAX-RS/Vert.x coupling); the runtime and + * reserved-endpoint packages wire them to the request/response edge. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.gateway.bff.session; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/OidcConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/OidcConfig.java index 6fb81c34..181584cf 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/OidcConfig.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/OidcConfig.java @@ -35,13 +35,17 @@ * @param logout the logout settings, empty when omitted * @param session the session settings, empty when omitted * @param stepUp the step-up authentication settings, empty when omitted + * @param userInfo the session/user-info reserved-endpoint settings, empty + * when omitted + * @param login the login-initiation reserved-path settings, empty when + * omitted * @author API Sheriff Team * @since 1.0 */ @Builder public record OidcConfig(Optional issuer, Optional clientId, Optional clientSecret, List scopes, Optional redirectUri, Optional logout, Optional session, -Optional stepUp) { +Optional stepUp, Optional userInfo, Optional login) { /** * Canonical constructor defensively copying {@code scopes} and normalizing @@ -56,6 +60,8 @@ public record OidcConfig(Optional issuer, Optional clientId, Opt logout = Objects.requireNonNullElse(logout, Optional.empty()); session = Objects.requireNonNullElse(session, Optional.empty()); stepUp = Objects.requireNonNullElse(stepUp, Optional.empty()); + userInfo = Objects.requireNonNullElse(userInfo, Optional.empty()); + login = Objects.requireNonNullElse(login, Optional.empty()); } /** @@ -70,8 +76,9 @@ public record OidcConfig(Optional issuer, Optional clientId, Opt */ @Override public String toString() { - return "OidcConfig[issuer=%s, clientId=%s, clientSecret=%s, scopes=%s, redirectUri=%s, logout=%s, session=%s, stepUp=%s]" - .formatted(issuer, clientId, redact(clientSecret), scopes, redirectUri, logout, session, stepUp); + return "OidcConfig[issuer=%s, clientId=%s, clientSecret=%s, scopes=%s, redirectUri=%s, logout=%s, session=%s, stepUp=%s, userInfo=%s, login=%s]" + .formatted(issuer, clientId, redact(clientSecret), scopes, redirectUri, logout, session, stepUp, userInfo, + login); } /** @@ -127,13 +134,16 @@ public record Logout(Optional path, Optional postLogoutRedirectU * omitted * @param csrf the CSRF settings, empty when omitted * @param refresh the token-refresh settings, empty when omitted + * @param maxSessions the server-mode upper bound on concurrently stored + * sessions — a DoS guard on the in-memory store, empty + * when omitted * @author API Sheriff Team * @since 1.0 */ @Builder public record Session(Optional mode, Optional store, Optional cookieName, Optional encryptionKey, Optional previousKey, Optional ttlSeconds, - Optional csrf, Optional refresh) { + Optional csrf, Optional refresh, Optional maxSessions) { /** * Canonical constructor normalizing absent components to {@link Optional#empty()}. @@ -147,6 +157,7 @@ public record Session(Optional mode, Optional store, Optional mode, Optional store, Optional enabled, Optional honorUpstreamC honorUpstreamChallenge = Objects.requireNonNullElse(honorUpstreamChallenge, Optional.empty()); } } + + /** + * Session/user-info reserved-endpoint settings (fold). Configures the curated + * identity view the gateway serves to the browser, capped by an operator-owned + * claim allowlist whose secure default is closed: an empty {@code allowedClaims} + * discloses nothing, so the operator — never the browser client — widens + * disclosure. + * + * @param path the gateway-served user-info path, empty when omitted + * @param allowedClaims the operator claim allowlist; empty is the secure closed + * default that discloses nothing + * @param defaultView the curated default-view claim selector returned when the + * caller requests no explicit claims; every entry must lie + * within {@code allowedClaims} + * @author API Sheriff Team + * @since 1.0 + */ + @Builder + public record UserInfo(Optional path, List allowedClaims, List defaultView) { + + /** + * Canonical constructor defensively copying the claim lists and normalizing an + * absent path to {@link Optional#empty()}. + */ + public UserInfo { + path = Objects.requireNonNullElse(path, Optional.empty()); + allowedClaims = allowedClaims == null ? List.of() : List.copyOf(allowedClaims); + defaultView = defaultView == null ? List.of() : List.copyOf(defaultView); + } + } + + /** + * Login-initiation reserved-path settings (fold). Mirrors the {@link Logout} + * shape so the login-initiation endpoint reads as {@code oidc.login.path}. + * + * @param path the gateway-served login-initiation path, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ + @Builder + public record Login(Optional path) { + + /** + * Canonical constructor normalizing an absent path to {@link Optional#empty()}. + */ + public Login { + path = Objects.requireNonNullElse(path, Optional.empty()); + } + } } diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidator.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidator.java index 1f639cc2..9f8d8acc 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidator.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/validation/ConfigValidator.java @@ -106,6 +106,14 @@ public final class ConfigValidator { private static final String REQUIRE_NONE = "none"; private static final String REQUIRE_BEARER = "bearer"; private static final String REQUIRE_SESSION = "session"; + // java:S1075 — a fixed JSON-pointer into the config document (schema key), not a customizable URI/filesystem path. + @SuppressWarnings("java:S1075") + private static final String OIDC_USER_INFO_PATH_POINTER = "/oidc/user_info/path"; + private static final String OIDC_USER_INFO_DEFAULT_VIEW_POINTER = "/oidc/user_info/default_view"; + // java:S1075 — a fixed JSON-pointer into the config document (schema key), not a customizable URI/filesystem path. + @SuppressWarnings("java:S1075") + private static final String OIDC_LOGIN_PATH_POINTER = "/oidc/login/path"; + private static final String OIDC_SESSION_MAX_SESSIONS_POINTER = "/oidc/session/max_sessions"; private static final List DEFAULT_RULES = List.of( (gateway, endpoints, topology, errors) -> validateVersion(gateway, errors), @@ -125,6 +133,9 @@ public final class ConfigValidator { (gateway, endpoints, topology, errors) -> validateForwardedTrust(gateway, errors), (gateway, endpoints, topology, errors) -> validateCors(gateway, errors), (gateway, endpoints, topology, errors) -> validateSessionMode(gateway, errors), + (gateway, endpoints, topology, errors) -> validateSessionMaxSessions(gateway, errors), + (gateway, endpoints, topology, errors) -> validateUserInfo(gateway, errors), + (gateway, endpoints, topology, errors) -> validateLoginPath(gateway, errors), (gateway, endpoints, topology, errors) -> validatePassthroughHostCollision(gateway, endpoints, errors), (gateway, endpoints, topology, errors) -> validatePassthroughAliasResolvable(gateway, topology, errors), (gateway, endpoints, topology, errors) -> validateWebSocketConfig(gateway, endpoints, errors)); @@ -734,6 +745,76 @@ private static void validateSessionMode(GatewayConfig gateway, List })); } + /** + * Rule: the server-mode session store bound (D1/D3). When + * {@code oidc.session.max_sessions} is present it must be a positive integer — + * the bound is a DoS guard on the in-memory store, so a non-positive value is a + * misconfiguration. A no-op when the bound is omitted (the store applies its own + * documented default bound). + */ + private static void validateSessionMaxSessions(GatewayConfig gateway, List errors) { + gateway.oidc().flatMap(OidcConfig::session).flatMap(OidcConfig.Session::maxSessions).ifPresent(max -> { + if (max <= 0) { + errors.add(new ConfigError(GATEWAY_FILE, OIDC_SESSION_MAX_SESSIONS_POINTER, + "oidc session max_sessions must be a positive integer, but was %d".formatted(max))); + } + }); + } + + /** + * Rule: the session/user-info reserved endpoint (fold, D1). When an + * {@code oidc.user_info} block is present, its {@code path} must be an absolute + * gateway path, and every {@code default_view} claim must lie within the + * {@code allowed_claims} allowlist — the operator-owned allowlist caps + * disclosure and the default view can never exceed it. An empty allowlist is the + * secure closed default (nothing disclosed) and is not itself an error. Every + * violation collects into the shared list; the rule never fails fast. + */ + private static void validateUserInfo(GatewayConfig gateway, List errors) { + gateway.oidc().flatMap(OidcConfig::userInfo).ifPresent(userInfo -> { + userInfo.path().ifPresent(path -> { + if (!isAbsoluteGatewayPath(path)) { + errors.add(new ConfigError(GATEWAY_FILE, OIDC_USER_INFO_PATH_POINTER, + "oidc user_info path '%s' must be an absolute gateway path starting with a single '/'" + .formatted(path))); + } + }); + Set allowed = new HashSet<>(userInfo.allowedClaims()); + for (String claim : userInfo.defaultView()) { + if (!allowed.contains(claim)) { + errors.add(new ConfigError(GATEWAY_FILE, OIDC_USER_INFO_DEFAULT_VIEW_POINTER, + "oidc user_info default_view claim '%s' is not in allowed_claims; the default view cannot disclose a claim outside the operator allowlist" + .formatted(claim))); + } + } + }); + } + + /** + * Rule: the login-initiation reserved path (fold, D1). When + * {@code oidc.login.path} is present it must be an absolute gateway path — a + * schema-relative ({@code //host}) or off-path value is rejected as an + * open-redirect hazard. + */ + private static void validateLoginPath(GatewayConfig gateway, List errors) { + gateway.oidc().flatMap(OidcConfig::login).flatMap(OidcConfig.Login::path).ifPresent(path -> { + if (!isAbsoluteGatewayPath(path)) { + errors.add(new ConfigError(GATEWAY_FILE, OIDC_LOGIN_PATH_POINTER, + "oidc login path '%s' must be an absolute gateway path starting with a single '/'" + .formatted(path))); + } + }); + } + + /** + * Whether {@code path} is an absolute gateway path: it starts with a single + * {@code /} and is not a schema-relative {@code //host} URL (which would be an + * open-redirect vector for a reserved path). + */ + private static boolean isAbsoluteGatewayPath(String path) { + return path.startsWith("/") && !path.startsWith("//"); + } + private static void validatePassthroughHostCollision(GatewayConfig gateway, List endpoints, List errors) { Map passthrough = passthroughSni(gateway); diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java index d7a43136..5dc3ce74 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java @@ -19,6 +19,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.time.Duration; +import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.LinkedHashMap; import java.util.List; @@ -44,6 +45,9 @@ import de.cuioss.sheriff.gateway.asset.UpstreamAssetSource; import de.cuioss.sheriff.gateway.auth.AuthenticationStage; import de.cuioss.sheriff.gateway.auth.GatewayValidator; +import de.cuioss.sheriff.gateway.bff.reserved.ReservedPathRegistry; +import de.cuioss.sheriff.gateway.bff.reserved.ReservedPathRegistry.ReservedEndpoint; +import de.cuioss.sheriff.gateway.bff.runtime.BffRuntime; import de.cuioss.sheriff.gateway.config.model.ForwardedConfig; import de.cuioss.sheriff.gateway.config.model.GatewayConfig; import de.cuioss.sheriff.gateway.config.model.HttpMethod; @@ -78,6 +82,7 @@ import io.quarkus.runtime.ShutdownEvent; import io.quarkus.virtual.threads.VirtualThreads; import io.smallrye.faulttolerance.api.Guard; +import io.vertx.core.Future; import io.vertx.core.MultiMap; import io.vertx.core.Vertx; import io.vertx.core.buffer.Buffer; @@ -114,7 +119,9 @@ * last} so management / health routes keep working. Each request is admitted under a bounded * {@linkplain EdgeHardeningOptions#admissionCap() admission cap} before a virtual thread is * dispatched (a flood is rejected {@code 503} rather than spawning unbounded virtual threads), then - * the request stream is paused and the whole pipeline runs on a virtual thread: + * the request stream is paused and the whole pipeline runs on a virtual thread (a reserved POST path — + * the form_post callback and back-channel logout — instead has its small body read on the event loop + * first, then dispatches, so a handler never has to drain a paused stream from a virtual thread): *
      *
    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 @@ -141,13 +148,33 @@ public class GatewayEdgeRoute { private static final String PROBLEM_JSON = "application/problem+json"; private static final String DEFAULT_EMIT_MODE = "x-forwarded"; + private static final String REQUIRE_SESSION = "session"; + private static final String COOKIE_HEADER = "Cookie"; + private static final String LOCATION_HEADER = "Location"; + private static final String SET_COOKIE_HEADER = "Set-Cookie"; + private static final String CLAIMS_PARAM = "claims"; + private static final String RETURN_TO_PARAM = "return_to"; + private static final String STATE_PARAM = "state"; private static final int SERVICE_UNAVAILABLE = 503; private static final int INTERNAL_ERROR = 500; private static final int BAD_GATEWAY = 502; private static final long DRAIN_POLL_INTERVAL_MILLIS = 50L; + // Fail-closed deadline for reading a tiny reserved-POST form body (the form_post callback's + // code/state, or a back-channel logout_token). It bounds a genuinely slow/stalled body so it cannot + // pin admission indefinitely; it is deliberately generous because the body is read on a shared event + // loop that can be scheduling-starved under CPU contention, and the deadline handler still honours a + // body that has already fully arrived (see readReservedBodyThenDispatch), so a legitimate body is + // never falsely rejected — the deadline only fires for a body that truly never completes. + private static final long RESERVED_BODY_READ_TIMEOUT_SECONDS = 20L; /** Per-request {@link RoutingContext} data key holding the resolved metrics route label. */ private static final String ROUTE_KEY = "sheriff.route"; + /** Holds the fully-read {@code application/x-www-form-urlencoded} body of a reserved POST path + * (form_post callback / back-channel logout), buffered on the event loop in {@link #handle} before + * the virtual-thread dispatch so the handler never has to re-arm a paused stream. Left unset on a + * read failure or timeout, so {@link #readFormBody} reads {@code null} and the receiver fails closed + * to {@code 400}. */ + private static final String RESERVED_BODY_KEY = "sheriff.reservedbody"; private final List routes; private final ExecutorService virtualThreadExecutor; @@ -157,6 +184,8 @@ public class GatewayEdgeRoute { private final GatewayEventCounter gatewayEventCounter; private final UpstreamFailureMapper upstreamFailureMapper; private final SheriffMetrics sheriffMetrics; + private final ReservedPathRegistry reservedPathRegistry; + private final BffRuntime bffRuntime; private final SecurityHeadersStage securityHeadersStage; private final BasicChecksStage basicChecksStage; @@ -194,15 +223,22 @@ public class GatewayEdgeRoute { * @param hardening the edge transport / admission bounds * @param sheriffMetrics the Micrometer adapter the request/error/upstream signals are * recorded through + * @param bffRuntime the server-mode BFF runtime (D16); its {@code require: session} + * stage-4 runtime is injected into the session-aware + * {@link AuthenticationStage} and its reserved-endpoint handlers serve + * the carved-out OIDC paths. The {@linkplain BffRuntime#inert() inert} + * runtime (a bearer-only or cookie-mode gateway) leaves the bearer + * path and the empty reserved registry unchanged. */ @Inject public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig, @GatewayValidator Instance tokenValidator, Vertx vertx, @VirtualThreads ExecutorService virtualThreadExecutor, EdgeHardeningOptions hardening, - SheriffMetrics sheriffMetrics) { + SheriffMetrics sheriffMetrics, BffRuntime bffRuntime) { this.virtualThreadExecutor = virtualThreadExecutor; this.hardening = hardening; this.sheriffMetrics = sheriffMetrics; + this.bffRuntime = bffRuntime; this.admission = new Semaphore(hardening.admissionCap()); SecurityEventCounter securityEventCounter = new SecurityEventCounter(); @@ -229,8 +265,16 @@ public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig, GatewayEdgeRoute::assetSourceFor); LOGGER.info(ApiSheriffLogMessages.INFO.ROUTE_TABLE_COMPILED, routes.size()); + // Reserved OIDC endpoints (D2) are carved out of the proxy route table: the registry is + // consulted in process() ahead of route selection, so a proxy route such as + // path_prefix: /auth never swallows the exact /auth/callback. Empty (and inert) unless the + // global oidc block declares a redirect_uri. Built before basicChecksStage so the latter can + // reuse the same match to exempt gateway-terminated reserved params from the url-parameter + // pipeline (see BasicChecksStage — reserved handlers self-validate their own params). + this.reservedPathRegistry = ReservedPathRegistry.from(gatewayConfig.oidc()); this.securityHeadersStage = new SecurityHeadersStage(gatewayConfig.securityHeaders()); - this.basicChecksStage = new BasicChecksStage(defaultConfiguration, securityEventCounter); + this.basicChecksStage = new BasicChecksStage(defaultConfiguration, securityEventCounter, + (host, canonicalPath) -> reservedPathRegistry.match(host, canonicalPath).isPresent()); this.canonicalPathGuard = new CanonicalPathGuard(); this.framingGate = new FramingGate(); this.passthroughHostGuardStage = new PassthroughHostGuardStage( @@ -238,7 +282,12 @@ public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig, this.routeSelectionStage = new RouteSelectionStage(routes); this.verbGateStage = new VerbGateStage(); this.thoroughChecksStage = new ThoroughChecksStage(defaultConfiguration, securityEventCounter); - this.authenticationStage = new AuthenticationStage(tokenValidator); + // A server-mode BFF wires the session-aware AuthenticationStage so a require:session route is + // served through the SessionAuthenticationStage (D4) rather than failing at request time; a + // bearer-only gateway keeps the no-session constructor unchanged. + this.authenticationStage = bffRuntime.isActive() + ? new AuthenticationStage(tokenValidator, bffRuntime.sessionStage()) + : new AuthenticationStage(tokenValidator); this.forwardPolicyStage = new ForwardPolicyStage(resolver, peerGate, emitMode); this.responseStage = new ResponseStage(); this.originValidationStage = new OriginValidationStage(); @@ -296,7 +345,83 @@ private void handle(RoutingContext ctx) { releaseAdmission(admissionReleased); recordRequestMetrics(ctx, startNanos); }); - ctx.request().pause(); + if (needsReservedBodyRead(ctx)) { + // A reserved POST (form_post callback / back-channel logout) is dispatched on a virtual + // thread that cannot reliably re-arm a paused request stream. Read the small, gateway- + // terminated body here on its own event loop — the natural Vert.x path — under a bounded + // deadline, stash it, then dispatch. This avoids any paused-stream / cross-thread resume. + readReservedBodyThenDispatch(ctx, admissionReleased); + } else { + ctx.request().pause(); + dispatchProcessing(ctx, admissionReleased); + } + } + + /** + * @return {@code true} when the request is a reserved POST path whose {@code x-www-form-urlencoded} + * body a handler consumes (the form_post callback and back-channel logout). Matched on the + * raw path against the reserved registry's exact-match set, so only an exact clean reserved + * path (raw == canonical) triggers the eager body read; every other request pauses as before. + */ + private boolean needsReservedBodyRead(RoutingContext ctx) { + if (!"POST".equalsIgnoreCase(ctx.request().method().name())) { + return false; + } + String host = ctx.request().authority() != null ? ctx.request().authority().host() : ctx.request().host(); + return reservedPathRegistry.match(host, ctx.request().path()) + .filter(kind -> kind == ReservedEndpoint.CALLBACK || kind == ReservedEndpoint.BACKCHANNEL_LOGOUT) + .isPresent(); + } + + /** + * Reads the reserved-POST body on the event loop, stashes it under {@link #RESERVED_BODY_KEY}, then + * dispatches processing exactly once. The request is NOT paused: {@code body()} drains the stream on + * its own event loop, fully asynchronously — no virtual-thread {@code .get()} blocks on a contended + * event loop. A bounded deadline guards a body that truly never completes, but the deadline handler + * still honours a body that has already fully arrived (it inspects the read future), so a legitimate + * body delayed only by event-loop scheduling under CPU contention is never falsely rejected. + */ + private void readReservedBodyThenDispatch(RoutingContext ctx, AtomicBoolean admissionReleased) { + AtomicBoolean bodyDone = new AtomicBoolean(); + HttpServerRequest request = ctx.request(); + Future bodyFuture = request.body(); + long timer = ctx.vertx().setTimer(TimeUnit.SECONDS.toMillis(RESERVED_BODY_READ_TIMEOUT_SECONDS), id -> { + if (bodyDone.compareAndSet(false, true)) { + if (bodyFuture.succeeded() && bodyFuture.result() != null) { + // The body actually arrived; only its completion callback had not yet drained from + // this (contention-starved) event loop's queue. Honour it rather than falsely reject. + ctx.put(RESERVED_BODY_KEY, bodyFuture.result()); + } else { + // A body that truly never completed within the deadline — leave RESERVED_BODY_KEY + // unset so the receiver fails closed to 400 rather than pinning on it indefinitely. + LOGGER.debug("Reserved form body read did not complete within %s s — failing closed", + RESERVED_BODY_READ_TIMEOUT_SECONDS); + } + dispatchProcessing(ctx, admissionReleased); + } + }); + bodyFuture.onComplete(result -> { + if (bodyDone.compareAndSet(false, true)) { + ctx.vertx().cancelTimer(timer); + if (result.succeeded() && result.result() != null) { + ctx.put(RESERVED_BODY_KEY, result.result()); + } else { + // Read failure: leave RESERVED_BODY_KEY unset (fail closed to 400). + LOGGER.debug(result.cause(), "Reserved form body read failed — failing closed"); + } + dispatchProcessing(ctx, admissionReleased); + } + }); + // body() registers the collector but the inbound request stream arrives in fetch/paused mode; + // resume() (on this event loop) re-arms it so the buffered body is delivered. + request.resume(); + } + + /** + * Hands the request to the virtual-thread pipeline, rolling admission back and failing {@code 503} + * if the executor refuses the dispatch (a shutdown race) so the response always ends. + */ + private void dispatchProcessing(RoutingContext ctx, AtomicBoolean admissionReleased) { try { virtualThreadExecutor.execute(() -> process(ctx)); } catch (RejectedExecutionException rejected) { @@ -388,12 +513,31 @@ private void process(RoutingContext ctx) { return; } passthroughHostGuardStage.process(request); + if (handleReservedPath(ctx, request)) { + return; + } routeSelectionStage.process(request); verbGateStage.process(request); RouteRuntime route = requireSelectedRoute(request); ctx.put(ROUTE_KEY, route.getId()); thoroughChecksStage.process(request, route.getEffectiveAllowedPaths()); + // Fixed CSRF defence (D7): every unsafe-method require:session request must prove same-origin + // provenance before the session runtime resolves it. A bearer-only gateway has no session + // routes and never reaches this guard. + if (bffRuntime.isActive() && REQUIRE_SESSION.equals(route.getEffectiveAuth().require())) { + bffRuntime.csrfDefence().enforce(request); + } authenticationStage.process(request); + // Honor an auth-stage short-circuit before forwarding: the BFF SessionAuthenticationStage + // challenges an unauthenticated require:session navigation by setting shortCircuit(302) plus a + // Location header that redirects the browser into the auth-code flow. Without this gate the + // request would fall through to the upstream (200) instead of being challenged — both a broken + // login redirect and a security defect (an unauthenticated require:session request reaching the + // upstream). Mirrors the post-framing short-circuit gate above. + if (request.shortCircuitStatus().isPresent()) { + writeShortCircuit(ctx, request); + return; + } ForwardPolicyStage.Result forward = forwardPolicyStage.process(request, route.getEffectiveForward(), route.isNotModifiedEnabled()); // Protocol-dispatch seam: a WebSocket route validates its handshake Origin and hands the @@ -407,27 +551,120 @@ private void process(RoutingContext ctx) { dispatchAndRelay(ctx, request, route, forward); } } catch (GatewayException rejected) { - // Upstream failures are already metered inside UpstreamFailureMapper; meter the rest here. - if (rejected.getEventType().category() != EventCategory.UPSTREAM) { - gatewayEventCounter.increment(rejected.getEventType()); - } - if (rejected.getEventType() == EventType.SECURITY_FILTER_VIOLATION) { - // Security-relevant WARN (D4): the failure-type detail only, never the raw payload — - // rejected.getMessage() already carries a sanitized description (see GatewayException). - LOGGER.warn(ApiSheriffLogMessages.WARN.SECURITY_FILTER_VIOLATION, routeLabel(ctx), rejected.getMessage()); - } else if (rejected.getEventType() == EventType.PASSTHROUGH_HOST_SMUGGLED) { - // Security-relevant WARN: a terminated Host named a reserved passthrough SNI. The - // message is a fixed disposition (never the raw Host value). - LOGGER.warn(ApiSheriffLogMessages.WARN.PASSTHROUGH_HOST_SMUGGLED, rejected.getMessage()); - } - recordError(ctx, rejected.getEventType()); - renderRejection(ctx, request, rejected.getEventType()); + handleGatewayRejection(ctx, request, rejected); } catch (RuntimeException unexpected) { LOGGER.debug(unexpected, "Unexpected edge failure: %s", unexpected.getMessage()); renderProblem(ctx, request, null); } } + /** + * Resolves and dispatches a reserved OIDC carve-out (D2/D16): a reserved endpoint is matched here, + * ahead of the route table, so a proxy route such as {@code path_prefix: /auth} can never swallow + * the exact {@code /auth/callback}. When the server-mode BFF runtime is wired the matched path is + * DISPATCHED to its handler; a bearer-only / cookie-mode gateway carves it out of proxy dispatch + * and renders {@code NO_ROUTE_MATCHED} (the empty reserved registry never matches there anyway). + * + * @return {@code true} when the request was a reserved path and has been handled (the caller must + * stop processing); {@code false} when no reserved path matched and normal routing continues + */ + private boolean handleReservedPath(RoutingContext ctx, PipelineRequest request) { + Optional reserved = + reservedPathRegistry.match(request.host(), requireCanonicalPath(request)); + if (reserved.isEmpty()) { + return false; + } + if (bffRuntime.isActive()) { + dispatchReserved(ctx, request, reserved.get()); + } else { + LOGGER.debug("Reserved gateway path carved out of proxy dispatch: %s", + requireCanonicalPath(request)); + renderProblem(ctx, request, EventType.NO_ROUTE_MATCHED); + } + return true; + } + + /** + * Meters and renders a categorized {@link GatewayException} rejection: increments the event counter + * (except for upstream failures already metered inside {@code UpstreamFailureMapper}), emits the + * security-relevant WARN for filter violations and smuggled passthrough hosts, records the error + * metric, and renders the rejection. + */ + private void handleGatewayRejection(RoutingContext ctx, @Nullable PipelineRequest request, GatewayException rejected) { + // Upstream failures are already metered inside UpstreamFailureMapper; meter the rest here. + if (rejected.getEventType().category() != EventCategory.UPSTREAM) { + gatewayEventCounter.increment(rejected.getEventType()); + } + if (rejected.getEventType() == EventType.SECURITY_FILTER_VIOLATION) { + // Security-relevant WARN (D4): the failure-type detail only, never the raw payload — + // rejected.getMessage() already carries a sanitized description (see GatewayException). + LOGGER.warn(ApiSheriffLogMessages.WARN.SECURITY_FILTER_VIOLATION, routeLabel(ctx), rejected.getMessage()); + } else if (rejected.getEventType() == EventType.PASSTHROUGH_HOST_SMUGGLED) { + // Security-relevant WARN: a terminated Host named a reserved passthrough SNI. The + // message is a fixed disposition (never the raw Host value). + LOGGER.warn(ApiSheriffLogMessages.WARN.PASSTHROUGH_HOST_SMUGGLED, rejected.getMessage()); + } + recordError(ctx, rejected.getEventType()); + renderRejection(ctx, request, rejected.getEventType()); + } + + /** + * Dispatches a matched reserved OIDC path (D16): extracts the raw request pieces each handler + * consumes, drives the {@link BffRuntime#dispatch reserved dispatch}, and renders the normalized + * response. Runs on the virtual thread (the handler may reach the confidential-client engine), then + * hops the response mutation back onto the event loop like every other terminal path. + */ + private void dispatchReserved(RoutingContext ctx, PipelineRequest request, ReservedEndpoint kind) { + String cookieHeader = request.firstHeader(COOKIE_HEADER).orElse(null); + String method = ctx.request().method().name(); + // The reserved form body is read for two POST reserved paths: back-channel logout, and an OIDC + // response_mode=form_post callback (Keycloak POSTs the code/state to redirect_uri as an + // urlencoded body rather than returning a 302 with the code in the query). Both reuse the same + // bounded read; every other reserved path (and a GET callback) carries no body. + boolean callbackFormPost = kind == ReservedEndpoint.CALLBACK && "POST".equalsIgnoreCase(method); + String rawFormBody = kind == ReservedEndpoint.BACKCHANNEL_LOGOUT || callbackFormPost ? readFormBody(ctx) : null; + BffRuntime.ReservedHttpRequest reservedRequest = new BffRuntime.ReservedHttpRequest( + ctx.request().query(), cookieHeader, firstQueryParam(request, CLAIMS_PARAM), + firstQueryParam(request, RETURN_TO_PARAM), firstQueryParam(request, STATE_PARAM), rawFormBody, method); + BffRuntime.ReservedHttpResponse response = bffRuntime.dispatch(kind, reservedRequest, Instant.now()); + renderReserved(ctx, request, response); + } + + private void renderReserved(RoutingContext ctx, PipelineRequest request, + BffRuntime.ReservedHttpResponse response) { + Map stageHeaders = Map.copyOf(request.responseHeaders()); + ctx.vertx().runOnContext(v -> { + HttpServerResponse httpResponse = ctx.response(); + if (httpResponse.ended()) { + return; + } + httpResponse.setStatusCode(response.status()); + stageHeaders.forEach(httpResponse::putHeader); + response.headers().forEach(httpResponse::putHeader); + response.locationOptional().ifPresent(location -> httpResponse.putHeader(LOCATION_HEADER, location)); + // Multiple Set-Cookie headers must each be a distinct header line (never a comma-joined value). + response.setCookieHeaders().forEach(cookie -> httpResponse.headers().add(SET_COOKIE_HEADER, cookie)); + response.jsonBodyOptional().ifPresentOrElse(httpResponse::end, httpResponse::end); + }); + } + + private static @Nullable String firstQueryParam(PipelineRequest request, String name) { + List values = request.queryParameters().get(name); + return values == null || values.isEmpty() ? null : values.getFirst(); + } + + /** + * Returns the raw {@code application/x-www-form-urlencoded} body of a reserved POST path (the OIDC + * {@code response_mode=form_post} callback and back-channel logout), buffered on the event loop in + * {@link #handle} before this virtual-thread dispatch. A read failure or timeout stashed the + * {@link #RESERVED_BODY_FAILED} sentinel, so this returns {@code null} — the fail-closed default a + * receiver rejects {@code 400} (a body the gateway could not read is not an accepted token). + */ + private static @Nullable String readFormBody(RoutingContext ctx) { + Buffer body = ctx.get(RESERVED_BODY_KEY); + return body == null ? null : body.toString(StandardCharsets.UTF_8); + } + private void dispatchAndRelay(RoutingContext ctx, PipelineRequest request, RouteRuntime route, ForwardPolicyStage.Result forward) { String prefix = stripTrailingSlash(route.getMatcher().pathPrefix()); diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java index cf7a311c..50efb2a4 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/RouteRuntimeAssembler.java @@ -56,17 +56,17 @@ * resilience shape}.
    4. * * The heavy objects are produced by the injected factories (so tests supply fakes and the - * production wiring supplies the real Vert.x / SmallRye instances). A route requesting - * {@code session} auth, or a {@code GRPC} / {@code WEBSOCKET} protocol, fails boot with a - * {@link GatewayException} carrying {@link EventType#CONFIG_INVALID}. + * production wiring supplies the real Vert.x / SmallRye instances). An unsupported protocol fails + * boot through the {@link ProtocolProcessorRegistry}. {@code require: session} routes are compiled + * like any other route — their stage-4 runtime is the + * {@code de.cuioss.sheriff.gateway.bff.runtime.SessionAuthenticationStage} (D4), which replaces the + * boot-time rejection this assembler used to raise. * * @author API Sheriff Team * @since 1.0 */ public final class RouteRuntimeAssembler { - private static final String SESSION_REQUIRE = "session"; - private final ProtocolProcessorRegistry protocolRegistry; /** @@ -88,7 +88,7 @@ public RouteRuntimeAssembler(ProtocolProcessorRegistry protocolRegistry) { * @param assetSourceFactory builds the live {@link AssetSource} for an asset route's * terminal action * @return the assembled runtimes, in the table's longest-prefix-first order - * @throws GatewayException when a route requests {@code session} auth or an unsupported protocol + * @throws GatewayException when a route declares an unsupported protocol */ public List assemble(RouteTable table, SecurityConfigurationFactory securityConfigFactory, UpstreamClientFactory clientFactory, ResilienceGuardFactory guardFactory, @@ -105,7 +105,6 @@ public List assemble(RouteTable table, SecurityConfigurationFactor List runtimes = new ArrayList<>(); for (ResolvedRoute route : table.routes()) { - rejectUnsupportedAuth(route); ProtocolProcessor processor = protocolRegistry.require(route.protocol(), route.id()); Optional securityConfiguration = route.effectiveSecurityFilter() @@ -156,13 +155,6 @@ public List assemble(RouteTable table, SecurityConfigurationFactor return List.copyOf(runtimes); } - private static void rejectUnsupportedAuth(ResolvedRoute route) { - if (SESSION_REQUIRE.equals(route.effectiveAuth().require())) { - throw new GatewayException(EventType.CONFIG_INVALID, - "Route '" + route.id() + "' requires session authentication which is not yet implemented"); - } - } - private static Set toMethodSet(List methods) { return methods.isEmpty() ? EnumSet.noneOf(HttpMethod.class) : EnumSet.copyOf(methods); } diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/events/EventType.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/events/EventType.java index aa45045a..91997d93 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/events/EventType.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/events/EventType.java @@ -93,6 +93,26 @@ public enum EventType { /** The upstream call exceeded its configured timeout. */ UPSTREAM_TIMEOUT(EventCategory.UPSTREAM, 504), + // --- BFF server-session lifecycle (informational; no category, no HTTP mapping) --- + + /** A server-side session was established after a successful IdP login ({@code require: session}). */ + SESSION_CREATED(null, 0), + /** A server-side session was destroyed (RP-initiated logout, back-channel logout, TTL eviction, or refresh failure). */ + SESSION_DESTROYED(null, 0), + /** A transparent token refresh failed (IdP rejection or refresh-token reuse) and its session was destroyed. */ + SESSION_REFRESH_FAILED(null, 0), + /** A back-channel logout token was accepted and its affected sessions were destroyed. */ + BACKCHANNEL_LOGOUT(null, 0), + + // --- BFF back-channel logout rejection (400) --- + + /** + * A back-channel {@code logout_token} failed signature or claim validation; the back-channel + * logout receiver rejects the request {@code 400} (OpenID Connect Back-Channel Logout), so it + * carries an HTTP mapping despite being an authentication-class failure. + */ + LOGOUT_TOKEN_INVALID(EventCategory.AUTHENTICATION, 400), + // --- WebSocket (403 on the handshake; 1001 "Going Away" close on the established relay) --- /** diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/forward/ForwardPolicyStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/forward/ForwardPolicyStage.java index e1fa5a84..ef9fa414 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/forward/ForwardPolicyStage.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/forward/ForwardPolicyStage.java @@ -44,6 +44,10 @@ * {@linkplain TcpPeerGate#isTrustedPeer(String) trusted proxy}, inbound forwarding headers are * ignored (a spoofed chain from an untrusted peer never influences the regenerated set). *
    5. Static set headers. {@code set_headers} are appended verbatim.
    6. + *
    7. 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.
    8. *
    9. Conditional requests. {@code If-None-Match} / {@code If-Modified-Since} * cross only when the route enables {@code not_modified}; otherwise they are dropped here.
    10. * @@ -95,10 +99,21 @@ public Result process(PipelineRequest request, ForwardConfig forwardConfig, bool headers.putAll(forwardConfig.setHeaders()); applyConditionalHeaders(request, notModifiedEnabled, headers); applyRegeneratedForwarding(request, headers); + applyMediatedBearer(request, headers); return new Result(Map.copyOf(headers), copyAllowedQuery(request, forwardConfig)); } + /** + * Applies the {@code require: session} stage-4 mediated bearer as the outbound + * {@code Authorization} header, last, so it deterministically wins over any inbound + * {@code Authorization} an allowlist happened to forward. A pure-proxy or {@code require: bearer} + * route resolves no mediated bearer, so this is a no-op there. + */ + private static void applyMediatedBearer(PipelineRequest request, Map headers) { + request.mediatedBearer().ifPresent(token -> headers.put("Authorization", "Bearer " + token)); + } + private static void copyAllowedHeaders(PipelineRequest request, ForwardConfig forwardConfig, Map headers) { for (String name : forwardConfig.headersAllow()) { diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStage.java index 14dc6570..44ea98fd 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStage.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStage.java @@ -18,6 +18,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.BiPredicate; import de.cuioss.http.security.config.SecurityConfiguration; @@ -47,15 +48,25 @@ public final class BasicChecksStage { private final SecurityConfiguration configuration; private final PipelineFactory.PipelineSet pipelines; + private final BiPredicate reservedPathMatcher; /** - * @param configuration the gateway's default inbound validation policy - * @param eventCounter the shared cui-http security event counter (never a local instance) + * @param configuration the gateway's default inbound validation policy + * @param eventCounter the shared cui-http security event counter (never a local instance) + * @param reservedPathMatcher predicate {@code (host, canonicalPath) -> isReserved}: a matched + * reserved gateway path is terminated at the gateway (never proxied) and + * its query params are validated by the reserved handler itself, so the + * url-parameter value pipeline — an anti-injection defense for params + * forwarded to an upstream — is not applied to it (a same-origin + * {@code return_to} path legitimately carries {@code /}, which the + * pipeline would otherwise reject) */ - public BasicChecksStage(SecurityConfiguration configuration, SecurityEventCounter eventCounter) { + public BasicChecksStage(SecurityConfiguration configuration, SecurityEventCounter eventCounter, + BiPredicate reservedPathMatcher) { this.configuration = Objects.requireNonNull(configuration, "configuration"); this.pipelines = PipelineFactory.createCommonPipelines(configuration, Objects.requireNonNull(eventCounter, "eventCounter")); + this.reservedPathMatcher = Objects.requireNonNull(reservedPathMatcher, "reservedPathMatcher"); } /** @@ -70,9 +81,16 @@ public void process(PipelineRequest request) { Objects.requireNonNull(request, "request"); enforceCollectionLimits(request); String canonical = validatePath(request.requestPath()); - validateParameters(request.queryParameters()); - validateHeaders(request.headers()); request.canonicalPath(canonical); + // A gateway-terminated reserved path (callback/login/logout/userinfo/back-channel) self-validates + // its own params in its handler; the generic url-parameter value pipeline (an anti-injection + // defense for upstream-forwarded params) is not applied to it, so a legitimate same-origin + // return_to path carrying '/' is not rejected. The param-count cap, path pipeline, and header + // pipeline above/below still apply. + if (!reservedPathMatcher.test(request.host(), canonical)) { + validateParameters(request.queryParameters()); + } + validateHeaders(request.headers()); } private void enforceCollectionLimits(PipelineRequest request) { diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/PipelineRequest.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/PipelineRequest.java index d07829b3..fe2cd518 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/PipelineRequest.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/PipelineRequest.java @@ -63,6 +63,7 @@ public final class PipelineRequest { private @Nullable String canonicalPath; private @Nullable RouteRuntime selectedRoute; private @Nullable Integer shortCircuitStatus; + private @Nullable String mediatedBearer; private PipelineRequest(Builder builder) { this.method = Objects.requireNonNull(builder.method, "method"); @@ -252,6 +253,27 @@ public void shortCircuit(int status) { this.shortCircuitStatus = status; } + /** + * @return the mediated access token the {@code require: session} stage-4 runtime resolved for + * automatic upstream injection, or empty when no session mediation applied. The forward + * policy stage renders it as the outbound {@code Authorization: Bearer} header so the + * upstream sees only the mediated bearer, never the opaque session cookie. + */ + public Optional mediatedBearer() { + return Optional.ofNullable(mediatedBearer); + } + + /** + * Records the mediated access token the session stage-4 runtime injects automatically as the + * upstream {@code Authorization: Bearer} (never operator-configured). The token material stays + * server-side up to this point; the forward stage is its sole consumer. + * + * @param mediatedBearer the raw mediated access token + */ + public void mediatedBearer(String mediatedBearer) { + this.mediatedBearer = Objects.requireNonNull(mediatedBearer, "mediatedBearer"); + } + /** * Builder collecting the immutable inbound components of a {@link PipelineRequest}. */ diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java new file mode 100644 index 00000000..dd3c9192 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java @@ -0,0 +1,351 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.quarkus; + +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + + +import de.cuioss.sheriff.gateway.auth.GatewayValidator; +import de.cuioss.sheriff.gateway.bff.csrf.CsrfDefence; +import de.cuioss.sheriff.gateway.bff.login.LoginFlow; +import de.cuioss.sheriff.gateway.bff.logout.BackchannelLogoutReceiver; +import de.cuioss.sheriff.gateway.bff.logout.LogoutTokenValidator; +import de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout; +import de.cuioss.sheriff.gateway.bff.pending.BindingCookieCodec; +import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord; +import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationStore; +import de.cuioss.sheriff.gateway.bff.refresh.StepUpCoordinator; +import de.cuioss.sheriff.gateway.bff.refresh.TokenRefreshCoordinator; +import de.cuioss.sheriff.gateway.bff.reserved.BackchannelLogoutEndpoint; +import de.cuioss.sheriff.gateway.bff.reserved.CallbackEndpoint; +import de.cuioss.sheriff.gateway.bff.reserved.ClaimAllowlistFilter; +import de.cuioss.sheriff.gateway.bff.reserved.LoginInitiationEndpoint; +import de.cuioss.sheriff.gateway.bff.reserved.LogoutEndpoint; +import de.cuioss.sheriff.gateway.bff.reserved.UserInfoEndpoint; +import de.cuioss.sheriff.gateway.bff.runtime.BffRuntime; +import de.cuioss.sheriff.gateway.bff.runtime.SessionAuthenticationStage; +import de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore; +import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec; +import de.cuioss.sheriff.gateway.bff.session.SessionStore; +import de.cuioss.sheriff.gateway.config.model.GatewayConfig; +import de.cuioss.sheriff.gateway.config.model.OidcConfig; +import de.cuioss.sheriff.token.client.auth.ClientAuthentication; +import de.cuioss.sheriff.token.client.auth.ClientSecretBasicAuth; +import de.cuioss.sheriff.token.client.config.ClientAuthMethod; +import de.cuioss.sheriff.token.client.config.ClientConfiguration; +import de.cuioss.sheriff.token.client.discovery.DiscoveryResolver; +import de.cuioss.sheriff.token.client.discovery.ProviderMetadata; +import de.cuioss.sheriff.token.client.flow.AuthorizationCodeFlow; +import de.cuioss.sheriff.token.client.flow.RefreshFlow; +import de.cuioss.sheriff.token.client.flow.StepUpHandler; +import de.cuioss.sheriff.token.client.flow.TokenEndpointClient; +import de.cuioss.sheriff.token.client.logout.EndSessionFlow; +import de.cuioss.sheriff.token.client.logout.PostLogoutRedirectValidator; +import de.cuioss.sheriff.token.client.token.IdTokenValidationBridge; +import de.cuioss.sheriff.token.client.token.TokenValidationBridge; +import de.cuioss.sheriff.token.validation.TokenValidator; +import de.cuioss.sheriff.token.validation.domain.claim.ClaimValue; +import de.cuioss.tools.logging.CuiLogger; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Singleton; + +/** + * CDI producer of the server-mode {@link BffRuntime} — the D16 edge wiring that makes the D1–D12 BFF + * components reachable at the live gateway edge. + *

      + * The producer builds the active runtime only when a global {@code oidc} block with + * {@code session.mode=server} and a {@code redirect_uri} is configured; otherwise it produces + * the {@linkplain BffRuntime#inert() inert} runtime, so a bearer-only gateway (or a cookie-mode BFF) + * is unchanged and never touches the confidential-client engine. On the active path it assembles the + * server-side stores, cookie codecs, the CSRF defence, the token-refresh / step-up coordinators, the + * reserved-endpoint handlers, and the {@code require: session} stage-4 runtime, and binds the + * {@code token-sheriff-client} engine seams — {@code AuthorizationCodeFlow#authorize} / + * {@code #exchange} for login and callback, {@code RefreshFlow#refresh} for transparent refresh, and + * {@code StepUpHandler#initiate} for RFC 9470 re-drive — so the engine is reached at runtime. + *

      + * Lazy discovery. The OIDC provider metadata is resolved through a memoized supplier + * on first engine use, not at boot: a server-mode gateway therefore boots (and is unit-testable) + * without a live IdP, and the discovery-dependent {@code end_session_endpoint} the logout leg needs is + * materialized only when the first logout arrives. + * + * @author API Sheriff Team + * @since 1.0 + */ +@ApplicationScoped +public class BffRuntimeProducer { + + private static final CuiLogger LOGGER = new CuiLogger(BffRuntimeProducer.class); + + private static final String SESSION_MODE_SERVER = "server"; + private static final int DEFAULT_SESSION_TTL_SECONDS = 3600; + private static final int DEFAULT_MAX_SESSIONS = 10_000; + private static final int DEFAULT_MAX_PENDING = 10_000; + private static final int DEFAULT_REFRESH_LEEWAY_SECONDS = 30; + private static final Duration BACKCHANNEL_FRESHNESS_WINDOW = Duration.ofMinutes(2); + private static final Duration LOGOUT_STATE_TTL = Duration.ofMinutes(1); + private static final String DEFAULT_FINAL_REDIRECT = "/"; + + private final GatewayConfig gatewayConfig; + private final Instance tokenValidator; + + /** + * @param gatewayConfig the bound global gateway document carrying the {@code oidc} block + * @param tokenValidator a lazy handle to the gateway's shared offline validator, resolved only on + * the active server-mode path (a bearer-only gateway never triggers it) + */ + public BffRuntimeProducer(GatewayConfig gatewayConfig, + @GatewayValidator Instance tokenValidator) { + this.gatewayConfig = Objects.requireNonNull(gatewayConfig, "gatewayConfig"); + this.tokenValidator = Objects.requireNonNull(tokenValidator, "tokenValidator"); + } + + /** + * Produces the BFF runtime. + *

      + * {@link Singleton} (a pseudo-scope, no client proxy) because {@link BffRuntime} is a {@code final} + * class ArC cannot subclass to build a normal-scope proxy. The runtime is immutable and assembled + * once at boot, so a single instance is exact. + * + * @return the active server-mode runtime, or the inert runtime for a bearer-only / cookie-mode gateway + */ + @Produces + @Singleton + public BffRuntime bffRuntime() { + Optional oidc = gatewayConfig.oidc(); + if (!isServerModeBff(oidc)) { + LOGGER.debug("No server-mode oidc block — BFF runtime inert (bearer-only proxy path unchanged)"); + return BffRuntime.inert(); + } + return build(oidc.orElseThrow()); + } + + private static boolean isServerModeBff(Optional oidc) { + boolean serverMode = oidc.flatMap(OidcConfig::session).flatMap(OidcConfig.Session::mode) + .filter(SESSION_MODE_SERVER::equalsIgnoreCase).isPresent(); + boolean hasRedirect = oidc.flatMap(OidcConfig::redirectUri).isPresent(); + return serverMode && hasRedirect; + } + + private BffRuntime build(OidcConfig oidc) { + String redirectUri = oidc.redirectUri().orElseThrow(); + String gatewayOrigin = originOf(redirectUri); + String issuer = oidc.issuer().orElse(gatewayOrigin); + String clientId = oidc.clientId().orElse(""); + String clientSecret = oidc.clientSecret().orElse(""); + + Optional session = oidc.session(); + Duration sessionTtl = Duration.ofSeconds( + session.flatMap(OidcConfig.Session::ttlSeconds).orElse(DEFAULT_SESSION_TTL_SECONDS)); + String cookieName = session.flatMap(OidcConfig.Session::cookieName) + .orElse(SessionCookieCodec.DEFAULT_COOKIE_NAME); + int maxSessions = session.flatMap(OidcConfig.Session::maxSessions).orElse(DEFAULT_MAX_SESSIONS); + Duration refreshLeeway = Duration.ofSeconds(session.flatMap(OidcConfig.Session::refresh) + .flatMap(OidcConfig.Refresh::leewaySeconds).orElse(DEFAULT_REFRESH_LEEWAY_SECONDS)); + Set trustedOrigins = Set.copyOf(session.flatMap(OidcConfig.Session::csrf) + .map(OidcConfig.Csrf::trustedOrigins).filter(list -> !list.isEmpty()) + .orElse(List.of(gatewayOrigin))); + + ClientConfiguration clientConfiguration = ClientConfiguration.builder() + .issuer(issuer).clientId(clientId).clientSecret(clientSecret) + .authMethod(ClientAuthMethod.CLIENT_SECRET_BASIC) + .scopes(oidc.scopes()).redirectUri(redirectUri).build(); + ClientAuthentication clientAuthentication = new ClientSecretBasicAuth(clientId, clientSecret); + Supplier metadata = memoize(() -> new DiscoveryResolver(clientConfiguration).resolve()); + + TokenValidator validator = tokenValidator.get(); + TokenValidationBridge tokenBridge = new TokenValidationBridge(validator); + IdTokenValidationBridge idBridge = new IdTokenValidationBridge(validator); + TokenEndpointClient tokenEndpointClient = new TokenEndpointClient(clientConfiguration); + AuthorizationCodeFlow authorizationCodeFlow = new AuthorizationCodeFlow(clientConfiguration, + tokenEndpointClient, tokenBridge, idBridge); + RefreshFlow refreshFlow = new RefreshFlow(clientConfiguration, tokenEndpointClient, tokenBridge, + clientAuthentication); + + SessionCookieCodec sessionCookieCodec = new SessionCookieCodec(cookieName, sessionTtl); + BindingCookieCodec bindingCookieCodec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL); + SessionStore sessionStore = new InMemorySessionStore(maxSessions); + PendingAuthorizationStore pendingStore = new PendingAuthorizationStore.InMemory(DEFAULT_MAX_PENDING); + Clock clock = Clock.systemUTC(); + + // D5 login flow — the AuthorizationInitiation seam reaches the engine at runtime. + LoginFlow loginFlow = new LoginFlow(() -> authorizationCodeFlow.authorize(metadata.get()), + pendingStore, bindingCookieCodec, gatewayOrigin); + + // D2 callback — the CodeExchange seam reaches the engine's code exchange + token validation. + CallbackEndpoint callbackEndpoint = new CallbackEndpoint( + (context, params) -> authorizationCodeFlow.exchange(metadata.get(), context, params, + clientAuthentication), + pendingStore, bindingCookieCodec, sessionStore, sessionCookieCodec, sessionTtl); + + // D7/D9 transparent refresh — near-expiry decision + engine RefreshFlow, session persistence. + TokenRefreshCoordinator refreshCoordinator = new TokenRefreshCoordinator(refreshLeeway, + sessionRecord -> tokenBridge.validateAccessToken(sessionRecord.accessToken()) + .getExpirationDateTime().toInstant(), + refreshToken -> refreshFlow.refresh(metadata.get(), refreshToken), + sessionStore); + + // D4 session stage-4 runtime — binds refresh, scope enforcement, and the login-redirect seam. + SessionAuthenticationStage sessionStage = new SessionAuthenticationStage(sessionStore, sessionCookieCodec, + (sessionRecord, now) -> refreshCoordinator.refresh(sessionRecord, now).session().orElse(sessionRecord), + (accessToken, requiredScopes) -> tokenBridge.validateAccessToken(accessToken) + .providesScopes(requiredScopes), + (returnUrl, now) -> { + LoginFlow.LoginRedirect redirect = loginFlow.initiate(returnUrl, now); + return new SessionAuthenticationStage.LoginChallenge(redirect.authorizationUrl(), + redirect.setCookieHeaders()); + }, + clock); + + // D7 RFC 9470 step-up — instantiated with the engine StepUpHandler seam; the upstream-challenge + // edge integration is exercised by the Keycloak integration tests. + StepUpHandler stepUpHandler = new StepUpHandler(); + StepUpCoordinator stepUpCoordinator = new StepUpCoordinator( + (sessionRecord, challenge, now) -> Optional.empty(), + challenge -> stepUpHandler.initiate(clientConfiguration, metadata.get(), challenge), + pendingStore, bindingCookieCodec, gatewayOrigin); + + // D11 user-info fold — validated ID-token claims through the engine, capped by the allowlist. + ClaimAllowlistFilter claimFilter = new ClaimAllowlistFilter( + oidc.userInfo().map(OidcConfig.UserInfo::allowedClaims).orElse(List.of()), + oidc.userInfo().map(OidcConfig.UserInfo::defaultView).orElse(List.of())); + UserInfoEndpoint userInfoEndpoint = new UserInfoEndpoint(sessionStore, sessionCookieCodec, claimFilter, + sessionRecord -> toClaimMap(idBridge.validateRefreshedIdToken(sessionRecord.idToken()).getClaims())); + + // D12 login-initiation fold — the browser-facing start mirror of the callback. + LoginInitiationEndpoint loginInitiationEndpoint = new LoginInitiationEndpoint(loginFlow, sessionStore, + sessionCookieCodec, gatewayOrigin); + + // D2c back-channel logout — JWKS signature verification through the engine, then the claim residual. + BackchannelLogoutReceiver backchannelReceiver = new BackchannelLogoutReceiver( + idBridge::validateRefreshedIdToken, + new LogoutTokenValidator(issuer, clientId, BACKCHANNEL_FRESHNESS_WINDOW), + sessionStore); + BackchannelLogoutEndpoint backchannelLogoutEndpoint = new BackchannelLogoutEndpoint(backchannelReceiver); + + // D5 RP-initiated logout — lazy so the discovery-sourced end_session_endpoint is resolved on + // first logout, not at boot. Revocation at the IdP is best-effort; the authoritative logout is + // the local session destruction the LogoutEndpoint performs. + Supplier logoutEndpoint = memoize(() -> buildLogoutEndpoint(oidc, gatewayOrigin, + metadata.get(), sessionStore, sessionCookieCodec)); + + CsrfDefence csrfDefence = new CsrfDefence(trustedOrigins); + + LOGGER.debug("Server-mode BFF runtime assembled for origin %s (issuer %s)", gatewayOrigin, issuer); + return new BffRuntime(sessionStage, csrfDefence, stepUpCoordinator, callbackEndpoint, logoutEndpoint, + backchannelLogoutEndpoint, userInfoEndpoint, loginInitiationEndpoint); + } + + private static LogoutEndpoint buildLogoutEndpoint(OidcConfig oidc, String gatewayOrigin, ProviderMetadata metadata, + SessionStore sessionStore, SessionCookieCodec sessionCookieCodec) { + Optional logout = oidc.logout(); + String postLogoutRedirectUri = logout.flatMap(OidcConfig.Logout::postLogoutRedirectUri) + .orElse(gatewayOrigin + "/"); + String finalRedirect = logout.flatMap(OidcConfig.Logout::finalRedirect).orElse(DEFAULT_FINAL_REDIRECT); + String endSessionEndpoint = metadata.getEndSessionEndpoint() + .orElseThrow(() -> new IllegalStateException( + "OIDC provider metadata declares no end_session_endpoint — RP-initiated logout unavailable")); + EndSessionFlow endSessionFlow = new EndSessionFlow(new PostLogoutRedirectValidator(Set.of(postLogoutRedirectUri))); + RpInitiatedLogout rpInitiatedLogout = new RpInitiatedLogout(endSessionFlow, + sessionRecord -> { + // Best-effort by design: the authoritative logout is the local session destruction. + }, + endSessionEndpoint, postLogoutRedirectUri, finalRedirect, LOGOUT_STATE_TTL); + return new LogoutEndpoint(rpInitiatedLogout, sessionStore, sessionCookieCodec); + } + + private static Map toClaimMap(Map claims) { + Map converted = new LinkedHashMap<>(); + claims.forEach((name, value) -> { + if (value != null && !value.isNotPresentForClaimValueType()) { + String original = value.getOriginalString(); + if (original != null && !original.isBlank()) { + converted.put(name, original); + } + } + }); + return converted; + } + + /** + * Derives the gateway's own origin (scheme + host + optional non-default port) from the configured + * {@code redirect_uri}, used to same-origin-validate post-login return URLs and as the default + * CSRF trusted origin. + */ + static String originOf(String redirectUri) { + URI uri = URI.create(redirectUri); + String scheme = uri.getScheme(); + String host = uri.getHost(); + if (scheme == null || host == null) { + throw new IllegalStateException("oidc.redirect_uri is not an absolute URI: " + redirectUri); + } + int port = uri.getPort(); + StringBuilder origin = new StringBuilder(scheme).append("://").append(host); + // Browsers send the Origin without the scheme's default port, so https://gw:443 and + // http://gw:80 must reduce to https://gw / http://gw. Emitting the default port here would make + // the derived origin (same-origin return-URL check AND the default CSRF trusted-origins entry) + // reject a genuine same-origin unsafe request whose Origin omits the default port. + if (port != -1 && port != defaultPortFor(scheme)) { + origin.append(':').append(port); + } + return origin.toString(); + } + + private static int defaultPortFor(String scheme) { + if ("https".equalsIgnoreCase(scheme)) { + return 443; + } + if ("http".equalsIgnoreCase(scheme)) { + return 80; + } + return -1; + } + + /** + * Wraps {@code delegate} in a thread-safe memoizing supplier: the delegate runs at most once, on + * first {@link Supplier#get()}, and every later call returns the cached value. Used to defer OIDC + * discovery (and the discovery-dependent logout endpoint) to first request rather than boot. + */ + private static Supplier memoize(Supplier delegate) { + AtomicReference cache = new AtomicReference<>(); + return () -> { + T existing = cache.get(); + if (existing != null) { + return existing; + } + synchronized (cache) { + T current = cache.get(); + if (current == null) { + current = Objects.requireNonNull(delegate.get(), "memoized supplier produced null"); + cache.set(current); + } + return current; + } + }; + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/GatewayReadinessCheck.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/GatewayReadinessCheck.java index b0ee79eb..9de3f231 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/GatewayReadinessCheck.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/GatewayReadinessCheck.java @@ -21,6 +21,7 @@ import de.cuioss.sheriff.gateway.auth.GatewayValidator; import de.cuioss.sheriff.gateway.config.model.GatewayConfig; import de.cuioss.sheriff.gateway.config.model.Metadata; +import de.cuioss.sheriff.gateway.config.model.OidcConfig; import de.cuioss.sheriff.gateway.config.model.TokenValidationConfig; import de.cuioss.sheriff.gateway.events.GatewayException; import de.cuioss.sheriff.token.validation.TokenValidator; @@ -50,6 +51,17 @@ * resolution failure ({@link GatewayException}) marks the probe {@code DOWN} with the cause. * A gateway with no {@code token_validation} block needs no bearer validation, so JWKS is * reported {@code not-applicable} and does not gate readiness. + *

    11. 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.
    12. * * 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 @@ *
    13. {@value #SECURITY_EVENTS_TOTAL}{@code {failure_type}} — the {@code cui-http} * security-filter counts;
    14. *
    15. {@value #UPSTREAM_DURATION_SECONDS}{@code {route}} — downstream-call time, separated - * from gateway overhead.
    16. + * from gateway overhead; + *
    17. {@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.
    18. * * Route cardinality is bounded (route id is a config-fixed label; unmatched requests share the * fixed {@value #NO_ROUTE} value), so every meter is safe to keep always on. Each record call @@ -65,6 +69,8 @@ public class SheriffMetrics { public static final String SECURITY_EVENTS_TOTAL = "sheriff_security_events_total"; /** Per-route downstream-call time (timer). */ public static final String UPSTREAM_DURATION_SECONDS = "sheriff_upstream_duration_seconds"; + /** Counter of BFF {@code require: session} lifecycle events, keyed by {@link EventType} name. */ + public static final String SESSION_EVENTS_TOTAL = "sheriff_session_events_total"; /** The bounded label value shared by requests that matched no route. */ public static final String NO_ROUTE = ""; @@ -74,6 +80,7 @@ public class SheriffMetrics { private static final String TAG_STATUS_FAMILY = "status_family"; private static final String TAG_CATEGORY = "category"; private static final String TAG_FAILURE_TYPE = "failure_type"; + private static final String TAG_EVENT = "event"; private final MeterRegistry registry; @@ -118,6 +125,21 @@ public void recordError(String route, EventCategory category) { registry.counter(ERRORS_TOTAL, TAG_ROUTE, route, TAG_CATEGORY, category.slug()).increment(); } + /** + * Counts one BFF {@code require: session} lifecycle event against {@link #SESSION_EVENTS_TOTAL}, + * keyed by the {@link EventType} name. The counter surfaces the session create/destroy, + * transparent-refresh, CSRF, and logout events the confidential-client surface emits — the same + * catalogue {@link de.cuioss.sheriff.gateway.events.GatewayEventCounter} counts in-process, + * bridged here to a Micrometer meter. The {@code event} label cardinality is fixed at the + * {@link EventType} enum (never operator-controlled input), so the meter is safe to keep always on. + * + * @param eventType the session lifecycle event to count (e.g. {@link EventType#SESSION_CREATED}) + */ + public void recordSessionEvent(EventType eventType) { + Objects.requireNonNull(eventType, "eventType"); + registry.counter(SESSION_EVENTS_TOTAL, TAG_EVENT, eventType.name()).increment(); + } + /** * Binds the boot-shared {@code cui-http} {@link SecurityEventCounter} to the registry, exposing its * per-{@link UrlSecurityFailureType} violation counts as the {@link #SECURITY_EVENTS_TOTAL} meter. diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/TokenClientDslJsonReflection.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/TokenClientDslJsonReflection.java new file mode 100644 index 00000000..b8af7e46 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/TokenClientDslJsonReflection.java @@ -0,0 +1,78 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.quarkus; + +import de.cuioss.sheriff.token.client.discovery.ProviderMetadata; +import de.cuioss.sheriff.token.client.discovery._ProviderMetadata_DslJsonConverter; +import de.cuioss.sheriff.token.client.flow.ParResponse; +import de.cuioss.sheriff.token.client.flow._ParResponse_DslJsonConverter; +import de.cuioss.sheriff.token.client.token.TokenResponse; +import de.cuioss.sheriff.token.client.token.UserInfoResponse; +import de.cuioss.sheriff.token.client.token._TokenResponse_DslJsonConverter; +import de.cuioss.sheriff.token.client.token._UserInfoResponse_DslJsonConverter; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +/** + * Registers the {@code token-sheriff-client} confidential-client engine response + * DTOs and their generated DSL-JSON converters for reflection so the engine can + * deserialize IdP responses in a GraalVM native image. + *

      + * The engine parses every back-channel IdP response (OIDC discovery, the token + * endpoint, PAR, and userinfo) with a DSL-JSON instance built by + * {@code de.cuioss.sheriff.token.commons.transport.ParserConfig}. For each response + * type the DSL-JSON annotation processor emits a compile-time + * {@code __DslJsonConverter} that implements + * {@code com.dslplatform.json.Configuration}. DSL-JSON discovers these converters at + * runtime through {@code com.dslplatform.json.ExternalConverterAnalyzer}, which + * derives the converter class name by the {@code .__DslJsonConverter} + * naming convention, loads it via {@link ClassLoader#loadClass(String)}, instantiates + * it with the no-arg constructor, and calls {@code configure(dslJson)} to register the + * reader/writer bindings. + *

      + * That reflective {@code loadClass} + {@code newInstance} step works on the JVM but + * fails in a native image unless the converter classes are registered for reflection — + * the engine then throws {@code Unable to find reader for provided type ... and + * fallback serialization is not registered}. Registering the converter classes here is + * the load-bearing fix: it makes DSL-JSON's reflective lookup resolve in native. The + * DTO records are registered alongside as defence in depth; the converters instantiate + * their nested {@code $ObjectFormatConverter} readers via a direct {@code new} and are + * therefore reached by the native-image static analysis without an explicit entry. + *

      + * The class has no runtime behavior; it exists solely to carry the + * {@link RegisterForReflection} targets. Referencing the generated converter classes by + * symbol (rather than by string) makes any future rename in the client engine fail the + * build loudly instead of silently regressing native deserialization. + * + * @author API Sheriff Team + * @since 1.0 + */ +@RegisterForReflection(targets = { + _ProviderMetadata_DslJsonConverter.class, + _ParResponse_DslJsonConverter.class, + _TokenResponse_DslJsonConverter.class, + _UserInfoResponse_DslJsonConverter.class, + ProviderMetadata.class, + ParResponse.class, + TokenResponse.class, + UserInfoResponse.class +}) +public final class TokenClientDslJsonReflection { + + private TokenClientDslJsonReflection() { + // Reflection-registration holder; not instantiable. + } +} diff --git a/api-sheriff/src/main/resources/application.properties b/api-sheriff/src/main/resources/application.properties index 1aa86925..c1b7fea8 100644 --- a/api-sheriff/src/main/resources/application.properties +++ b/api-sheriff/src/main/resources/application.properties @@ -38,7 +38,21 @@ quarkus.tls.cipher-suites=TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256,TL quarkus.tls.alpn=true # Native Image Configuration -quarkus.native.additional-build-args=--enable-url-protocols=https,--enable-http,--enable-https,-O2 +# The three BFF records below each hold a `static final SecureRandom`. GraalVM initializes static +# state at BUILD time by default, which would bake a seeded SecureRandom into the image heap +# (a build failure — "instance of Random/SplittableRandom in the image heap" — AND a real security +# defect: predictable randomness from a cached seed). Force runtime initialization for each so the +# SecureRandom is seeded from the OS entropy source at process start. The list is comma-separated, +# so each class needs its own --initialize-at-run-time entry (a comma-joined class list inside a +# single flag would be split by the outer comma). +# The token-sheriff-client engine flow classes (PkceChallenge, FlowContext, and sibling +# refresh/step-up/end-session flows) likewise hold static SecureRandom fields. Deliverable 16 wired +# the engine into the live edge (BffRuntimeProducer -> AuthorizationCodeFlow -> FlowContext.create), +# so those static fields are now reachable in the image heap and hit the same build failure. A single +# package-prefix --initialize-at-run-time=de.cuioss.sheriff.token.client.flow covers the whole flow +# package (GraalVM treats a bare package name as a prefix over that package and its classes), so +# every current and future sibling flow class is forced to runtime initialization in one entry. +quarkus.native.additional-build-args=--enable-url-protocols=https,--enable-http,--enable-https,-O2,--initialize-at-run-time=de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord,--initialize-at-run-time=de.cuioss.sheriff.gateway.bff.session.SessionRecord,--initialize-at-run-time=de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout,--initialize-at-run-time=de.cuioss.sheriff.token.client.flow quarkus.native.resources.includes=**/*.p12,**/*.crt,**/*.key,schema/*.json quarkus.native.monitoring=jfr # Java 25 Mandrel builder image for JEP 491 (virtual threads without pinning) — see ADR-0001 diff --git a/api-sheriff/src/main/resources/schema/gateway.schema.json b/api-sheriff/src/main/resources/schema/gateway.schema.json index 6e8d3011..a7fb8c40 100644 --- a/api-sheriff/src/main/resources/schema/gateway.schema.json +++ b/api-sheriff/src/main/resources/schema/gateway.schema.json @@ -252,6 +252,10 @@ "leeway_seconds": { "type": "integer" }, "on_failure": { "type": "string", "enum": ["reauthenticate", "reject"] } } + }, + "max_sessions": { + "type": "integer", + "description": "Server-mode upper bound on concurrently stored sessions — a DoS guard on the in-memory store. Creating a session beyond the bound is refused fail-closed." } } }, @@ -262,6 +266,32 @@ "enabled": { "type": "boolean" }, "honor_upstream_challenge": { "type": "boolean" } } + }, + "user_info": { + "type": "object", + "additionalProperties": false, + "description": "Session/user-info reserved-endpoint settings (fold). The curated identity view the gateway serves to the browser, capped by an operator-owned claim allowlist whose secure default is closed: an empty allowed_claims discloses nothing.", + "properties": { + "path": { "type": "string" }, + "allowed_claims": { + "type": "array", + "description": "Operator claim allowlist; empty is the secure closed default that discloses nothing. The operator — never the browser client — widens disclosure.", + "items": { "type": "string" } + }, + "default_view": { + "type": "array", + "description": "Curated default-view claim selector returned when the caller requests no explicit claims; every entry must lie within allowed_claims.", + "items": { "type": "string" } + } + } + }, + "login": { + "type": "object", + "additionalProperties": false, + "description": "Login-initiation reserved-path settings (fold). Mirrors the logout shape so the login-initiation endpoint reads as oidc.login.path.", + "properties": { + "path": { "type": "string" } + } } } } diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/auth/AuthenticationStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/auth/AuthenticationStageTest.java index 2712f145..321342c2 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/auth/AuthenticationStageTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/auth/AuthenticationStageTest.java @@ -18,11 +18,21 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.List; import java.util.Map; +import de.cuioss.sheriff.gateway.bff.runtime.SessionAuthenticationStage; +import de.cuioss.sheriff.gateway.bff.runtime.SessionAuthenticationStage.LoginChallenge; +import de.cuioss.sheriff.gateway.bff.session.InMemorySessionStore; +import de.cuioss.sheriff.gateway.bff.session.SessionCookieCodec; +import de.cuioss.sheriff.gateway.bff.session.SessionRecord; import de.cuioss.sheriff.gateway.config.model.AuthConfig; import de.cuioss.sheriff.gateway.config.model.HttpMethod; import de.cuioss.sheriff.gateway.events.EventType; @@ -34,14 +44,19 @@ import de.cuioss.sheriff.token.validation.test.generator.TestTokenGenerators; import de.cuioss.test.generator.junit.EnableGeneratorController; +import jakarta.inject.Provider; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @EnableGeneratorController -@DisplayName("AuthenticationStage — stage 4 offline bearer-token validation") +@DisplayName("AuthenticationStage — stage 4 auth dispatch (offline bearer validation and session dispatch)") class AuthenticationStageTest { private static final String ABSENT_SCOPE = "gateway:definitely-absent-scope-xyz"; + private static final Instant NOW = Instant.parse("2026-07-23T10:00:00Z"); + private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + private static final String SESSION_ID = "opaque-session-id"; + private static final String MEDIATED_TOKEN = "mediated-access-token"; @Test @DisplayName("passes a require:none route without inspecting any token") @@ -125,11 +140,77 @@ void rejectsMissingScope() { assertEquals(EventType.SCOPE_MISSING, thrown.getEventType()); } + @Test + @DisplayName("dispatches a require:session route to the wired session stage-4 runtime") + void dispatchesSessionRouteToWiredSessionStage() { + // Arrange — a session stage wired with a live session; a require:session request carrying the + // session cookie must be dispatched here and complete, recording the mediated bearer. + AuthenticationStage stage = new AuthenticationStage(failingValidatorProvider(), sessionStage()); + PipelineRequest request = sessionRequest(authConfig("session", List.of())); + + // Act + Assert + assertDoesNotThrow(() -> stage.process(request)); + assertTrue(request.mediatedBearer().isPresent(), + "dispatch reached the session stage, which mediated the session's bearer"); + assertEquals(MEDIATED_TOKEN, request.mediatedBearer().orElseThrow()); + } + + @Test + @DisplayName("rejects a require:session route when no session runtime is wired") + void rejectsSessionRouteWithoutWiredSessionRuntime() { + // Arrange — a stage built without a session runtime (non-BFF gateway). + AuthenticationStage stage = stageFor(TestTokenGenerators.accessTokens().next()); + PipelineRequest request = sessionRequest(authConfig("session", List.of())); + + // Act + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> stage.process(request)); + + // Assert + assertTrue(thrown.getMessage().contains("no session runtime is wired"), + "the unwired session route is a boot-configuration error, not a served request"); + } + private static AuthenticationStage stageFor(TestTokenHolder holder) { TokenValidator validator = TokenValidator.builder().issuerConfig(holder.getIssuerConfig()).build(); return new AuthenticationStage(() -> validator); } + private static Provider failingValidatorProvider() { + return () -> { + throw new AssertionError("a require:session route must not resolve the bearer validator"); + }; + } + + private static SessionAuthenticationStage sessionStage() { + InMemorySessionStore store = new InMemorySessionStore(16); + store.create(SessionRecord.builder() + .sessionId(SESSION_ID) + .accessToken(MEDIATED_TOKEN) + .idToken("id-token") + .sub("subject") + .expiresAt(NOW.plusSeconds(3600)) + .build()); + SessionCookieCodec codec = new SessionCookieCodec(SessionCookieCodec.DEFAULT_COOKIE_NAME, Duration.ofHours(1)); + return new SessionAuthenticationStage(store, codec, + (session, now) -> session, + (accessToken, requiredScopes) -> true, + (returnUrl, now) -> new LoginChallenge("https://idp.example/authorize", List.of()), + CLOCK); + } + + private static PipelineRequest sessionRequest(AuthConfig auth) { + PipelineRequest request = PipelineRequest.builder() + .method(HttpMethod.GET) + .requestPath("/app/orders") + .queryParameters(Map.of()) + .headers(Map.of("cookie", List.of(SessionCookieCodec.DEFAULT_COOKIE_NAME + "=" + SESSION_ID), + "accept", List.of("application/json"))) + .build(); + request.canonicalPath("/app/orders"); + request.selectedRoute(RouteRuntime.builder().id("orders").effectiveAuth(auth).build()); + return request; + } + private static AuthConfig authConfig(String require, List requiredScopes) { return AuthConfig.builder().require(require).requiredScopes(requiredScopes).build(); } diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/csrf/CsrfDefenceTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/csrf/CsrfDefenceTest.java new file mode 100644 index 00000000..8bcab999 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/csrf/CsrfDefenceTest.java @@ -0,0 +1,160 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.csrf; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Map; +import java.util.Set; + + +import de.cuioss.sheriff.gateway.config.model.HttpMethod; +import de.cuioss.sheriff.gateway.events.EventType; +import de.cuioss.sheriff.gateway.events.GatewayException; +import de.cuioss.sheriff.gateway.pipeline.PipelineRequest; +import de.cuioss.test.juli.junit5.EnableTestLogger; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +/** + * Contract of the fixed CSRF defence for {@code require: session} routes (D7). The defence is + * fail-closed on unsafe methods: it accepts only a trusted {@code Origin} (exact full-origin match, + * case-insensitive) or, when {@code Origin} is absent, a {@code Sec-Fetch-Site: same-origin} header — + * everything else is rejected with {@link EventType#CSRF_REJECTED}. Safe methods + * ({@code GET}/{@code HEAD}/{@code OPTIONS}) are never checked. + */ +@EnableTestLogger +@DisplayName("CsrfDefence — fixed, fail-closed CSRF defence for session routes") +class CsrfDefenceTest { + + private static final String ORIGIN_HEADER = "Origin"; + private static final String SEC_FETCH_SITE_HEADER = "Sec-Fetch-Site"; + private static final String TRUSTED_ORIGIN = "https://app.example.com"; + private static final String UNTRUSTED_ORIGIN = "https://evil.example.com"; + + private final CsrfDefence defence = new CsrfDefence(Set.of(TRUSTED_ORIGIN)); + + private static PipelineRequest request(HttpMethod method, Map> headers) { + return PipelineRequest.builder() + .method(method) + .requestPath("/api/orders") + .headers(headers) + .build(); + } + + private static void assertRejected(CsrfDefence defence, PipelineRequest request, String reason) { + GatewayException thrown = assertThrows(GatewayException.class, () -> defence.enforce(request), reason); + assertEquals(EventType.CSRF_REJECTED, thrown.getEventType(), + "a CSRF rejection must carry the CSRF_REJECTED event"); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"GET", "HEAD", "OPTIONS"}) + @DisplayName("a CSRF-safe method bypasses the defence even with no Origin proof") + void safeMethodsBypass(HttpMethod method) { + PipelineRequest request = request(method, Map.of()); + + assertDoesNotThrow(() -> defence.enforce(request), + "safe methods carry no CSRF risk and are never checked"); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"POST", "PUT", "PATCH", "DELETE"}) + @DisplayName("an unsafe method with a trusted Origin is accepted") + void unsafeMethodTrustedOriginAccepted(HttpMethod method) { + PipelineRequest request = request(method, Map.of(ORIGIN_HEADER, List.of(TRUSTED_ORIGIN))); + + assertDoesNotThrow(() -> defence.enforce(request)); + } + + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"POST", "PUT", "PATCH", "DELETE"}) + @DisplayName("an unsafe method with an untrusted Origin is rejected") + void unsafeMethodUntrustedOriginRejected(HttpMethod method) { + PipelineRequest request = request(method, Map.of(ORIGIN_HEADER, List.of(UNTRUSTED_ORIGIN))); + + assertRejected(defence, request, "an untrusted Origin fails the exact-match check"); + } + + @Test + @DisplayName("the trusted-Origin match is case-insensitive") + void trustedOriginMatchIsCaseInsensitive() { + PipelineRequest request = request(HttpMethod.POST, Map.of(ORIGIN_HEADER, List.of("HTTPS://App.Example.COM"))); + + assertDoesNotThrow(() -> defence.enforce(request), + "the inbound Origin is lower-cased before comparison against the pre-lower-cased trusted set"); + } + + @Test + @DisplayName("an absent Origin with Sec-Fetch-Site: same-origin is accepted") + void absentOriginSameOriginFetchAccepted() { + PipelineRequest request = request(HttpMethod.POST, Map.of(SEC_FETCH_SITE_HEADER, List.of("same-origin"))); + + assertDoesNotThrow(() -> defence.enforce(request), + "with no Origin, a same-origin fetch-metadata header is the accepted proof"); + } + + @Test + @DisplayName("an absent Origin with Sec-Fetch-Site: cross-site is rejected") + void absentOriginCrossSiteFetchRejected() { + PipelineRequest request = request(HttpMethod.POST, Map.of(SEC_FETCH_SITE_HEADER, List.of("cross-site"))); + + assertRejected(defence, request, "a cross-site fetch is not same-origin provenance"); + } + + @Test + @DisplayName("an unsafe method with neither Origin nor Sec-Fetch-Site is rejected") + void bothHeadersAbsentRejected() { + PipelineRequest request = request(HttpMethod.POST, Map.of()); + + assertRejected(defence, request, "no proof of same-origin provenance is fail-closed"); + } + + @Test + @DisplayName("a present untrusted Origin is rejected even when Sec-Fetch-Site is same-origin") + void presentOriginTakesPrecedenceOverFetchMetadata() { + PipelineRequest request = request(HttpMethod.POST, Map.of( + ORIGIN_HEADER, List.of(UNTRUSTED_ORIGIN), + SEC_FETCH_SITE_HEADER, List.of("same-origin"))); + + assertRejected(defence, request, + "a present Origin is authoritative — an untrusted Origin is not rescued by fetch metadata"); + } + + @Test + @DisplayName("with an empty trusted set only a same-origin fetch can pass an unsafe method") + void emptyTrustedSetIsFailClosedExceptSameOriginFetch() { + CsrfDefence closed = new CsrfDefence(Set.of()); + + assertRejected(closed, request(HttpMethod.POST, Map.of(ORIGIN_HEADER, List.of(TRUSTED_ORIGIN))), + "with no trusted origins configured, every Origin is untrusted"); + assertDoesNotThrow(() -> closed.enforce(request(HttpMethod.POST, + Map.of(SEC_FETCH_SITE_HEADER, List.of("same-origin")))), + "a same-origin fetch still passes with an empty trusted set"); + } + + @Test + @DisplayName("the constructor rejects a null trusted-origin set") + void constructorRejectsNullTrustedOrigins() { + assertThrows(NullPointerException.class, () -> new CsrfDefence(null)); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/login/LoginFlowTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/login/LoginFlowTest.java new file mode 100644 index 00000000..433d7cde --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/login/LoginFlowTest.java @@ -0,0 +1,146 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.login; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.Optional; + + +import de.cuioss.sheriff.gateway.bff.login.LoginFlow.AuthorizationInitiation; +import de.cuioss.sheriff.gateway.bff.login.LoginFlow.LoginRedirect; +import de.cuioss.sheriff.gateway.bff.pending.BindingCookieCodec; +import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationRecord; +import de.cuioss.sheriff.gateway.bff.pending.PendingAuthorizationStore; +import de.cuioss.sheriff.token.client.flow.AuthorizationCodeFlow; +import de.cuioss.sheriff.token.client.flow.FlowContext; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Tests for {@link LoginFlow}: the login-initiation orchestration — engine-driven authorization, + * pending-record persistence, the browser-binding cookie, and the same-origin return-URL guard. + *

      + * The engine authorization is driven through the {@link AuthorizationInitiation} seam with a + * hand-built {@link AuthorizationCodeFlow.AuthorizationRedirect}, so the flow is exercised without a + * live IdP or discovery round-trip. + */ +class LoginFlowTest { + + private static final Instant T0 = Instant.parse("2026-07-23T10:00:00Z"); + private static final String GATEWAY_ORIGIN = "https://gw.example.com"; + private static final String AUTHORIZATION_URL = "https://idp.example.com/authorize?client_id=api-sheriff"; + + private PendingAuthorizationStore.InMemory pendingStore; + private BindingCookieCodec bindingCodec; + private FlowContext flowContext; + private LoginFlow loginFlow; + + @BeforeEach + void setUp() { + pendingStore = new PendingAuthorizationStore.InMemory(8); + bindingCodec = new BindingCookieCodec(PendingAuthorizationRecord.FIXED_TTL); + flowContext = FlowContext.create(GATEWAY_ORIGIN + "/auth/callback"); + AuthorizationCodeFlow.AuthorizationRedirect redirect = + new AuthorizationCodeFlow.AuthorizationRedirect(AUTHORIZATION_URL, flowContext); + AuthorizationInitiation authorization = () -> redirect; + loginFlow = new LoginFlow(authorization, pendingStore, bindingCodec, GATEWAY_ORIGIN); + } + + private PendingAuthorizationRecord consumeBoundRecord(LoginRedirect result) { + String cookieHeader = result.setCookieHeaders().getFirst().split(";", 2)[0]; + Optional recordId = bindingCodec.readRecordId(cookieHeader); + assertTrue(recordId.isPresent(), "the login sets a binding cookie carrying the record id"); + return pendingStore.consume(recordId.get(), T0).orElseThrow(); + } + + @Nested + @DisplayName("Engine-driven redirect") + class Redirect { + + @Test + @DisplayName("Should redirect to the engine authorization URL and set the binding cookie") + void shouldRedirectToAuthorizationUrl() { + LoginRedirect result = loginFlow.initiate("/dashboard", T0); + + assertEquals(AUTHORIZATION_URL, result.authorizationUrl(), "the URL comes from the engine, unchanged"); + assertEquals(1, result.setCookieHeaders().size()); + assertTrue(result.setCookieHeaders().getFirst().startsWith(BindingCookieCodec.COOKIE_NAME + "=")); + } + + @Test + @DisplayName("Should persist the engine FlowContext as a single-use pending record bound to the browser") + void shouldPersistPendingRecord() { + LoginRedirect result = loginFlow.initiate("/dashboard", T0); + + PendingAuthorizationRecord pending = consumeBoundRecord(result); + assertSame(flowContext, pending.flowContext(), "the record wraps the engine context, never re-invents it"); + assertEquals("/dashboard", pending.returnUrl()); + assertEquals(T0, pending.createdAt()); + } + } + + @Nested + @DisplayName("Same-origin return-URL guard (never an open redirect)") + class ReturnUrlGuard { + + @ParameterizedTest(name = "same-origin return URL \"{0}\" is recorded verbatim") + @ValueSource(strings = {"/dashboard", "/app/page?x=1", "https://gw.example.com/app"}) + @DisplayName("Should record a same-origin return URL verbatim") + void shouldRecordSameOriginReturnUrl(String returnUrl) { + LoginRedirect result = loginFlow.initiate(returnUrl, T0); + + assertEquals(returnUrl, consumeBoundRecord(result).returnUrl()); + } + + @ParameterizedTest(name = "off-origin return URL \"{0}\" falls back to the default landing") + @ValueSource(strings = {"https://evil.example.com/app", "//evil.example.com", "javascript:alert(1)"}) + @DisplayName("Should fall back to the default landing for a cross-origin or unparseable return URL") + void shouldFallBackForOffOrigin(String returnUrl) { + LoginRedirect result = loginFlow.initiate(returnUrl, T0); + + assertEquals(LoginFlow.DEFAULT_RETURN_URL, consumeBoundRecord(result).returnUrl()); + } + + @Test + @DisplayName("Should fall back to the default landing when no return URL is supplied") + void shouldFallBackForNull() { + LoginRedirect result = loginFlow.initiate(null, T0); + + assertEquals(LoginFlow.DEFAULT_RETURN_URL, consumeBoundRecord(result).returnUrl()); + } + } + + @Nested + @DisplayName("Argument contract") + class ArgumentContract { + + @Test + @DisplayName("Should reject a null reference instant") + void shouldRejectNullNow() { + assertThrows(NullPointerException.class, () -> loginFlow.initiate("/dashboard", null)); + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/logout/LogoutTokenValidatorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/logout/LogoutTokenValidatorTest.java new file mode 100644 index 00000000..c431468b --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/logout/LogoutTokenValidatorTest.java @@ -0,0 +1,323 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.bff.logout; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + + +import de.cuioss.sheriff.gateway.bff.logout.LogoutTokenValidator.LogoutSubject; +import de.cuioss.sheriff.token.validation.domain.claim.ClaimValue; +import de.cuioss.sheriff.token.validation.domain.token.IdTokenContent; +import de.cuioss.sheriff.token.validation.domain.token.TokenContent; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link LogoutTokenValidator}: the pure OIDC back-channel-logout-token claim residual + * applied after the engine has signature-verified the token. Every negative case in the check + * matrix — {@code iss} wrong, {@code aud} wrong, {@code iat} outside the freshness window (both + * directions) or absent, {@code events} missing the back-channel event, {@code nonce} present, and + * both {@code sub}/{@code sid} absent — must fail-closed to {@link Optional#empty()}, and the two + * single-subject success shapes ({@code sub}-only, {@code sid}-only) must resolve. + *

      + * The token is hand-built as an {@link IdTokenContent} claim carrier over a {@link ClaimValue} map, + * so every case is exercised without a live IdP or a signed token. + */ +class LogoutTokenValidatorTest { + + private static final String ISSUER = "https://idp.example.com"; + private static final String AUDIENCE = "bff-client"; + private static final Duration FRESHNESS = Duration.ofMinutes(2); + private static final Instant NOW = Instant.parse("2026-07-23T10:00:00Z"); + private static final String SUB = "user-sub-1"; + private static final String SID = "idp-sid-9"; + private static final String RAW = "raw-logout-token"; + private static final String OTHER_EVENT = "{\"http://schemas.openid.net/event/token-revoked\":{}}"; + + private final LogoutTokenValidator validator = new LogoutTokenValidator(ISSUER, AUDIENCE, FRESHNESS); + + private static Map validClaims() { + return new HashMap<>(Map.of( + "iss", ClaimValue.forPlainString(ISSUER), + "aud", ClaimValue.forList("aud", List.of(AUDIENCE)), + "iat", ClaimValue.forDateTime("iat", OffsetDateTime.ofInstant(NOW, ZoneOffset.UTC)), + "events", ClaimValue.forPlainString( + "{\"" + LogoutTokenValidator.BACKCHANNEL_LOGOUT_EVENT + "\":{}}"), + "sub", ClaimValue.forPlainString(SUB), + "sid", ClaimValue.forPlainString(SID))); + } + + private static TokenContent token(Map claims) { + return new IdTokenContent(claims, RAW); + } + + @Nested + @DisplayName("Accepted logout tokens") + class Accepted { + + @Test + @DisplayName("Should accept a fully valid logout token and resolve sub + sid") + void shouldAcceptValidToken() { + Optional result = validator.validate(token(validClaims()), NOW); + + assertTrue(result.isPresent(), "a fully valid logout token is accepted"); + assertEquals(Optional.of(SUB), result.get().sub()); + assertEquals(Optional.of(SID), result.get().sid()); + } + + @Test + @DisplayName("Should accept a token carrying only sub (sid absent)") + void shouldAcceptSubOnly() { + Map claims = validClaims(); + claims.remove("sid"); + + Optional result = validator.validate(token(claims), NOW); + + assertTrue(result.isPresent()); + assertEquals(Optional.of(SUB), result.get().sub()); + assertTrue(result.get().sid().isEmpty(), "sid is absent"); + } + + @Test + @DisplayName("Should accept a token carrying only sid (sub absent)") + void shouldAcceptSidOnly() { + Map claims = validClaims(); + claims.remove("sub"); + + Optional result = validator.validate(token(claims), NOW); + + assertTrue(result.isPresent()); + assertTrue(result.get().sub().isEmpty(), "sub is absent"); + assertEquals(Optional.of(SID), result.get().sid()); + } + + @Test + @DisplayName("Should accept a plain-string aud equal to the client id") + void shouldAcceptPlainStringAudience() { + Map claims = validClaims(); + claims.put("aud", ClaimValue.forPlainString(AUDIENCE)); + + assertTrue(validator.validate(token(claims), NOW).isPresent()); + } + + @Test + @DisplayName("Should accept an aud array that contains the client id among others") + void shouldAcceptAudienceListContainingClient() { + Map 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 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() { + 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() { + TokenContent token = token(validClaims()); + assertThrows(NullPointerException.class, () -> validator.validate(token, 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)); + } + } +} 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..4677fc04 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/pending/PendingAuthorizationStoreTest.java @@ -0,0 +1,250 @@ +/* + * 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 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; + +/** + * 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 pendingRecord(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 = pendingRecord("/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 = pendingRecord("/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 = pendingRecord("/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 = pendingRecord("/a", T0); + PendingAuthorizationRecord second = pendingRecord("/b", T0); + PendingAuthorizationRecord third = pendingRecord("/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 = pendingRecord("/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 = pendingRecord("/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 pending = PendingAuthorizationRecord.create(flow, "/dashboard", T0); + + 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 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 + @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)); + } + + @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() { + assertFalse(PendingAuthorizationRecord.sameOrigin(null, GATEWAY_ORIGIN)); + assertFalse(PendingAuthorizationRecord.sameOrigin(" ", GATEWAY_ORIGIN)); + } + } +} 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..b3eb43b0 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/StepUpCoordinatorTest.java @@ -0,0 +1,224 @@ +/* + * 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 pending = pendingStore.consume(recordIdFrom(outcome), NOW).orElseThrow(); + assertEquals(REPLAY_URL, pending.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 pending = pendingStore.consume(recordIdFrom(outcome), NOW).orElseThrow(); + assertEquals(StepUpCoordinator.DEFAULT_RETURN_URL, pending.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(); + var validSession = session(ACR); + var validChallenge = challenge(); + + assertThrows(NullPointerException.class, + () -> coordinator.coordinate(null, validChallenge, REPLAY_URL, NOW)); + assertThrows(NullPointerException.class, + () -> coordinator.coordinate(validSession, null, REPLAY_URL, NOW)); + assertThrows(NullPointerException.class, + () -> coordinator.coordinate(validSession, validChallenge, 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..6e6ad0e8 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/refresh/TokenRefreshCoordinatorTest.java @@ -0,0 +1,320 @@ +/* + * 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. 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); + 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 _) { + 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()); + + var session = session(CURRENT_REFRESH); + assertThrows(NullPointerException.class, () -> coordinator.refresh(null, NOW)); + assertThrows(NullPointerException.class, () -> coordinator.refresh(session, 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()); + } + } +} 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..3bdd907a --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/BackchannelLogoutEndpointTest.java @@ -0,0 +1,117 @@ +/* + * 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/CallbackEndpointTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java new file mode 100644 index 00000000..6d4d696a --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java @@ -0,0 +1,280 @@ +/* + * 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 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; + +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), + * 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 pending = PendingAuthorizationRecord.create(flow, RETURN_URL, T0); + pendingStore.store(pending); + recordId = pending.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<>(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); + 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().getFirst(); + 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().getFirst()).orElseThrow(); + Optional session = sessionStore.resolve(sessionId, T0); + + assertTrue(session.isPresent(), "the session was created under the opaque id from the cookie"); + 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 + @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/ClaimAllowlistFilterTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilterTest.java new file mode 100644 index 00000000..0993d874 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ClaimAllowlistFilterTest.java @@ -0,0 +1,175 @@ +/* + * 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() { + var allowedClaims = filter.allowedClaims(); + assertThrows(UnsupportedOperationException.class, () -> allowedClaims.add("email")); + } + + @Test + @DisplayName("defaultView is immutable") + void shouldExposeImmutableDefaultView() { + var defaultView = filter.defaultView(); + assertThrows(UnsupportedOperationException.class, () -> 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/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)); + } + } +} 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..375bf91a --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/LogoutEndpointTest.java @@ -0,0 +1,127 @@ +/* + * 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/reserved/ReservedPathRegistryTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistryTest.java new file mode 100644 index 00000000..c290111a --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/ReservedPathRegistryTest.java @@ -0,0 +1,147 @@ +/* + * 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 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; + +/** + * 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)); + } + } +} 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)); + } + } +} 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..8b837461 --- /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<>(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 new file mode 100644 index 00000000..b1e12594 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/runtime/SessionAuthenticationStageTest.java @@ -0,0 +1,346 @@ +/* + * 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("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 { + + @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 session) { + InMemorySessionStore store = new InMemorySessionStore(16); + store.create(session); + 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/bff/session/InMemorySessionStoreTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/InMemorySessionStoreTest.java new file mode 100644 index 00000000..bb0a27c4 --- /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 session = 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 = 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"); + 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..75515b6a --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/session/SessionCookieCodecTest.java @@ -0,0 +1,115 @@ +/* + * 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() { + 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/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); + } + } } 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..a75fc622 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java @@ -0,0 +1,507 @@ +/* + * 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.HashMap; +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.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; + +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, "GET"), 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, "POST"), 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, "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, "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); + } + } + + 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, + (session, instant) -> session, + (token, scopes) -> true, + (returnUrl, instant) -> new SessionAuthenticationStage.LoginChallenge("/login", List.of()), + Clock.systemUTC()); + + CsrfDefence csrf = new CsrfDefence(Set.of(ORIGIN)); + + StepUpCoordinator stepUp = new StepUpCoordinator( + (session, 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 793bf9a5..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 @@ -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; @@ -29,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; @@ -37,8 +37,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 +113,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 +153,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 @@ -186,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/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( 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/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/pipeline/BasicChecksStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStageTest.java index e7c4106e..95ec2f3a 100644 Binary files a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStageTest.java and b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/BasicChecksStageTest.java differ diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java new file mode 100644 index 00000000..7591662a --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java @@ -0,0 +1,264 @@ +/* + * 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, "GET"), + 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(); + BffRuntime.ReservedHttpRequest request = + new BffRuntime.ReservedHttpRequest("", null, null, null, null, null, "GET"); + Instant now = Instant.now(); + assertThrows(IllegalStateException.class, runtime::sessionStage); + assertThrows(IllegalStateException.class, + () -> runtime.dispatch(ReservedEndpoint.USER_INFO, request, now)); + } + } + + @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)); + } + + 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(); + } + } +} 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/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..24645594 --- /dev/null +++ b/benchmarks/src/main/resources/k6-scripts/session_mediated.js @@ -0,0 +1,156 @@ +/** + * @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. +// 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. +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. 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), + 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. */ 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) 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..fd7d30ee 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 @@ -473,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 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..61ac66cd --- /dev/null +++ b/doc/user/bff-session.adoc @@ -0,0 +1,150 @@ += 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. + +== 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. +* 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 } diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index a0b961b5..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) @@ -183,6 +197,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 @@ -257,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) @@ -274,6 +299,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/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 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 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..bd2eedbd 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 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:10444') is the gateway + # origin used for same-origin return-URL validation and the default CSRF trusted origin. + redirect_uri: https://localhost:10444/auth/callback + logout: + path: /auth/logout + post_logout_redirect_uri: https://localhost:10444/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:10444"] + 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/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..42e720ee 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: ${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 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..b1af2bf1 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/BffKeycloakLoginFlow.java @@ -0,0 +1,289 @@ +/* + * 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. + *

      + * 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 + * 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[^\"]*)\""); + + /** + * 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 + } + + /** + * 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. 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(200).extract().response(); + String formPostHtml = formPost.asString(); + String callbackAction = rewriteToHost(extractFormPostAction(formPostHtml)); + Map callbackFields = extractHiddenInputs(formPostHtml); + + // 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().post(callbackAction) + .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) { + // 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); + } + + /** + * 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) { + // 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); + } + + /** + * 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()) { + 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)); + } + + /** + * 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(">", ">"); + } +} 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..58fa8aa2 --- /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("claims.preferred_username"), + "the curated default view must disclose the session user's preferred_username"); + assertNull(response.path("claims.client_secret"), + "the fold must never disclose a claim outside the operator allowlist"); + } +}