diff --git a/.github/workflows/demo-client-e2e.yml b/.github/workflows/demo-client-e2e.yml new file mode 100644 index 00000000..4795b22d --- /dev/null +++ b/.github/workflows/demo-client-e2e.yml @@ -0,0 +1,116 @@ +# The demo SPA's browser end-to-end suite (demo-client), run against the real Keycloak stack. +# +# DEDICATED AND OPT-IN BY DESIGN. This workflow is triggered by workflow_dispatch and by pushes to +# main — deliberately NOT by any pull_request trigger, and deliberately NOT folded into maven.yml or +# integration-tests.yml. The suite builds a native image, starts containers and downloads a browser +# toolchain; putting that on the default pull-request path would slow every PR for a signal that is +# about the demo client, not about the gateway. Keep it that way: if this ever needs to gate a PR, +# that is a decision to take explicitly, not by adding a trigger here in passing. +name: Demo Client E2E + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +# One demo run at a time — the suite drives a shared container stack on fixed host ports, so two +# concurrent runs on the same runner class would collide. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + demo-client-e2e: + name: Demo SPA browser suite (both session modes) + runs-on: ubuntu-latest + # The native compile is the dominant and most variable term; the container bring-up and the two + # Playwright projects together are minutes, not tens of minutes. + timeout-minutes: 60 + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up JDK 25 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: '25' + distribution: 'temurin' + cache: maven + + # Build the native executable explicitly rather than letting the bring-up script's + # build-native-if-needed.sh do it implicitly. Both produce the same artifact, but doing it as + # its own step means a native compilation failure is reported as a native compilation failure + # instead of surfacing as a container that never became ready. + - name: Build the native executable + run: | + ./mvnw --no-transfer-progress clean package -Pnative -pl api-sheriff -am -DskipTests + + # The e2e-demo profile owns the whole lifecycle: it installs the pinned Node toolchain and the + # pinned browser, lints, runs start-dev-environment.sh (which rebuilds the api-sheriff image + # from the executable above and brings up the trimmed three-container stack), runs both + # Playwright projects, and tears the stack down again in post-integration-test. + - name: Run the demo E2E suite + run: | + ./mvnw --no-transfer-progress verify -Pe2e-demo -pl demo-client + + # THE TEARDOWN SAFETY NET, and it is not redundant with the profile's own post-integration-test + # teardown. frontend-maven-plugin's npm goal fails the build IMMEDIATELY on a non-zero npm exit + # — unlike maven-failsafe-plugin it records nothing for a later phase and honours no + # testFailureIgnore — so a FAILING suite means Maven never reaches post-integration-test and + # keycloak, api-sheriff and api-sheriff-cookie are all left running. Nothing else here reclaims + # them: the job runs `verify`, not `clean verify`, so the profile's pre-clean teardown never + # fires either. `always()` covers the failing-suite path and a cancelled run alike. + # + # `|| true` is a deliberate guard, not sloppiness. This step runs on EVERY outcome, including + # after a green suite whose post-integration-test teardown already emptied the stack — so the + # nothing-left-to-stop path is the common one, and it must never turn a green job red. + # stop-dev-environment.sh is not contracted to exit 0 on that path: demo-client/pom.xml already + # declares successCodes 0 AND 1 on both of its teardown executions for exactly this reason. + # (Observed locally on docker compose v2, the nothing-to-stop path does exit 0 — the guard + # covers the tolerated 1, it does not assume it.) + - name: Tear down the demo stack + if: always() + run: | + ./demo-client/scripts/stop-dev-environment.sh || true + + # Diagnostics, best-effort: the JUnit XML plus Playwright's failure-path traces, screenshots + # and videos all land under target/test-results. `always()` because a failing suite is exactly + # when they are worth having, and `warn` rather than `error` because an earlier step failing + # (a native compile that never produced an image, say) legitimately leaves nothing here — that + # is already reported by the step that actually failed, and re-reporting it as a missing + # artifact would only obscure the real cause. + - name: Upload JUnit results and failure diagnostics + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: demo-client-junit-results + path: demo-client/target/test-results/ + retention-days: 30 + if-no-files-found: warn + + # The documentation screenshot set: one parallel set per session-mode project, captured on the + # SUCCESS path at the meaningful states (anonymous, authenticated, full allowlisted view, + # claim denied, logged out). This is the artifact the demo exists to produce, so the + # assertion is deliberately strict — but scoped to `success()`, where it is meaningful: a + # suite that passed and produced no screenshots has silently stopped documenting anything, + # and that MUST fail. On a failed suite the set is legitimately incomplete, so the step does + # not run at all rather than adding a second red mark to an already-diagnosed failure. + - name: Upload documentation screenshots + if: success() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: demo-client-screenshots + path: demo-client/target/screenshots/ + retention-days: 30 + if-no-files-found: error diff --git a/.gitignore b/.gitignore index e507a5b9..b3e3b441 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ benchmarking/doc/templates/data/ .plan/* !.plan/marshal.json !.plan/project-architecture/ +node_modules/ diff --git a/.plan/project-architecture/_project.json b/.plan/project-architecture/_project.json index a39f9dd1..1023fabf 100644 --- a/.plan/project-architecture/_project.json +++ b/.plan/project-architecture/_project.json @@ -9,6 +9,8 @@ "api-sheriff": {}, "api-sheriff-parent": {}, "benchmarks": {}, + "demo-client-maven": {}, + "demo-client-npm": {}, "documentation": {}, "integration-tests": {} }, diff --git a/.plan/project-architecture/demo-client-maven/enriched.json b/.plan/project-architecture/demo-client-maven/enriched.json new file mode 100644 index 00000000..3348eda2 --- /dev/null +++ b/.plan/project-architecture/demo-client-maven/enriched.json @@ -0,0 +1,104 @@ +{ + "best_practices": [], + "insights": [], + "internal_dependencies": [], + "key_dependencies": [], + "key_dependencies_reasoning": "", + "key_packages": {}, + "purpose": "e2e-tests", + "purpose_reasoning": "packaging=pom with maven.compiler.skip and skipTests true and zero Java sources; the only executions are Playwright browser tests bound to integration-test behind -Pe2e-demo", + "responsibility": "Reactor-visible shell for the demo client: a Java-free pom-packaging module that owns the dependency-free demo SPA sources and the Playwright end-to-end suite, and wires the pinned Node/npm toolchain plus the stack bring-up and teardown scripts behind the opt-in e2e-demo profile. A default reactor build of this module is a deliberate no-op - no Node download, no npm install, no container, no artifact - and nothing here ever reaches the production image or the native build.", + "responsibility_reasoning": "demo-client/pom.xml description and comments, the e2e-demo profile executions (frontend-maven-plugin install-node-and-npm, npm ci, npm run lint:strict, npm run install-browser, npm run test) and the exec-maven-plugin start/stop-dev-environment bindings", + "skills_by_profile": { + "implementation": { + "defaults": [ + { + "description": "Foundational agent behavior rules (user interaction, tool usage, research, dependency management)", + "skill": "plan-marshall:persona-plan-marshall-agent" + }, + { + "description": "Language-agnostic code quality, refactoring, and documentation principles", + "skill": "plan-marshall:ref-code-quality" + }, + { + "description": "marshalld build-server consumption client (submit/wait/ping/preflight)", + "skill": "plan-marshall:build-server-client" + }, + { + "description": "Core JavaScript standards: ES modules, modern patterns, DOM trust boundaries and XSS prevention, code quality", + "skill": "pm-dev-frontend:javascript" + }, + { + "description": "ESLint flat config, Prettier and Stylelint configuration and enforcement", + "skill": "pm-dev-frontend:lint-config" + }, + { + "description": "Modern CSS standards covering responsive design, quality practices and tooling", + "skill": "pm-dev-frontend:css" + } + ] + }, + "module_testing": { + "defaults": [ + { + "description": "Foundational agent behavior rules (user interaction, tool usage, research, dependency management)", + "skill": "plan-marshall:persona-plan-marshall-agent" + }, + { + "description": "Language-agnostic code quality principles (SRP, CQS, complexity, error handling)", + "skill": "plan-marshall:ref-code-quality" + }, + { + "description": "Language-agnostic testing methodology (AAA, coverage, reliability, determinism)", + "skill": "plan-marshall:persona-module-tester" + }, + { + "description": "Integration-testing persona for the cross-component browser suite (Playwright)", + "skill": "plan-marshall:persona-integration-tester" + }, + { + "description": "marshalld build-server consumption client (submit/wait/ping/preflight)", + "skill": "plan-marshall:build-server-client" + }, + { + "description": "Core JavaScript standards: ES modules, modern patterns, DOM trust boundaries and XSS prevention, code quality", + "skill": "pm-dev-frontend:javascript" + } + ] + }, + "quality": { + "defaults": [ + { + "description": "Foundational agent behavior rules (user interaction, tool usage, research, dependency management)", + "skill": "plan-marshall:persona-plan-marshall-agent" + }, + { + "description": "Language-agnostic code quality, refactoring, and documentation principles", + "skill": "plan-marshall:ref-code-quality" + }, + { + "description": "Core JavaScript standards: ES modules, modern patterns, DOM trust boundaries and XSS prevention, code quality", + "skill": "pm-dev-frontend:javascript" + }, + { + "description": "ESLint flat config, Prettier and Stylelint configuration and enforcement", + "skill": "pm-dev-frontend:lint-config" + } + ] + }, + "security": { + "defaults": [ + { + "description": "JavaScript security: DOM trust boundaries, XSS sinks, sanitization and Trusted Types", + "skill": "pm-dev-frontend:javascript-security" + }, + { + "description": "Core JavaScript standards: ES modules, modern patterns, DOM trust boundaries and XSS prevention, code quality", + "skill": "pm-dev-frontend:javascript" + } + ] + } + }, + "skills_by_profile_reasoning": "Composed by hand rather than by add-domain because the domain detector keys off build_systems=maven and therefore offers only java, java-cui and general-dev for this module, while every file it owns (SPA app.js, Playwright specs, fixtures, utils, playwright.config.js, eslint.config.js) is JavaScript and it carries no Java at all. general-dev supplies the cross-cutting defaults; the pm-dev-frontend skills supply the surface that actually applies; persona-integration-tester is included under module_testing because the tests here are browser end-to-end specs, not unit tests, so jest-testing does not apply.", + "tips": [] +} \ No newline at end of file diff --git a/.plan/project-architecture/demo-client-npm/enriched.json b/.plan/project-architecture/demo-client-npm/enriched.json new file mode 100644 index 00000000..07858f94 --- /dev/null +++ b/.plan/project-architecture/demo-client-npm/enriched.json @@ -0,0 +1,100 @@ +{ + "best_practices": [], + "insights": [], + "internal_dependencies": [], + "key_dependencies": [], + "key_dependencies_reasoning": "", + "key_packages": {}, + "purpose": "e2e-tests", + "purpose_reasoning": "the module contributes no shipped artifact; its only outputs are Playwright browser test results and lint findings", + "responsibility": "npm-side view of the same demo-client directory: owns package.json, the pinned Playwright and ESLint toolchain, the lint:strict script and the browser end-to-end specs that drive the demo SPA against the integration-tests stack in both session modes (server-side and cookie). Installed and executed only from the demo-client-maven e2e-demo profile, never from a default reactor build.", + "responsibility_reasoning": "demo-client/package.json scripts and devDependencies, playwright.config.js project matrix, tests/01-04 spec files, eslint.config.js", + "skills_by_profile": { + "implementation": { + "defaults": [ + { + "description": "Core JavaScript development standards covering ES modules, modern patterns, web component patterns, DOM trust boundaries / XSS prevention, and code quality", + "skill": "pm-dev-frontend:javascript" + }, + { + "description": "Language-agnostic code quality principles (SRP, CQS, complexity, error handling)", + "skill": "plan-marshall:ref-code-quality" + }, + { + "description": "ESLint, Prettier, and Stylelint configuration and enforcement with systematic fixing", + "skill": "pm-dev-frontend:lint-config" + }, + { + "description": "Modern CSS standards covering essentials, responsive design, quality practices, and tooling", + "skill": "pm-dev-frontend:css" + }, + { + "description": "Foundational agent behavior rules (user interaction, tool usage, research, dependency management)", + "skill": "plan-marshall:persona-plan-marshall-agent" + }, + { + "description": "marshalld build-server consumption client (submit/wait/ping/preflight) \u2014 build-dispatch routes builds through the daemon when the project is registered, else falls back in-process", + "skill": "plan-marshall:build-server-client" + } + ] + }, + "module_testing": { + "defaults": [ + { + "description": "Core JavaScript development standards covering ES modules, modern patterns, web component patterns, DOM trust boundaries / XSS prevention, and code quality", + "skill": "pm-dev-frontend:javascript" + }, + { + "description": "Language-agnostic code quality principles (SRP, CQS, complexity, error handling)", + "skill": "plan-marshall:ref-code-quality" + }, + { + "description": "Language-agnostic testing methodology (AAA, coverage, reliability, determinism)", + "skill": "plan-marshall:persona-module-tester" + }, + { + "description": "JavaScript unit testing with Jest, DOM testing, mocking, async patterns", + "skill": "pm-dev-frontend:jest-testing" + }, + { + "description": "Foundational agent behavior rules (user interaction, tool usage, research, dependency management)", + "skill": "plan-marshall:persona-plan-marshall-agent" + }, + { + "description": "marshalld build-server consumption client (submit/wait/ping/preflight) \u2014 test-run dispatch routes through the daemon when the project is registered, else falls back in-process", + "skill": "plan-marshall:build-server-client" + } + ] + }, + "quality": { + "defaults": [ + { + "description": "Foundational agent behavior rules (user interaction, tool usage, research, dependency management)", + "skill": "plan-marshall:persona-plan-marshall-agent" + }, + { + "description": "Language-agnostic code quality, refactoring, and documentation principles", + "skill": "plan-marshall:ref-code-quality" + } + ] + }, + "security": { + "defaults": [ + { + "description": "Core JavaScript development standards covering ES modules, modern patterns, web component patterns, DOM trust boundaries / XSS prevention, and code quality", + "skill": "pm-dev-frontend:javascript" + }, + { + "description": "Language-agnostic code quality principles (SRP, CQS, complexity, error handling)", + "skill": "plan-marshall:ref-code-quality" + }, + { + "description": "JavaScript security \u2014 DOM trust boundaries, XSS sinks, sanitization, and Trusted Types", + "skill": "pm-dev-frontend:javascript-security" + } + ] + } + }, + "skills_by_profile_reasoning": "Every file this module owns is JavaScript: the Playwright specs, fixtures, utils, playwright.config.js and eslint.config.js. Optionals are included because lint-config (ESLint flat config behind npm run lint:strict) and css (the SPA stylesheet served from this directory) are both live surfaces.; Cross-cutting agent behaviour, code-quality and build-dispatch skills apply to every module.; The SPA renders identity and claim data into the DOM, so the JavaScript security surface (DOM trust boundaries, XSS sinks, sanitization) applies to this module under the security profile.", + "tips": [] +} \ No newline at end of file diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/LoginFlow.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/LoginFlow.java index bcabc429..93cd3457 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/LoginFlow.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/LoginFlow.java @@ -46,6 +46,12 @@ * 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. + *

+ * Response mode. The authorization URL the seam yields carries + * {@code response_mode=query} — see {@link QueryResponseModeAuthorizationRequestBuilder}, which the + * runtime wires into the engine. That is what makes the later callback a top-level GET navigation, + * the only shape on which the browser sends the {@code SameSite=Lax} binding cookie this flow sets + * here. The mode is therefore not incidental to the binding cookie — the two are one design. * * @author API Sheriff Team * @since 1.0 diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/QueryResponseModeAuthorizationRequestBuilder.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/QueryResponseModeAuthorizationRequestBuilder.java new file mode 100644 index 00000000..7a99a1c7 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/login/QueryResponseModeAuthorizationRequestBuilder.java @@ -0,0 +1,170 @@ +/* + * 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.util.ArrayList; +import java.util.List; +import java.util.Objects; + + +import de.cuioss.sheriff.gateway.bff.pending.BindingCookieCodec; +import de.cuioss.sheriff.token.client.config.ClientConfiguration; +import de.cuioss.sheriff.token.client.discovery.ProviderMetadata; +import de.cuioss.sheriff.token.client.flow.AuthorizationRequestBuilder; +import de.cuioss.sheriff.token.client.flow.FlowContext; +import de.cuioss.tools.logging.CuiLogger; + +/** + * The gateway-owned authorization-request builder that drives the OIDC auth-code flow with + * {@code response_mode=query} instead of the engine's built-in {@code response_mode=form_post}. + *

+ * Why the gateway overrides the engine here. {@link AuthorizationRequestBuilder} + * unconditionally emits {@code response_mode=form_post}, so after a successful IdP login the browser + * performs a cross-site POST (the IdP's auto-submit form) to the {@code redirect_uri}. The + * short-lived browser-binding cookie minted by {@link BindingCookieCodec} carries + * {@code SameSite=Lax}, and a Lax cookie is not sent on a cross-site POST — only on a + * top-level GET navigation. The real browser therefore dropped the binding cookie on the callback + * leg and every login dead-ended on the + * {@code OIDC callback without a browser-binding cookie — rejected} {@code 403} branch. Driving the + * request with {@code response_mode=query} makes the callback a {@code 302} top-level GET + * navigation, for which {@code SameSite=Lax} is sent, so the binding cookie survives. + *

+ * The rejected alternative was {@code SameSite=None} on the binding cookie: that would have weakened + * the exact cross-site binding control the cookie exists to provide. No cookie attribute is changed + * by this component — {@code __Host-} prefix, {@code Secure}, {@code HttpOnly}, {@code Path=/} and + * {@code SameSite=Lax} all stay exactly as they were. + *

+ * Accepted tradeoff — the authorization code travels in the URL query string. + * {@code response_mode=query} places the {@code code} in the callback URL rather than in a POST + * body, which exposes it to the {@code Referer} header, to proxy / CDN and server access logs, and to + * browser history. That exposure is the standard reason {@code form_post} is preferred, and it is + * accepted here deliberately, by operator decision, because it is what makes the + * browser-facing flow work at all. The mitigations, each verified in this codebase rather than + * assumed: + *

+ * Not overstated: the exposure of the code to intermediaries is real and is not removed by any of + * the above — it is bounded by them. + *

+ * How the rewrite works. {@link #build} delegates to the engine and then rewrites + * only the {@code response_mode} parameter of the returned URL. The rewrite is + * parameter-aware (it splits the query into its {@code name=value} pairs rather than substring- + * replacing the literal {@code form_post}), it preserves every other authorization parameter + * byte-for-byte in its original order and encoding ({@code client_id}, {@code redirect_uri}, + * {@code scope}, {@code state}, {@code nonce}, {@code code_challenge}, + * {@code code_challenge_method}, {@code acr_values}, {@code max_age}), and it is idempotent — a URL + * that already carries {@code response_mode=query} comes back unchanged. + *

+ * The same instance is wired into both engine seams that build an authorization URL — the + * {@code AuthorizationCodeFlow} login leg and the {@code StepUpHandler} RFC 9470 re-drive leg — so + * the step-up leg cannot keep emitting the broken mode. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class QueryResponseModeAuthorizationRequestBuilder extends AuthorizationRequestBuilder { + + private static final CuiLogger LOGGER = new CuiLogger(QueryResponseModeAuthorizationRequestBuilder.class); + + /** The authorization-request parameter this builder rewrites. */ + private static final String PARAM_RESPONSE_MODE = "response_mode"; + + /** The response mode the gateway drives: a {@code 302} top-level GET callback. */ + private static final String RESPONSE_MODE_QUERY = "query"; + + private static final String RESPONSE_MODE_PAIR = PARAM_RESPONSE_MODE + '=' + RESPONSE_MODE_QUERY; + private static final char QUERY_START = '?'; + private static final String PAIR_SEPARATOR = "&"; + private static final char NAME_VALUE_SEPARATOR = '='; + + /** + * Builds the engine's authorization URL and rewrites its {@code response_mode} to {@code query}. + * + * @param configuration the confidential-client configuration + * @param metadata the resolved OIDC provider metadata + * @param context the transaction context (state / nonce / PKCE, owned by the engine) + * @return the engine's authorization URL with {@code response_mode=query}, every other parameter + * preserved verbatim + */ + @Override + public String build(ClientConfiguration configuration, ProviderMetadata metadata, FlowContext context) { + return withQueryResponseMode(super.build(configuration, metadata, context)); + } + + /** + * Rewrites the {@code response_mode} parameter of an authorization URL to {@code query}, leaving + * every other parameter untouched. + *

+ * The comparison is against the literal parameter name because the engine form-encodes the query + * and {@code response_mode} contains no character that encoding alters. Values are never decoded + * and re-encoded: each untouched pair is copied through exactly as the engine emitted it, so no + * round-trip can corrupt an already-encoded {@code redirect_uri} or {@code scope}. The method is + * idempotent and total — a URL with no query at all, or with an empty query, simply gains the + * parameter. + * + * @param authorizationUrl the engine-built authorization URL + * @return the same URL with {@code response_mode=query} + */ + public static String withQueryResponseMode(String authorizationUrl) { + Objects.requireNonNull(authorizationUrl, "authorizationUrl"); + int queryStart = authorizationUrl.indexOf(QUERY_START); + if (queryStart < 0) { + return authorizationUrl + QUERY_START + RESPONSE_MODE_PAIR; + } + String prefix = authorizationUrl.substring(0, queryStart + 1); + String query = authorizationUrl.substring(queryStart + 1); + if (query.isEmpty()) { + return prefix + RESPONSE_MODE_PAIR; + } + List pairs = new ArrayList<>(); + boolean rewritten = false; + for (String pair : query.split(PAIR_SEPARATOR, -1)) { + if (PARAM_RESPONSE_MODE.equals(nameOf(pair))) { + pairs.add(RESPONSE_MODE_PAIR); + rewritten = true; + } else { + pairs.add(pair); + } + } + if (!rewritten) { + pairs.add(RESPONSE_MODE_PAIR); + } + // Never log the URL itself: it carries state, nonce and the PKCE code_challenge. + LOGGER.debug("Authorization request driven with response_mode=%s (rewritten=%s)", + RESPONSE_MODE_QUERY, rewritten); + return prefix + String.join(PAIR_SEPARATOR, pairs); + } + + private static String nameOf(String pair) { + int separator = pair.indexOf(NAME_VALUE_SEPARATOR); + return separator < 0 ? pair : pair.substring(0, separator); + } +} 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 index 76c3f8fa..20d09eb2 100644 --- 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 @@ -33,9 +33,19 @@ *

* 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. + * script access) and {@code SameSite=Lax}. 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. + *

