workflow-exec-engine 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- workflow_engine/__init__.py +95 -0
- workflow_engine/client/__init__.py +47 -0
- workflow_engine/client/a2a_transport.py +560 -0
- workflow_engine/client/agentcard_normalizer.py +106 -0
- workflow_engine/client/auth_manager.py +127 -0
- workflow_engine/client/auth_provider.py +47 -0
- workflow_engine/client/credential_crypto.py +102 -0
- workflow_engine/client/credential_service.py +229 -0
- workflow_engine/client/engine_client.py +374 -0
- workflow_engine/client/env_file_loader.py +68 -0
- workflow_engine/client/extension_handlers.py +197 -0
- workflow_engine/client/extension_interceptor.py +76 -0
- workflow_engine/client/extension_sender.py +203 -0
- workflow_engine/client/extensions.py +43 -0
- workflow_engine/client/protocol_logger.py +78 -0
- workflow_engine/client/sse_normalization.py +87 -0
- workflow_engine/client/ssl_context.py +84 -0
- workflow_engine/client/stub_engine_client.py +68 -0
- workflow_engine/control/__init__.py +26 -0
- workflow_engine/control/control_points.py +223 -0
- workflow_engine/core/__init__.py +34 -0
- workflow_engine/core/context_builder.py +101 -0
- workflow_engine/core/executor.py +278 -0
- workflow_engine/core/models.py +184 -0
- workflow_engine/registry/__init__.py +21 -0
- workflow_engine/registry/registry_client.py +177 -0
- workflow_engine/runner.py +247 -0
- workflow_exec_engine-0.0.2.dist-info/METADATA +309 -0
- workflow_exec_engine-0.0.2.dist-info/RECORD +32 -0
- workflow_exec_engine-0.0.2.dist-info/WHEEL +5 -0
- workflow_exec_engine-0.0.2.dist-info/licenses/LICENSE +17 -0
- workflow_exec_engine-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Copyright (c) 2026 Huawei Technologies Co., Ltd.
|
|
2
|
+
# All Rights Reserved.
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
#
|
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
7
|
+
# not use this file except in compliance with the License. You may obtain
|
|
8
|
+
# a copy of the License at
|
|
9
|
+
#
|
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
#
|
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
14
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
15
|
+
# License for the specific language governing permissions and limitations
|
|
16
|
+
# under the License.
|
|
17
|
+
|
|
18
|
+
"""Auth manager - builds interceptors from AgentCard, self-contained."""
|
|
19
|
+
|
|
20
|
+
from typing import Dict, Any, List, Optional
|
|
21
|
+
from loguru import logger
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from a2a.client.auth import AuthInterceptor
|
|
25
|
+
from a2a.client.interceptors import ClientCallInterceptor, BeforeArgs, AfterArgs
|
|
26
|
+
_A2A_AUTH_AVAILABLE = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
_A2A_AUTH_AVAILABLE = False
|
|
29
|
+
AuthInterceptor = None
|
|
30
|
+
ClientCallInterceptor = None
|
|
31
|
+
BeforeArgs = None
|
|
32
|
+
AfterArgs = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
from workflow_engine.client.credential_service import AgentAuthManager, CustomAuthInterceptor
|
|
36
|
+
from workflow_engine.client.extension_interceptor import ExtensionInterceptor
|
|
37
|
+
from workflow_engine.client.auth_provider import AuthProvider
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthManager:
|
|
41
|
+
"""Builds auth/extension interceptors from AgentCard securitySchemes."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, agent_cards: List[Any], credentials_config: Optional[str | Dict] = None):
|
|
44
|
+
self._interceptors: Dict[str, List[Any]] = {}
|
|
45
|
+
self._auth_manager: Optional[AgentAuthManager] = None
|
|
46
|
+
|
|
47
|
+
if not _A2A_AUTH_AVAILABLE:
|
|
48
|
+
logger.info("[AuthManager] a2a-sdk auth not available, authentication disabled")
|
|
49
|
+
return
|
|
50
|
+
logger.info(f"[AuthManager] Initializing with {len(agent_cards)} agent card(s), config={credentials_config is not None}")
|
|
51
|
+
|
|
52
|
+
if credentials_config:
|
|
53
|
+
if isinstance(credentials_config, str):
|
|
54
|
+
self._auth_manager = AgentAuthManager(config_path=credentials_config)
|
|
55
|
+
elif isinstance(credentials_config, dict):
|
|
56
|
+
self._auth_manager = AgentAuthManager(config=credentials_config)
|
|
57
|
+
else:
|
|
58
|
+
self._auth_manager = AgentAuthManager()
|
|
59
|
+
|
|
60
|
+
self._build_interceptors(agent_cards)
|
|
61
|
+
|
|
62
|
+
def _build_interceptors(self, agent_cards: List[Any]):
|
|
63
|
+
if not self._auth_manager:
|
|
64
|
+
logger.info("[AuthManager] No auth manager, skipping interceptor build")
|
|
65
|
+
return
|
|
66
|
+
for card in agent_cards:
|
|
67
|
+
if not hasattr(card, "name"):
|
|
68
|
+
continue
|
|
69
|
+
interceptors = []
|
|
70
|
+
cred_svc = None
|
|
71
|
+
if card.security_schemes and card.security_requirements:
|
|
72
|
+
cred_svc = self._auth_manager.get_service(card.name)
|
|
73
|
+
else:
|
|
74
|
+
logger.info(f"[AuthManager] Agent {card.name}: no security schemes, skipping auth")
|
|
75
|
+
if not (getattr(card, "capabilities", None) and card.capabilities.extensions):
|
|
76
|
+
continue
|
|
77
|
+
if cred_svc is not None:
|
|
78
|
+
logger.info(f"[AuthManager] Agent {card.name}: credentials found")
|
|
79
|
+
agent_cfg = self._auth_manager.get_config(card.name) or {}
|
|
80
|
+
if any(isinstance(v, dict) and (v.get("auth_header") or v.get("accept_header"))
|
|
81
|
+
for v in agent_cfg.values()):
|
|
82
|
+
interceptors.append(CustomAuthInterceptor(cred_svc, agent_cfg))
|
|
83
|
+
else:
|
|
84
|
+
interceptors.append(AuthInterceptor(cred_svc))
|
|
85
|
+
logger.info(f"[AuthManager] Agent {card.name}: configured with {type(interceptors[0]).__name__}")
|
|
86
|
+
if getattr(card, "capabilities", None) and card.capabilities.extensions:
|
|
87
|
+
ext_uris = [ext.uri for ext in card.capabilities.extensions if ext.uri]
|
|
88
|
+
if ext_uris:
|
|
89
|
+
interceptors.append(ExtensionInterceptor(ext_uris))
|
|
90
|
+
if interceptors:
|
|
91
|
+
self._interceptors[card.name] = interceptors
|
|
92
|
+
|
|
93
|
+
def get_interceptors(self, agent_name: str) -> List[Any]:
|
|
94
|
+
return self._interceptors.get(agent_name, [])
|
|
95
|
+
|
|
96
|
+
def set_httpx_client(self, client):
|
|
97
|
+
if self._auth_manager:
|
|
98
|
+
self._auth_manager.set_httpx_client(client)
|
|
99
|
+
class AuthProviderInterceptor(ClientCallInterceptor if _A2A_AUTH_AVAILABLE else object):
|
|
100
|
+
"""Wraps a custom AuthProvider as an a2a-sdk ClientCallInterceptor.
|
|
101
|
+
|
|
102
|
+
Calls ``auth_provider.apply_auth(agent_name, agent_card, headers)`` on
|
|
103
|
+
every ``before`` to inject auth headers. Mirrors the Java SDK's
|
|
104
|
+
``AuthProvider.applyAuth`` being called in buildClientCallContext.
|
|
105
|
+
"""
|
|
106
|
+
def __init__(self, auth_provider: AuthProvider, agent_name: str):
|
|
107
|
+
self._auth_provider = auth_provider
|
|
108
|
+
self._agent_name = agent_name
|
|
109
|
+
|
|
110
|
+
async def before(self, args: "BeforeArgs") -> None:
|
|
111
|
+
agent_card = args.agent_card
|
|
112
|
+
headers: Dict[str, str] = {}
|
|
113
|
+
try:
|
|
114
|
+
self._auth_provider.apply_auth(self._agent_name, agent_card, headers)
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.warning(f"[AuthProvider] apply_auth raised: {e}")
|
|
117
|
+
if args.context is None:
|
|
118
|
+
from a2a.client.client import ClientCallContext
|
|
119
|
+
args.context = ClientCallContext()
|
|
120
|
+
if args.context.service_parameters is None:
|
|
121
|
+
args.context.service_parameters = {}
|
|
122
|
+
args.context.service_parameters.update(headers)
|
|
123
|
+
if headers:
|
|
124
|
+
logger.info(f"[AuthProvider] Injected {len(headers)} header(s) for {self._agent_name}")
|
|
125
|
+
|
|
126
|
+
async def after(self, args: "AfterArgs") -> None:
|
|
127
|
+
pass
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Copyright (c) 2026 Huawei Technologies Co., Ltd.
|
|
2
|
+
# All Rights Reserved.
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
#
|
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
7
|
+
# not use this file except in compliance with the License. You may obtain
|
|
8
|
+
# a copy of the License at
|
|
9
|
+
#
|
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
#
|
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
14
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
15
|
+
# License for the specific language governing permissions and limitations
|
|
16
|
+
# under the License.
|
|
17
|
+
|
|
18
|
+
"""Custom authentication provider for injecting auth headers.
|
|
19
|
+
|
|
20
|
+
Implement this when the agent's authentication is not covered by the
|
|
21
|
+
credentials file or the AgentCard's security schemes (e.g. corporate SSO,
|
|
22
|
+
non-standard auth). Mirrors the Java SDK's ``AuthProvider`` interface.
|
|
23
|
+
|
|
24
|
+
Register via ``WorkflowEngineClient(..., auth_provider=my_provider)``.
|
|
25
|
+
The provider is called for every message send, regardless of whether the
|
|
26
|
+
AgentCard declares security schemes. If both a credentials config and a
|
|
27
|
+
custom AuthProvider are configured, both run (custom provider first,
|
|
28
|
+
credentials-based auth second).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from abc import ABC, abstractmethod
|
|
32
|
+
from typing import Any, Dict
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AuthProvider(ABC):
|
|
36
|
+
"""Apply authentication headers for sending a message to an agent."""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def apply_auth(self, agent_name: str, agent_card: Any, headers: Dict[str, str]) -> None:
|
|
40
|
+
"""Add auth headers to the mutable ``headers`` dict.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
agent_name: target agent name (matches AgentCard.name)
|
|
44
|
+
agent_card: the agent's card (security_schemes may be empty)
|
|
45
|
+
headers: mutable header map to add auth headers to
|
|
46
|
+
"""
|
|
47
|
+
...
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Copyright (c) 2026 Huawei Technologies Co., Ltd.
|
|
2
|
+
# All Rights Reserved.
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
#
|
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
7
|
+
# not use this file except in compliance with the License. You may obtain
|
|
8
|
+
# a copy of the License at
|
|
9
|
+
#
|
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
#
|
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
14
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
15
|
+
# License for the specific language governing permissions and limitations
|
|
16
|
+
# under the License.
|
|
17
|
+
|
|
18
|
+
"""AES-GCM credential encryption/decryption utility.
|
|
19
|
+
|
|
20
|
+
Supports encrypted values in credential config files using the ``enc:``
|
|
21
|
+
prefix. The encryption key is read from the ``A2AT_CRED_KEY`` environment
|
|
22
|
+
variable (32-byte hex string). Mirrors the Java SDK's ``CredentialCrypto``.
|
|
23
|
+
|
|
24
|
+
Usage in credentials JSON::
|
|
25
|
+
|
|
26
|
+
{"value": "enc:<base64-iv>:<base64-ciphertext>"}
|
|
27
|
+
|
|
28
|
+
Plaintext values (no ``enc:`` prefix) are returned as-is for backward compat.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
import base64
|
|
33
|
+
import secrets
|
|
34
|
+
from typing import Optional
|
|
35
|
+
|
|
36
|
+
from loguru import logger
|
|
37
|
+
|
|
38
|
+
_ENV_KEY = "A2AT_CRED_KEY"
|
|
39
|
+
_PREFIX = "enc:"
|
|
40
|
+
_IV_LENGTH = 12 # 96-bit IV for GCM
|
|
41
|
+
_TAG_LENGTH = 16 # 128-bit auth tag
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_key() -> Optional[str]:
|
|
45
|
+
"""Resolve the encryption key from OS environment."""
|
|
46
|
+
key = os.environ.get(_ENV_KEY)
|
|
47
|
+
if key:
|
|
48
|
+
return key
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def decrypt_if_needed(value: Optional[str]) -> Optional[str]:
|
|
53
|
+
"""Decrypt a credential value if it has the ``enc:`` prefix.
|
|
54
|
+
|
|
55
|
+
Values without the prefix are returned as-is (plaintext fallback).
|
|
56
|
+
"""
|
|
57
|
+
if not value or not value.startswith(_PREFIX):
|
|
58
|
+
return value
|
|
59
|
+
key_hex = _resolve_key()
|
|
60
|
+
if not key_hex or not key_hex.strip():
|
|
61
|
+
logger.warning(
|
|
62
|
+
f"[CredentialCrypto] Encrypted value found but {_ENV_KEY} not set, using as-is"
|
|
63
|
+
)
|
|
64
|
+
return value
|
|
65
|
+
try:
|
|
66
|
+
encoded = value[len(_PREFIX):]
|
|
67
|
+
parts = encoded.split(":", 1)
|
|
68
|
+
if len(parts) != 2:
|
|
69
|
+
logger.error("[CredentialCrypto] Invalid encrypted format, expected enc:<iv>:<ciphertext>")
|
|
70
|
+
return value
|
|
71
|
+
iv = base64.b64decode(parts[0])
|
|
72
|
+
ciphertext_and_tag = base64.b64decode(parts[1])
|
|
73
|
+
key_bytes = bytes.fromhex(key_hex)
|
|
74
|
+
ciphertext = ciphertext_and_tag[:-_TAG_LENGTH]
|
|
75
|
+
tag = ciphertext_and_tag[-_TAG_LENGTH:]
|
|
76
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
77
|
+
plaintext = AESGCM(key_bytes).decrypt(iv, ciphertext + tag, None)
|
|
78
|
+
return plaintext.decode("utf-8")
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.error(f"[CredentialCrypto] Decryption failed: {e}")
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def encrypt(plaintext: str) -> str:
|
|
85
|
+
"""Encrypt a plaintext value using AES-GCM with the key from A2AT_CRED_KEY.
|
|
86
|
+
|
|
87
|
+
Returns encrypted string in format ``enc:<base64-iv>:<base64-ciphertext>``.
|
|
88
|
+
Raises RuntimeError if the key env var is not set.
|
|
89
|
+
"""
|
|
90
|
+
key_hex = _resolve_key()
|
|
91
|
+
if not key_hex or not key_hex.strip():
|
|
92
|
+
raise RuntimeError(f"{_ENV_KEY} environment variable not set")
|
|
93
|
+
key_bytes = bytes.fromhex(key_hex)
|
|
94
|
+
iv = secrets.token_bytes(_IV_LENGTH)
|
|
95
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
96
|
+
ciphertext_and_tag = AESGCM(key_bytes).encrypt(iv, plaintext.encode("utf-8"), None)
|
|
97
|
+
return (
|
|
98
|
+
_PREFIX
|
|
99
|
+
+ base64.b64encode(iv).decode("ascii")
|
|
100
|
+
+ ":"
|
|
101
|
+
+ base64.b64encode(ciphertext_and_tag).decode("ascii")
|
|
102
|
+
)
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# Copyright (c) 2026 Huawei Technologies Co., Ltd.
|
|
2
|
+
# All Rights Reserved.
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
#
|
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
7
|
+
# not use this file except in compliance with the License. You may obtain
|
|
8
|
+
# a copy of the License at
|
|
9
|
+
#
|
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
#
|
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
14
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
15
|
+
# License for the specific language governing permissions and limitations
|
|
16
|
+
# under the License.
|
|
17
|
+
|
|
18
|
+
"""Agent credential service - self-contained, no orchestration center dependency.
|
|
19
|
+
|
|
20
|
+
Obtains Bearer tokens via login endpoints for agents requiring authentication.
|
|
21
|
+
Reads credentials from a user-provided config (JSON file or dict).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Dict, Optional
|
|
28
|
+
import httpx
|
|
29
|
+
from loguru import logger
|
|
30
|
+
|
|
31
|
+
from workflow_engine.client.credential_crypto import decrypt_if_needed
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from a2a.client.auth import CredentialService
|
|
35
|
+
from a2a.client.auth import InMemoryContextCredentialStore
|
|
36
|
+
from a2a.client.interceptors import ClientCallInterceptor, BeforeArgs, AfterArgs
|
|
37
|
+
_A2A_AVAILABLE = True
|
|
38
|
+
except ImportError:
|
|
39
|
+
_A2A_AVAILABLE = False
|
|
40
|
+
CredentialService = object
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AgentCredentialService(CredentialService if _A2A_AVAILABLE else object):
|
|
44
|
+
"""Obtains tokens via login endpoint, caches with TTL."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, agent_name: str, scheme_configs: Dict[str, dict],
|
|
47
|
+
httpx_client: Optional[httpx.AsyncClient] = None):
|
|
48
|
+
self._agent_name = agent_name
|
|
49
|
+
self._schemes = scheme_configs
|
|
50
|
+
self._httpx_client = httpx_client
|
|
51
|
+
self._tokens: Dict[str, tuple] = {}
|
|
52
|
+
self._lock = None
|
|
53
|
+
|
|
54
|
+
def _ensure_lock(self):
|
|
55
|
+
if self._lock is None:
|
|
56
|
+
import asyncio
|
|
57
|
+
self._lock = asyncio.Lock()
|
|
58
|
+
|
|
59
|
+
def set_httpx_client(self, client: httpx.AsyncClient):
|
|
60
|
+
self._httpx_client = client
|
|
61
|
+
|
|
62
|
+
async def get_credentials(self, security_scheme_name: str, context=None) -> Optional[str]:
|
|
63
|
+
scheme_cfg = self._schemes.get(security_scheme_name)
|
|
64
|
+
if not scheme_cfg:
|
|
65
|
+
return None
|
|
66
|
+
cached = self._tokens.get(security_scheme_name)
|
|
67
|
+
if cached:
|
|
68
|
+
token, expires_at = cached
|
|
69
|
+
if time.time() < expires_at - 60:
|
|
70
|
+
logger.info(f"[Auth] Cache hit for agent {self._agent_name} scheme {security_scheme_name}")
|
|
71
|
+
return token
|
|
72
|
+
self._ensure_lock()
|
|
73
|
+
async with self._lock:
|
|
74
|
+
cached = self._tokens.get(security_scheme_name)
|
|
75
|
+
if cached:
|
|
76
|
+
token, expires_at = cached
|
|
77
|
+
if time.time() < expires_at - 60:
|
|
78
|
+
return token
|
|
79
|
+
token = await self._login(scheme_cfg)
|
|
80
|
+
if token:
|
|
81
|
+
ttl = scheme_cfg.get("token_ttl", 3600)
|
|
82
|
+
self._tokens[security_scheme_name] = (token, time.time() + ttl)
|
|
83
|
+
logger.info(f"[Auth] Login succeeded: agent={self._agent_name}, scheme={security_scheme_name}")
|
|
84
|
+
return token
|
|
85
|
+
|
|
86
|
+
async def _login(self, scheme_cfg: dict) -> Optional[str]:
|
|
87
|
+
login_url = scheme_cfg.get("login_url")
|
|
88
|
+
if not login_url:
|
|
89
|
+
return None
|
|
90
|
+
method = scheme_cfg.get("method", "POST").upper()
|
|
91
|
+
content_type = scheme_cfg.get("content_type", "application/json")
|
|
92
|
+
token_field = scheme_cfg.get("token_field", "accessSession")
|
|
93
|
+
request_fields = scheme_cfg.get("request_fields")
|
|
94
|
+
if request_fields and isinstance(request_fields, dict):
|
|
95
|
+
body = {k: decrypt_if_needed(v) if isinstance(v, str) else v
|
|
96
|
+
for k, v in request_fields.items()}
|
|
97
|
+
else:
|
|
98
|
+
username = scheme_cfg.get("username")
|
|
99
|
+
password = decrypt_if_needed(scheme_cfg.get("password"))
|
|
100
|
+
if not username or not password:
|
|
101
|
+
return None
|
|
102
|
+
body = {scheme_cfg.get("username_field","username"): username, scheme_cfg.get("password_field","password"): password}
|
|
103
|
+
client = self._httpx_client or httpx.AsyncClient(timeout=httpx.Timeout(connect=30, read=30, write=30, pool=5.0), verify=False)
|
|
104
|
+
own_client = self._httpx_client is None
|
|
105
|
+
try:
|
|
106
|
+
logger.info(f"[Auth] Login attempt: agent={self._agent_name}, method={method}, url={login_url}, content_type={content_type}, params={_sanitize_body(body)}")
|
|
107
|
+
req_kwargs = {"method": method, "url": login_url}
|
|
108
|
+
if content_type == "application/x-www-form-urlencoded":
|
|
109
|
+
req_kwargs["data"] = body
|
|
110
|
+
else:
|
|
111
|
+
req_kwargs["json"] = body
|
|
112
|
+
resp = await client.request(**req_kwargs)
|
|
113
|
+
resp.raise_for_status()
|
|
114
|
+
data = resp.json()
|
|
115
|
+
token = self._extract_nested_value(data, token_field) if isinstance(data, dict) else None
|
|
116
|
+
if not token and isinstance(data, dict):
|
|
117
|
+
token = data.get("accessSession") or data.get("access_session") or data.get("access_token") or data.get("token")
|
|
118
|
+
return token
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.error(f"[Auth] Login failed: agent={self._agent_name}, url={login_url}, error={e}")
|
|
121
|
+
return None
|
|
122
|
+
finally:
|
|
123
|
+
if own_client:
|
|
124
|
+
await client.aclose()
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _extract_nested_value(data: dict, path: str) -> Optional[str]:
|
|
128
|
+
if not path:
|
|
129
|
+
return None
|
|
130
|
+
current = data
|
|
131
|
+
for part in path.split("."):
|
|
132
|
+
if not isinstance(current, dict):
|
|
133
|
+
return None
|
|
134
|
+
current = current.get(part)
|
|
135
|
+
if current is None:
|
|
136
|
+
return None
|
|
137
|
+
return current
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class AgentAuthManager:
|
|
141
|
+
"""Loads agent credentials from config, creates per-agent CredentialService."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, config: Optional[Dict[str, dict]] = None, config_path: Optional[str] = None):
|
|
144
|
+
self._config: Dict[str, dict] = {}
|
|
145
|
+
self._services: Dict[str, AgentCredentialService] = {}
|
|
146
|
+
if config:
|
|
147
|
+
self._config = config
|
|
148
|
+
elif config_path:
|
|
149
|
+
self._load_from_file(config_path)
|
|
150
|
+
|
|
151
|
+
def _load_from_file(self, path: str):
|
|
152
|
+
p = Path(path)
|
|
153
|
+
if not p.exists():
|
|
154
|
+
return
|
|
155
|
+
try:
|
|
156
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
157
|
+
self._config = json.load(f)
|
|
158
|
+
logger.info(f"[Auth] Loaded credentials for {len(self._config)} agent(s): {list(self._config.keys())}")
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logger.warning(f"[Auth] Failed to load credentials: {e}")
|
|
161
|
+
|
|
162
|
+
def get_service(self, agent_name: str) -> Optional[AgentCredentialService]:
|
|
163
|
+
if agent_name in self._services:
|
|
164
|
+
return self._services[agent_name]
|
|
165
|
+
agent_creds = self._config.get(agent_name)
|
|
166
|
+
if not agent_creds:
|
|
167
|
+
return None
|
|
168
|
+
service = AgentCredentialService(agent_name, agent_creds)
|
|
169
|
+
self._services[agent_name] = service
|
|
170
|
+
logger.info(f"[Auth] Created credential service for agent: {agent_name}")
|
|
171
|
+
return service
|
|
172
|
+
|
|
173
|
+
def get_config(self, agent_name: str) -> Optional[Dict[str, dict]]:
|
|
174
|
+
return self._config.get(agent_name)
|
|
175
|
+
|
|
176
|
+
def set_httpx_client(self, client: httpx.AsyncClient):
|
|
177
|
+
for svc in self._services.values():
|
|
178
|
+
svc.set_httpx_client(client)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class CustomAuthInterceptor(ClientCallInterceptor if _A2A_AVAILABLE else object):
|
|
182
|
+
"""Auth interceptor supporting custom header names."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, credential_service: AgentCredentialService, scheme_configs: Dict[str, dict]):
|
|
185
|
+
self._credential_service = credential_service
|
|
186
|
+
self._scheme_configs = scheme_configs
|
|
187
|
+
|
|
188
|
+
async def before(self, args: BeforeArgs) -> None:
|
|
189
|
+
agent_card = args.agent_card
|
|
190
|
+
if not agent_card.security_requirements or not agent_card.security_schemes:
|
|
191
|
+
return
|
|
192
|
+
for requirement in agent_card.security_requirements:
|
|
193
|
+
for scheme_name in requirement.schemes:
|
|
194
|
+
scheme_cfg = self._scheme_configs.get(scheme_name, {})
|
|
195
|
+
credential = await self._credential_service.get_credentials(scheme_name, args.context)
|
|
196
|
+
if not credential:
|
|
197
|
+
continue
|
|
198
|
+
if args.context is None:
|
|
199
|
+
from a2a.client.client import ClientCallContext
|
|
200
|
+
args.context = ClientCallContext()
|
|
201
|
+
if args.context.service_parameters is None:
|
|
202
|
+
args.context.service_parameters = {}
|
|
203
|
+
auth_header = scheme_cfg.get("auth_header")
|
|
204
|
+
if auth_header:
|
|
205
|
+
prefix = scheme_cfg.get("auth_header_prefix", "")
|
|
206
|
+
args.context.service_parameters[auth_header] = f"{prefix}{credential}"
|
|
207
|
+
logger.info(f"[CustomAuth] Set header {auth_header} for scheme {scheme_name}")
|
|
208
|
+
else:
|
|
209
|
+
args.context.service_parameters["Authorization"] = f"Bearer {credential}"
|
|
210
|
+
logger.info(f"[CustomAuth] Set Bearer header for scheme {scheme_name}")
|
|
211
|
+
accept_header = scheme_cfg.get("accept_header")
|
|
212
|
+
if accept_header:
|
|
213
|
+
args.context.service_parameters["Accept"] = accept_header
|
|
214
|
+
logger.info(f"[CustomAuth] Override Accept header to {accept_header} for agent {getattr(args.agent_card, chr(39)+chr(110)+chr(97)+chr(109)+chr(101)+chr(39), chr(63))}")
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
async def after(self, args: AfterArgs) -> None:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _sanitize_body(body: dict) -> dict:
|
|
222
|
+
"""Mask sensitive fields (password, value, accessSession) for safe logging."""
|
|
223
|
+
sanitized = {}
|
|
224
|
+
for k, v in body.items():
|
|
225
|
+
if k.lower() in ("password", "value", "accesssession"):
|
|
226
|
+
sanitized[k] = "***"
|
|
227
|
+
else:
|
|
228
|
+
sanitized[k] = v
|
|
229
|
+
return sanitized
|