diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 194e271..dc72f52 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -30,14 +30,14 @@ Compatibility mode is route-based and extensible: - converts selected `primary` booleans to string values - adds flattened enterprise manager alias key -Management security is profile-based: +Management security uses Auth0 OIDC: -- Default profile is `azure`, using interactive Azure OIDC login. -- `cloudflare` profile switches the management apps to JWT resource-server mode. -- Cloudflare mode reads the token from `Cf-Access-Jwt-Assertion` by default and maps roles from a configurable claim. +- Both management modules use standard Spring Security OAuth2 Client with Auth0 as the OIDC provider. +- Each module has its own `AUTH0_CLIENT_ID`, `AUTH0_CLIENT_SECRET`, and `AUTH0_ISSUER_URI`. +- Role claims are read from a configurable OIDC claim (`APP_SECURITY_OIDC_ROLE_CLAIM`, default `https://scimplayground.dev/roles`). - Management user persistence is email-based in both management modules; resolved emails are normalized and stored as the primary key. -- Management access now expects a usable email claim from OIDC/JWT principals. -- Shared helpers live in `scim-server-common` (`AzureOidcSecuritySupport`, `CloudflareJwtSecuritySupport`, `MgmtSecuritySupport`). +- Management access expects a usable email claim from OIDC principals. +- Shared helpers live in `scim-server-common` (`Auth0OidcSecuritySupport`, `MgmtSecuritySupport`). Kubernetes support is split into two trees: @@ -64,12 +64,14 @@ mvn clean install -Dskip.validator.tests=true # API local mode (requires datasource env vars and ACTUATOR_API_KEY) cd scim-server-api && mvn spring-boot:run -# Mgmt UI/API local mode (defaults to Azure profile; requires datasource env vars, -# ACTUATOR_API_KEY, and Azure OIDC env vars unless you explicitly set SPRING_PROFILES_ACTIVE=cloudflare) +# Mgmt UI/API local mode (requires datasource env vars, ACTUATOR_API_KEY, and +# Auth0 OIDC env vars: AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_ISSUER_URI, +# AUTH0_REDIRECT_URI — or set SPRING_PROFILES_ACTIVE=cloudflare for Cloudflare JWT mode) cd scim-server-mgmt && mvn spring-boot:run -# Validator management local mode (defaults to Azure profile; requires datasource env vars, -# ACTUATOR_API_KEY, and Azure OIDC env vars unless you explicitly set SPRING_PROFILES_ACTIVE=cloudflare) +# Validator management local mode (requires datasource env vars, ACTUATOR_API_KEY, and +# Auth0 OIDC env vars: AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_ISSUER_URI, +# AUTH0_REDIRECT_URI — or set SPRING_PROFILES_ACTIVE=cloudflare for Cloudflare JWT mode) cd scim-validator-mgmt && mvn spring-boot:run # Docker stack @@ -186,7 +188,7 @@ If you modify SCIM behavior, review impact across these areas: If you modify management authentication or deployment behavior, also review: -1. Both management modules' `AzureSecurityConfig` and `CloudflareSecurityConfig` +1. Both management modules' `SecurityConfig` 2. Shared helpers in `scim-server-common/src/main/java/.../security` 3. `docker-compose.yml` and `docker/env/*.env` 4. `k8s/app/**` and `k8s/cluster/**` diff --git a/README.md b/README.md index 8c043f9..4a3f8d0 100644 --- a/README.md +++ b/README.md @@ -63,9 +63,9 @@ playground service provider: | --- | --- | --- | --- | | `scim-server-common` | Shared JPA entities, repositories, and common security support | n/a | Imported by API and management modules | | `scim-server-api` | SCIM 2.0 provider API | `8080` | Stateless bearer-token auth per workspace | -| `scim-server-mgmt` | Thymeleaf management UI + management REST API | `8081` | Azure OIDC locally, Cloudflare Access JWT supported through the `cloudflare` profile | +| `scim-server-mgmt` | Thymeleaf management UI + management REST API | `8081` | Auth0 OIDC (interactive login); Cloudflare Access JWT in the `cloudflare` profile | | `scim-validator` | Groovy/Spock compliance suite | n/a | Builds a reusable test JAR consumed by validator-mgmt | -| `scim-validator-mgmt` | Validator execution UI + persistence | `8082` | Azure OIDC locally, Cloudflare Access JWT supported through the `cloudflare` profile | +| `scim-validator-mgmt` | Validator execution UI + persistence | `8082` | Auth0 OIDC (interactive login); Cloudflare Access JWT in the `cloudflare` profile | ### Request model @@ -182,15 +182,16 @@ The run currently executes these spec groups: The management applications support two deployment-facing authentication modes: -- `azure` profile, which is the default for manual local runs and uses - interactive Azure OIDC login +- Default mode uses Auth0 OIDC (Spring Security OAuth2 Client) for interactive + login. Required env vars: `AUTH0_CLIENT_ID`, `AUTH0_CLIENT_SECRET`, + `AUTH0_ISSUER_URI`, and `AUTH0_REDIRECT_URI`. - `cloudflare` profile, which switches the management apps to JWT resource server mode and validates the Cloudflare Access token from the configured request header, `Cf-Access-Jwt-Assertion` by default The Docker Compose env files and the Kubernetes manifests use the `cloudflare` -profile for the management applications. Manual local runs default to `azure` -unless you explicitly set `SPRING_PROFILES_ACTIVE=cloudflare`. +profile for the management applications. Manual local runs use the default +(Auth0 OIDC) mode unless you explicitly set `SPRING_PROFILES_ACTIVE=cloudflare`. ## Data Model Notes @@ -250,8 +251,7 @@ Some repository-specific implementation details matter if you extend the code: - Maven 3.9+ - Docker Desktop or compatible Docker Engine for the composed stack - PostgreSQL only if you want to run modules manually without Docker -- Microsoft Entra ID application registration if you want to use the management - UIs with Azure OIDC +- Auth0 application registration if you want to use the management UIs - `kubectl`, `kustomize`, `ksops`, `sops`, and an age private key if you want to apply the Kubernetes manifests directly from this repository - CloudNativePG installed in the target cluster if you want to use the provided @@ -402,14 +402,14 @@ export SPRING_DATASOURCE_PASSWORD=postgres export ACTUATOR_API_KEY=dev-actuator-key ``` -Azure OIDC profile for management apps (default): +Auth0 OIDC for management apps (default): ```bash -export AZURE_CLIENT_ID= -export AZURE_CLIENT_SECRET= -export AZURE_TENANT_ID= -export AZURE_SCOPES="openid,email,api:///usage" -export APP_SECURITY_AZURE_ROLE_CLAIM=roles +export AUTH0_CLIENT_ID= +export AUTH0_CLIENT_SECRET= +export AUTH0_ISSUER_URI=https:/// +export AUTH0_REDIRECT_URI=http://localhost:/login/oauth2/code/auth0 +export APP_SECURITY_OIDC_ROLE_CLAIM=/roles export APP_SECURITY_OIDC_ADMIN_ROLE=admin export APP_SECURITY_OIDC_USER_ROLE=user @@ -450,8 +450,7 @@ Use Docker Compose, Kubernetes, or run the modules manually. ### 2. Access the management UI -For the `azure` profile, open `http://localhost:8081` and sign in through the -configured Azure OIDC provider. +Open `http://localhost:8081` and sign in through Auth0 (default mode). For the `cloudflare` profile, place the application behind Cloudflare Access and let the proxy provide the access JWT header expected by the application. @@ -618,8 +617,8 @@ Project-specific conventions that matter when contributing: - static mapper utilities are heavily used for SCIM transformations - DTO layers in management applications make use of Java records - transactional boundaries in services and selected controllers are deliberate -- management security is profile-driven: Azure OIDC by default, Cloudflare JWT - resource-server mode when the `cloudflare` profile is active +- management security uses Auth0 OIDC by default; the `cloudflare` profile + switches to Cloudflare JWT resource-server mode - shared security helpers for the management apps live in `scim-server-common` If you add or change a SCIM attribute, align all of the following: diff --git a/docker-compose.yml b/docker-compose.yml index 609dd64..ddb06c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,6 @@ services: - "8081:8081" env_file: - ./docker/env/scim-server-mgmt.env - - ./docker/env/cloudflare.env environment: - SERVER_PORT=8081 depends_on: @@ -38,7 +37,6 @@ services: - "8082:8082" env_file: - ./docker/env/scim-validator-mgmt.env - - ./docker/env/cloudflare.env environment: - SERVER_PORT=8082 depends_on: diff --git a/k8s/app/scim-server-mgmt/configmap.yaml b/k8s/app/scim-server-mgmt/configmap.yaml index de27dd8..db046eb 100644 --- a/k8s/app/scim-server-mgmt/configmap.yaml +++ b/k8s/app/scim-server-mgmt/configmap.yaml @@ -4,14 +4,9 @@ metadata: name: scim-server-mgmt-k3s-config data: SERVER_PORT: "8081" - SPRING_PROFILES_ACTIVE: cloudflare SPRING_DATASOURCE_URL: jdbc:postgresql://scim-postgres-rw:5432/scimplayground + AUTH0_REDIRECT_URI: https://ui.scimsandbox.net/login/oauth2/code/auth0 APP_SCIM_API_BASE_URL: https://api.scimsandbox.net - APP_SECURITY_CLOUDFLARE_ROLE_CLAIM: https://scimsandbox.net/roles + APP_SECURITY_OIDC_ROLE_CLAIM: https://scimplayground.dev/roles APP_SECURITY_OIDC_ADMIN_ROLE: admin - APP_SECURITY_OIDC_USER_ROLE: user - CLOUDFLARE_ACCESS_ISSUER_URI: https://scimsandbox.cloudflareaccess.com - CLOUDFLARE_ACCESS_AUDIENCE: 5a682d5f1eb4ec59c07c916f28fe4420660b186656c5f1ae16fb231d012ec914 - CLOUDFLARE_ACCESS_JWK_SET_URI: https://scimsandbox.cloudflareaccess.com/cdn-cgi/access/certs - CLOUDFLARE_ACCESS_LOGOUT_URL: https://scimsandbox.cloudflareaccess.com/cdn-cgi/access/logout - CLOUDFLARE_ACCESS_TOKEN_HEADER: Cf-Access-Jwt-Assertion \ No newline at end of file + APP_SECURITY_OIDC_USER_ROLE: user \ No newline at end of file diff --git a/k8s/app/scim-server-mgmt/secrets/secret.sops.yaml b/k8s/app/scim-server-mgmt/secrets/secret.sops.yaml index 0660c81..f4ffdb5 100644 --- a/k8s/app/scim-server-mgmt/secrets/secret.sops.yaml +++ b/k8s/app/scim-server-mgmt/secrets/secret.sops.yaml @@ -5,10 +5,9 @@ metadata: type: Opaque stringData: ACTUATOR_API_KEY: ENC[AES256_GCM,data:NBNrxcKvNgBZ6HIwWt3yWNJ55VY3qu+DecOOs7cLevU=,iv:tIzmjAtqaFjCWg9VPgWk1eunJgLKfPG1TVTaySl5ax4=,tag:0t9JfRBln8T4q+dUapNXWQ==,type:str] - AZURE_CLIENT_ID: ENC[AES256_GCM,data:ddp6MlmrTWj1ChCrefdL3rMoIuOr75wFZ6JuqG1itxHIRICH,iv:JiGxcfHGMFvqThIpczYvDVkwC1IJ4I4hRZO7UXAOUxQ=,tag:kvjNZtW2L/MuaGKoZ3MxCw==,type:str] - AZURE_CLIENT_SECRET: ENC[AES256_GCM,data:k9LuKBzBxGE8/fJzeU/KFZaxKr8owo2P690aE8Cr2+C6FSyOOTlGFg==,iv:7o8l7wLA4AhYVsc3w48dsRxNyDnyyWudNlDpzsCCLO4=,tag:edJGt0hWOLnaFQ7N0FLdQw==,type:str] - AZURE_TENANT_ID: ENC[AES256_GCM,data:pYk1sI18AT9qviXfSKqL8O3KbqXTM+EcMUEI/BjbkDW7Boof,iv:ni82wW7uMNCxOmkMPl7yy04DgKDv7mjGie/UGEaBrXw=,tag:YUdlWZ2bFS+mMRSJsXgqHg==,type:str] - AZURE_SCOPES: ENC[AES256_GCM,data:Nrx4BUz9K3D+gEzmFMyR8V9pKHQ4mTtP9TTLlrHTBrIskH9D/N7OWnUwMuUlpyb7SD57/h53pG3pY2zifg==,iv:jpo777CTVGbS9qTROD+XR0NSenMKpsh0Eokkn/z9iVA=,tag:BtzF+T9SPf5y6MhPC9089w==,type:str] + AUTH0_CLIENT_ID: ENC[AES256_GCM,data:5V8zE+VVVCAd79vTloTNdBaNCTUNkx2/vmbThMMtDzk=,iv:i1dL4uaV5cBL5PTf2ZZk+ElkuFMjjJ1r/qFArbby6Tk=,tag:+swsxiuEESokN2Gq1GrgtQ==,type:str] + AUTH0_CLIENT_SECRET: ENC[AES256_GCM,data:FM0Kz+a/9m0O1dEUBZk6jAMmoNaLfT3trFhpPPkYdxE2N93nIUfmPB2NPZoWBCD6rfG49o/m3ayEK/Xvl5FHEQ==,iv:E8KBDydU7JE0557otwODSdbuYGMTzvwLnejF47cUjqw=,tag:yEyGZl5rP9x3U6K1PtYm9A==,type:str] + AUTH0_ISSUER_URI: ENC[AES256_GCM,data:g6dX7zTbpNKJhOzBMXEGGuiagqTdZYOKouuOKwU=,iv:XIV5LDlrVOnEf1ylpMSzfc6Cmt7LYI6UM73zrRAHXbM=,tag:0U+SQgcK+l9oDDcbF+Kqxg==,type:str] sops: age: - recipient: age1j0ka5qnc6cpldfavwstqg2u6k536ymxcjeatlceraa09dgvetq9s07jkkh @@ -20,7 +19,7 @@ sops: T2JreVVlVEt1S3J5LzZRekVSVFY4c0kKyOV5MRLGnYyWLyzcHa9UmfItp2d/hKsX b2duPUECnG01v19Hxkwo/UdJD/yIYgTvHpCl2oih/plqCO3baEmIqQ== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-04-10T14:44:04Z" - mac: ENC[AES256_GCM,data:KoHHmv3vdFUHBv/Jm7MHrIuevcmOI93q9LbLshn0PXRf2FiJLxuib2DVbwEkDux1e3j9m5yKC5XGGBExcW4WBHe75midHPR3/MrY3pmn3sFYi82Mjz0vrYc73ogPCRkIAwoCEhat0XTv7SAvInbZsicIu84TB/iVinTg/ruVCqQ=,iv:xRhcmX5ZlmJuvBYxCszfBLbAxFOgofOrb3d59+WVWv0=,tag:fFjxDw+sAjqbdD5nvbQVCw==,type:str] + lastmodified: "2026-04-11T09:07:15Z" + mac: ENC[AES256_GCM,data:ZlRh43Q039OBDIKAo79IvCdWFlxY98eGQK8FSiokC1k0/g58Fqu0BF3yQnJCcN245qmQeDkxq2K7JBqn+T6ptY5PNxXV/bSQmVGAK/KLJ8fABKFfi+xstMFUuuyiEItrzGUuwt/80Rm+WaWqHMZKL6HiTQ5mXMbO2vS9/4TCyKQ=,iv:bAGhHR3NCRzbPdXV43+w7W3cP0RFtmhCC+9sM+WF33o=,tag:/PvCGNUrGmF36tjBPNGwRA==,type:str] encrypted_regex: ^(data|stringData)$ version: 3.12.2 diff --git a/k8s/app/scim-validator-mgmt/configmap.yaml b/k8s/app/scim-validator-mgmt/configmap.yaml index 58d24d6..781b9cd 100644 --- a/k8s/app/scim-validator-mgmt/configmap.yaml +++ b/k8s/app/scim-validator-mgmt/configmap.yaml @@ -4,14 +4,9 @@ metadata: name: scim-validator-mgmt-k3s-config data: SERVER_PORT: "8082" - SPRING_PROFILES_ACTIVE: cloudflare SPRING_DATASOURCE_URL: jdbc:postgresql://scim-postgres-rw:5432/scimvalidation SPRING_DATASOURCE_DRIVER_CLASS_NAME: org.postgresql.Driver - APP_SECURITY_CLOUDFLARE_ROLE_CLAIM: https://scimsandbox.net/roles + AUTH0_REDIRECT_URI: https://val.scimsandbox.net/login/oauth2/code/auth0 + APP_SECURITY_OIDC_ROLE_CLAIM: https://scimplayground.dev/roles APP_SECURITY_OIDC_ADMIN_ROLE: admin - APP_SECURITY_OIDC_USER_ROLE: user - CLOUDFLARE_ACCESS_ISSUER_URI: https://scimsandbox.cloudflareaccess.com - CLOUDFLARE_ACCESS_AUDIENCE: 9b1ea9fac999e94d6d2522a61d4323ac8ca4f5759c2fbc73fe489a034fc51627 - CLOUDFLARE_ACCESS_JWK_SET_URI: https://scimsandbox.cloudflareaccess.com/cdn-cgi/access/certs - CLOUDFLARE_ACCESS_LOGOUT_URL: https://scimsandbox.cloudflareaccess.com/cdn-cgi/access/logout - CLOUDFLARE_ACCESS_TOKEN_HEADER: Cf-Access-Jwt-Assertion \ No newline at end of file + APP_SECURITY_OIDC_USER_ROLE: user \ No newline at end of file diff --git a/k8s/app/scim-validator-mgmt/secrets/secret.sops.yaml b/k8s/app/scim-validator-mgmt/secrets/secret.sops.yaml index 2264963..a2601b0 100644 --- a/k8s/app/scim-validator-mgmt/secrets/secret.sops.yaml +++ b/k8s/app/scim-validator-mgmt/secrets/secret.sops.yaml @@ -5,10 +5,9 @@ metadata: type: Opaque stringData: ACTUATOR_API_KEY: ENC[AES256_GCM,data:Ov1SSGKeXg6oEEL9OnROAD0hAvyOBxxrS/hen3cm4xM=,iv:HOswAW1cweaOgdA7uHFK+HbkFf4kPS4FfzbyCZqCkYg=,tag:92voVF9lnyOeT/hJWcojAQ==,type:str] - AZURE_CLIENT_ID: ENC[AES256_GCM,data:D8BUR3o0edi2BI+o+w0VQaPodn9dqOY5XeiUYJoVgrdmo7nI,iv:TgoW2GoAodCeDE+QzADtcN3QBHb/GhgSDp90RUvGjdY=,tag:7jmgtEmvWiwA0Kd2d3osag==,type:str] - AZURE_CLIENT_SECRET: ENC[AES256_GCM,data:gC7N54Ej81AbJMGoZ4LlOlY4HTAGspP5dH5bjxm4twUzRyCwF/ujIw==,iv:h5ID0cwjmHJYNUqHkybZgZHQ/VCva2bkqYcYJETSiV8=,tag:VU/mTCSyuWjukbi9es0j0Q==,type:str] - AZURE_TENANT_ID: ENC[AES256_GCM,data:XrqQK31TIyi42Ximvw3EBSjQU4bLK75ps8a4jqHb5SfjoaOn,iv:xC6xPTm6YtZQaHAruQlVEHCajgkVJ7Bysnw6cWOcpUU=,tag:QP0MmktwCIGaIRpQRZb16Q==,type:str] - AZURE_SCOPES: ENC[AES256_GCM,data:cd9cIP5bIANFMiHUb5CHKaRHwzey0RIe6dTQd0HzJ8g4y7d+tGJyQ7ANHh3pw+vA7V0ov0349FkQk0obAQ==,iv:1YjY3PJsJe+jm34IEWjQqmZjld4C5+8KNbDkvvY34gc=,tag:A4uXVB+X80crNqtHdLpuWw==,type:str] + AUTH0_CLIENT_ID: ENC[AES256_GCM,data:Rm9Wk2YhPlIuPqmwgo1for8T6bFm72vDFVU9b9/xPog=,iv:/6F4KMS+wqBPnz4+uXMKAV4uxYqMTMXU71C1EGEjhb4=,tag:UX70hLsCqNQoB8nnngW/WQ==,type:str] + AUTH0_CLIENT_SECRET: ENC[AES256_GCM,data:bkdQmX921hQV6wmNXwbG+TD2zwlvK1k+z8Cuzs9EXR58UMxzd8x7zoLM9NjbZ98/1+RElBjofwpIo/ZVzTWHmw==,iv:KNV6o2EjenKZW5DyvfHrnge2xkmxENvJVo7GG6aYL34=,tag:jo7i1lHYI8A2EO7ardY1EQ==,type:str] + AUTH0_ISSUER_URI: ENC[AES256_GCM,data:vkxGnH6Bsp9Xpz8oBaZDJpZJIdbsE8M/ZtnDSGk=,iv:NrZdlKqPcRyA+1USzn8hWlULsO+/kxzeNdo2wohNHhg=,tag:Y7hLZnj5CxptCaziJ8ZIaQ==,type:str] sops: age: - recipient: age1j0ka5qnc6cpldfavwstqg2u6k536ymxcjeatlceraa09dgvetq9s07jkkh @@ -20,7 +19,7 @@ sops: VExGZGtXWWJXOHJkb1paZHhTRHViNXMKoJYy5PatO+SFoJy93IUkqYAt1JZlexnM yVmxa66O6j9J5KGmgWuCcGF4AVLGql58QZqXElX2voPY4Hg2C/LDHA== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-04-10T14:14:44Z" - mac: ENC[AES256_GCM,data:hhX6DnEKPJgYz4i6POjszrFYmjIbTUdUe88ybf/9wcMWaXO5Yj6gpaId+GKt1nh++udVgxXUdIDdUuft9nQ7r+2qag7OICDSCaMi0uf39j6vNuezeCkkxuSGo/N0I2F3hIxTaPrE53fbszA57PVl8cRFImI9i8CRSEkbHOZvG6w=,iv:xFuczRFOpheXS5N5Xydnmx7nRQlTAbrMyZ001shmm6Q=,tag:n3RicYLM3y7AUCETZRkISg==,type:str] + lastmodified: "2026-04-11T09:08:54Z" + mac: ENC[AES256_GCM,data:oHVWVZ7YULV2FTU/0WtbZvNpDcYyiW2FgtpFZ1bnHo+JrZoaaGrYxwU9VTS1g+IfM8tkfGnwNgXAiW0UdhyjndE4OROEzIXQzZY/IZ4eTRtlZNriXomVO6E7qy2rI2e3m7JXiPaMN13ByOc+nJs9FNLQzCgkd9moSoAD622YD70=,iv:LnQsjeH6zuEY5ZsxqvM9NMaNyIJ1C0ADew/rmqrn1BE=,tag:6a5diLqJ+NaAm7sewE6jSg==,type:str] encrypted_regex: ^(data|stringData)$ version: 3.12.2 diff --git a/scim-server-api/src/main/java/de/palsoftware/scim/server/api/config/SecurityConfig.java b/scim-server-api/src/main/java/de/palsoftware/scim/server/api/config/SecurityConfig.java index 18e1693..9f16a1e 100644 --- a/scim-server-api/src/main/java/de/palsoftware/scim/server/api/config/SecurityConfig.java +++ b/scim-server-api/src/main/java/de/palsoftware/scim/server/api/config/SecurityConfig.java @@ -38,7 +38,7 @@ public SecurityConfig(WorkspaceTokenRepository tokenRepository, private RequestMatcher scimPaths() { return request -> { String uri = request.getRequestURI(); - return uri != null && uri.contains("/scim/v2"); + return uri != null && uri.startsWith("/ws/") && uri.contains("/scim/v2"); }; } diff --git a/scim-server-api/src/main/java/de/palsoftware/scim/server/api/controller/ScimBulkController.java b/scim-server-api/src/main/java/de/palsoftware/scim/server/api/controller/ScimBulkController.java index 2db2699..097b3f5 100644 --- a/scim-server-api/src/main/java/de/palsoftware/scim/server/api/controller/ScimBulkController.java +++ b/scim-server-api/src/main/java/de/palsoftware/scim/server/api/controller/ScimBulkController.java @@ -117,7 +117,14 @@ private static int parseFailOnErrors(Object failOnErrorsObj) { private static boolean isErrorResult(Map result) { String status = (String) result.get(KEY_STATUS); - return status != null && Integer.parseInt(status) >= 400; + if (status == null) { + return false; + } + try { + return Integer.parseInt(status) >= 400; + } catch (NumberFormatException e) { + return false; + } } private static boolean shouldStopProcessing(int failOnErrors, int errorCount) { diff --git a/scim-server-api/src/main/java/de/palsoftware/scim/server/api/scim/patch/ScimPatchEngine.java b/scim-server-api/src/main/java/de/palsoftware/scim/server/api/scim/patch/ScimPatchEngine.java index 123d6d0..26d0a5e 100644 --- a/scim-server-api/src/main/java/de/palsoftware/scim/server/api/scim/patch/ScimPatchEngine.java +++ b/scim-server-api/src/main/java/de/palsoftware/scim/server/api/scim/patch/ScimPatchEngine.java @@ -69,7 +69,11 @@ public static void applyPatchOperations(ScimUser user, List> } for (Map op : operations) { - String opType = ((String) op.get("op")).toLowerCase(); + Object rawOp = op.get("op"); + if (!(rawOp instanceof String)) { + throw new ScimException(400, "invalidValue", "PATCH operation must include a string 'op' field"); + } + String opType = ((String) rawOp).toLowerCase(); String path = (String) op.get("path"); Object value = op.get(KEY_VALUE); diff --git a/scim-server-api/src/main/java/de/palsoftware/scim/server/api/scim/schema/ScimSchemaDefinitions.java b/scim-server-api/src/main/java/de/palsoftware/scim/server/api/scim/schema/ScimSchemaDefinitions.java index ed2032d..bc29460 100644 --- a/scim-server-api/src/main/java/de/palsoftware/scim/server/api/scim/schema/ScimSchemaDefinitions.java +++ b/scim-server-api/src/main/java/de/palsoftware/scim/server/api/scim/schema/ScimSchemaDefinitions.java @@ -85,7 +85,7 @@ public static Map serviceProviderConfig() { PAGINATION_INDEX, true, "defaultPaginationMode", PAGINATION_INDEX, "defaultPageSize", 10, - "maxPageSize", 100, + "maxPageSize", 200, "cursorTimeout", 3600)); config.put("changePassword", Map.of(ATTR_SUPPORTED, false)); config.put("sort", Map.of(ATTR_SUPPORTED, true)); diff --git a/scim-server-api/src/main/java/de/palsoftware/scim/server/api/security/BearerTokenAuthFilter.java b/scim-server-api/src/main/java/de/palsoftware/scim/server/api/security/BearerTokenAuthFilter.java index d9a8b08..c785246 100644 --- a/scim-server-api/src/main/java/de/palsoftware/scim/server/api/security/BearerTokenAuthFilter.java +++ b/scim-server-api/src/main/java/de/palsoftware/scim/server/api/security/BearerTokenAuthFilter.java @@ -53,7 +53,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse try { workspaceId = UUID.fromString(workspaceIdStr); } catch (IllegalArgumentException e) { - sendScimError(response, 404, null, "Invalid workspace ID: " + workspaceIdStr); + sendScimError(response, 404, null, "Invalid workspace ID"); return; } diff --git a/scim-server-api/src/main/resources/application.yml b/scim-server-api/src/main/resources/application.yml index 40951ce..914e238 100644 --- a/scim-server-api/src/main/resources/application.yml +++ b/scim-server-api/src/main/resources/application.yml @@ -21,7 +21,7 @@ spring: logging: level: - "[com.scimplayground]": DEBUG + "[de.palsoftware.scim]": DEBUG "[org.springframework.security]": WARN management: diff --git a/scim-server-api/src/test/java/de/palsoftware/scim/server/api/logging/RequestResponseLoggingFilterTest.java b/scim-server-api/src/test/java/de/palsoftware/scim/server/api/logging/RequestResponseLoggingFilterTest.java new file mode 100644 index 0000000..e36e567 --- /dev/null +++ b/scim-server-api/src/test/java/de/palsoftware/scim/server/api/logging/RequestResponseLoggingFilterTest.java @@ -0,0 +1,76 @@ +package de.palsoftware.scim.server.api.logging; + +import de.palsoftware.scim.server.common.model.ScimRequestLog; +import de.palsoftware.scim.server.common.model.Workspace; +import de.palsoftware.scim.server.common.repository.ScimRequestLogRepository; +import de.palsoftware.scim.server.common.repository.WorkspaceRepository; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RequestResponseLoggingFilterTest { + + private ScimRequestLogRepository logRepository; + private WorkspaceRepository workspaceRepository; + private RequestResponseLoggingFilter filter; + private UUID workspaceId; + + @BeforeEach + void setUp() { + logRepository = Mockito.mock(ScimRequestLogRepository.class); + workspaceRepository = Mockito.mock(WorkspaceRepository.class); + filter = new RequestResponseLoggingFilter(logRepository, workspaceRepository); + workspaceId = UUID.randomUUID(); + + Workspace workspace = new Workspace(); + workspace.setId(workspaceId); + + when(workspaceRepository.getReferenceById(workspaceId)).thenReturn(workspace); + when(logRepository.save(any(ScimRequestLog.class))).thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + void requestAndResponseBodies_AreStoredWithoutRedaction() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("POST"); + request.setRequestURI("/ws/" + workspaceId + "/scim/v2/Users"); + request.setCharacterEncoding(StandardCharsets.UTF_8.name()); + request.setContentType("application/scim+json"); + request.setContent(("{\"userName\":\"alice\",\"password\":\"s\\\"ecret\"," + + "\"nested\":{\"password\":\"another-secret\"}}") + .getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + FilterChain chain = (servletRequest, servletResponse) -> { + servletRequest.getInputStream().readAllBytes(); + HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse; + httpServletResponse.setCharacterEncoding(StandardCharsets.UTF_8.name()); + httpServletResponse.setStatus(HttpServletResponse.SC_CREATED); + httpServletResponse.getWriter().write("{\"password\":\"reply-secret\",\"detail\":\"created\"}"); + }; + + filter.doFilter(request, response, chain); + + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(ScimRequestLog.class); + verify(logRepository).save(logCaptor.capture()); + + ScimRequestLog savedLog = logCaptor.getValue(); + assertEquals("{\"userName\":\"alice\",\"password\":\"s\\\"ecret\",\"nested\":{\"password\":\"another-secret\"}}", + savedLog.getRequestBody()); + assertEquals("{\"password\":\"reply-secret\",\"detail\":\"created\"}", savedLog.getResponseBody()); + assertEquals(HttpServletResponse.SC_CREATED, savedLog.getStatus()); + assertEquals("{\"password\":\"reply-secret\",\"detail\":\"created\"}", response.getContentAsString()); + } +} diff --git a/scim-server-common/pom.xml b/scim-server-common/pom.xml index b397b1a..8e6fa3c 100644 --- a/scim-server-common/pom.xml +++ b/scim-server-common/pom.xml @@ -45,14 +45,7 @@ org.springframework.security spring-security-oauth2-client - - org.springframework.security - spring-security-oauth2-jose - - - org.springframework.security - spring-security-oauth2-resource-server - + jakarta.servlet jakarta.servlet-api diff --git a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/model/Workspace.java b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/model/Workspace.java index 9c7733d..edfaab8 100644 --- a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/model/Workspace.java +++ b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/model/Workspace.java @@ -5,14 +5,18 @@ import java.util.UUID; @Entity -@Table(name = "workspaces") +@Table( + name = "workspaces", + uniqueConstraints = @UniqueConstraint( + name = "uk_workspaces_created_by_username_name", + columnNames = {"created_by_username", "name"})) public class Workspace { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; - @Column(unique = true, nullable = false) + @Column(nullable = false) private String name; private String description; diff --git a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/repository/WorkspaceRepository.java b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/repository/WorkspaceRepository.java index 24f1091..3d0bf02 100644 --- a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/repository/WorkspaceRepository.java +++ b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/repository/WorkspaceRepository.java @@ -12,8 +12,6 @@ import java.util.UUID; public interface WorkspaceRepository extends JpaRepository { - Optional findByName(String name); - Optional findByIdAndCreatedByUsername(UUID id, String createdByUsername); List findByCreatedByUsernameOrderByCreatedAtDesc(String createdByUsername); @@ -39,5 +37,5 @@ public interface WorkspaceRepository extends JpaRepository { """) int deleteByUpdatedAtBefore(@Param("cutoff") Instant cutoff); - boolean existsByName(String name); + boolean existsByNameAndCreatedByUsername(String name, String createdByUsername); } diff --git a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/ActuatorApiKeyAuthFilter.java b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/ActuatorApiKeyAuthFilter.java index 4ed299b..37d0aad 100644 --- a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/ActuatorApiKeyAuthFilter.java +++ b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/ActuatorApiKeyAuthFilter.java @@ -7,7 +7,8 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -import java.util.Objects; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; public class ActuatorApiKeyAuthFilter extends OncePerRequestFilter { @@ -31,7 +32,9 @@ protected boolean shouldNotFilter(HttpServletRequest request) { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String providedApiKey = request.getHeader(API_KEY_HEADER); - if (!Objects.equals(actuatorApiKey, providedApiKey)) { + if (providedApiKey == null || !MessageDigest.isEqual( + actuatorApiKey.getBytes(StandardCharsets.UTF_8), + providedApiKey.getBytes(StandardCharsets.UTF_8))) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing or invalid actuator API key"); return; } diff --git a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/AzureOidcSecuritySupport.java b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/Auth0OidcSecuritySupport.java similarity index 96% rename from scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/AzureOidcSecuritySupport.java rename to scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/Auth0OidcSecuritySupport.java index 0aff191..d835d0a 100644 --- a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/AzureOidcSecuritySupport.java +++ b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/Auth0OidcSecuritySupport.java @@ -12,9 +12,9 @@ import java.util.Set; import java.util.function.Consumer; -public final class AzureOidcSecuritySupport { +public final class Auth0OidcSecuritySupport { - private AzureOidcSecuritySupport() { + private Auth0OidcSecuritySupport() { } public static OidcUserService createOidcUserService(String roleClaim, @@ -56,4 +56,4 @@ private static String requireEmail(OidcUser oidcUser) { throw new OAuth2AuthenticationException( new OAuth2Error("invalid_token", "An email claim is required for management access", null)); } -} \ No newline at end of file +} diff --git a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/CloudflareJwtSecuritySupport.java b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/CloudflareJwtSecuritySupport.java deleted file mode 100644 index 23fdd41..0000000 --- a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/CloudflareJwtSecuritySupport.java +++ /dev/null @@ -1,115 +0,0 @@ -package de.palsoftware.scim.server.common.security; - -import org.springframework.core.convert.converter.Converter; -import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.OAuth2Error; -import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; -import org.springframework.security.oauth2.core.OAuth2TokenValidator; -import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.JwtValidators; -import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; -import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; - -import java.util.HashSet; -import java.util.Map; -import java.util.Set; -import java.util.function.Consumer; - -public final class CloudflareJwtSecuritySupport { - - private static final String INVALID_TOKEN_CODE = "invalid_token"; - - private CloudflareJwtSecuritySupport() { - } - - public static BearerTokenResolver createBearerTokenResolver(String tokenHeader) { - return request -> { - String token = request.getHeader(tokenHeader); - if (token == null || token.isBlank()) { - return null; - } - return token; - }; - } - - public static JwtDecoder createJwtDecoder(String issuerUri, String audience, String jwkSetUri) { - NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(resolveJwkSetUri(issuerUri, jwkSetUri)).build(); - OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>( - JwtValidators.createDefaultWithIssuer(issuerUri), - jwt -> validateAudience(jwt, audience)); - decoder.setJwtValidator(validator); - return decoder; - } - - public static Converter createJwtAuthenticationConverter(String roleClaim, - String adminRole, - String userRole, - Consumer userProvisioner) { - JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); - converter.setPrincipalClaimName("sub"); - converter.setJwtGrantedAuthoritiesConverter(jwt -> { - Set mappedAuthorities = new HashSet<>(); - Object roleClaimValue = resolveRoleClaimValue(jwt, roleClaim); - MgmtSecuritySupport.addMappedAuthorities( - MgmtSecuritySupport.extractClaimValues(roleClaimValue), - mappedAuthorities, - adminRole, - userRole); - return mappedAuthorities; - }); - return jwt -> { - String email = requireEmail(jwt); - AbstractAuthenticationToken authentication = converter.convert(jwt); - if (authentication == null) { - throw new OAuth2AuthenticationException( - new OAuth2Error(INVALID_TOKEN_CODE, "Unable to authenticate Cloudflare token", null)); - } - userProvisioner.accept(email); - return authentication; - }; - } - - private static Object resolveRoleClaimValue(Jwt jwt, String roleClaim) { - Object directClaimValue = jwt.getClaim(roleClaim); - if (directClaimValue != null) { - return directClaimValue; - } - Object customClaim = jwt.getClaim("custom"); - if (customClaim instanceof Map customClaims) { - return customClaims.get(roleClaim); - } - return null; - } - - private static OAuth2TokenValidatorResult validateAudience(Jwt jwt, String audience) { - if (jwt.getAudience().contains(audience)) { - return OAuth2TokenValidatorResult.success(); - } - return OAuth2TokenValidatorResult.failure( - new OAuth2Error(INVALID_TOKEN_CODE, "The required audience is missing", null)); - } - - private static String resolveJwkSetUri(String issuerUri, String jwkSetUri) { - if (jwkSetUri != null && !jwkSetUri.isBlank()) { - return jwkSetUri; - } - if (issuerUri.endsWith("/")) { - return issuerUri + "cdn-cgi/access/certs"; - } - return issuerUri + "/cdn-cgi/access/certs"; - } - - private static String requireEmail(Jwt jwt) { - String email = PrincipalEmailSupport.resolveEmail(jwt); - if (email != null) { - return email; - } - throw new OAuth2AuthenticationException( - new OAuth2Error(INVALID_TOKEN_CODE, "An email claim is required for management access", null)); - } -} \ No newline at end of file diff --git a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/PrincipalEmailSupport.java b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/PrincipalEmailSupport.java index 6d99d68..9f5ec85 100644 --- a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/PrincipalEmailSupport.java +++ b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/PrincipalEmailSupport.java @@ -1,10 +1,8 @@ package de.palsoftware.scim.server.common.security; import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.security.oauth2.jwt.Jwt; import java.util.Locale; -import java.util.Map; public final class PrincipalEmailSupport { @@ -22,17 +20,6 @@ public static String resolveEmail(OidcUser oidcUser) { oidcUser.getPreferredUsername())); } - public static String resolveEmail(Jwt jwt) { - if (jwt == null) { - return null; - } - return normalizeEmail(firstNonBlank( - claimAsString(jwt, "email"), - claimAsString(jwt, "upn"), - claimAsString(jwt, "unique_name"), - claimAsString(jwt, "preferred_username"))); - } - public static String normalizeEmail(String value) { if (value == null) { return null; @@ -44,21 +31,6 @@ public static String normalizeEmail(String value) { return normalized; } - private static String claimAsString(Jwt jwt, String claimName) { - String directClaim = jwt.getClaimAsString(claimName); - if (directClaim != null && !directClaim.isBlank()) { - return directClaim; - } - Object customClaim = jwt.getClaim("custom"); - if (customClaim instanceof Map customClaims) { - Object nestedClaim = customClaims.get(claimName); - if (nestedClaim instanceof String nestedString && !nestedString.isBlank()) { - return nestedString; - } - } - return null; - } - private static String firstNonBlank(String... values) { for (String value : values) { if (value != null && !value.isBlank()) { diff --git a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/TokenSecurityUtil.java b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/TokenSecurityUtil.java index 2f4accb..6147324 100644 --- a/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/TokenSecurityUtil.java +++ b/scim-server-common/src/main/java/de/palsoftware/scim/server/common/security/TokenSecurityUtil.java @@ -10,17 +10,13 @@ public final class TokenSecurityUtil { private static final SecureRandom SECURE_RANDOM = new SecureRandom(); - // We enforce a minimum of 64 bytes (512 bits) for strong cryptographic tokens. - private static final int MIN_TOKEN_BYTES = 64; + private static final int WORKSPACE_TOKEN_BYTES = 64; private TokenSecurityUtil() { } - public static String generateUrlSafeToken(int randomBytesLength) { - if (randomBytesLength < MIN_TOKEN_BYTES) { - throw new IllegalArgumentException("Token length must be at least " + MIN_TOKEN_BYTES + " bytes for security reasons."); - } - byte[] randomBytes = new byte[randomBytesLength]; + public static String generateSecureToken() { + byte[] randomBytes = new byte[WORKSPACE_TOKEN_BYTES]; SECURE_RANDOM.nextBytes(randomBytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); } diff --git a/scim-server-common/src/main/resources/db/common/V1__init_common_schema.sql b/scim-server-common/src/main/resources/db/common/V1__init_common_schema.sql index de5d037..442f0f5 100644 --- a/scim-server-common/src/main/resources/db/common/V1__init_common_schema.sql +++ b/scim-server-common/src/main/resources/db/common/V1__init_common_schema.sql @@ -1,12 +1,15 @@ CREATE TABLE workspaces ( id UUID PRIMARY KEY, - name VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, description VARCHAR(255), created_by_username VARCHAR(500), created_at TIMESTAMP WITH TIME ZONE NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL ); +ALTER TABLE workspaces + ADD CONSTRAINT uk_workspaces_created_by_username_name UNIQUE (created_by_username, name); + CREATE INDEX idx_workspaces_created_by_username ON workspaces (created_by_username); CREATE INDEX idx_workspaces_updated_at ON workspaces (updated_at); CREATE INDEX idx_workspaces_created_at ON workspaces (created_at DESC); @@ -45,6 +48,14 @@ CREATE TABLE scim_users ( enterprise_manager_value VARCHAR(255), enterprise_manager_ref VARCHAR(255), enterprise_manager_display VARCHAR(255), + emails JSON, + phone_numbers JSON, + addresses JSON, + entitlements JSON, + roles JSON, + ims JSON, + photos JSON, + x509_certificates JSON, created_at TIMESTAMP WITH TIME ZONE NOT NULL, last_modified TIMESTAMP WITH TIME ZONE NOT NULL, version BIGINT, @@ -70,122 +81,6 @@ CREATE TABLE scim_groups ( CREATE INDEX idx_group_external_id ON scim_groups (workspace_id, external_id); -CREATE TABLE scim_user_emails ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - workspace_id UUID NOT NULL, - attr_value VARCHAR(255), - type VARCHAR(255), - display VARCHAR(255), - primary_flag BOOLEAN NOT NULL, - CONSTRAINT fk_scim_user_emails_user FOREIGN KEY (user_id, workspace_id) - REFERENCES scim_users (id, workspace_id) ON DELETE CASCADE -); - -CREATE INDEX idx_user_emails_workspace_user_id ON scim_user_emails (workspace_id, user_id); - -CREATE TABLE scim_user_phone_numbers ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - workspace_id UUID NOT NULL, - attr_value VARCHAR(255), - type VARCHAR(255), - display VARCHAR(255), - primary_flag BOOLEAN NOT NULL, - CONSTRAINT fk_scim_user_phone_numbers_user FOREIGN KEY (user_id, workspace_id) - REFERENCES scim_users (id, workspace_id) ON DELETE CASCADE -); - -CREATE INDEX idx_user_phone_numbers_workspace_user_id ON scim_user_phone_numbers (workspace_id, user_id); - -CREATE TABLE scim_user_addresses ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - workspace_id UUID NOT NULL, - formatted VARCHAR(255), - street_address VARCHAR(255), - locality VARCHAR(255), - region VARCHAR(255), - postal_code VARCHAR(255), - country VARCHAR(255), - type VARCHAR(255), - primary_flag BOOLEAN NOT NULL, - CONSTRAINT fk_scim_user_addresses_user FOREIGN KEY (user_id, workspace_id) - REFERENCES scim_users (id, workspace_id) ON DELETE CASCADE -); - -CREATE INDEX idx_user_addresses_workspace_user_id ON scim_user_addresses (workspace_id, user_id); - -CREATE TABLE scim_user_entitlements ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - workspace_id UUID NOT NULL, - attr_value VARCHAR(255), - type VARCHAR(255), - display VARCHAR(255), - primary_flag BOOLEAN NOT NULL, - CONSTRAINT fk_scim_user_entitlements_user FOREIGN KEY (user_id, workspace_id) - REFERENCES scim_users (id, workspace_id) ON DELETE CASCADE -); - -CREATE INDEX idx_user_entitlements_workspace_user_id ON scim_user_entitlements (workspace_id, user_id); - -CREATE TABLE scim_user_roles ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - workspace_id UUID NOT NULL, - attr_value VARCHAR(255), - type VARCHAR(255), - display VARCHAR(255), - primary_flag BOOLEAN NOT NULL, - CONSTRAINT fk_scim_user_roles_user FOREIGN KEY (user_id, workspace_id) - REFERENCES scim_users (id, workspace_id) ON DELETE CASCADE -); - -CREATE INDEX idx_user_roles_workspace_user_id ON scim_user_roles (workspace_id, user_id); - -CREATE TABLE scim_user_ims ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - workspace_id UUID NOT NULL, - attr_value VARCHAR(255), - type VARCHAR(255), - display VARCHAR(255), - primary_flag BOOLEAN NOT NULL, - CONSTRAINT fk_scim_user_ims_user FOREIGN KEY (user_id, workspace_id) - REFERENCES scim_users (id, workspace_id) ON DELETE CASCADE -); - -CREATE INDEX idx_user_ims_workspace_user_id ON scim_user_ims (workspace_id, user_id); - -CREATE TABLE scim_user_photos ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - workspace_id UUID NOT NULL, - attr_value VARCHAR(255), - type VARCHAR(255), - display VARCHAR(255), - primary_flag BOOLEAN NOT NULL, - CONSTRAINT fk_scim_user_photos_user FOREIGN KEY (user_id, workspace_id) - REFERENCES scim_users (id, workspace_id) ON DELETE CASCADE -); - -CREATE INDEX idx_user_photos_workspace_user_id ON scim_user_photos (workspace_id, user_id); - -CREATE TABLE scim_user_x509_certificates ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - workspace_id UUID NOT NULL, - attr_value TEXT, - type VARCHAR(255), - display VARCHAR(255), - primary_flag BOOLEAN NOT NULL, - CONSTRAINT fk_scim_user_x509_certificates_user FOREIGN KEY (user_id, workspace_id) - REFERENCES scim_users (id, workspace_id) ON DELETE CASCADE -); - -CREATE INDEX idx_user_x509_certificates_workspace_user_id ON scim_user_x509_certificates (workspace_id, user_id); - CREATE TABLE scim_group_memberships ( id UUID PRIMARY KEY, group_id UUID NOT NULL, diff --git a/scim-server-common/src/main/resources/db/common/V2__migrate_user_collections_to_json.sql b/scim-server-common/src/main/resources/db/common/V2__migrate_user_collections_to_json.sql deleted file mode 100644 index f916f5e..0000000 --- a/scim-server-common/src/main/resources/db/common/V2__migrate_user_collections_to_json.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Add JSON columns to scim_users -ALTER TABLE scim_users ADD COLUMN emails JSON; -ALTER TABLE scim_users ADD COLUMN phone_numbers JSON; -ALTER TABLE scim_users ADD COLUMN addresses JSON; -ALTER TABLE scim_users ADD COLUMN entitlements JSON; -ALTER TABLE scim_users ADD COLUMN roles JSON; -ALTER TABLE scim_users ADD COLUMN ims JSON; -ALTER TABLE scim_users ADD COLUMN photos JSON; -ALTER TABLE scim_users ADD COLUMN x509_certificates JSON; - --- Drop the old tables -DROP TABLE scim_user_emails; -DROP TABLE scim_user_phone_numbers; -DROP TABLE scim_user_addresses; -DROP TABLE scim_user_entitlements; -DROP TABLE scim_user_roles; -DROP TABLE scim_user_ims; -DROP TABLE scim_user_photos; -DROP TABLE scim_user_x509_certificates; \ No newline at end of file diff --git a/scim-server-common/src/test/java/de/palsoftware/scim/server/common/repository/WorkspaceRepositoryTest.java b/scim-server-common/src/test/java/de/palsoftware/scim/server/common/repository/WorkspaceRepositoryTest.java index 0299ed2..82dbf35 100644 --- a/scim-server-common/src/test/java/de/palsoftware/scim/server/common/repository/WorkspaceRepositoryTest.java +++ b/scim-server-common/src/test/java/de/palsoftware/scim/server/common/repository/WorkspaceRepositoryTest.java @@ -3,6 +3,7 @@ import de.palsoftware.scim.server.common.model.Workspace; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -11,6 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @PostgresDataJpaTest class WorkspaceRepositoryTest extends PostgresRepositoryTestSupport { @@ -25,14 +27,34 @@ class WorkspaceRepositoryTest extends PostgresRepositoryTestSupport { private jakarta.persistence.EntityManager entityManager; @Test - void findByNameReturnsCorrectWorkspace() { + void sameNameDifferentOwnersIsAllowed() { Workspace w = new Workspace(); w.setName(TEST_NAME); + w.setCreatedByUsername("owner-one@example.com"); repository.saveAndFlush(w); - Optional result = repository.findByName(TEST_NAME); - assertThat(result).isPresent(); - assertThat(result.get().getName()).isEqualTo(TEST_NAME); + Workspace duplicateName = new Workspace(); + duplicateName.setName(TEST_NAME); + duplicateName.setCreatedByUsername("owner-two@example.com"); + + Workspace saved = repository.saveAndFlush(duplicateName); + + assertThat(saved.getId()).isNotNull(); + } + + @Test + void sameNameSameOwnerIsRejected() { + Workspace first = new Workspace(); + first.setName(TEST_NAME); + first.setCreatedByUsername(USERNAME); + repository.saveAndFlush(first); + + Workspace duplicate = new Workspace(); + duplicate.setName(TEST_NAME); + duplicate.setCreatedByUsername(USERNAME); + + assertThatThrownBy(() -> repository.saveAndFlush(duplicate)) + .isInstanceOf(DataIntegrityViolationException.class); } @Test @@ -114,19 +136,23 @@ void deleteByUpdatedAtBeforeRemovesOldWorkspaces() { Instant cutoff = Instant.now().minus(5, ChronoUnit.DAYS); int deleted = repository.deleteByUpdatedAtBefore(cutoff); + entityManager.flush(); + entityManager.clear(); assertThat(deleted).isEqualTo(1); - assertThat(repository.existsByName("Old")).isFalse(); - assertThat(repository.existsByName("New")).isTrue(); + assertThat(repository.findById(w1.getId())).isEmpty(); + assertThat(repository.findById(w2.getId())).isPresent(); } @Test - void existsByNameReturnsTrueIfPresent() { + void existsByNameAndCreatedByUsernameReturnsTrueIfPresent() { Workspace w = new Workspace(); w.setName("ExistsTest"); + w.setCreatedByUsername(USERNAME); repository.saveAndFlush(w); - assertThat(repository.existsByName("ExistsTest")).isTrue(); - assertThat(repository.existsByName("NotExists")).isFalse(); + assertThat(repository.existsByNameAndCreatedByUsername("ExistsTest", USERNAME)).isTrue(); + assertThat(repository.existsByNameAndCreatedByUsername("ExistsTest", "other-user")).isFalse(); + assertThat(repository.existsByNameAndCreatedByUsername("NotExists", USERNAME)).isFalse(); } } diff --git a/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/Auth0OidcSecuritySupportTest.java b/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/Auth0OidcSecuritySupportTest.java new file mode 100644 index 0000000..4888c3e --- /dev/null +++ b/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/Auth0OidcSecuritySupportTest.java @@ -0,0 +1,85 @@ +package de.palsoftware.scim.server.common.security; + +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class Auth0OidcSecuritySupportTest { + + private static final String ROLE_CLAIM = "https://scimplayground.dev/roles"; + private static final String ADMIN_ROLE = "admin"; + private static final String USER_ROLE = "user"; + + @Test + void loadUser_emailPresent_provisionsAndReturns() { + OidcUser upstream = oidcUser(Map.of("sub", "auth0|123", "email", "user@example.com")); + AtomicReference provisioned = new AtomicReference<>(); + + OidcUserService service = Auth0OidcSecuritySupport.createOidcUserService( + ROLE_CLAIM, ADMIN_ROLE, USER_ROLE, provisioned::set, stubDelegate(upstream)); + + OidcUser result = service.loadUser(mock(OidcUserRequest.class)); + + assertThat(provisioned.get()).isEqualTo("user@example.com"); + assertThat(result.getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .contains("ROLE_USER"); + } + + @Test + void loadUser_adminRole_grantsAdminAuthority() { + OidcUser upstream = oidcUser(Map.of( + "sub", "auth0|456", + "email", "admin@example.com", + ROLE_CLAIM, List.of(ADMIN_ROLE))); + AtomicReference provisioned = new AtomicReference<>(); + + OidcUserService service = Auth0OidcSecuritySupport.createOidcUserService( + ROLE_CLAIM, ADMIN_ROLE, USER_ROLE, provisioned::set, stubDelegate(upstream)); + + OidcUser result = service.loadUser(mock(OidcUserRequest.class)); + + assertThat(result.getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .contains("ROLE_ADMIN", "ROLE_USER"); + } + + @Test + void loadUser_noEmail_throws() { + OidcUser upstream = oidcUser(Map.of("sub", "auth0|789")); + + OidcUserService service = Auth0OidcSecuritySupport.createOidcUserService( + ROLE_CLAIM, ADMIN_ROLE, USER_ROLE, email -> {}, stubDelegate(upstream)); + + assertThatThrownBy(() -> service.loadUser(mock(OidcUserRequest.class))) + .isInstanceOf(OAuth2AuthenticationException.class); + } + + private static OidcUser oidcUser(Map claims) { + Instant now = Instant.now(); + OidcIdToken idToken = new OidcIdToken("token-value", now, now.plusSeconds(3600), claims); + return new DefaultOidcUser(Collections.emptyList(), idToken); + } + + private static OidcUserService stubDelegate(OidcUser result) { + OidcUserService delegate = mock(OidcUserService.class); + when(delegate.loadUser(org.mockito.ArgumentMatchers.any())).thenReturn(result); + return delegate; + } +} diff --git a/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/AzureOidcSecuritySupportTest.java b/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/AzureOidcSecuritySupportTest.java deleted file mode 100644 index a7fad56..0000000 --- a/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/AzureOidcSecuritySupportTest.java +++ /dev/null @@ -1,77 +0,0 @@ -package de.palsoftware.scim.server.common.security; - -import org.junit.jupiter.api.Test; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; -import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.oidc.OidcIdToken; -import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; - -import java.time.Instant; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class AzureOidcSecuritySupportTest { - - @Test - void oidcUserService_provisionsNormalizedEmailAndMapsRoles() { - AtomicReference provisionedEmail = new AtomicReference<>(); - OidcUser oidcUser = oidcUser(Map.of( - "sub", "user-123", - "roles", List.of("admin"), - "email", " User@Example.com " - )); - OidcUserService delegate = mock(OidcUserService.class); - OidcUserRequest request = mock(OidcUserRequest.class); - when(delegate.loadUser(request)).thenReturn(oidcUser); - - OidcUser loadedUser = AzureOidcSecuritySupport.createOidcUserService( - "roles", - "admin", - "user", - provisionedEmail::set, - delegate) - .loadUser(request); - - assertThat(provisionedEmail.get()).isEqualTo("user@example.com"); - assertThat(loadedUser.getAuthorities()) - .extracting(GrantedAuthority::getAuthority) - .containsExactlyInAnyOrder("ROLE_ADMIN", "ROLE_USER"); - } - - @Test - void oidcUserService_withoutEmailRejectsLogin() { - OidcUser oidcUser = oidcUser(Map.of( - "sub", "user-123", - "roles", List.of("user"), - "preferred_username", "not-an-email" - )); - OidcUserService delegate = mock(OidcUserService.class); - OidcUserRequest request = mock(OidcUserRequest.class); - when(delegate.loadUser(request)).thenReturn(oidcUser); - OidcUserService oidcUserService = AzureOidcSecuritySupport.createOidcUserService( - "roles", - "admin", - "user", - email -> { }, - delegate); - - assertThatThrownBy(() -> oidcUserService.loadUser(request)) - .isInstanceOf(OAuth2AuthenticationException.class) - .hasMessageContaining("email claim is required"); - } - - private static OidcUser oidcUser(Map claims) { - Instant issuedAt = Instant.now(); - OidcIdToken idToken = new OidcIdToken("token-value", issuedAt, issuedAt.plusSeconds(3600), claims); - return new DefaultOidcUser(List.of(), idToken); - } -} \ No newline at end of file diff --git a/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/CloudflareJwtSecuritySupportTest.java b/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/CloudflareJwtSecuritySupportTest.java deleted file mode 100644 index 528c8a9..0000000 --- a/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/CloudflareJwtSecuritySupportTest.java +++ /dev/null @@ -1,77 +0,0 @@ -package de.palsoftware.scim.server.common.security; - -import org.junit.jupiter.api.Test; -import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class CloudflareJwtSecuritySupportTest { - - @Test - void jwtAuthenticationConverter_mapsAdminRoleFromNestedCustomClaim() { - AtomicReference provisionedEmail = new AtomicReference<>(); - Jwt jwt = jwt(Map.of( - "sub", "user-123", - "email", "USER@example.com", - "custom", Map.of("https://scimsandbox.net/roles", new String[]{"admin"}) - )); - - AbstractAuthenticationToken authentication = CloudflareJwtSecuritySupport - .createJwtAuthenticationConverter( - "https://scimsandbox.net/roles", - "admin", - "user", - provisionedEmail::set) - .convert(jwt); - - assertThat(provisionedEmail.get()).isEqualTo("user@example.com"); - assertThat(authentication.getAuthorities()) - .extracting(GrantedAuthority::getAuthority) - .containsExactlyInAnyOrder("ROLE_ADMIN", "ROLE_USER"); - } - - @Test - void jwtAuthenticationConverter_mapsAdminRoleFromTopLevelClaim() { - Jwt jwt = jwt(Map.of( - "sub", "user-123", - "email", "user@example.com", - "roles", new String[]{"admin"} - )); - - AbstractAuthenticationToken authentication = CloudflareJwtSecuritySupport - .createJwtAuthenticationConverter("roles", "admin", "user", email -> { }) - .convert(jwt); - - assertThat(authentication.getAuthorities()) - .extracting(GrantedAuthority::getAuthority) - .containsExactlyInAnyOrder("ROLE_ADMIN", "ROLE_USER"); - } - - @Test - void jwtAuthenticationConverter_withoutEmailRejectsLogin() { - Jwt jwt = jwt(Map.of( - "sub", "user-123", - "preferred_username", "not-an-email", - "roles", new String[]{"user"} - )); - var converter = CloudflareJwtSecuritySupport - .createJwtAuthenticationConverter("roles", "admin", "user", email -> { }); - - assertThatThrownBy(() -> converter.convert(jwt)) - .isInstanceOf(OAuth2AuthenticationException.class) - .hasMessageContaining("email claim is required"); - } - - private static Jwt jwt(Map claims) { - Instant issuedAt = Instant.now(); - return new Jwt("token-value", issuedAt, issuedAt.plusSeconds(3600), Map.of("alg", "none"), claims); - } -} \ No newline at end of file diff --git a/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/TokenSecurityUtilTest.java b/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/TokenSecurityUtilTest.java index 787f55e..c76094a 100644 --- a/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/TokenSecurityUtilTest.java +++ b/scim-server-common/src/test/java/de/palsoftware/scim/server/common/security/TokenSecurityUtilTest.java @@ -1,14 +1,34 @@ package de.palsoftware.scim.server.common.security; import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + import static org.junit.jupiter.api.Assertions.*; class TokenSecurityUtilTest { + @Test - void testTokenGeneration() { - String token = TokenSecurityUtil.generateUrlSafeToken(64); + void testGenerateWorkspaceTokenIsUrlSafeAndExpectedLength() { + String token = TokenSecurityUtil.generateSecureToken(); + assertNotNull(token); assertFalse(token.isEmpty()); + assertEquals(86, token.length()); + assertFalse(token.contains("=")); + assertTrue(token.matches("^[A-Za-z0-9_-]+$")); + } + + @Test + void testGenerateWorkspaceTokenProducesDistinctValues() { + Set tokens = new HashSet<>(); + + for (int i = 0; i < 16; i++) { + tokens.add(TokenSecurityUtil.generateSecureToken()); + } + + assertEquals(16, tokens.size()); } @Test diff --git a/scim-server-mgmt/pom.xml b/scim-server-mgmt/pom.xml index dc907b2..51a83eb 100644 --- a/scim-server-mgmt/pom.xml +++ b/scim-server-mgmt/pom.xml @@ -61,10 +61,7 @@ org.springframework.boot spring-boot-starter-oauth2-client - - org.springframework.boot - spring-boot-starter-oauth2-resource-server - + de.pal-software.scim diff --git a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/AuthenticatedUser.java b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/AuthenticatedUser.java index e1ebfd4..ae69b7e 100644 --- a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/AuthenticatedUser.java +++ b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/AuthenticatedUser.java @@ -3,7 +3,6 @@ import de.palsoftware.scim.server.common.security.PrincipalEmailSupport; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.core.oidc.user.OidcUser; public final class AuthenticatedUser { @@ -23,13 +22,6 @@ public static String email(Authentication authentication) { } throw new IllegalStateException("Authenticated principal is missing an email address"); } - if (principal instanceof Jwt jwt) { - String resolved = PrincipalEmailSupport.resolveEmail(jwt); - if (resolved != null) { - return resolved; - } - throw new IllegalStateException("Authenticated principal is missing an email address"); - } String fallback = authentication.getName(); if (fallback == null || fallback.isBlank()) { throw new IllegalStateException("Unable to resolve authenticated email"); diff --git a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/CloudflareSecurityConfig.java b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/CloudflareSecurityConfig.java deleted file mode 100644 index 8421143..0000000 --- a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/CloudflareSecurityConfig.java +++ /dev/null @@ -1,86 +0,0 @@ -package de.palsoftware.scim.server.mgmt.security; - -import de.palsoftware.scim.server.common.security.CloudflareJwtSecuritySupport; -import de.palsoftware.scim.server.common.security.MgmtSecuritySupport; -import de.palsoftware.scim.server.mgmt.service.MgmtUserService; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Lazy; -import org.springframework.context.annotation.Profile; -import org.springframework.core.convert.converter.Converter; -import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -@EnableWebSecurity -@Profile("cloudflare") -public class CloudflareSecurityConfig { - - private static final String CSRF_COOKIE_NAME = "SCIM_SERVER_MGMT_XSRF"; - - private final String adminRole; - private final String userRole; - private final String roleClaim; - private final String actuatorApiKey; - private final MgmtUserService mgmtUserService; - - public CloudflareSecurityConfig(@Value("${app.security.oidc.admin-role}") String adminRole, - @Value("${app.security.oidc.user-role}") String userRole, - @Value("${app.security.cloudflare.role-claim}") String roleClaim, - @Lazy MgmtUserService mgmtUserService, - @Value("${app.security.actuator.api-key}") String actuatorApiKey) { - this.adminRole = adminRole; - this.userRole = userRole; - this.roleClaim = roleClaim; - this.mgmtUserService = mgmtUserService; - this.actuatorApiKey = actuatorApiKey; - } - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http, - JwtDecoder cloudflareJwtDecoder, - Converter cloudflareJwtAuthenticationConverter, - BearerTokenResolver cloudflareBearerTokenResolver, - @Value("${app.security.cloudflare.logout-url}") String logoutSuccessUrl) throws Exception { - MgmtSecuritySupport.configureBaseSecurity(http, actuatorApiKey) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .oauth2ResourceServer(oauth2 -> oauth2 - .bearerTokenResolver(cloudflareBearerTokenResolver) - .jwt(jwt -> jwt - .decoder(cloudflareJwtDecoder) - .jwtAuthenticationConverter(cloudflareJwtAuthenticationConverter))) - .logout(logout -> logout.logoutSuccessUrl(logoutSuccessUrl)) - .csrf(csrf -> csrf.csrfTokenRepository(MgmtSecuritySupport.csrfTokenRepository(CSRF_COOKIE_NAME))); - return http.build(); - } - - @Bean - public BearerTokenResolver cloudflareBearerTokenResolver( - @Value("${app.security.cloudflare.token-header}") String tokenHeader) { - return CloudflareJwtSecuritySupport.createBearerTokenResolver(tokenHeader); - } - - @Bean - public JwtDecoder cloudflareJwtDecoder( - @Value("${app.security.cloudflare.issuer-uri}") String issuerUri, - @Value("${app.security.cloudflare.audience}") String audience, - @Value("${app.security.cloudflare.jwk-set-uri}") String jwkSetUri) { - return CloudflareJwtSecuritySupport.createJwtDecoder(issuerUri, audience, jwkSetUri); - } - - @Bean - public Converter cloudflareJwtAuthenticationConverter() { - return CloudflareJwtSecuritySupport.createJwtAuthenticationConverter( - roleClaim, - adminRole, - userRole, - mgmtUserService::provisionUser); - } -} \ No newline at end of file diff --git a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/AzureSecurityConfig.java b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/SecurityConfig.java similarity index 57% rename from scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/AzureSecurityConfig.java rename to scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/SecurityConfig.java index e0d897a..2141eb0 100644 --- a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/AzureSecurityConfig.java +++ b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/security/SecurityConfig.java @@ -1,21 +1,22 @@ package de.palsoftware.scim.server.mgmt.security; -import de.palsoftware.scim.server.common.security.AzureOidcSecuritySupport; +import de.palsoftware.scim.server.common.security.Auth0OidcSecuritySupport; import de.palsoftware.scim.server.common.security.MgmtSecuritySupport; import de.palsoftware.scim.server.mgmt.service.MgmtUserService; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; -import org.springframework.context.annotation.Profile; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.context.annotation.Bean; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; @Configuration @EnableWebSecurity -@Profile("!cloudflare") -public class AzureSecurityConfig { +public class SecurityConfig { private static final String CSRF_COOKIE_NAME = "SCIM_SERVER_MGMT_XSRF"; @@ -25,11 +26,11 @@ public class AzureSecurityConfig { private final String actuatorApiKey; private final MgmtUserService mgmtUserService; - public AzureSecurityConfig(@Value("${app.security.oidc.admin-role}") String adminRole, - @Value("${app.security.oidc.user-role}") String userRole, - @Value("${app.security.azure.role-claim}") String roleClaim, - @Lazy MgmtUserService mgmtUserService, - @Value("${app.security.actuator.api-key}") String actuatorApiKey) { + public SecurityConfig(@Value("${app.security.oidc.admin-role}") String adminRole, + @Value("${app.security.oidc.user-role}") String userRole, + @Value("${app.security.oidc.role-claim}") String roleClaim, + @Value("${app.security.actuator.api-key}") String actuatorApiKey, + @Lazy MgmtUserService mgmtUserService) { this.adminRole = adminRole; this.userRole = userRole; this.roleClaim = roleClaim; @@ -38,18 +39,24 @@ public AzureSecurityConfig(@Value("${app.security.oidc.admin-role}") String admi } @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http, + LogoutSuccessHandler oidcLogoutSuccessHandler) throws Exception { MgmtSecuritySupport.configureBaseSecurity(http, actuatorApiKey) .oauth2Login(oauth2 -> oauth2 - .loginPage("/oauth2/authorization/azure") .userInfoEndpoint(userInfo -> userInfo.oidcUserService( - AzureOidcSecuritySupport.createOidcUserService( + Auth0OidcSecuritySupport.createOidcUserService( roleClaim, adminRole, userRole, mgmtUserService::provisionUser)))) - .logout(logout -> logout.logoutSuccessUrl("/")) + .logout(logout -> logout + .logoutSuccessHandler(oidcLogoutSuccessHandler)) .csrf(csrf -> csrf.csrfTokenRepository(MgmtSecuritySupport.csrfTokenRepository(CSRF_COOKIE_NAME))); return http.build(); } -} \ No newline at end of file + + @Bean + public LogoutSuccessHandler oidcLogoutSuccessHandler(ClientRegistrationRepository clientRegistrationRepository) { + return new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository); + } +} diff --git a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/service/WorkspaceService.java b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/service/WorkspaceService.java index ebe0b83..68748f9 100644 --- a/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/service/WorkspaceService.java +++ b/scim-server-mgmt/src/main/java/de/palsoftware/scim/server/mgmt/service/WorkspaceService.java @@ -34,6 +34,11 @@ public WorkspaceService(WorkspaceRepository workspaceRepository, @Transactional public Workspace createWorkspace(String name, String description, String createdByUsername) { + if (workspaceRepository.existsByNameAndCreatedByUsername(name, createdByUsername)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Workspace with name '" + name + "' already exists"); + } + Workspace ws = new Workspace(); ws.setName(name); ws.setDescription(description); @@ -55,10 +60,6 @@ public Optional getWorkspace(UUID id, String actorUsername, boolean a return workspaceRepository.findByIdAndCreatedByUsername(id, actorUsername); } - public Optional getWorkspaceByName(String name) { - return workspaceRepository.findByName(name); - } - public Workspace requireWorkspaceAccess(UUID id, String actorUsername, boolean admin) { return getWorkspace(id, actorUsername, admin) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Workspace not found")); @@ -117,6 +118,6 @@ public void revokeToken(UUID workspaceId, UUID tokenId, String actorUsername, bo } private String generateSecureToken() { - return TokenSecurityUtil.generateUrlSafeToken(64); + return TokenSecurityUtil.generateSecureToken(); } } diff --git a/scim-server-mgmt/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/scim-server-mgmt/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 3345f32..bd1545f 100644 --- a/scim-server-mgmt/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/scim-server-mgmt/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -12,18 +12,10 @@ "name": "app.security.actuator", "type": "java.lang.Object" }, - { - "name": "app.security.azure", - "type": "java.lang.Object" - }, { "name": "app.security.oidc", "type": "java.lang.Object" }, - { - "name": "app.security.cloudflare", - "type": "java.lang.Object" - }, { "name": "app.scim-api", "type": "java.lang.Object" @@ -40,44 +32,19 @@ "description": "Actuator API key used by the management filter." }, { - "name": "app.security.azure.role-claim", + "name": "app.security.oidc.role-claim", "type": "java.lang.String", - "description": "OIDC role claim used when Azure profile is active." + "description": "OIDC claim containing user roles." }, { "name": "app.security.oidc.admin-role", "type": "java.lang.String", - "description": "Configured Azure admin role name." + "description": "Configured admin role name." }, { "name": "app.security.oidc.user-role", "type": "java.lang.String", - "description": "Configured Azure user role name." - }, - { - "name": "app.security.cloudflare.role-claim", - "type": "java.lang.String", - "description": "JWT claim used when Cloudflare profile is active." - }, - { - "name": "app.security.cloudflare.issuer-uri", - "type": "java.lang.String" - }, - { - "name": "app.security.cloudflare.audience", - "type": "java.lang.String" - }, - { - "name": "app.security.cloudflare.jwk-set-uri", - "type": "java.lang.String" - }, - { - "name": "app.security.cloudflare.logout-url", - "type": "java.lang.String" - }, - { - "name": "app.security.cloudflare.token-header", - "type": "java.lang.String" + "description": "Configured user role name." }, { "name": "app.scim-api.base.url", diff --git a/scim-server-mgmt/src/main/resources/application.yml b/scim-server-mgmt/src/main/resources/application.yml index 7e3f1f2..1dc9452 100644 --- a/scim-server-mgmt/src/main/resources/application.yml +++ b/scim-server-mgmt/src/main/resources/application.yml @@ -11,8 +11,6 @@ spring: locations: - classpath:db/migration - classpath:db/common - profiles: - default: azure jpa: hibernate: ddl-auto: validate @@ -25,23 +23,20 @@ spring: oauth2: client: registration: - azure: - client-id: ${AZURE_CLIENT_ID} - client-secret: ${AZURE_CLIENT_SECRET} - scope: ${AZURE_SCOPES} + auth0: + client-id: ${AUTH0_CLIENT_ID} + client-secret: ${AUTH0_CLIENT_SECRET} + scope: openid,profile,email authorization-grant-type: authorization_code - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" - client-name: Azure + redirect-uri: ${AUTH0_REDIRECT_URI} provider: - azure: - issuer-uri: https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0 + auth0: + issuer-uri: ${AUTH0_ISSUER_URI} logging: level: - "[com.scimplayground]": DEBUG - "[org.springframework.security]": DEBUG - "[org.springframework.security.web.csrf]": TRACE - "[org.springframework.security.web.FilterChainProxy]": DEBUG + "[de.palsoftware.scim]": DEBUG + "[org.springframework.security]": WARN management: endpoint: @@ -64,18 +59,10 @@ app: security: actuator: api-key: ${ACTUATOR_API_KEY} - azure: - role-claim: ${APP_SECURITY_AZURE_ROLE_CLAIM:roles} oidc: + role-claim: ${APP_SECURITY_OIDC_ROLE_CLAIM:https://scimplayground.dev/roles} admin-role: ${APP_SECURITY_OIDC_ADMIN_ROLE:admin} user-role: ${APP_SECURITY_OIDC_USER_ROLE:user} - cloudflare: - role-claim: ${APP_SECURITY_CLOUDFLARE_ROLE_CLAIM:roles} - issuer-uri: ${CLOUDFLARE_ACCESS_ISSUER_URI} - audience: ${CLOUDFLARE_ACCESS_AUDIENCE} - jwk-set-uri: ${CLOUDFLARE_ACCESS_JWK_SET_URI:} - logout-url: ${CLOUDFLARE_ACCESS_LOGOUT_URL:/} - token-header: ${CLOUDFLARE_ACCESS_TOKEN_HEADER:Cf-Access-Jwt-Assertion} scim-api: base: diff --git a/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/controller/LazyLoadingIntegrationTest.java b/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/controller/LazyLoadingIntegrationTest.java index 6a6e7ca..dc8d417 100644 --- a/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/controller/LazyLoadingIntegrationTest.java +++ b/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/controller/LazyLoadingIntegrationTest.java @@ -36,10 +36,10 @@ @TestPropertySource(properties = { "spring.jpa.open-in-view=false", "ACTUATOR_API_KEY=test-key", - "AZURE_CLIENT_ID=test-client", - "AZURE_CLIENT_SECRET=test-secret", - "AZURE_SCOPES=openid", - "AZURE_TENANT_ID=common" + "AUTH0_CLIENT_ID=test-client", + "AUTH0_CLIENT_SECRET=test-secret", + "AUTH0_REDIRECT_URI=https://ui.scimsandbox.net/login/oauth2/code/auth0", + "AUTH0_ISSUER_URI=https://test.auth0.com/" }) class LazyLoadingIntegrationTest extends PostgresIntegrationTestSupport { diff --git a/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/security/AuthenticatedUserTest.java b/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/security/AuthenticatedUserTest.java index 47cdbc2..d0c022b 100644 --- a/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/security/AuthenticatedUserTest.java +++ b/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/security/AuthenticatedUserTest.java @@ -1,18 +1,14 @@ package de.palsoftware.scim.server.mgmt.security; import org.junit.jupiter.api.Test; -import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import java.time.Instant; import java.util.Collection; import java.util.Collections; import java.util.List; -import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -78,16 +74,6 @@ void email_oidcUser_missingEmail_throws() { .hasMessageContaining("missing an email"); } - @Test - void email_jwt_emailFallback() { - Authentication auth = new TestingAuthenticationToken(jwt(Map.of( - "sub", "jwt-sub", - "email", "jwt@example.com" - )), "n/a"); - - assertThat(AuthenticatedUser.email(auth)).isEqualTo("jwt@example.com"); - } - @Test void email_nonOidc_fallsBackToName() { Authentication auth = mock(Authentication.class); @@ -138,16 +124,6 @@ void displayName_oidcUser_noEmail_usesPreferredUsernameEmail() { assertThat(AuthenticatedUser.displayName(auth)).isEqualTo("preferred@example.com"); } - @Test - void displayName_jwt_returnsEmail() { - Authentication auth = new TestingAuthenticationToken(jwt(Map.of( - "sub", "jwt-sub", - "email", "display@example.com" - )), "n/a"); - - assertThat(AuthenticatedUser.displayName(auth)).isEqualTo("display@example.com"); - } - // ─── isAdmin ──────────────────────────────────────────────────────── @Test @@ -192,9 +168,4 @@ private Authentication mockAuthWithPrincipal(Object principal) { when(auth.getPrincipal()).thenReturn(principal); return auth; } - - private Jwt jwt(Map claims) { - Instant issuedAt = Instant.now(); - return new Jwt("token-value", issuedAt, issuedAt.plusSeconds(3600), Map.of("alg", "none"), claims); - } } diff --git a/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/service/WorkspaceServiceTest.java b/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/service/WorkspaceServiceTest.java index 80aa510..317214b 100644 --- a/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/service/WorkspaceServiceTest.java +++ b/scim-server-mgmt/src/test/java/de/palsoftware/scim/server/mgmt/service/WorkspaceServiceTest.java @@ -12,6 +12,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; import java.util.List; @@ -52,6 +53,7 @@ void setUp() { @Test void createWorkspace_success() { + when(workspaceRepository.existsByNameAndCreatedByUsername("WS", "owner")).thenReturn(false); when(workspaceRepository.save(any(Workspace.class))).thenAnswer(i -> { Workspace ws = i.getArgument(0); ws.setId(workspaceId); @@ -65,6 +67,17 @@ void createWorkspace_success() { verify(workspaceRepository).save(any(Workspace.class)); } + @Test + void createWorkspace_duplicateNameForOwner_throwsConflict() { + when(workspaceRepository.existsByNameAndCreatedByUsername("WS", "owner")).thenReturn(true); + + assertThatThrownBy(() -> service.createWorkspace("WS", "desc", "owner")) + .isInstanceOfSatisfying(ResponseStatusException.class, ex -> { + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(ex.getReason()).contains("Workspace with name 'WS' already exists"); + }); + } + // ─── listWorkspaces ───────────────────────────────────────────────── @Test @@ -201,14 +214,4 @@ void revokeToken_notFound_throws() { .isInstanceOf(ResponseStatusException.class); } - // ─── getWorkspaceByName ───────────────────────────────────────────── - - @Test - void getWorkspaceByName_found() { - when(workspaceRepository.findByName("test")).thenReturn(Optional.of(workspace)); - - Optional result = service.getWorkspaceByName("test"); - - assertThat(result).isPresent(); - } } diff --git a/scim-validator-mgmt/pom.xml b/scim-validator-mgmt/pom.xml index 165b3a6..4705a99 100644 --- a/scim-validator-mgmt/pom.xml +++ b/scim-validator-mgmt/pom.xml @@ -68,10 +68,7 @@ org.springframework.boot spring-boot-starter-oauth2-client - - org.springframework.boot - spring-boot-starter-oauth2-resource-server - + diff --git a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/AuthenticatedUser.java b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/AuthenticatedUser.java index 8d51fa2..8b52232 100644 --- a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/AuthenticatedUser.java +++ b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/AuthenticatedUser.java @@ -3,7 +3,6 @@ import de.palsoftware.scim.server.common.security.PrincipalEmailSupport; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.core.oidc.user.OidcUser; public final class AuthenticatedUser { @@ -23,13 +22,6 @@ public static String email(Authentication authentication) { } throw new IllegalStateException("Authenticated principal is missing an email address"); } - if (principal instanceof Jwt jwt) { - String resolved = PrincipalEmailSupport.resolveEmail(jwt); - if (resolved != null) { - return resolved; - } - throw new IllegalStateException("Authenticated principal is missing an email address"); - } String fallback = authentication.getName(); if (fallback == null || fallback.isBlank()) { throw new IllegalStateException("Unable to resolve authenticated email"); diff --git a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/CloudflareSecurityConfig.java b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/CloudflareSecurityConfig.java deleted file mode 100644 index c704e30..0000000 --- a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/CloudflareSecurityConfig.java +++ /dev/null @@ -1,86 +0,0 @@ -package de.palsoftware.scim.validator.mgmt.security; - -import de.palsoftware.scim.server.common.security.CloudflareJwtSecuritySupport; -import de.palsoftware.scim.server.common.security.MgmtSecuritySupport; -import de.palsoftware.scim.validator.mgmt.service.MgmtUserService; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Lazy; -import org.springframework.context.annotation.Profile; -import org.springframework.core.convert.converter.Converter; -import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -@EnableWebSecurity -@Profile("cloudflare") -public class CloudflareSecurityConfig { - - private static final String CSRF_COOKIE_NAME = "SCIM_VALIDATOR_MGMT_XSRF"; - - private final String adminRole; - private final String userRole; - private final String roleClaim; - private final String actuatorApiKey; - private final MgmtUserService mgmtUserService; - - public CloudflareSecurityConfig(@Value("${app.security.oidc.admin-role}") String adminRole, - @Value("${app.security.oidc.user-role}") String userRole, - @Value("${app.security.cloudflare.role-claim}") String roleClaim, - @Lazy MgmtUserService mgmtUserService, - @Value("${app.security.actuator.api-key}") String actuatorApiKey) { - this.adminRole = adminRole; - this.userRole = userRole; - this.roleClaim = roleClaim; - this.mgmtUserService = mgmtUserService; - this.actuatorApiKey = actuatorApiKey; - } - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http, - JwtDecoder cloudflareJwtDecoder, - Converter cloudflareJwtAuthenticationConverter, - BearerTokenResolver cloudflareBearerTokenResolver, - @Value("${app.security.cloudflare.logout-url}") String logoutSuccessUrl) throws Exception { - MgmtSecuritySupport.configureBaseSecurity(http, actuatorApiKey) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .oauth2ResourceServer(oauth2 -> oauth2 - .bearerTokenResolver(cloudflareBearerTokenResolver) - .jwt(jwt -> jwt - .decoder(cloudflareJwtDecoder) - .jwtAuthenticationConverter(cloudflareJwtAuthenticationConverter))) - .logout(logout -> logout.logoutSuccessUrl(logoutSuccessUrl)) - .csrf(csrf -> csrf.csrfTokenRepository(MgmtSecuritySupport.csrfTokenRepository(CSRF_COOKIE_NAME))); - return http.build(); - } - - @Bean - public BearerTokenResolver cloudflareBearerTokenResolver( - @Value("${app.security.cloudflare.token-header}") String tokenHeader) { - return CloudflareJwtSecuritySupport.createBearerTokenResolver(tokenHeader); - } - - @Bean - public JwtDecoder cloudflareJwtDecoder( - @Value("${app.security.cloudflare.issuer-uri}") String issuerUri, - @Value("${app.security.cloudflare.audience}") String audience, - @Value("${app.security.cloudflare.jwk-set-uri}") String jwkSetUri) { - return CloudflareJwtSecuritySupport.createJwtDecoder(issuerUri, audience, jwkSetUri); - } - - @Bean - public Converter cloudflareJwtAuthenticationConverter() { - return CloudflareJwtSecuritySupport.createJwtAuthenticationConverter( - roleClaim, - adminRole, - userRole, - mgmtUserService::provisionUser); - } -} \ No newline at end of file diff --git a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/AzureSecurityConfig.java b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/SecurityConfig.java similarity index 57% rename from scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/AzureSecurityConfig.java rename to scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/SecurityConfig.java index 72b8a6b..8d874aa 100644 --- a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/AzureSecurityConfig.java +++ b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/security/SecurityConfig.java @@ -1,21 +1,22 @@ package de.palsoftware.scim.validator.mgmt.security; -import de.palsoftware.scim.server.common.security.AzureOidcSecuritySupport; +import de.palsoftware.scim.server.common.security.Auth0OidcSecuritySupport; import de.palsoftware.scim.server.common.security.MgmtSecuritySupport; import de.palsoftware.scim.validator.mgmt.service.MgmtUserService; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; -import org.springframework.context.annotation.Profile; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; @Configuration @EnableWebSecurity -@Profile("!cloudflare") -public class AzureSecurityConfig { +public class SecurityConfig { private static final String CSRF_COOKIE_NAME = "SCIM_VALIDATOR_MGMT_XSRF"; @@ -23,14 +24,13 @@ public class AzureSecurityConfig { private final String userRole; private final String roleClaim; private final String actuatorApiKey; - private final MgmtUserService mgmtUserService; - public AzureSecurityConfig(@Value("${app.security.oidc.admin-role}") String adminRole, - @Value("${app.security.oidc.user-role}") String userRole, - @Value("${app.security.azure.role-claim}") String roleClaim, - @Lazy MgmtUserService mgmtUserService, - @Value("${app.security.actuator.api-key}") String actuatorApiKey) { + public SecurityConfig(@Value("${app.security.oidc.admin-role}") String adminRole, + @Value("${app.security.oidc.user-role}") String userRole, + @Value("${app.security.oidc.role-claim}") String roleClaim, + @Value("${app.security.actuator.api-key}") String actuatorApiKey, + @Lazy MgmtUserService mgmtUserService) { this.adminRole = adminRole; this.userRole = userRole; this.roleClaim = roleClaim; @@ -39,18 +39,24 @@ public AzureSecurityConfig(@Value("${app.security.oidc.admin-role}") String admi } @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http, + LogoutSuccessHandler oidcLogoutSuccessHandler) throws Exception { MgmtSecuritySupport.configureBaseSecurity(http, actuatorApiKey) .oauth2Login(oauth2 -> oauth2 - .loginPage("/oauth2/authorization/azure") .userInfoEndpoint(userInfo -> userInfo.oidcUserService( - AzureOidcSecuritySupport.createOidcUserService( + Auth0OidcSecuritySupport.createOidcUserService( roleClaim, adminRole, userRole, mgmtUserService::provisionUser)))) - .logout(logout -> logout.logoutSuccessUrl("/")) + .logout(logout -> logout + .logoutSuccessHandler(oidcLogoutSuccessHandler)) .csrf(csrf -> csrf.csrfTokenRepository(MgmtSecuritySupport.csrfTokenRepository(CSRF_COOKIE_NAME))); return http.build(); } -} \ No newline at end of file + + @Bean + public LogoutSuccessHandler oidcLogoutSuccessHandler(ClientRegistrationRepository clientRegistrationRepository) { + return new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository); + } +} diff --git a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/service/ValidationRunService.java b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/service/ValidationRunService.java index 03d6628..681b9cf 100644 --- a/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/service/ValidationRunService.java +++ b/scim-validator-mgmt/src/main/java/de/palsoftware/scim/validator/mgmt/service/ValidationRunService.java @@ -1,7 +1,9 @@ package de.palsoftware.scim.validator.mgmt.service; +import de.palsoftware.scim.validator.base.ScimBaseSpec; import de.palsoftware.scim.validator.base.ScimHttpExchange; import de.palsoftware.scim.validator.base.ScimRunContext; +import de.palsoftware.scim.validator.base.ValidatorConfiguration; import de.palsoftware.scim.validator.mgmt.dto.ValidationHttpExchangeView; import de.palsoftware.scim.validator.mgmt.dto.ValidationRunForm; import de.palsoftware.scim.validator.mgmt.dto.ValidationRunView; @@ -34,7 +36,6 @@ import java.io.StringWriter; import java.time.OffsetDateTime; import java.util.ArrayList; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -48,9 +49,6 @@ public class ValidationRunService { private static final Logger log = LoggerFactory.getLogger(ValidationRunService.class); - private static final String SCIM_BASE_URL_PROPERTY = "scim.baseUrl"; - private static final String SCIM_AUTH_TOKEN_PROPERTY = "scim.authToken"; - private static final List SPEC_CLASS_NAMES = List.of( "de.palsoftware.scim.validator.specs.A1_ServiceDiscoverySpec", "de.palsoftware.scim.validator.specs.A2_SchemaValidationSpec", @@ -94,14 +92,10 @@ public ValidationRun executeRun(ValidationRunForm form, String actorEmail) { run.setFailedTests(0); run = runRepository.save(run); - Map previousProperties = captureExistingProperties(); - try { - System.setProperty(SCIM_BASE_URL_PROPERTY, form.baseUrl().trim()); - System.setProperty(SCIM_AUTH_TOKEN_PROPERTY, form.authToken().trim()); - - ScimRunContext.reset(); - ScimRunContext.setCaptureEnabled(true); + ValidatorConfiguration.useRunOverrides(form.baseUrl(), form.authToken()); + ScimBaseSpec.resetRunState(); + ScimRunContext.beginRun(run.getId().toString()); ValidationExecutionListener listener = new ValidationExecutionListener(run, testResultRepository, exchangeRepository); @@ -117,9 +111,9 @@ public ValidationRun executeRun(ValidationRunForm form, String actorEmail) { log.error("Error executing validation run", ex); run.setStatus("ERROR"); } finally { - ScimRunContext.setCaptureEnabled(false); - ScimRunContext.reset(); - restoreProperties(previousProperties); + ScimRunContext.endRun(); + ScimBaseSpec.resetRunState(); + ValidatorConfiguration.clearRunOverrides(); } return runRepository.save(run); @@ -186,26 +180,6 @@ private static LauncherDiscoveryRequest buildRequest() throws ClassNotFoundExcep return builder.build(); } - private static Map captureExistingProperties() { - Map values = new HashMap<>(); - values.put(SCIM_BASE_URL_PROPERTY, System.getProperty(SCIM_BASE_URL_PROPERTY)); - values.put(SCIM_AUTH_TOKEN_PROPERTY, System.getProperty(SCIM_AUTH_TOKEN_PROPERTY)); - return values; - } - - private static void restoreProperties(Map previousProperties) { - restoreProperty(SCIM_BASE_URL_PROPERTY, previousProperties.get(SCIM_BASE_URL_PROPERTY)); - restoreProperty(SCIM_AUTH_TOKEN_PROPERTY, previousProperties.get(SCIM_AUTH_TOKEN_PROPERTY)); - } - - private static void restoreProperty(String key, String value) { - if (value == null) { - System.clearProperty(key); - } else { - System.setProperty(key, value); - } - } - private static class ValidationExecutionListener implements TestExecutionListener { private final ValidationRun run; diff --git a/scim-validator-mgmt/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/scim-validator-mgmt/src/main/resources/META-INF/additional-spring-configuration-metadata.json index a965c9b..bcae6f3 100644 --- a/scim-validator-mgmt/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/scim-validator-mgmt/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -12,17 +12,9 @@ "name": "app.security.actuator", "type": "java.lang.Object" }, - { - "name": "app.security.azure", - "type": "java.lang.Object" - }, { "name": "app.security.oidc", "type": "java.lang.Object" - }, - { - "name": "app.security.cloudflare", - "type": "java.lang.Object" } ], "properties": [ @@ -32,44 +24,19 @@ "description": "Actuator API key used by the management filter." }, { - "name": "app.security.azure.role-claim", + "name": "app.security.oidc.role-claim", "type": "java.lang.String", - "description": "OIDC role claim used when Azure profile is active." + "description": "OIDC claim containing user roles." }, { "name": "app.security.oidc.admin-role", "type": "java.lang.String", - "description": "Configured Azure admin role name." + "description": "Configured admin role name." }, { "name": "app.security.oidc.user-role", "type": "java.lang.String", - "description": "Configured Azure user role name." - }, - { - "name": "app.security.cloudflare.role-claim", - "type": "java.lang.String", - "description": "JWT claim used when Cloudflare profile is active." - }, - { - "name": "app.security.cloudflare.issuer-uri", - "type": "java.lang.String" - }, - { - "name": "app.security.cloudflare.audience", - "type": "java.lang.String" - }, - { - "name": "app.security.cloudflare.jwk-set-uri", - "type": "java.lang.String" - }, - { - "name": "app.security.cloudflare.logout-url", - "type": "java.lang.String" - }, - { - "name": "app.security.cloudflare.token-header", - "type": "java.lang.String" + "description": "Configured user role name." } ] } \ No newline at end of file diff --git a/scim-validator-mgmt/src/main/resources/application.yml b/scim-validator-mgmt/src/main/resources/application.yml index 6f88349..9d913dc 100644 --- a/scim-validator-mgmt/src/main/resources/application.yml +++ b/scim-validator-mgmt/src/main/resources/application.yml @@ -4,8 +4,6 @@ spring: flyway: locations: - classpath:db/validator - profiles: - default: azure jpa: hibernate: ddl-auto: validate @@ -14,16 +12,15 @@ spring: oauth2: client: registration: - azure: - client-id: ${AZURE_CLIENT_ID} - client-secret: ${AZURE_CLIENT_SECRET} - scope: ${AZURE_SCOPES} + auth0: + client-id: ${AUTH0_CLIENT_ID} + client-secret: ${AUTH0_CLIENT_SECRET} + scope: openid,profile,email authorization-grant-type: authorization_code - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" - client-name: Azure + redirect-uri: ${AUTH0_REDIRECT_URI} provider: - azure: - issuer-uri: https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0 + auth0: + issuer-uri: ${AUTH0_ISSUER_URI} server: servlet: @@ -49,33 +46,14 @@ management: logging: level: - "[com.scimplayground]": DEBUG - "[org.springframework.security]": DEBUG - "[org.springframework.security.web.csrf]": TRACE - "[org.springframework.security.web.FilterChainProxy]": DEBUG + "[de.palsoftware.scim]": DEBUG + "[org.springframework.security]": WARN app: security: actuator: api-key: ${ACTUATOR_API_KEY} - azure: - role-claim: ${APP_SECURITY_AZURE_ROLE_CLAIM:roles} oidc: + role-claim: ${APP_SECURITY_OIDC_ROLE_CLAIM:https://scimplayground.dev/roles} admin-role: ${APP_SECURITY_OIDC_ADMIN_ROLE:admin} user-role: ${APP_SECURITY_OIDC_USER_ROLE:user} - ---- -spring: - config: - activate: - on-profile: cloudflare - -app: - security: - cloudflare: - role-claim: ${APP_SECURITY_CLOUDFLARE_ROLE_CLAIM:roles} - issuer-uri: ${CLOUDFLARE_ACCESS_ISSUER_URI} - audience: ${CLOUDFLARE_ACCESS_AUDIENCE} - jwk-set-uri: ${CLOUDFLARE_ACCESS_JWK_SET_URI:} - logout-url: ${CLOUDFLARE_ACCESS_LOGOUT_URL:/} - token-header: ${CLOUDFLARE_ACCESS_TOKEN_HEADER:Cf-Access-Jwt-Assertion} diff --git a/scim-validator-mgmt/src/test/java/de/palsoftware/scim/validator/mgmt/security/AuthenticatedUserTest.java b/scim-validator-mgmt/src/test/java/de/palsoftware/scim/validator/mgmt/security/AuthenticatedUserTest.java index 690ca06..d9f04bb 100644 --- a/scim-validator-mgmt/src/test/java/de/palsoftware/scim/validator/mgmt/security/AuthenticatedUserTest.java +++ b/scim-validator-mgmt/src/test/java/de/palsoftware/scim/validator/mgmt/security/AuthenticatedUserTest.java @@ -7,7 +7,6 @@ import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; import org.springframework.security.oauth2.core.oidc.user.OidcUser; @@ -60,18 +59,6 @@ void email_oidcPrincipal_returnsEmail() { assertThat(result).isEqualTo("user@example.com"); } - @Test - void email_jwtPrincipal_returnsEmail() { - Authentication auth = new TestingAuthenticationToken(buildJwt(Map.of( - "sub", "jwt-sub-789", - "email", "jwt@example.com" - )), "n/a"); - - String result = AuthenticatedUser.email(auth); - - assertThat(result).isEqualTo("jwt@example.com"); - } - @Test void displayName_nullAuthentication_returnsNull() { String result = AuthenticatedUser.displayName(null); @@ -90,18 +77,6 @@ void displayName_oidcClaims_returnExpectedDisplayName(Map claims assertThat(result).isEqualTo(expectedDisplayName); } - @Test - void displayName_jwtClaims_returnExpectedDisplayName() { - Authentication auth = new TestingAuthenticationToken(buildJwt(Map.of( - "sub", "jwt-sub-123", - "email", "display@example.com" - )), "n/a"); - - String result = AuthenticatedUser.displayName(auth); - - assertThat(result).isEqualTo("display@example.com"); - } - @Test void isAdmin_nullAuthentication_returnsFalse() { boolean result = AuthenticatedUser.isAdmin(null); @@ -145,12 +120,6 @@ private static OidcUser buildOidcUser(Map claims) { return new DefaultOidcUser(Collections.emptyList(), idToken); } - private static Jwt buildJwt(Map claims) { - Instant issuedAt = Instant.now(); - Instant expiresAt = issuedAt.plusSeconds(3600); - return new Jwt("token-value", issuedAt, expiresAt, Map.of("alg", "none"), claims); - } - private static Stream usernameOidcCases() { return Stream.of( Arguments.of(Map.of("sub", "sub-123", "preferred_username", "preferred.user@example.com"), "preferred.user@example.com"), diff --git a/scim-validator/pom.xml b/scim-validator/pom.xml index 698f00f..2b371d5 100644 --- a/scim-validator/pom.xml +++ b/scim-validator/pom.xml @@ -147,6 +147,7 @@ 3.0.2 + compile compileTests @@ -192,6 +193,7 @@ 3.4.2 + test-compile test-jar diff --git a/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimBaseSpec.groovy b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimBaseSpec.groovy index 3d02203..b3490ff 100644 --- a/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimBaseSpec.groovy +++ b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimBaseSpec.groovy @@ -20,13 +20,24 @@ import spock.lang.Specification */ abstract class ScimBaseSpec extends Specification { + private static final InheritableThreadLocal STATE = new InheritableThreadLocal<>() + // ─── Configuration ─────────────────────────────────────────────────── - static String SCIM_API_URL + static String getBASE_URL() { + return state().baseUrl + } - @Shared static String BASE_URL - @Shared static String BASE_PATH - @Shared static String AUTH_TOKEN - @Shared static String workspaceId + static String getBASE_PATH() { + return state().basePath + } + + static String getAUTH_TOKEN() { + return state().authToken + } + + static String getWorkspaceId() { + return state().workspaceId + } static { refreshConfiguration() @@ -42,13 +53,33 @@ abstract class ScimBaseSpec extends Specification { static final String SPC_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig" // ─── Service Provider Config (loaded once) ─────────────────────────── - @Shared static boolean spcLoaded = false - @Shared static boolean patchSupported = false - @Shared static boolean bulkSupported = false - @Shared static int bulkMaxOperations = 0 - @Shared static int filterMaxResults = 0 - @Shared static boolean etagSupported = false - @Shared static boolean sortSupported = false + static boolean isSpcLoaded() { + return state().spcLoaded + } + + static boolean isPatchSupported() { + return state().patchSupported + } + + static boolean isBulkSupported() { + return state().bulkSupported + } + + static int getBulkMaxOperations() { + return state().bulkMaxOperations + } + + static int getFilterMaxResults() { + return state().filterMaxResults + } + + static boolean isEtagSupported() { + return state().etagSupported + } + + static boolean isSortSupported() { + return state().sortSupported + } // ─── Dynamic data generator ────────────────────────────────────────── @@ -64,7 +95,12 @@ abstract class ScimBaseSpec extends Specification { loadServiceProviderConfig() } + static void resetRunState() { + STATE.remove() + } + protected static void refreshConfiguration() { + RuntimeState currentState = state() ValidatorConfiguration.Config configuration = ValidatorConfiguration.current() String explicitBaseUrl = configuration.baseUrl String configuredWorkspace = configuration.workspaceId @@ -75,11 +111,11 @@ abstract class ScimBaseSpec extends Specification { if (ValidatorTargetConfiguration.shouldBootstrap(configuration)) { if (testcontainersEnabled) { ScimValidatorEnvironment.ScimRuntimeConfiguration runtimeConfiguration = ScimValidatorEnvironment.ensureStarted() - workspaceId = runtimeConfiguration.workspaceId - AUTH_TOKEN = runtimeConfiguration.authToken - SCIM_API_URL = runtimeConfiguration.apiUrl - BASE_PATH = "/ws/${workspaceId}/scim/v2" - BASE_URL = "${SCIM_API_URL}${BASE_PATH}" + currentState.workspaceId = runtimeConfiguration.workspaceId + currentState.authToken = runtimeConfiguration.authToken + currentState.scimApiUrl = runtimeConfiguration.apiUrl + currentState.basePath = "/ws/${currentState.workspaceId}/scim/v2" + currentState.baseUrl = "${currentState.scimApiUrl}${currentState.basePath}" return } @@ -89,25 +125,25 @@ abstract class ScimBaseSpec extends Specification { ) } - workspaceId = configuredWorkspace - AUTH_TOKEN = configuredAuthToken + currentState.workspaceId = configuredWorkspace + currentState.authToken = configuredAuthToken - if (AUTH_TOKEN == null || AUTH_TOKEN.isBlank()) { + if (currentState.authToken == null || currentState.authToken.isBlank()) { throw new IllegalStateException("SCIM_AUTH_TOKEN or -Dscim.authToken must be configured for validator runs") } if (explicitBaseUrl != null && !explicitBaseUrl.isBlank()) { URI uri = new URI(explicitBaseUrl.trim()) - SCIM_API_URL = "${uri.scheme}://${uri.authority}" - BASE_PATH = uri.path != null && !uri.path.isBlank() ? uri.path : "/" - BASE_URL = explicitBaseUrl.trim() + currentState.scimApiUrl = "${uri.scheme}://${uri.authority}" + currentState.basePath = uri.path != null && !uri.path.isBlank() ? uri.path : "/" + currentState.baseUrl = explicitBaseUrl.trim() } else { - if (workspaceId == null || workspaceId.isBlank()) { + if (currentState.workspaceId == null || currentState.workspaceId.isBlank()) { throw new IllegalStateException("SCIM_WORKSPACE_ID or -Dscim.workspaceId must be configured when SCIM_BASE_URL is not set") } - SCIM_API_URL = explicitApiUrl - BASE_PATH = "/ws/${workspaceId}/scim/v2" - BASE_URL = "${SCIM_API_URL}${BASE_PATH}" + currentState.scimApiUrl = explicitApiUrl + currentState.basePath = "/ws/${currentState.workspaceId}/scim/v2" + currentState.baseUrl = "${currentState.scimApiUrl}${currentState.basePath}" } } @@ -116,10 +152,7 @@ abstract class ScimBaseSpec extends Specification { } protected static void configureRestAssured() { - RestAssured.baseURI = SCIM_API_URL - RestAssured.basePath = BASE_PATH - // Avoid stacking duplicate capture filters across repeated configure calls. - RestAssured.replaceFiltersWith(new ScimExchangeCaptureFilter()) + state() } /** @@ -127,20 +160,21 @@ abstract class ScimBaseSpec extends Specification { */ protected void loadServiceProviderConfig() { configureRestAssured() - if (spcLoaded) return + RuntimeState currentState = state() + if (currentState.spcLoaded) return try { Response response = scimRequest() .get("/ServiceProviderConfig") if (response.statusCode() == 200) { def json = response.jsonPath() - patchSupported = asBoolean(json.get("patch.supported"), false) - bulkSupported = asBoolean(json.get("bulk.supported"), false) - bulkMaxOperations = asInt(json.get("bulk.maxOperations"), 0) - filterMaxResults = asInt(json.get("filter.maxResults"), 0) - etagSupported = asBoolean(json.get("etag.supported"), false) - sortSupported = asBoolean(json.get("sort.supported"), false) - spcLoaded = true + currentState.patchSupported = asBoolean(json.get("patch.supported"), false) + currentState.bulkSupported = asBoolean(json.get("bulk.supported"), false) + currentState.bulkMaxOperations = asInt(json.get("bulk.maxOperations"), 0) + currentState.filterMaxResults = asInt(json.get("filter.maxResults"), 0) + currentState.etagSupported = asBoolean(json.get("etag.supported"), false) + currentState.sortSupported = asBoolean(json.get("sort.supported"), false) + currentState.spcLoaded = true } } catch (Exception e) { System.err.println("WARNING: Could not load ServiceProviderConfig: ${e.message}") @@ -176,8 +210,12 @@ abstract class ScimBaseSpec extends Specification { */ protected RequestSpecification scimRequest() { configureRestAssured() + RuntimeState currentState = state() def req = RestAssured.given() - .header("Authorization", "Bearer ${AUTH_TOKEN}") + .baseUri(currentState.scimApiUrl) + .basePath(currentState.basePath) + .filter(new ScimExchangeCaptureFilter()) + .header("Authorization", "Bearer ${currentState.authToken}") .contentType(SCIM_CONTENT_TYPE) .accept(SCIM_CONTENT_TYPE) @@ -194,12 +232,54 @@ abstract class ScimBaseSpec extends Specification { */ protected RequestSpecification scimRequestQuiet() { configureRestAssured() + RuntimeState currentState = state() return RestAssured.given() - .header("Authorization", "Bearer ${AUTH_TOKEN}") + .baseUri(currentState.scimApiUrl) + .basePath(currentState.basePath) + .filter(new ScimExchangeCaptureFilter()) + .header("Authorization", "Bearer ${currentState.authToken}") .contentType(SCIM_CONTENT_TYPE) .accept(SCIM_CONTENT_TYPE) } + /** + * Build a REST Assured request without an Authorization header. + */ + protected RequestSpecification scimRequestAnonymous() { + configureRestAssured() + RuntimeState currentState = state() + return RestAssured.given() + .baseUri(currentState.scimApiUrl) + .basePath(currentState.basePath) + .filter(new ScimExchangeCaptureFilter()) + .contentType(SCIM_CONTENT_TYPE) + .accept(SCIM_CONTENT_TYPE) + } + + private static RuntimeState state() { + RuntimeState currentState = STATE.get() + if (currentState == null) { + currentState = new RuntimeState() + STATE.set(currentState) + } + return currentState + } + + private static final class RuntimeState { + String scimApiUrl + String baseUrl + String basePath + String authToken + String workspaceId + boolean spcLoaded + boolean patchSupported + boolean bulkSupported + int bulkMaxOperations + int filterMaxResults + boolean etagSupported + boolean sortSupported + } + /** * Create a minimal SCIM User and return the response. */ diff --git a/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimExchangeCaptureFilter.groovy b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimExchangeCaptureFilter.groovy index cd73ab9..e4f42b9 100644 --- a/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimExchangeCaptureFilter.groovy +++ b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimExchangeCaptureFilter.groovy @@ -2,6 +2,8 @@ package de.palsoftware.scim.validator.base import io.restassured.filter.Filter import io.restassured.filter.FilterContext +import io.restassured.http.Header +import io.restassured.http.Headers import io.restassured.response.Response import io.restassured.specification.FilterableRequestSpecification import io.restassured.specification.FilterableResponseSpecification @@ -20,7 +22,7 @@ class ScimExchangeCaptureFilter implements Filter { ScimHttpExchange exchange = new ScimHttpExchange( method: requestSpec.getMethod(), url: requestSpec.getURI(), - requestHeaders: requestSpec.getHeaders()?.toString(), + requestHeaders: stringifyRequestHeaders(requestSpec.getHeaders()), requestBody: stringifyRequestBody(requestSpec.getBody()), responseStatus: response.statusCode(), responseHeaders: response.getHeaders()?.toString(), @@ -39,4 +41,18 @@ class ScimExchangeCaptureFilter implements Filter { } return String.valueOf(body) } + + private static String stringifyRequestHeaders(Headers headers) { + if (headers == null) { + return null + } + + List
sanitizedHeaders = headers.asList().findAll { Header header -> + !"Authorization".equalsIgnoreCase(header.name) + } + if (sanitizedHeaders.isEmpty()) { + return null + } + return new Headers(sanitizedHeaders).toString() + } } diff --git a/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimExchangeCaptureFilterSpec.groovy b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimExchangeCaptureFilterSpec.groovy new file mode 100644 index 0000000..22e688a --- /dev/null +++ b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimExchangeCaptureFilterSpec.groovy @@ -0,0 +1,55 @@ +package de.palsoftware.scim.validator.base + +import io.restassured.filter.FilterContext +import io.restassured.http.Header +import io.restassured.http.Headers +import io.restassured.response.Response +import io.restassured.response.ResponseBody +import io.restassured.specification.FilterableRequestSpecification +import io.restassured.specification.FilterableResponseSpecification +import spock.lang.Specification + +class ScimExchangeCaptureFilterSpec extends Specification { + + def cleanup() { + ScimRunContext.endRun() + } + + def "filter does not capture authorization request headers"() { + given: + def filter = new ScimExchangeCaptureFilter() + def requestSpec = Mock(FilterableRequestSpecification) { + getMethod() >> "GET" + getURI() >> "https://example.test/ws/123/scim/v2/Users" + getHeaders() >> new Headers( + new Header("Authorization", "Bearer secret-token"), + new Header("Accept", "application/scim+json"), + new Header("Content-Type", "application/scim+json") + ) + getBody() >> null + } + def responseSpec = Mock(FilterableResponseSpecification) + def responseBody = Stub(ResponseBody) { + asString() >> '{"ok":true}' + } + def response = Mock(Response) { + statusCode() >> 200 + getHeaders() >> new Headers(new Header("Content-Type", "application/scim+json")) + getBody() >> responseBody + } + def context = Mock(FilterContext) { + next(requestSpec, responseSpec) >> response + } + ScimRunContext.beginRun("run-1") + ScimRunContext.beginTest("test-1") + + when: + filter.filter(requestSpec, responseSpec, context) + + then: + def exchanges = ScimRunContext.getForTest("test-1") + exchanges.size() == 1 + exchanges[0].requestHeaders == "Accept=application/scim+json\nContent-Type=application/scim+json" + !exchanges[0].requestHeaders.contains("Authorization") + } +} \ No newline at end of file diff --git a/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimRunContext.groovy b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimRunContext.groovy index adb1651..6119add 100644 --- a/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimRunContext.groovy +++ b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ScimRunContext.groovy @@ -5,31 +5,45 @@ import java.util.concurrent.CopyOnWriteArrayList class ScimRunContext { - private static final ThreadLocal CURRENT_TEST = new ThreadLocal<>() - private static final ConcurrentHashMap> EXCHANGES = new ConcurrentHashMap<>() + private static final InheritableThreadLocal CURRENT_RUN = new InheritableThreadLocal<>() + private static final InheritableThreadLocal CURRENT_TEST = new InheritableThreadLocal<>() + private static final ConcurrentHashMap>> EXCHANGES = new ConcurrentHashMap<>() - private static volatile boolean captureEnabled = false - - static void reset() { + static void beginRun(String runId) { + if (runId == null || runId.isBlank()) { + throw new IllegalArgumentException("runId is required") + } + CURRENT_RUN.set(runId) CURRENT_TEST.remove() - EXCHANGES.clear() + EXCHANGES.putIfAbsent(runId, new ConcurrentHashMap<>()) } - static void setCaptureEnabled(boolean enabled) { - captureEnabled = enabled + static void endRun() { + String runId = CURRENT_RUN.get() + CURRENT_TEST.remove() + CURRENT_RUN.remove() + if (runId != null) { + EXCHANGES.remove(runId) + } } static boolean isCaptureEnabled() { - return captureEnabled + return CURRENT_RUN.get() != null } static void beginTest(String testId) { + String runId = CURRENT_RUN.get() + if (runId == null) { + CURRENT_TEST.remove() + return + } if (testId == null) { CURRENT_TEST.remove() return } CURRENT_TEST.set(testId) - EXCHANGES.putIfAbsent(testId, new CopyOnWriteArrayList<>()) + EXCHANGES.computeIfAbsent(runId, ignored -> new ConcurrentHashMap<>()) + .putIfAbsent(testId, new CopyOnWriteArrayList<>()) } static void endTest() { @@ -37,20 +51,31 @@ class ScimRunContext { } static void record(ScimHttpExchange exchange) { - if (!captureEnabled || exchange == null) { + if (!isCaptureEnabled() || exchange == null) { + return + } + String runId = CURRENT_RUN.get() + if (runId == null) { return } String testId = CURRENT_TEST.get() if (testId == null) { testId = "_unassigned" } - EXCHANGES.computeIfAbsent(testId, k -> new CopyOnWriteArrayList<>()).add(exchange) + EXCHANGES.computeIfAbsent(runId, ignored -> new ConcurrentHashMap<>()) + .computeIfAbsent(testId, ignored -> new CopyOnWriteArrayList<>()) + .add(exchange) } static List getForTest(String testId) { - if (testId == null) { + String runId = CURRENT_RUN.get() + if (runId == null || testId == null) { + return List.of() + } + Map> runExchanges = EXCHANGES.get(runId) + if (runExchanges == null) { return List.of() } - return new ArrayList<>(EXCHANGES.getOrDefault(testId, new CopyOnWriteArrayList<>())) + return new ArrayList<>(runExchanges.getOrDefault(testId, new CopyOnWriteArrayList<>())) } } diff --git a/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ValidatorConfiguration.groovy b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ValidatorConfiguration.groovy index 54fbe71..0bafc60 100644 --- a/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ValidatorConfiguration.groovy +++ b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ValidatorConfiguration.groovy @@ -14,13 +14,34 @@ final class ValidatorConfiguration { "SCIM_VALIDATOR_POSTGRES_IMAGE", "scim.testcontainers.postgresImage", "SCIM_VALIDATOR_API_IMAGE", "scim.testcontainers.apiImage" ) - private static final Config CURRENT = load() + private static final Config DEFAULT_CONFIG = load() + private static final InheritableThreadLocal CURRENT_OVERRIDE = new InheritableThreadLocal<>() private ValidatorConfiguration() { } static Config current() { - return CURRENT + Config override = CURRENT_OVERRIDE.get() + return override != null ? override : DEFAULT_CONFIG + } + + static void useRunOverrides(String baseUrl, String authToken) { + Config base = DEFAULT_CONFIG + CURRENT_OVERRIDE.set(new Config( + hasText(baseUrl) ? baseUrl.trim() : base.baseUrl, + base.apiUrl, + base.workspaceId, + hasText(authToken) ? authToken.trim() : base.authToken, + base.testcontainersEnabled, + base.postgresImage, + base.apiImage, + base.postgres, + base.api + )) + } + + static void clearRunOverrides() { + CURRENT_OVERRIDE.remove() } private static Config load() { @@ -118,6 +139,10 @@ final class ValidatorConfiguration { return System.getProperty(propertyName) } + private static boolean hasText(String value) { + return value != null && !value.isBlank() + } + static final class Config { final String baseUrl final String apiUrl diff --git a/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ValidatorRunIsolationSpec.groovy b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ValidatorRunIsolationSpec.groovy new file mode 100644 index 0000000..ba119b9 --- /dev/null +++ b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/base/ValidatorRunIsolationSpec.groovy @@ -0,0 +1,212 @@ +package de.palsoftware.scim.validator.base + +import io.restassured.filter.log.RequestLoggingFilter +import io.restassured.filter.log.ResponseLoggingFilter +import io.restassured.specification.FilterableRequestSpecification +import spock.lang.Specification + +import java.time.OffsetDateTime +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicReference + +class ValidatorRunIsolationSpec extends Specification { + + def "concurrent runs keep validator state isolated across inherited child threads and request builders"() { + given: + Map defaultConfiguration = snapshotConfiguration(ValidatorConfiguration.current()) + Map> results = new ConcurrentHashMap<>() + List failures = new CopyOnWriteArrayList<>() + + Thread runOne = new Thread({ + captureStateForRun("run-1", "https://example-1.test/ws/1/scim/v2", "token-1", results, failures) + }) + Thread runTwo = new Thread({ + captureStateForRun("run-2", "https://example-2.test/ws/2/scim/v2", "token-2", results, failures) + }) + + when: + runOne.start() + runTwo.start() + runOne.join() + runTwo.join() + + then: + failures.isEmpty() + results["run-1"].configuration.baseUrl == "https://example-1.test/ws/1/scim/v2" + results["run-1"].configuration.basePath == "/ws/1/scim/v2" + results["run-1"].configuration.authToken == "token-1" + results["run-1"].childConfiguration == results["run-1"].configuration + results["run-1"].captureEnabled + results["run-1"].childCaptureEnabled + results["run-1"].requestBuilders.authenticated.baseUri == "https://example-1.test" + results["run-1"].requestBuilders.authenticated.basePath == "/ws/1/scim/v2" + results["run-1"].requestBuilders.authenticated.authorization == "Bearer token-1" + results["run-1"].requestBuilders.authenticated.filters.contains("ScimExchangeCaptureFilter") + !results["run-1"].requestBuilders.authenticated.filters.contains("RequestLoggingFilter") + !results["run-1"].requestBuilders.authenticated.filters.contains("ResponseLoggingFilter") + results["run-1"].requestBuilders.quiet.authorization == "Bearer token-1" + results["run-1"].requestBuilders.anonymous.authorization == null + results["run-1"].requestBuilders.anonymous.baseUri == "https://example-1.test" + results["run-1"].requestBuilders.anonymous.basePath == "/ws/1/scim/v2" + results["run-1"].exchanges*.url as Set == [ + "https://example-1.test/ws/1/scim/v2/Users", + "https://example-1.test/ws/1/scim/v2/Groups" + ] as Set + + results["run-2"].configuration.baseUrl == "https://example-2.test/ws/2/scim/v2" + results["run-2"].configuration.basePath == "/ws/2/scim/v2" + results["run-2"].configuration.authToken == "token-2" + results["run-2"].childConfiguration == results["run-2"].configuration + results["run-2"].captureEnabled + results["run-2"].childCaptureEnabled + results["run-2"].requestBuilders.authenticated.baseUri == "https://example-2.test" + results["run-2"].requestBuilders.authenticated.basePath == "/ws/2/scim/v2" + results["run-2"].requestBuilders.authenticated.authorization == "Bearer token-2" + results["run-2"].requestBuilders.authenticated.filters.contains("ScimExchangeCaptureFilter") + !results["run-2"].requestBuilders.authenticated.filters.contains("RequestLoggingFilter") + !results["run-2"].requestBuilders.authenticated.filters.contains("ResponseLoggingFilter") + results["run-2"].requestBuilders.quiet.authorization == "Bearer token-2" + results["run-2"].requestBuilders.anonymous.authorization == null + results["run-2"].requestBuilders.anonymous.baseUri == "https://example-2.test" + results["run-2"].requestBuilders.anonymous.basePath == "/ws/2/scim/v2" + results["run-2"].exchanges*.url as Set == [ + "https://example-2.test/ws/2/scim/v2/Users", + "https://example-2.test/ws/2/scim/v2/Groups" + ] as Set + + !ScimRunContext.isCaptureEnabled() + snapshotConfiguration(ValidatorConfiguration.current()) == defaultConfiguration + } + + def "ending a run clears captured exchanges and clearing overrides restores defaults"() { + given: + Map defaultConfiguration = snapshotConfiguration(ValidatorConfiguration.current()) + Map activeConfiguration + List capturedBeforeEnd + List capturedAfterRestart + Map restoredConfiguration + + when: + ValidatorConfiguration.useRunOverrides("https://cleanup.test/ws/cleanup/scim/v2", "cleanup-token") + TestScimBaseSpec.resetRunState() + ScimRunContext.beginRun("cleanup") + ScimRunContext.beginTest("test-cleanup") + ScimRunContext.record(new ScimHttpExchange( + method: "GET", + url: "https://cleanup.test/ws/cleanup/scim/v2/Users", + createdAt: OffsetDateTime.now() + )) + activeConfiguration = TestScimBaseSpec.snapshotConfiguration() + capturedBeforeEnd = ScimRunContext.getForTest("test-cleanup") + + ScimRunContext.endRun() + TestScimBaseSpec.resetRunState() + ValidatorConfiguration.clearRunOverrides() + + ScimRunContext.beginRun("cleanup") + capturedAfterRestart = ScimRunContext.getForTest("test-cleanup") + ScimRunContext.endRun() + restoredConfiguration = snapshotConfiguration(ValidatorConfiguration.current()) + + then: + activeConfiguration.baseUrl == "https://cleanup.test/ws/cleanup/scim/v2" + activeConfiguration.basePath == "/ws/cleanup/scim/v2" + activeConfiguration.authToken == "cleanup-token" + capturedBeforeEnd*.url == ["https://cleanup.test/ws/cleanup/scim/v2/Users"] + capturedAfterRestart.isEmpty() + !ScimRunContext.isCaptureEnabled() + restoredConfiguration == defaultConfiguration + } + + private static void captureStateForRun(String runId, + String baseUrl, + String authToken, + Map> results, + List failures) { + try { + ValidatorConfiguration.useRunOverrides(baseUrl, authToken) + TestScimBaseSpec.resetRunState() + ScimRunContext.beginRun(runId) + ScimRunContext.beginTest("test-${runId}") + boolean captureEnabled = ScimRunContext.isCaptureEnabled() + ScimRunContext.record(new ScimHttpExchange( + method: "GET", + url: "${baseUrl}/Users", + createdAt: OffsetDateTime.now() + )) + + AtomicReference> childConfiguration = new AtomicReference<>() + AtomicReference>> childRequestBuilders = new AtomicReference<>() + AtomicReference childCaptureEnabled = new AtomicReference<>(false) + Thread child = new Thread({ + childConfiguration.set(TestScimBaseSpec.snapshotConfiguration()) + childRequestBuilders.set(TestScimBaseSpec.snapshotRequestBuilders()) + childCaptureEnabled.set(ScimRunContext.isCaptureEnabled()) + ScimRunContext.record(new ScimHttpExchange( + method: "GET", + url: "${baseUrl}/Groups", + createdAt: OffsetDateTime.now() + )) + }) + child.start() + child.join() + + results[runId] = [ + configuration : TestScimBaseSpec.snapshotConfiguration(), + childConfiguration : childConfiguration.get(), + requestBuilders : TestScimBaseSpec.snapshotRequestBuilders(), + childRequestBuilders: childRequestBuilders.get(), + captureEnabled : captureEnabled, + childCaptureEnabled: childCaptureEnabled.get(), + exchanges : ScimRunContext.getForTest("test-${runId}") + ] + } catch (Throwable throwable) { + failures.add(throwable) + } finally { + ScimRunContext.endRun() + TestScimBaseSpec.resetRunState() + ValidatorConfiguration.clearRunOverrides() + } + } + + private static Map snapshotConfiguration(ValidatorConfiguration.Config config) { + return [ + baseUrl : config.baseUrl, + apiUrl : config.apiUrl, + workspaceId: config.workspaceId, + authToken : config.authToken + ] + } + + private static final class TestScimBaseSpec extends ScimBaseSpec { + + static Map snapshotConfiguration() { + refreshConfiguration() + return [ + baseUrl : BASE_URL, + basePath : BASE_PATH, + authToken: AUTH_TOKEN + ] + } + + static Map> snapshotRequestBuilders() { + TestScimBaseSpec spec = new TestScimBaseSpec() + return [ + authenticated: snapshotRequest(spec.scimRequest() as FilterableRequestSpecification), + quiet : snapshotRequest(spec.scimRequestQuiet() as FilterableRequestSpecification), + anonymous : snapshotRequest(spec.scimRequestAnonymous() as FilterableRequestSpecification) + ] + } + + private static Map snapshotRequest(FilterableRequestSpecification request) { + return [ + baseUri : request.baseUri, + basePath : request.basePath, + contentType : request.contentType, + authorization: request.headers.getValue("Authorization"), + filters : request.definedFilters.collect { it.class.simpleName } + ] + } + } +} \ No newline at end of file diff --git a/scim-validator/src/test/groovy/de/palsoftware/scim/validator/specs/A8_SecurityAndRobustnessSpec.groovy b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/specs/A8_SecurityAndRobustnessSpec.groovy index ec75066..8f8567c 100644 --- a/scim-validator/src/test/groovy/de/palsoftware/scim/validator/specs/A8_SecurityAndRobustnessSpec.groovy +++ b/scim-validator/src/test/groovy/de/palsoftware/scim/validator/specs/A8_SecurityAndRobustnessSpec.groovy @@ -1,7 +1,6 @@ package de.palsoftware.scim.validator.specs import de.palsoftware.scim.validator.base.ScimBaseSpec -import io.restassured.RestAssured import io.restassured.response.Response import groovy.json.JsonOutput import spock.lang.Shared @@ -31,9 +30,7 @@ class A8_SecurityAndRobustnessSpec extends ScimBaseSpec { def "SEC_01: Request without Authorization header returns 401"() { // RFC 7644 §2 — Authentication and Authorization when: - Response response = RestAssured.given() - .contentType(SCIM_CONTENT_TYPE) - .accept(SCIM_CONTENT_TYPE) + Response response = scimRequestAnonymous() .get("/Users") then: @@ -48,10 +45,8 @@ class A8_SecurityAndRobustnessSpec extends ScimBaseSpec { def "SEC_02: Request with invalid Bearer token returns 401"() { when: - Response response = RestAssured.given() + Response response = scimRequestAnonymous() .header("Authorization", "Bearer INVALID_TOKEN_12345") - .contentType(SCIM_CONTENT_TYPE) - .accept(SCIM_CONTENT_TYPE) .get("/Users") then: diff --git a/scim-validator/src/test/resources/application.yml b/scim-validator/src/test/resources/application.yml deleted file mode 100644 index afbf5b8..0000000 --- a/scim-validator/src/test/resources/application.yml +++ /dev/null @@ -1,16 +0,0 @@ -scim: - base-url: "${SCIM_BASE_URL:}" - api-url: "${SCIM_API_URL:http://localhost:8080}" - workspace-id: "${SCIM_WORKSPACE_ID:}" - auth-token: "${SCIM_AUTH_TOKEN:}" - testcontainers: - enabled: "${SCIM_TESTCONTAINERS_ENABLED:true}" - postgres-image: "${SCIM_VALIDATOR_POSTGRES_IMAGE:postgres:18-alpine3.22}" - api-image: "${SCIM_VALIDATOR_API_IMAGE:edipal/scim-server-api:latest}" - postgres: - alias: validator-postgres - database-name: scimplayground - username: scim_playground - password: scim_playground - api: - port: 8080 \ No newline at end of file