Skip to content
This repository was archived by the owner on Mar 13, 2022. It is now read-only.

Commit f6f9fb9

Browse files
author
Eric Menendez
committed
Refresh exec-based API credentials when they expire
This is a fix for kubernetes-client/python#741. As described in kubernetes-client/python#741, some of the authentication schemes supported by Kubernetes require updating the client's credentials from time to time. The Kubernetes Python client currently does not support this, except for when using the `gcp` auth scheme. This is because the OpenAPI-generated client code does not generally expect credentials to change after the client is configured. However, in OpenAPITools/openapi-generator#3594, the OpenAPI generator added a (undocumented) hook on the `Configuration` object which provides a method for the client credentials to be refreshed as needed. Now that this hook exists, the `load_kube_config()` function, used by the Kubernetes API to set up the `Configuration` object from the client's local k8s config, just needs to be updated to take advantage of this hook. This patch does this for `exec`-based authentication, which should resolve kubernetes-client/python#741. Also, as noted above, `load_kube_config()` already has a special-case monkeypatch to refresh GCP tokens. I presume this functionality was added before the OpenAPI generator added support for the refresh hook. This patch also refactors the GCP token refreshing code to use the new hook instead of the monkeypatch. Tests are also updated.
1 parent dd15ac6 commit f6f9fb9

File tree

2 files changed

+76
-53
lines changed

2 files changed

+76
-53
lines changed

config/kube_config.py

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,8 @@ def _load_gcp_token(self, provider):
359359
self._refresh_gcp_token()
360360

361361
self.token = "Bearer %s" % provider['config']['access-token']
362+
if 'expiry' in provider['config']:
363+
self.expiry = parse_rfc3339(provider['config']['expiry'])
362364
return self.token
363365

364366
def _refresh_gcp_token(self):
@@ -483,8 +485,7 @@ def _load_from_exec_plugin(self):
483485
status = ExecProvider(self._user['exec']).run()
484486
if 'token' in status:
485487
self.token = "Bearer %s" % status['token']
486-
return True
487-
if 'clientCertificateData' in status:
488+
elif 'clientCertificateData' in status:
488489
# https://kubernetes.io/docs/reference/access-authn-authz/authentication/#input-and-output-formats
489490
# Plugin has provided certificates instead of a token.
490491
if 'clientKeyData' not in status:
@@ -504,10 +505,13 @@ def _load_from_exec_plugin(self):
504505
file_base_path=base_path,
505506
base64_file_content=False,
506507
temp_file_path=self._temp_file_path).as_file()
507-
return True
508-
logging.error('exec: missing token or clientCertificateData field '
509-
'in plugin output')
510-
return None
508+
else:
509+
logging.error('exec: missing token or clientCertificateData '
510+
'field in plugin output')
511+
return None
512+
if 'expirationTimestamp' in status:
513+
self.expiry = parse_rfc3339(status['expirationTimestamp'])
514+
return True
511515
except Exception as e:
512516
logging.error(str(e))
513517

@@ -560,25 +564,15 @@ def _load_cluster_info(self):
560564
if 'insecure-skip-tls-verify' in self._cluster:
561565
self.verify_ssl = not self._cluster['insecure-skip-tls-verify']
562566

563-
def _using_gcp_auth_provider(self):
564-
return self._user and \
565-
'auth-provider' in self._user and \
566-
'name' in self._user['auth-provider'] and \
567-
self._user['auth-provider']['name'] == 'gcp'
568-
569567
def _set_config(self, client_configuration):
570-
if self._using_gcp_auth_provider():
571-
# GCP auth tokens must be refreshed regularly, but swagger expects
572-
# a constant token. Replace the swagger-generated client config's
573-
# get_api_key_with_prefix method with our own to allow automatic
574-
# token refresh.
575-
def _gcp_get_api_key(*args):
576-
return self._load_gcp_token(self._user['auth-provider'])
577-
client_configuration.get_api_key_with_prefix = _gcp_get_api_key
578568
if 'token' in self.__dict__:
579-
# Note: this line runs for GCP auth tokens as well, but this entry
580-
# will not be updated upon GCP token refresh.
581569
client_configuration.api_key['authorization'] = self.token
570+
571+
def _refresh_api_key(client_configuration):
572+
if ('expiry' in self.__dict__ and _is_expired(self.expiry)):
573+
self._load_authentication()
574+
self._set_config(client_configuration)
575+
client_configuration.refresh_api_key_hook = _refresh_api_key
582576
# copy these keys directly from self to configuration object
583577
keys = ['host', 'ssl_ca_cert', 'cert_file', 'key_file', 'verify_ssl']
584578
for key in keys:

