From 111896107f2031c4abe49e7ecaa228e35864c39e Mon Sep 17 00:00:00 2001 From: Luis Toledo Date: Fri, 26 Jan 2018 10:46:43 -0300 Subject: [PATCH] Add support to OIDC auth Fix for the `TypeError: Incorrect padding` error Adding test with "mocked" variables Persist the new token (refresh token) and add a not-ssl-verification for the refresh token call (i didn't find a way to pass the certificate to OAuth2Session fixing the refresh-token problem (ssl certificate) and saving returning the new refresh-token Fix test fixing coding style errors Fixing test update-pep8 Fix test_oidc_with_refresh error --- config/kube_config.py | 103 ++++++++++++++++++++++++++++++++++++- config/kube_config_test.py | 87 +++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 1 deletion(-) diff --git a/config/kube_config.py b/config/kube_config.py index 4e09d6a9..c0990098 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, Configuration @@ -169,7 +173,8 @@ def _load_authentication(self): 1. GCP auth-provider 2. token_data 3. token field (point to a token file) - 4. username/password + 4. oidc auth-provider + 5. username/password """ if not self._user: return @@ -177,6 +182,8 @@ 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): @@ -208,6 +215,100 @@ def _refresh_gcp_token(self): if self._config_persister: self._config_persister(self._config.value) + 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, + tz=UTC)))): + self._refresh_oidc(provider) + + if self._config_persister: + self._config_persister(self._config.value) + + 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 = Configuration() + config.ssl_ca_cert = ca_cert.name + + client = ApiClient(configuration=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'], + auth=(provider['config']['client-id'], + provider['config']['client-secret']), + verify=ca_cert.name + ) + except oauthlib.oauth2.rfc6749.errors.InvalidClientIdError: + return + + provider['config'].value['id-token'] = refresh['id_token'] + provider['config'].value['refresh-token'] = refresh['refresh_token'] + def _load_user_token(self): token = FileOrData( self._user, 'tokenFile', 'token', diff --git a/config/kube_config_test.py b/config/kube_config_test.py index d6586713..c98dff02 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 @@ -67,6 +69,17 @@ def _raise_exception(st): TEST_CLIENT_CERT_BASE64 = _base64(TEST_CLIENT_CERT) +TEST_OIDC_TOKEN = "test-oidc-token" +TEST_OIDC_INFO = "{\"name\": \"test\"}" +TEST_OIDC_BASE = _base64(TEST_OIDC_TOKEN) + "." + _base64(TEST_OIDC_INFO) +TEST_OIDC_LOGIN = TEST_OIDC_BASE + "." + TEST_CLIENT_CERT_BASE64 +TEST_OIDC_TOKEN = "Bearer %s" % TEST_OIDC_LOGIN +TEST_OIDC_EXP = "{\"name\": \"test\",\"exp\": 536457600}" +TEST_OIDC_EXP_BASE = _base64(TEST_OIDC_TOKEN) + "." + _base64(TEST_OIDC_EXP) +TEST_OIDC_EXPIRED_LOGIN = TEST_OIDC_EXP_BASE + "." + TEST_CLIENT_CERT_BASE64 +TEST_OIDC_CA = _base64(TEST_CERTIFICATE_AUTH) + + class BaseTestCase(unittest.TestCase): def setUp(self): @@ -317,6 +330,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 +461,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": { @@ -528,6 +582,39 @@ 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('kubernetes.config.kube_config.OAuth2Session.refresh_token') + @mock.patch('kubernetes.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", + "refresh_token": "newtoken123"} + + 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()