+ * Why {@code SameSite=Lax} is both correct and sufficient here. The gateway drives + * the authorization request with {@code response_mode=query} (see + * {@link de.cuioss.sheriff.gateway.bff.login.QueryResponseModeAuthorizationRequestBuilder}), so the + * callback the IdP sends the browser to is a top-level GET navigation — precisely the + * request shape a Lax cookie is sent on, while every cross-site send is still blocked. That is + * exactly why the gateway does not need {@code SameSite=None} on this cookie: + * {@code None} would have to be paired with the cross-site sends this cookie exists to prevent, so + * it would weaken the browser-binding control itself. It was considered and rejected. The mode + * choice and this attribute are one design — do not change either in isolation. * * @author API Sheriff Team * @since 1.0 diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java index 9eaa6d65..bd3542e0 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpoint.java @@ -49,18 +49,42 @@ * 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. + * Query-mode callback. The gateway drives the authorization request with + * {@code response_mode=query} (see + * {@link de.cuioss.sheriff.gateway.bff.login.QueryResponseModeAuthorizationRequestBuilder}), so + * after a successful login the IdP answers a {@code 302} that navigates the browser to the + * {@code redirect_uri} with the {@code code}/{@code state} in the query string. That top-level GET + * navigation is the only callback shape on which the browser sends the {@code SameSite=Lax} + * browser-binding cookie the {@code 403} branch below requires; the engine's built-in + * {@code response_mode=form_post} produced a cross-site POST instead, on which a Lax cookie is + * dropped, so every real-browser login dead-ended there. The edge therefore hands this endpoint the + * raw query, and no body is read for the callback path at all. *

- * 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. + * Callback HPP defence (BFF-13) — verified on the query path. 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. The defence was re-verified end to end for the query shape rather than assumed to carry + * over: the edge populates {@code ReservedHttpRequest.rawQuery} from the genuinely raw Vert.x + * {@code HttpServerRequest.query()} — the untouched request-target query string, not a + * first-value-wins projection of the parsed parameter map — and + * {@code BffRuntime.callbackParameters} feeds exactly that string to the same {@code parse}. No + * stage between the edge and this endpoint collapses, reorders or de-duplicates the query, so a + * duplicated {@code code} or {@code state} still reaches {@code parse} and is still rejected + * {@code 400}. + *

+ * Accepted tradeoff — the code travels in the URL. {@code response_mode=query} + * places the authorization code in the callback URL, exposing it to the {@code Referer} header, to + * proxy / CDN and server access logs, and to browser history. That exposure is the standard reason + * {@code form_post} is preferred; it is accepted here deliberately, by operator decision, + * because it is what makes the browser-facing flow work at all. It is bounded — not removed — by + * PKCE (the engine always emits {@code code_challenge}/{@code code_challenge_method} and refuses a + * provider that does not advertise {@code S256}, so a leaked code is not redeemable without the + * gateway-held verifier), by the single-use, short-lived nature of the code at the token endpoint, + * and by the binding-cookie + {@code state} double-check this endpoint performs below, which + * rejects {@code 403} a code replayed from a different browser even while it is still live. The + * full statement of the tradeoff and of how each mitigation was verified lives on + * {@link de.cuioss.sheriff.gateway.bff.login.QueryResponseModeAuthorizationRequestBuilder}. *