config/kube_config_test.py

Lines changed: 60 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from kubernetes.client import Configuration
3030

3131
from .config_exception import ConfigException
32-
from .dateutil import parse_rfc3339
32+
from .dateutil import format_rfc3339, parse_rfc3339
3333
from .kube_config import (ENV_KUBECONFIG_PATH_SEPARATOR, CommandTokenSource,
3434
ConfigNode, FileOrData, KubeConfigLoader,
3535
KubeConfigMerger, _cleanup_temp_files,
@@ -346,9 +346,12 @@ def test_get_with_name_on_duplicate_name(self):
346346
class FakeConfig:
347347

348348
FILE_KEYS = ["ssl_ca_cert", "key_file", "cert_file"]
349+
IGNORE_KEYS = ["refresh_api_key_hook"]
349350

350351
def __init__(self, token=None, **kwargs):
351352
self.api_key = {}
353+
# Provided by the OpenAPI-generated Configuration class
354+
self.refresh_api_key_hook = None
352355
if token:
353356
self.api_key['authorization'] = token
354357

@@ -358,6 +361,8 @@ def __eq__(self, other):
358361
if len(self.__dict__) != len(other.__dict__):
359362
return
360363
for k, v in self.__dict__.items():
364+
if k in self.IGNORE_KEYS:
365+
continue
361366
if k not in other.__dict__:
362367
return
363368
if k in self.FILE_KEYS:
@@ -956,17 +961,15 @@ def test_load_user_token(self):
956961

957962
def test_gcp_no_refresh(self):
958963
fake_config = FakeConfig()
959-
# swagger-generated config has this, but FakeConfig does not.
960-
self.assertFalse(hasattr(fake_config, 'get_api_key_with_prefix'))
964+
self.assertIsNone(fake_config.refresh_api_key_hook)
961965
KubeConfigLoader(
962966
config_dict=self.TEST_KUBE_CONFIG,
963967
active_context="gcp",
964968
get_google_credentials=lambda: _raise_exception(
965969
"SHOULD NOT BE CALLED")).load_and_set(fake_config)
966970
# Should now be populated with a gcp token fetcher.
967-
self.assertIsNotNone(fake_config.get_api_key_with_prefix)
971+
self.assertIsNotNone(fake_config.refresh_api_key_hook)
968972
self.assertEqual(TEST_HOST, fake_config.host)
969-
# For backwards compatibility, authorization field should still be set.
970973
self.assertEqual(BEARER_TOKEN_FORMAT % TEST_DATA_BASE64,
971974
fake_config.api_key['authorization'])
972975

@@ -997,7 +1000,7 @@ def cred(): return None
9971000
self.assertEqual(BEARER_TOKEN_FORMAT % TEST_ANOTHER_DATA_BASE64,
9981001
loader.token)
9991002

1000-
def test_gcp_get_api_key_with_prefix(self):
1003+
def test_gcp_refresh_api_key_hook(self):
10011004
class cred_old:
10021005
token = TEST_DATA_BASE64
10031006
expiry = DATETIME_EXPIRY_PAST
@@ -1015,15 +1018,13 @@ class cred_new:
10151018
get_google_credentials=_get_google_credentials)
10161019
loader.load_and_set(fake_config)
10171020
original_expiry = _get_expiry(loader, "expired_gcp_refresh")
1018-
# Call GCP token fetcher.
1019-
token = fake_config.get_api_key_with_prefix()
1021+
# Refresh the GCP token.
1022+
fake_config.refresh_api_key_hook(fake_config)
10201023
new_expiry = _get_expiry(loader, "expired_gcp_refresh")
10211024

10221025
self.assertTrue(new_expiry > original_expiry)
10231026
self.assertEqual(BEARER_TOKEN_FORMAT % TEST_ANOTHER_DATA_BASE64,
10241027
loader.token)
1025-
self.assertEqual(BEARER_TOKEN_FORMAT % TEST_ANOTHER_DATA_BASE64,
1026-
token)
10271028

10281029
def test_oidc_no_refresh(self):
10291030
loader = KubeConfigLoader(
@@ -1383,6 +1384,38 @@ def test_user_exec_auth(self, mock):
13831384
active_context="exec_cred_user").load_and_set(actual)
13841385
self.assertEqual(expected, actual)
13851386

