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

Commit b9cc79e

Browse files
authored
Merge pull request #250 from twitter-forks/emenendez/741
Refresh exec-based API credentials when they expire
2 parents a66f8df + 70b78cd commit b9cc79e

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
@@ -360,6 +360,8 @@ def _load_gcp_token(self, provider):
360360
self._refresh_gcp_token()
361361

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

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

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

564-
def _using_gcp_auth_provider(self):
565-
return self._user and \
566-
'auth-provider' in self._user and \
567-
'name' in self._user['auth-provider'] and \
568-
self._user['auth-provider']['name'] == 'gcp'
569-
570568
def _set_config(self, client_configuration):
571-
if self._using_gcp_auth_provider():
572-
# GCP auth tokens must be refreshed regularly, but swagger expects
573-
# a constant token. Replace the swagger-generated client config's
574-
# get_api_key_with_prefix method with our own to allow automatic
575-
# token refresh.
576-
def _gcp_get_api_key(*args):
577-
return self._load_gcp_token(self._user['auth-provider'])
578-
client_configuration.get_api_key_with_prefix = _gcp_get_api_key
579569
if 'token' in self.__dict__:
580-
# Note: this line runs for GCP auth tokens as well, but this entry
581-
# will not be updated upon GCP token refresh.
582570
client_configuration.api_key['authorization'] = self.token
571+
572+
def _refresh_api_key(client_configuration):
573+
if ('expiry' in self.__dict__ and _is_expired(self.expiry)):
574+
self._load_authentication()
575+
self._set_config(client_configuration)
576+
client_configuration.refresh_api_key_hook = _refresh_api_key
583577
# copy these keys directly from self to configuration object
584578
keys = ['host', 'ssl_ca_cert', 'cert_file', 'key_file', 'verify_ssl']
585579
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(
@@ -1390,6 +1391,38 @@ def test_user_exec_auth(self, mock):
13901391
active_context="exec_cred_user").load_and_set(actual)
13911392
self.assertEqual(expected, actual)
13921393

1394+
@mock.patch('kubernetes.config.kube_config.ExecProvider.run')
1395+
def test_user_exec_auth_with_expiry(self, mock):
1396+
expired_token = "expired"
1397+
current_token = "current"
1398+
mock.side_effect = [
1399+
{
1400+
"token": expired_token,
1401+
"expirationTimestamp": format_rfc3339(DATETIME_EXPIRY_PAST)
1402+
},
1403+
{
1404+
"token": current_token,
1405+
"expirationTimestamp": format_rfc3339(DATETIME_EXPIRY_FUTURE)
1406+
}
1407+
]
1408+
1409+
fake_config = FakeConfig()
1410+
self.assertIsNone(fake_config.refresh_api_key_hook)
1411+
1412+
KubeConfigLoader(
1413+
config_dict=self.TEST_KUBE_CONFIG,
1414+
active_context="exec_cred_user").load_and_set(fake_config)
1415+
# The kube config should use the first token returned from the
1416+
# exec provider.
1417+
self.assertEqual(fake_config.api_key["authorization"],
1418+
BEARER_TOKEN_FORMAT % expired_token)
1419+
# Should now be populated with a method to refresh expired tokens.
1420+
self.assertIsNotNone(fake_config.refresh_api_key_hook)
1421+
# Refresh the token; the kube config should be updated.
1422+
fake_config.refresh_api_key_hook(fake_config)
1423+
self.assertEqual(fake_config.api_key["authorization"],
1424+
BEARER_TOKEN_FORMAT % current_token)
1425+
13931426
@mock.patch('kubernetes.config.kube_config.ExecProvider.run')
13941427
def test_user_exec_auth_certificates(self, mock):
13951428
mock.return_value = {
@@ -1419,7 +1452,6 @@ def test_user_cmd_path(self):
14191452
KubeConfigLoader(
14201453
config_dict=self.TEST_KUBE_CONFIG,
14211454
active_context="contexttestcmdpath").load_and_set(actual)
1422-
del actual.get_api_key_with_prefix
14231455
self.assertEqual(expected, actual)
14241456

14251457
def test_user_cmd_path_empty(self):
@@ -1497,31 +1529,28 @@ def test__get_kube_config_loader_dict_no_persist(self):
14971529
class TestKubernetesClientConfiguration(BaseTestCase):
14981530
# Verifies properties of kubernetes.client.Configuration.
14991531
# These tests guard against changes to the upstream configuration class,
1500-
# since GCP authorization overrides get_api_key_with_prefix to refresh its
1501-
# token regularly.
1532+
# since GCP and Exec authorization use refresh_api_key_hook to refresh
1533+
# their tokens regularly.
15021534

1503-
def test_get_api_key_with_prefix_exists(self):
1504-
self.assertTrue(hasattr(Configuration, 'get_api_key_with_prefix'))
1535+
def test_refresh_api_key_hook_exists(self):
1536+
self.assertTrue(hasattr(Configuration(), 'refresh_api_key_hook'))
15051537

1506-
def test_get_api_key_with_prefix_returns_token(self):
1507-
expected_token = 'expected_token'
1508-
config = Configuration()
1509-
config.api_key['authorization'] = expected_token
1510-
self.assertEqual(expected_token,
1511-
config.get_api_key_with_prefix('authorization'))
1512-
1513-
def test_auth_settings_calls_get_api_key_with_prefix(self):
1538+
def test_get_api_key_calls_refresh_api_key_hook(self):
1539+
identifier = 'authorization'
15141540
expected_token = 'expected_token'
15151541
old_token = 'old_token'
1542+
config = Configuration(
1543+
api_key={identifier: old_token},
1544+
api_key_prefix={identifier: 'Bearer'}
1545+
)
1546+
1547+
def refresh_api_key_hook(client_config):
1548+
self.assertEqual(client_config, config)
1549+
client_config.api_key[identifier] = expected_token
1550+
config.refresh_api_key_hook = refresh_api_key_hook
15161551

1517-
def fake_get_api_key_with_prefix(identifier):
1518-
self.assertEqual('authorization', identifier)
1519-
return expected_token
1520-
config = Configuration()
1521-
config.api_key['authorization'] = old_token
1522-
config.get_api_key_with_prefix = fake_get_api_key_with_prefix
1523-
self.assertEqual(expected_token,
1524-
config.auth_settings()['BearerToken']['value'])
1552+
self.assertEqual('Bearer ' + expected_token,
1553+
config.get_api_key_with_prefix(identifier))
15251554

15261555

15271556
class TestKubeConfigMerger(BaseTestCase):

0 commit comments

Comments
 (0)