Skip to content

Commit 2010e2d

Browse files
authored
Merge pull request kubernetes-client#48 from ltamaster/add-oidc-auth-support
Add oidc auth
2 parents 11da619 + 5731554 commit 2010e2d

File tree

2 files changed

+189
-1
lines changed

2 files changed

+189
-1
lines changed

config/kube_config.py

+102-1
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,17 @@
1515
import atexit
1616
import base64
1717
import datetime
18+
import json
1819
import os
1920
import tempfile
2021

2122
import google.auth
2223
import google.auth.transport.requests
24+
import oauthlib.oauth2
2325
import urllib3
2426
import yaml
27+
from requests_oauthlib import OAuth2Session
28+
from six import PY3
2529

2630
from kubernetes.client import ApiClient, Configuration
2731

@@ -169,14 +173,17 @@ def _load_authentication(self):
169173
1. GCP auth-provider
170174
2. token_data
171175
3. token field (point to a token file)
172-
4. username/password
176+
4. oidc auth-provider
177+
5. username/password
173178
"""
174179
if not self._user:
175180
return
176181
if self._load_gcp_token():
177182
return
178183
if self._load_user_token():
179184
return
185+
if self._load_oid_token():
186+
return
180187
self._load_user_pass_token()
181188

182189
def _load_gcp_token(self):
@@ -208,6 +215,100 @@ def _refresh_gcp_token(self):
208215
if self._config_persister:
209216
self._config_persister(self._config.value)
210217

218+
def _load_oid_token(self):
219+
if 'auth-provider' not in self._user:
220+
return
221+
provider = self._user['auth-provider']
222+
223+
if 'name' not in provider or 'config' not in provider:
224+
return
225+
226+
if provider['name'] != 'oidc':
227+
return
228+
229+
parts = provider['config']['id-token'].split('.')
230+
231+
if len(parts) != 3: # Not a valid JWT
232+
return None
233+
234+
if PY3:
235+
jwt_attributes = json.loads(
236+
base64.b64decode(parts[1]).decode('utf-8')
237+
)
238+
else:
239+
jwt_attributes = json.loads(
240+
base64.b64decode(parts[1] + "==")
241+
)
242+
243+
expire = jwt_attributes.get('exp')
244+
245+
if ((expire is not None) and
246+
(_is_expired(datetime.datetime.fromtimestamp(expire,
247+
tz=UTC)))):
248+
self._refresh_oidc(provider)
249+
250+
if self._config_persister:
251+
self._config_persister(self._config.value)
252+
253+
self.token = "Bearer %s" % provider['config']['id-token']
254+
255+
return self.token
256+
257+
def _refresh_oidc(self, provider):
258+
ca_cert = tempfile.NamedTemporaryFile(delete=True)
259+
260+
if PY3:
261+
cert = base64.b64decode(
262+
provider['config']['idp-certificate-authority-data']
263+
).decode('utf-8')
264+
else:
265+
cert = base64.b64decode(
266+
provider['config']['idp-certificate-authority-data'] + "=="
267+
)
268+
269+
with open(ca_cert.name, 'w') as fh:
270+
fh.write(cert)
271+
272+
config = Configuration()
273+
config.ssl_ca_cert = ca_cert.name
274+
275+
client = ApiClient(configuration=config)
276+
277+
response = client.request(
278+
method="GET",
279+
url="%s/.well-known/openid-configuration"
280+
% provider['config']['idp-issuer-url']
281+
)
282+
283+
if response.status != 200:
284+
return
285+
286+
response = json.loads(response.data)
287+
288+
request = OAuth2Session(
289+
client_id=provider['config']['client-id'],
290+
token=provider['config']['refresh-token'],
291+
auto_refresh_kwargs={
292+
'client_id': provider['config']['client-id'],
293+
'client_secret': provider['config']['client-secret']
294+
},
295+
auto_refresh_url=response['token_endpoint']
296+
)
297+
298+
try:
299+
refresh = request.refresh_token(
300+
token_url=response['token_endpoint'],
301+
refresh_token=provider['config']['refresh-token'],
302+
auth=(provider['config']['client-id'],
303+
provider['config']['client-secret']),
304+
verify=ca_cert.name
305+
)
306+
except oauthlib.oauth2.rfc6749.errors.InvalidClientIdError:
307+
return
308+
309+
provider['config'].value['id-token'] = refresh['id_token']
310+
provider['config'].value['refresh-token'] = refresh['refresh_token']
311+
211312
def _load_user_token(self):
212313
token = FileOrData(
213314
self._user, 'tokenFile', 'token',

config/kube_config_test.py

+87
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@
1414

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

23+
import mock
2224
import yaml
2325
from six import PY3
2426

@@ -67,6 +69,17 @@ def _raise_exception(st):
6769
TEST_CLIENT_CERT_BASE64 = _base64(TEST_CLIENT_CERT)
6870

6971

72+
TEST_OIDC_TOKEN = "test-oidc-token"
73+
TEST_OIDC_INFO = "{\"name\": \"test\"}"
74+
TEST_OIDC_BASE = _base64(TEST_OIDC_TOKEN) + "." + _base64(TEST_OIDC_INFO)
75+
TEST_OIDC_LOGIN = TEST_OIDC_BASE + "." + TEST_CLIENT_CERT_BASE64
76+
TEST_OIDC_TOKEN = "Bearer %s" % TEST_OIDC_LOGIN
77+
TEST_OIDC_EXP = "{\"name\": \"test\",\"exp\": 536457600}"
78+
TEST_OIDC_EXP_BASE = _base64(TEST_OIDC_TOKEN) + "." + _base64(TEST_OIDC_EXP)
79+
TEST_OIDC_EXPIRED_LOGIN = TEST_OIDC_EXP_BASE + "." + TEST_CLIENT_CERT_BASE64
80+
TEST_OIDC_CA = _base64(TEST_CERTIFICATE_AUTH)
81+
82+
7083
class BaseTestCase(unittest.TestCase):
7184

7285
def setUp(self):
@@ -326,6 +339,20 @@ class TestKubeConfigLoader(BaseTestCase):
326339
"user": "expired_gcp"
327340
}
328341
},
342+
{
343+
"name": "oidc",
344+
"context": {
345+
"cluster": "default",
346+
"user": "oidc"
347+
}
348+
},
349+
{
350+
"name": "expired_oidc",
351+
"context": {
352+
"cluster": "default",
353+
"user": "expired_oidc"
354+
}
355+
},
329356
{
330357
"name": "user_pass",
331358
"context": {
@@ -443,6 +470,33 @@ class TestKubeConfigLoader(BaseTestCase):
443470
"password": TEST_PASSWORD, # should be ignored
444471
}
445472
},
473+
{
474+
"name": "oidc",
475+
"user": {
476+
"auth-provider": {
477+
"name": "oidc",
478+
"config": {
479+
"id-token": TEST_OIDC_LOGIN
480+
}
481+
}
482+
}
483+
},
484+
{
485+
"name": "expired_oidc",
486+
"user": {
487+
"auth-provider": {
488+
"name": "oidc",
489+
"config": {
490+
"client-id": "tectonic-kubectl",
491+
"client-secret": "FAKE_SECRET",
492+
"id-token": TEST_OIDC_EXPIRED_LOGIN,
493+
"idp-certificate-authority-data": TEST_OIDC_CA,
494+
"idp-issuer-url": "https://example.org/identity",
495+
"refresh-token": "lucWJjEhlxZW01cXI3YmVlcYnpxNGhzk"
496+
}
497+
}
498+
}
499+
},
446500
{
447501
"name": "user_pass",
448502
"user": {
@@ -537,6 +591,39 @@ def cred(): return None
537591
self.assertEqual(BEARER_TOKEN_FORMAT % TEST_ANOTHER_DATA_BASE64,
538592
loader.token)
539593

594+
def test_oidc_no_refresh(self):
595+
loader = KubeConfigLoader(
596+
config_dict=self.TEST_KUBE_CONFIG,
597+
active_context="oidc",
598+
)
599+
self.assertTrue(loader._load_oid_token())
600+
self.assertEqual(TEST_OIDC_TOKEN, loader.token)
601+
602+
@mock.patch('kubernetes.config.kube_config.OAuth2Session.refresh_token')
603+
@mock.patch('kubernetes.config.kube_config.ApiClient.request')
604+
def test_oidc_with_refresh(self, mock_ApiClient, mock_OAuth2Session):
605+
mock_response = mock.MagicMock()
606+
type(mock_response).status = mock.PropertyMock(
607+
return_value=200
608+
)
609+
type(mock_response).data = mock.PropertyMock(
610+
return_value=json.dumps({
611+
"token_endpoint": "https://example.org/identity/token"
612+
})
613+
)
614+
615+
mock_ApiClient.return_value = mock_response
616+
617+
mock_OAuth2Session.return_value = {"id_token": "abc123",
618+
"refresh_token": "newtoken123"}
619+
620+
loader = KubeConfigLoader(
621+
config_dict=self.TEST_KUBE_CONFIG,
622+
active_context="expired_oidc",
623+
)
624+
self.assertTrue(loader._load_oid_token())
625+
self.assertEqual("Bearer abc123", loader.token)
626+
540627
def test_user_pass(self):
541628
expected = FakeConfig(host=TEST_HOST, token=TEST_BASIC_TOKEN)
542629
actual = FakeConfig()

0 commit comments

Comments
 (0)