1387+
@mock.patch('kubernetes.config.kube_config.ExecProvider.run')
1388+
def test_user_exec_auth_with_expiry(self, mock):
1389+
expired_token = "expired"
1390+
current_token = "current"
1391+
mock.side_effect = [
1392+
{
1393+
"token": expired_token,
1394+
"expirationTimestamp": format_rfc3339(DATETIME_EXPIRY_PAST)
1395+
},
1396+
{
1397+
"token": current_token,
1398+
"expirationTimestamp": format_rfc3339(DATETIME_EXPIRY_FUTURE)
1399+
}
1400+
]
1401+
1402+
fake_config = FakeConfig()
1403+
self.assertIsNone(fake_config.refresh_api_key_hook)
1404+
1405+
KubeConfigLoader(
1406+
config_dict=self.TEST_KUBE_CONFIG,
1407+
active_context="exec_cred_user").load_and_set(fake_config)
1408+
# The kube config should use the first token returned from the
1409+
# exec provider.
1410+
self.assertEqual(fake_config.api_key["authorization"],
1411+
BEARER_TOKEN_FORMAT % expired_token)
1412+
# Should now be populated with a method to refresh expired tokens.
1413+
self.assertIsNotNone(fake_config.refresh_api_key_hook)
1414+
# Refresh the token; the kube config should be updated.
1415+
fake_config.refresh_api_key_hook(fake_config)
1416+
self.assertEqual(fake_config.api_key["authorization"],
1417+
BEARER_TOKEN_FORMAT % current_token)
1418+
13861419
@mock.patch('kubernetes.config.kube_config.ExecProvider.run')
13871420
def test_user_exec_auth_certificates(self, mock):
13881421
mock.return_value = {
@@ -1412,7 +1445,6 @@ def test_user_cmd_path(self):
14121445
KubeConfigLoader(
14131446
config_dict=self.TEST_KUBE_CONFIG,
14141447
active_context="contexttestcmdpath").load_and_set(actual)
1415-
del actual.get_api_key_with_prefix
14161448
self.assertEqual(expected, actual)
14171449

14181450
def test_user_cmd_path_empty(self):
@@ -1490,31 +1522,28 @@ def test__get_kube_config_loader_dict_no_persist(self):
14901522
class TestKubernetesClientConfiguration(BaseTestCase):
14911523
# Verifies properties of kubernetes.client.Configuration.
14921524
# These tests guard against changes to the upstream configuration class,
1493-
# since GCP authorization overrides get_api_key_with_prefix to refresh its
1494-
# token regularly.
1525+
# since GCP and Exec authorization use refresh_api_key_hook to refresh
1526+
# their tokens regularly.
14951527

1496-
def test_get_api_key_with_prefix_exists(self):
1497-
self.assertTrue(hasattr(Configuration, 'get_api_key_with_prefix'))
1528+
def test_refresh_api_key_hook_exists(self):
1529+
self.assertTrue(hasattr(Configuration(), 'refresh_api_key_hook'))
14981530

1499-
def test_get_api_key_with_prefix_returns_token(self):
1500-
expected_token = 'expected_token'
1501-
config = Configuration()
1502-
config.api_key['authorization'] = expected_token
1503-
self.assertEqual(expected_token,
1504-
config.get_api_key_with_prefix('authorization'))
1505-
1506-
def test_auth_settings_calls_get_api_key_with_prefix(self):
1531+
def test_get_api_key_calls_refresh_api_key_hook(self):
1532+
identifier = 'authorization'
15071533
expected_token = 'expected_token'
15081534
old_token = 'old_token'
1535+
config = Configuration(
1536+
api_key={identifier: old_token},
1537+
api_key_prefix={identifier: 'Bearer'}
1538+
)
1539+
1540+
def refresh_api_key_hook(client_config):
1541+
self.assertEqual(client_config, config)
1542+
client_config.api_key[identifier] = expected_token
1543+
config.refresh_api_key_hook = refresh_api_key_hook
15091544

1510-
def fake_get_api_key_with_prefix(identifier):
1511-
self.assertEqual('authorization', identifier)
1512-
return expected_token
1513-
config = Configuration()
1514-
config.api_key['authorization'] = old_token
1515-
config.get_api_key_with_prefix = fake_get_api_key_with_prefix
1516-
self.assertEqual(expected_token,
1517-
config.auth_settings()['BearerToken']['value'])
1545+
self.assertEqual('Bearer ' + expected_token,
1546+
config.get_api_key_with_prefix(identifier))
15181547

15191548

15201549
class TestKubeConfigMerger(BaseTestCase):

0 commit comments

Comments
 (0)