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}"

Reply via email to