diff --git a/config/kube_config.py b/config/kube_config.py index 0b328b16..86122815 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -15,13 +15,17 @@ import atexit import base64 import datetime +import json import os import tempfile 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 @@ -169,6 +173,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 +182,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 +225,93 @@ 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 + + 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') + + 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['config'].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( diff --git a/config/kube_config_test.py b/config/kube_config_test.py index 6fa48b60..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 @@ -58,6 +60,63 @@ 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_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) @@ -317,6 +376,20 @@ class TestKubeConfigLoader(BaseTestCase): "user": "expired_gcp" } }, + { + "name": "oidc", + "context": { + "cluster": "default", + "user": "oidc" + } + }, + { + "name": "expired_oidc", + "context": { + "cluster": "default", + "user": "expired_oidc" + } + }, { "name": "user_pass", "context": { @@ -434,6 +507,33 @@ class TestKubeConfigLoader(BaseTestCase): "password": TEST_PASSWORD, # should be ignored } }, + { + "name": "oidc", + "user": { + "auth-provider": { + "name": "oidc", + "config": { + "id-token": TEST_OIDC_LOGIN + } + } + } + }, + { + "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": { @@ -531,6 +631,38 @@ 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) + + @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()