Skip to content

Commit ffc31d5

Browse files
author
Ben Picolo
committed
Add support for open id connect token auth
1 parent b2ecd4c commit ffc31d5

File tree

3 files changed

+250
-3
lines changed

3 files changed

+250
-3
lines changed

kubernetes_asyncio/config/kube_config.py

+108-2
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,14 @@
1616
import atexit
1717
import base64
1818
import datetime
19+
import json
1920
import os
2021
import tempfile
2122

2223
import urllib3
2324
import yaml
25+
from oauthlib import oauth2
26+
from requests_oauthlib import OAuth2Session
2427

2528
from kubernetes_asyncio.client import ApiClient, Configuration
2629

@@ -30,6 +33,7 @@
3033

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

3539

@@ -163,8 +167,10 @@ async def _load_authentication(self):
163167
1. GCP auth-provider
164168
2. token_data
165169
3. token field (point to a token file)
166-
4. username/password
170+
4. oidc auth-provider
171+
5. username/password
167172
"""
173+
168174
if not self._user:
169175
return
170176

@@ -173,7 +179,12 @@ async def _load_authentication(self):
173179
return
174180

175181
if self._load_user_token():
182+
176183
return
184+
185+
if await self._load_oid_token():
186+
return
187+
177188
self._load_user_pass_token()
178189

179190
async def load_gcp_token(self):
@@ -184,7 +195,7 @@ async def load_gcp_token(self):
184195
config = self._user['auth-provider']['config']
185196

186197
if (('access-token' not in config) or
187-
('expiry' in config and _is_expired(config['expiry']))):
198+
('expiry' in config and _is_expired(config['expiry']))):
188199

189200
if self._get_google_credentials is not None:
190201
if asyncio.iscoroutinefunction(self._get_google_credentials):
@@ -201,6 +212,101 @@ async def load_gcp_token(self):
201212
self.token = "Bearer %s" % config['access-token']
202213
return self.token
203214

215+
async def _load_oid_token(self):
216+
if 'auth-provider' not in self._user:
217+
return
218+
219+
provider = self._user['auth-provider']
220+
if 'name' not in provider or provider['name'] != PROVIDER_TYPE_OIDC or 'config' not in provider:
221+
return
222+
223+
if 'id-token' not in provider['config']:
224+
await self._refresh_oidc(provider)
225+
self.token = 'Bearer {}'.format(provider['config']['id-token'])
226+
return self.token
227+
228+
parts = provider['config']['id-token'].split('.')
229+
230+
if len(parts) != 3:
231+
raise ValueError('oidc: JWT tokens should contain 3 period-delimited parts')
232+
233+
id_token = parts[1]
234+
# Re-pad the unpadded JWT token
235+
id_token += (4 - len(id_token) % 4) * '='
236+
jwt_attributes = json.loads(base64.b64decode(id_token).decode('utf8'))
237+
expires = jwt_attributes.get('exp')
238+
239+
if (
240+
expires is not None and
241+
_is_expired(datetime.datetime.utcfromtimestamp(expires))
242+
):
243+
await self._refresh_oidc(provider)
244+
245+
self.token = 'Bearer {}'.format(provider['config']['id-token'])
246+
return self.token
247+
248+
async def _refresh_oidc(self, provider):
249+
if 'refresh-token' not in provider['config']:
250+
raise ValueError('oidc: No valid id-token, and cannot refresh without refresh-token')
251+
252+
with tempfile.NamedTemporaryFile(delete=True) as certfile:
253+
configuration = Configuration()
254+
cert_auth_data = self._retrieve_oidc_cacert(provider)
255+
if cert_auth_data is not None:
256+
certfile.write(cert_auth_data)
257+
certfile.flush()
258+
configuration.ssl_ca_cert = certfile.name
259+
260+
client = ApiClient(configuration=configuration)
261+
issuer_url = provider['config']['idp-issuer-url']
262+
response = await client.request(
263+
method='GET',
264+
url='{}/.well-known/openid-configuration'.format(issuer_url.rstrip("/"))
265+
)
266+
267+
if response.status != 200:
268+
raise ValueError('oidc: failed to query metadata endpoint')
269+
270+
well_known_data = json.loads(response.data)
271+
272+
self._perform_oauth_refresh(provider, client, well_known_data)
273+
274+
def _perform_oauth_refresh(self, provider, client, well_known_data):
275+
# It's not optimal that this isn't async, but there's no existing well-supported
276+
# aiohttp-based oauth library right now (that I can find) and it's quite a feat
277+
# to recreate from scratch. Luckily, oauth refresh is a fairly rare occurrence
278+
request = OAuth2Session(
279+
client_id=provider['config']['client-id'],
280+
token=provider['config']['refresh-token'],
281+
auto_refresh_kwargs={
282+
'client_id': provider['config']['client-id'],
283+
'client_secret': provider['config']['client-secret'],
284+
},
285+
auto_refresh_url=well_known_data['token_endpoint']
286+
)
287+
288+
try:
289+
refresh = request.refresh_token(
290+
token_url=well_known_data['token_endpoint'],
291+
refresh_token=provider['config']['refresh-token'],
292+
auth=(provider['config']['client-id'], provider['config']['client-secret']),
293+
verify=client.configuration.ssl_ca_cert
294+
)
295+
except oauth2.rfc6749.errors.InvalidClientIdError:
296+
raise ValueError('oidc: client-id was invalid')
297+
298+
provider['config'].value['id-token'] = refresh['id_token']
299+
provider['config'].value['refresh-token'] = refresh['refresh_token']
300+
301+
if self._config_persister:
302+
self._config_persister(self._config.value)
303+
304+
def _retrieve_oidc_cacert(self, provider):
305+
if 'idp-certificate-authority-data' in provider['config']:
306+
return base64.b64decode(provider['config']['idp-certificate-authority-data'])
307+
308+
return None
309+
204310
def _load_user_token(self):
205311
token = FileOrData(
206312
self._user, 'tokenFile', 'token',

kubernetes_asyncio/config/kube_config_test.py

+141-1
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@
1414

1515
import base64
1616
import datetime
17+
import json
1718
import os
1819
import shutil
1920
import tempfile
2021
from types import SimpleNamespace
2122

2223
import yaml
23-
from asynctest import Mock, TestCase, main, patch
24+
from asynctest import Mock, PropertyMock, TestCase, main, patch
2425
from six import PY3
2526

2627
from .config_exception import ConfigException
@@ -39,6 +40,10 @@ def _base64(string):
3940
return base64.encodestring(string.encode()).decode()
4041

4142

43+
def _unpadded_base64(string):
44+
return base64.b64encode(string.encode()).decode().rstrip('')
45+
46+
4247
def _raise_exception(st):
4348
raise Exception(st)
4449

@@ -67,6 +72,20 @@ def _raise_exception(st):
6772
TEST_CLIENT_CERT = "client-cert"
6873
TEST_CLIENT_CERT_BASE64 = _base64(TEST_CLIENT_CERT)
6974

75+
TEST_OIDC_TOKEN = "test-oidc-token"
76+
TEST_OIDC_INFO = "{\"name\": \"test\"}"
77+
TEST_OIDC_BASE = _unpadded_base64(TEST_OIDC_TOKEN) + "." + _unpadded_base64(TEST_OIDC_INFO)
78+
TEST_OIDC_LOGIN = TEST_OIDC_BASE + "." + TEST_CLIENT_CERT_BASE64
79+
TEST_OIDC_TOKEN = "Bearer %s" % TEST_OIDC_LOGIN
80+
TEST_OIDC_EXP = "{\"name\": \"test\",\"exp\": 536457600}"
81+
TEST_OIDC_EXP_BASE = _unpadded_base64(TEST_OIDC_TOKEN) + "." + _unpadded_base64(TEST_OIDC_EXP)
82+
TEST_OIDC_EXPIRED_LOGIN = TEST_OIDC_EXP_BASE + "." + TEST_CLIENT_CERT_BASE64
83+
TEST_OIDC_CA = _base64(TEST_CERTIFICATE_AUTH)
84+
85+
86+
async def _return_async_value(val):
87+
return val
88+
7089

7190
class BaseTestCase(TestCase):
7291

@@ -333,6 +352,27 @@ class TestKubeConfigLoader(BaseTestCase):
333352
"user": "expired_gcp"
334353
}
335354
},
355+
{
356+
"name": "oidc",
357+
"context": {
358+
"cluster": "default",
359+
"user": "oidc"
360+
}
361+
},
362+
{
363+
"name": "expired_oidc",
364+
"context": {
365+
"cluster": "default",
366+
"user": "expired_oidc"
367+
}
368+
},
369+
{
370+
"name": "expired_oidc_no_idp_cert_data",
371+
"context": {
372+
"cluster": "default",
373+
"user": "expired_oidc_no_idp_cert_data"
374+
}
375+
},
336376
{
337377
"name": "user_pass",
338378
"context": {
@@ -450,6 +490,48 @@ class TestKubeConfigLoader(BaseTestCase):
450490
"password": TEST_PASSWORD, # should be ignored
451491
}
452492
},
493+
{
494+
"name": "oidc",
495+
"user": {
496+
"auth-provider": {
497+
"name": "oidc",
498+
"config": {
499+
"id-token": TEST_OIDC_LOGIN
500+
}
501+
}
502+
}
503+
},
504+
{
505+
"name": "expired_oidc",
506+
"user": {
507+
"auth-provider": {
508+
"name": "oidc",
509+
"config": {
510+
"client-id": "tectonic-kubectl",
511+
"client-secret": "FAKE_SECRET",
512+
"id-token": TEST_OIDC_EXPIRED_LOGIN,
513+
"idp-certificate-authority-data": TEST_OIDC_CA,
514+
"idp-issuer-url": "https://example.localhost/identity",
515+
"refresh-token": "lucWJjEhlxZW01cXI3YmVlcYnpxNGhzk"
516+
}
517+
}
518+
}
519+
},
520+
{
521+
"name": "expired_oidc_no_idp_cert_data",
522+
"user": {
523+
"auth-provider": {
524+
"name": "oidc",
525+
"config": {
526+
"client-id": "tectonic-kubectl",
527+
"client-secret": "FAKE_SECRET",
528+
"id-token": TEST_OIDC_EXPIRED_LOGIN,
529+
"idp-issuer-url": "https://example.localhost/identity",
530+
"refresh-token": "lucWJjEhlxZW01cXI3YmVlcYnpxNGhzk"
531+
}
532+
}
533+
}
534+
},
453535
{
454536
"name": "user_pass",
455537
"user": {
@@ -564,6 +646,64 @@ async def cred():
564646
self.assertEqual(BEARER_TOKEN_FORMAT % TEST_ANOTHER_DATA_BASE64,
565647
loader.token)
566648

649+
async def test_oidc_no_refresh(self):
650+
loader = KubeConfigLoader(
651+
config_dict=self.TEST_KUBE_CONFIG,
652+
active_context='oidc',
653+
)
654+
self.assertTrue(await loader._load_oid_token())
655+
self.assertEqual(TEST_OIDC_TOKEN, loader.token)
656+
657+
@patch('kubernetes_asyncio.config.kube_config.OAuth2Session.refresh_token')
658+
@patch('kubernetes_asyncio.config.kube_config.ApiClient')
659+
async def test_oidc_with_refresh(self, mock_api_client, mock_oauth2_session):
660+
mock_response = Mock()
661+
type(mock_response).status = PropertyMock(
662+
return_value=200
663+
)
664+
type(mock_response).data = PropertyMock(
665+
return_value=json.dumps({
666+
'token_endpoint': 'https://example.localhost/identity/token'
667+
})
668+
)
669+
670+
mock_api_client().request.return_value = _return_async_value(mock_response)
671+
mock_oauth2_session.return_value = {
672+
'id_token': 'abc123',
673+
'refresh_token': 'newtoken123'
674+
}
675+
loader = KubeConfigLoader(
676+
config_dict=self.TEST_KUBE_CONFIG,
677+
active_context='expired_oidc',
678+
)
679+
self.assertTrue(await loader._load_oid_token())
680+
self.assertEqual('Bearer abc123', loader.token)
681+
682+
@patch('kubernetes_asyncio.config.kube_config.OAuth2Session.refresh_token')
683+
@patch('kubernetes_asyncio.config.kube_config.ApiClient')
684+
async def test_oidc_with_refresh_no_idp_cert_data(self, mock_api_client, mock_oauth2_session):
685+
mock_response = Mock()
686+
type(mock_response).status = PropertyMock(
687+
return_value=200
688+
)
689+
type(mock_response).data = PropertyMock(
690+
return_value=json.dumps({
691+
'token_endpoint': 'https://example.localhost/identity/token'
692+
})
693+
)
694+
695+
mock_api_client().request.return_value = _return_async_value(mock_response)
696+
mock_oauth2_session.return_value = {
697+
'id_token': 'abc123',
698+
'refresh_token': 'newtoken123'
699+
}
700+
loader = KubeConfigLoader(
701+
config_dict=self.TEST_KUBE_CONFIG,
702+
active_context='expired_oidc_no_idp_cert_data',
703+
)
704+
self.assertTrue(await loader._load_oid_token())
705+
self.assertEqual('Bearer abc123', loader.token)
706+
567707
async def test_user_pass(self):
568708
expected = FakeConfig(host=TEST_HOST, token=TEST_BASIC_TOKEN)
569709
actual = FakeConfig()

requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ python-dateutil>=2.5.3 # BSD
44
setuptools>=21.0.0 # PSF/ZPL
55
urllib3>=1.19.1,!=1.21,<1.23 # MIT
66
pyyaml>=3.12 # MIT
7+
requests-oauthlib # ISC
78
aiohttp>=2.3.10 # # Apache-2.0

0 commit comments

Comments
 (0)