From 9c14eb31dc074278bbe501fef70a0d819352647f Mon Sep 17 00:00:00 2001 From: Michael Shin Date: Mon, 15 Sep 2025 16:39:32 -0400 Subject: [PATCH 1/6] Add support for service accounts --- centml/cli/login.py | 11 +++++++++++ centml/sdk/api.py | 3 +++ centml/sdk/auth.py | 45 +++++++++++++++++++++++++++++++++++++++++--- centml/sdk/config.py | 8 ++++++++ 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/centml/cli/login.py b/centml/cli/login.py index dd4c7ed..321cf40 100644 --- a/centml/cli/login.py +++ b/centml/cli/login.py @@ -12,6 +12,7 @@ from centml.sdk import auth +from centml.sdk.api import get_centml_client from centml.sdk.config import settings @@ -95,6 +96,16 @@ def login(token_file): if token_file: auth.store_centml_cred(token_file) + # Try client credentials flow first if SERVICE_ACCOUNT_ID and SERVICE_ACCOUNT_SECRET are set + if settings.CENTML_SERVICE_ACCOUNT_ID and settings.CENTML_SERVICE_ACCOUNT_SECRET: + click.echo("Authenticating with client credentials...") + access_token = auth.authenticate_with_client_credentials() + if access_token: + with get_centml_client() as cclient: + cclient.initialize_user() + click.echo("✅ Login successful with client credentials") + return + cred = auth.load_centml_cred() if cred is not None and auth.refresh_centml_token(cred.get("refresh_token")): click.echo("Authenticating with stored credentials...\n") diff --git a/centml/sdk/api.py b/centml/sdk/api.py index 8e77548..1823dcb 100644 --- a/centml/sdk/api.py +++ b/centml/sdk/api.py @@ -134,6 +134,9 @@ def get_deployment_usage( step=step, ).values + def initialize_user(self): + return self._api.setup_stripe_customer_payments_setup_post() + @contextmanager def get_centml_client(): diff --git a/centml/sdk/auth.py b/centml/sdk/auth.py index 4547edf..606cefa 100644 --- a/centml/sdk/auth.py +++ b/centml/sdk/auth.py @@ -64,13 +64,52 @@ def get_centml_token(): exp_time = int(jwt.decode(cred["access_token"], options={"verify_signature": False})["exp"]) if time.time() >= exp_time - 100: - cred = refresh_centml_token(cred["refresh_token"]) - if cred is None: - sys.exit("Could not refresh credentials. Please login and try again...") + # Check if we have a refresh token (interactive flow) or should use client credentials + refresh_token = cred.get("refresh_token") + if refresh_token is not None: + # Use refresh token for interactive authentication + cred = refresh_centml_token(cred["refresh_token"]) + if cred is None: + sys.exit("Could not refresh credentials. Please login and try again...") + else: + # No refresh token - try client credentials flow + access_token = authenticate_with_client_credentials() + if access_token is not None: + cred = {"access_token": access_token} + else: + sys.exit("Could not refresh credentials. Please login and try again...") return cred["access_token"] +def authenticate_with_client_credentials(): + """ + Authenticate using client credentials flow for service-to-service authentication. + Returns access token if successful, None otherwise. + """ + if not settings.CENTML_SERVICE_ACCOUNT_ID or not settings.CENTML_SERVICE_ACCOUNT_SECRET: + return None + + params = { + 'grant_type': 'client_credentials', + 'client_id': settings.CENTML_SERVICE_ACCOUNT_ID, + 'client_secret': settings.CENTML_SERVICE_ACCOUNT_SECRET, + 'scope': 'openid profile email', + } + response = requests.post(settings.CENTML_SERVICE_ACCOUNT_TOKEN_URL, data=params, timeout=10) + response.raise_for_status() + response_data = response.json() + access_token = response_data.get('access_token') + if access_token: + # Store the access token (without refresh token for client credentials) + cred = {'access_token': access_token} + os.makedirs(os.path.dirname(settings.CENTML_CRED_FILE_PATH), exist_ok=True) + with open(settings.CENTML_CRED_FILE_PATH, "w") as f: + json.dump(cred, f) + return access_token + return None + + def remove_centml_cred(): if os.path.exists(settings.CENTML_CRED_FILE_PATH): os.remove(settings.CENTML_CRED_FILE_PATH) diff --git a/centml/sdk/config.py b/centml/sdk/config.py index 3f935c0..3e8b6ea 100644 --- a/centml/sdk/config.py +++ b/centml/sdk/config.py @@ -1,4 +1,5 @@ import os +from typing import Optional from pathlib import Path from pydantic_settings import BaseSettings, SettingsConfigDict @@ -16,5 +17,12 @@ class Config(BaseSettings): CENTML_WORKOS_CLIENT_ID: str = os.getenv("CENTML_WORKOS_CLIENT_ID", default="client_01JP5TWW2997MF8AYQXHJEGYR0") + # Long-term credentials - can be set via environment variables + CENTML_SERVICE_ACCOUNT_SECRET: Optional[str] = os.getenv("CENTML_SERVICE_ACCOUNT_SECRET", default=None) + CENTML_SERVICE_ACCOUNT_ID: Optional[str] = os.getenv("CENTML_SERVICE_ACCOUNT_ID", default=None) + CENTML_SERVICE_ACCOUNT_TOKEN_URL: str = os.getenv( + "CENTML_SERVICE_ACCOUNT_TOKEN_URL", default="https://signin.centml.com/oauth2/token" + ) + settings = Config() From 7f030dc346848c2d434aa76a1cf256ee18c08e1f Mon Sep 17 00:00:00 2001 From: Michael Shin Date: Mon, 15 Sep 2025 16:41:18 -0400 Subject: [PATCH 2/6] update version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f49fc7f..75d7d65 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='centml', - version='0.4.4', + version='0.4.5', packages=find_packages(), python_requires=">=3.10", long_description=open('README.md').read(), From c9e4cd3d2c82421ed3e471ffb9c99f0a6d316b00 Mon Sep 17 00:00:00 2001 From: Michael Shin Date: Mon, 15 Sep 2025 17:14:54 -0400 Subject: [PATCH 3/6] address comments --- centml/cli/login.py | 12 ++++-------- centml/sdk/api.py | 20 ++++++++++++++++++++ centml/sdk/auth.py | 28 +++++++++++++--------------- 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/centml/cli/login.py b/centml/cli/login.py index 321cf40..d595277 100644 --- a/centml/cli/login.py +++ b/centml/cli/login.py @@ -12,7 +12,7 @@ from centml.sdk import auth -from centml.sdk.api import get_centml_client +from centml.sdk.api import initialize_client_credentials_login from centml.sdk.config import settings @@ -97,14 +97,10 @@ def login(token_file): auth.store_centml_cred(token_file) # Try client credentials flow first if SERVICE_ACCOUNT_ID and SERVICE_ACCOUNT_SECRET are set - if settings.CENTML_SERVICE_ACCOUNT_ID and settings.CENTML_SERVICE_ACCOUNT_SECRET: + if initialize_client_credentials_login(): click.echo("Authenticating with client credentials...") - access_token = auth.authenticate_with_client_credentials() - if access_token: - with get_centml_client() as cclient: - cclient.initialize_user() - click.echo("✅ Login successful with client credentials") - return + click.echo("✅ Login successful with client credentials") + return cred = auth.load_centml_cred() if cred is not None and auth.refresh_centml_token(cred.get("refresh_token")): diff --git a/centml/sdk/api.py b/centml/sdk/api.py index 1823dcb..acb762c 100644 --- a/centml/sdk/api.py +++ b/centml/sdk/api.py @@ -138,6 +138,26 @@ def initialize_user(self): return self._api.setup_stripe_customer_payments_setup_post() +def initialize_client_credentials_login(): + if not settings.CENTML_SERVICE_ACCOUNT_ID or not settings.CENTML_SERVICE_ACCOUNT_SECRET: + return False + + access_token = auth.authenticate_with_client_credentials() + if access_token: + # Create a temporary client for client initialization + configuration = platform_api_python_client.Configuration( + host=settings.CENTML_PLATFORM_API_URL, access_token=access_token + ) + + with platform_api_python_client.ApiClient(configuration) as api_client: + api_instance = platform_api_python_client.EXTERNALApi(api_client) + client = CentMLClient(api_instance) + client.initialize_user() + + return True + return False + + @contextmanager def get_centml_client(): configuration = platform_api_python_client.Configuration( diff --git a/centml/sdk/auth.py b/centml/sdk/auth.py index 606cefa..7af9bb6 100644 --- a/centml/sdk/auth.py +++ b/centml/sdk/auth.py @@ -58,13 +58,22 @@ def load_centml_cred(): def get_centml_token(): + # Always use fresh client credentials if available + if settings.CENTML_SERVICE_ACCOUNT_ID and settings.CENTML_SERVICE_ACCOUNT_SECRET: + access_token = authenticate_with_client_credentials() + if access_token is not None: + return access_token + else: + sys.exit("Could not authenticate with client credentials. Please check your service account configuration...") + + # Fall back to stored credentials for interactive flows cred = load_centml_cred() if not cred: sys.exit("CentML credentials not found. Please login...") exp_time = int(jwt.decode(cred["access_token"], options={"verify_signature": False})["exp"]) if time.time() >= exp_time - 100: - # Check if we have a refresh token (interactive flow) or should use client credentials + # Check if we have a refresh token (interactive flow) refresh_token = cred.get("refresh_token") if refresh_token is not None: # Use refresh token for interactive authentication @@ -72,12 +81,7 @@ def get_centml_token(): if cred is None: sys.exit("Could not refresh credentials. Please login and try again...") else: - # No refresh token - try client credentials flow - access_token = authenticate_with_client_credentials() - if access_token is not None: - cred = {"access_token": access_token} - else: - sys.exit("Could not refresh credentials. Please login and try again...") + sys.exit("Could not refresh credentials. Please login and try again...") return cred["access_token"] @@ -100,14 +104,8 @@ def authenticate_with_client_credentials(): response.raise_for_status() response_data = response.json() access_token = response_data.get('access_token') - if access_token: - # Store the access token (without refresh token for client credentials) - cred = {'access_token': access_token} - os.makedirs(os.path.dirname(settings.CENTML_CRED_FILE_PATH), exist_ok=True) - with open(settings.CENTML_CRED_FILE_PATH, "w") as f: - json.dump(cred, f) - return access_token - return None + return access_token + def remove_centml_cred(): From 497e77df151441e3f7d1c9888e7772affaf9b282 Mon Sep 17 00:00:00 2001 From: Michael Shin Date: Mon, 15 Sep 2025 17:16:45 -0400 Subject: [PATCH 4/6] lint --- centml/sdk/api.py | 6 +++--- centml/sdk/auth.py | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/centml/sdk/api.py b/centml/sdk/api.py index acb762c..945238a 100644 --- a/centml/sdk/api.py +++ b/centml/sdk/api.py @@ -141,19 +141,19 @@ def initialize_user(self): def initialize_client_credentials_login(): if not settings.CENTML_SERVICE_ACCOUNT_ID or not settings.CENTML_SERVICE_ACCOUNT_SECRET: return False - + access_token = auth.authenticate_with_client_credentials() if access_token: # Create a temporary client for client initialization configuration = platform_api_python_client.Configuration( host=settings.CENTML_PLATFORM_API_URL, access_token=access_token ) - + with platform_api_python_client.ApiClient(configuration) as api_client: api_instance = platform_api_python_client.EXTERNALApi(api_client) client = CentMLClient(api_instance) client.initialize_user() - + return True return False diff --git a/centml/sdk/auth.py b/centml/sdk/auth.py index 7af9bb6..01e85cb 100644 --- a/centml/sdk/auth.py +++ b/centml/sdk/auth.py @@ -64,8 +64,10 @@ def get_centml_token(): if access_token is not None: return access_token else: - sys.exit("Could not authenticate with client credentials. Please check your service account configuration...") - + sys.exit( + "Could not authenticate with client credentials. Please check your service account configuration..." + ) + # Fall back to stored credentials for interactive flows cred = load_centml_cred() if not cred: @@ -107,7 +109,6 @@ def authenticate_with_client_credentials(): return access_token - def remove_centml_cred(): if os.path.exists(settings.CENTML_CRED_FILE_PATH): os.remove(settings.CENTML_CRED_FILE_PATH) From f3b362de32ea9ee4659b90c7bb9170609e3de7d7 Mon Sep 17 00:00:00 2001 From: Michael Shin Date: Mon, 15 Sep 2025 17:29:11 -0400 Subject: [PATCH 5/6] more update --- centml/cli/login.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/centml/cli/login.py b/centml/cli/login.py index d595277..dd4c7ed 100644 --- a/centml/cli/login.py +++ b/centml/cli/login.py @@ -12,7 +12,6 @@ from centml.sdk import auth -from centml.sdk.api import initialize_client_credentials_login from centml.sdk.config import settings @@ -96,12 +95,6 @@ def login(token_file): if token_file: auth.store_centml_cred(token_file) - # Try client credentials flow first if SERVICE_ACCOUNT_ID and SERVICE_ACCOUNT_SECRET are set - if initialize_client_credentials_login(): - click.echo("Authenticating with client credentials...") - click.echo("✅ Login successful with client credentials") - return - cred = auth.load_centml_cred() if cred is not None and auth.refresh_centml_token(cred.get("refresh_token")): click.echo("Authenticating with stored credentials...\n") From ec753c79586cc7a2bd6d217b4a4ccfd7e5f322ff Mon Sep 17 00:00:00 2001 From: Michael Shin Date: Tue, 16 Sep 2025 11:09:17 -0400 Subject: [PATCH 6/6] remove unused function --- centml/sdk/api.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/centml/sdk/api.py b/centml/sdk/api.py index 945238a..1823dcb 100644 --- a/centml/sdk/api.py +++ b/centml/sdk/api.py @@ -138,26 +138,6 @@ def initialize_user(self): return self._api.setup_stripe_customer_payments_setup_post() -def initialize_client_credentials_login(): - if not settings.CENTML_SERVICE_ACCOUNT_ID or not settings.CENTML_SERVICE_ACCOUNT_SECRET: - return False - - access_token = auth.authenticate_with_client_credentials() - if access_token: - # Create a temporary client for client initialization - configuration = platform_api_python_client.Configuration( - host=settings.CENTML_PLATFORM_API_URL, access_token=access_token - ) - - with platform_api_python_client.ApiClient(configuration) as api_client: - api_instance = platform_api_python_client.EXTERNALApi(api_client) - client = CentMLClient(api_instance) - client.initialize_user() - - return True - return False - - @contextmanager def get_centml_client(): configuration = platform_api_python_client.Configuration(