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 3546d02423d Add FAB option to log users out after a maximum session
lifetime (#72825)
3546d02423d is described below
commit 3546d02423d06463f44b992974511cb12327c743
Author: Vincent <[email protected]>
AuthorDate: Tue Sep 15 14:33:53 2026 -0400
Add FAB option to log users out after a maximum session lifetime (#72825)
Deployments with compliance requirements need users to re-authenticate on a
fixed schedule. The existing [fab] session_lifetime_minutes cannot express
that: it is an inactivity deadline, and an Airflow UI tab left open keeps
the
session active on its own because the UI polls the API in the background and
silently re-authenticates whenever its token expires, so the deadline never
arrives.
Reported in https://github.com/apache/airflow/issues/48787.
---
providers/fab/provider.yaml | 29 +++-
.../src/airflow/providers/fab/get_provider_info.py | 9 +-
providers/fab/src/airflow/providers/fab/www/app.py | 6 +-
.../providers/fab/www/extensions/init_session.py | 59 ++++++++
.../fab/src/airflow/providers/fab/www/views.py | 24 +++-
.../fab/tests/unit/fab/www/extensions/__init__.py | 16 +++
.../unit/fab/www/extensions/test_init_session.py | 157 +++++++++++++++++++++
providers/fab/tests/unit/fab/www/test_views.py | 67 +++++++++
8 files changed, 363 insertions(+), 4 deletions(-)
diff --git a/providers/fab/provider.yaml b/providers/fab/provider.yaml
index 4ee49559ea5..f0f45b9048c 100644
--- a/providers/fab/provider.yaml
+++ b/providers/fab/provider.yaml
@@ -222,11 +222,38 @@ config:
session_lifetime_minutes:
description: |
The UI cookie lifetime in minutes. User will be logged out from UI
after
- ``[fab] session_lifetime_minutes`` of non-activity
+ ``[fab] session_lifetime_minutes`` of inactivity: the deadline
slides forward on every
+ request, so it is only reached once the session has been idle for
the whole period.
+
+ Note that leaving an Airflow UI tab open counts as activity even
when nobody is at the
+ keyboard. The UI polls the API in the background and silently
re-authenticates whenever
+ its API token expires, which keeps sliding the deadline, so a
session with an open tab is
+ never idle and never expires. Use ``[fab]
session_max_lifetime_minutes`` to log users out
+ after a fixed period regardless of activity.
version_added: 2.0.0
type: integer
example: ~
default: "43200"
+ session_max_lifetime_minutes:
+ description: |
+ Maximum lifetime of a UI session in minutes, counted from the login
time and never
+ extended by activity. Unlike ``[fab] session_lifetime_minutes``,
this deadline is
+ reached even when the user keeps working in the UI, so it forces
periodic
+ re-authentication. Set to ``0`` (the default) to disable it.
+
+ The API tokens the UI receives are capped so that they never outlive
the deadline: their
+ expiry is the shorter of ``[api_auth] jwt_expiration_time`` and the
time left in the
+ session. Without that cap the deadline would only be noticed the
next time the UI came
+ back to the auth manager — which it does when its token expires —
and an already-issued
+ token would keep working against the API in the meantime.
+
+ This applies to UI sessions only. Tokens minted for programmatic
clients by
+ ``POST /auth/token`` belong to no session and always last
``[api_auth]
+ jwt_expiration_time``.
+ version_added: 3.9.0
+ type: integer
+ example: "480"
+ default: "0"
enable_proxy_fix:
description: |
Enable werkzeug ``ProxyFix`` middleware for reverse proxy
diff --git a/providers/fab/src/airflow/providers/fab/get_provider_info.py
b/providers/fab/src/airflow/providers/fab/get_provider_info.py
index ae6b9fe73c9..02922ce7ad3 100644
--- a/providers/fab/src/airflow/providers/fab/get_provider_info.py
+++ b/providers/fab/src/airflow/providers/fab/get_provider_info.py
@@ -136,12 +136,19 @@ def get_provider_info():
"default": "database",
},
"session_lifetime_minutes": {
- "description": "The UI cookie lifetime in minutes.
User will be logged out from UI after\n``[fab] session_lifetime_minutes`` of
non-activity\n",
+ "description": "The UI cookie lifetime in minutes.
User will be logged out from UI after\n``[fab] session_lifetime_minutes`` of
inactivity: the deadline slides forward on every\nrequest, so it is only
reached once the session has been idle for the whole period.\n\nNote that
leaving an Airflow UI tab open counts as activity even when nobody is at
the\nkeyboard. The UI polls the API in the background and silently
re-authenticates whenever\nits API token expires, whi [...]
"version_added": "2.0.0",
"type": "integer",
"example": None,
"default": "43200",
},
+ "session_max_lifetime_minutes": {
+ "description": "Maximum lifetime of a UI session in
minutes, counted from the login time and never\nextended by activity. Unlike
``[fab] session_lifetime_minutes``, this deadline is\nreached even when the
user keeps working in the UI, so it forces periodic\nre-authentication. Set to
``0`` (the default) to disable it.\n\nThe API tokens the UI receives are capped
so that they never outlive the deadline: their\nexpiry is the shorter of
``[api_auth] jwt_expiration_tim [...]
+ "version_added": "3.9.0",
+ "type": "integer",
+ "example": "480",
+ "default": "0",
+ },
"enable_proxy_fix": {
"description": "Enable werkzeug ``ProxyFix``
middleware for reverse proxy\n",
"version_added": "2.1.0",
diff --git a/providers/fab/src/airflow/providers/fab/www/app.py
b/providers/fab/src/airflow/providers/fab/www/app.py
index cf7658b69c9..82d38db7d61 100644
--- a/providers/fab/src/airflow/providers/fab/www/app.py
+++ b/providers/fab/src/airflow/providers/fab/www/app.py
@@ -35,7 +35,10 @@ from airflow.providers.fab.www.extensions.init_appbuilder
import init_appbuilder
from airflow.providers.fab.www.extensions.init_jinja_globals import
init_jinja_globals
from airflow.providers.fab.www.extensions.init_manifest_files import
configure_manifest_files
from airflow.providers.fab.www.extensions.init_security import init_api_auth
-from airflow.providers.fab.www.extensions.init_session import
init_airflow_session_interface
+from airflow.providers.fab.www.extensions.init_session import (
+ init_airflow_session_interface,
+ init_session_max_lifetime,
+)
from airflow.providers.fab.www.extensions.init_views import (
init_error_handlers,
init_plugins,
@@ -127,6 +130,7 @@ def create_app(enable_plugins: bool):
init_plugins(flask_app)
elif isinstance(get_auth_manager(), FabAuthManager):
init_airflow_session_interface(flask_app, db)
+ init_session_max_lifetime(flask_app)
init_jinja_globals(flask_app, enable_plugins=enable_plugins)
init_wsgi_middleware(flask_app)
return flask_app
diff --git
a/providers/fab/src/airflow/providers/fab/www/extensions/init_session.py
b/providers/fab/src/airflow/providers/fab/www/extensions/init_session.py
index 49dad277a21..6ca31ddb296 100644
--- a/providers/fab/src/airflow/providers/fab/www/extensions/init_session.py
+++ b/providers/fab/src/airflow/providers/fab/www/extensions/init_session.py
@@ -16,7 +16,11 @@
# under the License.
from __future__ import annotations
+import logging
+import time
+
from flask import session as builtin_flask_session
+from flask_login import current_user, logout_user, user_logged_in
from airflow.exceptions import AirflowConfigException
from airflow.providers.common.compat.sdk import conf
@@ -25,6 +29,10 @@ from airflow.providers.fab.www.session import (
AirflowSecureCookieSessionInterface,
)
+log = logging.getLogger(__name__)
+
+SESSION_LOGIN_TIME_KEY = "_login_at"
+
def init_airflow_session_interface(app, db):
"""Set airflow session interface."""
@@ -62,3 +70,54 @@ def init_airflow_session_interface(app, db):
f"[fab] session_backend: '{selected_backend}'. Please set "
"this to either 'database' or 'securecookie'."
)
+
+
+def get_max_session_lifetime_seconds() -> int:
+ """Return ``[fab] session_max_lifetime_minutes`` in seconds, or ``0`` when
the cap is disabled."""
+ return max(conf.getint("fab", "session_max_lifetime_minutes", fallback=0),
0) * 60
+
+
+def get_remaining_session_lifetime() -> float | None:
+ """
+ Return how many seconds are left before the current session hits its
maximum lifetime.
+
+ ``None`` means the session is not capped, either because ``[fab]
+ session_max_lifetime_minutes`` is disabled or because the session carries
no login stamp.
+ """
+ max_lifetime_seconds = get_max_session_lifetime_seconds()
+ if not max_lifetime_seconds:
+ return None
+ login_time = builtin_flask_session.get(SESSION_LOGIN_TIME_KEY)
+ if login_time is None:
+ return None
+ return login_time + max_lifetime_seconds - time.time()
+
+
+def init_session_max_lifetime(app):
+ """Expire sessions ``[fab] session_max_lifetime_minutes`` after login,
regardless of activity."""
+ if not get_max_session_lifetime_seconds():
+ return
+
+ # ``weak=False``: the receiver is a local function, so a weak subscription
could be collected.
+ @user_logged_in.connect_via(app, weak=False)
+ def stamp_login_time(sender, user, **kwargs):
+ # Wall clock rather than ``time.monotonic()``: the stamp is persisted
in the session and
+ # read back by other API server processes, which share no monotonic
clock origin.
+ builtin_flask_session[SESSION_LOGIN_TIME_KEY] = time.time()
+
+ @app.before_request
+ def expire_session_past_max_lifetime():
+ if SESSION_LOGIN_TIME_KEY not in builtin_flask_session:
+ # Sessions that predate this setting have no stamp; cap them from
now on rather than
+ # leaving them exempt forever.
+ if current_user.is_authenticated:
+ builtin_flask_session[SESSION_LOGIN_TIME_KEY] = time.time()
+ return
+ remaining = get_remaining_session_lifetime()
+ if remaining is not None and remaining < 0:
+ log.debug("Session reached [fab] session_max_lifetime_minutes,
expiring it.")
+ # ``logout_user`` rather than emptying the session: it also
invalidates the
+ # remember-me cookie and drops the user from the request context,
so the request that
+ # crossed the deadline is itself unauthenticated.
+ logout_user()
+ builtin_flask_session.pop(SESSION_LOGIN_TIME_KEY, None)
diff --git a/providers/fab/src/airflow/providers/fab/www/views.py
b/providers/fab/src/airflow/providers/fab/www/views.py
index 166343bb14d..f89517846ef 100644
--- a/providers/fab/src/airflow/providers/fab/www/views.py
+++ b/providers/fab/src/airflow/providers/fab/www/views.py
@@ -33,6 +33,7 @@ from airflow.api_fastapi.app import get_auth_manager
from airflow.api_fastapi.auth.managers.base_auth_manager import
COOKIE_NAME_JWT_TOKEN
from airflow.providers.common.compat.sdk import conf
from airflow.providers.fab.version_compat import AIRFLOW_V_3_1_1_PLUS,
AIRFLOW_V_3_1_8_PLUS
+from airflow.providers.fab.www.extensions.init_session import
get_remaining_session_lifetime
if AIRFLOW_V_3_1_8_PLUS:
from airflow.api_fastapi.app import get_cookie_path
@@ -114,9 +115,30 @@ def get_safe_url(url):
return redirect_url.geturl()
+def get_token_expiration_seconds() -> int:
+ """
+ Return how long the API token handed to the browser should live.
+
+ The UI only comes back to the auth manager once its token expires, so the
token expiry — not
+ the session cookie — is what actually forces a re-authentication. Capping
it at whatever is
+ left of the session keeps ``[fab] session_max_lifetime_minutes`` an exact
deadline instead of
+ one the user overshoots by up to ``[api_auth] jwt_expiration_time``.
+ """
+ expiration_seconds = conf.getint("api_auth", "jwt_expiration_time")
+ remaining_session_lifetime = get_remaining_session_lifetime()
+ if remaining_session_lifetime is None:
+ return expiration_seconds
+ # Truncate rather than round so the token never survives the deadline. At
least a second: a
+ # session already past its deadline is logged out on its next request
anyway, and a
+ # non-positive expiry would be rejected as malformed rather than as
expired.
+ return max(min(expiration_seconds, int(remaining_session_lifetime)), 1)
+
+
def redirect(*args, **kwargs):
if g.user is not None and g.user.is_authenticated:
- token = get_auth_manager().generate_jwt(g.user)
+ token = get_auth_manager().generate_jwt(
+ g.user, expiration_time_in_seconds=get_token_expiration_seconds()
+ )
response = make_response(flask_redirect(*args, **kwargs))
secure = request.scheme == "https" or bool(conf.get("api", "ssl_cert",
fallback=""))
diff --git a/providers/fab/tests/unit/fab/www/extensions/__init__.py
b/providers/fab/tests/unit/fab/www/extensions/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/providers/fab/tests/unit/fab/www/extensions/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/providers/fab/tests/unit/fab/www/extensions/test_init_session.py
b/providers/fab/tests/unit/fab/www/extensions/test_init_session.py
new file mode 100644
index 00000000000..4784f286d9c
--- /dev/null
+++ b/providers/fab/tests/unit/fab/www/extensions/test_init_session.py
@@ -0,0 +1,157 @@
+# 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 datetime
+import time
+from contextlib import contextmanager
+from datetime import timedelta
+
+import pytest
+import time_machine
+from flask import Flask, session as builtin_flask_session
+from flask_login import LoginManager, current_user, login_user
+
+from airflow.providers.fab.www.extensions.init_session import (
+ SESSION_LOGIN_TIME_KEY,
+ get_remaining_session_lifetime,
+ init_session_max_lifetime,
+)
+
+from tests_common.test_utils.config import conf_vars
+
+LOGIN_TIME = datetime.datetime(2026, 9, 9, 12, 0, tzinfo=datetime.timezone.utc)
+
+
+class FakeUser:
+ is_authenticated = True
+ is_active = True
+ is_anonymous = False
+
+ def get_id(self):
+ return "1"
+
+
+@contextmanager
+def build_client(max_lifetime_minutes: int, remember_cookie_name: str | None =
None):
+ """Yield a test client for an app wired up with
``session_max_lifetime_minutes`` in force."""
+ app = Flask(__name__)
+ app.secret_key = "test-secret-key"
+ if remember_cookie_name:
+ app.config["REMEMBER_COOKIE_NAME"] = remember_cookie_name
+
+ login_manager = LoginManager(app)
+ login_manager.session_protection = None
+ login_manager.user_loader(lambda user_id: FakeUser() if user_id == "1"
else None)
+
+ @app.route("/login")
+ def login():
+ login_user(FakeUser(), remember=bool(remember_cookie_name))
+ return ""
+
+ @app.route("/whoami")
+ def whoami():
+ return "authenticated" if current_user.is_authenticated else
"anonymous"
+
+ with conf_vars({("fab", "session_max_lifetime_minutes"):
str(max_lifetime_minutes)}):
+ init_session_max_lifetime(app)
+ yield app.test_client()
+
+
+def get_body(response) -> str:
+ return response.get_data(as_text=True)
+
+
[email protected](
+ ("minutes_since_login", "expected"),
+ [(30, "authenticated"), (31, "anonymous")],
+)
+def test_session_expires_at_max_lifetime_despite_activity(minutes_since_login,
expected):
+ with build_client(30) as client, time_machine.travel(LOGIN_TIME,
tick=False) as traveller:
+ client.get("/login")
+ # Requesting throughout the window must not push the deadline back.
+ traveller.shift(timedelta(minutes=minutes_since_login - 1))
+ assert get_body(client.get("/whoami")) == "authenticated"
+ traveller.shift(timedelta(minutes=1))
+ assert get_body(client.get("/whoami")) == expected
+
+
+def test_session_never_expires_when_max_lifetime_is_disabled():
+ with build_client(0) as client, time_machine.travel(LOGIN_TIME,
tick=False) as traveller:
+ client.get("/login")
+ traveller.shift(timedelta(days=30))
+ assert get_body(client.get("/whoami")) == "authenticated"
+
+
+def test_session_predating_the_setting_is_capped_from_its_next_request():
+ with build_client(30) as client, time_machine.travel(LOGIN_TIME,
tick=False) as traveller:
+ with client.session_transaction() as flask_session:
+ flask_session["_user_id"] = "1"
+
+ assert get_body(client.get("/whoami")) == "authenticated"
+ traveller.shift(timedelta(minutes=31))
+ assert get_body(client.get("/whoami")) == "anonymous"
+
+
+def test_expiring_a_session_clears_the_remember_me_cookie():
+ with (
+ build_client(30, remember_cookie_name="remember_token") as client,
+ time_machine.travel(LOGIN_TIME, tick=False) as traveller,
+ ):
+ login = client.get("/login")
+ assert any(h.startswith("remember_token=") for h in
login.headers.getlist("Set-Cookie"))
+ traveller.shift(timedelta(minutes=31))
+
+ response = client.get("/whoami")
+
+ assert get_body(response) == "anonymous"
+ # Read the headers rather than the client cookie jar: the jar
accessors differ between the
+ # Werkzeug versions we support.
+ assert any(h.startswith("remember_token=;") for h in
response.headers.getlist("Set-Cookie"))
+
+
[email protected](
+ ("max_lifetime_minutes", "minutes_since_login", "expected"),
+ [
+ pytest.param(0, 0, None, id="uncapped-when-disabled"),
+ pytest.param(30, 0, 1800, id="full-window-at-login"),
+ pytest.param(30, 10, 1200, id="shrinks-as-the-session-ages"),
+ pytest.param(30, 31, -60, id="negative-once-past-the-deadline"),
+ ],
+)
+def test_get_remaining_session_lifetime(max_lifetime_minutes,
minutes_since_login, expected):
+ app = Flask(__name__)
+ app.secret_key = "test-secret-key"
+
+ with (
+ conf_vars({("fab", "session_max_lifetime_minutes"):
str(max_lifetime_minutes)}),
+ time_machine.travel(LOGIN_TIME, tick=False) as traveller,
+ app.test_request_context(),
+ ):
+ builtin_flask_session[SESSION_LOGIN_TIME_KEY] = time.time()
+ traveller.shift(timedelta(minutes=minutes_since_login))
+
+ assert get_remaining_session_lifetime() == expected
+
+
+def test_remaining_session_lifetime_is_unknown_without_a_login_stamp():
+ app = Flask(__name__)
+ app.secret_key = "test-secret-key"
+
+ with conf_vars({("fab", "session_max_lifetime_minutes"): "30"}),
app.test_request_context():
+ assert get_remaining_session_lifetime() is None
diff --git a/providers/fab/tests/unit/fab/www/test_views.py
b/providers/fab/tests/unit/fab/www/test_views.py
new file mode 100644
index 00000000000..e859121203a
--- /dev/null
+++ b/providers/fab/tests/unit/fab/www/test_views.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.
+
+from __future__ import annotations
+
+import time
+
+import pytest
+from flask import Flask, session as builtin_flask_session
+
+from airflow.providers.fab.www.extensions.init_session import
SESSION_LOGIN_TIME_KEY
+from airflow.providers.fab.www.views import get_token_expiration_seconds
+
+from tests_common.test_utils.config import conf_vars
+
+JWT_EXPIRATION_TIME = 86400
+
+
[email protected]
+def app():
+ flask_app = Flask(__name__)
+ flask_app.secret_key = "test-secret-key"
+ return flask_app
+
+
[email protected](
+ ("max_lifetime_minutes", "seconds_since_login", "expected"),
+ [
+ pytest.param(0, 0, JWT_EXPIRATION_TIME,
id="uncapped-when-max-lifetime-disabled"),
+ pytest.param(480, 0, 480 * 60, id="capped-to-the-session-deadline"),
+ pytest.param(480, 600, 480 * 60 - 600,
id="capped-to-what-is-left-of-the-session"),
+ pytest.param(2880, 0, JWT_EXPIRATION_TIME,
id="jwt-expiration-wins-when-it-is-shorter"),
+ pytest.param(480, 480 * 60, 1,
id="floor-of-one-second-at-the-deadline"),
+ pytest.param(480, 480 * 60 + 60, 1,
id="floor-of-one-second-past-the-deadline"),
+ ],
+)
+def test_get_token_expiration_seconds(app, max_lifetime_minutes,
seconds_since_login, expected):
+ overrides = {
+ ("api_auth", "jwt_expiration_time"): str(JWT_EXPIRATION_TIME),
+ ("fab", "session_max_lifetime_minutes"): str(max_lifetime_minutes),
+ }
+ with app.test_request_context(), conf_vars(overrides):
+ builtin_flask_session[SESSION_LOGIN_TIME_KEY] = time.time() -
seconds_since_login
+ assert get_token_expiration_seconds() == pytest.approx(expected, abs=1)
+
+
+def test_token_expiration_is_uncapped_for_a_session_without_a_login_stamp(app):
+ overrides = {
+ ("api_auth", "jwt_expiration_time"): str(JWT_EXPIRATION_TIME),
+ ("fab", "session_max_lifetime_minutes"): "480",
+ }
+ with app.test_request_context(), conf_vars(overrides):
+ assert get_token_expiration_seconds() == JWT_EXPIRATION_TIME