Skip to content

Commit 2ebec16

Browse files
authored
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 <gustafsonjosef@gmail.com> * 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 <gustafsonjosef@gmail.com>
1 parent baecfdd commit 2ebec16

File tree

3 files changed

+136
-3
lines changed

3 files changed

+136
-3
lines changed

providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@
5151
from flask_appbuilder.security.views import (
5252
AuthDBView,
5353
AuthLDAPView,
54-
AuthOAuthView,
5554
AuthRemoteUserView,
5655
AuthView,
5756
RegisterUserModelView,
@@ -81,6 +80,7 @@
8180
)
8281
from airflow.providers.fab.auth_manager.models.anonymous_user import AnonymousUser
8382
from airflow.providers.fab.auth_manager.security_manager.constants import EXISTING_ROLES
83+
from airflow.providers.fab.auth_manager.views.auth_oauth import CustomAuthOAuthView
8484
from airflow.providers.fab.auth_manager.views.permissions import (
8585
ActionModelView,
8686
PermissionPairModelView,
@@ -187,8 +187,8 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
187187
""" Override if you want your own Authentication DB view """
188188
authldapview = AuthLDAPView
189189
""" Override if you want your own Authentication LDAP view """
190-
authoauthview = AuthOAuthView
191-
""" Override if you want your own Authentication OAuth view """
190+
authoauthview = CustomAuthOAuthView
191+
""" Custom Authentication OAuth view with session commit fix for #57981 """
192192
authremoteuserview = AuthRemoteUserView
193193
""" Override if you want your own Authentication REMOTE_USER view """
194194
registeruserdbview = RegisterUserDBView
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
"""Custom OAuth authentication view to fix session timing race condition."""
19+
20+
from __future__ import annotations
21+
22+
import logging
23+
24+
from flask import session
25+
from flask_appbuilder.security.views import AuthOAuthView
26+
27+
from airflow.configuration import conf
28+
29+
log = logging.getLogger(__name__)
30+
31+
32+
class CustomAuthOAuthView(AuthOAuthView):
33+
"""
34+
Custom OAuth authentication view that ensures session is committed before redirect.
35+
36+
Fixes issue #57981 where UI requests fail with 401 during OAuth flow because
37+
the Flask session is not yet committed when the redirect response is sent.
38+
"""
39+
40+
def oauth_authorized(self, provider):
41+
"""
42+
OAuth callback handler that explicitly commits session before redirect.
43+
44+
This method overrides the parent's oauth_authorized to ensure that the
45+
Flask session (containing OAuth tokens and user authentication) is fully
46+
committed to the session backend (cookie or database) before redirecting
47+
the user to the UI.
48+
49+
This prevents the race condition where the UI makes API requests before
50+
the session is available, causing temporary 401 errors during login.
51+
52+
Args:
53+
provider: OAuth provider name (e.g., 'azure', 'google', 'github')
54+
55+
Returns:
56+
Flask response object (redirect to original URL or home page)
57+
"""
58+
log.debug("OAuth callback received for provider: %s", provider)
59+
60+
# Call parent's OAuth callback handling
61+
# This processes the OAuth response, authenticates the user, and sets session data
62+
response = super().oauth_authorized(provider)
63+
64+
# Explicitly mark the Flask session as modified to ensure it's persisted
65+
# before the redirect response is sent to the client
66+
session.modified = True
67+
session_backend = conf.get("fab", "SESSION_BACKEND", fallback="securecookie")
68+
log.debug("Marked session as modified for %s backend", session_backend)
69+
70+
log.debug("OAuth callback handling completed for provider: %s", provider)
71+
72+
return response
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
from __future__ import annotations
19+
20+
from unittest import mock
21+
22+
import pytest
23+
from flask_appbuilder.security.views import AuthOAuthView
24+
25+
from airflow.providers.fab.auth_manager.views.auth_oauth import CustomAuthOAuthView
26+
27+
28+
class TestCustomAuthOAuthView:
29+
"""Test CustomAuthOAuthView."""
30+
31+
@pytest.mark.parametrize("backend", ["database", "securecookie"])
32+
@mock.patch("airflow.providers.fab.auth_manager.views.auth_oauth.conf")
33+
def test_oauth_authorized_marks_session_modified(self, mock_conf, backend):
34+
"""Test that oauth_authorized marks session as modified regardless of backend."""
35+
mock_conf.get.return_value = backend
36+
mock_session = mock.MagicMock()
37+
view = CustomAuthOAuthView()
38+
39+
with (
40+
mock.patch("airflow.providers.fab.auth_manager.views.auth_oauth.session", new=mock_session),
41+
mock.patch.object(AuthOAuthView, "oauth_authorized", return_value=mock.Mock()) as mock_parent,
42+
):
43+
view.oauth_authorized("test_provider")
44+
45+
mock_parent.assert_called_once_with("test_provider")
46+
assert mock_session.modified is True
47+
mock_conf.get.assert_called_once_with("fab", "SESSION_BACKEND", fallback="securecookie")
48+
49+
@mock.patch("airflow.providers.fab.auth_manager.views.auth_oauth.conf")
50+
def test_oauth_authorized_returns_parent_response(self, mock_conf):
51+
"""Test that oauth_authorized returns the response from parent method."""
52+
mock_conf.get.return_value = "database"
53+
view = CustomAuthOAuthView()
54+
mock_response = mock.Mock()
55+
56+
with (
57+
mock.patch("airflow.providers.fab.auth_manager.views.auth_oauth.session", new=mock.MagicMock()),
58+
mock.patch.object(AuthOAuthView, "oauth_authorized", return_value=mock_response),
59+
):
60+
result = view.oauth_authorized("google")
61+
assert result == mock_response

0 commit comments

Comments
 (0)