diff --git a/demo-client/README.adoc b/demo-client/README.adoc index f172a5fd..720dbd74 100644 --- a/demo-client/README.adoc +++ b/demo-client/README.adoc @@ -8,8 +8,9 @@ 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. +against *both* session modes, which turns "the two variants are browser-observably identical" into an +executable assertion rather than a claim -- see +link:doc/playwright-suite.adoc#_why_the_demo_exists[Why the Demo Exists]. This module ships no Java and produces no artifact. Nothing here reaches the production image or the native executable. @@ -25,84 +26,63 @@ 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: +both Playwright projects, and teardown on a successful run: [source,bash] ---- -python3 .plan/execute-script.py plan-marshall:build-maven:maven \ - run --command-args "verify -Pe2e-demo -pl demo-client" +./mvnw verify -Pe2e-demo -pl demo-client ---- -When iterating on specs, hold the stack up and re-run only the tests: +A *failing* suite deliberately leaves the containers up: the npm goal aborts the build before Maven +reaches its teardown phase, so the stack you need for diagnosis is still there. Stop it with +`demo-client/scripts/stop-dev-environment.sh`, or with a `clean` build under the same profile +(`./mvnw clean -Pe2e-demo -pl demo-client`) -- the `pre-clean` teardown lives inside `e2e-demo`, so a +plain `clean` does not run it. See +link:doc/playwright-suite.adoc#_running_the_suite_locally[Running the Suite Locally]. + +When iterating on specs, hold the stack up and re-run only the tests. `playwright.config.js` takes +its three addresses from the environment and fails loudly when any is unset; the `e2e-demo` profile +supplies them from `demo-client/pom.xml`, so a standalone run has to export them itself: [source,bash] ---- demo-client/scripts/start-dev-environment.sh + +# Mirrors the demo.baseUrl.server, demo.baseUrl.cookie and demo.keycloak.url properties in +# demo-client/pom.xml, which remain the single source of these addresses. +export PLAYWRIGHT_BASE_URL_SERVER=https://localhost:10443 +export PLAYWRIGHT_BASE_URL_COOKIE=https://localhost:10445 +export KEYCLOAK_HOST_URL=https://localhost:1443 + 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. +`npm run test`, never `npx` -- and no Maven npm execution runs outside `-Pe2e-demo`: a default +reactor build downloads no Node, runs no npm command, starts no container and produces no artifact. +The direct `npm run test` above is the one npm invocation outside the profile, and it is a deliberate +developer opt-in rather than part of any build. Both are load-bearing choices rather than style, and +the reasoning is in link:doc/playwright-suite.adoc#_the_opt_in_mechanism[The Opt-In Mechanism]. 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`. +`integration-user` / `integration-password`. Note the explicit filename -- the gateway serves +link:doc/integration-sample.adoc#_no_directory_index[no directory index]. -| `scripts/` -| The trimmed bring-up and teardown of exactly three containers. -|=== +Everything generated sits under `target/` and is git-ignored. == 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* +* link:doc/integration-sample.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. +* link:doc/playwright-suite.adoc[Demo Client and the Playwright End-to-End Suite] -- the + *contributor* layer: the link:doc/playwright-suite.adoc#_module_layout[module layout], where a run + link:doc/playwright-suite.adoc#_running_the_suite_locally[leaves its results and screenshots], 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/doc/user/demo-client.adoc b/demo-client/doc/integration-sample.adoc similarity index 88% rename from doc/user/demo-client.adoc rename to demo-client/doc/integration-sample.adoc index 5c986360..67414e89 100644 --- a/doc/user/demo-client.adoc +++ b/demo-client/doc/integration-sample.adoc @@ -9,14 +9,14 @@ four identity-disclosure states an application can reach, the redirect rules the your behalf, and the `gateway.yaml` a deployment needs so the gateway serves the application itself. The runnable sample lives in `demo-client/` (see -link:../../demo-client/README.adoc[`demo-client/README.adoc`] for the quickstart). How that module is +link:../README.adoc[`demo-client/README.adoc`] for the quickstart). How that module is built, and why its test suite is shaped as it is, is the contributor concern documented in -link:../development/demo-client.adoc[Demo Client and the Playwright End-to-End Suite]. +link:playwright-suite.adoc[Demo Client and the Playwright End-to-End Suite]. The field-level contract for every key named here is -link:../configuration.adoc[Configuration Reference]; the setup guides for the two session variants are -link:bff-session.adoc[Server-Session BFF -- Operator Setup] and -link:bff-cookie.adoc[Cookie-Based BFF -- Operator Setup]. This document is the *sample*, not a second +link:../../doc/configuration.adoc[Configuration Reference]; the setup guides for the two session variants are +link:../../doc/user/bff-session.adoc[Server-Session BFF -- Operator Setup] and +link:../../doc/user/bff-cookie.adoc[Cookie-Based BFF -- Operator Setup]. This document is the *sample*, not a second copy of either. == The Browser-Facing Contract @@ -80,6 +80,7 @@ what makes the whole contract reachable with a plain same-origin `fetch`. Two properties of this table are worth stating explicitly, because assuming otherwise is the most common way to mis-integrate: +[#_the_401_versus_302_split] * *The `401`-versus-`302` split is PATH-based, not `Accept`-based.* Both reserved endpoints are blind to the request's `Accept` header. `/auth/userinfo` is an XHR probe and answers `401 application/problem+json` without a session -- it never redirects, whatever you send. Do not @@ -104,6 +105,13 @@ The whole leg is navigations the browser performs; your application starts it an Tokens never reach the browser at any point: the gateway holds them, and the application's only credential is the `HttpOnly` session cookie it cannot read. +image::../../doc/resources/diagrams/demo-client-login-flow.svg[Demo-client login flow as the single-page application sees it, align=center] + +The diagram draws only the legs your application participates in. The identity-provider interior -- +the authorization request, the user authenticating, and the gateway's back-channel code exchange -- +is deliberately not redrawn here; it is the same handshake both BFF variants perform, drawn once in +link:../../doc/resources/diagrams/bff-login-sequence.svg[the BFF login handshake diagram]. + [NOTE] .What you must configure at the identity provider ==== @@ -135,7 +143,7 @@ What this means for you as an integrator: *treat access logs and `Referer`-beari callback path as sensitive*, and do not add third-party analytics or error reporting that captures full URLs on `/auth/callback`. Nothing else in your application is affected -- the code never reaches your code. The full statement is in -link:../architecture.adoc#_oidc_callback_response_mode[Architecture -- OIDC callback response mode]. +link:../../doc/architecture.adoc#_oidc_callback_response_mode[Architecture -- OIDC callback response mode]. ==== == The Four Identity-Disclosure States @@ -171,6 +179,8 @@ Against the sample's `allowed_claims: ["sub", "preferred_username", "email", "gr because the operator did not allow it. |=== +image::../../doc/resources/diagrams/demo-client-userinfo-probe.svg[Demo-client userinfo probe and the four identity-disclosure states, align=center] + The last row is the one to internalise. *The operator -- never the browser -- widens disclosure.* A claim outside `allowed_claims` can never be disclosed even when the identity provider issued it, and naming one explicitly is rejected `403` before any disclosure happens. An empty allowlist discloses @@ -219,11 +229,12 @@ with a live session redirects straight to the validated return URL. No new login started and no binding cookie is set. An application may therefore link to `/auth/login` unconditionally without worrying about spurious re-authentication. +[#_serving_your_application_from_the_gateway] == Serving Your Application from the Gateway The sample is served through the asset terminal action -(link:../adr/0014-asset-serving-terminal-action.adoc[ADR-0014]) under a `type: asset` / -`access: public` anchor (link:../adr/0013-anchor-type-access-axes.adoc[ADR-0013]). The route is one +(link:../../doc/adr/0014-asset-serving-terminal-action.adoc[ADR-0014]) under a `type: asset` / +`access: public` anchor (link:../../doc/adr/0013-anchor-type-access-axes.adoc[ADR-0013]). The route is one block: [source,yaml] @@ -249,7 +260,7 @@ Three things a deployment needs, and one it does not: * You do *not* need a new anchor per bundle. Reusing an existing `access: public` asset anchor keeps the anchor set -- which must stay pairwise prefix-disjoint -- unchanged. -[IMPORTANT] +[IMPORTANT#_no_directory_index] ==== *There is no directory index.* A request for `/assets/demo/` without a filename returns `404`. Every entry point must name a concrete file: `/assets/demo/index.html`, not `/assets/demo/`. This applies @@ -327,5 +338,9 @@ The sample runs unchanged against both session variants -- `session.mode: server That is the intended property. An application is written against the reserved-path contract, and the operator chooses the session variant on operational grounds -(link:bff-session.adoc[server-session setup], link:bff-cookie.adoc[cookie setup]) without the +(link:../../doc/user/bff-session.adoc[server-session setup], link:../../doc/user/bff-cookie.adoc[cookie setup]) without the application knowing which was chosen. + +You are not asked to take that on trust: the Playwright suite runs the same specs against both +variants, which is what makes "browser-observably identical" an executable assertion rather than a +claim. link:playwright-suite.adoc#_why_the_demo_exists[Why the Demo Exists] records the mechanism. diff --git a/doc/development/demo-client.adoc b/demo-client/doc/playwright-suite.adoc similarity index 87% rename from doc/development/demo-client.adoc rename to demo-client/doc/playwright-suite.adoc index 85f6de61..27f90aaf 100644 --- a/doc/development/demo-client.adoc +++ b/demo-client/doc/playwright-suite.adoc @@ -11,8 +11,8 @@ 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 +link:integration-sample.adoc[Demo Client -- Integration Sample]. The module's one-screen quickstart +is link:../README.adoc[`demo-client/README.adoc`]. This document does not restate either. [IMPORTANT] @@ -23,6 +23,7 @@ executable, and the asset route that serves it lives in the `integration-tests` configuration -- not in any production example. See <<_the_demo_is_excluded_from_the_production_image>>. ==== +[#_why_the_demo_exists] == Why the Demo Exists The gateway's server-session and cookie-session BFF variants expose a browser-facing contract: @@ -44,6 +45,16 @@ session modes -- `session.mode: server` on port 10443 and `session.mode: cookie` 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. +The logout round-trip is the clearest example of what only a browser can assert. The suite drives the +whole navigation chain and then re-probes the info endpoint, because the `401` -- not the landing page +rendering -- is what proves the session is gone: + +image::../../doc/resources/diagrams/demo-client-logout-flow.svg[Demo-client logout round-trip and the 401 re-probe that proves it, align=center] + +Note where the authoritative step sits: the gateway destroys the local session *before* any +identity-provider leg, so the elided round-trip cannot leave a usable local session behind even when +it fails outright. + [[_why_the_java_it_suite_could_not_catch_it]] === The first gap was not hypothetical -- it hid a real, total login failure @@ -58,7 +69,7 @@ Lax cookie is not sent on a cross-site POST*. A real browser therefore arrived a 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] +link:../../doc/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] @@ -77,6 +88,7 @@ it. That limitation is now recorded on `BffKeycloakLoginFlow` itself, so the nex the helper rather than in this document. ==== +[#_module_layout] == Module Layout `demo-client/` is a Maven module with `pom` and no Java. Every Java plugin is @@ -124,7 +136,7 @@ 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 +action decided in link:../../doc/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 @@ -139,13 +151,16 @@ 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 - `