33Customer agents read credentials from a file mounted on the pod filesystem.
44This flow is used when credential files are detected.
55
6+ In transparent TLS mode (TlsMode.TRANSPARENT) the OpenShell Gateway handles
7+ the mTLS handshake and credential injection. The agent reads only endpoint URLs
8+ from environment variables and never loads certificate or private key material.
9+
610Authentication flow:
7- - Tool discovery: mTLS client credentials → system-scoped token
8- - Tool invocation: mTLS + jwt-bearer grant → user-scoped token (principal propagation)
11+ - Standard mode: mTLS client credentials → system-scoped token
12+ - Transparent mode: plain HTTPS (gateway-injected mTLS) → system-scoped token
13+ - Tool invocation: jwt-bearer grant → user-scoped token (principal propagation)
914"""
1015
1116import json
2530 MCPTool ,
2631)
2732from sap_cloud_sdk .agentgateway ._token_cache import _TokenCache
33+ from sap_cloud_sdk .agentgateway .config import TlsMode
2834from sap_cloud_sdk .agentgateway .exceptions import AgentGatewaySDKError
2935
3036logger = logging .getLogger (__name__ )
4248_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
4349_GRANT_TYPE_JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer"
4450
51+ # Environment variables for transparent mode
52+ _ENV_CLIENT_ID = "CLIENT_ID"
53+ _ENV_TOKEN_SERVICE_URL = "TOKEN_SERVICE_URL"
54+ _ENV_GATEWAY_URL = "GATEWAY_URL"
55+ _ENV_INTEGRATION_DEPENDENCIES = "INTEGRATION_DEPENDENCIES"
56+
4557
4658def _cache_scope_key (credentials : CustomerCredentials , app_tid : str | None ) -> str :
4759 """Build a cache scope key for customer-flow tokens."""
@@ -62,16 +74,30 @@ class _CredentialFields:
6274 GLOBAL_TENANT_ID = "globalTenantId"
6375
6476
65- def detect_customer_agent_credentials () -> str | None :
77+ def detect_customer_agent_credentials (
78+ tls_mode : TlsMode = TlsMode .STANDARD ,
79+ ) -> str | None :
6680 """Check if customer agent credentials file exists.
6781
68- Checks for credential file in the following order:
82+ In transparent mode the agent never uses a credentials file — the OpenShell
83+ Gateway injects certificates at the TLS layer and the agent reads only
84+ endpoint URLs from environment variables. Returns None immediately so the
85+ caller falls through to load_customer_credentials_from_env().
86+
87+ In standard mode checks for a credential file in the following order:
6988 1. Path specified in AGW_CREDENTIALS_PATH env var
7089 2. Default mounted path: /etc/ums/credentials/credentials
7190
91+ Args:
92+ tls_mode: TLS handling mode. When TRANSPARENT, skips file detection.
93+
7294 Returns:
73- Path to credentials file if found, None otherwise.
95+ Path to credentials file if found (standard mode only) , None otherwise.
7496 """
97+ if tls_mode == TlsMode .TRANSPARENT :
98+ logger .debug ("TLS_MODE=transparent: skipping credentials file detection" )
99+ return None
100+
75101 # Check env var first (path may be customized)
76102 path_from_env = os .environ .get (_CREDENTIALS_PATH_ENV )
77103 if path_from_env and os .path .isfile (path_from_env ):
@@ -161,6 +187,63 @@ def load_customer_credentials(path: str) -> CustomerCredentials:
161187 )
162188
163189
190+ def load_customer_credentials_from_env () -> CustomerCredentials :
191+ """Load customer credentials from environment variables (transparent mode).
192+
193+ Used when TlsMode.TRANSPARENT is active and the OpenShell Gateway handles
194+ mTLS. Certificate and private key are not required — they are injected at
195+ the TLS layer by the gateway.
196+
197+ Environment variables:
198+ CLIENT_ID: IAS client ID (may be a gateway-resolved placeholder at runtime)
199+ TOKEN_SERVICE_URL: IAS token service endpoint
200+ GATEWAY_URL: Agent Gateway base URL
201+ INTEGRATION_DEPENDENCIES: JSON array of {ordId, globalTenantId} objects
202+
203+ Returns:
204+ CustomerCredentials with certificate and private_key set to None.
205+
206+ Raises:
207+ AgentGatewaySDKError: If required environment variables are missing or
208+ INTEGRATION_DEPENDENCIES cannot be parsed.
209+ """
210+ required = [_ENV_CLIENT_ID , _ENV_TOKEN_SERVICE_URL , _ENV_GATEWAY_URL ]
211+ missing = [v for v in required if not os .environ .get (v )]
212+ if missing :
213+ raise AgentGatewaySDKError (
214+ f"TLS_MODE=transparent requires environment variables: { missing } "
215+ )
216+
217+ raw_deps = os .environ .get (_ENV_INTEGRATION_DEPENDENCIES , "[]" )
218+ try :
219+ deps_data = json .loads (raw_deps )
220+ integration_dependencies = [
221+ IntegrationDependency (
222+ ord_id = dep [_CredentialFields .ORD_ID ],
223+ global_tenant_id = dep [_CredentialFields .GLOBAL_TENANT_ID ],
224+ )
225+ for dep in deps_data
226+ ]
227+ except (json .JSONDecodeError , KeyError , TypeError ) as e :
228+ raise AgentGatewaySDKError (
229+ f"Failed to parse INTEGRATION_DEPENDENCIES: { e } . "
230+ 'Expected format: [{"ordId": "...", "globalTenantId": "..."}]'
231+ )
232+
233+ logger .debug (
234+ "Loaded %d integration dependencies from environment" , len (integration_dependencies )
235+ )
236+
237+ return CustomerCredentials (
238+ token_service_url = os .environ [_ENV_TOKEN_SERVICE_URL ],
239+ client_id = os .environ [_ENV_CLIENT_ID ],
240+ certificate = None ,
241+ private_key = None ,
242+ gateway_url = os .environ [_ENV_GATEWAY_URL ].rstrip ("/" ),
243+ integration_dependencies = integration_dependencies ,
244+ )
245+
246+
164247def _create_ssl_context (certificate : str , private_key : str ) -> ssl .SSLContext :
165248 """Create SSL context for mTLS from in-memory certificate and key.
166249
@@ -212,6 +295,106 @@ def _create_ssl_context(certificate: str, private_key: str) -> ssl.SSLContext:
212295 os .unlink (key_file .name )
213296
214297
298+ def _create_http_client_transparent (timeout : float ) -> httpx .Client :
299+ """Create an HTTP client for transparent TLS mode.
300+
301+ The OpenShell Gateway handles TLS and mTLS on behalf of the agent, so no
302+ SSL context is configured. All HTTPS requests are routed through the proxy
303+ set in the HTTPS_PROXY environment variable.
304+
305+ Args:
306+ timeout: HTTP timeout in seconds.
307+
308+ Returns:
309+ httpx.Client without any custom SSL context.
310+ """
311+ return httpx .Client (timeout = timeout )
312+
313+
314+ def _request_token_transparent (
315+ credentials : CustomerCredentials ,
316+ grant_type : str ,
317+ timeout : float ,
318+ app_tid : str | None = None ,
319+ extra_data : dict | None = None ,
320+ ) -> dict :
321+ """Make a token request in transparent TLS mode.
322+
323+ The OpenShell Gateway intercepts the HTTPS connection and:
324+ 1. Injects the client certificate at the TLS layer (mTLS handshake).
325+ 2. Rewrites the client_id placeholder in the request body.
326+
327+ The agent sends the OAuth2 POST body without loading any certificate or key.
328+
329+ Args:
330+ credentials: Customer credentials (certificate/private_key are None).
331+ grant_type: OAuth2 grant type.
332+ timeout: HTTP timeout in seconds.
333+ app_tid: BTP Application Tenant ID of subscriber (optional).
334+ extra_data: Additional form data for the token request.
335+
336+ Returns:
337+ Token response payload.
338+
339+ Raises:
340+ AgentGatewaySDKError: If token request fails.
341+ """
342+ data : dict = {
343+ "client_id" : credentials .client_id ,
344+ "grant_type" : grant_type ,
345+ "resource" : _AGW_RESOURCE_URN ,
346+ }
347+
348+ if app_tid :
349+ data ["app_tid" ] = app_tid
350+
351+ if extra_data :
352+ data .update (extra_data )
353+
354+ logger .debug (
355+ "Requesting token (transparent mode) from %s with grant_type=%s" ,
356+ credentials .token_service_url ,
357+ grant_type ,
358+ )
359+
360+ try :
361+ with _create_http_client_transparent (timeout ) as client :
362+ response = client .post (
363+ credentials .token_service_url ,
364+ data = data ,
365+ headers = {
366+ "Content-Type" : "application/x-www-form-urlencoded" ,
367+ "Accept" : "application/json" ,
368+ },
369+ )
370+
371+ if response .status_code != 200 :
372+ logger .error (
373+ "Token request failed with status %d: %s" ,
374+ response .status_code ,
375+ response .text [:500 ],
376+ )
377+ raise AgentGatewaySDKError (
378+ f"Token request failed with status { response .status_code } : { response .text [:200 ]} "
379+ )
380+
381+ token_data = response .json ()
382+ access_token = token_data .get ("access_token" )
383+
384+ if not access_token :
385+ raise AgentGatewaySDKError (
386+ f"Token response missing 'access_token'. Keys: { list (token_data .keys ())} "
387+ )
388+
389+ logger .debug (
390+ "Token acquired successfully (transparent mode, length: %d)" , len (access_token )
391+ )
392+ return token_data
393+
394+ except httpx .RequestError as e :
395+ raise AgentGatewaySDKError (f"Token request failed: { e } " )
396+
397+
215398def _request_token_mtls (
216399 credentials : CustomerCredentials ,
217400 grant_type : str ,
@@ -341,6 +524,53 @@ def get_system_token_mtls(
341524 return access_token
342525
343526
527+ def get_system_token_transparent (
528+ credentials : CustomerCredentials ,
529+ timeout : float ,
530+ app_tid : str | None = None ,
531+ token_cache : _TokenCache | None = None ,
532+ ) -> str :
533+ """Get system-scoped token in transparent TLS mode.
534+
535+ Equivalent to get_system_token_mtls() but uses _request_token_transparent()
536+ so the agent never performs a TLS handshake directly.
537+
538+ Args:
539+ credentials: Customer credentials loaded from environment variables.
540+ timeout: HTTP timeout in seconds.
541+ app_tid: BTP Application Tenant ID of subscriber (optional).
542+ token_cache: Optional token cache.
543+
544+ Returns:
545+ System-scoped access token, fetched or served from cache.
546+ """
547+ scope_key = _cache_scope_key (credentials , app_tid )
548+ if token_cache :
549+ cached_token = token_cache .get_system_token (scope_key )
550+ if cached_token :
551+ logger .debug ("Using cached system token for scope '%s'" , scope_key )
552+ return cached_token
553+
554+ logger .info ("Acquiring system token via transparent mode (gateway-injected mTLS)" )
555+ token_data = _request_token_transparent (
556+ credentials ,
557+ grant_type = _GRANT_TYPE_CLIENT_CREDENTIALS ,
558+ timeout = timeout ,
559+ app_tid = app_tid ,
560+ extra_data = {"response_type" : "token" },
561+ )
562+ access_token = token_data ["access_token" ]
563+
564+ if token_cache :
565+ token_cache .set_system_token (
566+ access_token ,
567+ token_cache .compute_expires_at (token_data ),
568+ scope_key ,
569+ )
570+
571+ return access_token
572+
573+
344574def exchange_user_token (
345575 credentials : CustomerCredentials ,
346576 user_token : str ,
@@ -395,6 +625,61 @@ def exchange_user_token(
395625 return access_token
396626
397627
628+ def exchange_user_token_transparent (
629+ credentials : CustomerCredentials ,
630+ user_token : str ,
631+ timeout : float ,
632+ app_tid : str | None = None ,
633+ token_cache : _TokenCache | None = None ,
634+ ) -> str :
635+ """Exchange user token for AGW-scoped token in transparent TLS mode.
636+
637+ Equivalent to exchange_user_token() but uses _request_token_transparent()
638+ so the agent never performs a TLS handshake directly.
639+
640+ Args:
641+ credentials: Customer credentials loaded from environment variables.
642+ user_token: User's JWT token to exchange.
643+ timeout: HTTP timeout in seconds.
644+ app_tid: BTP Application Tenant ID of subscriber (optional).
645+ token_cache: Optional token cache.
646+
647+ Returns:
648+ AGW-scoped access token with user identity, fetched or served from cache.
649+ """
650+ scope_key = _cache_scope_key (credentials , app_tid )
651+ if token_cache :
652+ cached_token = token_cache .get_user_token (user_token , scope_key )
653+ if cached_token :
654+ logger .debug ("Using cached exchanged user token for scope '%s'" , scope_key )
655+ return cached_token
656+
657+ logger .info (
658+ "Exchanging user token for AGW-scoped token via jwt-bearer grant (transparent mode)"
659+ )
660+ token_data = _request_token_transparent (
661+ credentials ,
662+ grant_type = _GRANT_TYPE_JWT_BEARER ,
663+ timeout = timeout ,
664+ app_tid = app_tid ,
665+ extra_data = {
666+ "assertion" : user_token ,
667+ "token_format" : "jwt" ,
668+ },
669+ )
670+ access_token = token_data ["access_token" ]
671+
672+ if token_cache :
673+ token_cache .set_user_token (
674+ user_token ,
675+ access_token ,
676+ token_cache .compute_expires_at (token_data ),
677+ scope_key ,
678+ )
679+
680+ return access_token
681+
682+
398683def _build_mcp_url (gateway_url : str , ord_id : str , gt_id : str ) -> str :
399684 """Build MCP server URL from gateway URL, ord_id, and gt_id.
400685
0 commit comments