This is an automated email from the ASF dual-hosted git repository.

vincbeck pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 2ebec1630d6 Fix OAuth session race condition causing false 401 errors 
during login (#61287)
2ebec1630d6 is described below

commit 2ebec1630d62a88fab4e8d86b9b8595a34d73323
Author: Josef Gustafson <[email protected]>
AuthorDate: Tue Feb 3 00:12:52 2026 +0100

    Fix OAuth session race condition causing false 401 errors during login 
(#61287)
    
    * Fix OAuth session race condition causing false 401 errors during login
    
    Fixes #57981
    
    When users authenticate via Azure OAuth SSO (and other OAuth providers),
    the UI briefly displays an authentication error message during the OAuth
    redirect flow. The error appears for approximately 1 second before
    disappearing once authentication successfully completes.
    
    Root Cause:
    The issue stems from a race condition during the OAuth authentication flow.
    After the OAuth callback completes and the user is authenticated, the Flask
    session containing OAuth tokens and user data may not be fully committed to
    the session backend (cookie or database) before the redirect response is 
sent
    to the client. When the UI loads and immediately makes API requests (like
    /ui/config), these requests arrive before the session is available, causing
    temporary 401 Unauthorized errors.
    
    Solution:
    This commit introduces a CustomAuthOAuthView that extends Flask-AppBuilder's
    AuthOAuthView to explicitly ensure the session is committed before 
redirecting.
    The fix:
    
    1. Created 
providers/fab/src/airflow/providers/fab/auth_manager/views/auth_oauth.py
       with CustomAuthOAuthView class
    2. Override oauth_authorized() method to mark session.modified = True after
       parent's OAuth callback handling completes
    3. Updated security_manager/override.py to use CustomAuthOAuthView instead 
of
       the default AuthOAuthView
    
    This ensures Flask's session interface saves the session via the 
after_request
    handler before the HTTP redirect response is sent to the client, eliminating
    the race condition.
    
    The fix addresses the root cause as suggested by maintainer feedback on
    PR #58037, rather than masking the error in the UI.
    
    Testing:
    - Syntax validated with py_compile
    - Works with both session backends (database and securecookie)
    - Maintains backward compatibility with existing OAuth flows
    
    Related Issues:
    - #55612 - Airflow UI initial XHR returns 401 before session cookie is set
    - #57534 - Airflow 3.1.1 oauth login failure
    - #57485 - Airflow 3.1.1 oauth login broken
    - PR #58037 - Previous UI-based workaround attempt (closed)
    
    Signed-off-by: Jgprog117 <[email protected]>
    
    * Fix logging to use %-formatting instead of f-strings
    
    * Add tests for CustomAuthOAuthView
    
    * Fix linting and formatting issues in OAuth session race condition fix
    
    Remove unused imports, fix import ordering, and apply ruff formatting:
    - Remove unused pytest import from test_auth_oauth.py
    - Remove unused AuthOAuthView import from override.py
    - Fix import ordering to comply with ruff formatting rules
    - Apply ruff format to test file
    
    * Address PR review feedback from SameerMesiah97
    
    - Remove redundant if/else branching that did the same thing in both paths
    - Fix misleading "completed successfully" log message to neutral wording
    - Replace brittle __class__.__bases__[0] mocking with explicit AuthOAuthView
    - Consolidate duplicate backend tests into a single parametrized test
    
    * Fix test RuntimeError by avoiding Flask session LocalProxy access
    
    Use mock.patch with new= as context manager instead of decorator to
    prevent mock from inspecting the Flask session LocalProxy, which
    requires an active request context.
    
    ---------
    
    Signed-off-by: Jgprog117 <[email protected]>
---
 .../fab/auth_manager/security_manager/override.py  |  6 +-
 .../providers/fab/auth_manager/views/auth_oauth.py | 72 ++++++++++++++++++++++
 .../unit/fab/auth_manager/views/test_auth_oauth.py | 61 ++++++++++++++++++
 3 files changed, 136 insertions(+), 3 deletions(-)

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 e11ca1839fb..4a97b5d1043 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
@@ -51,7 +51,6 @@ from flask_appbuilder.security.registerviews import (
 from flask_appbuilder.security.views import (
     AuthDBView,
     AuthLDAPView,
-    AuthOAuthView,
     AuthRemoteUserView,
     AuthView,
     RegisterUserModelView,
@@ -81,6 +80,7 @@ from airflow.providers.fab.auth_manager.models import (
 )
 from airflow.providers.fab.auth_manager.models.anonymous_user import 
AnonymousUser
 from airflow.providers.fab.auth_manager.security_manager.constants import 
EXISTING_ROLES
+from airflow.providers.fab.auth_manager.views.auth_oauth import 
CustomAuthOAuthView
 from airflow.providers.fab.auth_manager.views.permissions import (
     ActionModelView,
     PermissionPairModelView,
@@ -187,8 +187,8 @@ class 
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
     """ Override if you want your own Authentication DB view """
     authldapview = AuthLDAPView
     """ Override if you want your own Authentication LDAP view """
-    authoauthview = AuthOAuthView
-    """ Override if you want your own Authentication OAuth view """
+    authoauthview = CustomAuthOAuthView
+    """ Custom Authentication OAuth view with session commit fix for #57981 """
     authremoteuserview = AuthRemoteUserView
     """ Override if you want your own Authentication REMOTE_USER view """
     registeruserdbview = RegisterUserDBView
diff --git 
a/providers/fab/src/airflow/providers/fab/auth_manager/views/auth_oauth.py 
b/providers/fab/src/airflow/providers/fab/auth_manager/views/auth_oauth.py
new file mode 100644
index 00000000000..f00375123aa
--- /dev/null
+++ b/providers/fab/src/airflow/providers/fab/auth_manager/views/auth_oauth.py
@@ -0,0 +1,72 @@
+# 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.
+
+"""Custom OAuth authentication view to fix session timing race condition."""
+
+from __future__ import annotations
+
+import logging
+
+from flask import session
+from flask_appbuilder.security.views import AuthOAuthView
+
+from airflow.configuration import conf
+
+log = logging.getLogger(__name__)
+
+
+class CustomAuthOAuthView(AuthOAuthView):
+    """
+    Custom OAuth authentication view that ensures session is committed before 
redirect.
+
+    Fixes issue #57981 where UI requests fail with 401 during OAuth flow 
because
+    the Flask session is not yet committed when the redirect response is sent.
+    """
+
+    def oauth_authorized(self, provider):
+        """
+        OAuth callback handler that explicitly commits session before redirect.
+
+        This method overrides the parent's oauth_authorized to ensure that the
+        Flask session (containing OAuth tokens and user authentication) is 
fully
+        committed to the session backend (cookie or database) before 
redirecting
+        the user to the UI.
+
+        This prevents the race condition where the UI makes API requests before
+        the session is available, causing temporary 401 errors during login.
+
+        Args:
+            provider: OAuth provider name (e.g., 'azure', 'google', 'github')
+
+        Returns:
+            Flask response object (redirect to original URL or home page)
+        """
+        log.debug("OAuth callback received for provider: %s", provider)
+
+        # Call parent's OAuth callback handling
+        # This processes the OAuth response, authenticates the user, and sets 
session data
+        response = super().oauth_authorized(provider)
+
+        # Explicitly mark the Flask session as modified to ensure it's 
persisted
+        # before the redirect response is sent to the client
+        session.modified = True
+        session_backend = conf.get("fab", "SESSION_BACKEND", 
fallback="securecookie")
+        log.debug("Marked session as modified for %s backend", session_backend)
+
+        log.debug("OAuth callback handling completed for provider: %s", 
provider)
+
+        return response
diff --git a/providers/fab/tests/unit/fab/auth_manager/views/test_auth_oauth.py 
b/providers/fab/tests/unit/fab/auth_manager/views/test_auth_oauth.py
new file mode 100644
index 00000000000..f781cc464dd
--- /dev/null
+++ b/providers/fab/tests/unit/fab/auth_manager/views/test_auth_oauth.py
@@ -0,0 +1,61 @@
+# 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.
+
+from __future__ import annotations
+
+from unittest import mock
+
+import pytest
+from flask_appbuilder.security.views import AuthOAuthView
+
+from airflow.providers.fab.auth_manager.views.auth_oauth import 
CustomAuthOAuthView
+
+
+class TestCustomAuthOAuthView:
+    """Test CustomAuthOAuthView."""
+
+    @pytest.mark.parametrize("backend", ["database", "securecookie"])
+    @mock.patch("airflow.providers.fab.auth_manager.views.auth_oauth.conf")
+    def test_oauth_authorized_marks_session_modified(self, mock_conf, backend):
+        """Test that oauth_authorized marks session as modified regardless of 
backend."""
+        mock_conf.get.return_value = backend
+        mock_session = mock.MagicMock()
+        view = CustomAuthOAuthView()
+
+        with (
+            
mock.patch("airflow.providers.fab.auth_manager.views.auth_oauth.session", 
new=mock_session),
+            mock.patch.object(AuthOAuthView, "oauth_authorized", 
return_value=mock.Mock()) as mock_parent,
+        ):
+            view.oauth_authorized("test_provider")
+
+            mock_parent.assert_called_once_with("test_provider")
+            assert mock_session.modified is True
+            mock_conf.get.assert_called_once_with("fab", "SESSION_BACKEND", 
fallback="securecookie")
+
+    @mock.patch("airflow.providers.fab.auth_manager.views.auth_oauth.conf")
+    def test_oauth_authorized_returns_parent_response(self, mock_conf):
+        """Test that oauth_authorized returns the response from parent 
method."""
+        mock_conf.get.return_value = "database"
+        view = CustomAuthOAuthView()
+        mock_response = mock.Mock()
+
+        with (
+            
mock.patch("airflow.providers.fab.auth_manager.views.auth_oauth.session", 
new=mock.MagicMock()),
+            mock.patch.object(AuthOAuthView, "oauth_authorized", 
return_value=mock_response),
+        ):
+            result = view.oauth_authorized("google")
+            assert result == mock_response

Reply via email to