From de0fccd427cfba2e4ac8fec72b865632c1de6cf2 Mon Sep 17 00:00:00 2001 From: Pavlo Shchelokovskyy Date: Fri, 6 Dec 2019 18:48:04 +0200 Subject: [PATCH] Add support for OpenIDConnect token refresh Creatively adapted from kubernetes-client/python-base. Adds new setup extra 'oidc' to install required OAuth-related libraries. Tested with local Keycloak installation. --- poetry.lock | 45 +++++++++++++++++- pykube/config.py | 6 +++ pykube/http.py | 114 ++++++++++++++++++++++++++++++++++++++++----- pyproject.toml | 2 + tests/test_http.py | 22 +++++---- 5 files changed, 168 insertions(+), 21 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5692434..90bfcfa 100644 --- a/poetry.lock +++ b/poetry.lock @@ -352,6 +352,19 @@ optional = false python-versions = "*" version = "1.3.5" +[[package]] +category = "main" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +name = "oauthlib" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "3.1.0" + +[package.extras] +rsa = ["cryptography"] +signals = ["blinker"] +signedtoken = ["cryptography", "pyjwt (>=1.0.0)"] + [[package]] category = "dev" description = "Core utilities for Python packages" @@ -564,6 +577,21 @@ urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] +[[package]] +category = "main" +description = "OAuthlib authentication support for Requests." +name = "requests-oauthlib" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.3.0" + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib (>=3.0.0)"] + [[package]] category = "dev" description = "A utility library for mocking out the `requests` Python library." @@ -815,9 +843,10 @@ testing = ["jaraco.itertools", "func-timeout"] [extras] gcp = ["google-auth", "jsonpath-ng"] +oidc = ["requests-oauthlib"] [metadata] -content-hash = "07b976c13120a94c007bc43d4db838aa2421d077315b41c3933f832ebef38f22" +content-hash = "f8cff38600e2717366fcc3c5d1a17593379c42ded2f26a28d43f4073ea12e7a4" python-versions = ">=3.6" [metadata.files] @@ -998,6 +1027,11 @@ markupsafe = [ {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, ] mccabe = [ @@ -1031,6 +1065,10 @@ mypy-extensions = [ nodeenv = [ {file = "nodeenv-1.3.5-py2.py3-none-any.whl", hash = "sha256:5b2438f2e42af54ca968dd1b374d14a1194848955187b0e5e4be1f73813a5212"}, ] +oauthlib = [ + {file = "oauthlib-3.1.0-py2.py3-none-any.whl", hash = "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"}, + {file = "oauthlib-3.1.0.tar.gz", hash = "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889"}, +] packaging = [ {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, {file = "packaging-20.3.tar.gz", hash = "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3"}, @@ -1153,6 +1191,11 @@ requests = [ {file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"}, {file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"}, ] +requests-oauthlib = [ + {file = "requests-oauthlib-1.3.0.tar.gz", hash = "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a"}, + {file = "requests_oauthlib-1.3.0-py2.py3-none-any.whl", hash = "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d"}, + {file = "requests_oauthlib-1.3.0-py3.7.egg", hash = "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc"}, +] responses = [ {file = "responses-0.10.14-py2.py3-none-any.whl", hash = "sha256:3d596d0be06151330cb230a2d630717ab20f7a81f205019481e206eb5db79915"}, {file = "responses-0.10.14.tar.gz", hash = "sha256:1a78bc010b20a5022a2c0cb76b8ee6dc1e34d887972615ebd725ab9a166a4960"}, diff --git a/pykube/config.py b/pykube/config.py index 725d2fb..10b9660 100644 --- a/pykube/config.py +++ b/pykube/config.py @@ -173,6 +173,12 @@ def users(self): us[ur["name"]] = u = copy.deepcopy(ur["user"]) BytesOrFile.maybe_set(u, "client-certificate", self.kubeconfig_file) BytesOrFile.maybe_set(u, "client-key", self.kubeconfig_file) + if "auth-provider" in u: + BytesOrFile.maybe_set( + u["auth-provider"]["config"], + "idp-certificate-authority", + self.kubeconfig_file, + ) self._users = us return self._users diff --git a/pykube/http.py b/pykube/http.py index 4cce6ba..a46f70f 100644 --- a/pykube/http.py +++ b/pykube/http.py @@ -1,8 +1,10 @@ """ HTTP request related code. """ +import base64 import datetime import json +import logging import os import shlex import subprocess @@ -15,19 +17,28 @@ google_auth_installed = True except ImportError: google_auth_installed = False +try: + from requests_oauthlib import OAuth2Session + + oidc_auth_installed = True +except ImportError: + oidc_auth_installed = False import requests.adapters from http import HTTPStatus from urllib.parse import urlparse -from .exceptions import HTTPError +from .exceptions import HTTPError, PyKubeError from .utils import jsonpath_installed, jsonpath_parse, join_url_path from .config import KubeConfig from . import __version__ DEFAULT_HTTP_TIMEOUT = 10 # seconds +EXPIRY_SKEW_PREVENTION_DELAY = datetime.timedelta(minutes=5) +UTC = datetime.timezone.utc +LOG = logging.getLogger(__name__) class KubernetesHTTPAdapter(requests.adapters.HTTPAdapter): @@ -41,12 +52,11 @@ def __init__(self, kube_config: KubeConfig, **kwargs): super().__init__(**kwargs) - def _persist_credentials(self, config, token, expiry): + def _persist_credentials(self, config, opts): user_name = config.contexts[config.current_context]["user"] user = [u["user"] for u in config.doc["users"] if u["name"] == user_name][0] auth_config = user["auth-provider"].setdefault("config", {}) - auth_config["access-token"] = token - auth_config["expiry"] = expiry + auth_config.update(opts) config.persist_doc() config.reload() @@ -70,17 +80,91 @@ def _auth_gcp(self, request, token, expiry, config): ) if should_persist and config: - self._persist_credentials(config, credentials.token, credentials.expiry) + auth_opts = { + "access-token": credentials.token, + "expiry": credentials.expiry, + } + self._persist_credentials(config, auth_opts) def retry(send_kwargs): credentials.refresh(auth_request) response = self.send(original_request, **send_kwargs) if response.ok and config: - self._persist_credentials(config, credentials.token, credentials.expiry) + auth_opts = { + "access-token": credentials.token, + "expiry": credentials.expiry, + } + self._persist_credentials(config, auth_opts) return response return retry + def _is_valid_jwt(self, token): + """Validate JWT token for correctness and near expiration""" + if not token: + return False + reserved_characters = frozenset(["=", "+", "/"]) + if any(char in token for char in reserved_characters): + # Invalid jwt, as it contains url-unsafe chars + return False + parts = token.split(".") + if len(parts) != 3: # Not a valid JWT + return False + padding = (4 - len(parts[1]) % 4) * "=" + if len(padding) == 3: + # According to spec, 3 padding characters cannot occur + # in a valid jwt + # https://tools.ietf.org/html/rfc7515#appendix-C + return False + jwt_attributes = json.loads( + base64.b64decode(parts[1] + padding).decode("utf-8") + ) + expire = jwt_attributes.get("exp") + # allow missing exp, but deny tokens that are about to expire soon + return expire is None or ( + datetime.datetime.fromtimestamp(expire, tz=UTC) + - EXPIRY_SKEW_PREVENTION_DELAY + ) > datetime.datetime.utcnow().replace(tzinfo=UTC) + + def _refresh_oidc_token(self, config): + if not oidc_auth_installed: + raise ImportError( + "missing dependencies for OIDC token refresh support " + "(try pip install pykube-ng[oidc]" + ) + auth_config = config.user["auth-provider"]["config"] + if "idp-certificate-authority" in auth_config: + verify = auth_config["idp-certificate-authority"].filename() + else: + verify = None + oauth = OAuth2Session() + discovery = oauth.get( + f"{auth_config['idp-issuer-url']}/.well-known/openid-configuration", + verify=verify, + timeout=DEFAULT_HTTP_TIMEOUT, + withhold_token=True, + ) + + if discovery.status_code != HTTPStatus.OK: + raise PyKubeError( + f"Failed to discover OpenID token endpoint - " + f"HTTP {discovery.status_code}: {discovery.text}" + ) + discovery = discovery.json() + refresh = oauth.refresh_token( + token_url=discovery["token_endpoint"], + refresh_token=auth_config["refresh-token"], + client_id=auth_config["client-id"], + client_secret=auth_config.get("client-secret"), + verify=verify, + timeout=DEFAULT_HTTP_TIMEOUT, + ) + auth_opts = { + "id-token": refresh["id_token"], + "refresh-token": refresh["refresh_token"], + } + self._persist_credentials(config, auth_opts) + def send(self, request, **kwargs): if "kube_config" in kwargs: config = kwargs.pop("kube_config") @@ -179,11 +263,19 @@ def _setup_request_auth(self, config, request, kwargs): return retry_func elif auth_provider.get("name") == "oidc": auth_config = auth_provider.get("config", {}) - # @@@ support token refresh - if "id-token" in auth_config: - request.headers["Authorization"] = "Bearer {}".format( - auth_config["id-token"] - ) + if not self._is_valid_jwt(auth_config.get("id-token")): + try: + self._refresh_oidc_token(config) + # ignoring all exceptions, rely on retries + except Exception as oidc_exc: + LOG.warning(f"Failed to refresh OpenID token: {oidc_exc}") + + # not using auth_config handle here as the config might have + # been reloaded during token refresh + request.headers["Authorization"] = "Bearer {}".format( + config.user["auth-provider"]["config"]["id-token"] + ) + return None def _setup_request_certificates(self, config, request, kwargs): diff --git a/pyproject.toml b/pyproject.toml index 26d7eea..5f84fbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,10 +26,12 @@ google-auth = {optional = true, version = "*"} jsonpath-ng = {optional = true, version = "*"} pyyaml = "*" requests = ">=2.12" +requests-oauthlib = {version = "^1.3.0", optional = true} [tool.poetry.extras] gcp = ["google-auth", "jsonpath-ng"] +oidc = ["requests-oauthlib"] [tool.poetry.dev-dependencies] black = "^19.10b0" diff --git a/tests/test_http.py b/tests/test_http.py index 5acb23f..517a86d 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -2,7 +2,7 @@ pykube.http unittests """ import os -from unittest.mock import MagicMock +from unittest import mock import pytest @@ -22,7 +22,7 @@ def test_http(monkeypatch): cfg = KubeConfig.from_file(GOOD_CONFIG_FILE_PATH) api = HTTPClient(cfg) - mock_send = MagicMock() + mock_send = mock.MagicMock() mock_send.side_effect = Exception("MOCK HTTP") monkeypatch.setattr("pykube.http.KubernetesHTTPAdapter._do_send", mock_send) @@ -43,7 +43,7 @@ def test_http_with_dry_run(monkeypatch): cfg = KubeConfig.from_file(GOOD_CONFIG_FILE_PATH) api = HTTPClient(cfg, dry_run=True) - mock_send = MagicMock() + mock_send = mock.MagicMock() mock_send.side_effect = Exception("MOCK HTTP") monkeypatch.setattr("pykube.http.KubernetesHTTPAdapter._do_send", mock_send) @@ -59,7 +59,7 @@ def test_http_insecure_skip_tls_verify(monkeypatch): cfg = KubeConfig.from_file(CONFIG_WITH_INSECURE_SKIP_TLS_VERIFY) api = HTTPClient(cfg) - mock_send = MagicMock() + mock_send = mock.MagicMock() mock_send.side_effect = Exception("MOCK HTTP") monkeypatch.setattr("pykube.http.KubernetesHTTPAdapter._do_send", mock_send) @@ -75,7 +75,7 @@ def test_http_do_not_overwrite_auth(monkeypatch): cfg = KubeConfig.from_file(GOOD_CONFIG_FILE_PATH) api = HTTPClient(cfg) - mock_send = MagicMock() + mock_send = mock.MagicMock() mock_send.side_effect = Exception("MOCK HTTP") monkeypatch.setattr("pykube.http.KubernetesHTTPAdapter._do_send", mock_send) @@ -86,16 +86,20 @@ def test_http_do_not_overwrite_auth(monkeypatch): assert mock_send.call_args[0][0].headers["Authorization"] == "Bearer testtoken" -def test_http_with_oidc_auth(monkeypatch): +def test_http_with_oidc_auth_no_refresh(monkeypatch): cfg = KubeConfig.from_file(CONFIG_WITH_OIDC_AUTH) api = HTTPClient(cfg) - mock_send = MagicMock() + mock_send = mock.MagicMock() mock_send.side_effect = Exception("MOCK HTTP") monkeypatch.setattr("pykube.http.KubernetesHTTPAdapter._do_send", mock_send) - with pytest.raises(Exception): - api.get(url="test") + with mock.patch( + "pykube.http.KubernetesHTTPAdapter._is_valid_jwt", return_value=True + ) as mock_jwt: + with pytest.raises(Exception): + api.get(url="test") + mock_jwt.assert_called_once_with("some-id-token") mock_send.assert_called_once() assert mock_send.call_args[0][0].headers["Authorization"] == "Bearer some-id-token"