Skip to content

Commit 63d06ff

Browse files
CM-55782: Add OIDC authentication (#362)
1 parent a8b1e4a commit 63d06ff

File tree

15 files changed

+530
-107
lines changed

15 files changed

+530
-107
lines changed

README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ To install the Cycode CLI application on your local machine, perform the followi
101101
./cycode
102102
```
103103

104-
3. Finally authenticate the CLI. There are three methods to set the Cycode client ID and client secret:
104+
3. Finally authenticate the CLI. There are three methods to set the Cycode client ID and credentials (client secret or OIDC ID token):
105105

106106
- [cycode auth](#using-the-auth-command) (**Recommended**)
107107
- [cycode configure](#using-the-configure-command)
@@ -164,11 +164,15 @@ To install the Cycode CLI application on your local machine, perform the followi
164164

165165
`Cycode Client ID []: 7fe5346b-xxxx-xxxx-xxxx-55157625c72d`
166166

167-
5. Enter your Cycode Client Secret value.
167+
5. Enter your Cycode Client Secret value (skip if you plan to use an OIDC ID token).
168168

169169
`Cycode Client Secret []: c1e24929-xxxx-xxxx-xxxx-8b08c1839a2e`
170170

171-
6. If the values were entered successfully, you'll see the following message:
171+
6. Enter your Cycode OIDC ID Token value (optional).
172+
173+
`Cycode ID Token []: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...`
174+
175+
7. If the values were entered successfully, you'll see the following message:
172176
173177
`Successfully configured CLI credentials!`
174178
@@ -193,6 +197,12 @@ and
193197
export CYCODE_CLIENT_SECRET={your Cycode Secret Key}
194198
```
195199

200+
If your organization uses OIDC authentication, you can provide the ID token instead (or in addition):
201+
202+
```bash
203+
export CYCODE_ID_TOKEN={your Cycode OIDC ID token}
204+
```
205+
196206
#### On Windows
197207

198208
1. From the Control Panel, navigate to the System menu:
@@ -207,7 +217,7 @@ export CYCODE_CLIENT_SECRET={your Cycode Secret Key}
207217

208218
<img height="30" src="https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image3.png" alt="environments variables button"/>
209219

210-
4. Create `CYCODE_CLIENT_ID` and `CYCODE_CLIENT_SECRET` variables with values matching your ID and Secret Key, respectively:
220+
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:
211221

212222
<img height="100" src="https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/image4.png" alt="environment variables window"/>
213223

@@ -321,6 +331,7 @@ The following are the options and commands available with the Cycode CLI applica
321331
| `-o`, `--output [rich\|text\|json\|table]` | Specify the output type. The default is `rich`. |
322332
| `--client-id TEXT` | Specify a Cycode client ID for this specific scan execution. |
323333
| `--client-secret TEXT` | Specify a Cycode client secret for this specific scan execution. |
334+
| `--id-token TEXT` | Specify a Cycode OIDC ID token for this specific scan execution. |
324335
| `--install-completion` | Install completion for the current shell.. |
325336
| `--show-completion [bash\|zsh\|fish\|powershell\|pwsh]` | Show completion for the specified shell, to copy it or customize the installation. |
326337
| `-h`, `--help` | Show options for given command. |

cycode/cli/app.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,13 @@ def app_callback(
110110
rich_help_panel=_AUTH_RICH_HELP_PANEL,
111111
),
112112
] = None,
113+
id_token: Annotated[
114+
Optional[str],
115+
typer.Option(
116+
help='Specify a Cycode OIDC ID token for this specific scan execution.',
117+
rich_help_panel=_AUTH_RICH_HELP_PANEL,
118+
),
119+
] = None,
113120
_: Annotated[
114121
Optional[bool],
115122
typer.Option(
@@ -152,6 +159,7 @@ def app_callback(
152159

153160
ctx.obj['client_id'] = client_id
154161
ctx.obj['client_secret'] = client_secret
162+
ctx.obj['id_token'] = id_token
155163

156164
ctx.obj['progress_bar'] = get_progress_bar(hidden=no_progress_meter, sections=SCAN_PROGRESS_BAR_SECTIONS)
157165

cycode/cli/apps/auth/auth_common.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, RequestHttpError
55
from cycode.cli.user_settings.credentials_manager import CredentialsManager
66
from cycode.cli.utils.jwt_utils import get_user_and_tenant_ids_from_access_token
7+
from cycode.cyclient.cycode_oidc_based_client import CycodeOidcBasedClient
78
from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient
89

910
if TYPE_CHECKING:
@@ -13,9 +14,23 @@
1314
def get_authorization_info(ctx: 'Context') -> Optional[AuthInfo]:
1415
printer = ctx.obj.get('console_printer')
1516

16-
client_id, client_secret = ctx.obj.get('client_id'), ctx.obj.get('client_secret')
17+
client_id = ctx.obj.get('client_id')
18+
client_secret = ctx.obj.get('client_secret')
19+
id_token = ctx.obj.get('id_token')
20+
21+
credentials_manager = CredentialsManager()
22+
23+
auth_info = _try_oidc_authorization(ctx, printer, client_id, id_token)
24+
if auth_info:
25+
return auth_info
26+
1727
if not client_id or not client_secret:
18-
client_id, client_secret = CredentialsManager().get_credentials()
28+
stored_client_id, stored_id_token = credentials_manager.get_oidc_credentials()
29+
auth_info = _try_oidc_authorization(ctx, printer, stored_client_id, stored_id_token)
30+
if auth_info:
31+
return auth_info
32+
33+
client_id, client_secret = credentials_manager.get_credentials()
1934

2035
if not client_id or not client_secret:
2136
return None
@@ -32,3 +47,23 @@ def get_authorization_info(ctx: 'Context') -> Optional[AuthInfo]:
3247
printer.print_exception()
3348

3449
return None
50+
51+
52+
def _try_oidc_authorization(
53+
ctx: 'Context', printer: any, client_id: Optional[str], id_token: Optional[str]
54+
) -> Optional[AuthInfo]:
55+
if not client_id or not id_token:
56+
return None
57+
58+
try:
59+
access_token = CycodeOidcBasedClient(client_id, id_token).get_access_token()
60+
if not access_token:
61+
return None
62+
63+
user_id, tenant_id = get_user_and_tenant_ids_from_access_token(access_token)
64+
return AuthInfo(user_id=user_id, tenant_id=tenant_id)
65+
except (RequestHttpError, HttpUnauthorizedError):
66+
if ctx:
67+
printer.print_exception()
68+
69+
return None

cycode/cli/apps/configure/configure_command.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
get_app_url_input,
88
get_client_id_input,
99
get_client_secret_input,
10+
get_id_token_input,
1011
)
1112
from cycode.cli.console import console
1213
from cycode.cli.utils.sentry import add_breadcrumb
@@ -32,6 +33,7 @@ def configure_command() -> None:
3233
* APP URL: The base URL for Cycode's web application (for on-premise or EU installations)
3334
* Client ID: Your Cycode client ID for authentication
3435
* Client Secret: Your Cycode client secret for authentication
36+
* ID Token: Your Cycode ID token for authentication
3537
3638
Example usage:
3739
* `cycode configure`: Start interactive configuration
@@ -55,15 +57,22 @@ def configure_command() -> None:
5557
config_updated = True
5658

5759
current_client_id, current_client_secret = CREDENTIALS_MANAGER.get_credentials_from_file()
60+
_, current_id_token = CREDENTIALS_MANAGER.get_oidc_credentials_from_file()
5861
client_id = get_client_id_input(current_client_id)
5962
client_secret = get_client_secret_input(current_client_secret)
63+
id_token = get_id_token_input(current_id_token)
6064

6165
credentials_updated = False
6266
if _should_update_value(current_client_id, client_id) or _should_update_value(current_client_secret, client_secret):
6367
credentials_updated = True
6468
CREDENTIALS_MANAGER.update_credentials(client_id, client_secret)
6569

70+
oidc_credentials_updated = False
71+
if _should_update_value(current_client_id, client_id) or _should_update_value(current_id_token, id_token):
72+
oidc_credentials_updated = True
73+
CREDENTIALS_MANAGER.update_oidc_credentials(client_id, id_token)
74+
6675
if config_updated:
6776
console.print(get_urls_update_result_message())
68-
if credentials_updated:
77+
if credentials_updated or oidc_credentials_updated:
6978
console.print(get_credentials_update_result_message())

cycode/cli/apps/configure/prompts.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,14 @@ def get_api_url_input(current_api_url: Optional[str]) -> str:
4646
default = current_api_url
4747

4848
return typer.prompt(text=prompt_text, default=default, type=str)
49+
50+
51+
def get_id_token_input(current_id_token: Optional[str]) -> Optional[str]:
52+
prompt_text = 'Cycode ID Token'
53+
54+
prompt_suffix = ' []: '
55+
if current_id_token:
56+
prompt_suffix = f' [{obfuscate_text(current_id_token)}]: '
57+
58+
new_id_token = typer.prompt(text=prompt_text, prompt_suffix=prompt_suffix, default='', show_default=False)
59+
return new_id_token or current_id_token

cycode/cli/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
# env vars
66
CYCODE_CLIENT_ID_ENV_VAR_NAME = 'CYCODE_CLIENT_ID'
77
CYCODE_CLIENT_SECRET_ENV_VAR_NAME = 'CYCODE_CLIENT_SECRET'
8+
CYCODE_ID_TOKEN_ENV_VAR_NAME = 'CYCODE_ID_TOKEN'

cycode/cli/user_settings/credentials_manager.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
from pathlib import Path
33
from typing import Optional
44

5-
from cycode.cli.config import CYCODE_CLIENT_ID_ENV_VAR_NAME, CYCODE_CLIENT_SECRET_ENV_VAR_NAME
5+
from cycode.cli.config import (
6+
CYCODE_CLIENT_ID_ENV_VAR_NAME,
7+
CYCODE_CLIENT_SECRET_ENV_VAR_NAME,
8+
CYCODE_ID_TOKEN_ENV_VAR_NAME,
9+
)
610
from cycode.cli.user_settings.base_file_manager import BaseFileManager
711
from cycode.cli.user_settings.jwt_creator import JwtCreator
812
from cycode.cli.utils.sentry import setup_scope_from_access_token
@@ -15,6 +19,7 @@ class CredentialsManager(BaseFileManager):
1519

1620
CLIENT_ID_FIELD_NAME: str = 'cycode_client_id'
1721
CLIENT_SECRET_FIELD_NAME: str = 'cycode_client_secret'
22+
ID_TOKEN_FIELD_NAME: str = 'cycode_id_token'
1823
ACCESS_TOKEN_FIELD_NAME: str = 'cycode_access_token'
1924
ACCESS_TOKEN_EXPIRES_IN_FIELD_NAME: str = 'cycode_access_token_expires_in'
2025
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]]:
3843
client_secret = file_content.get(self.CLIENT_SECRET_FIELD_NAME)
3944
return client_id, client_secret
4045

