diff --git a/README.md b/README.md index e861148..30d60f3 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,35 @@ This project provides a **SCIM 2.0-compliant extension** for [Keycloak](https://www.keycloak.org/), enabling SCIM-based user and group provisioning. It supports: -- **Realm-level SCIM APIs**: +- **Realm-level SCIM APIs**: `/realms/{realm}/scim/v2` -- **Organization-level SCIM APIs** (Keycloak 26+ with Organizations): +- **Organization-level SCIM APIs** (Keycloak 26+ with Organizations): `/realms/{realm}/scim/v2/organizations/{organizationId}` +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Installation](#installation) + - [Option 1: Include from GitHub Release](#option-1-include-it-directly-from-github-release) + - [Option 2: Install from GitHub Packages](#option-2-install-from-github-packages-recommended) + - [Option 3: Build from Source](#option-3-build-from-source) +- [Configuration](#configuration) + - [Instance-Level Configuration](#instance-level-configuration) + - [Realm-Level Configuration](#realm-level-configuration) + - [Organization-Level Configuration](#organization-level-configuration) +- [Authentication](#authentication) + - [Keycloak Authentication](#keycloak-authentication) + - [External JWT (JWKS) Authentication](#external-jwt-jwks-authentication) + - [External Shared Secret (Bearer Token) Authentication](#external-shared-secret-bearer-token-authentication) + - [External Basic Auth Authentication](#external-basic-auth-authentication) +- [Vendor Configuration Guides](#vendor-configuration-guides) + - [Microsoft Entra ID](#microsoft-entra-id) + - [Okta](#okta) +- [Identity Provider Linking](#identity-provider-linking) + - [Identity Provider Linking with Azure Entra ID](#identity-provider-linking-with-azure-entra-id) +- [SCIM-Managed Users](#scim-managed-users) +- [License](#license) + ## Prerequisites - **Keycloak**: This extension is developed for Keycloak **26.3.5**. It may work with other versions, but compatibility is not guaranteed. @@ -14,19 +38,46 @@ This project provides a **SCIM 2.0-compliant extension** for [Keycloak](https:// ## Installation -### Option 1: Install from GitHub Packages (recommended) +### Option 1: Include it directly from GitHub Release + +You can reference the JAR file from a GitHub Release directly in your init container or Dockerfile. + +For example, using a Helm `values.yaml`: +```yaml +extraInitContainers: | + - name: download-scim-plugin + image: alpine:latest + command: + - sh + - -c + - > + apk add --no-cache curl && + curl -L -o /extensions/keycloak-scim-server-.jar https://github.com/Metatavu/keycloak-scim-server/releases/download/v/keycloak-scim-server-.jar + volumeMounts: + - name: extensions + mountPath: /extensions + +extraVolumeMounts: | + - name: extensions + mountPath: /opt/keycloak/providers + +extraVolumes: | + - name: extensions + emptyDir: {} +``` + +### Option 2: Install from GitHub Packages (recommended) -Easiest way to use the extension is to download a JAR file from GitHub packages. +Download the JAR file from GitHub packages. 1. Download the latest JAR from: [GitHub Packages](https://github.com/Metatavu/keycloak-scim-server/packages/2454996) 2. Copy it to your Keycloak instance: ```bash - cp keycloak-scim-server-*.jar $KEYCLOAK_HOME/providers/ +cp keycloak-scim-server-*.jar $KEYCLOAK_HOME/providers/ ``` 3. Restart Keycloak. - -### Option 2: Build from Source +### Option 3: Build from Source 1. Build the extension: ```bash @@ -39,29 +90,30 @@ cp build/libs/keycloak-scim-server-*.jar $KEYCLOAK_HOME/providers/ ## Configuration -### Configuration on Instance level +All settings can be applied at three levels. Settings at a more specific level override broader ones (organization > realm > instance). -Configuration on instance level is done by defining environment variables in the Keycloak server. +### Instance-Level Configuration -The following environment variables are available: +Configuration on instance level is done by defining environment variables on the Keycloak server. -| Setting | Value | -|--------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| SCIM_AUTHENTICATION_MODE | Authentication mode for SCIM API. Possible values are KEYCLOAK and EXTERNAL. If the value is not set the server will respond unauthorzed for all requests. | -| SCIM_EXTERNAL_ISSUER | Issuer for the external authentication. This is used to validate the JWT token. | -| SCIM_EXTERNAL_AUDIENCE | JWKS URI for the external authentication. This is used to validate the JWT token. | -| SCIM_EXTERNAL_JWKS_URI | Audience for the external authentication. This is used to validate the JWT token. | -| SCIM_EXTERNAL_SHARED_SECRET | Shared secret value used for request authentication/validation. | -| SCIM_EXTERNAL_SHARED_SECRET_HASH_ALGORITHM | PHC String Format representing hash algorithms and its parameters, used for request authentication/validation ([must be on of the following](https://www.keycloak.org/docs/26.1.5/server_admin/index.html#hashalgorithm)). | -| SCIM_LINK_IDP | Enables support for linking realm identity provider with user. | -| SCIM_IDENTITY_PROVIDER_ALIAS | Alias of Identity Provider to be linked to the user. | +| Setting | Description | +|------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | Authentication mode for SCIM API. Possible values are `KEYCLOAK` and `EXTERNAL`. If not set the server will respond unauthorized for all requests. | +| `SCIM_EXTERNAL_ISSUER` | Issuer for external JWT authentication. Used to validate the JWT token issuer claim. | +| `SCIM_EXTERNAL_AUDIENCE` | Audience for external JWT authentication. Used to validate the JWT token audience claim. | +| `SCIM_EXTERNAL_JWKS_URI` | JWKS URI for external JWT authentication. Used to fetch public keys for JWT token signature validation. | +| `SCIM_EXTERNAL_SHARED_SECRET`| Shared secret in [PHC String Format](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md) used for bearer token authentication/validation. | +| `SCIM_BASIC_AUTH_USERNAME` | Username for HTTP Basic authentication. | +| `SCIM_BASIC_AUTH_PASSWORD` | Password hash in [PHC String Format](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md) for HTTP Basic authentication. | +| `SCIM_LINK_IDP` | Enables support for linking realm identity provider with user. | +| `SCIM_IDENTITY_PROVIDER_ALIAS`| Alias of Identity Provider to be linked to the user. | ### Configuration on Realm level -The following REST call can be called through the Keycloak Admin API to store the settings under realm attributes. +The following REST call can be made through the Keycloak Admin API to store settings as realm attributes. Realm-level settings override instance-level settings. PUT `/admin/realms/{realm}` -``` +```json { "attributes": { "scim.authentication.mode": "EXTERNAL|KEYCLOAK", @@ -69,7 +121,9 @@ PUT `/admin/realms/{realm}` "scim.external.jwks.uri": "string", "scim.external.audience": "string", "scim.external.shared.secret": "string", - "scim.external.shared.secret.hash.algorithm": "string" + "scim.external.shared.secret.hash.algorithm": "string", + "scim.basic.auth.username": "string", + "scim.basic.auth.password": "string", "scim.link.idp": "true|false", "scim.identity.provider.alias": "string" } @@ -78,78 +132,162 @@ PUT `/admin/realms/{realm}` ### Configuration on Organization level -Configuration on organization level is done by defining organization attributes in the Keycloak server. -The following organization attributes are available: +Configuration on organization level is done by defining organization attributes in the Keycloak server. Only `EXTERNAL` authentication mode is supported at the organization level. + +| Setting | Description | +|------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | Must be `EXTERNAL`. Only external authentication is supported at the organization level. | +| `SCIM_EXTERNAL_ISSUER` | Issuer for external JWT authentication. | +| `SCIM_EXTERNAL_AUDIENCE` | Audience for external JWT authentication. | +| `SCIM_EXTERNAL_JWKS_URI` | JWKS URI for external JWT authentication. | +| `SCIM_EXTERNAL_SHARED_SECRET`| Shared secret in PHC String Format for bearer token authentication. | +| `SCIM_BASIC_AUTH_USERNAME` | Username for HTTP Basic authentication. | +| `SCIM_BASIC_AUTH_PASSWORD` | Password hash in PHC String Format for HTTP Basic authentication. | +| `SCIM_LINK_IDP` | Enables support for linking organization identity provider with user. | +| `SCIM_EMAIL_AS_USERNAME` | Forces server to use email as username instead of actual username. When enabled, username will be unaffected by update operations. Organization-level only. | + +## Authentication + +The SCIM server supports four authentication methods. For `EXTERNAL` mode, the method is determined automatically based on the authorization header and configuration. + +### Keycloak Authentication + +Uses Keycloak's built-in service account authentication. The SCIM client authenticates using an OAuth2 client credentials flow against Keycloak itself, and the resulting access token is validated natively. + +**Required settings:** + +| Setting | Value | +|----------------------------|-------------| +| `SCIM_AUTHENTICATION_MODE` | `KEYCLOAK` | + +**Requirements:** +- A Keycloak client with **Service Accounts Enabled** +- The client's service account must have the `scim-access` realm role + +**Example:** The SCIM client obtains a token via the Keycloak token endpoint and sends it as a bearer token: +``` +Authorization: Bearer +``` + +### External JWT (JWKS) Authentication + +Validates a JWT bearer token issued by an external identity provider. The token signature is verified against public keys fetched from the configured JWKS endpoint, and the issuer and audience claims are validated. + +**Required settings:** + +| Setting | Value | +|----------------------------|--------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | `EXTERNAL` | +| `SCIM_EXTERNAL_ISSUER` | Expected `iss` claim (e.g., `https://sts.windows.net//`) | +| `SCIM_EXTERNAL_AUDIENCE` | Expected `aud` claim | +| `SCIM_EXTERNAL_JWKS_URI` | URL to the JWKS endpoint for public keys | + +**Example request:** +``` +Authorization: Bearer +``` + +### External Shared Secret (Bearer Token) Authentication + +Validates a static bearer token against a pre-configured hash. The client sends the raw secret as a bearer token, and the server verifies it against the stored hash using Keycloak's password hashing infrastructure. -| Setting | Value | -|--------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| SCIM_AUTHENTICATION_MODE | Authentication mode for SCIM API. Possible values are KEYCLOAK and EXTERNAL. If the value is not set the server will respond unauthorzed for all requests. Currently on organization level only EXTERNAL is supported. | -| SCIM_EXTERNAL_ISSUER | Issuer for the external authentication. This is used to validate the JWT token. | -| SCIM_EXTERNAL_AUDIENCE | Audience for the external authentication. This is used to validate the JWT token. | -| SCIM_EXTERNAL_JWKS_URI | JWKS URI for the external authentication. This is used to validate the JWT token. | -| SCIM_LINK_IDP | Enables support for linking organization identity provider with user. | -| SCIM_EMAIL_AS_USERNAME | Forces server to user email as username instead of actual username. When this setting is enabled username will be unaffected by any update operations. This setting is currently supported only in organization level configuration | -| SCIM_EXTERNAL_SHARED_SECRET | Shared secret value used for request authentication/validation. | -| SCIM_EXTERNAL_SHARED_SECRET_HASH_ALGORITHM | PHC String Format representing hash algorithms and its parameters, used for request authentication/validation ([must be on of the following](https://www.keycloak.org/docs/26.1.5/server_admin/index.html#hashalgorithm)). | +**Required settings:** -### Azure Entra ID SCIM Configuration +| Setting | Value | +|------------------------------|------------------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | `EXTERNAL` | +| `SCIM_EXTERNAL_SHARED_SECRET`| Hashed token in PHC String Format (e.g., Argon2id) | -This extension is compatible with **Microsoft Entra ID** SCIM provisioning. +The hash must be in [PHC String Format](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md) using a [Keycloak-supported hash algorithm](https://www.keycloak.org/docs/26.1.5/server_admin/index.html#hashalgorithm) (e.g., `argon2id`, `pbkdf2-sha512`). -#### Keycloak Configuration +**Example:** If the shared secret is `my-secret-token`, hash it using Argon2id and configure the resulting PHC string: +``` +SCIM_EXTERNAL_SHARED_SECRET=$argon2id$v=19$m=16,t=2,p=1$$ +``` -Before Entra ID can provision users and groups to Keycloak via SCIM, you need to configure SCIM authentication settings. +### Microsoft Entra ID SCIM Configuration +The client then sends the raw secret: +``` +Authorization: Bearer my-secret-token +``` -These settings can be applied either: +### External Basic Auth Authentication -* At the realm level (for /realms/{realm}/scim/v2) -* Or at the organization level (for /realms/organizations/scim/v2/organizations/{organizationId}) +Validates credentials sent via HTTP Basic Authentication. The client sends a Base64-encoded `username:password` pair, and the server verifies the username against the configured value and the password against a stored hash. -For more details, refer to the sections [Configuration on Realm Level] and [Configuration on Organization Level in this document]. +**Required settings:** -SCIM Settings for Entra ID +| Setting | Value | +|----------------------------|------------------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | `EXTERNAL` | +| `SCIM_BASIC_AUTH_USERNAME` | Expected username | +| `SCIM_BASIC_AUTH_PASSWORD` | Password hash in PHC String Format (e.g., Argon2id) | -When using Entra ID settings will be following: +The password hash must be in [PHC String Format](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md) using a [Keycloak-supported hash algorithm](https://www.keycloak.org/docs/26.1.5/server_admin/index.html#hashalgorithm). -| Setting | Value | -|--------------------------|------------------------------------------------------------------------------| -| SCIM_AUTHENTICATION_MODE | ```EXTERNAL``` | -| SCIM_EXTERNAL_ISSUER | ```https://sts.windows.net//``` | -| SCIM_EXTERNAL_AUDIENCE | ```8adf8e6e-67b2-4cf2-a259-e3dc5476c621``` | -| SCIM_EXTERNAL_JWKS_URI | ```https://login.microsoftonline.com//discovery/v2.0/keys``` | +**Example:** If the username is `scim-admin` and password is `my-password`, hash the password using Argon2id and configure: +``` +SCIM_BASIC_AUTH_USERNAME=scim-admin +SCIM_BASIC_AUTH_PASSWORD=$argon2id$v=19$m=16,t=2,p=1$$ +``` -Replace with your actual Azure tenant ID. +The client then sends: +``` +Authorization: Basic +``` + +## Vendor Configuration Guides + +### Microsoft Entra ID -* SCIM_AUTHENTICATION_MODE enables external authentication support for the SCIM server. In this case the external authentication source will be the Azure Entra ID. +Entra ID supports two authentication options when provisioning to this SCIM server: + +* SCIM_AUTHENTICATION_MODE enables external authentication support for the SCIM server. In this case the external authentication source will be the Microsoft Entra ID. * SCIM_EXTERNAL_ISSUER ensures the JWT token was issued by your tenant. * SCIM_EXTERNAL_AUDIENCE must be exactly 8adf8e6e-67b2-4cf2-a259-e3dc5476c621 — this is the default audience used by Entra ID for non-gallery applications. * SCIM_EXTERNAL_JWKS_URI allows Keycloak to fetch public keys for token validation. +#### Option A: JWT Authentication (recommended) + +Entra ID sends a bearer token signed by Microsoft's identity platform. The SCIM server validates it using Microsoft's JWKS endpoint. + +**Keycloak settings:** -OR +| Setting | Value | +|----------------------------|----------------------------------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | `EXTERNAL` | +| `SCIM_EXTERNAL_ISSUER` | `https://sts.windows.net//` | +| `SCIM_EXTERNAL_AUDIENCE` | `8adf8e6e-67b2-4cf2-a259-e3dc5476c621` | +| `SCIM_EXTERNAL_JWKS_URI` | `https://login.microsoftonline.com//discovery/v2.0/keys` | -| Setting | Value | -|-----------------------------|----------------------------| -| SCIM_AUTHENTICATION_MODE | ```EXTERNAL``` | -| SCIM_EXTERNAL_SHARED_SECRET | `````` | +Replace `` with your Azure tenant ID. The audience `8adf8e6e-67b2-4cf2-a259-e3dc5476c621` is the default used by Entra ID for non-gallery applications. -Replace with your hashed token value (using SHA-512 Hex). +#### Option B: Shared Secret Authentication -#### Azure Configuration +Entra ID sends a static bearer token that you generate and configure on both sides. -Step-by-step guide on the Azure: +**Keycloak settings:** -1. Sign in to the [Azure portal](https://portal.azure.com) -2. Go to **Identity → Applications → Enterprise applications** -3. Click **+ New application → + Create your own application** -4. Enter a name for your application (e.g., My Keycloak SCIM). -5. Choose **Integrate any other application you don't find in the gallery.** -6. Click **Create** to create the application. The application will open automatically in its management screen. +| Setting | Value | +|------------------------------|----------------------------| +| `SCIM_AUTHENTICATION_MODE` | `EXTERNAL` | +| `SCIM_EXTERNAL_SHARED_SECRET`| `` | + +Replace `` with the PHC String Format hash of your token. + +#### Azure Portal Setup + +1. Sign in to the [Azure portal](https://portal.azure.com). +2. Go to **Identity > Applications > Enterprise applications**. +3. Click **+ New application > + Create your own application**. +4. Enter a name for your application (e.g., "My Keycloak SCIM"). +5. Choose **Integrate any other application you don't find in the gallery**. +6. Click **Create**. 7. In the application's left-hand menu, select **Provisioning**. 8. Click **+ New configuration**. 9. Fill in the following: - - Tenant URL (realm): https://mykeycloak.example.com/realms/my-realm/scim/v2 or - - Tenant URL (organization): https://mykeycloak.example.com/realms/my-realm/scim/v2/organizations/{organizationId} - - Secret Token: Leave this field empty (the application will use the Entra ID bearer token) OR enter the shared secret value (not hashed). + - **Tenant URL** (realm): `https://mykeycloak.example.com/realms/my-realm/scim/v2` + - **Tenant URL** (organization): `https://mykeycloak.example.com/realms/my-realm/scim/v2/organizations/{organizationId}` + - **Secret Token**: Leave empty for JWT authentication (Option A), or enter the raw shared secret (Option B). 10. Click **Test Connection** to verify the SCIM endpoint. 11. Click **Create**. 12. Navigate to **Attribute Mapping (Preview)**. @@ -180,92 +318,329 @@ For more information, refer to the following documents: https://learn.microsoft.com/en-us/entra/identity/saas-apps/tutorial-list -#### Identity Provider Linking with Azure Entra ID +#### Identity Provider Linking with Microsoft Entra ID +16. Go back and open **Provision Microsoft Entra ID Users**. +17. Define mappings. The following are required: + - `userName` + - `active` + - `emails[type eq "work"].value` + - `name.givenName` + - `name.familyName` +18. Click **Save**. +19. Go back to **Provisioning**. +20. Set **Provisioning Status** to **On**. +21. Click **Save**. +22. Reload the page to ensure the configuration was saved. +23. Navigate to **Manage > Users and groups > + Add user/group**. +24. Select the user you want to provision and click **Assign**. +25. Navigate to **Provision on demand**. +26. Find the user you just assigned. +27. Click on the user and select **Provision**. +28. Verify that the provisioning completes successfully. + +For more information, refer to the [Microsoft Entra ID SCIM provisioning documentation](https://learn.microsoft.com/en-us/entra/identity/saas-apps/tutorial-list). + +### Okta + +Okta supports three authentication methods when provisioning to a SCIM server. All three are supported by this extension. + +For general Okta SCIM setup, refer to the [Okta SCIM provisioning documentation](https://help.okta.com/en-us/content/topics/apps/apps_app_integration_wizard_scim.htm). + +#### Option A: HTTP Header (Bearer Token) + +Okta sends a static bearer token in the `Authorization` header. This uses the shared secret authentication method. + +**Keycloak settings:** + +| Setting | Value | +|------------------------------|------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | `EXTERNAL` | +| `SCIM_EXTERNAL_SHARED_SECRET`| PHC String Format hash of your API token | + +**Okta setup:** +1. In the Okta Admin Console, go to **Applications > Applications**. +2. Select your SCIM application. +3. Go to the **Provisioning** tab and click **Configure API Integration**. +4. Select **HTTP Header** as the authentication mode. +5. In the **Authorization** field, paste the raw API token (the unhashed value). +6. Set the **SCIM connector base URL** to: `https://mykeycloak.example.com/realms/my-realm/scim/v2` +7. Click **Test API Credentials** to verify. +8. Click **Save**. + +#### Option B: Basic Auth + +Okta sends credentials via HTTP Basic Authentication (Base64-encoded `username:password`). + +**Keycloak settings:** + +| Setting | Value | +|----------------------------|-------------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | `EXTERNAL` | +| `SCIM_BASIC_AUTH_USERNAME` | The username you want Okta to authenticate with | +| `SCIM_BASIC_AUTH_PASSWORD` | PHC String Format hash of the password | + +**Okta setup:** +1. In the Okta Admin Console, go to **Applications > Applications**. +2. Select your SCIM application. +3. Go to the **Provisioning** tab and click **Configure API Integration**. +4. Select **Basic Auth** as the authentication mode. +5. Enter the **Username** and **Password** (the raw, unhashed values). +6. Set the **SCIM connector base URL** to: `https://mykeycloak.example.com/realms/my-realm/scim/v2` +7. Click **Test API Credentials** to verify. +8. Click **Save**. + +#### Option C: OAuth2 + +Okta obtains an access token from an OAuth2 token endpoint, then sends it as a bearer token. Since Keycloak is itself an OAuth2 provider, you can point Okta at Keycloak's token endpoint. The resulting JWT is then validated by the SCIM server using the JWKS authentication method. + +**Keycloak prerequisites:** +1. Create a Keycloak client with **Client Authentication** enabled (confidential client). +2. Enable **Service Accounts Enabled** on the client. +3. Note the client ID and client secret. + +**Keycloak settings:** + +| Setting | Value | +|----------------------------|------------------------------------------------------------------------------------| +| `SCIM_AUTHENTICATION_MODE` | `EXTERNAL` | +| `SCIM_EXTERNAL_ISSUER` | `https://mykeycloak.example.com/realms/my-realm` | +| `SCIM_EXTERNAL_AUDIENCE` | The client ID of the Keycloak client, or `account` | +| `SCIM_EXTERNAL_JWKS_URI` | `https://mykeycloak.example.com/realms/my-realm/protocol/openid-connect/certs` | + +**Okta setup:** +1. In the Okta Admin Console, go to **Applications > Applications**. +2. Select your SCIM application. +3. Go to the **Provisioning** tab and click **Configure API Integration**. +4. Select **OAuth2** as the authentication mode. +5. Configure the following: + - **Access Token Endpoint**: `https://mykeycloak.example.com/realms/my-realm/protocol/openid-connect/token` + - **Client ID**: The Keycloak client ID + - **Client Secret**: The Keycloak client secret +6. Set the **SCIM connector base URL** to: `https://mykeycloak.example.com/realms/my-realm/scim/v2` +7. Click **Test API Credentials** to verify. +8. Click **Save**. + +## Identity Provider Linking + +### Identity Provider Linking with Azure Entra ID Identity Provider linking with Entra ID requires a few additional configuration steps on both the Entra and Keycloak sides. **Step 1: Add externalId** -In the Keycloak admin console, ensure that you have externalId attribute defined in your Realm Settings > User Profile. This attribute is used to store user's external id in the Keycloak side and without it the Identity Provider linking will fail. +In the Keycloak admin console, ensure that you have an `externalId` attribute defined in your **Realm Settings > User Profile**. This attribute is used to store the user's external ID in Keycloak and without it the Identity Provider linking will fail. **Step 2: Map externalId in SCIM provisioning** -In the Entra Id, make sure that the objectId from Entra ID is mapped into the SCIM externalId field: +In Entra ID, make sure that the `objectId` from Entra ID is mapped into the SCIM `externalId` field: -1. Navigate to your **Enterprise Application** > **Provisioning** > **Attribute Mapping (Preview)** > **Provision Microsoft Entra ID Users**. +1. Navigate to your **Enterprise Application > Provisioning > Attribute Mapping (Preview) > Provision Microsoft Entra ID Users**. 2. Click **Add New Mapping**. 3. Set: - - **Source attribute**: objectId - - **Target attribute**: externalId + - **Source attribute**: `objectId` + - **Target attribute**: `externalId` 4. Click **Save**. -This ensures that during SCIM provisioning, the Entra objectId is stored in Keycloak as the user’s externalId, which will later be used for identity linking. +This ensures that during SCIM provisioning, the Entra `objectId` is stored in Keycloak as the user's `externalId`, which will later be used for identity linking. **Step 3: Configure Keycloak Identity Provider to Use Object ID** -Next, configure your Entra ID Identity Provider in Keycloak to use the oid claim from the login token instead of the default sub claim (which is app-specific). +Configure your Entra ID Identity Provider in Keycloak to use the `oid` claim from the login token instead of the default `sub` claim (which is app-specific). 1. Navigate to **Identity Providers** > select your **Entra ID provider**. -2. Go to the **Mappers tab**. +2. Go to the **Mappers** tab. 3. Click **Add Mapper**. 4. Fill in the mapper details: - - **Name**: map_oid_as_brokerid (or any descriptive name) + - **Name**: `map_oid_as_brokerid` (or any descriptive name) - **Sync Mode**: Force - **Mapper Type**: Username Template Importer - - **Template**: ${CLAIM.oid} + - **Template**: `${CLAIM.oid}` - **Target**: BROKER_ID 5. Click **Save**. -This mapper tells Keycloak to use the Entra oid claim as the Broker ID, ensuring that the login user is matched correctly with the SCIM-provisioned user. +This mapper tells Keycloak to use the Entra `oid` claim as the Broker ID, ensuring that the login user is matched correctly with the SCIM-provisioned user. **Step 4: Enable Identity Provider Linking in SCIM** -Finally, instruct your SCIM server to automatically link users to the configured Identity Provider during provisioning: +Add the following settings to your SCIM configuration: -Add the following attribute to your SCIM configuration: - - SCIM_LINK_IDP=true +``` +SCIM_LINK_IDP=true +``` -In case you want to link user to a realm level identity provider, also add the following attribute: +If you want to link users to a realm-level identity provider, also add: - SCIM_IDENTITY_PROVIDER_ALIAS= +``` +SCIM_IDENTITY_PROVIDER_ALIAS= +``` -This will ensure that when a user is provisioned via SCIM, a corresponding Identity Provider link is also created automatically based on the externalId / oid. +This ensures that when a user is provisioned via SCIM, a corresponding Identity Provider link is created automatically based on the `externalId` / `oid`. ## SCIM-Managed Users -By default, the SCIM server only exposes users who are explicitly assigned the scim-managed role within the realm. This ensures that only users intended to be managed through SCIM are returned or modifiable via SCIM API operations. +By default, the SCIM server only exposes users who are explicitly assigned the `scim-managed` role within the realm. This ensures that only users intended to be managed through SCIM are returned or modifiable via SCIM API operations. This prevents accidental exposure or modification of users that were: - - created manually via the Keycloak admin UI - - imported from external identity providers - - or otherwise not intended to be managed through SCIM +- created manually via the Keycloak admin UI +- imported from external identity providers +- or otherwise not intended to be managed through SCIM -If you want to expose all users (i.e., bypass filtering), you can simply assign the scim-managed role to every user. This effectively disables the filter, making the SCIM behavior equivalent to an unfiltered list. +If you want to expose all users (i.e., bypass filtering), you can simply assign the `scim-managed` role to every user. This effectively disables the filter, making the SCIM behavior equivalent to an unfiltered list. This role-based filtering applies to all SCIM operations, including: - - GET /Users - - PATCH /Users/{id} - - DELETE /Users/{id} +- `GET /Users` +- `PATCH /Users/{id}` +- `DELETE /Users/{id}` -Users without the scim-managed role will be invisible to SCIM clients — they won’t be listed, updated, or removed through SCIM. +Users without the `scim-managed` role will be invisible to SCIM clients -- they won't be listed, updated, or removed through SCIM. This filtering mechanism is designed to improve safety, especially in complex deployments involving federated users, legacy accounts, or overlapping identity sources (such as Entra ID + local users). -This design does mean that provisioning a user through SCIM who previously existed without the role may cause conflicts or provisioning failures if role assignment isn’t handled correctly. However, this is a deliberate design choice to provide fine-grained control over which users are SCIM-visible. +This design does mean that provisioning a user through SCIM who previously existed without the role may cause conflicts or provisioning failures if role assignment isn't handled correctly. However, this is a deliberate design choice to provide fine-grained control over which users are SCIM-visible. + + +## User attributes for SCIM provisioning + +This section explains how to provision custom user attributes (e.g., `job`, `department`, `employeeId`) from an external +identity provider (such as Microsoft Entra ID) into Keycloak via SCIM. + +By default, the SCIM server only exposes built-in user attributes (`userName`, `email`, `name.givenName`, +`name.familyName`, `active`). To provision additional custom attributes, you need to configure Keycloak to accept +unmanaged attributes and define identity provider mappers that tell the SCIM server which attributes to expose. + +### Prerequisites + +Before custom attributes can be provisioned, ensure the following conditions are met: + +1. **Unmanaged Attribute Policy**: The realm's User Profile must have `UnmanagedAttributePolicy` set to `ENABLED`. This + allows Keycloak to store attributes that are not explicitly defined in the User Profile schema. + +2. **Identity Provider Alias**: The `SCIM_IDENTITY_PROVIDER_ALIAS` environment variable (or realm/organization + attribute) must be configured with the alias of your identity provider. + +3. **Identity Provider Mappers**: User attribute mappers must be configured on the identity provider to define which + attributes should be provisioned. + +### How It Works + +When a SCIM provisioning request is received, the SCIM server: + +1. Checks if `UnmanagedAttributePolicy` is set to `ENABLED` in the realm +2. Looks up the identity provider specified by `SCIM_IDENTITY_PROVIDER_ALIAS` +3. Reads all user attribute mappers configured on that identity provider +4. Exposes those mapped attributes as valid SCIM attributes that can be provisioned + +This means the identity provider mappers serve as the **source of truth** for which custom attributes are available via +SCIM. + +### Step-by-Step Configuration + +#### Step 1: Enable Unmanaged Attributes in Keycloak + +1. Navigate to **Realm Settings** > **User Profile** +2. Click **JSON Editor** +3. Add or update the `unmanagedAttributePolicy` field: + +```json +{ + "unmanagedAttributePolicy": "ENABLED", + "attributes": [ + ] +} +``` + +4. Click **Save** + +#### Step 2: Configure the Identity Provider Alias + +Add the following environment variable to your Keycloak server: + +```bash +SCIM_IDENTITY_PROVIDER_ALIAS= +``` + +Or set it as a realm attribute via the Admin API: + +```json +{ + "attributes": { + "scim.identity.provider.alias": "" + } +} +``` + +The alias must match the alias of your configured identity provider (e.g., `entra-id`, `keycloak-oidc`). + +#### Step 3: Create User Attribute Mappers on the Identity Provider + +For each custom attribute you want to provision via SCIM: + +1. Navigate to **Identity Providers** > select your provider (e.g., Entra ID) +2. Go to the **Mappers** tab +3. Click **Add Mapper** +4. Configure the mapper: + +| Field | Value | +|--------------------|---------------------------------------------------------| +| **Name** | A descriptive name (e.g., `map-job-attribute`) | +| **Sync Mode** | `INHERIT` or `FORCE` | +| **Mapper Type** | `Attribute Importer` | +| **Claim** (OIDC) | The claim name from the external IdP (e.g., `jobTitle`) | +| **User Attribute** | The Keycloak attribute name (e.g., `job`) | + +5. Click **Save** +6. Repeat for each attribute you want to provision (e.g., `department`, `employeeId`) + +#### Step 4: Map Attributes in Your SCIM Client (e.g., Entra ID) + +In your SCIM client (e.g., Microsoft Entra ID Enterprise Application): + +1. Navigate to **Provisioning** > **Attribute Mapping (Preview)** > **Provision Microsoft Entra ID Users** +2. Click **Add New Mapping** +3. Map the source attribute to the custom SCIM attribute: + +| Source Attribute | Target Attribute | +|------------------|------------------| +| `jobTitle` | `job` | +| `department` | `department` | + +4. Click **Save** + +The target attribute name must match the `User Attribute` value configured in the Keycloak identity provider mapper. + +### Example: Provisioning a "job" Attribute + +This example shows how to provision the `jobTitle` attribute from Microsoft Entra ID to Keycloak as a `job` attribute. + +**Keycloak Configuration:** + +1. Enable `UnmanagedAttributePolicy` in the realm's User Profile +2. Set `SCIM_IDENTITY_PROVIDER_ALIAS=entra-id` +3. Create an identity provider mapper: + - **Mapper Type**: Attribute Importer + - **Claim**: `jobTitle` + - **User Attribute**: `job` + +**Microsoft Entra Configuration:** + +1. In the Enterprise Application provisioning settings, add a mapping: + - **Source attribute**: `jobTitle` + - **Target attribute**: `job` + +When Entra ID provisions a user, the `job` attribute will be stored in Keycloak and available on the user's attributes. ## License [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0) - + --- diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/AbstractController.java b/src/main/java/fi/metatavu/keycloak/scim/server/AbstractController.java index 9a36923..2ed10d1 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/AbstractController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/AbstractController.java @@ -33,6 +33,36 @@ protected fi.metatavu.keycloak.scim.server.model.Meta getMeta( return result; } + /** + * Whether the given SCIM attribute path refers to a read-only or + * structural core attribute that PATCH must ignore per RFC 7644 §3.5.2 + * (and the SCIM core schema, RFC 7643 §3.1). + * + * Concretely: "id" (server-assigned resource identifier), "meta" + * (server-assigned metadata), and "schemas" (structural). Servers MUST + * not error on these in PATCH payloads; clients (notably Okta on Group + * Push and user provisioning) echo them back when a PATCH value is + * constructed from a prior GET. Resource controllers consult this + * before resolving an attribute and silently skip the operation when + * it matches. + * + * Note: "externalId" is intentionally NOT in this list. Per RFC 7643 + * §3.1 externalId is an OPTIONAL, client-settable attribute and a + * legitimate target of PATCH. + * + * @param attrPath attribute path from a PatchOp (path-less or path-based) + * @return true if the attribute is read-only / structural and must be ignored + */ + protected static boolean isReadOnlyOrStructural(String attrPath) { + if (attrPath == null) { + return false; + } + return switch (attrPath) { + case "id", "meta", "schemas" -> true; + default -> false; + }; + } + /** * Returns date based on year, month and date * diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/AbstractScimServer.java b/src/main/java/fi/metatavu/keycloak/scim/server/AbstractScimServer.java index 61311b6..3666a88 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/AbstractScimServer.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/AbstractScimServer.java @@ -7,6 +7,7 @@ import fi.metatavu.keycloak.scim.server.groups.GroupsController; import fi.metatavu.keycloak.scim.server.metadata.MetadataController; import fi.metatavu.keycloak.scim.server.users.UsersController; +import java.util.Base64; import jakarta.mail.internet.AddressException; import jakarta.mail.internet.InternetAddress; import jakarta.ws.rs.ForbiddenException; @@ -105,8 +106,36 @@ public void verifyPermissions(T scimContext) { if (config.getAuthenticationMode() == ScimConfig.AuthenticationMode.KEYCLOAK) { keycloakAuthentication(context, session, realm, headers); + } else if (authorization.startsWith("Basic ")) { + basicAuthentication(config, authorization, session); } else { - externalAuthentication(config, extractToken(authorization), session); + externalAuthentication(config, extractBearerToken(authorization), session); + } + } + + private void basicAuthentication(ScimConfig config, String authorization, KeycloakSession session) { + String basicAuthUsername = config.getBasicAuthUsername(); + String basicAuthPassword = config.getBasicAuthPassword(); + + if (basicAuthUsername == null || basicAuthUsername.isBlank() || basicAuthPassword == null || basicAuthPassword.isBlank()) { + logger.warn("Basic auth credentials received but Basic auth is not configured"); + throw new NotAuthorizedException("Basic auth is not configured"); + } + + String encoded = authorization.substring("Basic ".length()).trim(); + String decoded; + try { + decoded = new String(Base64.getDecoder().decode(encoded)); + } catch (IllegalArgumentException e) { + logger.warn("Invalid Base64 in Basic auth header"); + throw new NotAuthorizedException("Invalid Basic auth header"); + } + + Verifier verifier = VerifierFactory.buildBasicAuth(config, session); + + if (!verifier.verify(decoded)) { + logger.warn("Basic auth verification failed"); + throw new NotAuthorizedException("Basic auth verification failed"); } } @@ -153,7 +182,7 @@ private void keycloakAuthentication(KeycloakContext context, KeycloakSession ses } } - private String extractToken(String authorization) { + private String extractBearerToken(String authorization) { if (authorization.startsWith("Bearer ")) { return authorization.substring("Bearer ".length()).trim(); } else { diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/ScimErrors.java b/src/main/java/fi/metatavu/keycloak/scim/server/ScimErrors.java new file mode 100644 index 0000000..6d6ca40 --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/ScimErrors.java @@ -0,0 +1,83 @@ +package fi.metatavu.keycloak.scim.server; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.ws.rs.core.Response; + +/** + * Helper for building SCIM 2.0 Error responses (RFC 7644 §3.12). + * + * Existing call sites returned plain-text bodies (e.g. "Unsupported group path", + * "Missing userName"), which broke clients that strictly parse error responses + * as JSON (Okta, Entra ID). All error responses go through this helper now and + * return a valid SCIM Error JSON document with the application/scim+json media + * type. + * + * The body is built via Jackson so any control character or quote that arrives + * in {@code detail} (typically from a user-supplied attribute path interpolated + * into an error message) is escaped correctly. A hand-rolled escape used to + * cover only `\\` and `"` and reintroduced the JSON parse failure on the client + * side as soon as a `\\n` or `\\t` reached `detail`. + */ +public final class ScimErrors { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"; + + private ScimErrors() { + // utility class + } + + /** + * Build a SCIM 2.0 Error response. + * + * @param status HTTP status (e.g. BAD_REQUEST) + * @param detail human-readable error detail; null is rendered as the empty string + * @return Response carrying a SCIM Error JSON body and application/scim+json type + */ + public static Response error(Response.Status status, String detail) { + ObjectNode node = MAPPER.createObjectNode(); + node.putArray("schemas").add(ERROR_SCHEMA); + node.put("status", Integer.toString(status.getStatusCode())); + node.put("detail", detail == null ? "" : detail); + String body; + try { + body = MAPPER.writeValueAsString(node); + } catch (JsonProcessingException e) { + // ObjectNode is always serializable; fall back to a static body if Jackson + // somehow fails so we still return SCIM-shaped JSON. + body = "{\"schemas\":[\"" + ERROR_SCHEMA + "\"],\"status\":\"" + + status.getStatusCode() + "\",\"detail\":\"\"}"; + } + return Response.status(status).type("application/scim+json").entity(body).build(); + } + + /** + * Convenience for HTTP 400 errors. + */ + public static Response badRequest(String detail) { + return error(Response.Status.BAD_REQUEST, detail); + } + + /** + * Convenience for HTTP 404 errors. + */ + public static Response notFound(String detail) { + return error(Response.Status.NOT_FOUND, detail); + } + + /** + * Convenience for HTTP 409 errors. + */ + public static Response conflict(String detail) { + return error(Response.Status.CONFLICT, detail); + } + + /** + * Convenience for HTTP 403 errors. + */ + public static Response forbidden(String detail) { + return error(Response.Status.FORBIDDEN, detail); + } +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProvider.java b/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProvider.java index 26f9193..60d64fe 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProvider.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProvider.java @@ -1,15 +1,22 @@ package fi.metatavu.keycloak.scim.server; import org.keycloak.services.resource.RealmResourceProvider; +import org.keycloak.models.KeycloakSession; /** * SCIM realm resource provider */ public class ScimRealmResourceProvider implements RealmResourceProvider { + private final KeycloakSession session; + + public ScimRealmResourceProvider(KeycloakSession session) { + this.session = session; + } + @Override public Object getResource() { - return new ScimResources(); + return new ScimResources(session); } @Override diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java b/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java index a80d658..d904ecf 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/ScimRealmResourceProviderFactory.java @@ -5,6 +5,7 @@ import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.services.resource.RealmResourceProvider; import org.keycloak.services.resource.RealmResourceProviderFactory; +import org.jboss.logging.Logger; /** * SCIM realm resource provider factory @@ -13,13 +14,16 @@ */ public class ScimRealmResourceProviderFactory implements RealmResourceProviderFactory { + private static final Logger logger = Logger.getLogger(ScimRealmResourceProviderFactory.class); + @Override public RealmResourceProvider create(KeycloakSession session) { - return new ScimRealmResourceProvider(); + return new ScimRealmResourceProvider(session); } @Override - public void init(Config.Scope config) {} + public void init(Config.Scope config) { + } @Override public void postInit(KeycloakSessionFactory factory) {} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java b/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java index e100019..da24c89 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/ScimResources.java @@ -6,6 +6,7 @@ import fi.metatavu.keycloak.scim.server.model.Group; import fi.metatavu.keycloak.scim.server.organization.OrganizationScimContext; import fi.metatavu.keycloak.scim.server.organization.OrganizationScimServer; +import fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProvider; import fi.metatavu.keycloak.scim.server.realm.RealmScimContext; import fi.metatavu.keycloak.scim.server.realm.RealmScimServer; import jakarta.ws.rs.*; @@ -21,12 +22,31 @@ public class ScimResources { private static final Logger logger = Logger.getLogger(ScimResources.class.getName()); private final ScimFilterParser scimFilterParser; private final RealmScimServer realmScimServer; - private final OrganizationScimServer organizationScimServer; + private final KeycloakSession session; + private OrganizationScimServer organizationScimServer; - ScimResources() { + ScimResources(KeycloakSession session) { + this.session = session; scimFilterParser = new ScimFilterParser(); realmScimServer = new RealmScimServer(); - organizationScimServer = new OrganizationScimServer(); + } + + private OrganizationScimServer getOrganizationScimServer() { + if (organizationScimServer == null) { + try { + OrganizationScimServerProvider provider = session.getProvider(OrganizationScimServerProvider.class); + if (provider == null) { + throw new NotFoundException("No OrganizationScimServerProvider is registered. Organization SCIM endpoints are not available."); + } + organizationScimServer = provider.getScimServer(session); + } catch (NotFoundException e) { + throw e; + } catch (Exception e) { + logger.warn("Failed to load OrganizationScimServerProvider. Organization SCIM endpoints will not be available.", e); + throw new NotFoundException("Organization SCIM endpoints are not available."); + } + } + return organizationScimServer; } // Realm Server endpoints @@ -40,6 +60,7 @@ public Response createRealmUser( @Context KeycloakSession session, fi.metatavu.keycloak.scim.server.model.User createRequest ) { + logger.debug("POST /v2/Users"); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -59,6 +80,7 @@ public Response listRealmUsers( @QueryParam("startIndex") @DefaultValue("0") Integer startIndex, @QueryParam("count") @DefaultValue("100") Integer count ) { + logger.debugf("GET /v2/Users filter=%s startIndex=%d count=%d", filter, startIndex, count); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -67,7 +89,7 @@ public Response listRealmUsers( scimFilter = parseFilter(filter); } catch (Exception e) { logger.warn(String.format("Failed to parse filter: '%s'", filter), e); - return Response.status(Response.Status.BAD_REQUEST).entity("Invalid filter").build(); + return ScimErrors.badRequest("Invalid filter"); } return realmScimServer.listUsers( @@ -86,6 +108,7 @@ public Response findRealmUser( @Context KeycloakSession session, @PathParam("id") String userId ) { + logger.debugf("GET /v2/Users/%s", userId); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -105,6 +128,7 @@ public Response updateRealmUser( @PathParam("id") String userId, fi.metatavu.keycloak.scim.server.model.User updateRequest ) { + logger.debugf("PUT /v2/Users/%s", userId); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -125,6 +149,7 @@ public Response patchRealmUser( @PathParam("id") String userId, fi.metatavu.keycloak.scim.server.model.PatchRequest patchRequest ) { + logger.debugf("PATCH /v2/Users/%s", userId); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -143,6 +168,7 @@ public Response deleteRealmUser( @Context KeycloakSession session, @PathParam("id") String userId ) { + logger.debugf("DELETE /v2/Users/%s", userId); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -158,6 +184,7 @@ public Response createRealmGroup( @Context KeycloakSession session, fi.metatavu.keycloak.scim.server.model.Group createRequest ) { + logger.debug("POST /v2/Groups"); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -177,6 +204,7 @@ public Response listRealmGroups( @QueryParam("startIndex") @DefaultValue("0") int startIndex, @QueryParam("count") @DefaultValue("100") int count ) { + logger.debugf("GET /v2/Groups filter=%s startIndex=%d count=%d", filter, startIndex, count); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -185,7 +213,7 @@ public Response listRealmGroups( scimFilter = parseFilter(filter); } catch (Exception e) { logger.warn(String.format("Failed to parse filter: '%s'", filter), e); - return Response.status(Response.Status.BAD_REQUEST).entity("Invalid filter").build(); + return ScimErrors.badRequest("Invalid filter"); } return realmScimServer.listGroups( @@ -204,6 +232,7 @@ public Response findRealmGroup( @Context KeycloakSession session, @PathParam("id") String id ) { + logger.debugf("GET /v2/Groups/%s", id); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -223,6 +252,7 @@ public Response updateRealmGroup( @Context KeycloakSession session, Group updateRequest ) { + logger.debugf("PUT /v2/Groups/%s", id); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -243,6 +273,7 @@ public Response patchRealmGroup( @PathParam("id") String groupId, fi.metatavu.keycloak.scim.server.model.PatchRequest patchRequest ) { + logger.debugf("PATCH /v2/Groups/%s", groupId); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -260,6 +291,7 @@ public Response deleteRealmGroup( @Context KeycloakSession session, @PathParam("id") String id ) { + logger.debugf("DELETE /v2/Groups/%s", id); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -277,6 +309,7 @@ public Response listRealmResourceTypes( @Context KeycloakSession session, @Context UriInfo uriInfo ) { + logger.debug("GET /v2/ResourceTypes"); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -291,6 +324,7 @@ public Response findRealmResourceType( @Context KeycloakSession session, @PathParam("id") String id ) { + logger.debugf("GET /v2/ResourceTypes/%s", id); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -308,6 +342,7 @@ public Response listRealmSchemas( @Context KeycloakSession session, @Context UriInfo uriInfo ) { + logger.debug("GET /v2/Schemas"); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -322,6 +357,7 @@ public Response findRealmSchema( @Context KeycloakSession session, @PathParam("id") String id ) { + logger.debugf("GET /v2/Schemas/%s", id); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -339,6 +375,7 @@ public Response getRealmServiceProviderConfig( @Context KeycloakSession session, @Context UriInfo uriInfo ) { + logger.debug("GET /v2/ServiceProviderConfig"); RealmScimContext scimContext = realmScimServer.getScimContext(session); realmScimServer.verifyPermissions(scimContext); @@ -357,10 +394,11 @@ public Response createOrganizationUser( @PathParam("organizationId") String organizationId, fi.metatavu.keycloak.scim.server.model.User createRequest ) { - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + logger.debugf("POST /v2/organizations/%s/Users", organizationId); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.createUser( + return getOrganizationScimServer().createUser( scimContext, createRequest ); @@ -377,18 +415,19 @@ public Response listOrganizationUsers( @QueryParam("startIndex") @DefaultValue("0") Integer startIndex, @QueryParam("count") @DefaultValue("100") Integer count ) { - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + logger.debugf("GET /v2/organizations/%s/Users filter=%s startIndex=%d count=%d", organizationId, filter, startIndex, count); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); ScimFilter scimFilter; try { scimFilter = parseFilter(filter); } catch (Exception e) { logger.warn(String.format("Failed to parse filter: '%s'", filter), e); - return Response.status(Response.Status.BAD_REQUEST).entity("Invalid filter").build(); + return ScimErrors.badRequest("Invalid filter"); } - return organizationScimServer.listUsers( + return getOrganizationScimServer().listUsers( scimContext, scimFilter, startIndex, @@ -405,10 +444,11 @@ public Response findOrganizationUser( @PathParam("id") String userId, @PathParam("organizationId") String organizationId ) { - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + logger.debugf("GET /v2/organizations/%s/Users/%s", organizationId, userId); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.findUser( + return getOrganizationScimServer().findUser( scimContext, userId ); @@ -425,10 +465,11 @@ public Response updateOrganizationUser( @PathParam("organizationId") String organizationId, fi.metatavu.keycloak.scim.server.model.User updateRequest ) { - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + logger.debugf("PUT /v2/organizations/%s/Users/%s", organizationId, userId); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.updateUser( + return getOrganizationScimServer().updateUser( scimContext, userId, updateRequest @@ -446,10 +487,11 @@ public Response patchOrganizationUser( @PathParam("organizationId") String organizationId, fi.metatavu.keycloak.scim.server.model.PatchRequest patchRequest ) { - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + logger.debugf("PATCH /v2/organizations/%s/Users/%s", organizationId, userId); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.patchUser( + return getOrganizationScimServer().patchUser( scimContext, userId, patchRequest @@ -465,10 +507,11 @@ public Response deleteOrganizationUser( @PathParam("organizationId") String organizationId, @PathParam("id") String userId ) { - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + logger.debugf("DELETE /v2/organizations/%s/Users/%s", organizationId, userId); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.deleteUser(scimContext, userId); + return getOrganizationScimServer().deleteUser(scimContext, userId); } @POST @@ -481,10 +524,11 @@ public Response createOrganizationGroup( @PathParam("organizationId") String organizationId, fi.metatavu.keycloak.scim.server.model.Group createRequest ) { - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + logger.debugf("POST /v2/organizations/%s/Groups", organizationId); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.createGroup( + return getOrganizationScimServer().createGroup( scimContext, createRequest ); @@ -501,18 +545,19 @@ public Response listOrganizationGroups( @QueryParam("startIndex") @DefaultValue("0") int startIndex, @QueryParam("count") @DefaultValue("100") int count ) { - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + logger.debugf("GET /v2/organizations/%s/Groups filter=%s startIndex=%d count=%d", organizationId, filter, startIndex, count); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); ScimFilter scimFilter; try { scimFilter = parseFilter(filter); } catch (Exception e) { logger.warn(String.format("Failed to parse filter: '%s'", filter), e); - return Response.status(Response.Status.BAD_REQUEST).entity("Invalid filter").build(); + return ScimErrors.badRequest("Invalid filter"); } - return organizationScimServer.listGroups( + return getOrganizationScimServer().listGroups( scimContext, scimFilter, startIndex, @@ -529,10 +574,11 @@ public Response findOrganizationGroup( @PathParam("organizationId") String organizationId, @PathParam("id") String id ) { - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + logger.debugf("GET /v2/organizations/%s/Groups/%s", organizationId, id); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.findGroup( + return getOrganizationScimServer().findGroup( scimContext, id ); @@ -549,10 +595,11 @@ public Response updateOrganizationGroup( @PathParam("organizationId") String organizationId, Group updateRequest ) { - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + logger.debugf("PUT /v2/organizations/%s/Groups/%s", organizationId, id); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.updateGroup( + return getOrganizationScimServer().updateGroup( scimContext, id, updateRequest @@ -570,10 +617,11 @@ public Response patchOrganizationGroup( @PathParam("organizationId") String organizationId, fi.metatavu.keycloak.scim.server.model.PatchRequest patchRequest ) { - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + logger.debugf("PATCH /v2/organizations/%s/Groups/%s", organizationId, groupId); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.patchGroup( + return getOrganizationScimServer().patchGroup( scimContext, groupId, patchRequest @@ -588,10 +636,11 @@ public Response deleteOrganizationGroup( @PathParam("organizationId") String organizationId, @PathParam("id") String id ) { - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + logger.debugf("DELETE /v2/organizations/%s/Groups/%s", organizationId, id); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.deleteGroup( + return getOrganizationScimServer().deleteGroup( scimContext, id ); @@ -606,10 +655,11 @@ public Response listOrganizationResourceTypes( @Context UriInfo uriInfo, @PathParam("organizationId") String organizationId ) { - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + logger.debugf("GET /v2/organizations/%s/ResourceTypes", organizationId); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.listResourceTypes( + return getOrganizationScimServer().listResourceTypes( scimContext ); } @@ -623,10 +673,11 @@ public Response findOrganizationResourceType( @PathParam("organizationId") String organizationId, @PathParam("id") String id ) { - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + logger.debugf("GET /v2/organizations/%s/ResourceTypes/%s", organizationId, id); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.findResourceType( + return getOrganizationScimServer().findResourceType( scimContext, id ); @@ -641,10 +692,11 @@ public Response listOrganizationSchemas( @PathParam("organizationId") String organizationId, @Context UriInfo uriInfo ) { - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + logger.debugf("GET /v2/organizations/%s/Schemas", organizationId); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.listSchemas( + return getOrganizationScimServer().listSchemas( scimContext ); } @@ -658,10 +710,11 @@ public Response findOrganizationSchema( @PathParam("organizationId") String organizationId, @PathParam("id") String id ) { - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); + logger.debugf("GET /v2/organizations/%s/Schemas/%s", organizationId, id); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); - return organizationScimServer.findSchema( + return getOrganizationScimServer().findSchema( scimContext, id ); @@ -676,9 +729,10 @@ public Response getOrganizationServiceProviderConfig( @PathParam("organizationId") String organizationId, @Context UriInfo uriInfo ) { - OrganizationScimContext scimContext = organizationScimServer.getScimContext(session, organizationId); - organizationScimServer.verifyPermissions(scimContext); - return organizationScimServer.getServiceProviderConfig(scimContext); + logger.debugf("GET /v2/organizations/%s/ServiceProviderConfig", organizationId); + OrganizationScimContext scimContext = getOrganizationScimServer().getScimContext(session, organizationId); + getOrganizationScimServer().verifyPermissions(scimContext); + return getOrganizationScimServer().getServiceProviderConfig(scimContext); } /** diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/adminEvents/AdminEventController.java b/src/main/java/fi/metatavu/keycloak/scim/server/adminEvents/AdminEventController.java index 8eb7b72..ccdc393 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/adminEvents/AdminEventController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/adminEvents/AdminEventController.java @@ -13,12 +13,16 @@ import org.keycloak.models.RealmModel; import org.keycloak.util.JsonSerialization; +import org.jboss.logging.Logger; + import java.io.IOException; import java.util.List; import java.util.Map; import java.util.UUID; public class AdminEventController extends AbstractController { + + private static final Logger logger = Logger.getLogger(AdminEventController.class.getName()); /** * Sends an admin event * @@ -82,6 +86,7 @@ public void sendAdminEvent( try { event.setRepresentation(JsonSerialization.writeValueAsString(representation)); } catch (IOException e) { + logger.errorf(e, "Failed to serialize representation for admin event: %s %s %s", operationType, resourceType, resourcePath); throw new RuntimeException(e); } } @@ -92,6 +97,8 @@ public void sendAdminEvent( EventStoreProvider store = session.getProvider(EventStoreProvider.class); if (store != null) { store.onEvent(event, includeRepresentation); + } else { + logger.warn("Admin events enabled but no EventStoreProvider found — event not persisted"); } } @@ -101,6 +108,7 @@ public void sendAdminEvent( .map(providerFactory -> providerFactory.create(session)) .forEach(provider -> { if (provider instanceof EventListenerProvider eventListenerProvider) { + logger.debugf("Sending admin event: %s %s %s", operationType, resourceType, resourcePath); eventListenerProvider.onEvent(event, includeRepresentation); } }); diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/BasicAuthVerifier.java b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/BasicAuthVerifier.java new file mode 100644 index 0000000..241ce14 --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/BasicAuthVerifier.java @@ -0,0 +1,85 @@ +package fi.metatavu.keycloak.scim.server.authentication; + +import java.util.List; +import java.util.Optional; +import org.jboss.logging.Logger; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.credential.hash.PasswordHashProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.credential.PasswordCredentialModel; + +/** + * Verifies Basic Auth credentials (username and password) + */ +public class BasicAuthVerifier implements Verifier { + + private static final Logger logger = Logger.getLogger(BasicAuthVerifier.class); + + private final KeycloakSession session; + private final String expectedUsername; + private final String hashedPassword; + + /** + * Constructor + * + * @param session Keycloak Session + * @param expectedUsername expected username + * @param hashedPassword password hash in PHC String format + */ + public BasicAuthVerifier(KeycloakSession session, String expectedUsername, String hashedPassword) { + this.session = session; + this.expectedUsername = expectedUsername; + this.hashedPassword = hashedPassword; + } + + /** + * Verifies the given credentials. + * + * @param credentials username:password string (already Base64-decoded) + * @return true if the credentials are valid, false otherwise + */ + @Override + public boolean verify(String credentials) { + int colonIndex = credentials.indexOf(':'); + if (colonIndex < 0) { + logger.warn("Basic auth credentials missing colon separator"); + return false; + } + + String username = credentials.substring(0, colonIndex); + String password = credentials.substring(colonIndex + 1); + + if (!expectedUsername.equals(username)) { + logger.warn("Basic auth username mismatch"); + return false; + } + + if (hashedPassword == null || hashedPassword.isBlank()) { + logger.warn("Basic auth password hash is null or blank"); + return false; + } + + PasswordCredentialModel model = PhcStringUtils.fromPHCString(hashedPassword); + String algorithm = model.getPasswordCredentialData().getAlgorithm(); + MultivaluedHashMap additionalParameters = model.getPasswordCredentialData() + .getAdditionalParameters(); + String type = Optional.ofNullable(additionalParameters) + .map(params -> params.get("type")) + .filter(typeList -> !typeList.isEmpty()) + .map(List::getFirst) + .orElse(""); + PasswordHashProvider hashProvider = session.getProvider(PasswordHashProvider.class, algorithm.replace(type, "")); + + if (hashProvider == null) { + throw new RuntimeException( + String.format( + "Hash provider not found with hash algorithm: %s. Only official Keycloak hash algorithms are expected (see README.md).", + algorithm + ) + ); + } + + return hashProvider.verify(password, model); + } + +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/ExternalTokenVerifier.java b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/ExternalTokenVerifier.java index 34f8fbc..a52c445 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/ExternalTokenVerifier.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/ExternalTokenVerifier.java @@ -46,12 +46,11 @@ public boolean verify(String tokenString) { try { for (JwkKey jwkKey : JwksUtils.getPublicKeysFromJwks(jwksUrl)) { if (verify(tokenString, jwkKey.getPublicKey())) { - logger.info("Token verification succeeded with key: " + jwkKey.getKid()); + logger.debug("Token verification succeeded with key: " + jwkKey.getKid()); return true; } - - logger.warn("Token verification failed with key: " + jwkKey.getKid()); } + logger.warn("Token verification failed with all keys"); } catch (URISyntaxException | IOException | JWSInputException e) { logger.warn("Failed to verify permissions", e); throw new NotAuthorizedException(e); @@ -61,7 +60,7 @@ public boolean verify(String tokenString) { logger.warn("Failed to verify permissions", e); throw new NotAuthorizedException(e); } - + logger.warn("Token verification failed "); return false; } @@ -77,7 +76,6 @@ private boolean verify(String tokenString, PublicKey publicKey) throws JWSInputE boolean validSignature = RSAProvider.verify(jwsInput, publicKey); if (!validSignature) { - logger.warn("Token signature verification failed"); return false; } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/JwksUtils.java b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/JwksUtils.java index 38233e3..05cbb15 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/JwksUtils.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/JwksUtils.java @@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.keycloak.jose.jwk.JWKParser; +import org.jboss.logging.Logger; + import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; @@ -20,6 +22,8 @@ */ public class JwksUtils { + private static final Logger logger = Logger.getLogger(JwksUtils.class.getName()); + /** * Loads all public keys from JWKS URL * @@ -38,6 +42,7 @@ public static List getPublicKeysFromJwks(String jwksUrl) throws URISynta HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); if (response.statusCode() != 200) { + logger.errorf("Failed to fetch JWKS from %s: HTTP %d", jwksUrl, response.statusCode()); throw new RuntimeException("Failed to fetch JWKS: HTTP " + response.statusCode()); } @@ -48,6 +53,7 @@ public static List getPublicKeysFromJwks(String jwksUrl) throws URISynta List> keys = (List>) jwks.get("keys"); if (keys == null || keys.isEmpty()) { + logger.errorf("No keys found in JWKS response from %s", jwksUrl); throw new RuntimeException("No keys found in JWKS"); } @@ -55,7 +61,10 @@ public static List getPublicKeysFromJwks(String jwksUrl) throws URISynta String kid = (String) jwk.get("kid"); String use = (String) jwk.get("use"); - if (kid == null) continue; + if (kid == null) { + logger.warn("Skipping JWK entry with no 'kid' field"); + continue; + } if (use == null) { use = "sig"; @@ -69,6 +78,7 @@ public static List getPublicKeysFromJwks(String jwksUrl) throws URISynta result.add(new JwkKey(publicKey, kid, use)); } + logger.debugf("Loaded %d public key(s) from JWKS", result.size()); return result; } } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/PhcStringUtils.java b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/PhcStringUtils.java index fcb832d..bb3603a 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/PhcStringUtils.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/PhcStringUtils.java @@ -1,5 +1,7 @@ package fi.metatavu.keycloak.scim.server.authentication; +import org.jboss.logging.Logger; + import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -9,6 +11,8 @@ public class PhcStringUtils { + private static final Logger logger = Logger.getLogger(PhcStringUtils.class.getName()); + public static final String ARGON_2_PREFIX = "argon2"; public static final String PBKDF2_PREFIX = "pbkdf2"; @@ -33,11 +37,14 @@ public static PasswordCredentialModel fromPHCString(String phcString) { String algId = parts[1]; + logger.debugf("Parsing PHC string with algorithm: %s", algId); + if (algId.startsWith(ARGON_2_PREFIX)) { return parseArgon2(parts); } else if (algId.startsWith(PBKDF2_PREFIX)) { return parsePbkdf2(parts); } else { + logger.warnf("Unknown algorithm in PHC string: %s", algId); throw new IllegalArgumentException("Unknown algorithm in PHC string: " + algId); } } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/VerifierFactory.java b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/VerifierFactory.java index 25ce16e..b370830 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/authentication/VerifierFactory.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/authentication/VerifierFactory.java @@ -2,6 +2,7 @@ import fi.metatavu.keycloak.scim.server.config.ScimConfig; import fi.metatavu.keycloak.scim.server.config.ScimConfig.AuthenticationMode; +import org.jboss.logging.Logger; import org.keycloak.models.KeycloakSession; /** @@ -9,10 +10,12 @@ */ public class VerifierFactory { + private static final Logger logger = Logger.getLogger(VerifierFactory.class.getName()); + private VerifierFactory() {} /** - * Builds the verifier based on the ScimConfig + * Builds a verifier for Bearer token authentication based on the ScimConfig */ public static Verifier build(ScimConfig config, KeycloakSession session) { if (config.getAuthenticationMode() != AuthenticationMode.EXTERNAL) { @@ -28,4 +31,14 @@ public static Verifier build(ScimConfig config, KeycloakSession session) { return new ExternalSharedSecretVerifier(session, sharedSecret); } } + + /** + * Builds a verifier for Basic Auth authentication based on the ScimConfig + */ + public static Verifier buildBasicAuth(ScimConfig config, KeycloakSession session) { + if (config.getAuthenticationMode() != AuthenticationMode.EXTERNAL) { + throw new IllegalArgumentException("Authentication mode must be EXTERNAL"); + } + return new BasicAuthVerifier(session, config.getBasicAuthUsername(), config.getBasicAuthPassword()); + } } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/config/ScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/config/ScimConfig.java index 7765be2..d6ac248 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/config/ScimConfig.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/config/ScimConfig.java @@ -75,4 +75,18 @@ enum AuthenticationMode { * @return true if email should be used as username */ boolean getEmailAsUsername(); + + /** + * Gets the basic auth username (if using EXTERNAL mode with Basic auth) + * + * @return basic auth username or null if not configured + */ + String getBasicAuthUsername(); + + /** + * Gets the basic auth password in PHC String format (if using EXTERNAL mode with Basic auth) + * + * @return basic auth password hash or null if not configured + */ + String getBasicAuthPassword(); } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/filter/ScimFilterParser.java b/src/main/java/fi/metatavu/keycloak/scim/server/filter/ScimFilterParser.java index 04d9752..2fc256a 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/filter/ScimFilterParser.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/filter/ScimFilterParser.java @@ -1,5 +1,7 @@ package fi.metatavu.keycloak.scim.server.filter; +import org.jboss.logging.Logger; + import java.util.regex.*; /** @@ -7,6 +9,8 @@ */ public class ScimFilterParser { + private static final Logger logger = Logger.getLogger(ScimFilterParser.class.getName()); + private static final Pattern EQ_PATTERN = Pattern.compile( "(\\w+(\\.\\w+)*)\\s+eq\\s+(\"[^\"]+\"|true|false|\\d+)", Pattern.CASE_INSENSITIVE @@ -83,6 +87,7 @@ public ScimFilter parse(String filter) { return parseComparison(ew, ScimFilter.Operator.EW); } + logger.warnf("Unsupported SCIM filter expression: %s", filter); throw new UnsupportedFilter(filter); } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java b/src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java index 96adb9c..40d9d0e 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/groups/GroupsController.java @@ -22,6 +22,7 @@ import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.representations.idm.GroupRepresentation; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -157,10 +158,7 @@ public fi.metatavu.keycloak.scim.server.model.Group patchGroup( ScimContext scimContext, GroupModel existing, fi.metatavu.keycloak.scim.server.model.PatchRequest patchRequest - ) throws UnsupportedGroupPath, UnsupportedPatchOperation { - KeycloakSession session = scimContext.getSession(); - RealmModel realm = scimContext.getRealm(); - + ) throws UnsupportedGroupPath, UnsupportedPatchOperation, InvalidGroupMemberReference { for (var operation : patchRequest.getOperations()) { PatchOperation op = PatchOperation.fromString(operation.getOp()); String path = operation.getPath(); @@ -171,11 +169,43 @@ public fi.metatavu.keycloak.scim.server.model.Group patchGroup( throw new UnsupportedPatchOperation("Unsupported patch operation: " + operation.getOp()); } + // RFC 7644 §3.5.2: when "path" is omitted, "value" carries a map of + // attribute -> value to apply to the resource. Okta's Group Push + // (add/remove members) emits this shape: + // {"op":"replace","value":{"members":[{"value":""}]}} + // Without this branch the code below would call findByScimPath(null), + // get null, and throw UnsupportedGroupPath, breaking Okta group pushes. + if (path == null) { + if (!(value instanceof Map valueMap)) { + throw new UnsupportedGroupPath("PatchOp without 'path' requires a map-valued 'value'"); + } + for (Map.Entry entry : valueMap.entrySet()) { + String attrPath = String.valueOf(entry.getKey()); + if (isReadOnlyOrStructural(attrPath)) { + // RFC 7644 §3.5.2 / §7.5: ignore read-only and + // structural attributes (id, meta, schemas) + // on PATCH. Okta echoes the resource id back inside + // 'value' on Group Push. + continue; + } + GroupAttribute attr = GroupAttribute.findByScimPath(attrPath); + if (attr == null) { + throw new UnsupportedGroupPath("Unsupported attribute: " + attrPath); + } + applyGroupPatch(scimContext, op, attr, attrPath, entry.getValue(), existing); + } + continue; + } + // Extract base attribute path (e.g., "members" from "members[value eq \"id\"]") - String attributePath = path != null && path.contains("[") + String attributePath = path.contains("[") ? path.substring(0, path.indexOf("[")) : path; + if (isReadOnlyOrStructural(attributePath)) { + continue; + } + GroupAttribute groupAttribute = GroupAttribute.findByScimPath(attributePath); if (groupAttribute == null) { throw new UnsupportedGroupPath("Unsupported patch path: " + path); @@ -187,75 +217,139 @@ public fi.metatavu.keycloak.scim.server.model.Group patchGroup( break; } - switch (op) { - case REPLACE, ADD -> { - switch (groupAttribute) { - case DISPLAY_NAME -> existing.setName((String) value); - case MEMBERS -> { - // Clear current members if REPLACE, just add if ADD - if (op == PatchOperation.REPLACE) { - session.users().getGroupMembersStream(realm, existing) - .forEach(user -> user.leaveGroup(existing)); - } - - for (Object obj : (List) value) { - if (!(obj instanceof Map memberMap)) { - logger.warn("Invalid member object: " + obj); - continue; - } - - String memberId = (String) memberMap.get("value"); - if (memberId == null) { - logger.warn("Member value missing: " + obj); - continue; - } - - UserModel user = scimContext.getSession().users().getUserById(scimContext.getRealm(), memberId); - if (user != null) { - user.joinGroup(existing); - dispatchGroupMembershipJoinEvent(scimContext, existing, user); - } - } + // For REMOVE with a path filter (e.g. members[value eq "id"]), extract + // the member ID from the filter and wrap it in list form so applyGroupPatch + // can handle it uniformly. + Object effectiveValue = value; + if (op == PatchOperation.REMOVE && groupAttribute == GroupAttribute.MEMBERS && path.contains("[")) { + String memberId = extractValueFromFilter(path); + if (memberId != null) { + effectiveValue = List.of(Map.of("value", memberId)); + } else { + throw new UnsupportedGroupPath("Unsupported members filter: " + path); + } + } + + applyGroupPatch(scimContext, op, groupAttribute, path, effectiveValue, existing); + } + + return translateGroup(scimContext, existing); + } + + /** + * Apply a single SCIM PatchOp on a Group. + * + *

Atomicity scope: per operation. If a PatchRequest contains multiple + * operations, each is applied independently in order. An earlier + * successful operation is NOT rolled back if a later one fails. + * + *

For MEMBERS modifications (ADD/REPLACE/REMOVE), every incoming member + * ID is resolved via {@link #resolveMembers} before any mutation. The + * first unresolved ID raises {@link InvalidGroupMemberReference}, so the + * group's membership stays intact for that operation. + * + *

Used by both the path-less and path-based PatchOp branches of + * {@link #patchGroup}. + */ + private void applyGroupPatch( + ScimContext scimContext, + PatchOperation op, + GroupAttribute attr, + String attrPath, + Object value, + GroupModel existing + ) throws InvalidGroupMemberReference, UnsupportedGroupPath { + KeycloakSession session = scimContext.getSession(); + RealmModel realm = scimContext.getRealm(); + + switch (op) { + case REPLACE, ADD -> { + switch (attr) { + case DISPLAY_NAME -> { + if (!(value instanceof String s)) { + throw new UnsupportedGroupPath("displayName requires a string value"); + } + existing.setName(s); + } + case MEMBERS -> { + List resolved = resolveMembers(session, realm, value); + if (op == PatchOperation.REPLACE) { + session.users().getGroupMembersStream(realm, existing) + .forEach(u -> { + u.leaveGroup(existing); + dispatchGroupMembershipLeaveEvent(scimContext, existing, u); + }); + } + for (UserModel u : resolved) { + u.joinGroup(existing); + dispatchGroupMembershipJoinEvent(scimContext, existing, u); } } } - - case REMOVE -> { - switch (groupAttribute) { - case DISPLAY_NAME -> existing.setName(null); - case MEMBERS -> { - // Handle path filter (e.g., "members[value eq \"user-id\"]") - if (path != null && path.contains("[")) { - String memberId = extractValueFromFilter(path); - if (memberId != null) { - UserModel user = session.users().getUserById(realm, memberId); - if (user != null) { - user.leaveGroup(existing); - dispatchGroupMembershipLeaveEvent(scimContext, existing, user); - } - } - } else if (value instanceof List list) { - // Handle direct value list - for (Object obj : list) { - if (obj instanceof Map memberMap) { - String memberId = (String) memberMap.get("value"); - if (memberId != null) { - UserModel user = session.users().getUserById(realm, memberId); - if (user != null) { - user.leaveGroup(existing); - dispatchGroupMembershipLeaveEvent(scimContext, existing, user); - } - } - } - } - } + } + case REMOVE -> { + switch (attr) { + case DISPLAY_NAME -> existing.setName(null); + case MEMBERS -> { + // REMOVE shares the strict resolution path with REPLACE/ADD: an unknown + // member id surfaces as 400 InvalidGroupMemberReference rather than a + // silent no-op. SCIM clients with stale state get an actionable error + // instead of believing the membership change went through. + List resolved = resolveMembers(session, realm, value); + for (UserModel u : resolved) { + u.leaveGroup(existing); + dispatchGroupMembershipLeaveEvent(scimContext, existing, u); } } } } } + } - return translateGroup(scimContext, existing); + /** + * Resolve every member-id-shaped entry in {@code value} into a UserModel, + * failing with {@link InvalidGroupMemberReference} on the first unknown ID + * before any mutation. Accepts a List of Maps each carrying a "value" key. + * Returns an empty list for any non-list input (tolerates null/empty values + * on REMOVE operations). + * + *

Atomicity scope: within a single operation only. Resolution runs in + * full before any group membership is modified, so a bad ID aborts the + * operation without partially applying changes. It does NOT span multiple + * operations in the same PatchRequest (see {@link #applyGroupPatch}). + * + *

REMOVE uses this same path: an unknown member ID returns 400 rather + * than silently no-oping, so SCIM clients with stale state receive an + * actionable error instead of a false success. + */ + private List resolveMembers( + KeycloakSession session, + RealmModel realm, + Object value + ) throws InvalidGroupMemberReference { + List out = new ArrayList<>(); + if (!(value instanceof List list)) { + return out; + } + for (Object obj : list) { + if (!(obj instanceof Map memberMap)) { + continue; + } + Object idObj = memberMap.get("value"); + if (!(idObj instanceof String rawMemberId)) { + continue; + } + String memberId = rawMemberId.strip(); + if (memberId.isEmpty()) { + continue; + } + UserModel user = session.users().getUserById(realm, memberId); + if (user == null) { + throw new InvalidGroupMemberReference(memberId); + } + out.add(user); + } + return out; } /** @@ -411,7 +505,7 @@ protected void dispatchGroupMembershipLeaveEvent( groupRepresentation, Map.of( UserModel.USERNAME, user.getUsername(), - UserModel.EMAIL, user.getEmail() + UserModel.EMAIL, user.getEmail() == null ? "" : user.getEmail() ) ); } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/groups/InvalidGroupMemberReference.java b/src/main/java/fi/metatavu/keycloak/scim/server/groups/InvalidGroupMemberReference.java new file mode 100644 index 0000000..f83022b --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/groups/InvalidGroupMemberReference.java @@ -0,0 +1,16 @@ +package fi.metatavu.keycloak.scim.server.groups; + +/** + * Thrown when a SCIM PATCH on a Group references one or more member IDs that + * do not resolve to a user in the realm. The entire patch is rejected without + * mutating membership, so the client receives an actionable 400 instead of an + * empty / truncated group with HTTP 200. + */ +public class InvalidGroupMemberReference extends Exception { + + private static final long serialVersionUID = 1L; + + public InvalidGroupMemberReference(String memberId) { + super("Unknown group member: " + memberId); + } +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/BooleanUserAttribute.java b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/BooleanUserAttribute.java index 190936f..a2bf3be 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/BooleanUserAttribute.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/BooleanUserAttribute.java @@ -4,6 +4,7 @@ import org.keycloak.models.UserModel; import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.function.Function; /** @@ -12,7 +13,7 @@ public class BooleanUserAttribute extends UserAttribute { /** - * Constructor + * Constructor without explicit remover. Defaults to {@code write(user, null)}. * * @param source source * @param sourceId source id @@ -27,5 +28,24 @@ public class BooleanUserAttribute extends UserAttribute { public BooleanUserAttribute(Source source, String sourceId, String scimPath, String description, SchemaAttribute.TypeEnum type, SchemaAttribute.MutabilityEnum mutability, SchemaAttribute.UniquenessEnum uniqueness, Function reader, BiConsumer writer) { super(source, sourceId, scimPath, description, type, mutability, uniqueness, reader, writer); } - + + /** + * Constructor with an explicit remover. Use when {@code write(user, null)} would be unsafe + * (e.g. boolean attributes backed by a primitive setter which would NPE on auto-unboxing null). + * + * @param source source + * @param sourceId source id + * @param scimPath SCIM path + * @param description description + * @param type type + * @param mutability mutability + * @param uniqueness uniqueness + * @param reader reader + * @param writer writer + * @param remover remover + */ + public BooleanUserAttribute(Source source, String sourceId, String scimPath, String description, SchemaAttribute.TypeEnum type, SchemaAttribute.MutabilityEnum mutability, SchemaAttribute.UniquenessEnum uniqueness, Function reader, BiConsumer writer, Consumer remover) { + super(source, sourceId, scimPath, description, type, mutability, uniqueness, reader, writer, remover); + } + } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java index 6ee21bf..b3a7e57 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/MetadataController.java @@ -16,17 +16,24 @@ import fi.metatavu.keycloak.scim.server.model.AuthenticationScheme; import fi.metatavu.keycloak.scim.server.model.ResourceTypeListResponse; import fi.metatavu.keycloak.scim.server.model.SchemaAttribute; +import org.jboss.logging.Logger; +import org.keycloak.models.IdentityProviderStorageProvider; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserModel; import org.keycloak.representations.userprofile.config.UPAttribute; import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.userprofile.UserProfileProvider; +import org.keycloak.utils.StringUtil; + +import static org.keycloak.broker.oidc.mappers.UserAttributeMapper.USER_ATTRIBUTE; /** * Controller for metadata */ public class MetadataController extends AbstractController { + private static final Logger logger = Logger.getLogger(MetadataController.class); + /** * Lists resource types supported by the SCIM server * @@ -275,7 +282,8 @@ private List> getUserAttributeMappingList(ScimContext scimConte SchemaAttribute.MutabilityEnum.READWRITE, SchemaAttribute.UniquenessEnum.NONE, UserModel::isEnabled, - UserModel::setEnabled + UserModel::setEnabled, + user -> user.setEnabled(false) ) ); @@ -302,10 +310,44 @@ private List> getUserAttributeMappingList(ScimContext scimConte SchemaAttribute.MutabilityEnum.READWRITE, SchemaAttribute.UniquenessEnum.NONE, user -> user.getFirstAttribute(userProfileAttribute.getName()), - (user, value) -> user.setAttribute(userProfileAttribute.getName(), List.of(value)) + (user, value) -> user.setAttribute(userProfileAttribute.getName(), List.of(value)), + user -> user.removeAttribute(userProfileAttribute.getName()) )); } } + + if (UPConfig.UnmanagedAttributePolicy.ENABLED.equals(userProfileProvider.getConfiguration().getUnmanagedAttributePolicy())) { + String identityProviderAlias = scimContext.getConfig().getIdentityProviderAlias(); + if (!StringUtil.isNullOrEmpty(identityProviderAlias)) { + try { + IdentityProviderStorageProvider identityProviderStorageProvider = session.getProvider(IdentityProviderStorageProvider.class); + identityProviderStorageProvider.getMappersByAliasStream(identityProviderAlias).forEach(mapper -> { + if (mapper.getConfig() == null) { + return; + } + String attribute = mapper.getConfig().get(USER_ATTRIBUTE); + if (StringUtil.isNullOrEmpty(attribute)) { + return; + } + if (!builtInAttributeNames.contains(attribute) && customAttributes.stream().noneMatch(a -> a.getScimPath().equals(attribute))) { + customAttributes.add(new StringUserAttribute( + UserAttribute.Source.IDP_MAPPER, + attribute, + attribute, + attribute, + SchemaAttribute.TypeEnum.STRING, + SchemaAttribute.MutabilityEnum.READWRITE, + SchemaAttribute.UniquenessEnum.NONE, + user -> user.getFirstAttribute(attribute), + (user, value) -> user.setAttribute(attribute, List.of(value)) + )); + } + }); + } catch (Exception e) { + logger.warnf("Failed to read identity provider mappers for alias %s: %s", identityProviderAlias, e.getMessage()); + } + } + } } List> result = new ArrayList<>(builtIn); diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/StringUserAttribute.java b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/StringUserAttribute.java index deedf44..7d65fec 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/StringUserAttribute.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/StringUserAttribute.java @@ -4,6 +4,7 @@ import org.keycloak.models.UserModel; import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.function.Function; /** @@ -12,7 +13,7 @@ public class StringUserAttribute extends UserAttribute { /** - * Constructor + * Constructor without explicit remover. Defaults to {@code write(user, null)}. * * @param source source * @param sourceId source id @@ -28,4 +29,22 @@ public StringUserAttribute(Source source, String sourceId, String scimPath, Stri super(source, sourceId, scimPath, description, type, mutability, uniqueness, reader, writer); } + /** + * Constructor with an explicit remover. + * + * @param source source + * @param sourceId source id + * @param scimPath SCIM path + * @param description description + * @param type type + * @param mutability mutability + * @param uniqueness uniqueness + * @param reader reader + * @param writer writer + * @param remover remover + */ + public StringUserAttribute(Source source, String sourceId, String scimPath, String description, SchemaAttribute.TypeEnum type, SchemaAttribute.MutabilityEnum mutability, SchemaAttribute.UniquenessEnum uniqueness, Function reader, BiConsumer writer, Consumer remover) { + super(source, sourceId, scimPath, description, type, mutability, uniqueness, reader, writer, remover); + } + } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/UserAttribute.java b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/UserAttribute.java index 484bb38..0f64016 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/metadata/UserAttribute.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/metadata/UserAttribute.java @@ -4,6 +4,7 @@ import org.keycloak.models.UserModel; import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.function.Function; /** @@ -19,8 +20,12 @@ public class UserAttribute { * Attribute source */ public enum Source { + // User model attributes are stored in Keycloak user model USER_MODEL, - USER_PROFILE + // Custom attributes defined in user profile + USER_PROFILE, + // Attributes defined in identity provider attribute mapper + IDP_MAPPER } private final Source source; @@ -32,9 +37,10 @@ public enum Source { private final SchemaAttribute.UniquenessEnum uniqueness; private final Function reader; private final BiConsumer writer; + private final Consumer remover; /** - * Constructor + * Constructor without explicit remover. Defaults to {@code write(user, null)}. * * @param source attribute source * @param sourceId attribute source id @@ -56,6 +62,37 @@ public enum Source { SchemaAttribute.UniquenessEnum uniqueness, Function reader, BiConsumer writer + ) { + this(source, sourceId, scimPath, description, type, mutability, uniqueness, reader, writer, null); + } + + /** + * Constructor with an explicit remover. Use when {@code write(user, null)} would be unsafe + * (e.g. USER_PROFILE attributes backed by {@code user.setAttribute(name, List.of(value))} + * which throws NPE on {@code List.of(null)}). + * + * @param source attribute source + * @param sourceId attribute source id + * @param scimPath SCIM path + * @param description attribute description + * @param type attribute type + * @param mutability attribute mutability + * @param uniqueness attribute uniqueness + * @param reader attribute reader + * @param writer attribute writer + * @param remover attribute remover (nullable; falls back to {@code write(user, null)} when null) + */ + UserAttribute( + Source source, + String sourceId, + String scimPath, + String description, + SchemaAttribute.TypeEnum type, + SchemaAttribute.MutabilityEnum mutability, + SchemaAttribute.UniquenessEnum uniqueness, + Function reader, + BiConsumer writer, + Consumer remover ) { this.source = source; this.sourceId = sourceId; @@ -66,6 +103,7 @@ public enum Source { this.uniqueness = uniqueness; this.reader = reader; this.writer = writer; + this.remover = remover; } /** @@ -151,4 +189,21 @@ public void write(UserModel user, T value) { writer.accept(user, value); } + /** + * Removes this attribute from the user. + *

+ * Uses the explicit remover when one was provided at construction time; otherwise falls back + * to {@code write(user, null)}. USER_PROFILE attributes must supply an explicit remover + * because their writer uses {@code List.of(value)}, which throws NPE when value is null. + * + * @param user user + */ + public void clear(UserModel user) { + if (remover != null) { + remover.accept(user); + } else { + write(user, null); + } + } + } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationController.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationController.java deleted file mode 100644 index 2277d7a..0000000 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationController.java +++ /dev/null @@ -1,33 +0,0 @@ -package fi.metatavu.keycloak.scim.server.organization; - -import fi.metatavu.keycloak.scim.server.AbstractController; -import org.keycloak.models.KeycloakContext; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.OrganizationModel; -import org.keycloak.organization.OrganizationProvider; - -public class OrganizationController extends AbstractController { - - public OrganizationModel findOrganizationById( - KeycloakSession session, - String organizationId - ) { - return getOrganizationProvider(session).getById(organizationId); - } - - /** - * Returns the organization provider - * - * @param session Keycloak session - * @return Organization provider - */ - private OrganizationProvider getOrganizationProvider(KeycloakSession session) { - KeycloakContext context = session.getContext(); - if (context == null) { - throw new IllegalStateException("Keycloak context is not set"); - } - - return session.getProvider(OrganizationProvider.class); - } - -} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java index 4a9cf68..c1a1f69 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimConfig.java @@ -2,14 +2,14 @@ import fi.metatavu.keycloak.scim.server.config.ConfigurationError; import fi.metatavu.keycloak.scim.server.config.ScimConfig; -import java.util.List; -import java.util.Map; -import org.keycloak.models.OrganizationModel; +import org.jboss.logging.Logger; /** * SCIM configuration for organizations */ -public class OrganizationScimConfig implements ScimConfig { +public interface OrganizationScimConfig extends ScimConfig { + + Logger logger = Logger.getLogger(OrganizationScimConfig.class.getName()); public static final String SCIM_EXTERNAL_SHARED_SECRET = "SCIM_EXTERNAL_SHARED_SECRET"; public static final String SCIM_EXTERNAL_JWKS_URI = "SCIM_EXTERNAL_JWKS_URI"; @@ -18,37 +18,50 @@ public class OrganizationScimConfig implements ScimConfig { public static final String SCIM_EXTERNAL_ISSUER = "SCIM_EXTERNAL_ISSUER"; public static final String SCIM_AUTHENTICATION_MODE = "SCIM_AUTHENTICATION_MODE"; public static final String SCIM_EMAIL_AS_USERNAME = "SCIM_EMAIL_AS_USERNAME"; + public static final String SCIM_BASIC_AUTH_USERNAME = "SCIM_BASIC_AUTH_USERNAME"; + public static final String SCIM_BASIC_AUTH_PASSWORD = "SCIM_BASIC_AUTH_PASSWORD"; - private final OrganizationModel organization; - - public OrganizationScimConfig(OrganizationModel organization) { - this.organization = organization; - } - - @Override - public void validateConfig() throws ConfigurationError { + default void validateConfig() throws ConfigurationError { AuthenticationMode mode = getAuthenticationMode(); if (mode == null) { + logger.warnf("Organization SCIM config invalid: %s is not set", SCIM_AUTHENTICATION_MODE); throw new ConfigurationError(SCIM_AUTHENTICATION_MODE + " is not set"); } + logger.debugf("Organization SCIM authentication mode: %s", mode); + boolean isSharedSecretPresent = getSharedSecret() != null && !getSharedSecret().isBlank(); + boolean isBasicAuthUsernamePresent = getBasicAuthUsername() != null && !getBasicAuthUsername().isBlank(); + boolean isBasicAuthPasswordPresent = getBasicAuthPassword() != null && !getBasicAuthPassword().isBlank(); if (mode == AuthenticationMode.EXTERNAL) { - if (!isSharedSecretPresent) { + if (isBasicAuthUsernamePresent || isBasicAuthPasswordPresent) { + if (!isBasicAuthUsernamePresent) { + logger.warnf("Organization SCIM config invalid: %s is not set", SCIM_BASIC_AUTH_USERNAME); + throw new ConfigurationError(SCIM_BASIC_AUTH_USERNAME + " must be set when " + SCIM_BASIC_AUTH_PASSWORD + " is set"); + } + if (!isBasicAuthPasswordPresent) { + logger.warnf("Organization SCIM config invalid: %s is not set", SCIM_BASIC_AUTH_PASSWORD); + throw new ConfigurationError(SCIM_BASIC_AUTH_PASSWORD + " must be set when " + SCIM_BASIC_AUTH_USERNAME + " is set"); + } + } else if (!isSharedSecretPresent) { if (getExternalIssuer() == null) { + logger.warnf("Organization SCIM config invalid: %s is not set", SCIM_EXTERNAL_ISSUER); throw new ConfigurationError(SCIM_EXTERNAL_ISSUER + " is not set"); } if (getExternalJwksUri() == null) { + logger.warnf("Organization SCIM config invalid: %s is not set", SCIM_EXTERNAL_JWKS_URI); throw new ConfigurationError(SCIM_EXTERNAL_JWKS_URI + " is not set"); } if (getExternalAudience() == null) { + logger.warnf("Organization SCIM config invalid: %s is not set", SCIM_EXTERNAL_AUDIENCE); throw new ConfigurationError(SCIM_EXTERNAL_AUDIENCE + " is not set"); } } } else { + logger.warnf("Organization SCIM config invalid: authentication mode %s is not supported in organization mode", mode); throw new ConfigurationError( String.format( SCIM_AUTHENTICATION_MODE + " %s AuthenticationMode not supported in organization mode", @@ -58,70 +71,15 @@ public void validateConfig() throws ConfigurationError { } } - @Override - public AuthenticationMode getAuthenticationMode() { - String value = getAttribute(SCIM_AUTHENTICATION_MODE); - if (value == null || value.isEmpty()) { - return null; - } - - return AuthenticationMode.valueOf(value); - } - - @Override - public String getExternalIssuer() { - return getAttribute(SCIM_EXTERNAL_ISSUER); - } - - @Override - public String getExternalJwksUri() { - return getAttribute(SCIM_EXTERNAL_JWKS_URI); - } - - @Override - public String getExternalAudience() { - return getAttribute(SCIM_EXTERNAL_AUDIENCE); - } - - @Override - public String getSharedSecret() { - return getAttribute(SCIM_EXTERNAL_SHARED_SECRET); - } - - @Override - public boolean getLinkIdp() { - return "true".equalsIgnoreCase(getAttribute(SCIM_LINK_IDP)); - } - // Organization SCIM configuration does not support identity provider alias, so we return empty string @Override - public String getIdentityProviderAlias() { + default String getIdentityProviderAlias() { return ""; } - @Override - public boolean getEmailAsUsername() { - return "true".equalsIgnoreCase(getAttribute(SCIM_EMAIL_AS_USERNAME)); - } - /** - * Gets the organization attribute - * - * @return organization attribute value + * Is the organization enabled for SCIM */ - private String getAttribute(String attributeName) { - Map> attributes = organization.getAttributes(); - if (attributes == null) { - return null; - } - - List values = attributes.get(attributeName); - if (values == null || values.isEmpty()) { - return null; - } - - return values.getFirst(); - } - + public boolean isEnabled(); } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimContext.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimContext.java index 1ba6862..ac7d8a3 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimContext.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimContext.java @@ -2,38 +2,90 @@ import fi.metatavu.keycloak.scim.server.ScimContext; import org.keycloak.models.KeycloakSession; -import org.keycloak.models.OrganizationModel; import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; import java.net.URI; +import java.util.stream.Stream; /** - * SCIM context for organizations + * SCIM context for organizations. Extended to allow hiding of which organization + * implementation is being used. */ -public class OrganizationScimContext extends ScimContext { - - private final OrganizationModel organization; - - /** - * Constructor - * - * @param baseUri base URI - * @param session keycloak session - * @param realm realm - * @param organization organization - */ - public OrganizationScimContext(URI baseUri, KeycloakSession session, RealmModel realm, OrganizationModel organization, OrganizationScimConfig config) { - super(baseUri, session, realm, config); - this.organization = organization; - } - - /** - * Gets the organization - * - * @return organization - */ - public OrganizationModel getOrganization() { - return organization; - } +public abstract class OrganizationScimContext extends ScimContext { + protected final String organizationId; + + /** + * Constructor + * + * @param baseUri base URI + * @param session keycloak session + * @param realm realm + * @param organizationId organizationId + * @param config organization scim config + */ + public OrganizationScimContext(URI baseUri, KeycloakSession session, RealmModel realm, String organizationId, OrganizationScimConfig config) { + super(baseUri, session, realm, config); + this.organizationId = organizationId; + } + + /** + * Gets the organizationId + * + * @return organizationId + */ + public String getOrganizationId() { + return organizationId; + } + + /** + * Get a paginated stream of this organization's users + * + * @return Stream of UserModel + */ + public abstract Stream getMembersStream(Integer first, Integer max); + + /** + * Find the user that is a member of this organization given the userId + * + * @return UserModel found user, or null + */ + public abstract UserModel findUser(String userId); + + /** + * Add a member to this organization + * + * @return true if the user was successfully added as a member + */ + public abstract boolean addMember(UserModel user); + + /** + * Checks if the user is a member of this organization + * + * @return true if the user is a member + */ + public abstract boolean isMember(UserModel user); + + /** + * Remove a member from this organization + * + * @return true if the user was successfully removed as a member + */ + public abstract boolean removeMember(UserModel user); + + /** + * Link the user to this organization's first IdP + * + * @return true if the user was successfully linked + */ + public abstract boolean linkUserIdp(UserModel user, String scimUserEmail, String scimUserName, String scimExternalId); + + /** + * Gets the organization representation suitable for serializaing to JSON + * for admin events or REST responses + * + * @return organization + */ + public abstract Object toRepresentation(); } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java index 0f92da2..8cae92f 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServer.java @@ -1,6 +1,7 @@ package fi.metatavu.keycloak.scim.server.organization; import fi.metatavu.keycloak.scim.server.AbstractScimServer; +import fi.metatavu.keycloak.scim.server.ScimErrors; import fi.metatavu.keycloak.scim.server.config.ConfigurationError; import fi.metatavu.keycloak.scim.server.filter.ScimFilter; import fi.metatavu.keycloak.scim.server.jacoco.ExcludeFromJacocoGeneratedReport; @@ -21,14 +22,12 @@ /** * SCIM server implementation for organizations */ -public class OrganizationScimServer extends AbstractScimServer { +public abstract class OrganizationScimServer extends AbstractScimServer { private static final Logger logger = Logger.getLogger(OrganizationScimServer.class); - private final OrganizationController organizationController; private final OrganizationUserController organizationUserController; public OrganizationScimServer() { - this.organizationController = new OrganizationController(); this.organizationUserController = new OrganizationUserController(); } @@ -38,12 +37,27 @@ public Response createUser(OrganizationScimContext scimContext, User createReque if (isBlank(createRequest.getUserName())) { logger.warn("Cannot create user: Missing userName"); - return Response.status(Response.Status.BAD_REQUEST).entity("Missing userName").build(); + return ScimErrors.badRequest("Missing userName"); } if (emailAsUsername && !isValidEmail(createRequest.getUserName())) { logger.warn("Cannot create user: Invalid email format for userName"); - return Response.status(Response.Status.BAD_REQUEST).entity("Invalid email format for userName").build(); + return ScimErrors.badRequest("Invalid email format for userName"); + } + + KeycloakSession session = scimContext.getSession(); + RealmModel realm = scimContext.getRealm(); + + UserModel existing = session.users().getUserByUsername(realm, createRequest.getUserName()); + if (existing != null) { + return ScimErrors.conflict(String.format("User already exists with username: %s", createRequest.getUserName())); + } + + String requestedEmail = createRequest.getEmails() != null && !createRequest.getEmails().isEmpty() + ? createRequest.getEmails().getFirst().getValue() + : null; + if (requestedEmail != null && !realm.isDuplicateEmailsAllowed() && session.users().getUserByEmail(realm, requestedEmail) != null) { + return ScimErrors.conflict(String.format("User already exists with email: %s", requestedEmail)); } UserAttributes userAttributes = metadataController.getUserAttributes(scimContext); @@ -70,19 +84,19 @@ public Response updateUser(OrganizationScimContext scimContext, String userId, f if (isBlank(username)) { logger.warn("Missing userName"); - return Response.status(Response.Status.BAD_REQUEST).entity("Missing userName").build(); + return ScimErrors.badRequest("Missing userName"); } if (emailAsUsername && !isValidEmail(updateRequest.getUserName())) { logger.warn("Cannot update user: Invalid email format for userName"); - return Response.status(Response.Status.BAD_REQUEST).entity("Invalid email format for userName").build(); + return ScimErrors.badRequest("Invalid email format for userName"); } if (emailAsUsername && updateRequest.getEmails() != null) { for (fi.metatavu.keycloak.scim.server.model.UserEmailsInner email : updateRequest.getEmails()) { if (!Objects.equals(email.getValue(), updateRequest.getUserName())) { logger.warn("Conflicting email and userName when emailAsUsername is enabled"); - return Response.status(Response.Status.BAD_REQUEST).entity("Username and email must match when emailAsUsername is enabled").build(); + return ScimErrors.badRequest("Username and email must match when emailAsUsername is enabled"); } } } @@ -91,7 +105,7 @@ public Response updateUser(OrganizationScimContext scimContext, String userId, f UserModel user = session.users().getUserById(realm, userId); if (user == null) { logger.warn(String.format("User not found: %s", userId)); - return Response.status(Response.Status.NOT_FOUND).entity("User not found").build(); + return ScimErrors.notFound("User not found"); } // Check if username is being changed to an already existing one @@ -104,7 +118,7 @@ public Response updateUser(OrganizationScimContext scimContext, String userId, f if (existing != null && !existing.getId().equals(userId)) { logger.warn(String.format("User name already taken: %s", updateRequest.getUserName())); - return Response.status(Response.Status.CONFLICT).entity("User name already taken").build(); + return ScimErrors.conflict("User name already taken"); } UserAttributes userAttributes = metadataController.getUserAttributes(scimContext); @@ -121,7 +135,7 @@ public Response patchUser(OrganizationScimContext scimContext, String userId, fi UserModel existing = session.users().getUserById(realm, userId); if (existing == null) { logger.warn(String.format("User not found: %s", userId)); - return Response.status(Response.Status.NOT_FOUND).entity("User not found").build(); + return ScimErrors.notFound("User not found"); } UserAttributes userAttributes = metadataController.getUserAttributes(scimContext); @@ -130,7 +144,7 @@ public Response patchUser(OrganizationScimContext scimContext, String userId, fi fi.metatavu.keycloak.scim.server.model.User result = organizationUserController.patchOrganizationUser(scimContext, userAttributes, existing, patchRequest); return Response.ok(result).build(); } catch (UnsupportedPatchOperation e) { - return Response.status(Response.Status.BAD_REQUEST).entity("Unsupported patch operation").build(); + return ScimErrors.badRequest("Unsupported patch operation"); } } @@ -175,7 +189,7 @@ public Response deleteUser(OrganizationScimContext scimContext, String userId) { RoleModel scimManagedRole = realm.getRole("scim-managed"); if (scimManagedRole != null && !user.hasRole(scimManagedRole)) { logger.warn(String.format("User is not SCIM-managed: %s", userId)); - return Response.status(Response.Status.FORBIDDEN).entity("User is not managed by SCIM").build(); + return ScimErrors.forbidden("User is not managed by SCIM"); } organizationUserController.deleteOrganizationUser(scimContext, user); @@ -225,46 +239,6 @@ public Response deleteGroup(OrganizationScimContext scimContext, String id) { return Response.status(Response.Status.NOT_IMPLEMENTED).build(); } - /** - * Returns SCIM context - * - * @param session Keycloak session - * @return SCIM context - */ - public OrganizationScimContext getScimContext(KeycloakSession session, String organizationId) { - RealmModel realm = session.getContext().getRealm(); - if (realm == null) { - throw new NotFoundException("Realm not found"); - } - - OrganizationModel organization = organizationController.findOrganizationById( - session, - organizationId - ); - - if (organization == null) { - throw new NotFoundException("Organization not found"); - } - - KeycloakContext context = session.getContext(); - context.setOrganization(organization); - - URI baseUri = session.getContext().getUri().getBaseUri().resolve(String.format("realms/%s/scim/v2/organizations/%s/", realm.getName(), organization.getId())); - OrganizationScimConfig config = new OrganizationScimConfig(organization); - - try { - config.validateConfig(); - } catch (ConfigurationError e) { - throw new InternalServerErrorException("Invalid SCIM configuration", e); - } - - return new OrganizationScimContext( - baseUri, - session, - realm, - organization, - config - ); - } + public abstract OrganizationScimContext getScimContext(KeycloakSession session, String organizationId); } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerProvider.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerProvider.java new file mode 100644 index 0000000..5a703b4 --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerProvider.java @@ -0,0 +1,11 @@ +package fi.metatavu.keycloak.scim.server.organization; + +import org.keycloak.provider.Provider; +import org.keycloak.models.KeycloakSession; + +public interface OrganizationScimServerProvider extends Provider { + + public OrganizationScimServer getScimServer(KeycloakSession session); + + default void close() {} +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerProviderFactory.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerProviderFactory.java new file mode 100644 index 0000000..1f77c4b --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerProviderFactory.java @@ -0,0 +1,5 @@ +package fi.metatavu.keycloak.scim.server.organization; + +import org.keycloak.provider.ProviderFactory; + +public interface OrganizationScimServerProviderFactory extends ProviderFactory {} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerSpi.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerSpi.java new file mode 100644 index 0000000..c4a71e3 --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationScimServerSpi.java @@ -0,0 +1,29 @@ +package fi.metatavu.keycloak.scim.server.organization; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +public class OrganizationScimServerSpi implements Spi { + + @Override + public boolean isInternal() { + return false; + } + + @Override + public String getName() { + return "organizationScimServerProvider"; + } + + @Override + public Class getProviderClass() { + return OrganizationScimServerProvider.class; + } + + @Override + @SuppressWarnings("rawtypes") + public Class getProviderFactoryClass() { + return OrganizationScimServerProviderFactory.class; + } +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java index 70291e4..6debf60 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/OrganizationUserController.java @@ -17,9 +17,11 @@ import org.jboss.logging.Logger; import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.ResourceType; -import org.keycloak.models.*; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; import org.keycloak.models.utils.ModelToRepresentation; -import org.keycloak.organization.OrganizationProvider; import java.util.Collections; import java.util.HashMap; @@ -46,7 +48,6 @@ public fi.metatavu.keycloak.scim.server.model.User createOrganizationUser( ) { KeycloakSession session = scimContext.getSession(); RealmModel realm = scimContext.getRealm(); - OrganizationModel organization = scimContext.getOrganization(); ScimConfig config = scimContext.getConfig(); UserModel user = session.users().addUser(realm, scimUser.getUserName()); @@ -93,8 +94,7 @@ public fi.metatavu.keycloak.scim.server.model.User createOrganizationUser( }); } - OrganizationProvider organizationProvider = getOrganizationProvider(scimContext.getSession()); - organizationProvider.addManagedMember(organization, user); + scimContext.addMember(user); User createdUser = translateUser( scimContext, @@ -103,9 +103,10 @@ public fi.metatavu.keycloak.scim.server.model.User createOrganizationUser( ); if (config.getLinkIdp()) { - String scimUsername = createdUser.getUserName(); + scimUserEmail = getScimUserEmail(createdUser, config); + String scimUserName = createdUser.getUserName(); String externalId = getExternalId(createdUser); - linkUserIdp(organizationProvider, organization, session, realm, user, scimUserEmail, scimUsername, externalId); + scimContext.linkUserIdp(user, scimUserEmail, scimUserName, externalId); } dispatchUserCreateEvent(scimContext, user); @@ -131,7 +132,6 @@ public fi.metatavu.keycloak.scim.server.model.User updateOrganizationUser( ) { KeycloakSession session = scimContext.getSession(); RealmModel realm = scimContext.getRealm(); - OrganizationModel organization = scimContext.getOrganization(); ScimConfig config = scimContext.getConfig(); ((StringUserAttribute) userAttributes.findByScimPath("userName")).write(existing, scimUser.getUserName()); @@ -177,11 +177,10 @@ public fi.metatavu.keycloak.scim.server.model.User updateOrganizationUser( ); if (config.getLinkIdp()) { - OrganizationProvider organizationProvider = getOrganizationProvider(scimContext.getSession()); String scimUserEmail = getScimUserEmail(updatedUser, config); - String scimUsername = updatedUser.getUserName(); + String scimUserName = updatedUser.getUserName(); String externalId = getExternalId(updatedUser); - linkUserIdp(organizationProvider, organization, session, realm, existing, scimUserEmail, scimUsername, externalId); + scimContext.linkUserIdp(existing, scimUserEmail, scimUserName, externalId); } dispatchUserUpdateEvent(scimContext, existing); @@ -206,47 +205,9 @@ public fi.metatavu.keycloak.scim.server.model.User patchOrganizationUser( ) throws UnsupportedPatchOperation { KeycloakSession session = scimContext.getSession(); RealmModel realm = scimContext.getRealm(); - OrganizationModel organization = scimContext.getOrganization(); ScimConfig config = scimContext.getConfig(); - for (var operation : patchRequest.getOperations()) { - PatchOperation op = PatchOperation.fromString(operation.getOp()); - if (op == null) { - logger.warn("Invalid patch operation: " + operation.getOp()); - throw new UnsupportedPatchOperation("Unsupported patch operation: " + operation.getOp()); - } - - UserAttribute userAttribute = userAttributes.findByScimPath(operation.getPath()); - Object value = operation.getValue(); - - if (userAttribute == null) { - throw new UnsupportedUserPath("Unsupported attribute: " + operation.getPath()); - } - - switch (op) { - case REPLACE, ADD -> { - switch (value) { - case null: - logger.warn("Value is null for patch operation: " + op); - break; - case String s when userAttribute instanceof StringUserAttribute: - ((StringUserAttribute) userAttribute).write(existing, s); - break; - case String s when userAttribute instanceof BooleanUserAttribute: - ((BooleanUserAttribute) userAttribute).write(existing, Boolean.parseBoolean(s)); - break; - case Boolean b when userAttribute instanceof BooleanUserAttribute: - ((BooleanUserAttribute) userAttribute).write(existing, b); - break; - default: - logger.warn("Unsupported value type for patch operation: " + value.getClass()); - break; - } - - } - case REMOVE -> userAttribute.write(existing, null); - } - } + applyPatchOperations(userAttributes, existing, patchRequest); fi.metatavu.keycloak.scim.server.model.User patchedUser = translateUser( scimContext, @@ -255,11 +216,10 @@ public fi.metatavu.keycloak.scim.server.model.User patchOrganizationUser( ); if (config.getLinkIdp()) { - OrganizationProvider organizationProvider = getOrganizationProvider(scimContext.getSession()); String scimUserEmail = getScimUserEmail(patchedUser, config); - String scimUsername = patchedUser.getUserName(); + String scimUserName = patchedUser.getUserName(); String externalId = getExternalId(patchedUser); - linkUserIdp(organizationProvider, organization, session, realm, existing, scimUserEmail, scimUsername, externalId); + scimContext.linkUserIdp(existing, scimUserEmail, scimUserName, externalId); } dispatchUserUpdateEvent(scimContext, existing); @@ -281,10 +241,7 @@ public fi.metatavu.keycloak.scim.server.model.User findOrganizationUser( String userId ) { try { - UserModel organizationUser = getOrganizationProvider(scimContext.getSession()).getMemberById( - scimContext.getOrganization(), - userId - ); + UserModel organizationUser = scimContext.findUser(userId); return translateUser( scimContext, @@ -321,7 +278,7 @@ public fi.metatavu.keycloak.scim.server.model.UsersList listOrganizationUsers( throw new IllegalStateException("SCIM managed role not found"); } - List filteredUsers = getOrganizationProvider(session).getMembersStream(scimContext.getOrganization(), Collections.emptyMap(), true, null, null) + List filteredUsers = scimContext.getMembersStream(null, null) .filter(user -> matchScimFilter(user, userAttributes, scimFilter)) .filter(user -> user.hasRole(scimManagedRole)) .toList(); @@ -348,10 +305,9 @@ public fi.metatavu.keycloak.scim.server.model.UsersList listOrganizationUsers( */ public void deleteOrganizationUser(OrganizationScimContext scimContext, UserModel user) { KeycloakSession session = scimContext.getSession(); - OrganizationProvider organizationProvider = getOrganizationProvider(session); - if (organizationProvider.isManagedMember(scimContext.getOrganization(), user)) { - organizationProvider.removeMember(scimContext.getOrganization(), user); + if (scimContext.isMember(user)) { + scimContext.removeMember(user); dispatchOrganizationMemberDeleteEvent(scimContext, user); dispatchUserDeleteEvent(scimContext, user); } else { @@ -359,21 +315,6 @@ public void deleteOrganizationUser(OrganizationScimContext scimContext, UserMode } } - /** - * Returns the organization provider - * - * @param session Keycloak session - * @return Organization provider - */ - private OrganizationProvider getOrganizationProvider(KeycloakSession session) { - KeycloakContext context = session.getContext(); - if (context == null) { - throw new IllegalStateException("Keycloak context is not set"); - } - - return session.getProvider(OrganizationProvider.class); - } - /** * Gets the email from SCIM user * @@ -408,70 +349,6 @@ private String getExternalId(fi.metatavu.keycloak.scim.server.model.User scimUse return externalId; } - /** - * Links user to identity provider - * - * @param organizationProvider organization provider - * @param organization organization - * @param session Keycloak session - * @param realm Keycloak realm - * @param user Keycloak user - * @param scimUserEmail SCIM user email - * @param scimUserName SCIM username - * @param scimExternalId SCIM user external ID - */ - private void linkUserIdp( - OrganizationProvider organizationProvider, - OrganizationModel organization, - KeycloakSession session, - RealmModel realm, - UserModel user, - String scimUserEmail, - String scimUserName, - String scimExternalId - ) { - if (scimUserEmail == null) { - logger.warn("User email is not set. Cannot link user to identity provider"); - return; - } - - if (scimExternalId == null) { - logger.warn("User externalId is not set. Cannot link user to identity provider"); - return; - } - - String emailDomain = getEmailDomain(scimUserEmail); - if (emailDomain == null) { - logger.warn("User email domain is not set. Cannot link user to identity provider"); - return; - } - - IdentityProviderModel identityProvider = organizationProvider.getIdentityProviders(organization) - .filter(identityProviderModel -> { - String identityProviderDomain = identityProviderModel.getConfig().get("kc.org.domain"); - return identityProviderDomain != null && identityProviderDomain.equals(emailDomain); - }) - .findFirst() - .orElse(null); - - if (identityProvider == null) { - logger.warn("No identity provider found for email domain: " + emailDomain + ". Cannot link user to identity provider"); - return; - } - - if (session.users().getFederatedIdentity(realm, user, identityProvider.getAlias()) == null) { - logger.info("Linking user to identity provider: " + identityProvider.getAlias()); - - FederatedIdentityModel identityModel = new FederatedIdentityModel( - identityProvider.getAlias(), - scimExternalId, - scimUserName - ); - - session.users().addFederatedIdentity(realm, user, identityModel); - } - } - /** * Dispatches an event when a user is added to the organization * @@ -482,7 +359,6 @@ private void dispatchOrganizationMemberAddEvent( OrganizationScimContext scimContext, UserModel member ) { - OrganizationModel organization = scimContext.getOrganization(); Map eventDetails = new HashMap<>(); if (member.getUsername() != null) { @@ -497,8 +373,8 @@ private void dispatchOrganizationMemberAddEvent( scimContext, OperationType.CREATE, ResourceType.ORGANIZATION_MEMBERSHIP, - "organizations/" + organization.getId() + "/members", - ModelToRepresentation.toRepresentation(organization), + "organizations/" + scimContext.getOrganizationId() + "/members", + scimContext.toRepresentation(), eventDetails ); } @@ -513,7 +389,6 @@ private void dispatchOrganizationMemberDeleteEvent( OrganizationScimContext scimContext, UserModel member ) { - OrganizationModel organization = scimContext.getOrganization(); Map eventDetails = new HashMap<>(); if (member.getUsername() != null) { @@ -528,8 +403,8 @@ private void dispatchOrganizationMemberDeleteEvent( scimContext, OperationType.DELETE, ResourceType.ORGANIZATION_MEMBERSHIP, - "organizations/" + organization.getId() + "/members/" + member.getId(), - ModelToRepresentation.toRepresentation(organization), + "organizations/" + scimContext.getOrganizationId() + "/members/" + member.getId(), + scimContext.toRepresentation(), eventDetails ); } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimConfig.java new file mode 100644 index 0000000..210045b --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimConfig.java @@ -0,0 +1,93 @@ +package fi.metatavu.keycloak.scim.server.organization.keycloak; + +import fi.metatavu.keycloak.scim.server.config.ConfigurationError; +import fi.metatavu.keycloak.scim.server.organization.OrganizationScimConfig; +import org.keycloak.models.OrganizationModel; +import java.util.List; +import java.util.Map; + +/** + * SCIM configuration for organizations + */ +public class KeycloakOrganizationScimConfig implements OrganizationScimConfig { + + private final OrganizationModel organization; + + public KeycloakOrganizationScimConfig(OrganizationModel organization) { + this.organization = organization; + } + + @Override + public boolean isEnabled() { + try { + validateConfig(); + return true; + } catch (ConfigurationError e) { + return false; + } + } + + @Override + public AuthenticationMode getAuthenticationMode() { + String value = getAttribute(SCIM_AUTHENTICATION_MODE); + if (value == null || value.isEmpty()) { + return null; + } + + return AuthenticationMode.valueOf(value); + } + + @Override + public String getExternalIssuer() { + return getAttribute(SCIM_EXTERNAL_ISSUER); + } + + @Override + public String getExternalJwksUri() { + return getAttribute(SCIM_EXTERNAL_JWKS_URI); + } + + @Override + public String getExternalAudience() { + return getAttribute(SCIM_EXTERNAL_AUDIENCE); + } + + @Override + public String getSharedSecret() { + return getAttribute(SCIM_EXTERNAL_SHARED_SECRET); + } + + @Override + public boolean getLinkIdp() { + return "true".equalsIgnoreCase(getAttribute(SCIM_LINK_IDP)); + } + + @Override + public boolean getEmailAsUsername() { + return "true".equalsIgnoreCase(getAttribute(SCIM_EMAIL_AS_USERNAME)); + } + + @Override + public String getBasicAuthUsername() { + return getAttribute(SCIM_BASIC_AUTH_USERNAME); + } + + @Override + public String getBasicAuthPassword() { + return getAttribute(SCIM_BASIC_AUTH_PASSWORD); + } + + private String getAttribute(String attributeName) { + Map> attributes = organization.getAttributes(); + if (attributes == null) { + return null; + } + List values = attributes.get(attributeName); + if (values == null || values.isEmpty()) { + return null; + } + + return values.getFirst(); + } + +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimContext.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimContext.java new file mode 100644 index 0000000..9636970 --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimContext.java @@ -0,0 +1,105 @@ +package fi.metatavu.keycloak.scim.server.organization.keycloak; + +import static fi.metatavu.keycloak.scim.server.users.UsersController.getEmailDomain; + +import fi.metatavu.keycloak.scim.server.ScimContext; +import fi.metatavu.keycloak.scim.server.organization.*; +import org.keycloak.models.FederatedIdentityModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.OrganizationModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.organization.OrganizationProvider; +import org.keycloak.models.utils.ModelToRepresentation; +import java.net.URI; +import java.util.Collections; +import java.util.stream.Stream; +import org.jboss.logging.Logger; + +/** + * SCIM context for Keycloak organizations. + */ +public class KeycloakOrganizationScimContext extends OrganizationScimContext { + + private static final Logger logger = Logger.getLogger(KeycloakOrganizationScimContext.class); + + protected final OrganizationModel organization; + + public KeycloakOrganizationScimContext(URI baseUri, KeycloakSession session, RealmModel realm, OrganizationScimConfig config, OrganizationModel organization) { + super(baseUri, session, realm, organization.getId(), config); + this.organization = organization; + } + + @Override + public Stream getMembersStream(Integer first, Integer max) { + return getSession().getProvider(OrganizationProvider.class).getMembersStream(organization, Collections.emptyMap(), true, null, null); + } + + @Override + public UserModel findUser(String userId) { + return getSession().getProvider(OrganizationProvider.class).getMemberById(organization, userId); + } + + @Override + public boolean addMember(UserModel user) { + return getSession().getProvider(OrganizationProvider.class).addManagedMember(organization, user); + } + + @Override + public boolean isMember(UserModel user) { + return getSession().getProvider(OrganizationProvider.class).isManagedMember(organization, user); + } + + @Override + public boolean removeMember(UserModel user) { + return getSession().getProvider(OrganizationProvider.class).removeMember(organization, user); + } + + @Override + public boolean linkUserIdp(UserModel user, String scimUserEmail, String scimUserName, String scimExternalId) { + if (scimUserEmail == null) { + logger.warn("User email is not set. Cannot link user to identity provider"); + return false; + } + + if (scimExternalId == null) { + logger.warn("User externalId is not set. Cannot link user to identity provider"); + return false; + } + + String emailDomain = getEmailDomain(scimUserEmail); + if (emailDomain == null) { + logger.warn("User email domain is not set. Cannot link user to identity provider"); + return false; + } + + IdentityProviderModel identityProvider = + getSession().getProvider(OrganizationProvider.class).getIdentityProviders(organization) + .filter(identityProviderModel -> { + String identityProviderDomain = identityProviderModel.getConfig().get("kc.org.domain"); + return identityProviderDomain != null && identityProviderDomain.equals(emailDomain); + }) + .findFirst() + .orElse(null); + + if (identityProvider == null) { + logger.warn("No identity provider found for email domain: " + emailDomain + ". Cannot link user to identity provider"); + return false; + } + + if (getSession().users().getFederatedIdentity(getRealm(), user, identityProvider.getAlias()) == null) { + logger.info("Linking user to identity provider: " + identityProvider.getAlias()); + FederatedIdentityModel identityModel = new FederatedIdentityModel(identityProvider.getAlias(), scimExternalId, scimUserName); + getSession().users().addFederatedIdentity(getRealm(), user, identityModel); + return true; + } + + return false; + } + + @Override + public Object toRepresentation() { + return ModelToRepresentation.toRepresentation(organization); + } +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProvider.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProvider.java new file mode 100644 index 0000000..5a21d35 --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProvider.java @@ -0,0 +1,58 @@ +package fi.metatavu.keycloak.scim.server.organization.keycloak; + +import org.keycloak.models.RealmModel; +import org.keycloak.models.KeycloakContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OrganizationModel; +import org.keycloak.organization.OrganizationProvider; +import fi.metatavu.keycloak.scim.server.organization.*; +import java.net.URI; +import jakarta.ws.rs.InternalServerErrorException; +import jakarta.ws.rs.NotFoundException; +import fi.metatavu.keycloak.scim.server.config.ConfigurationError; + +public class KeycloakOrganizationScimServerProvider implements OrganizationScimServerProvider { + + @Override + public OrganizationScimServer getScimServer(KeycloakSession session) { + return new OrganizationScimServer() { + @Override + public OrganizationScimContext getScimContext(KeycloakSession session, String organizationId) { + return createScimContext(session, organizationId); + } + }; + } + + private static OrganizationScimContext createScimContext(KeycloakSession session, String organizationId) { + RealmModel realm = session.getContext().getRealm(); + if (realm == null) { + throw new NotFoundException("Realm not found"); + } + + final OrganizationModel organization = session.getProvider(OrganizationProvider.class).getById(organizationId); + + if (organization == null) { + throw new NotFoundException("Organization not found"); + } + + KeycloakContext context = session.getContext(); + context.setOrganization(organization); + + URI baseUri = session.getContext().getUri().getBaseUri().resolve(String.format("realms/%s/scim/v2/organizations/%s/", realm.getName(), organization.getId())); + OrganizationScimConfig config = new KeycloakOrganizationScimConfig(organization); + + try { + config.validateConfig(); + } catch (ConfigurationError e) { + throw new InternalServerErrorException("Invalid SCIM configuration", e); + } + + return new KeycloakOrganizationScimContext( + baseUri, + session, + realm, + config, + organization); + } + +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProviderFactory.java b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProviderFactory.java new file mode 100644 index 0000000..9993d89 --- /dev/null +++ b/src/main/java/fi/metatavu/keycloak/scim/server/organization/keycloak/KeycloakOrganizationScimServerProviderFactory.java @@ -0,0 +1,30 @@ +package fi.metatavu.keycloak.scim.server.organization.keycloak; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.Config.Scope; +import fi.metatavu.keycloak.scim.server.organization.*; + +public class KeycloakOrganizationScimServerProviderFactory implements OrganizationScimServerProviderFactory { + + public static final String PROVIDER_ID = "default"; + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public OrganizationScimServerProvider create(KeycloakSession session) { + return new KeycloakOrganizationScimServerProvider(); + } + + @Override + public void init(Scope config) {} + + @Override + public void postInit(KeycloakSessionFactory factory) {} + + @Override + public void close() {} +} diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java index 9673f75..a5697ce 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimConfig.java @@ -4,6 +4,7 @@ import fi.metatavu.keycloak.scim.server.config.ScimConfig; import org.eclipse.microprofile.config.Config; import org.eclipse.microprofile.config.ConfigProvider; +import org.jboss.logging.Logger; import org.keycloak.models.RealmModel; import java.util.Optional; @@ -13,14 +14,19 @@ */ public class RealmScimConfig implements ScimConfig { + private static final Logger logger = Logger.getLogger(RealmScimConfig.class.getName()); + public static final String SCIM_EXTERNAL_JWKS_URI = "scim.external.jwks.uri"; public static final String SCIM_EXTERNAL_AUDIENCE = "scim.external.audience"; public static final String SCIM_EXTERNAL_SHARED_SECRET = "scim.external.shared.secret"; public static final String SCIM_AUTHENTICATION_MODE = "scim.authentication.mode"; public static final String SCIM_EXTERNAL_ISSUER = "scim.external.issuer"; - public static final String SCIM_LINK_IDP = "scim.link.idp"; public static final String SCIM_IDENTITY_PROVIDER_ALIAS = "scim.identity.provider.alias"; + + public static final String SCIM_LINK_IDP = "scim.link.idp"; public static final String SCIM_EMAIL_AS_USERNAME = "scim.email.as.username"; + public static final String SCIM_BASIC_AUTH_USERNAME = "scim.basic.auth.username"; + public static final String SCIM_BASIC_AUTH_PASSWORD = "scim.basic.auth.password"; private final Config config; private final RealmModel realm; @@ -38,22 +44,41 @@ public RealmScimConfig(RealmModel realm) { public void validateConfig() throws ConfigurationError { AuthenticationMode mode = getAuthenticationMode(); if (mode == null) { + logger.warn("Realm SCIM config invalid: SCIM_AUTHENTICATION_MODE is not set"); throw new ConfigurationError("SCIM_AUTHENTICATION_MODE is not set"); } - boolean isSharedSecretPresent = getSharedSecret() != null && !getSharedSecret().isBlank(); - - if (mode == AuthenticationMode.EXTERNAL && !isSharedSecretPresent) { - if (getExternalIssuer() == null) { - throw new ConfigurationError("SCIM_EXTERNAL_ISSUER is not set"); - } - - if (getExternalJwksUri() == null) { - throw new ConfigurationError("SCIM_EXTERNAL_JWKS_URI is not set"); - } + logger.debugf("Realm SCIM authentication mode: %s", mode); - if (getExternalAudience() == null) { - throw new ConfigurationError("SCIM_EXTERNAL_AUDIENCE is not set"); + boolean isSharedSecretPresent = getSharedSecret() != null && !getSharedSecret().isBlank(); + boolean isBasicAuthUsernamePresent = getBasicAuthUsername() != null && !getBasicAuthUsername().isBlank(); + boolean isBasicAuthPasswordPresent = getBasicAuthPassword() != null && !getBasicAuthPassword().isBlank(); + + if (mode == AuthenticationMode.EXTERNAL) { + if (isBasicAuthUsernamePresent || isBasicAuthPasswordPresent) { + if (!isBasicAuthUsernamePresent) { + logger.warn("Realm SCIM config invalid: SCIM_BASIC_AUTH_USERNAME is not set"); + throw new ConfigurationError("SCIM_BASIC_AUTH_USERNAME must be set when SCIM_BASIC_AUTH_PASSWORD is set"); + } + if (!isBasicAuthPasswordPresent) { + logger.warn("Realm SCIM config invalid: SCIM_BASIC_AUTH_PASSWORD is not set"); + throw new ConfigurationError("SCIM_BASIC_AUTH_PASSWORD must be set when SCIM_BASIC_AUTH_USERNAME is set"); + } + } else if (!isSharedSecretPresent) { + if (getExternalIssuer() == null) { + logger.warn("Realm SCIM config invalid: SCIM_EXTERNAL_ISSUER is not set"); + throw new ConfigurationError("SCIM_EXTERNAL_ISSUER is not set"); + } + + if (getExternalJwksUri() == null) { + logger.warn("Realm SCIM config invalid: SCIM_EXTERNAL_JWKS_URI is not set"); + throw new ConfigurationError("SCIM_EXTERNAL_JWKS_URI is not set"); + } + + if (getExternalAudience() == null) { + logger.warn("Realm SCIM config invalid: SCIM_EXTERNAL_AUDIENCE is not set"); + throw new ConfigurationError("SCIM_EXTERNAL_AUDIENCE is not set"); + } } } @@ -145,6 +170,20 @@ public boolean getEmailAsUsername() { .orElse(false); } + @Override + public String getBasicAuthUsername() { + return readRealmAttribute(SCIM_BASIC_AUTH_USERNAME) + .or(() -> config.getOptionalValue(SCIM_BASIC_AUTH_USERNAME, String.class)) + .orElse(null); + } + + @Override + public String getBasicAuthPassword() { + return readRealmAttribute(SCIM_BASIC_AUTH_PASSWORD) + .or(() -> config.getOptionalValue(SCIM_BASIC_AUTH_PASSWORD, String.class)) + .orElse(null); + } + /** * Helper method to read the first string from a realm attribute. */ diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java index 19d087c..8dfe400 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/realm/RealmScimServer.java @@ -1,8 +1,10 @@ package fi.metatavu.keycloak.scim.server.realm; import fi.metatavu.keycloak.scim.server.AbstractScimServer; +import fi.metatavu.keycloak.scim.server.ScimErrors; import fi.metatavu.keycloak.scim.server.config.ConfigurationError; import fi.metatavu.keycloak.scim.server.filter.ScimFilter; +import fi.metatavu.keycloak.scim.server.groups.InvalidGroupMemberReference; import fi.metatavu.keycloak.scim.server.groups.UnsupportedGroupPath; import fi.metatavu.keycloak.scim.server.metadata.UserAttributes; import fi.metatavu.keycloak.scim.server.model.User; @@ -37,12 +39,19 @@ public Response createUser( if (isBlank(createRequest.getUserName())) { logger.warn("Cannot create user: Missing userName"); - return Response.status(Response.Status.BAD_REQUEST).entity("Missing userName").build(); + return ScimErrors.badRequest("Missing userName"); } UserModel existing = session.users().getUserByUsername(realm, createRequest.getUserName()); if (existing != null) { - return Response.status(Response.Status.CONFLICT).entity("User already exists").build(); + return ScimErrors.conflict(String.format("User already exists with username: %s", createRequest.getUserName())); + } + + String requestedEmail = createRequest.getEmails() != null && !createRequest.getEmails().isEmpty() + ? createRequest.getEmails().getFirst().getValue() + : null; + if (requestedEmail != null && !realm.isDuplicateEmailsAllowed() && session.users().getUserByEmail(realm, requestedEmail) != null) { + return ScimErrors.conflict(String.format("User already exists with email: %s", requestedEmail)); } UserAttributes userAttributes = metadataController.getUserAttributes(scimContext); @@ -68,19 +77,19 @@ public Response updateUser(RealmScimContext scimContext, String userId, fi.metat if (isBlank(updateRequest.getUserName())) { logger.warn("Missing userName"); - return Response.status(Response.Status.BAD_REQUEST).entity("Missing userName").build(); + return ScimErrors.badRequest("Missing userName"); } if (emailAsUsername && !isValidEmail(updateRequest.getUserName())) { logger.warn("Cannot update user: Invalid email format for userName"); - return Response.status(Response.Status.BAD_REQUEST).entity("Invalid email format for userName").build(); + return ScimErrors.badRequest("Invalid email format for userName"); } if (emailAsUsername && updateRequest.getEmails() != null) { for (fi.metatavu.keycloak.scim.server.model.UserEmailsInner email : updateRequest.getEmails()) { if (!Objects.equals(email.getValue(), updateRequest.getUserName())) { logger.warn("Conflicting email and userName when emailAsUsername is enabled"); - return Response.status(Response.Status.BAD_REQUEST).entity("Username and email must match when emailAsUsername is enabled").build(); + return ScimErrors.badRequest("Username and email must match when emailAsUsername is enabled"); } } } @@ -89,7 +98,7 @@ public Response updateUser(RealmScimContext scimContext, String userId, fi.metat UserModel user = session.users().getUserById(realm, userId); if (user == null) { logger.warn(String.format("User not found: %s", userId)); - return Response.status(Response.Status.NOT_FOUND).entity("User not found").build(); + return ScimErrors.notFound("User not found"); } // Check if username is being changed to an already existing one @@ -102,7 +111,7 @@ public Response updateUser(RealmScimContext scimContext, String userId, fi.metat if (existing != null && !existing.getId().equals(userId)) { logger.warn(String.format("User name already taken: %s", updateRequest.getUserName())); - return Response.status(Response.Status.CONFLICT).entity("User name already taken").build(); + return ScimErrors.conflict("User name already taken"); } UserAttributes userAttributes = metadataController.getUserAttributes(scimContext); @@ -119,7 +128,7 @@ public Response patchUser(RealmScimContext scimContext, String userId, fi.metata UserModel existing = session.users().getUserById(realm, userId); if (existing == null) { logger.warn(String.format("User not found: %s", userId)); - return Response.status(Response.Status.NOT_FOUND).entity("User not found").build(); + return ScimErrors.notFound("User not found"); } UserAttributes userAttributes = metadataController.getUserAttributes(scimContext); @@ -128,7 +137,7 @@ public Response patchUser(RealmScimContext scimContext, String userId, fi.metata fi.metatavu.keycloak.scim.server.model.User result = usersController.patchUser(scimContext, userAttributes, existing, patchRequest); return Response.ok(result).build(); } catch (UnsupportedPatchOperation e) { - return Response.status(Response.Status.BAD_REQUEST).entity("Unsupported patch operation").build(); + return ScimErrors.badRequest("Unsupported patch operation"); } } @@ -173,7 +182,7 @@ public Response deleteUser(RealmScimContext scimContext, String userId) { RoleModel scimManagedRole = realm.getRole("scim-managed"); if (scimManagedRole != null && !user.hasRole(scimManagedRole)) { logger.warn(String.format("User is not SCIM-managed: %s", userId)); - return Response.status(Response.Status.FORBIDDEN).entity("User is not managed by SCIM").build(); + return ScimErrors.forbidden("User is not managed by SCIM"); } usersController.deleteUser(scimContext, user); @@ -187,7 +196,7 @@ public Response createGroup(RealmScimContext scimContext, fi.metatavu.keycloak.s if (isBlank(createRequest.getDisplayName())) { logger.warn("Cannot create group: Missing displayName"); - return Response.status(Response.Status.BAD_REQUEST).entity("Missing displayName").build(); + return ScimErrors.badRequest("Missing displayName"); } fi.metatavu.keycloak.scim.server.model.Group created = groupsController.createGroup(scimContext, createRequest); @@ -225,7 +234,7 @@ public Response updateGroup(RealmScimContext scimContext, String id, fi.metatavu } if (!id.equals(existing.getId())) { - return Response.status(Response.Status.BAD_REQUEST).entity("Group ID mismatch").build(); + return ScimErrors.badRequest("Group ID mismatch"); } fi.metatavu.keycloak.scim.server.model.Group updated = groupsController.updateGroup( @@ -247,16 +256,18 @@ public Response patchGroup(RealmScimContext scimContext, String groupId, fi.meta } if (!groupId.equals(existing.getId())) { - return Response.status(Response.Status.BAD_REQUEST).entity("Group ID mismatch").build(); + return ScimErrors.badRequest("Group ID mismatch"); } try { fi.metatavu.keycloak.scim.server.model.Group updated = groupsController.patchGroup(scimContext, existing, patchRequest); return Response.ok(updated).build(); + } catch (InvalidGroupMemberReference e) { + return ScimErrors.badRequest(e.getMessage()); } catch (UnsupportedGroupPath e) { - return Response.status(Response.Status.BAD_REQUEST).entity("Unsupported group path").build(); + return ScimErrors.badRequest(e.getMessage() != null ? e.getMessage() : "Unsupported group path"); } catch (UnsupportedPatchOperation e) { - return Response.status(Response.Status.BAD_REQUEST).entity("Unsupported patch operation").build(); + return ScimErrors.badRequest("Unsupported patch operation"); } } diff --git a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java index 24c7311..3a8f67e 100644 --- a/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java +++ b/src/main/java/fi/metatavu/keycloak/scim/server/users/UsersController.java @@ -232,8 +232,12 @@ public fi.metatavu.keycloak.scim.server.model.User updateUser( ((BooleanUserAttribute) userAttributes.findByScimPath("active")).write(existing, scimUser.getActive() == null || Boolean.TRUE.equals(scimUser.getActive())); if (scimUser.getName() != null) { - ((StringUserAttribute) userAttributes.findByScimPath("name.givenName")).write(existing, scimUser.getName().getGivenName()); - ((StringUserAttribute) userAttributes.findByScimPath("name.familyName")).write(existing, scimUser.getName().getFamilyName()); + if (scimUser.getName().getGivenName() != null) { + ((StringUserAttribute) userAttributes.findByScimPath("name.givenName")).write(existing, scimUser.getName().getGivenName()); + } + if (scimUser.getName().getFamilyName() != null) { + ((StringUserAttribute) userAttributes.findByScimPath("name.familyName")).write(existing, scimUser.getName().getFamilyName()); + } } if (scimUser.getEmails() != null && !scimUser.getEmails().isEmpty()) { @@ -295,44 +299,7 @@ public fi.metatavu.keycloak.scim.server.model.User patchUser( UserModel existing, fi.metatavu.keycloak.scim.server.model.PatchRequest patchRequest ) throws UnsupportedPatchOperation { - for (var operation : patchRequest.getOperations()) { - PatchOperation op = PatchOperation.fromString(operation.getOp()); - if (op == null) { - logger.warn("Invalid patch operation: " + operation.getOp()); - throw new UnsupportedPatchOperation("Unsupported patch operation: " + operation.getOp()); - } - - UserAttribute userAttribute = userAttributes.findByScimPath(operation.getPath()); - Object value = operation.getValue(); - - if (userAttribute == null) { - throw new UnsupportedUserPath("Unsupported attribute: " + operation.getPath()); - } - - switch (op) { - case REPLACE, ADD -> { - switch (value) { - case null: - logger.warn("Value is null for patch operation: " + op); - break; - case String s when userAttribute instanceof StringUserAttribute: - ((StringUserAttribute) userAttribute).write(existing, s); - break; - case String s when userAttribute instanceof BooleanUserAttribute: - ((BooleanUserAttribute) userAttribute).write(existing, Boolean.parseBoolean(s)); - break; - case Boolean b when userAttribute instanceof BooleanUserAttribute: - ((BooleanUserAttribute) userAttribute).write(existing, b); - break; - default: - logger.warn("Unsupported value type for patch operation: " + value.getClass() + " for SCIM path " + userAttribute.getScimPath()); - break; - } - - } - case REMOVE -> userAttribute.write(existing, null); - } - } + applyPatchOperations(userAttributes, existing, patchRequest); dispatchUserUpdateEvent(scimContext, existing); @@ -351,6 +318,109 @@ public fi.metatavu.keycloak.scim.server.model.User patchUser( return patchedUser; } + /** + * Walk a PatchRequest's operations and apply each one to {@code existing}. + * Shared between {@link #patchUser} and + * {@link fi.metatavu.keycloak.scim.server.organization.OrganizationUserController#patchOrganizationUser} + * so the realm-scope and org-scope SCIM PATCH endpoints handle path-less / + * path-based shapes and read-only / structural attributes identically. + * + * @param userAttributes user attributes metadata + * @param existing user being patched + * @param patchRequest SCIM patch request + */ + protected void applyPatchOperations( + UserAttributes userAttributes, + UserModel existing, + fi.metatavu.keycloak.scim.server.model.PatchRequest patchRequest + ) throws UnsupportedPatchOperation { + for (var operation : patchRequest.getOperations()) { + PatchOperation op = PatchOperation.fromString(operation.getOp()); + if (op == null) { + logger.warn("Invalid patch operation: " + operation.getOp()); + throw new UnsupportedPatchOperation("Unsupported patch operation: " + operation.getOp()); + } + + String path = operation.getPath(); + Object value = operation.getValue(); + + // RFC 7644 §3.5.2: when "path" is omitted, "value" carries a map of + // attribute -> value to apply to the resource. Okta's Deactivate User + // emits this shape: {"op":"replace","value":{"active":false}}. + if (path == null) { + if (!(value instanceof Map valueMap)) { + throw new UnsupportedUserPath("PatchOp without 'path' requires a map-valued 'value'"); + } + for (Map.Entry entry : valueMap.entrySet()) { + String attrPath = String.valueOf(entry.getKey()); + if (isReadOnlyOrStructural(attrPath)) { + // RFC 7644 §3.5.2 / §7.5: ignore read-only and + // structural attributes (id, meta, schemas) + // on PATCH. Clients (Okta) echo them back from a prior GET. + continue; + } + UserAttribute ua = userAttributes.findByScimPath(attrPath); + if (ua == null) { + throw new UnsupportedUserPath("Unsupported attribute: " + attrPath); + } + applyPatchValue(op, ua, existing, entry.getValue()); + } + continue; + } + + if (isReadOnlyOrStructural(path)) { + continue; + } + + UserAttribute userAttribute = userAttributes.findByScimPath(path); + if (userAttribute == null) { + throw new UnsupportedUserPath("Unsupported attribute: " + path); + } + applyPatchValue(op, userAttribute, existing, value); + } + } + + /** + * Apply a single PATCH operation (REPLACE/ADD/REMOVE) against one + * resolved user attribute. Extracted so the path-less PatchOp shape + * (RFC 7644 §3.5.2, map-valued "value") and the with-path shape share + * the same write semantics. + * + * @param op patch operation kind + * @param attr resolved user attribute target + * @param existing user being patched + * @param value raw operation value + */ + protected void applyPatchValue( + PatchOperation op, + UserAttribute attr, + UserModel existing, + Object value + ) { + switch (op) { + case REPLACE, ADD -> { + switch (value) { + case null: + logger.warn("Value is null for patch operation: " + op); + break; + case String s when attr instanceof StringUserAttribute: + ((StringUserAttribute) attr).write(existing, s); + break; + case String s when attr instanceof BooleanUserAttribute: + ((BooleanUserAttribute) attr).write(existing, Boolean.parseBoolean(s)); + break; + case Boolean b when attr instanceof BooleanUserAttribute: + ((BooleanUserAttribute) attr).write(existing, b); + break; + default: + logger.warn("Unsupported value type for patch operation: " + value.getClass() + " for SCIM path " + attr.getScimPath()); + break; + } + } + case REMOVE -> attr.clear(existing); + } + } + /** * Dispatches user create event * @@ -522,7 +592,9 @@ protected fi.metatavu.keycloak.scim.server.model.User translateUser( .givenName(user.getFirstName()) ); - List> customAttributes = userAttributes.listBySource(UserAttribute.Source.USER_PROFILE); + List> customAttributes = new ArrayList<>(); + customAttributes.addAll(userAttributes.listBySource(UserAttribute.Source.USER_PROFILE)); + customAttributes.addAll(userAttributes.listBySource(UserAttribute.Source.IDP_MAPPER)); for (UserAttribute userAttribute : customAttributes) { Object value = userAttribute.read(user); if (value != null) { @@ -618,7 +690,7 @@ private void linkUserIdp( * @param email email address * @return email domain */ - protected String getEmailDomain(String email) { + public static String getEmailDomain(String email) { if (email != null && email.contains("@")) { return email.substring(email.indexOf('@') + 1); } diff --git a/src/main/resources/META-INF/services/fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProviderFactory b/src/main/resources/META-INF/services/fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProviderFactory new file mode 100644 index 0000000..df1d858 --- /dev/null +++ b/src/main/resources/META-INF/services/fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerProviderFactory @@ -0,0 +1 @@ +fi.metatavu.keycloak.scim.server.organization.keycloak.KeycloakOrganizationScimServerProviderFactory diff --git a/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/src/main/resources/META-INF/services/org.keycloak.provider.Spi new file mode 100644 index 0000000..d77eb55 --- /dev/null +++ b/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -0,0 +1 @@ +fi.metatavu.keycloak.scim.server.organization.OrganizationScimServerSpi diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/ScimClient.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/ScimClient.java index bc8711d..6504fc7 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/ScimClient.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/ScimClient.java @@ -8,6 +8,7 @@ import fi.metatavu.keycloak.scim.server.test.client.model.*; import java.net.URI; +import java.util.Base64; /** * SCIM client @@ -15,10 +16,10 @@ public class ScimClient { private final URI scimUri; - private final String accessToken; + private final String authorizationHeader; /** - * Constructor + * Constructor for Bearer token authentication * * @param scimUri SCIM URI * @param accessToken access token @@ -28,7 +29,23 @@ public ScimClient( String accessToken ) { this.scimUri = scimUri; - this.accessToken = accessToken; + this.authorizationHeader = "Bearer " + accessToken; + } + + /** + * Constructor for Basic authentication + * + * @param scimUri SCIM URI + * @param username username + * @param password password + */ + public ScimClient( + URI scimUri, + String username, + String password + ) { + this.scimUri = scimUri; + this.authorizationHeader = "Basic " + Base64.getEncoder().encodeToString((username + ":" + password).getBytes()); } /** @@ -246,7 +263,7 @@ private ApiClient getApiClient() { result.setHost(scimUri.getHost()); result.setScheme(scimUri.getScheme()); result.setPort(scimUri.getPort()); - result.setRequestInterceptor(builder -> builder.header("Authorization", "Bearer " + accessToken)); + result.setRequestInterceptor(builder -> builder.header("Authorization", authorizationHeader)); return result; } } \ No newline at end of file diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserCreateTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserCreateTestsIT.java index 961e85e..df58358 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserCreateTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserCreateTestsIT.java @@ -118,17 +118,53 @@ void testCreateDuplicateUserReturnsConflict() throws ApiException { User created = scimClient.createUser(user); assertNotNull(created); - // Second creation should fail with 409 Conflict + // Second creation should fail with 409 Conflict and identify the duplicated field ApiException exception = assertThrows(ApiException.class, () -> scimClient.createUser(user) ); assertEquals(409, exception.getCode()); + assertTrue(exception.getMessage().contains("username"), + "Expected conflict message to mention 'username'; got: " + exception.getMessage()); + assertTrue(exception.getMessage().contains("dupe-user"), + "Expected conflict message to include the offending username; got: " + exception.getMessage()); // Clean up deleteRealmUser(TestConsts.ORGANIZATIONS_REALM, created.getId()); } + @Test + void testCreateDuplicateEmailReturnsConflict() throws ApiException { + ScimClient scimClient = getAuthenticatedScimClient(TestConsts.ORGANIZATION_1_ID); + + User first = new User(); + first.setUserName("org-dupe-email-first"); + first.setActive(true); + first.setSchemas(List.of("urn:ietf:params:scim:schemas:core:2.0:User")); + first.setEmails(getEmails("org.dupe.email@example.com")); + + User created = scimClient.createUser(first); + assertNotNull(created); + + User second = new User(); + second.setUserName("org-dupe-email-second"); + second.setActive(true); + second.setSchemas(List.of("urn:ietf:params:scim:schemas:core:2.0:User")); + second.setEmails(getEmails("org.dupe.email@example.com")); + + ApiException exception = assertThrows(ApiException.class, () -> + scimClient.createUser(second) + ); + + assertEquals(409, exception.getCode()); + assertTrue(exception.getMessage().contains("email"), + "Expected conflict message to mention 'email'; got: " + exception.getMessage()); + assertTrue(exception.getMessage().contains("org.dupe.email@example.com"), + "Expected conflict message to include the offending email; got: " + exception.getMessage()); + + deleteRealmUser(TestConsts.ORGANIZATIONS_REALM, created.getId()); + } + @Test void testCreateEmailAsUsername() throws ApiException { ScimClient scimClient = getAuthenticatedScimClient(TestConsts.ORGANIZATION_EMAIL_AS_USERNAME_ID); diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserListTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserListTestsIT.java index 4c60e7e..d0890e5 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserListTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserListTestsIT.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.List; +import fi.metatavu.keycloak.scim.server.test.utils.ScimErrorAssertions; import static org.junit.jupiter.api.Assertions.*; /** @@ -228,7 +229,7 @@ void testInvalidFilterMissingOperator() { scimClient.listUsers("userName \"bob\"", 0, 10) ); - assertEquals("listUsers call failed with: 400 - Invalid filter", exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } @Test @@ -239,7 +240,7 @@ void testInvalidFilterUnsupportedOperator() { scimClient.listUsers("userName gt \"bob\"", 0, 10) ); - assertEquals("listUsers call failed with: 400 - Invalid filter", exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } @Test @@ -250,7 +251,7 @@ void testInvalidFilterUnquotedString() { scimClient.listUsers("userName eq bob", 0, 10) ); - assertEquals("listUsers call failed with: 400 - Invalid filter", exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } @Test @@ -261,7 +262,7 @@ void testInvalidFilterBadLogicalStructure() { scimClient.listUsers("userName eq \"a\" and", 0, 10) ); - assertEquals("listUsers call failed with: 400 - Invalid filter", exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } @Test diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserPatchTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserPatchTestsIT.java index 9a79662..547dd5c 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserPatchTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/OrganizationUserPatchTestsIT.java @@ -207,6 +207,37 @@ void testPatchAttributes() throws ApiException { deleteRealmUser(TestConsts.ORGANIZATIONS_REALM, created.getId()); } + /** + * Regression: SCIM REMOVE on a USER_PROFILE-backed attribute in org-scope previously threw NPE + * because applyOrgPatchValue called attr.write(user, null) -> List.of(null). After the fix both + * realm and org controllers share applyPatchValue which routes REMOVE through attr.clear(user). + */ + @Test + void testRemoveExternalIdDoesNotNpe() throws ApiException { + ScimClient scimClient = getAuthenticatedScimClient(TestConsts.ORGANIZATION_1_ID); + + User created = new User(); + created.setUserName("org-remove-extid-test"); + created.setActive(true); + created.putAdditionalProperty("externalId", "00uORGREMOVETEST"); + User u = scimClient.createUser(created); + + try { + PatchRequest patch = new PatchRequest(); + patch.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner op = new PatchRequestOperationsInner(); + op.setOp("remove"); + op.setPath("externalId"); + patch.setOperations(List.of(op)); + + // Before this fix: applyOrgPatchValue called attr.write(user, null) -> NPE -> HTTP 500. + User after = scimClient.patchUser(u.getId(), patch); + assertNull(after.getAdditionalProperty("externalId")); + } finally { + deleteRealmUser(TestConsts.ORGANIZATIONS_REALM, u.getId()); + } + } + @Test void testPatchUsernameEmailAsUsername() throws ApiException { ScimClient scimClient = getAuthenticatedScimClient(TestConsts.ORGANIZATION_EMAIL_AS_USERNAME_ID); diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmBasicAuthTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmBasicAuthTestsIT.java new file mode 100644 index 0000000..a96faa4 --- /dev/null +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmBasicAuthTestsIT.java @@ -0,0 +1,56 @@ +package fi.metatavu.keycloak.scim.server.test.tests.functional; + +import dasniko.testcontainers.keycloak.KeycloakContainer; +import fi.metatavu.keycloak.scim.server.test.ScimClient; +import fi.metatavu.keycloak.scim.server.test.client.ApiException; +import fi.metatavu.keycloak.scim.server.test.tests.AbstractRealmScimTest; +import fi.metatavu.keycloak.scim.server.test.utils.KeycloakTestUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +@Testcontainers +public class RealmBasicAuthTestsIT extends AbstractRealmScimTest { + + @Container + protected static final KeycloakContainer keycloakContainer = KeycloakTestUtils.createBasicAuthRealmKeycloakContainer(network); + + @Override + protected KeycloakContainer getKeycloakContainer() { + return keycloakContainer; + } + + @AfterAll + static void tearDown() { + KeycloakTestUtils.stopKeycloakContainer(keycloakContainer); + } + + @Test + void testGetResourceTypesWithBasicAuth() throws ApiException { + ScimClient scimClient = new ScimClient(getScimUri(), "scim-admin", "tutu"); + scimClient.getResourceTypes(); + } + + @Test + void testErrorGetResourceTypesWithWrongPassword() { + assertThrows( + ApiException.class, () -> { + ScimClient scimClient = new ScimClient(getScimUri(), "scim-admin", "wrong-password"); + scimClient.getResourceTypes(); + } + ); + } + + @Test + void testErrorGetResourceTypesWithWrongUsername() { + assertThrows( + ApiException.class, () -> { + ScimClient scimClient = new ScimClient(getScimUri(), "wrong-user", "tutu"); + scimClient.getResourceTypes(); + } + ); + } +} diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupListTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupListTestsIT.java index c7f72b2..2641535 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupListTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupListTestsIT.java @@ -13,6 +13,7 @@ import java.util.Collections; import java.util.List; +import fi.metatavu.keycloak.scim.server.test.utils.ScimErrorAssertions; import static org.junit.jupiter.api.Assertions.*; /** @@ -200,7 +201,7 @@ void testInvalidFilterMissingOperator() throws ApiException { scimClient.listGroups("displayName \"test\"", 0, 10) ); - assertEquals("listGroups call failed with: 400 - Invalid filter", exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } finally { deleteGroup(scimClient, group.getId()); } @@ -218,7 +219,7 @@ void testInvalidFilterUnquotedString() throws ApiException { scimClient.listGroups("displayName eq test", 0, 10) ); - assertEquals("listGroups call failed with: 400 - Invalid filter", exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } finally { deleteGroup(scimClient, group.getId()); } @@ -236,7 +237,7 @@ void testInvalidFilterBadLogicalStructure() throws ApiException { scimClient.listGroups("displayName eq \"test\" and", 0, 10) ); - assertEquals("listGroups call failed with: 400 - Invalid filter", exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } finally { deleteGroup(scimClient, group.getId()); } diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java index 671533e..94a76f4 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmGroupPatchTestsIT.java @@ -9,11 +9,13 @@ import org.keycloak.events.admin.AdminEvent; import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.ResourceType; +import org.keycloak.representations.idm.UserRepresentation; import org.testcontainers.junit.jupiter.Testcontainers; import java.io.IOException; import java.util.Collections; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -208,4 +210,348 @@ void testRemoveGroupMemberAdminEvents() throws ApiException, IOException { deleteRealmUser(TestConsts.TEST_REALM, user.getId()); deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); } + + /** + * An ADD members operation that includes one valid and one unknown member ID + * must be rejected atomically: HTTP 400 and the valid member must NOT sneak + * into the group. Complements the REPLACE / REMOVE atomicity tests below. + */ + @Test + void testAddMembersRejectsUnknownIdWithoutMutation() throws ApiException, IOException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User seeded = createUser(scimClient, "atomic-add-seeded", "Atomic", "Seeded"); + User candidate = createUser(scimClient, "atomic-add-candidate", "Atomic", "Candidate"); + Group group = createGroup(scimClient, "atomic-add-group"); + + try { + // Seed with a known member so we can verify the group is left exactly + // as it was (and the candidate did not sneak in). + seedGroupWithMember(scimClient, group, seeded); + + // ADD [candidate, unknown] -- must fail atomically with HTTP 400. + GroupMembersInner candidateRef = new GroupMembersInner(); + candidateRef.setValue(candidate.getId()); + GroupMembersInner unknown = new GroupMembersInner(); + unknown.setValue("22222222-2222-2222-2222-222222222222"); + PatchRequest add = new PatchRequest(); + add.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner addOp = new PatchRequestOperationsInner(); + addOp.setOp("add"); + addOp.setPath("members"); + addOp.setValue(List.of(candidateRef, unknown)); + add.setOperations(List.of(addOp)); + + ApiException ex = assertThrows(ApiException.class, + () -> scimClient.patchGroup(group.getId(), add)); + assertEquals(400, ex.getCode()); + + com.fasterxml.jackson.databind.JsonNode body = + new com.fasterxml.jackson.databind.ObjectMapper().readTree(ex.getResponseBody()); + assertEquals("400", body.get("status").asText()); + assertTrue(body.get("detail").asText().contains("22222222-2222-2222-2222-222222222222"), + "detail should name the unknown member id; got: " + body.get("detail").asText()); + + // Group must be unchanged: only the seeded member, candidate did NOT sneak in. + assertGroupHasOnlyMember(scimClient, group, seeded); + } finally { + deleteRealmUser(TestConsts.TEST_REALM, candidate.getId()); + deleteRealmUser(TestConsts.TEST_REALM, seeded.getId()); + deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); + } + } + + /** + * A REPLACE operation that includes one valid and one unknown member ID must + * be rejected atomically: HTTP 400 with an error body naming the bad ID, and + * the group's original membership must be unchanged. + */ + @Test + void testReplaceMembersRejectsUnknownIdWithoutMutation() throws ApiException, IOException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User user = createUser(scimClient, "atomic-1", "Atomic", "One"); + Group group = createGroup(scimClient, "atomic-group"); + + try { + // Seed with a known member + seedGroupWithMember(scimClient, group, user); + + // REPLACE with [known, unknown] -- must fail atomically with HTTP 400. + GroupMembersInner known = new GroupMembersInner(); + known.setValue(user.getId()); + GroupMembersInner unknown = new GroupMembersInner(); + unknown.setValue("00000000-0000-0000-0000-000000000000"); + PatchRequest replace = new PatchRequest(); + replace.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner replaceOp = new PatchRequestOperationsInner(); + replaceOp.setOp("replace"); + replaceOp.setPath("members"); + replaceOp.setValue(List.of(known, unknown)); + replace.setOperations(List.of(replaceOp)); + + ApiException ex = assertThrows(ApiException.class, + () -> scimClient.patchGroup(group.getId(), replace)); + assertEquals(400, ex.getCode()); + + com.fasterxml.jackson.databind.JsonNode body = + new com.fasterxml.jackson.databind.ObjectMapper().readTree(ex.getResponseBody()); + assertEquals("400", body.get("status").asText()); + assertTrue(body.get("detail").asText().contains("00000000-0000-0000-0000-000000000000"), + "detail should name the unknown member id; got: " + body.get("detail").asText()); + + // Group state must be unchanged: original known member still present. + assertGroupHasOnlyMember(scimClient, group, user); + } finally { + deleteRealmUser(TestConsts.TEST_REALM, user.getId()); + deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); + } + } + + /** + * Same atomicity guarantee for the path-less PatchOp shape (Okta Group Push): + * {"op":"replace","value":{"members":[...]}}. An unknown member ID must yield + * HTTP 400 without mutating the group. + */ + @Test + void testReplaceMembersPathLessRejectsUnknownIdWithoutMutation() throws ApiException, IOException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User user = createUser(scimClient, "atomic-pathless-1", "Atomic", "Pathless"); + Group group = createGroup(scimClient, "atomic-pathless-group"); + + try { + // Seed with a known member via path-based ADD + seedGroupWithMember(scimClient, group, user); + + // Path-less REPLACE with one known + one unknown member. + // Shape: {"op":"replace","value":{"members":[{"value":""}, {"value":""}]}} + Map knownMap = Map.of("value", user.getId()); + Map unknownMap = Map.of("value", "00000000-0000-0000-0000-000000000000"); + PatchRequest replace = new PatchRequest(); + replace.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner replaceOp = new PatchRequestOperationsInner(); + replaceOp.setOp("replace"); + // No path -- value is a map of attribute -> list, Okta Group Push shape + replaceOp.setValue(Map.of("members", List.of(knownMap, unknownMap))); + replace.setOperations(List.of(replaceOp)); + + ApiException ex = assertThrows(ApiException.class, + () -> scimClient.patchGroup(group.getId(), replace)); + assertEquals(400, ex.getCode()); + + com.fasterxml.jackson.databind.JsonNode body = + new com.fasterxml.jackson.databind.ObjectMapper().readTree(ex.getResponseBody()); + assertEquals("400", body.get("status").asText()); + assertTrue(body.get("detail").asText().contains("00000000-0000-0000-0000-000000000000"), + "detail should name the unknown member id; got: " + body.get("detail").asText()); + + // Group state must be unchanged: original known member still present. + assertGroupHasOnlyMember(scimClient, group, user); + } finally { + deleteRealmUser(TestConsts.TEST_REALM, user.getId()); + deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); + } + } + + @Test + void testRemoveUnknownMemberIsRejected() throws ApiException, java.io.IOException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User user = createUser(scimClient, "remove-unknown-1", "Remove", "Unknown"); + Group group = createGroup(scimClient, "remove-unknown-group"); + + try { + // Seed with the known member + seedGroupWithMember(scimClient, group, user); + + // REMOVE [unknown] should fail 400 atomically; the known member stays. + GroupMembersInner unknown = new GroupMembersInner(); + unknown.setValue("11111111-1111-1111-1111-111111111111"); + PatchRequest remove = new PatchRequest(); + remove.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner removeOp = new PatchRequestOperationsInner(); + removeOp.setOp("remove"); + removeOp.setPath("members"); + removeOp.setValue(List.of(unknown)); + remove.setOperations(List.of(removeOp)); + + ApiException ex = assertThrows(ApiException.class, + () -> scimClient.patchGroup(group.getId(), remove)); + assertEquals(400, ex.getCode()); + com.fasterxml.jackson.databind.JsonNode body = + new com.fasterxml.jackson.databind.ObjectMapper().readTree(ex.getResponseBody()); + assertTrue(body.get("detail").asText().contains("11111111-1111-1111-1111-111111111111"), + "detail should name the unknown member id; got: " + body.get("detail").asText()); + + // Known member untouched + assertGroupHasOnlyMember(scimClient, group, user); + } finally { + deleteRealmUser(TestConsts.TEST_REALM, user.getId()); + deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); + } + } + + /** + * REMOVE with an unquoted (malformed) filter value must return HTTP 400 + * with a SCIM error body, not silently succeed. + * Example malformed path: members[value eq abc] (no quotes around the id) + */ + @Test + void testRemoveMemberWithMalformedFilterReturns400() throws ApiException, IOException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User user = createUser(scimClient, "malformed-filter-user", "Malformed", "Filter"); + Group group = createGroup(scimClient, "malformed-filter-group"); + + try { + // Seed with a known member so the group is non-empty + seedGroupWithMember(scimClient, group, user); + + // REMOVE with unquoted filter value -- must be rejected with 400. + PatchRequest remove = new PatchRequest(); + remove.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner removeOp = new PatchRequestOperationsInner(); + removeOp.setOp("remove"); + // Intentionally malformed: value not quoted + removeOp.setPath("members[value eq " + user.getId() + "]"); + remove.setOperations(List.of(removeOp)); + + ApiException ex = assertThrows(ApiException.class, + () -> scimClient.patchGroup(group.getId(), remove)); + assertEquals(400, ex.getCode()); + + com.fasterxml.jackson.databind.JsonNode body = + new com.fasterxml.jackson.databind.ObjectMapper().readTree(ex.getResponseBody()); + assertEquals("400", body.get("status").asText()); + + // Group state must be unchanged: original member still present. + assertGroupHasOnlyMember(scimClient, group, user); + } finally { + deleteRealmUser(TestConsts.TEST_REALM, user.getId()); + deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); + } + } + + /** + * Okta Group Push wire shape: path-less PatchOp where members are nested under the value map. + * Example body: {"op":"replace","value":{"members":[{"value":""}]}} + */ + @Test + void testAddMemberPathLessPatchOp() throws ApiException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User user = createUser(scimClient, "pathless-add-1", "Pathless", "Add"); + Group group = createGroup(scimClient, "pathless-add-group"); + + try { + // Okta Group Push wire shape: no "path", members nested under the value map. + PatchRequest patchRequest = new PatchRequest(); + patchRequest.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + + PatchRequestOperationsInner op = new PatchRequestOperationsInner(); + // Okta's Group Push uses op=replace (not add) for the path-less wire shape, + // even when populating an empty group for the first time. The members list + // inside `value` is the new authoritative set. + op.setOp("replace"); + op.setValue(Map.of("members", List.of(Map.of("value", user.getId())))); + + patchRequest.setOperations(List.of(op)); + + Group patched = scimClient.patchGroup(group.getId(), patchRequest); + + assertNotNull(patched); + assertNotNull(patched.getMembers()); + assertEquals(1, patched.getMembers().size()); + assertEquals(user.getId(), patched.getMembers().get(0).getValue()); + + // Verify via a fresh GET as well. + Group after = scimClient.findGroup(group.getId()); + assertNotNull(after.getMembers()); + assertEquals(1, after.getMembers().size()); + assertEquals(user.getId(), after.getMembers().get(0).getValue()); + } finally { + deleteRealmUser(TestConsts.TEST_REALM, user.getId()); + deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); + } + } + + /** + * Regression: removing a group member whose email is null must not 500. + * Map.of() rejects null values, so the admin event dispatch previously + * NPE'd when the user had no email set. + */ + @Test + void testRemoveGroupMemberWithoutEmail() throws ApiException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User user = createUser(scimClient, "no-email-user", "No", "Email"); + Group group = createGroup(scimClient, "no-email-group"); + + UserRepresentation userRep = findRealmUser(TestConsts.TEST_REALM, user.getId()); + userRep.setEmail(null); + getKeycloakContainer().getKeycloakAdminClient() + .realms() + .realm(TestConsts.TEST_REALM) + .users() + .get(user.getId()) + .update(userRep); + + PatchRequest addRequest = new PatchRequest(); + addRequest.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner addOperation = new PatchRequestOperationsInner(); + addOperation.setOp("add"); + addOperation.setPath("members"); + GroupMembersInner member = new GroupMembersInner(); + member.setValue(user.getId()); + addOperation.setValue(Collections.singletonList(member)); + addRequest.setOperations(List.of(addOperation)); + scimClient.patchGroup(group.getId(), addRequest); + + PatchRequest removeRequest = new PatchRequest(); + removeRequest.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner removeOperation = new PatchRequestOperationsInner(); + removeOperation.setOp("remove"); + removeOperation.setPath("members[value eq \"" + user.getId() + "\"]"); + removeRequest.setOperations(List.of(removeOperation)); + + Group patched = scimClient.patchGroup(group.getId(), removeRequest); + + assertTrue(patched.getMembers() == null || patched.getMembers().isEmpty()); + + deleteRealmUser(TestConsts.TEST_REALM, user.getId()); + deleteRealmGroup(TestConsts.TEST_REALM, group.getId()); + } + + // --- helpers shared by the atomic-resolution tests --- + + /** + * Seed a group with a single member via a path-based ADD members PatchOp. + * Mirrors what an upstream IdP would push to establish initial membership + * before the test exercises a subsequent REPLACE/REMOVE op. + */ + private void seedGroupWithMember(ScimClient scimClient, Group group, User user) throws ApiException { + PatchRequest seed = new PatchRequest(); + seed.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner addOp = new PatchRequestOperationsInner(); + addOp.setOp("add"); + addOp.setPath("members"); + GroupMembersInner known = new GroupMembersInner(); + known.setValue(user.getId()); + addOp.setValue(List.of(known)); + seed.setOperations(List.of(addOp)); + scimClient.patchGroup(group.getId(), seed); + } + + /** + * Re-fetch the group and assert it has exactly one member, whose value + * matches the given user's id. Used to confirm membership is unchanged + * after a failed atomic PatchOp. + */ + private void assertGroupHasOnlyMember(ScimClient scimClient, Group group, User user) throws ApiException { + Group after = scimClient.findGroup(group.getId()); + assertNotNull(after.getMembers()); + assertEquals(1, after.getMembers().size()); + assertEquals(user.getId(), after.getMembers().get(0).getValue()); + } } diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmSchemasTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmSchemasTestsIT.java index 6347bb9..0d7a4d3 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmSchemasTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmSchemasTestsIT.java @@ -64,7 +64,7 @@ private void assertUserSchema(SchemaListItem schema) { assertEquals("User", schema.getName()); assertNotNull(schema.getDescription()); assertNotNull(schema.getAttributes()); - assertEquals(8, schema.getAttributes().size()); + assertEquals(9, schema.getAttributes().size()); assertUserAttribute(schema.getAttributes(), "userName", SchemaAttribute.TypeEnum.STRING); assertUserAttribute(schema.getAttributes(), "email", SchemaAttribute.TypeEnum.STRING); @@ -74,6 +74,7 @@ private void assertUserSchema(SchemaListItem schema) { assertUserAttribute(schema.getAttributes(), "displayName", SchemaAttribute.TypeEnum.STRING); assertUserAttribute(schema.getAttributes(), "externalId", SchemaAttribute.TypeEnum.STRING); assertUserAttribute(schema.getAttributes(), "preferredLanguage", SchemaAttribute.TypeEnum.STRING); + assertUserAttribute(schema.getAttributes(), "job", SchemaAttribute.TypeEnum.STRING); } /** diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java index 89b20ad..f1ca2bc 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserCreateTestsIT.java @@ -1,10 +1,10 @@ package fi.metatavu.keycloak.scim.server.test.tests.functional; -import fi.metatavu.keycloak.scim.server.test.tests.AbstractInternalAuthRealmScimTest; import fi.metatavu.keycloak.scim.server.test.ScimClient; import fi.metatavu.keycloak.scim.server.test.TestConsts; import fi.metatavu.keycloak.scim.server.test.client.ApiException; import fi.metatavu.keycloak.scim.server.test.client.model.User; +import fi.metatavu.keycloak.scim.server.test.tests.AbstractInternalAuthRealmScimTest; import org.junit.jupiter.api.Test; import org.keycloak.events.admin.AdminEvent; import org.keycloak.events.admin.OperationType; @@ -17,7 +17,12 @@ import java.io.IOException; import java.util.List; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; /** * Tests for SCIM 2.0 User create endpoint @@ -38,6 +43,7 @@ void testCreateUser() throws ApiException { user.putAdditionalProperty("externalId", "my-external-id"); user.putAdditionalProperty("preferredLanguage", "fi-FI"); user.putAdditionalProperty("displayName", "The New User"); + user.putAdditionalProperty("job", "farmer"); User created = scimClient.createUser(user); @@ -51,6 +57,7 @@ void testCreateUser() throws ApiException { "fi-FI", "The New User" ); + assertEquals("farmer", created.getAdditionalProperty("job")); // Assert that the user was created in Keycloak UserRepresentation realmUser = findRealmUser(TestConsts.TEST_REALM, created.getId()); @@ -63,6 +70,7 @@ void testCreateUser() throws ApiException { assertEquals("my-external-id", realmUser.getAttributes().get("externalId").getFirst()); assertEquals("fi-FI", realmUser.getAttributes().get("preferredLanguage").getFirst()); assertEquals("The New User", realmUser.getAttributes().get("displayName").getFirst()); + assertEquals("farmer", realmUser.getAttributes().get("job").getFirst()); // Assert that user has correct roles @@ -112,17 +120,53 @@ void testCreateDuplicateUserReturnsConflict() throws ApiException { User created = scimClient.createUser(user); assertNotNull(created); - // Second creation should fail with 409 Conflict + // Second creation should fail with 409 Conflict and identify the duplicated field ApiException exception = assertThrows(ApiException.class, () -> scimClient.createUser(user) ); assertEquals(409, exception.getCode()); + assertTrue(exception.getMessage().contains("username"), + "Expected conflict message to mention 'username'; got: " + exception.getMessage()); + assertTrue(exception.getMessage().contains("dupe-user"), + "Expected conflict message to include the offending username; got: " + exception.getMessage()); // Clean up deleteRealmUser(TestConsts.TEST_REALM, created.getId()); } + @Test + void testCreateDuplicateEmailReturnsConflict() throws ApiException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User first = new User(); + first.setUserName("dupe-email-first"); + first.setActive(true); + first.setSchemas(List.of("urn:ietf:params:scim:schemas:core:2.0:User")); + first.setEmails(getEmails("dupe.email@example.com")); + + User created = scimClient.createUser(first); + assertNotNull(created); + + User second = new User(); + second.setUserName("dupe-email-second"); + second.setActive(true); + second.setSchemas(List.of("urn:ietf:params:scim:schemas:core:2.0:User")); + second.setEmails(getEmails("dupe.email@example.com")); + + ApiException exception = assertThrows(ApiException.class, () -> + scimClient.createUser(second) + ); + + assertEquals(409, exception.getCode()); + assertTrue(exception.getMessage().contains("email"), + "Expected conflict message to mention 'email'; got: " + exception.getMessage()); + assertTrue(exception.getMessage().contains("dupe.email@example.com"), + "Expected conflict message to include the offending email; got: " + exception.getMessage()); + + deleteRealmUser(TestConsts.TEST_REALM, created.getId()); + } + @Test void testCreateUserAdminEvents() throws ApiException, IOException { ScimClient scimClient = getAuthenticatedScimClient(); diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserListTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserListTestsIT.java index 5f18358..acf5585 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserListTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserListTestsIT.java @@ -12,6 +12,7 @@ import java.util.ArrayList; import java.util.List; +import fi.metatavu.keycloak.scim.server.test.utils.ScimErrorAssertions; import static org.junit.jupiter.api.Assertions.*; /** @@ -211,7 +212,7 @@ void testInvalidFilterMissingOperator() { scimClient.listUsers("userName \"bob\"", 0, 10) ); - assertEquals("listUsers call failed with: 400 - Invalid filter", exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } @Test @@ -222,7 +223,7 @@ void testInvalidFilterUnsupportedOperator() { scimClient.listUsers("userName gt \"bob\"", 0, 10) ); - assertEquals("listUsers call failed with: 400 - Invalid filter", exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } @Test @@ -233,7 +234,7 @@ void testInvalidFilterUnquotedString() { scimClient.listUsers("userName eq bob", 0, 10) ); - assertEquals("listUsers call failed with: 400 - Invalid filter", exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } @Test @@ -244,7 +245,7 @@ void testInvalidFilterBadLogicalStructure() { scimClient.listUsers("userName eq \"a\" and", 0, 10) ); - assertEquals("listUsers call failed with: 400 - Invalid filter", exception.getMessage()); + ScimErrorAssertions.assertScimError(exception, 400, "Invalid filter"); } @Test diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java index a4d4b8f..cdc6866 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserPatchTestsIT.java @@ -1,12 +1,12 @@ package fi.metatavu.keycloak.scim.server.test.tests.functional; -import fi.metatavu.keycloak.scim.server.test.tests.AbstractInternalAuthRealmScimTest; import fi.metatavu.keycloak.scim.server.test.ScimClient; import fi.metatavu.keycloak.scim.server.test.TestConsts; import fi.metatavu.keycloak.scim.server.test.client.ApiException; import fi.metatavu.keycloak.scim.server.test.client.model.PatchRequest; import fi.metatavu.keycloak.scim.server.test.client.model.PatchRequestOperationsInner; import fi.metatavu.keycloak.scim.server.test.client.model.User; +import fi.metatavu.keycloak.scim.server.test.tests.AbstractInternalAuthRealmScimTest; import org.junit.jupiter.api.Test; import org.keycloak.events.admin.AdminEvent; import org.keycloak.events.admin.OperationType; @@ -15,8 +15,13 @@ import java.io.IOException; import java.util.List; +import java.util.Map; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Tests for SCIM 2.0 User create endpoint @@ -97,8 +102,9 @@ void testPatchAttributes() throws ApiException { assertNull(created.getAdditionalProperty("externalId")); assertNull(created.getAdditionalProperty("displayName")); assertNull(created.getAdditionalProperty("preferredLanguage")); + assertNull(created.getAdditionalProperty("job")); - // Patch externalId, displayName, preferredLanguage + // Patch externalId, displayName, preferredLanguage from user profile and job from user attribute mapper User patched = scimClient.patchUser(created.getId(), new PatchRequest() .schemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")) .operations(List.of( @@ -137,13 +143,26 @@ void testPatchAttributes() throws ApiException { new PatchRequestOperationsInner() .op("replace") .path("preferredLanguage") - .value("en_US") + .value("en_US"), + new PatchRequestOperationsInner() + .op("replace") + .path("job") + .value("pilot") )) ); assertEquals("external-5678", patchedAgain.getAdditionalProperty("externalId")); assertEquals("Updated Display", patchedAgain.getAdditionalProperty("displayName")); assertEquals("en_US", patchedAgain.getAdditionalProperty("preferredLanguage")); + assertEquals("pilot", patchedAgain.getAdditionalProperty("job")); + + // Also verify state in Keycloak + UserRepresentation realmUser = findRealmUser(TestConsts.TEST_REALM, created.getId()); + assertNotNull(realmUser); + assertEquals("external-5678", realmUser.getAttributes().get("externalId").getFirst()); + assertEquals("Updated Display", realmUser.getAttributes().get("displayName").getFirst()); + assertEquals("en_US", realmUser.getAttributes().get("preferredLanguage").getFirst()); + assertEquals("pilot", realmUser.getAttributes().get("job").getFirst()); // Cleanup deleteRealmUser(TestConsts.TEST_REALM, created.getId()); @@ -189,4 +208,88 @@ void testPatchUserAdminEvents() throws ApiException, IOException { deleteRealmUser(TestConsts.TEST_REALM, created.getId()); } + /** + * Regression: SCIM REMOVE on a USER_PROFILE-backed attribute (externalId, displayName, etc.) + * previously called attr.write(user, null) which passed null into List.of(value) and threw NPE, + * returning HTTP 500 instead of a clean removal. + */ + @Test + void testRemoveExternalIdDoesNotNpe() throws ApiException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User created = new User(); + created.setUserName("remove-extid-test"); + created.setActive(true); + created.putAdditionalProperty("externalId", "00uREMOVETEST"); + User u = scimClient.createUser(created); + + try { + PatchRequest patch = new PatchRequest(); + patch.setSchemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")); + PatchRequestOperationsInner op = new PatchRequestOperationsInner(); + op.setOp("remove"); + op.setPath("externalId"); + patch.setOperations(List.of(op)); + + // Before this fix: attr.write(user, null) -> List.of(null) -> NPE -> HTTP 500. + User after = scimClient.patchUser(u.getId(), patch); + assertNull(after.getAdditionalProperty("externalId")); + } finally { + deleteRealmUser(TestConsts.TEST_REALM, u.getId()); + } + } + + /** + * Okta's Deactivate User action emits a PATCH without a top-level "path", + * carrying the attribute change inside a map-valued "value" (RFC 7644 + * §3.5.2). This test covers that shape; the other tests only cover the + * with-path form. + */ + @Test + void testDeactivateUserPathLessPatchOp() throws ApiException { + ScimClient scimClient = getAuthenticatedScimClient(); + + // Create an active user + User user = new User(); + user.setUserName("patch-pathless-user"); + user.setActive(true); + user.setSchemas(List.of("urn:ietf:params:scim:schemas:core:2.0:User")); + + User created = scimClient.createUser(user); + assertNotNull(created); + assertTrue(created.getActive()); + + // Okta shape: no "path", value is a map {"active": false} + User deactivated = scimClient.patchUser(created.getId(), new PatchRequest() + .schemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")) + .operations(List.of( + new PatchRequestOperationsInner() + .op("replace") + .value(Map.of("active", Boolean.FALSE)) + ))); + + assertNotNull(deactivated); + assertNotNull(deactivated.getActive()); + assertFalse(deactivated.getActive()); + + UserRepresentation deactivatedRealmUser = findRealmUser(TestConsts.TEST_REALM, created.getId()); + assertNotNull(deactivatedRealmUser); + assertFalse(deactivatedRealmUser.isEnabled()); + + // Re-activate via the same shape to confirm the code path is symmetric + User activated = scimClient.patchUser(created.getId(), new PatchRequest() + .schemas(List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp")) + .operations(List.of( + new PatchRequestOperationsInner() + .op("replace") + .value(Map.of("active", Boolean.TRUE)) + ))); + + assertNotNull(activated); + assertTrue(activated.getActive()); + + // Cleanup + deleteRealmUser(TestConsts.TEST_REALM, created.getId()); + } + } \ No newline at end of file diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserUpdateTestsIT.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserUpdateTestsIT.java index 881f4f2..d2b34df 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserUpdateTestsIT.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/functional/RealmUserUpdateTestsIT.java @@ -1,10 +1,11 @@ package fi.metatavu.keycloak.scim.server.test.tests.functional; -import fi.metatavu.keycloak.scim.server.test.tests.AbstractInternalAuthRealmScimTest; import fi.metatavu.keycloak.scim.server.test.ScimClient; import fi.metatavu.keycloak.scim.server.test.TestConsts; import fi.metatavu.keycloak.scim.server.test.client.ApiException; import fi.metatavu.keycloak.scim.server.test.client.model.User; +import fi.metatavu.keycloak.scim.server.test.client.model.UserName; +import fi.metatavu.keycloak.scim.server.test.tests.AbstractInternalAuthRealmScimTest; import org.junit.jupiter.api.Test; import org.keycloak.events.admin.AdminEvent; import org.keycloak.events.admin.OperationType; @@ -14,7 +15,11 @@ import java.io.IOException; import java.util.List; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; /** * Tests for SCIM 2.0 User update (PUT) endpoint @@ -36,7 +41,8 @@ void testReplaceUser() throws ApiException { user.putAdditionalProperty("externalId", "replace-external-id"); user.putAdditionalProperty("preferredLanguage", "en_US"); user.putAdditionalProperty("displayName", "Replace User"); - + user.putAdditionalProperty("job", "farmer"); + User created = scimClient.createUser(user); assertNotNull(created); String userId = created.getId(); @@ -51,6 +57,7 @@ void testReplaceUser() throws ApiException { replacement.putAdditionalProperty("displayName", "Replaced User"); replacement.putAdditionalProperty("externalId", "replaced-external-id"); replacement.putAdditionalProperty("preferredLanguage", "fi_FI"); + replacement.putAdditionalProperty("job", "chef"); User updated = scimClient.updateUser(userId, replacement); @@ -66,6 +73,7 @@ void testReplaceUser() throws ApiException { assertEquals("Replaced User", updated.getAdditionalProperty("displayName")); assertEquals("replaced-external-id", updated.getAdditionalProperty("externalId")); assertEquals("fi_FI", updated.getAdditionalProperty("preferredLanguage")); + assertEquals("chef", updated.getAdditionalProperty("job")); assertFalse(updated.getActive()); // Also verify state in Keycloak @@ -78,6 +86,7 @@ void testReplaceUser() throws ApiException { assertEquals("Replaced User", realmUser.getAttributes().get("displayName").getFirst()); assertEquals("replaced-external-id", realmUser.getAttributes().get("externalId").getFirst()); assertEquals("fi_FI", realmUser.getAttributes().get("preferredLanguage").getFirst()); + assertEquals("chef", realmUser.getAttributes().get("job").getFirst()); assertFalse(realmUser.isEnabled()); // Clean up @@ -183,4 +192,46 @@ void testUpdateUserAdminEvents() throws ApiException, IOException { // Cleanup deleteRealmUser(TestConsts.TEST_REALM, created.getId()); } + + /** + * Regression: omitted "name.familyName" in a PUT body must retain the existing value. + * Previously the request unconditionally wrote null when the parent "name" object was + * present, clearing the existing family name. RFC 7644 §3.5.1 says omitted readWrite + * attributes MAY be assumed to be unchanged — applies to subattributes too. + */ + @Test + void testReplaceUserRetainsOmittedNameSubattributes() throws ApiException { + ScimClient scimClient = getAuthenticatedScimClient(); + + User user = new User(); + user.setUserName("omitted-name-sub"); + user.setActive(true); + user.setSchemas(List.of("urn:ietf:params:scim:schemas:core:2.0:User")); + user.setName(getName("Original", "Lastname")); + user.setEmails(getEmails("omitted.name.sub@example.com")); + + User created = scimClient.createUser(user); + String userId = created.getId(); + + // PUT with only givenName under name; familyName omitted + User replacement = new User(); + replacement.setUserName(user.getUserName()); + replacement.setActive(true); + replacement.setSchemas(List.of("urn:ietf:params:scim:schemas:core:2.0:User")); + UserName partialName = new UserName(); + partialName.setGivenName("NewGiven"); + replacement.setName(partialName); + + User updated = scimClient.updateUser(userId, replacement); + + assertNotNull(updated.getName()); + assertEquals("NewGiven", updated.getName().getGivenName()); + assertEquals("Lastname", updated.getName().getFamilyName(), "familyName must be retained when omitted in PUT"); + + UserRepresentation realmUser = findRealmUser(TestConsts.TEST_REALM, userId); + assertEquals("NewGiven", realmUser.getFirstName()); + assertEquals("Lastname", realmUser.getLastName()); + + deleteRealmUser(TestConsts.TEST_REALM, userId); + } } \ No newline at end of file diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/unit/ScimErrorsTest.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/unit/ScimErrorsTest.java new file mode 100644 index 0000000..0289546 --- /dev/null +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/tests/unit/ScimErrorsTest.java @@ -0,0 +1,40 @@ +package fi.metatavu.keycloak.scim.server.test.tests.unit; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import fi.metatavu.keycloak.scim.server.ScimErrors; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ScimErrorsTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void encodesPlainDetail() throws Exception { + Response r = ScimErrors.badRequest("Unsupported attribute: members.value"); + assertEquals(400, r.getStatus()); + assertEquals("application/scim+json", r.getMediaType().toString()); + JsonNode body = MAPPER.readTree((String) r.getEntity()); + assertTrue(body.get("schemas").toString().contains("urn:ietf:params:scim:api:messages:2.0:Error")); + assertEquals("400", body.get("status").asText()); + assertEquals("Unsupported attribute: members.value", body.get("detail").asText()); + } + + @Test + void encodesDetailWithControlChars() throws Exception { + // Reviewer point #2: a newline / tab in detail must not produce malformed JSON. + Response r = ScimErrors.badRequest("Unsupported attribute: weird\n\tpath\"with\\quotes"); + JsonNode body = MAPPER.readTree((String) r.getEntity()); + assertEquals("Unsupported attribute: weird\n\tpath\"with\\quotes", body.get("detail").asText()); + } + + @Test + void nullDetailIsEmptyString() throws Exception { + Response r = ScimErrors.notFound(null); + JsonNode body = MAPPER.readTree((String) r.getEntity()); + assertEquals("", body.get("detail").asText()); + } +} diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/KeycloakTestUtils.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/KeycloakTestUtils.java index 3313b03..ad2ae57 100644 --- a/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/KeycloakTestUtils.java +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/KeycloakTestUtils.java @@ -138,6 +138,24 @@ public static KeycloakContainer createExternalAuthSharedSecretRealmKeycloakConta .withLogConsumer(outputFrame -> System.out.printf("KEYCLOAK: %s", outputFrame.getUtf8String())); } + @SuppressWarnings("resource") + public static KeycloakContainer createBasicAuthRealmKeycloakContainer(Network network) { + return new KeycloakContainer(KeycloakTestUtils.getKeycloakImage()) + .withNetwork(network) + .withNetworkAliases("scim-keycloak") + .withEnv("SCIM_AUTHENTICATION_MODE", "EXTERNAL") + .withEnv("SCIM_BASIC_AUTH_USERNAME", "scim-admin") + .withEnv("SCIM_BASIC_AUTH_PASSWORD", "$argon2id$v=19$m=16,t=2,p=1$UUppcFAwQUp0SkQwVGZudQ$j5RwfEzt3Gvwpbqp0VDcJg") // tutu with argon2id + .withProviderLibsFrom(KeycloakTestUtils.getBuildProviders()) + .withRealmImportFile("kc-test.json") + .withEnv("JAVA_OPTS_APPEND", "-javaagent:/jacoco-agent/org.jacoco.agent-runtime.jar=destfile=/tmp/jacoco.exec") + .withCopyFileToContainer( + MountableFile.forHostPath(getJacocoAgentPath()), + "/jacoco-agent/org.jacoco.agent-runtime.jar" + ) + .withLogConsumer(outputFrame -> System.out.printf("KEYCLOAK: %s", outputFrame.getUtf8String())); + } + @SuppressWarnings("resource") public static KeycloakContainer createNoAuthRealmKeycloakContainer(Network network) { return new KeycloakContainer(KeycloakTestUtils.getKeycloakImage()) diff --git a/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/ScimErrorAssertions.java b/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/ScimErrorAssertions.java new file mode 100644 index 0000000..d656a35 --- /dev/null +++ b/src/test/java/fi/metatavu/keycloak/scim/server/test/utils/ScimErrorAssertions.java @@ -0,0 +1,37 @@ +package fi.metatavu.keycloak.scim.server.test.utils; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import fi.metatavu.keycloak.scim.server.test.client.ApiException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public final class ScimErrorAssertions { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"; + + private ScimErrorAssertions() {} + + /** + * Assert the ApiException carries a SCIM 2.0 Error JSON body with the + * given HTTP status and a detail field that contains {@code detailSubstring}. + */ + public static void assertScimError(ApiException ex, int expectedStatus, String detailSubstring) { + assertEquals(expectedStatus, ex.getCode(), + "HTTP status mismatch; body=" + ex.getResponseBody()); + JsonNode body; + try { + body = MAPPER.readTree(ex.getResponseBody()); + } catch (Exception e) { + throw new AssertionError("ApiException body is not valid JSON: " + ex.getResponseBody(), e); + } + assertTrue(body.path("schemas").toString().contains(ERROR_SCHEMA), + "schemas should contain Error URN; got: " + body.path("schemas")); + assertEquals(Integer.toString(expectedStatus), body.path("status").asText(), + "SCIM Error status field mismatch; body=" + body); + assertTrue(body.path("detail").asText().contains(detailSubstring), + "detail should contain '" + detailSubstring + "'; got: " + body.path("detail")); + } +} diff --git a/src/test/resources/kc-test.json b/src/test/resources/kc-test.json index 930c4c6..458e3e7 100644 --- a/src/test/resources/kc-test.json +++ b/src/test/resources/kc-test.json @@ -1415,7 +1415,19 @@ } } ], - "identityProviderMappers" : [ ], + "identityProviderMappers": [ + { + "id": "3a1e8b54-f29f-4b86-96f2-cfd9838a059e", + "name": "attribute_mapping-job", + "identityProviderAlias": "keycloak-oidc", + "identityProviderMapper": "oidc-user-attribute-idp-mapper", + "config": { + "syncMode": "INHERIT", + "claim": "job", + "user.attribute": "job" + } + } + ], "components" : { "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy" : [ { "id" : "c9fb719f-552e-4e14-967f-7061d20eb23c", @@ -1491,9 +1503,8 @@ "id" : "e539510c-f806-4ccf-a016-a193d7503803", "providerId" : "declarative-user-profile", "subComponents" : { }, - "config": { - "kc.user.profile.config": [ - "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"displayName\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"externalId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"preferredLanguage\",\"displayName\":\"${profile.attributes.preferredLanguage}\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[],\"edit\":[\"admin\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}],\"unmanagedAttributePolicy\":\"ENABLED\"}" ] + "config" : { + "kc.user.profile.config" : [ "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"displayName\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"externalId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"preferredLanguage\",\"displayName\":\"${profile.attributes.preferredLanguage}\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[],\"edit\":[\"admin\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}],\"unmanagedAttributePolicy\":\"ENABLED\"}"] } } ], "org.keycloak.keys.KeyProvider" : [ {