This is an automated email from the ASF dual-hosted git repository.
ash 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 106b8726724 Ensure that the the generated airflow.cfg contains a
random jwt_secret and fernet_key (#46966)
106b8726724 is described below
commit 106b8726724fa712176dfc17ae192dbf5be83e17
Author: Ash Berlin-Taylor <[email protected]>
AuthorDate: Fri Feb 21 18:15:35 2025 +0000
Ensure that the the generated airflow.cfg contains a random jwt_secret and
fernet_key (#46966)
* Ensure that the the generated airflow.cfg contains a random jwt_secret
and fernet_key
I don't know exactly when this got broken, and unforutnaltey it wasn't
tested,
but I suspect it might have been around the time we swapped the default
config
from a config file to the yaml based values. I.e. a while ago!
To make sure it doesn't get broken I've gone and added some unit tests
And to make my next PR and test easier I have done the same thing with the
`auth_jwt_secret` that we do for fernet_key -- of only set it in the config
file if we're generating that file, not always in memory.
* Improve upgrade path by generating and warning about the missing config
---
airflow/api_fastapi/core_api/security.py | 4 ++--
airflow/auth/managers/base_auth_manager.py | 4 ++--
airflow/auth/managers/simple/services/login.py | 5 ++---
airflow/configuration.py | 23 +++++++++-----------
airflow/utils/jwt_signer.py | 18 ++++++++++++++++
tests/core/test_configuration.py | 30 ++++++++++++++++++++++++++
6 files changed, 64 insertions(+), 20 deletions(-)
diff --git a/airflow/api_fastapi/core_api/security.py
b/airflow/api_fastapi/core_api/security.py
index 50c1337c95c..eac282e7e3c 100644
--- a/airflow/api_fastapi/core_api/security.py
+++ b/airflow/api_fastapi/core_api/security.py
@@ -27,7 +27,7 @@ from airflow.api_fastapi.app import get_auth_manager
from airflow.auth.managers.models.base_user import BaseUser
from airflow.auth.managers.models.resource_details import DagAccessEntity,
DagDetails
from airflow.configuration import conf
-from airflow.utils.jwt_signer import JWTSigner
+from airflow.utils.jwt_signer import JWTSigner, get_signing_key
if TYPE_CHECKING:
from airflow.auth.managers.base_auth_manager import ResourceMethod
@@ -38,7 +38,7 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
@cache
def get_signer() -> JWTSigner:
return JWTSigner(
- secret_key=conf.get("api", "auth_jwt_secret"),
+ secret_key=get_signing_key("api", "auth_jwt_secret"),
expiration_time_in_seconds=conf.getint("api",
"auth_jwt_expiration_time"),
audience="front-apis",
)
diff --git a/airflow/auth/managers/base_auth_manager.py
b/airflow/auth/managers/base_auth_manager.py
index ebe10d282c6..ec62677c9db 100644
--- a/airflow/auth/managers/base_auth_manager.py
+++ b/airflow/auth/managers/base_auth_manager.py
@@ -30,7 +30,7 @@ from airflow.configuration import conf
from airflow.exceptions import AirflowException
from airflow.models import DagModel
from airflow.typing_compat import Literal
-from airflow.utils.jwt_signer import JWTSigner
+from airflow.utils.jwt_signer import JWTSigner, get_signing_key
from airflow.utils.log.logging_mixin import LoggingMixin
from airflow.utils.session import NEW_SESSION, provide_session
@@ -464,7 +464,7 @@ class BaseAuthManager(Generic[T], LoggingMixin):
:meta private:
"""
return JWTSigner(
- secret_key=conf.get("api", "auth_jwt_secret"),
+ secret_key=get_signing_key("api", "auth_jwt_secret"),
expiration_time_in_seconds=conf.getint("api",
"auth_jwt_expiration_time"),
audience="front-apis",
)
diff --git a/airflow/auth/managers/simple/services/login.py
b/airflow/auth/managers/simple/services/login.py
index 2971b95fb14..4d8bb612d66 100644
--- a/airflow/auth/managers/simple/services/login.py
+++ b/airflow/auth/managers/simple/services/login.py
@@ -23,8 +23,7 @@ from airflow.api_fastapi.app import get_auth_manager
from airflow.auth.managers.simple.datamodels.login import LoginBody,
LoginResponse
from airflow.auth.managers.simple.simple_auth_manager import SimpleAuthManager
from airflow.auth.managers.simple.user import SimpleAuthManagerUser
-from airflow.configuration import conf
-from airflow.utils.jwt_signer import JWTSigner
+from airflow.utils.jwt_signer import JWTSigner, get_signing_key
class SimpleAuthManagerLogin:
@@ -66,7 +65,7 @@ class SimpleAuthManagerLogin:
)
signer = JWTSigner(
- secret_key=conf.get("api", "auth_jwt_secret"),
+ secret_key=get_signing_key("api", "auth_jwt_secret"),
expiration_time_in_seconds=expiration_time_in_sec,
audience="front-apis",
)
diff --git a/airflow/configuration.py b/airflow/configuration.py
index a9a255dde47..350aa5fd3ca 100644
--- a/airflow/configuration.py
+++ b/airflow/configuration.py
@@ -1751,7 +1751,6 @@ class AirflowConfigParser(ConfigParser):
with StringIO(unit_test_config) as test_config_file:
self.read_file(test_config_file)
# set fernet key to a random value
- global FERNET_KEY
FERNET_KEY = Fernet.generate_key().decode()
self.expand_all_configuration_values()
log.info("Unit test configuration loaded from 'config_unit_tests.cfg'")
@@ -1881,7 +1880,7 @@ def get_airflow_config(airflow_home: str) -> str:
def get_all_expansion_variables() -> dict[str, Any]:
- return {k: v for d in [globals(), locals()] for k, v in d.items()}
+ return {k: v for d in [globals(), locals()] for k, v in d.items() if not
k.startswith("_")}
def _generate_fernet_key() -> str:
@@ -1942,6 +1941,7 @@ def create_provider_config_fallback_defaults() ->
ConfigParser:
def write_default_airflow_configuration_if_needed() -> AirflowConfigParser:
+ global FERNET_KEY, JWT_SECRET_KEY
airflow_config = pathlib.Path(AIRFLOW_CONFIG)
if airflow_config.is_dir():
msg = (
@@ -1953,10 +1953,7 @@ def write_default_airflow_configuration_if_needed() ->
AirflowConfigParser:
log.debug("Creating new Airflow config file in: %s",
airflow_config.__fspath__())
config_directory = airflow_config.parent
if not config_directory.exists():
- # Compatibility with Python 3.8, ``PurePath.is_relative_to`` was
added in Python 3.9
- try:
- config_directory.relative_to(AIRFLOW_HOME)
- except ValueError:
+ if not config_directory.is_relative_to(AIRFLOW_HOME):
msg = (
f"Config directory {config_directory.__fspath__()!r} not
exists "
f"and it is not relative to AIRFLOW_HOME {AIRFLOW_HOME!r}.
"
@@ -1965,13 +1962,14 @@ def write_default_airflow_configuration_if_needed() ->
AirflowConfigParser:
raise FileNotFoundError(msg) from None
log.debug("Create directory %r for Airflow config",
config_directory.__fspath__())
config_directory.mkdir(parents=True, exist_ok=True)
- if conf.get("core", "fernet_key", fallback=None) is None:
+ if conf.get("core", "fernet_key", fallback=None) in (None, ""):
# We know that FERNET_KEY is not set, so we can generate it, set
as global key
# and also write it to the config file so that same key will be
used next time
- global FERNET_KEY
FERNET_KEY = _generate_fernet_key()
- conf.remove_option("core", "fernet_key")
- conf.set("core", "fernet_key", FERNET_KEY)
+
conf.configuration_description["core"]["options"]["fernet_key"]["default"] =
FERNET_KEY
+
+ JWT_SECRET_KEY = b64encode(os.urandom(16)).decode("utf-8")
+
conf.configuration_description["api"]["options"]["auth_jwt_secret"]["default"]
= JWT_SECRET_KEY
pathlib.Path(airflow_config.__fspath__()).touch()
make_group_other_inaccessible(airflow_config.__fspath__())
with open(airflow_config, "w") as file:
@@ -2134,8 +2132,7 @@ def initialize_auth_manager() -> BaseAuthManager:
if not auth_manager_cls:
raise AirflowConfigException(
- "No auth manager defined in the config. "
- "Please specify one using section/key [core/auth_manager]."
+ "No auth manager defined in the config. Please specify one using
section/key [core/auth_manager]."
)
return auth_manager_cls()
@@ -2166,8 +2163,8 @@ else:
TEST_PLUGINS_FOLDER = os.path.join(AIRFLOW_HOME, "plugins")
SECRET_KEY = b64encode(os.urandom(16)).decode("utf-8")
-JWT_SECRET_KEY = b64encode(os.urandom(16)).decode("utf-8")
FERNET_KEY = "" # Set only if needed when generating a new file
+JWT_SECRET_KEY = ""
WEBSERVER_CONFIG = "" # Set by initialize_config
conf: AirflowConfigParser = initialize_config()
diff --git a/airflow/utils/jwt_signer.py b/airflow/utils/jwt_signer.py
index fe4811eb827..5d8e82965b3 100644
--- a/airflow/utils/jwt_signer.py
+++ b/airflow/utils/jwt_signer.py
@@ -16,6 +16,9 @@
# under the License.
from __future__ import annotations
+import logging
+import os
+from base64 import b64encode
from datetime import timedelta
from typing import Any
@@ -24,6 +27,21 @@ import jwt
from airflow.utils import timezone
+def get_signing_key(section: str, key: str) -> str:
+ from airflow.configuration import conf
+
+ secret_key = conf.get(section, key, fallback="")
+
+ if secret_key == "":
+ logging.getLogger(__name__).warning(
+ "`%s/%s` was empty, using a generated one for now. Please set this
in your config", section, key
+ )
+ secret_key = b64encode(os.urandom(16)).decode("utf-8")
+ # Set it back so any other callers get the same value for the duration
of this process
+ conf.set(section, key, secret_key)
+ return secret_key
+
+
class JWTSigner:
"""
Signs and verifies JWT Token. Used to authorise and verify requests.
diff --git a/tests/core/test_configuration.py b/tests/core/test_configuration.py
index 735a78c64b2..859acda6554 100644
--- a/tests/core/test_configuration.py
+++ b/tests/core/test_configuration.py
@@ -1748,3 +1748,33 @@ class TestWriteDefaultAirflowConfigurationIfNeeded:
mock_mask_secret.assert_any_call("supersecret2")
assert mock_mask_secret.call_count == 2
+
+
+@conf_vars({("core", "unit_test_mode"): "False"})
+def test_write_default_config_contains_generated_secrets(tmp_path,
monkeypatch):
+ import airflow.configuration
+
+ cfgpath = tmp_path / "airflow-gneerated.cfg"
+ # Patch these globals so it gets reverted by monkeypath after this test is
over.
+ monkeypatch.setattr(airflow.configuration, "FERNET_KEY", "")
+ monkeypatch.setattr(airflow.configuration, "JWT_SECRET_KEY", "")
+ monkeypatch.setattr(airflow.configuration, "AIRFLOW_CONFIG", str(cfgpath))
+
+ # Create a new global conf object so our changes don't persist
+ localconf: AirflowConfigParser = airflow.configuration.initialize_config()
+ monkeypatch.setattr(airflow.configuration, "conf", localconf)
+
+ airflow.configuration.write_default_airflow_configuration_if_needed()
+
+ assert cfgpath.is_file()
+
+ lines = cfgpath.read_text().splitlines()
+
+ assert airflow.configuration.FERNET_KEY
+ assert airflow.configuration.JWT_SECRET_KEY
+
+ fernet_line = next(line for line in lines if line.startswith("fernet_key =
"))
+ jwt_secret_line = next(line for line in lines if
line.startswith("auth_jwt_secret = "))
+
+ assert fernet_line == f"fernet_key = {airflow.configuration.FERNET_KEY}"
+ assert jwt_secret_line == f"auth_jwt_secret =
{airflow.configuration.JWT_SECRET_KEY}"