46+
def get_oidc_credentials_from_file(self) -> tuple[Optional[str], Optional[str]]:
47+
file_content = self.read_file()
48+
client_id = file_content.get(self.CLIENT_ID_FIELD_NAME)
49+
id_token = file_content.get(self.ID_TOKEN_FIELD_NAME)
50+
return client_id, id_token
51+
52+
def get_oidc_credentials(self) -> tuple[Optional[str], Optional[str]]:
53+
client_id = os.getenv(CYCODE_CLIENT_ID_ENV_VAR_NAME)
54+
id_token = os.getenv(CYCODE_ID_TOKEN_ENV_VAR_NAME)
55+
56+
if client_id is not None and id_token is not None:
57+
return client_id, id_token
58+
59+
return self.get_oidc_credentials_from_file()
60+
61+
def update_oidc_credentials(self, client_id: str, id_token: str) -> None:
62+
file_content_to_update = {self.CLIENT_ID_FIELD_NAME: client_id, self.ID_TOKEN_FIELD_NAME: id_token}
63+
self.write_content_to_file(file_content_to_update)
64+
4165
def update_credentials(self, client_id: str, client_secret: str) -> None:
4266
file_content_to_update = {self.CLIENT_ID_FIELD_NAME: client_id, self.CLIENT_SECRET_FIELD_NAME: client_secret}
4367
self.write_content_to_file(file_content_to_update)

