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 bb213a30692 Honor next when an authenticated user reaches the FAB
login page (#75665)
bb213a30692 is described below
commit bb213a306922ba527b1c7677f3a66ba269732abf
Author: Mike Barry <[email protected]>
AuthorDate: Mon Oct 26 11:20:29 2026 -0400
Honor next when an authenticated user reaches the FAB login page (#75665)
---
.../fab/auth_manager/security_manager/override.py | 7 +-
.../providers/fab/auth_manager/views/auth_login.py | 67 ++++++++++
.../providers/fab/auth_manager/views/auth_oauth.py | 9 ++
.../unit/fab/auth_manager/views/test_auth_login.py | 146 +++++++++++++++++++++
.../tests/unit/fab/www/views/test_views_login.py | 90 +++++++++++++
5 files changed, 315 insertions(+), 4 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 8f193335136..40d00b05293 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
@@ -53,8 +53,6 @@ from flask_appbuilder.security.registerviews import (
RegisterUserOAuthView,
)
from flask_appbuilder.security.views import (
- AuthDBView,
- AuthLDAPView,
AuthRemoteUserView,
AuthView,
RegisterUserModelView,
@@ -87,6 +85,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_login import
CustomAuthDBView, CustomAuthLDAPView
from airflow.providers.fab.auth_manager.views.auth_oauth import
CustomAuthOAuthView
from airflow.providers.fab.auth_manager.views.permissions import (
ActionModelView,
@@ -213,9 +212,9 @@ class
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
permission_model = Permission
""" Views """
- authdbview = AuthDBView
+ authdbview = CustomAuthDBView
""" Override if you want your own Authentication DB view """
- authldapview = AuthLDAPView
+ authldapview = CustomAuthLDAPView
""" Override if you want your own Authentication LDAP view """
authoauthview = CustomAuthOAuthView
""" Custom Authentication OAuth view with session commit fix for #57981 """
diff --git
a/providers/fab/src/airflow/providers/fab/auth_manager/views/auth_login.py
b/providers/fab/src/airflow/providers/fab/auth_manager/views/auth_login.py
new file mode 100644
index 00000000000..9dc2bba91cf
--- /dev/null
+++ b/providers/fab/src/airflow/providers/fab/auth_manager/views/auth_login.py
@@ -0,0 +1,67 @@
+# 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.
+"""Login views that honor ``next`` when the user is already authenticated."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from flask import g, request
+from flask_appbuilder import expose
+from flask_appbuilder.security.decorators import no_cache
+from flask_appbuilder.security.views import AuthDBView, AuthLDAPView
+
+from airflow.providers.fab.www.views import get_safe_url, redirect
+
+if TYPE_CHECKING:
+ from werkzeug.wrappers import Response
+
+
+def redirect_if_authenticated() -> Response | None:
+ """
+ Redirect an already-authenticated user to the requested ``next`` URL.
+
+ The UI sends the user back to the login page when its API token has
expired, even though the
+ Flask session is still valid. Flask-AppBuilder's login views then redirect
to the index page and
+ discard ``next``, so the user loses the page they were opening.
``redirect`` sets a fresh API
+ token cookie, so the user lands on the requested page authenticated.
+ """
+ if g.user is not None and g.user.is_authenticated:
+ return redirect(get_safe_url(request.args.get("next")))
+ return None
+
+
+class CustomAuthDBView(AuthDBView):
+ """Database login view that honors ``next`` for already-authenticated
users."""
+
+ @expose("/login/", methods=["GET", "POST"])
+ @no_cache
+ def login(self):
+ if (response := redirect_if_authenticated()) is not None:
+ return response
+ return super().login()
+
+
+class CustomAuthLDAPView(AuthLDAPView):
+ """LDAP login view that honors ``next`` for already-authenticated users."""
+
+ @expose("/login/", methods=["GET", "POST"])
+ @no_cache
+ def login(self):
+ if (response := redirect_if_authenticated()) is not None:
+ return response
+ return super().login()
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
index a5d4e520d07..e9140152ff8 100644
--- 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
@@ -26,6 +26,7 @@ from flask_appbuilder import expose
from flask_appbuilder.security.views import AuthOAuthView
from airflow.providers.common.compat.sdk import conf
+from airflow.providers.fab.auth_manager.views.auth_login import
redirect_if_authenticated
log = logging.getLogger(__name__)
@@ -38,6 +39,14 @@ class CustomAuthOAuthView(AuthOAuthView):
the Flask session is not yet committed when the redirect response is sent.
"""
+ @expose("/login/")
+ @expose("/login/<provider>")
+ def login(self, provider=None):
+ """Redirect already-authenticated users to ``next`` instead of the
index page."""
+ if (response := redirect_if_authenticated()) is not None:
+ return response
+ return super().login(provider)
+
@expose("/oauth-authorized/<provider>")
def oauth_authorized(self, provider):
"""
diff --git a/providers/fab/tests/unit/fab/auth_manager/views/test_auth_login.py
b/providers/fab/tests/unit/fab/auth_manager/views/test_auth_login.py
new file mode 100644
index 00000000000..d6cdfa999a3
--- /dev/null
+++ b/providers/fab/tests/unit/fab/auth_manager/views/test_auth_login.py
@@ -0,0 +1,146 @@
+# 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 import Flask, Response, g
+from flask_appbuilder.security.views import AuthDBView, AuthLDAPView,
AuthOAuthView
+
+from airflow.providers.fab.auth_manager.views import auth_login
+from airflow.providers.fab.auth_manager.views.auth_login import (
+ CustomAuthDBView,
+ CustomAuthLDAPView,
+ redirect_if_authenticated,
+)
+from airflow.providers.fab.auth_manager.views.auth_oauth import
CustomAuthOAuthView
+
+MODULE = "airflow.providers.fab.auth_manager.views.auth_login"
+
+
[email protected]
+def flask_app():
+ return Flask(__name__)
+
+
[email protected]
+def mock_get_safe_url():
+ with mock.patch(f"{MODULE}.get_safe_url", side_effect=lambda url:
f"safe:{url}") as get_safe_url:
+ yield get_safe_url
+
+
[email protected]
+def mock_redirect():
+ with mock.patch(f"{MODULE}.redirect") as redirect:
+ yield redirect
+
+
+def _set_user(authenticated: bool | None) -> None:
+ g.user = None if authenticated is None else
mock.Mock(is_authenticated=authenticated)
+
+
+class TestRedirectIfAuthenticated:
+ def test_redirects_authenticated_user_to_next(self, flask_app,
mock_get_safe_url, mock_redirect):
+ with flask_app.test_request_context("/login/?next=/dags/my_dag"):
+ _set_user(authenticated=True)
+ response = redirect_if_authenticated()
+
+ mock_get_safe_url.assert_called_once_with("/dags/my_dag")
+ mock_redirect.assert_called_once_with("safe:/dags/my_dag")
+ assert response is mock_redirect.return_value
+
+ def test_falls_back_to_index_without_next(self, flask_app,
mock_get_safe_url, mock_redirect):
+ with flask_app.test_request_context("/login/"):
+ _set_user(authenticated=True)
+ redirect_if_authenticated()
+
+ mock_get_safe_url.assert_called_once_with(None)
+ mock_redirect.assert_called_once_with("safe:None")
+
+ @pytest.mark.parametrize("authenticated", [None, False])
+ def test_does_nothing_for_anonymous_user(
+ self, flask_app, authenticated, mock_get_safe_url, mock_redirect
+ ):
+ with flask_app.test_request_context("/login/?next=/dags/my_dag"):
+ _set_user(authenticated=authenticated)
+ assert redirect_if_authenticated() is None
+
+ mock_redirect.assert_not_called()
+
+
+class TestCustomLoginViews:
+ @pytest.mark.parametrize(
+ ("view_class", "routes"),
+ [
+ (CustomAuthDBView, {"/login/"}),
+ (CustomAuthLDAPView, {"/login/"}),
+ (CustomAuthOAuthView, {"/login/", "/login/<provider>"}),
+ ],
+ )
+ def test_login_routes_are_exposed(self, view_class, routes):
+ assert {route for route, _methods in view_class.login._urls} == routes
+
+ @pytest.mark.parametrize(
+ ("view_class", "parent_class", "args"),
+ [
+ (CustomAuthDBView, AuthDBView, ()),
+ (CustomAuthLDAPView, AuthLDAPView, ()),
+ (CustomAuthOAuthView, AuthOAuthView, ("azure",)),
+ ],
+ )
+ def test_authenticated_login_redirects_to_next(self, flask_app,
view_class, parent_class, args):
+ view = view_class()
+ redirect_response = Response(status=302, headers={"Location":
"/dags/my_dag"})
+ with (
+ flask_app.test_request_context("/login/?next=/dags/my_dag"),
+ mock.patch(f"{view_class.__module__}.redirect_if_authenticated",
return_value=redirect_response),
+ mock.patch.object(parent_class, "login") as parent_login,
+ ):
+ response = view.login(*args)
+
+ assert response.status_code == 302
+ assert response.location == "/dags/my_dag"
+ parent_login.assert_not_called()
+
+ @pytest.mark.parametrize(
+ ("view_class", "parent_class", "args", "expected_args"),
+ [
+ (CustomAuthDBView, AuthDBView, (), ()),
+ (CustomAuthLDAPView, AuthLDAPView, (), ()),
+ (CustomAuthOAuthView, AuthOAuthView, ("azure",), ("azure",)),
+ (CustomAuthOAuthView, AuthOAuthView, (), (None,)),
+ ],
+ )
+ def test_anonymous_login_uses_parent(self, flask_app, view_class,
parent_class, args, expected_args):
+ view = view_class()
+ with (
+ flask_app.test_request_context("/login/"),
+ mock.patch(f"{view_class.__module__}.redirect_if_authenticated",
return_value=None),
+ mock.patch.object(parent_class, "login",
return_value=Response("login page")) as parent_login,
+ ):
+ response = view.login(*args)
+
+ assert response.get_data(as_text=True) == "login page"
+ parent_login.assert_called_once_with(*expected_args)
+
+
+def test_module_uses_token_setting_redirect():
+ from airflow.providers.fab.www import views
+
+ assert auth_login.redirect is views.redirect
diff --git a/providers/fab/tests/unit/fab/www/views/test_views_login.py
b/providers/fab/tests/unit/fab/www/views/test_views_login.py
new file mode 100644
index 00000000000..bb54bcafc93
--- /dev/null
+++ b/providers/fab/tests/unit/fab/www/views/test_views_login.py
@@ -0,0 +1,90 @@
+# 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
+
+import pytest
+
+from airflow.api_fastapi.auth.managers.base_auth_manager import
COOKIE_NAME_JWT_TOKEN
+from airflow.providers.fab.www import app as application
+from airflow.providers.fab.www.security import permissions
+
+from tests_common.test_utils.config import conf_vars
+from unit.fab.auth_manager.test_utils import create_user, delete_user
+from unit.fab.utils import client_with_login
+
+pytestmark = pytest.mark.db_test
+
+USERNAME = "test_login_next"
+
+
[email protected]
+def app():
+ with conf_vars(
+ {("core", "auth_manager"):
"airflow.providers.fab.auth_manager.fab_auth_manager.FabAuthManager"}
+ ):
+ app = application.create_app(enable_plugins=False)
+ app.config["WTF_CSRF_ENABLED"] = False
+ try:
+ yield app
+ finally:
+ with app.app_context():
+ for fab_engine in
app.extensions["sqlalchemy"].engines.values():
+ fab_engine.dispose()
+
+
[email protected]
+def client(app):
+ with app.app_context():
+ create_user(
+ app,
+ username=USERNAME,
+ role_name="role_login_next",
+ permissions=[(permissions.ACTION_CAN_READ,
permissions.RESOURCE_WEBSITE)],
+ )
+ try:
+ yield client_with_login(app, username=USERNAME, password=USERNAME)
+ finally:
+ delete_user(app, USERNAME)
+
+
+def _sets_token_cookie(response) -> bool:
+ return any(
+ header.startswith(f"{COOKIE_NAME_JWT_TOKEN}=") for header in
response.headers.getlist("Set-Cookie")
+ )
+
+
+class TestLoginWhenAuthenticated:
+ def test_redirects_to_next(self, client):
+ response = client.get("/login/?next=/dags/my_dag/tasks/my_task")
+
+ assert response.status_code == 302
+ assert response.location ==
"http://localhost/dags/my_dag/tasks/my_task"
+ assert _sets_token_cookie(response)
+
+ def test_redirects_to_index_without_next(self, client):
+ response = client.get("/login/")
+
+ assert response.status_code == 302
+ assert response.location == "/"
+ assert _sets_token_cookie(response)
+
+ def test_rejects_external_next(self, client):
+ response = client.get("/login/?next=https://evil.example.com/")
+
+ assert response.status_code == 302
+ assert "evil.example.com" not in response.location