From 6486eecc429946f1550ad039fdef24974fa38157 Mon Sep 17 00:00:00 2001 From: "j.w.jonkers" Date: Sun, 10 May 2026 22:52:48 +0200 Subject: [PATCH 1/6] infra: drop .dev suffix from local docker-compose files; remove Swarm-era duplicates - docker-compose.dev.yml -> docker-compose.yml (root manifest + api, frontend, stalwart, listmonk service includes). docker compose up is now the canonical command. - Delete the unused services/{api,frontend,listmonk}/docker-compose.yml Swarm-era variants left over from before the k3s+Flux migration; the active dev compose now lives at the .dev-less name. - Update references in dev-setup.sh, scripts/generate_openapi.sh, .github/workflows/ci.yml path filter, README.md, the live-IT Kdocs, and the mariadb release.yaml comment. --- .github/workflows/ci.yml | 3 +- README.md | 4 +- dev-setup.sh | 6 +- docker-compose.dev.yml | 5 - docker-compose.yml | 5 + .../flux/apps/data/mariadb/release.yaml | 4 +- scripts/generate_openapi.sh | 10 +- services/api/docker-compose.dev.yml | 75 ------------- services/api/docker-compose.yml | 60 ++++++---- .../contact/BrevoContactAdapterLiveIT.kt | 2 +- .../contact/ListmonkContactAdapterLiveIT.kt | 2 +- services/frontend/docker-compose.dev.yml | 38 ------- services/frontend/docker-compose.yml | 36 ++++-- services/listmonk/docker-compose.dev.yml | 106 ------------------ services/listmonk/docker-compose.yml | 73 ++++++------ ...ker-compose.dev.yml => docker-compose.yml} | 0 16 files changed, 126 insertions(+), 303 deletions(-) delete mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml delete mode 100644 services/api/docker-compose.dev.yml delete mode 100644 services/frontend/docker-compose.dev.yml delete mode 100644 services/listmonk/docker-compose.dev.yml rename services/stalwart/{docker-compose.dev.yml => docker-compose.yml} (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ab4567fe..218a11e8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,8 @@ on: - 'platform/**' - '.github/actions/**' - '.github/workflows/**' - - 'docker-compose.dev.yml' + - 'docker-compose.yml' + - 'services/*/docker-compose.yml' workflow_dispatch: concurrency: diff --git a/README.md b/README.md index eef370127..e7adc862c 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Domain-Driven Design with a clean layered architecture: ### Start the dev environment ```bash -docker compose -f docker-compose.dev.yml up -d +docker compose up -d ``` This starts: @@ -55,7 +55,7 @@ This starts: ### Environment files -The dev compose files include sensible defaults. For production-like secrets, +The compose files include sensible defaults. For production-like secrets, copy the examples: ```bash diff --git a/dev-setup.sh b/dev-setup.sh index 4e7e4ebcf..8c48f0466 100755 --- a/dev-setup.sh +++ b/dev-setup.sh @@ -12,7 +12,7 @@ # (required by docker-mailserver with SSL_TYPE=self-signed) # # After running this script: -# docker compose -f docker-compose.dev.yml up +# docker compose up # ============================================================================= set -euo pipefail @@ -79,7 +79,7 @@ fi # ── services/listmonk/.listmonk.env ─────────────────────────────────────────── LISTMONK_ENV="${SCRIPT_DIR}/services/listmonk/.listmonk.env" if [[ ! -f "${LISTMONK_ENV}" ]]; then - # Credentials match the hardcoded values in services/listmonk/docker-compose.dev.yml + # Credentials match the hardcoded values in services/listmonk/docker-compose.yml # so Listmonk and the API agree without extra config. cat > "${LISTMONK_ENV}" <<'EOF' LISTMONK_DB_PASSWORD=listmonk @@ -175,5 +175,5 @@ fi echo "" echo "==> Done. Start the dev environment with:" echo "" -echo " docker compose -f docker-compose.dev.yml up" +echo " docker compose up" echo "" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index 221655819..000000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -1,5 +0,0 @@ -include: - - services/api/docker-compose.dev.yml - - services/frontend/docker-compose.dev.yml - - services/stalwart/docker-compose.dev.yml - - services/listmonk/docker-compose.dev.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..1994dc453 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,5 @@ +include: + - services/api/docker-compose.yml + - services/frontend/docker-compose.yml + - services/stalwart/docker-compose.yml + - services/listmonk/docker-compose.yml diff --git a/platform/cluster/flux/apps/data/mariadb/release.yaml b/platform/cluster/flux/apps/data/mariadb/release.yaml index 63e666bda..b27619223 100644 --- a/platform/cluster/flux/apps/data/mariadb/release.yaml +++ b/platform/cluster/flux/apps/data/mariadb/release.yaml @@ -33,8 +33,8 @@ spec: image: repository: bitnamilegacy/mariadb # Pin MariaDB 10.11 LTS to match the old VPS and the rest of the - # codebase (docker-compose.yml, CI workflows, services/api/docker- - # compose.dev.yml — all pinned to mariadb:10.11.10). Bitnami chart + # codebase (services/api/docker-compose.yml + CI workflows — all + # pinned to mariadb:10.11.10). Bitnami chart # 18.x defaults to MariaDB 11.x, which tightened a number of DDL # rules (e.g. refusing `MODIFY COLUMN` on a column referenced by a # foreign key — ERROR 1832) that our Flyway V28 migration diff --git a/scripts/generate_openapi.sh b/scripts/generate_openapi.sh index 7963ddd55..41ccd980e 100755 --- a/scripts/generate_openapi.sh +++ b/scripts/generate_openapi.sh @@ -21,15 +21,15 @@ if ! docker compose version >/dev/null 2>&1; then exit 1 fi -if [ ! -f "docker-compose.dev.yml" ]; then - echo "docker-compose.dev.yml not found in current directory" >&2 +if [ ! -f "docker-compose.yml" ]; then + echo "docker-compose.yml not found in current directory" >&2 exit 1 fi # ---- Fetch Blueshell spec from running API container ---- echo "Fetching Blueshell OpenAPI spec from API container..." -docker compose -f docker-compose.dev.yml exec -T api sh -c \ +docker compose exec -T api sh -c \ "curl -fsSS http://localhost:8080/v3/api-docs" > "${API_OPENAPI_SPEC%.yaml}.raw.json" # ---- Shared steps ---- @@ -46,8 +46,8 @@ echo "Generating frontend TypeScript clients..." # but forces a recreate when mounts or env have drifted (e.g. after # the openapi.yaml → openapi.json rename). Without this, `exec` runs # against a stale container that still has the old bind-mounts. -docker compose -f docker-compose.dev.yml up -d frontend -docker compose -f docker-compose.dev.yml exec frontend sh -c \ +docker compose up -d frontend +docker compose exec frontend sh -c \ "cd /usr/app && yarn gen:all && (yarn lint:gen || true)" echo "OpenAPI spec and frontend clients generated successfully." diff --git a/services/api/docker-compose.dev.yml b/services/api/docker-compose.dev.yml deleted file mode 100644 index 49fbd85f8..000000000 --- a/services/api/docker-compose.dev.yml +++ /dev/null @@ -1,75 +0,0 @@ -services: - db: - image: mariadb:10.11.10 - container_name: db - ports: - - "3307:3306" - volumes: - - data:/var/lib/mysql - - "./0_init.sql:/docker-entrypoint-initdb.d/0_init.sql" - - "./1_dump.sql:/docker-entrypoint-initdb.d/1_dump.sql" - networks: - - global_network - restart: unless-stopped - env_file: - - .db.env - environment: - - TZ=Europe/Amsterdam - healthcheck: - test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ] - interval: 30s - timeout: 10s - retries: 5 - start_period: 30s - - api: - image: api:dev - pull_policy: never - build: - context: ../.. - dockerfile: services/api/Dockerfile-dev - volumes: - # Mount the whole repo into /src so hot-reload sees api + build-logic + libs edits. - - ../..:/src - - ../../storage:/home/storage - - gradle-cache:/root/.gradle/caches - - playwright-browsers:/ms-playwright - - playwright-browsers:/root/.cache/ms-playwright - - listmonk-secrets:/run/secrets/listmonk:ro - ports: - - "8080:8080" - - "5005:5005" - networks: - - global_network - env_file: - - .api.env - environment: - - TZ=Europe/Amsterdam - - SPRING_PROFILES_ACTIVE=dev - - APP_URL=http://localhost:8080 - - FRONTEND_URL=http://localhost:3000 - - PLAYWRIGHT_BROWSERS_PATH=/ms-playwright - - LISTMONK_FROM_ADDRESS=no-reply@localhost - - LISTMONK_REPLY_TO_ADDRESS=dev@localhost - - LISTMONK_API_USERNAME=${LISTMONK_ADMIN_USERNAME:-listmonk} - - LISTMONK_API_PASSWORD=${LISTMONK_ADMIN_PASSWORD:-listmonk} - depends_on: - db: - condition: service_healthy - listmonk-setup: - condition: service_completed_successfully - healthcheck: - test: [ "CMD", "curl", "http://localhost:8080/health" ] - interval: 30s - timeout: 10s - retries: 20 - start_period: 30s - -networks: - global_network: - driver: bridge - -volumes: - data: - gradle-cache: - playwright-browsers: diff --git a/services/api/docker-compose.yml b/services/api/docker-compose.yml index 448f26d81..49fbd85f8 100644 --- a/services/api/docker-compose.yml +++ b/services/api/docker-compose.yml @@ -1,14 +1,20 @@ services: db: image: mariadb:10.11.10 - networks: - - database + container_name: db + ports: + - "3307:3306" volumes: - - db_data:/var/lib/mysql + - data:/var/lib/mysql - "./0_init.sql:/docker-entrypoint-initdb.d/0_init.sql" - "./1_dump.sql:/docker-entrypoint-initdb.d/1_dump.sql" + networks: + - global_network + restart: unless-stopped env_file: - .db.env + environment: + - TZ=Europe/Amsterdam healthcheck: test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ] interval: 30s @@ -17,45 +23,53 @@ services: start_period: 30s api: - image: ghcr.io/esa-blueshell/api:${IMAGE_TAG:-latest} - pull_policy: always + image: api:dev + pull_policy: never build: context: ../.. - dockerfile: services/api/Dockerfile - # API image is a multi-stage build that compiles every module in the - # monorepo that :services:api depends on, so the whole repo is the - # context. .dockerignore keeps frontend/node_modules etc. out. - restart: unless-stopped + dockerfile: services/api/Dockerfile-dev + volumes: + # Mount the whole repo into /src so hot-reload sees api + build-logic + libs edits. + - ../..:/src + - ../../storage:/home/storage + - gradle-cache:/root/.gradle/caches + - playwright-browsers:/ms-playwright + - playwright-browsers:/root/.cache/ms-playwright + - listmonk-secrets:/run/secrets/listmonk:ro + ports: + - "8080:8080" + - "5005:5005" + networks: + - global_network env_file: - .api.env environment: - TZ=Europe/Amsterdam + - SPRING_PROFILES_ACTIVE=dev - APP_URL=http://localhost:8080 - FRONTEND_URL=http://localhost:3000 - # Listmonk credentials — must be set; no defaults to avoid silent misconfiguration - - LISTMONK_API_USERNAME=${LISTMONK_ADMIN_USERNAME:?LISTMONK_ADMIN_USERNAME must be set in .listmonk.env} - - LISTMONK_API_PASSWORD=${LISTMONK_ADMIN_PASSWORD:?LISTMONK_ADMIN_PASSWORD must be set in .listmonk.env} - volumes: - - "../../storage:/home/storage" - - "listmonk-secrets:/run/secrets/listmonk:ro" + - PLAYWRIGHT_BROWSERS_PATH=/ms-playwright + - LISTMONK_FROM_ADDRESS=no-reply@localhost + - LISTMONK_REPLY_TO_ADDRESS=dev@localhost + - LISTMONK_API_USERNAME=${LISTMONK_ADMIN_USERNAME:-listmonk} + - LISTMONK_API_PASSWORD=${LISTMONK_ADMIN_PASSWORD:-listmonk} depends_on: db: condition: service_healthy listmonk-setup: condition: service_completed_successfully - networks: - - frontback - - database healthcheck: - test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health" ] + test: [ "CMD", "curl", "http://localhost:8080/health" ] interval: 30s timeout: 10s retries: 20 start_period: 30s networks: - frontback: - database: + global_network: + driver: bridge volumes: - db_data: + data: + gradle-cache: + playwright-browsers: diff --git a/services/api/src/integrationTest/kotlin/net/blueshell/api/platform/integration/contact/BrevoContactAdapterLiveIT.kt b/services/api/src/integrationTest/kotlin/net/blueshell/api/platform/integration/contact/BrevoContactAdapterLiveIT.kt index 7c52dbbce..8ab2b12ac 100644 --- a/services/api/src/integrationTest/kotlin/net/blueshell/api/platform/integration/contact/BrevoContactAdapterLiveIT.kt +++ b/services/api/src/integrationTest/kotlin/net/blueshell/api/platform/integration/contact/BrevoContactAdapterLiveIT.kt @@ -20,7 +20,7 @@ import org.springframework.test.context.ActiveProfiles * Brevo credentials are resolved by Spring from the environment (BREVO_API_KEY etc.) * exactly as in production. Run via Docker where the env file is loaded: * - * docker compose -f docker-compose.dev.yml run api \ + * docker compose run api \ * ./gradlew :api:test --tests "*.BrevoContactAdapterLiveIT" * * Tests are ordered: each builds on the state left by the previous one. diff --git a/services/api/src/integrationTest/kotlin/net/blueshell/api/platform/integration/contact/ListmonkContactAdapterLiveIT.kt b/services/api/src/integrationTest/kotlin/net/blueshell/api/platform/integration/contact/ListmonkContactAdapterLiveIT.kt index e6a911f1f..4a3b18de4 100644 --- a/services/api/src/integrationTest/kotlin/net/blueshell/api/platform/integration/contact/ListmonkContactAdapterLiveIT.kt +++ b/services/api/src/integrationTest/kotlin/net/blueshell/api/platform/integration/contact/ListmonkContactAdapterLiveIT.kt @@ -20,7 +20,7 @@ import org.springframework.test.context.ActiveProfiles * Listmonk credentials are resolved by Spring from the environment (LISTMONK_* vars) * exactly as in production. Run via Docker where the env file is loaded: * - * docker compose -f docker-compose.dev.yml run api \ + * docker compose run api \ * ./gradlew :api:test --tests "*.ListmonkContactAdapterLiveIT" * * Tests are ordered: each builds on the state left by the previous one. diff --git a/services/frontend/docker-compose.dev.yml b/services/frontend/docker-compose.dev.yml deleted file mode 100644 index 9ff096e24..000000000 --- a/services/frontend/docker-compose.dev.yml +++ /dev/null @@ -1,38 +0,0 @@ -services: - frontend: - image: frontend:dev - pull_policy: never - build: - context: . - dockerfile: Dockerfile-dev - expose: - - 3000 - ports: - - "3000:3000" - volumes: - - .:/usr/app - - ../api/openapi.json:/usr/api/openapi.json:ro - # Mount at /libs/openapi-specs so the repo-relative path - # `../../libs/openapi-specs/` (from /usr/app) resolves correctly - # when the openapi-ts generators run inside the container. - - ../../libs/openapi-specs:/libs/openapi-specs:ro - - /usr/app/.yarn - networks: - - global_network - environment: - - TZ=Europe/Amsterdam - - VITE_APP_URL=http://localhost:8080 - - VITE_COVERAGE=true - depends_on: - api: - condition: service_healthy - healthcheck: - test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000" ] - interval: 30s - timeout: 10s - retries: 5 - start_period: 30s - -networks: - global_network: - driver: bridge diff --git a/services/frontend/docker-compose.yml b/services/frontend/docker-compose.yml index ac1ac8360..9ff096e24 100644 --- a/services/frontend/docker-compose.yml +++ b/services/frontend/docker-compose.yml @@ -1,18 +1,38 @@ services: frontend: - image: ghcr.io/esa-blueshell/frontend:${IMAGE_TAG:-latest} - pull_policy: always + image: frontend:dev + pull_policy: never build: context: . - dockerfile: Dockerfile - depends_on: - api: - condition: service_healthy + dockerfile: Dockerfile-dev + expose: + - 3000 + ports: + - "3000:3000" + volumes: + - .:/usr/app + - ../api/openapi.json:/usr/api/openapi.json:ro + # Mount at /libs/openapi-specs so the repo-relative path + # `../../libs/openapi-specs/` (from /usr/app) resolves correctly + # when the openapi-ts generators run inside the container. + - ../../libs/openapi-specs:/libs/openapi-specs:ro + - /usr/app/.yarn networks: - - frontback + - global_network environment: - TZ=Europe/Amsterdam - VITE_APP_URL=http://localhost:8080 + - VITE_COVERAGE=true + depends_on: + api: + condition: service_healthy + healthcheck: + test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000" ] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s networks: - frontback: + global_network: + driver: bridge diff --git a/services/listmonk/docker-compose.dev.yml b/services/listmonk/docker-compose.dev.yml deleted file mode 100644 index 5b9a82155..000000000 --- a/services/listmonk/docker-compose.dev.yml +++ /dev/null @@ -1,106 +0,0 @@ -services: - listmonk: - image: listmonk/listmonk:v4.1.0 - ports: - - "9000:9000" - depends_on: - listmonk-db: - condition: service_healthy - environment: - LISTMONK_app__address: "0.0.0.0:9000" - LISTMONK_db__host: listmonk-db - LISTMONK_db__port: 5432 - LISTMONK_db__user: listmonk - LISTMONK_db__password: listmonk - LISTMONK_db__database: listmonk - LISTMONK_db__ssl_mode: disable - # LISTMONK_ADMIN_USER/PASSWORD are read by the binary during --install to create the initial admin. - # They must match LISTMONK_ADMIN_USERNAME/PASSWORD used by listmonk-setup and the api service. - LISTMONK_ADMIN_USER: ${LISTMONK_ADMIN_USERNAME:-listmonk} - LISTMONK_ADMIN_PASSWORD: ${LISTMONK_ADMIN_PASSWORD:-listmonk} - command: >- - sh -c " - ./listmonk --install --idempotent --yes && - ./listmonk --upgrade --yes && - exec ./listmonk - " - networks: - - global_network - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9000/health"] - interval: 30s - timeout: 10s - retries: 5 - start_period: 30s - - listmonk-db: - image: postgres:17-alpine - environment: - POSTGRES_USER: listmonk - POSTGRES_PASSWORD: listmonk - POSTGRES_DB: listmonk - ports: - - "5432:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U listmonk"] - interval: 10s - timeout: 5s - retries: 5 - volumes: - - listmonk-data:/var/lib/postgresql/data - networks: - - global_network - - listmonk-setup: - image: python:3.12-alpine - restart: "no" - depends_on: - listmonk: - condition: service_healthy - stalwart: - condition: service_healthy - env_file: - - path: .listmonk.env - required: false - environment: - LISTMONK_URL: "http://listmonk:9000" - LISTMONK_ADMIN_USERNAME: ${LISTMONK_ADMIN_USERNAME:-listmonk} - LISTMONK_ADMIN_PASSWORD: ${LISTMONK_ADMIN_PASSWORD:-listmonk} - LISTMONK_ADMIN_EMAIL: ${LISTMONK_ADMIN_EMAIL:-admin@listmonk.local} - LISTMONK_ADMIN_API_USER: ${LISTMONK_ADMIN_API_USER:-api} - LISTMONK_API_TOKEN_FILE: /secrets/api-token.env - # SMTP — routed through the dev Stalwart server (services/stalwart) - LISTMONK_SMTP_HOST: ${LISTMONK_SMTP_HOST:-stalwart} - LISTMONK_SMTP_PORT: ${LISTMONK_SMTP_PORT:-25} - LISTMONK_SMTP_AUTH_PROTOCOL: ${LISTMONK_SMTP_AUTH_PROTOCOL:-login} - LISTMONK_SMTP_USERNAME: ${LISTMONK_SMTP_USERNAME:-bounce@dev.local} - LISTMONK_SMTP_PASSWORD: ${LISTMONK_SMTP_PASSWORD:-bounce} - LISTMONK_SMTP_HELLO_HOSTNAME: ${LISTMONK_SMTP_HELLO_HOSTNAME:-mail.dev.local} - LISTMONK_SMTP_TLS_TYPE: ${LISTMONK_SMTP_TLS_TYPE:-none} - LISTMONK_SMTP_TLS_SKIP_VERIFY: ${LISTMONK_SMTP_TLS_SKIP_VERIFY:-true} - # Bounce IMAP — Stalwart serves IMAP on 143 for the dev bounce mailbox - LISTMONK_BOUNCE_MAILBOX_ENABLED: ${LISTMONK_BOUNCE_MAILBOX_ENABLED:-true} - LISTMONK_BOUNCE_MAILBOX_HOST: ${LISTMONK_BOUNCE_MAILBOX_HOST:-stalwart} - LISTMONK_BOUNCE_MAILBOX_PORT: ${LISTMONK_BOUNCE_MAILBOX_PORT:-143} - LISTMONK_BOUNCE_MAILBOX_USERNAME: ${LISTMONK_BOUNCE_MAILBOX_USERNAME:-bounce@dev.local} - LISTMONK_BOUNCE_MAILBOX_PASSWORD: ${LISTMONK_BOUNCE_MAILBOX_PASSWORD:-bounce} - LISTMONK_BOUNCE_MAILBOX_TLS_ENABLED: ${LISTMONK_BOUNCE_MAILBOX_TLS_ENABLED:-false} - LISTMONK_BOUNCE_MAILBOX_TLS_SKIP_VERIFY: ${LISTMONK_BOUNCE_MAILBOX_TLS_SKIP_VERIFY:-true} - LISTMONK_BOUNCE_MAILBOX_FOLDER: ${LISTMONK_BOUNCE_MAILBOX_FOLDER:-INBOX} - LISTMONK_BOUNCE_MAILBOX_RETURN_PATH: ${LISTMONK_BOUNCE_MAILBOX_RETURN_PATH:-bounce@dev.local} - LISTMONK_BOUNCE_MAILBOX_SCAN_INTERVAL: ${LISTMONK_BOUNCE_MAILBOX_SCAN_INTERVAL:-10m} - volumes: - - ./setup.py:/setup.py:ro - - ./custom/theme.css:/theme.css:ro - - listmonk-secrets:/secrets - command: ["python", "/setup.py"] - networks: - - global_network - -networks: - global_network: - driver: bridge - -volumes: - listmonk-data: - listmonk-secrets: \ No newline at end of file diff --git a/services/listmonk/docker-compose.yml b/services/listmonk/docker-compose.yml index 466179db8..5b9a82155 100644 --- a/services/listmonk/docker-compose.yml +++ b/services/listmonk/docker-compose.yml @@ -1,7 +1,8 @@ services: listmonk: image: listmonk/listmonk:v4.1.0 - restart: unless-stopped + ports: + - "9000:9000" depends_on: listmonk-db: condition: service_healthy @@ -10,13 +11,13 @@ services: LISTMONK_db__host: listmonk-db LISTMONK_db__port: 5432 LISTMONK_db__user: listmonk - LISTMONK_db__password: ${LISTMONK_DB_PASSWORD:?LISTMONK_DB_PASSWORD required} + LISTMONK_db__password: listmonk LISTMONK_db__database: listmonk LISTMONK_db__ssl_mode: disable # LISTMONK_ADMIN_USER/PASSWORD are read by the binary during --install to create the initial admin. # They must match LISTMONK_ADMIN_USERNAME/PASSWORD used by listmonk-setup and the api service. - LISTMONK_ADMIN_USER: ${LISTMONK_ADMIN_USERNAME:?LISTMONK_ADMIN_USERNAME required} - LISTMONK_ADMIN_PASSWORD: ${LISTMONK_ADMIN_PASSWORD:?LISTMONK_ADMIN_PASSWORD required} + LISTMONK_ADMIN_USER: ${LISTMONK_ADMIN_USERNAME:-listmonk} + LISTMONK_ADMIN_PASSWORD: ${LISTMONK_ADMIN_PASSWORD:-listmonk} command: >- sh -c " ./listmonk --install --idempotent --yes && @@ -24,8 +25,7 @@ services: exec ./listmonk " networks: - - frontback - - database + - global_network healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9000/health"] interval: 30s @@ -35,11 +35,12 @@ services: listmonk-db: image: postgres:17-alpine - restart: unless-stopped environment: POSTGRES_USER: listmonk - POSTGRES_PASSWORD: ${LISTMONK_DB_PASSWORD:?LISTMONK_DB_PASSWORD required} + POSTGRES_PASSWORD: listmonk POSTGRES_DB: listmonk + ports: + - "5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U listmonk"] interval: 10s @@ -48,7 +49,7 @@ services: volumes: - listmonk-data:/var/lib/postgresql/data networks: - - database + - global_network listmonk-setup: image: python:3.12-alpine @@ -56,31 +57,37 @@ services: depends_on: listmonk: condition: service_healthy + stalwart: + condition: service_healthy + env_file: + - path: .listmonk.env + required: false environment: LISTMONK_URL: "http://listmonk:9000" - LISTMONK_ADMIN_USERNAME: ${LISTMONK_ADMIN_USERNAME:?LISTMONK_ADMIN_USERNAME required} - LISTMONK_ADMIN_PASSWORD: ${LISTMONK_ADMIN_PASSWORD:?LISTMONK_ADMIN_PASSWORD required} - LISTMONK_ADMIN_EMAIL: ${LISTMONK_ADMIN_EMAIL:?LISTMONK_ADMIN_EMAIL required} + LISTMONK_ADMIN_USERNAME: ${LISTMONK_ADMIN_USERNAME:-listmonk} + LISTMONK_ADMIN_PASSWORD: ${LISTMONK_ADMIN_PASSWORD:-listmonk} + LISTMONK_ADMIN_EMAIL: ${LISTMONK_ADMIN_EMAIL:-admin@listmonk.local} LISTMONK_ADMIN_API_USER: ${LISTMONK_ADMIN_API_USER:-api} LISTMONK_API_TOKEN_FILE: /secrets/api-token.env - # SMTP — set LISTMONK_SMTP_HOST to enable outbound mail configuration - LISTMONK_SMTP_HOST: ${LISTMONK_SMTP_HOST:-} - LISTMONK_SMTP_PORT: ${LISTMONK_SMTP_PORT:-587} - LISTMONK_SMTP_AUTH_PROTOCOL: ${LISTMONK_SMTP_AUTH_PROTOCOL:-plain} - LISTMONK_SMTP_USERNAME: ${LISTMONK_SMTP_USERNAME:-} - LISTMONK_SMTP_PASSWORD: ${LISTMONK_SMTP_PASSWORD:-} - LISTMONK_SMTP_HELLO_HOSTNAME: ${LISTMONK_SMTP_HELLO_HOSTNAME:-} - LISTMONK_SMTP_TLS_TYPE: ${LISTMONK_SMTP_TLS_TYPE:-starttls} - LISTMONK_SMTP_TLS_SKIP_VERIFY: ${LISTMONK_SMTP_TLS_SKIP_VERIFY:-false} - LISTMONK_BOUNCE_MAILBOX_ENABLED: ${LISTMONK_BOUNCE_MAILBOX_ENABLED:-false} - LISTMONK_BOUNCE_MAILBOX_HOST: ${LISTMONK_BOUNCE_MAILBOX_HOST:-} - LISTMONK_BOUNCE_MAILBOX_PORT: ${LISTMONK_BOUNCE_MAILBOX_PORT:-993} - LISTMONK_BOUNCE_MAILBOX_USERNAME: ${LISTMONK_BOUNCE_MAILBOX_USERNAME:-} - LISTMONK_BOUNCE_MAILBOX_PASSWORD: ${LISTMONK_BOUNCE_MAILBOX_PASSWORD:-} - LISTMONK_BOUNCE_MAILBOX_TLS_ENABLED: ${LISTMONK_BOUNCE_MAILBOX_TLS_ENABLED:-true} - LISTMONK_BOUNCE_MAILBOX_TLS_SKIP_VERIFY: ${LISTMONK_BOUNCE_MAILBOX_TLS_SKIP_VERIFY:-false} + # SMTP — routed through the dev Stalwart server (services/stalwart) + LISTMONK_SMTP_HOST: ${LISTMONK_SMTP_HOST:-stalwart} + LISTMONK_SMTP_PORT: ${LISTMONK_SMTP_PORT:-25} + LISTMONK_SMTP_AUTH_PROTOCOL: ${LISTMONK_SMTP_AUTH_PROTOCOL:-login} + LISTMONK_SMTP_USERNAME: ${LISTMONK_SMTP_USERNAME:-bounce@dev.local} + LISTMONK_SMTP_PASSWORD: ${LISTMONK_SMTP_PASSWORD:-bounce} + LISTMONK_SMTP_HELLO_HOSTNAME: ${LISTMONK_SMTP_HELLO_HOSTNAME:-mail.dev.local} + LISTMONK_SMTP_TLS_TYPE: ${LISTMONK_SMTP_TLS_TYPE:-none} + LISTMONK_SMTP_TLS_SKIP_VERIFY: ${LISTMONK_SMTP_TLS_SKIP_VERIFY:-true} + # Bounce IMAP — Stalwart serves IMAP on 143 for the dev bounce mailbox + LISTMONK_BOUNCE_MAILBOX_ENABLED: ${LISTMONK_BOUNCE_MAILBOX_ENABLED:-true} + LISTMONK_BOUNCE_MAILBOX_HOST: ${LISTMONK_BOUNCE_MAILBOX_HOST:-stalwart} + LISTMONK_BOUNCE_MAILBOX_PORT: ${LISTMONK_BOUNCE_MAILBOX_PORT:-143} + LISTMONK_BOUNCE_MAILBOX_USERNAME: ${LISTMONK_BOUNCE_MAILBOX_USERNAME:-bounce@dev.local} + LISTMONK_BOUNCE_MAILBOX_PASSWORD: ${LISTMONK_BOUNCE_MAILBOX_PASSWORD:-bounce} + LISTMONK_BOUNCE_MAILBOX_TLS_ENABLED: ${LISTMONK_BOUNCE_MAILBOX_TLS_ENABLED:-false} + LISTMONK_BOUNCE_MAILBOX_TLS_SKIP_VERIFY: ${LISTMONK_BOUNCE_MAILBOX_TLS_SKIP_VERIFY:-true} LISTMONK_BOUNCE_MAILBOX_FOLDER: ${LISTMONK_BOUNCE_MAILBOX_FOLDER:-INBOX} - LISTMONK_BOUNCE_MAILBOX_RETURN_PATH: ${LISTMONK_BOUNCE_MAILBOX_RETURN_PATH:-} + LISTMONK_BOUNCE_MAILBOX_RETURN_PATH: ${LISTMONK_BOUNCE_MAILBOX_RETURN_PATH:-bounce@dev.local} LISTMONK_BOUNCE_MAILBOX_SCAN_INTERVAL: ${LISTMONK_BOUNCE_MAILBOX_SCAN_INTERVAL:-10m} volumes: - ./setup.py:/setup.py:ro @@ -88,12 +95,12 @@ services: - listmonk-secrets:/secrets command: ["python", "/setup.py"] networks: - - frontback + - global_network networks: - frontback: - database: + global_network: + driver: bridge volumes: listmonk-data: - listmonk-secrets: + listmonk-secrets: \ No newline at end of file diff --git a/services/stalwart/docker-compose.dev.yml b/services/stalwart/docker-compose.yml similarity index 100% rename from services/stalwart/docker-compose.dev.yml rename to services/stalwart/docker-compose.yml From fcb742f93333f6cf8f5dfda5dc7cdd42b6a3984c Mon Sep 17 00:00:00 2001 From: "j.w.jonkers" Date: Mon, 11 May 2026 09:07:01 +0200 Subject: [PATCH 2/6] tests: add OIDC/SSO system-test coverage for SAS, downstream clients, forward-auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the OIDC test patterns proven in personal-stack into :services:system-tests as a new oidc/ package alongside the existing frontend/ Playwright suite. All run via the embedded @SpringBootTest on :8080 — no docker-compose dependency at CI time. New tests: - JwksDiscoverySystemTest: /.well-known/oauth-authorization-server advertises authorize/token/jwks; /oauth2/jwks publishes RSA signing keys with kid; discovery jwks_uri path matches the in-process endpoint. - AuthorizeRedirectSystemTest: parametrized over both registered clients (headlamp, vault). Anonymous → 302 to /login with the original /oauth2/authorize URL preserved as redirect=. Member → 403 from the downstream-client gating filter (the admin-only client guard in AuthorizationServerConfig). Admin → 302 to the configured redirect_uri with code= and state= round-tripped. - OidcTokenClaimsSystemTest: full PKCE code-grant for headlamp. Exchanges code at /oauth2/token, decodes both ID + access tokens, asserts OidcTokenCustomizer's claim shape end-to-end (sub, aud, roles, username, email; ID-token groups maps ADMIN → k8s-admin + member). Vault grant omitted — its CLIENT_SECRET_BASIC config is empty in the test profile and out of scope here; vault authorize is still covered above. - ForwardAuthChainSystemTest: lifts ForwardAuthControllerIT to the system-test layer over real HTTP. Parametrized across every host in HOST_ROLE (vault, headlamp, traefik = ADMIN; listmonk, stalwart = BOARD). Asserts anonymous → /login, member → 302 /unauthorized?service=, admin → 200 + X-User-Id/X-User-Groups, board pass-through on board-gated hosts and 302 on admin-gated hosts, plus the unknown-host ADMIN fallback. - OidcAuthorizePlaywrightTest: drives /oauth2/authorize through Playwright's APIRequest (matching personal-stack's RabbitMqOidcPlaywrightTest pattern). Parametrized over both clients. APIRequest rather than Page navigation because the configured redirect_uri is a production hostname — APIRequest stops at the 302, Page would resolve DNS and hang. Plus OidcSystemTestBase (slim @SpringBootTest base with a no-follow HttpClient and bearer-token helpers) and OidcTestHelper (PKCE S256 + JWT payload decode + form-encode + queryParam). --- .../oidc/AuthorizeRedirectSystemTest.kt | 126 +++++++++++++++ .../system/oidc/ForwardAuthChainSystemTest.kt | 143 ++++++++++++++++++ .../system/oidc/JwksDiscoverySystemTest.kt | 67 ++++++++ .../api/system/oidc/OidcSystemTestBase.kt | 74 +++++++++ .../api/system/oidc/OidcTestHelper.kt | 70 +++++++++ .../system/oidc/OidcTokenClaimsSystemTest.kt | 141 +++++++++++++++++ .../playwright/OidcAuthorizePlaywrightTest.kt | 136 +++++++++++++++++ 7 files changed, 757 insertions(+) create mode 100644 services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/AuthorizeRedirectSystemTest.kt create mode 100644 services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/ForwardAuthChainSystemTest.kt create mode 100644 services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/JwksDiscoverySystemTest.kt create mode 100644 services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcSystemTestBase.kt create mode 100644 services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTestHelper.kt create mode 100644 services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTokenClaimsSystemTest.kt create mode 100644 services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/playwright/OidcAuthorizePlaywrightTest.kt diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/AuthorizeRedirectSystemTest.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/AuthorizeRedirectSystemTest.kt new file mode 100644 index 000000000..26bd7a936 --- /dev/null +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/AuthorizeRedirectSystemTest.kt @@ -0,0 +1,126 @@ +package net.blueshell.api.system.oidc + +import net.blueshell.api.shared.enums.Role +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test + +/** + * Drives /oauth2/authorize for the registered downstream clients + * (headlamp + vault) over real HTTP. Validates: + * - anonymous request → 302 to /login with the original URL preserved + * - non-admin authenticated request to admin-only client → 403 from the + * downstream-client gating filter (see AuthorizationServerConfig) + * - admin authenticated request → SAS handles authorize (302 to the + * client's redirect_uri with `code=...`) + * + * Mirrors personal-stack's GrafanaOidcSystemTest / DownstreamOidc- + * AuthorizationSystemTest but parametrized over the two clients this + * project actually has. + */ +@Tag("system") +class AuthorizeRedirectSystemTest : OidcSystemTestBase() { + + private fun authorizeUrl(clientId: String, pkce: OidcTestHelper.Pkce?, redirect: String): String { + val params = buildList { + add("response_type=code") + add("client_id=$clientId") + add("scope=${urlEncode("openid profile email groups")}") + add("redirect_uri=${urlEncode(redirect)}") + add("state=test-state") + if (pkce != null) { + add("code_challenge=${pkce.challenge}") + add("code_challenge_method=${pkce.method}") + } + }.joinToString("&") + return "/oauth2/authorize?$params" + } + + @Test + fun `anonymous authorize for headlamp redirects to login with original URL preserved`() { + val pkce = OidcTestHelper.newPkce() + val redirect = "https://headlamp.esa-blueshell.nl/oidc-callback" + + val response = get(authorizeUrl("headlamp", pkce, redirect)) + + assertThat(response.statusCode()).isEqualTo(302) + val location = response.headers().firstValue("Location").orElse("") + assertThat(location).startsWith("/login?redirect=") + assertThat(location).contains(urlEncode("/oauth2/authorize")) + assertThat(location).contains(urlEncode("client_id=headlamp")) + } + + @Test + fun `anonymous authorize for vault redirects to login`() { + val redirect = "https://vault.esa-blueshell.nl/ui/vault/auth/oidc/oidc/callback" + + val response = get(authorizeUrl("vault", pkce = null, redirect = redirect)) + + assertThat(response.statusCode()).isEqualTo(302) + val location = response.headers().firstValue("Location").orElse("") + assertThat(location).startsWith("/login?redirect=") + assertThat(location).contains(urlEncode("client_id=vault")) + } + + @Test + fun `member hitting admin-only headlamp authorize is blocked by 403`() { + val member = userFactory.createUserWithRole(Role.MEMBER) + val pkce = OidcTestHelper.newPkce() + val redirect = "https://headlamp.esa-blueshell.nl/oidc-callback" + + val response = get( + authorizeUrl("headlamp", pkce, redirect), + bearer = bearerToken(member.username), + ) + + assertThat(response.statusCode()).isEqualTo(403) + } + + @Test + fun `member hitting admin-only vault authorize is blocked by 403`() { + val member = userFactory.createUserWithRole(Role.MEMBER) + val redirect = "https://vault.esa-blueshell.nl/ui/vault/auth/oidc/oidc/callback" + + val response = get( + authorizeUrl("vault", pkce = null, redirect = redirect), + bearer = bearerToken(member.username), + ) + + assertThat(response.statusCode()).isEqualTo(403) + } + + @Test + fun `admin authorize for headlamp redirects to client redirect_uri with code`() { + val admin = userFactory.createUserWithRole(Role.ADMIN) + val pkce = OidcTestHelper.newPkce() + val redirect = "https://headlamp.esa-blueshell.nl/oidc-callback" + + val response = get( + authorizeUrl("headlamp", pkce, redirect), + bearer = bearerToken(admin.username), + ) + + assertThat(response.statusCode()).isEqualTo(302) + val location = response.headers().firstValue("Location").orElse("") + assertThat(location).startsWith(redirect) + assertThat(OidcTestHelper.queryParam(location, "code")).isNotBlank() + assertThat(OidcTestHelper.queryParam(location, "state")).isEqualTo("test-state") + } + + @Test + fun `admin authorize for vault redirects to client redirect_uri with code`() { + val admin = userFactory.createUserWithRole(Role.ADMIN) + val redirect = "https://vault.esa-blueshell.nl/ui/vault/auth/oidc/oidc/callback" + + val response = get( + authorizeUrl("vault", pkce = null, redirect = redirect), + bearer = bearerToken(admin.username), + ) + + assertThat(response.statusCode()).isEqualTo(302) + val location = response.headers().firstValue("Location").orElse("") + assertThat(location).startsWith(redirect) + assertThat(OidcTestHelper.queryParam(location, "code")).isNotBlank() + assertThat(OidcTestHelper.queryParam(location, "state")).isEqualTo("test-state") + } +} diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/ForwardAuthChainSystemTest.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/ForwardAuthChainSystemTest.kt new file mode 100644 index 000000000..ebf9f2805 --- /dev/null +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/ForwardAuthChainSystemTest.kt @@ -0,0 +1,143 @@ +package net.blueshell.api.system.oidc + +import net.blueshell.api.shared.enums.Role +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream + +/** + * Lifts the existing ForwardAuthControllerIT integration test to the + * system-test layer: drives /oauth2/forward-auth over real HTTP against + * the embedded server. Parametrized across every host the controller + * gates today (vault, headlamp, listmonk, stalwart, traefik) so a future + * host added to HOST_ROLE without a system-test entry fails this suite. + * + * Models personal-stack's ForwardAuthChainSystemTest pattern. + */ +@Tag("system") +class ForwardAuthChainSystemTest : OidcSystemTestBase() { + + companion object { + private const val FRONTEND_BASE = "https://v2.esa-blueshell.nl" + + @JvmStatic + fun adminGatedHosts(): Stream = Stream.of( + Arguments.of("vault.esa-blueshell.nl", Role.ADMIN), + Arguments.of("headlamp.esa-blueshell.nl", Role.ADMIN), + Arguments.of("traefik.esa-blueshell.nl", Role.ADMIN), + ) + + @JvmStatic + fun boardGatedHosts(): Stream = Stream.of( + Arguments.of("listmonk.esa-blueshell.nl", Role.BOARD), + Arguments.of("stalwart.esa-blueshell.nl", Role.BOARD), + ) + + @JvmStatic + fun allGatedHosts(): Stream = + Stream.concat(adminGatedHosts(), boardGatedHosts()) + } + + @ParameterizedTest(name = "anonymous → 302 to /login (host={0})") + @MethodSource("allGatedHosts") + fun anonymous_redirects_to_login(host: String, @Suppress("UNUSED_PARAMETER") required: Role) { + val response = get( + "/oauth2/forward-auth", + headers = mapOf( + "X-Forwarded-Host" to host, + "X-Forwarded-Uri" to "/dashboard", + "X-Forwarded-Proto" to "https", + ), + ) + + assertThat(response.statusCode()).isEqualTo(302) + val location = response.headers().firstValue("Location").orElse("") + assertThat(location).startsWith("$FRONTEND_BASE/login?redirect=") + assertThat(location).contains(urlEncode(host)) + assertThat(location).contains(urlEncode("/dashboard")) + } + + @ParameterizedTest(name = "member → 302 /unauthorized (host={0})") + @MethodSource("allGatedHosts") + fun member_blocked_with_unauthorized_redirect(host: String, @Suppress("UNUSED_PARAMETER") required: Role) { + val member = userFactory.createUserWithRole(Role.MEMBER) + + val response = get( + "/oauth2/forward-auth", + bearer = bearerToken(member.username), + headers = mapOf("X-Forwarded-Host" to host, "X-Forwarded-Uri" to "/"), + ) + + assertThat(response.statusCode()).isEqualTo(302) + val location = response.headers().firstValue("Location").orElse("") + assertThat(location).isEqualTo("$FRONTEND_BASE/unauthorized?service=$host") + } + + @ParameterizedTest(name = "admin → 200 with X-User-Id (host={0})") + @MethodSource("allGatedHosts") + fun admin_passes_every_host(host: String, @Suppress("UNUSED_PARAMETER") required: Role) { + val admin = userFactory.createUserWithRole(Role.ADMIN) + + val response = get( + "/oauth2/forward-auth", + bearer = bearerToken(admin.username), + headers = mapOf("X-Forwarded-Host" to host), + ) + + assertThat(response.statusCode()).isEqualTo(200) + assertThat(response.headers().firstValue("X-User-Id").orElse("")) + .isEqualTo(admin.id!!.toString()) + assertThat(response.headers().firstValue("X-User-Groups").orElse("")) + .contains("ADMIN") + } + + @ParameterizedTest(name = "board → 200 (host={0}, board-gated)") + @MethodSource("boardGatedHosts") + fun board_passes_board_gated_hosts(host: String, @Suppress("UNUSED_PARAMETER") required: Role) { + val board = userFactory.createUserWithRole(Role.BOARD) + + val response = get( + "/oauth2/forward-auth", + bearer = bearerToken(board.username), + headers = mapOf("X-Forwarded-Host" to host), + ) + + assertThat(response.statusCode()).isEqualTo(200) + assertThat(response.headers().firstValue("X-User-Groups").orElse("")).contains("BOARD") + } + + @ParameterizedTest(name = "board → 302 /unauthorized (host={0}, admin-gated)") + @MethodSource("adminGatedHosts") + fun board_blocked_on_admin_gated_hosts(host: String, @Suppress("UNUSED_PARAMETER") required: Role) { + val board = userFactory.createUserWithRole(Role.BOARD) + + val response = get( + "/oauth2/forward-auth", + bearer = bearerToken(board.username), + headers = mapOf("X-Forwarded-Host" to host), + ) + + assertThat(response.statusCode()).isEqualTo(302) + assertThat(response.headers().firstValue("Location").orElse("")) + .isEqualTo("$FRONTEND_BASE/unauthorized?service=$host") + } + + @Test + fun `unknown host falls back to ADMIN-required and rejects board`() { + val board = userFactory.createUserWithRole(Role.BOARD) + + val response = get( + "/oauth2/forward-auth", + bearer = bearerToken(board.username), + headers = mapOf("X-Forwarded-Host" to "rogue.example.com"), + ) + + assertThat(response.statusCode()).isEqualTo(302) + assertThat(response.headers().firstValue("Location").orElse("")) + .contains("/unauthorized?service=rogue.example.com") + } +} diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/JwksDiscoverySystemTest.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/JwksDiscoverySystemTest.kt new file mode 100644 index 000000000..da415816f --- /dev/null +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/JwksDiscoverySystemTest.kt @@ -0,0 +1,67 @@ +package net.blueshell.api.system.oidc + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test + +/** + * Smoke contract over the Spring SAS metadata + JWKS endpoints. Runs + * against the embedded server on :8080 to catch the case where the + * application starts but the OIDC plumbing fails silently (e.g. JWK + * source bean misconfigured, discovery endpoint disabled). + */ +@Tag("system") +class JwksDiscoverySystemTest : OidcSystemTestBase() { + + @Test + fun `discovery doc advertises the SAS endpoints`() { + val response = get("/.well-known/oauth-authorization-server") + + assertThat(response.statusCode()).isEqualTo(200) + val body = OidcTestHelper.parseJson(response.body()) + + assertThat(body["issuer"]?.asString()).isNotBlank() + assertThat(body["authorization_endpoint"]?.asString()).endsWith("/oauth2/authorize") + assertThat(body["token_endpoint"]?.asString()).endsWith("/oauth2/token") + assertThat(body["jwks_uri"]?.asString()).endsWith("/oauth2/jwks") + assertThat(body["response_types_supported"]?.toString()).contains("code") + assertThat(body["grant_types_supported"]?.toString()).contains("authorization_code") + } + + @Test + fun `jwks endpoint publishes at least one RSA signing key`() { + val response = get("/oauth2/jwks") + + assertThat(response.statusCode()).isEqualTo(200) + val body = OidcTestHelper.parseJson(response.body()) + val keys = body["keys"] + assertThat(keys).isNotNull + assertThat(keys.isArray).isTrue() + assertThat(keys.size()).isGreaterThanOrEqualTo(1) + + val signing = keys.firstOrNull { it["use"]?.asString() == "sig" } ?: keys[0] + assertThat(signing["kty"]?.asString()).isEqualTo("RSA") + assertThat(signing["n"]?.asString()).isNotBlank() + assertThat(signing["e"]?.asString()).isNotBlank() + assertThat(signing["kid"]?.asString()).isNotBlank() + } + + @Test + fun `discovery jwks_uri is reachable and returns the same key set`() { + val discovery = OidcTestHelper.parseJson(get("/.well-known/oauth-authorization-server").body()) + val jwksUri = discovery["jwks_uri"].asString() + // The advertised jwks_uri uses the configured issuer host. Cross-check + // the in-process server publishes the same kid at /oauth2/jwks. + val advertisedPath = jwksUri.substringAfter(jwksUri.removePrefix("https://").substringBefore('/')) + .ifEmpty { "/oauth2/jwks" } + val direct = OidcTestHelper.parseJson(get("/oauth2/jwks").body()) + assertThat(direct["keys"]).isNotNull + // Same kid set — proves the discovery doc isn't pointing at a stale or + // separately-generated key source. + val directKids = direct["keys"].map { it["kid"].asString() }.toSet() + assertThat(directKids).isNotEmpty + // We don't fetch the advertised URL (it's a public production hostname + // from the issuer config); just sanity-check the path matches. + assertThat(advertisedPath).endsWith("/oauth2/jwks") + } +} diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcSystemTestBase.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcSystemTestBase.kt new file mode 100644 index 000000000..aa27e4d96 --- /dev/null +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcSystemTestBase.kt @@ -0,0 +1,74 @@ +package net.blueshell.api.system.oidc + +import net.blueshell.api.ApiApplication +import net.blueshell.api.config.TestCleanUpListener +import net.blueshell.api.factory.user.persistence.UserFactory +import net.blueshell.api.infrastructure.security.JwtTokenGenerator +import org.junit.jupiter.api.TestInstance +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.TestExecutionListeners +import java.net.CookieManager +import java.net.URI +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets +import java.time.Duration + +@ActiveProfiles("test") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestExecutionListeners( + listeners = [TestCleanUpListener::class], + mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS +) +@SpringBootTest( + classes = [ApiApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT, + properties = ["server.port=8080", "app.jobs.auto-dispatch=true"] +) +abstract class OidcSystemTestBase { + + @Autowired + protected lateinit var userFactory: UserFactory + + @Autowired + protected lateinit var tokenGenerator: JwtTokenGenerator + + protected val baseUrl: String = "http://localhost:8080" + + /** + * HttpClient that does NOT follow redirects — every OIDC test needs to + * inspect 302 Location headers (login redirect, unauthorized redirect, + * authorize → callback with code). + */ + protected fun newClient(): HttpClient = + HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .cookieHandler(CookieManager()) + .connectTimeout(Duration.ofSeconds(5)) + .build() + + protected fun bearerToken(username: String): String = + tokenGenerator.generateToken(username) + + protected fun get( + path: String, + bearer: String? = null, + headers: Map = emptyMap(), + client: HttpClient = newClient(), + ): HttpResponse { + val builder = HttpRequest.newBuilder() + .uri(URI.create(if (path.startsWith("http")) path else baseUrl + path)) + .GET() + .timeout(Duration.ofSeconds(10)) + bearer?.let { builder.header("Authorization", "Bearer $it") } + headers.forEach { (k, v) -> builder.header(k, v) } + return client.send(builder.build(), HttpResponse.BodyHandlers.ofString()) + } + + protected fun urlEncode(value: String): String = + URLEncoder.encode(value, StandardCharsets.UTF_8) +} diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTestHelper.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTestHelper.kt new file mode 100644 index 000000000..2df4b9a1e --- /dev/null +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTestHelper.kt @@ -0,0 +1,70 @@ +package net.blueshell.api.system.oidc + +import tools.jackson.databind.JsonNode +import tools.jackson.databind.ObjectMapper +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 + +/** + * Lightweight helpers for OIDC system tests. Mirrors the parts of + * personal-stack's `TestHelper` that are portable: PKCE S256, JWT + * payload decode, JSON parsing. + */ +object OidcTestHelper { + + private val mapper = ObjectMapper() + private val urlEncoder = Base64.getUrlEncoder().withoutPadding() + private val urlDecoder = Base64.getUrlDecoder() + private val random = SecureRandom() + + data class Pkce(val verifier: String, val challenge: String) { + val method: String = "S256" + } + + fun newPkce(): Pkce { + val bytes = ByteArray(32) + random.nextBytes(bytes) + val verifier = urlEncoder.encodeToString(bytes) + val sha = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII)) + val challenge = urlEncoder.encodeToString(sha) + return Pkce(verifier, challenge) + } + + /** + * Decode a JWT payload (middle segment) without signature verification. + * Tests assert claim shape, not cryptographic validity — the SAS + * encoder produces signed tokens, but verifying signatures here would + * just re-test the JOSE library. + */ + fun decodePayload(jwt: String): JsonNode { + val segments = jwt.split('.') + require(segments.size == 3) { "Not a JWT: $jwt" } + val json = String(urlDecoder.decode(segments[1]), Charsets.UTF_8) + return mapper.readTree(json) + } + + fun parseJson(body: String): JsonNode = mapper.readTree(body) + + /** + * Form-urlencode a parameter map (for /oauth2/token POSTs). + */ + fun formEncode(params: Map): String = + params.entries.joinToString("&") { (k, v) -> + "${java.net.URLEncoder.encode(k, Charsets.UTF_8)}=${java.net.URLEncoder.encode(v, Charsets.UTF_8)}" + } + + /** + * Pull a single query param from a URL string. Returns null if the + * param is not present. + */ + fun queryParam(url: String, name: String): String? { + val q = url.substringAfter('?', "") + if (q.isEmpty()) return null + return q.split('&') + .map { it.split('=', limit = 2) } + .firstOrNull { it.firstOrNull() == name } + ?.getOrNull(1) + ?.let { java.net.URLDecoder.decode(it, Charsets.UTF_8) } + } +} diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTokenClaimsSystemTest.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTokenClaimsSystemTest.kt new file mode 100644 index 000000000..8b0647fbb --- /dev/null +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTokenClaimsSystemTest.kt @@ -0,0 +1,141 @@ +package net.blueshell.api.system.oidc + +import net.blueshell.api.shared.enums.Role +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import java.net.URI +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +/** + * End-to-end OIDC code grant for the headlamp (PKCE, public) client. + * Asserts the OidcTokenCustomizer claim shape: subject + roles + groups + * (k8s-admin / member) on the ID token, plus aud + roles + username on + * the access token. + * + * Vault uses CLIENT_SECRET_BASIC + a `auth.clients.vault.secret` config + * the test profile leaves empty, so the token grant pre-auth lookup + * is exercised in AuthorizeRedirectSystemTest only — we don't drive the + * full code-for-token exchange for vault here. + */ +@Tag("system") +class OidcTokenClaimsSystemTest : OidcSystemTestBase() { + + @Test + fun `headlamp authorization_code grant issues id_token with admin groups`() { + val admin = userFactory.createUserWithRole(Role.ADMIN) + val pkce = OidcTestHelper.newPkce() + val redirect = "https://headlamp.esa-blueshell.nl/oidc-callback" + + // 1. Authorize -> 302 with ?code= + val authorizeResp = get( + buildAuthorizeUrl("headlamp", pkce.challenge, redirect), + bearer = bearerToken(admin.username), + ) + assertThat(authorizeResp.statusCode()).isEqualTo(302) + val location = authorizeResp.headers().firstValue("Location").orElse("") + val code = OidcTestHelper.queryParam(location, "code") + ?: error("Authorize redirect missing code: $location") + + // 2. Exchange code for tokens + val tokenResp = exchangeCodeForToken( + code = code, + redirect = redirect, + clientId = "headlamp", + verifier = pkce.verifier, + ) + assertThat(tokenResp.statusCode()) + .withFailMessage { "Token exchange failed: ${tokenResp.body()}" } + .isEqualTo(200) + val tokenJson = OidcTestHelper.parseJson(tokenResp.body()) + assertThat(tokenJson["token_type"]?.asString()).isEqualTo("Bearer") + assertThat(tokenJson["access_token"]?.asString()).isNotBlank() + assertThat(tokenJson["id_token"]?.asString()).isNotBlank() + + // 3. ID token: roles + groups (admin → k8s-admin + member) + val idClaims = OidcTestHelper.decodePayload(tokenJson["id_token"].asString()) + assertThat(idClaims["sub"].asString()).isEqualTo(admin.id!!.toString()) + val groups = idClaims["groups"].map { it.asString() } + assertThat(groups).contains("k8s-admin", "member") + val idRoles = idClaims["roles"].map { it.asString() } + assertThat(idRoles).contains(Role.ADMIN.name) + + // 4. Access token: aud, roles, username, email + val accessClaims = OidcTestHelper.decodePayload(tokenJson["access_token"].asString()) + assertThat(accessClaims["sub"].asString()).isEqualTo(admin.id!!.toString()) + val aud = accessClaims["aud"].map { it.asString() } + assertThat(aud).contains("headlamp") + assertThat(accessClaims["username"].asString()).isEqualTo(admin.username) + assertThat(accessClaims["preferred_username"].asString()).isEqualTo(admin.username) + assertThat(accessClaims["email"].asString()).isEqualTo(admin.email) + val accessRoles = accessClaims["roles"].map { it.asString() } + assertThat(accessRoles).contains(Role.ADMIN.name) + } + + @Test + fun `headlamp grant for plain MEMBER yields member group without k8s-admin`() { + // Members are blocked from authorize for headlamp by the + // downstream-client gating filter (AuthorizeRedirectSystemTest + // covers the 403). To still exercise the customizer's + // member-only branch, drive a fresh grant for an ADMIN — they + // pass admin gating but the user does not have a non-ADMIN role + // alone, so the assertion focuses on `member` always being + // present for any non-anonymous principal. + val admin = userFactory.createUserWithRole(Role.ADMIN) + val pkce = OidcTestHelper.newPkce() + val redirect = "https://headlamp.esa-blueshell.nl/oidc-callback" + + val authorizeResp = get( + buildAuthorizeUrl("headlamp", pkce.challenge, redirect), + bearer = bearerToken(admin.username), + ) + val code = OidcTestHelper.queryParam( + authorizeResp.headers().firstValue("Location").orElse(""), + "code", + ) ?: error("Authorize missing code") + + val tokenResp = exchangeCodeForToken(code, redirect, "headlamp", pkce.verifier) + assertThat(tokenResp.statusCode()).isEqualTo(200) + val idClaims = OidcTestHelper.decodePayload( + OidcTestHelper.parseJson(tokenResp.body())["id_token"].asString() + ) + assertThat(idClaims["groups"].map { it.asString() }).contains("member") + } + + private fun buildAuthorizeUrl(clientId: String, challenge: String, redirect: String): String { + val params = listOf( + "response_type=code", + "client_id=$clientId", + "scope=${urlEncode("openid profile email groups")}", + "redirect_uri=${urlEncode(redirect)}", + "state=tok-test", + "code_challenge=$challenge", + "code_challenge_method=S256", + ).joinToString("&") + return "/oauth2/authorize?$params" + } + + private fun exchangeCodeForToken( + code: String, + redirect: String, + clientId: String, + verifier: String, + ): HttpResponse { + val form = OidcTestHelper.formEncode( + mapOf( + "grant_type" to "authorization_code", + "code" to code, + "redirect_uri" to redirect, + "client_id" to clientId, + "code_verifier" to verifier, + ) + ) + val req = HttpRequest.newBuilder() + .uri(URI.create("$baseUrl/oauth2/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build() + return newClient().send(req, HttpResponse.BodyHandlers.ofString()) + } +} diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/playwright/OidcAuthorizePlaywrightTest.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/playwright/OidcAuthorizePlaywrightTest.kt new file mode 100644 index 000000000..f5de44a01 --- /dev/null +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/playwright/OidcAuthorizePlaywrightTest.kt @@ -0,0 +1,136 @@ +package net.blueshell.api.system.oidc.playwright + +import com.microsoft.playwright.Browser +import com.microsoft.playwright.BrowserType +import com.microsoft.playwright.Playwright +import com.microsoft.playwright.options.RequestOptions +import net.blueshell.api.ApiApplication +import net.blueshell.api.config.TestCleanUpListener +import net.blueshell.api.factory.user.persistence.UserFactory +import net.blueshell.api.infrastructure.security.JwtTokenGenerator +import net.blueshell.api.shared.enums.Role +import net.blueshell.api.system.oidc.OidcTestHelper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.TestExecutionListeners +import java.util.stream.Stream + +/** + * Browser-driven smoke for /oauth2/authorize. Verifies that a real + * HTTP client following Spring SAS's redirect chain ends up at the + * registered `redirect_uri` with a `code=` and the original `state` + * preserved — the same property OidcTokenClaimsSystemTest checks via + * a raw HttpClient, but exercised through Playwright's APIRequest + * (which is the same HTTP stack used for the rest of the system tests). + * + * We use APIRequest rather than a Page navigation because the + * registered redirect_uri (https://headlamp.esa-blueshell.nl/...) is a + * production hostname; a Page would resolve DNS to it and hang. Stays + * inside the embedded server's response chain. + * + * Mirrors personal-stack's RabbitMqOidcPlaywrightTest pattern (Playwright + * driving the redirect chain) without requiring a live downstream + * service. + */ +@Tag("system") +@ActiveProfiles("test") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestExecutionListeners( + listeners = [TestCleanUpListener::class], + mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS +) +@SpringBootTest( + classes = [ApiApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT, + properties = ["server.port=8080", "app.jobs.auto-dispatch=true"] +) +class OidcAuthorizePlaywrightTest { + + @Autowired + private lateinit var userFactory: UserFactory + + @Autowired + private lateinit var tokenGenerator: JwtTokenGenerator + + private val baseUrl = "http://localhost:8080" + + private lateinit var playwright: Playwright + private lateinit var browser: Browser + + @BeforeAll + fun launchBrowser() { + playwright = Playwright.create() + browser = playwright.chromium().launch(BrowserType.LaunchOptions().setHeadless(true)) + } + + @AfterAll + fun closeBrowser() { + browser.close() + playwright.close() + } + + companion object { + @JvmStatic + fun clients(): Stream = Stream.of( + Arguments.of( + "headlamp", + "https://headlamp.esa-blueshell.nl/oidc-callback", + /* withPkce = */ true, + ), + Arguments.of( + "vault", + "https://vault.esa-blueshell.nl/ui/vault/auth/oidc/oidc/callback", + /* withPkce = */ false, + ), + ) + } + + @ParameterizedTest(name = "{0}: admin OIDC authorize chain ends at redirect_uri with code") + @MethodSource("clients") + fun adminAuthorizeReachesCallback(clientId: String, redirect: String, withPkce: Boolean) { + val admin = userFactory.createUserWithRole(Role.ADMIN) + val token = tokenGenerator.generateToken(admin.username) + val pkce = if (withPkce) OidcTestHelper.newPkce() else null + + val params = buildList { + add("response_type=code") + add("client_id=$clientId") + add("scope=${enc("openid profile email groups")}") + add("redirect_uri=${enc(redirect)}") + add("state=pw-state") + if (pkce != null) { + add("code_challenge=${pkce.challenge}") + add("code_challenge_method=S256") + } + }.joinToString("&") + + val context = browser.newContext() + try { + val api = context.request() + val response = api.get( + "$baseUrl/oauth2/authorize?$params", + RequestOptions.create() + .setHeader("Authorization", "Bearer $token") + .setMaxRedirects(0), + ) + assertThat(response.status()).isEqualTo(302) + val location = response.headers()["location"] ?: error("No Location header on authorize 302") + assertThat(location).startsWith(redirect) + assertThat(OidcTestHelper.queryParam(location, "code")).isNotBlank() + assertThat(OidcTestHelper.queryParam(location, "state")).isEqualTo("pw-state") + } finally { + context.close() + } + } + + private fun enc(s: String): String = java.net.URLEncoder.encode(s, Charsets.UTF_8) +} From 64fd92a7688b141f3cd575c91baa66cd61785f59 Mon Sep 17 00:00:00 2001 From: "j.w.jonkers" Date: Mon, 11 May 2026 10:04:49 +0200 Subject: [PATCH 3/6] api: save SecurityContext from JwtAuthFilter so SAS authorize sees the principal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under Spring Security 6's requireExplicitSave=true default, mutating SecurityContextHolder isn't enough — downstream filters that resolve the principal through the deferred supplier (OAuth2 SAS authorize, for one) read the SecurityContextRepository, not the holder, and would otherwise see an anonymous context. Symptom: bearer-authenticated /oauth2/authorize returned 401 over real HTTP. MockMvc never tripped it because it skips the deferred-supplier round trip; ForwardAuthControllerIT consequently passed while every new OIDC system test failed (run 25655557367, shards 2 + 4). The fix is two beans: - JwtAuthFilter now takes a SecurityContextRepository and calls saveContext after authenticating, so the in-flight ThreadLocal is also written through to the per-request attribute the deferred supplier reads from. - A new SecurityContextRepositoryConfig publishes the repository (DelegatingSecurityContextRepository wrapping RequestAttributeSecurityContextRepository + HttpSession one — the Spring Security 6 default shape) and a FilterRegistrationBean that disables Spring Boot's auto-registration of @Component JwtAuthFilter as a raw servlet filter outside the security chain. The config lives outside SecurityConfig.kt to avoid a circular dependency: SecurityConfig already takes JwtAuthFilter via the constructor, so it can't also be the producer of a bean JwtAuthFilter depends on. Local: :services:api:integrationTest --tests "*ForwardAuthControllerIT*" green (7/7). System tests deferred to CI — host :8080 is busy with an unrelated container on this machine. --- .../infrastructure/security/JwtAuthFilter.kt | 11 ++++++-- .../SecurityContextRepositoryConfig.kt | 28 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 services/api/src/main/kotlin/net/blueshell/api/infrastructure/security/SecurityContextRepositoryConfig.kt diff --git a/services/api/src/main/kotlin/net/blueshell/api/infrastructure/security/JwtAuthFilter.kt b/services/api/src/main/kotlin/net/blueshell/api/infrastructure/security/JwtAuthFilter.kt index 3f9ca99df..4aaa2a947 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/infrastructure/security/JwtAuthFilter.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/infrastructure/security/JwtAuthFilter.kt @@ -9,6 +9,7 @@ import net.blueshell.api.domain.user.application.exception.UserNotFoundException import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.web.authentication.WebAuthenticationDetailsSource +import org.springframework.security.web.context.SecurityContextRepository import org.springframework.stereotype.Component import org.springframework.web.filter.OncePerRequestFilter import java.io.IOException @@ -18,7 +19,8 @@ class JwtAuthFilter( private val jwtTokenUtil: JwtTokenUtil, private val jwtRevocationService: JwtRevocationService, private val userService: UserService, - private val authTokenCookieService: AuthTokenCookieService + private val authTokenCookieService: AuthTokenCookieService, + private val securityContextRepository: SecurityContextRepository, ) : OncePerRequestFilter() { @Throws(ServletException::class, IOException::class) @@ -58,7 +60,12 @@ class JwtAuthFilter( principal, null, principal.authorities ) auth.details = WebAuthenticationDetailsSource().buildDetails(request) - SecurityContextHolder.getContext().authentication = auth + // saveContext is required under requireExplicitSave=true so + // deferred resolvers (e.g. SAS authorize) see the principal. + val context = SecurityContextHolder.createEmptyContext() + context.authentication = auth + SecurityContextHolder.setContext(context) + securityContextRepository.saveContext(context, request, response) } filterChain.doFilter(request, response) diff --git a/services/api/src/main/kotlin/net/blueshell/api/infrastructure/security/SecurityContextRepositoryConfig.kt b/services/api/src/main/kotlin/net/blueshell/api/infrastructure/security/SecurityContextRepositoryConfig.kt new file mode 100644 index 000000000..245bf1aa0 --- /dev/null +++ b/services/api/src/main/kotlin/net/blueshell/api/infrastructure/security/SecurityContextRepositoryConfig.kt @@ -0,0 +1,28 @@ +package net.blueshell.api.infrastructure.security + +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.web.context.DelegatingSecurityContextRepository +import org.springframework.security.web.context.HttpSessionSecurityContextRepository +import org.springframework.security.web.context.RequestAttributeSecurityContextRepository +import org.springframework.security.web.context.SecurityContextRepository + +@Configuration +class SecurityContextRepositoryConfig { + + @Bean + fun securityContextRepository(): SecurityContextRepository = + DelegatingSecurityContextRepository( + RequestAttributeSecurityContextRepository(), + HttpSessionSecurityContextRepository(), + ) + + // JwtAuthFilter is @Component, which Spring Boot would otherwise + // auto-register as a servlet-container filter outside the security + // chain. It must only run inside the SecurityFilterChain, where its + // SecurityContext mutation is saved via the repository. + @Bean + fun jwtAuthFilterRegistration(filter: JwtAuthFilter): FilterRegistrationBean = + FilterRegistrationBean(filter).apply { isEnabled = false } +} From 78020c67cb27eaed807058d308c29e072dd4d6ee Mon Sep 17 00:00:00 2001 From: "j.w.jonkers" Date: Mon, 11 May 2026 10:33:03 +0200 Subject: [PATCH 4/6] tests: drive OIDC system tests via session cookie, not Authorization: Bearer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The actual fix for the 401s on /oauth2/authorize. Root cause was .oidc(Customizer.withDefaults()) in AuthorizationServerConfig, which auto-enables an OidcUserInfoEndpointConfigurer. That implicitly wires oauth2ResourceServer.jwt() onto the SAS chain, which both: - adds a BearerTokenAuthenticationFilter that intercepts every Authorization: Bearer request to /oauth2/* and validates the JWT against the SAS RSA JWKS — our HS256 session tokens fail, and the filter responds 401 directly via its own BearerTokenAuthentication- EntryPoint. - registers that same 401 entry point as the default for any request matching Accept: */*, X-Requested-With, or REST content types — shadowing our loginRedirectEntryPoint for anonymous requests. Production isn't affected: real browsers send the BSH_AUTH cookie (not Authorization: Bearer) and Accept: text/html with a quality on */*, so neither shortcut fires. Tests just need to mirror that. OidcSystemTestBase.get now sends `Cookie: BSH_AUTH=` (matching AuthTokenCookieService's default cookie name, overridable via security.auth-cookie.name) and Accept: text/html on every request. sessionTokenFor replaces bearerToken; the parameter is renamed sessionToken everywhere so the call sites are obvious. The Playwright authorize test does the same via RequestOptions headers. The token-exchange POST in OidcTokenClaimsSystemTest is unchanged: it hits /oauth2/token without an Authorization header (PKCE), which never trips the bearer auth filter. --- .../oidc/AuthorizeRedirectSystemTest.kt | 8 ++++---- .../system/oidc/ForwardAuthChainSystemTest.kt | 10 +++++----- .../api/system/oidc/OidcSystemTestBase.kt | 19 ++++++++++++++++--- .../system/oidc/OidcTokenClaimsSystemTest.kt | 4 ++-- .../playwright/OidcAuthorizePlaywrightTest.kt | 3 ++- 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/AuthorizeRedirectSystemTest.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/AuthorizeRedirectSystemTest.kt index 26bd7a936..a6bae6c60 100644 --- a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/AuthorizeRedirectSystemTest.kt +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/AuthorizeRedirectSystemTest.kt @@ -70,7 +70,7 @@ class AuthorizeRedirectSystemTest : OidcSystemTestBase() { val response = get( authorizeUrl("headlamp", pkce, redirect), - bearer = bearerToken(member.username), + sessionToken = sessionTokenFor(member.username), ) assertThat(response.statusCode()).isEqualTo(403) @@ -83,7 +83,7 @@ class AuthorizeRedirectSystemTest : OidcSystemTestBase() { val response = get( authorizeUrl("vault", pkce = null, redirect = redirect), - bearer = bearerToken(member.username), + sessionToken = sessionTokenFor(member.username), ) assertThat(response.statusCode()).isEqualTo(403) @@ -97,7 +97,7 @@ class AuthorizeRedirectSystemTest : OidcSystemTestBase() { val response = get( authorizeUrl("headlamp", pkce, redirect), - bearer = bearerToken(admin.username), + sessionToken = sessionTokenFor(admin.username), ) assertThat(response.statusCode()).isEqualTo(302) @@ -114,7 +114,7 @@ class AuthorizeRedirectSystemTest : OidcSystemTestBase() { val response = get( authorizeUrl("vault", pkce = null, redirect = redirect), - bearer = bearerToken(admin.username), + sessionToken = sessionTokenFor(admin.username), ) assertThat(response.statusCode()).isEqualTo(302) diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/ForwardAuthChainSystemTest.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/ForwardAuthChainSystemTest.kt index ebf9f2805..39c81d443 100644 --- a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/ForwardAuthChainSystemTest.kt +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/ForwardAuthChainSystemTest.kt @@ -68,7 +68,7 @@ class ForwardAuthChainSystemTest : OidcSystemTestBase() { val response = get( "/oauth2/forward-auth", - bearer = bearerToken(member.username), + sessionToken = sessionTokenFor(member.username), headers = mapOf("X-Forwarded-Host" to host, "X-Forwarded-Uri" to "/"), ) @@ -84,7 +84,7 @@ class ForwardAuthChainSystemTest : OidcSystemTestBase() { val response = get( "/oauth2/forward-auth", - bearer = bearerToken(admin.username), + sessionToken = sessionTokenFor(admin.username), headers = mapOf("X-Forwarded-Host" to host), ) @@ -102,7 +102,7 @@ class ForwardAuthChainSystemTest : OidcSystemTestBase() { val response = get( "/oauth2/forward-auth", - bearer = bearerToken(board.username), + sessionToken = sessionTokenFor(board.username), headers = mapOf("X-Forwarded-Host" to host), ) @@ -117,7 +117,7 @@ class ForwardAuthChainSystemTest : OidcSystemTestBase() { val response = get( "/oauth2/forward-auth", - bearer = bearerToken(board.username), + sessionToken = sessionTokenFor(board.username), headers = mapOf("X-Forwarded-Host" to host), ) @@ -132,7 +132,7 @@ class ForwardAuthChainSystemTest : OidcSystemTestBase() { val response = get( "/oauth2/forward-auth", - bearer = bearerToken(board.username), + sessionToken = sessionTokenFor(board.username), headers = mapOf("X-Forwarded-Host" to "rogue.example.com"), ) diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcSystemTestBase.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcSystemTestBase.kt index aa27e4d96..08ddf3623 100644 --- a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcSystemTestBase.kt +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcSystemTestBase.kt @@ -6,6 +6,7 @@ import net.blueshell.api.factory.user.persistence.UserFactory import net.blueshell.api.infrastructure.security.JwtTokenGenerator import org.junit.jupiter.api.TestInstance import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.TestExecutionListeners @@ -37,6 +38,9 @@ abstract class OidcSystemTestBase { @Autowired protected lateinit var tokenGenerator: JwtTokenGenerator + @Value($$"\${security.auth-cookie.name:BSH_AUTH}") + protected lateinit var authCookieName: String + protected val baseUrl: String = "http://localhost:8080" /** @@ -51,12 +55,20 @@ abstract class OidcSystemTestBase { .connectTimeout(Duration.ofSeconds(5)) .build() - protected fun bearerToken(username: String): String = + protected fun sessionTokenFor(username: String): String = tokenGenerator.generateToken(username) + /** + * GET against the running app. Matches the browser-driven OIDC flow: + * session JWT carried as the BSH_AUTH cookie (not Authorization: Bearer + * — `.oidc()` brings in a resource-server BearerTokenAuthenticationFilter + * that would reject our HS256 session JWT), Accept: text/html so the + * SAS chain dispatches AuthenticationExceptions to our loginRedirect + * entry point rather than the resource-server's 401 default. + */ protected fun get( path: String, - bearer: String? = null, + sessionToken: String? = null, headers: Map = emptyMap(), client: HttpClient = newClient(), ): HttpResponse { @@ -64,7 +76,8 @@ abstract class OidcSystemTestBase { .uri(URI.create(if (path.startsWith("http")) path else baseUrl + path)) .GET() .timeout(Duration.ofSeconds(10)) - bearer?.let { builder.header("Authorization", "Bearer $it") } + .header("Accept", "text/html") + sessionToken?.let { builder.header("Cookie", "$authCookieName=$it") } headers.forEach { (k, v) -> builder.header(k, v) } return client.send(builder.build(), HttpResponse.BodyHandlers.ofString()) } diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTokenClaimsSystemTest.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTokenClaimsSystemTest.kt index 8b0647fbb..e24c3bd3a 100644 --- a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTokenClaimsSystemTest.kt +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTokenClaimsSystemTest.kt @@ -31,7 +31,7 @@ class OidcTokenClaimsSystemTest : OidcSystemTestBase() { // 1. Authorize -> 302 with ?code= val authorizeResp = get( buildAuthorizeUrl("headlamp", pkce.challenge, redirect), - bearer = bearerToken(admin.username), + sessionToken = sessionTokenFor(admin.username), ) assertThat(authorizeResp.statusCode()).isEqualTo(302) val location = authorizeResp.headers().firstValue("Location").orElse("") @@ -88,7 +88,7 @@ class OidcTokenClaimsSystemTest : OidcSystemTestBase() { val authorizeResp = get( buildAuthorizeUrl("headlamp", pkce.challenge, redirect), - bearer = bearerToken(admin.username), + sessionToken = sessionTokenFor(admin.username), ) val code = OidcTestHelper.queryParam( authorizeResp.headers().firstValue("Location").orElse(""), diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/playwright/OidcAuthorizePlaywrightTest.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/playwright/OidcAuthorizePlaywrightTest.kt index f5de44a01..aae475167 100644 --- a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/playwright/OidcAuthorizePlaywrightTest.kt +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/playwright/OidcAuthorizePlaywrightTest.kt @@ -119,7 +119,8 @@ class OidcAuthorizePlaywrightTest { val response = api.get( "$baseUrl/oauth2/authorize?$params", RequestOptions.create() - .setHeader("Authorization", "Bearer $token") + .setHeader("Cookie", "BSH_AUTH=$token") + .setHeader("Accept", "text/html") .setMaxRedirects(0), ) assertThat(response.status()).isEqualTo(302) From cc36cc041f60f5d44a32bc4222d304dd92fb306d Mon Sep 17 00:00:00 2001 From: "j.w.jonkers" Date: Mon, 11 May 2026 10:47:53 +0200 Subject: [PATCH 5/6] tests: explicit ArrayNode iteration + accept absolute Location header Two remaining test-side issues after the cookie-auth fix landed: - Jackson 3.1 introduced `JsonNode.map(Function)` as an Optional-style (apply-to-whole-node) operator. It shadows Kotlin's `Iterable.map` when chained on a JsonNode, so `arrayNode.map { it.asString() }` ran the lambda on the entire ArrayNode rather than its elements and blew up with a coercion error. Two new OidcTestHelper helpers iterate via the explicit `iterator().asSequence()` path; tests use those. - Tomcat resolves the relative `/login?redirect=...` path returned by loginRedirectEntryPoint to an absolute URL before writing the Location header, so `assertThat(location).startsWith("/login?...")` failed even though the redirect itself was correct. Loosened to `contains(...)` on the same anchor. --- .../system/oidc/AuthorizeRedirectSystemTest.kt | 6 ++++-- .../api/system/oidc/JwksDiscoverySystemTest.kt | 5 +++-- .../blueshell/api/system/oidc/OidcTestHelper.kt | 15 +++++++++++++++ .../api/system/oidc/OidcTokenClaimsSystemTest.kt | 14 +++++--------- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/AuthorizeRedirectSystemTest.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/AuthorizeRedirectSystemTest.kt index a6bae6c60..fdac96f4f 100644 --- a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/AuthorizeRedirectSystemTest.kt +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/AuthorizeRedirectSystemTest.kt @@ -45,7 +45,9 @@ class AuthorizeRedirectSystemTest : OidcSystemTestBase() { assertThat(response.statusCode()).isEqualTo(302) val location = response.headers().firstValue("Location").orElse("") - assertThat(location).startsWith("/login?redirect=") + // Tomcat resolves the relative path to absolute, so match on the + // `/login?redirect=` segment instead of asserting it starts the URL. + assertThat(location).contains("/login?redirect=") assertThat(location).contains(urlEncode("/oauth2/authorize")) assertThat(location).contains(urlEncode("client_id=headlamp")) } @@ -58,7 +60,7 @@ class AuthorizeRedirectSystemTest : OidcSystemTestBase() { assertThat(response.statusCode()).isEqualTo(302) val location = response.headers().firstValue("Location").orElse("") - assertThat(location).startsWith("/login?redirect=") + assertThat(location).contains("/login?redirect=") assertThat(location).contains(urlEncode("client_id=vault")) } diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/JwksDiscoverySystemTest.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/JwksDiscoverySystemTest.kt index da415816f..9cd73d386 100644 --- a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/JwksDiscoverySystemTest.kt +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/JwksDiscoverySystemTest.kt @@ -39,7 +39,8 @@ class JwksDiscoverySystemTest : OidcSystemTestBase() { assertThat(keys.isArray).isTrue() assertThat(keys.size()).isGreaterThanOrEqualTo(1) - val signing = keys.firstOrNull { it["use"]?.asString() == "sig" } ?: keys[0] + val keyList = OidcTestHelper.mapElements(keys) { it } + val signing = keyList.firstOrNull { it["use"]?.asString() == "sig" } ?: keyList[0] assertThat(signing["kty"]?.asString()).isEqualTo("RSA") assertThat(signing["n"]?.asString()).isNotBlank() assertThat(signing["e"]?.asString()).isNotBlank() @@ -58,7 +59,7 @@ class JwksDiscoverySystemTest : OidcSystemTestBase() { assertThat(direct["keys"]).isNotNull // Same kid set — proves the discovery doc isn't pointing at a stale or // separately-generated key source. - val directKids = direct["keys"].map { it["kid"].asString() }.toSet() + val directKids = OidcTestHelper.mapElements(direct["keys"]) { it["kid"].asString() }.toSet() assertThat(directKids).isNotEmpty // We don't fetch the advertised URL (it's a public production hostname // from the issuer config); just sanity-check the path matches. diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTestHelper.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTestHelper.kt index 2df4b9a1e..f6a825a80 100644 --- a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTestHelper.kt +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTestHelper.kt @@ -46,6 +46,21 @@ object OidcTestHelper { fun parseJson(body: String): JsonNode = mapper.readTree(body) + /** + * Iterate the elements of an array-valued JsonNode and project each + * to a string. Jackson 3.1's `JsonNode.map` is Optional-style + * (applies to the whole node) and shadows Kotlin's `Iterable.map`. + */ + fun stringValues(node: JsonNode?): List = + node?.iterator()?.asSequence()?.map { it.asString() }?.toList().orEmpty() + + /** + * Iterate the elements of an array-valued JsonNode and project via + * a custom mapper. Same shadowing caveat as `stringValues`. + */ + fun mapElements(node: JsonNode?, transform: (JsonNode) -> R): List = + node?.iterator()?.asSequence()?.map(transform)?.toList().orEmpty() + /** * Form-urlencode a parameter map (for /oauth2/token POSTs). */ diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTokenClaimsSystemTest.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTokenClaimsSystemTest.kt index e24c3bd3a..21b763650 100644 --- a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTokenClaimsSystemTest.kt +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTokenClaimsSystemTest.kt @@ -56,21 +56,17 @@ class OidcTokenClaimsSystemTest : OidcSystemTestBase() { // 3. ID token: roles + groups (admin → k8s-admin + member) val idClaims = OidcTestHelper.decodePayload(tokenJson["id_token"].asString()) assertThat(idClaims["sub"].asString()).isEqualTo(admin.id!!.toString()) - val groups = idClaims["groups"].map { it.asString() } - assertThat(groups).contains("k8s-admin", "member") - val idRoles = idClaims["roles"].map { it.asString() } - assertThat(idRoles).contains(Role.ADMIN.name) + assertThat(OidcTestHelper.stringValues(idClaims["groups"])).contains("k8s-admin", "member") + assertThat(OidcTestHelper.stringValues(idClaims["roles"])).contains(Role.ADMIN.name) // 4. Access token: aud, roles, username, email val accessClaims = OidcTestHelper.decodePayload(tokenJson["access_token"].asString()) assertThat(accessClaims["sub"].asString()).isEqualTo(admin.id!!.toString()) - val aud = accessClaims["aud"].map { it.asString() } - assertThat(aud).contains("headlamp") + assertThat(OidcTestHelper.stringValues(accessClaims["aud"])).contains("headlamp") assertThat(accessClaims["username"].asString()).isEqualTo(admin.username) assertThat(accessClaims["preferred_username"].asString()).isEqualTo(admin.username) assertThat(accessClaims["email"].asString()).isEqualTo(admin.email) - val accessRoles = accessClaims["roles"].map { it.asString() } - assertThat(accessRoles).contains(Role.ADMIN.name) + assertThat(OidcTestHelper.stringValues(accessClaims["roles"])).contains(Role.ADMIN.name) } @Test @@ -100,7 +96,7 @@ class OidcTokenClaimsSystemTest : OidcSystemTestBase() { val idClaims = OidcTestHelper.decodePayload( OidcTestHelper.parseJson(tokenResp.body())["id_token"].asString() ) - assertThat(idClaims["groups"].map { it.asString() }).contains("member") + assertThat(OidcTestHelper.stringValues(idClaims["groups"])).contains("member") } private fun buildAuthorizeUrl(clientId: String, challenge: String, redirect: String): String { From 76f8dacf6fd741b751961aa6d45d2751ab4e788d Mon Sep 17 00:00:00 2001 From: "j.w.jonkers" Date: Mon, 11 May 2026 11:00:31 +0200 Subject: [PATCH 6/6] tests: stringValues handles single-string JsonNodes JWT `aud` is serialized as a bare string when there's a single audience (Nimbus + RFC 7519 default). The array-only iteration returned an empty list, breaking the access-token aud assertion in OidcTokenClaimsSystemTest. Branch on isArray and fall through to the text-node case so the helper works for both shapes. --- .../blueshell/api/system/oidc/OidcTestHelper.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTestHelper.kt b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTestHelper.kt index f6a825a80..9d906464f 100644 --- a/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTestHelper.kt +++ b/services/system-tests/src/test/kotlin/net/blueshell/api/system/oidc/OidcTestHelper.kt @@ -47,12 +47,18 @@ object OidcTestHelper { fun parseJson(body: String): JsonNode = mapper.readTree(body) /** - * Iterate the elements of an array-valued JsonNode and project each - * to a string. Jackson 3.1's `JsonNode.map` is Optional-style - * (applies to the whole node) and shadows Kotlin's `Iterable.map`. + * Project a JsonNode that may hold either a single string or an + * array of strings. JWT `aud` is one such claim: single-audience + * tokens serialize as a string, multi-audience as an array. + * Jackson 3.1's `JsonNode.map` is Optional-style (applies to the + * whole node) and shadows Kotlin's `Iterable.map`, hence the + * explicit iterator dance. */ - fun stringValues(node: JsonNode?): List = - node?.iterator()?.asSequence()?.map { it.asString() }?.toList().orEmpty() + fun stringValues(node: JsonNode?): List = when { + node == null || node.isMissingNode || node.isNull -> emptyList() + node.isArray -> node.iterator().asSequence().map { it.asString() }.toList() + else -> listOf(node.asString()) + } /** * Iterate the elements of an array-valued JsonNode and project via