cycode/cli/utils/get_api_client.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,37 +14,59 @@
1414

1515

1616
def _get_cycode_client(
17-
create_client_func: callable, client_id: Optional[str], client_secret: Optional[str], hide_response_log: bool
17+
create_client_func: callable,
18+
client_id: Optional[str],
19+
client_secret: Optional[str],
20+
hide_response_log: bool,
21+
id_token: Optional[str] = None,
1822
) -> Union['ScanClient', 'ReportClient']:
23+
if client_id and id_token:
24+
return create_client_func(client_id, None, hide_response_log, id_token)
25+
26+
if not client_id or not id_token:
27+
oidc_client_id, oidc_id_token = _get_configured_oidc_credentials()
28+
if oidc_client_id and oidc_id_token:
29+
return create_client_func(oidc_client_id, None, hide_response_log, oidc_id_token)
30+
if oidc_id_token and not oidc_client_id:
31+
raise click.ClickException('Cycode client id needed for OIDC authentication.')
32+
1933
if not client_id or not client_secret:
2034
client_id, client_secret = _get_configured_credentials()
2135
if not client_id:
2236
raise click.ClickException('Cycode client id needed.')
2337
if not client_secret:
2438
raise click.ClickException('Cycode client secret is needed.')
2539

26-
return create_client_func(client_id, client_secret, hide_response_log)
40+
return create_client_func(client_id, client_secret, hide_response_log, None)
2741

