From 62fe1ee076b0c9d925a9e1df3476c4d6cf7bddbb Mon Sep 17 00:00:00 2001 From: Hugo Brito Date: Thu, 6 Nov 2025 09:46:46 -0300 Subject: [PATCH] Add OIDC authentication support to DC CLI This commit introduces OIDC authentication to the DC CLI while preserving Keystone as the default authentication method. Behavior & configuration: - New authentication selector available as: * Environment variable: STX_AUTH_TYPE={keystone|oidc} * CLI parameter: --stx-auth-type keystone|oidc - Default: keystone (existing behavior) - Keystone flow (default or when STX_AUTH_TYPE/--stx-auth-type=keystone): - Unchanged; authenticate with Keystone using X-Auth-Token. - OIDC flow (when STX_AUTH_TYPE/--stx-auth-type=oidc): - Obtain OIDC ID/Refresh tokens from $KUBECONFIG (or ~/.kube/config if not specified); no local token discovery beyond kubeconfig. - Send OIDC ID-Token via custom HTTPS header: OIDC-Token. - Build REST API URL without Keystone catalog: * Protocol from OS_INTERFACE: public/admin=https, internal=http. * IP/host from OS_AUTH_URL (MGMT locally, OAM remotely). * Hard-code port and API path per interface (v1/v3 variants), including region dimension (RegionOne vs SystemController). Test Plan: PASS: CLI authenticates with Keystone by default or with STX_AUTH_TYPE/--stx-auth-type=keystone. PASS: CLI authenticates with OIDC when STX_AUTH_TYPE/--stx-auth-type=oidc is specified. PASS: CLI reads OIDC ID/Refresh tokens from kubeconfig. PASS: CLI sends OIDC-Token header in OIDC mode. PASS: In an https enabled system, run a dcmanager command with --os-endpoint-type parameter set to public and the --os-auth-url pointing to the oam ip and verify the request succeeds Depends-On: https://review.opendev.org/c/starlingx/distcloud/+/966303 Story: 2011646 Task: 53595 Change-Id: Iff55f653258bdf40247baf3490943f884d41d781 Signed-off-by: Hugo Brito --- .gitignore | 1 + .zuul.yaml | 8 ++ distributedcloud-client/.pylintrc | 2 +- .../dcmanagerclient/api/httpclient.py | 11 +- .../dcmanagerclient/api/v1/client.py | 133 ++++++++++++++++-- .../dcmanagerclient/shell.py | 57 +++++--- .../dcmanagerclient/tests/test_client.py | 83 ++++++++++- distributedcloud-client/requirements.txt | 2 + distributedcloud-client/tox.ini | 2 + 9 files changed, 262 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index e621e982..1a778d3c 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ nosetests.xml .stestr .testrepository .venv +test.yaml # Translations *.mo diff --git a/.zuul.yaml b/.zuul.yaml index d2d0d3e6..307245ff 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -27,6 +27,8 @@ parent: tox-py39 description: Run py39 for distcloud-client nodeset: debian-bullseye + required-projects: + - starlingx/utilities vars: python_version: 3.9 tox_envlist: py39 @@ -37,6 +39,8 @@ parent: tox description: Run pylint for distcloud-client nodeset: debian-bullseye + required-projects: + - starlingx/utilities vars: python_version: 3.9 tox_envlist: pylint @@ -47,6 +51,8 @@ parent: tox description: Run pep8 for distcloud-client nodeset: debian-bullseye + required-projects: + - starlingx/utilities vars: python_version: 3.9 tox_envlist: pep8 @@ -57,6 +63,8 @@ parent: tox description: Run black for distcloud-client nodeset: debian-bullseye + required-projects: + - starlingx/utilities vars: python_version: 3.9 tox_envlist: black_check diff --git a/distributedcloud-client/.pylintrc b/distributedcloud-client/.pylintrc index c8cff074..7e5c5eae 100644 --- a/distributedcloud-client/.pylintrc +++ b/distributedcloud-client/.pylintrc @@ -92,7 +92,7 @@ ignore-mixin-members=yes # List of module names for which member attributes should not be checked # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis -ignored-modules=distutils,eventlet.green.subprocess +ignored-modules=distutils,platform_util,eventlet.green.subprocess # List of classes names for which member attributes should not be checked # (useful for classes with attributes dynamically set). diff --git a/distributedcloud-client/dcmanagerclient/api/httpclient.py b/distributedcloud-client/dcmanagerclient/api/httpclient.py index f26e849f..c6e373dd 100644 --- a/distributedcloud-client/dcmanagerclient/api/httpclient.py +++ b/distributedcloud-client/dcmanagerclient/api/httpclient.py @@ -1,7 +1,7 @@ # Copyright 2013 - Mirantis, Inc. # Copyright 2016 - StackStorm, Inc. # Copyright 2016 - Ericsson AB. -# Copyright (c) 2017-2021, 2024 Wind River Systems, Inc. +# Copyright (c) 2017-2021, 2024, 2026 Wind River Systems, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -51,11 +51,13 @@ class HTTPClient: user_id=None, cacert=None, insecure=False, + auth_type="keystone", ): self.base_url = base_url self.token = token self.project_id = project_id self.user_id = user_id + self.auth_type = auth_type self.ssl_options = {} if self.base_url.startswith("https"): @@ -120,7 +122,12 @@ class HTTPClient: token = headers.get("x-auth-token", self.token) if token: - headers["x-auth-token"] = token + if self.auth_type == "oidc": + # For OIDC authentication, set OIDC-Token header + headers["OIDC-Token"] = token + else: + # For Keystone authentication, set X-Auth-Token header + headers["x-auth-token"] = token project_id = headers.get("X-Project-Id", self.project_id) if project_id: diff --git a/distributedcloud-client/dcmanagerclient/api/v1/client.py b/distributedcloud-client/dcmanagerclient/api/v1/client.py index 90898c66..8b45e21a 100644 --- a/distributedcloud-client/dcmanagerclient/api/v1/client.py +++ b/distributedcloud-client/dcmanagerclient/api/v1/client.py @@ -1,7 +1,7 @@ # Copyright 2014 - Mirantis, Inc. # Copyright 2015 - StackStorm, Inc. # Copyright 2016 - Ericsson AB. -# Copyright (c) 2017-2025 Wind River Systems, Inc. +# Copyright (c) 2017-2026 Wind River Systems, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -19,10 +19,12 @@ import datetime import logging from typing import Union +from urllib.parse import urlparse import keystoneauth1.identity.generic as auth_plugin -import osprofiler.profiler from keystoneauth1 import session as ks_session +import osprofiler.profiler +from platform_util.oidc import oidc_utils from dcmanagerclient import utils from dcmanagerclient.api import httpclient @@ -47,6 +49,7 @@ from dcmanagerclient.api.v1.sw_prestage_manager import SwPrestageManager from dcmanagerclient.api.v1.sw_strategy_manager import SwStrategyManager from dcmanagerclient.api.v1.sw_update_options_manager import SwUpdateOptionsManager from dcmanagerclient.api.v1.system_peer_manager import SystemPeerManager +from dcmanagerclient import exceptions LOG = logging.getLogger(__name__) _DEFAULT_DCMANAGER_URL = "http://localhost:8119/v1.0" @@ -68,7 +71,7 @@ class Client: api_key=None, project_name=None, auth_url=None, - project_id=None, + project_id="admin", endpoint_type="publicURL", service_type="dcmanager", auth_token=None, @@ -108,10 +111,23 @@ class Client: refresh_cache=refresh_cache, **kwargs, ) + elif auth_type == "oidc": + if not username: + raise RuntimeError("Username is required for OIDC authentication") + + try: + (dcmanager_url, auth_token) = _get_oidc_data( + username, + auth_url, + endpoint_type, + service_type, + ) + except Exception as e: + raise RuntimeError(f"OIDC authentication failed: {e}") from e else: raise RuntimeError( "Invalid authentication type " - f"[value={auth_type}, valid_values=keystone]" + f"[value={auth_type}, valid_values=keystone,oidc]" ) if not dcmanager_url: @@ -127,6 +143,7 @@ class Client: user_id, cacert=cacert, insecure=insecure, + auth_type=auth_type, ) # Create all managers @@ -182,6 +199,8 @@ def authenticate( user_domain_id = kwargs.get("user_domain_id") project_domain_name = kwargs.get("project_domain_name") project_domain_id = kwargs.get("project_domain_id") + token = None + verify = False if insecure else (cacert if cacert else True) if session is None: if auth_token: @@ -192,8 +211,6 @@ def authenticate( project_name=project_name, project_domain_name=project_domain_name, project_domain_id=project_domain_id, - cacert=cacert, - insecure=insecure, ) elif api_key and (username or user_id): @@ -212,11 +229,10 @@ def authenticate( else: raise RuntimeError( - "You must either provide a valid token or" - "a password (api_key) and a user." + "You must either provide a valid token or " + "a password (api_key) and a username." ) - if auth: - session = ks_session.Session(auth=auth) + session = ks_session.Session(auth=auth, verify=verify) if session: token, cache_key = None, _cache_key(username) @@ -251,3 +267,100 @@ def authenticate( ) return dcmanager_url, token, project_id, user_id + + +def _get_oidc_data(username, auth_url, endpoint_type, service_type): + """Get OIDC token and dcmanager URL. + + Args: + username: Username for OIDC token lookup + auth_url: OS_AUTH_URL for service URL construction + endpoint_type: Interface type (publicURL, internalURL, adminURL) + service_type: Service type (dcmanager) + + Returns: + tuple: (dcmanager_url, oidc_token) + """ + dcmanager_url = _build_service_url(auth_url, service_type, endpoint_type) + + # Client should ALWAYS read OIDC Token from $KUBECONFIG file, no caching + oidc_token = oidc_utils.get_oidc_token(username) + if not oidc_token: + raise exceptions.DCManagerClientException( + f"No OIDC token found for user '{username}' in kubeconfig" + ) + + return dcmanager_url, oidc_token + + +def _build_service_url(auth_url, service_type, endpoint_type): + """Build service URL from auth_url and service parameters. + + Args: + auth_url: OS_AUTH_URL containing the IP address + service_type: Service type (e.g., 'dcmanager') + endpoint_type: Interface type (publicURL, internalURL, adminURL) + + Returns: + str: Complete service URL + """ + if not auth_url: + raise exceptions.DCManagerClientException( + "OS_AUTH_URL is required for OIDC authentication" + ) + + # Parse IP address from auth_url + parsed = urlparse(auth_url) + ip_address = parsed.hostname + + if not ip_address: + raise exceptions.DCManagerClientException( + f"Cannot extract IP address from OS_AUTH_URL: {auth_url}" + ) + + # Map endpoint types to interface names + interface_map = { + "publicURL": "public", + "internalURL": "internal", + "adminURL": "admin", + } + + interface = interface_map.get(endpoint_type) + + # Service port and path mappings + service_ports = { + "public": {"dcmanager": 8119}, + "internal": {"dcmanager": 8119}, + "admin": {"dcmanager": 8120}, + } + + service_paths = { + "public": {"dcmanager": "/v1.0"}, + "internal": {"dcmanager": "/v1.0"}, + "admin": {"dcmanager": "/v1.0"}, + } + + # Determine protocol + # HTTPS should always be enabled for public endpoints when using OIDC + if interface in ["public", "admin"]: + protocol = "https" + else: + protocol = "http" + + # Get port and path + port = service_ports.get(interface, {}).get(service_type) + path = service_paths.get(interface, {}).get(service_type) + + if not port or not path: + raise exceptions.DCManagerClientException( + f"No port/path mapping for service {service_type} on " + f"interface {interface}" + ) + + # Format IP address for URL (IPv6 needs brackets) + if ":" in ip_address and not ip_address.startswith("["): + formatted_ip = f"[{ip_address}]" + else: + formatted_ip = ip_address + + return f"{protocol}://{formatted_ip}:{port}{path}" diff --git a/distributedcloud-client/dcmanagerclient/shell.py b/distributedcloud-client/dcmanagerclient/shell.py index 2b3d8064..4b2fd39f 100644 --- a/distributedcloud-client/dcmanagerclient/shell.py +++ b/distributedcloud-client/dcmanagerclient/shell.py @@ -1,5 +1,5 @@ # Copyright 2015 - Ericsson AB. -# Copyright (c) 2017-2025 Wind River Systems, Inc. +# Copyright (c) 2017-2026 Wind River Systems, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -529,6 +529,15 @@ class DCManagerShell(app.App): ), ) + parser.add_argument( + "--stx-auth-type", + action="store", + dest="stx_auth_type", + default=env("STX_AUTH_TYPE", default="keystone"), + choices=["keystone", "oidc"], + help="Authentication type: keystone or oidc (Env: STX_AUTH_TYPE)", + ) + return parser def print_help_if_requested(self): @@ -538,28 +547,35 @@ class DCManagerShell(app.App): def load_client(self, refresh_cache, skip_auth=True): if self.options.auth_url and not self.options.token and not skip_auth: - if not self.options.tenant_name: - raise exceptions.CommandError( - ( - "You must provide a tenant_name " - "via --os-tenantname env[OS_TENANT_NAME]" + if getattr(self.options, "stx_auth_type", "keystone") == "oidc": + if not self.options.username: + raise exceptions.CommandError( + "You must provide a username via --os-username " + "env[OS_USERNAME] for OIDC authentication" ) - ) - if not self.options.username: - raise exceptions.CommandError( - ( - "You must provide a username " - "via --os-username env[OS_USERNAME]" + else: + if not self.options.tenant_name: + raise exceptions.CommandError( + ( + "You must provide a tenant_name " + "via --os-tenantname env[OS_TENANT_NAME]" + ) + ) + if not self.options.username: + raise exceptions.CommandError( + ( + "You must provide a username " + "via --os-username env[OS_USERNAME]" + ) ) - ) - if not self.options.password: - raise exceptions.CommandError( - ( - "You must provide a password " - "via --os-password env[OS_PASSWORD]" + if not self.options.password: + raise exceptions.CommandError( + ( + "You must provide a password " + "via --os-password env[OS_PASSWORD]" + ) ) - ) kwargs = { "user_domain_name": self.options.user_domain_name, @@ -568,6 +584,8 @@ class DCManagerShell(app.App): "project_domain_id": self.options.project_domain_id, } + auth_type = getattr(self.options, "stx_auth_type", "keystone") + self.client = client.client( dcmanager_url=self.options.dcmanager_url, username=self.options.username, @@ -583,6 +601,7 @@ class DCManagerShell(app.App): profile=self.options.profile, refresh_cache=refresh_cache, cache_allowed=self.options.no_cache is False, + auth_type=auth_type, **kwargs, ) diff --git a/distributedcloud-client/dcmanagerclient/tests/test_client.py b/distributedcloud-client/dcmanagerclient/tests/test_client.py index a35858d0..97b5ba66 100644 --- a/distributedcloud-client/dcmanagerclient/tests/test_client.py +++ b/distributedcloud-client/dcmanagerclient/tests/test_client.py @@ -1,7 +1,7 @@ # Copyright 2015 - Huawei Technologies Co., Ltd. # Copyright 2016 - StackStorm, Inc. # Copyright 2016 - Ericsson AB. -# Copyright (c) 2017, 2019, 2021, 2024 Wind River Systems, Inc. +# Copyright (c) 2017, 2019, 2021, 2024, 2026 Wind River Systems, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -25,6 +25,7 @@ import osprofiler.profiler import testtools from dcmanagerclient.api import client +from dcmanagerclient.api.v1.client import _build_service_url AUTH_HTTP_URL = "http://localhost:35357/v3" AUTH_HTTPS_URL = AUTH_HTTP_URL.replace("http", "https") @@ -53,7 +54,11 @@ class BaseClientTests(testtools.TestCase): expected_args = (DCMANAGER_HTTP_URL, token, project_id, user_id) - expected_kwargs = {"cacert": None, "insecure": False} + expected_kwargs = { + "auth_type": "keystone", + "cacert": None, + "insecure": False, + } client.client( username="dcmanager", @@ -81,7 +86,11 @@ class BaseClientTests(testtools.TestCase): expected_args = (DCMANAGER_HTTPS_URL, token, project_id, user_id) - expected_kwargs = {"cacert": None, "insecure": True} + expected_kwargs = { + "auth_type": "keystone", + "cacert": None, + "insecure": True, + } client.client( dcmanager_url=DCMANAGER_HTTPS_URL, @@ -112,7 +121,11 @@ class BaseClientTests(testtools.TestCase): expected_args = (DCMANAGER_HTTPS_URL, token, project_id, user_id) - expected_kwargs = {"cacert": path, "insecure": False} + expected_kwargs = { + "auth_type": "keystone", + "cacert": path, + "insecure": False, + } try: client.client( @@ -185,7 +198,11 @@ class BaseClientTests(testtools.TestCase): expected_args = (DCMANAGER_HTTP_URL, token, project_id, user_id) - expected_kwargs = {"cacert": None, "insecure": False} + expected_kwargs = { + "auth_type": "keystone", + "cacert": None, + "insecure": False, + } client.client( username="dcmanager", @@ -238,3 +255,59 @@ class BaseClientTests(testtools.TestCase): auth_url=AUTH_HTTP_URL, **FAKE_KWARGS ) + + @mock.patch("dcmanagerclient.api.v1.client._get_oidc_data") + @mock.patch("dcmanagerclient.api.httpclient.HTTPClient") + def test_oidc_auth_success(self, mock_client, mock_oidc_data): + mock_oidc_data.return_value = (DCMANAGER_HTTP_URL, "oidc_token_123") + + expected_args = (DCMANAGER_HTTP_URL, "oidc_token_123", None, None) + expected_kwargs = { + "auth_type": "oidc", + "cacert": None, + "insecure": False, + } + + client.client(username="test_user", auth_url=AUTH_HTTP_URL, auth_type="oidc") + + self.assertTrue(mock_client.called) + self.assertEqual(mock_client.call_args[0], expected_args) + self.assertDictEqual(mock_client.call_args[1], expected_kwargs) + mock_oidc_data.assert_called_once_with( + "test_user", + AUTH_HTTP_URL, + "publicURL", + "dcmanager", + ) + + def test_oidc_auth_missing_username(self): + self.assertRaises( + RuntimeError, client.client, auth_url=AUTH_HTTP_URL, auth_type="oidc" + ) + + def test_invalid_auth_type(self): + self.assertRaises( + RuntimeError, + client.client, + username="test_user", + auth_url=AUTH_HTTP_URL, + auth_type="invalid_auth", + ) + + def test_build_service_url_ipv4_public(self): + result = _build_service_url( + "http://192.168.1.1:5000/v3", "dcmanager", "publicURL" + ) + self.assertEqual(result, "https://192.168.1.1:8119/v1.0") + + def test_build_service_url_ipv6_admin(self): + result = _build_service_url( + "http://[2001:db8::1]:5000/v3", "dcmanager", "adminURL" + ) + self.assertEqual(result, "https://[2001:db8::1]:8120/v1.0") + + def test_build_service_url_ipv6_internal(self): + result = _build_service_url( + "http://[fd00::1]:5000/v3", "dcmanager", "internalURL" + ) + self.assertEqual(result, "http://[fd00::1]:8119/v1.0") diff --git a/distributedcloud-client/requirements.txt b/distributedcloud-client/requirements.txt index 1010cbdd..6aa4bda9 100644 --- a/distributedcloud-client/requirements.txt +++ b/distributedcloud-client/requirements.txt @@ -11,3 +11,5 @@ python-keystoneclient>=3.8.0 # Apache-2.0 PyYAML>=3.10.0 # MIT requests!=2.12.2,!=2.13.0,>=2.10.0 # Apache-2.0 requests-toolbelt # Apache-2.0 +oic +pyjwkest diff --git a/distributedcloud-client/tox.ini b/distributedcloud-client/tox.ini index 8cfb2311..1f980b71 100644 --- a/distributedcloud-client/tox.ini +++ b/distributedcloud-client/tox.ini @@ -6,6 +6,7 @@ toxworkdir = /tmp/{env:USER}_dc_client_tox [dcclient] client_base_dir = . +platform_util_src_dir = ../../utilities/utilities/platform-util/platform-util [testenv] basepython = python3.9 @@ -19,6 +20,7 @@ deps = -c{env:UPPER_CONSTRAINTS_FILE:https://opendev.org/starlingx/root/raw/branch/master/build-tools/requirements/debian/upper-constraints.txt} -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt + -e{[dcclient]platform_util_src_dir} allowlist_externals = rm