Skip to content

Commit

Permalink
Add support for open id connect token auth (#36)
Browse files Browse the repository at this point in the history
* Add support for open id connect token auth
  • Loading branch information
bpicolo authored and tomplus committed Aug 21, 2018
1 parent a29ed44 commit 76c111e
Show file tree
Hide file tree
Showing 5 changed files with 396 additions and 2 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
__pycache__/
*.py[cod]
*$py.class
/.pytest_cache

# C extensions
*.so
Expand Down
79 changes: 77 additions & 2 deletions kubernetes_asyncio/config/kube_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import atexit
import base64
import datetime
import json
import os
import tempfile

Expand All @@ -27,9 +28,11 @@
from .config_exception import ConfigException
from .dateutil import UTC, parse_rfc3339
from .google_auth import google_auth_credentials
from .openid import OpenIDRequestor

EXPIRY_SKEW_PREVENTION_DELAY = datetime.timedelta(minutes=5)
KUBE_CONFIG_DEFAULT_LOCATION = os.environ.get('KUBECONFIG', '~/.kube/config')
PROVIDER_TYPE_OIDC = 'oidc'
_temp_files = {}


Expand Down Expand Up @@ -163,17 +166,24 @@ async 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

if self.provider == 'gcp':
await self.load_gcp_token()
return

if self.provider == PROVIDER_TYPE_OIDC:
await self._load_oid_token()
return

if self._load_user_token():
return

self._load_user_pass_token()

async def load_gcp_token(self):
Expand All @@ -184,7 +194,7 @@ async def load_gcp_token(self):
config = self._user['auth-provider']['config']

if (('access-token' not in config) or
('expiry' in config and _is_expired(config['expiry']))):
('expiry' in config and _is_expired(config['expiry']))):

if self._get_google_credentials is not None:
if asyncio.iscoroutinefunction(self._get_google_credentials):
Expand All @@ -201,6 +211,71 @@ async def load_gcp_token(self):
self.token = "Bearer %s" % config['access-token']
return self.token

async def _load_oid_token(self):
provider = self._user['auth-provider']

if 'config' not in provider:
raise ValueError('oidc: missing configuration')

if 'id-token' not in provider['config']:
await self._refresh_oidc(provider)

self.token = 'Bearer {}'.format(provider['config']['id-token'])
return self.token

parts = provider['config']['id-token'].split('.')

if len(parts) != 3:
raise ValueError('oidc: JWT tokens should contain 3 period-delimited parts')

id_token = parts[1]
# Re-pad the unpadded JWT token
id_token += (4 - len(id_token) % 4) * '='
jwt_attributes = json.loads(base64.b64decode(id_token).decode('utf8'))
expires = jwt_attributes.get('exp')

if (
expires is not None and
_is_expired(datetime.datetime.utcfromtimestamp(expires))
):
await self._refresh_oidc(provider)

self.token = 'Bearer {}'.format(provider['config']['id-token'])
return self.token

async def _refresh_oidc(self, provider):
if 'refresh-token' not in provider['config']:
raise ConfigException('oidc: No valid id-token, and cannot refresh without refresh-token')

with tempfile.NamedTemporaryFile(delete=True) as certfile:
ssl_ca_cert = None
cert_auth_data = self._retrieve_oidc_cacert(provider)
if cert_auth_data is not None:
certfile.write(cert_auth_data)
certfile.flush()
ssl_ca_cert = certfile.name

requestor = OpenIDRequestor(
provider['config']['client-id'],
provider['config']['client-secret'],
provider['config']['idp-issuer-url'],
ssl_ca_cert,
)

resp = await requestor.refresh_token(provider['config']['refresh-token'])

provider['config'].value['id-token'] = resp['id_token']
provider['config'].value['refresh-token'] = resp['refresh_token']

if self._config_persister:
self._config_persister(self._config.value)

def _retrieve_oidc_cacert(self, provider):
if 'idp-certificate-authority-data' in provider['config']:
return base64.b64decode(provider['config']['idp-certificate-authority-data'])

return None

