From 079c8fdef1f545d93e2d74aecc169f90bff5b4c4 Mon Sep 17 00:00:00 2001 From: Damian Myerscough Date: Fri, 25 Aug 2017 14:29:40 -0700 Subject: [PATCH 1/5] Enable oidc auth --- config/kube_config.py | 92 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/config/kube_config.py b/config/kube_config.py index 0b328b16..1a417219 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -15,19 +15,25 @@ import atexit import base64 import datetime +import json import os import tempfile +import oauthlib.oauth2 import google.auth import google.auth.transport.requests import urllib3 import yaml +from six import PY3 + from kubernetes.client import ApiClient, ConfigurationObject, configuration from .config_exception import ConfigException from .dateutil import UTC, format_rfc3339, parse_rfc3339 +from requests_oauthlib import OAuth2Session + EXPIRY_SKEW_PREVENTION_DELAY = datetime.timedelta(minutes=5) KUBE_CONFIG_DEFAULT_LOCATION = os.environ.get('KUBECONFIG', '~/.kube/config') _temp_files = {} @@ -169,6 +175,7 @@ def _load_authentication(self): 1. GCP auth-provider 2. token_data 3. token field (point to a token file) + 4. oidc auth-provider 4. username/password """ if not self._user: @@ -177,12 +184,15 @@ def _load_authentication(self): return if self._load_user_token(): return + if self._load_oid_token(): + return self._load_user_pass_token() def _load_gcp_token(self): if 'auth-provider' not in self._user: return provider = self._user['auth-provider'] + if 'name' not in provider: return if provider['name'] != 'gcp': @@ -217,6 +227,88 @@ def _load_user_token(self): self.token = "Bearer %s" % token return True + def _load_oid_token(self): + if 'auth-provider' not in self._user: + return + provider = self._user['auth-provider'] + + if 'name' not in provider or 'config' not in provider: + return + + if provider['name'] != 'oidc': + return + + parts = provider['config']['id-token'].split('.') + + if len(parts) != 3: # Not a valid JWT + return None + + jwt_attributes = json.loads( + base64.b64decode(parts[1]) + ) + + expire = jwt_attributes.get('exp') + + if ((expire is not None) and + (_is_expired(datetime.datetime.fromtimestamp(expire)))): + self._refresh_oidc(provider) + + self.token = "Bearer %s" % provider['config']['id-token'] + + return self.token + + def _refresh_oidc(self, provider): + ca_cert = tempfile.NamedTemporaryFile(delete=True) + + if PY3: + cert = base64.b64decode( + provider['config']['idp-certificate-authority-data'] + ).decode('utf-8') + else: + cert = base64.b64decode( + provider['config']['idp-certificate-authority-data'] + ) + + with open(ca_cert.name, 'w') as fh: + fh.write(cert) + + config = ConfigurationObject() + config.ssl_ca_cert = ca_cert.name + + client = ApiClient(config=config) + + response = client.request( + method="GET", + url="%s/.well-known/openid-configuration" + % provider['config']['idp-issuer-url'] + ) + + if response.status != 200: + return + + response = json.loads(response.data) + + request = OAuth2Session( + client_id=provider['config']['client-id'], + token=provider['config']['refresh-token'], + auto_refresh_kwargs={ + 'client_id': provider['config']['client-id'], + 'client_secret': provider['config']['client-secret'] + }, + auto_refresh_url=response['token_endpoint'] + ) + + try: + refresh = request.refresh_token( + token_url=response['token_endpoint'], + refresh_token=provider['config']['refresh-token'], + verify=ca_cert.name + ) + except oauthlib.oauth2.rfc6749.errors.InvalidClientIdError: + return + + provider.value['id-token'] = refresh['id_token'] + def _load_user_pass_token(self): if 'username' in self._user and 'password' in self._user: self.token = urllib3.util.make_headers( From c246d19e58ef1cf68a4206ce7c9f787083f5494f Mon Sep 17 00:00:00 2001 From: Damian Myerscough Date: Tue, 5 Sep 2017 21:39:06 -0700 Subject: [PATCH 2/5] Correct import ordering --- config/kube_config.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index 1a417219..be794bfa 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -19,12 +19,12 @@ import os import tempfile -import oauthlib.oauth2 import google.auth import google.auth.transport.requests +import oauthlib.oauth2 import urllib3 import yaml - +from requests_oauthlib import OAuth2Session from six import PY3 from kubernetes.client import ApiClient, ConfigurationObject, configuration @@ -32,8 +32,6 @@ from .config_exception import ConfigException from .dateutil import UTC, format_rfc3339, parse_rfc3339 -from requests_oauthlib import OAuth2Session - EXPIRY_SKEW_PREVENTION_DELAY = datetime.timedelta(minutes=5) KUBE_CONFIG_DEFAULT_LOCATION = os.environ.get('KUBECONFIG', '~/.kube/config') _temp_files = {} @@ -250,7 +248,7 @@ def _load_oid_token(self): expire = jwt_attributes.get('exp') if ((expire is not None) and - (_is_expired(datetime.datetime.fromtimestamp(expire)))): + (_is_expired(datetime.datetime.fromtimestamp(expire)))): self._refresh_oidc(provider) self.token = "Bearer %s" % provider['config']['id-token'] From 6ff4cb7a5b15dea91752f85384f0d6cd8d8926e4 Mon Sep 17 00:00:00 2001 From: Damian Myerscough Date: Tue, 19 Sep 2017 13:04:35 -0700 Subject: [PATCH 3/5] Test OIDC without refresh token --- config/kube_config_test.py | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index 6fa48b60..3fabe8e7 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -58,6 +58,22 @@ def _raise_exception(st): # token for me:pass TEST_BASIC_TOKEN = "Basic bWU6cGFzcw==" +TEST_OIDC_LOGIN = ( + "eyJhbGciOiJSUzI1NiIsImtpZCI6ImVmM2Y0NjIxODhiNjhhMzY2YjQ1MWE0YjkwY2UxYjYyY" + "mEyYzliNDkifQ.eyJpc3MiOiJodHRwczovL2V4YW1wbGUudXMtd2VzdC0xLmF3cy5uZXQvaWR" + "lbnRpdHkiLCJzdWIiOiJBQUFBQUFBQUFBQUEiLCJhdWQiOiJ0ZWN0b25pYy1rdWJlY3RsIiwi" + "ZXhwIjoxMDM4MjI1NjAwMCwiaWF0IjoxMDM4MjI1NjAwMCwiYXRfaGFzaCI6IlhYWFhYWF9YW" + "FhYWFhYIiwiZW1haWwiOiJkYW1pYW4ubXllcnNjb3VnaEBnbWFpbC5jb20iLCJlbWFpbF92ZX" + "JpZmllZCI6dHJ1ZSwiZ3JvdXBzIjpbInRlYW0taW5mcmEiXSwibmFtZSI6IkRhbWlhbiBNeWV" + "yc2NvdWdoIn0=.BZwpd0_hKYMIaYRj88QjPTrg8JFtaiyVXOqLgKkJHBVzivdzs9JjM9jvV3q" + "zj2DUwaeGeAZqxlbmwEXXePU-jFg70HGo7FDq4G29x516XNZWW2BaelcevFPspcIJTQ92VhYZ" + "vCiWp8r7SmhZ1TSss3nmuDHn3FTdasqUm22LJOqCfCDaOOf_Uq3uP0zHj4UHJAqvgMfw1j5tZ" + "XTYJ613vGGPkCz_K1Jnv6YIxVVnuZM3PyNNdSXQl5_GM01Zf5wJCgqMdRZ01ZrWhOda6wzlKr" + "h7TClbW12_vMo56aOj9HOAjhKyjcbLHjIWAWqmt3nmhwkzf8sYc9-WpscPTNalsQ" +) + +TEST_OIDC_TOKEN = "Bearer %s" % TEST_OIDC_LOGIN + TEST_SSL_HOST = "https://test-host" TEST_CERTIFICATE_AUTH = "cert-auth" TEST_CERTIFICATE_AUTH_BASE64 = _base64(TEST_CERTIFICATE_AUTH) @@ -317,6 +333,13 @@ class TestKubeConfigLoader(BaseTestCase): "user": "expired_gcp" } }, + { + "name": "oidc", + "context": { + "cluster": "default", + "user": "oidc" + } + }, { "name": "user_pass", "context": { @@ -434,6 +457,17 @@ class TestKubeConfigLoader(BaseTestCase): "password": TEST_PASSWORD, # should be ignored } }, + { + "name": "oidc", + "user": { + "auth-provider": { + "name": "oidc", + "config": { + "id-token": TEST_OIDC_LOGIN + } + } + } + }, { "name": "user_pass", "user": { @@ -531,6 +565,14 @@ def cred(): return None self.assertEqual(BEARER_TOKEN_FORMAT % TEST_ANOTHER_DATA_BASE64, loader.token) + def test_oidc_no_refresh(self): + loader = KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="oidc", + ) + self.assertTrue(loader._load_oid_token()) + self.assertEqual(TEST_OIDC_TOKEN, loader.token) + def test_user_pass(self): expected = FakeConfig(host=TEST_HOST, token=TEST_BASIC_TOKEN) actual = FakeConfig() From e62a87a746610301423bdc3c1b46ad7acbfe8046 Mon Sep 17 00:00:00 2001 From: Damian Myerscough Date: Tue, 19 Sep 2017 13:33:37 -0700 Subject: [PATCH 4/5] Handle bytes string for Python 3 --- config/kube_config.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index be794bfa..f6b5bf0a 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -241,9 +241,14 @@ def _load_oid_token(self): if len(parts) != 3: # Not a valid JWT return None - jwt_attributes = json.loads( - base64.b64decode(parts[1]) - ) + if PY3: + jwt_attributes = json.loads( + base64.b64decode(parts[1]).decode('utf-8') + ) + else: + jwt_attributes = json.loads( + base64.b64decode(parts[1]) + ) expire = jwt_attributes.get('exp') From b67735cf8b90371a0ed6c644a2ea0ae11ec91e46 Mon Sep 17 00:00:00 2001 From: Damian Myerscough Date: Tue, 19 Sep 2017 17:20:44 -0700 Subject: [PATCH 5/5] Mock out oidc refresh --- config/kube_config.py | 2 +- config/kube_config_test.py | 90 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/config/kube_config.py b/config/kube_config.py index f6b5bf0a..86122815 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -310,7 +310,7 @@ def _refresh_oidc(self, provider): except oauthlib.oauth2.rfc6749.errors.InvalidClientIdError: return - provider.value['id-token'] = refresh['id_token'] + provider['config'].value['id-token'] = refresh['id_token'] def _load_user_pass_token(self): if 'username' in self._user and 'password' in self._user: diff --git a/config/kube_config_test.py b/config/kube_config_test.py index 3fabe8e7..0e0b4e58 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -14,11 +14,13 @@ import base64 import datetime +import json import os import shutil import tempfile import unittest +import mock import yaml from six import PY3 @@ -74,6 +76,47 @@ def _raise_exception(st): TEST_OIDC_TOKEN = "Bearer %s" % TEST_OIDC_LOGIN +TEST_OIDC_EXPIRED_LOGIN = ( + "eyJhbGciOiJSUzI1NiIsImtpZCI6ImVmM2Y0NjIxODhiNjhhMzY2YjQ1MWE0YjkwY2UxYjYyY" + "mEyYzliNDkifQ.eyJpc3MiOiJodHRwczovL2V4YW1wbGUudXMtd2VzdC0xLmF3cy5uZXQvaWR" + "lbnRpdHkiLCJzdWIiOiJBQUFBQUFBQUFBQUEiLCJhdWQiOiJ0ZWN0b25pYy1rdWJlY3RsIiwi" + "ZXhwIjo1MzY0NTc2MDAsImlhdCI6NTM2NDU3NjAwLCJhdF9oYXNoIjoiWFhYWFhYX1hYWFhYW" + "FgiLCJlbWFpbCI6ImRhbWlhbi5teWVyc2NvdWdoQGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaW" + "VkIjp0cnVlLCJncm91cHMiOlsidGVhbS1pbmZyYSJdLCJuYW1lIjoiRGFtaWFuIE15ZXJzY29" + "1Z2gifQ==.BZwpd0_hKYMIaYRj88QjPTrg8JFtaiyVXOqLgKkJHBVzivdzs9JjM9jvV3qzj2D" + "UwaeGeAZqxlbmwEXXePU-jFg70HGo7FDq4G29x516XNZWW2BaelcevFPspcIJTQ92VhYZvCiW" + "p8r7SmhZ1TSss3nmuDHn3FTdasqUm22LJOqCfCDaOOf_Uq3uP0zHj4UHJAqvgMfw1j5tZXTYJ" + "613vGGPkCz_K1Jnv6YIxVVnuZM3PyNNdSXQl5_GM01Zf5wJCgqMdRZ01ZrWhOda6wzlKrh7TC" + "lbW12_vMo56aOj9HOAjhKyjcbLHjIWAWqmt3nmhwkzf8sYc9-WpscPTNalsQ" +) + +TEST_OIDC_CA = ( + "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURoVENDQW0yZ0F3SUJBZ0lSQUt0elJOd" + "2J0M3dyVWlobVROYklheU13RFFZSktvWklodmNOQVFFTEJRQXcKWERFSk1BY0dBMVVFQmhNQU" + "1Ra3dCd1lEVlFRSUV3QXhDVEFIQmdOVkJBY1RBREVKTUFjR0ExVUVFUk1BTVJFdwpEd1lEVlF" + "RS0V3aGliMjkwYTNWaVpURUpNQWNHQTFVRUN4TUFNUkF3RGdZRFZRUURFd2RyZFdKbExXTmhN" + "QjRYCkRURTNNRGN4TWpJeE16TTBNVm9YRFRFNE1EY3hNakl4TXpNME1Wb3dYREVKTUFjR0ExV" + "UVCaE1BTVFrd0J3WUQKVlFRSUV3QXhDVEFIQmdOVkJBY1RBREVKTUFjR0ExVUVFUk1BTVJFd0" + "R3WURWUVFLRXdoaWIyOTBhM1ZpWlRFSgpNQWNHQTFVRUN4TUFNUkF3RGdZRFZRUURFd2RyZFd" + "KbExXTmhNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DCkFROEFNSUlCQ2dLQ0FRRUF1KzJn" + "VEtKc2NNKzgwdDlLNE9PTU1JSDhXeU1aLzZiUFBtbFU2WE0zVUhLa2tLVW0KbStkd3hraXI4e" + "URRQ1pTNERWam9vUXVodzJTNWY0dk80ZENncGg3Rmt6LzBZcUVNcDRzblFwQmVUVGw3ZEJLSw" + "pRNitFelVQdGZjaUZtemNBbUtXN292bUV5K2plSW1QQjYyMTY4WVJYcTFNaHFqZCtsVTJGaFB" + "SVzNXZEtHRnp0Ck1Pa2o5amRqaGd4cTNDZmRTSGk3ejdidVVYbm5WQnNuaEFCamlvOGFuK3M1" + "ZVBJOUVBNExJZk8zQldMZHdWejQKdThGQU91eExxSXBja2VKejNXSW5MUURXcWpFZkhUWVA2U" + "TlaMzA3MGxhMnVGWkNuY3pkbFh6V0haQmNuSUlscwp0VXZnVmhxbUNQRzlGLzBrWFhpYWQwUG" + "kvYUYzSXFOYUphOEViUUlEQVFBQm8wSXdRREFPQmdOVkhROEJBZjhFCkJBTUNBcVF3RHdZRFZ" + "SMFRBUUgvQkFVd0F3RUIvekFkQmdOVkhRNEVGZ1FVL1hCYlNUMWJ3VXczT1VpVHlmN2MKMzJR" + "Q3B4c3dEUVlKS29aSWh2Y05BUUVMQlFBRGdnRUJBSGdqelpINkx3cGF3eXlMWmVKTUZOcFdMY" + "Ws4RThHMApPcmlka3dESWhoWjVCQ0ZLSEdIZE82T1ZQTk1ZcWt6TzJpUzhyOFhNWjN3OExqMW" + "M2UVF4VzhJNG8wdDhJWDNnCkNnRTNhOXR1bjNRNC96cnVlNU5EUWp2MVMrR1V5QW12c2p5Z1N" + "FS3VFVXRHVkxwTlhYemlDN0lSMG41MHBpZnQKZ1JJVzFQOThUcTROYzVMaVluNTJXTnJwUnFo" + "WllNays5SWJiSGZZN3Y3VkY3eEJVSDJlWGFiMGViM2lCR09OUgorVTc2ZG5NRDNrbUs2dGpnU" + "UVCWnUwRTVVTnJZRlUvclZEYjVYb1dXYjEyMFhSYUZSWGRZV1ZreWFYQW0vc3EwCkRaUEZKTT" + "dvU1JZcGNKSWlYZExPamYyT1VQNzI1LzVtRDJpd3FGbTJ0V3BjMkdTbjlvWGZseGs9Ci0tLS0" + "tRU5EIENFUlRJRklDQVRFLS0tLS0K" +) + TEST_SSL_HOST = "https://test-host" TEST_CERTIFICATE_AUTH = "cert-auth" TEST_CERTIFICATE_AUTH_BASE64 = _base64(TEST_CERTIFICATE_AUTH) @@ -340,6 +383,13 @@ class TestKubeConfigLoader(BaseTestCase): "user": "oidc" } }, + { + "name": "expired_oidc", + "context": { + "cluster": "default", + "user": "expired_oidc" + } + }, { "name": "user_pass", "context": { @@ -468,6 +518,22 @@ class TestKubeConfigLoader(BaseTestCase): } } }, + { + "name": "expired_oidc", + "user": { + "auth-provider": { + "name": "oidc", + "config": { + "client-id": "tectonic-kubectl", + "client-secret": "FAKE_SECRET", + "id-token": TEST_OIDC_EXPIRED_LOGIN, + "idp-certificate-authority-data": TEST_OIDC_CA, + "idp-issuer-url": "https://example.org/identity", + "refresh-token": "lucWJjEhlxZW01cXI3YmVlcYnpxNGhzk" + } + } + } + }, { "name": "user_pass", "user": { @@ -573,6 +639,30 @@ def test_oidc_no_refresh(self): self.assertTrue(loader._load_oid_token()) self.assertEqual(TEST_OIDC_TOKEN, loader.token) + @mock.patch('config.kube_config.OAuth2Session.refresh_token') + @mock.patch('config.kube_config.ApiClient.request') + def test_oidc_with_refresh(self, mock_ApiClient, mock_OAuth2Session): + mock_response = mock.MagicMock() + type(mock_response).status = mock.PropertyMock( + return_value=200 + ) + type(mock_response).data = mock.PropertyMock( + return_value=json.dumps({ + "token_endpoint": "https://example.org/identity/token" + }) + ) + + mock_ApiClient.return_value = mock_response + + mock_OAuth2Session.return_value = {"id_token": "abc123"} + + loader = KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="expired_oidc", + ) + self.assertTrue(loader._load_oid_token()) + self.assertEqual("Bearer abc123", loader.token) + def test_user_pass(self): expected = FakeConfig(host=TEST_HOST, token=TEST_BASIC_TOKEN) actual = FakeConfig()