From ef69a114f234995b62fd3bd514805af74d2f9a6f Mon Sep 17 00:00:00 2001 From: Mikita Okuneu Date: Mon, 1 Dec 2025 16:51:14 +0100 Subject: [PATCH 1/5] CM-55782: Add OIDC authentication --- cycode/cli/app.py | 8 ++ cycode/cli/apps/auth/auth_common.py | 39 +++++++- .../cli/apps/configure/configure_command.py | 11 ++- cycode/cli/apps/configure/prompts.py | 11 +++ cycode/cli/config.py | 1 + .../cli/user_settings/credentials_manager.py | 26 ++++- cycode/cli/utils/get_api_client.py | 31 +++++- cycode/cyclient/client_creator.py | 34 +++++-- cycode/cyclient/cycode_oidc_based_client.py | 97 +++++++++++++++++++ .../configure/test_configure_command.py | 96 ++++++++++++++++-- tests/conftest.py | 44 +++++++++ tests/cyclient/test_oidc_based_client.py | 91 +++++++++++++++++ 12 files changed, 467 insertions(+), 22 deletions(-) create mode 100644 cycode/cyclient/cycode_oidc_based_client.py create mode 100644 tests/cyclient/test_oidc_based_client.py diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 04872b7d..3ef0b322 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -110,6 +110,13 @@ def app_callback( rich_help_panel=_AUTH_RICH_HELP_PANEL, ), ] = None, + id_token: Annotated[ + Optional[str], + typer.Option( + help='Specify a Cycode OIDC ID token for this specific scan execution.', + rich_help_panel=_AUTH_RICH_HELP_PANEL, + ), + ] = None, _: Annotated[ Optional[bool], typer.Option( @@ -152,6 +159,7 @@ def app_callback( ctx.obj['client_id'] = client_id ctx.obj['client_secret'] = client_secret + ctx.obj['id_token'] = id_token ctx.obj['progress_bar'] = get_progress_bar(hidden=no_progress_meter, sections=SCAN_PROGRESS_BAR_SECTIONS) diff --git a/cycode/cli/apps/auth/auth_common.py b/cycode/cli/apps/auth/auth_common.py index 96fec4cf..f4ea09d9 100644 --- a/cycode/cli/apps/auth/auth_common.py +++ b/cycode/cli/apps/auth/auth_common.py @@ -4,6 +4,7 @@ from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, RequestHttpError from cycode.cli.user_settings.credentials_manager import CredentialsManager from cycode.cli.utils.jwt_utils import get_user_and_tenant_ids_from_access_token +from cycode.cyclient.cycode_oidc_based_client import CycodeOidcBasedClient from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient if TYPE_CHECKING: @@ -13,9 +14,23 @@ def get_authorization_info(ctx: 'Context') -> Optional[AuthInfo]: printer = ctx.obj.get('console_printer') - client_id, client_secret = ctx.obj.get('client_id'), ctx.obj.get('client_secret') + client_id = ctx.obj.get('client_id') + client_secret = ctx.obj.get('client_secret') + id_token = ctx.obj.get('id_token') + + credentials_manager = CredentialsManager() + + auth_info = _try_oidc_authorization(ctx, printer, client_id, id_token) + if auth_info: + return auth_info + if not client_id or not client_secret: - client_id, client_secret = CredentialsManager().get_credentials() + stored_client_id, stored_id_token = credentials_manager.get_oidc_credentials() + auth_info = _try_oidc_authorization(ctx, printer, stored_client_id, stored_id_token) + if auth_info: + return auth_info + + client_id, client_secret = credentials_manager.get_credentials() if not client_id or not client_secret: return None @@ -32,3 +47,23 @@ def get_authorization_info(ctx: 'Context') -> Optional[AuthInfo]: printer.print_exception() return None + + +def _try_oidc_authorization( + ctx: 'Context', printer: any, client_id: Optional[str], id_token: Optional[str] +) -> Optional[AuthInfo]: + if not client_id or not id_token: + return None + + try: + access_token = CycodeOidcBasedClient(client_id, id_token).get_access_token() + if not access_token: + return None + + user_id, tenant_id = get_user_and_tenant_ids_from_access_token(access_token) + return AuthInfo(user_id=user_id, tenant_id=tenant_id) + except (RequestHttpError, HttpUnauthorizedError): + if ctx: + printer.print_exception() + + return None diff --git a/cycode/cli/apps/configure/configure_command.py b/cycode/cli/apps/configure/configure_command.py index 348e3ccb..a8759459 100644 --- a/cycode/cli/apps/configure/configure_command.py +++ b/cycode/cli/apps/configure/configure_command.py @@ -7,6 +7,7 @@ get_app_url_input, get_client_id_input, get_client_secret_input, + get_id_token_input, ) from cycode.cli.console import console from cycode.cli.utils.sentry import add_breadcrumb @@ -32,6 +33,7 @@ def configure_command() -> None: * APP URL: The base URL for Cycode's web application (for on-premise or EU installations) * Client ID: Your Cycode client ID for authentication * Client Secret: Your Cycode client secret for authentication + * ID Token: Your Cycode ID token for authentication Example usage: * `cycode configure`: Start interactive configuration @@ -55,15 +57,22 @@ def configure_command() -> None: config_updated = True current_client_id, current_client_secret = CREDENTIALS_MANAGER.get_credentials_from_file() + _, current_id_token = CREDENTIALS_MANAGER.get_oidc_credentials_from_file() client_id = get_client_id_input(current_client_id) client_secret = get_client_secret_input(current_client_secret) + id_token = get_id_token_input(current_id_token) credentials_updated = False if _should_update_value(current_client_id, client_id) or _should_update_value(current_client_secret, client_secret): credentials_updated = True CREDENTIALS_MANAGER.update_credentials(client_id, client_secret) + oidc_credentials_updated = False + if _should_update_value(current_client_id, client_id) or _should_update_value(current_id_token, id_token): + oidc_credentials_updated = True + CREDENTIALS_MANAGER.update_oidc_credentials(client_id, id_token) + if config_updated: console.print(get_urls_update_result_message()) - if credentials_updated: + if credentials_updated or oidc_credentials_updated: console.print(get_credentials_update_result_message()) diff --git a/cycode/cli/apps/configure/prompts.py b/cycode/cli/apps/configure/prompts.py index 3025688d..63fae12c 100644 --- a/cycode/cli/apps/configure/prompts.py +++ b/cycode/cli/apps/configure/prompts.py @@ -46,3 +46,14 @@ def get_api_url_input(current_api_url: Optional[str]) -> str: default = current_api_url return typer.prompt(text=prompt_text, default=default, type=str) + + +def get_id_token_input(current_id_token: Optional[str]) -> Optional[str]: + prompt_text = 'Cycode ID Token' + + prompt_suffix = ' []: ' + if current_id_token: + prompt_suffix = f' [{obfuscate_text(current_id_token)}]: ' + + new_id_token = typer.prompt(text=prompt_text, prompt_suffix=prompt_suffix, default='', show_default=False) + return new_id_token or current_id_token diff --git a/cycode/cli/config.py b/cycode/cli/config.py index 73491546..79a84fe2 100644 --- a/cycode/cli/config.py +++ b/cycode/cli/config.py @@ -5,3 +5,4 @@ # env vars CYCODE_CLIENT_ID_ENV_VAR_NAME = 'CYCODE_CLIENT_ID' CYCODE_CLIENT_SECRET_ENV_VAR_NAME = 'CYCODE_CLIENT_SECRET' +CYCODE_ID_TOKEN_ENV_VAR_NAME = 'CYCODE_ID_TOKEN' diff --git a/cycode/cli/user_settings/credentials_manager.py b/cycode/cli/user_settings/credentials_manager.py index 7af43569..32564b0e 100644 --- a/cycode/cli/user_settings/credentials_manager.py +++ b/cycode/cli/user_settings/credentials_manager.py @@ -2,7 +2,11 @@ from pathlib import Path from typing import Optional -from cycode.cli.config import CYCODE_CLIENT_ID_ENV_VAR_NAME, CYCODE_CLIENT_SECRET_ENV_VAR_NAME +from cycode.cli.config import ( + CYCODE_CLIENT_ID_ENV_VAR_NAME, + CYCODE_CLIENT_SECRET_ENV_VAR_NAME, + CYCODE_ID_TOKEN_ENV_VAR_NAME, +) from cycode.cli.user_settings.base_file_manager import BaseFileManager from cycode.cli.user_settings.jwt_creator import JwtCreator from cycode.cli.utils.sentry import setup_scope_from_access_token @@ -15,6 +19,7 @@ class CredentialsManager(BaseFileManager): CLIENT_ID_FIELD_NAME: str = 'cycode_client_id' CLIENT_SECRET_FIELD_NAME: str = 'cycode_client_secret' + ID_TOKEN_FIELD_NAME: str = 'cycode_id_token' ACCESS_TOKEN_FIELD_NAME: str = 'cycode_access_token' ACCESS_TOKEN_EXPIRES_IN_FIELD_NAME: str = 'cycode_access_token_expires_in' ACCESS_TOKEN_CREATOR_FIELD_NAME: str = 'cycode_access_token_creator' @@ -38,6 +43,25 @@ def get_credentials_from_file(self) -> tuple[Optional[str], Optional[str]]: client_secret = file_content.get(self.CLIENT_SECRET_FIELD_NAME) return client_id, client_secret + def get_oidc_credentials_from_file(self) -> tuple[Optional[str], Optional[str]]: + file_content = self.read_file() + client_id = file_content.get(self.CLIENT_ID_FIELD_NAME) + id_token = file_content.get(self.ID_TOKEN_FIELD_NAME) + return client_id, id_token + + def get_oidc_credentials(self) -> tuple[Optional[str], Optional[str]]: + client_id = os.getenv(CYCODE_CLIENT_ID_ENV_VAR_NAME) + id_token = os.getenv(CYCODE_ID_TOKEN_ENV_VAR_NAME) + + if client_id is not None and id_token is not None: + return client_id, id_token + + return self.get_oidc_credentials_from_file() + + def update_oidc_credentials(self, client_id: str, id_token: str) -> None: + file_content_to_update = {self.CLIENT_ID_FIELD_NAME: client_id, self.ID_TOKEN_FIELD_NAME: id_token} + self.write_content_to_file(file_content_to_update) + def update_credentials(self, client_id: str, client_secret: str) -> None: file_content_to_update = {self.CLIENT_ID_FIELD_NAME: client_id, self.CLIENT_SECRET_FIELD_NAME: client_secret} self.write_content_to_file(file_content_to_update) diff --git a/cycode/cli/utils/get_api_client.py b/cycode/cli/utils/get_api_client.py index ba98d937..db4296a0 100644 --- a/cycode/cli/utils/get_api_client.py +++ b/cycode/cli/utils/get_api_client.py @@ -14,37 +14,58 @@ def _get_cycode_client( - create_client_func: callable, client_id: Optional[str], client_secret: Optional[str], hide_response_log: bool + create_client_func: callable, + client_id: Optional[str], + client_secret: Optional[str], + hide_response_log: bool, + id_token: Optional[str] = None, ) -> Union['ScanClient', 'ReportClient']: + if id_token: + if not client_id: + raise click.ClickException('Cycode client id needed for OIDC authentication.') + return create_client_func(client_id, None, hide_response_log, id_token) + if not client_id or not client_secret: + oidc_client_id, oidc_id_token = _get_configured_oidc_credentials() + if oidc_client_id and oidc_id_token: + return create_client_func(oidc_client_id, None, hide_response_log, oidc_id_token) + client_id, client_secret = _get_configured_credentials() if not client_id: raise click.ClickException('Cycode client id needed.') if not client_secret: raise click.ClickException('Cycode client secret is needed.') - return create_client_func(client_id, client_secret, hide_response_log) + return create_client_func(client_id, client_secret, hide_response_log, None) def get_scan_cycode_client(ctx: 'typer.Context') -> 'ScanClient': client_id = ctx.obj.get('client_id') client_secret = ctx.obj.get('client_secret') + id_token = ctx.obj.get('id_token') hide_response_log = not ctx.obj.get('show_secret', False) - return _get_cycode_client(create_scan_client, client_id, client_secret, hide_response_log) + return _get_cycode_client(create_scan_client, client_id, client_secret, hide_response_log, id_token) def get_report_cycode_client(ctx: 'typer.Context', hide_response_log: bool = True) -> 'ReportClient': client_id = ctx.obj.get('client_id') client_secret = ctx.obj.get('client_secret') - return _get_cycode_client(create_report_client, client_id, client_secret, hide_response_log) + id_token = ctx.obj.get('id_token') + return _get_cycode_client(create_report_client, client_id, client_secret, hide_response_log, id_token) def get_import_sbom_cycode_client(ctx: 'typer.Context', hide_response_log: bool = True) -> 'ImportSbomClient': client_id = ctx.obj.get('client_id') client_secret = ctx.obj.get('client_secret') - return _get_cycode_client(create_import_sbom_client, client_id, client_secret, hide_response_log) + id_token = ctx.obj.get('id_token') + return _get_cycode_client(create_import_sbom_client, client_id, client_secret, hide_response_log, id_token) def _get_configured_credentials() -> tuple[str, str]: credentials_manager = CredentialsManager() return credentials_manager.get_credentials() + + +def _get_configured_oidc_credentials() -> tuple[Optional[str], Optional[str]]: + credentials_manager = CredentialsManager() + return credentials_manager.get_oidc_credentials() diff --git a/cycode/cyclient/client_creator.py b/cycode/cyclient/client_creator.py index 68845646..01ab6b59 100644 --- a/cycode/cyclient/client_creator.py +++ b/cycode/cyclient/client_creator.py @@ -1,6 +1,9 @@ +from typing import Optional + from cycode.cyclient.config import dev_mode from cycode.cyclient.config_dev import DEV_CYCODE_API_URL from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient +from cycode.cyclient.cycode_oidc_based_client import CycodeOidcBasedClient from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient from cycode.cyclient.import_sbom_client import ImportSbomClient from cycode.cyclient.report_client import ReportClient @@ -8,22 +11,41 @@ from cycode.cyclient.scan_config_base import DefaultScanConfig, DevScanConfig -def create_scan_client(client_id: str, client_secret: str, hide_response_log: bool) -> ScanClient: +def create_scan_client( + client_id: str, client_secret: Optional[str] = None, hide_response_log: bool = False, id_token: Optional[str] = None +) -> ScanClient: if dev_mode: client = CycodeDevBasedClient(DEV_CYCODE_API_URL) scan_config = DevScanConfig() else: - client = CycodeTokenBasedClient(client_id, client_secret) + if id_token: + client = CycodeOidcBasedClient(client_id, id_token) + else: + client = CycodeTokenBasedClient(client_id, client_secret) scan_config = DefaultScanConfig() return ScanClient(client, scan_config, hide_response_log) -def create_report_client(client_id: str, client_secret: str, _: bool) -> ReportClient: - client = CycodeDevBasedClient(DEV_CYCODE_API_URL) if dev_mode else CycodeTokenBasedClient(client_id, client_secret) +def create_report_client( + client_id: str, client_secret: Optional[str] = None, _: bool = False, id_token: Optional[str] = None +) -> ReportClient: + if dev_mode: + client = CycodeDevBasedClient(DEV_CYCODE_API_URL) + elif id_token: + client = CycodeOidcBasedClient(client_id, id_token) + else: + client = CycodeTokenBasedClient(client_id, client_secret) return ReportClient(client) -def create_import_sbom_client(client_id: str, client_secret: str, _: bool) -> ImportSbomClient: - client = CycodeDevBasedClient(DEV_CYCODE_API_URL) if dev_mode else CycodeTokenBasedClient(client_id, client_secret) +def create_import_sbom_client( + client_id: str, client_secret: Optional[str] = None, _: bool = False, id_token: Optional[str] = None +) -> ImportSbomClient: + if dev_mode: + client = CycodeDevBasedClient(DEV_CYCODE_API_URL) + elif id_token: + client = CycodeOidcBasedClient(client_id, id_token) + else: + client = CycodeTokenBasedClient(client_id, client_secret) return ImportSbomClient(client) diff --git a/cycode/cyclient/cycode_oidc_based_client.py b/cycode/cyclient/cycode_oidc_based_client.py new file mode 100644 index 00000000..7eea7dec --- /dev/null +++ b/cycode/cyclient/cycode_oidc_based_client.py @@ -0,0 +1,97 @@ +from threading import Lock +from typing import Optional + +import arrow +from requests import Response + +from cycode.cli.user_settings.credentials_manager import CredentialsManager +from cycode.cli.user_settings.jwt_creator import JwtCreator +from cycode.cyclient.cycode_client import CycodeClient + +_NGINX_PLAIN_ERRORS = [ + b'Invalid JWT Token', + b'JWT Token Needed', + b'JWT Token validation failed', +] + + +class CycodeOidcBasedClient(CycodeClient): + """Send requests with JWT obtained via OIDC ID token.""" + + def __init__(self, client_id: str, id_token: str) -> None: + super().__init__() + self.client_id = client_id + self.id_token = id_token + + self._credentials_manager = CredentialsManager() + # load cached access token + access_token, expires_in, creator = self._credentials_manager.get_access_token() + + self._access_token = self._expires_in = None + if creator == JwtCreator.create(client_id, id_token): + # we must be sure that cached access token is created using the same client id and client secret. + # because client id and client secret could be passed via command, via env vars or via config file. + # we must not use cached access token if client id or client secret was changed. + self._access_token = access_token + self._expires_in = arrow.get(expires_in) if expires_in else None + + self._lock = Lock() + + def get_access_token(self) -> str: + with self._lock: + self.refresh_access_token_if_needed() + return self._access_token + + def invalidate_access_token(self, in_storage: bool = False) -> None: + self._access_token = None + self._expires_in = None + + if in_storage: + self._credentials_manager.update_access_token(None, None, None) + + def refresh_access_token_if_needed(self) -> None: + if self._access_token is None or self._expires_in is None or arrow.utcnow() >= self._expires_in: + self.refresh_access_token() + + def refresh_access_token(self) -> None: + auth_response = self.post( + url_path='api/v1/auth/oidc/api-token', + body={'client_id': self.client_id, 'id_token': self.id_token}, + without_auth=True, + hide_response_content_log=True, + ) + auth_response_data = auth_response.json() + + self._access_token = auth_response_data['token'] + self._expires_in = arrow.utcnow().shift(seconds=auth_response_data['expires_in'] * 0.8) + + jwt_creator = JwtCreator.create(self.client_id, self.id_token) + self._credentials_manager.update_access_token(self._access_token, self._expires_in.timestamp(), jwt_creator) + + def get_request_headers(self, additional_headers: Optional[dict] = None, without_auth: bool = False) -> dict: + headers = super().get_request_headers(additional_headers=additional_headers) + + if not without_auth: + headers = self._add_auth_header(headers) + + return headers + + def _add_auth_header(self, headers: dict) -> dict: + headers['Authorization'] = f'Bearer {self.get_access_token()}' + return headers + + def _execute( + self, + *args, + **kwargs, + ) -> Response: + response = super()._execute(*args, **kwargs) + + # backend returns 200 and plain text. no way to catch it with .raise_for_status() + nginx_error_response = any(response.content.startswith(plain_error) for plain_error in _NGINX_PLAIN_ERRORS) + if response.status_code == 200 and nginx_error_response: + # if cached token is invalid, try to refresh it and retry the request + self.refresh_access_token() + response = super()._execute(*args, **kwargs) + + return response diff --git a/tests/cli/commands/configure/test_configure_command.py b/tests/cli/commands/configure/test_configure_command.py index 5ed94c1d..bb24d3f2 100644 --- a/tests/cli/commands/configure/test_configure_command.py +++ b/tests/cli/commands/configure/test_configure_command.py @@ -14,11 +14,16 @@ def test_configure_command_no_exist_values_in_file(mocker: 'MockerFixture') -> N api_url_user_input = 'new api url' client_id_user_input = 'new client id' client_secret_user_input = 'new client secret' + id_token_user_input = 'new id token' mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', return_value=(None, None), ) + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_oidc_credentials_from_file', + return_value=(None, None), + ) mocker.patch( 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', return_value=None, @@ -31,12 +36,15 @@ def test_configure_command_no_exist_values_in_file(mocker: 'MockerFixture') -> N # side effect - multiple return values, each item in the list represents return of a call mocker.patch( 'typer.prompt', - side_effect=[api_url_user_input, app_url_user_input, client_id_user_input, client_secret_user_input], + side_effect=[api_url_user_input, app_url_user_input, client_id_user_input, client_secret_user_input, id_token_user_input], ) mocked_update_credentials = mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' ) + mocked_update_oidc_credentials = mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_oidc_credentials' + ) mocked_update_api_base_url = mocker.patch( 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' ) @@ -49,6 +57,7 @@ def test_configure_command_no_exist_values_in_file(mocker: 'MockerFixture') -> N # Assert mocked_update_credentials.assert_called_once_with(client_id_user_input, client_secret_user_input) + mocked_update_oidc_credentials.assert_called_once_with(client_id_user_input, id_token_user_input) mocked_update_api_base_url.assert_called_once_with(api_url_user_input) mocked_update_app_base_url.assert_called_once_with(app_url_user_input) @@ -59,11 +68,16 @@ def test_configure_command_update_current_configs_in_files(mocker: 'MockerFixtur api_url_user_input = 'new api url' client_id_user_input = 'new client id' client_secret_user_input = 'new client secret' + id_token_user_input = 'new id token' mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', return_value=('client id file', 'client secret file'), ) + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_oidc_credentials_from_file', + return_value=('client id file', 'id token file'), + ) mocker.patch( 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', return_value='api url file', @@ -76,7 +90,7 @@ def test_configure_command_update_current_configs_in_files(mocker: 'MockerFixtur # side effect - multiple return values, each item in the list represents return of a call mocker.patch( 'typer.prompt', - side_effect=[api_url_user_input, app_url_user_input, client_id_user_input, client_secret_user_input], + side_effect=[api_url_user_input, app_url_user_input, client_id_user_input, client_secret_user_input, id_token_user_input], ) mocked_update_credentials = mocker.patch( @@ -88,12 +102,16 @@ def test_configure_command_update_current_configs_in_files(mocker: 'MockerFixtur mocked_update_app_base_url = mocker.patch( 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_app_base_url' ) + mocker_update_oidc_credentials = mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_oidc_credentials' + ) # Act CliRunner().invoke(app, ['configure']) # Assert mocked_update_credentials.assert_called_once_with(client_id_user_input, client_secret_user_input) + mocker_update_oidc_credentials.assert_called_once_with(client_id_user_input, id_token_user_input) mocked_update_api_base_url.assert_called_once_with(api_url_user_input) mocked_update_app_base_url.assert_called_once_with(app_url_user_input) @@ -108,7 +126,7 @@ def test_set_credentials_update_only_client_id(mocker: 'MockerFixture') -> None: ) # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=['', '', client_id_user_input, '']) + mocker.patch('typer.prompt', side_effect=['', '', client_id_user_input, '', '']) mocked_update_credentials = mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' ) @@ -131,7 +149,7 @@ def test_configure_command_update_only_client_secret(mocker: 'MockerFixture') -> ) # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=['', '', '', client_secret_user_input]) + mocker.patch('typer.prompt', side_effect=['', '', '', client_secret_user_input, '']) mocked_update_credentials = mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' ) @@ -154,7 +172,7 @@ def test_configure_command_update_only_api_url(mocker: 'MockerFixture') -> None: ) # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=[api_url_user_input, '', '', '']) + mocker.patch('typer.prompt', side_effect=[api_url_user_input, '', '', '', '']) mocked_update_api_base_url = mocker.patch( 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' ) @@ -166,6 +184,34 @@ def test_configure_command_update_only_api_url(mocker: 'MockerFixture') -> None: mocked_update_api_base_url.assert_called_once_with(api_url_user_input) +def test_configure_command_update_only_id_token(mocker: 'MockerFixture') -> None: + # Arrange + current_client_id = 'client id file' + current_id_token = 'old id token' + new_id_token = 'new id token' + + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', + return_value=(current_client_id, 'client secret file'), + ) + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_oidc_credentials_from_file', + return_value=(current_client_id, current_id_token), + ) + + mocker.patch('typer.prompt', side_effect=['', '', '', '', new_id_token]) + + mocked_update_oidc_credentials = mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_oidc_credentials' + ) + + # Act + CliRunner().invoke(app, ['configure']) + + # Assert + mocked_update_oidc_credentials.assert_called_once_with(current_client_id, new_id_token) + + def test_configure_command_should_not_update_credentials(mocker: 'MockerFixture') -> None: # Arrange client_id_user_input = '' @@ -177,7 +223,7 @@ def test_configure_command_should_not_update_credentials(mocker: 'MockerFixture' ) # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=['', '', client_id_user_input, client_secret_user_input]) + mocker.patch('typer.prompt', side_effect=['', '', client_id_user_input, client_secret_user_input, '']) mocked_update_credentials = mocker.patch( 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' ) @@ -204,7 +250,7 @@ def test_configure_command_should_not_update_config_file(mocker: 'MockerFixture' ) # side effect - multiple return values, each item in the list represents return of a call - mocker.patch('typer.prompt', side_effect=[api_url_user_input, app_url_user_input, '', '']) + mocker.patch('typer.prompt', side_effect=[api_url_user_input, app_url_user_input, '', '', '']) mocked_update_api_base_url = mocker.patch( 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' ) @@ -218,3 +264,39 @@ def test_configure_command_should_not_update_config_file(mocker: 'MockerFixture' # Assert assert not mocked_update_api_base_url.called assert not mocked_update_app_base_url.called + + +def test_configure_command_should_not_update_oidc_credentials(mocker: 'MockerFixture') -> None: + # Arrange + current_client_id = 'client id file' + current_client_secret = 'client secret file' + current_id_token = 'old id token' + + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', + return_value=(current_client_id, current_client_secret), + ) + mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_oidc_credentials_from_file', + return_value=(current_client_id, current_id_token), + ) + mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_api_url', + return_value='api url file', + ) + mocker.patch( + 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.get_app_url', + return_value='app url file', + ) + + mocker.patch('typer.prompt', side_effect=['', '', '', '', '']) + + mocked_update_oidc_credentials = mocker.patch( + 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_oidc_credentials' + ) + + # Act + CliRunner().invoke(app, ['configure']) + + # Assert + mocked_update_oidc_credentials.assert_not_called() diff --git a/tests/conftest.py b/tests/conftest.py index 6fa50f55..f1df29dd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ from cycode.cli.user_settings.credentials_manager import CredentialsManager from cycode.cyclient.client_creator import create_scan_client +from cycode.cyclient.cycode_oidc_based_client import CycodeOidcBasedClient from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient from cycode.cyclient.scan_client import ScanClient @@ -14,6 +15,7 @@ _CLIENT_ID = 'b1234568-0eaa-1234-beb8-6f0c12345678' _CLIENT_SECRET = 'a12345a-42b2-1234-3bdd-c0130123456' +_ID_TOKEN = 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMzQ1NiJ9.eyJzdWIiOiI4NzY1NDMyMSIsImF1ZCI6ImN5Y29kZSIsImV4cCI6MTUxNjIzOTAyMiwiaXNfb2lkYyI6MX0.Rrby2hPzsoMM3' # noqa: E501 CLI_ENV_VARS = {'CYCODE_CLIENT_ID': _CLIENT_ID, 'CYCODE_CLIENT_SECRET': _CLIENT_SECRET} @@ -45,6 +47,17 @@ def create_token_based_client( return CycodeTokenBasedClient(client_id, client_secret) +def create_oidc_based_client(client_id: Optional[str] = None, id_token: Optional[str] = None) -> CycodeOidcBasedClient: + CredentialsManager.FILE_NAME = 'unit-tests-credentials.yaml' + + if client_id is None: + client_id = _CLIENT_ID + if id_token is None: + id_token = _ID_TOKEN + + return CycodeOidcBasedClient(client_id, id_token) + + @pytest.fixture(scope='session') def token_based_client() -> CycodeTokenBasedClient: return create_token_based_client() @@ -74,3 +87,34 @@ def api_token_response(api_token_url: str) -> responses.Response: def api_token(token_based_client: CycodeTokenBasedClient, api_token_response: responses.Response) -> str: responses.add(api_token_response) return token_based_client.get_access_token() + + +@pytest.fixture(scope='session') +def oidc_based_client() -> CycodeOidcBasedClient: + return create_oidc_based_client() + + +@pytest.fixture(scope='session') +def oidc_api_token_url(oidc_based_client: CycodeOidcBasedClient) -> str: + return f'{oidc_based_client.api_url}/api/v1/auth/oidc/api-token' + + +@pytest.fixture(scope='session') +@responses.activate +def oidc_api_token(oidc_based_client: CycodeOidcBasedClient, oidc_api_token_response: responses.Response) -> str: + responses.add(oidc_api_token_response) + return oidc_based_client.get_access_token() + + +@pytest.fixture(scope='session') +def oidc_api_token_response(oidc_api_token_url: str) -> responses.Response: + return responses.Response( + method=responses.POST, + url=oidc_api_token_url, + json={ + 'token': _EXPECTED_API_TOKEN, + 'refresh_token': '12345678-0c68-1234-91ba-a13123456789', + 'expires_in': 86400, + }, + status=200, + ) diff --git a/tests/cyclient/test_oidc_based_client.py b/tests/cyclient/test_oidc_based_client.py new file mode 100644 index 00000000..070448b3 --- /dev/null +++ b/tests/cyclient/test_oidc_based_client.py @@ -0,0 +1,91 @@ +import arrow +import responses + +from cycode.cyclient.cycode_oidc_based_client import CycodeOidcBasedClient +from tests.conftest import _EXPECTED_API_TOKEN, create_oidc_based_client + + +@responses.activate +def test_access_token_new( + oidc_based_client: CycodeOidcBasedClient, oidc_api_token_response: responses.Response +) -> None: + responses.add(oidc_api_token_response) + + api_token = oidc_based_client.get_access_token() + + assert api_token == _EXPECTED_API_TOKEN + + +@responses.activate +def test_access_token_expired( + oidc_based_client: CycodeOidcBasedClient, oidc_api_token_response: responses.Response +) -> None: + responses.add(oidc_api_token_response) + + oidc_based_client.get_access_token() + + oidc_based_client._expires_in = arrow.utcnow().shift(hours=-1) + + api_token_refreshed = oidc_based_client.get_access_token() + + assert api_token_refreshed == _EXPECTED_API_TOKEN + + +def test_get_request_headers(oidc_based_client: CycodeOidcBasedClient, oidc_api_token: str) -> None: + expected_headers = { + **oidc_based_client.MANDATORY_HEADERS, + 'Authorization': f'Bearer {_EXPECTED_API_TOKEN}', + } + + assert oidc_based_client.get_request_headers() == expected_headers + + +@responses.activate +def test_access_token_cached( + oidc_based_client: CycodeOidcBasedClient, oidc_api_token_response: responses.Response +) -> None: + responses.add(oidc_api_token_response) + oidc_based_client.get_access_token() + + client2 = create_oidc_based_client() + assert client2._access_token == oidc_based_client._access_token + assert client2._expires_in == oidc_based_client._expires_in + + +@responses.activate +def test_access_token_cached_creator_changed( + oidc_based_client: CycodeOidcBasedClient, oidc_api_token_response: responses.Response +) -> None: + responses.add(oidc_api_token_response) + oidc_based_client.get_access_token() + + client2 = create_oidc_based_client('client_id2', 'different-token') + assert client2._access_token is None + assert client2._expires_in is None + + +@responses.activate +def test_access_token_invalidation( + oidc_based_client: CycodeOidcBasedClient, oidc_api_token_response: responses.Response +) -> None: + responses.add(oidc_api_token_response) + oidc_based_client.get_access_token() + + expected_access_token = oidc_based_client._access_token + expected_expires_in = oidc_based_client._expires_in + + oidc_based_client.invalidate_access_token() + assert oidc_based_client._access_token is None + assert oidc_based_client._expires_in is None + + client2 = create_oidc_based_client() + assert client2._access_token == expected_access_token + assert client2._expires_in == expected_expires_in + + client2.invalidate_access_token(in_storage=True) + assert client2._access_token is None + assert client2._expires_in is None + + client3 = create_oidc_based_client() + assert client3._access_token is None + assert client3._expires_in is None From 71f5eec1b1b8ffaf4a6349b3bcf2341a5473553f Mon Sep 17 00:00:00 2001 From: Mikita Okuneu Date: Tue, 2 Dec 2025 11:47:46 +0100 Subject: [PATCH 2/5] CM-55782: Fixed comments --- cycode/cli/utils/get_api_client.py | 9 +- cycode/cyclient/base_token_auth_client.py | 101 +++++++++++++++++++ cycode/cyclient/cycode_oidc_based_client.py | 89 ++-------------- cycode/cyclient/cycode_token_based_client.py | 89 ++-------------- 4 files changed, 122 insertions(+), 166 deletions(-) create mode 100644 cycode/cyclient/base_token_auth_client.py diff --git a/cycode/cli/utils/get_api_client.py b/cycode/cli/utils/get_api_client.py index db4296a0..5c712288 100644 --- a/cycode/cli/utils/get_api_client.py +++ b/cycode/cli/utils/get_api_client.py @@ -20,16 +20,17 @@ def _get_cycode_client( hide_response_log: bool, id_token: Optional[str] = None, ) -> Union['ScanClient', 'ReportClient']: - if id_token: - if not client_id: - raise click.ClickException('Cycode client id needed for OIDC authentication.') + if client_id and id_token: return create_client_func(client_id, None, hide_response_log, id_token) - if not client_id or not client_secret: + if not client_id or not id_token: oidc_client_id, oidc_id_token = _get_configured_oidc_credentials() if oidc_client_id and oidc_id_token: return create_client_func(oidc_client_id, None, hide_response_log, oidc_id_token) + if oidc_id_token and not oidc_client_id: + raise click.ClickException('Cycode client id needed for OIDC authentication.') + if not client_id or not client_secret: client_id, client_secret = _get_configured_credentials() if not client_id: raise click.ClickException('Cycode client id needed.') diff --git a/cycode/cyclient/base_token_auth_client.py b/cycode/cyclient/base_token_auth_client.py new file mode 100644 index 00000000..7be7deec --- /dev/null +++ b/cycode/cyclient/base_token_auth_client.py @@ -0,0 +1,101 @@ +from abc import ABC, abstractmethod +from threading import Lock +from typing import Any, Optional + +import arrow +from requests import Response + +from cycode.cli.user_settings.credentials_manager import CredentialsManager +from cycode.cli.user_settings.jwt_creator import JwtCreator +from cycode.cyclient.cycode_client import CycodeClient + +_NGINX_PLAIN_ERRORS = [ + b'Invalid JWT Token', + b'JWT Token Needed', + b'JWT Token validation failed', +] + + +class BaseTokenAuthClient(CycodeClient, ABC): + """Base client for token-based authentication flows with cached JWTs.""" + + def __init__(self, client_id: str) -> None: + super().__init__() + self.client_id = client_id + + self._credentials_manager = CredentialsManager() + # load cached access token + access_token, expires_in, creator = self._credentials_manager.get_access_token() + + self._access_token = self._expires_in = None + expected_creator = self._create_jwt_creator() + if creator == expected_creator: + # we must be sure that cached access token is created using the same client id and client secret. + # because client id and client secret could be passed via command, via env vars or via config file. + # we must not use cached access token if client id or client secret was changed. + self._access_token = access_token + self._expires_in = arrow.get(expires_in) if expires_in else None + + self._lock = Lock() + + def get_access_token(self) -> str: + with self._lock: + self.refresh_access_token_if_needed() + return self._access_token + + def invalidate_access_token(self, in_storage: bool = False) -> None: + self._access_token = None + self._expires_in = None + + if in_storage: + self._credentials_manager.update_access_token(None, None, None) + + def refresh_access_token_if_needed(self) -> None: + if self._access_token is None or self._expires_in is None or arrow.utcnow() >= self._expires_in: + self.refresh_access_token() + + def refresh_access_token(self) -> None: + auth_response = self._request_new_access_token() + self._access_token = auth_response['token'] + + self._expires_in = arrow.utcnow().shift(seconds=auth_response['expires_in'] * 0.8) + + jwt_creator = self._create_jwt_creator() + self._credentials_manager.update_access_token(self._access_token, self._expires_in.timestamp(), jwt_creator) + + def get_request_headers(self, additional_headers: Optional[dict] = None, without_auth: bool = False) -> dict: + headers = super().get_request_headers(additional_headers=additional_headers) + + if not without_auth: + headers = self._add_auth_header(headers) + + return headers + + def _add_auth_header(self, headers: dict) -> dict: + headers['Authorization'] = f'Bearer {self.get_access_token()}' + return headers + + def _execute( + self, + *args, + **kwargs, + ) -> Response: + response = super()._execute(*args, **kwargs) + + # backend returns 200 and plain text. no way to catch it with .raise_for_status() + nginx_error_response = any(response.content.startswith(plain_error) for plain_error in _NGINX_PLAIN_ERRORS) + if response.status_code == 200 and nginx_error_response: + # if cached token is invalid, try to refresh it and retry the request + self.refresh_access_token() + response = super()._execute(*args, **kwargs) + + return response + + @abstractmethod + def _create_jwt_creator(self) -> JwtCreator: + """Create a JwtCreator instance for the current credential type.""" + + @abstractmethod + def _request_new_access_token(self) -> dict[str, Any]: + """Return the authentication payload with token and expires_in.""" + diff --git a/cycode/cyclient/cycode_oidc_based_client.py b/cycode/cyclient/cycode_oidc_based_client.py index 7eea7dec..5208cf5d 100644 --- a/cycode/cyclient/cycode_oidc_based_client.py +++ b/cycode/cyclient/cycode_oidc_based_client.py @@ -1,97 +1,24 @@ -from threading import Lock -from typing import Optional +from typing import Any -import arrow -from requests import Response - -from cycode.cli.user_settings.credentials_manager import CredentialsManager from cycode.cli.user_settings.jwt_creator import JwtCreator -from cycode.cyclient.cycode_client import CycodeClient - -_NGINX_PLAIN_ERRORS = [ - b'Invalid JWT Token', - b'JWT Token Needed', - b'JWT Token validation failed', -] +from cycode.cyclient.base_token_auth_client import BaseTokenAuthClient -class CycodeOidcBasedClient(CycodeClient): +class CycodeOidcBasedClient(BaseTokenAuthClient): """Send requests with JWT obtained via OIDC ID token.""" def __init__(self, client_id: str, id_token: str) -> None: - super().__init__() - self.client_id = client_id self.id_token = id_token + super().__init__(client_id) - self._credentials_manager = CredentialsManager() - # load cached access token - access_token, expires_in, creator = self._credentials_manager.get_access_token() - - self._access_token = self._expires_in = None - if creator == JwtCreator.create(client_id, id_token): - # we must be sure that cached access token is created using the same client id and client secret. - # because client id and client secret could be passed via command, via env vars or via config file. - # we must not use cached access token if client id or client secret was changed. - self._access_token = access_token - self._expires_in = arrow.get(expires_in) if expires_in else None - - self._lock = Lock() - - def get_access_token(self) -> str: - with self._lock: - self.refresh_access_token_if_needed() - return self._access_token - - def invalidate_access_token(self, in_storage: bool = False) -> None: - self._access_token = None - self._expires_in = None - - if in_storage: - self._credentials_manager.update_access_token(None, None, None) - - def refresh_access_token_if_needed(self) -> None: - if self._access_token is None or self._expires_in is None or arrow.utcnow() >= self._expires_in: - self.refresh_access_token() - - def refresh_access_token(self) -> None: + def _request_new_access_token(self) -> dict[str, Any]: auth_response = self.post( url_path='api/v1/auth/oidc/api-token', body={'client_id': self.client_id, 'id_token': self.id_token}, without_auth=True, hide_response_content_log=True, ) - auth_response_data = auth_response.json() - - self._access_token = auth_response_data['token'] - self._expires_in = arrow.utcnow().shift(seconds=auth_response_data['expires_in'] * 0.8) - - jwt_creator = JwtCreator.create(self.client_id, self.id_token) - self._credentials_manager.update_access_token(self._access_token, self._expires_in.timestamp(), jwt_creator) - - def get_request_headers(self, additional_headers: Optional[dict] = None, without_auth: bool = False) -> dict: - headers = super().get_request_headers(additional_headers=additional_headers) - - if not without_auth: - headers = self._add_auth_header(headers) - - return headers - - def _add_auth_header(self, headers: dict) -> dict: - headers['Authorization'] = f'Bearer {self.get_access_token()}' - return headers - - def _execute( - self, - *args, - **kwargs, - ) -> Response: - response = super()._execute(*args, **kwargs) - - # backend returns 200 and plain text. no way to catch it with .raise_for_status() - nginx_error_response = any(response.content.startswith(plain_error) for plain_error in _NGINX_PLAIN_ERRORS) - if response.status_code == 200 and nginx_error_response: - # if cached token is invalid, try to refresh it and retry the request - self.refresh_access_token() - response = super()._execute(*args, **kwargs) + return auth_response.json() - return response + def _create_jwt_creator(self) -> JwtCreator: + return JwtCreator.create(self.client_id, self.id_token) diff --git a/cycode/cyclient/cycode_token_based_client.py b/cycode/cyclient/cycode_token_based_client.py index ef538c89..bc2dc66e 100644 --- a/cycode/cyclient/cycode_token_based_client.py +++ b/cycode/cyclient/cycode_token_based_client.py @@ -1,97 +1,24 @@ -from threading import Lock -from typing import Optional +from typing import Any -import arrow -from requests import Response - -from cycode.cli.user_settings.credentials_manager import CredentialsManager from cycode.cli.user_settings.jwt_creator import JwtCreator -from cycode.cyclient.cycode_client import CycodeClient - -_NGINX_PLAIN_ERRORS = [ - b'Invalid JWT Token', - b'JWT Token Needed', - b'JWT Token validation failed', -] +from cycode.cyclient.base_token_auth_client import BaseTokenAuthClient -class CycodeTokenBasedClient(CycodeClient): +class CycodeTokenBasedClient(BaseTokenAuthClient): """Send requests with JWT.""" def __init__(self, client_id: str, client_secret: str) -> None: - super().__init__() self.client_secret = client_secret - self.client_id = client_id - - self._credentials_manager = CredentialsManager() - # load cached access token - access_token, expires_in, creator = self._credentials_manager.get_access_token() - - self._access_token = self._expires_in = None - if creator == JwtCreator.create(client_id, client_secret): - # we must be sure that cached access token is created using the same client id and client secret. - # because client id and client secret could be passed via command, via env vars or via config file. - # we must not use cached access token if client id or client secret was changed. - self._access_token = access_token - self._expires_in = arrow.get(expires_in) if expires_in else None - - self._lock = Lock() - - def get_access_token(self) -> str: - with self._lock: - self.refresh_access_token_if_needed() - return self._access_token - - def invalidate_access_token(self, in_storage: bool = False) -> None: - self._access_token = None - self._expires_in = None - - if in_storage: - self._credentials_manager.update_access_token(None, None, None) - - def refresh_access_token_if_needed(self) -> None: - if self._access_token is None or self._expires_in is None or arrow.utcnow() >= self._expires_in: - self.refresh_access_token() + super().__init__(client_id) - def refresh_access_token(self) -> None: + def _request_new_access_token(self) -> dict[str, Any]: auth_response = self.post( url_path='api/v1/auth/api-token', body={'clientId': self.client_id, 'secret': self.client_secret}, without_auth=True, hide_response_content_log=True, ) - auth_response_data = auth_response.json() - - self._access_token = auth_response_data['token'] - self._expires_in = arrow.utcnow().shift(seconds=auth_response_data['expires_in'] * 0.8) - - jwt_creator = JwtCreator.create(self.client_id, self.client_secret) - self._credentials_manager.update_access_token(self._access_token, self._expires_in.timestamp(), jwt_creator) - - def get_request_headers(self, additional_headers: Optional[dict] = None, without_auth: bool = False) -> dict: - headers = super().get_request_headers(additional_headers=additional_headers) - - if not without_auth: - headers = self._add_auth_header(headers) - - return headers - - def _add_auth_header(self, headers: dict) -> dict: - headers['Authorization'] = f'Bearer {self.get_access_token()}' - return headers - - def _execute( - self, - *args, - **kwargs, - ) -> Response: - response = super()._execute(*args, **kwargs) - - # backend returns 200 and plain text. no way to catch it with .raise_for_status() - nginx_error_response = any(response.content.startswith(plain_error) for plain_error in _NGINX_PLAIN_ERRORS) - if response.status_code == 200 and nginx_error_response: - # if cached token is invalid, try to refresh it and retry the request - self.refresh_access_token() - response = super()._execute(*args, **kwargs) + return auth_response.json() - return response + def _create_jwt_creator(self) -> JwtCreator: + return JwtCreator.create(self.client_id, self.client_secret) From 432bf66abc1d53b509d23f6bbcdbc60184707665 Mon Sep 17 00:00:00 2001 From: Mikita Okuneu Date: Tue, 2 Dec 2025 12:05:52 +0100 Subject: [PATCH 3/5] CM-55782: Update README --- README.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0cf2dc76..991ba56c 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ To install the Cycode CLI application on your local machine, perform the followi ./cycode ``` -3. Finally authenticate the CLI. There are three methods to set the Cycode client ID and client secret: +3. Finally authenticate the CLI. There are three methods to set the Cycode client ID and credentials (client secret or OIDC ID token): - [cycode auth](#using-the-auth-command) (**Recommended**) - [cycode configure](#using-the-configure-command) @@ -164,11 +164,15 @@ To install the Cycode CLI application on your local machine, perform the followi `Cycode Client ID []: 7fe5346b-xxxx-xxxx-xxxx-55157625c72d` -5. Enter your Cycode Client Secret value. +5. Enter your Cycode Client Secret value (skip if you plan to use an OIDC ID token). `Cycode Client Secret []: c1e24929-xxxx-xxxx-xxxx-8b08c1839a2e` -6. If the values were entered successfully, you'll see the following message: +6. Enter your Cycode OIDC ID Token value (optional). + + `Cycode ID Token []: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...` + +7. If the values were entered successfully, you'll see the following message: `Successfully configured CLI credentials!` @@ -193,6 +197,12 @@ and export CYCODE_CLIENT_SECRET={your Cycode Secret Key} ``` +If your organization uses OIDC authentication, you can provide the ID token instead (or in addition): + +```bash +export CYCODE_ID_TOKEN={your Cycode OIDC ID token} +``` + #### On Windows 1. From the Control Panel, navigate to the System menu: @@ -207,7 +217,7 @@ export CYCODE_CLIENT_SECRET={your Cycode Secret Key} environments variables button -4. Create `CYCODE_CLIENT_ID` and `CYCODE_CLIENT_SECRET` variables with values matching your ID and Secret Key, respectively: +4. Create `CYCODE_CLIENT_ID` and `CYCODE_CLIENT_SECRET` variables with values matching your ID and Secret Key, respectively. If you authenticate via OIDC, add `CYCODE_ID_TOKEN` with your OIDC ID token value as well: environment variables window @@ -321,6 +331,7 @@ The following are the options and commands available with the Cycode CLI applica | `-o`, `--output [rich\|text\|json\|table]` | Specify the output type. The default is `rich`. | | `--client-id TEXT` | Specify a Cycode client ID for this specific scan execution. | | `--client-secret TEXT` | Specify a Cycode client secret for this specific scan execution. | +| `--id-token TEXT` | Specify a Cycode OIDC ID token for this specific scan execution. | | `--install-completion` | Install completion for the current shell.. | | `--show-completion [bash\|zsh\|fish\|powershell\|pwsh]` | Show completion for the specified shell, to copy it or customize the installation. | | `-h`, `--help` | Show options for given command. | From cecfdb97023041bc32c8decc5de1127846490694 Mon Sep 17 00:00:00 2001 From: Mikita Okuneu Date: Tue, 2 Dec 2025 13:33:48 +0100 Subject: [PATCH 4/5] CM-55782: Fix formatting --- .../commands/configure/test_configure_command.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/cli/commands/configure/test_configure_command.py b/tests/cli/commands/configure/test_configure_command.py index bb24d3f2..0d763edd 100644 --- a/tests/cli/commands/configure/test_configure_command.py +++ b/tests/cli/commands/configure/test_configure_command.py @@ -36,7 +36,13 @@ def test_configure_command_no_exist_values_in_file(mocker: 'MockerFixture') -> N # side effect - multiple return values, each item in the list represents return of a call mocker.patch( 'typer.prompt', - side_effect=[api_url_user_input, app_url_user_input, client_id_user_input, client_secret_user_input, id_token_user_input], + side_effect=[ + api_url_user_input, + app_url_user_input, + client_id_user_input, + client_secret_user_input, + id_token_user_input, + ], ) mocked_update_credentials = mocker.patch( @@ -90,7 +96,13 @@ def test_configure_command_update_current_configs_in_files(mocker: 'MockerFixtur # side effect - multiple return values, each item in the list represents return of a call mocker.patch( 'typer.prompt', - side_effect=[api_url_user_input, app_url_user_input, client_id_user_input, client_secret_user_input, id_token_user_input], + side_effect=[ + api_url_user_input, + app_url_user_input, + client_id_user_input, + client_secret_user_input, + id_token_user_input, + ], ) mocked_update_credentials = mocker.patch( From a5eba089662a4366b01a83363897c6f997170eae Mon Sep 17 00:00:00 2001 From: Mikita Okuneu Date: Tue, 2 Dec 2025 14:04:23 +0100 Subject: [PATCH 5/5] CM-55782: Fix formatting --- cycode/cyclient/base_token_auth_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cycode/cyclient/base_token_auth_client.py b/cycode/cyclient/base_token_auth_client.py index 7be7deec..3f164836 100644 --- a/cycode/cyclient/base_token_auth_client.py +++ b/cycode/cyclient/base_token_auth_client.py @@ -98,4 +98,3 @@ def _create_jwt_creator(self) -> JwtCreator: @abstractmethod def _request_new_access_token(self) -> dict[str, Any]: """Return the authentication payload with token and expires_in.""" -