Skip to content

Commit 684859a

Browse files
committed
feat(agw): new customer agents auth
1 parent 101492b commit 684859a

6 files changed

Lines changed: 726 additions & 18 deletions

File tree

src/sap_cloud_sdk/agentgateway/_customer.py

Lines changed: 290 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33
Customer agents read credentials from a file mounted on the pod filesystem.
44
This 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+
610
Authentication 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

1116
import json
@@ -25,6 +30,7 @@
2530
MCPTool,
2631
)
2732
from sap_cloud_sdk.agentgateway._token_cache import _TokenCache
33+
from sap_cloud_sdk.agentgateway.config import TlsMode
2834
from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError
2935

3036
logger = logging.getLogger(__name__)
@@ -42,6 +48,12 @@
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

4658
def _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+
164247
def _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+
215398
def _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+
344574
def 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+
398683
def _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

src/sap_cloud_sdk/agentgateway/_models.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,21 +74,23 @@ class IntegrationDependency:
7474
class CustomerCredentials:
7575
"""Credentials for customer agent mTLS authentication.
7676
77-
Loaded from the credentials file mounted on the pod filesystem.
78-
Used internally by the customer agent flow.
77+
Loaded from the credentials file mounted on the pod filesystem, or from
78+
environment variables when TlsMode.TRANSPARENT is active (in which case
79+
certificate and private_key are None — the OpenShell Gateway injects them
80+
at the TLS layer).
7981
8082
Attributes:
8183
token_service_url: IAS token service endpoint URL
8284
client_id: IAS client ID
83-
certificate: PEM-encoded client certificate
84-
private_key: PEM-encoded private key
85+
certificate: PEM-encoded client certificate. None in transparent mode.
86+
private_key: PEM-encoded private key. None in transparent mode.
8587
gateway_url: Agent Gateway base URL
8688
integration_dependencies: List of MCP servers with their ord_id and global_tenant_id.
8789
"""
8890

8991
token_service_url: str
9092
client_id: str
91-
certificate: str
92-
private_key: str
93+
certificate: str | None
94+
private_key: str | None
9395
gateway_url: str
9496
integration_dependencies: list[IntegrationDependency]

0 commit comments

Comments
 (0)