2842

2943
def get_scan_cycode_client(ctx: 'typer.Context') -> 'ScanClient':
3044
client_id = ctx.obj.get('client_id')
3145
client_secret = ctx.obj.get('client_secret')
46+
id_token = ctx.obj.get('id_token')
3247
hide_response_log = not ctx.obj.get('show_secret', False)
33-
return _get_cycode_client(create_scan_client, client_id, client_secret, hide_response_log)
48+
return _get_cycode_client(create_scan_client, client_id, client_secret, hide_response_log, id_token)
3449

3550

3651
def get_report_cycode_client(ctx: 'typer.Context', hide_response_log: bool = True) -> 'ReportClient':
3752
client_id = ctx.obj.get('client_id')
3853
client_secret = ctx.obj.get('client_secret')
39-
return _get_cycode_client(create_report_client, client_id, client_secret, hide_response_log)
54+
id_token = ctx.obj.get('id_token')
55+
return _get_cycode_client(create_report_client, client_id, client_secret, hide_response_log, id_token)
4056

4157

4258
def get_import_sbom_cycode_client(ctx: 'typer.Context', hide_response_log: bool = True) -> 'ImportSbomClient':
4359
client_id = ctx.obj.get('client_id')
4460
client_secret = ctx.obj.get('client_secret')
45-
return _get_cycode_client(create_import_sbom_client, client_id, client_secret, hide_response_log)
61+
id_token = ctx.obj.get('id_token')
62+
return _get_cycode_client(create_import_sbom_client, client_id, client_secret, hide_response_log, id_token)
4663

4764

4865
def _get_configured_credentials() -> tuple[str, str]:
4966
credentials_manager = CredentialsManager()
5067
return credentials_manager.get_credentials()
68+
69+
70+
def _get_configured_oidc_credentials() -> tuple[Optional[str], Optional[str]]:
71+
credentials_manager = CredentialsManager()
72+
return credentials_manager.get_oidc_credentials()

0 commit comments

Comments
 (0)