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