def _load_user_token(self):
token = FileOrData(
self._user, 'tokenFile', 'token',
Expand Down
140 changes: 140 additions & 0 deletions kubernetes_asyncio/config/kube_config_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ def _base64(string):
return base64.encodestring(string.encode()).decode()


def _unpadded_base64(string):
return base64.b64encode(string.encode()).decode().rstrip('')


def _raise_exception(st):
raise Exception(st)

Expand Down Expand Up @@ -67,6 +71,20 @@ def _raise_exception(st):
TEST_CLIENT_CERT = "client-cert"
TEST_CLIENT_CERT_BASE64 = _base64(TEST_CLIENT_CERT)

TEST_OIDC_TOKEN = "test-oidc-token"
TEST_OIDC_INFO = "{\"name\": \"test\"}"
TEST_OIDC_BASE = _unpadded_base64(TEST_OIDC_TOKEN) + "." + _unpadded_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 = _unpadded_base64(TEST_OIDC_TOKEN) + "." + _unpadded_base64(TEST_OIDC_EXP)
TEST_OIDC_EXPIRED_LOGIN = TEST_OIDC_EXP_BASE + "." + TEST_CLIENT_CERT_BASE64
TEST_OIDC_CA = _base64(TEST_CERTIFICATE_AUTH)


async def _return_async_value(val):
return val


class BaseTestCase(TestCase):

Expand Down Expand Up @@ -333,6 +351,27 @@ class TestKubeConfigLoader(BaseTestCase):
"user": "expired_gcp"
}
},
{
"name": "oidc",
"context": {
"cluster": "default",
"user": "oidc"
}
},
{
"name": "expired_oidc",
"context": {
"cluster": "default",
"user": "expired_oidc"
}
},
{
"name": "expired_oidc_no_idp_cert_data",
"context": {
"cluster": "default",
"user": "expired_oidc_no_idp_cert_data"
}
},
{
"name": "user_pass",
"context": {
Expand Down Expand Up @@ -450,6 +489,48 @@ 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.localhost/identity",
"refresh-token": "lucWJjEhlxZW01cXI3YmVlcYnpxNGhzk"
}
}
}
},
{
"name": "expired_oidc_no_idp_cert_data",
"user": {
"auth-provider": {
"name": "oidc",
"config": {
"client-id": "tectonic-kubectl",
"client-secret": "FAKE_SECRET",
"id-token": TEST_OIDC_EXPIRED_LOGIN,
"idp-issuer-url": "https://example.localhost/identity",
"refresh-token": "lucWJjEhlxZW01cXI3YmVlcYnpxNGhzk"
}
}
}
},
{
"name": "user_pass",
"user": {
Expand Down Expand Up @@ -564,6 +645,65 @@ async def cred():
self.assertEqual(BEARER_TOKEN_FORMAT % TEST_ANOTHER_DATA_BASE64,
loader.token)

async def test_oidc_no_refresh(self):
loader = KubeConfigLoader(
config_dict=self.TEST_KUBE_CONFIG,
active_context='oidc',
)
await loader._load_authentication()
self.assertEqual(TEST_OIDC_TOKEN, loader.token)

@patch('kubernetes_asyncio.config.kube_config.OpenIDRequestor.refresh_token')
async def test_oidc_with_refresh(self, mock_refresh_token):
mock_refresh_token.return_value = {
'id_token': 'abc123',
'refresh_token': 'newtoken123'
}

loader = KubeConfigLoader(
config_dict=self.TEST_KUBE_CONFIG,
active_context='expired_oidc',
)
await loader._load_authentication()
self.assertEqual('Bearer abc123', loader.token)

@patch('kubernetes_asyncio.config.kube_config.OpenIDRequestor.refresh_token')
async def test_oidc_with_refresh_no_idp_cert_data(self, mock_refresh_token):
mock_refresh_token.return_value = {
'id_token': 'abc123',
'refresh_token': 'newtoken123'
}

loader = KubeConfigLoader(
config_dict=self.TEST_KUBE_CONFIG,
active_context='expired_oidc_no_idp_cert_data',
)
await loader._load_authentication()
self.assertEqual('Bearer abc123', loader.token)

async def test_invalid_oidc_configs(self):
loader = KubeConfigLoader(config_dict=self.TEST_KUBE_CONFIG)

with self.assertRaises(ValueError):
loader._user = {'auth-provider': {}}
await loader._load_oid_token()

with self.assertRaises(ValueError):
loader._user = {
'auth-provider': {
'config': {
'id-token': 'notvalid'
},
}
}
await loader._load_oid_token()

async def test_invalid_refresh(self):
loader = KubeConfigLoader(config_dict=self.TEST_KUBE_CONFIG)

with self.assertRaises(ConfigException):
await loader._refresh_oidc({'config': {}})

async def test_user_pass(self):
expected = FakeConfig(host=TEST_HOST, token=TEST_BASIC_TOKEN)
actual = FakeConfig()
Expand Down
78 changes: 78 additions & 0 deletions kubernetes_asyncio/config/openid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import aiohttp

from .config_exception import ConfigException

GRANT_TYPE_REFRESH_TOKEN = 'refresh_token'


class OpenIDRequestor:

def __init__(self, client_id, client_secret, issuer_url, ssl_ca_cert=None):
"""OpenIDRequestor implements a very limited subset of the oauth2 APIs that we
require in order to refresh access tokens"""

self._client_id = client_id
self._client_secret = client_secret
self._issuer_url = issuer_url
self._ssl_ca_cert = ssl_ca_cert
self._well_known = None

def _get_connector(self):
return aiohttp.TCPConnector(
verify_ssl=self._ssl_ca_cert is not None,
ssl_context=self._ssl_ca_cert
)

def _client_session(self):
return aiohttp.ClientSession(
headers=self._default_headers,
connector=self._get_connector(),
auth=aiohttp.BasicAuth(self._client_id, self._client_secret),
raise_for_status=True,
)

async def refresh_token(self, refresh_token):
"""
:param refresh_token: an openid refresh-token from a previous token request
"""
async with self._client_session() as client:
well_known = await self._get_well_known(client)

try:
return await self._post(
client,
well_known['token_endpoint'],
data={
'grant_type': GRANT_TYPE_REFRESH_TOKEN,
'refresh_token': refresh_token,
}
)
except aiohttp.ClientResponseError as e:
raise ConfigException('oidc: failed to refresh access token')

async def _get(self, client, *args, **kwargs):
async with client.get(*args, **kwargs) as resp:
return await resp.json()

async def _post(self, client, *args, **kwargs):
async with client.post(*args, **kwargs) as resp:
return await resp.json()

async def _get_well_known(self, client):
if self._well_known is None:
try:
self._well_known = await self._get(
client,
'{}/.well-known/openid-configuration'.format(self._issuer_url.rstrip('/'))
)
except aiohttp.ClientResponseError:
raise ConfigException('oidc: failed to query well-known metadata endpoint')

return self._well_known

@property
def _default_headers(self):
return {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
}
Loading

0 comments on commit 76c111e

Please sign in to comment.