You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@dolphinscheduler.apache.org by zi...@apache.org on 2025/10/20 02:14:21 UTC
(dolphinscheduler) branch dev updated: [DSIP-88][Auth] Enhancing Apache DolphinScheduler with Generalized OIDC Authentication (#17340)
This is an automated email from the ASF dual-hosted git repository.
zihaoxiang pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/dolphinscheduler.git
The following commit(s) were added to refs/heads/dev by this push:
new f9a6c15177 [DSIP-88][Auth] Enhancing Apache DolphinScheduler with Generalized OIDC Authentication (#17340)
f9a6c15177 is described below
commit f9a6c15177340c614b04781a3a5d2c40b9d7a3ca
Author: Aryan Kumar <15...@users.noreply.github.com>
AuthorDate: Mon Oct 20 07:44:15 2025 +0530
[DSIP-88][Auth] Enhancing Apache DolphinScheduler with Generalized OIDC Authentication (#17340)
---
.github/workflows/api-test.yml | 2 +
.../en/contribute/development-environment-setup.md | 2 +-
docs/docs/en/guide/security/authentication-type.md | 212 +++-
.../guide/security/oidc-local-environment-setup.md | 111 +++
dolphinscheduler-api-test/README.md | 47 +
.../api/test/cases/OidcLoginAPITest.java | 369 +++++++
.../api/test/entity/HttpResponse.java | 5 +
.../api/test/pages/OidcLoginPage.java | 104 ++
.../api/test/utils/RequestClient.java | 36 +-
.../resources/docker/oidc-login/application.yaml | 241 +++++
.../docker/oidc-login/docker-compose.yaml | 71 ++
.../resources/docker/oidc-login/realm-export.json | 182 ++++
.../docker/oidc-login/wait-and-start-ds.sh | 47 +
.../api/configuration/ApiConfig.java | 4 +
.../api/configuration/AppConfiguration.java | 3 +-
.../api/controller/LoginController.java | 156 ++-
.../apache/dolphinscheduler/api/enums/Status.java | 6 +
.../api/security/AuthenticationType.java | 3 +-
.../api/security/SecurityConfig.java | 28 +-
.../api/security/impl/oidc/OidcAuthenticator.java | 403 ++++++++
.../security/impl/oidc/OidcConfigProperties.java | 25 +-
.../api/security/impl/oidc/OidcProviderConfig.java | 63 ++
.../api/security/impl/oidc/OidcUserConfig.java | 33 +-
.../dolphinscheduler/api/service/UsersService.java | 10 +-
.../api/service/impl/UsersServiceImpl.java | 16 +
.../src/main/resources/application.yaml | 29 +-
.../api/controller/LoginControllerOidcTest.java | 326 ++++++
.../api/controller/LoginControllerTest.java | 280 ++++++
.../api/security/SecurityConfigCasdoorTest.java | 51 +
.../api/security/SecurityConfigEdgeCasesTest.java | 75 ++
.../api/security/SecurityConfigOidcTest.java | 44 +
.../security/impl/oidc/OidcAuthenticatorTest.java | 1034 ++++++++++++++++++++
.../impl/oidc/OidcConfigPropertiesTest.java | 71 ++
.../security/impl/oidc/OidcProviderConfigTest.java | 104 ++
.../api/security/impl/oidc/OidcUserConfigTest.java | 95 ++
.../api/service/UsersServiceTest.java | 25 +
dolphinscheduler-ui/src/locales/en_US/login.ts | 2 +-
.../src/service/modules/login/index.ts | 7 +
.../src/service/modules/login/types.ts | 8 +-
.../src/views/login/index.module.scss | 39 +-
dolphinscheduler-ui/src/views/login/index.tsx | 27 +-
dolphinscheduler-ui/src/views/login/use-login.ts | 50 +-
42 files changed, 4387 insertions(+), 59 deletions(-)
diff --git a/.github/workflows/api-test.yml b/.github/workflows/api-test.yml
index 7251d7f73b..41944b744c 100644
--- a/.github/workflows/api-test.yml
+++ b/.github/workflows/api-test.yml
@@ -119,6 +119,8 @@ jobs:
class: org.apache.dolphinscheduler.api.test.cases.WorkflowInstanceAPITest
- name: LdapLoginAPITest
class: org.apache.dolphinscheduler.api.test.cases.LdapLoginAPITest
+ - name: OidcLoginAPITest
+ class: org.apache.dolphinscheduler.api.test.cases.OidcLoginAPITest
env:
RECORDING_PATH: /tmp/recording-${{ matrix.case.name }}
steps:
diff --git a/docs/docs/en/contribute/development-environment-setup.md b/docs/docs/en/contribute/development-environment-setup.md
index a988ee5244..bf39a248e6 100644
--- a/docs/docs/en/contribute/development-environment-setup.md
+++ b/docs/docs/en/contribute/development-environment-setup.md
@@ -27,7 +27,7 @@ Supporting system:
- MacOS
- Liunx
-Run `mvn clean install -Prelease -Dmaven.test.skip=true`
+Run `./mvnw clean install -Prelease -Dmaven.test.skip=true`
### Code Style
diff --git a/docs/docs/en/guide/security/authentication-type.md b/docs/docs/en/guide/security/authentication-type.md
index ae82f54976..15a2a92aa8 100644
--- a/docs/docs/en/guide/security/authentication-type.md
+++ b/docs/docs/en/guide/security/authentication-type.md
@@ -1,6 +1,6 @@
# Authentication Type
-* So far we support four authentication types, Apache DolphinScheduler password, LDAP, Casdoor SSO and OAuth2,the OAuth2 authorization login mode can be used with other authentication modes.
+* So far we support four authentication types, Apache DolphinScheduler password, LDAP, Casdoor SSO, OAuth2, and OIDC (OpenID Connect). The OAuth2 authorization login mode can be used with other authentication modes.
## Change Authentication Type
@@ -9,7 +9,7 @@
```yaml
security:
authentication:
- # Authentication types (supported types: PASSWORD,LDAP,CASDOOR_SSO)
+ # Authentication types (supported types: PASSWORD,LDAP,CASDOOR_SSO, OIDC)
type: LDAP
# IF you set type `LDAP`, below config will be effective
ldap:
@@ -141,7 +141,7 @@ Dolphinscheduler can support multiple OAuth2 providers.
```yaml
security:
authentication:
- …… # omit
+ # omit
oauth2:
# Set enable to true to enable oauth2 login mode
enable: true
@@ -189,3 +189,209 @@ security:
### Step.3 Login With OAuth2

+
+---
+
+## OIDC (OpenID Connect)
+
+The OIDC authentication method allows DolphinScheduler to integrate with a wide range of external identity providers, enabling centralized Single Sign-On (SSO). This is ideal for enterprise environments that need to connect with their internal user directories or third-party providers.
+
+This implementation is generalized and supports any OIDC-compliant provider, such as **Keycloak, Okta, Microsoft Entra ID (Azure AD), Google, DexIDP, Auth0, Feishu, and WeChat Work Login**.
+
+### Step 1. Enable OIDC in the API's Configuration File
+
+1. To enable OIDC, you first need to set the authentication `type` to `OIDC`, modify the following configuration in `dolphinscheduler-api/src/main/resources/application.yaml`:
+
+```yaml
+security:
+ authentication:
+ # Authentication types (supported types: PASSWORD, LDAP, CASDOOR_SSO, OIDC)
+ type: OIDC
+```
+
+2. Then, you must configure the OIDC providers you wish to use. The following is a complete example configuration using Keycloak, with a detailed explanation of each parameter.
+
+> **Note**: Before configuring OIDC, ensure you have set the public-facing URLs for the API and UI, as these are crucial for constructing the correct callback URLs for the OIDC provider.
+
+```yaml
+# dolphinscheduler-api/src/main/resources/application.yaml
+
+# These top-level URLs are essential for OIDC to function correctly.
+api:
+ # The public-facing base URL of the DolphinScheduler API server.
+ # This is used to build the `redirect_uri` for the OIDC provider.
+ # It must be reachable by the user's browser.
+ base-url: http://localhost:12345/dolphinscheduler
+ # The public-facing URL of the DolphinScheduler UI.
+ # Users will be redirected here after a successful login.
+ ui-url: http://localhost:5173
+
+security:
+ authentication:
+ # Set the primary authentication type to OIDC.
+ type: OIDC
+ oidc:
+ # Master switch to enable or disable the OIDC feature.
+ enable: true
+ # A map of OIDC provider configurations. The key (e.g., "keycloak") is the provider's unique registrationId,
+ # which is used in the login and callback URLs.
+ providers:
+ # --- Example for Keycloak ---
+ keycloak:
+ # The text displayed on the login button on the UI.
+ display-name: "Login with Keycloak"
+ # The URL of your OIDC provider's issuer. This is the core endpoint for OIDC discovery.
+ # For Keycloak, it typically ends with /realms/{your-realm-name}.
+ issuer-uri: http://localhost:8080/realms/dolphinscheduler
+ # The relative path to an icon for the login button. The image should be placed in the `dolphinscheduler-ui/public/images/providers-icon/` directory.
+ icon-uri: "/images/providers-icon/keycloak.png"
+ # The Client ID obtained from your OIDC provider after registering DolphinScheduler as a client.
+ client-id: dolphinscheduler-client
+ # The Client Secret obtained from your OIDC provider.
+ client-secret: dolphinscheduler-client-secret
+ # (Optional) The method used to authenticate with the token endpoint.
+ # Can be "client_secret_basic" (default) or "client_secret_post".
+ # client-authentication-method: client_secret_basic
+ # The OIDC scopes to request. "openid" is mandatory. "profile", "email", and "groups" are recommended
+ # to get user information and roles.
+ scope: openid, profile, email, groups
+ # The claim in the ID Token or UserInfo response to use as the DolphinScheduler username.
+ # "preferred_username" is common, but could also be "email", "sub", or a custom claim.
+ user-name-attribute: preferred_username
+ # (Optional) The claim that contains the user's group or role memberships.
+ # This is required for admin role mapping.
+ groups-claim: groups
+
+ # --- You can add more providers here ---
+ # okta:
+ # display-name: "Login with Okta"
+ # issuer-uri: [https://your-okta-domain.okta.com/oauth2/default](https://your-okta-domain.okta.com/oauth2/default)
+ # ...
+
+ # Settings for automatic user provisioning in DolphinScheduler upon first OIDC login.
+ user:
+ # If true, a new DolphinScheduler user will be created if one doesn't exist upon successful login.
+ # If false, only existing users can log in. Default is false.
+ auto-create: true
+ # The default tenant to assign to newly created users.
+ default-tenant-code: "default"
+ # The default queue to assign to newly created users.
+ default-queue: "default"
+ # A list of group names from the OIDC provider that will grant a user ADMIN privileges in DolphinScheduler.
+ # The user's groups are read from the claim specified in "groups-claim".
+ admin-group-mapping:
+ - dolphinscheduler-admins
+```
+
+> **Note on `issuer-uri`**: The correct value depends on your environment.
+> - If running DolphinScheduler **locally (e.g., in your IDE)** and Keycloak in Docker, use the host machine's address (e.g., `http://localhost:8080/...`).
+> - If both DolphinScheduler and Keycloak are running **within the same Docker network** (as in the provided ), you must use the Docker service name (e.g., `http://keycloak:8080/...`) for container-to-container communication. `docker-compose.yaml`
+>
+> **💡 Tip**: **Configuring with Environment Variables** - All properties in `application.yaml` can be configured using environment variables. This is particularly useful for containerized deployments. To convert a YAML path to an environment variable, use uppercase letters and replace dots (`.`) and hyphens (`-`) with underscores (`_`).
+> For example, `security.authentication.oidc.providers.keycloak.client-id` becomes `SECURITY_AUTHENTICATION_OIDC_PROVIDERS_KEYCLOAK_CLIENT_ID`.
+
+### Step 2. Configure Your OIDC Provider (Keycloak Example)
+
+You need to register DolphinScheduler as a client in your OIDC provider. Here’s how to do it with Keycloak:
+
+#### 2.1. Easy setup using Pre-configured Realm Export:
+
+1. Start Keycloak with the provided `docker-compose.yaml` that includes a pre-configured realm, Ensure docker is running in the background:
+
+ ```bash
+ cd dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/resources/docker/oidc-login/
+ docker-compose up -d keycloak
+ ```
+
+ This will start Keycloak on port `8081` and imports a realm with the necessary client, users, and groups.
+
+2. Access the Keycloak Admin Console at `http://localhost:8081` (Username: `admin`, Password: `admin`).
+
+3. Switch to the `dolphinscheduler` realm and verify the imported configuration.
+
+4. Update the **Valid redirect URIs** in the `dolphinscheduler-client` client to match your local setup:
+
+ * `http://localhost:12345/dolphinscheduler/login/oauth2/code/keycloak`
+5. Update **Web origins** to include `http://localhost:5173` to prevent CORS issues.
+6. Save your changes.
+7. Obtain the **Client ID** and **Client Secret** from the `dolphinscheduler-client` client in Keycloak.
+8. Use these values in your DolphinScheduler configuration as shown in Step 1.
+9. Once done with the testing/development, you can stop the Keycloak container using:
+
+ ```bash
+ docker-compose down
+ ```
+
+_**or,**_
+
+#### 2.2. Using Custom configuration:
+
+1. Spin up a Keycloak instance using docker (if you haven't already), Ensure docker is running in the background:
+
+ ```bash
+ docker run --rm -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:22.0.1 start-dev
+ ```
+
+ This starts Keycloak on port `8080` with an admin user (Username: `admin`, Password: `admin`).
+
+2. **Create a Realm**: If you don't have one, create a new realm (e.g., `dolphinscheduler`).
+
+3. **Create a Client**:
+
+ * Navigate to **Clients** and click **Create client**.
+ * Set the **Client ID** to match your configuration (e.g., `dolphinscheduler-client`).
+ * Ensure **Client authentication** is **On**.
+ * On the next screen, set **Valid redirect URIs**. This is critical and must match the URL constructed from your `api.base-url`:
+ * `http://{your-dolphinscheduler-host:port}/dolphinscheduler/login/oauth2/code/{registrationId}`
+ * For our example: `http://localhost:12345/dolphinscheduler/login/oauth2/code/keycloak`
+ * Set **Web origins** to allow the UI to communicate with Keycloak (e.g., `http://localhost:5173`).
+ * **Configure Client Scopes for Email and Profile**:
+ * In the Keycloak Admin Console, go to **Client scopes**.
+ * Ensure the built-in `email` and `profile` scopes are assigned to your client:
+ * Navigate to your client (**Clients** -> select your client -> **Client scopes** tab).
+ * Under **Assigned Default Client Scopes**, add `email` and `profile` if they are not already present.
+ * This ensures that the OIDC tokens include the user's email and profile information, which DolphinScheduler may require for user provisioning and display.
+4. **Get Credentials**:
+
+* Go to the **Credentials** tab for your new client and copy the **Client secret**.
+
+5. **Create a Reusable `groups` Scope (Recommended)**:
+ - In the left menu, navigate to **Client Scopes** and click **Create client scope**.
+ - Set the **Name** to `groups` and click **Save**.
+ - Go to the **Mappers** tab for your new `groups` scope.
+ - Click **Create mapper** and select **Group Membership** from the list.
+ - Give it a **Name** (e.g., "groups mapper").
+ - Set the **Token Claim Name** to `groups` (this must match the property in your ). `groups-claim``application.yaml`
+ - Ensure **Add to ID token** is enabled. Click **Save**.
+ - Finally, navigate back to your client (**Clients** -> -> **Client Scopes** tab). `dolphinscheduler-client`
+ - Add your new `groups` scope to the **Default Client Scopes**. This ensures the `groups` claim is included for all users of this client.
+6. **Create Groups and Users**:
+
+* Navigate to **Groups** and create a group with the name you specified in `admin-group-mapping` (e.g., `dolphinscheduler-admins`).
+ * Navigate to **Users**, create a new user, and assign them to this group.
+
+_**or,**_
+
+#### 2.3. If you have existing OIDC provider (Okta, Azure AD, Google, etc.)
+
+1. Follow the provider's documentation to register a new application/client.
+2. Set the redirect URI to `http://{your-dolphinscheduler-host:port}/dolphinscheduler/login/oauth2/code/{registrationId}`.
+3. Obtain the Client ID and Client Secret.
+4. Configure scopes to include `openid`, `profile`, `email`, and any `group/role` claims if needed.
+5. Ensure the user information endpoint provides the necessary claims for username and groups.
+6. Map any necessary roles or groups in your OIDC provider to match the `admin-group-mapping` in your DolphinScheduler configuration.
+7. Make sure to test the configuration by logging in with a user that belongs to the admin group.
+8. Refer to the provider-specific documentation for any additional settings required.
+9. Ensure that the OIDC provider's metadata endpoint (usually `/.well-known/openid-configuration`) is accessible from the DolphinScheduler API server.
+10. Adjust firewall or network settings if necessary to allow communication between DolphinScheduler and the OIDC provider.
+11. If using a self-signed certificate for HTTPS, ensure that the DolphinScheduler API server trusts the certificate.
+12. Regularly update the Client Secret and review the OIDC provider's security settings to maintain a secure integration.
+
+### Step 3. Login with OIDC
+
+After restarting the DolphinScheduler API server with the new configuration, the login page will now display a new button for each configured OIDC provider.
+
+Clicking the button will redirect you to your OIDC provider to authenticate. After a successful login, you will be redirected back to DolphinScheduler and automatically logged in.
+
+> **Note:** When a user clicks a provider's login button, they are first directed to a specific endpoint on the DolphinScheduler backend (e.g., `/dolphinscheduler/oauth2/authorization/keycloak`). The backend then constructs the full request and redirects the user's browser to the OIDC provider's login page.
+
diff --git a/docs/docs/en/guide/security/oidc-local-environment-setup.md b/docs/docs/en/guide/security/oidc-local-environment-setup.md
new file mode 100644
index 0000000000..30aa3230ae
--- /dev/null
+++ b/docs/docs/en/guide/security/oidc-local-environment-setup.md
@@ -0,0 +1,111 @@
+# OIDC Local Development Setup (with Keycloak)
+
+If you are developing or testing the OIDC authentication feature, you'll need a local OIDC provider. This guide explains how to set up **Keycloak** using Docker and configure it for DolphinScheduler development.
+
+## Prerequisites
+
+* You have [Docker](https://www.docker.com/products/docker-desktop/) installed and running.
+* You have already cloned the DolphinScheduler repository and can build the project.
+
+## Step 1: Start Keycloak with a Pre-configured Realm
+
+For convenience, we provide a pre-configured Keycloak realm export that sets up the necessary client, users, and groups.
+
+1. **Navigate to the API test resources directory** where the Keycloak configuration is located:
+
+```bash
+cd dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/resources/docker/oidc-login/
+```
+
+2. **Start Keycloak using Docker Compose**:
+ The provided `docker-compose.yaml` in this directory is configured to start Keycloak and import the realm automatically.
+
+```bash
+docker-compose up -d keycloak
+```
+
+This command starts a Keycloak container on port `8081` (to avoid conflicts with other services) and imports `realm-export.json`.
+
+## Step 2: Access and Verify Keycloak
+
+1. Open your browser and navigate to the **Keycloak Admin Console**: `http://localhost:8081`.
+2. Log in with username `admin` and password `admin`.
+3. In the top-left corner, switch from the `master` realm to the `dolphinscheduler` realm.
+4. You can explore **Clients** (`dolphinscheduler-client`), **Users** (`admin_user`, `general_user`), and **Groups** (`dolphinscheduler-admins`) to see the imported configuration.
+
+## Step 3: Configure DolphinScheduler API Server
+
+Modify your `dolphinscheduler-api/src/main/resources/application.yaml` to enable OIDC and connect to your local Keycloak instance.
+
+```yaml
+security:
+ authentication:
+ type: OIDC
+ oidc:
+ enable: true
+ providers:
+ keycloak:
+ display-name: "Login with Keycloak"
+ # Point to your local Keycloak realm
+ issuer-uri: http://localhost:8080/realms/dolphinscheduler
+ client-id: dolphinscheduler-client
+ client-secret: dolphinscheduler-client-secret
+ scope: openid, profile, email, groups
+ user-name-attribute: preferred_username
+ groups-claim: groups
+ user:
+ auto-create: true
+ default-tenant-code: "default"
+ default-queue: "default"
+ user-type: "ADMIN_USER"
+```
+
+> **Note**:
+> 1. Even though the Keycloak container's external port is `8081`, its internal issuer URL is still based on port `8080`. The configuration in `application.yaml` should use `http://localhost:8080` unless you have modified the issuer URL inside Keycloak itself.
+> 2. `scope: openid, profile, email, groups`
+> - `openid`: Mandatory for OIDC.
+> - `profile`: Often provides claims used for the username, like `preferred_username` or `name`.
+> - `email`: Provides the `email` claim.
+> - `groups`: A common (but sometimes custom) scope needed to retrieve the user's role/group memberships
+
+## Step 4: Start DolphinScheduler Services
+
+Start the backend services in your IDE as described in the "Normal Mode" guide, ensuring you start at least:
+* `MasterServer`
+* `ApiApplicationServer`
+
+or,
+
+* Start only the `StandaloneServer` (if you prefer standalone mode)
+
+## Step 5: Start Frontend Server
+
+Run the frontend development server:
+
+```bash
+cd dolphinscheduler-ui
+pnpm install
+pnpm run dev
+```
+
+You can now access the UI at `http://localhost:5173`, where you will see the "Login with Keycloak" button.
+
+## Developer Recommendations
+
+It is recommended to set up `pre-commit` before pushing your code to GitHub (see `docs/docs/en/contribute/development-environment-setup.md`). If you encounter issues with `pre-commit`, to avoid CI failures and maintain code quality, always perform the following checks manually:
+
+* **Backend Changes**: Run Spotless to format your Java code.
+
+```bash
+./mvnw spotless:apply
+```
+
+* **Frontend Changes**: Run the linter to format your TypeScript/Vue code.
+
+```bash
+cd dolphinscheduler-ui
+pnpm run lint
+```
+
+* **Security**: It's highly recommended to use a SonarQube plugin in your IDE to scan for potential security vulnerabilities early.
+
diff --git a/dolphinscheduler-api-test/README.md b/dolphinscheduler-api-test/README.md
index 1db1196841..0a8da9bd9b 100644
--- a/dolphinscheduler-api-test/README.md
+++ b/dolphinscheduler-api-test/README.md
@@ -69,3 +69,50 @@ Add VM options to the test configuration in IntelliJ IDEA:
Since it does not participate in project compilation, it is not in the main project.
- Running run test class `org.apache.dolphinscheduler.api.test.cases.TenantAPITest` in the IDE.
+---
+## Running the OIDC API Test
+
+The OIDC feature includes a dedicated API test suite that uses Testcontainers to spin up a full environment, including DolphinScheduler and a Keycloak OIDC provider.
+
+### Step 1: Build the Local Docker Image
+
+The API test requires a Docker image of DolphinScheduler that includes your latest code changes.
+
+1. **Build the image**: From the root of the project, run the following command. This creates a standalone server image tagged as `ci`.
+ ```bash
+ ./mvnw -B clean package -Dmaven.test.skip=true -Dspotless.skip=true -Ddocker.tag=ci -Pdocker,release
+ ```
+2. **Verify the image**: Ensure the image `apache/dolphinscheduler-standalone-server:ci` was created successfully.
+ ```bash
+ docker images | grep "dolphinscheduler-standalone-server"
+ ```
+
+### Step 2: Run the Test Environment with Docker Compose
+
+You can manually start the full test stack to inspect it before running the test cases.
+
+1. Navigate to the OIDC test resource directory:
+ ```bash
+ cd dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/resources/docker/oidc-login/
+ ```
+2. Start the services:
+ ```bash
+ docker-compose up --build
+ ```
+ * The `--build` flag is recommended to ensure the latest configurations are used.
+ * To completely reset the environment (including the database), first run `docker-compose down -v`.
+
+### Step 3: Run the API Test from your IDE
+
+1. Ensure the `dolphinscheduler-api-test` module is included in your Maven projects in your IDE.
+2. It's recommended to use **Java 11** for running the API tests.
+> **Notes**: Ensure that you run the required `RegistryTestCase` and `TenantAPITest` before running the OidcLoginAPITest, as they set up the necessary prerequisites.
+3. Navigate to the `OidcLoginAPITest.java` file and run it as a JUnit test. The test framework will automatically manage the Docker containers.
+> **Note**: Before running the API test using the IDE run button, ensure that port `8081` is free, as Keycloak will use this port. Also, make sure Docker Desktop is running. If you previously started the environment with `docker-compose up --build`, run `docker-compose down -v` to avoid port conflicts. The API test will automatically manage the required containers.
+
+### Customizing the API Test Environment
+
+You can easily customize the test environment for your own needs:
+
+* **To modify DolphinScheduler's configuration**: Edit the `environment` section for the `dolphinscheduler` service in `docker/oidc-login/docker-compose.yaml` to change OIDC settings or other parameters.
+* **To modify the OIDC provider's behavior**: Edit the `docker/oidc-login/realm-export.json` file. You can change client settings, add new test users, or modify group mappers to test different scenarios. After making changes, restart the environment with `docker-compose up --build` to apply them.
diff --git a/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/cases/OidcLoginAPITest.java b/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/cases/OidcLoginAPITest.java
new file mode 100644
index 0000000000..f6e9a4d6f7
--- /dev/null
+++ b/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/cases/OidcLoginAPITest.java
@@ -0,0 +1,369 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.dolphinscheduler.api.test.cases;
+
+import org.apache.dolphinscheduler.api.test.core.Constants;
+import org.apache.dolphinscheduler.api.test.core.DolphinScheduler;
+import org.apache.dolphinscheduler.api.test.entity.GetUserInfoResponseData;
+import org.apache.dolphinscheduler.api.test.entity.HttpResponse;
+import org.apache.dolphinscheduler.api.test.pages.OidcLoginPage;
+import org.apache.dolphinscheduler.api.test.pages.security.UserPage;
+import org.apache.dolphinscheduler.api.test.utils.JSONUtils;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.URLDecoder;
+import java.net.UnknownHostException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.Cookie;
+import okhttp3.CookieJar;
+import okhttp3.Dns;
+import okhttp3.FormBody;
+import okhttp3.HttpUrl;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+
+@DolphinScheduler(composeFiles = "docker/oidc-login/docker-compose.yaml")
+@Slf4j
+public class OidcLoginAPITest {
+
+ private static final String PROVIDER_ID = "keycloak";
+
+ private enum HtmlEntity {
+
+ AMP("&", "&"),
+ LT("<", "<"),
+ GT(">", ">"),
+ QUOT(""", "\""),
+ APOS("'", "'");
+
+ private final String encoded;
+ private final String decoded;
+
+ HtmlEntity(String encoded, String decoded) {
+ this.encoded = encoded;
+ this.decoded = decoded;
+ }
+ }
+
+ private enum Delimiter {
+
+ QUERY_START("?"),
+ PARAM_SEP("&"),
+ KEY_VALUE_SEP("=");
+
+ private final String value;
+
+ Delimiter(String value) {
+ this.value = value;
+ }
+
+ @Override
+ public String toString() {
+ return value;
+ }
+ }
+
+ @Test
+ @Order(1)
+ public void testGetOidcProviders() {
+ OidcLoginPage oidcLoginPage = new OidcLoginPage();
+ HttpResponse response = oidcLoginPage.getOidcProviders();
+ Assertions.assertEquals(200, response.getStatusCode());
+ List<Map<String, String>> providers = (List<Map<String, String>>) response.getBody().getData();
+ Assertions.assertNotNull(providers);
+ Assertions.assertFalse(providers.isEmpty());
+ Map<String, String> provider = providers.get(0);
+ Assertions.assertEquals(PROVIDER_ID, provider.get("id"));
+ Assertions.assertEquals("Login with Keycloak", provider.get("displayName"));
+ Assertions.assertTrue(provider.containsKey("iconUri"));
+ Assertions.assertTrue(provider.get("iconUri").endsWith("keycloak.png"));
+ }
+
+ @Test
+ @Order(2)
+ public void testInitiateOidcLogin_validProvider() {
+ OidcLoginPage oidcLoginPage = new OidcLoginPage();
+ HttpResponse response = oidcLoginPage.initiateOidcLogin(PROVIDER_ID);
+ Assertions.assertEquals(302, response.getStatusCode());
+ }
+
+ @Test
+ @Order(3)
+ public void testInitiateOidcLogin_invalidProvider() {
+ OidcLoginPage oidcLoginPage = new OidcLoginPage();
+ HttpResponse response = oidcLoginPage.initiateOidcLoginWithInvalidProvider("invalid-provider");
+ Assertions.assertEquals(302, response.getStatusCode());
+ }
+
+ @Test
+ @Order(4)
+ public void testOidcCallbackMissingCode() {
+ OidcLoginPage oidcLoginPage = new OidcLoginPage();
+ String state = PROVIDER_ID + ":" + UUID.randomUUID().toString();
+ HttpResponse response = oidcLoginPage.handleOidcCallbackMissingCode(PROVIDER_ID, state);
+ Assertions.assertEquals(302, response.getStatusCode());
+ }
+
+ @Test
+ @Order(5)
+ public void testOidcCallbackError() {
+ OidcLoginPage oidcLoginPage = new OidcLoginPage();
+ String state = PROVIDER_ID + ":" + UUID.randomUUID().toString();
+ HttpResponse response = oidcLoginPage.handleOidcCallbackError(PROVIDER_ID, "access_denied", state);
+ Assertions.assertEquals(302, response.getStatusCode());
+ }
+
+ @Test
+ @Order(6)
+ public void testOidcCallbackErrorWithSpecialChars() {
+ OidcLoginPage oidcLoginPage = new OidcLoginPage();
+ String state = PROVIDER_ID + ":" + UUID.randomUUID().toString();
+ HttpResponse response = oidcLoginPage.handleOidcCallbackError(PROVIDER_ID, "err\nor\t", state);
+ Assertions.assertEquals(302, response.getStatusCode());
+ }
+
+ @Test
+ @Order(7)
+ public void testOidcCallbackWithInvalidProviderInState() {
+ OidcLoginPage oidcLoginPage = new OidcLoginPage();
+ String state = "unknownprovider:" + UUID.randomUUID().toString();
+ String code = "dummy_code";
+ HttpResponse response = oidcLoginPage.handleOidcCallback("unknownprovider", code, state);
+ Assertions.assertEquals(302, response.getStatusCode());
+ }
+
+ @Test
+ @Order(8)
+ public void testLoginEndpointDisabledInOidcMode() {
+ OidcLoginPage oidcLoginPage = new OidcLoginPage();
+ HttpResponse response = oidcLoginPage.loginWithPassword("anyuser", "anypassword");
+ Assertions.assertEquals(200, response.getStatusCode());
+ Assertions.assertEquals(10013, response.getBody().getCode(), "Expected 'user name or password error'");
+ }
+
+ @Test
+ @Order(10)
+ public void testAdminUserLoginSuccess() throws Exception {
+ String sessionId = doOidcLogin("admin_user", "password");
+ Assertions.assertNotNull(sessionId);
+
+ UserPage userPage = new UserPage();
+ HttpResponse getUserInfoHttpResponse = userPage.getUserInfo(sessionId);
+ GetUserInfoResponseData userInfo =
+ JSONUtils.convertValue(getUserInfoHttpResponse.getBody().getData(), GetUserInfoResponseData.class);
+ Assertions.assertEquals("admin_user", userInfo.getUserName());
+ Assertions.assertEquals("ADMIN_USER", userInfo.getUserType().name());
+ }
+
+ @Test
+ @Order(20)
+ public void testGeneralUserLoginSuccess() throws Exception {
+ String sessionId = doOidcLogin("general_user", "password");
+ Assertions.assertNotNull(sessionId);
+
+ UserPage userPage = new UserPage();
+ HttpResponse getUserInfoHttpResponse = userPage.getUserInfo(sessionId);
+ GetUserInfoResponseData userInfo =
+ JSONUtils.convertValue(getUserInfoHttpResponse.getBody().getData(), GetUserInfoResponseData.class);
+ Assertions.assertEquals("general_user", userInfo.getUserName());
+ Assertions.assertEquals("GENERAL_USER", userInfo.getUserType().name());
+ }
+
+ @Test
+ @Order(30)
+ public void testLoginFailedWrongPassword() throws Exception {
+ String sessionId = doOidcLoginExpectingFailure("general_user", "wrong");
+ Assertions.assertNull(sessionId);
+ }
+
+ @SneakyThrows
+ private String doOidcLogin(String username, String password) {
+ OkHttpClient http = buildClientWithCookieJar();
+
+ String authorizeUrl = getAuthorizationRedirect(http);
+
+ String loginAction = fetchLoginFormAction(http, authorizeUrl);
+
+ String callbackUrl = submitCredentialsAndGetCallback(http, loginAction, username, password);
+
+ return callDsCallbackAndExtractSessionId(http, callbackUrl);
+ }
+
+ @SneakyThrows
+ private String doOidcLoginExpectingFailure(String username, String password) {
+ OkHttpClient http = buildClientWithCookieJar();
+ String authorizeUrl = getAuthorizationRedirect(http);
+ String loginAction = fetchLoginFormAction(http, authorizeUrl);
+ String callbackUrl = submitCredentialsAndGetCallback(http, loginAction, username, password);
+ if (callbackUrl == null) {
+ return null;
+ }
+ String sessionId = callDsCallbackAndExtractSessionId(http, callbackUrl);
+ return sessionId;
+ }
+
+ private OkHttpClient buildClientWithCookieJar() {
+ return new OkHttpClient.Builder()
+ .followRedirects(false)
+ .dns(hostname -> {
+ if ("keycloak".equalsIgnoreCase(hostname)) {
+ try {
+ return Collections.singletonList(InetAddress.getByName("127.0.0.1"));
+ } catch (UnknownHostException e) {
+ return Dns.SYSTEM.lookup(hostname);
+ }
+ }
+ return Dns.SYSTEM.lookup(hostname);
+ })
+ .cookieJar(new CookieJar() {
+
+ private final List<Cookie> cookieStore = new ArrayList<>();
+
+ @Override
+ public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
+ cookieStore.addAll(cookies);
+ }
+
+ @Override
+ public List<Cookie> loadForRequest(HttpUrl url) {
+ return cookieStore;
+ }
+ })
+ .build();
+ }
+
+ @SneakyThrows
+ private String getAuthorizationRedirect(OkHttpClient http) {
+ String kickOffUrl = Constants.DOLPHINSCHEDULER_API_URL + "/oauth2/authorization/" + PROVIDER_ID;
+ Request req = new Request.Builder().url(kickOffUrl).get().build();
+ try (Response resp = http.newCall(req).execute()) {
+ Assertions.assertEquals(302, resp.code(), "DS should redirect to OIDC provider");
+ String location = resp.header("Location");
+ log.info("Authorization redirect location: {}", location);
+ return location;
+ }
+ }
+
+ @SneakyThrows
+ private String fetchLoginFormAction(OkHttpClient http, String authorizationUrl) {
+ Request req = new Request.Builder().url(authorizationUrl).get().build();
+ try (Response resp = http.newCall(req).execute()) {
+ Assertions.assertEquals(200, resp.code(), "Should load Keycloak login page");
+ String html = resp.body() != null ? resp.body().string() : "";
+ String action = extractBetween(html, "action=\"", "\"");
+ if (action != null) {
+ action = htmlUnescape(action);
+ }
+ log.info("Keycloak login form action (decoded): {}", action);
+ Assertions.assertNotNull(action, "Cannot find Keycloak login form action");
+ return action.startsWith("http") ? action : "http://" + HttpUrl.parse(authorizationUrl).host() + action;
+ }
+ }
+
+ private String htmlUnescape(String input) {
+ if (input == null) {
+ return null;
+ }
+ String result = input;
+ for (HtmlEntity entity : HtmlEntity.values()) {
+ result = result.replace(entity.encoded, entity.decoded);
+ }
+ return result;
+ }
+
+ @SneakyThrows
+ private String submitCredentialsAndGetCallback(OkHttpClient http, String loginAction, String username,
+ String password) {
+ RequestBody form = new FormBody.Builder()
+ .add("username", username)
+ .add("password", password)
+ .add("credentialId", "")
+ .build();
+ Request req = new Request.Builder().url(loginAction).post(form).build();
+ try (Response resp = http.newCall(req).execute()) {
+ if (resp.code() != 302) {
+ log.warn("Login did not redirect, likely wrong password. Code={}", resp.code());
+ return null;
+ }
+ String loc = resp.header("Location");
+ log.info("Post-login redirect location: {}", loc);
+ return loc;
+ }
+ }
+
+ private String callDsCallbackAndExtractSessionId(OkHttpClient http, String callbackUrl) throws IOException {
+ if (callbackUrl == null) {
+ return null;
+ }
+ Request req = new Request.Builder().url(callbackUrl).get().build();
+ try (Response resp = http.newCall(req).execute()) {
+ if (resp.code() != 302) {
+ log.warn("DS callback did not redirect to UI. Code={}", resp.code());
+ return null;
+ }
+ String finalLocation = resp.header("Location");
+ log.info("Final redirect location: {}", finalLocation);
+ return parseQueryParam(finalLocation, "sessionId");
+ }
+ }
+
+ private String extractBetween(String text, String start, String end) {
+ int s = text.indexOf(start);
+ if (s < 0)
+ return null;
+ s += start.length();
+ int e = text.indexOf(end, s);
+ if (e < 0)
+ return null;
+ return text.substring(s, e);
+ }
+
+ private String parseQueryParam(String url, String name) {
+ final int KEY_VALUE_PAIR_LENGTH = 2;
+ final int KEY_INDEX = 0;
+ final int VALUE_INDEX = 1;
+
+ int qIndex = url.indexOf(Delimiter.QUERY_START.toString());
+ if (qIndex < 0) {
+ return null;
+ }
+ String query = url.substring(qIndex + 1);
+ for (String p : query.split(Delimiter.PARAM_SEP.toString())) {
+ String[] kv = p.split(Delimiter.KEY_VALUE_SEP.toString(), KEY_VALUE_PAIR_LENGTH);
+ if (kv.length == KEY_VALUE_PAIR_LENGTH && kv[KEY_INDEX].equals(name)) {
+ return URLDecoder.decode(kv[VALUE_INDEX], StandardCharsets.UTF_8);
+ }
+ }
+ return null;
+ }
+}
diff --git a/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/entity/HttpResponse.java b/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/entity/HttpResponse.java
index 6ee3d29ee5..6b652d81f6 100644
--- a/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/entity/HttpResponse.java
+++ b/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/entity/HttpResponse.java
@@ -17,6 +17,9 @@
package org.apache.dolphinscheduler.api.test.entity;
+import java.util.HashMap;
+import java.util.Map;
+
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -29,4 +32,6 @@ public class HttpResponse {
private int statusCode;
private HttpResponseBody body;
+
+ private Map<String, String> headers = new HashMap<>();
}
diff --git a/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/pages/OidcLoginPage.java b/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/pages/OidcLoginPage.java
new file mode 100644
index 0000000000..541d81838e
--- /dev/null
+++ b/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/pages/OidcLoginPage.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.dolphinscheduler.api.test.pages;
+
+import org.apache.dolphinscheduler.api.test.core.Constants;
+import org.apache.dolphinscheduler.api.test.entity.HttpResponse;
+import org.apache.dolphinscheduler.api.test.entity.HttpResponseBody;
+import org.apache.dolphinscheduler.api.test.utils.JSONUtils;
+import org.apache.dolphinscheduler.api.test.utils.RequestClient;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import lombok.SneakyThrows;
+import okhttp3.Headers;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public final class OidcLoginPage {
+
+ private final OkHttpClient client = new OkHttpClient.Builder()
+ .followRedirects(false)
+ .build();
+
+ @SneakyThrows
+ private HttpResponse get(String url, Map<String, Object> params) {
+ String requestUrl =
+ String.format("%s%s%s", Constants.DOLPHINSCHEDULER_API_URL, url, RequestClient.getParams(params));
+ Request request = new Request.Builder().url(requestUrl).get().build();
+
+ try (Response response = client.newCall(request).execute()) {
+ int responseCode = response.code();
+ Map<String, String> responseHeaders = new HashMap<>();
+ Headers responseHeadersObj = response.headers();
+ for (String name : responseHeadersObj.names()) {
+ responseHeaders.put(name, responseHeadersObj.get(name));
+ }
+
+ HttpResponseBody responseData = null;
+ if (response.body() != null) {
+ String contentType = response.header("Content-Type");
+ if (contentType != null && contentType.contains("application/json")) {
+ responseData = JSONUtils.parseObject(response.body().string(), HttpResponseBody.class);
+ }
+ }
+ return new HttpResponse(responseCode, responseData, responseHeaders);
+ }
+ }
+
+ public HttpResponse getOidcProviders() {
+ return new RequestClient().get("/oidc-providers", null, new HashMap<>());
+ }
+
+ public HttpResponse initiateOidcLogin(String providerId) {
+ return get("/oauth2/authorization/" + providerId, new HashMap<>());
+ }
+
+ public HttpResponse initiateOidcLoginWithInvalidProvider(String invalidProviderId) {
+ return get("/oauth2/authorization/" + invalidProviderId, new HashMap<>());
+ }
+
+ public HttpResponse handleOidcCallback(String providerId, String code, String state) {
+ Map<String, Object> params = new HashMap<>();
+ params.put("code", code);
+ params.put("state", state);
+ return get("/login/oauth2/code/" + providerId, params);
+ }
+
+ public HttpResponse handleOidcCallbackError(String providerId, String error, String state) {
+ Map<String, Object> params = new HashMap<>();
+ params.put("error", error);
+ params.put("state", state);
+ return get("/login/oauth2/code/" + providerId, params);
+ }
+
+ public HttpResponse handleOidcCallbackMissingCode(String providerId, String state) {
+ Map<String, Object> params = new HashMap<>();
+ params.put("state", state);
+ return get("/login/oauth2/code/" + providerId, params);
+ }
+
+ /**
+ * Optional: Test /login endpoint in OIDC mode should be disabled
+ */
+ public HttpResponse loginWithPassword(String username, String password) {
+ return new LoginPage().login(username, password);
+ }
+}
diff --git a/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/utils/RequestClient.java b/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/utils/RequestClient.java
index f8852f0879..37faa358ea 100644
--- a/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/utils/RequestClient.java
+++ b/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/utils/RequestClient.java
@@ -75,12 +75,19 @@ public class RequestClient {
HttpResponseBody responseData = null;
int responseCode = response.code();
+ Map<String, String> responseHeaders = new HashMap<>();
+
+ Headers responseHeadersObj = response.headers();
+ for (String name : responseHeadersObj.names()) {
+ responseHeaders.put(name, responseHeadersObj.get(name));
+ }
+
if (response.body() != null) {
responseData = JSONUtils.parseObject(response.body().string(), HttpResponseBody.class);
}
response.close();
- HttpResponse httpResponse = new HttpResponse(responseCode, responseData);
+ HttpResponse httpResponse = new HttpResponse(responseCode, responseData, responseHeaders);
log.info("GET response: {}", httpResponse);
@@ -124,12 +131,19 @@ public class RequestClient {
Response response = this.httpClient.newCall(request).execute();
int responseCode = response.code();
HttpResponseBody responseData = null;
+ Map<String, String> responseHeaders = new HashMap<>();
+
+ Headers responseHeadersObj = response.headers();
+ for (String name : responseHeadersObj.names()) {
+ responseHeaders.put(name, responseHeadersObj.get(name));
+ }
+
if (response.body() != null) {
responseData = JSONUtils.parseObject(response.body().string(), HttpResponseBody.class);
}
response.close();
- HttpResponse httpResponse = new HttpResponse(responseCode, responseData);
+ HttpResponse httpResponse = new HttpResponse(responseCode, responseData, responseHeaders);
log.info("POST response: {}", httpResponse);
@@ -155,12 +169,19 @@ public class RequestClient {
Response response = this.httpClient.newCall(request).execute();
int responseCode = response.code();
HttpResponseBody responseData = null;
+ Map<String, String> responseHeaders = new HashMap<>();
+
+ Headers responseHeadersObj = response.headers();
+ for (String name : responseHeadersObj.names()) {
+ responseHeaders.put(name, responseHeadersObj.get(name));
+ }
+
if (response.body() != null) {
responseData = JSONUtils.parseObject(response.body().string(), HttpResponseBody.class);
}
response.close();
- HttpResponse httpResponse = new HttpResponse(responseCode, responseData);
+ HttpResponse httpResponse = new HttpResponse(responseCode, responseData, responseHeaders);
log.info("PUT response: {}", httpResponse);
@@ -217,12 +238,19 @@ public class RequestClient {
int responseCode = response.code();
HttpResponseBody responseData = null;
+ Map<String, String> responseHeaders = new HashMap<>();
+
+ Headers responseHeadersObj = response.headers();
+ for (String name : responseHeadersObj.names()) {
+ responseHeaders.put(name, responseHeadersObj.get(name));
+ }
+
if (response.body() != null) {
responseData = JSONUtils.parseObject(response.body().string(), HttpResponseBody.class);
}
response.close();
- HttpResponse httpResponse = new HttpResponse(responseCode, responseData);
+ HttpResponse httpResponse = new HttpResponse(responseCode, responseData, responseHeaders);
log.info("DELETE response: {}", httpResponse);
diff --git a/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/resources/docker/oidc-login/application.yaml b/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/resources/docker/oidc-login/application.yaml
new file mode 100644
index 0000000000..cb37190244
--- /dev/null
+++ b/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/resources/docker/oidc-login/application.yaml
@@ -0,0 +1,241 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+spring:
+ profiles:
+ active: h2
+ jackson:
+ time-zone: UTC
+ date-format: "yyyy-MM-dd HH:mm:ss"
+ banner:
+ charset: UTF-8
+ location: classpath:standalone-banner.txt
+ sql:
+ init:
+ schema-locations: classpath:sql/dolphinscheduler_h2.sql
+ datasource:
+ driver-class-name: org.h2.Driver
+ url: jdbc:h2:mem:dolphinscheduler;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=true
+ username: sa
+ password: ""
+ quartz:
+ job-store-type: jdbc
+ jdbc:
+ initialize-schema: never
+ properties:
+ org.quartz.threadPool.threadPriority: 5
+ org.quartz.jobStore.isClustered: true
+ org.quartz.jobStore.class: org.springframework.scheduling.quartz.LocalDataSourceJobStore
+ org.quartz.scheduler.instanceId: AUTO
+ org.quartz.jobStore.tablePrefix: QRTZ_
+ org.quartz.jobStore.acquireTriggersWithinLock: true
+ org.quartz.scheduler.instanceName: DolphinScheduler
+ org.quartz.threadPool.class: org.quartz.simpl.SimpleThreadPool
+ org.quartz.jobStore.useProperties: false
+ org.quartz.threadPool.makeThreadsDaemons: true
+ org.quartz.threadPool.threadCount: 25
+ org.quartz.jobStore.misfireThreshold: 60000
+ org.quartz.scheduler.makeSchedulerThreadDaemon: true
+ org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
+ org.quartz.jobStore.clusterCheckinInterval: 5000
+ org.quartz.scheduler.batchTriggerAcquisitionMaxCount: 25
+ servlet:
+ multipart:
+ max-file-size: 1024MB
+ max-request-size: 1024MB
+ messages:
+ basename: i18n/messages
+ jpa:
+ hibernate:
+ ddl-auto: none
+ mvc:
+ pathmatch:
+ matching-strategy: ANT_PATH_MATCHER
+ cloud.discovery.client.composite-indicator.enabled: false
+
+mybatis-plus:
+ mapper-locations: classpath:org/apache/dolphinscheduler/dao/mapper/*Mapper.xml
+ type-aliases-package: org.apache.dolphinscheduler.dao.entity
+ configuration:
+ cache-enabled: false
+ call-setters-on-nulls: true
+ map-underscore-to-camel-case: true
+ jdbc-type-for-null: NULL
+ global-config:
+ db-config:
+ id-type: auto
+ banner: false
+
+registry:
+ type: jdbc
+
+security:
+ authentication:
+ type: OIDC
+ oidc:
+ enable: true
+ providers:
+ keycloak:
+ display-name: "Login with Keycloak"
+ issuer-uri: http://keycloak:8080/realms/dolphinscheduler
+ icon-uri: "/images/providers-icon/keycloak.png"
+ client-id: dolphinscheduler-client
+ client-secret: dolphinscheduler-client-secret
+ scope: openid, profile, email, groups
+ user-name-attribute: preferred_username
+ groups-claim: groups
+ user:
+ auto-create: true
+ default-tenant-code: default
+ default-queue: default
+ admin-group-mapping:
+ - dolphinscheduler-admins
+
+casdoor:
+ endpoint: http://localhost:8000
+ client-id: ""
+ client-secret: ""
+ certificate: ""
+ organization-name: built-in
+ application-name: dolphinscheduler
+ redirect-url: http://localhost:5173/login
+
+master:
+ listen-port: 5678
+ max-heartbeat-interval: 10s
+ server-load-protection:
+ enabled: true
+ max-system-cpu-usage-percentage-thresholds: 1
+ max-jvm-cpu-usage-percentage-thresholds: 0.9
+ max-system-memory-usage-percentage-thresholds: 0.9
+ max-disk-usage-percentage-thresholds: 0.9
+ worker-load-balancer-configuration-properties:
+ type: DYNAMIC_WEIGHTED_ROUND_ROBIN
+ dynamic-weight-config-properties:
+ memory-usage-weight: 30
+ cpu-usage-weight: 30
+ task-thread-pool-usage-weight: 40
+ worker-group-refresh-interval: 10s
+ command-fetch-strategy:
+ type: ID_SLOT_BASED
+ config:
+ id-step: 1
+ fetch-size: 10
+
+worker:
+ listen-port: 1234
+ exec-threads: 10
+ max-heartbeat-interval: 10s
+ host-weight: 100
+ group: default
+ server-load-protection:
+ enabled: true
+ max-system-cpu-usage-percentage-thresholds: 1
+ max-jvm-cpu-usage-percentage-thresholds: 0.9
+ max-system-memory-usage-percentage-thresholds: 0.9
+ max-disk-usage-percentage-thresholds: 0.9
+ task-execute-threads-full-policy: REJECT
+ tenant-config:
+ auto-create-tenant-enabled: true
+ distributed-tenant: false
+ default-tenant-enabled: true
+
+alert:
+ port: 50052
+ wait-timeout: 0
+ max-heartbeat-interval: 60s
+ sender-parallelism: 5
+
+api:
+ base-url: http://localhost:12345/dolphinscheduler
+ ui-url: http://localhost:5173
+ audit-enable: false
+ traffic-control:
+ global-switch: false
+ max-global-qps-rate: 300
+ tenant-switch: false
+ default-tenant-qps-rate: 10
+ python-gateway:
+ enabled: true
+ auth-token: jwUDzpLsNKEFER4*a8gruBH_GsAurNxU7A@Xc
+ gateway-server-address: 0.0.0.0
+ gateway-server-port: 25333
+ python-address: 127.0.0.1
+ python-port: 25334
+ connect-timeout: 0
+ read-timeout: 0
+
+server:
+ port: 12345
+ servlet:
+ session:
+ timeout: 120m
+ context-path: /dolphinscheduler/
+ compression:
+ enabled: true
+ mime-types: text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml
+ jetty:
+ max-http-form-post-size: 5000000
+ accesslog:
+ enabled: true
+ custom-format: '%{client}a - %u %t "%r" %s %O %{ms}Tms'
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,metrics,prometheus
+ endpoint:
+ health:
+ enabled: true
+ show-details: always
+ health:
+ db:
+ enabled: true
+ defaults:
+ enabled: false
+ metrics:
+ tags:
+ application: ${spring.application.name}
+
+---
+spring:
+ config:
+ activate:
+ on-profile: postgresql
+ quartz:
+ properties:
+ org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
+ datasource:
+ driver-class-name: org.postgresql.Driver
+ url: jdbc:postgresql://127.0.0.1:5432/dolphinscheduler
+ username: root
+ password: root
+
+---
+spring:
+ config:
+ activate:
+ on-profile: mysql
+ sql:
+ init:
+ schema-locations: classpath:sql/dolphinscheduler_mysql.sql
+ datasource:
+ driver-class-name: com.mysql.cj.jdbc.Driver
+ url: jdbc:mysql://127.0.0.1:3306/dolphinscheduler?useUnicode=true&characterEncoding=UTF-8
+ username: root
+ password: root
diff --git a/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/resources/docker/oidc-login/docker-compose.yaml b/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/resources/docker/oidc-login/docker-compose.yaml
new file mode 100644
index 0000000000..6d46f58195
--- /dev/null
+++ b/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/resources/docker/oidc-login/docker-compose.yaml
@@ -0,0 +1,71 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+version: "3.8"
+
+services:
+ dolphinscheduler:
+ image: apache/dolphinscheduler-standalone-server:ci
+ environment:
+ WORKER_TENANT_AUTO_CREATE: "true"
+ ports:
+ - "12345:12345"
+ volumes:
+ - ./application.yaml:/opt/dolphinscheduler/standalone-server/conf/application.yaml
+ # Mount the wait script into the container
+ - ./wait-and-start-ds.sh:/wait-and-start-ds.sh
+ # Override the default entrypoint to run our script
+ entrypoint:
+ - /bin/bash
+ - -c
+ - "chmod +x /wait-and-start-ds.sh && /wait-and-start-ds.sh"
+ networks:
+ - api-test
+ healthcheck:
+ test: ["CMD","bash","-c","{ printf 'GET /dolphinscheduler/actuator/health HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n' >&0; grep -q 'HTTP/1.1 200'; } 0<>/dev/tcp/localhost/12345" ]
+ interval: 5s
+ timeout: 60s
+ retries: 120
+ depends_on:
+ - keycloak # No health dependency; we gate startup with the wait script
+
+ keycloak:
+ hostname: keycloak
+ image: quay.io/keycloak/keycloak:22.0.1
+ ports:
+ - "8080:8080"
+ environment:
+ KEYCLOAK_ADMIN: admin
+ KEYCLOAK_ADMIN_PASSWORD: admin
+ KC_PROXY: edge
+ command: ["start-dev", "--import-realm"]
+ volumes:
+ - ./realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
+ networks:
+ - api-test
+ tty: true
+ stdin_open: true
+ restart: always
+ healthcheck:
+ # Wait until the dolphinscheduler realm OIDC metadata is served (implies import done)
+ test: ["CMD","bash","-c","{ printf 'GET /realms/dolphinscheduler/.well-known/openid-configuration HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n' >&0; grep -q 'HTTP/1.1 200'; } 0<>/dev/tcp/localhost/8080"]
+ interval: 5s
+ timeout: 60s
+ retries: 120
+
+networks:
+ api-test:
diff --git a/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/resources/docker/oidc-login/realm-export.json b/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/resources/docker/oidc-login/realm-export.json
new file mode 100644
index 0000000000..a33e510f36
--- /dev/null
+++ b/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/resources/docker/oidc-login/realm-export.json
@@ -0,0 +1,182 @@
+{
+ "id": "dolphinscheduler",
+ "realm": "dolphinscheduler",
+ "displayName": "DolphinScheduler Realm",
+ "enabled": true,
+ "sslRequired": "external",
+ "registrationAllowed": false,
+ "loginWithEmailAllowed": true,
+ "duplicateEmailsAllowed": false,
+ "resetPasswordAllowed": true,
+ "editUsernameAllowed": false,
+ "bruteForceProtected": true,
+ "permanentLockout": false,
+ "maxFailureWaitSeconds": 900,
+ "minimumQuickLoginWaitSeconds": 60,
+ "waitIncrementSeconds": 60,
+ "quickLoginCheckMilliSeconds": 1000,
+ "maxDeltaTimeSeconds": 43200,
+ "failureFactor": 30,
+ "defaultRoles": [
+ "offline_access",
+ "uma_authorization"
+ ],
+ "requiredCredentials": [
+ "password"
+ ],
+ "clients": [
+ {
+ "clientId": "dolphinscheduler-client",
+ "enabled": true,
+ "clientAuthenticatorType": "client-secret",
+ "secret": "dolphinscheduler-client-secret",
+ "redirectUris": [
+ "http://localhost:12345/dolphinscheduler/login/oauth2/code/keycloak",
+ "http://127.0.0.1:12345/dolphinscheduler/login/oauth2/code/keycloak"
+ ],
+ "webOrigins": [
+ "http://localhost:12345",
+ "http://127.0.0.1:12345",
+ "http://localhost:5173",
+ "http://127.0.0.1:5173"
+ ],
+ "standardFlowEnabled": true,
+ "protocol": "openid-connect",
+ "defaultClientScopes": [
+ "email",
+ "profile",
+ "groups"
+ ],
+ "optionalClientScopes": [],
+ "attributes": {
+ "saml.assertion.signature": "false",
+ "saml.force.post.binding": "false",
+ "saml.multivalued.roles": "false",
+ "saml.encrypt": "false",
+ "saml.server.signature": "false",
+ "saml.server.signature.keyinfo.ext": "false",
+ "exclude.session.state.from.auth.response": "false",
+ "saml_force_name_id_format": "false",
+ "saml.client.signature": "false",
+ "tls.client.certificate.bound.access.tokens": "false",
+ "saml.authnstatement": "false",
+ "display.on.consent.screen": "false",
+ "saml.onetimeuse.condition": "false"
+ },
+ "protocolMappers": []
+ }
+ ],
+ "clientScopes": [
+ {
+ "name": "groups",
+ "description": "Group membership",
+ "protocol": "openid-connect",
+ "attributes": { "include.in.token.scope": "true", "display.on.consent.screen": "false" },
+ "protocolMappers": [
+ {
+ "name": "groups",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-group-membership-mapper",
+ "consentRequired": false,
+ "config": {
+ "full.path": "false",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "groups",
+ "userinfo.token.claim": "true"
+ }
+ }
+ ]
+ },
+ {
+ "name": "email",
+ "description": "Provides email related claims.",
+ "protocol": "openid-connect",
+ "attributes": { "include.in.token.scope": "true", "display.on.consent.screen": "false" },
+ "protocolMappers": [
+ {
+ "name": "email",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "email",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "email",
+ "jsonType.label": "String"
+ }
+ }
+ ]
+ },
+ {
+ "name": "profile",
+ "description": "Provides user profile information.",
+ "protocol": "openid-connect",
+ "attributes": { "include.in.token.scope": "true", "display.on.consent.screen": "false" },
+ "protocolMappers": [
+ {
+ "name": "username",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "username",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "preferred_username",
+ "jsonType.label": "String"
+ }
+ }
+ ]
+ }
+ ],
+ "groups": [
+ {
+ "name": "dolphinscheduler-admins",
+ "path": "/dolphinscheduler-admins"
+ },
+ {
+ "name": "users",
+ "path": "/users"
+ }
+ ],
+ "users": [
+ {
+ "username": "admin_user",
+ "enabled": true,
+ "email": "admin@example.com",
+ "firstName": "Admin",
+ "lastName": "User",
+ "credentials": [
+ {
+ "type": "password",
+ "value": "password",
+ "temporary": false
+ }
+ ],
+ "groups": [
+ "/dolphinscheduler-admins"
+ ]
+ },
+ {
+ "username": "general_user",
+ "enabled": true,
+ "email": "user@example.com",
+ "firstName": "General",
+ "lastName": "User",
+ "credentials": [
+ {
+ "type": "password",
+ "value": "password",
+ "temporary": false
+ }
+ ],
+ "groups": [
+ "/users"
+ ]
+ }
+ ]
+}
diff --git a/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/resources/docker/oidc-login/wait-and-start-ds.sh b/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/resources/docker/oidc-login/wait-and-start-ds.sh
new file mode 100644
index 0000000000..c61be104a6
--- /dev/null
+++ b/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/resources/docker/oidc-login/wait-and-start-ds.sh
@@ -0,0 +1,47 @@
+#!/bin/bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+set -euo pipefail
+
+echo "Container started. Waiting for 40 seconds to allow Keycloak to initialize..."
+
+# Wait for 60 seconds
+sleep 60
+
+echo "Wait finished. Preparing to start DolphinScheduler standalone server..."
+
+# Ensure the start script is executable
+if [ ! -x /opt/dolphinscheduler/standalone-server/bin/start.sh ]; then
+ echo "start.sh not executable. Applying chmod +x ..."
+ chmod +x /opt/dolphinscheduler/standalone-server/bin/start.sh || {
+ echo "Failed to chmod start.sh" >&2
+ ls -l /opt/dolphinscheduler/standalone-server/bin/start.sh || true
+ exit 1
+ }
+fi
+
+ls -l /opt/dolphinscheduler/standalone-server/bin/start.sh || true
+
+echo "Starting DolphinScheduler..."
+/opt/dolphinscheduler/standalone-server/bin/start.sh
+
+while true; do
+ sleep 5
+ if ! pgrep -f 'org.apache.dolphinscheduler' >/dev/null 2>&1; then
+ echo "Warning: DolphinScheduler process not detected yet (or already exited)." >&2
+ fi
+done
diff --git a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/configuration/ApiConfig.java b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/configuration/ApiConfig.java
index 3a33ad2d58..f5f22e0eef 100644
--- a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/configuration/ApiConfig.java
+++ b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/configuration/ApiConfig.java
@@ -38,6 +38,8 @@ import org.springframework.validation.annotation.Validated;
@ConfigurationProperties(value = "api")
public class ApiConfig implements Validator {
+ private String baseUrl;
+ private String uiUrl;
private boolean auditEnable = false;
private TrafficConfiguration trafficControl = new TrafficConfiguration();
@@ -55,6 +57,8 @@ public class ApiConfig implements Validator {
}
private void printConfig() {
+ log.info("API config: baseUrl -> {} ", baseUrl);
+ log.info("API config: uiUrl -> {} ", uiUrl);
log.info("API config: auditEnable -> {} ", auditEnable);
log.info("API config: trafficControl -> {} ", trafficControl);
log.info("API config: pythonGateway -> {} ", pythonGateway);
diff --git a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/configuration/AppConfiguration.java b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/configuration/AppConfiguration.java
index 50293fc6e7..629cc9db26 100644
--- a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/configuration/AppConfiguration.java
+++ b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/configuration/AppConfiguration.java
@@ -106,7 +106,8 @@ public class AppConfiguration implements WebMvcConfigurer {
.excludePathPatterns(LOGIN_PATH_PATTERN, REGISTER_PATH_PATTERN,
"/swagger-resources/**", "/webjars/**", "/v3/api-docs/**", "/api-docs/**", "/swagger-ui.html",
"/doc.html", "/swagger-ui/**", "*.html", "/ui/**", "/error", "/oauth2-provider",
- "/redirect/login/oauth2", "/cookies");
+ "/redirect/login/oauth2", "/cookies", "/oidc-providers", "/oauth2/authorization/**",
+ "/login/oauth2/code/**");
}
@Override
diff --git a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/controller/LoginController.java b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/controller/LoginController.java
index 5e69c35722..6bcd4834d6 100644
--- a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/controller/LoginController.java
+++ b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/controller/LoginController.java
@@ -22,11 +22,14 @@ import static org.apache.dolphinscheduler.api.enums.Status.NOT_SUPPORT_SSO;
import static org.apache.dolphinscheduler.api.enums.Status.SIGN_OUT_ERROR;
import static org.apache.dolphinscheduler.api.enums.Status.USER_LOGIN_FAILURE;
+import org.apache.dolphinscheduler.api.configuration.ApiConfig;
import org.apache.dolphinscheduler.api.configuration.OAuth2Configuration;
import org.apache.dolphinscheduler.api.enums.Status;
import org.apache.dolphinscheduler.api.exceptions.ApiException;
import org.apache.dolphinscheduler.api.security.Authenticator;
import org.apache.dolphinscheduler.api.security.impl.AbstractSsoAuthenticator;
+import org.apache.dolphinscheduler.api.security.impl.oidc.OidcAuthenticator;
+import org.apache.dolphinscheduler.api.security.impl.oidc.OidcConfigProperties;
import org.apache.dolphinscheduler.api.service.SessionService;
import org.apache.dolphinscheduler.api.service.UsersService;
import org.apache.dolphinscheduler.api.utils.Result;
@@ -42,11 +45,13 @@ import org.apache.dolphinscheduler.dao.entity.User;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpStatus;
+import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
@@ -61,6 +66,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -82,17 +88,27 @@ import io.swagger.v3.oas.annotations.tags.Tag;
@Slf4j
public class LoginController extends BaseController {
- @Autowired
- private SessionService sessionService;
-
- @Autowired
- private Authenticator authenticator;
-
- @Autowired(required = false)
- private OAuth2Configuration oAuth2Configuration;
+ private final SessionService sessionService;
+ private final Authenticator authenticator;
+ private final OAuth2Configuration oAuth2Configuration;
+ private final OidcConfigProperties oidcConfigProperties;
+ private final UsersService usersService;
+ private final ApiConfig apiConfig;
@Autowired
- private UsersService usersService;
+ public LoginController(SessionService sessionService,
+ Authenticator authenticator,
+ UsersService usersService,
+ Optional<OAuth2Configuration> oAuth2Configuration,
+ Optional<OidcConfigProperties> oidcConfigProperties,
+ ApiConfig apiConfig) {
+ this.sessionService = sessionService;
+ this.authenticator = authenticator;
+ this.usersService = usersService;
+ this.oAuth2Configuration = oAuth2Configuration.orElse(null);
+ this.oidcConfigProperties = oidcConfigProperties.orElse(null);
+ this.apiConfig = apiConfig;
+ }
/**
* login
@@ -152,6 +168,10 @@ public class LoginController extends BaseController {
@GetMapping(value = "/login/sso")
@ApiException(NOT_SUPPORT_SSO)
public Result ssoLogin(HttpServletRequest request) {
+ if (authenticator instanceof OidcAuthenticator) {
+ return Result.success(null);
+ }
+
if (authenticator instanceof AbstractSsoAuthenticator) {
String randomState = UUID.randomUUID().toString();
HttpSession session = request.getSession();
@@ -207,6 +227,31 @@ public class LoginController extends BaseController {
return Result.success(providers);
}
+ /**
+ * Get OIDC providers
+ * @return list of OIDC providers
+ */
+ @Operation(summary = "getOidcProviders", description = "GET_OIDC_PROVIDERS")
+ @GetMapping("/oidc-providers")
+ public Result<List<Map<String, String>>> oidcProviders() {
+ if (oidcConfigProperties == null || !oidcConfigProperties.isEnable()
+ || oidcConfigProperties.getProviders() == null) {
+ return Result.success(new ArrayList<>());
+ }
+
+ List<Map<String, String>> providers = oidcConfigProperties.getProviders().entrySet().stream()
+ .map(entry -> {
+ Map<String, String> provider = new HashMap<>();
+ provider.put("id", entry.getKey());
+ provider.put("displayName", entry.getValue().getDisplayName());
+ provider.put("iconUri", entry.getValue().getIconUri());
+ return provider;
+ })
+ .collect(Collectors.toList());
+
+ return Result.success(providers);
+ }
+
@SneakyThrows
@Operation(summary = "redirectToOauth2", description = "REDIRECT_TO_OAUTH2_LOGIN")
@GetMapping("redirect/login/oauth2")
@@ -263,4 +308,97 @@ public class LoginController extends BaseController {
"oauth2", "oauth2 auth error"));
}
}
+
+ /**
+ * Handle OIDC callback
+ *
+ * @param code authorization code
+ * @param state state parameter
+ * @param response HTTP response
+ */
+ @SneakyThrows
+ @Operation(summary = "handleOidcCallback", description = "HANDLE_OIDC_CALLBACK")
+ @GetMapping("/login/oauth2/code/{providerId}")
+ public void handleOidcCallback(@PathVariable String providerId,
+ @RequestParam(name = "code", required = false) String code,
+ @RequestParam(name = "error", required = false) String error,
+ @RequestParam(name = "state") String state,
+ HttpServletResponse response) throws IOException {
+
+ if (!state.startsWith(providerId + ":")) {
+ log.error("OIDC login failed: State parameter does not match the provider ID.");
+ response.sendRedirect("/dolphinscheduler/ui/#/login?error=oidc_invalid_state");
+ return;
+ }
+
+ // Handle login failure from OIDC provider
+ if (error != null) {
+ String sanitizedError = error.replaceAll("[\n\r\t]", "_");
+ log.error("OIDC login failed with error: {}.", sanitizedError);
+ response.sendRedirect("/dolphinscheduler/ui/#/login?error=oidc_login_failed");
+ return;
+ }
+
+ // Handle the case where code is missing without an error
+ if (code == null) {
+ log.error("OIDC login failed: The authorization code was not provided.");
+ response.sendRedirect("/dolphinscheduler/ui/#/login?error=oidc_missing_code");
+ return;
+ }
+
+ try {
+ if (!(authenticator instanceof OidcAuthenticator)) {
+ log.error("OIDC authentication is not active or authenticator type is incorrect.");
+ response.sendRedirect("/dolphinscheduler/ui/#/login?error=oidc_not_enabled");
+ return;
+ }
+
+ User user = ((OidcAuthenticator) authenticator).login(state, code);
+
+ if (user == null) {
+ log.error("OIDC authentication failed. User could not be authenticated or created.");
+ response.sendRedirect("/dolphinscheduler/ui/#/login?error=oidc_authentication_failed");
+ return;
+ }
+
+ Session session = sessionService.createSessionIfAbsent(user);
+
+ response.setStatus(HttpStatus.SC_MOVED_TEMPORARILY);
+ response.sendRedirect(String.format("%s/login?sessionId=%s&authType=%s",
+ apiConfig.getUiUrl(), session.getId(), "oidc"));
+ } catch (Exception ex) {
+ log.error("A critical error occurred during the OIDC callback process.", ex);
+ try {
+ response.sendRedirect("/dolphinscheduler/ui/#/login?error=oidc_critical_error");
+ } catch (IOException e) {
+ log.error("Failed to redirect to login page after a critical error.", e);
+ }
+ }
+ }
+
+ @Operation(summary = "redirectToOidc", description = "REDIRECT_TO_OIDC_LOGIN")
+ @GetMapping("/oauth2/authorization/{providerId}")
+ public void redirectToOidc(@PathVariable String providerId, HttpServletRequest request,
+ HttpServletResponse response) throws IOException {
+
+ // Validate the providerId before using it
+ if (oidcConfigProperties == null || oidcConfigProperties.getProviders() == null
+ || !oidcConfigProperties.getProviders().containsKey(providerId)) {
+ log.error("Invalid OIDC provider ID requested: {}", providerId);
+ response.sendRedirect("/dolphinscheduler/ui/#/login?error=invalid_provider");
+ return;
+ }
+
+ String state = providerId + ":" + UUID.randomUUID().toString();
+ request.getSession().setAttribute(Constants.SSO_LOGIN_USER_STATE, state);
+ String authorizationUrl = ((OidcAuthenticator) authenticator).getSignInUrl(state);
+
+ if (authorizationUrl == null) {
+ log.error("OIDC authorization URL is null for providerId: {}", providerId);
+ response.sendRedirect("/dolphinscheduler/ui/#/login?error=oidc_authorization_url_null");
+ return;
+ }
+
+ response.sendRedirect(authorizationUrl);
+ }
}
diff --git a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/enums/Status.java b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/enums/Status.java
index 4454a62825..daf7026e78 100644
--- a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/enums/Status.java
+++ b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/enums/Status.java
@@ -294,6 +294,12 @@ public enum Status {
QUERY_PROJECT_PREFERENCE_ERROR(10302, "query project preference error", "查询项目偏好设置错误"),
UPDATE_PROJECT_PREFERENCE_STATE_ERROR(10303, "Failed to update the state of the project preference", "更新项目偏好设置错误"),
VERSION_INFO_STATE_ERROR(10304, "Failed to obtain project version and address", "获取版本信息错误"),
+
+ OIDC_TOKEN_EXCHANGE_FAILED(15000, "OIDC token exchange failed", "OIDC令牌交换失败"),
+ OIDC_ID_TOKEN_ISSUER_INVALID(15001, "Invalid issuer in OIDC ID token", "OIDC ID令牌的颁发者无效"),
+ OIDC_ID_TOKEN_AUDIENCE_INVALID(15002, "Invalid audience in OIDC ID token", "OIDC ID令牌的受众无效"),
+ OIDC_ID_TOKEN_EXPIRED(15003, "OIDC ID token has expired", "OIDC ID令牌已过期"),
+
RESOURCE_NOT_EXIST(20004, "resource not exist", "资源不存在"),
RESOURCE_EXIST(20005, "resource already exists", "资源已存在"),
RESOURCE_SUFFIX_NOT_SUPPORT_VIEW(20006, "resource suffix do not support online viewing", "资源文件后缀不支持查看"),
diff --git a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/AuthenticationType.java b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/AuthenticationType.java
index 8ef2e6b1c4..9fe35b2610 100644
--- a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/AuthenticationType.java
+++ b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/AuthenticationType.java
@@ -26,7 +26,8 @@ public enum AuthenticationType {
PASSWORD(0, "verify via user name and password"),
LDAP(1, "verify via LDAP server"),
- CASDOOR_SSO(2, "verify via casdoor sso provider");
+ CASDOOR_SSO(2, "verify via casdoor sso provider"),
+ OIDC(3, "verify via OpenID Connect provider");
AuthenticationType(int code, String desc) {
this.code = code;
diff --git a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/SecurityConfig.java b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/SecurityConfig.java
index eff754cc0d..ccca23b848 100644
--- a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/SecurityConfig.java
+++ b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/SecurityConfig.java
@@ -17,12 +17,18 @@
package org.apache.dolphinscheduler.api.security;
+import org.apache.dolphinscheduler.api.configuration.ApiConfig;
import org.apache.dolphinscheduler.api.security.impl.ldap.LdapAuthenticator;
+import org.apache.dolphinscheduler.api.security.impl.oidc.OidcAuthenticator;
+import org.apache.dolphinscheduler.api.security.impl.oidc.OidcConfigProperties;
import org.apache.dolphinscheduler.api.security.impl.pwd.PasswordAuthenticator;
import org.apache.dolphinscheduler.api.security.impl.sso.CasdoorAuthenticator;
+import org.apache.dolphinscheduler.api.service.UsersService;
import org.apache.commons.lang3.StringUtils;
+import java.util.Optional;
+
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@@ -30,6 +36,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
@Configuration
@Slf4j
@@ -38,12 +45,21 @@ public class SecurityConfig {
@Value("${security.authentication.type:PASSWORD}")
private String type;
- private AutowireCapableBeanFactory beanFactory;
+ private final AutowireCapableBeanFactory beanFactory;
+ private final OidcConfigProperties oidcConfig;
+ private final UsersService usersService;
private AuthenticationType authenticationType;
+ private final ApiConfig apiConfig;
@Autowired
- public SecurityConfig(AutowireCapableBeanFactory beanFactory) {
+ public SecurityConfig(AutowireCapableBeanFactory beanFactory,
+ Optional<OidcConfigProperties> oidcConfig,
+ Optional<UsersService> usersService,
+ ApiConfig apiConfig) {
this.beanFactory = beanFactory;
+ this.oidcConfig = oidcConfig.orElse(null);
+ this.usersService = usersService.orElse(null);
+ this.apiConfig = apiConfig;
}
private void setAuthenticationType(String type) {
@@ -57,6 +73,7 @@ public class SecurityConfig {
}
@Bean(name = "authenticator")
+ @Primary
public Authenticator authenticator() {
setAuthenticationType(type);
Authenticator authenticator;
@@ -70,6 +87,13 @@ public class SecurityConfig {
case CASDOOR_SSO:
authenticator = new CasdoorAuthenticator();
break;
+ case OIDC:
+ if (oidcConfig == null || usersService == null) {
+ throw new IllegalStateException(
+ "OIDC authentication is configured, but required beans are not available.");
+ }
+ authenticator = new OidcAuthenticator(oidcConfig, usersService, apiConfig);
+ break;
default:
throw new IllegalStateException("Unexpected value: " + authenticationType);
}
diff --git a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcAuthenticator.java b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcAuthenticator.java
new file mode 100644
index 0000000000..ecbcf80279
--- /dev/null
+++ b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcAuthenticator.java
@@ -0,0 +1,403 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.dolphinscheduler.api.security.impl.oidc;
+
+import org.apache.dolphinscheduler.api.configuration.ApiConfig;
+import org.apache.dolphinscheduler.api.enums.Status;
+import org.apache.dolphinscheduler.api.exceptions.ServiceException;
+import org.apache.dolphinscheduler.api.security.impl.AbstractSsoAuthenticator;
+import org.apache.dolphinscheduler.api.service.UsersService;
+import org.apache.dolphinscheduler.api.utils.RegexUtils;
+import org.apache.dolphinscheduler.common.constants.Constants;
+import org.apache.dolphinscheduler.common.enums.UserType;
+import org.apache.dolphinscheduler.dao.entity.User;
+
+import java.net.URI;
+import java.security.MessageDigest;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import javax.servlet.http.HttpServletRequest;
+
+import lombok.NonNull;
+import lombok.extern.slf4j.Slf4j;
+
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import com.nimbusds.jwt.JWT;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.oauth2.sdk.AuthorizationCode;
+import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant;
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.ResponseType;
+import com.nimbusds.oauth2.sdk.Scope;
+import com.nimbusds.oauth2.sdk.TokenRequest;
+import com.nimbusds.oauth2.sdk.TokenResponse;
+import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
+import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic;
+import com.nimbusds.oauth2.sdk.auth.ClientSecretPost;
+import com.nimbusds.oauth2.sdk.auth.Secret;
+import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+import com.nimbusds.oauth2.sdk.id.State;
+import com.nimbusds.oauth2.sdk.token.AccessToken;
+import com.nimbusds.openid.connect.sdk.AuthenticationRequest;
+import com.nimbusds.openid.connect.sdk.Nonce;
+import com.nimbusds.openid.connect.sdk.OIDCTokenResponse;
+import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser;
+import com.nimbusds.openid.connect.sdk.UserInfoRequest;
+import com.nimbusds.openid.connect.sdk.UserInfoResponse;
+import com.nimbusds.openid.connect.sdk.claims.IDTokenClaimsSet;
+import com.nimbusds.openid.connect.sdk.claims.UserInfo;
+import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
+import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
+
+@Slf4j
+@Component("oidcAuthenticator")
+public class OidcAuthenticator extends AbstractSsoAuthenticator {
+
+ private final OidcConfigProperties oidcConfig;
+ private final UsersService usersService;
+ private final ApiConfig apiConfig;
+
+ private static final String EMAIL_ATTRIBUTE = "email";
+ private static final String STATE_DELIMITER = ":";
+ private static final int STATE_PARTS_COUNT = 2;
+ private static final int PROVIDER_ID_INDEX = 0;
+
+ private final Map<String, OIDCProviderMetadata> providerMetadataCache = new ConcurrentHashMap<>();
+
+ public OidcAuthenticator(OidcConfigProperties oidcConfig, UsersService usersService, ApiConfig apiConfig) {
+ this.oidcConfig = oidcConfig;
+ this.usersService = usersService;
+ this.apiConfig = apiConfig;
+ }
+
+ @Override
+ public User login(@NonNull String state, String code) {
+
+ try {
+ ServletRequestAttributes servletRequestAttributes =
+ (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+ if (servletRequestAttributes == null) {
+ log.error("ServletRequestAttributes is null, cannot get session");
+ return null;
+ }
+
+ HttpServletRequest request = servletRequestAttributes.getRequest();
+ String originalState = (String) request.getSession().getAttribute(Constants.SSO_LOGIN_USER_STATE);
+ request.getSession().removeAttribute(Constants.SSO_LOGIN_USER_STATE);
+
+ if (originalState == null || !MessageDigest.isEqual(originalState.getBytes(), state.getBytes())) {
+ log.error("State validation failed. Expected: {}, Actual: {}", originalState,
+ RegexUtils.escapeNRT(state));
+ return null;
+ }
+
+ String[] stateParts = state.split(STATE_DELIMITER, STATE_PARTS_COUNT);
+ if (stateParts.length != STATE_PARTS_COUNT) {
+ log.error("Invalid state format: {}", RegexUtils.escapeNRT(state));
+ return null;
+ }
+
+ String providerId = stateParts[PROVIDER_ID_INDEX];
+ OidcProviderConfig providerConfig = oidcConfig.getProviders().get(providerId);
+ if (providerConfig == null) {
+ log.error("Provider not found: {}", RegexUtils.escapeNRT(providerId));
+ return null;
+ }
+
+ OIDCProviderMetadata providerMetadata = getProviderMetadata(providerId, providerConfig);
+ OIDCTokens tokens = exchangeCodeForTokens(providerMetadata, providerConfig, code, providerId);
+ IDTokenClaimsSet idTokenClaims = validateIdToken(providerMetadata, providerConfig, tokens.getIDToken());
+ UserInfo userInfo = getUserInfo(providerMetadata, tokens.getAccessToken());
+
+ String username = extractUsername(providerConfig, idTokenClaims, userInfo);
+ String email = extractEmail(idTokenClaims, userInfo);
+ List<String> groups = extractGroups(providerConfig, idTokenClaims, userInfo);
+
+ User user = usersService.getUserByUserName(username);
+ UserType userType = determineUserType(groups);
+
+ if (user == null) {
+ if (oidcConfig.getUser().isAutoCreate()) {
+ user = usersService.createUser(userType, username, email);
+ }
+ } else {
+ if (user.getUserType() != userType) {
+ user.setUserType(userType);
+ user = usersService.updateUser(user);
+ }
+ }
+
+ return user;
+ } catch (Exception e) {
+ log.error("An error occurred during OIDC authentication:", e);
+ return null;
+ }
+ }
+
+ @Override
+ public String getSignInUrl(String state) {
+
+ try {
+ String[] stateParts = state.split(STATE_DELIMITER, STATE_PARTS_COUNT);
+ String providerId = stateParts[PROVIDER_ID_INDEX];
+ OidcProviderConfig providerConfig = oidcConfig.getProviders().get(providerId);
+
+ if (providerConfig == null) {
+ log.error("Provider not found: {}", RegexUtils.escapeNRT(providerId));
+ return null;
+ }
+
+ OIDCProviderMetadata providerMetadata = getProviderMetadata(providerId, providerConfig);
+ ClientID clientID = new ClientID(providerConfig.getClientId());
+ URI redirectURI = new URI(getCallbackUrl(providerId));
+ Scope scope = parseScope(providerConfig.getScope());
+ State nimbusState = new State(state);
+ Nonce nonce = new Nonce();
+
+ AuthenticationRequest authRequest = new AuthenticationRequest.Builder(
+ new ResponseType(ResponseType.Value.CODE),
+ scope,
+ clientID,
+ redirectURI)
+ .state(nimbusState)
+ .nonce(nonce)
+ .endpointURI(providerMetadata.getAuthorizationEndpointURI())
+ .build();
+
+ return authRequest.toURI().toString();
+ } catch (Exception e) {
+ log.error("Error generating OIDC sign-in URL:", e);
+ return null;
+ }
+ }
+
+ private String getCallbackUrl(String providerId) {
+ return String.format("%s/login/oauth2/code/%s", apiConfig.getBaseUrl(), providerId);
+ }
+
+ /**
+ * Get OIDC provider metadata
+ */
+ private OIDCProviderMetadata getProviderMetadata(String providerId,
+ OidcProviderConfig providerConfig) throws Exception {
+ if (providerMetadataCache.containsKey(providerId)) {
+ return providerMetadataCache.get(providerId);
+ }
+
+ URI wellKnownURI = new URI(providerConfig.getIssuerUri() + "/.well-known/openid-configuration");
+ HTTPRequest httpRequest =
+ new HTTPRequest(HTTPRequest.Method.GET,
+ wellKnownURI.toURL());
+
+ HTTPResponse httpResponse = httpRequest.send();
+ OIDCProviderMetadata metadata = OIDCProviderMetadata.parse(httpResponse.getContent());
+ providerMetadataCache.put(providerId, metadata);
+
+ return metadata;
+ }
+
+ /**
+ * Exchange authorization code for tokens
+ */
+ private OIDCTokens exchangeCodeForTokens(OIDCProviderMetadata providerMetadata, OidcProviderConfig providerConfig,
+ String code, String providerId) {
+ try {
+ ClientID clientID = new ClientID(providerConfig.getClientId());
+
+ Secret clientSecret = new Secret(providerConfig.getClientSecret());
+ URI redirectURI = new URI(getCallbackUrl(providerId));
+
+ AuthorizationCode authorizationCode = new AuthorizationCode(code);
+ AuthorizationCodeGrant codeGrant = new AuthorizationCodeGrant(authorizationCode, redirectURI);
+
+ ClientAuthentication clientAuth;
+ if ("client_secret_post".equalsIgnoreCase(providerConfig.getClientAuthenticationMethod())) {
+ clientAuth = new ClientSecretPost(clientID, clientSecret);
+ } else {
+ clientAuth = new ClientSecretBasic(clientID, clientSecret);
+ }
+
+ TokenRequest tokenRequest = new TokenRequest(
+ providerMetadata.getTokenEndpointURI(),
+ clientAuth,
+ codeGrant);
+
+ TokenResponse tokenResponse;
+ try {
+ tokenResponse = OIDCTokenResponseParser.parse(tokenRequest.toHTTPRequest().send());
+ } catch (Exception e) {
+ log.error("Failed to send token request", e);
+ throw new ServiceException(Status.OIDC_TOKEN_EXCHANGE_FAILED);
+ }
+
+ if (!tokenResponse.indicatesSuccess()) {
+ log.error("Token request failed: {}", tokenResponse.toErrorResponse().getErrorObject());
+ throw new ServiceException(Status.OIDC_TOKEN_EXCHANGE_FAILED);
+ }
+
+ return ((OIDCTokenResponse) tokenResponse).getOIDCTokens();
+ } catch (java.net.URISyntaxException e) {
+ log.error("Invalid redirect URI configured for OIDC provider: {}", providerId, e);
+ throw new ServiceException("Failed to construct OIDC redirect URI", e);
+ }
+ }
+
+ /**
+ * Validate ID token and extract claims
+ */
+ private IDTokenClaimsSet validateIdToken(OIDCProviderMetadata providerMetadata,
+ OidcProviderConfig providerConfig, JWT idToken) {
+ JWTClaimsSet claimsSet;
+ try {
+ claimsSet = idToken.getJWTClaimsSet();
+ } catch (java.text.ParseException e) {
+ throw new ServiceException("Error parsing ID token claims", e);
+ }
+
+ String issuer = claimsSet.getIssuer();
+ if (issuer == null || !issuer.equals(providerMetadata.getIssuer().getValue())) {
+ throw new ServiceException(Status.OIDC_ID_TOKEN_ISSUER_INVALID);
+ }
+
+ List<String> audiences = claimsSet.getAudience();
+ if (audiences == null || !audiences.contains(providerConfig.getClientId())) {
+ throw new ServiceException(Status.OIDC_ID_TOKEN_AUDIENCE_INVALID);
+ }
+
+ Date expirationTime = claimsSet.getExpirationTime();
+ if (expirationTime == null || expirationTime.before(new Date())) {
+ throw new ServiceException(Status.OIDC_ID_TOKEN_EXPIRED);
+ }
+
+ try {
+ return new IDTokenClaimsSet(claimsSet);
+ } catch (ParseException e) {
+ log.error("Failed to parse ID token claims, required claims may be missing.", e);
+ throw new ServiceException("ID token is missing required claims", e);
+ }
+ }
+
+ /**
+ * Get user info from UserInfo endpoint
+ */
+ private UserInfo getUserInfo(OIDCProviderMetadata providerMetadata, AccessToken accessToken) throws Exception {
+ UserInfoRequest userInfoRequest = new UserInfoRequest(
+ providerMetadata.getUserInfoEndpointURI(),
+ accessToken);
+
+ HTTPResponse httpResponse = userInfoRequest.toHTTPRequest().send();
+ UserInfoResponse userInfoResponse = UserInfoResponse.parse(httpResponse);
+
+ if (!userInfoResponse.indicatesSuccess()) {
+ log.error("User info request failed: {}", userInfoResponse.toErrorResponse().getErrorObject());
+ return null;
+ }
+
+ return userInfoResponse.toSuccessResponse().getUserInfo();
+ }
+
+ private String extractUsername(OidcProviderConfig providerConfig, IDTokenClaimsSet idTokenClaims,
+ UserInfo userInfo) {
+ String userNameAttribute = providerConfig.getUserNameAttribute();
+
+ Object usernameFromIdToken = idTokenClaims.getClaim(userNameAttribute);
+ if (usernameFromIdToken != null) {
+ return usernameFromIdToken.toString();
+ }
+
+ if (userInfo != null) {
+ Object usernameFromUserInfo = userInfo.getClaim(userNameAttribute);
+ if (usernameFromUserInfo != null) {
+ return usernameFromUserInfo.toString();
+ }
+ }
+
+ return idTokenClaims.getSubject().getValue();
+ }
+
+ private String extractEmail(IDTokenClaimsSet idTokenClaims, UserInfo userInfo) {
+ Object emailFromIdToken = idTokenClaims.getClaim(EMAIL_ATTRIBUTE);
+ if (emailFromIdToken != null) {
+ return emailFromIdToken.toString();
+ }
+
+ if (userInfo != null) {
+ Object emailFromUserInfo = userInfo.getClaim(EMAIL_ATTRIBUTE);
+ if (emailFromUserInfo != null) {
+ return emailFromUserInfo.toString();
+ }
+ }
+
+ return null;
+ }
+
+ private List<String> extractGroups(OidcProviderConfig providerConfig, IDTokenClaimsSet idTokenClaims,
+ UserInfo userInfo) {
+ String groupsClaim = providerConfig.getGroupsClaim();
+ if (groupsClaim == null || groupsClaim.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ Object groupsFromIdToken = idTokenClaims.getClaim(groupsClaim);
+ if (groupsFromIdToken instanceof List) {
+ @SuppressWarnings("unchecked")
+ List<String> groups = (List<String>) groupsFromIdToken;
+ return groups;
+ }
+
+ if (userInfo != null) {
+ Object groupsFromUserInfo = userInfo.getClaim(groupsClaim);
+ if (groupsFromUserInfo instanceof List) {
+ @SuppressWarnings("unchecked")
+ List<String> groups = (List<String>) groupsFromUserInfo;
+ return groups;
+ }
+ }
+
+ return Collections.emptyList();
+ }
+
+ private UserType determineUserType(List<String> groups) {
+ List<String> adminGroups = oidcConfig.getUser().getAdminGroupMapping();
+ if (adminGroups != null && !adminGroups.isEmpty() && groups != null) {
+ for (String group : groups) {
+ if (adminGroups.contains(group)) {
+ return UserType.ADMIN_USER;
+ }
+ }
+ }
+
+ return UserType.GENERAL_USER;
+ }
+
+ private Scope parseScope(String scopeString) {
+ if (scopeString == null || scopeString.isEmpty()) {
+ return new Scope("openid", "profile", EMAIL_ATTRIBUTE);
+ }
+ return Scope.parse(scopeString);
+ }
+}
diff --git a/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/entity/HttpResponse.java b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcConfigProperties.java
similarity index 57%
copy from dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/entity/HttpResponse.java
copy to dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcConfigProperties.java
index 6ee3d29ee5..8e7b5e700d 100644
--- a/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/entity/HttpResponse.java
+++ b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcConfigProperties.java
@@ -15,18 +15,27 @@
* limitations under the License.
*/
-package org.apache.dolphinscheduler.api.test.entity;
+package org.apache.dolphinscheduler.api.security.impl.oidc;
+
+import java.util.Map;
-import lombok.AllArgsConstructor;
import lombok.Data;
-import lombok.NoArgsConstructor;
-@AllArgsConstructor
-@NoArgsConstructor
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.NestedConfigurationProperty;
+import org.springframework.stereotype.Component;
+
+/**
+ * OIDC configuration properties
+ */
@Data
-public class HttpResponse {
+@Component
+@ConfigurationProperties(prefix = "security.authentication.oidc")
+public class OidcConfigProperties {
- private int statusCode;
+ private boolean enable = false;
+ private Map<String, OidcProviderConfig> providers;
- private HttpResponseBody body;
+ @NestedConfigurationProperty
+ private OidcUserConfig user = new OidcUserConfig();
}
diff --git a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcProviderConfig.java b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcProviderConfig.java
new file mode 100644
index 0000000000..2bc449146d
--- /dev/null
+++ b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcProviderConfig.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.dolphinscheduler.api.security.impl.oidc;
+
+import lombok.Data;
+
+/**
+ * OIDC provider configuration
+ */
+@Data
+public class OidcProviderConfig {
+
+ /**
+ * Display the name for the provider (to show on the login button)
+ */
+ private String displayName;
+
+ private String issuerUri;
+
+ /**
+ * URI for the provider's icon (logo) to be displayed on the login button.
+ */
+ private String iconUri;
+
+ private String clientId;
+
+ private String clientSecret;
+
+ /**
+ * OIDC client authentication method (e.g., client_secret_basic, client_secret_post)
+ */
+ private String clientAuthenticationMethod = "client_secret_basic";
+
+ /**
+ * OIDC scopes (comma-separated)
+ */
+ private String scope = "openid,profile,email";
+
+ /**
+ * OIDC username attribute (claim to use as username)
+ */
+ private String userNameAttribute = "preferred_username";
+
+ /**
+ * OIDC groups claim (claim containing user groups/roles)
+ */
+ private String groupsClaim;
+}
diff --git a/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/entity/HttpResponse.java b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcUserConfig.java
similarity index 58%
copy from dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/entity/HttpResponse.java
copy to dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcUserConfig.java
index 6ee3d29ee5..6a9b3b30e3 100644
--- a/dolphinscheduler-api-test/dolphinscheduler-api-test-case/src/test/java/org/apache/dolphinscheduler/api/test/entity/HttpResponse.java
+++ b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcUserConfig.java
@@ -15,18 +15,35 @@
* limitations under the License.
*/
-package org.apache.dolphinscheduler.api.test.entity;
+package org.apache.dolphinscheduler.api.security.impl.oidc;
+
+import java.util.List;
-import lombok.AllArgsConstructor;
import lombok.Data;
-import lombok.NoArgsConstructor;
-@AllArgsConstructor
-@NoArgsConstructor
+/**
+ * OIDC user configuration
+ */
@Data
-public class HttpResponse {
+public class OidcUserConfig {
+
+ /**
+ * Whether to auto-create users if they don't exist
+ */
+ private boolean autoCreate = false;
+
+ /**
+ * Default tenant code for auto-created users
+ */
+ private String defaultTenantCode = "default";
- private int statusCode;
+ /**
+ * Default queue for auto-created users
+ */
+ private String defaultQueue = "default";
- private HttpResponseBody body;
+ /**
+ * Groups that map to an administrator role
+ */
+ private List<String> adminGroupMapping;
}
diff --git a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/service/UsersService.java b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/service/UsersService.java
index 61f6a138ba..a3bc375f71 100644
--- a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/service/UsersService.java
+++ b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/service/UsersService.java
@@ -108,12 +108,20 @@ public interface UsersService {
*
* @param loginUser login user
* @param pageNo page number
- * @param searchVal search avlue
+ * @param searchVal search value
* @param pageSize page size
* @return user list page
*/
Result queryUserList(User loginUser, String searchVal, Integer pageNo, Integer pageSize);
+ /**
+ * Update an existing user entity.
+ *
+ * @param user The user object to update.
+ * @return The updated user object.
+ */
+ User updateUser(User user);
+
User updateUser(User loginUser,
Integer userId,
String userName,
diff --git a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/service/impl/UsersServiceImpl.java b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/service/impl/UsersServiceImpl.java
index 2f499af17a..5a3738cac1 100644
--- a/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/service/impl/UsersServiceImpl.java
+++ b/dolphinscheduler-api/src/main/java/org/apache/dolphinscheduler/api/service/impl/UsersServiceImpl.java
@@ -339,6 +339,22 @@ public class UsersServiceImpl extends BaseServiceImpl implements UsersService {
return result;
}
+ @Override
+ @Transactional
+ public User updateUser(User user) {
+ if (user == null || user.getId() == null) {
+ throw new ServiceException(Status.USER_NOT_EXIST);
+ }
+ // Ensure the update time is set
+ user.setUpdateTime(new Date());
+ int updatedRows = userMapper.updateById(user);
+
+ if (updatedRows == 0) {
+ throw new ServiceException(Status.UPDATE_USER_ERROR);
+ }
+ return user;
+ }
+
@Override
@Transactional
public User updateUser(User loginUser,
diff --git a/dolphinscheduler-api/src/main/resources/application.yaml b/dolphinscheduler-api/src/main/resources/application.yaml
index 98255ff2e6..2dbda1fa81 100644
--- a/dolphinscheduler-api/src/main/resources/application.yaml
+++ b/dolphinscheduler-api/src/main/resources/application.yaml
@@ -129,6 +129,10 @@ registry:
digest: ~
api:
+ # The base URL for the API server, used for constructing callback URLs for features like OIDC.
+ # This should be the public-facing URL of the DolphinScheduler API.
+ base-url: http://127.0.0.1:12345/dolphinscheduler
+ ui-url: http://127.0.0.1:5173
audit-enable: false
# Traffic control, if you turn on this config, the maximum number of request/s will be limited.
# global max request number per second
@@ -167,7 +171,7 @@ api:
security:
authentication:
- # Authentication types (supported types: PASSWORD,LDAP,CASDOOR_SSO)
+ # Authentication types (supported types: PASSWORD, LDAP, CASDOOR_SSO, OIDC)
type: PASSWORD
# IF you set type `LDAP`, below config will be effective
ldap:
@@ -216,6 +220,29 @@ security:
callbackUrl: ""
iconUri: ""
provider: google
+ oidc:
+ enable: true
+ providers:
+ # registrationId used in URLs and internal mapping
+ keycloak:
+ display-name: "Login with Keycloak" # Text for UI Button
+ issuer-uri: http://keycloak:8080/realms/dolphinscheduler
+ icon-uri: "/images/providers-icon/keycloak.png"
+ client-id: dolphinscheduler-client
+ client-secret: dolphinscheduler-client-secret
+ # Optional: Specify client auth method (e.g., client_secret_basic, client_secret_post). Defaults if omitted.
+ # client-authentication-method: client_secret_basic
+ scope: openid, profile, email, groups # Default: openid, profile, email
+ user-name-attribute: preferred_username # Claim to use as username (e.g., email, preferred_username)
+ groups-claim: groups # Optional: Claim containing user groups/roles
+ # Add more providers here (e.g., okta, azure, google)
+ # okta: ....
+ user: # Settings for auto-provisioning OIDC users
+ auto-create: false # Create a DS user if not found? Default: false
+ default-tenant-code: "default" # Tenant code for auto-created users
+ default-queue: "default" # Queue for auto-created users (if needed by permissions)
+ admin-group-mapping: # Groups that map to an administrator role
+ - dolphinscheduler-admins
casdoor:
# Your Casdoor server url
endpoint: ""
diff --git a/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/controller/LoginControllerOidcTest.java b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/controller/LoginControllerOidcTest.java
new file mode 100644
index 0000000000..a113ee6ebf
--- /dev/null
+++ b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/controller/LoginControllerOidcTest.java
@@ -0,0 +1,326 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.dolphinscheduler.api.controller;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.apache.dolphinscheduler.api.enums.Status;
+import org.apache.dolphinscheduler.api.security.Authenticator;
+import org.apache.dolphinscheduler.api.security.impl.oidc.OidcAuthenticator;
+import org.apache.dolphinscheduler.api.security.impl.oidc.OidcConfigProperties;
+import org.apache.dolphinscheduler.api.security.impl.oidc.OidcProviderConfig;
+import org.apache.dolphinscheduler.api.security.impl.pwd.PasswordAuthenticator;
+import org.apache.dolphinscheduler.api.service.SessionService;
+import org.apache.dolphinscheduler.common.constants.Constants;
+import org.apache.dolphinscheduler.dao.entity.Session;
+import org.apache.dolphinscheduler.dao.entity.User;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.ApplicationContext;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+
+/**
+ * Unit tests for {@link LoginController} with OIDC authentication enabled.
+ */
+@TestPropertySource(properties = {
+ "security.authentication.type=OIDC",
+ "security.authentication.oidc.enable=true",
+ "api.ui-url=http://ds.ui"
+})
+public class LoginControllerOidcTest extends AbstractControllerTest {
+
+ private final String providerId = "keycloak";
+ private final String uiUrl = "http://ds.ui";
+
+ @MockBean
+ private OidcAuthenticator oidcAuthenticator;
+
+ @MockBean
+ private SessionService sessionService;
+
+ @MockBean
+ private OidcConfigProperties oidcConfigProperties;
+
+ @Autowired
+ private LoginController loginController;
+
+ @Autowired
+ private ApplicationContext applicationContext;
+
+ @BeforeEach
+ public void setUp() {
+ ReflectionTestUtils.setField(loginController, "authenticator", oidcAuthenticator);
+ ReflectionTestUtils.setField(loginController, "oidcConfigProperties", oidcConfigProperties);
+ }
+
+ @Test
+ public void testSsoLogin_whenOidcEnabled_returnsSuccessWithNull() throws Exception {
+ mockMvc.perform(get("/login/sso"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(Status.SUCCESS.getCode()))
+ .andExpect(jsonPath("$.data").isEmpty());
+ }
+
+ @Test
+ public void testGetOidcProviders_whenEnabled_returnsProviderList() throws Exception {
+ Map<String, OidcProviderConfig> providers = new HashMap<>();
+ OidcProviderConfig providerConfig = new OidcProviderConfig();
+ providerConfig.setDisplayName("Login with Keycloak");
+ providers.put(providerId, providerConfig);
+
+ when(oidcConfigProperties.isEnable()).thenReturn(true);
+ when(oidcConfigProperties.getProviders()).thenReturn(providers);
+
+ mockMvc.perform(get("/oidc-providers"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(Status.SUCCESS.getCode()))
+ .andExpect(jsonPath("$.data[0].id").value(providerId))
+ .andExpect(jsonPath("$.data[0].displayName").value("Login with Keycloak"));
+ }
+
+ @Test
+ public void testGetOidcProviders_returnsIconUri() throws Exception {
+ Map<String, OidcProviderConfig> providers = new HashMap<>();
+ OidcProviderConfig providerConfig = new OidcProviderConfig();
+ providerConfig.setDisplayName("Login with Keycloak");
+ providerConfig.setIconUri("/images/providers-icon/keycloak.png");
+ providers.put(providerId, providerConfig);
+
+ when(oidcConfigProperties.isEnable()).thenReturn(true);
+ when(oidcConfigProperties.getProviders()).thenReturn(providers);
+
+ mockMvc.perform(get("/oidc-providers"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(Status.SUCCESS.getCode()))
+ .andExpect(jsonPath("$.data[0].id").value(providerId))
+ .andExpect(jsonPath("$.data[0].displayName").value("Login with Keycloak"))
+ .andExpect(jsonPath("$.data[0].iconUri").value("/images/providers-icon/keycloak.png"));
+ }
+
+ @Test
+ public void testGetOidcProviders_whenDisabled_returnsEmptyList() throws Exception {
+ when(oidcConfigProperties.isEnable()).thenReturn(false);
+
+ mockMvc.perform(get("/oidc-providers"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(Status.SUCCESS.getCode()))
+ .andExpect(jsonPath("$.data").isEmpty());
+ }
+
+ @Test
+ public void testRedirectToOidc() throws Exception {
+ String state = providerId + ":" + UUID.randomUUID();
+ String authUrl = "http://oidc-provider.com/auth?state=" + state;
+
+ when(oidcAuthenticator.getSignInUrl(anyString())).thenReturn(authUrl);
+
+ Map<String, OidcProviderConfig> providers = new HashMap<>();
+ providers.put(providerId, new OidcProviderConfig());
+ when(oidcConfigProperties.getProviders()).thenReturn(providers);
+
+ mockMvc.perform(get("/oauth2/authorization/{providerId}", providerId))
+ .andExpect(status().isFound())
+ .andExpect(redirectedUrl(authUrl));
+ }
+
+ @Test
+ public void testRedirectToOidc_withAuthenticatorError() throws Exception {
+ when(oidcAuthenticator.getSignInUrl(anyString())).thenReturn(null);
+
+ Map<String, OidcProviderConfig> providers = new HashMap<>();
+ providers.put(providerId, new OidcProviderConfig());
+ when(oidcConfigProperties.getProviders()).thenReturn(providers);
+
+ mockMvc.perform(get("/oauth2/authorization/{providerId}", providerId))
+ .andExpect(status().isFound())
+ .andExpect(redirectedUrl("/dolphinscheduler/ui/#/login?error=oidc_authorization_url_null"));
+ }
+
+ @Test
+ public void testRedirectToOidc_withInvalidProviderId() throws Exception {
+ String invalidProviderId = "invalid";
+ when(oidcConfigProperties.getProviders()).thenReturn(new HashMap<>());
+
+ mockMvc.perform(get("/oauth2/authorization/{providerId}", invalidProviderId))
+ .andExpect(status().isFound())
+ .andExpect(redirectedUrl("/dolphinscheduler/ui/#/login?error=invalid_provider"));
+ }
+
+ @Test
+ public void testHandleOidcCallback_withSuccess() throws Exception {
+ String code = "testCode";
+ String state = providerId + ":" + UUID.randomUUID();
+ String sessionId = UUID.randomUUID().toString();
+ User user = new User();
+ user.setId(1);
+ user.setUserName("oidc_user");
+
+ Session session = new Session();
+ session.setId(sessionId);
+ session.setUserId(user.getId());
+
+ when(oidcAuthenticator.login(state, code)).thenReturn(user);
+ when(sessionService.createSessionIfAbsent(user)).thenReturn(session);
+
+ String expectedRedirectUrl = String.format("%s/login?sessionId=%s&authType=%s", uiUrl, sessionId, "oidc");
+
+ performOidcCallback(code, null, state)
+ .andExpect(status().isFound())
+ .andExpect(redirectedUrl(expectedRedirectUrl));
+ }
+
+ @Test
+ public void testHandleOidcCallback_withLoginErrorFromProvider() throws Exception {
+ String error = "access_denied";
+ String state = providerId + ":" + UUID.randomUUID();
+ String expectedRedirectUrl = "/dolphinscheduler/ui/#/login?error=oidc_login_failed";
+
+ performOidcCallback(null, error, state)
+ .andExpect(status().isFound())
+ .andExpect(redirectedUrl(expectedRedirectUrl));
+ }
+
+ @Test
+ public void testHandleOidcCallback_withErrorContainingNewlinesTabs() throws Exception {
+ String error = "access_denied\n\t";
+ String state = providerId + ":" + UUID.randomUUID();
+ String expectedRedirectUrl = "/dolphinscheduler/ui/#/login?error=oidc_login_failed";
+
+ performOidcCallback(null, error, state)
+ .andExpect(status().isFound())
+ .andExpect(redirectedUrl(expectedRedirectUrl));
+ }
+
+ @Test
+ public void testHandleOidcCallback_withMissingCode() throws Exception {
+ String state = providerId + ":" + UUID.randomUUID();
+ String expectedRedirectUrl = "/dolphinscheduler/ui/#/login?error=oidc_missing_code";
+
+ performOidcCallback(null, null, state)
+ .andExpect(status().isFound())
+ .andExpect(redirectedUrl(expectedRedirectUrl));
+ }
+
+ @Test
+ public void testHandleOidcCallback_whenAuthenticatorFails() throws Exception {
+ String code = "testCode";
+ String state = providerId + ":" + UUID.randomUUID();
+ String expectedRedirectUrl = "/dolphinscheduler/ui/#/login?error=oidc_authentication_failed";
+
+ when(oidcAuthenticator.login(state, code)).thenReturn(null);
+
+ performOidcCallback(code, null, state)
+ .andExpect(status().isFound())
+ .andExpect(redirectedUrl(expectedRedirectUrl));
+ }
+
+ @Test
+ public void testHandleOidcCallback_withCriticalException() throws Exception {
+ String code = "testCode";
+ String state = providerId + ":" + UUID.randomUUID();
+ String expectedRedirectUrl = "/dolphinscheduler/ui/#/login?error=oidc_critical_error";
+
+ when(oidcAuthenticator.login(state, code)).thenThrow(new RuntimeException("Critical DB error"));
+
+ performOidcCallback(code, null, state)
+ .andExpect(status().isFound())
+ .andExpect(redirectedUrl(expectedRedirectUrl));
+ }
+
+ @Test
+ public void testHandleOidcCallback_withNonOidcAuthenticator() throws Exception {
+ Authenticator passwordAuthenticator = new PasswordAuthenticator();
+ ReflectionTestUtils.setField(loginController, "authenticator", passwordAuthenticator);
+
+ String code = "testCode";
+ String state = providerId + ":" + UUID.randomUUID();
+ String expectedRedirectUrl = "/dolphinscheduler/ui/#/login?error=oidc_not_enabled";
+
+ performOidcCallback(code, null, state)
+ .andExpect(status().isFound())
+ .andExpect(redirectedUrl(expectedRedirectUrl));
+
+ ReflectionTestUtils.setField(loginController, "authenticator", oidcAuthenticator);
+ }
+
+ @Test
+ public void testHandleOidcCallback_withInvalidState() throws Exception {
+ String code = "testCode";
+ String state = "otherProvider:" + UUID.randomUUID();
+ mockMvc.perform(get("/login/oauth2/code/{providerId}", providerId)
+ .param("code", code)
+ .param("state", state))
+ .andExpect(status().isFound())
+ .andExpect(redirectedUrl("/dolphinscheduler/ui/#/login?error=oidc_invalid_state"));
+ }
+
+ @Test
+ public void testRedirectToOidc_StateStoredInSession() throws Exception {
+ String authUrl = "http://oidc-provider.com/auth?state=fakestate";
+ when(oidcAuthenticator.getSignInUrl(anyString())).thenReturn(authUrl);
+ Map<String, OidcProviderConfig> providers = new HashMap<>();
+ providers.put(providerId, new OidcProviderConfig());
+ when(oidcConfigProperties.getProviders()).thenReturn(providers);
+
+ mockMvc.perform(get("/oauth2/authorization/{providerId}", providerId))
+ .andExpect(status().isFound())
+ .andExpect(redirectedUrl(authUrl))
+ .andExpect(result -> {
+ javax.servlet.http.HttpSession session = result.getRequest().getSession(false);
+ Assertions.assertNotNull(session, "Session should exist");
+ Object storedState = session.getAttribute(Constants.SSO_LOGIN_USER_STATE);
+ Assertions.assertNotNull(storedState, "State should be stored in session");
+ Assertions.assertTrue(storedState.toString().startsWith(providerId + ":"),
+ "Stored state should start with providerId prefix");
+ });
+ }
+
+ private ResultActions performOidcCallback(String code, String error, String state) throws Exception {
+ MockHttpServletRequestBuilder requestBuilder =
+ get("/login/oauth2/code/{providerId}", providerId)
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .param("state", state)
+ .sessionAttr(Constants.SSO_LOGIN_USER_STATE, state);
+
+ if (code != null) {
+ requestBuilder.param("code", code);
+ }
+ if (error != null) {
+ requestBuilder.param("error", error);
+ }
+
+ return mockMvc.perform(requestBuilder);
+ }
+}
diff --git a/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/controller/LoginControllerTest.java b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/controller/LoginControllerTest.java
index c954f22498..aca9c0d19b 100644
--- a/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/controller/LoginControllerTest.java
+++ b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/controller/LoginControllerTest.java
@@ -17,32 +17,59 @@
package org.apache.dolphinscheduler.api.controller;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import org.apache.dolphinscheduler.api.configuration.ApiConfig;
+import org.apache.dolphinscheduler.api.configuration.OAuth2Configuration;
+import org.apache.dolphinscheduler.api.configuration.OAuth2Configuration.OAuth2ClientProperties;
import org.apache.dolphinscheduler.api.enums.Status;
+import org.apache.dolphinscheduler.api.security.Authenticator;
+import org.apache.dolphinscheduler.api.security.impl.AbstractSsoAuthenticator;
+import org.apache.dolphinscheduler.api.service.SessionService;
+import org.apache.dolphinscheduler.api.service.UsersService;
import org.apache.dolphinscheduler.api.utils.Result;
import org.apache.dolphinscheduler.common.constants.Constants;
+import org.apache.dolphinscheduler.common.enums.UserType;
+import org.apache.dolphinscheduler.common.model.OkHttpResponse;
import org.apache.dolphinscheduler.common.utils.JSONUtils;
+import org.apache.dolphinscheduler.common.utils.OkHttpUtils;
import org.apache.dolphinscheduler.dao.entity.Session;
+import org.apache.dolphinscheduler.dao.entity.User;
import org.apache.dolphinscheduler.dao.repository.SessionDao;
import org.apache.http.HttpStatus;
import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
import java.util.Map;
+import java.util.Optional;
import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
+import org.springframework.lang.NonNull;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.util.LinkedMultiValueMap;
@@ -78,6 +105,28 @@ public class LoginControllerTest extends AbstractControllerTest {
Assertions.assertNotEquals(Constants.SECURITY_CONFIG_TYPE_LDAP, data.get(Constants.SECURITY_CONFIG_TYPE));
}
+ @Test
+ public void testLogin_withNullUserName() throws Exception {
+ MultiValueMap<String, String> paramsMap = new LinkedMultiValueMap<>();
+ paramsMap.add("userName", "");
+ paramsMap.add("userPassword", "dolphinscheduler123");
+
+ mockMvc.perform(post("/login").params(paramsMap))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(Status.USER_NAME_NULL.getCode()));
+ }
+
+ @Test
+ public void testLogin_withInvalidCredentials() throws Exception {
+ MultiValueMap<String, String> paramsMap = new LinkedMultiValueMap<>();
+ paramsMap.add("userName", "admin");
+ paramsMap.add("userPassword", "invalid_password");
+
+ mockMvc.perform(post("/login").params(paramsMap))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(Status.USER_NAME_PASSWD_ERROR.getCode()));
+ }
+
@Test
public void testSignOut() throws Exception {
MultiValueMap<String, String> paramsMap = new LinkedMultiValueMap<>();
@@ -129,4 +178,235 @@ public class LoginControllerTest extends AbstractControllerTest {
Result result = JSONUtils.parseObject(mvcResult.getResponse().getContentAsString(), Result.class);
Assertions.assertEquals(Status.SUCCESS.getCode(), result.getCode().intValue());
}
+
+ @Test
+ public void testSsoLogin_WithPasswordAuthenticator_ReturnsSuccess() throws Exception {
+ mockMvc.perform(get("/login/sso"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(Status.SUCCESS.getCode()));
+ }
+
+ @Test
+ public void testLogin_withEmptyIp() {
+ Authenticator authenticator = new Authenticator() {
+
+ @Override
+ public Result<Map<String, String>> authenticate(@org.springframework.lang.NonNull String username,
+ String password,
+ @org.springframework.lang.NonNull String ip) {
+ Result<Map<String, String>> res = new Result<>();
+ res.setCode(Status.SUCCESS.getCode());
+ res.setMsg(Status.SUCCESS.getMsg());
+ res.setData(new HashMap<>());
+ return res;
+ }
+
+ @Override
+ public User getAuthUser(HttpServletRequest request) {
+ return null;
+ }
+ };
+ SessionService sessionService = mock(SessionService.class);
+ UsersService usersService = mock(UsersService.class);
+ ApiConfig apiConfig = mock(ApiConfig.class);
+ LoginController controller =
+ new LoginController(sessionService, authenticator, usersService, Optional.empty(), Optional.empty(),
+ apiConfig);
+
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ when(request.getHeader(Constants.HTTP_X_FORWARDED_FOR)).thenReturn(null);
+ when(request.getHeader(Constants.HTTP_X_REAL_IP)).thenReturn(null);
+ when(request.getRemoteAddr()).thenReturn(null); // force null
+
+ Result result = controller.login("admin", "pwd", request, response);
+ Assertions.assertEquals(Status.IP_IS_EMPTY.getCode(), result.getCode());
+ }
+
+ // OAuth2 tests
+ @Test
+ public void testLoginByAuth2_Success() throws Exception {
+ OAuth2Configuration oAuth2Configuration = new OAuth2Configuration();
+ OAuth2ClientProperties clientProps = new OAuth2ClientProperties();
+ clientProps.setClientId("cid");
+ clientProps.setClientSecret("csecret");
+ clientProps.setRedirectUri("http://localhost/redirect");
+ clientProps.setTokenUri("http://oauth/token");
+ clientProps.setUserInfoUri("http://oauth/userinfo");
+ clientProps.setCallbackUrl("http://ui/callback");
+ oAuth2Configuration.getProvider().put("github", clientProps);
+
+ Authenticator auth = new Authenticator() {
+
+ @Override
+ public Result<Map<String, String>> authenticate(@org.springframework.lang.NonNull String username,
+ String password,
+ @org.springframework.lang.NonNull String ip) {
+ return null;
+ }
+
+ @Override
+ public User getAuthUser(HttpServletRequest request) {
+ return null;
+ }
+ };
+ SessionService sessionService = mock(SessionService.class);
+ UsersService usersService = mock(UsersService.class);
+ ApiConfig apiConfig = mock(ApiConfig.class);
+ LoginController controller =
+ new LoginController(sessionService, auth, usersService, Optional.of(oAuth2Configuration),
+ Optional.empty(), apiConfig);
+
+ try (MockedStatic<OkHttpUtils> okHttpMock = Mockito.mockStatic(OkHttpUtils.class)) {
+ OkHttpResponse tokenResp = new OkHttpResponse(200, "{\"access_token\":\"tok123\"}");
+ okHttpMock.when(() -> OkHttpUtils.post(
+ eq("http://oauth/token"),
+ any(), any(), any(), anyInt(), anyInt(), anyInt()))
+ .thenReturn(tokenResp);
+ OkHttpResponse userInfoResp = new OkHttpResponse(200, "{\"login\":\"oauth_user\"}");
+ okHttpMock.when(() -> OkHttpUtils.get(
+ eq("http://oauth/userinfo"),
+ any(), any(), anyInt(), anyInt(), anyInt()))
+ .thenReturn(userInfoResp);
+
+ User user = new User();
+ user.setUserName("oauth_user");
+ when(usersService.getUserByUserName("oauth_user")).thenReturn(null);
+ when(usersService.createUser(UserType.GENERAL_USER, "oauth_user", null)).thenReturn(user);
+ Session session = new Session();
+ session.setId("sid-1");
+ when(sessionService.createSessionIfAbsent(user)).thenReturn(session);
+
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = new MockHttpServletResponse();
+ controller.loginByAuth2("code123", "github", request, response);
+ Assertions.assertEquals(302, ((MockHttpServletResponse) response).getStatus());
+ String redirect = ((MockHttpServletResponse) response).getRedirectedUrl();
+ Assertions.assertTrue(redirect.startsWith("http://ui/callback?sessionId=sid-1"));
+ }
+ }
+
+ @Test
+ public void testLoginByAuth2_Failure() throws Exception {
+ OAuth2Configuration oAuth2Configuration = new OAuth2Configuration();
+ OAuth2ClientProperties clientProps = new OAuth2ClientProperties();
+ clientProps.setClientId("cid");
+ clientProps.setClientSecret("csecret");
+ clientProps.setRedirectUri("http://localhost/redirect");
+ clientProps.setTokenUri("http://oauth/token");
+ clientProps.setUserInfoUri("http://oauth/userinfo");
+ clientProps.setCallbackUrl("http://ui/callback");
+ oAuth2Configuration.getProvider().put("github", clientProps);
+
+ Authenticator auth = new Authenticator() {
+
+ @Override
+ public Result<Map<String, String>> authenticate(@NonNull String username,
+ String password,
+ @NonNull String ip) {
+ return null;
+ }
+
+ @Override
+ public User getAuthUser(HttpServletRequest request) {
+ return null;
+ }
+ };
+ SessionService sessionService = mock(SessionService.class);
+ UsersService usersService = mock(UsersService.class);
+ ApiConfig apiConfig = mock(ApiConfig.class);
+ LoginController controller =
+ new LoginController(sessionService, auth, usersService, Optional.of(oAuth2Configuration),
+ Optional.empty(), apiConfig);
+
+ try (MockedStatic<OkHttpUtils> okHttpMock = Mockito.mockStatic(OkHttpUtils.class)) {
+ okHttpMock.when(() -> OkHttpUtils.post(
+ eq("http://oauth/token"),
+ any(), any(), any(), anyInt(), anyInt(), anyInt()))
+ .thenThrow(new RuntimeException("network error"));
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = new MockHttpServletResponse();
+ controller.loginByAuth2("code123", "github", request, response);
+ Assertions.assertEquals(302, ((MockHttpServletResponse) response).getStatus());
+ String redirect = ((MockHttpServletResponse) response).getRedirectedUrl();
+ Assertions.assertTrue(
+ redirect.contains("error=oauth2 auth error") || redirect.contains("error=oauth2%20auth%20error"),
+ "Redirect URL should contain error parameter");
+ }
+ }
+
+ @Test
+ void testGetOauth2ProviderWithNoConfig() {
+ Authenticator authenticator = mock(Authenticator.class);
+ SessionService sessionService = mock(SessionService.class);
+ UsersService usersService = mock(UsersService.class);
+ ApiConfig apiConfig = mock(ApiConfig.class);
+ LoginController controller =
+ new LoginController(sessionService, authenticator, usersService, Optional.empty(), Optional.empty(),
+ apiConfig);
+
+ Result<List<OAuth2ClientProperties>> result = controller.oauth2Provider();
+ Assertions.assertEquals(Status.SUCCESS.getCode(), result.getCode());
+ Assertions.assertTrue(result.getData().isEmpty());
+ }
+
+ @Test
+ void testGetClientIpAddress_variousScenarios() {
+ HttpServletRequest request1 = mock(HttpServletRequest.class);
+ when(request1.getHeader(Constants.HTTP_X_FORWARDED_FOR)).thenReturn("192.168.1.1, 10.0.0.1");
+ Assertions.assertEquals("192.168.1.1", BaseController.getClientIpAddress(request1));
+
+ HttpServletRequest request2 = mock(HttpServletRequest.class);
+ when(request2.getHeader(Constants.HTTP_X_FORWARDED_FOR)).thenReturn("unknown");
+ when(request2.getHeader(Constants.HTTP_X_REAL_IP)).thenReturn("192.168.1.2");
+ Assertions.assertEquals("192.168.1.2", BaseController.getClientIpAddress(request2));
+
+ HttpServletRequest request3 = mock(HttpServletRequest.class);
+ when(request3.getHeader(Constants.HTTP_X_FORWARDED_FOR)).thenReturn("");
+ when(request3.getHeader(Constants.HTTP_X_REAL_IP)).thenReturn("192.168.1.3");
+ Assertions.assertEquals("192.168.1.3", BaseController.getClientIpAddress(request3));
+
+ HttpServletRequest request4 = mock(HttpServletRequest.class);
+ when(request4.getHeader(Constants.HTTP_X_FORWARDED_FOR)).thenReturn(null);
+ when(request4.getHeader(Constants.HTTP_X_REAL_IP)).thenReturn(null);
+ when(request4.getRemoteAddr()).thenReturn("192.168.1.4");
+ Assertions.assertEquals("192.168.1.4", BaseController.getClientIpAddress(request4));
+
+ HttpServletRequest request5 = mock(HttpServletRequest.class);
+ when(request5.getHeader(Constants.HTTP_X_FORWARDED_FOR)).thenReturn("192.168.1.5");
+ Assertions.assertEquals("192.168.1.5", BaseController.getClientIpAddress(request5));
+ }
+
+ private static class DummySsoAuthenticator extends AbstractSsoAuthenticator {
+
+ @Override
+ public User login(@org.springframework.lang.NonNull String state, String code) {
+ return null;
+ }
+
+ @Override
+ public String getSignInUrl(String state) {
+ return "http://sso.example.com/auth?state=" + state;
+ }
+ }
+
+ @Test
+ public void testSsoLogin_WithAbstractSsoAuthenticator_ReturnsSignInUrl() {
+ DummySsoAuthenticator dummy = new DummySsoAuthenticator();
+ SessionService sessionService = mock(SessionService.class);
+ UsersService usersService = mock(UsersService.class);
+ ApiConfig apiConfig = mock(ApiConfig.class);
+ LoginController controller =
+ new LoginController(sessionService, dummy, usersService, Optional.empty(), Optional.empty(),
+ apiConfig);
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpSession session = mock(HttpSession.class);
+ when(request.getSession()).thenReturn(session);
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(null);
+
+ Result result = controller.ssoLogin(request);
+ Assertions.assertEquals(Status.SUCCESS.getCode(), result.getCode());
+ Assertions.assertNotNull(result.getData());
+ verify(session).setAttribute(eq(Constants.SSO_LOGIN_USER_STATE), Mockito.any());
+ }
}
diff --git a/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/SecurityConfigCasdoorTest.java b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/SecurityConfigCasdoorTest.java
new file mode 100644
index 0000000000..93c88528a2
--- /dev/null
+++ b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/SecurityConfigCasdoorTest.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.dolphinscheduler.api.security;
+
+import org.apache.dolphinscheduler.api.controller.AbstractControllerTest;
+import org.apache.dolphinscheduler.api.security.impl.sso.CasdoorAuthenticator;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.TestPropertySource;
+
+@TestPropertySource(properties = {
+ "security.authentication.type=CASDOOR_SSO",
+ "casdoor.endpoint=http://localhost:8000",
+ "casdoor.client-id=client-id",
+ "casdoor.client-secret=client-secret",
+ "casdoor.certificate=public-key",
+ "casdoor.organization-name=built-in",
+ "casdoor.application-name=app-built-in",
+ "casdoor.redirect-url=http://localhost:8888/view/login/index.html",
+ "security.authentication.casdoor.user.admin=admin"
+})
+public class SecurityConfigCasdoorTest extends AbstractControllerTest {
+
+ @Autowired
+ private SecurityConfig securityConfig;
+
+ @Test
+ public void testCasdoorAuthenticatorBean() {
+ Authenticator authenticator = securityConfig.authenticator();
+ Assertions.assertNotNull(authenticator);
+ Assertions.assertInstanceOf(CasdoorAuthenticator.class, authenticator);
+ Assertions.assertEquals("CASDOOR_SSO", securityConfig.getType());
+ }
+}
diff --git a/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/SecurityConfigEdgeCasesTest.java b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/SecurityConfigEdgeCasesTest.java
new file mode 100644
index 0000000000..c48f033619
--- /dev/null
+++ b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/SecurityConfigEdgeCasesTest.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.dolphinscheduler.api.security;
+
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+
+import org.apache.dolphinscheduler.api.configuration.ApiConfig;
+
+import java.lang.reflect.Field;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
+
+/**
+ * Edge case tests for {@link SecurityConfig} that are not covered by Spring Boot integration tests.
+ */
+public class SecurityConfigEdgeCasesTest {
+
+ private SecurityConfig newSecurityConfigWithType(String typeValue, Optional<?> oidcConfigOpt,
+ Optional<?> usersServiceOpt) throws Exception {
+ @SuppressWarnings("unchecked")
+ Optional orgOidc = (Optional) oidcConfigOpt;
+ @SuppressWarnings("unchecked")
+ Optional orgUsers = (Optional) usersServiceOpt;
+ AutowireCapableBeanFactory beanFactory = mock(AutowireCapableBeanFactory.class);
+ doNothing().when(beanFactory).autowireBean(Mockito.any());
+ ApiConfig apiConfig = mock(ApiConfig.class);
+ SecurityConfig securityConfig = new SecurityConfig(beanFactory, orgOidc, orgUsers, apiConfig);
+ Field typeField = SecurityConfig.class.getDeclaredField("type");
+ typeField.setAccessible(true);
+ typeField.set(securityConfig, typeValue);
+ return securityConfig;
+ }
+
+ @Test
+ public void testBlankTypeDefaultsToPassword() throws Exception {
+ SecurityConfig securityConfig = newSecurityConfigWithType(" ", Optional.empty(), Optional.empty());
+ Authenticator authenticator = securityConfig.authenticator();
+ Assertions.assertNotNull(authenticator, "Authenticator should not be null for blank type");
+ // getType should still return the original injected value (blank) per current implementation
+ Assertions.assertEquals(" ", securityConfig.getType());
+ }
+
+ @Test
+ public void testInvalidTypeThrowsException() throws Exception {
+ SecurityConfig securityConfig =
+ newSecurityConfigWithType("NOT_A_VALID_TYPE", Optional.empty(), Optional.empty());
+ Assertions.assertThrows(IllegalArgumentException.class, securityConfig::authenticator);
+ }
+
+ @Test
+ public void testOidcTypeMissingBeansThrowsIllegalState() throws Exception {
+ SecurityConfig securityConfig = newSecurityConfigWithType("OIDC", Optional.empty(), Optional.empty());
+ Assertions.assertThrows(IllegalStateException.class, securityConfig::authenticator);
+ }
+}
diff --git a/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/SecurityConfigOidcTest.java b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/SecurityConfigOidcTest.java
new file mode 100644
index 0000000000..27c73313eb
--- /dev/null
+++ b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/SecurityConfigOidcTest.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.dolphinscheduler.api.security;
+
+import org.apache.dolphinscheduler.api.controller.AbstractControllerTest;
+import org.apache.dolphinscheduler.api.security.impl.oidc.OidcAuthenticator;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.TestPropertySource;
+
+@TestPropertySource(properties = {
+ "security.authentication.type=OIDC"
+})
+public class SecurityConfigOidcTest extends AbstractControllerTest {
+
+ @Autowired
+ private SecurityConfig securityConfig;
+
+ @Test
+ public void testOidcAuthenticatorBean() {
+ Authenticator authenticator = securityConfig.authenticator();
+
+ Assertions.assertNotNull(authenticator);
+ Assertions.assertInstanceOf(OidcAuthenticator.class, authenticator);
+ Assertions.assertEquals("OIDC", securityConfig.getType());
+ }
+}
diff --git a/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcAuthenticatorTest.java b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcAuthenticatorTest.java
new file mode 100644
index 0000000000..628ff424c2
--- /dev/null
+++ b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcAuthenticatorTest.java
@@ -0,0 +1,1034 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.dolphinscheduler.api.security.impl.oidc;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.apache.dolphinscheduler.api.configuration.ApiConfig;
+import org.apache.dolphinscheduler.api.service.UsersService;
+import org.apache.dolphinscheduler.common.constants.Constants;
+import org.apache.dolphinscheduler.common.enums.UserType;
+import org.apache.dolphinscheduler.dao.entity.User;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.text.ParseException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpSession;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.util.ReflectionUtils;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import com.nimbusds.jwt.JWT;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.PlainJWT;
+import com.nimbusds.oauth2.sdk.ErrorObject;
+import com.nimbusds.oauth2.sdk.Scope;
+import com.nimbusds.oauth2.sdk.TokenErrorResponse;
+import com.nimbusds.oauth2.sdk.TokenRequest;
+import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+import com.nimbusds.oauth2.sdk.id.Issuer;
+import com.nimbusds.oauth2.sdk.id.Subject;
+import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
+import com.nimbusds.openid.connect.sdk.OIDCTokenResponse;
+import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser;
+import com.nimbusds.openid.connect.sdk.UserInfoErrorResponse;
+import com.nimbusds.openid.connect.sdk.UserInfoRequest;
+import com.nimbusds.openid.connect.sdk.UserInfoResponse;
+import com.nimbusds.openid.connect.sdk.UserInfoSuccessResponse;
+import com.nimbusds.openid.connect.sdk.claims.IDTokenClaimsSet;
+import com.nimbusds.openid.connect.sdk.claims.UserInfo;
+import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
+import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
+
+@MockitoSettings(strictness = Strictness.LENIENT)
+@ExtendWith(MockitoExtension.class)
+public class OidcAuthenticatorTest {
+
+ @InjectMocks
+ private OidcAuthenticator oidcAuthenticator;
+
+ @Mock
+ private OidcConfigProperties oidcConfigProperties;
+
+ @Mock
+ private UsersService usersService;
+
+ @Mock
+ private ApiConfig apiConfig;
+
+ @Mock
+ private HttpServletRequest request;
+ @Mock
+ private HttpSession session;
+ @Mock
+ private ServletRequestAttributes attributes;
+
+ private final String providerId = "keycloak";
+ private final String username = "oidc_user";
+ private final String email = "oidc_user@example.com";
+ private final String code = "test_auth_code";
+
+ @BeforeEach
+ public void setUp() {
+ when(attributes.getRequest()).thenReturn(request);
+ when(request.getSession()).thenReturn(session);
+ RequestContextHolder.setRequestAttributes(attributes);
+ when(apiConfig.getBaseUrl()).thenReturn("http://localhost:12345/dolphinscheduler");
+ }
+
+ @Test
+ public void testLogin_Success_NewAdminUser() throws Exception {
+ String state = providerId + ":" + UUID.randomUUID();
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(state);
+ when(usersService.getUserByUserName(username)).thenReturn(null);
+
+ mockOidcConfiguration(providerId, true, Collections.singletonList("admin-group"));
+
+ User newUser = new User();
+ newUser.setUserName(username);
+ newUser.setUserType(UserType.ADMIN_USER);
+ when(usersService.createUser(UserType.ADMIN_USER, username, email)).thenReturn(newUser);
+
+ executeLoginWithMocks(() -> {
+ User resultUser = oidcAuthenticator.login(state, code);
+
+ Assertions.assertNotNull(resultUser);
+ Assertions.assertEquals(username, resultUser.getUserName());
+ Assertions.assertEquals(UserType.ADMIN_USER, resultUser.getUserType());
+ verify(usersService).createUser(UserType.ADMIN_USER, username, email);
+ }, Collections.singletonList("admin-group"), true, true, "http://fake-issuer.com");
+ }
+
+ @Test
+ public void testLogin_Success_ExistingUser() throws Exception {
+ String state = providerId + ":" + UUID.randomUUID();
+ User existingUser = new User();
+ existingUser.setUserName(username);
+ existingUser.setUserType(UserType.GENERAL_USER);
+
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(state);
+ when(usersService.getUserByUserName(username)).thenReturn(existingUser);
+ mockOidcConfiguration(providerId, true, Collections.emptyList());
+
+ executeLoginWithMocks(() -> {
+ User resultUser = oidcAuthenticator.login(state, code);
+ Assertions.assertNotNull(resultUser);
+ Assertions.assertEquals(username, resultUser.getUserName());
+ verify(usersService, never()).createUser(any(), anyString(), anyString());
+ }, Collections.emptyList(), true, true, "http://fake-issuer.com");
+ }
+
+ @Test
+ public void testLogin_Failure_AutoCreateOff() throws Exception {
+ String state = providerId + ":" + UUID.randomUUID();
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(state);
+ when(usersService.getUserByUserName(username)).thenReturn(null);
+
+ mockOidcConfiguration(providerId, false, Collections.emptyList());
+
+ executeLoginWithMocks(() -> {
+ User resultUser = oidcAuthenticator.login(state, code);
+
+ Assertions.assertNull(resultUser);
+ verify(usersService, never()).createUser(any(), anyString(), anyString());
+ }, Collections.emptyList(), true, true, "http://fake-issuer.com");
+ }
+
+ @Test
+ public void testLogin_Failure_TokenExchangeFails() throws Exception {
+ String state = providerId + ":" + UUID.randomUUID();
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(state);
+ mockOidcConfiguration(providerId, true, Collections.emptyList());
+
+ executeLoginWithMocks(() -> {
+ User resultUser = oidcAuthenticator.login(state, code);
+ Assertions.assertNull(resultUser);
+ }, Collections.emptyList(), false, true, "http://fake-issuer.com");
+ }
+
+ @Test
+ public void testLogin_Failure_UserInfoRequestFails() throws Exception {
+ String state = providerId + ":" + UUID.randomUUID();
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(state);
+ mockOidcConfiguration(providerId, true, Collections.emptyList());
+
+ executeLoginWithMocks(() -> {
+ User resultUser = oidcAuthenticator.login(state, code);
+ Assertions.assertNull(resultUser);
+ }, Collections.emptyList(), true, false, "http://fake-issuer.com");
+ }
+
+ @Test
+ public void testLogin_Failure_InvalidIdTokenIssuer() throws Exception {
+ String state = providerId + ":" + UUID.randomUUID();
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(state);
+ mockOidcConfiguration(providerId, true, Collections.emptyList());
+
+ executeLoginWithMocks(() -> {
+ User resultUser = oidcAuthenticator.login(state, code);
+ Assertions.assertNull(resultUser);
+ }, Collections.emptyList(), true, true, "http://invalid-issuer.com");
+ }
+
+ @Test
+ public void testLogin_Failure_StateMismatch() {
+ String providerState = providerId + ":" + UUID.randomUUID();
+ String sessionState = providerId + ":" + UUID.randomUUID();
+
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(sessionState);
+
+ User resultUser = oidcAuthenticator.login(providerState, code);
+
+ Assertions.assertNull(resultUser);
+ }
+
+ @Test
+ public void testLogin_Failure_InvalidProviderId() {
+ String invalidProviderId = "unknown-provider";
+ String state = invalidProviderId + ":" + UUID.randomUUID();
+
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(state);
+ mockOidcConfiguration(providerId, true, Collections.emptyList());
+
+ User resultUser = oidcAuthenticator.login(state, code);
+
+ Assertions.assertNull(resultUser);
+ }
+
+ @Test
+ public void testGetSignInUrl_Success() throws Exception {
+ String state = providerId + ":" + UUID.randomUUID();
+
+ OidcProviderConfig providerConfig = new OidcProviderConfig();
+ providerConfig.setIssuerUri("http://fake-issuer.com");
+ providerConfig.setClientId("test-client");
+ providerConfig.setClientSecret("test-secret");
+ providerConfig.setUserNameAttribute("preferred_username");
+ providerConfig.setGroupsClaim("groups");
+
+ Map<String, OidcProviderConfig> providers = new HashMap<>();
+ providers.put(providerId, providerConfig);
+
+ when(oidcConfigProperties.getProviders()).thenReturn(providers);
+
+ OIDCProviderMetadata metadata = mock(OIDCProviderMetadata.class);
+ when(metadata.getTokenEndpointURI()).thenReturn(new URI("http://fake/token"));
+ when(metadata.getUserInfoEndpointURI()).thenReturn(new URI("http://fake/userinfo"));
+ when(metadata.getIssuer()).thenReturn(new Issuer("http://fake-issuer.com"));
+ when(metadata.getAuthorizationEndpointURI()).thenReturn(new URI("http://fake/authorize"));
+
+ Field cacheField = OidcAuthenticator.class.getDeclaredField("providerMetadataCache");
+ ReflectionUtils.makeAccessible(cacheField);
+ @SuppressWarnings("unchecked")
+ Map<String, OIDCProviderMetadata> cache =
+ (Map<String, OIDCProviderMetadata>) ReflectionUtils.getField(cacheField, oidcAuthenticator);
+ cache.put(providerId, metadata);
+
+ String signInUrl = oidcAuthenticator.getSignInUrl(state);
+
+ Assertions.assertNotNull(signInUrl);
+ Assertions.assertTrue(signInUrl.contains("response_type=code"), "URL should contain response_type=code");
+ Assertions.assertTrue(signInUrl.contains("client_id=test-client"), "URL should contain client_id=test-client");
+
+ String encodedState = state.replace(":", "%3A");
+ Assertions.assertTrue(signInUrl.contains("state=" + encodedState),
+ "URL should contain encoded state=" + encodedState);
+ }
+
+ @Test
+ public void testGetSignInUrl_Failure() {
+ String invalidState = "invalid-state-format";
+
+ String signInUrl = oidcAuthenticator.getSignInUrl(invalidState);
+
+ Assertions.assertNull(signInUrl);
+ }
+
+ @Test
+ public void testParseScope() throws Exception {
+ Method parseScopeMethod =
+ OidcAuthenticator.class.getDeclaredMethod("parseScope", String.class);
+ ReflectionUtils.makeAccessible(parseScopeMethod);
+
+ Scope scope1 =
+ (Scope) parseScopeMethod.invoke(oidcAuthenticator, (String) null);
+ Assertions.assertTrue(scope1.contains("openid"));
+ Assertions.assertTrue(scope1.contains("profile"));
+ Assertions.assertTrue(scope1.contains("email"));
+
+ Scope scope2 =
+ (Scope) parseScopeMethod.invoke(oidcAuthenticator, "");
+ Assertions.assertTrue(scope2.contains("openid"));
+ Assertions.assertTrue(scope2.contains("profile"));
+ Assertions.assertTrue(scope2.contains("email"));
+
+ Scope scope3 =
+ (Scope) parseScopeMethod.invoke(oidcAuthenticator, "openid,custom");
+ Assertions.assertTrue(scope3.contains("openid"));
+ Assertions.assertTrue(scope3.contains("custom"));
+ Assertions.assertFalse(scope3.contains("profile"));
+ }
+
+ @Test
+ public void testExtractUsername() throws Exception {
+ Method extractUsernameMethod = OidcAuthenticator.class.getDeclaredMethod(
+ "extractUsername", OidcProviderConfig.class,
+ IDTokenClaimsSet.class,
+ UserInfo.class);
+ ReflectionUtils.makeAccessible(extractUsernameMethod);
+
+ OidcProviderConfig providerConfig = new OidcProviderConfig();
+ providerConfig.setUserNameAttribute("preferred_username");
+
+ Subject subject = new Subject("subject-id");
+
+ IDTokenClaimsSet idTokenClaims1 =
+ mock(IDTokenClaimsSet.class);
+ when(idTokenClaims1.getSubject()).thenReturn(subject);
+ when(idTokenClaims1.getClaim("preferred_username")).thenReturn("test-username");
+
+ UserInfo userInfo1 =
+ new UserInfo(subject);
+ userInfo1.setClaim("preferred_username", "user-info-username");
+
+ String username1 =
+ (String) extractUsernameMethod.invoke(oidcAuthenticator, providerConfig, idTokenClaims1, userInfo1);
+ Assertions.assertEquals("test-username", username1);
+
+ IDTokenClaimsSet idTokenClaims2 =
+ mock(IDTokenClaimsSet.class);
+ when(idTokenClaims2.getSubject()).thenReturn(subject);
+ when(idTokenClaims2.getClaim("preferred_username")).thenReturn(null);
+
+ String username2 =
+ (String) extractUsernameMethod.invoke(oidcAuthenticator, providerConfig, idTokenClaims2, userInfo1);
+ Assertions.assertEquals("user-info-username", username2);
+
+ UserInfo userInfo2 =
+ new UserInfo(subject);
+
+ String username3 =
+ (String) extractUsernameMethod.invoke(oidcAuthenticator, providerConfig, idTokenClaims2, userInfo2);
+ Assertions.assertEquals("subject-id", username3);
+ }
+
+ @Test
+ public void testExtractEmail() throws Exception {
+ Method extractEmailMethod = OidcAuthenticator.class.getDeclaredMethod(
+ "extractEmail", IDTokenClaimsSet.class,
+ UserInfo.class);
+ ReflectionUtils.makeAccessible(extractEmailMethod);
+
+ Subject subject = new Subject("subject-id");
+
+ IDTokenClaimsSet idTokenClaims1 =
+ mock(IDTokenClaimsSet.class);
+ when(idTokenClaims1.getClaim("email")).thenReturn("test@example.com");
+
+ UserInfo userInfo1 =
+ new UserInfo(subject);
+ userInfo1.setClaim("email", "userinfo@example.com");
+
+ String email1 = (String) extractEmailMethod.invoke(oidcAuthenticator, idTokenClaims1, userInfo1);
+ Assertions.assertEquals("test@example.com", email1);
+
+ IDTokenClaimsSet idTokenClaims2 =
+ mock(IDTokenClaimsSet.class);
+ when(idTokenClaims2.getClaim("email")).thenReturn(null);
+
+ String email2 = (String) extractEmailMethod.invoke(oidcAuthenticator, idTokenClaims2, userInfo1);
+ Assertions.assertEquals("userinfo@example.com", email2);
+
+ UserInfo userInfo2 =
+ new UserInfo(subject);
+
+ String email3 = (String) extractEmailMethod.invoke(oidcAuthenticator, idTokenClaims2, userInfo2);
+ Assertions.assertNull(email3);
+ }
+
+ @Test
+ public void testExtractGroups() throws Exception {
+ Method extractGroupsMethod = OidcAuthenticator.class.getDeclaredMethod(
+ "extractGroups", OidcProviderConfig.class,
+ IDTokenClaimsSet.class,
+ UserInfo.class);
+ ReflectionUtils.makeAccessible(extractGroupsMethod);
+
+ OidcProviderConfig providerConfig = new OidcProviderConfig();
+ providerConfig.setGroupsClaim("groups");
+
+ Subject subject = new Subject("subject-id");
+
+ List<String> idTokenGroups = Arrays.asList("group1", "group2");
+
+ IDTokenClaimsSet idTokenClaims1 =
+ mock(IDTokenClaimsSet.class);
+ when(idTokenClaims1.getClaim("groups")).thenReturn(idTokenGroups);
+
+ UserInfo userInfo1 =
+ new UserInfo(subject);
+ userInfo1.setClaim("groups", Arrays.asList("group3", "group4"));
+
+ @SuppressWarnings("unchecked")
+ List<String> groups1 =
+ (List<String>) extractGroupsMethod.invoke(oidcAuthenticator, providerConfig, idTokenClaims1, userInfo1);
+ Assertions.assertEquals(idTokenGroups, groups1);
+
+ IDTokenClaimsSet idTokenClaims2 =
+ mock(IDTokenClaimsSet.class);
+ when(idTokenClaims2.getClaim("groups")).thenReturn(null);
+
+ List<String> userInfoGroups = Arrays.asList("group3", "group4");
+ UserInfo userInfo2 =
+ new UserInfo(subject);
+ userInfo2.setClaim("groups", userInfoGroups);
+
+ @SuppressWarnings("unchecked")
+ List<String> groups2 =
+ (List<String>) extractGroupsMethod.invoke(oidcAuthenticator, providerConfig, idTokenClaims2, userInfo2);
+ Assertions.assertEquals(userInfoGroups, groups2);
+
+ OidcProviderConfig providerConfig2 = new OidcProviderConfig();
+ providerConfig2.setGroupsClaim(null);
+
+ @SuppressWarnings("unchecked")
+ List<String> groups3 = (List<String>) extractGroupsMethod.invoke(oidcAuthenticator, providerConfig2,
+ idTokenClaims1, userInfo1);
+ Assertions.assertTrue(groups3.isEmpty());
+
+ @SuppressWarnings("unchecked")
+ List<String> groups4 =
+ (List<String>) extractGroupsMethod.invoke(oidcAuthenticator, providerConfig, idTokenClaims2,
+ new UserInfo(subject));
+ Assertions.assertTrue(groups4.isEmpty());
+ }
+
+ @Test
+ public void testDetermineUserType() throws Exception {
+ Method determineUserTypeMethod = OidcAuthenticator.class.getDeclaredMethod(
+ "determineUserType", List.class);
+ ReflectionUtils.makeAccessible(determineUserTypeMethod);
+
+ OidcUserConfig userConfig = new OidcUserConfig();
+ userConfig.setAdminGroupMapping(Arrays.asList("admin-group", "super-admin"));
+ when(oidcConfigProperties.getUser()).thenReturn(userConfig);
+
+ List<String> adminGroups = Arrays.asList("user-group", "admin-group");
+ UserType userType1 = (UserType) determineUserTypeMethod.invoke(oidcAuthenticator, new Object[]{adminGroups});
+ Assertions.assertEquals(UserType.ADMIN_USER, userType1);
+
+ List<String> regularGroups = Arrays.asList("user-group", "another-group");
+ UserType userType2 = (UserType) determineUserTypeMethod.invoke(oidcAuthenticator, new Object[]{regularGroups});
+ Assertions.assertEquals(UserType.GENERAL_USER, userType2);
+
+ UserType userType3 = (UserType) determineUserTypeMethod.invoke(oidcAuthenticator, new Object[]{null});
+ Assertions.assertEquals(UserType.GENERAL_USER, userType3);
+
+ userConfig.setAdminGroupMapping(Collections.emptyList());
+ UserType userType4 = (UserType) determineUserTypeMethod.invoke(oidcAuthenticator, new Object[]{adminGroups});
+ Assertions.assertEquals(UserType.GENERAL_USER, userType4);
+
+ userConfig.setAdminGroupMapping(null);
+ UserType userType5 = (UserType) determineUserTypeMethod.invoke(oidcAuthenticator, new Object[]{adminGroups});
+ Assertions.assertEquals(UserType.GENERAL_USER, userType5);
+ }
+
+ private void executeLoginWithMocks(Runnable assertions, List<String> groups, boolean tokenSuccess,
+ boolean userInfoSuccess, String issuer) throws Exception {
+ injectMockMetadataIntoCache();
+
+ try (
+ MockedStatic<OIDCTokenResponseParser> tokenParserMock =
+ Mockito.mockStatic(OIDCTokenResponseParser.class);
+ MockedStatic<UserInfoResponse> userInfoResponseMock = Mockito.mockStatic(UserInfoResponse.class);
+ MockedConstruction<TokenRequest> tokenRequestConstruction = Mockito.mockConstruction(TokenRequest.class,
+ (mock, context) -> {
+ HTTPRequest httpRequest = mock(HTTPRequest.class);
+ HTTPResponse httpResponse = mock(HTTPResponse.class);
+ when(mock.toHTTPRequest()).thenReturn(httpRequest);
+ when(httpRequest.send()).thenReturn(httpResponse);
+ });
+ MockedConstruction<UserInfoRequest> userInfoRequestConstruction =
+ Mockito.mockConstruction(UserInfoRequest.class,
+ (mock, context) -> {
+ HTTPRequest httpRequest = mock(HTTPRequest.class);
+ HTTPResponse httpResponse = mock(HTTPResponse.class);
+ when(mock.toHTTPRequest()).thenReturn(httpRequest);
+ when(httpRequest.send()).thenReturn(httpResponse);
+ })) {
+
+ mockTokenAndUserInfoFlow(tokenParserMock, userInfoResponseMock, groups, tokenSuccess, userInfoSuccess,
+ issuer);
+ assertions.run();
+ }
+ }
+
+ private void injectMockMetadataIntoCache() throws Exception {
+ OIDCProviderMetadata metadata = mock(OIDCProviderMetadata.class);
+ when(metadata.getTokenEndpointURI()).thenReturn(new URI("http://fake/token"));
+ when(metadata.getUserInfoEndpointURI()).thenReturn(new URI("http://fake/userinfo"));
+ when(metadata.getIssuer()).thenReturn(new Issuer("http://fake-issuer.com"));
+
+ Field cacheField = OidcAuthenticator.class.getDeclaredField("providerMetadataCache");
+ ReflectionUtils.makeAccessible(cacheField);
+ @SuppressWarnings("unchecked")
+ Map<String, OIDCProviderMetadata> cache =
+ (Map<String, OIDCProviderMetadata>) ReflectionUtils.getField(cacheField, oidcAuthenticator);
+ cache.put(providerId, metadata);
+ }
+
+ /**
+ * Helper method to mock the OIDC provider configuration.
+ */
+ private void mockOidcConfiguration(String providerId, boolean autoCreate, List<String> adminGroups) {
+ OidcProviderConfig providerConfig = new OidcProviderConfig();
+ providerConfig.setIssuerUri("http://fake-issuer.com");
+ providerConfig.setClientId("test-client");
+ providerConfig.setClientSecret("test-secret");
+ providerConfig.setUserNameAttribute("preferred_username");
+ providerConfig.setGroupsClaim("groups");
+
+ OidcUserConfig userConfig = new OidcUserConfig();
+ userConfig.setAutoCreate(autoCreate);
+ userConfig.setAdminGroupMapping(adminGroups);
+
+ Map<String, OidcProviderConfig> providers = new HashMap<>();
+ providers.put(providerId, providerConfig);
+
+ when(oidcConfigProperties.getProviders()).thenReturn(providers);
+ when(oidcConfigProperties.getUser()).thenReturn(userConfig);
+ }
+
+ /**
+ * Mocks the token and user info parts of the OIDC flow, assuming metadata is already cached.
+ */
+ private void mockTokenAndUserInfoFlow(
+ MockedStatic<OIDCTokenResponseParser> tokenParserMock,
+ MockedStatic<UserInfoResponse> userInfoResponseMock,
+ List<String> groups,
+ boolean tokenSuccess,
+ boolean userInfoSuccess,
+ String issuer) throws Exception {
+
+ if (!tokenSuccess) {
+ TokenErrorResponse errorResponse = mock(TokenErrorResponse.class);
+ when(errorResponse.indicatesSuccess()).thenReturn(false);
+ when(errorResponse.getErrorObject()).thenReturn(new ErrorObject("invalid_grant"));
+ tokenParserMock
+ .when(() -> OIDCTokenResponseParser.parse(any(HTTPResponse.class)))
+ .thenReturn(errorResponse);
+ return;
+ }
+
+ OIDCTokenResponse tokenResponse = mock(OIDCTokenResponse.class);
+ when(tokenResponse.indicatesSuccess()).thenReturn(true);
+ OIDCTokens oidcTokens = mock(OIDCTokens.class);
+ when(oidcTokens.getAccessToken()).thenReturn(new BearerAccessToken());
+
+ JWTClaimsSet claimsSet =
+ new JWTClaimsSet.Builder()
+ .issuer(issuer)
+ .audience("test-client")
+ .subject(username)
+ .claim("preferred_username", username)
+ .claim("email", email)
+ .claim("groups", groups)
+ .expirationTime(new Date(System.currentTimeMillis() + 60000))
+ .issueTime(new Date())
+ .build();
+ PlainJWT idToken = new PlainJWT(claimsSet);
+ when(oidcTokens.getIDToken()).thenReturn(idToken);
+ when(tokenResponse.getOIDCTokens()).thenReturn(oidcTokens);
+ tokenParserMock
+ .when(() -> OIDCTokenResponseParser.parse(any(HTTPResponse.class)))
+ .thenReturn(tokenResponse);
+
+ if (!userInfoSuccess) {
+ UserInfoErrorResponse userInfoErrorResponse = mock(UserInfoErrorResponse.class);
+ when(userInfoErrorResponse.indicatesSuccess()).thenReturn(false);
+ when(userInfoErrorResponse.getErrorObject()).thenReturn(new ErrorObject("server_error"));
+ userInfoResponseMock
+ .when(() -> UserInfoResponse.parse(any(HTTPResponse.class)))
+ .thenReturn(userInfoErrorResponse);
+ return;
+ }
+
+ UserInfoResponse userInfoResponse = mock(UserInfoResponse.class);
+ when(userInfoResponse.indicatesSuccess()).thenReturn(true);
+ UserInfoSuccessResponse successResponse = mock(UserInfoSuccessResponse.class);
+ when(userInfoResponse.toSuccessResponse()).thenReturn(successResponse);
+
+ UserInfo nimbusUserInfo = new UserInfo(new Subject(username));
+ nimbusUserInfo.setClaim("preferred_username", username);
+ nimbusUserInfo.setClaim("email", email);
+ nimbusUserInfo.setClaim("groups", groups);
+ when(successResponse.getUserInfo()).thenReturn(nimbusUserInfo);
+
+ userInfoResponseMock
+ .when(() -> UserInfoResponse.parse(any(HTTPResponse.class)))
+ .thenReturn(userInfoResponse);
+ }
+
+ @Test
+ public void testLogin_Failure_NullServletRequestAttributes() {
+ RequestContextHolder.resetRequestAttributes();
+ String state = providerId + ":" + UUID.randomUUID();
+ User result = oidcAuthenticator.login(state, code);
+ Assertions.assertNull(result);
+ }
+
+ @Test
+ public void testLogin_Failure_InvalidStateFormat_NoDelimiter() {
+ String state = providerId + UUID.randomUUID(); // missing ':'
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(state);
+ RequestContextHolder.setRequestAttributes(attributes);
+ User result = oidcAuthenticator.login(state, code);
+ Assertions.assertNull(result);
+ }
+
+ @Test
+ public void testLogin_ExistingUserRoleUpdated() throws Exception {
+ String state = providerId + ":" + UUID.randomUUID();
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(state);
+ mockOidcConfiguration(providerId, true, Collections.singletonList("admin-group"));
+
+ // Existing user currently GENERAL but should become ADMIN due to group mapping
+ User existingUser = new User();
+ existingUser.setUserName(username);
+ existingUser.setUserType(UserType.GENERAL_USER);
+ when(usersService.getUserByUserName(username)).thenReturn(existingUser);
+ when(usersService.updateUser(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
+
+ executeLoginWithCustomClaims(() -> {
+ User result = oidcAuthenticator.login(state, code);
+ Assertions.assertNotNull(result);
+ Assertions.assertEquals(UserType.ADMIN_USER, result.getUserType());
+ verify(usersService).updateUser(any(User.class));
+ }, Collections.singletonList("admin-group"), true, true, builder -> builder.issuer("http://fake-issuer.com"));
+ }
+
+ @Test
+ public void testLogin_Failure_InvalidAudience() throws Exception {
+ String state = providerId + ":" + UUID.randomUUID();
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(state);
+ mockOidcConfiguration(providerId, true, Collections.emptyList());
+
+ executeLoginWithCustomClaims(() -> {
+ User result = oidcAuthenticator.login(state, code);
+ Assertions.assertNull(result);
+ }, Collections.emptyList(), true, true, builder -> builder.audience("wrong-client"));
+ }
+
+ @Test
+ public void testLogin_Failure_ExpiredToken() throws Exception {
+ String state = providerId + ":" + UUID.randomUUID();
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(state);
+ mockOidcConfiguration(providerId, true, Collections.emptyList());
+
+ executeLoginWithCustomClaims(() -> {
+ User result = oidcAuthenticator.login(state, code);
+ Assertions.assertNull(result);
+ }, Collections.emptyList(), true, true,
+ builder -> builder.expirationTime(new Date(System.currentTimeMillis() - 60000))); // already expired
+ }
+
+ @Test
+ public void testLogin_Failure_TokenRequestSendException() throws Exception {
+ String state = providerId + ":" + UUID.randomUUID();
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(state);
+ mockOidcConfiguration(providerId, true, Collections.emptyList());
+ injectMockMetadataIntoCache();
+
+ try (
+ MockedStatic<OIDCTokenResponseParser> tokenParserMock =
+ Mockito.mockStatic(OIDCTokenResponseParser.class);
+ MockedConstruction<TokenRequest> tokenRequestConstruction = Mockito.mockConstruction(TokenRequest.class,
+ (mock, context) -> {
+ HTTPRequest httpRequest = mock(HTTPRequest.class);
+ when(mock.toHTTPRequest()).thenReturn(httpRequest);
+ when(httpRequest.send()).thenThrow(new RuntimeException("network failure"));
+ })) {
+ User result = oidcAuthenticator.login(state, code);
+ Assertions.assertNull(result);
+ }
+ }
+
+ @Test
+ public void testLogin_Failure_InvalidRedirectUri() throws Exception {
+ String state = providerId + ":" + UUID.randomUUID();
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(state);
+ mockOidcConfiguration(providerId, true, Collections.emptyList());
+ when(apiConfig.getBaseUrl()).thenReturn("http://localhost:12345/dolphinscheduler invalid");
+
+ executeLoginWithMocks(() -> {
+ User result = oidcAuthenticator.login(state, code);
+ Assertions.assertNull(result);
+ }, Collections.emptyList(), true, true, "http://fake-issuer.com");
+ }
+
+ @Test
+ public void testLogin_Success_ClientSecretPost() throws Exception {
+ String state = providerId + ":" + UUID.randomUUID();
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(state);
+ OidcProviderConfig providerConfig = new OidcProviderConfig();
+ providerConfig.setIssuerUri("http://fake-issuer.com");
+ providerConfig.setClientId("test-client");
+ providerConfig.setClientSecret("test-secret");
+ providerConfig.setClientAuthenticationMethod("client_secret_post");
+ providerConfig.setUserNameAttribute("preferred_username");
+ providerConfig.setGroupsClaim("groups");
+
+ OidcUserConfig userConfig = new OidcUserConfig();
+ userConfig.setAutoCreate(true);
+ userConfig.setAdminGroupMapping(Collections.emptyList());
+
+ Map<String, OidcProviderConfig> providers = new HashMap<>();
+ providers.put(providerId, providerConfig);
+ when(oidcConfigProperties.getProviders()).thenReturn(providers);
+ when(oidcConfigProperties.getUser()).thenReturn(userConfig);
+
+ when(usersService.getUserByUserName(username)).thenReturn(null);
+ User newUser = new User();
+ newUser.setUserName(username);
+ newUser.setUserType(UserType.GENERAL_USER);
+ when(usersService.createUser(UserType.GENERAL_USER, username, email)).thenReturn(newUser);
+
+ executeLoginWithMocks(() -> {
+ User result = oidcAuthenticator.login(state, code);
+ Assertions.assertNotNull(result);
+ Assertions.assertEquals(username, result.getUserName());
+ }, Collections.emptyList(), true, true, "http://fake-issuer.com");
+ }
+
+ private interface ClaimsCustomizer {
+
+ JWTClaimsSet.Builder customize(JWTClaimsSet.Builder builder);
+ }
+
+ private void executeLoginWithCustomClaims(Runnable assertions,
+ List<String> groups,
+ boolean tokenSuccess,
+ boolean userInfoSuccess,
+ ClaimsCustomizer claimsCustomizer) throws Exception {
+ injectMockMetadataIntoCache();
+ try (
+ MockedStatic<OIDCTokenResponseParser> tokenParserMock =
+ Mockito.mockStatic(OIDCTokenResponseParser.class);
+ MockedStatic<UserInfoResponse> userInfoResponseMock = Mockito.mockStatic(UserInfoResponse.class);
+ MockedConstruction<TokenRequest> tokenRequestConstruction = Mockito.mockConstruction(TokenRequest.class,
+ (mock, context) -> {
+ HTTPRequest httpRequest = mock(HTTPRequest.class);
+ HTTPResponse httpResponse = mock(HTTPResponse.class);
+ when(mock.toHTTPRequest()).thenReturn(httpRequest);
+ when(httpRequest.send()).thenReturn(httpResponse);
+ });
+ MockedConstruction<UserInfoRequest> userInfoRequestConstruction =
+ Mockito.mockConstruction(UserInfoRequest.class,
+ (mock, context) -> {
+ HTTPRequest httpRequest = mock(HTTPRequest.class);
+ HTTPResponse httpResponse = mock(HTTPResponse.class);
+ when(mock.toHTTPRequest()).thenReturn(httpRequest);
+ when(httpRequest.send()).thenReturn(httpResponse);
+ })) {
+
+ if (!tokenSuccess) {
+ TokenErrorResponse errorResponse = mock(TokenErrorResponse.class);
+ when(errorResponse.indicatesSuccess()).thenReturn(false);
+ when(errorResponse.getErrorObject()).thenReturn(new ErrorObject("invalid_grant"));
+ tokenParserMock.when(() -> OIDCTokenResponseParser.parse(any(HTTPResponse.class)))
+ .thenReturn(errorResponse);
+ assertions.run();
+ return;
+ }
+
+ OIDCTokenResponse tokenResponse = mock(OIDCTokenResponse.class);
+ when(tokenResponse.indicatesSuccess()).thenReturn(true);
+ OIDCTokens oidcTokens = mock(OIDCTokens.class);
+ when(oidcTokens.getAccessToken()).thenReturn(new BearerAccessToken());
+
+ JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
+ .issuer("http://fake-issuer.com")
+ .audience("test-client")
+ .subject(username)
+ .claim("preferred_username", username)
+ .claim("email", email)
+ .claim("groups", groups)
+ .expirationTime(new Date(System.currentTimeMillis() + 60000))
+ .issueTime(new Date());
+ builder = claimsCustomizer.customize(builder);
+ JWTClaimsSet claimsSet = builder.build();
+ PlainJWT idToken = new PlainJWT(claimsSet);
+ when(oidcTokens.getIDToken()).thenReturn(idToken);
+ when(tokenResponse.getOIDCTokens()).thenReturn(oidcTokens);
+ tokenParserMock.when(() -> OIDCTokenResponseParser.parse(any(HTTPResponse.class)))
+ .thenReturn(tokenResponse);
+
+ if (!userInfoSuccess) {
+ UserInfoErrorResponse userInfoErrorResponse = mock(UserInfoErrorResponse.class);
+ when(userInfoErrorResponse.indicatesSuccess()).thenReturn(false);
+ when(userInfoErrorResponse.getErrorObject()).thenReturn(new ErrorObject("server_error"));
+ userInfoResponseMock.when(() -> UserInfoResponse.parse(any(HTTPResponse.class)))
+ .thenReturn(userInfoErrorResponse);
+ assertions.run();
+ return;
+ }
+
+ UserInfoResponse userInfoResponse = mock(UserInfoResponse.class);
+ when(userInfoResponse.indicatesSuccess()).thenReturn(true);
+ UserInfoSuccessResponse successResponse = mock(UserInfoSuccessResponse.class);
+ when(userInfoResponse.toSuccessResponse()).thenReturn(successResponse);
+ UserInfo nimbusUserInfo = new UserInfo(new Subject(username));
+ nimbusUserInfo.setClaim("preferred_username", username);
+ nimbusUserInfo.setClaim("email", email);
+ nimbusUserInfo.setClaim("groups", groups);
+ when(successResponse.getUserInfo()).thenReturn(nimbusUserInfo);
+ userInfoResponseMock.when(() -> UserInfoResponse.parse(any(HTTPResponse.class)))
+ .thenReturn(userInfoResponse);
+
+ assertions.run();
+ }
+ }
+
+ @Test
+ public void testGetProviderMetadata_CacheBehavior() throws Exception {
+ Field cacheField = OidcAuthenticator.class.getDeclaredField("providerMetadataCache");
+ ReflectionUtils.makeAccessible(cacheField);
+ @SuppressWarnings("unchecked")
+ Map<String, OIDCProviderMetadata> cache =
+ (Map<String, OIDCProviderMetadata>) ReflectionUtils.getField(cacheField, oidcAuthenticator);
+ cache.clear();
+
+ OidcProviderConfig providerConfig = new OidcProviderConfig();
+ providerConfig.setIssuerUri("http://fake-issuer.com");
+ providerConfig.setClientId("test-client");
+ providerConfig.setClientSecret("test-secret");
+ providerConfig.setUserNameAttribute("preferred_username");
+ providerConfig.setGroupsClaim("groups");
+ Map<String, OidcProviderConfig> providers = new HashMap<>();
+ providers.put(providerId, providerConfig);
+ when(oidcConfigProperties.getProviders()).thenReturn(providers);
+
+ String state = providerId + ":" + UUID.randomUUID();
+ when(apiConfig.getBaseUrl()).thenReturn("http://localhost:12345/dolphinscheduler");
+ String metadataJson = "{" +
+ "\"issuer\":\"http://fake-issuer.com\"," +
+ "\"authorization_endpoint\":\"http://fake/authorize\"," +
+ "\"token_endpoint\":\"http://fake/token\"," +
+ "\"userinfo_endpoint\":\"http://fake/userinfo\"," +
+ "\"jwks_uri\":\"http://fake/jwks\"," +
+ "\"response_types_supported\":[\"code\"]," +
+ "\"subject_types_supported\":[\"public\"]," +
+ "\"id_token_signing_alg_values_supported\":[\"RS256\"]" +
+ "}";
+
+ try (
+ MockedConstruction<HTTPRequest> requestConstruction = Mockito.mockConstruction(HTTPRequest.class,
+ (mock, context) -> {
+ HTTPResponse httpResponse = new HTTPResponse(200);
+ httpResponse.setContent(metadataJson);
+ when(mock.send()).thenReturn(httpResponse);
+ })) {
+ String url1 = oidcAuthenticator.getSignInUrl(state);
+ Assertions.assertNotNull(url1, "First call should fetch metadata and produce URL");
+ Assertions.assertEquals(1, requestConstruction.constructed().size(),
+ "Should have constructed exactly one HTTPRequest");
+ String url2 = oidcAuthenticator.getSignInUrl(state);
+ Assertions.assertNotNull(url2, "Second call should use cached metadata");
+ Assertions.assertEquals(1, requestConstruction.constructed().size(),
+ "Cache should prevent new HTTPRequest construction");
+ }
+ }
+
+ @Test
+ public void testLogin_Failure_IdTokenParseError() throws Exception {
+ String state = providerId + ":" + UUID.randomUUID();
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(state);
+ mockOidcConfiguration(providerId, true, Collections.emptyList());
+ injectMockMetadataIntoCache();
+
+ try (
+ MockedStatic<OIDCTokenResponseParser> tokenParserMock =
+ Mockito.mockStatic(OIDCTokenResponseParser.class);
+ MockedConstruction<TokenRequest> tokenRequestConstruction = Mockito.mockConstruction(TokenRequest.class,
+ (mock, context) -> {
+ HTTPRequest httpRequest = mock(HTTPRequest.class);
+ HTTPResponse httpResponse = mock(HTTPResponse.class);
+ when(mock.toHTTPRequest()).thenReturn(httpRequest);
+ when(httpRequest.send()).thenReturn(httpResponse);
+ });
+ MockedConstruction<UserInfoRequest> userInfoRequestConstruction =
+ Mockito.mockConstruction(UserInfoRequest.class,
+ (mock, context) -> {
+ HTTPRequest httpRequest = mock(HTTPRequest.class);
+ HTTPResponse httpResponse = mock(HTTPResponse.class);
+ when(mock.toHTTPRequest()).thenReturn(httpRequest);
+ when(httpRequest.send()).thenReturn(httpResponse);
+ })) {
+
+ OIDCTokenResponse tokenResponse = mock(OIDCTokenResponse.class);
+ when(tokenResponse.indicatesSuccess()).thenReturn(true);
+ OIDCTokens oidcTokens = mock(OIDCTokens.class);
+ JWT badJwt = mock(JWT.class);
+ when(badJwt.getJWTClaimsSet()).thenThrow(new ParseException("bad token", 0));
+ when(oidcTokens.getIDToken()).thenReturn(badJwt);
+ when(tokenResponse.getOIDCTokens()).thenReturn(oidcTokens);
+ tokenParserMock.when(() -> OIDCTokenResponseParser.parse(any(HTTPResponse.class)))
+ .thenReturn(tokenResponse);
+
+ User result = oidcAuthenticator.login(state, code);
+ Assertions.assertNull(result, "Login should fail on ID token parse error");
+ }
+ }
+
+ @Test
+ public void testLogin_Failure_MissingExpirationClaim() throws Exception {
+ String state = providerId + ":" + UUID.randomUUID();
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(state);
+ mockOidcConfiguration(providerId, true, Collections.emptyList());
+ injectMockMetadataIntoCache();
+
+ try (
+ MockedStatic<OIDCTokenResponseParser> tokenParserMock =
+ Mockito.mockStatic(OIDCTokenResponseParser.class);
+ MockedConstruction<TokenRequest> tokenRequestConstruction = Mockito.mockConstruction(TokenRequest.class,
+ (mock, context) -> {
+ HTTPRequest httpRequest = mock(HTTPRequest.class);
+ HTTPResponse httpResponse = mock(HTTPResponse.class);
+ when(mock.toHTTPRequest()).thenReturn(httpRequest);
+ when(httpRequest.send()).thenReturn(httpResponse);
+ });
+ MockedConstruction<UserInfoRequest> userInfoRequestConstruction =
+ Mockito.mockConstruction(UserInfoRequest.class,
+ (mock, context) -> {
+ HTTPRequest httpRequest = mock(HTTPRequest.class);
+ HTTPResponse httpResponse = mock(HTTPResponse.class);
+ when(mock.toHTTPRequest()).thenReturn(httpRequest);
+ when(httpRequest.send()).thenReturn(httpResponse);
+ })) {
+
+ OIDCTokenResponse tokenResponse = mock(OIDCTokenResponse.class);
+ when(tokenResponse.indicatesSuccess()).thenReturn(true);
+ OIDCTokens oidcTokens = mock(OIDCTokens.class);
+ JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
+ .issuer("http://fake-issuer.com")
+ .audience("test-client")
+ .subject(username)
+ .claim("preferred_username", username)
+ .claim("email", email)
+ // deliberately omit expirationTime
+ .issueTime(new Date())
+ .build();
+ PlainJWT idToken = new PlainJWT(claimsSet);
+ when(oidcTokens.getIDToken()).thenReturn(idToken);
+ when(tokenResponse.getOIDCTokens()).thenReturn(oidcTokens);
+ tokenParserMock.when(() -> OIDCTokenResponseParser.parse(any(HTTPResponse.class)))
+ .thenReturn(tokenResponse);
+
+ User result = oidcAuthenticator.login(state, code);
+ Assertions.assertNull(result, "Login should fail due to missing expiration claim");
+ }
+ }
+
+ @Test
+ public void testLogin_Failure_MissingSessionState() {
+ String state = providerId + ":" + UUID.randomUUID();
+ when(session.getAttribute(Constants.SSO_LOGIN_USER_STATE)).thenReturn(null); // simulate missing attribute
+ User result = oidcAuthenticator.login(state, code);
+ Assertions.assertNull(result, "Login should fail if session state attribute is missing");
+ }
+
+ @Test
+ public void testGetSignInUrl_Failure_ProviderNotFound() {
+ String state = "unknown:" + UUID.randomUUID();
+ when(oidcConfigProperties.getProviders()).thenReturn(Collections.emptyMap());
+ String url = oidcAuthenticator.getSignInUrl(state);
+ Assertions.assertNull(url, "Sign-in URL should be null for unknown provider");
+ }
+
+ @Test
+ public void testExtractGroups_EmptyClaimConfigured() throws Exception {
+ Method extractGroupsMethod = OidcAuthenticator.class.getDeclaredMethod(
+ "extractGroups", OidcProviderConfig.class, IDTokenClaimsSet.class, UserInfo.class);
+ ReflectionUtils.makeAccessible(extractGroupsMethod);
+ OidcProviderConfig providerConfig = new OidcProviderConfig();
+ providerConfig.setGroupsClaim("");
+ IDTokenClaimsSet idTokenClaims = mock(IDTokenClaimsSet.class);
+ UserInfo userInfo = null;
+ @SuppressWarnings("unchecked")
+ List<String> groups =
+ (List<String>) extractGroupsMethod.invoke(oidcAuthenticator, providerConfig, idTokenClaims, userInfo);
+ Assertions.assertTrue(groups.isEmpty(), "Groups should be empty when groupsClaim is blank");
+ }
+
+ @Test
+ public void testExtractGroups_InvalidTypes() throws Exception {
+ Method extractGroupsMethod = OidcAuthenticator.class.getDeclaredMethod(
+ "extractGroups", OidcProviderConfig.class, IDTokenClaimsSet.class, UserInfo.class);
+ ReflectionUtils.makeAccessible(extractGroupsMethod);
+ OidcProviderConfig providerConfig = new OidcProviderConfig();
+ providerConfig.setGroupsClaim("groups");
+ IDTokenClaimsSet idTokenClaims = mock(IDTokenClaimsSet.class);
+ when(idTokenClaims.getClaim("groups")).thenReturn("not-a-list");
+ UserInfo userInfo = new UserInfo(new Subject("sub"));
+ userInfo.setClaim("groups", "still-not-a-list");
+ @SuppressWarnings("unchecked")
+ List<String> groups =
+ (List<String>) extractGroupsMethod.invoke(oidcAuthenticator, providerConfig, idTokenClaims, userInfo);
+ Assertions.assertTrue(groups.isEmpty(), "Groups should be empty when claims are not list types");
+ }
+
+ @Test
+ public void testExtractEmail_UserInfoNull() throws Exception {
+ Method extractEmailMethod = OidcAuthenticator.class.getDeclaredMethod(
+ "extractEmail", IDTokenClaimsSet.class, UserInfo.class);
+ ReflectionUtils.makeAccessible(extractEmailMethod);
+ IDTokenClaimsSet idTokenClaims = mock(IDTokenClaimsSet.class);
+ when(idTokenClaims.getClaim("email")).thenReturn(null);
+ String emailValue = (String) extractEmailMethod.invoke(oidcAuthenticator, idTokenClaims, null);
+ Assertions.assertNull(emailValue, "Email should be null when not present in ID token or user info");
+ }
+}
diff --git a/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcConfigPropertiesTest.java b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcConfigPropertiesTest.java
new file mode 100644
index 0000000000..b4c90b547e
--- /dev/null
+++ b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcConfigPropertiesTest.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.dolphinscheduler.api.security.impl.oidc;
+
+import org.apache.dolphinscheduler.api.controller.AbstractControllerTest;
+
+import java.util.Collections;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.TestPropertySource;
+
+@SpringBootTest
+@TestPropertySource(properties = {
+ "security.authentication.type=OIDC",
+ "security.authentication.oidc.enable=true",
+ "security.authentication.oidc.providers.keycloak.display-name=Login with Keycloak",
+ "security.authentication.oidc.providers.keycloak.issuer-uri=http://keycloak:8080/realms/dolphinscheduler",
+ "security.authentication.oidc.providers.keycloak.client-id=dolphinscheduler-client",
+ "security.authentication.oidc.providers.keycloak.client-secret=test-secret",
+ "security.authentication.oidc.providers.keycloak.icon-uri=/icons/keycloak.png",
+ "security.authentication.oidc.providers.keycloak.user-name-attribute=preferred_username",
+ "security.authentication.oidc.providers.keycloak.groups-claim=groups",
+ "security.authentication.oidc.user.auto-create=true",
+ "security.authentication.oidc.user.default-tenant-code=default",
+ "security.authentication.oidc.user.admin-group-mapping[0]=dolphinscheduler-admins"
+})
+public class OidcConfigPropertiesTest extends AbstractControllerTest {
+
+ @Autowired
+ private OidcConfigProperties oidcConfigProperties;
+
+ @Test
+ public void testOidcConfigPropertiesLoading() {
+ Assertions.assertTrue(oidcConfigProperties.isEnable());
+
+ OidcProviderConfig keycloakConfig = oidcConfigProperties.getProviders().get("keycloak");
+ Assertions.assertNotNull(keycloakConfig);
+ Assertions.assertEquals("Login with Keycloak", keycloakConfig.getDisplayName());
+ Assertions.assertEquals("http://keycloak:8080/realms/dolphinscheduler", keycloakConfig.getIssuerUri());
+ Assertions.assertEquals("dolphinscheduler-client", keycloakConfig.getClientId());
+ Assertions.assertEquals("test-secret", keycloakConfig.getClientSecret());
+ Assertions.assertEquals("/icons/keycloak.png", keycloakConfig.getIconUri());
+ Assertions.assertEquals("preferred_username", keycloakConfig.getUserNameAttribute());
+ Assertions.assertEquals("groups", keycloakConfig.getGroupsClaim());
+
+ OidcUserConfig userConfig = oidcConfigProperties.getUser();
+ Assertions.assertNotNull(userConfig);
+ Assertions.assertTrue(userConfig.isAutoCreate());
+ Assertions.assertEquals("default", userConfig.getDefaultTenantCode());
+ Assertions.assertEquals(Collections.singletonList("dolphinscheduler-admins"),
+ userConfig.getAdminGroupMapping());
+ }
+}
diff --git a/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcProviderConfigTest.java b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcProviderConfigTest.java
new file mode 100644
index 0000000000..74d9d2095d
--- /dev/null
+++ b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcProviderConfigTest.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.dolphinscheduler.api.security.impl.oidc;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class OidcProviderConfigTest {
+
+ @Test
+ public void testGettersAndSetters() {
+ OidcProviderConfig config = new OidcProviderConfig();
+
+ Assertions.assertEquals("client_secret_basic", config.getClientAuthenticationMethod());
+ Assertions.assertEquals("openid,profile,email", config.getScope());
+ Assertions.assertEquals("preferred_username", config.getUserNameAttribute());
+
+ config.setDisplayName("Test Provider");
+ Assertions.assertEquals("Test Provider", config.getDisplayName());
+
+ config.setIssuerUri("https://test-issuer.com");
+ Assertions.assertEquals("https://test-issuer.com", config.getIssuerUri());
+
+ config.setIconUri("/icons/test.png");
+ Assertions.assertEquals("/icons/test.png", config.getIconUri());
+
+ config.setClientId("test-client-id");
+ Assertions.assertEquals("test-client-id", config.getClientId());
+
+ config.setClientSecret("test-client-secret");
+ Assertions.assertEquals("test-client-secret", config.getClientSecret());
+
+ config.setClientAuthenticationMethod("client_secret_post");
+ Assertions.assertEquals("client_secret_post", config.getClientAuthenticationMethod());
+
+ config.setScope("openid,email");
+ Assertions.assertEquals("openid,email", config.getScope());
+
+ config.setUserNameAttribute("email");
+ Assertions.assertEquals("email", config.getUserNameAttribute());
+
+ config.setGroupsClaim("roles");
+ Assertions.assertEquals("roles", config.getGroupsClaim());
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ OidcProviderConfig config1 = new OidcProviderConfig();
+ config1.setDisplayName("Test Provider");
+ config1.setIssuerUri("https://test-issuer.com");
+ config1.setClientId("test-client-id");
+ config1.setIconUri("/icon.png");
+
+ OidcProviderConfig config2 = new OidcProviderConfig();
+ config2.setDisplayName("Test Provider");
+ config2.setIssuerUri("https://test-issuer.com");
+ config2.setClientId("test-client-id");
+ config2.setIconUri("/icon.png");
+
+ OidcProviderConfig config3 = new OidcProviderConfig();
+ config3.setDisplayName("Different Provider");
+ config3.setIssuerUri("https://different-issuer.com");
+ config3.setClientId("different-client-id");
+ config3.setIconUri("/different.png");
+
+ Assertions.assertEquals(config1, config2);
+ Assertions.assertNotEquals(config1, config3);
+
+ Assertions.assertEquals(config1.hashCode(), config2.hashCode());
+ Assertions.assertNotEquals(config1.hashCode(), config3.hashCode());
+ }
+
+ @Test
+ public void testToString() {
+ OidcProviderConfig config = new OidcProviderConfig();
+ config.setDisplayName("Test Provider");
+ config.setIssuerUri("https://test-issuer.com");
+ config.setClientId("test-client-id");
+ config.setIconUri("/icon.png");
+
+ String toString = config.toString();
+
+ Assertions.assertNotNull(toString);
+ Assertions.assertTrue(toString.contains("displayName=Test Provider"));
+ Assertions.assertTrue(toString.contains("issuerUri=https://test-issuer.com"));
+ Assertions.assertTrue(toString.contains("clientId=test-client-id"));
+ Assertions.assertTrue(toString.contains("iconUri=/icon.png"));
+ }
+}
diff --git a/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcUserConfigTest.java b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcUserConfigTest.java
new file mode 100644
index 0000000000..e9df76eb16
--- /dev/null
+++ b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/security/impl/oidc/OidcUserConfigTest.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.dolphinscheduler.api.security.impl.oidc;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class OidcUserConfigTest {
+
+ @Test
+ public void testGettersAndSetters() {
+ OidcUserConfig config = new OidcUserConfig();
+
+ Assertions.assertFalse(config.isAutoCreate());
+ Assertions.assertEquals("default", config.getDefaultTenantCode());
+ Assertions.assertEquals("default", config.getDefaultQueue());
+ Assertions.assertNull(config.getAdminGroupMapping());
+
+ config.setAutoCreate(true);
+ Assertions.assertTrue(config.isAutoCreate());
+
+ config.setDefaultTenantCode("custom-tenant");
+ Assertions.assertEquals("custom-tenant", config.getDefaultTenantCode());
+
+ config.setDefaultQueue("custom-queue");
+ Assertions.assertEquals("custom-queue", config.getDefaultQueue());
+
+ List<String> adminGroups = Arrays.asList("admin-group-1", "admin-group-2");
+ config.setAdminGroupMapping(adminGroups);
+ Assertions.assertEquals(adminGroups, config.getAdminGroupMapping());
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ OidcUserConfig config1 = new OidcUserConfig();
+ config1.setAutoCreate(true);
+ config1.setDefaultTenantCode("custom-tenant");
+ config1.setDefaultQueue("custom-queue");
+ config1.setAdminGroupMapping(Collections.singletonList("admin-group"));
+
+ OidcUserConfig config2 = new OidcUserConfig();
+ config2.setAutoCreate(true);
+ config2.setDefaultTenantCode("custom-tenant");
+ config2.setDefaultQueue("custom-queue");
+ config2.setAdminGroupMapping(Collections.singletonList("admin-group"));
+
+ OidcUserConfig config3 = new OidcUserConfig();
+ config3.setAutoCreate(false);
+ config3.setDefaultTenantCode("different-tenant");
+ config3.setDefaultQueue("different-queue");
+ config3.setAdminGroupMapping(Collections.singletonList("different-group"));
+
+ Assertions.assertEquals(config1, config2);
+ Assertions.assertNotEquals(config1, config3);
+
+ Assertions.assertEquals(config1.hashCode(), config2.hashCode());
+ Assertions.assertNotEquals(config1.hashCode(), config3.hashCode());
+ }
+
+ @Test
+ public void testToString() {
+ OidcUserConfig config = new OidcUserConfig();
+ config.setAutoCreate(true);
+ config.setDefaultTenantCode("custom-tenant");
+ config.setDefaultQueue("custom-queue");
+ config.setAdminGroupMapping(Collections.singletonList("admin-group"));
+
+ String toString = config.toString();
+
+ Assertions.assertNotNull(toString);
+ Assertions.assertTrue(toString.contains("autoCreate=true"));
+ Assertions.assertTrue(toString.contains("defaultTenantCode=custom-tenant"));
+ Assertions.assertTrue(toString.contains("defaultQueue=custom-queue"));
+ Assertions.assertTrue(toString.contains("adminGroupMapping=[admin-group]"));
+ }
+}
diff --git a/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/service/UsersServiceTest.java b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/service/UsersServiceTest.java
index e09eeb2dcb..f3b0454c0a 100644
--- a/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/service/UsersServiceTest.java
+++ b/dolphinscheduler-api/src/test/java/org/apache/dolphinscheduler/api/service/UsersServiceTest.java
@@ -337,6 +337,31 @@ public class UsersServiceTest {
"Asia/Shanghai"));
}
+ @Test
+ public void testUpdateUserSimple() {
+ // null user -> USER_NOT_EXIST
+ assertThrowsServiceException(Status.USER_NOT_EXIST, () -> usersService.updateUser(null));
+
+ // user with null id -> USER_NOT_EXIST
+ User noIdUser = new User();
+ assertThrowsServiceException(Status.USER_NOT_EXIST, () -> usersService.updateUser(noIdUser));
+
+ // update failure (0 rows affected) -> UPDATE_USER_ERROR
+ User failingUser = new User();
+ failingUser.setId(1);
+ Mockito.when(userMapper.updateById(Mockito.any())).thenReturn(0);
+ assertThrowsServiceException(Status.UPDATE_USER_ERROR, () -> usersService.updateUser(failingUser));
+
+ // success path (1 row affected)
+ User successUser = new User();
+ successUser.setId(2);
+ Mockito.when(userMapper.updateById(Mockito.any())).thenReturn(1);
+ User updated = usersService.updateUser(successUser);
+ Assertions.assertNotNull(updated);
+ Assertions.assertEquals(2, updated.getId());
+ Assertions.assertNotNull(updated.getUpdateTime(), "updateTime should be set");
+ }
+
@Test
public void testDeleteUserById() {
User loginUser = new User();
diff --git a/dolphinscheduler-ui/src/locales/en_US/login.ts b/dolphinscheduler-ui/src/locales/en_US/login.ts
index 7d2cdcfbab..d9410002b9 100644
--- a/dolphinscheduler-ui/src/locales/en_US/login.ts
+++ b/dolphinscheduler-ui/src/locales/en_US/login.ts
@@ -22,6 +22,6 @@ export default {
userPassword: 'Password',
userPassword_tips: 'Please enter your password',
login: 'Login',
- loginWithOAuth2: 'login with OAuth2',
+ loginWithOAuth2: 'Login with OAuth2',
ssoLogin: 'SSO Login'
}
diff --git a/dolphinscheduler-ui/src/service/modules/login/index.ts b/dolphinscheduler-ui/src/service/modules/login/index.ts
index 05c40d068c..19ec830289 100644
--- a/dolphinscheduler-ui/src/service/modules/login/index.ts
+++ b/dolphinscheduler-ui/src/service/modules/login/index.ts
@@ -40,6 +40,13 @@ export function getOauth2Provider(): any {
})
}
+export function getOidcProviders(): any {
+ return axios({
+ url: '/oidc-providers',
+ method: 'get'
+ })
+}
+
export function clearCookie(): any {
return axios({
url: '/cookies',
diff --git a/dolphinscheduler-ui/src/service/modules/login/types.ts b/dolphinscheduler-ui/src/service/modules/login/types.ts
index e8c07fdee5..a3bef69f6f 100644
--- a/dolphinscheduler-ui/src/service/modules/login/types.ts
+++ b/dolphinscheduler-ui/src/service/modules/login/types.ts
@@ -33,4 +33,10 @@ interface OAuth2Provider {
iconUri: string
}
-export { LoginReq, LoginRes, OAuth2Provider }
+interface OidcProvider {
+ id: string
+ displayName: string
+ iconUri?: string
+}
+
+export { LoginReq, LoginRes, OAuth2Provider, OidcProvider }
diff --git a/dolphinscheduler-ui/src/views/login/index.module.scss b/dolphinscheduler-ui/src/views/login/index.module.scss
index 250ea080fe..e3ad9eefb3 100644
--- a/dolphinscheduler-ui/src/views/login/index.module.scss
+++ b/dolphinscheduler-ui/src/views/login/index.module.scss
@@ -59,4 +59,41 @@
margin-bottom: 30px;
}
}
-}
\ No newline at end of file
+}
+
+.oidc-provider-link {
+ text-decoration: none;
+ width: 100%;
+ display: block;
+ margin-bottom: 12px;
+}
+
+.oidc-provider-btn {
+ font-weight: 350 !important;
+ color: #24292e !important;
+
+ background-color: #f6f8fa;
+ border-radius: 6px;
+ height: 40px;
+
+ transition: all 0.2s ease-in-out;
+
+ &:hover {
+ transform: translate(1px, -1px);
+ box-shadow: 3px 3px 8px rgba(0, 0, 0, 0.08);
+ }
+}
+
+.oidc-btn-content {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+}
+
+.oidc-btn-icon {
+ width: 20px;
+ height: 20px;
+ margin-right: 8px;
+ object-fit: contain;
+}
diff --git a/dolphinscheduler-ui/src/views/login/index.tsx b/dolphinscheduler-ui/src/views/login/index.tsx
index f8d3d533e1..ba8166e190 100644
--- a/dolphinscheduler-ui/src/views/login/index.tsx
+++ b/dolphinscheduler-ui/src/views/login/index.tsx
@@ -41,7 +41,10 @@ import { useLocalesStore } from '@/store/locales/locales'
import { useThemeStore } from '@/store/theme/theme'
import cookies from 'js-cookie'
import { ssoLoginUrl } from '@/service/modules/login'
-import type { OAuth2Provider } from '@/service/modules/login/types'
+import type {
+ OAuth2Provider,
+ OidcProvider
+} from '@/service/modules/login/types'
const login = defineComponent({
name: 'login',
@@ -52,7 +55,9 @@ const login = defineComponent({
const {
handleLogin,
handleGetOAuth2Provider,
+ handleGetOidcProviders,
oauth2Providers,
+ oidcProviders,
gotoOAuth2Page,
handleRedirect
} = useLogin(state)
@@ -86,6 +91,7 @@ const login = defineComponent({
})
handleGetOAuth2Provider()
+ handleGetOidcProviders()
return {
t,
handleChange,
@@ -94,6 +100,7 @@ const login = defineComponent({
localesStore,
trim,
oauth2Providers,
+ oidcProviders,
gotoOAuth2Page
}
},
@@ -183,7 +190,8 @@ const login = defineComponent({
</NButton>
</a>
</div>
- {this.oauth2Providers.length > 0 && (
+ {(this.oauth2Providers.length > 0 ||
+ this.oidcProviders.length > 0) && (
<NDivider>{this.t('login.loginWithOAuth2')}</NDivider>
)}
@@ -199,6 +207,21 @@ const login = defineComponent({
</NButton>
)
})}
+ {this.oidcProviders?.map((e: OidcProvider) => {
+ const authUrl = `/dolphinscheduler/oauth2/authorization/${e.id}`
+ return (
+ <a href={authUrl} class={styles['oidc-provider-link']}>
+ <NButton block class={styles['oidc-provider-btn']}>
+ <div class={styles['oidc-btn-content']}>
+ {e.iconUri && (
+ <img src={e.iconUri} class={styles['oidc-btn-icon']} />
+ )}
+ <span>{e.displayName}</span>
+ </div>
+ </NButton>
+ </a>
+ )
+ })}
</NSpace>
</div>
</div>
diff --git a/dolphinscheduler-ui/src/views/login/use-login.ts b/dolphinscheduler-ui/src/views/login/use-login.ts
index bac91ff7e8..204f88ba5c 100644
--- a/dolphinscheduler-ui/src/views/login/use-login.ts
+++ b/dolphinscheduler-ui/src/views/login/use-login.ts
@@ -16,11 +16,20 @@
*/
import { useRouter, useRoute } from 'vue-router'
-import { clearCookie, getOauth2Provider, login } from '@/service/modules/login'
+import {
+ clearCookie,
+ getOauth2Provider,
+ getOidcProviders,
+ login
+} from '@/service/modules/login'
import { getUserInfo } from '@/service/modules/users'
import { useUserStore } from '@/store/user/user'
import type { Router } from 'vue-router'
-import type { LoginRes, OAuth2Provider } from '@/service/modules/login/types'
+import type {
+ LoginRes,
+ OAuth2Provider,
+ OidcProvider
+} from '@/service/modules/login/types'
import type { UserInfoRes } from '@/service/modules/users/types'
import { useRouteStore } from '@/store/route/route'
import { useTimezoneStore } from '@/store/timezone/timezone'
@@ -66,7 +75,14 @@ export function useLogin(state: any) {
})
}
+ const handleGetOidcProviders = () => {
+ getOidcProviders().then((res: Array<OidcProvider> | []) => {
+ oidcProviders.value = res
+ })
+ }
+
const oauth2Providers = ref<Array<OAuth2Provider> | []>([])
+ const oidcProviders = ref<Array<OidcProvider> | []>([])
const gotoOAuth2Page = async (oauth2Provider: OAuth2Provider) => {
await clearCookie()
@@ -77,19 +93,21 @@ export function useLogin(state: any) {
const handleRedirect = async () => {
const authType = route.query.authType
- if (authType && authType === 'oauth2') {
- const sessionId = route.query.sessionId
- if (sessionId) {
- cookies.set('sessionId', String(sessionId), { path: '/' })
- const userInfoRes: UserInfoRes = await getUserInfo()
- await userStore.setUserInfo(userInfoRes)
- const timezone = userInfoRes.timeZone ? userInfoRes.timeZone : 'UTC'
- await timezoneStore.setTimezone(timezone)
- router.push('home')
- }
- const error = route.query.error
- if (error) {
- window.$message.error(error)
+ if (authType) {
+ if (authType === 'oauth2' || authType === 'oidc') {
+ const sessionId = route.query.sessionId
+ if (sessionId) {
+ cookies.set('sessionId', String(sessionId), { path: '/' })
+ const userInfoRes: UserInfoRes = await getUserInfo()
+ await userStore.setUserInfo(userInfoRes)
+ const timezone = userInfoRes.timeZone ? userInfoRes.timeZone : 'UTC'
+ await timezoneStore.setTimezone(timezone)
+ router.push('home')
+ }
+ const error = route.query.error
+ if (error) {
+ window.$message.error(String(error))
+ }
}
}
}
@@ -97,8 +115,10 @@ export function useLogin(state: any) {
return {
handleLogin,
handleGetOAuth2Provider,
+ handleGetOidcProviders,
gotoOAuth2Page,
oauth2Providers,
+ oidcProviders,
handleRedirect
}
}