Skip to content

Commit 76c111e

Browse files
bpicolotomplus
authored andcommitted
Add support for open id connect token auth (#36)
* Add support for open id connect token auth
1 parent a29ed44 commit 76c111e

File tree

5 files changed

+396
-2
lines changed

5 files changed

+396
-2
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

+140
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ def _base64(string):
3939
return base64.encodestring(string.encode()).decode()
4040

4141

42+
def _unpadded_base64(string):
43+
return base64.b64encode(string.encode()).decode().rstrip('')
44+
45+
4246
def _raise_exception(st):
4347
raise Exception(st)
4448

@@ -67,6 +71,20 @@ def _raise_exception(st):
6771
TEST_CLIENT_CERT = "client-cert"
6872
TEST_CLIENT_CERT_BASE64 = _base64(TEST_CLIENT_CERT)
6973

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

7189
class BaseTestCase(TestCase):
7290

@@ -333,6 +351,27 @@ class TestKubeConfigLoader(BaseTestCase):
333351
"user": "expired_gcp"
334352
}
335353
},
354+
{
355+
"name": "oidc",
356+
"context": {
357+
"cluster": "default",
358+
"user": "oidc"
359+
}
360+
},
361+
{
362+
"name": "expired_oidc",
363+
"context": {
364+
"cluster": "default",
365+
"user": "expired_oidc"
366+
}
367+
},
368+
{
369+
"name": "expired_oidc_no_idp_cert_data",
370+
"context": {
371+
"cluster": "default",
372+
"user": "expired_oidc_no_idp_cert_data"
373+
}
374+
},
336375
{
337376
"name": "user_pass",
338377
"context": {
@@ -450,6 +489,48 @@ class TestKubeConfigLoader(BaseTestCase):
450489
"password": TEST_PASSWORD, # should be ignored
451490
}
452491
},
492+
{
493+
"name": "oidc",
494+
"user": {
495+
"auth-provider": {
496+
"name": "oidc",
497+
"config": {
498+
"id-token": TEST_OIDC_LOGIN
499+
}
500+
}
501+
}
502+
},
503+
{
504+
"name": "expired_oidc",
505+
"user": {
506+
"auth-provider": {
507+
"name": "oidc",
508+
"config": {
509+
"client-id": "tectonic-kubectl",
510+
"client-secret": "FAKE_SECRET",
511+
"id-token": TEST_OIDC_EXPIRED_LOGIN,
512+
"idp-certificate-authority-data": TEST_OIDC_CA,
513+
"idp-issuer-url": "https://example.localhost/identity",
514+
"refresh-token": "lucWJjEhlxZW01cXI3YmVlcYnpxNGhzk"
515+
}
516+
}
517+
}
518+
},
519+
{
520+
"name": "expired_oidc_no_idp_cert_data",
521+
"user": {
522+
"auth-provider": {
523+
"name": "oidc",
524+
"config": {
525+
"client-id": "tectonic-kubectl",
526+
"client-secret": "FAKE_SECRET",
527+
"id-token": TEST_OIDC_EXPIRED_LOGIN,
528+
"idp-issuer-url": "https://example.localhost/identity",
529+
"refresh-token": "lucWJjEhlxZW01cXI3YmVlcYnpxNGhzk"
530+
}
531+
}
532+
}
533+
},
453534
{
454535
"name": "user_pass",
455536
"user": {
@@ -564,6 +645,65 @@ async def cred():
564645
self.assertEqual(BEARER_TOKEN_FORMAT % TEST_ANOTHER_DATA_BASE64,
565646
loader.token)
566647

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

kubernetes_asyncio/config/openid.py

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import aiohttp
2+
3+
from .config_exception import ConfigException
4+
5+
GRANT_TYPE_REFRESH_TOKEN = 'refresh_token'
6+
7+
8+
class OpenIDRequestor:
9+
10+
def __init__(self, client_id, client_secret, issuer_url, ssl_ca_cert=None):
11+
"""OpenIDRequestor implements a very limited subset of the oauth2 APIs that we
12+
require in order to refresh access tokens"""
13+
14+
self._client_id = client_id
15+
self._client_secret = client_secret
16+
self._issuer_url = issuer_url
17+
self._ssl_ca_cert = ssl_ca_cert
18+
self._well_known = None
19+
20+
def _get_connector(self):
21+
return aiohttp.TCPConnector(
22+
verify_ssl=self._ssl_ca_cert is not None,
23+
ssl_context=self._ssl_ca_cert
24+
)
25+
26+
def _client_session(self):
27+
return aiohttp.ClientSession(
28+
headers=self._default_headers,
29+
connector=self._get_connector(),
30+
auth=aiohttp.BasicAuth(self._client_id, self._client_secret),
31+
raise_for_status=True,
32+
)
33+
34+
async def refresh_token(self, refresh_token):
35+
"""
36+
:param refresh_token: an openid refresh-token from a previous token request
37+
"""
38+
async with self._client_session() as client:
39+
well_known = await self._get_well_known(client)
40+
41+
try:
42+
return await self._post(
43+
client,
44+
well_known['token_endpoint'],
45+
data={
46+
'grant_type': GRANT_TYPE_REFRESH_TOKEN,
47+
'refresh_token': refresh_token,
48+
}
49+
)
50+
except aiohttp.ClientResponseError as e:
51+
raise ConfigException('oidc: failed to refresh access token')
52+
53+
async def _get(self, client, *args, **kwargs):
54+
async with client.get(*args, **kwargs) as resp:
55+
return await resp.json()
56+
57+
async def _post(self, client, *args, **kwargs):
58+
async with client.post(*args, **kwargs) as resp:
59+
return await resp.json()
60+
61+
async def _get_well_known(self, client):
62+
if self._well_known is None:
63+
try:
64+
self._well_known = await self._get(
65+
client,
66+
'{}/.well-known/openid-configuration'.format(self._issuer_url.rstrip('/'))
67+
)
68+
except aiohttp.ClientResponseError:
69+
raise ConfigException('oidc: failed to query well-known metadata endpoint')
70+
71+
return self._well_known
72+
73+
@property
74+
def _default_headers(self):
75+
return {
76+
'Accept': 'application/json',
77+
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
78+
}

0 commit comments

Comments
 (0)