* Browser binding (D2b). The pending-authorization record is resolved by the * unguessable id carried in the {@link BindingCookieCodec browser-binding cookie}, not by the @@ -125,9 +149,8 @@ public CallbackEndpoint(CodeExchange codeExchange, PendingAuthorizationStore pen * Handles one OIDC callback: validates the binding, drives the engine exchange, creates the * session, and returns the browser response. * - * @param 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 rawParameters the raw callback parameter string — the untouched query string of the + * {@code response_mode=query} GET 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, a {@code 400}/{@code 403} error outcome when the @@ -275,7 +298,7 @@ public interface CodeExchange { * {@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) + * @param params the parsed callback parameters (from the raw, never map-collapsed 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) diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java index 371495db..a5b34d6c 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/bff/runtime/BffRuntime.java @@ -218,14 +218,18 @@ public ReservedHttpResponse dispatch(ReservedEndpoint kind, ReservedHttpRequest } /** - * Selects the raw parameter string the callback parses. Keycloak drives the OIDC auth-code - * callback with {@code response_mode=form_post}: after a successful login it returns a 200 - * auto-submit form whose {@code code}/{@code state} are POSTed to the {@code redirect_uri} as an - * {@code application/x-www-form-urlencoded} body — never a 302 with the code in the query. A POST - * callback therefore parses the raw form body; a GET callback parses the raw query. Both are the - * same urlencoded parameter shape, so the single {@code CallbackParameters.parse} the callback - * uses handles either source and preserves the BFF-13 duplicate-parameter rejection (the collapsed - * {@code of(Map)} form is never taken). + * Selects the raw parameter string the callback parses. The gateway drives the OIDC auth-code + * flow with {@code response_mode=query}, so the live callback is a {@code 302}-driven top-level + * GET whose {@code code}/{@code state} arrive in the query string — the only shape on which the + * browser sends the {@code SameSite=Lax} binding cookie the callback requires. The GET branch + * hands the callback the raw query verbatim, never a map-collapsed projection, so the BFF-13 + * duplicate-parameter rejection holds (the collapsed {@code of(Map)} form is never taken). + *

+ * The POST branch is retained as a fail-closed path only. The edge no longer reads a + * body for the callback (only back-channel logout keeps that eager reserved-body read), so a + * stray {@code POST} to the callback path finds {@code rawFormBody} absent, normalizes to the + * empty string here, and is rejected {@code 400} for a missing {@code state} — an honest + * rejection, never a {@code 500}. */ private static String callbackParameters(ReservedHttpRequest req) { final String raw = req.isFormPost() ? req.rawFormBody() : req.rawQuery(); @@ -273,19 +277,18 @@ private static T requireNonNull(@Nullable T value) { * reads {@link #rawFormBody}, the user-info fold reads {@link #claimsParam}, and so on). * * @param rawQuery the raw query string (without the leading {@code ?}), never map-collapsed - * — a GET callback re-parses it to re-detect a duplicated {@code code}/{@code - * state} (BFF-13) + * — the {@code response_mode=query} 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 returnUrlParam the raw post-login {@code returnUrl} 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) + * back-channel logout, the one reserved path that still consumes a body. The + * {@code response_mode=query} callback carries its {@code code}/{@code state} + * in {@link #rawQuery} and no body is read for it, so this is absent there + * @param httpMethod the request HTTP method. Absent normalizes to {@code GET} (the CSRF-safe + * default), which is also the method of the live query-mode callback * @author API Sheriff Team * @since 1.0 */ @@ -313,9 +316,9 @@ public record ReservedHttpRequest(String rawQuery, @Nullable } /** - * @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} + * @return {@code true} when this is a {@code POST}. The gateway drives + * {@code response_mode=query}, so the live callback is never a POST; this selects the + * fail-closed body branch for a stray POST to a reserved path instead */ public boolean isFormPost() { return "POST".equalsIgnoreCase(httpMethod); diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/EdgeHardeningOptions.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/EdgeHardeningOptions.java index dd1bc2e7..a5ba6a9c 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/EdgeHardeningOptions.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/EdgeHardeningOptions.java @@ -40,8 +40,8 @@ * the graceful-shutdown wait for in-flight requests to complete on {@code SIGTERM}, and the * {@linkplain #reservedBodyMaxBytes() reserved-body ceiling} bounds the cumulative bytes the edge * will buffer for a gateway-terminated reserved POST path (rejected with {@code 413}), so an - * unauthenticated caller cannot exhaust heap through the pre-authentication callback / back-channel - * logout paths that never reach the per-route body cap. + * unauthenticated caller cannot exhaust heap through the pre-authentication back-channel logout + * path that never reaches the per-route body cap. *

* Transport bounds are fixed; the admission budget is operator-configurable. The * codec limits above are deliberate secure defaults chosen to keep the abuse surface bounded while @@ -71,13 +71,13 @@ public class EdgeHardeningOptions implements HttpServerOptionsCustomizer { private static final int IDLE_TIMEOUT_SECONDS = 60; /** - * Ceiling in bytes for a gateway-terminated reserved-path request body (the OIDC - * {@code response_mode=form_post} callback and the back-channel logout receiver). + * Ceiling in bytes for a gateway-terminated reserved-path request body — since the gateway drives + * {@code response_mode=query} and its OIDC callback is a bodyless top-level GET, the back-channel + * logout receiver is the one path this bounds. *

- * Derivation — deliberately not a second independent number. Both payloads are - * small, gateway-terminated inbound units of exactly the same class as the request header block - * this file already bounds: a form_post callback carries a urlencoded {@code code}/{@code state} - * pair (hundreds of bytes in practice) and a back-channel logout carries a single compact + * Derivation — deliberately not a second independent number. That payload is a + * small, gateway-terminated inbound unit of exactly the same class as the request header block + * this file already bounds: a back-channel logout carries a single compact * {@code logout_token} JWT (low single-digit KiB even with a large signing certificate chain). * The ceiling is therefore defined as {@link #MAX_HEADER_SIZE_BYTES} rather than * restated as its own literal, so the gateway's single 16 KiB inbound-unit bound cannot drift @@ -159,9 +159,8 @@ public long drainTimeoutMillis() { /** * @return the byte ceiling the edge enforces on a gateway-terminated reserved-path request body - * (form_post callback / back-channel logout). A request declaring more, or actually - * streaming more, is rejected {@code 413} — see {@link #RESERVED_BODY_MAX_BYTES} for the - * derivation + * (the back-channel logout receiver). A request declaring more, or actually streaming + * more, is rejected {@code 413} — see {@link #RESERVED_BODY_MAX_BYTES} for the derivation */ public long reservedBodyMaxBytes() { return RESERVED_BODY_MAX_BYTES; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java index eedba714..9ada13ef 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 @@ -127,8 +127,8 @@ * 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 (a reserved POST path — - * the form_post callback and back-channel logout — instead has its small body read on the event loop + * the request stream is paused and the whole pipeline runs on a virtual thread (the one body-carrying + * reserved POST path — back-channel logout — instead has its small body read on the event loop * first, under the {@linkplain EdgeHardeningOptions#reservedBodyMaxBytes() reserved-body byte * ceiling}, then dispatches, so a handler never has to drain a paused stream from a virtual thread): *

    @@ -173,14 +173,14 @@ public class GatewayEdgeRoute { private static final String CONNECTION_HEADER = "Connection"; private static final String CONNECTION_CLOSE = "close"; private static final String CLAIMS_PARAM = "claims"; - private static final String RETURN_TO_PARAM = "return_to"; + private static final String RETURN_URL_PARAM = "returnUrl"; 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 + // Fail-closed deadline for reading a tiny reserved-POST form body (a back-channel logout_token — + // the only such body left). 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 @@ -189,8 +189,8 @@ public class GatewayEdgeRoute { /** 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 + /** Holds the fully-read {@code application/x-www-form-urlencoded} body of the one body-carrying + * reserved POST path (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}. */ @@ -408,7 +408,7 @@ private void handle(RoutingContext ctx) { recordRequestMetrics(ctx, startNanos); }); if (needsReservedBodyRead(ctx)) { - // A reserved POST (form_post callback / back-channel logout) is dispatched on a virtual + // The one body-carrying reserved POST (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. @@ -421,9 +421,26 @@ private void handle(RoutingContext ctx) { /** * @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. + * body a handler consumes. 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. + *

    + * Allowlist decision — {@code CALLBACK} was removed, deliberately. The + * gateway now drives the authorization request with {@code response_mode=query}, so the + * OIDC callback is a top-level GET carrying its {@code code}/{@code state} in the query + * string and consuming no body at all. Leaving {@code ReservedEndpoint.CALLBACK} in this + * allowlist would not have been inert: it would keep granting any unauthenticated + * {@code POST} to the callback path a pre-authentication, pre-pipeline body read of up to + * {@link EdgeHardeningOptions#reservedBodyMaxBytes()} before the handler could reject it — + * a retained surface with no remaining purpose. The tighter posture was chosen. A stray + * {@code POST} to the callback path now takes the ordinary paused-stream path, reaches the + * callback with no body, and is rejected {@code 400} for a missing {@code state} (see + * {@code BffRuntime.callbackParameters}) — an honest rejection, never a {@code 500}. An + * IdP still configured to {@code form_post} to this gateway is therefore no longer + * supported, which is intended: the gateway itself selects the mode. + *

    + * Back-channel logout is unaffected. It remains a genuinely + * body-carrying reserved POST — it stays in this allowlist and its ceiling is unchanged. */ private boolean needsReservedBodyRead(RoutingContext ctx) { if (!"POST".equalsIgnoreCase(ctx.request().method().name())) { @@ -431,7 +448,7 @@ private boolean needsReservedBodyRead(RoutingContext ctx) { } 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) + .filter(kind -> kind == ReservedEndpoint.BACKCHANNEL_LOGOUT) .isPresent(); } @@ -441,7 +458,7 @@ private boolean needsReservedBodyRead(RoutingContext ctx) { * the stream drains on its own event loop, fully asynchronously — no virtual-thread {@code .get()} * blocks on a contended event loop. *

    - * Two bounds, both mandatory. These two reserved paths are read + * Two bounds, both mandatory. The back-channel logout path is read * pre-authentication and before {@code basicChecksStage} / * {@code thoroughChecksStage} run, so the per-route {@link SecurityConfiguration#maxBodySize()} cap * that bounds every ordinary proxied route can never apply here, and the transport's @@ -782,15 +799,15 @@ private void handleGatewayRejection(RoutingContext ctx, @Nullable PipelineReques private void dispatchReserved(RoutingContext ctx, PipelineRequest request, ReservedEndpoint kind) { String cookieHeader = request.firstHeader(COOKIE_HEADER).orElse(null); String 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; + // Back-channel logout is the ONE reserved path that still consumes a request body, so it is the + // only kind that reads the eagerly buffered body here (and the only kind needsReservedBodyRead + // buffers one for). The OIDC callback carries its code/state in the query under + // response_mode=query and is handed ctx.request().query() below — the genuinely raw, never + // map-collapsed query string the BFF-13 duplicate-parameter defence re-parses. + String rawFormBody = kind == ReservedEndpoint.BACKCHANNEL_LOGOUT ? readFormBody(ctx) : null; BffRuntime.ReservedHttpRequest reservedRequest = new BffRuntime.ReservedHttpRequest( ctx.request().query(), cookieHeader, firstQueryParam(request, CLAIMS_PARAM), - firstQueryParam(request, RETURN_TO_PARAM), firstQueryParam(request, STATE_PARAM), rawFormBody, method); + firstQueryParam(request, RETURN_URL_PARAM), firstQueryParam(request, STATE_PARAM), rawFormBody, method); BffRuntime.ReservedHttpResponse response = bffRuntime.dispatch(kind, reservedRequest, Instant.now()); renderReserved(ctx, request, response); } @@ -831,8 +848,8 @@ private static void applyStageSetCookies(HttpServerResponse response, List diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java index 731fa81c..19e4be18 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducer.java @@ -34,6 +34,7 @@ import de.cuioss.sheriff.gateway.bff.cookie.SealedSessionCookieCodec; import de.cuioss.sheriff.gateway.bff.csrf.CsrfDefence; import de.cuioss.sheriff.gateway.bff.login.LoginFlow; +import de.cuioss.sheriff.gateway.bff.login.QueryResponseModeAuthorizationRequestBuilder; import de.cuioss.sheriff.gateway.bff.logout.BackchannelLogoutReceiver; import de.cuioss.sheriff.gateway.bff.logout.LogoutTokenValidator; import de.cuioss.sheriff.gateway.bff.logout.RpInitiatedLogout; @@ -63,6 +64,9 @@ 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.AuthorizationRequestBuilder; +import de.cuioss.sheriff.token.client.flow.CallbackHandler; +import de.cuioss.sheriff.token.client.flow.IssValidator; import de.cuioss.sheriff.token.client.flow.RefreshFlow; import de.cuioss.sheriff.token.client.flow.StepUpHandler; import de.cuioss.sheriff.token.client.flow.TokenEndpointClient; @@ -100,6 +104,12 @@ * {@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. *

    + * Response mode. Both authorization-URL seams are wired with the gateway-owned + * {@link QueryResponseModeAuthorizationRequestBuilder}, so the flow is driven with + * {@code response_mode=query} and the callback is a top-level GET the browser sends the + * {@code SameSite=Lax} binding cookie on. See that class for the reasoning and for the accepted + * code-in-the-URL tradeoff. + *

    * Lazy discovery. The OIDC provider metadata is resolved through a memoized supplier * on first engine use, not at boot: a BFF gateway in either session mode therefore boots (and is * unit-testable) without a live IdP, and the discovery-dependent {@code end_session_endpoint} the @@ -205,8 +215,18 @@ private BffRuntime build(OidcConfig oidc) { TokenValidationBridge tokenBridge = new TokenValidationBridge(validator); IdTokenValidationBridge idBridge = new IdTokenValidationBridge(validator); TokenEndpointClient tokenEndpointClient = new TokenEndpointClient(clientConfiguration); + // The gateway drives response_mode=query, NOT the engine's built-in form_post: the callback has + // to be a top-level GET navigation so the SameSite=Lax browser-binding cookie is actually sent + // on it (a Lax cookie is dropped on the cross-site POST a form_post callback performs, which + // dead-ended every real-browser login on the "no binding cookie" 403 branch). One instance is + // shared with the step-up leg below, so BOTH engine seams that build an authorization URL carry + // the corrected mode. Every other collaborator here is exactly what the 4-arg + // AuthorizationCodeFlow constructor supplies on its own — a default IssValidator and + // CallbackHandler, and no sender constraint (DPoP is not in use) — so nothing else changes. + AuthorizationRequestBuilder authorizationRequestBuilder = new QueryResponseModeAuthorizationRequestBuilder(); AuthorizationCodeFlow authorizationCodeFlow = new AuthorizationCodeFlow(clientConfiguration, - tokenEndpointClient, tokenBridge, idBridge); + tokenEndpointClient, tokenBridge, idBridge, new IssValidator(), authorizationRequestBuilder, + new CallbackHandler(), null); RefreshFlow refreshFlow = new RefreshFlow(clientConfiguration, tokenEndpointClient, tokenBridge, clientAuthentication); @@ -262,7 +282,11 @@ private BffRuntime build(OidcConfig oidc) { // 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(); + // Built with the SAME response-mode-corrected builder as the login leg: StepUpHandler#initiate + // constructs its own authorization URL through an AuthorizationRequestBuilder, so leaving it on + // the default builder would keep the step-up re-drive emitting response_mode=form_post and + // reintroduce the dropped-binding-cookie failure on that leg alone. + StepUpHandler stepUpHandler = new StepUpHandler(authorizationRequestBuilder); StepUpCoordinator stepUpCoordinator = new StepUpCoordinator( (sessionRecord, challenge, now) -> Optional.empty(), challenge -> stepUpHandler.initiate(clientConfiguration, metadata.get(), challenge), diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/login/QueryResponseModeAuthorizationRequestBuilderTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/login/QueryResponseModeAuthorizationRequestBuilderTest.java new file mode 100644 index 00000000..512346d8 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/login/QueryResponseModeAuthorizationRequestBuilderTest.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.login; + +import static org.junit.jupiter.api.Assertions.assertAll; +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.net.URI; +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.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Tests for {@link QueryResponseModeAuthorizationRequestBuilder}: the rewrite that switches the + * engine-built authorization URL from {@code response_mode=form_post} to {@code response_mode=query}. + *

    + * The rewrite is the reason the OIDC callback is a top-level GET navigation, which is in turn the + * only request shape the browser sends the {@code SameSite=Lax} binding cookie on. Two properties + * therefore matter and are pinned here: the mode really does become {@code query}, and + * nothing else in the URL is disturbed — a rewrite that silently re-encoded + * {@code redirect_uri}, dropped {@code code_challenge} or reordered the query would break the flow + * in ways no response-mode assertion alone would catch. + *

    + * The static {@link QueryResponseModeAuthorizationRequestBuilder#withQueryResponseMode(String)} is + * exercised directly rather than through {@code build(..)}: the engine's superclass needs live + * client configuration and provider metadata, while the rewrite — the part this class actually owns + * — is a total function on the URL string. Testing it directly is what makes the parameter-survival + * assertions expressible against a URL carrying every parameter at once. + */ +@DisplayName("QueryResponseModeAuthorizationRequestBuilder — response_mode rewrite") +class QueryResponseModeAuthorizationRequestBuilderTest { + + private static final String AUTHORIZE = "https://idp.example.com/realms/integration/protocol/openid-connect/auth"; + + /** + * A representative engine-built authorization URL: every parameter the engine emits, in the + * engine's order, with {@code redirect_uri} and {@code scope} already form-encoded — the two + * values a careless decode/re-encode round-trip would corrupt. + */ + private static String engineUrl(String responseModePair) { + List pairs = new ArrayList<>(List.of( + "response_type=code", + "client_id=api-sheriff", + "redirect_uri=https%3A%2F%2Fgw.example.com%2Fauth%2Fcallback", + "scope=openid+profile+email", + "state=Xy7-state_value", + "nonce=Nn9-nonce_value", + "code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + "code_challenge_method=S256")); + if (responseModePair != null) { + pairs.add(responseModePair); + } + return AUTHORIZE + "?" + String.join("&", pairs); + } + + /** Splits a URL's query into ordered name → value pairs, without decoding either side. */ + private static Map queryPairs(String url) { + Map pairs = new LinkedHashMap<>(); + String query = URI.create(url).getRawQuery(); + if (query == null || query.isEmpty()) { + return pairs; + } + for (String pair : query.split("&", -1)) { + int separator = pair.indexOf('='); + if (separator < 0) { + pairs.put(pair, ""); + } else { + pairs.put(pair.substring(0, separator), pair.substring(separator + 1)); + } + } + return pairs; + } + + @Nested + @DisplayName("The response mode becomes query") + class ResponseMode { + + @Test + @DisplayName("Should rewrite response_mode=form_post to response_mode=query") + void shouldRewriteFormPostToQuery() { + String rewritten = QueryResponseModeAuthorizationRequestBuilder + .withQueryResponseMode(engineUrl("response_mode=form_post")); + + assertAll("the engine's built-in form_post is replaced, not merely appended to", + () -> assertEquals("query", queryPairs(rewritten).get("response_mode"), + "the gateway drives response_mode=query"), + () -> assertFalse(rewritten.contains("form_post"), + "no form_post value may survive anywhere in the URL: " + rewritten)); + } + + @Test + @DisplayName("Should add response_mode=query when the engine emitted no response_mode at all") + void shouldAddResponseModeWhenAbsent() { + String rewritten = QueryResponseModeAuthorizationRequestBuilder + .withQueryResponseMode(engineUrl(null)); + + assertEquals("query", queryPairs(rewritten).get("response_mode"), + "the mode is asserted explicitly rather than left to the response_type=code default"); + } + + @Test + @DisplayName("Should be idempotent — applying the rewrite twice yields the same URL") + void shouldBeIdempotent() { + String once = QueryResponseModeAuthorizationRequestBuilder + .withQueryResponseMode(engineUrl("response_mode=form_post")); + + String twice = QueryResponseModeAuthorizationRequestBuilder.withQueryResponseMode(once); + + assertEquals(once, twice, "a URL already carrying response_mode=query comes back unchanged"); + } + + @Test + @DisplayName("Should rewrite only the response_mode parameter, never a lookalike name") + void shouldNotRewriteALookalikeParameterName() { + String url = AUTHORIZE + "?response_mode_hint=form_post&response_mode=form_post"; + + String rewritten = QueryResponseModeAuthorizationRequestBuilder.withQueryResponseMode(url); + + assertAll("the rewrite matches on the whole parameter NAME, not on a substring", + () -> assertEquals("form_post", queryPairs(rewritten).get("response_mode_hint"), + "a differently-named parameter that merely starts the same is untouched"), + () -> assertEquals("query", queryPairs(rewritten).get("response_mode"))); + } + } + + @Nested + @DisplayName("Every other authorization parameter survives verbatim") + class ParameterSurvival { + + @ParameterizedTest(name = "{0} survives the rewrite byte-for-byte") + @ValueSource(strings = {"response_type", "client_id", "redirect_uri", "scope", "state", "nonce", + "code_challenge", "code_challenge_method"}) + @DisplayName("Should preserve each authorization parameter's value exactly") + void shouldPreserveParameterValue(String parameter) { + String original = engineUrl("response_mode=form_post"); + + String rewritten = QueryResponseModeAuthorizationRequestBuilder.withQueryResponseMode(original); + + assertEquals(queryPairs(original).get(parameter), queryPairs(rewritten).get(parameter), + parameter + " must be copied through exactly as the engine emitted it — values are never " + + "decoded and re-encoded, so no round-trip can corrupt an already-encoded value"); + } + + @Test + @DisplayName("Should preserve acr_values and max_age when the step-up leg supplies them") + void shouldPreserveStepUpParameters() { + String original = engineUrl("response_mode=form_post") + + "&acr_values=urn%3Amace%3Aincommon%3Aiap%3Asilver&max_age=0"; + + String rewritten = QueryResponseModeAuthorizationRequestBuilder.withQueryResponseMode(original); + + assertAll("the RFC 9470 step-up re-drive shares this builder, so its parameters matter too", + () -> assertEquals("urn%3Amace%3Aincommon%3Aiap%3Asilver", queryPairs(rewritten).get("acr_values")), + () -> assertEquals("0", queryPairs(rewritten).get("max_age")), + () -> assertEquals("query", queryPairs(rewritten).get("response_mode"))); + } + + @Test + @DisplayName("Should preserve the parameter order and the authorization endpoint itself") + void shouldPreserveOrderAndEndpoint() { + String original = engineUrl("response_mode=form_post"); + + String rewritten = QueryResponseModeAuthorizationRequestBuilder.withQueryResponseMode(original); + + assertAll("only the response_mode VALUE changes; the URL's shape does not", + () -> assertTrue(rewritten.startsWith(AUTHORIZE + "?"), + "the authorization endpoint is untouched: " + rewritten), + () -> assertEquals(List.copyOf(queryPairs(original).keySet()), + List.copyOf(queryPairs(rewritten).keySet()), + "the parameter names keep their original order — the rewrite is in-place")); + } + } + + @Nested + @DisplayName("Total over degenerate URL shapes") + class DegenerateShapes { + + @Test + @DisplayName("Should append the parameter to a URL carrying no query at all") + void shouldAppendToUrlWithoutQuery() { + String rewritten = QueryResponseModeAuthorizationRequestBuilder.withQueryResponseMode(AUTHORIZE); + + assertEquals(AUTHORIZE + "?response_mode=query", rewritten); + } + + @Test + @DisplayName("Should append the parameter to a URL whose query is empty") + void shouldAppendToUrlWithEmptyQuery() { + String rewritten = QueryResponseModeAuthorizationRequestBuilder.withQueryResponseMode(AUTHORIZE + "?"); + + assertEquals(AUTHORIZE + "?response_mode=query", rewritten); + } + + @Test + @DisplayName("Should reject a null authorization URL") + void shouldRejectNullUrl() { + assertThrows(NullPointerException.class, + () -> QueryResponseModeAuthorizationRequestBuilder.withQueryResponseMode(null)); + } + } +} 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 index e170b027..9e62c788 100644 --- 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 @@ -15,7 +15,9 @@ */ package de.cuioss.sheriff.gateway.bff.pending; +import static org.junit.jupiter.api.Assertions.assertAll; 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; @@ -54,6 +56,46 @@ void shouldEmitHardenedCookie() { assertTrue(header.contains("; Max-Age=300"), header); } + /** + * A regression fence around the alternative remedy that was CONSIDERED AND REJECTED. + *

    + * The gateway drives {@code response_mode=query} precisely so the OIDC callback is a + * top-level GET navigation — the request shape a {@code SameSite=Lax} cookie IS sent on. + * The alternative was to leave the flow on {@code response_mode=form_post} (a cross-site + * POST, on which a Lax cookie is dropped) and relax this cookie to {@code SameSite=None} + * instead. That would have had to permit exactly the cross-site sends this cookie exists to + * prevent, weakening the browser-binding control itself, so it was rejected. + *

    + * This test asserts the rejection is still in force. It is deliberately expressed as an + * explicit absence check rather than left implicit in the positive assertions: a + * future edit that appended {@code SameSite=None} without removing {@code SameSite=Lax} + * would still satisfy a `contains("SameSite=Lax")` assertion while shipping the weaker + * attribute to the browser. The two directions are complementary and BOTH header forms carry + * both: an absence check cannot see a dropped attribute, and a positive check cannot see an + * appended one. + */ + @Test + @DisplayName("Should never emit SameSite=None — the rejected alternative to response_mode=query") + void shouldNeverEmitSameSiteNone() { + String setCookie = codec.toSetCookieHeader("record-123"); + String clearing = codec.toClearingSetCookieHeader(); + + assertAll("no cookie attribute is weakened to make the browser flow work", + () -> assertFalse(setCookie.contains("SameSite=None"), + "SameSite=None would permit the cross-site sends this cookie exists to block: " + + setCookie), + () -> assertFalse(clearing.contains("SameSite=None"), + "the clearing form must not weaken the attribute either: " + clearing), + () -> assertTrue(setCookie.contains("; SameSite=Lax"), + "Lax is correct AND sufficient because the callback is a top-level GET: " + setCookie), + // The absence checks above cannot see an attribute that was DROPPED, and absent is + // not None: a change that removed SameSite entirely from the clearing header would + // satisfy every other assertion here. The set form is fenced positively, so the + // clearing form must be too. + () -> assertTrue(clearing.contains("; SameSite=Lax"), + "the clearing form must carry Lax positively, not merely lack None: " + clearing)); + } + @Test @DisplayName("Should clear the cookie with Max-Age=0 while keeping the __Host- attributes") void shouldClearCookie() { diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java index 13b64eeb..d26a626a 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/bff/reserved/CallbackEndpointTest.java @@ -60,6 +60,14 @@ * 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. *

    + * Every case below is a {@code response_mode=query} callback. The gateway drives + * the authorization request with {@code response_mode=query}, so the live callback is a top-level + * GET whose {@code code}/{@code state} arrive in the query string, and the string each test hands + * {@code handle(..)} IS that raw query. The endpoint itself stays source-neutral by design — it + * parses whatever raw parameter string it is given — which is exactly why the assertion that the + * runtime hands it the uncollapsed query lives one layer out, in + * {@code GatewayEdgeRouteBffWiringTest}'s query-mode dispatch coverage rather than here. + *

    * 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. @@ -147,8 +155,17 @@ SealedSessionCookieCodec.DEFAULT_COOKIE_VALUE_BUDGET, new SecretKeySpec(key, "AE salt); } + /** + * The BFF-13 duplicate-parameter defence — the Keycloak CVE-2026-9689 class — asserted on the + * raw query, which under {@code response_mode=query} is the live callback shape. + *

    + * The defence is structural: the endpoint parses the raw string with + * {@code CallbackParameters.parse(String)} and never {@code CallbackParameters.of(Map)}, because + * only the raw parse can still SEE a duplicated {@code code} or {@code state}. A collapsed map + * has already silently chosen one occurrence by the time the endpoint runs. + */ @Nested - @DisplayName("BFF-13 duplicate-parameter rejection (raw parse)") + @DisplayName("BFF-13 duplicate-parameter rejection on the raw query") class DuplicateParameterRejection { @Test @@ -163,12 +180,16 @@ void shouldRejectDuplicateCode() { } @Test - @DisplayName("Should reject a duplicate-state callback 400") + @DisplayName("Should reject a duplicate-state callback 400 without consuming the pending record") void shouldRejectDuplicateState() { CallbackOutcome outcome = endpoint.handle("code=abc&state=" + state + "&state=other", bindingCookieHeader, T0); - assertEquals(400, outcome.status()); + assertEquals(400, outcome.status(), + "state is the parameter the binding check compares, so a duplicate must be refused " + + "outright rather than resolved to whichever occurrence a parser happened to pick"); + assertTrue(pendingStore.consume(recordId, T0).isPresent(), + "the record is untouched — parse fails before binding resolution"); } } diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java index b7f17c04..5975343f 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRouteBffWiringTest.java @@ -86,6 +86,7 @@ import io.vertx.core.Vertx; import io.vertx.core.http.HttpClient; import io.vertx.core.http.HttpClientRequest; +import io.vertx.core.http.HttpClientResponse; import io.vertx.core.http.HttpServer; import io.vertx.core.http.RequestOptions; import io.vertx.core.net.SocketAddress; @@ -241,7 +242,8 @@ void tearDown() throws Exception { @DisplayName("a reserved path is served by its handler and never reaches the route's allowed_paths gate") void reservedPathNeverReachesThoroughChecks() throws Exception { // Act — a reserved user-info request under the same /auth prefix the proxy route claims, - // carrying a return_to value the url-parameter pipeline would reject + // carrying an arbitrary query parameter the url-parameter pipeline would reject. The + // parameter NAME is incidental here — it is not a login parameter and carries no contract. int status = statusOf(USER_INFO_PATH + "?return_to=%2Fhome"); // Assert — 401 is the user-info handler's own no-session answer. A 400 would mean the @@ -286,6 +288,113 @@ private static ResolvedRoute rejectEverythingRoute() { } } + /** + * Pins the wire name of the login return-URL query parameter to {@code returnUrl}. + *

    + * The name is a browser-facing contract shared by the demo SPA, the Playwright helper and + * {@link LoginInitiationEndpoint}'s own documented surface, but the only place it is actually read + * is the edge's reserved dispatch — so a rename there silently breaks the whole login flow with no + * compile error anywhere: every internal identifier on the path is already {@code returnUrl}, and a + * value the edge fails to extract simply degrades to {@link LoginFlow#DEFAULT_RETURN_URL}. That + * degradation is invisible to a type checker and to every test that does not drive a real request + * through the edge, which is why these two run over a live Vert.x server rather than calling + * {@link BffRuntime#dispatch} directly. + *

    + * The pair is deliberately a matched positive/negative control: the accepted spelling must reach + * the login fold, and the retired {@code return_to} spelling must NOT. Asserting only the positive + * case would still pass if the edge accepted both. + */ + @Nested + @DisplayName("login initiation reads the returnUrl wire parameter — and only that spelling") + class LoginReturnUrlWireName { + + private static final String RETURN_TARGET = "/dashboard"; + private static final String ENCODED_RETURN_TARGET = "%2Fdashboard"; + + private Vertx vertx; + private ExecutorService virtualThreadExecutor; + private HttpServer front; + private HttpClient client; + private String sessionCookie; + + @BeforeEach + void setUp() throws Exception { + vertx = Vertx.vertx(); + virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor(); + TokenValidator tokenValidator = TokenValidator.builder() + .issuerConfig(TestTokenGenerators.accessTokens().next().getIssuerConfig()).build(); + + // A live session makes login initiation take the already-authenticated short-circuit, whose + // redirect Location IS the return URL the edge extracted — the cleanest observable for the + // wire name, and one that never reaches the IdP engine. + SessionStore store = new InMemorySessionStore(16); + String sessionId = SessionRecord.newSessionId(); + store.create(SessionRecord.builder().sessionId(sessionId).accessToken("a").idToken("i").sub("sub") + .expiresAt(Instant.now().plus(Duration.ofHours(1))).build()); + sessionCookie = SessionCookieCodec.DEFAULT_COOKIE_NAME + "=" + sessionId; + + GatewayConfig gatewayConfig = GatewayConfig.builder().version(1).oidc(Optional.of(fullOidc())).build(); + GatewayEdgeRoute edge = new GatewayEdgeRoute(new RouteTable(List.of()), gatewayConfig, + new SingletonInstance<>(tokenValidator), vertx, virtualThreadExecutor, + new EdgeHardeningOptions(), new SheriffMetrics(new SimpleMeterRegistry()), + activeRuntime(serverBinding(store))); + Router router = Router.router(vertx); + edge.registerRoutes(router); + front = vertx.createHttpServer().requestHandler(router) + .listen(0).toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS); + client = vertx.createHttpClient(); + } + + @AfterEach + void tearDown() throws Exception { + client.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS); + front.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS); + virtualThreadExecutor.close(); + vertx.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS); + } + + @Test + @DisplayName("returnUrl reaches the login fold and becomes the short-circuit redirect target") + void shouldReadReturnUrlParameter() throws Exception { + // Arrange — see setUp: a live session and a same-origin relative return target + + // Act + HttpClientResponse response = login("?returnUrl=" + ENCODED_RETURN_TARGET); + + // Assert + assertEquals(302, response.statusCode(), "the live-session login short-circuit is a 302"); + assertEquals(RETURN_TARGET, response.getHeader("Location"), + "returnUrl is the login wire parameter, so its value must reach the login fold"); + } + + @Test + @DisplayName("the retired return_to spelling is ignored and degrades to the default return URL") + void shouldIgnoreRetiredReturnToSpelling() throws Exception { + // Arrange — see setUp: identical request except for the parameter spelling + + // Act + HttpClientResponse response = login("?return_to=" + ENCODED_RETURN_TARGET); + + // Assert — the regression pin. If the wire name ever reverts to return_to, this request + // would be honoured and the Location would be RETURN_TARGET instead of the default. + assertEquals(302, response.statusCode(), "the live-session login short-circuit is a 302"); + assertEquals(LoginFlow.DEFAULT_RETURN_URL, response.getHeader("Location"), + "return_to is not the login wire parameter, so it must not reach the login fold"); + } + + private HttpClientResponse login(String query) throws Exception { + // Connect to the local front server but present the OIDC host in the authority: the + // reserved-path registry is keyed on (host, canonicalPath). + RequestOptions options = new RequestOptions() + .setServer(SocketAddress.inetSocketAddress(front.actualPort(), "localhost")) + .setHost(OIDC_HOST).setPort(front.actualPort()) + .setMethod(io.vertx.core.http.HttpMethod.GET).setURI(LOGIN_PATH + query); + return client.request(options) + .compose(request -> request.putHeader("Cookie", sessionCookie).send()) + .toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS); + } + } + @Nested @DisplayName("BffRuntime.dispatch routes each reserved path to its handler (not NO_ROUTE_MATCHED)") class ReservedDispatch { @@ -353,9 +462,22 @@ private BffRuntime.ReservedHttpRequest request(String cookie, String claims) { } } + /** + * The callback leg one layer out from {@link CallbackEndpoint}: through + * {@link BffRuntime#dispatch}, whose {@code callbackParameters} selects WHICH raw string the + * endpoint parses. + *

    + * That selection is the reason this coverage exists separately from + * {@code CallbackEndpointTest}. The endpoint is source-neutral — it parses whatever string it is + * handed — so an endpoint-level duplicate-parameter test proves the parse rejects duplicates, but + * NOT that the runtime hands it the genuinely raw, uncollapsed query. Under + * {@code response_mode=query} the {@code code}/{@code state} arrive in the query string, so the + * rawQuery selection path is now the live one and its BFF-13 duplicate-parameter defence (the + * Keycloak CVE-2026-9689 class) is asserted HERE, at the seam that could silently collapse it. + */ @Nested - @DisplayName("form_post callback dispatch (POST /auth/callback, body-parsed code/state)") - class FormPostCallbackDispatch { + @DisplayName("Query-mode callback dispatch (GET /auth/callback, raw-query code/state)") + class QueryCallbackDispatch { private static final String RETURN_URL = "/dashboard"; private static final String RAW_ACCESS_TOKEN = "raw-access-token"; @@ -382,36 +504,69 @@ void setUp() { pendingStore.store(pending); bindingCookieHeader = bindingCodec.toSetCookieHeader(pending.id()).split(";", 2)[0]; - runtime = formPostRuntime(); + runtime = callbackRuntime(); + } + + /** A {@code response_mode=query} callback: GET, {@code code}/{@code state} in the raw query, no body. */ + private BffRuntime.ReservedHttpRequest queryCallback(String rawQuery) { + return new BffRuntime.ReservedHttpRequest(rawQuery, bindingCookieHeader, null, null, null, null, "GET"); } @Test - @DisplayName("POST body with code+state resolves the pending record and creates the session (302 + session cookie)") - void shouldCompleteFormPostLogin() { + @DisplayName("GET query with code+state resolves the pending record and creates the session (302 + session cookie)") + void shouldCompleteQueryModeLogin() { BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.CALLBACK, - new BffRuntime.ReservedHttpRequest("", bindingCookieHeader, null, null, null, - "code=auth-code&state=" + state, "POST"), now); + queryCallback("code=auth-code&state=" + state), now); - assertEquals(302, response.status(), "form_post code exchange completes the login"); + assertEquals(302, response.status(), "the query-mode 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"); + "the session cookie is set from the query-mode callback"); } @Test - @DisplayName("A duplicate code in the POST body is still rejected 400 (BFF-13 raw-parse defence)") - void shouldRejectDuplicateCodeInFormBody() { + @DisplayName("A duplicate code in the RAW QUERY is rejected 400 (BFF-13 defence survives the mode switch)") + void shouldRejectDuplicateCodeInRawQuery() { BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.CALLBACK, - new BffRuntime.ReservedHttpRequest("", bindingCookieHeader, null, null, null, - "code=first&code=second&state=" + state, "POST"), now); + queryCallback("code=first&code=second&state=" + state), now); - assertEquals(400, response.status(), "a duplicated code in the form body is rejected by parse()"); + assertEquals(400, response.status(), + "a duplicated code reaches parse() uncollapsed and is rejected — a first-value-wins " + + "projection anywhere between the edge and the endpoint would have let it through"); assertTrue(pendingStore.consume(bindingCodec.readRecordId(bindingCookieHeader).orElseThrow(), now) .isPresent(), "the record is untouched — parse fails before binding resolution"); } - private BffRuntime formPostRuntime() { + @Test + @DisplayName("A duplicate state in the RAW QUERY is rejected 400 (BFF-13 defence survives the mode switch)") + void shouldRejectDuplicateStateInRawQuery() { + BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.CALLBACK, + queryCallback("code=auth-code&state=" + state + "&state=attacker-supplied"), now); + + assertEquals(400, response.status(), + "a duplicated state is rejected too — state is the parameter the binding check compares, " + + "so a collapsed map choosing either occurrence would be exploitable"); + assertTrue(pendingStore.consume(bindingCodec.readRecordId(bindingCookieHeader).orElseThrow(), now) + .isPresent(), "the record is untouched — parse fails before binding resolution"); + } + + /** + * The fail-closed counterpart: the edge no longer buffers a body for the callback, so a stray + * POST to that path arrives with no body at all. It must be an honest {@code 400}, never a + * {@code 500} from a null body reaching the parse. + */ + @Test + @DisplayName("A stray POST to the callback arrives bodyless and is rejected 400, never 500") + void shouldRejectStrayPostBodyless() { + BffRuntime.ReservedHttpResponse response = runtime.dispatch(ReservedEndpoint.CALLBACK, + new BffRuntime.ReservedHttpRequest("", bindingCookieHeader, null, null, null, null, "POST"), now); + + assertEquals(400, response.status(), + "an absent form body normalizes to the empty string and fails for a missing state"); + } + + private BffRuntime callbackRuntime() { CallbackEndpoint callback = new CallbackEndpoint((context, params) -> { Map accessClaims = new HashMap<>(); accessClaims.put(ClaimName.SUBJECT.getName(), ClaimValue.forPlainString(SUBJECT)); diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/ReservedBodyCeilingTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/ReservedBodyCeilingTest.java index 98831b02..8763bb90 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/ReservedBodyCeilingTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/edge/ReservedBodyCeilingTest.java @@ -58,11 +58,16 @@ import org.junit.jupiter.api.Test; /** - * Pins the byte ceiling the edge enforces on a gateway-terminated reserved POST body (the OIDC - * {@code response_mode=form_post} callback and the back-channel logout receiver), driven over a live - * Vert.x HTTP server — no Docker, no Quarkus. + * Pins the byte ceiling the edge enforces on a gateway-terminated reserved POST body, driven over a + * live Vert.x HTTP server — no Docker, no Quarkus. *

    - * Those two paths are read pre-authentication and before the pipeline's per-route + * The back-channel logout receiver is the one path this bounds. The gateway drives + * the OIDC flow with {@code response_mode=query}, so its callback is a bodyless top-level GET that + * consumes no request body at all and is deliberately no longer on the edge's reserved-body-read + * allowlist. Back-channel logout remains a genuinely body-carrying reserved POST — it carries a + * {@code logout_token} JWT — so it keeps the ceiling, and every assertion below drives it. + *

    + * That path is read pre-authentication and before the pipeline's per-route * {@code maxBodySize} cap can apply, so without a ceiling at the read itself an unauthenticated caller * could buffer an arbitrarily large body straight into the gateway heap. The ceiling is enforced twice * — a {@code Content-Length} pre-check and a streaming cumulative counter — and both arms are pinned @@ -79,6 +84,11 @@ class ReservedBodyCeilingTest { private static final String CALLBACK_PATH = "/auth/callback"; + /** + * The one reserved path that still consumes a request body, and therefore the path every + * ceiling assertion below drives. + */ + private static final String BACKCHANNEL_LOGOUT_PATH = "/auth/backchannel-logout"; private static final String CRLF = "\r\n"; /** Bounded wait proving the pre-check refused BEFORE buffering: far below the 20s body deadline. */ private static final long NO_BUFFERING_TIMEOUT_SECONDS = 5L; @@ -102,6 +112,9 @@ void setUp() throws Exception { .version(1) .oidc(Optional.of(OidcConfig.builder() .redirectUri(Optional.of("https://localhost" + CALLBACK_PATH)) + .logout(Optional.of(OidcConfig.Logout.builder() + .backchannelPath(Optional.of(BACKCHANNEL_LOGOUT_PATH)) + .build())) .build())) .build(); EdgeHardeningOptions hardening = new EdgeHardeningOptions(); @@ -135,7 +148,7 @@ void tearDown() throws Exception { void acceptsBodyExactlyAtTheCeiling() throws Exception { String body = formBody(ceiling); - int status = post(CALLBACK_PATH, body); + int status = post(BACKCHANNEL_LOGOUT_PATH, body); assertEquals(ceiling, body.length(), "The arranged body must sit exactly on the boundary for this to pin the ceiling"); @@ -148,19 +161,48 @@ void acceptsBodyExactlyAtTheCeiling() throws Exception { void rejectsBodyOverTheCeiling() throws Exception { String body = formBody(ceiling + 1); - int status = post(CALLBACK_PATH, body); + int status = post(BACKCHANNEL_LOGOUT_PATH, body); assertEquals(413, status, "A body beyond the ceiling is refused 413 — the honest status for an over-large payload"); } + /** + * The counterpart assertion to every test in this class: the OIDC callback is NOT bounded here, + * because it no longer reads a body at all. + *

    + * The gateway drives {@code response_mode=query}, so the live callback is a top-level GET + * carrying {@code code}/{@code state} in the query string. {@code ReservedEndpoint.CALLBACK} was + * therefore removed from {@code GatewayEdgeRoute.needsReservedBodyRead}'s allowlist — and that + * removal was not cosmetic: leaving it there would have kept granting ANY unauthenticated POST + * to the callback path a pre-authentication, pre-pipeline body read of up to the ceiling, a + * retained attack surface with no remaining purpose. + *

    + * An over-ceiling POST to the callback path is consequently no longer refused {@code 413}. It + * takes the ordinary paused-stream path and reaches the reserved carve-out ({@code 404} under + * the inert runtime here; {@code 400} for a missing {@code state} against a live one). Asserting + * the {@code 413} is GONE is what pins the allowlist decision — without it, silently restoring + * {@code CALLBACK} to the allowlist would break no test. + */ + @Test + @DisplayName("no longer applies the ceiling to the callback path — it reads no body under response_mode=query") + void doesNotApplyTheCeilingToTheBodylessCallback() throws Exception { + String body = formBody(ceiling + 1); + + int status = post(CALLBACK_PATH, body); + + assertEquals(404, status, + "The callback is not on the reserved-body-read allowlist, so no ceiling applies and the " + + "request reaches the reserved carve-out instead of being refused 413"); + } + @Test @DisplayName("rejects an over-ceiling declared Content-Length without buffering any body byte") void rejectsOverCeilingContentLengthWithoutBuffering() { // A raw request head declaring far more than the ceiling, with ZERO body bytes ever sent. A // gateway that buffered first would still be blocked on its 20-second body deadline; only a // gateway that refuses on the declared length alone can answer inside the bounded wait below. - String head = "POST " + CALLBACK_PATH + " HTTP/1.1" + CRLF + String head = "POST " + BACKCHANNEL_LOGOUT_PATH + " HTTP/1.1" + CRLF + "Host: localhost:" + frontPort + CRLF + "Content-Length: " + (ceiling * 64) + CRLF + "Connection: close" + CRLF + CRLF; @@ -188,7 +230,7 @@ void rejectsOverCeilingChunkedBody() throws Exception { // altogether. That is what is exercised here. int chunkSize = 1024; long chunks = ceiling / chunkSize + 2; - StringBuilder raw = new StringBuilder("POST " + CALLBACK_PATH + " HTTP/1.1" + CRLF + StringBuilder raw = new StringBuilder("POST " + BACKCHANNEL_LOGOUT_PATH + " HTTP/1.1" + CRLF + "Host: localhost:" + frontPort + CRLF + "Transfer-Encoding: chunked" + CRLF + "Connection: close" + CRLF + CRLF); @@ -214,7 +256,7 @@ void retiresConnectionAfterDeclaredLengthRejection() { // NOT hand the connection back for reuse: the request body was never consumed, so pending bytes // would desync the next request framed on it — or an attacker could pin connections by // repeatedly tripping the 413, which is exactly the DoS this ceiling exists to bound. - String head = "POST " + CALLBACK_PATH + " HTTP/1.1" + CRLF + String head = "POST " + BACKCHANNEL_LOGOUT_PATH + " HTTP/1.1" + CRLF + "Host: localhost:" + frontPort + CRLF + "Content-Length: " + (ceiling * 64) + CRLF + CRLF; @@ -234,7 +276,7 @@ void retiresConnectionAfterStreamedRejection() { // same retirement applies — this path left the most unread bytes of the two. int chunkSize = 1024; long chunks = ceiling / chunkSize + 2; - StringBuilder raw = new StringBuilder("POST " + CALLBACK_PATH + " HTTP/1.1" + CRLF + StringBuilder raw = new StringBuilder("POST " + BACKCHANNEL_LOGOUT_PATH + " HTTP/1.1" + CRLF + "Host: localhost:" + frontPort + CRLF + "Transfer-Encoding: chunked" + CRLF + CRLF); String chunk = "x".repeat(chunkSize); @@ -282,11 +324,11 @@ private CompletableFuture sendRawAwaitingClose(String rawRequest) { /** * Builds a urlencoded form body of exactly {@code length} characters, shaped like the - * {@code state=…} a real form_post callback carries so the payload is representative rather than - * arbitrary filler. + * {@code logout_token=…} a real back-channel logout carries so the payload is representative + * rather than arbitrary filler. */ private static String formBody(long length) { - String prefix = "state=" + Generators.letterStrings(8, 16).next() + "&code="; + String prefix = "sid=" + Generators.letterStrings(8, 16).next() + "&logout_token="; return prefix + "a".repeat((int) length - prefix.length()); } diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java index ba35e9e2..90fe240c 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/BffRuntimeProducerTest.java @@ -15,26 +15,39 @@ */ package de.cuioss.sheriff.gateway.quarkus; +import static org.junit.jupiter.api.Assertions.assertAll; 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.assertInstanceOf; 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.lang.reflect.Field; +import java.lang.reflect.InaccessibleObjectException; +import java.lang.reflect.Modifier; import java.time.Instant; +import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; +import java.util.Collections; +import java.util.Deque; +import java.util.IdentityHashMap; import java.util.Iterator; import java.util.List; import java.util.Optional; +import java.util.Set; +import de.cuioss.sheriff.gateway.bff.login.QueryResponseModeAuthorizationRequestBuilder; 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.client.flow.AuthorizationRequestBuilder; import de.cuioss.sheriff.token.validation.TokenValidator; import de.cuioss.sheriff.token.validation.test.generator.TestTokenGenerators; import de.cuioss.test.generator.junit.EnableGeneratorController; @@ -44,6 +57,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; /** * Covers {@link BffRuntimeProducer}: the runtime is active (and its reserved handlers and session @@ -103,6 +117,92 @@ void shouldWireUserInfo() { Instant.parse("2026-07-25T10:00:00Z")); assertEquals(401, response.status()); } + + /** + * The wiring-level half of the response-mode assertion — the seam's own behaviour is pinned by + * {@code QueryResponseModeAuthorizationRequestBuilderTest}. + *

    + * This exists because the failure mode it guards is an omission, and an omission is + * invisible to a behavioural test of the seam. The engine's + * {@link AuthorizationRequestBuilder} emits {@code response_mode=form_post} unconditionally, + * and its shorter constructors silently install that default: a future refactor that rebuilt + * {@code AuthorizationCodeFlow} through the 4-argument constructor, or {@code StepUpHandler} + * through its no-argument one, would compile, pass every seam test, and quietly reintroduce + * the cross-site POST callback on which the {@code SameSite=Lax} binding cookie is dropped. + *

    + * The assertion is therefore made against the object graph the producer actually built, and + * it is type-directed rather than name-directed: it finds every + * {@code AuthorizationRequestBuilder} reachable from the assembled runtime and requires each + * one to be the gateway's query-mode subclass. Renaming an engine field does not break it; + * reverting a seam to the engine default does — which is exactly the intended sensitivity. + */ + @Test + @DisplayName("Should wire the query-mode response builder into every engine authorization seam") + void shouldWireQueryResponseModeIntoEveryAuthorizationSeam() { + List wired = reachableAuthorizationRequestBuilders(runtime); + + assertFalse(wired.isEmpty(), + "no AuthorizationRequestBuilder was reachable from the assembled runtime — this test " + + "must never pass vacuously; if the producer's wiring moved, retarget the walk"); + assertAll("every engine seam that builds an authorization URL carries the query-mode builder", + wired.stream().map(builder -> (Executable) () -> + assertInstanceOf(QueryResponseModeAuthorizationRequestBuilder.class, builder, + "an engine seam is still on the default builder, which emits " + + "response_mode=form_post"))); + } + } + + /** + * Collects every {@link AuthorizationRequestBuilder} reachable from {@code root} by walking + * instance fields, following lambda captures so a seam held only inside a closure is still seen. + *

    + * The walk is bounded to the gateway's and the engine's own packages: it never descends into JDK + * or container types, which keeps it away from the strongly-encapsulated {@code java.*} modules + * and stops it wandering through collections and class loaders. A field the JVM refuses to open + * is skipped rather than failing the walk — the caller's non-empty assertion is what guarantees + * the result is still meaningful. + */ + private static List reachableAuthorizationRequestBuilders(Object root) { + List found = new ArrayList<>(); + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + Deque pending = new ArrayDeque<>(); + pending.push(root); + while (!pending.isEmpty()) { + Object current = pending.pop(); + if (current == null || !seen.add(current)) { + continue; + } + if (current instanceof AuthorizationRequestBuilder builder) { + found.add(builder); + continue; + } + for (Class type = current.getClass(); type != null && type != Object.class; type = type.getSuperclass()) { + for (Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.getType().isPrimitive()) { + continue; + } + try { + field.setAccessible(true); + Object value = field.get(current); + if (value != null && isWalkable(value.getClass())) { + pending.push(value); + } + } catch (ReflectiveOperationException | InaccessibleObjectException _) { + // A field the JVM will not open tells us nothing; the non-empty assertion above + // is what keeps an over-skipped walk from passing vacuously. Only the two + // exceptions setAccessible/get can actually raise here are caught: a broader + // catch would swallow a genuine defect in the walk itself. + } + } + } + } + return found; + } + + /** Restricts the walk to gateway and engine types — never JDK, container or collection internals. */ + private static boolean isWalkable(Class type) { + String name = type.getName(); + return name.startsWith("de.cuioss.sheriff.gateway.") || name.startsWith("de.cuioss.sheriff.token.client."); } @Nested diff --git a/demo-client/README.adoc b/demo-client/README.adoc new file mode 100644 index 00000000..f172a5fd --- /dev/null +++ b/demo-client/README.adoc @@ -0,0 +1,108 @@ += API Sheriff Demo Client +:toc: +:toclevels: 2 +:sectnums: + +A dependency-free demo single-page application for the API Sheriff BFF, plus a Playwright suite that +drives it in a real browser against the running `integration-tests` stack. + +The SPA is served *by the gateway itself* as a public asset, so it is same-origin with the reserved +`/auth` paths and exercises the genuine browser-facing contract. The suite runs the same specs +against *both* session modes -- `server` on port 10443 and `cookie` on port 10445 -- so "the two +variants are browser-observably identical" is an executable assertion rather than a claim. + +This module ships no Java and produces no artifact. Nothing here reaches the production image or the +native executable. + +== Prerequisites + +* Docker with Compose +* A built `api-sheriff:distroless` native image + +Neither Node nor a browser is a prerequisite -- the `e2e-demo` profile downloads a pinned Node +toolchain into `target/` and installs the Chromium build Playwright drives. + +== Running It + +One command does everything -- toolchain install, lint, browser install, three-container bring-up, +both Playwright projects, teardown: + +[source,bash] +---- +python3 .plan/execute-script.py plan-marshall:build-maven:maven \ + run --command-args "verify -Pe2e-demo -pl demo-client" +---- + +When iterating on specs, hold the stack up and re-run only the tests: + +[source,bash] +---- +demo-client/scripts/start-dev-environment.sh +cd demo-client && npm run test +demo-client/scripts/stop-dev-environment.sh +---- + +`npm run test`, never `npx`: `npm run` resolves the binary from `node_modules/.bin` only, so a +missing binary is a hard failure rather than a silent registry download. The build makes the same +choice, for the same reason. + +Without `-Pe2e-demo` this module is a no-op: a default reactor build downloads no Node, runs no npm +command, starts no container and produces no artifact. + +Once the stack is up, the application is at `https://localhost:10443/assets/demo/index.html` +(server-session mode) and `https://localhost:10445/assets/demo/index.html` (cookie mode). Sign in as +`integration-user` / `integration-password`. Note the explicit filename -- the gateway serves no +directory index, so `/assets/demo/` returns `404`. + +== Where the Output Lands + +Everything generated sits under `target/` and is git-ignored: + +[cols="1,2"] +|=== +| Path | Contents + +| `target/test-results/` +| Playwright JSON and JUnit results. There is no HTML reporter, so no report server is spawned. + +| `target/screenshots/{project}/` +| The documentation screenshots -- `anonymous`, `authenticated-default-view`, + `full-allowlisted-view`, `claim-denied`, `logged-out` -- one parallel set per session mode. + +| `target/node/`, `target/node_modules/` +| The pinned Node toolchain and the installed packages. +|=== + +== Layout + +[cols="1,2"] +|=== +| Path | What it is + +| `src/main/resources/spa/` +| The SPA: `index.html`, `app.js`, `app.css`, `landing.html`. No framework, no bundler, no build + step -- the served files are the authored files, bind-mounted into the gateway containers. + +| `tests/`, `fixtures/`, `utils/` +| The Playwright suite, its shared fixtures, and the Keycloak login helper. + +| `playwright.config.js` +| Two projects, `session-server` and `session-cookie`, differing only in `baseURL`. + +| `scripts/` +| The trimmed bring-up and teardown of exactly three containers. +|=== + +== Further Reading + +Both layers are authoritative; this file is only the front door. + +* link:../doc/user/demo-client.adoc[Demo Client -- the BFF Integration Sample] -- the *integrator* + layer: the browser-facing contract, the four identity-disclosure states, the same-origin + `returnUrl` rule, and the `gateway.yaml` a deployment needs to serve its own bundle this way. +* link:../doc/development/demo-client.adoc[Demo Client and the Playwright End-to-End Suite] -- the + *contributor* layer: the module layout, the opt-in build mechanism, the Chromium host-resolver + mapping and why it is required, and the standing prohibitions this module carries. + +Read the contributor document before changing the suite. Several of its constraints look arbitrary +and are not. diff --git a/demo-client/eslint.config.js b/demo-client/eslint.config.js new file mode 100644 index 00000000..c84b40b3 --- /dev/null +++ b/demo-client/eslint.config.js @@ -0,0 +1,76 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import security from 'eslint-plugin-security'; +import prettier from 'eslint-config-prettier'; + +/** + * Flat ESLint configuration for the demo client. + * + * Two source trees with different runtimes share one config: + * + * - `src/main/resources/spa/**` runs in the BROWSER. It is the demo SPA the gateway serves as a + * public asset, so it gets browser globals only. It must not reach for Node APIs. + * - `tests/**`, `fixtures/**`, `utils/**` run under NODE, inside the Playwright runner, and get + * node globals plus the Playwright test globals. + * + * `eslint-config-prettier` is last so it can switch off every stylistic rule Prettier owns. + * Formatting is Prettier's job; this config only carries correctness and security rules. + */ +export default [ + { + // Generated and downloaded material — never linted. + ignores: ['target/**', 'node_modules/**', 'test-results/**', 'playwright-report/**'], + }, + + js.configs.recommended, + + { + // Shared baseline for every JavaScript file in the module. + languageOptions: { + ecmaVersion: 2023, + sourceType: 'module', + }, + plugins: { security }, + rules: { + ...security.configs.recommended.rules, + eqeqeq: ['error', 'always'], + 'no-var': 'error', + 'prefer-const': 'error', + 'no-implicit-coercion': 'error', + }, + }, + + { + // The demo SPA: browser runtime, no bundler, no framework. + files: ['src/main/resources/spa/**/*.js'], + languageOptions: { + globals: globals.browser, + }, + rules: { + // The SPA is a sample an integrator reads. It must not log anything, and it must never be + // the place a token or a session value gets written to the console. + 'no-console': 'error', + // The gateway owns the response envelope and serves the SPA same-origin; there is no reason + // for the demo to build DOM from strings. Rendering goes through textContent. + 'no-alert': 'error', + }, + }, + + { + // The Playwright suite: node runtime plus the runner's globals. + files: ['tests/**/*.js', 'fixtures/**/*.js', 'utils/**/*.js', 'playwright.config.js'], + languageOptions: { + globals: { + ...globals.node, + }, + }, + rules: { + // Playwright reads credentials and base URLs from the environment the POM supplies; the + // security plugin's non-literal-fs-filename rule is noisy against the runner's own APIs and + // carries no signal for a test tree that touches no filesystem paths from user input. + 'security/detect-non-literal-fs-filename': 'off', + }, + }, + + prettier, +]; diff --git a/demo-client/fixtures/test-fixtures.js b/demo-client/fixtures/test-fixtures.js new file mode 100644 index 00000000..627d8154 --- /dev/null +++ b/demo-client/fixtures/test-fixtures.js @@ -0,0 +1,102 @@ +import { test as base } from '@playwright/test'; + +import { SCREENSHOT_DIR, SPA } from '../utils/constants.js'; + +/** + * Shared fixtures for the demo suite. + * + * Two of them, both closing a gap a raw `page` leaves: + * + * - `demoPage`: a page already on the SPA entry, so no spec repeats the navigation or restates the + * entry path. + * - `probe`: issues same-origin fetches FROM INSIDE the page context, so the HttpOnly session + * cookie is attached exactly as the SPA itself would attach it. Driving the same request from + * Playwright's APIRequestContext would use a separate cookie jar and would prove something + * weaker than what a browser actually observes. + */ + +/** + * The observable shape of one reserved-endpoint response. + * + * @typedef {object} ProbeResult + * @property {number} status the HTTP status + * @property {boolean} redirected whether the response followed a redirect (must be false for the info endpoint) + * @property {string} contentType the response Content-Type + * @property {string} cacheControl the response Cache-Control + * @property {?object} body the parsed JSON body, or null when the body is not JSON + */ + +export const test = base.extend({ + /** + * A page already sitting on the demo SPA entry. + * + * @param {{page: import('@playwright/test').Page}} fixtures the base fixtures + * @param {Function} use the fixture consumer + */ + demoPage: async ({ page }, use) => { + await page.goto(SPA.index); + await use(page); + }, + + /** + * A same-origin fetch probe evaluated inside the page context. + * + * @param {{page: import('@playwright/test').Page}} fixtures the base fixtures + * @param {Function} use the fixture consumer + */ + probe: async ({ page }, use) => { + /** + * Fetches a same-origin path from inside the page and reports what the browser observed. + * + * `redirect: 'manual'` is deliberate: it lets a spec assert that the info endpoint did NOT + * redirect, which is the contract. Note that no `Accept` header is sent — both reserved + * endpoints are Accept-blind, and asserting otherwise is a PROHIBITED ASSERTION. + * + * @param {string} path the same-origin path to probe + * @returns {Promise} the observed response + */ + const probePath = (path) => + page.evaluate(async (target) => { + const response = await fetch(target, { + credentials: 'same-origin', + redirect: 'manual', + }); + let body; + try { + body = await response.json(); + } catch { + body = null; + } + return { + status: response.status, + redirected: response.redirected, + contentType: response.headers.get('content-type') ?? '', + cacheControl: response.headers.get('cache-control') ?? '', + body, + }; + }, path); + + await use(probePath); + }, +}); + +export { expect } from '@playwright/test'; + +/** + * Captures a documentation screenshot on the SUCCESS path. + * + * A deliberate divergence from capture-only-on-failure: these images are documentation artifacts of + * the meaningful states, written under `target/screenshots/{project}/` so both session-mode projects + * produce a parallel set. Failure-path retention stays configured separately for diagnostics. + * + * @param {import('@playwright/test').Page} page the page to capture + * @param {import('@playwright/test').TestInfo} testInfo the running test's info, for the project name + * @param {string} name the screenshot name, without extension + * @returns {Promise} resolves once the file is written + */ +export async function captureState(page, testInfo, name) { + await page.screenshot({ + path: `${SCREENSHOT_DIR}/${testInfo.project.name}/${name}.png`, + fullPage: true, + }); +} diff --git a/demo-client/package-lock.json b/demo-client/package-lock.json new file mode 100644 index 00000000..735c32c3 --- /dev/null +++ b/demo-client/package-lock.json @@ -0,0 +1,1056 @@ +{ + "name": "api-sheriff-demo-client", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "api-sheriff-demo-client", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@eslint/js": "^10.0.0", + "@playwright/test": "^1.49.0", + "eslint": "^10.0.0", + "eslint-config-prettier": "^10.0.0", + "eslint-plugin-security": "^4.0.1", + "globals": "^15.0.0", + "prettier": "^3.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-security": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-4.0.1.tgz", + "integrity": "sha512-/lZCkOxPOWaf1jXAqgICrS8St3BMBccIPvhOSUYuV6VCr1o5nFVG998FnTLt6w2Nxb8Uo0nM8fzmnhp+GY/aEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-regex": "^2.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/demo-client/package.json b/demo-client/package.json new file mode 100644 index 00000000..d6ba5c3b --- /dev/null +++ b/demo-client/package.json @@ -0,0 +1,27 @@ +{ + "name": "api-sheriff-demo-client", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Dependency-free demo SPA for the API Sheriff BFF and the Playwright end-to-end suite that drives it.", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "scripts": { + "lint:strict": "eslint --max-warnings 0 --report-unused-disable-directives '{tests,utils,fixtures,src/main/resources/spa}/**/*.js'", + "format": "prettier --write '{tests,utils,fixtures,src/main/resources/spa}/**/*.js'", + "format:check": "prettier --check '{tests,utils,fixtures,src/main/resources/spa}/**/*.js'", + "install-browser": "playwright install chromium", + "test": "playwright test" + }, + "devDependencies": { + "@eslint/js": "^10.0.0", + "@playwright/test": "^1.49.0", + "eslint": "^10.0.0", + "eslint-config-prettier": "^10.0.0", + "eslint-plugin-security": "^4.0.1", + "globals": "^15.0.0", + "prettier": "^3.0.3" + } +} diff --git a/demo-client/playwright.config.js b/demo-client/playwright.config.js new file mode 100644 index 00000000..6bca334c --- /dev/null +++ b/demo-client/playwright.config.js @@ -0,0 +1,114 @@ +import { defineConfig } from '@playwright/test'; + +/** + * Playwright configuration for the API Sheriff BFF demo suite. + * + * Two projects run the SAME specs and differ ONLY in `baseURL`: `session-server` against the + * server-session gateway and `session-cookie` against the cookie-session gateway. That single + * difference is what makes "the two session modes are browser-observably identical" an executable + * assertion rather than a claim in a design document. + * + * Every URL and credential comes from the environment, which the POM supplies from its own + * properties — no port number is restated in JavaScript. + */ + +/** + * Reads a required environment variable. + * + * Failing loudly here is deliberate: a missing base URL would otherwise surface as every spec + * navigating to `about:blank` and failing with an unrelated assertion message. + * + * @param {string} name the variable name + * @returns {string} the value + */ +function required(name) { + const value = process.env[name]; + if (!value) { + throw new Error( + `${name} is not set. Run the suite through 'verify -Pe2e-demo -pl demo-client', which supplies it from the POM.` + ); + } + return value; +} + +const keycloakHostUrl = required('KEYCLOAK_HOST_URL'); + +/** + * Builds the Chromium host-resolver rule that maps Keycloak's container-internal authority onto its + * published host address. + * + * THIS IS LOAD-BEARING, not a convenience. The `integration` realm pins + * `frontendUrl https://keycloak:8443`, so every authorization URL the gateway redirects to carries a + * container-internal authority the host cannot resolve. The landed Java integration helper sidesteps + * this by string-rewriting each redirect location — a real browser cannot do that, because following + * redirects is the browser's own job. So the resolution is pushed down into the browser's resolver. + * + * Do NOT "fix" this by changing the realm's frontendUrl: that value is the issuer authority baked + * into every token the realm mints, and changing it breaks every landed bearer-token test. + * + * @returns {string} the `--host-resolver-rules` flag value + */ +function hostResolverRule() { + const { hostname, port } = new URL(keycloakHostUrl); + return `MAP keycloak:8443 ${hostname === 'localhost' ? '127.0.0.1' : hostname}:${port}`; +} + +const chromiumLaunchArgs = [ + // The stack serves a self-signed localhost bundle; this covers the browser's own navigations, + // while ignoreHTTPSErrors covers the API-level requests. + '--ignore-certificate-errors', + `--host-resolver-rules=${hostResolverRule()}`, +]; + +export default defineConfig({ + testDir: './tests', + // Everything generated lands under the Maven-standard target/ tree, which is already git-ignored. + outputDir: 'target/test-results', + + // The suite drives ONE shared stack with a real IdP and real server-side sessions. Running specs + // in parallel would have them log in and log out from under each other, so serial execution is a + // correctness requirement rather than a performance trade-off. + fullyParallel: false, + workers: 1, + // No retries: a flaky browser assertion against this contract is a defect to diagnose, not to + // paper over by re-running until it passes. + retries: 0, + forbidOnly: !!process.env.CI, + timeout: 60_000, + expect: { timeout: 10_000 }, + + // Deliberately NO html reporter: it would spawn a report server, and a blocked run in CI is + // indistinguishable from a hang. + reporter: [ + ['list'], + ['json', { outputFile: 'target/test-results/results.json' }], + ['junit', { outputFile: 'target/test-results/junit.xml' }], + ], + + use: { + ignoreHTTPSErrors: true, + // Failure-path artefacts for diagnostics. The DOCUMENTATION screenshots are captured explicitly + // on the success path by the specs themselves — see fixtures/test-fixtures.js. + screenshot: 'retain-on-failure', + trace: 'retain-on-failure', + video: 'retain-on-failure', + launchOptions: { args: chromiumLaunchArgs }, + }, + + projects: [ + { + name: 'session-server', + use: { + browserName: 'chromium', + baseURL: required('PLAYWRIGHT_BASE_URL_SERVER'), + }, + }, + { + name: 'session-cookie', + use: { + browserName: 'chromium', + baseURL: required('PLAYWRIGHT_BASE_URL_COOKIE'), + }, + }, + ], +}); diff --git a/demo-client/pom.xml b/demo-client/pom.xml new file mode 100644 index 00000000..43708346 --- /dev/null +++ b/demo-client/pom.xml @@ -0,0 +1,296 @@ + + + 4.0.0 + + de.cuioss.sheriff.gateway + api-sheriff-parent + 0.1.0-SNAPSHOT + ../pom.xml + + + demo-client + pom + API Sheriff Demo Client + Dependency-free demo SPA for the API Sheriff BFF plus the Playwright end-to-end suite that + drives it against the integration-tests stack. Demo and compose material only — never a shipped + default, never part of the production image or the native build. + + + + + + + + + + true + true + true + true + true + true + true + true + true + true + true + true + true + true + + + true + + + + v22.14.0 + 10.9.2 + 1.15.1 + + + + true + + + + https://localhost:10443 + https://localhost:10445 + https://localhost:1443 + + + + + + e2e-demo + + false + + false + + + + + + org.codehaus.mojo + exec-maven-plugin + + + stop-dev-environment-pre-clean + pre-clean + + exec + + + ${skipPlaywrightTests} + ./scripts/stop-dev-environment.sh + ${project.basedir} + + + 0 + 1 + + + + + start-dev-environment + pre-integration-test + + exec + + + ${skipPlaywrightTests} + ./scripts/start-dev-environment.sh + ${project.basedir} + + + + + stop-dev-environment-post-integration-test + post-integration-test + + exec + + + ${skipPlaywrightTests} + ./scripts/stop-dev-environment.sh + ${project.basedir} + + 0 + 1 + + + + + + + + com.github.eirslett + frontend-maven-plugin + ${version.frontend-maven-plugin} + + + ${project.build.directory} + ${skipPlaywrightTests} + + + + install-node-and-npm + initialize + + install-node-and-npm + + + ${node.version} + ${npm.version} + + + + npm-install + initialize + + npm + + + + ci + + + + npm-lint-strict + + process-resources + + npm + + + run lint:strict + + + + npm-install-browser + + process-resources + + npm + + + run install-browser + + + + playwright-test + integration-test + + npm + + + + run test + + ${demo.baseUrl.server} + ${demo.baseUrl.cookie} + ${demo.keycloak.url} + + never + + + + + + + + + + diff --git a/demo-client/scripts/start-dev-environment.sh b/demo-client/scripts/start-dev-environment.sh new file mode 100755 index 00000000..b7d497bb --- /dev/null +++ b/demo-client/scripts/start-dev-environment.sh @@ -0,0 +1,274 @@ +#!/bin/bash +# Bring up the TRIMMED three-container stack the demo suite needs: keycloak, api-sheriff and +# api-sheriff-cookie — and nothing else. +# +# This script starts a SUBSET of integration-tests/docker-compose.yml. It declares no stack of its +# own and changes nothing in that file: a second compose stack would be a second thing to keep in +# sync with the gateway configuration, and the two would drift. +# +# --------------------------------------------------------------------------------------------- +# STANDING PROHIBITION — a Compose profile cannot express the trimmed bring-up +# +# A Compose profile cannot express a trimmed bring-up, because a service with no `profiles:` key +# always starts and profiles therefore only ever ADD to the default set. +# +# Subtracting the other nine services would require tagging THEM with a profile, which would have +# two consequences in integration-tests: +# +# * they would drop out of the unqualified `compose up -d` that +# integration-tests/scripts/start-integration-container.sh relies on (that script's line +# `(cd "${PROJECT_DIR}" && $COMPOSE_CMD up -d)`); and +# * they would disappear from `compose config --format json`, which that same script's +# gateway-readiness discovery enumerates — and which hard-exits when the resulting set is empty. +# +# The demo therefore uses EXPLICIT SERVICE SELECTION. It delivers the identical three-container +# footprint with ZERO structural change to docker-compose.yml, which is strictly more minimal and +# carries no risk to the landed integration-test suite. Consequently this module adds no `profiles:` +# key to docker-compose.yml and touches neither of the two lines above. +# +# DO NOT restore the profile approach. +# +# The one refinement over a bare service list: `--no-deps` on the gateway bring-up. Both gateway +# services declare `depends_on: [keycloak, go-httpbin, asset-origin]`, so an unqualified +# `up -d keycloak api-sheriff api-sheriff-cookie` would also start go-httpbin and asset-origin — +# five containers, not three. The demo drives only the gateway's reserved /auth/* paths and the +# /assets/demo/* directory asset source, neither of which touches those two upstreams, and Keycloak +# (the one dependency that IS load-bearing, for JWKS) is started and waited on explicitly below +# before the gateways come up. `--no-deps` is what makes the three-container footprint real; it is +# still explicit service selection and it still changes nothing in docker-compose.yml. +# --------------------------------------------------------------------------------------------- + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODULE_DIR="$(dirname "$SCRIPT_DIR")" +ROOT_DIR="$(cd "${MODULE_DIR}/.." && pwd)" +IT_DIR="${ROOT_DIR}/integration-tests" +IT_SCRIPT_DIR="${IT_DIR}/scripts" + +# The exact three services the demo needs. This list is the deliverable's whole mechanism. +DEMO_IDP_SERVICE="keycloak" +DEMO_GATEWAY_SERVICES=(api-sheriff api-sheriff-cookie) + +# Reuse — never reimplement — the integration-tests helpers. +# shellcheck source=../../integration-tests/scripts/lib-docker-compose.sh +source "${IT_SCRIPT_DIR}/lib-docker-compose.sh" + +echo "🚀 Starting the trimmed API Sheriff demo stack (${DEMO_IDP_SERVICE} + ${DEMO_GATEWAY_SERVICES[*]})" + +COMPOSE_BASE="$(resolve_compose_cmd || true)" +if [[ -z "$COMPOSE_BASE" ]]; then + echo "❌ Docker Compose not available (neither 'docker compose' nor 'docker-compose')" + exit 1 +fi +if ! docker_daemon_up; then + echo "❌ Docker daemon not running — start Docker/Rancher Desktop first" + exit 1 +fi + +# Build the native executable only if it is missing. Reused wholesale from integration-tests; the +# demo has no native-build logic of its own. +"${IT_SCRIPT_DIR}/build-native-if-needed.sh" + +cd "${IT_DIR}" +COMPOSE_CMD="$COMPOSE_BASE -f docker-compose.yml" + +# Every port this script touches is DERIVED from the resolved Compose model, and none is restated +# here. docker-compose.yml owns all of them — the `de.cuioss.sheriff.management-scheme` label, the +# host port published against the management container port 9000, and the host port published +# against the public TLS container port 8443 — and a list in this file that had to mirror them would +# be a defect the moment any of them changed. This is the same derivation +# integration-tests/scripts/start-integration-container.sh performs, narrowed to the demo's three +# named services and widened to carry the public port the closing banner prints. +# +# It runs BEFORE the image rebuild on purpose: a model this script cannot read is a failure worth +# having in two seconds rather than after a native image build. +# +# The one scheme NOT derived is the public one. Every service in this stack terminates TLS on its +# public listener — the gateways mount the localhost bundle and Keycloak runs with +# KC_HTTP_ENABLED=false — so `https` is a property of the stack rather than a per-service knob, and +# the management-scheme label deliberately describes only the management interface. +echo "⏳ Discovering the demo stack's published ports from the Compose model..." +if ! DEMO_TARGETS="$($COMPOSE_CMD config --format json | python3 -c ' +import json +import sys + +SCHEME_LABEL = "de.cuioss.sheriff.management-scheme" +MANAGEMENT_CONTAINER_PORT = "9000" +PUBLIC_CONTAINER_PORT = "8443" + +wanted = sys.argv[1:] + +try: + model = json.load(sys.stdin) +except ValueError as exc: + sys.exit("could not parse the resolved Compose model as JSON (%s). This script needs a Compose " + "version supporting `config --format json`." % exc) + +services = model.get("services") or {} + + +def published_for(spec, container_port): + return [port.get("published") for port in (spec.get("ports") or []) + if str(port.get("target")) == container_port and port.get("published")] + + +rows = [] +problems = [] +for name in wanted: + spec = services.get(name) + if spec is None: + problems.append("%s: not present in the resolved Compose model" % name) + continue + scheme = (spec.get("labels") or {}).get(SCHEME_LABEL) + ports = {"management": published_for(spec, MANAGEMENT_CONTAINER_PORT), + "public": published_for(spec, PUBLIC_CONTAINER_PORT)} + usable = True + if scheme not in ("http", "https"): + problems.append("%s: missing or invalid %s label (got %r)" % (name, SCHEME_LABEL, scheme)) + usable = False + for role, container_port in (("management", MANAGEMENT_CONTAINER_PORT), + ("public", PUBLIC_CONTAINER_PORT)): + if len(ports[role]) != 1: + problems.append("%s: expected exactly one host port published against the %s container " + "port %s, found %r" % (name, role, container_port, ports[role])) + usable = False + if usable: + rows.append("%s %s %s %s" % (name, scheme, ports["management"][0], ports["public"][0])) + +if problems: + sys.exit("demo stack port discovery failed:\n " + "\n ".join(problems)) + +sys.stdout.write("\n".join(rows) + "\n") +' "${DEMO_IDP_SERVICE}" "${DEMO_GATEWAY_SERVICES[@]}")"; then + echo "❌ Could not derive the demo stack ports from docker-compose.yml (see above)" + exit 1 +fi + +# Split the derived rows by role. The IdP row drives the readiness wait and the Keycloak banner +# entry; the gateway rows drive the gateway readiness loop and the SPA entry-point banner. +IDP_TARGET="$(printf '%s\n' "$DEMO_TARGETS" | grep "^${DEMO_IDP_SERVICE} ")" +GATEWAY_TARGETS="$(printf '%s\n' "$DEMO_TARGETS" | grep -v "^${DEMO_IDP_SERVICE} ")" +read -r _ IDP_MGMT_SCHEME IDP_MGMT_PORT IDP_PUBLIC_PORT <<< "$IDP_TARGET" + +# Rebuild the image from the (possibly just-rebuilt) native executable. This is LOAD-BEARING: +# `compose up` alone silently reuses a stale image, so a native fix appears not to take effect and +# the suite fails against code that is no longer on disk. Same shape as integration-tests/pom.xml's +# docker-build-distroless execution. api-sheriff-cookie runs the SAME api-sheriff:distroless image, +# so building the one service covers both gateways. +echo "🐳 Rebuilding the api-sheriff image from the native executable..." +export DOCKER_BUILDKIT=1 +$COMPOSE_CMD build api-sheriff + +# Quarkus file logging writes to the bind-mounted /logs. The container runs as uid 1001 while this +# host directory is created by the (differently-numbered) build user, so without a world-writable +# dedicated subdirectory the file sink fails with "FileNotFoundException: /logs/quarkus.log +# (Permission denied)". Grant world write on that subdirectory ONLY — least privilege, ephemeral +# test output — exactly as integration-tests/scripts/start-integration-container.sh does. The +# container keeps its no-new-privileges / cap_drop / read_only posture. +LOG_TARGET_ROOT="${LOG_TARGET_DIR:-${IT_DIR}/target}" +export LOG_TARGET_DIR="${LOG_TARGET_ROOT}/quarkus-logs" +mkdir -p "${LOG_TARGET_DIR}" +chmod 0777 "${LOG_TARGET_DIR}" +echo "📁 Quarkus logs will be written to: ${LOG_TARGET_DIR}/quarkus.log" + +# Keycloak FIRST, and READY, before either gateway starts. The native app eagerly loads the realm's +# JWKS at boot; if it starts before Keycloak can answer, that load fails and — with a long +# background-refresh interval — the issuer stays unhealthy for the whole run, so every login's token +# validation fails with "No healthy issuer configuration found". Gating the gateway start on a ready +# Keycloak removes the race. +echo "🐳 Starting ${DEMO_IDP_SERVICE} first (the gateways start only after it is ready)..." +$COMPOSE_CMD up -d "${DEMO_IDP_SERVICE}" + +# -f, matching the gateway probe below: /health/ready answers 503 while Keycloak is still starting, +# and without -f curl exits 0 on that 503 — so the wait would clear as soon as the port ACCEPTED +# rather than when Keycloak was actually ready, which is the very race the comment above says this +# gate exists to remove. +IDP_PROBE_OPTS=(-sf --connect-timeout 2 --max-time 5) +if [[ "$IDP_MGMT_SCHEME" == "https" ]]; then + # -k is load-bearing on an HTTPS management interface: it serves a self-signed localhost bundle, + # and without it curl fails certificate validation and this wait degrades into a silent + # 120-attempt timeout against a perfectly healthy container. + IDP_PROBE_OPTS+=(-k) +fi +IDP_HEALTH_URL="${IDP_MGMT_SCHEME}://localhost:${IDP_MGMT_PORT}/health/ready" + +echo "⏳ Waiting for ${DEMO_IDP_SERVICE} to be ready (management ${IDP_MGMT_SCHEME} on ${IDP_MGMT_PORT})..." +for i in {1..120}; do + if curl "${IDP_PROBE_OPTS[@]}" "${IDP_HEALTH_URL}" > /dev/null 2>&1; then + echo "✅ ${DEMO_IDP_SERVICE} is ready!" + break + fi + if [ "$i" -eq 120 ]; then + echo "❌ ${DEMO_IDP_SERVICE} did not answer ${IDP_HEALTH_URL} within 120 attempts" + echo "Check logs with: ${COMPOSE_CMD} logs ${DEMO_IDP_SERVICE}" + exit 1 + fi + echo "⏳ Waiting for ${DEMO_IDP_SERVICE}... (attempt $i/120)" + sleep 1 +done + +# The trimmed bring-up itself — explicit service selection, --no-deps (see the standing prohibition +# in the header for both halves of that decision). +echo "🐳 Starting ONLY ${DEMO_GATEWAY_SERVICES[*]} (no other stack service is touched)..." +$COMPOSE_CMD up -d --no-deps "${DEMO_GATEWAY_SERVICES[@]}" + +echo "⏳ Waiting for the demo gateway instances to be ready..." +while read -r GATEWAY_SERVICE GATEWAY_MGMT_SCHEME GATEWAY_MGMT_PORT _; do + [[ -z "$GATEWAY_SERVICE" ]] && continue + + GATEWAY_PROBE_OPTS=(-sf --connect-timeout 2 --max-time 5) + GATEWAY_DIAG_OPTS=(-s --connect-timeout 2 --max-time 5) + if [[ "$GATEWAY_MGMT_SCHEME" == "https" ]]; then + # -k is load-bearing on an HTTPS management interface: it serves a self-signed localhost + # bundle, and without it curl fails certificate validation and this wait degrades into a + # silent 30-attempt timeout against a perfectly healthy container. + GATEWAY_PROBE_OPTS+=(-k) + GATEWAY_DIAG_OPTS+=(-k) + fi + GATEWAY_MGMT_URL="${GATEWAY_MGMT_SCHEME}://localhost:${GATEWAY_MGMT_PORT}" + + echo "⏳ Waiting for ${GATEWAY_SERVICE} (management ${GATEWAY_MGMT_SCHEME} on ${GATEWAY_MGMT_PORT})..." + for i in {1..30}; do + if curl "${GATEWAY_PROBE_OPTS[@]}" "${GATEWAY_MGMT_URL}/q/health/live" > /dev/null 2>&1; then + echo "✅ ${GATEWAY_SERVICE} is ready!" + break + fi + if [ "$i" -eq 30 ]; then + echo "❌ ${GATEWAY_SERVICE} failed to start within 30 attempts" + # Capture the container log + health payload so a startup failure is diagnosable from + # the CI artifacts rather than only from a lost console. + DIAG_DIR="${MODULE_DIR}/target/test-results" + mkdir -p "$DIAG_DIR" + echo "----- $COMPOSE_CMD logs ${GATEWAY_SERVICE} -----" + $COMPOSE_CMD logs --no-color "${GATEWAY_SERVICE}" &1 | tee "$DIAG_DIR/${GATEWAY_SERVICE}-app.log" + echo "----- ${GATEWAY_MGMT_URL}/q/health -----" + curl "${GATEWAY_DIAG_OPTS[@]}" "${GATEWAY_MGMT_URL}/q/health" 2>&1 | tee "$DIAG_DIR/${GATEWAY_SERVICE}-health.json" + echo "" + exit 1 + fi + echo "⏳ Waiting for ${GATEWAY_SERVICE}... (attempt $i/30)" + sleep 1 + done +done <<< "$GATEWAY_TARGETS" + +echo "" +echo "🎉 The trimmed demo stack is running — three containers:" +$COMPOSE_CMD ps --format "table {{.Service}}\t{{.State}}" "${DEMO_IDP_SERVICE}" "${DEMO_GATEWAY_SERVICES[@]}" +echo "" +# Printed from the SAME derived rows the readiness loop used, so the banner cannot drift from what +# docker-compose.yml actually publishes. demo-client/pom.xml's demo.baseUrl.* properties carry the +# same addresses for the Playwright suite; a literal copy here would be a third place to keep in +# lockstep with the model. +echo "📱 Demo entry points (the SPA is served BY the gateway, so it is same-origin with /auth/*):" +while read -r GATEWAY_SERVICE _ _ GATEWAY_PUBLIC_PORT; do + [[ -z "$GATEWAY_SERVICE" ]] && continue + echo " 🖥️ ${GATEWAY_SERVICE}: https://localhost:${GATEWAY_PUBLIC_PORT}/assets/demo/index.html" +done <<< "$GATEWAY_TARGETS" +echo " 🔑 ${DEMO_IDP_SERVICE}: https://localhost:${IDP_PUBLIC_PORT}/auth" +echo "" +# 'npm run test', NOT npx: npm run resolves node_modules/.bin only, while npx's auto-confirm +# suppresses the prompt guarding its registry fallback. The build was deliberately moved off npx +# (demo-client/pom.xml, playwright-test execution); this banner must not send a developer back to it. +echo "🧪 Run the suite: cd demo-client && npm run test" +echo "🛑 To stop: demo-client/scripts/stop-dev-environment.sh" diff --git a/demo-client/scripts/stop-dev-environment.sh b/demo-client/scripts/stop-dev-environment.sh new file mode 100755 index 00000000..51bbb9f4 --- /dev/null +++ b/demo-client/scripts/stop-dev-environment.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Tear down the trimmed demo stack — and ONLY it. +# +# The teardown mirrors start-dev-environment.sh's explicit service selection exactly: it stops and +# removes keycloak, api-sheriff and api-sheriff-cookie by name. It deliberately does NOT run +# `compose down`, which would tear down every service in integration-tests/docker-compose.yml — +# including containers a developer started by other means (a full +# integration-tests/scripts/start-integration-container.sh stack running alongside, say). Removing +# something this script did not start is not cleanup, it is collateral damage. +# +# For the same reason there is no `--remove-orphans` here: an "orphan" from this selection's point +# of view is simply somebody else's container. +# +# This runs at Maven's pre-clean and post-integration-test phases as best-effort cleanup, so a host +# with no Docker at all exits cleanly rather than failing the build. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODULE_DIR="$(dirname "$SCRIPT_DIR")" +ROOT_DIR="$(cd "${MODULE_DIR}/.." && pwd)" +IT_DIR="${ROOT_DIR}/integration-tests" + +# The SAME three services start-dev-environment.sh selects. +DEMO_SERVICES=(keycloak api-sheriff api-sheriff-cookie) + +# shellcheck source=../../integration-tests/scripts/lib-docker-compose.sh +source "${IT_DIR}/scripts/lib-docker-compose.sh" + +echo "🛑 Stopping the trimmed API Sheriff demo stack (${DEMO_SERVICES[*]})" + +COMPOSE_BASE="$(resolve_compose_cmd || true)" +if [[ -z "$COMPOSE_BASE" ]]; then + echo "ℹ️ Docker Compose not available — nothing to stop, skipping cleanup." + exit 0 +fi +if ! docker_daemon_up; then + echo "ℹ️ Docker daemon not running — nothing to stop, skipping cleanup." + exit 0 +fi + +cd "${IT_DIR}" +COMPOSE_CMD="$COMPOSE_BASE -f docker-compose.yml" + +# Nothing running is the normal case (an aborted run may have left nothing behind), not a failure. +echo "📦 Stopping and removing the demo containers..." +$COMPOSE_CMD rm --stop --force "${DEMO_SERVICES[@]}" + +# Report what — if anything — the selection left behind. Any container still up here belongs to +# somebody else's bring-up and is deliberately untouched. +REMAINING="$($COMPOSE_CMD ps --services --filter status=running || true)" +if [[ -z "$REMAINING" ]]; then + echo "✅ Demo stack stopped — no compose container is running." +else + echo "✅ Demo stack stopped. These containers were started by other means and are left alone:" + echo "$REMAINING" | sed 's/^/ • /' +fi diff --git a/demo-client/src/main/resources/spa/app.css b/demo-client/src/main/resources/spa/app.css new file mode 100644 index 00000000..1a85dc8c --- /dev/null +++ b/demo-client/src/main/resources/spa/app.css @@ -0,0 +1,159 @@ +/* + * Minimal styling for the API Sheriff BFF demo client and its post-logout landing page. + * + * No component library, no CSS framework, no preprocessor -- the served file is the authored file. + * Custom properties keep the palette in one place; everything else is plain, readable CSS. + */ + +:root { + --bg: #f6f7f9; + --surface: #ffffff; + --border: #d6dae0; + --ink: #1c2430; + --ink-muted: #5b6773; + --accent: #1f5f8b; + --warn: #8b2f1f; + --radius: 6px; + --gap: 1rem; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 2rem 1rem; + background: var(--bg); + color: var(--ink); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + line-height: 1.5; +} + +.app, +.landing { + max-width: 44rem; + margin: 0 auto; +} + +h1 { + margin: 0 0 0.25rem; + font-size: 1.6rem; +} + +h2 { + margin: 0 0 0.5rem; + font-size: 1.05rem; + color: var(--accent); +} + +code { + padding: 0.05em 0.3em; + border-radius: 3px; + background: #e9ecf1; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.9em; +} + +.app__lede, +.hint { + margin: 0 0 var(--gap); + color: var(--ink-muted); +} + +.app__footer { + margin-top: 1.5rem; + color: var(--ink-muted); + font-size: 0.9rem; +} + +.panel { + margin-bottom: var(--gap); + padding: var(--gap); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} + +.status { + margin: 0 0 0.5rem; + font-weight: 600; +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +button, +select { + padding: 0.4rem 0.8rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: inherit; + font: inherit; +} + +button { + border-color: var(--accent); + background: var(--accent); + color: #ffffff; + cursor: pointer; +} + +button:hover { + filter: brightness(1.1); +} + +[hidden] { + display: none; +} + +.kv { + display: grid; + grid-template-columns: minmax(9rem, auto) 1fr; + gap: 0.25rem 1rem; + margin: 0; +} + +.kv dt { + color: var(--ink-muted); + font-weight: 600; +} + +.kv dd { + margin: 0; + overflow-wrap: anywhere; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.kv .empty { + grid-column: 1 / -1; + color: var(--ink-muted); + font-family: inherit; + font-style: italic; +} + +.problem { + margin: var(--gap) 0 0; + padding: 0.5rem 0.75rem; + border-left: 3px solid var(--warn); + background: #fbf0ee; + color: var(--warn); + font-weight: 600; +} + +.landing { + padding: 2rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + text-align: center; +} + +.landing a { + color: var(--accent); +} diff --git a/demo-client/src/main/resources/spa/app.js b/demo-client/src/main/resources/spa/app.js new file mode 100644 index 00000000..75b00bb6 --- /dev/null +++ b/demo-client/src/main/resources/spa/app.js @@ -0,0 +1,265 @@ +/** + * API Sheriff BFF demo client. + * + * The whole client, deliberately in one readable file: no framework, no bundler, no build step. + * It exercises the gateway's browser-facing contract and nothing else. + * + * Three rules govern everything below, and each is a property of the gateway rather than a style + * choice here: + * + * 1. `/auth/userinfo` is an XHR PROBE. It answers `200 application/json` with a live session and + * `401 application/problem+json` without one. It NEVER redirects, and it does not vary on + * `Accept`. This client therefore fetches it and branches on the status code -- it must not + * follow or expect a redirect from it. + * 2. `/auth/login` and `/auth/logout` are NAVIGATIONS. They answer `302` into the identity + * provider, which only the browser can follow meaningfully. This client uses + * `window.location.assign(...)` for both, never `fetch`. + * 3. No token material exists here to handle. The endpoint discloses none, the session cookie is + * `HttpOnly`, and this client neither reads a cookie nor writes to storage. Nothing is logged. + */ + +const USERINFO_PATH = '/auth/userinfo'; +const LOGIN_PATH = '/auth/login'; +const LOGOUT_PATH = '/auth/logout'; + +const EM_DASH = '—'; + +/** + * The four disclosure states the operator's claim allowlist makes reachable. The last one asks for + * a claim OUTSIDE the allowlist on purpose, so its `403` is a demonstrated state rather than an + * invisible edge case. + * + * `claims` semantics: absent selects the operator's curated default view, `*` selects the full + * allowlisted view, and any other value is a comma-separated list of specific claim names. + */ +const CLAIM_VIEWS = [ + { id: 'default', label: 'Curated default view (no claims parameter)', claims: null }, + { id: 'selected', label: 'Explicit selection: email', claims: 'email' }, + { id: 'full', label: 'Full allowlisted view (claims=*)', claims: '*' }, + { id: 'denied', label: 'Outside the allowlist: given_name (expects 403)', claims: 'given_name' }, +]; + +const el = (testId) => document.querySelector(`[data-testid="${testId}"]`); + +/** + * Replaces an element's entire content with a single text node. + * + * Every value rendered by this client comes from the identity provider by way of the gateway, and + * is written through `textContent` rather than `innerHTML`. There is no HTML-building sink in this + * file at all, which is what keeps a hostile claim value inert. + * + * @param {Element} target the element to fill + * @param {string} text the text to render + */ +function setText(target, text) { + target.textContent = text; +} + +/** + * Renders a key/value map into a definition list, replacing whatever was there. + * + * @param {Element} list the `
    ` to fill + * @param {Object} values the map to render; an empty map renders the placeholder instead + * @param {string} emptyMessage the placeholder shown for an empty map + */ +function renderPairs(list, values, emptyMessage) { + list.replaceChildren(); + const entries = Object.entries(values ?? {}); + if (entries.length === 0) { + const placeholder = document.createElement('dd'); + placeholder.className = 'empty'; + setText(placeholder, emptyMessage); + list.append(placeholder); + return; + } + for (const [name, value] of entries) { + const term = document.createElement('dt'); + setText(term, name); + const detail = document.createElement('dd'); + detail.dataset.claim = name; + setText(detail, Array.isArray(value) ? value.join(', ') : String(value)); + list.append(term, detail); + } +} + +/** + * Builds the info-endpoint URL for a claim view. + * + * @param {?string} claims the `claims` parameter value, or `null` for the curated default view + * @returns {string} the same-origin request path + */ +function userInfoUrl(claims) { + if (claims === null) { + return USERINFO_PATH; + } + return `${USERINFO_PATH}?claims=${encodeURIComponent(claims)}`; +} + +/** + * Probes the info endpoint for one claim view. + * + * `credentials: 'same-origin'` attaches the gateway's `HttpOnly` session cookie -- the only reason + * the SPA has to be served same-origin with the reserved paths. `redirect: 'error'` encodes rule 1 + * above as a runtime assertion: this endpoint must never redirect, so a redirect is a contract + * violation rather than something to follow. + * + * @param {?string} claims the `claims` parameter value, or `null` + * @returns {Promise<{status: number, contentType: string, cacheControl: string, body: ?Object}>} + * the observed response + */ +async function probeUserInfo(claims) { + const response = await fetch(userInfoUrl(claims), { + credentials: 'same-origin', + redirect: 'error', + headers: { Accept: 'application/json' }, + }); + + let body; + try { + body = await response.json(); + } catch { + // A body-less or non-JSON response is itself reportable state; the status carries the meaning. + body = null; + } + + return { + status: response.status, + contentType: response.headers.get('content-type') ?? '', + cacheControl: response.headers.get('cache-control') ?? '', + body, + }; +} + +/** + * Renders one probe result across the response, claims and session panels. + * + * @param {{status: number, contentType: string, cacheControl: string, body: ?Object}} result + * the observed response + */ +function render(result) { + setText(el('response-status'), String(result.status)); + setText(el('response-content-type'), result.contentType || EM_DASH); + setText(el('response-cache-control'), result.cacheControl || EM_DASH); + + const problem = el('problem'); + const authenticated = result.status === 200; + + el('login').hidden = authenticated; + el('logout').hidden = !authenticated; + + if (authenticated) { + setText(el('auth-state'), 'authenticated'); + setText(el('status'), 'A live session is present.'); + problem.hidden = true; + setText(problem, ''); + renderPairs(el('claims'), result.body?.claims, 'No claims disclosed.'); + renderPairs(el('session'), result.body?.session, 'No session metadata.'); + return; + } + + // 401 (no live session) and 403 (a claim outside the operator allowlist) are both RFC 9457 + // problem documents. Neither is an error to hide: they are the contract being demonstrated. + const title = result.body?.title ?? 'Request refused'; + problem.hidden = false; + setText(problem, `${result.status} ${EM_DASH} ${title}`); + + if (result.status === 403) { + setText(el('auth-state'), 'authenticated'); + setText(el('status'), 'The session is live; the operator allowlist refused the claim.'); + renderPairs(el('claims'), null, 'Refused -- claim outside the operator allowlist.'); + return; + } + + setText(el('auth-state'), 'anonymous'); + setText(el('status'), 'No live session. Log in to see the disclosed identity.'); + renderPairs(el('claims'), null, 'No claims disclosed.'); + renderPairs(el('session'), null, 'No live session.'); +} + +/** + * Renders a probe that never produced a response at all. + * + * Two causes reach here, and the second is the one worth seeing: a transport or TLS failure, and a + * REDIRECT -- `redirect: 'error'` (rule 1) turns the redirect this endpoint must never send into a + * rejected promise. Without this the page would sit on its initial `Loading...` forever and the + * contract violation would exist only as an unhandled rejection in the console. + * + * Every panel `render` owns is reset here, so a failure after a success leaves no stale identity on + * screen. Both navigation buttons are hidden because a failed probe discloses nothing about whether + * a session exists -- which is exactly what the `unknown` auth state says. + * + * @param {unknown} reason the rejection value from the probe + */ +function renderTransportFailure(reason) { + setText(el('response-status'), EM_DASH); + setText(el('response-content-type'), EM_DASH); + setText(el('response-cache-control'), EM_DASH); + + setText(el('auth-state'), 'unknown'); + setText(el('status'), 'The probe did not complete. No response was observed.'); + + el('login').hidden = true; + el('logout').hidden = true; + + // The message only: it describes the transport, never a response body, and there is no token + // material anywhere in this client to leak into it. + const detail = reason instanceof Error ? reason.message : String(reason); + const problem = el('problem'); + problem.hidden = false; + setText(problem, `Request failed ${EM_DASH} ${detail}`); + + renderPairs(el('claims'), null, 'No claims disclosed.'); + renderPairs(el('session'), null, 'No live session.'); +} + +/** + * Runs the selected claim view and renders the outcome. + * + * @returns {Promise} resolves once the panels are updated + */ +async function requestSelectedView() { + const selected = CLAIM_VIEWS.find((view) => view.id === el('claim-view').value); + try { + render(await probeUserInfo(selected ? selected.claims : null)); + } catch (reason) { + renderTransportFailure(reason); + } +} + +/** + * Wires the claim-view selector and the two navigation buttons. + */ +function wireControls() { + const selector = el('claim-view'); + for (const view of CLAIM_VIEWS) { + const option = document.createElement('option'); + option.value = view.id; + setText(option, view.label); + selector.append(option); + } + + el('apply-view').addEventListener('click', () => { + void requestSelectedView(); + }); + selector.addEventListener('change', () => { + void requestSelectedView(); + }); + + // Login is a TOP-LEVEL NAVIGATION, never a fetch: the gateway answers 302 into the OIDC + // authorization endpoint, and following that is the browser's job. The returnUrl is this page's + // own URL -- always same-origin, because the gateway serves this page. An off-origin returnUrl + // would not be an error; the gateway would silently fall back to its default return URL. + el('login').addEventListener('click', () => { + window.location.assign(`${LOGIN_PATH}?returnUrl=${encodeURIComponent(window.location.href)}`); + }); + + // Logout is likewise a top-level navigation: the RP-initiated round-trip goes through the IdP + // end-session leg and the state-verified return leg before landing on the configured + // final_redirect. + el('logout').addEventListener('click', () => { + window.location.assign(LOGOUT_PATH); + }); +} + +wireControls(); +void requestSelectedView(); diff --git a/demo-client/src/main/resources/spa/index.html b/demo-client/src/main/resources/spa/index.html new file mode 100644 index 00000000..1a45bcb6 --- /dev/null +++ b/demo-client/src/main/resources/spa/index.html @@ -0,0 +1,89 @@ + + + + + + API Sheriff -- BFF Demo Client + + + + +
    +
    +

    API Sheriff BFF Demo

    +

    + A dependency-free sample of the browser-facing contract: the gateway serves this page, + so it is same-origin with the reserved /auth paths and can exercise them + with a plain fetch. +

    +
    + +
    +

    Session

    +

    Loading…

    +

    + State: unknown +

    +
    + + +
    +
    + +
    +

    Claim view

    +

    + The claims parameter selects what the info endpoint discloses. The operator + — never this client — decides what is allowed; the last option deliberately + asks for a claim outside the allowlist so the 403 is a visible state. +

    +
    + + + +
    +
    + +
    +

    Last response

    +
    +
    Status
    +
    +
    Content-Type
    +
    +
    Cache-Control
    +
    +
    + +
    + +
    +

    Disclosed claims

    +
    +
    No claims disclosed.
    +
    +
    + +
    +

    Session metadata

    +
    +
    No live session.
    +
    +
    + +
    +

    + No token material is displayed, stored or logged here — the endpoint discloses + none, and the session cookie is HttpOnly and belongs to the gateway. +

    +
    +
    + + + + diff --git a/demo-client/src/main/resources/spa/landing.html b/demo-client/src/main/resources/spa/landing.html new file mode 100644 index 00000000..c14cb21a --- /dev/null +++ b/demo-client/src/main/resources/spa/landing.html @@ -0,0 +1,26 @@ + + + + + + Signed out -- API Sheriff BFF Demo + + + + +
    +

    Signed out

    +

    Your session has ended.

    +

    Back to the demo

    +
    + + diff --git a/demo-client/tests/01-anonymous-contract.spec.js b/demo-client/tests/01-anonymous-contract.spec.js new file mode 100644 index 00000000..b8ab4f34 --- /dev/null +++ b/demo-client/tests/01-anonymous-contract.spec.js @@ -0,0 +1,78 @@ +import { captureState, expect, test } from '../fixtures/test-fixtures.js'; +import { RESERVED, SPA } from '../utils/constants.js'; + +/** + * The anonymous contract: what a browser observes with no session. + * + * PROHIBITED ASSERTION — this suite MUST NOT send `Accept: text/html` (or any `Accept` value) to the + * info endpoint expecting a `302`, and MUST NOT assert the endpoint varies on `Accept`. Both reserved + * endpoints are `Accept`-blind: the info endpoint always answers `401 application/problem+json` + * without a session, and the login endpoint always answers `302`. The split is PATH-based. + */ +test.describe('anonymous contract', () => { + test('the SPA is served by the gateway, same-origin with the reserved paths', async ({ + demoPage, + }) => { + // Served through the gateway-owned asset envelope. Note the explicit filename: the directory + // asset source performs no directory-index resolution. + await expect(demoPage).toHaveURL((url) => url.pathname === SPA.index); + await expect(demoPage.getByRole('heading', { name: 'API Sheriff BFF Demo' })).toBeVisible(); + }); + + test('the info endpoint answers 401 problem+json, uncacheable, and never redirects', async ({ + demoPage, + probe, + }) => { + // The probe runs inside the page context, so the page must be on the SPA origin first. + await expect(demoPage).toHaveURL((url) => url.pathname === SPA.index); + + const result = await probe(RESERVED.userInfo); + + expect(result.status).toBe(401); + expect(result.contentType).toContain('application/problem+json'); + // Every info response — success or error — is uncacheable, so an identity view is never written + // to a shared cache or the browser's. + expect(result.cacheControl).toBe('no-store'); + // The endpoint is an XHR probe. A redirect here would be a contract violation, not something a + // client should follow. + expect(result.redirected).toBe(false); + expect(result.status).not.toBe(302); + }); + + test('the SPA renders the login affordance and discloses nothing', async ({ demoPage }, testInfo) => { + await expect(demoPage.getByTestId('auth-state')).toHaveText('anonymous'); + await expect(demoPage.getByTestId('login')).toBeVisible(); + await expect(demoPage.getByTestId('logout')).toBeHidden(); + await expect(demoPage.getByTestId('response-status')).toHaveText('401'); + await expect(demoPage.getByTestId('claims')).toContainText('No claims disclosed.'); + + await captureState(demoPage, testInfo, 'anonymous'); + }); + + test('a probe that never answers renders the failure instead of leaving the page loading', async ({ + page, + }) => { + // The raw `page`, not `demoPage`: the interception has to be installed BEFORE the navigation + // that fires the SPA's first probe, and `demoPage` has already navigated. + await page.route( + (url) => url.pathname === RESERVED.userInfo, + (route) => route.abort('failed') + ); + + await page.goto(SPA.index); + + // The page must leave its initial 'Loading...' state. A transport failure is a state the SPA + // knows how to detect (it is also how `redirect: 'error'` surfaces a forbidden redirect), so it + // has to be a state the SPA renders. + await expect(page.getByTestId('problem')).toBeVisible(); + await expect(page.getByTestId('problem')).toContainText('Request failed'); + await expect(page.getByTestId('status')).toHaveText( + 'The probe did not complete. No response was observed.' + ); + await expect(page.getByTestId('auth-state')).toHaveText('unknown'); + // Nothing is disclosed and no navigation is offered: a failed probe says nothing about whether + // a session exists. + await expect(page.getByTestId('login')).toBeHidden(); + await expect(page.getByTestId('logout')).toBeHidden(); + }); +}); diff --git a/demo-client/tests/02-login-and-identity.spec.js b/demo-client/tests/02-login-and-identity.spec.js new file mode 100644 index 00000000..00df3efe --- /dev/null +++ b/demo-client/tests/02-login-and-identity.spec.js @@ -0,0 +1,76 @@ +import { captureState, expect, test } from '../fixtures/test-fixtures.js'; +import { login } from '../utils/keycloak-login.js'; +import { CLAIMS, RESERVED, SPA } from '../utils/constants.js'; + +/** + * Login and identity disclosure: the auth-code round-trip through the real IdP, and what the info + * endpoint discloses once a session exists. + * + * Login is a TOP-LEVEL NAVIGATION. The gateway answers `302` into the OIDC authorization endpoint, + * and only the browser can follow that meaningfully — which is precisely why this suite exists + * alongside the Java integration tests. + */ +test.describe('login and identity', () => { + test('login drives the auth-code flow and lands back on the same-origin returnUrl', async ({ + demoPage, + probe, + }, testInfo) => { + await login(demoPage); + + // The callback leg landed the browser back on the returnUrl the SPA supplied — its own URL, + // always same-origin because the gateway serves the SPA. + await expect(demoPage).toHaveURL((url) => url.pathname === SPA.index); + + const result = await probe(RESERVED.userInfo); + expect(result.status).toBe(200); + expect(result.contentType).toContain('application/json'); + expect(result.cacheControl).toBe('no-store'); + + // The curated DEFAULT view: what the operator decided a client sees when it asks for nothing in + // particular. Exactly these claims, no more. + expect(Object.keys(result.body.claims).sort()).toEqual([...CLAIMS.defaultView].sort()); + + // Session metadata is the gateway's own bookkeeping, never token material. + expect(result.body.session).toHaveProperty('expires_at'); + + await expect(demoPage.getByTestId('auth-state')).toHaveText('authenticated'); + await expect(demoPage.getByTestId('logout')).toBeVisible(); + await expect(demoPage.getByTestId('login')).toBeHidden(); + + await captureState(demoPage, testInfo, 'authenticated-default-view'); + }); + + test('no token material is exposed to the client', async ({ demoPage, probe }) => { + await login(demoPage); + + const result = await probe(RESERVED.userInfo); + const disclosed = JSON.stringify(result.body); + for (const forbidden of ['access_token', 'refresh_token', 'id_token']) { + expect(disclosed).not.toContain(forbidden); + } + + // The session cookie is HttpOnly and belongs to the gateway, and the SPA invents no place to + // put a token: neither storage backend holds anything. + const stored = await demoPage.evaluate(() => ({ + local: globalThis.localStorage.length, + session: globalThis.sessionStorage.length, + })); + expect(stored.local).toBe(0); + expect(stored.session).toBe(0); + }); + + test('login with a live session short-circuits, with no fresh authorization flow', async ({ + demoPage, + }) => { + await login(demoPage); + + // A second navigation to the login endpoint while the session is live must redirect straight to + // the validated returnUrl. The proof that no fresh flow started is that the Keycloak form is + // never rendered. + await demoPage.goto(`${RESERVED.login}?returnUrl=${encodeURIComponent(demoPage.url())}`); + + await expect(demoPage).toHaveURL((url) => url.pathname === SPA.index); + await expect(demoPage.locator('#kc-login')).toHaveCount(0); + await expect(demoPage.getByTestId('auth-state')).toHaveText('authenticated'); + }); +}); diff --git a/demo-client/tests/03-claim-views-and-allowlist.spec.js b/demo-client/tests/03-claim-views-and-allowlist.spec.js new file mode 100644 index 00000000..de438718 --- /dev/null +++ b/demo-client/tests/03-claim-views-and-allowlist.spec.js @@ -0,0 +1,60 @@ +import { captureState, expect, test } from '../fixtures/test-fixtures.js'; +import { login } from '../utils/keycloak-login.js'; +import { CLAIMS, FULL_VIEW, RESERVED } from '../utils/constants.js'; + +/** + * The claim views and the operator allowlist cap. + * + * The load-bearing property is the last test: the OPERATOR — never the browser — widens disclosure. + * A claim outside `allowed_claims` is refused `403` before any disclosure happens, even though the + * identity provider issued it and it is present in the validated ID token. + */ +test.describe('claim views and the operator allowlist', () => { + test.beforeEach(async ({ demoPage }) => { + await login(demoPage); + }); + + test('an explicit selection discloses exactly the named claims', async ({ probe }) => { + const result = await probe(`${RESERVED.userInfo}?claims=email`); + + expect(result.status).toBe(200); + expect(result.cacheControl).toBe('no-store'); + expect(Object.keys(result.body.claims)).toEqual(['email']); + }); + + test('claims=* discloses the full allowlisted view and nothing beyond it', async ({ + demoPage, + probe, + }, testInfo) => { + const result = await probe(`${RESERVED.userInfo}?claims=${encodeURIComponent(FULL_VIEW)}`); + + expect(result.status).toBe(200); + expect(result.cacheControl).toBe('no-store'); + // The widest disclosure that exists is still capped by the operator's allowlist, not by the + // client's request. + expect(Object.keys(result.body.claims).sort()).toEqual([...CLAIMS.allowed].sort()); + expect(Object.keys(result.body.claims)).not.toContain(CLAIMS.disallowed); + + await demoPage.getByTestId('claim-view').selectOption('full'); + await expect(demoPage.getByTestId('response-status')).toHaveText('200'); + await captureState(demoPage, testInfo, 'full-allowlisted-view'); + }); + + test('a claim outside the allowlist is refused 403 before any disclosure', async ({ + demoPage, + probe, + }, testInfo) => { + const result = await probe(`${RESERVED.userInfo}?claims=${CLAIMS.disallowed}`); + + expect(result.status).toBe(403); + expect(result.contentType).toContain('application/problem+json'); + expect(result.cacheControl).toBe('no-store'); + // The refusal is total: the problem document carries no claims member at all. + expect(result.body).not.toHaveProperty('claims'); + + await demoPage.getByTestId('claim-view').selectOption('denied'); + await expect(demoPage.getByTestId('response-status')).toHaveText('403'); + await expect(demoPage.getByTestId('problem')).toBeVisible(); + await captureState(demoPage, testInfo, 'claim-denied'); + }); +}); diff --git a/demo-client/tests/04-logout.spec.js b/demo-client/tests/04-logout.spec.js new file mode 100644 index 00000000..ac07ebea --- /dev/null +++ b/demo-client/tests/04-logout.spec.js @@ -0,0 +1,53 @@ +import { captureState, expect, test } from '../fixtures/test-fixtures.js'; +import { login } from '../utils/keycloak-login.js'; +import { RESERVED, SPA } from '../utils/constants.js'; + +/** + * RP-initiated logout: the full round-trip through the IdP end-session leg and the state-verified + * return leg, ending on the configured `final_redirect`. + * + * THE AUTHORITATIVE COMPLETION PROOF IS THE 401 RE-PROBE. The landing page improves the + * human-visible flow and gives the screenshot something to show; it does NOT replace that assertion, + * and a green landing page with a live session still fails this spec. + */ +test.describe('logout', () => { + test('the round-trip lands on the landing page and the session is genuinely destroyed', async ({ + demoPage, + probe, + }, testInfo) => { + await login(demoPage); + + await demoPage.getByTestId('logout').click(); + + // final_redirect names a CONCRETE FILE under the already-existing public asset anchor. It cannot + // be '/': a path_prefix: / anchor fails the pairwise-disjointness boot check fail-closed, and the + // directory asset source performs no directory-index resolution. + await demoPage.waitForURL(`**${SPA.landing}`); + await expect(demoPage.getByTestId('landing')).toBeVisible(); + + // The landing page links back to the demo, and the link works. + const back = demoPage.getByTestId('back-to-demo'); + await expect(back).toBeVisible(); + + // THE assertion: the session is gone, not merely that a static page rendered. + const afterLogout = await probe(RESERVED.userInfo); + expect(afterLogout.status).toBe(401); + expect(afterLogout.contentType).toContain('application/problem+json'); + expect(afterLogout.cacheControl).toBe('no-store'); + + await captureState(demoPage, testInfo, 'logged-out'); + + await back.click(); + await expect(demoPage).toHaveURL((url) => url.pathname === SPA.index); + await expect(demoPage.getByTestId('auth-state')).toHaveText('anonymous'); + }); + + test('the landing page is public static content, reachable without a session', async ({ page }) => { + const response = await page.goto(SPA.landing); + + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toContain('text/html'); + // The gateway owns the response envelope regardless of the served content. + expect(response.headers()['x-content-type-options']).toBe('nosniff'); + }); +}); diff --git a/demo-client/utils/constants.js b/demo-client/utils/constants.js new file mode 100644 index 00000000..fa91a6bf --- /dev/null +++ b/demo-client/utils/constants.js @@ -0,0 +1,62 @@ +/** + * The coordinates the suite drives: the gateway's reserved paths, the demo SPA entry points, and the + * Keycloak realm credentials. + * + * No port number appears here. Ports live in the POM properties and reach the suite as environment + * variables, so a published-port change is made in exactly one place. + */ + +/** The gateway-owned reserved paths (exact-match, on the OIDC host). */ +export const RESERVED = { + /** Login initiation. Answers 302 into the OIDC authorization endpoint — a NAVIGATION, never a fetch. */ + login: '/auth/login', + /** The XHR identity probe. 200 with a live session, 401 application/problem+json without one — never a redirect. */ + userInfo: '/auth/userinfo', + /** RP-initiated logout. Answers 302 into the IdP end-session leg — a NAVIGATION. */ + logout: '/auth/logout', +}; + +/** + * The demo SPA entry points. + * + * Both name a CONCRETE FILE. The gateway's directory asset source performs no directory-index + * resolution, so `/assets/demo/` without a filename returns 404. + */ +export const SPA = { + /** The demo application. */ + index: '/assets/demo/index.html', + /** The post-logout landing target, configured as oidc.logout.final_redirect. */ + landing: '/assets/demo/landing.html', +}; + +/** + * The `integration` realm's test user, imported from integration-realm.json. + * + * Stated literally rather than read from the environment: the suite drives the realm that + * docker-compose imports, so these credentials are a property of that fixed realm, not a knob. + */ +export const REALM_USER = { + username: 'integration-user', + password: 'integration-password', +}; + +/** + * The operator's claim allowlist as the two demo gateways configure it + * (`oidc.user_info.allowed_claims` / `default_view`). The suite asserts against these rather than + * against whatever the IdP happens to mint, because the ALLOWLIST — not the token — is what bounds + * disclosure. + */ +export const CLAIMS = { + /** Disclosed when no `claims` parameter is supplied. */ + defaultView: ['sub', 'preferred_username'], + /** The full allowlisted view, selected by `claims=*`. */ + allowed: ['email', 'groups', 'preferred_username', 'sub'], + /** Present in the validated ID token, deliberately OUTSIDE the allowlist — earns a 403. */ + disallowed: 'given_name', +}; + +/** The `claims` parameter value selecting the full allowlisted view (UserInfoEndpoint.FULL_VIEW). */ +export const FULL_VIEW = '*'; + +/** Where the documentation screenshots land, one parallel set per session-mode project. */ +export const SCREENSHOT_DIR = 'target/screenshots'; diff --git a/demo-client/utils/keycloak-login.js b/demo-client/utils/keycloak-login.js new file mode 100644 index 00000000..34a1527b --- /dev/null +++ b/demo-client/utils/keycloak-login.js @@ -0,0 +1,33 @@ +import { expect } from '@playwright/test'; + +import { REALM_USER, SPA } from './constants.js'; + +/** + * Drives the Keycloak login form and waits for the auth-code round-trip to land back on the SPA. + * + * The whole flow is a sequence of TOP-LEVEL NAVIGATIONS the browser owns: + * + * click Log in -> /auth/login?returnUrl=... -> 302 -> Keycloak authorization endpoint + * -> the login form -> 302 -> /auth/callback?code&state -> 302 -> the validated returnUrl + * + * The browser reaches Keycloak because Chromium is launched with a host-resolver rule mapping the + * realm's pinned `keycloak:8443` frontendUrl onto the published host port — see playwright.config.js. + * + * @param {import('@playwright/test').Page} page a page already on the SPA entry + * @returns {Promise} resolves once the browser is back on the SPA with a live session + */ +export async function login(page) { + await page.getByTestId('login').click(); + + // The Keycloak login form. Waiting on the field rather than on a URL keeps the helper independent + // of the authority the resolver rule maps to. + const username = page.locator('#username'); + await expect(username).toBeVisible(); + await username.fill(REALM_USER.username); + await page.locator('#password').fill(REALM_USER.password); + await page.locator('#kc-login').click(); + + // The callback leg lands on the validated returnUrl, which is the SPA entry the login started from. + await page.waitForURL(`**${SPA.index}`); + await expect(page.getByTestId('auth-state')).toHaveText('authenticated'); +} diff --git a/doc/LogMessages.adoc b/doc/LogMessages.adoc index 44a0d551..bce08d19 100644 --- a/doc/LogMessages.adoc +++ b/doc/LogMessages.adoc @@ -63,7 +63,7 @@ diagnostics use the logger directly and are not catalogued. |ApiSheriff-106 |EDGE |WebSocket relay on route '%s' reclaimed after idle timeout of %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-107 |EDGE |TLS ClientHello failed closed to terminated path: %s |Logged when an accept-time TLS ClientHello is failed closed to the terminated-strict path (GW-06) because it carried no usable SNI, was malformed, or exceeded the reassembly bound; records the disposition only — never the raw ClientHello bytes |ApiSheriff-108 |EDGE |Host-vs-SNI smuggle rejected before route selection: %s |Logged when a terminated request's Host header names a reserved passthrough SNI hostname and is rejected 404 before route selection; records a fixed disposition only — never the raw Host value -|ApiSheriff-109 |EDGE |Reserved-path request body exceeded the %s byte ceiling (%s) — rejected 413 |Logged when a gateway-terminated reserved POST path (the OIDC `response_mode=form_post` callback or the back-channel logout receiver) declares or streams a body beyond the edge's reserved-body byte ceiling; these paths are read pre-authentication, before the per-route body cap can apply, so the ceiling is enforced at the read itself. The first `%s` is the ceiling in bytes and the second a fixed disposition (`declared-content-length` / `streamed-body`) — the offending body is never logged +|ApiSheriff-109 |EDGE |Reserved-path request body exceeded the %s byte ceiling (%s) — rejected 413 |Logged when a gateway-terminated reserved POST path -- the back-channel logout receiver, the one such path that still consumes a request body now that the gateway drives `response_mode=query` and its OIDC callback is a bodyless top-level GET -- declares or streams a body beyond the edge's reserved-body byte ceiling; that path is read pre-authentication, before the per-route body cap can apply, so the ceiling is enforced at the read itself. The first `%s` is the ceiling in bytes and the second a fixed disposition (`declared-content-length` / `streamed-body`) — the offending body is never logged |ApiSheriff-110 |BFF |CSRF defence rejected an unsafe-method session request: %s |Logged when the fixed CSRF defence rejects an unsafe-method `require: session` request; `%s` is the non-sensitive rejection disposition (`untrusted-origin` / `no-origin-proof`) — the raw offending `Origin` value is never logged |ApiSheriff-111 |BFF |Token refresh failed for a require:session route (%s) — session destroyed |Logged when a transparent token refresh fails (IdP rejection or engine-detected refresh-token reuse) and the session is destroyed; `%s` is a bounded, non-sensitive reason — never the presented refresh token or session id |ApiSheriff-112 |BFF |Back-channel logout token rejected: %s |Logged when a back-channel `logout_token` fails signature or claim validation; `%s` is the non-sensitive rejection disposition (`signature` / `claims`) — the raw logout token is never logged diff --git a/doc/adr/0018-BFF_session_mode_is_one_SessionBinding_seam_behind_a_fixed_reserved-path_and_CSRF_model.adoc b/doc/adr/0018-BFF_session_mode_is_one_SessionBinding_seam_behind_a_fixed_reserved-path_and_CSRF_model.adoc index 771a7d1d..fce21b72 100644 --- a/doc/adr/0018-BFF_session_mode_is_one_SessionBinding_seam_behind_a_fixed_reserved-path_and_CSRF_model.adoc +++ b/doc/adr/0018-BFF_session_mode_is_one_SessionBinding_seam_behind_a_fixed_reserved-path_and_CSRF_model.adoc @@ -7,8 +7,8 @@ // Progressive-disclosure metadata block (see manage-adr SKILL.md → "ADR Template // Structure"). Read by `manage-adr.py scan` so a caller can assess an ADR's // relevance without reading the full file. List fields are comma-separated. -// summary: Session mode selects only which SessionBinding is assembled; reserved paths, CSRF and session identity are fixed, mode-neutral models behind that one seam -// tags: bff, session-mode, csrf, reserved-paths, cookie-session, key-material, session-identity +// summary: Session mode selects only which SessionBinding is assembled; reserved paths, CSRF and session identity are fixed, mode-neutral models behind that one seam; amended because the fixed SameSite=Lax policy forces the OIDC authorization response to return by response_mode=query, accepting a bounded code-in-URL exposure rather than weakening the browser-binding cookie +// tags: bff, session-mode, csrf, reserved-paths, cookie-session, key-material, session-identity, oidc, authorization-code-flow, response-mode, samesite, browser-binding, pkce, accepted-tradeoff // affects: api-sheriff // supersedes: // end-adr-metadata @@ -126,6 +126,154 @@ and there is deliberately no acceptance path for the previous format: admitting would require synthesizing the missing nonce, which would silently change the derived identity — exactly the defect the nonce exists to prevent. +== Amendment: the fixed `SameSite=Lax` policy determines the OIDC authorization-response transport + +*This record is amended, not superseded. Decisions (i) through (v) are unchanged. This amendment +records a decision that decision (ii) forces — the authorization-response transport is not free to +be chosen once the cookie policy is fixed — together with the tradeoff that choice accepts.* + +=== The coupling + +A BFF acting as an OIDC confidential client must choose how the authorization response travels from +the identity provider back to the gateway. Two transports are standard: an auto-submitted cross-site +`POST` to the redirect URI (`response_mode=form_post`), or a top-level `GET` navigation carrying the +parameters in the query string (`response_mode=query`). Read on its own, that choice has a +well-known answer — `form_post` keeps the authorization code out of the URL, and therefore out of +the `Referer` header, out of proxy, CDN and server access logs, and out of browser history. Most +OIDC client libraries default to it for exactly that reason. + +The choice is not on its own. The gateway binds the *in-flight* authorization transaction to the +browser that started it, using a short-lived pre-session binding cookie — the pre-session analogue of +the session cookie, and the control that makes a callback replayed from a different browser +rejectable even while the code is still live. That cookie carries `SameSite=Lax` under decision +(ii), and a `Lax` cookie accompanies top-level `GET` navigations only: it is *not* sent on a +cross-site `POST`. + +The transport decision and the cookie-policy decision are therefore coupled, and the coupling is +easy to miss because each looks locally correct. Driving the request with `form_post` while holding a +`Lax` binding cookie produces a callback that *structurally cannot* carry the binding: the browser +drops the cookie on the cross-site POST leg, the gateway resolves no pending record, and every +real-browser login terminates at the "no binding cookie" `403`. The pre-session control does not fail +loudly — it is simply never presented. Because decision (ii) makes `SameSite=Lax` a fixed property +with no configuration switch, the cookie side cannot move; the transport side is the only side that +can. + +Two properties make the coupling worth recording rather than leaving implicit. It is *invisible +below a real browser* — any client that constructs the callback request directly supplies its own +cookies and passes on either transport, so the entire test pyramid can be green while the +browser-facing flow is unusable. And its *obvious local fix inverts a security property*, as the +rejected alternatives below set out. + +=== The decision + +*The gateway itself selects the authorization response mode, and it selects `query`. The +consequential exposure of the authorization code in the callback URL is accepted and explicitly +bounded; the binding cookie is not weakened by one attribute.* + +A gateway-owned authorization-request builder overrides the client engine's built-in `form_post` and +emits `response_mode=query`, making the callback a top-level `GET` navigation for which `SameSite=Lax` +*is* sent. The override rewrites that one parameter and copies every other authorization parameter +through byte-for-byte in its original order and encoding, so no decode/re-encode round-trip can +corrupt an already-encoded value; it is idempotent and total. The same builder is wired into *both* +seams that construct an authorization URL — the login leg and the step-up re-drive leg — so a second +entry point cannot keep emitting the broken mode. There is deliberately no configuration key: the +mode is a property of the gateway's own browser-facing contract, because the alternative value +re-creates a login flow that cannot work. + +*The accepted tradeoff.* `response_mode=query` places the authorization code in the callback URL, +exposing it to the `Referer` header, to intermediary access logs, and to browser history. That +exposure is real, it is the standard reason `form_post` is preferred, and nothing below removes it. +It is accepted deliberately, because it is what makes the browser-facing flow work at all. + +=== The mitigations are standing obligations, not observations + +The tradeoff above is acceptable only while all four of the following hold. Each is an obligation on +future work: an edit that removes one removes the basis on which the exposure was accepted, and the +first two are one-line edits that look harmless in isolation. + +. *The binding cookie keeps `SameSite=Lax` and every other attribute.* Relaxing it to `SameSite=None` + is the change this decision exists to avoid; relaxing it would also make the transport choice moot + in the wrong direction. +. *The response mode stays `query`.* Reverting to `form_post` — directly, or by adding an + authorization-URL seam that bypasses the gateway-owned builder, or through a client-engine upgrade + that changes the override's shape — re-breaks the browser-facing flow silently. +. *PKCE stays mandatory with the verifier gateway-held.* The flow context carries a non-optional PKCE + challenge, the request always emits `code_challenge` / `code_challenge_method`, and the flow + refuses a provider that does not advertise `S256`. A code observed in a log or a history entry is + not redeemable without the verifier. +. *The pending record stays single-use and the `state` check stays.* Resolution removes the record as + it returns it under the store's own lock, so a second callback for the same transaction resolves to + empty; the record is short-lived, with TTL expiry enforced on that same consumption path. A + callback is honoured only when the binding cookie resolves a live record *and* the returned `state` + matches it in constant time, so a code replayed from a different browser is rejected `403` even + while the code itself is still live at the token endpoint. + +No lifetime figure is asserted here for the authorization code. The short lifetime that bounds it is +the identity provider's own default — the gateway does not widen it and does not restate it. The +TTL the gateway does own is the pending record's, which is a distinct quantity and must not be read +as the code's. + +Not overstated: the exposure of the code to intermediaries is real and is bounded by the above, never +removed by it. + +=== Consequential narrowing of the edge surface + +A `query`-mode callback is a top-level `GET` that reads no request body at all, so the callback path +is removed from the edge's reserved-body-read allowlist rather than left in it as an apparently inert +entry. The removal is not cosmetic: retaining the entry would keep granting *any unauthenticated* +`POST` to the callback path a pre-authentication, pre-pipeline body read up to the reserved-body +ceiling before any handler could reject it. A stray `POST` now takes the ordinary path, reaches the +callback with no body, and is rejected `400` for a missing `state`. Back-channel logout remains a +genuinely body-carrying reserved `POST` and stays in the allowlist with its ceiling unchanged — it is +now the only entry. + +An identity provider still configured to `form_post` to this gateway is consequently no longer +supported. That is intended: the gateway selects the mode. + +=== Alternatives considered for this amendment + +*Relax the browser-binding cookie to `SameSite=None`.* The shortest path: it keeps `form_post`, keeps +the code out of the URL, and makes the cookie survive the cross-site POST. Rejected because it +weakens the exact cross-site binding control the cookie exists to provide. The cookie's whole purpose +is to bind an in-flight authorization to one browser; a binding cookie sent cross-site no longer +distinguishes the browser that started the transaction from one that did not, which is precisely the +check the `403` branch performs. It would also contradict decision (ii) directly. The code's URL +exposure is bounded on four independent axes; the binding has no layer behind it. + +*Stay on `form_post` and accept that the binding cookie is absent on the callback.* This would +preserve the preferred transport and require no change. Rejected because it is not a choice between +two working designs: with the cookie absent the callback resolves no pending record, so the +browser-facing flow simply does not work. Making it work means dropping the binding requirement, +which is the previous alternative under another name. + +*Expose the response mode as a configuration key defaulting to `query`.* Rejected because the +non-default value produces a login flow that structurally cannot succeed while the binding cookie +stays `Lax`, so the key's only reachable second value is a broken one. A knob whose alternative +setting is always wrong is a trap, not flexibility. + +*Bind the transaction through the `state` parameter alone and drop the pre-session cookie.* With no +cookie in play either transport would work and the coupling would disappear. Rejected because `state` +travels with the callback wherever the callback goes: it is returned by whichever browser presents +the code, so it correlates a response to a request but cannot attest that the response arrived in the +browser that started it. This would remove the replay-from-another-browser rejection entirely — a +strictly larger loss than the URL exposure it avoids. + +These alternatives fall into the same class as those below: each restores a local convenience by +removing a structural guarantee, and each pays for a bounded, well-understood exposure with an +unbounded one. + +=== Residual risks specific to this amendment + +* *Diagnostic and test infrastructure republishing the code.* Browser-automation failure traces + capture the code-bearing callback URL, and a CI system retaining those traces as artifacts + republishes it to everyone who can read the build. The residual is bounded by the code being + single-use, already consumed by the time the trace is written, PKCE-bound, and issued by a + disposable test realm — but the shape (a tool that faithfully records URLs now records a + credential) applies to any URL-carried secret and outlives any one tool's retention setting. +* *Green tests over a broken flow.* The defect class this amendment resolves is invisible to every + test that constructs the callback itself. Only a real-browser end-to-end path can detect its + return. + == Consequences === Positive diff --git a/doc/adr/0025-A_security_bound_declared_at_one_inbound-validation_stage_is_re-asserted_at_every_later_stage_that_re-validates_the_same_input.adoc b/doc/adr/0026-A_security_bound_declared_at_one_inbound-validation_stage_is_re-asserted_at_every_later_stage_that_re-validates_the_same_input.adoc similarity index 99% rename from doc/adr/0025-A_security_bound_declared_at_one_inbound-validation_stage_is_re-asserted_at_every_later_stage_that_re-validates_the_same_input.adoc rename to doc/adr/0026-A_security_bound_declared_at_one_inbound-validation_stage_is_re-asserted_at_every_later_stage_that_re-validates_the_same_input.adoc index 341b9a7c..23315903 100644 --- a/doc/adr/0025-A_security_bound_declared_at_one_inbound-validation_stage_is_re-asserted_at_every_later_stage_that_re-validates_the_same_input.adoc +++ b/doc/adr/0026-A_security_bound_declared_at_one_inbound-validation_stage_is_re-asserted_at_every_later_stage_that_re-validates_the_same_input.adoc @@ -1,4 +1,4 @@ -= ADR-0025: A security bound declared at one inbound-validation stage is re-asserted at every later stage that re-validates the same input += ADR-0026: A security bound declared at one inbound-validation stage is re-asserted at every later stage that re-validates the same input :toc: left :toclevels: 2 :sectnums: diff --git a/doc/architecture.adoc b/doc/architecture.adoc index 661b32a1..e7f01179 100644 --- a/doc/architecture.adoc +++ b/doc/architecture.adoc @@ -355,6 +355,71 @@ link:adr/0019-Reserved_BFF_paths_bypass_the_url-parameter_value_pipeline_and_the `SessionBinding` is assembled. Provider-metadata discovery is resolved lazily on first engine use, so a BFF gateway boots without a live IdP. +[[_oidc_callback_response_mode]] +==== OIDC callback response mode: `query`, and the tradeoff that buys + +The gateway drives the authorization request with *`response_mode=query`*, so after a successful +login the IdP answers a `302` that navigates the browser to `oidc.redirect_uri` with the +`code` and `state` in the *query string*. The callback is therefore a *top-level GET navigation*, +and it carries no request body at all. + +This is not the engine default. `token-sheriff-client`'s `AuthorizationRequestBuilder` emits +`response_mode=form_post`; the gateway overrides it with its own +`QueryResponseModeAuthorizationRequestBuilder`, wired into *both* engine seams that build an +authorization URL -- the auth-code login leg and the RFC 9470 step-up re-drive. The override +rewrites only the `response_mode` parameter and preserves every other authorization parameter +verbatim. + +*Why the mode is load-bearing, not cosmetic.* The short-lived browser-binding cookie +(`__Host-sheriff-binding`) that ties an in-flight login to one browser is `SameSite=Lax`, and *a +Lax cookie is not sent on a cross-site POST* -- only on a top-level GET navigation. Under +`form_post` the IdP's auto-submit form performs exactly that cross-site POST, so a real browser +dropped the binding cookie on the callback leg and every login dead-ended on the callback's +"no browser-binding cookie" `403` branch. Driving the request with `response_mode=query` makes the +callback the one request shape on which the cookie *is* sent. *The response mode and the cookie's +`SameSite` attribute are one design; do not change either in isolation.* + +[IMPORTANT] +.`SameSite=None` was considered and rejected +==== +The alternative remedy was to keep `form_post` and relax the binding cookie to `SameSite=None`. +That was *rejected*: `None` would have to permit precisely the cross-site sends the cookie exists +to block, weakening the browser-binding control itself. No cookie attribute is weakened by the +mode switch -- the binding cookie keeps its `__Host-` prefix, `Secure`, `HttpOnly`, `Path=/` and +`SameSite=Lax`, and a regression test asserts `SameSite=None` is never emitted. +==== + +[WARNING] +.Accepted tradeoff -- the authorization code travels in the URL +==== +`response_mode=query` places the authorization `code` in the callback URL rather than in a POST +body, which exposes it to the `Referer` header, to proxy / CDN and server access logs, and to +browser history. *That exposure is the standard reason `form_post` is normally preferred, and it +is accepted here deliberately, by operator decision*, because it is what makes the browser-facing +flow work at all. + +It is *bounded -- not removed* -- by three mitigations, each verified in this codebase rather than +assumed: + +* *PKCE is in force.* The engine always emits `code_challenge` / `code_challenge_method` and + refuses to start the flow against a provider that does not advertise `S256`. A leaked code is + not redeemable without the verifier, which never leaves the gateway. +* *The code is single-use and short-lived.* It is redeemed once at the token endpoint and a replay + of the same code fails there. The gateway does not widen the IdP's own code lifetime. +* *The binding cookie plus the `state` double-check.* The callback resolves the pending record by + the unguessable id in the binding cookie *and* constant-time-compares the returned `state`, so a + code replayed from a different browser is rejected `403` even while the code is still live. +==== + +*Consequence for the reserved-POST surface.* Because the callback consumes no body, +`ReservedEndpoint.CALLBACK` was removed from the edge's reserved-body-read allowlist. That removal +is a hardening, not bookkeeping: leaving it there would keep granting *any unauthenticated* POST to +the callback path a pre-authentication, pre-pipeline body read, a retained surface with no +remaining purpose. A stray POST to the callback path now takes the ordinary paused-stream path and +is rejected `400` for a missing `state` -- an honest rejection, never a `500`. *The back-channel +logout receiver remains a genuinely body-carrying reserved POST*: it stays in the allowlist and the +<<_threading_model,reserved-body byte ceiling>> continues to bound it, unchanged. + === Forward Policy (Zero-Trust) Nothing crosses to the upstream unless it is explicitly allowed. The forward policy defines @@ -658,6 +723,7 @@ behaviour where it differs (WebSocket upgrade, gRPC streaming). Connections whos listed in `tls.passthrough_sni` are relayed at L4 and never reach the route table, so no L7 behaviour applies to them. +[[_threading_model]] == Threading Model The gateway targets a virtual-thread-per-request model: @@ -689,10 +755,11 @@ 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). +* *Reading a body the gateway itself consumes* (the back-channel logout receiver -- the one + reserved path that still carries a request body): 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 @@ -700,10 +767,15 @@ thread, then draining the paused body from that virtual thread -- e.g. blocking `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. +loop. The failure signature is a body-read timeout surfacing as a downstream 400 -- a reserved +receiver rejecting the request because the body it needed never arrived. The OIDC callback used to +be the surface where this was first observed ("missing state"); it no longer carries a body at all +(see <<_oidc_callback_response_mode>>), so today the reachable instance of this defect is the +back-channel logout receiver rejecting an absent `logout_token`. *The rule is unchanged and the +lesson is not historical*: it applies to every reserved body the gateway consumes, and to the next +one added. 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]] diff --git a/doc/configuration.adoc b/doc/configuration.adoc index ec4f0bce..9b172d7d 100644 --- a/doc/configuration.adoc +++ b/doc/configuration.adoc @@ -273,6 +273,8 @@ 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 + # register at the IdP as a plain GET redirect target: the + # gateway drives response_mode=query (fixed in code, no knob) login: path: /auth/login # gateway-owned login-initiation entry point (reserved path) user_info: @@ -567,7 +569,8 @@ A request that matches no route is rejected with `404`. 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*; +. the `oidc.redirect_uri` path -- the auth-code *callback*, reached by a top-level `GET` with + `code` and `state` in the query string (`response_mode=query`) and carrying no request body; . `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); @@ -1487,6 +1490,12 @@ ignored when no route's effective auth is `require: session`. | `issuer`, `client_id`, `client_secret`, `scopes`, `redirect_uri` | 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. + *Register it as a plain redirect target reached by a `GET`*: the gateway drives the + authorization request with `response_mode=query`, so the IdP must be free to answer a `302` + carrying `code` and `state` in the query string. An IdP client pinned to force `form_post` + breaks the flow. *There is no configuration knob for the response mode* -- it is fixed in code + (see link:architecture.adoc#_oidc_callback_response_mode[Architecture -- OIDC callback response + mode]), deliberately, because it is coupled to the `SameSite=Lax` browser-binding cookie. | `login.path` | Gateway-owned *login-initiation* entry point -- the reserved path a browser navigates to in diff --git a/doc/development/README.adoc b/doc/development/README.adoc index 2d3701be..58aff788 100644 --- a/doc/development/README.adoc +++ b/doc/development/README.adoc @@ -73,6 +73,13 @@ This tree is seeded here and grows as contributor-facing material lands. certificate and configuration material, and the two trust boundaries -- drawn from `docker-compose.yml` and the mounted `gateway.yaml`, which stay the authoritative sources. +| link:demo-client.adoc[Demo Client and the Playwright End-to-End Suite] +| The `demo-client` module -- why the demo SPA is served by the gateway rather than a side-car + static server, the opt-in `skipPlaywrightTests` / `e2e-demo` build mechanism that keeps it out of + the default lane, how to run the suite locally, the Chromium host-resolver mapping the pinned + realm `frontendUrl` forces, and the standing prohibitions the module carries (the `Accept`-based + info-endpoint assertion, and why a Compose profile cannot express the trimmed bring-up). + | link:diagram-type-deployment.md[Deployment Diagram Type -- Authoring Standard] | How a deployment/topology SVG is drawn -- containment nesting with its depth limit and insets, protocol-and-port edge labels, the trust-boundary visual that stays inside the theme-neutral @@ -84,8 +91,8 @@ This tree is seeded here and grows as contributor-facing material lands. Documents in `doc/development/` cover: -* *Build* -- the Maven module structure (`api-sheriff`, `integration-tests`, `benchmarks`) and - the canonical build commands. +* *Build* -- the Maven module structure (`api-sheriff`, `integration-tests`, `benchmarks`, + `demo-client`) and the canonical build commands. * *Test* -- the unit, module, and integration test layers, the CUI test generator, and the coverage floor. * *Module layout* -- package structure, the framework-agnostic seam diff --git a/doc/development/demo-client.adoc b/doc/development/demo-client.adoc new file mode 100644 index 00000000..85f6de61 --- /dev/null +++ b/doc/development/demo-client.adoc @@ -0,0 +1,432 @@ += API Sheriff -- Demo Client and the Playwright End-to-End Suite +:toc: +:toclevels: 2 +:sectnums: + +The *contributor* view of `demo-client/` -- the dependency-free demo single-page application and the +Playwright suite that drives it against the real `integration-tests` stack. It exists so a +contributor changing the demo, or reading a failing browser test, can see why each piece is shaped +the way it is before touching it. Several of those shapes look arbitrary and are not: they are the +only forms that work against this gateway, and this document records which. + +The operator- and integrator-facing view -- the browser-facing contract, the claim views, the asset +anchor an integrator declares in their own `gateway.yaml` -- is +link:../user/demo-client.adoc[Demo Client -- Integration Sample]. The module's one-screen quickstart +is link:../../demo-client/README.adoc[`demo-client/README.adoc`]. This document does not restate +either. + +[IMPORTANT] +==== +The demo is *demo and compose configuration only*. It is never a shipped default. No `demo-client` +path enters `api-sheriff/src/main/docker/Dockerfile.native`, no demo asset is embedded in the native +executable, and the asset route that serves it lives in the `integration-tests` compose +configuration -- not in any production example. See <<_the_demo_is_excluded_from_the_production_image>>. +==== + +== Why the Demo Exists + +The gateway's server-session and cookie-session BFF variants expose a browser-facing contract: +gateway-owned reserved paths under `/auth`, an XHR identity probe, an operator-capped claim +allowlist, and an RP-initiated logout round-trip. That contract had two gaps before this module: + +* *No executable browser proof.* The landed integration tests drive the contract from Java, through + a helper that rewrites redirect locations as strings. A real browser cannot do that, so the Java + suite proves the gateway's behaviour without proving that a browser can consume it. +* *No copyable sample.* A downstream frontend team standing up their own SPA behind the gateway had + no worked example of the same-origin `returnUrl` rule, the `401`-versus-`302` split, or the + claim-view parameter -- only reference documentation. + +`demo-client/` closes both. It is a sample first and a test second: the SPA is written to be read in +one sitting and copied wholesale, and the suite exists to prove the sample is not merely plausible. + +A second property falls out of running the same specs twice: the suite executes against *both* +session modes -- `session.mode: server` on port 10443 and `session.mode: cookie` on port 10445 -- +as two Playwright projects that differ only in `baseURL`. "The two variants are browser-observably +identical" stops being a claim in a design document and becomes an executable assertion. + +[[_why_the_java_it_suite_could_not_catch_it]] +=== The first gap was not hypothetical -- it hid a real, total login failure + +The "no executable browser proof" gap above is written in the past tense for a reason: the very +first run of this suite found a defect that made *every* browser login fail, against a Java IT +suite that was fully green. + +The gateway drove the authorization request with the engine's default `response_mode=form_post`, so +the IdP completed a login by returning an auto-submit form that *POSTs* `code`/`state` to the +callback -- a *cross-site* POST. The short-lived browser-binding cookie is `SameSite=Lax`, and *a +Lax cookie is not sent on a cross-site POST*. A real browser therefore arrived at the callback +without the binding cookie and was rejected `403` on the callback's "no browser-binding cookie" +branch. Every login. The fix was to drive `response_mode=query` so the callback is a top-level GET +navigation, on which a Lax cookie *is* sent -- see +link:../architecture.adoc#_oidc_callback_response_mode[Architecture -- OIDC callback response mode] +for the mode, the rejected `SameSite=None` alternative, and the accepted code-in-URL tradeoff. + +[IMPORTANT] +.The durable lesson: a green Java IT suite is not evidence about cookie policy +==== +`BffKeycloakLoginFlow` replays cookies from a `Map` it manages itself. *`SameSite` is a browser +policy, and RestAssured never enforces it* -- the helper records the attribute and sends the cookie +regardless. The suite was therefore structurally incapable of observing this defect, and it was +green throughout. It was not a weak test; it was a test of something else. + +Generalise it before adding the next control: *any control whose enforcement lives in the browser -- +`SameSite`, `Secure`, the `__Host-` prefix, CORS, `Referrer-Policy`, CSP -- cannot be proven by a +programmatic HTTP client, however faithful.* Only a real browser applies the policy. When you add or +change one, add the assertion to this Playwright suite; a green `Bff*IT` run tells you nothing about +it. That limitation is now recorded on `BffKeycloakLoginFlow` itself, so the next reader meets it at +the helper rather than in this document. +==== + +== Module Layout + +`demo-client/` is a Maven module with `pom` and no Java. Every Java plugin is +skipped by property; the module's real toolchain is npm, driven by `frontend-maven-plugin`. + +[cols="2,3"] +|=== +| Path | What it is + +| `pom.xml` +| The module descriptor. Skips every Java plugin, pins the Node/npm versions, and confines *all* + npm-touching plugin executions to the `e2e-demo` profile (see <<_the_opt_in_mechanism>>). + +| `package.json` +| `private`, `type: module`. Declares the approved devDependency set and every script the profile + drives -- `lint:strict`, `format`, `format:check`, `install-browser` and `test`. The lifecycle + invokes each through `npm run`, never `npx`. + +| `eslint.config.js` +| Flat config covering both source trees -- `browser` globals for the SPA, `node` globals for the + suite. + +| `src/main/resources/spa/` +| *The SPA*: `index.html`, `app.js`, `app.css`, `landing.html`. No framework, no bundler, no build + step -- the served files are the authored files. + +| `playwright.config.js` +| Two projects, `session-server` and `session-cookie`, differing only in `baseURL`. + +| `tests/`, `fixtures/`, `utils/` +| The suite: four spec files, the shared fixtures, and the Keycloak login helper. + +| `scripts/` +| `start-dev-environment.sh` / `stop-dev-environment.sh` -- the trimmed three-container bring-up. + +| `target/` +| Everything generated: the downloaded Node toolchain, test results, and the screenshot set. Already + covered by the root `.gitignore`'s `**/target` pattern. +|=== + +The SPA lives under `src/main/resources/spa/` rather than at the module root because that is the +directory the compose stack bind-mounts, and because it keeps the authored web assets on the +Maven-standard source path even though no Maven plugin processes them. + +== Why the Gateway Serves the SPA + +The SPA is served *by the gateway itself*, through the `type: asset` / `access: public` terminal +action decided in link:../adr/0014-asset-serving-terminal-action.adoc[ADR-0014]. It is not served by +a side-car static-file container, and that is the load-bearing decision in the whole module. + +*Same-origin is what makes the contract observable.* The gateway's reserved paths are exact-match +paths on the OIDC host -- `/auth/login`, `/auth/callback`, `/auth/userinfo`, `/auth/logout`, +`/auth/logout/return`. The session cookie is `HttpOnly` and scoped to that origin. A SPA served from +any other origin would have to reach those paths cross-origin, which changes what is being tested: +cookies stop riding along on a plain `fetch`, CSRF enters the picture as a separate configuration +exercise, and the top-level login navigation crosses an origin boundary the real deployment shape +does not have. Serving the SPA from the gateway puts the demo in the *same* origin as the reserved +paths, so what the suite asserts is the deployed contract rather than a cross-origin approximation +of it. + +Two consequences follow, and both are landed behaviour rather than demo choices: + +* The gateway owns the response envelope. It sets the `Content-Type` from the file extension, adds + `X-Content-Type-Options: nosniff`, and strips any `Set-Cookie`. The SPA therefore needs no inline + `