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

Enable oidc auth #31

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions config/kube_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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':
Expand Down Expand Up @@ -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])

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get an error in my test:

TypeError: Incorrect padding

Would be nice to handle this error here.

)

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using auth here can remove all the use of cert staff above:

refresh = request.refresh_token(
    token_url=response['token_endpoint'],
    refresh_token=provider['config']['refresh-token'],
    auth=(provider['config']['client-id'], provider['config']['client-secret']))

)
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(
Expand Down
132 changes: 132 additions & 0 deletions config/kube_config_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -58,6 +60,63 @@ def _raise_exception(st):
# token for me:pass
TEST_BASIC_TOKEN = "Basic bWU6cGFzcw=="

TEST_OIDC_LOGIN = (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should use the actual login keys in test. Can you mock it and use something like "test_login..."

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can mock this out; I noticed due to the mocking the code coverage test fails.

"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)
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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()
Expand Down