Repository: apache/airflow
Pull request #50921: Add support for unknown OIDC providers
Author: login: KKIEEK
Labels: name: area:providers
name: provider:fab
Created: 2025-05-21T17:51:03Z | Merged: 2025-05-28T13:48:47Z

While setting up RBAC with [dex](https://github.com/dexidp/dex) in a k8s cluster, I found that support for OIDC providers in Airflow is hardcoded. So I just added simple fallback mechanism to handle unregistered ones.

### References:
- [`OpenIDMixin.userinfo`](https://github.com/authlib/authlib/blob/036a0b71532ada9371f0fc41f6bcd2287666bb20/authlib/integrations/base_client/sync_openid.py#L28)
- [`UserInfo`](https://github.com/authlib/authlib/blob/036a0b71532ada9371f0fc41f6bcd2287666bb20/authlib/oidc/core/claims.py#L222)

### Example w/ dex:
```yaml
webserverConfig: |
    from flask_appbuilder.security.manager import AUTH_OAUTH

    AUTH_TYPE = AUTH_OAUTH

    AUTH_USER_REGISTRATION = True
    AUTH_USER_REGISTRATION_ROLE = "User"

    OAUTH_PROVIDERS = [
        {
            "name": "dex",
            "icon": "fa-circle",
            "token_key": "access_token",
            "remote_app": {
                "client_id": "airflow",
                "client_secret": "...",
                "client_kwargs": {
                    "scope": "openid profile email groups"
                },
                "api_base_url": "https://www.domain.com",
                "access_token_url": "https://www.domain.com/token",
                "authorize_url": "https://www.domain.com/auth",
                "userinfo_endpoint": "https://www.domain.com/userinfo",
                "server_metadata_url": "https://www.domain.com/.well-known/openid-configuration",
            },
        },
    ]
```


Diff:
diff --git a/providers/fab/docs/auth-manager/webserver-authentication.rst b/providers/fab/docs/auth-manager/webserver-authentication.rst
index 7ecc40ab83c42..6f865ba777fa0 100644
--- a/providers/fab/docs/auth-manager/webserver-authentication.rst
+++ b/providers/fab/docs/auth-manager/webserver-authentication.rst
@@ -57,9 +57,10 @@ Other Methods
 '''''''''''''
 
 A ``webserver_config.py`` configuration file is automatically generated and can be used to configure FAB auth manager to support authentication
-methods like OAuth, OpenID, LDAP, REMOTE_USER. It should be noted that due to the limitation of Flask AppBuilder
-and Authlib, only a selection of OAuth2 providers is supported. This list includes ``github``, ``githublocal``, ``twitter``,
-``linkedin``, ``google``, ``azure``, ``openshift``, ``okta``, ``keycloak`` and ``keycloak_before_17``.
+methods like OAuth, OpenID, LDAP and REMOTE_USER. It should be noted that due to the limitation of Flask AppBuilder
+and Authlib, some OAuth2 providers may not be supported. Currently supported providers include ``github``, ``githublocal``, ``twitter``,
+``linkedin``, ``google``, ``azure``, ``openshift``, ``okta``, ``auth0``, ``keycloak``, ``keycloak_before_17`` and ``authentik``.
+If your provider is not on the list, you may need to adjust the ``remote_app`` configuration to match your provider's OAuth2 specification.
 
 By default, the following entry in the ``$AIRFLOW_HOME/webserver_config.py`` is used.
 
diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py b/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
index 3e63f7053f40e..e86a1a10f79c3 100644
--- a/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
+++ b/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
@@ -2132,8 +2132,16 @@ def get_oauth_user_info(self, provider: str, resp: dict[str, Any]) -> dict[str,
                 "username": me["nickname"],
                 "role_keys": me.get("groups", []),
             }
-
-        return {}
+        # for other providers
+        data = self.oauth_remotes[provider].userinfo()
+        log.debug("User info from %s: %s", provider, data)
+        return {
+            "username": data.get("preferred_username", ""),
+            "first_name": data.get("given_name", ""),
+            "last_name": data.get("family_name", ""),
+            "email": data.get("email", ""),
+            "role_keys": data.get("groups", []),
+        }
 
     @staticmethod
     def oauth_token_getter():
diff --git a/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py b/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py
index 0a4132851718e..7b9e9696e5776 100644
--- a/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py
+++ b/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py
@@ -19,6 +19,8 @@
 from unittest import mock
 from unittest.mock import Mock
 
+import pytest
+
 from tests_common.test_utils.compat import ignore_provider_compatibility_error
 
 with ignore_provider_compatibility_error("2.9.0+", __file__):
@@ -75,3 +77,173 @@ def test_check_password_not_match(self, check_password):
         sm.find_user = Mock(return_value=mock_user)
         check_password.return_value = False
         assert not sm.check_password("test_user", "test_password")
+
+    @pytest.mark.parametrize(
+        "provider, resp, user_info",
+        [
+            ("github", {"login": "test"}, {"username": "github_test"}),
+            ("githublocal", {"login": "test"}, {"username": "github_test"}),
+            ("twitter", {"screen_name": "test"}, {"username": "twitter_test"}),
+            (
+                "linkedin",
+                {"id": "test", "firstName": "John", "lastName": "Doe", "email-address": "test@example.com"},
+                {
+                    "username": "linkedin_test",
+                    "first_name": "John",
+                    "last_name": "Doe",
+                    "email": "test@example.com",
+                },
+            ),
+            (
+                "google",
+                {"id": "test", "given_name": "John", "family_name": "Doe", "email": "test@example.com"},
+                {
+                    "username": "google_test",
+                    "first_name": "John",
+                    "last_name": "Doe",
+                    "email": "test@example.com",
+                },
+            ),
+            (
+                "azure",
+                {
+                    "oid": "test",
+                    "given_name": "John",
+                    "family_name": "Doe",
+                    "email": "test@example.com",
+                    "roles": ["admin"],
+                },
+                {
+                    "username": "test",
+                    "first_name": "John",
+                    "last_name": "Doe",
+                    "email": "test@example.com",
+                    "role_keys": ["admin"],
+                },
+            ),
+            (
+                "azure",
+                {
+                    "oid": "test",
+                    "given_name": "John",
+                    "family_name": "Doe",
+                    "upn": "test@example.com",
+                    "roles": ["admin"],
+                },
+                {
+                    "username": "test",
+                    "first_name": "John",
+                    "last_name": "Doe",
+                    "email": "test@example.com",
+                    "role_keys": ["admin"],
+                },
+            ),
+            ("openshift", {"metadata": {"name": "test"}}, {"username": "openshift_test"}),
+            (
+                "okta",
+                {
+                    "sub": "test",
+                    "given_name": "John",
+                    "family_name": "Doe",
+                    "email": "test@example.com",
+                    "groups": ["admin"],
+                },
+                {
+                    "username": "okta_test",
+                    "first_name": "John",
+                    "last_name": "Doe",
+                    "email": "test@example.com",
+                    "role_keys": ["admin"],
+                },
+            ),
+            ("okta", {"error": "access_denied", "error_description": "Invalid bearer token."}, {}),
+            (
+                "auth0",
+                {
+                    "sub": "test",
+                    "given_name": "John",
+                    "family_name": "Doe",
+                    "email": "test@example.com",
+                    "groups": ["admin"],
+                },
+                {
+                    "username": "auth0_test",
+                    "first_name": "John",
+                    "last_name": "Doe",
+                    "email": "test@example.com",
+                    "role_keys": ["admin"],
+                },
+            ),
+            (
+                "keycloak",
+                {
+                    "preferred_username": "test",
+                    "given_name": "John",
+                    "family_name": "Doe",
+                    "email": "test@example.com",
+                    "groups": ["admin"],
+                },
+                {
+                    "username": "test",
+                    "first_name": "John",
+                    "last_name": "Doe",
+                    "email": "test@example.com",
+                    "role_keys": ["admin"],
+                },
+            ),
+            (
+                "keycloak_before_17",
+                {
+                    "preferred_username": "test",
+                    "given_name": "John",
+                    "family_name": "Doe",
+                    "email": "test@example.com",
+                    "groups": ["admin"],
+                },
+                {
+                    "username": "test",
+                    "first_name": "John",
+                    "last_name": "Doe",
+                    "email": "test@example.com",
+                    "role_keys": ["admin"],
+                },
+            ),
+            (
+                "authentik",
+                {
+                    "nickname": "test",
+                    "given_name": "John",
+                    "preferred_username": "test@example.com",
+                    "groups": ["admin"],
+                },
+                {
+                    "username": "test",
+                    "first_name": "John",
+                    "email": "test@example.com",
+                    "role_keys": ["admin"],
+                },
+            ),
+            (
+                "other",
+                {"preferred_username": "test", "email": "test@example.com"},
+                {
+                    "username": "test",
+                    "first_name": "",
+                    "last_name": "",
+                    "email": "test@example.com",
+                    "role_keys": [],
+                },
+            ),
+        ],
+    )
+    def test_get_oauth_user_info(self, provider, resp, user_info):
+        sm = EmptySecurityManager()
+        sm.appbuilder = Mock(sm=sm)
+        sm.oauth_remotes = {}
+        sm.oauth_remotes[provider] = Mock(
+            get=Mock(return_value=Mock(json=Mock(return_value=resp))),
+            userinfo=Mock(return_value=resp),
+        )
+        sm._decode_and_validate_azure_jwt = Mock(return_value=resp)
+        sm._get_authentik_token_info = Mock(return_value=resp)
+        assert sm.get_oauth_user_info(provider, {"id_token": None}) == user_info


Reviews:
user: login: jason810496
body: Nice! Thanks for the PR, could you add corresponding unit tests for the change?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T03:44:25Z
user: login: jason810496
body: > @jason810496 Thanks for the review! I noticed that there are no existing test cases for the `get_oauth_user_info` method in `providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py`. Would you like me to create a full test case from scratch (including the already supported providers)? If so, I'll be on it.

Yes, you are on the right track!

Thanks for adding the unit test, I left a comment for testing different auth provider scenarios.

state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-25T02:37:35Z
user: login: KKIEEK
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-25T08:05:37Z
user: login: jason810496
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-28T02:11:47Z
user: login: jason810496
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-28T02:13:51Z
user: login: vincbeck
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T13:48:41Z

Review comments:
diff_hunk: @@ -75,3 +75,22 @@ def test_check_password_not_match(self, check_password):
         sm.find_user = Mock(return_value=mock_user)
         check_password.return_value = False
         assert not sm.check_password("test_user", "test_password")
+
+    def test_get_oauth_user_info(self):
path: providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py
user: login: jason810496
body: How about having `pytest.parametrize` for specifying different auth providers and the _other_ provider as well.
By verifying `self.oauth_remotes[<provider_name>]` is called in the test should be enough.
created_at: 2025-05-25T02:35:14Z
author_association: MEMBER
diff_hunk: @@ -75,3 +75,22 @@ def test_check_password_not_match(self, check_password):
         sm.find_user = Mock(return_value=mock_user)
         check_password.return_value = False
         assert not sm.check_password("test_user", "test_password")
+
+    def test_get_oauth_user_info(self):
path: providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py
user: login: KKIEEK
body: I’ve applied your suggestion. Could you please check this out again?
created_at: 2025-05-25T08:05:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,3 +75,22 @@ def test_check_password_not_match(self, check_password):
         sm.find_user = Mock(return_value=mock_user)
         check_password.return_value = False
         assert not sm.check_password("test_user", "test_password")
+
+    def test_get_oauth_user_info(self):
path: providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py
user: login: jason810496
body: Thanks for adding the tests! LGTM.
created_at: 2025-05-28T02:11:46Z
author_association: MEMBER

PR comments:
user: login: boring-cyborg[bot]
created_at: 2025-05-21T17:51:07Z
body: Congratulations on your first Pull Request and welcome to the Apache Airflow community! If you have any issues or are unsure about any anything please check our Contributors' Guide (https://github.com/apache/airflow/blob/main/contributing-docs/README.rst)
Here are some useful points:
- Pay attention to the quality of your code (ruff, mypy and type annotations). Our [pre-commits]( https://github.com/apache/airflow/blob/main/contributing-docs/08_static_code_checks.rst#prerequisites-for-pre-commit-hooks) will help you with that.
- In case of a new feature add useful documentation (in docstrings or in `docs/` directory). Adding a new operator? Check this short [guide](https://github.com/apache/airflow/blob/main/airflow-core/docs/howto/custom-operator.rst) Consider adding an example DAG that shows how users should use it.
- Consider using [Breeze environment](https://github.com/apache/airflow/blob/main/dev/breeze/doc/README.rst) for testing locally, it's a heavy docker but it ships with a working Airflow and a lot of integrations.
- Be patient and persistent. It might take some time to get a review or get the final approval from Committers.
- Please follow [ASF Code of Conduct](https://www.apache.org/foundation/policies/conduct) for all communication including (but not limited to) comments on Pull Requests, Mailing list and Slack.
- Be sure to read the [Airflow Coding style]( https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#coding-style-and-best-practices).
- Always keep your Pull Requests rebased, otherwise your build might fail due to changes not related to your commits.
Apache Airflow is a community-driven project and together we are making it better 🚀.
In case of doubts contact the developers at:
Mailing List: dev@airflow.apache.org
Slack: https://s.apache.org/airflow-slack

author_association: NONE
user: login: KKIEEK
created_at: 2025-05-23T08:30:18Z
body: @jason810496 Thanks for the review!
I noticed that there are no existing test cases for the `get_oauth_user_info` method in `providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py`. Would you like me to create a full test case from scratch (including the already supported providers)? If so, I'll be on it.
author_association: CONTRIBUTOR
user: login: boring-cyborg[bot]
created_at: 2025-05-28T13:48:49Z
body: Awesome work, congrats on your first merged pull request! You are invited to check our [Issue Tracker](https://github.com/apache/airflow/issues) for additional contributions.

author_association: NONE