This is an automated email from the ASF dual-hosted git repository.
jasonliu 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 43b51932f90 Add JWT/OIDC authentication support to Hashicorp Vault
provider (#61439)
43b51932f90 is described below
commit 43b51932f90d27d94c4e8d13fcb7ef85a97ebe98
Author: Piotr Klinski <[email protected]>
AuthorDate: Mon Feb 9 14:56:41 2026 +0100
Add JWT/OIDC authentication support to Hashicorp Vault provider (#61439)
* Add JWT/OIDC authentication support to Hashicorp Vault provider
This adds JWT/OIDC authentication method support to the Hashicorp Vault
provider, enabling token-less authentication through identity federation.
Key features:
- New 'jwt' auth_type for VaultClient, VaultHook, and VaultBackend
- Support for jwt_token parameter or automatic token retrieval from jwt_path
- Configurable jwt_role for Vault role binding
- Full backwards compatibility with existing auth methods
Use cases enabled:
- Kubernetes workload identity with projected service account tokens
- Cloud provider identity (AWS IAM roles, GCP Workload Identity, Azure AD)
- CI/CD pipelines (GitHub Actions OIDC, GitLab CI)
- External identity providers (Auth0, Okta, Keycloak)
Co-Authored-By: Claude Opus 4.5 <[email protected]>
* Update
providers/hashicorp/src/airflow/providers/hashicorp/_internal_client/vault_client.py
Co-authored-by: Wei Lee <[email protected]>
* Update providers/hashicorp/src/airflow/providers/hashicorp/hooks/vault.py
Co-authored-by: Wei Lee <[email protected]>
* update the args order for methods
* Update
providers/hashicorp/src/airflow/providers/hashicorp/_internal_client/vault_client.py
Co-authored-by: Wei Lee <[email protected]>
* apply fixes for oorder in new jwt parameter for docsstring
* Address PR review: use stricter mock assertions and inline kwargs
Replace assert_called_with with call_args_list assertions in JWT tests
to verify exact number of calls. Inline kwargs dicts directly into
VaultHook() constructor calls where they are only used once.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* - remove jwt token defaults
- fix documentaion
- minor fiex
* Remove DEFAULT_JWT_TOKEN_PATH constant and K8s fallback from JWT auth
JWT is a general-purpose Vault auth method, not tied to Kubernetes.
Remove the DEFAULT_JWT_TOKEN_PATH constant (which pointed to the K8s
service account token path) and its fallback in VaultHook. Users must
now explicitly provide either jwt_token or jwt_token_path when using
JWT auth, otherwise _VaultClient raises a clear validation error.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.5 <[email protected]>
Co-authored-by: Wei Lee <[email protected]>
---
.../docs/secrets-backends/hashicorp-vault.rst | 29 +++++++
.../hashicorp/_internal_client/vault_client.py | 37 +++++++-
.../src/airflow/providers/hashicorp/hooks/vault.py | 31 ++++++-
.../airflow/providers/hashicorp/secrets/vault.py | 11 ++-
.../_internal_client/test_vault_client.py | 99 +++++++++++++++++++++-
.../tests/unit/hashicorp/hooks/test_vault.py | 79 +++++++++++++++++
.../tests/unit/hashicorp/secrets/test_vault.py | 51 +++++++++++
7 files changed, 331 insertions(+), 6 deletions(-)
diff --git a/providers/hashicorp/docs/secrets-backends/hashicorp-vault.rst
b/providers/hashicorp/docs/secrets-backends/hashicorp-vault.rst
index 7c6ad07c26d..faf4db70f01 100644
--- a/providers/hashicorp/docs/secrets-backends/hashicorp-vault.rst
+++ b/providers/hashicorp/docs/secrets-backends/hashicorp-vault.rst
@@ -230,6 +230,35 @@ For more details, please refer to the AWS Assume Role
Authentication documentati
backend = airflow.providers.hashicorp.secrets.vault.VaultBackend
backend_kwargs = {"connections_path": "airflow-connections",
"variables_path": null, "mount_point": "airflow", "url":
"http://127.0.0.1:8200", "auth_type": "aws_iam", "assume_role_kwargs":
{"RoleArn":"arn:aws:iam::123456789000:role/hashicorp-aws-iam-role",
"RoleSessionName": "Airflow"}}
+Vault authentication with JWT/OIDC
+""""""""""""""""""""""""""""""""""
+
+JWT/OIDC authentication is useful for:
+
+- Cloud provider identity (AWS IAM roles, GCP Workload Identity, Azure AD)
+- CI/CD pipelines (GitHub Actions OIDC, GitLab CI)
+- External identity providers (Auth0, Okta, Keycloak)
+- Kubernetes workload identity with projected service account tokens
+
+To use JWT authentication, set ``auth_type`` to ``jwt`` and provide
``jwt_role``. You must also provide a JWT token
+via ``jwt_token`` (inline) or ``jwt_token_path`` (path to a file containing
the token):
+
+.. code-block:: ini
+
+ [secrets]
+ backend = airflow.providers.hashicorp.secrets.vault.VaultBackend
+ backend_kwargs = {"connections_path": "connections", "variables_path":
"variables", "mount_point": "airflow", "url": "http://127.0.0.1:8200",
"auth_type": "jwt", "jwt_role": "airflow-role", "jwt_token_path":
"/var/run/secrets/tokens/vault-token"}
+
+You can also provide the token directly:
+
+.. code-block:: ini
+
+ [secrets]
+ backend = airflow.providers.hashicorp.secrets.vault.VaultBackend
+ backend_kwargs = {"connections_path": "connections", "variables_path":
"variables", "mount_point": "airflow", "url": "http://127.0.0.1:8200",
"auth_type": "jwt", "jwt_role": "airflow-role", "jwt_token":
"eyJhbGciOiJSUzI1NiIs..."}
+
+If you need to use a different mount point for the JWT auth method (default is
``jwt``), you can specify it with ``auth_mount_point``.
+
Using multiple mount points
"""""""""""""""""""""""""""
diff --git
a/providers/hashicorp/src/airflow/providers/hashicorp/_internal_client/vault_client.py
b/providers/hashicorp/src/airflow/providers/hashicorp/_internal_client/vault_client.py
index e1e63d788c2..8ce9a735e89 100644
---
a/providers/hashicorp/src/airflow/providers/hashicorp/_internal_client/vault_client.py
+++
b/providers/hashicorp/src/airflow/providers/hashicorp/_internal_client/vault_client.py
@@ -39,6 +39,7 @@ VALID_AUTH_TYPES: list[str] = [
"azure",
"github",
"gcp",
+ "jwt",
"kubernetes",
"ldap",
"radius",
@@ -58,7 +59,7 @@ class _VaultClient(LoggingMixin):
:param url: Base URL for the Vault instance being addressed.
:param auth_type: Authentication Type for Vault. Default is ``token``.
Available values are in
- ('approle', 'aws_iam', 'azure', 'github', 'gcp', 'kubernetes', 'ldap',
'radius', 'token', 'userpass')
+ ('approle', 'aws_iam', 'azure', 'github', 'gcp', 'jwt', 'kubernetes',
'ldap', 'radius', 'token', 'userpass')
:param auth_mount_point: It can be used to define mount_point for
authentication chosen
Default depends on the authentication method used.
:param mount_point: The "path" the secret engine was mounted on. Default
is "secret". Note that
@@ -93,6 +94,9 @@ class _VaultClient(LoggingMixin):
:param radius_host: Host for radius (for ``radius`` auth_type).
:param radius_secret: Secret for radius (for ``radius`` auth_type).
:param radius_port: Port for radius (for ``radius`` auth_type).
+ :param jwt_role: Role for Authentication (for ``jwt`` auth_type).
+ :param jwt_token: JWT token for Authentication (for ``jwt`` auth_type).
+ :param jwt_token_path: Path to file containing JWT token for
Authentication (for ``jwt`` auth_type).
"""
def __init__(
@@ -112,7 +116,7 @@ class _VaultClient(LoggingMixin):
role_id: str | None = None,
region: str | None = None,
kubernetes_role: str | None = None,
- kubernetes_jwt_path: str | None =
"/var/run/secrets/kubernetes.io/serviceaccount/token",
+ kubernetes_jwt_path: str | None = DEFAULT_KUBERNETES_JWT_PATH,
gcp_key_path: str | None = None,
gcp_keyfile_dict: dict | None = None,
gcp_scopes: str | None = None,
@@ -121,6 +125,10 @@ class _VaultClient(LoggingMixin):
radius_host: str | None = None,
radius_secret: str | None = None,
radius_port: int | None = None,
+ *,
+ jwt_role: str | None = None,
+ jwt_token: str | None = None,
+ jwt_token_path: str | None = None,
**kwargs,
):
super().__init__()
@@ -143,6 +151,11 @@ class _VaultClient(LoggingMixin):
raise VaultError("The 'kubernetes' authentication type
requires 'kubernetes_role'")
if not kubernetes_jwt_path:
raise VaultError("The 'kubernetes' authentication type
requires 'kubernetes_jwt_path'")
+ if auth_type == "jwt":
+ if not jwt_role:
+ raise VaultError("The 'jwt' authentication type requires
'jwt_role'")
+ if not jwt_token and not jwt_token_path:
+ raise VaultError("The 'jwt' authentication type requires
'jwt_token' or 'jwt_token_path'")
if auth_type == "azure":
if not azure_resource:
raise VaultError("The 'azure' authentication type requires
'azure_resource'")
@@ -188,6 +201,9 @@ class _VaultClient(LoggingMixin):
self.radius_host = radius_host
self.radius_secret = radius_secret
self.radius_port = radius_port
+ self.jwt_role = jwt_role
+ self.jwt_token = jwt_token
+ self.jwt_token_path = jwt_token_path
@property
def client(self):
@@ -241,6 +257,8 @@ class _VaultClient(LoggingMixin):
self._auth_gcp(_client)
elif self.auth_type == "github":
self._auth_github(_client)
+ elif self.auth_type == "jwt":
+ self._auth_jwt(_client)
elif self.auth_type == "kubernetes":
self._auth_kubernetes(_client)
elif self.auth_type == "ldap":
@@ -299,6 +317,21 @@ class _VaultClient(LoggingMixin):
else:
Kubernetes(_client.adapter).login(role=self.kubernetes_role,
jwt=jwt)
+ def _auth_jwt(self, _client: hvac.Client) -> None:
+ """Authenticate using JWT auth method."""
+ if self.jwt_token:
+ jwt = self.jwt_token.strip()
+ elif self.jwt_token_path:
+ with open(self.jwt_token_path) as f:
+ jwt = f.read().strip()
+ else:
+ raise VaultError("The jwt_token or jwt_token_path should be set
here. This should not happen.")
+
+ if self.auth_mount_point:
+ _client.auth.jwt.jwt_login(role=self.jwt_role, jwt=jwt,
path=self.auth_mount_point)
+ else:
+ _client.auth.jwt.jwt_login(role=self.jwt_role, jwt=jwt)
+
def _auth_github(self, _client: hvac.Client) -> None:
if self.auth_mount_point:
_client.auth.github.login(token=self.token,
mount_point=self.auth_mount_point)
diff --git a/providers/hashicorp/src/airflow/providers/hashicorp/hooks/vault.py
b/providers/hashicorp/src/airflow/providers/hashicorp/hooks/vault.py
index c84f9a70b02..05d14b9b4db 100644
--- a/providers/hashicorp/src/airflow/providers/hashicorp/hooks/vault.py
+++ b/providers/hashicorp/src/airflow/providers/hashicorp/hooks/vault.py
@@ -79,7 +79,7 @@ class VaultHook(BaseHook):
:param vault_conn_id: The id of the connection to use
:param auth_type: Authentication Type for the Vault. Default is ``token``.
Available values are:
- ('approle', 'github', 'gcp', 'kubernetes', 'ldap', 'token', 'userpass')
+ ('approle', 'github', 'gcp', 'jwt', 'kubernetes', 'ldap', 'token',
'userpass')
:param auth_mount_point: It can be used to define mount_point for
authentication chosen
Default depends on the authentication method used.
:param kv_engine_version: Select the version of the engine to run (``1``
or ``2``). Defaults to
@@ -99,7 +99,9 @@ class VaultHook(BaseHook):
(for ``azure`` auth_type)
:param radius_host: Host for radius (for ``radius`` auth_type)
:param radius_port: Port for radius (for ``radius`` auth_type)
-
+ :param jwt_role: Role for Authentication (for ``jwt`` auth_type)
+ :param jwt_token: JWT token for Authentication (for ``jwt`` auth_type)
+ :param jwt_token_path: Path to file containing JWT token for
Authentication (for ``jwt`` auth_type).
"""
conn_name_attr = "vault_conn_id"
@@ -124,6 +126,9 @@ class VaultHook(BaseHook):
azure_resource: str | None = None,
radius_host: str | None = None,
radius_port: int | None = None,
+ jwt_role: str | None = None,
+ jwt_token: str | None = None,
+ jwt_token_path: str | None = None,
**kwargs,
):
super().__init__()
@@ -171,6 +176,11 @@ class VaultHook(BaseHook):
if auth_type == "kubernetes"
else (None, None)
)
+ jwt_role, jwt_token, jwt_token_path = (
+ self._get_jwt_parameters_from_connection(jwt_role, jwt_token,
jwt_token_path)
+ if auth_type == "jwt"
+ else (None, None, None)
+ )
radius_host, radius_port = (
self._get_radius_parameters_from_connection(radius_host,
radius_port)
if auth_type == "radius"
@@ -225,6 +235,9 @@ class VaultHook(BaseHook):
radius_host=radius_host,
radius_secret=self.connection.password,
radius_port=radius_port,
+ jwt_role=jwt_role,
+ jwt_token=jwt_token,
+ jwt_token_path=jwt_token_path,
)
self.vault_client = _VaultClient(**client_kwargs)
@@ -240,6 +253,17 @@ class VaultHook(BaseHook):
kubernetes_role =
self.connection.extra_dejson.get("kubernetes_role")
return kubernetes_jwt_path, kubernetes_role
+ def _get_jwt_parameters_from_connection(
+ self, jwt_role: str | None, jwt_token: str | None, jwt_token_path: str
| None
+ ) -> tuple[str | None, str | None, str | None]:
+ if not jwt_role:
+ jwt_role = self.connection.extra_dejson.get("jwt_role")
+ if not jwt_token:
+ jwt_token = self.connection.extra_dejson.get("jwt_token")
+ if not jwt_token_path:
+ jwt_token_path = self.connection.extra_dejson.get("jwt_token_path")
+ return jwt_role, jwt_token, jwt_token_path
+
def _get_gcp_parameters_from_connection(
self,
gcp_key_path: str | None,
@@ -376,6 +400,9 @@ class VaultHook(BaseHook):
"kubernetes_jwt_path": StringField(
lazy_gettext("Kubernetes jwt path"),
widget=BS3TextFieldWidget()
),
+ "jwt_role": StringField(lazy_gettext("JWT role"),
widget=BS3TextFieldWidget()),
+ "jwt_token": StringField(lazy_gettext("JWT token"),
widget=BS3TextFieldWidget()),
+ "jwt_token_path": StringField(lazy_gettext("JWT token path"),
widget=BS3TextFieldWidget()),
"token_path": StringField(lazy_gettext("Token path"),
widget=BS3TextFieldWidget()),
"gcp_key_path": StringField(lazy_gettext("GCP key path"),
widget=BS3TextFieldWidget()),
"gcp_scopes": StringField(lazy_gettext("GCP scopes"),
widget=BS3TextFieldWidget()),
diff --git
a/providers/hashicorp/src/airflow/providers/hashicorp/secrets/vault.py
b/providers/hashicorp/src/airflow/providers/hashicorp/secrets/vault.py
index 40812a44830..b60e6238510 100644
--- a/providers/hashicorp/src/airflow/providers/hashicorp/secrets/vault.py
+++ b/providers/hashicorp/src/airflow/providers/hashicorp/secrets/vault.py
@@ -54,7 +54,7 @@ class VaultBackend(BaseSecretsBackend, LoggingMixin):
(default: 'config'). If set to None (null), requests for
configurations will not be sent to Vault.
:param url: Base URL for the Vault instance being addressed.
:param auth_type: Authentication Type for Vault. Default is ``token``.
Available values are:
- ('approle', 'aws_iam', 'azure', 'github', 'gcp', 'kubernetes', 'ldap',
'radius', 'token', 'userpass')
+ ('approle', 'aws_iam', 'azure', 'github', 'gcp', 'jwt', 'kubernetes',
'ldap', 'radius', 'token', 'userpass')
:param auth_mount_point: It can be used to define mount_point for
authentication chosen
Default depends on the authentication method used.
:param mount_point: The "path" the secret engine was mounted on. Default
is "secret". Note that
@@ -89,6 +89,9 @@ class VaultBackend(BaseSecretsBackend, LoggingMixin):
:param radius_host: Host for radius (for ``radius`` auth_type).
:param radius_secret: Secret for radius (for ``radius`` auth_type).
:param radius_port: Port for radius (for ``radius`` auth_type).
+ :param jwt_role: Role for Authentication (for ``jwt`` auth_type).
+ :param jwt_token: JWT token for Authentication (for ``jwt`` auth_type).
+ :param jwt_token_path: Path to file containing JWT token for
Authentication (for ``jwt`` auth_type).
"""
def __init__(
@@ -120,6 +123,9 @@ class VaultBackend(BaseSecretsBackend, LoggingMixin):
radius_host: str | None = None,
radius_secret: str | None = None,
radius_port: int | None = None,
+ jwt_role: str | None = None,
+ jwt_token: str | None = None,
+ jwt_token_path: str | None = None,
**kwargs,
):
super().__init__()
@@ -153,6 +159,9 @@ class VaultBackend(BaseSecretsBackend, LoggingMixin):
radius_host=radius_host,
radius_secret=radius_secret,
radius_port=radius_port,
+ jwt_role=jwt_role,
+ jwt_token=jwt_token,
+ jwt_token_path=jwt_token_path,
**kwargs,
)
diff --git
a/providers/hashicorp/tests/unit/hashicorp/_internal_client/test_vault_client.py
b/providers/hashicorp/tests/unit/hashicorp/_internal_client/test_vault_client.py
index c9239b75a99..fe27763e60c 100644
---
a/providers/hashicorp/tests/unit/hashicorp/_internal_client/test_vault_client.py
+++
b/providers/hashicorp/tests/unit/hashicorp/_internal_client/test_vault_client.py
@@ -19,7 +19,7 @@ from __future__ import annotations
import json
import time
from unittest import mock
-from unittest.mock import mock_open, patch
+from unittest.mock import call, mock_open, patch
import pytest
from hvac.exceptions import InvalidPath, VaultError
@@ -587,6 +587,103 @@ class TestVaultClient:
url="http://localhost:8180",
)
+
@mock.patch("airflow.providers.hashicorp._internal_client.vault_client.hvac")
+ def test_jwt_with_token(self, mock_hvac):
+ mock_client = mock.MagicMock()
+ mock_hvac.Client.return_value = mock_client
+ vault_client = _VaultClient(
+ auth_type="jwt",
+ jwt_role="my-role",
+ jwt_token="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test",
+ url="http://localhost:8180",
+ session=None,
+ )
+ client = vault_client.client
+ assert mock_hvac.Client.call_args_list ==
[call(url="http://localhost:8180", session=None)]
+ assert client.auth.jwt.jwt_login.call_args_list == [
+ call(role="my-role",
jwt="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test")
+ ]
+ assert client.is_authenticated.call_args_list == [call(), call()]
+ assert vault_client.kv_engine_version == 2
+
+
@mock.patch("airflow.providers.hashicorp._internal_client.vault_client.hvac")
+ def test_jwt_with_token_path(self, mock_hvac):
+ mock_client = mock.MagicMock()
+ mock_hvac.Client.return_value = mock_client
+ vault_client = _VaultClient(
+ auth_type="jwt",
+ jwt_role="my-role",
+ jwt_token_path="path/to/jwt",
+ url="http://localhost:8180",
+ session=None,
+ )
+ with patch("builtins.open",
mock_open(read_data="eyJhbGciOiJSUzI1NiJ9.jwt-from-file")) as mock_file:
+ client = vault_client.client
+ assert mock_file.call_args_list == [call("path/to/jwt")]
+ assert mock_hvac.Client.call_args_list ==
[call(url="http://localhost:8180", session=None)]
+ assert client.auth.jwt.jwt_login.call_args_list == [
+ call(role="my-role", jwt="eyJhbGciOiJSUzI1NiJ9.jwt-from-file")
+ ]
+ assert client.is_authenticated.call_args_list == [call(), call()]
+ assert vault_client.kv_engine_version == 2
+
+
@mock.patch("airflow.providers.hashicorp._internal_client.vault_client.hvac")
+ def test_jwt_with_token_strips_whitespace(self, mock_hvac):
+ mock_client = mock.MagicMock()
+ mock_hvac.Client.return_value = mock_client
+ vault_client = _VaultClient(
+ auth_type="jwt",
+ jwt_role="my-role",
+ jwt_token=" eyJhbGciOiJSUzI1NiJ9.test \n",
+ url="http://localhost:8180",
+ session=None,
+ )
+ client = vault_client.client
+ client.auth.jwt.jwt_login.assert_called_with(role="my-role",
jwt="eyJhbGciOiJSUzI1NiJ9.test")
+
+
@mock.patch("airflow.providers.hashicorp._internal_client.vault_client.hvac")
+ def test_jwt_different_auth_mount_point(self, mock_hvac):
+ mock_client = mock.MagicMock()
+ mock_hvac.Client.return_value = mock_client
+ vault_client = _VaultClient(
+ auth_type="jwt",
+ jwt_role="my-role",
+ jwt_token="eyJhbGciOiJSUzI1NiJ9.test",
+ auth_mount_point="custom-jwt",
+ url="http://localhost:8180",
+ session=None,
+ )
+ client = vault_client.client
+ assert mock_hvac.Client.call_args_list ==
[call(url="http://localhost:8180", session=None)]
+ assert client.auth.jwt.jwt_login.call_args_list == [
+ call(role="my-role", jwt="eyJhbGciOiJSUzI1NiJ9.test",
path="custom-jwt")
+ ]
+ assert client.is_authenticated.call_args_list == [call(), call()]
+ assert vault_client.kv_engine_version == 2
+
+
@mock.patch("airflow.providers.hashicorp._internal_client.vault_client.hvac")
+ def test_jwt_missing_role(self, mock_hvac):
+ mock_client = mock.MagicMock()
+ mock_hvac.Client.return_value = mock_client
+ with pytest.raises(VaultError, match="requires 'jwt_role'"):
+ _VaultClient(
+ auth_type="jwt",
+ jwt_token="eyJhbGciOiJSUzI1NiJ9.test",
+ url="http://localhost:8180",
+ )
+
+
@mock.patch("airflow.providers.hashicorp._internal_client.vault_client.hvac")
+ def test_jwt_missing_token_and_path(self, mock_hvac):
+ mock_client = mock.MagicMock()
+ mock_hvac.Client.return_value = mock_client
+ with pytest.raises(VaultError, match="requires 'jwt_token' or
'jwt_token_path'"):
+ _VaultClient(
+ auth_type="jwt",
+ jwt_role="my-role",
+ jwt_token_path=None,
+ url="http://localhost:8180",
+ )
+
@mock.patch("airflow.providers.hashicorp._internal_client.vault_client.hvac")
def test_ldap(self, mock_hvac):
mock_client = mock.MagicMock()
diff --git a/providers/hashicorp/tests/unit/hashicorp/hooks/test_vault.py
b/providers/hashicorp/tests/unit/hashicorp/hooks/test_vault.py
index a072f373703..50b9f9e022c 100644
--- a/providers/hashicorp/tests/unit/hashicorp/hooks/test_vault.py
+++ b/providers/hashicorp/tests/unit/hashicorp/hooks/test_vault.py
@@ -733,6 +733,85 @@ class TestVaultHook:
test_client.is_authenticated.assert_called_with()
assert test_hook.vault_client.kv_engine_version == 2
+
@mock.patch("airflow.providers.hashicorp.hooks.vault.VaultHook.get_connection")
+
@mock.patch("airflow.providers.hashicorp._internal_client.vault_client.hvac")
+ def test_jwt_init_params(self, mock_hvac, mock_get_connection):
+ mock_client = mock.MagicMock()
+ mock_hvac.Client.return_value = mock_client
+ mock_connection = self.get_mock_connection()
+ mock_get_connection.return_value = mock_connection
+
+ connection_dict = {}
+
+ mock_connection.extra_dejson.get.side_effect = connection_dict.get
+ test_hook = VaultHook(
+ auth_type="jwt",
+ jwt_role="my-role",
+ jwt_token="eyJhbGciOiJSUzI1NiJ9.test",
+ vault_conn_id="vault_conn_id",
+ session=None,
+ )
+ mock_get_connection.assert_called_with("vault_conn_id")
+ test_client = test_hook.get_conn()
+ mock_hvac.Client.assert_called_with(url="http://localhost:8180",
session=None)
+ test_client.auth.jwt.jwt_login.assert_called_with(role="my-role",
jwt="eyJhbGciOiJSUzI1NiJ9.test")
+ test_client.is_authenticated.assert_called_with()
+ assert test_hook.vault_client.kv_engine_version == 2
+
+
@mock.patch("airflow.providers.hashicorp.hooks.vault.VaultHook.get_connection")
+
@mock.patch("airflow.providers.hashicorp._internal_client.vault_client.hvac")
+ def test_jwt_dejson(self, mock_hvac, mock_get_connection):
+ mock_client = mock.MagicMock()
+ mock_hvac.Client.return_value = mock_client
+ mock_connection = self.get_mock_connection()
+ mock_get_connection.return_value = mock_connection
+
+ connection_dict = {
+ "auth_type": "jwt",
+ "jwt_role": "my-role",
+ "jwt_token": "eyJhbGciOiJSUzI1NiJ9.dejson-test",
+ }
+
+ mock_connection.extra_dejson.get.side_effect = connection_dict.get
+
+ test_hook = VaultHook(vault_conn_id="vault_conn_id", session=None)
+ mock_get_connection.assert_called_with("vault_conn_id")
+ test_client = test_hook.get_conn()
+ mock_hvac.Client.assert_called_with(url="http://localhost:8180",
session=None)
+ test_client.auth.jwt.jwt_login.assert_called_with(
+ role="my-role", jwt="eyJhbGciOiJSUzI1NiJ9.dejson-test"
+ )
+ test_client.is_authenticated.assert_called_with()
+ assert test_hook.vault_client.kv_engine_version == 2
+
+
@mock.patch("airflow.providers.hashicorp.hooks.vault.VaultHook.get_connection")
+
@mock.patch("airflow.providers.hashicorp._internal_client.vault_client.hvac")
+ def test_jwt_with_token_path(self, mock_hvac, mock_get_connection):
+ mock_client = mock.MagicMock()
+ mock_hvac.Client.return_value = mock_client
+ mock_connection = self.get_mock_connection()
+ mock_get_connection.return_value = mock_connection
+
+ connection_dict = {
+ "auth_type": "jwt",
+ "jwt_role": "my-role",
+ "jwt_token_path": "/path/to/jwt",
+ }
+
+ mock_connection.extra_dejson.get.side_effect = connection_dict.get
+
+ with patch("builtins.open",
mock_open(read_data="eyJhbGciOiJSUzI1NiJ9.from-file")) as mock_file:
+ test_hook = VaultHook(vault_conn_id="vault_conn_id", session=None)
+ test_client = test_hook.get_conn()
+ mock_get_connection.assert_called_with("vault_conn_id")
+ mock_file.assert_called_with("/path/to/jwt")
+ mock_hvac.Client.assert_called_with(url="http://localhost:8180",
session=None)
+ test_client.auth.jwt.jwt_login.assert_called_with(
+ role="my-role", jwt="eyJhbGciOiJSUzI1NiJ9.from-file"
+ )
+ test_client.is_authenticated.assert_called_with()
+ assert test_hook.vault_client.kv_engine_version == 2
+
@mock.patch("airflow.providers.hashicorp.hooks.vault.VaultHook.get_connection")
@mock.patch("airflow.providers.hashicorp._internal_client.vault_client.hvac")
def test_client_kwargs(self, mock_hvac, mock_get_connection):
diff --git a/providers/hashicorp/tests/unit/hashicorp/secrets/test_vault.py
b/providers/hashicorp/tests/unit/hashicorp/secrets/test_vault.py
index 667e7859450..274e07119c9 100644
--- a/providers/hashicorp/tests/unit/hashicorp/secrets/test_vault.py
+++ b/providers/hashicorp/tests/unit/hashicorp/secrets/test_vault.py
@@ -350,6 +350,57 @@ class TestVaultSecrets:
with pytest.raises(FileNotFoundError, match=path):
VaultBackend(**kwargs).get_connection(conn_id="test")
+ def test_auth_type_jwt_with_unreadable_jwt_raises_error(self):
+ path = "/var/tmp/this_does_not_exist/jwt_token_file"
+ kwargs = {
+ "auth_type": "jwt",
+ "jwt_role": "default",
+ "jwt_token_path": path,
+ "url": "http://127.0.0.1:8200",
+ }
+
+ with pytest.raises(FileNotFoundError, match=path):
+ VaultBackend(**kwargs).get_connection(conn_id="test")
+
+
@mock.patch("airflow.providers.hashicorp._internal_client.vault_client.hvac")
+ def test_jwt_auth_type(self, mock_hvac):
+ mock_client = mock.MagicMock()
+ mock_hvac.Client.return_value = mock_client
+ mock_client.secrets.kv.v2.read_secret_version.return_value = {
+ "request_id": "94011e25-f8dc-ec29-221b-1f9c1d9ad2ae",
+ "lease_id": "",
+ "renewable": False,
+ "lease_duration": 0,
+ "data": {
+ "data": {"conn_uri":
"postgresql://airflow:airflow@host:5432/airflow"},
+ "metadata": {
+ "created_time": "2020-03-16T21:01:43.331126Z",
+ "deletion_time": "",
+ "destroyed": False,
+ "version": 1,
+ },
+ },
+ "wrap_info": None,
+ "warnings": None,
+ "auth": None,
+ }
+
+ kwargs = {
+ "connections_path": "connections",
+ "mount_point": "airflow",
+ "auth_type": "jwt",
+ "jwt_role": "airflow-role",
+ "jwt_token": "eyJhbGciOiJSUzI1NiJ9.test",
+ "url": "http://127.0.0.1:8200",
+ }
+
+ test_client = VaultBackend(**kwargs)
+ connection = test_client.get_connection(conn_id="test_postgres")
+ assert connection.get_uri() ==
"postgres://airflow:airflow@host:5432/airflow"
+ mock_client.auth.jwt.jwt_login.assert_called_with(
+ role="airflow-role", jwt="eyJhbGciOiJSUzI1NiJ9.test"
+ )
+
@mock.patch("airflow.providers.hashicorp._internal_client.vault_client.hvac")
def test_get_config_value(self, mock_hvac):
mock_client = mock.MagicMock()