From 2e19f121f3a368280d4d551ef84962629b07ce7c Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 1 Apr 2025 00:03:46 +0200 Subject: [PATCH 1/5] Adds oidc login Ref https://github.com/inventree/InvenTree/pull/9333 --- inventree/api.py | 27 ++++++++-- inventree/oAuthClient.py | 103 +++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + setup.py | 3 +- 4 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 inventree/oAuthClient.py diff --git a/inventree/api.py b/inventree/api.py index d749c922..fb467b74 100644 --- a/inventree/api.py +++ b/inventree/api.py @@ -13,6 +13,7 @@ import requests from requests.auth import HTTPBasicAuth from requests.exceptions import Timeout +from . import oAuthClient as oauth logger = logging.getLogger('inventree') @@ -45,6 +46,9 @@ def __init__(self, host=None, **kwargs): token - Authentication token (if provided, username/password are ignored) token-name - Name of the token to use (default = 'inventree-python-client') use_token_auth - Use token authentication? (default = True) + use_oidc_auth - Use OIDC authentication? (default = False) + oidc_client_id - OIDC client ID (defaults to InvenTree public client) + oidc_scopes - OIDC scopes (default = ['openid', 'g:read']) verbose - Print extra debug messages (default = False) strict - Enforce strict HTTPS certificate checking (default = True) timeout - Set timeout to use (in seconds). Default: 10 @@ -56,6 +60,9 @@ def __init__(self, host=None, **kwargs): INVENTREE_API_PASSWORD - Password INVENTREE_API_TOKEN - User access token INVENTREE_API_TIMEOUT - Timeout value, in seconds + INVENTREE_API_OIDC - Use OIDC + INVENTREE_API_OIDC_CLIENT_ID - OIDC client ID + INVENTREE_API_OIDC_SCOPES - OIDC scopes """ self.setHostName(host or os.environ.get('INVENTREE_API_HOST', None)) @@ -68,8 +75,11 @@ def __init__(self, host=None, **kwargs): self.timeout = kwargs.get('timeout', os.environ.get('INVENTREE_API_TIMEOUT', 10)) self.proxies = kwargs.get('proxies', dict()) self.strict = bool(kwargs.get('strict', True)) + self.oidc_client_id = kwargs.get('oidc_client_id', os.environ.get('INVENTREE_API_OIDC_CLIENT_ID', 'zDFnsiRheJIOKNx6aCQ0quBxECg1QBHtVFDPloJ6')) + self.oidc_scopes = kwargs.get('oidc_scopes', os.environ.get('INVENTREE_API_OIDC_SCOPES', ['openid', 'g:read'])) self.use_token_auth = kwargs.get('use_token_auth', True) + self.use_oidc_auth = kwargs.get('use_oidc_auth', os.environ.get('INVENTREE_API_OIDC', None)) self.verbose = kwargs.get('verbose', False) self.auth = None @@ -132,9 +142,10 @@ def connect(self): if not self.testAuth(): raise ConnectionError("Authentication at InvenTree server failed") - if self.use_token_auth: - if not self.token: - self.requestToken() + if self.use_token_auth and not self.token: + self.requestToken() + elif self.use_oidc_auth and not self.token: + self.requestOidcToken() def constructApiUrl(self, endpoint_url): """Construct an API endpoint URL based on the provided API URL. @@ -273,6 +284,14 @@ def requestToken(self): return self.token + def requestOidcToken(self): + """Return authentication token from the server using OIDC.""" + client = oauth.OAuthClient(self.base_url, self.oidc_client_id, self.oidc_scopes) + self.token = client._access_token + + return self.token + + def request(self, api_url, **kwargs): """ Perform a URL request to the Inventree API """ @@ -316,7 +335,7 @@ def request(self, api_url, **kwargs): 'timeout': kwargs.get('timeout', self.timeout), } - if self.use_token_auth and self.token: + if (self.use_token_auth or self.use_oidc_auth) and self.token: headers['AUTHORIZATION'] = f'Token {self.token}' auth = None else: diff --git a/inventree/oAuthClient.py b/inventree/oAuthClient.py new file mode 100644 index 00000000..ee8dba14 --- /dev/null +++ b/inventree/oAuthClient.py @@ -0,0 +1,103 @@ +import os +from http.server import BaseHTTPRequestHandler, HTTPServer +from requests_oauthlib import OAuth2Session +import webbrowser +import urllib.parse as urlparse + +# Environment setup +os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" +USABLE_PORT_RANGE = (29170, 292180) + + +class OAuthClient: + def __init__(self, server_url: str = "http://localhost:8000", client_id: str ='', scopes: list[str] = None) -> None: + self.server_url = server_url + self.client_id = client_id + self.scopes = scopes if scopes is not None else [] + + self._handler_wrapper = RequestHandlerWrapper(self) + self._setup_callback() + self._poll_user() + + def get_url(self, path: str) -> str: + """Get the authorization URL.""" + return urlparse.urljoin(self.server_url, path) + + def _setup_callback(self): + for port in range(*USABLE_PORT_RANGE): + try: + self.server = HTTPServer(("127.0.0.1", port), self._handler_wrapper.request_handler) + self._port = port + break + except OSError: + continue + else: + raise Exception("No port found.") + + def _poll_user(self): + self._session = OAuth2Session( + self.client_id, scope=self.scopes, redirect_uri=f"http://localhost:{self._port}", pkce="S256" + ) + auth_url, state = self._session.authorization_url(self.get_url('/o/authorize/'), access_type="offline") + self._state = state + webbrowser.open_new_tab(auth_url) + + while not self._handler_wrapper.done: + self.server.handle_request() + if self._handler_wrapper.error: + raise Exception(self._handler_wrapper.error) + + def callback(self, callback_url: str): + self._session.fetch_token(self.get_url("/o/token/"), authorization_response=callback_url, include_client_id=True) + self._access_token = self._session.access_token + + +class RequestHandlerWrapper: + """Provides callback for OIDC endpint.""" + def __init__(self, oauth_client) -> None: + self.done = False + self.error = None + self.client: OAuthClient = oauth_client + + @property + def request_handler(self): + wrapper = self + + class RequestHandler(BaseHTTPRequestHandler): + def do_GET(self): + parsed_url = urlparse.urlparse(self.path) + if parsed_url.path == "/": + error = urlparse.parse_qs(parsed_url.query).get("error", [None])[0] + if error: + wrapper.error = error + self.send(200) + else: + try: + wrapper.client.callback(self.path) + except OAuthError as e: + wrapper.error = e.message + self.send(400) + else: + self.send(200, 'Success! You can close this window.') + wrapper.done = True + else: + self.send(404) + + def send(self, status_code, content=None): + self.send_response(status_code) + if content: + self.wfile.write(content.encode("utf-8")) + else: + self.wfile.write(b"") + self.send_header("Content-type", "text/html") + self.end_headers() + + def log_message(self, *args): + pass # Suppress logging + + return RequestHandler + +class OAuthError(Exception): + """Exception raised during the OAuth process.""" + def __init__(self, message: str) -> None: + self.message = message diff --git a/requirements.txt b/requirements.txt index bfc2b0bb..7bb3d083 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ invoke>=1.4.0 coverage>=6.4.1 # Run tests, measure coverage coveralls>=3.3.1 Pillow>=9.1.1 +requests-oauthlib # Modern auth experience \ No newline at end of file diff --git a/setup.py b/setup.py index 69442449..a558fc8b 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,8 @@ ), install_requires=[ - "requests>=2.27.0" + "requests>=2.27.0", + "requests-oauthlib" ], setup_requires=[ From f3a7f70f1ea5e7e660ea72484e21e68b170eabbb Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 1 Apr 2025 00:14:33 +0200 Subject: [PATCH 2/5] fix type for 3.9 --- inventree/oAuthClient.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inventree/oAuthClient.py b/inventree/oAuthClient.py index ee8dba14..0cf2ff7e 100644 --- a/inventree/oAuthClient.py +++ b/inventree/oAuthClient.py @@ -10,7 +10,7 @@ class OAuthClient: - def __init__(self, server_url: str = "http://localhost:8000", client_id: str ='', scopes: list[str] = None) -> None: + def __init__(self, server_url: str = "http://localhost:8000", client_id: str ='', scopes: list = None) -> None: self.server_url = server_url self.client_id = client_id self.scopes = scopes if scopes is not None else [] From 20deff4eb18dcf885d2a57e0ff92c8dd09b0bc48 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 1 Apr 2025 00:16:48 +0200 Subject: [PATCH 3/5] style fixes --- inventree/api.py | 2 +- inventree/oAuthClient.py | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/inventree/api.py b/inventree/api.py index fb467b74..86155262 100644 --- a/inventree/api.py +++ b/inventree/api.py @@ -13,6 +13,7 @@ import requests from requests.auth import HTTPBasicAuth from requests.exceptions import Timeout + from . import oAuthClient as oauth logger = logging.getLogger('inventree') @@ -291,7 +292,6 @@ def requestOidcToken(self): return self.token - def request(self, api_url, **kwargs): """ Perform a URL request to the Inventree API """ diff --git a/inventree/oAuthClient.py b/inventree/oAuthClient.py index 0cf2ff7e..dcd39c6e 100644 --- a/inventree/oAuthClient.py +++ b/inventree/oAuthClient.py @@ -1,8 +1,9 @@ import os +import urllib.parse as urlparse +import webbrowser from http.server import BaseHTTPRequestHandler, HTTPServer + from requests_oauthlib import OAuth2Session -import webbrowser -import urllib.parse as urlparse # Environment setup os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" @@ -10,7 +11,7 @@ class OAuthClient: - def __init__(self, server_url: str = "http://localhost:8000", client_id: str ='', scopes: list = None) -> None: + def __init__(self, server_url: str = "http://localhost:8000", client_id: str = '', scopes: list = None) -> None: self.server_url = server_url self.client_id = client_id self.scopes = scopes if scopes is not None else [] @@ -18,7 +19,7 @@ def __init__(self, server_url: str = "http://localhost:8000", client_id: str ='' self._handler_wrapper = RequestHandlerWrapper(self) self._setup_callback() self._poll_user() - + def get_url(self, path: str) -> str: """Get the authorization URL.""" return urlparse.urljoin(self.server_url, path) @@ -53,7 +54,7 @@ def callback(self, callback_url: str): class RequestHandlerWrapper: - """Provides callback for OIDC endpint.""" + """Provides callback for OIDC endpoint.""" def __init__(self, oauth_client) -> None: self.done = False self.error = None @@ -97,6 +98,7 @@ def log_message(self, *args): return RequestHandler + class OAuthError(Exception): """Exception raised during the OAuth process.""" def __init__(self, message: str) -> None: From f190cf626d4189cb5a478c43c2732f7b0b357910 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 1 Apr 2025 00:56:41 +0200 Subject: [PATCH 4/5] fix connection logic --- inventree/api.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/inventree/api.py b/inventree/api.py index 86155262..d59a8c14 100644 --- a/inventree/api.py +++ b/inventree/api.py @@ -137,6 +137,10 @@ def connect(self): except Exception: raise ConnectionRefusedError("Could not connect to InvenTree server") + if self.use_oidc_auth: + self.requestOidcToken() + return + # Basic authentication self.auth = HTTPBasicAuth(self.username, self.password) @@ -145,8 +149,6 @@ def connect(self): if self.use_token_auth and not self.token: self.requestToken() - elif self.use_oidc_auth and not self.token: - self.requestOidcToken() def constructApiUrl(self, endpoint_url): """Construct an API endpoint URL based on the provided API URL. @@ -335,9 +337,12 @@ def request(self, api_url, **kwargs): 'timeout': kwargs.get('timeout', self.timeout), } - if (self.use_token_auth or self.use_oidc_auth) and self.token: + if self.use_token_auth and self.token: headers['AUTHORIZATION'] = f'Token {self.token}' auth = None + elif self.use_oidc_auth and self.token: + headers['AUTHORIZATION'] = f'Bearer {self.token}' + auth = None else: auth = self.auth From ef321332b966a04de1d1a64dd0b36c9d7ac56a3e Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 1 Apr 2025 01:05:48 +0200 Subject: [PATCH 5/5] patch token references --- inventree/api.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/inventree/api.py b/inventree/api.py index d59a8c14..7db27822 100644 --- a/inventree/api.py +++ b/inventree/api.py @@ -80,7 +80,9 @@ def __init__(self, host=None, **kwargs): self.oidc_scopes = kwargs.get('oidc_scopes', os.environ.get('INVENTREE_API_OIDC_SCOPES', ['openid', 'g:read'])) self.use_token_auth = kwargs.get('use_token_auth', True) - self.use_oidc_auth = kwargs.get('use_oidc_auth', os.environ.get('INVENTREE_API_OIDC', None)) + self.use_oidc_auth = kwargs.get('use_oidc_auth', os.environ.get('INVENTREE_API_OIDC', False)) + if self.use_oidc_auth and self.use_token_auth: + self.use_token_auth = False self.verbose = kwargs.get('verbose', False) self.auth = None @@ -603,8 +605,9 @@ def downloadFile(self, url, destination, overwrite=False, params=None, proxies=d raise FileExistsError(f"Destination file '{destination}' already exists") if self.token: + headername = 'Token' if self.use_token_auth else 'Bearer' headers = { - 'AUTHORIZATION': f"Token {self.token}" + 'AUTHORIZATION': f"{headername} {self.token}" } auth = None else: