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
 
 ![login-with-oauth2](../../../../img/security/authentication/login-with-oauth2.png)
+
+---
+
+## 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("&amp;", "&"),
+        LT("&lt;", "<"),
+        GT("&gt;", ">"),
+        QUOT("&quot;", "\""),
+        APOS("&#39;", "'");
+
+        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
   }
 }