From 69f7da21bc72a469a1593fbf5f2b4bfa450891b9 Mon Sep 17 00:00:00 2001 From: Thomas Graf Date: Wed, 27 May 2026 14:45:45 +0200 Subject: [PATCH 1/2] feat: Support Keycloak (OpenID) token generation --- ChangeLog.md | 4 + poetry.lock | 21 ++- pyproject.toml | 1 + sw360/__init__.py | 4 +- sw360/sw360keycloak.py | 140 ++++++++++++++++ tests/test_sw360_keycloak.py | 303 +++++++++++++++++++++++++++++++++++ 6 files changed, 469 insertions(+), 4 deletions(-) create mode 100644 sw360/sw360keycloak.py create mode 100644 tests/test_sw360_keycloak.py diff --git a/ChangeLog.md b/ChangeLog.md index d306796..7d0ac5a 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -5,6 +5,10 @@ # SW360 Base Library for Python +## NEXT + +* Support Keycloak (OpenID) token generation. + ## V1.11.2 * Dependency updates ... to fix CVE-2026-41066 for lxml. diff --git a/poetry.lock b/poetry.lock index beed50c..13315d3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "alabaster" @@ -547,7 +547,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} idna = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} isoduration = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format-nongpl\""} -jsonschema-specifications = ">=2023.3.6" +jsonschema-specifications = ">=2023.03.6" referencing = ">=0.28.4" rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""} @@ -1349,6 +1349,21 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyjwt" +version = "2.13.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"}, + {file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] + [[package]] name = "pyparsing" version = "3.3.2" @@ -2017,4 +2032,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "4df91b147efa140c9a3f0f11b4960f143632494cae6938ac53527bb25620855f" +content-hash = "175ad1080a491f4f36464d2e52af6dc6bf3710a22c541d50ab3a1de7c472cbba" diff --git a/pyproject.toml b/pyproject.toml index 39df3b9..171eab8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.11" requests = "^2.33" +pyjwt = "^2.13.0" [tool.poetry.group.dev.dependencies] colorama = "^0.4.6" diff --git a/sw360/__init__.py b/sw360/__init__.py index 3313bc0..ecd246e 100644 --- a/sw360/__init__.py +++ b/sw360/__init__.py @@ -11,10 +11,12 @@ from .sw360_api import SW360 from .sw360error import SW360Error +from .sw360keycloak import SW360Keycloak from .sw360oauth2 import SW360OAuth2 __all__ = [ "SW360", "SW360Error", - "SW360OAuth2" + "SW360OAuth2", + "SW360Keycloak" ] diff --git a/sw360/sw360keycloak.py b/sw360/sw360keycloak.py new file mode 100644 index 0000000..ca4b1ca --- /dev/null +++ b/sw360/sw360keycloak.py @@ -0,0 +1,140 @@ +# ------------------------------------------------------------------------------- +# Copyright (c) 2026 Siemens +# All Rights Reserved. +# Authors: thomas.graf@siemens.com +# +# Licensed under the MIT license. +# SPDX-License-Identifier: MIT +# ------------------------------------------------------------------------------- + +from datetime import datetime +from typing import Optional + +import jwt +import requests + +from sw360 import SW360, SW360Error + + +class SW360Keycloak(SW360): + """SW360 Keycloak Authentication + + This class extends the SW360 class to support authentication using Keycloak. + It retrieves the necessary credentials from a configuration file and obtains an + access token from Keycloak to authenticate with the SW360 API. + + :param url: URL of the SW360 instance + :param config_file: Path to the configuration file containing Keycloak credentials + :type url: string + :type config_file: string + """ + + def __init__(self, url: str) -> None: + if url[-1] != "/": + url += "/" + self.url: str = url + + def get_keycloak_token(self, client_id: str, client_secret: str, write_access: bool = False) -> Optional[str]: + """ + Gets a token for REST API access to SW360 from the Keycloak server. The token is obtained using the + client credentials grant type, which requires a client_id and client_secret. The token can be used + for authentication when accessing the SW360 REST API. If write_access is True, the token will have + permissions to perform write operations on SW360, otherwise it will only have read access. + + :param client_id: the SW360 client_id to be used for token generation + :type client_id: str + :param client_secret: the SW360 client_secret to be used for token generation + :type client_secret: str + :param write_access: whether the token should have write access + :type write_access: bool + :return: the generate token + :rtype: string or None if there was an error obtaining the token + """ + token_endpoint = self.url + "kc/realms/sw360/protocol/openid-connect/token" + headers = { + "Content-Type": "application/x-www-form-urlencoded" + } + + scope = "email profile" + if write_access: + scope += " WRITE" # must be uppercase! + + data = { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "scope": scope + } + + response = requests.post(token_endpoint, headers=headers, data=data) + if response.ok: + token_response = response.json() + return token_response.get("access_token") + + raise SW360Error(response, token_endpoint, message="Unable to obtain token from Keycloak") + + def is_token_expired(self, token: str) -> bool: + """ + Checks if the given JWT token is expired. + + :param token: the JWT token to check + :type token: str + :return: True if the token is expired, False otherwise + :rtype: bool + """ + + try: + # alg = RS256 + decoded = jwt.decode(token, algorithms=["HS256"], options={"verify_signature": False}) + # print(decoded) + # { + # 'exp': 1776699510, + # 'iat': 1702769910, + # 'jti': 'trrtcc:6f1d3934-b319-1183-a059-8b7606f0a647', + # 'iss': 'https://stage.sw360.siemens.com/kc/realms/sw360', + # 'aud': 'account', + # 'sub': 'cf3fb608-4dba-42e0-bb89-7e13f995b931', + # 'typ': 'Bearer', + # 'azp': '7f75885d309970833f4187295d9babb8', + # 'acr': '1', + # 'realm_access': {'roles': ['default-roles-sw360', 'offline_access', 'uma_authorization']}, + # 'resource_access': {'account': {'roles': ['manage-account', 'manage-account-links', 'view-profile']}}, + # 'scope': ''READ profile email'', + # 'clientHost': '139.21.146.160' + # 'email_verified': False, + # 'preferred_username': 'service-account-7f75885d309970833f4187295d9babb8', + # 'clientAddress': '139.21.146.160', + # 'email': 'thomas.graf@siemens.com', + # 'client_id': 'xxx' + # } + except Exception as ex: + raise SW360Error(message="Unable to analyze token: " + repr(ex)) + + if "exp" in decoded: + exp_seconds = int(decoded["exp"]) + exp = datetime.fromtimestamp(exp_seconds) + return exp < datetime.now() + else: + raise SW360Error(message="Unable to analyze token: exp field missing!") + + def is_write_token(self, token: str) -> bool: + """ + Checks if the given JWT token has write access. + + :param token: the JWT token to check + :type token: str + :return: True if the token has write access, False otherwise + :rtype: bool + """ + + try: + # alg = RS256 + decoded = jwt.decode(token, algorithms=["HS256"], options={"verify_signature": False}) + except Exception as ex: + raise SW360Error(message="Unable to analyze token: " + repr(ex)) + + if "scope" in decoded: + scope = decoded["scope"] + return scope.lower().find("write") >= 0 + else: + raise SW360Error(message="Unable to analyze token: scope field missing!") diff --git a/tests/test_sw360_keycloak.py b/tests/test_sw360_keycloak.py new file mode 100644 index 0000000..0283fb4 --- /dev/null +++ b/tests/test_sw360_keycloak.py @@ -0,0 +1,303 @@ +# ------------------------------------------------------------------------------- +# Copyright (c) 2026 Siemens +# All Rights Reserved. +# Author: thomas.graf@siemens.com +# +# Licensed under the MIT license. +# SPDX-License-Identifier: MIT +# ------------------------------------------------------------------------------- + +import sys +import unittest +import warnings +from datetime import datetime + +import jwt +import responses + +sys.path.insert(1, "..") + +from sw360 import SW360Error # noqa: E402 +from sw360.sw360keycloak import SW360Keycloak # noqa: E402 + + +class Sw360TestKeycloak(unittest.TestCase): + MYTOKEN = "MYTOKEN" + MYURL = "https://my.server.com/" + ERROR_MSG_NO_LOGIN = "Unable to login" + + def setUp(self) -> None: + warnings.filterwarnings( + "ignore", category=ResourceWarning, + message="unclosed.*") + + def _add_login_response(self) -> None: + """ + Add the response for a successful login. + """ + responses.add( + method=responses.GET, + url=self.MYURL + "resource/api/", + body="{'status': 'ok'}", + status=200, + content_type="application/json", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) + + @responses.activate + def test_get_keycloak_read_token(self) -> None: + responses.add( + responses.POST, + url=self.MYURL + "kc/realms/sw360/protocol/openid-connect/token", + json={"access_token": "my_mock_access_token"}, + status=200, + match=[ + responses.matchers.urlencoded_params_matcher({ + "grant_type": "client_credentials", + "client_id": "mock_client_id", + "client_secret": "mock_client_secret", + "scope": "email profile" + }) + ] + ) + + lib = SW360Keycloak(self.MYURL) + actual = lib.get_keycloak_token("mock_client_id", "mock_client_secret", False) + self.assertIsNotNone(actual) + self.assertEqual(actual, "my_mock_access_token") + + @responses.activate + def test_get_keycloak_write_token(self) -> None: + responses.add( + responses.POST, + url="https://my.server.com/kc/realms/sw360/protocol/openid-connect/token", + json={"access_token": "my_mock_write_access_token"}, + status=200, + match=[ + responses.matchers.urlencoded_params_matcher({ + "grant_type": "client_credentials", + "client_id": "mock_client_id", + "client_secret": "mock_client_secret", + "scope": "email profile WRITE" + }) + ] + ) + + lib = SW360Keycloak("https://my.server.com") # without trailing dash + actual = lib.get_keycloak_token("mock_client_id", "mock_client_secret", True) + self.assertIsNotNone(actual) + self.assertEqual(actual, "my_mock_write_access_token") + + def test_is_token_expired_yes(self) -> None: + # create a JWT token + # set expiration date to 10 seconds in the past + expiration_date = datetime.now().timestamp() - 10 + test_token = jwt.encode({ + 'exp': expiration_date, + 'iat': 1702769910, + 'jti': 'trrtcc:6f1d3934-b319-1183-a059-8b7606f0a647', + 'iss': 'https://my.server.com/kc/realms/sw360', + 'aud': 'account', + 'sub': 'cf3fb608-4dba-42e0-bb89-7e13f995b931', + 'typ': 'Bearer', + 'azp': '7f75885d309970833f4187295d9babb8', + 'acr': '1', + 'realm_access': {'roles': ['default-roles-sw360', 'offline_access', 'uma_authorization']}, + 'resource_access': {'account': {'roles': ['manage-account', 'manage-account-links', 'view-profile']}}, + 'scope': 'READ profile email', + 'clientHost': '139.21.146.160', + 'email_verified': 'False', + 'preferred_username': 'service-account-xxx5885d309970833f4187295d9babb8', + 'clientAddress': '127.0.0.1', + 'email': 'john.doe@somewhere.com', + 'client_id': '007' + }, "secret1234567890abcdefghijklmnop", algorithm="HS256") + + lib = SW360Keycloak(self.MYURL) + result = lib.is_token_expired(test_token) + self.assertTrue(result) + + @responses.activate + def test_get_keycloak_token_error(self) -> None: + responses.add( + responses.POST, + url=self.MYURL + "kc/realms/sw360/protocol/openid-connect/token", + json={"error": "unauthorized_client"}, + status=401, + ) + + lib = SW360Keycloak(self.MYURL) + with self.assertRaises(SW360Error): + lib.get_keycloak_token("invalid_client_id", "invalid_client_secret", False) + + def test_is_token_expired_no(self) -> None: + # create a JWT token + expiration_date = datetime.now().timestamp() + 60 + test_token = jwt.encode({ + 'exp': expiration_date, + 'iat': 1702769910, + 'jti': 'trrtcc:6f1d3934-b319-1183-a059-8b7606f0a647', + 'iss': 'https://my.server.com/kc/realms/sw360', + 'aud': 'account', + 'sub': 'cf3fb608-4dba-42e0-bb89-7e13f995b931', + 'typ': 'Bearer', + 'azp': '7f75885d309970833f4187295d9babb8', + 'acr': '1', + 'realm_access': {'roles': ['default-roles-sw360', 'offline_access', 'uma_authorization']}, + 'resource_access': {'account': {'roles': ['manage-account', 'manage-account-links', 'view-profile']}}, + 'scope': 'READ profile email', + 'clientHost': '139.21.146.160', + 'email_verified': 'False', + 'preferred_username': 'service-account-xxx5885d309970833f4187295d9babb8', + 'clientAddress': '127.0.0.1', + 'email': 'john.doe@somewhere.com', + 'client_id': '007' + }, "secret1234567890abcdefghijklmnop", algorithm="HS256") + + lib = SW360Keycloak(self.MYURL) + result = lib.is_token_expired(test_token) + self.assertFalse(result) + + def test_is_token_expired_invalid_token1(self) -> None: + lib = SW360Keycloak(self.MYURL) + with self.assertRaises(SW360Error) as context: + lib.is_token_expired("test_token") + + if not context.exception: + self.assertTrue(False, "no exception") + + self.assertTrue(context.exception.message.startswith("Unable to analyze token:")) + + def test_is_token_expired_invalid_token2(self) -> None: + # create a JWT token without exp field + test_token = jwt.encode({ + 'iat': 1702769910, + 'jti': 'trrtcc:6f1d3934-b319-1183-a059-8b7606f0a647', + 'iss': 'https://my.server.com/kc/realms/sw360', + 'aud': 'account', + 'sub': 'cf3fb608-4dba-42e0-bb89-7e13f995b931', + 'typ': 'Bearer', + 'azp': '7f75885d309970833f4187295d9babb8', + 'acr': '1', + 'realm_access': {'roles': ['default-roles-sw360', 'offline_access', 'uma_authorization']}, + 'resource_access': {'account': {'roles': ['manage-account', 'manage-account-links', 'view-profile']}}, + 'scope': 'READ profile email', + 'clientHost': '139.21.146.160', + 'email_verified': 'False', + 'preferred_username': 'service-account-xxx5885d309970833f4187295d9babb8', + 'clientAddress': '127.0.0.1', + 'email': 'john.doe@somewhere.com', + 'client_id': '007' + }, "secret1234567890abcdefghijklmnop", algorithm="HS256") + + lib = SW360Keycloak(self.MYURL) + with self.assertRaises(SW360Error) as context: + lib.is_token_expired(test_token) + + if not context.exception: + self.assertTrue(False, "no exception") + + self.assertTrue(context.exception.message.startswith("Unable to analyze token: exp field missing!")) + + def test_is_write_token_no(self) -> None: + # create a JWT token + expiration_date = datetime.now().timestamp() + 60 + test_token = jwt.encode({ + 'exp': expiration_date, + 'iat': 1702769910, + 'jti': 'trrtcc:6f1d3934-b319-1183-a059-8b7606f0a647', + 'iss': 'https://my.server.com/kc/realms/sw360', + 'aud': 'account', + 'sub': 'cf3fb608-4dba-42e0-bb89-7e13f995b931', + 'typ': 'Bearer', + 'azp': '7f75885d309970833f4187295d9babb8', + 'acr': '1', + 'realm_access': {'roles': ['default-roles-sw360', 'offline_access', 'uma_authorization']}, + 'resource_access': {'account': {'roles': ['manage-account', 'manage-account-links', 'view-profile']}}, + 'scope': 'READ profile email', + 'clientHost': '139.21.146.160', + 'email_verified': 'False', + 'preferred_username': 'service-account-xxx5885d309970833f4187295d9babb8', + 'clientAddress': '127.0.0.1', + 'email': 'john.doe@somewhere.com', + 'client_id': '007' + }, "secret1234567890abcdefghijklmnop", algorithm="HS256") + + lib = SW360Keycloak(self.MYURL) + result = lib.is_write_token(test_token) + self.assertFalse(result) + + def test_is_write_token_yes(self) -> None: + # create a JWT token + expiration_date = datetime.now().timestamp() + 60 + test_token = jwt.encode({ + 'exp': expiration_date, + 'iat': 1702769910, + 'jti': 'trrtcc:6f1d3934-b319-1183-a059-8b7606f0a647', + 'iss': 'https://my.server.com/kc/realms/sw360', + 'aud': 'account', + 'sub': 'cf3fb608-4dba-42e0-bb89-7e13f995b931', + 'typ': 'Bearer', + 'azp': '7f75885d309970833f4187295d9babb8', + 'acr': '1', + 'realm_access': {'roles': ['default-roles-sw360', 'offline_access', 'uma_authorization']}, + 'resource_access': {'account': {'roles': ['manage-account', 'manage-account-links', 'view-profile']}}, + 'scope': 'READ profile WRITE email', + 'clientHost': '139.21.146.160', + 'email_verified': 'False', + 'preferred_username': 'service-account-xxx5885d309970833f4187295d9babb8', + 'clientAddress': '127.0.0.1', + 'email': 'john.doe@somewhere.com', + 'client_id': '007' + }, "secret1234567890abcdefghijklmnop", algorithm="HS256") + + lib = SW360Keycloak(self.MYURL) + result = lib.is_write_token(test_token) + self.assertTrue(result) + + def test_is_write_token_invalid1(self) -> None: + # create a JWT token + expiration_date = datetime.now().timestamp() + 60 + test_token = jwt.encode({ + 'exp': expiration_date, + 'iat': 1702769910, + 'jti': 'trrtcc:6f1d3934-b319-1183-a059-8b7606f0a647', + 'iss': 'https://my.server.com/kc/realms/sw360', + 'aud': 'account', + 'sub': 'cf3fb608-4dba-42e0-bb89-7e13f995b931', + 'typ': 'Bearer', + 'azp': '7f75885d309970833f4187295d9babb8', + 'acr': '1', + 'realm_access': {'roles': ['default-roles-sw360', 'offline_access', 'uma_authorization']}, + 'resource_access': {'account': {'roles': ['manage-account', 'manage-account-links', 'view-profile']}}, + 'clientHost': '139.21.146.160', + 'email_verified': 'False', + 'preferred_username': 'service-account-xxx5885d309970833f4187295d9babb8', + 'clientAddress': '127.0.0.1', + 'email': 'john.doe@somewhere.com', + 'client_id': '007' + }, "secret1234567890abcdefghijklmnop", algorithm="HS256") + + lib = SW360Keycloak(self.MYURL) + with self.assertRaises(SW360Error) as context: + lib.is_write_token(test_token) + + if not context.exception: + self.assertTrue(False, "no exception") + + self.assertTrue(context.exception.message.startswith("Unable to analyze token: scope field missing!")) + + def test_is_write_token_invalid2(self) -> None: + lib = SW360Keycloak(self.MYURL) + with self.assertRaises(SW360Error) as context: + lib.is_write_token("test_token") + + if not context.exception: + self.assertTrue(False, "no exception") + + self.assertTrue(context.exception.message.startswith("Unable to analyze token")) + + +if __name__ == "__main__": + sut = Sw360TestKeycloak() + sut.test_is_token_expired_invalid_token2() From f0a5a50d5bcf5a077f13bbdd9c297448e341cc9b Mon Sep 17 00:00:00 2001 From: Thomas Graf Date: Thu, 28 May 2026 07:10:31 +0200 Subject: [PATCH 2/2] docs(ChangeLog): improve description --- ChangeLog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ChangeLog.md b/ChangeLog.md index 7d0ac5a..26c1c84 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -8,6 +8,7 @@ ## NEXT * Support Keycloak (OpenID) token generation. + We have new methods `get_keycloak_token()`, `is_token_expired()`, and `is_write_token()`. ## V1.11.2