builder: support for sso via oauth2

Implement support for authentication via OAuth2 using the client
credentials "Client Credentials Grant" flow (4.4 of RFC 6749).
For this a new configuration section is added to the config file,
where the client_id, client_secret and token_url have to be
specified.
The impelmention does currently not support "refresh tokens", but
does support refreshing the token if an `expires_in` is present
in the token itself.
Corresponding unit tests have been added.

[1] https://datatracker.ietf.org/doc/html/rfc6749#section-4.4
This commit is contained in:
Christian Kellner 2022-01-31 10:56:12 +00:00
parent ca05cc9f00
commit 940e122ae9
2 changed files with 304 additions and 5 deletions

View file

@ -191,6 +191,76 @@ class ComposeLogs:
return cls(image_logs, import_logs, init_logs) 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: class Client:
def __init__(self, url): def __init__(self, url):
self.server = url self.server = url
@ -209,8 +279,27 @@ class Client:
return certs 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): 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: def get(self, url: str) -> requests.Response:
return self.request("GET", url) return self.request("GET", url)
@ -319,6 +408,13 @@ class OSBuildImage(BaseTaskHandler):
self.client.http.verify = val self.client.http.verify = val
self.logger.debug("ssl verify: %s", 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): def upload_json(self, data: Dict, name: str):
fd = io.StringIO() fd = io.StringIO()
json.dump(data, fd, indent=4, sort_keys=True) json.dump(data, fd, indent=4, sort_keys=True)

View file

@ -7,6 +7,7 @@ import json
import os import os
import sys import sys
import tempfile import tempfile
import time
import urllib.parse import urllib.parse
import uuid import uuid
import unittest.mock import unittest.mock
@ -21,7 +22,7 @@ from plugintest import PluginTest
API_BASE = "api/composer-koji/v1/" API_BASE = "api/composer-koji/v1/"
class MockComposer: class MockComposer: # pylint: disable=too-many-instance-attributes
def __init__(self, url, *, architectures=None): def __init__(self, url, *, architectures=None):
self.url = urllib.parse.urljoin(url, API_BASE) self.url = urllib.parse.urljoin(url, API_BASE)
self.architectures = architectures or ["x86_64"] self.architectures = architectures or ["x86_64"]
@ -30,6 +31,8 @@ class MockComposer:
self.build_id = 1 self.build_id = 1
self.status = "success" self.status = "success"
self.routes = {} self.routes = {}
self.oauth = None
self.oauth_check_delay = 0
def httpretty_regsiter(self): def httpretty_regsiter(self):
httpretty.register_uri( httpretty.register_uri(
@ -44,6 +47,10 @@ class MockComposer:
return build_id return build_id
def compose_create(self, request, _uri, response_headers): 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") content_type = request.headers.get("Content-Type")
if content_type != "application/json": if content_type != "application/json":
return [400, response_headers, "Bad Request"] return [400, response_headers, "Bad Request"]
@ -95,7 +102,11 @@ class MockComposer:
return [201, response_headers, json.dumps(compose)] 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) target = os.path.basename(uri)
compose = self.composes.get(target) compose = self.composes.get(target)
if not compose: if not compose:
@ -112,7 +123,10 @@ class MockComposer:
} }
return [200, response_headers, json.dumps(result)] 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") route = self.routes.get("logs")
if route and route["status"] != 200: if route and route["status"] != 200:
return [route["status"], response_headers, "Internal error"] return [route["status"], response_headers, "Internal error"]
@ -132,8 +146,11 @@ class MockComposer:
} }
return [200, response_headers, json.dumps(result)] 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") route = self.routes.get("manifests")
if route and route["status"] != 200: if route and route["status"] != 200:
return [route["status"], response_headers, "Internal error"] return [route["status"], response_headers, "Internal error"]
@ -149,6 +166,73 @@ class MockComposer:
] ]
return [200, response_headers, json.dumps(result)] 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: class UploadTracker:
"""Mock koji file uploading and keep track of uploaded files """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): with unittest.mock.patch.object(sys, 'argv', args):
res = self.plugin.main() res = self.plugin.main()
self.assertEqual(res, 0) 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"