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:
parent
ca05cc9f00
commit
940e122ae9
2 changed files with 304 additions and 5 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue