diff --git a/plugins/builder/osbuild.py b/plugins/builder/osbuild.py index fb2bf6e..840c9da 100644 --- a/plugins/builder/osbuild.py +++ b/plugins/builder/osbuild.py @@ -191,6 +191,76 @@ class ComposeLogs: return cls(image_logs, import_logs, init_logs) +class OAuth2(requests.auth.AuthBase): + """Auth provider for requests supporting OAuth2 client credentials + + This auth provider supports the obtaining a token via the "Client + Credentials Grant" (RFC 6749 section 4.4[1]). Required properties + are the client id, client secret and the token url. + + Automatic refreshing of the token is supported if the token was + acquired specified a `expires_in` field. + + Currently, this implementation does not support a actual "refresh + token". + + [1] https://datatracker.ietf.org/doc/html/rfc6749#section-4.4 + """ + + class Token: + def __init__(self, data): + self.data = data["access_token"] + self.type = data["token_type"] + self.expires_in = int(data["expires_in"]) + self.scope = data.get("scope") + + self.created = time.time() + + @property + def expired(self) -> bool: + if not self.expires_in: + return False + + now = time.time() + return now > self.created + self.expires_in + + def __init__(self, cid: str, secret: str, token_url: str) -> None: + self.id = cid + self.secret = secret + self.token_url = token_url + self.token = None + + @property + def token_expired(self) -> bool: + return not self.token or self.token.expired + + def fetch_token(self, http: requests.Session): + data = { + "grant_type": "client_credentials", + "client_id": self.id, + "client_secret": self.id + } + + res = http.post(self.token_url, data=data) + if res.status_code != 200: + body = res.content.decode("utf-8").strip() + msg = f"Failed to authenticate via SSO/OAuth: {body}" + raise koji.GenericError(msg) from None + + token_data = res.json() + self.token = self.Token(token_data) + + def __call__(self, r: requests.Request): + """Called by requests to obtain authorization""" + + # don't add the header if we fetch the token + if r.url == self.token_url: + return r + + r.headers["authorization"] = "Bearer " + self.token.data + return r + + class Client: def __init__(self, url): self.server = url @@ -209,8 +279,27 @@ class Client: return certs + def oauth_init(self, client_id: str, secret: str, token_url: str): + oauth = OAuth2(client_id, secret, token_url) + self.http.auth = oauth + + def oauth_check(self) -> bool: + auth = self.http.auth + if auth and auth.token_expired: + auth.fetch_token(self.http) + return True + + return False + def request(self, method: str, url: str, js: Optional[Dict] = None): - return self.http.request(method, url, json=js) + + self.oauth_check() + res = self.http.request(method, url, json=js) + + if res.status_code == 401 and self.oauth_check(): + res = self.http.request(method, url, json=js) + + return res def get(self, url: str) -> requests.Response: return self.request("GET", url) @@ -319,6 +408,13 @@ class OSBuildImage(BaseTaskHandler): self.client.http.verify = val self.logger.debug("ssl verify: %s", val) + if "composer:oauth" in cfg: + oa = cfg["composer:oauth"] + client_id, client_secret = oa["client_id"], oa["client_secret"] + token_url = oa["token_url"] + self.logger.debug("Using OAuth2 with token url: %s", token_url) + self.client.oauth_init(client_id, client_secret, token_url) + def upload_json(self, data: Dict, name: str): fd = io.StringIO() json.dump(data, fd, indent=4, sort_keys=True) diff --git a/test/unit/test_builder.py b/test/unit/test_builder.py index ee02d24..c00c707 100644 --- a/test/unit/test_builder.py +++ b/test/unit/test_builder.py @@ -7,6 +7,7 @@ import json import os import sys import tempfile +import time import urllib.parse import uuid import unittest.mock @@ -21,7 +22,7 @@ from plugintest import PluginTest API_BASE = "api/composer-koji/v1/" -class MockComposer: +class MockComposer: # pylint: disable=too-many-instance-attributes def __init__(self, url, *, architectures=None): self.url = urllib.parse.urljoin(url, API_BASE) self.architectures = architectures or ["x86_64"] @@ -30,6 +31,8 @@ class MockComposer: self.build_id = 1 self.status = "success" self.routes = {} + self.oauth = None + self.oauth_check_delay = 0 def httpretty_regsiter(self): httpretty.register_uri( @@ -44,6 +47,10 @@ class MockComposer: return build_id def compose_create(self, request, _uri, response_headers): + check = self.oauth_check(request, response_headers) + if check: + return check + content_type = request.headers.get("Content-Type") if content_type != "application/json": return [400, response_headers, "Bad Request"] @@ -95,7 +102,11 @@ class MockComposer: return [201, response_headers, json.dumps(compose)] - def compose_status(self, _request, uri, response_headers): + def compose_status(self, request, uri, response_headers): + check = self.oauth_check(request, response_headers) + if check: + return check + target = os.path.basename(uri) compose = self.composes.get(target) if not compose: @@ -112,7 +123,10 @@ class MockComposer: } return [200, response_headers, json.dumps(result)] - def compose_logs(self, _request, uri, response_headers): + def compose_logs(self, request, uri, response_headers): + check = self.oauth_check(request, response_headers) + if check: + return check route = self.routes.get("logs") if route and route["status"] != 200: return [route["status"], response_headers, "Internal error"] @@ -132,8 +146,11 @@ class MockComposer: } return [200, response_headers, json.dumps(result)] + def compose_manifests(self, request, uri, response_headers): + check = self.oauth_check(request, response_headers) + if check: + return check - def compose_manifests(self, _request, uri, response_headers): route = self.routes.get("manifests") if route and route["status"] != 200: return [route["status"], response_headers, "Internal error"] @@ -149,6 +166,73 @@ class MockComposer: ] return [200, response_headers, json.dumps(result)] + def oauth_acquire_token(self, req, _uri, response_headers): + + data = urllib.parse.parse_qs(req.body.decode("utf-8")) + + grant_type = data.get("grant_type", []) + if len(grant_type) != 1 or grant_type[0] != "client_credentials": + return [400, response_headers, "Invalid grant type"] + + client_id = data.get("client_id", []) + if len(client_id) != 1 or client_id[0] != "koji-osbuild": + return [400, response_headers, "Invalid credentials"] + + client_secret = data.get("client_secret", []) + if len(client_secret) != 1 or client_secret[0] != "koji-osbuild": + return [400, response_headers, "Invalid credentials"] + + token = { + "access_token": str(uuid.uuid4()), + "expires_in": 1, + "token_type": "Bearer", + "scope": "profile email", + } + + reply = json.dumps(token) + self.oauth = token + + token["created_at"] = time.time() + + return [200, response_headers, reply] + + def oauth_check(self, request, response_headers): + if self.oauth is None: + return None + oauth = self.oauth + + auth = request.headers.get("authorization") + if not auth or not auth.startswith("Bearer "): + return [401, response_headers, "Unauthorized"] + + token = auth[7:] + + if oauth.get("access_token") != token: + return [401, response_headers, "Unauthorized"] + + if self.oauth_check_delay: + time.sleep(self.oauth_check_delay) + # Reset it so that we can actually authorize at + # the subsequent request + self.oauth_check_delay = 0 + + now = time.time() + + if oauth["created_at"] + oauth["expires_in"] < now: + return [401, response_headers, "Token expired"] + + return None + + def oauth_activate(self, token_url: str): + httpretty.register_uri( + httpretty.POST, + token_url, + body=self.oauth_acquire_token + ) + + self.oauth = {} + print("OAuth active!") + class UploadTracker: """Mock koji file uploading and keep track of uploaded files @@ -654,3 +738,122 @@ class TestBuilderPlugin(PluginTest): with unittest.mock.patch.object(sys, 'argv', args): res = self.plugin.main() self.assertEqual(res, 0) + + @httpretty.activate + def test_oauth2_fail_auth(self): + composer_url = self.plugin.DEFAULT_COMPOSER_URL + koji_url = self.plugin.DEFAULT_KOJIHUB_URL + token_url = "https://localhost/token" + + cfg = configparser.ConfigParser() + cfg["composer"] = { + "server": composer_url, + } + cfg["koji"] = { + "server": koji_url + } + + handler = self.make_handler(config=cfg) + + url = self.plugin.DEFAULT_COMPOSER_URL + composer = MockComposer(url, architectures=["x86_64"]) + composer.httpretty_regsiter() + + # initialize oauth + composer.oauth_activate(token_url) + + args = ["name", "version", "distro", + ["image_type"], + "fedora-candidate", + ["x86_64"], + {}] + + with self.assertRaises(koji.GenericError): + handler.handler(*args) + + @httpretty.activate + def test_oauth2_basic(self): + composer_url = self.plugin.DEFAULT_COMPOSER_URL + koji_url = self.plugin.DEFAULT_KOJIHUB_URL + token_url = "https://localhost/token" + + cfg = configparser.ConfigParser() + cfg["composer"] = { + "server": composer_url, + } + cfg["composer:oauth"] = { + "client_id": "koji-osbuild", + "client_secret": "s3cr3t", + "token_url": token_url + } + cfg["koji"] = { + "server": koji_url + } + + handler = self.make_handler(config=cfg) + + self.assertEqual(handler.composer_url, composer_url) + self.assertEqual(handler.koji_url, koji_url) + + url = self.plugin.DEFAULT_COMPOSER_URL + composer = MockComposer(url, architectures=["x86_64"]) + composer.httpretty_regsiter() + + # initialize oauth + composer.oauth_activate(token_url) + + arches = ["x86_64"] + repos = ["http://1.repo", "https://2.repo"] + args = ["name", "version", "distro", + ["image_type"], + "fedora-candidate", + arches, + {"repo": repos}] + + res = handler.handler(*args) + assert res, "invalid compose result" + + @httpretty.activate + def test_oauth2_delay(self): + composer_url = self.plugin.DEFAULT_COMPOSER_URL + koji_url = self.plugin.DEFAULT_KOJIHUB_URL + token_url = "https://localhost/token" + + cfg = configparser.ConfigParser() + cfg["composer"] = { + "server": composer_url, + } + cfg["composer:oauth"] = { + "client_id": "koji-osbuild", + "client_secret": "s3cr3t", + "token_url": token_url + } + cfg["koji"] = { + "server": koji_url + } + + handler = self.make_handler(config=cfg) + + self.assertEqual(handler.composer_url, composer_url) + self.assertEqual(handler.koji_url, koji_url) + + url = self.plugin.DEFAULT_COMPOSER_URL + composer = MockComposer(url, architectures=["x86_64"]) + composer.httpretty_regsiter() + + # initialize oauth + composer.oauth_activate(token_url) + + # have the token expire during the check + composer.oauth_check_delay = 1.1 + + arches = ["x86_64"] + repos = ["http://1.repo", "https://2.repo"] + args = ["name", "version", "distro", + ["image_type"], + "fedora-candidate", + arches, + {"repo": repos}] + + res = handler.handler(*args) + assert res, "invalid compose result"