Skip to content

Commit 5267788

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

File tree

7 files changed

+403
-4
lines changed

7 files changed

+403
-4
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
__pycache__/
33
*.py[cod]
44
*$py.class
5+
/.pytest_cache
56

67
# C extensions
78
*.so

kubernetes_asyncio/config/kube_config.py

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

@@ -27,9 +28,11 @@
2728
from .config_exception import ConfigException
2829
from .dateutil import UTC, parse_rfc3339
2930
from .google_auth import google_auth_credentials
31+
from .openid import OpenIDRequestor
3032

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

3538

@@ -163,17 +166,24 @@ async def _load_authentication(self):
163166
1. GCP auth-provider
164167
2. token_data
165168
3. token field (point to a token file)
166-
4. username/password
169+
4. oidc auth-provider
170+
5. username/password
167171
"""
172+
168173
if not self._user:
169174
return
170175

171176
if self.provider == 'gcp':
172177
await self.load_gcp_token()
173178
return
174179

180+
if self.provider == PROVIDER_TYPE_OIDC:
181+
await self._load_oid_token()
182+
return
183+
175184
if self._load_user_token():
176185
return
186+
177187
self._load_user_pass_token()
178188

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

186196
if (('access-token' not in config) or
187-
('expiry' in config and _is_expired(config['expiry']))):
197+
('expiry' in config and _is_expired(config['expiry']))):
188198

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

214+
async def _load_oid_token(self):
215+
provider = self._user['auth-provider']
216+
217+
if 'config' not in provider:
218+
raise ValueError('oidc: missing configuration')
219+
220+
if 'id-token' not in provider['config']:
221+
await self._refresh_oidc(provider)
222+
223+
self.token = 'Bearer {}'.format(provider['config']['id-token'])
224+
return self.token
225+
226+
parts = provider['config']['id-token'].split('.')
227+
228+
if len(parts) != 3:
229+
raise ValueError('oidc: JWT tokens should contain 3 period-delimited parts')
230+
231+
id_token = parts[1]
232+
# Re-pad the unpadded JWT token
233+
id_token += (4 - len(id_token) % 4) * '='
234+
jwt_attributes = json.loads(base64.b64decode(id_token).decode('utf8'))
235+
expires = jwt_attributes.get('exp')
236+
237+
if (
238+
expires is not None and
239+
_is_expired(datetime.datetime.utcfromtimestamp(expires))
240+
):
241+
await self._refresh_oidc(provider)
242+
243+
self.token = 'Bearer {}'.format(provider['config']['id-token'])
244+
return self.token
245+
246+
async def _refresh_oidc(self, provider):
247+
if 'refresh-token' not in provider['config']:
248+
raise ConfigException('oidc: No valid id-token, and cannot refresh without refresh-token')
249+
250+
with tempfile.NamedTemporaryFile(delete=True) as certfile:
251+
ssl_ca_cert = None
252+
cert_auth_data = self._retrieve_oidc_cacert(provider)
253+
if cert_auth_data is not None:
254+
certfile.write(cert_auth_data)
255+
certfile.flush()
256+
ssl_ca_cert = certfile.name
257+
258+
requestor = OpenIDRequestor(
259+
provider['config']['client-id'],
260+
provider['config']['client-secret'],
261+
provider['config']['idp-issuer-url'],
262+
ssl_ca_cert,
263+
)
264+
265+
resp = await requestor.refresh_token(provider['config']['refresh-token'])
266+
267+
provider['config'].value['id-token'] = resp['id_token']
268+
provider['config'].value['refresh-token'] = resp['refresh_token']
269+
270+
if self._config_persister:
271+
self._config_persister(self._config.value)
272+
273+
def _retrieve_oidc_cacert(self, provider):
274+
if 'idp-certificate-authority-data' in provider['config']:
275+
return base64.b64decode(provider['config']['idp-certificate-authority-data'])
276+
277+
return None
278+
204279
def _load_user_token(self):
205280
token = FileOrData(
206281
self._user, 'tokenFile', 'token',

kubernetes_asyncio/config/kube_config_test.py

+142-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,65 @@ 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+
await loader._load_authentication()
655+
self.assertEqual(TEST_OIDC_TOKEN, loader.token)
656+
657+
@patch('kubernetes_asyncio.config.kube_config.OpenIDRequestor.refresh_token')
658+
async def test_oidc_with_refresh(self, mock_refresh_token):
659+
mock_refresh_token.return_value = {
660+
'id_token': 'abc123',
661+
'refresh_token': 'newtoken123'
662+
}
663+
664+
loader = KubeConfigLoader(
665+
config_dict=self.TEST_KUBE_CONFIG,
666+
active_context='expired_oidc',
667+
)
668+
await loader._load_authentication()
669+
self.assertEqual('Bearer abc123', loader.token)
670+
671+
@patch('kubernetes_asyncio.config.kube_config.OpenIDRequestor.refresh_token')
672+
async def test_oidc_with_refresh_no_idp_cert_data(self, mock_refresh_token):
673+
mock_refresh_token.return_value = {
674+
'id_token': 'abc123',
675+
'refresh_token': 'newtoken123'
676+
}
677+
678+
loader = KubeConfigLoader(
679+
config_dict=self.TEST_KUBE_CONFIG,
680+
active_context='expired_oidc_no_idp_cert_data',
681+
)
682+
await loader._load_authentication()
683+
self.assertEqual('Bearer abc123', loader.token)
684+
685+
async def test_invalid_oidc_configs(self):
686+
loader = KubeConfigLoader(config_dict=self.TEST_KUBE_CONFIG)
687+
688+
with self.assertRaises(ValueError):
689+
loader._user = {'auth-provider': {}}
690+
await loader._load_oid_token()
691+
692+
with self.assertRaises(ValueError):
693+
loader._user = {
694+
'auth-provider': {
695+
'config': {
696+
'id-token': 'notvalid'
697+
},
698+
}
699+
}
700+
await loader._load_oid_token()
701+
702+
async def test_invalid_refresh(self):
703+
loader = KubeConfigLoader(config_dict=self.TEST_KUBE_CONFIG)
704+
705+
with self.assertRaises(ConfigException):
706+
await loader._refresh_oidc({'config': {}})
707+
567708
async def test_user_pass(self):
568709
expected = FakeConfig(host=TEST_HOST, token=TEST_BASIC_TOKEN)
569710
actual = FakeConfig()

0 commit comments

Comments
 (0)