diff --git a/README.md b/README.md index b80412d..06586ac 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ and need no setup. The complete, compiled example is [HostQuickStart.java](samples/src/main/java/dev/openan/workflow/engine/examples/demo/HostQuickStart.java). It converts registry JSON maps to typed AgentCards, runs a remote task and local aggregation, cancels timed-out work, and closes caller-owned transport resources. Run its main method in IDEA with three arguments: registry URL, -target AgentCard name, and credentials JSON path. The registry and target agent must be reachable; TLS verification is enabled. +target AgentCard name, and credentials JSON path. First set `REGISTRY_API_TOKEN` from the registry's configured identity provider; the example fails locally when it is missing. Registry credentials are independent from agent credentials and `ORCH_USERNAME` / `ORCH_PASSWORD`. The registry and target agent must be reachable; TLS verification is enabled. ```java // In the samples module; HostQuickStart is example source, not a class shipped in the SDK jar. diff --git a/docs/en/API_REFERENCE.md b/docs/en/API_REFERENCE.md index 2e1ac22..ec587ff 100644 --- a/docs/en/API_REFERENCE.md +++ b/docs/en/API_REFERENCE.md @@ -388,6 +388,10 @@ static Workflow load(String baseUrl, String psopId, String accessToken, boolean sslVerify) static Workflow load(String baseUrl, String psopId, String accessToken, boolean sslVerify, LoadPsop.Timeouts timeouts) +static Workflow load(String baseUrl, String psopId, + HttpAuthProvider auth, TlsPolicy tls) +static Workflow load(String baseUrl, String psopId, + HttpAuthProvider auth, TlsPolicy tls, LoadPsop.Timeouts timeouts) static Workflow load(String baseUrl, String psopId) ``` @@ -403,6 +407,11 @@ static List search( static List search( String baseUrl, String intent, int topN, String accessToken, boolean sslVerify, LoadPsop.Timeouts timeouts) +static List search( + String baseUrl, String intent, int topN, HttpAuthProvider auth, TlsPolicy tls) +static List search( + String baseUrl, String intent, int topN, HttpAuthProvider auth, TlsPolicy tls, + LoadPsop.Timeouts timeouts) static List search( String baseUrl, String intent) @@ -418,8 +427,39 @@ This is distinct from the engine's southbound HTTP/JSON-RPC verification policy. default to 30 seconds. Supply `new LoadPsop.Timeouts(connectTimeout, readTimeout)` to set positive per-call values. +### OrchestrationAccountAuth + +```java +new OrchestrationAccountAuth(baseUrl, username, password, tls) +new OrchestrationAccountAuth(baseUrl, username, password, tls, loginTimeouts) +``` + +Implements HttpAuthProvider for PSOP loading/search. Uses the same account as the browser +login through POST /api/v1/auth/token (JSON username/password). A shared instance lazily +acquires and caches the opaque Bearer with a monotonic expiry budget; expiry renews by +login, not an OAuth2 refresh grant. Login responses must have a non-empty access_token, +Bearer token_type and positive integral expires_in. Login responses are size-bounded; +rejected/malformed responses never expose their body or parser cause. + +LoadPsop read/search calls can invoke HttpAuthProvider.onUnauthorized(uri, rejectedHeaders) +once after 401. The account implementation invalidates only the rejected cached session; +the default hook returns false for external Bearer/custom providers. No 403/service-error +retry, business-write replay or anonymous/local-workflow fallback. Scope is restricted to +this center's PSOP API; redirects are not followed. Pass the same TLS policy to account +login and LoadPsop; HTTP exposes the password, so prefer verified HTTPS. + ### RegistryClient +Authentication is independent of TLS. HttpAuthProvider.bearer(tokenSupplier) reads credentials for +every request; OAuth2 token acquisition/refresh belongs to the application. LoadPsop accessToken +is transmitted in Authorization: Bearer, never in query parameters. +HttpServiceException exposes getStatusCode() and isAuthenticationFailure(); errors are sanitized. +HttpResponseException exposes getStatusCode() for JSON/model decoding failures after a successful +HTTP response, without retaining the raw body or parser cause that may echo credentials. +TlsPolicy.strict()/trustedCa() verify trust and hostname, insecure() skips both checks per client, +and mutualTls() configures client identity. Old anonymous boolean constructors retain their +chain-only false semantics; prefer the explicit authentication/TLS constructors. + The two-argument constructor uses a 30-second complete-response deadline. Override it with `new RegistryClient(url, sslVerify, Duration.ofSeconds(15))`; the duration must be positive. The deadline includes response-body consumption, and interruption cancels the pending request. @@ -429,7 +469,9 @@ Registry methods return JSON maps; convert them to AgentCard with AgentCardJacks Fetch and register AgentCards from the Registry Center. ```java -new RegistryClient("https://127.0.0.1:5000",false) +new RegistryClient(url, HttpAuthProvider.bearer(tokenSupplier)) +new RegistryClient(url, auth, TlsPolicy.strict()) +new RegistryClient(url, auth, TlsPolicy.insecure(), Duration.ofSeconds(15)) List> fetchAgentCards() diff --git a/docs/en/DEVELOPER_GUIDE.md b/docs/en/DEVELOPER_GUIDE.md index 0094ed4..9801d85 100644 --- a/docs/en/DEVELOPER_GUIDE.md +++ b/docs/en/DEVELOPER_GUIDE.md @@ -256,32 +256,47 @@ provider, not template overrides or production fallbacks. ### SSE Server (Spring WebFlux) +Create one shared account provider per host/base URL, not per request. Login and retrieval use the same TLS policy; credentials belong in the secret environment. Complete initial password change in the web UI first. Fetch cards independently with `RegistryClient(registryUrl, registryAuth, registryTls)`; do not reuse the orchestration token for registry or Agents. + ```java +// Host-owned fields, constructed once; imports are in dev.openan.workflow.engine.registry. +private final TlsPolicy orchTls = TlsPolicy.strict(); +private final HttpAuthProvider orchAuth = new OrchestrationAccountAuth( + baseUrl, System.getenv("ORCH_USERNAME"), System.getenv("ORCH_PASSWORD"), orchTls); +``` + + -@GetMapping("/execute/{psopId}") +```java + +@GetMapping(value = "/execute/{psopId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux execute(@PathVariable String psopId) { - Workflow workflow = LoadPsop.load(baseUrl, psopId, token, false); - - return Flux.create(sink -> { - ExecutePsop.builder() - .psop(workflow) - .agentCards(cards) - .controlPoint(cp) - .eventCallback(new EventCallback() { - @Override - public void onEvent(String type, Map data) { - sink.next("data: " + toJson(type, data) + "\n\n"); - } - }) - .onFinish((r, e) -> { - sink.complete(); - return CompletableFuture.completedFuture(null); - }) - .execute(); - }); + // LoadPsop is blocking; keep login/retrieval off the reactive event loop. + return Mono.fromCallable(() -> LoadPsop.load(baseUrl, psopId, orchAuth, orchTls)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMapMany(workflow -> Flux.create(sink -> { + CompletableFuture execution = ExecutePsop.builder() + .psop(workflow) + .agentCards(cards) + .controlPoint(cp) + .eventCallback(new EventCallback() { + @Override + public void onEvent(String type, Map data) { + sink.next(toJson(type, data)); + } + }) + .execute(); + execution.whenComplete((result, error) -> { + if (error != null) sink.error(error); + else sink.complete(); + }); + sink.onCancel(() -> execution.cancel(true)); + })); } ``` +Imports for the reactive adapter: `reactor.core.publisher.Mono/Flux` and `reactor.core.scheduler.Schedulers` and `org.springframework.http.MediaType`; expose it with the host's SSE response media type/serialization. The framework owns SSE framing. For explicit machine Bearer mode, `HttpAuthProvider.bearer(tokenSupplier)` remains supported, with host-owned token lifecycle. Account PSOP reads have at most one 401 renewal/retry; writes are not replayed. See [account integration](INTEGRATION_GUIDE.md). + ### Cancellation ExecutePsop.builder().execute() returns a CompletableFuture. Cancellation prevents late callback results from being diff --git a/docs/en/INTEGRATION_GUIDE.md b/docs/en/INTEGRATION_GUIDE.md index f8d0ba5..0cc5883 100644 --- a/docs/en/INTEGRATION_GUIDE.md +++ b/docs/en/INTEGRATION_GUIDE.md @@ -90,7 +90,8 @@ AgentCard card = mapper.readValue( new File("agentcard/my_agent.json"), AgentCard.class); // Option B: From Registry Center -RegistryClient registry = new RegistryClient("https://127.0.0.1:5000", false); +HttpAuthProvider registryAuth = HttpAuthProvider.bearer(() -> System.getenv("REGISTRY_API_TOKEN")); +RegistryClient registry = new RegistryClient("https://127.0.0.1:5000", registryAuth, TlsPolicy.strict()); ObjectMapper cardMapper = new ObjectMapper().registerModule(new AgentCardJacksonModule()); List cards = registry.fetchAgentCards().stream() .map(raw -> cardMapper.convertValue(raw, AgentCard.class)).toList(); @@ -99,8 +100,9 @@ List cards = registry.fetchAgentCards().stream() RegistryClient defaults to a 30-second deadline including response-body consumption. For another budget, use -`new RegistryClient(url, true, Duration.ofSeconds(15))`; only positive durations are accepted. Query access tokens -are logged as ``; do not log raw discovery URLs from host code. +`new RegistryClient(url, registryAuth, TlsPolicy.strict(), Duration.ofSeconds(15))`; only positive +durations are accepted. All discovery and registration requests apply credentials immediately before sending. +Credentials are sent in headers, never in URL query parameters. Missing/invalid Bearer tokens fail locally. ### 4.3 Implement ControlPoint @@ -547,13 +549,18 @@ Common event types: `START`, `STEP_START`, `TASK_REQUEST`, ## 11. Load Workflows from Orchestration Center ```java +// Center credentials are independent of A2A AuthProvider and agent credentials. +TlsPolicy orchTls = TlsPolicy.strict(); // Or trustedCa("/external/certs/ca.pem"). +HttpAuthProvider orchAuth = new OrchestrationAccountAuth( + "https://orchestration.example.com", System.getenv("ORCH_USERNAME"), + System.getenv("ORCH_PASSWORD"), orchTls); // Search by intent List results = LoadPsop.search( - "https://orchestration.example.com", "analyze service request", 5, null, true); + "https://orchestration.example.com", "analyze service request", 5, orchAuth, orchTls); // Load full workflow by ID Workflow workflow = LoadPsop.load( - "https://orchestration.example.com", results.get(0).getWorkflowId(), null, true); + "https://orchestration.example.com", results.get(0).getWorkflowId(), orchAuth, orchTls); ``` ## 12. Custom Extensions @@ -566,17 +573,56 @@ metadata and activation URIs. AgentCard declarations never cause implicit genera ### 13.1 Orchestration-center HTTPS development -`LoadPsop.search/load` receives an explicit `sslVerify` argument. Keep it `true` in production. In a controlled -development environment, `false` disables certificate-chain and hostname checks for that connection only and emits a -`[Registry] INSECURE_TLS` warning. It does not modify JVM-wide TLS defaults. +`TlsPolicy.strict()` verifies certificate trust and hostname. Use `TlsPolicy.trustedCa(caPemPath)` +for a private/self-signed CA. `TlsPolicy.insecure()` explicitly skips both checks for the current +management client only (Python `verify=False` equivalent) and emits an `INSECURE_TLS` warning. +It does not modify JVM-wide TLS defaults or bypass Bearer authentication. The HTTPS server must still present a certificate, and client-certificate requirements (mTLS) still apply. This mode cannot authenticate the server and is vulnerable to interception; do not use it in production. Keep verification enabled in production, configure matching server SANs and trust its CA in the running JVM. +For mTLS, use `TlsPolicy.mutualTls(ca, clientCert, pkcs8Key, password)`; the insecure overload +`TlsPolicy.insecure(clientCert, pkcs8Key, password)` retains client identity. The dispatched-agent `caCertsPath` setting is not forwarded to LoadPsop. LoadPsop never changes JVM-wide SSLContext, SocketFactory or HostnameVerifier defaults. Dispatched-agent HTTP/JSON-RPC retains hostname checks when chain verification is disabled. +Existing LoadPsop methods retain their arguments, but now send accessToken in the Bearer header, +not the URL. Existing RegistryClient boolean constructors remain anonymous; their false setting +still disables only certificate-chain checks. Prefer the explicit authentication/TLS constructors. + +`HttpAuthProvider.bearer(tokenSupplier)` reads the current token once per request. The application/IAM +client owns OAuth2 acquisition, caching and refresh; the SDK does not retry business writes on 401. +Custom authentication implements `headers(URI)` and returns business-defined header names/values. +Duplicate case-insensitive or reserved transport headers are rejected. Use `HttpAuthProvider.none()` +only when the service deliberately allows anonymous access or uses mTLS identity instead. + +SpringSpnDemo uses `ORCH_USERNAME` and `ORCH_PASSWORD` (the login-page account), +`ORCH_VERIFY_SERVER` (default true), and optional `ORCH_CA_CERTS_PATH`, independently +of `A2A_SSL_VERIFY` and agent/Order credentials. Supply the password through an external +secret, never in committed YAML or command-line arguments. The shared account provider +logs in lazily at `POST /api/v1/auth/token`, sends `{username, password}` as JSON, and +caches `access_token` according to `expires_in`. Expiry is handled by logging in again; +this first-party endpoint is not an OAuth2 password grant or refresh-token server. + +Only PSOP load/search may retry once after 401 with refreshed account credentials. +403, 429, service errors, TLS/network failures and rejected logins are not retried. +Credentials are scoped to the configured center's PSOP API, never query strings/cookies +or another origin. Login and PSOP calls must use the same center TLS policy; skipping +server verification does not skip account authentication. HTTP is supported for isolated +development, but exposes the login password; prefer verified HTTPS in deployments. +Share one `OrchestrationAccountAuth` instance across executions to reuse its thread-safe +session cache. No center credentials affect Task-T or Notification-T streams. + +Alternatively set `ORCH_API_TOKEN` alone for an externally managed Bearer; do not combine +it with account credentials. Static/supplier Bearer providers do not opt into retries. +For dynamic/custom credentials, provide a bean named `orchestrationHttpAuthProvider`. +For mTLS or an application-owned TLS policy, provide a `TlsPolicy` bean named `orchestrationTlsPolicy`. +HostQuickStart requires `REGISTRY_API_TOKEN` for registry discovery. +Center failures (including 401/403, TLS/network errors and empty search results) never select a +different workflow. `--a2a.local-workflow-enabled=true` explicitly runs the offline sample workflow +without contacting the center; it is disabled by default and is not a production fallback. + ### 13.2 Dispatched-agent endpoints The engine consumes AgentCards and never starts dispatched-agent servers. The host agent owns AgentCard discovery, diff --git a/docs/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index 9ea3271..e97c5ef 100644 --- a/docs/zh/API_REFERENCE.md +++ b/docs/zh/API_REFERENCE.md @@ -363,6 +363,10 @@ static Workflow load(String baseUrl, String psopId, String accessToken, boolean sslVerify) static Workflow load(String baseUrl, String psopId, String accessToken, boolean sslVerify, LoadPsop.Timeouts timeouts) +static Workflow load(String baseUrl, String psopId, + HttpAuthProvider auth, TlsPolicy tls) +static Workflow load(String baseUrl, String psopId, + HttpAuthProvider auth, TlsPolicy tls, LoadPsop.Timeouts timeouts) static Workflow load(String baseUrl, String psopId) ``` @@ -378,6 +382,11 @@ static List search( static List search( String baseUrl, String intent, int topN, String accessToken, boolean sslVerify, LoadPsop.Timeouts timeouts) +static List search( + String baseUrl, String intent, int topN, HttpAuthProvider auth, TlsPolicy tls) +static List search( + String baseUrl, String intent, int topN, HttpAuthProvider auth, TlsPolicy tls, + LoadPsop.Timeouts timeouts) static List search( String baseUrl, String intent) @@ -391,8 +400,37 @@ LoadPsop 的简便重载默认 `sslVerify=true`,使用 JVM 信任库并校验 生产环境必须保持验证并配置正确的服务端 SAN 与信任库;本设置不是引擎南向 HTTP/JSON-RPC 的 TLS 策略变更。 连接与读取超时默认均为 30 秒;可通过 `new LoadPsop.Timeouts(connectTimeout, readTimeout)` 为单次调用设置正值。 +### OrchestrationAccountAuth + +```java +new OrchestrationAccountAuth(baseUrl, username, password, tls) +new OrchestrationAccountAuth(baseUrl, username, password, tls, loginTimeouts) +``` + +实现 PSOP 加载/搜索的 HttpAuthProvider,使用与网页相同的账号向 +POST /api/v1/auth/token 发送 username/password JSON。共享实例懒登录并以 +单调时钟缓存 opaque Bearer;到期重新登录,不是 OAuth2 refresh grant。 +响应要求非空 access_token、Bearer token_type 和正整数 expires_in。 +登录响应有大小上限;错误及畸形响应不保留原始正文或解析 cause。 + +LoadPsop 仅在读/搜索返回 401 后调用一次 +HttpAuthProvider.onUnauthorized(uri, rejectedHeaders)。账号实现只使被拒绝 +的缓存会话失效;外部 Bearer/自定义提供器默认返回 false,不参与自动重试。 +403/服务错误不重试,不重放业务写入,不回退匿名或本地工作流。 +凭据限定在本中心 PSOP API,不跟随重定向。登录与 LoadPsop 传相同 TLS +策略;HTTP 明文传输密码,部署应优先使用验证对端的 HTTPS。 + ### RegistryClient +认证与 TLS 独立。HttpAuthProvider.bearer(tokenSupplier) 每次请求读取当前凭据; +OAuth2 获取/刷新由应用负责。LoadPsop 的 accessToken 改用 Authorization: Bearer,不进入查询参数。 +HttpServiceException 提供 getStatusCode()、isAuthenticationFailure(),异常内容经过脱敏。 +成功 HTTP 状态的 JSON/模型转换失败抛 HttpResponseException,提供 getStatusCode(); +不携带原始正文或解析 cause,避免异常链泄露凭据。 +TlsPolicy.strict()/trustedCa() 校验证书链与主机名,insecure() 对当前客户端关闭两项校验, +mutualTls() 配置客户端身份。旧匿名 boolean 构造的 false 仍只关闭证书链校验; +新集成优先使用显式认证/TLS 构造。 + 双参数构造默认使用 30 秒完整响应截止时间。可通过 `new RegistryClient(url, sslVerify, Duration.ofSeconds(15))` 设置正值预算,包含响应正文读取;线程中断会取消待处理请求。 注册中心方法返回 JSON Map,使用 AgentCardJacksonModule 转换为 AgentCard,详见集成指南。 @@ -401,7 +439,9 @@ LoadPsop 的简便重载默认 `sslVerify=true`,使用 JVM 信任库并校验 从注册中心获取和注册 AgentCard。 ```java -new RegistryClient("https://127.0.0.1:5000",false) +new RegistryClient(url, HttpAuthProvider.bearer(tokenSupplier)) +new RegistryClient(url, auth, TlsPolicy.strict()) +new RegistryClient(url, auth, TlsPolicy.insecure(), Duration.ofSeconds(15)) List> fetchAgentCards() diff --git a/docs/zh/DEVELOPER_GUIDE.md b/docs/zh/DEVELOPER_GUIDE.md index 8825829..ac7b4e1 100644 --- a/docs/zh/DEVELOPER_GUIDE.md +++ b/docs/zh/DEVELOPER_GUIDE.md @@ -242,31 +242,46 @@ provider,不覆盖模板,也不是生产失败兜底。 ### SSE Server(Spring WebFlux) +每个宿主/中心地址共享一个账号 provider,不要按请求创建;登录与资源查询复用同一 TLS 策略,凭据从秘密环境获取。首次先在网页完成改密。AgentCard 独立使用 `RegistryClient(registryUrl, registryAuth, registryTls)`,不将编排账号 Token 复用于注册中心或 Agent。 + ```java -@GetMapping("/execute/{psopId}") +// Host-owned fields, constructed once; imports are in dev.openan.workflow.engine.registry. +private final TlsPolicy orchTls = TlsPolicy.strict(); +private final HttpAuthProvider orchAuth = new OrchestrationAccountAuth( + baseUrl, System.getenv("ORCH_USERNAME"), System.getenv("ORCH_PASSWORD"), orchTls); +``` + + + +```java +@GetMapping(value = "/execute/{psopId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux execute(@PathVariable String psopId) { - Workflow workflow = LoadPsop.load(baseUrl, psopId, token, false); - - return Flux.create(sink -> { - ExecutePsop.builder() - .psop(workflow) - .agentCards(cards) - .controlPoint(cp) - .eventCallback(new EventCallback() { - @Override - public void onEvent(String type, Map data) { - sink.next("data: " + toJson(type, data) + "\n\n"); - } - }) - .onFinish((r, e) -> { - sink.complete(); - return CompletableFuture.completedFuture(null); - }) - .execute(); - }); + // LoadPsop is blocking; keep login/retrieval off the reactive event loop. + return Mono.fromCallable(() -> LoadPsop.load(baseUrl, psopId, orchAuth, orchTls)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMapMany(workflow -> Flux.create(sink -> { + CompletableFuture execution = ExecutePsop.builder() + .psop(workflow) + .agentCards(cards) + .controlPoint(cp) + .eventCallback(new EventCallback() { + @Override + public void onEvent(String type, Map data) { + sink.next(toJson(type, data)); + } + }) + .execute(); + execution.whenComplete((result, error) -> { + if (error != null) sink.error(error); + else sink.complete(); + }); + sink.onCancel(() -> execution.cancel(true)); + })); } ``` +响应式适配使用 `reactor.core.publisher.Mono/Flux`、`reactor.core.scheduler.Schedulers` 和 `org.springframework.http.MediaType`,由宿主设置 SSE 媒体类型及序列化,框架负责 SSE 帧格式。显式机器 Bearer 模式仍可用 `HttpAuthProvider.bearer(tokenSupplier)`,Token 生命周期由宿主负责。账号 PSOP 读取最多一次 401 续期/重试,不重放写请求。详见[账号接入](INTEGRATION_GUIDE.md)。 + ### 取消 `ExecutePsop.builder().execute()` 返回 `CompletableFuture`。`cancel(true)` 停止后续本地调度、忽略迟到回调并清理执行器拥有的资源。 diff --git a/docs/zh/INTEGRATION_GUIDE.md b/docs/zh/INTEGRATION_GUIDE.md index eb490ae..02046bd 100644 --- a/docs/zh/INTEGRATION_GUIDE.md +++ b/docs/zh/INTEGRATION_GUIDE.md @@ -87,7 +87,8 @@ AgentCard card = mapper.readValue( new File("agentcard/my_agent.json"), AgentCard.class); // 方式二:从注册中心拉取 -RegistryClient registry = new RegistryClient("https://127.0.0.1:5000", false); +HttpAuthProvider registryAuth = HttpAuthProvider.bearer(() -> System.getenv("REGISTRY_API_TOKEN")); +RegistryClient registry = new RegistryClient("https://127.0.0.1:5000", registryAuth, TlsPolicy.strict()); ObjectMapper cardMapper = new ObjectMapper().registerModule(new AgentCardJacksonModule()); List cards = registry.fetchAgentCards().stream() .map(raw -> cardMapper.convertValue(raw, AgentCard.class)).toList(); @@ -96,8 +97,8 @@ List cards = registry.fetchAgentCards().stream() RegistryClient 默认设置覆盖正文读取的 30 秒截止时间;可使用 -`new RegistryClient(url, true, Duration.ofSeconds(15))` 设置正值预算。编排中心 query token 日志仅显示 ``, -集成方也不要自行打印带凭据的原始 URL。 +`new RegistryClient(url, registryAuth, TlsPolicy.strict(), Duration.ofSeconds(15))` 设置正值预算。 +列表、单卡查询和注册均在发送前应用认证;凭据只放请求头,不放 URL。缺失或非法 Bearer Token 在本地拒绝。 ### 4.3 实现 ControlPoint @@ -511,13 +512,18 @@ ExecutePsop.builder() ## 11. 从编排中心加载工作流 ```java +// 中心凭据与 A2A AuthProvider、智能体凭据独立。 +TlsPolicy orchTls = TlsPolicy.strict(); // 私有 CA 可用 trustedCa("/external/certs/ca.pem")。 +HttpAuthProvider orchAuth = new OrchestrationAccountAuth( + "https://orchestration.example.com", System.getenv("ORCH_USERNAME"), + System.getenv("ORCH_PASSWORD"), orchTls); // 按意图搜索 List results = LoadPsop.search( - "https://orchestration.example.com", "分析服务请求", 5, null, true); + "https://orchestration.example.com", "分析服务请求", 5, orchAuth, orchTls); // 按 ID 加载完整工作流 Workflow workflow = LoadPsop.load( - "https://orchestration.example.com", results.get(0).getWorkflowId(), null, true); + "https://orchestration.example.com", results.get(0).getWorkflowId(), orchAuth, orchTls); ``` ## 12. 自定义扩展 @@ -530,15 +536,50 @@ Workflow workflow = LoadPsop.load( ### 13.1 编排中心 HTTPS 联调 -`LoadPsop.search/load` 显式接收 `sslVerify` 参数。生产环境保持 `true`。受控开发环境可传入 `false`,仅对当前连接跳过证书链和主机名校验,并打印 `[Registry] INSECURE_TLS` 警告;不修改 JVM 全局 TLS 默认值。 +`TlsPolicy.strict()` 默认校验证书链和主机名;私有或自签 CA 使用 `TlsPolicy.trustedCa(caPemPath)`。 +`TlsPolicy.insecure()` 显式对当前管理面客户端关闭两项校验,等同 Python 的 `verify=False`, +打印 `INSECURE_TLS` 警告,不修改 JVM 全局默认值,也不关闭 Bearer 鉴权。 “免配本地证书”不表示 HTTPS 服务端不需要证书,也不能绕过要求客户端证书的 mTLS。 该非验证模式不能确认服务端身份,存在中间人风险,仅用于受控联调。 生产保持 `true`,给服务端配置匹配实际 URL 的 SAN,并让运行 JVM 信任其 CA; +mTLS 使用 `TlsPolicy.mutualTls(ca, clientCert, pkcs8Key, password)`; +`TlsPolicy.insecure(clientCert, pkcs8Key, password)` 关闭对端校验但仍携带客户端身份。 被调度智能体的 `caCertsPath` 不会自动传给 LoadPsop。 LoadPsop 不修改 JVM 全局 SSLContext、默认 SocketFactory 或 HostnameVerifier; 被调度智能体 HTTP/JSON-RPC 的 `false` 仍仅跳过证书链校验。 +原 LoadPsop 方法签名保留,但 accessToken 改用 Bearer 请求头,不再进入 URL。 +原 RegistryClient boolean 构造仍为匿名调用,false 仍只关闭证书链校验; +新接入应使用显式的认证/TLS 构造,不要混淆两种语义。 + +`HttpAuthProvider.bearer(tokenSupplier)` 每次请求读取一次当前 Token。OAuth2 获取、缓存、刷新 +由应用接入的 IAM 客户端负责,SDK 不会因 401 自动重放业务写请求。业务自定义认证实现 +`headers(URI)`,返回自定义请求头;大小写重复及保留的传输请求头会被拒绝。 +`HttpAuthProvider.none()` 只用于服务明确允许匿名访问或使用 mTLS 身份的场景。 + +SpringSpnDemo 使用 `ORCH_USERNAME`、`ORCH_PASSWORD`(与登录页面相同的账号)、 +`ORCH_VERIFY_SERVER`(默认 true)及可选 `ORCH_CA_CERTS_PATH`,不复用 +`A2A_SSL_VERIFY` 或智能体/Order 凭据。密码通过外部 Secret 注入,不写入提交的 +YAML 或命令行参数。共享账号提供器首次调用时向 `POST /api/v1/auth/token` +发送 `{username, password}` JSON,按 `expires_in` 缓存 `access_token`;到期后 +重新登录。该第一方接口不是 OAuth2 密码授权或 refresh-token 服务。 + +只有 PSOP 加载/搜索在 401 后允许刷新凭据并最多重试一次;403、429、服务错误、 +TLS/网络失败和登录拒绝均不重试。凭据限定在配置的编排中心 PSOP API,不进入 +查询参数或 Cookie,不发往其他来源。登录和 PSOP 使用相同的中心 TLS 策略; +关闭服务端证书校验不关闭账号鉴权。HTTP 用于隔离联调,但会明文传输密码, +部署应优先使用校验对端的 HTTPS。多个执行共享同一 `OrchestrationAccountAuth` +实例,复用线程安全的会话缓存;中心账号不影响 Task-T 或 Notification-T 流。 + +外部管理的 Bearer 可单独设置 `ORCH_API_TOKEN`,不能与账号密码混用; +静态 Token/supplier 提供器默认不参与 401 自动重试。 +动态或自定义认证可提供名为 `orchestrationHttpAuthProvider` 的 Spring bean; +mTLS 或应用自管 TLS 策略可提供名为 `orchestrationTlsPolicy` 的 TlsPolicy bean。 +HostQuickStart 使用 `REGISTRY_API_TOKEN` 完成注册中心发现认证。 +中心的 401/403、TLS/网络错误、无候选结果均不会改为执行另一条工作流。 +`--a2a.local-workflow-enabled=true` 明确选择离线示例工作流,不访问中心,默认关闭,不作为生产兜底。 + ### 13.2 被调度智能体端点 引擎只消费 AgentCard,不启动被调度智能体服务。宿主智能体负责 AgentCard 发现、端点可达性校验与开发测试资源。使用外部管理的被调度智能体时,不得在相同端点上启动本地测试服务。生产 AgentCard 和凭据应保存在仓库外部。 diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/config/OrchestrationAccess.java b/samples/src/main/java/dev/openan/workflow/engine/examples/config/OrchestrationAccess.java new file mode 100644 index 0000000..3cb2d26 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/config/OrchestrationAccess.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and limitations under the License. + */ +package dev.openan.workflow.engine.examples.config; + +import dev.openan.workflow.engine.registry.HttpAuthProvider; +import dev.openan.workflow.engine.registry.OrchestrationAccountAuth; +import dev.openan.workflow.engine.registry.TlsPolicy; + +/** Sample composition only: center account authentication is independent of the agent transport. */ +public final class OrchestrationAccess { + private OrchestrationAccess() {} + + /** Builds one shared provider; never silently chooses between two configured identities. */ + public static HttpAuthProvider authentication(WorkbenchClientProperties properties, TlsPolicy tls) { + boolean username = present(properties.getOrchUsername()); + boolean password = present(properties.getOrchPassword()); + if (username || password) { + if (!username || !password) { + throw new IllegalArgumentException("Configure both ORCH_USERNAME and ORCH_PASSWORD"); + } + if (present(properties.getOrchApiToken())) { + throw new IllegalArgumentException("Choose account login or ORCH_API_TOKEN, not both"); + } + return new OrchestrationAccountAuth(properties.getOrchUrl(), properties.getOrchUsername(), + properties.getOrchPassword(), tls); + } + // Externally supplied Bearer remains explicit. Missing credentials fail at request time; + // constructing an offline sample neither contacts the center nor acquires a token. + return HttpAuthProvider.bearer(properties::getOrchApiToken); + } + + private static boolean present(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/config/WorkbenchClientProperties.java b/samples/src/main/java/dev/openan/workflow/engine/examples/config/WorkbenchClientProperties.java index 01b4034..3e00af8 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/config/WorkbenchClientProperties.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/config/WorkbenchClientProperties.java @@ -27,6 +27,12 @@ @ConfigurationProperties(prefix = "a2a") public class WorkbenchClientProperties { private String orchUrl = "https://127.0.0.1:5001"; + private String orchApiToken = ""; + private String orchUsername = ""; + private String orchPassword = ""; + private boolean orchVerifyServer = true; + private String orchCaCertsPath = ""; + private boolean localWorkflowEnabled; private String credentialsPath = ""; private boolean sslVerify; private String a2atEnvPath = ""; @@ -55,6 +61,22 @@ public String getOrchUrl() { return orchUrl; } + /** Center-only token; never reused for agent or Order authentication. */ + public String getOrchApiToken() { return orchApiToken; } + public void setOrchApiToken(String value) { orchApiToken = value; } + /** Login-page account; never reused for dispatched agents. */ + public String getOrchUsername() { return orchUsername; } + public void setOrchUsername(String value) { orchUsername = value; } + /** External secret used only to acquire the center's account Bearer token. */ + public String getOrchPassword() { return orchPassword; } + public void setOrchPassword(String value) { orchPassword = value; } + public boolean isOrchVerifyServer() { return orchVerifyServer; } + public void setOrchVerifyServer(boolean value) { orchVerifyServer = value; } + public String getOrchCaCertsPath() { return orchCaCertsPath; } + public void setOrchCaCertsPath(String value) { orchCaCertsPath = value; } + public boolean isLocalWorkflowEnabled() { return localWorkflowEnabled; } + public void setLocalWorkflowEnabled(boolean value) { localWorkflowEnabled = value; } + public void setOrchUrl(String orchUrl) { this.orchUrl = orchUrl; } diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/demo/HostQuickStart.java b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/HostQuickStart.java index 37c1dfb..52327dd 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/demo/HostQuickStart.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/HostQuickStart.java @@ -27,6 +27,7 @@ import dev.openan.workflow.engine.control.ControlPoint; import dev.openan.workflow.engine.model.*; import dev.openan.workflow.engine.registry.RegistryClient; +import dev.openan.workflow.engine.registry.HttpAuthProvider; import dev.openan.workflow.engine.runner.ExecutePsop; import java.util.List; import java.util.Map; @@ -38,12 +39,13 @@ public final class HostQuickStart { private HostQuickStart() {} - /** Arguments: registry URL, target agent name, credentials JSON path. */ + /** Arguments: registry URL, target agent name, credentials JSON path. REGISTRY_API_TOKEN authenticates discovery. */ public static void main(String[] args) throws Exception { if (args.length != 3) { throw new IllegalArgumentException("Expected: registry-url agent-name credentials-json"); } - List cards = loadCards(new RegistryClient(args[0], true)); + List cards = loadCards(new RegistryClient( + args[0], HttpAuthProvider.bearer(() -> System.getenv("REGISTRY_API_TOKEN")))); try (A2ATransport transport = new A2ATransport(cards, null, WorkflowEngineClientConfig.builder().sslVerify(true).credentialsConfigPath(args[2]).build())) { ExecutionResult result = execute(workflow(args[1]), cards, new DefaultWorkflowEngineClient(transport)); @@ -80,8 +82,13 @@ public static ExecutionResult execute(Workflow workflow, List cards, .onNegotiation(request -> CompletableFuture.completedFuture( new NegotiationReply.Stop("manual.required", "Host review required"))) .build(); - CompletableFuture execution = ExecutePsop.builder().psop(workflow) - .engineClient(client).controlPoint(callbacks).runtimeIntent("Diagnose the supplied issue").execute(); + CompletableFuture execution = + ExecutePsop.builder() + .psop(workflow) + .engineClient(client) + .controlPoint(callbacks) + .runtimeIntent("Diagnose the supplied issue") + .execute(); try { return execution.get(10, TimeUnit.MINUTES); } finally { diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExecutor.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExecutor.java index 4e0bd6c..d4cc897 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExecutor.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/SpringWorkbenchExecutor.java @@ -23,6 +23,8 @@ import dev.openan.workflow.engine.examples.agents.BaseAgentExecutor; import dev.openan.workflow.engine.examples.config.WorkbenchClientProperties; import dev.openan.workflow.engine.examples.util.EnvResolver; +import dev.openan.workflow.engine.registry.HttpAuthProvider; +import dev.openan.workflow.engine.registry.TlsPolicy; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -48,12 +50,26 @@ public class SpringWorkbenchExecutor extends BaseAgentExecutor { private static final Logger log = LoggerFactory.getLogger(SpringWorkbenchExecutor.class); private final WorkbenchClientProperties properties; + private final HttpAuthProvider centerAuth; + private final TlsPolicy centerTls; private volatile WorkbenchTaskInputParser taskInputParser; private final java.util.concurrent.ConcurrentMap executions = new java.util.concurrent.ConcurrentHashMap<>(); - public SpringWorkbenchExecutor(WorkbenchClientProperties properties) { + public SpringWorkbenchExecutor( + WorkbenchClientProperties properties, + @org.springframework.beans.factory.annotation.Qualifier("orchestrationHttpAuthProvider") + org.springframework.beans.factory.ObjectProvider centerAuthProvider, + @org.springframework.beans.factory.annotation.Qualifier("orchestrationTlsPolicy") + org.springframework.beans.factory.ObjectProvider centerTlsProvider) { this.properties = properties; + this.centerTls = centerTlsProvider.getIfAvailable(() -> + !properties.isOrchVerifyServer() ? TlsPolicy.insecure() + : properties.getOrchCaCertsPath() == null || properties.getOrchCaCertsPath().isBlank() + ? TlsPolicy.strict() : TlsPolicy.trustedCa(properties.getOrchCaCertsPath())); + this.centerAuth = centerAuthProvider.getIfAvailable( + () -> dev.openan.workflow.engine.examples.config.OrchestrationAccess.authentication( + properties, centerTls)); } private static long elapsedMillis(long startedNanos) { @@ -109,7 +125,11 @@ public void execute(RequestContext ctx, AgentEmitter emitter) throws A2AError { properties.getOrchUrl(), resolveCredentialsPath(), properties.isSslVerify(), - resolveEnvPath()) + resolveEnvPath(), + null, + null, + new WorkbenchOrchestrator.CenterAccess( + centerAuth, centerTls, properties.isLocalWorkflowEnabled())) .run(input, properties.isDemoNegotiationEnabled(), cancellation); cancellation.check(); Map metadata = new LinkedHashMap<>(); diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java index e9ea0d5..fbf7281 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java @@ -21,6 +21,7 @@ import dev.openan.workflow.engine.client.A2AJavaClientRuntime; import dev.openan.workflow.engine.client.A2ATransport; +import dev.openan.workflow.engine.client.AuthProvider; import dev.openan.workflow.engine.client.DefaultWorkflowEngineClient; import dev.openan.workflow.engine.client.WorkflowEngineClient; import dev.openan.workflow.engine.client.WorkflowEngineClientConfig; @@ -34,7 +35,9 @@ import dev.openan.workflow.engine.model.Workflow; import dev.openan.workflow.engine.model.WorkflowSearchResult; import dev.openan.workflow.engine.model.WorkflowStep; +import dev.openan.workflow.engine.registry.HttpAuthProvider; import dev.openan.workflow.engine.registry.LoadPsop; +import dev.openan.workflow.engine.registry.TlsPolicy; import dev.openan.workflow.engine.runner.ExecutePsop; import java.util.List; import java.util.Map; @@ -70,10 +73,12 @@ public class WorkbenchOrchestrator { private final boolean sslVerify; private final String a2atEnvPath; private final A2AJavaClientRuntime clientRuntime; + private final AuthProvider authProvider; + private final CenterAccess centerAccess; public WorkbenchOrchestrator( String orchUrl, String credentialsPath, boolean sslVerify, String a2atEnvPath) { - this(orchUrl, credentialsPath, sslVerify, a2atEnvPath, null); + this(orchUrl, credentialsPath, sslVerify, a2atEnvPath, null, null); } public WorkbenchOrchestrator( @@ -82,11 +87,45 @@ public WorkbenchOrchestrator( boolean sslVerify, String a2atEnvPath, A2AJavaClientRuntime clientRuntime) { + this(orchUrl, credentialsPath, sslVerify, a2atEnvPath, clientRuntime, null); + } + + public WorkbenchOrchestrator( + String orchUrl, + String credentialsPath, + boolean sslVerify, + String a2atEnvPath, + A2AJavaClientRuntime clientRuntime, + AuthProvider authProvider) { + this( + orchUrl, + credentialsPath, + sslVerify, + a2atEnvPath, + clientRuntime, + authProvider, + new CenterAccess(HttpAuthProvider.none(), TlsPolicy.strict(), false)); + } + + /** + * Configures center access independently of agent transport credentials and TLS. A supplied + * runtime is owned by this single execution, including preparation failures. + */ + public WorkbenchOrchestrator( + String orchUrl, + String credentialsPath, + boolean sslVerify, + String a2atEnvPath, + A2AJavaClientRuntime clientRuntime, + AuthProvider authProvider, + CenterAccess centerAccess) { this.orchUrl = orchUrl; this.credentialsPath = credentialsPath; this.sslVerify = sslVerify; this.a2atEnvPath = a2atEnvPath; this.clientRuntime = clientRuntime; + this.authProvider = authProvider; + this.centerAccess = java.util.Objects.requireNonNull(centerAccess, "centerAccess"); } static String buildResultText(ExecutionResult result) { @@ -246,122 +285,137 @@ public String run(String messageText, boolean demoNegotiationEnabled) throws Exc String run(String messageText, boolean demoNegotiationEnabled, ExecutionCancellation cancellation) throws Exception { - cancellation.check(); - long runStarted = System.nanoTime(); - String runtimeName = - clientRuntime != null - ? clientRuntime.getClass().getSimpleName() - : "DefaultA2AJavaClientRuntime"; - log.info( - "[Orchestrator] START orchUrl={}, runtime={}, sslVerify={}, inputChars={}", - orchUrl, - runtimeName, - sslVerify, - messageText != null ? messageText.length() : 0); - - long stageStarted = System.nanoTime(); - log.info("[Orchestrator] STAGE_START stage=load-agent-cards"); - List agentCards = loadAgentCards(); - cancellation.check(); - log.info( - "[Orchestrator] STAGE_DONE stage=load-agent-cards, count={}, agents={}, elapsedMs={}", - agentCards.size(), - agentCards.stream().map(AgentCard::name).toList(), - elapsedMillis(stageStarted)); - - stageStarted = System.nanoTime(); - log.info("[Orchestrator] STAGE_START stage=search-load-psop"); - ResolvedWorkflow resolvedWorkflow = resolveWorkflow(messageText); - cancellation.check(); - String psopId = resolvedWorkflow.psopId(); - Workflow workflow = resolvedWorkflow.workflow(); - log.info( - "[Orchestrator] STAGE_DONE stage=search-load-psop, psopId={}, workflow={}, " - + "steps={}, elapsedMs={}", - psopId, - workflow.getName(), - workflow.getSteps().size(), - elapsedMillis(stageStarted)); - - stageStarted = System.nanoTime(); - log.info("[Orchestrator] STAGE_START stage=create-engine-client, runtime={}", runtimeName); - A2ATransport transport = - new A2ATransport( - agentCards, - clientRuntime, - WorkflowEngineClientConfig.builder() - .sslVerify(sslVerify) - .credentialsConfigPath(credentialsPath) - .build()); - log.info( - "[Orchestrator] STAGE_DONE stage=create-engine-client, contextId={}, elapsedMs={}", - transport.getContextId(), - elapsedMillis(stageStarted)); + A2ATransport transport = null; try { - WorkflowEngineClient engineClient = new DefaultWorkflowEngineClient(transport); - - stageStarted = System.nanoTime(); + cancellation.check(); + long runStarted = System.nanoTime(); + String runtimeName = + clientRuntime != null + ? clientRuntime.getClass().getSimpleName() + : "DefaultA2AJavaClientRuntime"; log.info( - "[Orchestrator] STAGE_START stage=execute-workflow, contextId={}, " - + "psopId={}, workflow={}", - transport.getContextId(), - psopId, - workflow.getName()); - WorkbenchControlPoint controlPoint = - new WorkbenchControlPoint( - a2atEnvPath, new NegotiationStrategy(a2atEnvPath), demoNegotiationEnabled); + "[Orchestrator] START orchUrl={}, runtime={}, sslVerify={}, inputChars={}", + orchUrl, + runtimeName, + sslVerify, + messageText != null ? messageText.length() : 0); + + long stageStarted = System.nanoTime(); + log.info("[Orchestrator] STAGE_START stage=load-agent-cards"); + List agentCards = loadAgentCards(); cancellation.check(); - CompletableFuture execution = - ExecutePsop.builder() - .psop(workflow) - .controlPoint(controlPoint) - .engineClient(engineClient) - .runtimeIntent(messageText) - .lang("zh") - .eventCallback(createLogCallback(transport.getContextId())) - .onFinish( - (r, events) -> { - log.info( - "[Orchestrator] WORKFLOW_FINISH success={}, history={}, events={}", - r.isSuccess(), - r.getHistory() != null ? r.getHistory().size() : 0, - events.size()); - return CompletableFuture.completedFuture(null); - }) - .execute(); - cancellation.bind(execution); - ExecutionResult result = execution.join(); + log.info( + "[Orchestrator] STAGE_DONE stage=load-agent-cards, count={}, agents={}, elapsedMs={}", + agentCards.size(), + agentCards.stream().map(AgentCard::name).toList(), + elapsedMillis(stageStarted)); + + stageStarted = System.nanoTime(); + log.info("[Orchestrator] STAGE_START stage=search-load-psop"); + ResolvedWorkflow resolvedWorkflow = resolveWorkflow(messageText); cancellation.check(); + String psopId = resolvedWorkflow.psopId(); + Workflow workflow = resolvedWorkflow.workflow(); log.info( - "[Orchestrator] STAGE_DONE stage=execute-workflow, contextId={}, success={}, " - + "history={}, elapsedMs={}", - transport.getContextId(), - result.isSuccess(), - result.getHistory() != null ? result.getHistory().size() : 0, + "[Orchestrator] STAGE_DONE stage=search-load-psop, psopId={}, workflow={}, " + + "steps={}, elapsedMs={}", + psopId, + workflow.getName(), + workflow.getSteps().size(), elapsedMillis(stageStarted)); - String resultText = buildResultText(result, workflow); + + stageStarted = System.nanoTime(); + log.info("[Orchestrator] STAGE_START stage=create-engine-client, runtime={}", runtimeName); + transport = + new A2ATransport( + agentCards, + clientRuntime, + WorkflowEngineClientConfig.builder() + .sslVerify(sslVerify) + .credentialsConfigPath(credentialsPath) + .authProvider(authProvider) + .build()); log.info( - "[Orchestrator] DONE contextId={}, success={}, resultChars={}, elapsedMs={}", + "[Orchestrator] STAGE_DONE stage=create-engine-client, contextId={}, elapsedMs={}", transport.getContextId(), - result.isSuccess(), - resultText.length(), - elapsedMillis(runStarted)); - if (!result.isSuccess()) { - throw new IllegalStateException(resultText); + elapsedMillis(stageStarted)); + try { + WorkflowEngineClient engineClient = new DefaultWorkflowEngineClient(transport); + + stageStarted = System.nanoTime(); + log.info( + "[Orchestrator] STAGE_START stage=execute-workflow, contextId={}, " + + "psopId={}, workflow={}", + transport.getContextId(), + psopId, + workflow.getName()); + WorkbenchControlPoint controlPoint = + new WorkbenchControlPoint( + a2atEnvPath, new NegotiationStrategy(a2atEnvPath), demoNegotiationEnabled); + cancellation.check(); + CompletableFuture execution = + ExecutePsop.builder() + .psop(workflow) + .controlPoint(controlPoint) + .engineClient(engineClient) + .runtimeIntent(messageText) + .lang("zh") + .eventCallback(createLogCallback(transport.getContextId())) + .onFinish( + (r, events) -> { + log.info( + "[Orchestrator] WORKFLOW_FINISH success={}, history={}, events={}", + r.isSuccess(), + r.getHistory() != null ? r.getHistory().size() : 0, + events.size()); + return CompletableFuture.completedFuture(null); + }) + .execute(); + cancellation.bind(execution); + ExecutionResult result = execution.join(); + cancellation.check(); + log.info( + "[Orchestrator] STAGE_DONE stage=execute-workflow, contextId={}, success={}, " + + "history={}, elapsedMs={}", + transport.getContextId(), + result.isSuccess(), + result.getHistory() != null ? result.getHistory().size() : 0, + elapsedMillis(stageStarted)); + String resultText = buildResultText(result, workflow); + log.info( + "[Orchestrator] DONE contextId={}, success={}, resultChars={}, elapsedMs={}", + transport.getContextId(), + result.isSuccess(), + resultText.length(), + elapsedMillis(runStarted)); + if (!result.isSuccess()) { + throw new IllegalStateException(resultText); + } + return resultText; + } catch (Exception e) { + log.error( + "[Orchestrator] FAILED contextId={}, elapsedMs={}, errorType={}, message={}", + transport.getContextId(), + elapsedMillis(runStarted), + e.getClass().getSimpleName(), + e.getMessage(), + e); + throw e; } - return resultText; - } catch (Exception e) { - log.error( - "[Orchestrator] FAILED contextId={}, elapsedMs={}, errorType={}, message={}", - transport.getContextId(), - elapsedMillis(runStarted), - e.getClass().getSimpleName(), - e.getMessage(), - e); - throw e; } finally { - log.info("[Orchestrator] TRANSPORT_CLOSE contextId={}", transport.getContextId()); - transport.close(); + if (transport != null) { + log.info("[Orchestrator] TRANSPORT_CLOSE contextId={}", transport.getContextId()); + transport.close(); + } else if (clientRuntime != null) { + // Preparation/cancellation/constructor failure: transport never took ownership. + try { + clientRuntime.close(); + } catch (RuntimeException closeError) { + log.warn( + "[Orchestrator] RUNTIME_CLOSE_FAILED errorType={}", + closeError.getClass().getSimpleName()); + } + } } } @@ -369,21 +423,21 @@ private List loadAgentCards() { return new WorkbenchAgentCatalog().load(); } - private ResolvedWorkflow resolveWorkflow(String messageText) { - try { - List results = - LoadPsop.search(orchUrl, messageText, 3, null, sslVerify); - if (!results.isEmpty()) { - String psopId = results.get(0).getWorkflowId(); - log.info("[Orchestrator] Found PSOP: {} (score={})", psopId, results.get(0).getScore()); - return new ResolvedWorkflow(psopId, LoadPsop.load(orchUrl, psopId, null, sslVerify)); - } - log.warn("[Orchestrator] PSOP search returned no candidates"); - } catch (Exception e) { - log.warn("[Orchestrator] PSOP search failed, using fallback: {}", e.getMessage()); + ResolvedWorkflow resolveWorkflow(String messageText) throws Exception { + if (centerAccess.localWorkflowEnabled()) { + log.warn( + "[Orchestrator] LOCAL_DEMO_WORKFLOW psopId={} (center access explicitly disabled)", + FALLBACK_PSOP_ID); + return new ResolvedWorkflow(FALLBACK_PSOP_ID, fallbackWorkflow()); } - log.warn("[Orchestrator] PSOP_FALLBACK psopId={}", FALLBACK_PSOP_ID); - return new ResolvedWorkflow(FALLBACK_PSOP_ID, fallbackWorkflow()); + List results = + LoadPsop.search(orchUrl, messageText, 3, centerAccess.auth(), centerAccess.tls()); + if (results.isEmpty()) + throw new IllegalStateException("Orchestration center returned no matching workflow"); + String psopId = results.get(0).getWorkflowId(); + log.info("[Orchestrator] Found PSOP: {} (score={})", psopId, results.get(0).getScore()); + return new ResolvedWorkflow( + psopId, LoadPsop.load(orchUrl, psopId, centerAccess.auth(), centerAccess.tls())); } private EventCallback createLogCallback(String contextId) { @@ -445,5 +499,13 @@ public void onEvent(String type, Map data) { }; } - private record ResolvedWorkflow(String psopId, Workflow workflow) {} + record ResolvedWorkflow(String psopId, Workflow workflow) {} + + /** Local workflow mode is an explicit offline demo, never a response to center failures. */ + public record CenterAccess(HttpAuthProvider auth, TlsPolicy tls, boolean localWorkflowEnabled) { + public CenterAccess { + java.util.Objects.requireNonNull(auth, "auth"); + java.util.Objects.requireNonNull(tls, "tls"); + } + } } diff --git a/samples/src/main/resources/application.yml b/samples/src/main/resources/application.yml index c6b6304..c10ca5d 100644 --- a/samples/src/main/resources/application.yml +++ b/samples/src/main/resources/application.yml @@ -20,6 +20,16 @@ a2at: a2a: orch-url: https://127.0.0.1:5001 + # Center HTTP credentials/TLS are independent of agent credentials/TLS. + # Use the same account as the login page. Password comes from an external secret. + orch-username: ${ORCH_USERNAME:} + orch-password: ${ORCH_PASSWORD:} + # Alternative externally managed Bearer; do not combine with username/password. + orch-api-token: ${ORCH_API_TOKEN:} + orch-verify-server: ${ORCH_VERIFY_SERVER:true} + orch-ca-certs-path: ${ORCH_CA_CERTS_PATH:} + # Explicit offline demo only: do not contact the center or hide its authentication failures. + local-workflow-enabled: ${A2A_LOCAL_WORKFLOW_ENABLED:false} ssl-verify: ${A2A_SSL_VERIFY:false} task-cleanup-enabled: ${A2A_TASK_CLEANUP_ENABLED:true} task-cleanup-fail-fast: ${A2A_TASK_CLEANUP_FAIL_FAST:true} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/config/OrchestrationAccessTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/config/OrchestrationAccessTest.java new file mode 100644 index 0000000..8835169 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/config/OrchestrationAccessTest.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and limitations under the License. + */ +package dev.openan.workflow.engine.examples.config; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.openan.workflow.engine.registry.OrchestrationAccountAuth; +import dev.openan.workflow.engine.registry.TlsPolicy; +import java.net.URI; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.StandardEnvironment; + +class OrchestrationAccessTest { + @Test + void bindsLoginAccountIndependentlyOfAgentCredentialsAndBuildsLazyProvider() { + var environment = new StandardEnvironment(); + environment.getPropertySources().addFirst(new MapPropertySource("accounts", Map.of( + "a2a.orch-username", "operator", "a2a.orch-password", "synthetic-password", + "a2a.orch-url", "https://127.0.0.1:1", "a2a.orch-verify-server", "false", + "a2a.ssl-verify", "true"))); + var properties = Binder.get(environment).bind("a2a", Bindable.of(WorkbenchClientProperties.class)) + .orElseThrow(AssertionError::new); + assertEquals("operator", properties.getOrchUsername()); + assertEquals("synthetic-password", properties.getOrchPassword()); + assertTrue(properties.isSslVerify()); + assertFalse(properties.isOrchVerifyServer()); + assertInstanceOf(OrchestrationAccountAuth.class, + OrchestrationAccess.authentication(properties, TlsPolicy.insecure())); + } + + @Test + void ambiguousOrIncompleteConfigurationFailsInsteadOfUsingAnotherIdentity() { + var properties = new WorkbenchClientProperties(); + properties.setOrchUsername("operator"); + assertThrows(IllegalArgumentException.class, + () -> OrchestrationAccess.authentication(properties, TlsPolicy.strict())); + properties.setOrchPassword("synthetic-password"); + properties.setOrchApiToken("external-token"); + assertThrows(IllegalArgumentException.class, + () -> OrchestrationAccess.authentication(properties, TlsPolicy.strict())); + } + + @Test + void externalTokenIsExplicitAndMissingCredentialsNeverBecomeAnonymous() { + var properties = new WorkbenchClientProperties(); + var auth = OrchestrationAccess.authentication(properties, TlsPolicy.strict()); + URI uri = URI.create(properties.getOrchUrl() + "/api/v1/orchestrate/search"); + assertThrows(IllegalArgumentException.class, () -> auth.headers(uri)); + properties.setOrchApiToken("external-token"); + assertEquals(Map.of("Authorization", "Bearer external-token"), auth.headers(uri)); + assertFalse(auth.onUnauthorized(uri, auth.headers(uri))); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/config/WorkbenchClientPropertiesTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/config/WorkbenchClientPropertiesTest.java index bc8fcf8..45d5a27 100644 --- a/samples/src/test/java/dev/openan/workflow/engine/examples/config/WorkbenchClientPropertiesTest.java +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/config/WorkbenchClientPropertiesTest.java @@ -28,6 +28,25 @@ import org.springframework.core.env.StandardEnvironment; class WorkbenchClientPropertiesTest { + @Test + void centerCredentialsAndTlsAreConfiguredIndependentlyOfAgentTransport() { + WorkbenchClientProperties defaults = new WorkbenchClientProperties(); + org.junit.jupiter.api.Assertions.assertTrue(defaults.isOrchVerifyServer()); + org.junit.jupiter.api.Assertions.assertFalse(defaults.isLocalWorkflowEnabled()); + StandardEnvironment environment = new StandardEnvironment(); + environment.getPropertySources().addFirst(new MapPropertySource("centers", Map.of( + "a2a.orch-api-token", "synthetic-center-token", + "a2a.orch-verify-server", "false", + "a2a.ssl-verify", "true", + "a2a.local-workflow-enabled", "true"))); + WorkbenchClientProperties properties = Binder.get(environment) + .bind("a2a", Bindable.of(WorkbenchClientProperties.class)).orElseThrow(AssertionError::new); + assertEquals("synthetic-center-token", properties.getOrchApiToken()); + org.junit.jupiter.api.Assertions.assertFalse(properties.isOrchVerifyServer()); + org.junit.jupiter.api.Assertions.assertTrue(properties.isSslVerify()); + org.junit.jupiter.api.Assertions.assertTrue(properties.isLocalWorkflowEnabled()); + } + @Test void notificationAcknowledgementTimeoutIsExternallyConfigurable() { StandardEnvironment environment = new StandardEnvironment(); diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/DocumentedCallbackExampleTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/DocumentedCallbackExampleTest.java index 104c077..7093fbf 100644 --- a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/DocumentedCallbackExampleTest.java +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/DocumentedCallbackExampleTest.java @@ -76,10 +76,29 @@ class DocumentedIntegration { void run() throws Exception { """ + block(integration, "Workflow workflow =") + "\n" - + block(integration, "RegistryClient registry =") + "\n" + + block(integration, "HttpAuthProvider registryAuth =") + "\n" + block(integration, "ControlPoint callbacks =") + "\n" + block(integration, "CompletableFuture execution =") + "\n}}"; - for (String source : List.of(callbackSource, quickStartSource)) { + String centerSource = """ + import java.util.*; + import dev.openan.workflow.engine.registry.*; + import dev.openan.workflow.engine.model.*; + class DocumentedCenters { + void run() throws Exception { + """ + + block(integration, "TlsPolicy orchTls =") + "\n}}"; + String development = Files.readString(root.resolve("docs/" + language + "/DEVELOPER_GUIDE.md")); + assertTrue(development.contains("orchAuth, orchTls")); + assertFalse(development.contains("psopId, token, false")); + assertTrue(development.contains("subscribeOn(Schedulers.boundedElastic())")); + assertTrue(development.contains("produces = MediaType.TEXT_EVENT_STREAM_VALUE")); + String developmentSource = + "import dev.openan.workflow.engine.registry.*; import dev.openan.workflow.engine.model.*; " + + "class DocumentedDevelopment { String baseUrl = \"https://orchestration.example\";\n" + + block(development, "private final TlsPolicy orchTls =") + "\n" + + "Workflow load(String psopId) throws Exception { " + + "return LoadPsop.load(baseUrl, psopId, orchAuth, orchTls); }}"; + for (String source : List.of(callbackSource, quickStartSource, centerSource, developmentSource)) { JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); assertNotNull(compiler, "Tests require a JDK, not a JRE"); DiagnosticCollector diagnostics = new DiagnosticCollector<>(); diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoE2ETest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoE2ETest.java index 7d5d060..b90d338 100644 --- a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoE2ETest.java +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoE2ETest.java @@ -26,17 +26,61 @@ /** Runs the actual northbound Spring demo with local OMCs and the offline SDK provider. */ class SpringSpnDemoE2ETest { private dev.openan.workflow.engine.examples.testsupport.CapturedLogs captured; + @org.junit.jupiter.api.io.TempDir java.nio.file.Path centerTestDirectory; + private String previousCardLocations; + private final int[] cityPorts = new int[2]; + private java.nio.file.Path demoCredentials; @org.junit.jupiter.api.BeforeEach - void captureLogs() { + void captureLogs() throws Exception { captured = new dev.openan.workflow.engine.examples.testsupport.CapturedLogs(); + previousCardLocations = System.getProperty("A2A_AGENT_CARD_LOCATIONS"); + // Keep existing developer OMCs running: use isolated loopback ports and public demo materials. + try (var first = new java.net.ServerSocket(0); var second = new java.net.ServerSocket(0)) { + cityPorts[0] = first.getLocalPort(); + cityPorts[1] = second.getLocalPort(); + } + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + var locations = new java.util.ArrayList(); + for (int city = 1; city <= 2; city++) { + try (var input = getClass().getClassLoader().getResourceAsStream( + "agentcard/spn_domain_agent_city" + city + ".json")) { + var card = mapper.readTree(input); + ((com.fasterxml.jackson.databind.node.ObjectNode) card.path("supportedInterfaces").get(0)) + .put("url", cityTarget(city) + "/a2a/json"); + java.nio.file.Path path = centerTestDirectory.resolve("city" + city + ".json"); + mapper.writeValue(path.toFile(), card); + locations.add(path.toUri().toString()); + } + } + locations.add("classpath:agentcard/transport_workbench_agent.json"); + System.setProperty("A2A_AGENT_CARD_LOCATIONS", String.join(",", locations)); + String credentials; + try (var input = getClass().getClassLoader().getResourceAsStream("spn_agent_credentials.example.json")) { + credentials = new String(java.util.Objects.requireNonNull(input).readAllBytes(), + java.nio.charset.StandardCharsets.UTF_8); + } + credentials = credentials.replace(":26335/", ":" + cityPorts[0] + "/") + .replace(":26336/", ":" + cityPorts[1] + "/"); + demoCredentials = centerTestDirectory.resolve("credentials.json"); + java.nio.file.Files.writeString(demoCredentials, credentials); } @org.junit.jupiter.api.AfterEach void closeCapture() { + if (previousCardLocations == null) System.clearProperty("A2A_AGENT_CARD_LOCATIONS"); + else System.setProperty("A2A_AGENT_CARD_LOCATIONS", previousCardLocations); captured.close(); } + protected String cityTarget(int city) { + return "https://127.0.0.1:" + cityPorts[city - 1]; + } + + protected String demoCredentialsPath() { + return demoCredentials.toString(); + } + @Test @Timeout(120) void defaultLocalDemoNegotiatesOnlyCity1WithoutChangingGlobalSwitch() throws Exception { @@ -181,6 +225,8 @@ protected String[] arguments() { return new String[] { "--a2a.transport-mode=direct", "--a2a.orch-url=http://127.0.0.1:1", + "--a2a.local-workflow-enabled=true", + "--a2a.credentials-path=" + demoCredentialsPath(), "--a2a.a2at-env-path=" + OfflineA2ATLlmClient.envPath() }; } @@ -218,4 +264,51 @@ private void runDemo() throws Exception { else System.setProperty("a2at.env.path", previous); } } + + @Test + @Timeout(120) + void malformedCenterResponseCannotLeakTokenIntoNorthboundTaskOrLogs() throws Exception { + String token = "SyntheticNorthboundCenterCredential12345"; + var requests = new java.util.concurrent.atomic.AtomicInteger(); + var server = com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + if (("Bearer " + token).equals(exchange.getRequestHeaders().getFirst("Authorization"))) requests.incrementAndGet(); + exchange.getRequestBody().readAllBytes(); + byte[] body = token.getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (var output = exchange.getResponseBody()) { output.write(body); } + finally { exchange.close(); } + }); + server.start(); + int offset = captured.length(); + String previous = System.getProperty("a2at.env.path"); + try { + OfflineA2ATLlmClient.install(); + System.setProperty("a2at.env.path", OfflineA2ATLlmClient.envPath()); + var onlineArguments = new java.util.ArrayList<>(java.util.Arrays.stream(arguments()) + .filter(arg -> !arg.startsWith("--a2a.orch-url=") && !arg.startsWith("--a2a.local-workflow-enabled=")) + .toList()); + onlineArguments.add("--a2a.orch-url=http://127.0.0.1:" + server.getAddress().getPort()); + onlineArguments.add("--a2a.local-workflow-enabled=false"); + onlineArguments.add("--a2a.orch-api-token=" + token); + var error = org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, + () -> new SpringSpnDemo().run(onlineArguments.toArray(String[]::new))); + var stack = new java.io.StringWriter(); + error.printStackTrace(new java.io.PrintWriter(stack)); + org.junit.jupiter.api.Assertions.assertFalse(stack.toString().contains(token)); + org.junit.jupiter.api.Assertions.assertTrue(error.getMessage().contains("TASK_STATE_FAILED")); + org.junit.jupiter.api.Assertions.assertTrue(error.getMessage().contains("invalid response")); + String evidence = captured.since(offset); + org.junit.jupiter.api.Assertions.assertFalse(evidence.contains(token)); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("HttpResponseException")); + org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("SHUTDOWN_DONE success=false")); + org.junit.jupiter.api.Assertions.assertFalse(evidence.contains("LOCAL_DEMO_WORKFLOW")); + org.junit.jupiter.api.Assertions.assertFalse(evidence.contains("stage=execute-workflow")); + org.junit.jupiter.api.Assertions.assertEquals(1, requests.get()); + } finally { + server.stop(0); + if (previous == null) System.clearProperty("a2at.env.path"); + else System.setProperty("a2at.env.path", previous); + } + } } diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoFailureE2ETest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoFailureE2ETest.java index a9369b4..ce6730a 100644 --- a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoFailureE2ETest.java +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoFailureE2ETest.java @@ -46,6 +46,7 @@ protected String[] arguments(String city1, String city2) throws Exception { "--a2a.transport-mode=direct", "--a2a.embedded-omc-enabled=false", "--a2a.orch-url=http://127.0.0.1:1", + "--a2a.local-workflow-enabled=true", "--a2a.a2at-env-path=" + OfflineA2ATLlmClient.envPath() }; } diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchCenterAccessTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchCenterAccessTest.java new file mode 100644 index 0000000..0251db5 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchCenterAccessTest.java @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.examples.workbench; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sun.net.httpserver.HttpServer; +import dev.openan.workflow.engine.registry.HttpAuthProvider; +import dev.openan.workflow.engine.registry.HttpResponseException; +import dev.openan.workflow.engine.registry.HttpServiceException; +import dev.openan.workflow.engine.registry.TlsPolicy; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class WorkbenchCenterAccessTest { + @ParameterizedTest + @ValueSource(ints = {401, 403, 500}) + void centerErrorsNeverSelectAnAlternateWorkflow(int status) throws Exception { + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/", + exchange -> { + exchange.getRequestBody().readAllBytes(); + byte[] body = "rejected".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, body.length); + try (var output = exchange.getResponseBody()) { + output.write(body); + } finally { + exchange.close(); + } + }); + server.start(); + try { + var orchestrator = orchestrator("http://127.0.0.1:" + server.getAddress().getPort(), false); + assertEquals( + status, + assertThrows(HttpServiceException.class, () -> orchestrator.resolveWorkflow("intent")) + .getStatusCode()); + } finally { + server.stop(0); + } + } + + @Test + void searchAndLoadBothReceiveCenterCredentialsAndLoadRejectionIsNotHidden() throws Exception { + var received = new AtomicInteger(); + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/", + exchange -> { + exchange.getRequestBody().readAllBytes(); + if ("Bearer center.token" + .equals(exchange.getRequestHeaders().getFirst("Authorization"))) { + received.incrementAndGet(); + } + boolean search = exchange.getRequestURI().getPath().endsWith("/search"); + byte[] body = + (search ? "{\"data\":[{\"workflow_id\":\"flow\"}]}" : "denied") + .getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(search ? 200 : 403, body.length); + try (var output = exchange.getResponseBody()) { + output.write(body); + } finally { + exchange.close(); + } + }); + server.start(); + try { + var orchestrator = orchestrator("http://127.0.0.1:" + server.getAddress().getPort(), false); + assertEquals( + 403, + assertThrows(HttpServiceException.class, () -> orchestrator.resolveWorkflow("intent")) + .getStatusCode()); + assertEquals(2, received.get()); + } finally { + server.stop(0); + } + } + + @Test + void explicitLocalDemoDoesNotContactTheCenterOrEvaluateItsCredentials() throws Exception { + HttpAuthProvider forbidden = + uri -> { + throw new AssertionError("must not acquire center credentials"); + }; + var orchestrator = + new WorkbenchOrchestrator( + "http://127.0.0.1:1", + null, + false, + null, + null, + null, + new WorkbenchOrchestrator.CenterAccess(forbidden, TlsPolicy.strict(), true)); + assertEquals(3, orchestrator.resolveWorkflow("intent").workflow().getSteps().size()); + assertThrows( + Exception.class, () -> orchestrator("http://127.0.0.1:1", false).resolveWorkflow("intent")); + } + + private static WorkbenchOrchestrator orchestrator(String url, boolean local) { + return new WorkbenchOrchestrator( + url, + null, + false, + null, + null, + null, + new WorkbenchOrchestrator.CenterAccess( + HttpAuthProvider.bearer("center.token"), TlsPolicy.strict(), local)); + } + + @ParameterizedTest + @ValueSource( + strings = { + "rejected", + "empty", + "malformed", + "cancelled", + "constructor", + "completed", + "workflow-failed", + "close-failed" + }) + void suppliedRuntimeIsClosedExactlyOnceOnEveryExecutionExit(String scenario) throws Exception { + dev.openan.workflow.engine.examples.testsupport.OfflineA2ATLlmClient.install(); + var closes = new AtomicInteger(); + var sends = new AtomicInteger(); + var cancellation = new ExecutionCancellation(); + if (scenario.equals("cancelled")) cancellation.cancel(); + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/", + exchange -> { + exchange.getRequestBody().readAllBytes(); + boolean search = exchange.getRequestURI().getPath().endsWith("/search"); + int status = scenario.equals("rejected") || scenario.equals("close-failed") ? 401 : 200; + String body = + scenario.equals("malformed") + ? "InvalidSyntheticBody" + : search + ? scenario.equals("empty") + ? "{\"data\":[]}" + : "{\"data\":[{\"workflow_id\":\"flow\"}]}" + : scenario.equals("workflow-failed") + ? "{\"data\":{\"name\":\"flow\",\"steps\":[{\"name\":\"invalid\",\"next\":[{\"step\":\"missing\"}]}]}}" + : "{\"data\":{\"name\":\"flow\",\"steps\":[]}}"; + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + try (var output = exchange.getResponseBody()) { + output.write(bytes); + } finally { + exchange.close(); + } + }); + server.start(); + dev.openan.workflow.engine.client.A2AJavaClientRuntime runtime = + new dev.openan.workflow.engine.client.A2AJavaClientRuntime() { + public Iterable sendMessage( + org.a2aproject.sdk.spec.AgentCard card, + org.a2aproject.sdk.spec.MessageSendParams params, + org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext context, + java.util.function.Consumer sink, + java.util.function.Consumer logs) { + sends.incrementAndGet(); + return java.util.List.of(); + } + + public void close() { + closes.incrementAndGet(); + if (scenario.equals("close-failed")) + throw new IllegalStateException("SyntheticCloseFailure"); + } + }; + try { + // A non-existent credentials path makes transport construction fail deterministically. + String credentials = + scenario.equals("constructor") + ? "classpath:nonexistent-review-credentials-" + java.util.UUID.randomUUID() + ".json" + : null; + var orchestrator = + new WorkbenchOrchestrator( + "http://127.0.0.1:" + server.getAddress().getPort(), + credentials, + false, + dev.openan.workflow.engine.examples.testsupport.OfflineA2ATLlmClient.envPath(), + runtime, + null, + new WorkbenchOrchestrator.CenterAccess( + HttpAuthProvider.bearer("center.token"), TlsPolicy.strict(), false)); + switch (scenario) { + case "completed" -> assertNotNull(orchestrator.run("intent", false, cancellation)); + case "rejected", "close-failed" -> + assertEquals( + 401, + assertThrows( + HttpServiceException.class, + () -> orchestrator.run("intent", false, cancellation)) + .getStatusCode()); + case "malformed" -> + assertThrows( + HttpResponseException.class, () -> orchestrator.run("intent", false, cancellation)); + case "cancelled" -> + assertThrows( + java.util.concurrent.CancellationException.class, + () -> orchestrator.run("intent", false, cancellation)); + case "empty" -> + assertTrue( + assertThrows( + IllegalStateException.class, + () -> orchestrator.run("intent", false, cancellation)) + .getMessage() + .contains("no matching workflow")); + case "constructor" -> + assertTrue( + assertThrows( + IllegalStateException.class, + () -> orchestrator.run("intent", false, cancellation)) + .getMessage() + .contains("Failed to load credentials")); + case "workflow-failed" -> + assertTrue( + assertThrows( + IllegalStateException.class, + () -> orchestrator.run("intent", false, cancellation)) + .getMessage() + .contains("missing")); + default -> fail("Unknown scenario"); + } + assertEquals(1, closes.get()); + assertEquals(0, sends.get()); + } finally { + server.stop(0); + } + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SslContextFactory.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SslContextFactory.java index c3ed979..feab3b2 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SslContextFactory.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SslContextFactory.java @@ -243,6 +243,56 @@ public static SSLContext createTrustAll() { } } + /** + * Builds a connection-local management TLS opt-out, including hostname verification. Existing A2A + * trust-all methods retain their hostname-verification contract. Client identity is still loaded + * even when remote identity is not verified. + */ + public static SSLContext createInsecure(String certPath, String keyPath, String keyPassword) { + try { + SSLContext context = SSLContext.getInstance("TLS"); + context.init( + loadKeyManagers(certPath, keyPath, keyPassword), + new TrustManager[] {new InsecureExtendedTrustManager()}, + null); + return context; + } catch (Exception error) { + throw new IllegalStateException("Failed to build insecure management TLS context", error); + } + } + + // JDK HttpClient forces HTTPS endpoint identification. An extended manager is necessary: + // an ordinary trust-all manager is wrapped by JSSE, which re-enables hostname checking. + private static final class InsecureExtendedTrustManager + extends javax.net.ssl.X509ExtendedTrustManager { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) {} + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) {} + + @Override + public void checkClientTrusted( + X509Certificate[] chain, String authType, java.net.Socket socket) {} + + @Override + public void checkServerTrusted( + X509Certificate[] chain, String authType, java.net.Socket socket) {} + + @Override + public void checkClientTrusted( + X509Certificate[] chain, String authType, javax.net.ssl.SSLEngine engine) {} + + @Override + public void checkServerTrusted( + X509Certificate[] chain, String authType, javax.net.ssl.SSLEngine engine) {} + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } + private static X509TrustManager trustAllManager() { return new X509TrustManager() { public void checkClientTrusted(X509Certificate[] chain, String authType) {} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/CenterHttpClient.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/CenterHttpClient.java new file mode 100644 index 0000000..22ce184 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/CenterHttpClient.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and limitations under the License. + */ +package dev.openan.workflow.engine.registry; + +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import javax.net.ssl.HttpsURLConnection; + +/** Shared connection-local transport; does not follow redirects or own authentication refresh. */ +final class CenterHttpClient { + private CenterHttpClient() {} + + static Response execute( + String method, + URI uri, + String body, + HttpAuthProvider auth, + TlsPolicy tls, + LoadPsop.Timeouts timeouts, + int responseLimit) + throws Exception { + Map headers = HttpAuthentication.headers(auth, uri); + var connection = (HttpURLConnection) uri.toURL().openConnection(); + try { + if (connection instanceof HttpsURLConnection https) tls.configure(https); + connection.setInstanceFollowRedirects(false); + connection.setConnectTimeout(timeouts.connectTimeoutMillis()); + connection.setReadTimeout(timeouts.readTimeoutMillis()); + connection.setRequestMethod(method); + headers.forEach(connection::setRequestProperty); + if (body != null) { + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/json"); + try (var output = connection.getOutputStream()) { + output.write(body.getBytes(StandardCharsets.UTF_8)); + } + } + int status = connection.getResponseCode(); + try (InputStream input = + status >= 400 ? connection.getErrorStream() : connection.getInputStream()) { + byte[] bytes = + input == null + ? new byte[0] + : responseLimit > 0 ? input.readNBytes(responseLimit + 1) : input.readAllBytes(); + if (responseLimit > 0 && bytes.length > responseLimit) { + throw new HttpResponseException("Orchestration center", "read response", status); + } + return new Response(status, new String(bytes, StandardCharsets.UTF_8), headers); + } + } finally { + connection.disconnect(); + } + } + + record Response(int statusCode, String body, Map credentials) {} +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpAuthProvider.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpAuthProvider.java new file mode 100644 index 0000000..02fed70 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpAuthProvider.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.registry; + +import java.net.URI; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Supplies management HTTP credentials immediately before each request. Independent of A2A + * authentication: applications own OAuth2 acquisition/refresh; OrchestrationAccountAuth provides + * optional first-party account sessions for PSOP APIs. Implementations shared across threads must + * be thread-safe and must not log credentials. + */ +@FunctionalInterface +public interface HttpAuthProvider { + /** Returns authentication headers for this request; never null. */ + Map headers(URI requestUri); + + /** + * Allows one credential refresh after a rejected PSOP read/search. The headers are those actually + * sent, so implementations can avoid invalidating a newer token acquired by another thread. + * Defaults to no retry; this hook is never used to replay business writes. + */ + default boolean onUnauthorized(URI requestUri, Map rejectedHeaders) { + return false; + } + + /** Explicit anonymous access, only for a service configured to allow it. */ + static HttpAuthProvider none() { + return uri -> Map.of(); + } + + /** Uses a fixed Bearer token. Missing or malformed tokens fail before any request is sent. */ + static HttpAuthProvider bearer(String token) { + requireBearerToken(token); + return bearer(() -> token); + } + + /** Reads the current token once per request; the supplier owns refresh and expiry handling. */ + static HttpAuthProvider bearer(Supplier tokens) { + Objects.requireNonNull(tokens, "tokens"); + return uri -> { + String token = tokens.get(); + requireBearerToken(token); + return Map.of("Authorization", "Bearer " + token); + }; + } + + private static void requireBearerToken(String token) { + if (token == null || !token.matches("[A-Za-z0-9._~+/-]+=*")) { + throw new IllegalArgumentException("Bearer token is missing or malformed"); + } + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpAuthentication.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpAuthentication.java new file mode 100644 index 0000000..bb63815 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpAuthentication.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.registry; + +import java.net.URI; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Shared request-boundary validation; never renders credential values in errors. */ +final class HttpAuthentication { + private static final Set RESERVED = + Set.of( + "host", + "connection", + "content-length", + "transfer-encoding", + "upgrade", + "expect", + "content-type", + "proxy-authorization"); + + private HttpAuthentication() {} + + static String baseUrl(String url) { + Objects.requireNonNull(url, "url"); + URI uri; + try { + uri = URI.create(url); + } catch (IllegalArgumentException invalidUrl) { + // URI parsing errors render their input, which may itself contain misplaced credentials. + throw new IllegalArgumentException("Service URL must be a valid HTTP(S) URL"); + } + if (!("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme())) + || uri.getHost() == null + || uri.getRawUserInfo() != null + || uri.getRawQuery() != null + || uri.getRawFragment() != null) { + throw new IllegalArgumentException( + "Service URL must be HTTP(S), without credentials, query or fragment"); + } + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + + static Map headers(HttpAuthProvider auth, URI uri) { + Map supplied; + try { + supplied = Objects.requireNonNull(auth.headers(uri), "authentication headers"); + } catch (HttpServiceException | HttpResponseException safeFailure) { + throw safeFailure; + } catch (RuntimeException providerFailure) { + // IAM clients may include credentials or response bodies in their exception messages. + throw new IllegalStateException( + "HTTP authentication provider failed (" + + providerFailure.getClass().getSimpleName() + + ")"); + } + Map result = new LinkedHashMap<>(); + Set seen = new HashSet<>(); + supplied.forEach( + (name, value) -> { + if (name == null + || !name.matches("[!#$%&'*+.^_`|~0-9A-Za-z-]+") + || RESERVED.contains(name.toLowerCase(Locale.ROOT)) + || !seen.add(name.toLowerCase(Locale.ROOT)) + || value == null + || value.isBlank() + || value.chars().anyMatch(c -> c < 32 || c > 126)) { + throw new IllegalArgumentException( + "Invalid, duplicate or reserved authentication header"); + } + result.put(name, value); + }); + return Map.copyOf(result); + } + + static boolean onUnauthorized(HttpAuthProvider auth, URI uri, Map headers) { + try { + return auth.onUnauthorized(uri, headers); + } catch (RuntimeException providerFailure) { + throw new IllegalStateException( + "HTTP authentication refresh failed (" + + providerFailure.getClass().getSimpleName() + + ")"); + } + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpResponseException.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpResponseException.java new file mode 100644 index 0000000..ab3607c --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpResponseException.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package dev.openan.workflow.engine.registry; + +/** + * A successful management HTTP response with invalid JSON or an incompatible payload. Deliberately + * contains neither the body nor the original exception: parsers can echo credentials in messages. + */ +public final class HttpResponseException extends RuntimeException { + private static final long serialVersionUID = 1L; + private final int statusCode; + + HttpResponseException(String service, String operation, int statusCode) { + super( + service + " returned an invalid response for " + operation + " (HTTP " + statusCode + ")"); + this.statusCode = statusCode; + } + + /** The successful HTTP status whose response could not be interpreted. */ + public int getStatusCode() { + return statusCode; + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpResponseParser.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpResponseParser.java new file mode 100644 index 0000000..dd19f1d --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpResponseParser.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package dev.openan.workflow.engine.registry; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; + +/** Security boundary for both JSON decoding and response-to-model conversion. */ +final class HttpResponseParser { + private static final ObjectMapper JSON = + new ObjectMapper().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + + private HttpResponseParser() {} + + @SuppressWarnings("unchecked") + static T parse( + String service, + String operation, + int status, + String body, + Function, T> decode) { + try { + Map data = Objects.requireNonNull(JSON.readValue(body, Map.class)); + return decode.apply(data); + } catch (IOException | RuntimeException invalidResponse) { + // Do not retain parser/mapping causes or suppressed exceptions; these may contain body data. + throw new HttpResponseException(service, operation, status); + } + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpServiceException.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpServiceException.java new file mode 100644 index 0000000..2dca9c4 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpServiceException.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.registry; + +import dev.openan.workflow.engine.util.SensitiveDataRedactor; +import java.util.Map; + +/** A management service HTTP rejection, distinguishable from transport and parsing failures. */ +public final class HttpServiceException extends RuntimeException { + private static final long serialVersionUID = 1L; + private final int statusCode; + + HttpServiceException(String service, int status, String body, Map credentials) { + super(service + " returned " + status + detail(status, body, credentials)); + statusCode = status; + } + + /** HTTP response status. */ + public int getStatusCode() { + return statusCode; + } + + /** Authentication and permission errors must not trigger an alternate business execution. */ + public boolean isAuthenticationFailure() { + return statusCode == 401 || statusCode == 403; + } + + private static String detail(int status, String body, Map credentials) { + // Authentication response bodies may echo opaque tokens without a recognizable field name. + if (status == 401 || status == 403) return ""; + String safe = body == null ? "" : body; + for (String value : credentials.values()) { + safe = safe.replace(value, "***"); + if (value.regionMatches(true, 0, "Bearer ", 0, 7)) { + safe = safe.replace(value.substring(7), "***"); + } + } + safe = SensitiveDataRedactor.redact(safe).replace("\r", "\\r").replace("\n", "\\n"); + if (safe.length() > 512) safe = safe.substring(0, 512) + "..."; + return safe.isBlank() ? "" : ": " + safe; + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/LoadPsop.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/LoadPsop.java index 5df62bd..efae4d2 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/LoadPsop.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/LoadPsop.java @@ -20,20 +20,14 @@ package dev.openan.workflow.engine.registry; import com.fasterxml.jackson.databind.ObjectMapper; -import dev.openan.workflow.engine.client.SslContextFactory; import dev.openan.workflow.engine.model.Workflow; import dev.openan.workflow.engine.model.WorkflowSearchResult; -import dev.openan.workflow.engine.util.SensitiveDataRedactor; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.HttpURLConnection; import java.net.URI; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; import java.util.Map; -import javax.net.ssl.HttpsURLConnection; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -65,27 +59,48 @@ public static Workflow load(String baseUrl, String psopId, String accessToken, b public static Workflow load( String baseUrl, String psopId, String accessToken, boolean sslVerify, Timeouts timeouts) throws Exception { - StringBuilder urlBuilder = - new StringBuilder(baseUrl).append("/api/v1/orchestrate/psop/").append(psopId); - if (accessToken != null && !accessToken.isEmpty()) { - urlBuilder - .append("?access_token=") - .append(URLEncoder.encode(accessToken, StandardCharsets.UTF_8)); + return load( + baseUrl, + psopId, + tokenAuth(accessToken), + sslVerify ? TlsPolicy.strict() : TlsPolicy.insecure(), + timeouts); + } + + /** Loads a workflow using independent HTTP authentication and TLS policy. */ + public static Workflow load(String baseUrl, String psopId, HttpAuthProvider auth, TlsPolicy tls) + throws Exception { + return load(baseUrl, psopId, auth, tls, DEFAULT_TIMEOUTS); + } + + /** Loads a workflow with per-request credentials and explicit deadlines. */ + public static Workflow load( + String baseUrl, String psopId, HttpAuthProvider auth, TlsPolicy tls, Timeouts timeouts) + throws Exception { + java.util.Objects.requireNonNull(psopId, "psopId"); + if (psopId.isBlank() || psopId.equals(".") || psopId.equals("..")) { + throw new IllegalArgumentException("psopId must identify a workflow"); } - String url = urlBuilder.toString(); - log.info( - "[Registry] Loading PSOP from {} (ssl_verify={})", - anonymousUrl(url, accessToken), - sslVerify); - HttpResult resp = execute("GET", url, null, sslVerify, timeouts); + String url = + HttpAuthentication.baseUrl(baseUrl) + + "/api/v1/orchestrate/psop/" + + URLEncoder.encode(psopId, StandardCharsets.UTF_8).replace("+", "%20"); + log.info("[Registry] Loading PSOP from {}", url); + CenterHttpClient.Response resp = execute("GET", url, null, auth, tls, timeouts); if (resp.statusCode() != 200) { throw requestFailure(resp); } - @SuppressWarnings("unchecked") - Map data = mapper.readValue(resp.body(), Map.class); - @SuppressWarnings("unchecked") - Map psopData = (Map) data.getOrDefault("data", data); - Workflow wf = Workflow.fromMap(psopData); + Workflow wf = + HttpResponseParser.parse( + "Orchestration center", + "load workflow", + resp.statusCode(), + resp.body(), + data -> { + @SuppressWarnings("unchecked") + Map psopData = (Map) data.getOrDefault("data", data); + return Workflow.fromMap(psopData); + }); log.info("[Registry] Loaded workflow: {}, {} steps", wf.getName(), wf.getSteps().size()); return wf; } @@ -109,97 +124,101 @@ public static List search( boolean sslVerify, Timeouts timeouts) throws Exception { - StringBuilder urlBuilder = new StringBuilder(baseUrl).append("/api/v1/orchestrate/search"); - if (accessToken != null && !accessToken.isEmpty()) { - urlBuilder - .append("?access_token=") - .append(URLEncoder.encode(accessToken, StandardCharsets.UTF_8)); - } - String url = urlBuilder.toString(); - log.info("[Registry] Searching PSOP at {} (top_n={})", anonymousUrl(url, accessToken), topN); + return search( + baseUrl, + intent, + topN, + tokenAuth(accessToken), + sslVerify ? TlsPolicy.strict() : TlsPolicy.insecure(), + timeouts); + } + + /** Searches using credentials evaluated before every request. */ + public static List search( + String baseUrl, String intent, int topN, HttpAuthProvider auth, TlsPolicy tls) + throws Exception { + return search(baseUrl, intent, topN, auth, tls, DEFAULT_TIMEOUTS); + } + + /** Searches with independent authentication, TLS policy and explicit deadlines. */ + public static List search( + String baseUrl, + String intent, + int topN, + HttpAuthProvider auth, + TlsPolicy tls, + Timeouts timeouts) + throws Exception { + String url = HttpAuthentication.baseUrl(baseUrl) + "/api/v1/orchestrate/search"; + log.info("[Registry] Searching PSOP at {} (top_n={})", url, topN); String jsonBody = mapper.writeValueAsString(Map.of("intent", intent, "top_n", topN)); - HttpResult resp = execute("POST", url, jsonBody, sslVerify, timeouts); + CenterHttpClient.Response resp = execute("POST", url, jsonBody, auth, tls, timeouts); if (resp.statusCode() != 200) { throw requestFailure(resp); } - @SuppressWarnings("unchecked") - Map data = mapper.readValue(resp.body(), Map.class); - @SuppressWarnings("unchecked") - List> rawResults = - (List>) data.getOrDefault("data", List.of()); - List results = new java.util.ArrayList<>(); - for (Map raw : rawResults) { - results.add( - WorkflowSearchResult.builder() - .workflowId((String) raw.getOrDefault("workflow_id", raw.getOrDefault("id", ""))) - .workflowType((String) raw.getOrDefault("workflow_type", "")) - .name((String) raw.getOrDefault("name", "")) - .description((String) raw.getOrDefault("description", null)) - .createdAt(raw.get("created_at") != null ? raw.get("created_at").toString() : "") - .score(raw.get("score") instanceof Number n ? n.doubleValue() : 1.0) - .userIntent((String) raw.getOrDefault("user_intent", null)) - .relatedPreflow((String) raw.getOrDefault("related_preflow", null)) - .tasksSummary((String) raw.getOrDefault("tasks_summary", null)) - .build()); - } + List results = + HttpResponseParser.parse( + "Orchestration center", + "search workflows", + resp.statusCode(), + resp.body(), + data -> { + @SuppressWarnings("unchecked") + List> rawResults = + (List>) data.getOrDefault("data", List.of()); + List parsed = new java.util.ArrayList<>(); + for (Map raw : rawResults) { + parsed.add( + WorkflowSearchResult.builder() + .workflowId( + (String) raw.getOrDefault("workflow_id", raw.getOrDefault("id", ""))) + .workflowType((String) raw.getOrDefault("workflow_type", "")) + .name((String) raw.getOrDefault("name", "")) + .description((String) raw.getOrDefault("description", null)) + .createdAt( + raw.get("created_at") != null ? raw.get("created_at").toString() : "") + .score(raw.get("score") instanceof Number n ? n.doubleValue() : 1.0) + .userIntent((String) raw.getOrDefault("user_intent", null)) + .relatedPreflow((String) raw.getOrDefault("related_preflow", null)) + .tasksSummary((String) raw.getOrDefault("tasks_summary", null)) + .build()); + } + return parsed; + }); log.info("[Registry] Search returned {} workflow(s)", results.size()); return results; } - /** Preserve token presence for diagnostics without exposing any token characters or length. */ - static String anonymousUrl(String url, String token) { + private static HttpAuthProvider tokenAuth(String token) { return token == null || token.isEmpty() - ? url - : url.substring(0, url.indexOf("?access_token=")) + "?access_token="; + ? HttpAuthProvider.none() + : HttpAuthProvider.bearer(token); } - private static RuntimeException requestFailure(HttpResult response) { - String detail = - SensitiveDataRedactor.redact(response.body()).replace("\r", "\\r").replace("\n", "\\n"); - if (detail.length() > 512) detail = detail.substring(0, 512) + "..."; - return new RuntimeException( - "Orchestration center returned " - + response.statusCode() - + (detail.isBlank() ? "" : ": " + detail)); + private static HttpServiceException requestFailure(CenterHttpClient.Response response) { + return new HttpServiceException( + "Orchestration center", response.statusCode(), response.body(), response.credentials()); } - private static HttpResult execute( - String method, String url, String jsonBody, boolean sslVerify, Timeouts timeouts) + private static CenterHttpClient.Response execute( + String method, + String url, + String jsonBody, + HttpAuthProvider auth, + TlsPolicy tls, + Timeouts timeouts) throws Exception { java.util.Objects.requireNonNull(timeouts, "timeouts"); - HttpURLConnection connection = (HttpURLConnection) URI.create(url).toURL().openConnection(); - if (!sslVerify && connection instanceof HttpsURLConnection https) { - // Development opt-out is connection-local: never replace JVM-wide TLS defaults. - https.setSSLSocketFactory( - SslContextFactory.create(false, null).orElseThrow().getSocketFactory()); - https.setHostnameVerifier((hostname, session) -> true); - log.warn( - "[Registry] INSECURE_TLS host={}, port={}: certificate and hostname verification disabled; development only", - https.getURL().getHost(), - https.getURL().getPort()); - } - try { - connection.setInstanceFollowRedirects(false); - connection.setConnectTimeout(timeouts.connectTimeoutMillis()); - connection.setReadTimeout(timeouts.readTimeoutMillis()); - connection.setRequestMethod(method); - if (jsonBody != null) { - connection.setDoOutput(true); - connection.setRequestProperty("Content-Type", "application/json"); - try (OutputStream output = connection.getOutputStream()) { - output.write(jsonBody.getBytes(StandardCharsets.UTF_8)); - } - } - int status = connection.getResponseCode(); - InputStream input = status >= 400 ? connection.getErrorStream() : connection.getInputStream(); - String body = input == null ? "" : new String(input.readAllBytes(), StandardCharsets.UTF_8); - if (input != null) { - input.close(); - } - return new HttpResult(status, body); - } finally { - connection.disconnect(); + java.util.Objects.requireNonNull(auth, "auth"); + java.util.Objects.requireNonNull(tls, "tls"); + URI uri = URI.create(url); + var response = CenterHttpClient.execute(method, uri, jsonBody, auth, tls, timeouts, 0); + // Both operations are read-only (search uses POST); no business execution/write is replayed. + if (response.statusCode() == 401 + && HttpAuthentication.onUnauthorized(auth, uri, response.credentials())) { + return CenterHttpClient.execute(method, uri, jsonBody, auth, tls, timeouts, 0); } + return response; } /** Convenience: search with defaults (top_n=5, no token, ssl_verify=true). */ @@ -240,6 +259,4 @@ int readTimeoutMillis() { return Math.toIntExact(readTimeout.toMillis()); } } - - private record HttpResult(int statusCode, String body) {} } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/OrchestrationAccountAuth.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/OrchestrationAccountAuth.java new file mode 100644 index 0000000..4f59adc --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/OrchestrationAccountAuth.java @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and limitations under the License. + */ +package dev.openan.workflow.engine.registry; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.function.LongSupplier; + +/** + * Thread-safe first-party account login for orchestration-center PSOP APIs. Logs in lazily at + * {@code POST /api/v1/auth/token}, caches the opaque Bearer until its advertised lifetime, and + * renews by login (not an OAuth refresh grant). Share one instance across executions. Credentials + * are scoped to the configured center's PSOP API; no cookies, agent credentials or global TLS + * state. HTTP sends the password in plaintext; prefer verified HTTPS outside isolated local + * development. + */ +public final class OrchestrationAccountAuth implements HttpAuthProvider { + private static final ObjectMapper JSON = new ObjectMapper(); + private final URI loginUri; + private final URI centerUri; + private final String apiPath; + private final String username; + private final String password; + private final TlsPolicy tls; + private final LoadPsop.Timeouts timeouts; + private final LongSupplier clock; + private String token; + private long validUntil; + + /** Uses 30-second connection/read deadlines and the supplied independent center TLS policy. */ + public OrchestrationAccountAuth(String baseUrl, String username, String password, TlsPolicy tls) { + this( + baseUrl, + username, + password, + tls, + new LoadPsop.Timeouts(Duration.ofSeconds(30), Duration.ofSeconds(30))); + } + + /** + * Uses explicit deadlines for credential acquisition, independently of PSOP request deadlines. + */ + public OrchestrationAccountAuth( + String baseUrl, String username, String password, TlsPolicy tls, LoadPsop.Timeouts timeouts) { + this(baseUrl, username, password, tls, timeouts, System::nanoTime); + } + + OrchestrationAccountAuth( + String baseUrl, + String username, + String password, + TlsPolicy tls, + LoadPsop.Timeouts timeouts, + LongSupplier clock) { + String base = HttpAuthentication.baseUrl(baseUrl); + centerUri = URI.create(base); + loginUri = URI.create(base + "/api/v1/auth/token"); + apiPath = centerUri.getRawPath() + "/api/v1/orchestrate/"; + if (username == null || username.isBlank() || password == null || password.isBlank()) { + throw new IllegalArgumentException( + "Orchestration account username and password are required"); + } + this.username = username; + this.password = password; + this.tls = Objects.requireNonNull(tls, "tls"); + this.timeouts = Objects.requireNonNull(timeouts, "timeouts"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public synchronized Map headers(URI requestUri) { + requireScope(requestUri); + if (token == null || clock.getAsLong() - validUntil >= 0) login(); + return Map.of("Authorization", "Bearer " + token); + } + + @Override + public synchronized boolean onUnauthorized(URI requestUri, Map rejectedHeaders) { + requireScope(requestUri); + if (Objects.equals(rejectedHeaders.get("Authorization"), "Bearer " + token)) token = null; + return true; + } + + private void requireScope(URI uri) { + if (uri == null + || !centerUri.getScheme().equalsIgnoreCase(uri.getScheme()) + || !centerUri.getHost().equalsIgnoreCase(uri.getHost()) + || port(centerUri) != port(uri) + || uri.getRawUserInfo() != null + || uri.getRawQuery() != null + || uri.getRawFragment() != null + || !uri.getRawPath().startsWith(apiPath) + || !uri.normalize().getRawPath().equals(uri.getRawPath())) { + throw new IllegalArgumentException( + "Account credentials are restricted to this center's PSOP API"); + } + } + + private static int port(URI uri) { + return uri.getPort() >= 0 + ? uri.getPort() + : "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; + } + + private synchronized void login() { + token = null; // Failed renewal must not preserve an expired/rejected credential. + long started = clock.getAsLong(); + try { + String body = JSON.writeValueAsString(Map.of("username", username, "password", password)); + var response = + CenterHttpClient.execute( + "POST", loginUri, body, HttpAuthProvider.none(), tls, timeouts, 64 * 1024); + if (response.statusCode() != 200) { + // Login responses may echo any submitted secret; discard bodies for every status. + throw new HttpServiceException( + "Orchestration account login", response.statusCode(), "", Map.of()); + } + Session acquired = + HttpResponseParser.parse( + "Orchestration account login", + "issue token", + response.statusCode(), + response.body(), + data -> { + Object rawToken = data.get("access_token"); + if (!(rawToken instanceof String receivedToken) + || !(data.get("token_type") instanceof String type) + || !"Bearer".equalsIgnoreCase(type) + || !(data.get("expires_in") instanceof Integer + || data.get("expires_in") instanceof Long)) { + throw new IllegalArgumentException(); + } + HttpAuthProvider.bearer(receivedToken); // Validate without rendering its contents. + long seconds = ((Number) data.get("expires_in")).longValue(); + if (seconds <= 0 || seconds > Long.MAX_VALUE / 2 / 1_000_000_000L) { + throw new IllegalArgumentException(); + } + long lifetime = seconds * 1_000_000_000L; + // Start before the request and allow bounded skew, even for a one-second TTL. + return new Session( + receivedToken, started + lifetime - Math.min(lifetime / 10, 30_000_000_000L)); + }); + token = acquired.token; + validUntil = acquired.validUntil; + } catch (HttpServiceException | HttpResponseException safeFailure) { + throw safeFailure; + } catch (Exception failure) { + // No parser/transport causes: they may render the password, token or full login body. + throw new IllegalStateException( + "Orchestration account login failed (" + failure.getClass().getSimpleName() + ")"); + } + } + + // Intentionally no generated toString(): opaque credentials must not become diagnostics. + private static final class Session { + private final String token; + private final long validUntil; + + Session(String token, long validUntil) { + this.token = token; + this.validUntil = validUntil; + } + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/RegistryClient.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/RegistryClient.java index ae24596..6849fba 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/RegistryClient.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/RegistryClient.java @@ -22,7 +22,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import dev.openan.workflow.engine.client.AgentCardNormalizer; import dev.openan.workflow.engine.client.SslContextFactory; -import dev.openan.workflow.engine.util.SensitiveDataRedactor; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -43,6 +42,7 @@ public class RegistryClient { private final String baseUrl; private final HttpClient httpClient; private final java.time.Duration requestTimeout; + private final HttpAuthProvider auth; public RegistryClient(String url, boolean sslVerify) { this(url, sslVerify, java.time.Duration.ofSeconds(30)); @@ -50,26 +50,56 @@ public RegistryClient(String url, boolean sslVerify) { /** Creates a discovery client with a positive deadline for each complete HTTP response. */ public RegistryClient(String url, boolean sslVerify, java.time.Duration requestTimeout) { + // Preserve the old chain-only opt-out. Use TlsPolicy.insecure() explicitly to skip hostname + // checks. + this( + url, + HttpAuthProvider.none(), + sslVerify ? TlsPolicy.strict() : legacyTrustAll(), + requestTimeout); + } + + /** Creates a client with per-request authentication, strict TLS by default. */ + public RegistryClient(String url, HttpAuthProvider auth) { + this(url, auth, TlsPolicy.strict()); + } + + /** Creates a client with independent authentication and TLS policy. */ + public RegistryClient(String url, HttpAuthProvider auth, TlsPolicy tls) { + this(url, auth, tls, java.time.Duration.ofSeconds(30)); + } + + /** Creates a client with authentication and a complete-response deadline. */ + public RegistryClient( + String url, HttpAuthProvider auth, TlsPolicy tls, java.time.Duration requestTimeout) { if (requestTimeout == null || requestTimeout.isZero() || requestTimeout.isNegative()) { throw new IllegalArgumentException("requestTimeout must be positive"); } this.requestTimeout = requestTimeout; - this.baseUrl = url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + this.baseUrl = HttpAuthentication.baseUrl(url); + this.auth = java.util.Objects.requireNonNull(auth, "auth"); + java.util.Objects.requireNonNull(tls, "tls"); HttpClient.Builder clientBuilder = HttpClient.newBuilder() .connectTimeout(java.time.Duration.ofSeconds(30)) .followRedirects(HttpClient.Redirect.NEVER); - if (!sslVerify) { - clientBuilder.sslContext(SslContextFactory.createTrustAll()); - } + tls.configure(clientBuilder); this.httpClient = clientBuilder.build(); } - private static RuntimeException requestFailure(String service, int status, String body) { - String detail = SensitiveDataRedactor.redact(body).replace("\r", "\\r").replace("\n", "\\n"); - if (detail.length() > 512) detail = detail.substring(0, 512) + "..."; - return new RuntimeException( - service + " returned " + status + (detail.isBlank() ? "" : ": " + detail)); + private static TlsPolicy legacyTrustAll() { + return TlsPolicy.verifiedHostname(SslContextFactory.createTrustAll()); + } + + private static HttpServiceException requestFailure( + String service, HttpResponse response) { + Map credentials = new java.util.LinkedHashMap<>(); + response + .request() + .headers() + .map() + .forEach((name, values) -> credentials.put(name, String.join(", ", values))); + return new HttpServiceException(service, response.statusCode(), response.body(), credentials); } @SuppressWarnings("unchecked") @@ -85,14 +115,9 @@ public List> fetchAgentCards() throws Exception { HttpRequest.newBuilder().uri(URI.create(url)).timeout(requestTimeout).GET().build(); HttpResponse resp = send(request); if (resp.statusCode() != 200) { - throw requestFailure("Registry", resp.statusCode(), resp.body()); + throw requestFailure("Registry", resp); } - Map data = mapper.readValue(resp.body(), Map.class); - List> cards = - (List>) - data.getOrDefault("agentCards", data.getOrDefault("data", List.of())); - // Normalize each card's security schemes to a compatible format - cards = cards.stream().map(AgentCardNormalizer::normalize).toList(); + List> cards = parseCards(resp); log.info("[Registry] Received {} agent card(s)", cards.size()); return cards; } @@ -121,18 +146,26 @@ public Map fetchAgentCard(String name, String organization) thro HttpRequest.newBuilder().uri(URI.create(url)).timeout(requestTimeout).GET().build(); HttpResponse resp = send(request); if (resp.statusCode() != 200) { - throw requestFailure("Registry", resp.statusCode(), resp.body()); + throw requestFailure("Registry", resp); } - Map data = mapper.readValue(resp.body(), Map.class); - List> cards = - (List>) - data.getOrDefault("agentCards", data.getOrDefault("data", List.of())); - if (cards.isEmpty()) { + Map card = + HttpResponseParser.parse( + "Registry", + "fetch agent card", + resp.statusCode(), + resp.body(), + data -> { + List> cards = + (List>) + data.getOrDefault("agentCards", data.getOrDefault("data", List.of())); + return cards.isEmpty() ? null : AgentCardNormalizer.normalize(cards.get(0)); + }); + if (card == null) { log.warn("[Registry] Agent card not found: name={}", name); return null; } log.info("[Registry] Agent card found: name={}", name); - return AgentCardNormalizer.normalize(cards.get(0)); + return card; } /** @@ -156,14 +189,34 @@ public Map registerAgentCard(Map agentCard) thro .build(); HttpResponse resp = send(request); if (resp.statusCode() != 200 && resp.statusCode() != 201) { - throw requestFailure("Registry registration", resp.statusCode(), resp.body()); + throw requestFailure("Registry registration", resp); } - Map result = mapper.readValue(resp.body(), Map.class); + Map result = + HttpResponseParser.parse( + "Registry", "register agent card", resp.statusCode(), resp.body(), data -> data); log.info("[Registry] Agent card registered: name={}", agentCard.get("name")); return result; } + @SuppressWarnings("unchecked") + private static List> parseCards(HttpResponse response) { + return HttpResponseParser.parse( + "Registry", + "fetch agent cards", + response.statusCode(), + response.body(), + data -> { + List> cards = + (List>) + data.getOrDefault("agentCards", data.getOrDefault("data", List.of())); + return cards.stream().map(AgentCardNormalizer::normalize).toList(); + }); + } + private HttpResponse send(HttpRequest request) throws Exception { + HttpRequest.Builder authenticated = HttpRequest.newBuilder(request, (name, value) -> true); + HttpAuthentication.headers(auth, request.uri()).forEach(authenticated::setHeader); + request = authenticated.build(); var response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()); try { // Also bound body consumption: a peer may send headers and then stall its body. diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/TlsPolicy.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/TlsPolicy.java new file mode 100644 index 0000000..410212a --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/TlsPolicy.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.registry; + +import dev.openan.workflow.engine.client.SslContextFactory; +import java.net.http.HttpClient; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Connection-local TLS policy for center management APIs, independent of HTTP authentication. + * Strict is the default. Insecure disables both certificate and hostname checks on this client + * only; it never disables server-side mTLS or changes A2A transport policy or JVM defaults. + */ +public final class TlsPolicy { + private static final Logger log = LoggerFactory.getLogger(TlsPolicy.class); + private final SSLContext context; + private final boolean insecure; + + private TlsPolicy(SSLContext context, boolean insecure) { + this.context = context; + this.insecure = insecure; + } + + /** JVM CA trust and hostname verification. */ + public static TlsPolicy strict() { + return new TlsPolicy(null, false); + } + + static TlsPolicy verifiedHostname(SSLContext context) { + return new TlsPolicy(context, false); + } + + /** Strict verification using an application-owned PEM CA bundle. */ + public static TlsPolicy trustedCa(String caCertsPath) { + java.util.Objects.requireNonNull(caCertsPath, "caCertsPath"); + return mutualTls(caCertsPath, null, null, null); + } + + /** Strict verification and optional client identity (PEM certificate and PKCS#8 key). */ + public static TlsPolicy mutualTls( + String caCertsPath, String clientCertPath, String clientKeyPath, String keyPassword) { + return new TlsPolicy( + SslContextFactory.create( + true, caCertsPath, clientCertPath, clientKeyPath, keyPassword, null) + .orElseThrow(), + false); + } + + /** Explicit development opt-out, equivalent to Python verify=False for HTTPS requests. */ + public static TlsPolicy insecure() { + return insecure(null, null, null); + } + + /** Opt-out retaining a client identity, if the server requires mTLS. */ + public static TlsPolicy insecure( + String clientCertPath, String clientKeyPath, String keyPassword) { + return new TlsPolicy( + SslContextFactory.createInsecure(clientCertPath, clientKeyPath, keyPassword), true); + } + + void configure(HttpClient.Builder client) { + if (context != null) client.sslContext(context); + warnIfInsecure(); + } + + void configure(HttpsURLConnection connection) { + if (context != null) connection.setSSLSocketFactory(context.getSocketFactory()); + if (insecure) connection.setHostnameVerifier((hostname, session) -> true); + warnIfInsecure(); + } + + private void warnIfInsecure() { + if (insecure) { + log.warn( + "INSECURE_TLS: certificate and hostname verification disabled for this management client"); + } + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/CenterAuthenticationTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/CenterAuthenticationTest.java new file mode 100644 index 0000000..22b01f8 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/CenterAuthenticationTest.java @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.registry; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class CenterAuthenticationTest { + @Test + void allManagementOperationsUseCurrentBearerWithoutQueryTokens() throws Exception { + var expected = new AtomicReference<>("first.token"); + var received = new ArrayList(); + var queries = new ArrayList(); + var methods = new ArrayList(); + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/", + exchange -> { + String authorization = exchange.getRequestHeaders().getFirst("Authorization"); + received.add(authorization); + queries.add(exchange.getRequestURI().getRawQuery()); + methods.add(exchange.getRequestMethod()); + exchange.getRequestBody().readAllBytes(); + int status = ("Bearer " + expected.get()).equals(authorization) ? 200 : 401; + String path = exchange.getRequestURI().getPath(); + String body = + path.endsWith("/search") + ? "{\"data\":[{\"workflow_id\":\"flow\"}]}" + : path.contains("/psop/") + ? "{\"data\":{\"name\":\"flow\",\"steps\":[]}}" + : "{\"agentCards\":[{\"name\":\"Host Agent\"}]}"; + respond(exchange, status, body); + }); + server.start(); + try { + String url = "http://127.0.0.1:" + server.getAddress().getPort(); + var provider = HttpAuthProvider.bearer(expected::get); + var registry = new RegistryClient(url, provider); + assertEquals("flow", LoadPsop.load(url, "flow", "first.token", true).getName()); + assertEquals( + "flow", LoadPsop.search(url, "intent", 3, "first.token", true).get(0).getWorkflowId()); + expected.set("second.token"); + assertEquals( + "flow", + LoadPsop.search(url, "intent", 3, provider, TlsPolicy.strict()).get(0).getWorkflowId()); + assertEquals("flow", LoadPsop.load(url, "flow", provider, TlsPolicy.strict()).getName()); + assertEquals(1, registry.fetchAgentCards().size()); + assertEquals("Host Agent", registry.fetchAgentCard("Host Agent", "Huawei").get("name")); + expected.set("third.token"); + assertNotNull(registry.registerAgentCard(Map.of("name", "Host Agent"))); + assertEquals( + List.of( + "Bearer first.token", + "Bearer first.token", + "Bearer second.token", + "Bearer second.token", + "Bearer second.token", + "Bearer second.token", + "Bearer third.token"), + received); + assertTrue(queries.stream().noneMatch(q -> q != null && q.contains("access_token"))); + assertEquals("name=Host+Agent&organization=Huawei", queries.get(5)); + assertEquals(List.of("GET", "POST", "POST", "GET", "GET", "GET", "POST"), methods); + + var wrong = new RegistryClient(url, HttpAuthProvider.bearer("wrong.token")); + var denied = assertThrows(HttpServiceException.class, wrong::fetchAgentCards); + assertEquals(401, denied.getStatusCode()); + assertTrue(denied.isAuthenticationFailure()); + assertThrows( + HttpServiceException.class, () -> new RegistryClient(url, true).fetchAgentCards()); + assertThrows(HttpServiceException.class, () -> LoadPsop.search(url, "intent")); + } finally { + server.stop(0); + } + } + + @Test + void customHeadersAreInjectedOnLoadAndRegistryRequests() throws Exception { + var received = new ArrayList(); + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/", + exchange -> { + received.add(exchange.getRequestHeaders().getFirst("X-Customer-Credential")); + exchange.getRequestBody().readAllBytes(); + respond(exchange, 200, "{\"data\":{\"name\":\"flow\",\"steps\":[]},\"agentCards\":[]}"); + }); + server.start(); + try { + String url = "http://127.0.0.1:" + server.getAddress().getPort(); + HttpAuthProvider auth = uri -> Map.of("X-Customer-Credential", "opaque-secret"); + LoadPsop.load(url, "flow", auth, TlsPolicy.strict()); + new RegistryClient(url, auth).fetchAgentCards(); + assertEquals(List.of("opaque-secret", "opaque-secret"), received); + } finally { + server.stop(0); + } + } + + @Test + void missingOrUnsafeCredentialsFailClosedWithoutPrintingValues() { + for (String token : List.of("", " ", " secret", "secret\r\nInjected: value")) { + var error = + assertThrows(IllegalArgumentException.class, () -> HttpAuthProvider.bearer(token)); + assertFalse(error.getMessage().contains("secret")); + } + var uri = java.net.URI.create("http://localhost/request"); + var refreshed = HttpAuthProvider.bearer(() -> null); + assertThrows(IllegalArgumentException.class, () -> refreshed.headers(uri)); + for (String reserved : + List.of("Host", "Content-Length", "Content-Type", "Proxy-Authorization")) { + assertThrows( + IllegalArgumentException.class, + () -> HttpAuthentication.headers(u -> Map.of(reserved, "secret"), uri)); + } + Map duplicate = new LinkedHashMap<>(); + duplicate.put("Authorization", "Bearer first"); + duplicate.put("authorization", "Bearer second"); + assertThrows( + IllegalArgumentException.class, () -> HttpAuthentication.headers(u -> duplicate, uri)); + assertThrows( + IllegalArgumentException.class, + () -> + HttpAuthentication.headers(u -> Map.of("Authorization", "secret\r\nInjected: x"), uri)); + for (String invalid : + List.of( + "file:///tmp/config", + "http://user:secret@localhost", + "http://localhost?access_token=secret", + "https://localhost/#fragment")) { + assertThrows(IllegalArgumentException.class, () -> new RegistryClient(invalid, true)); + } + } + + @Test + void managementClientsNeverForwardCredentialsThroughRedirects() throws Exception { + var forwarded = new AtomicInteger(); + var destination = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + destination.createContext( + "/", + exchange -> { + forwarded.incrementAndGet(); + respond(exchange, 200, "{\"agentCards\":[]}"); + }); + destination.start(); + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/", + exchange -> { + exchange + .getResponseHeaders() + .set("Location", "http://127.0.0.1:" + destination.getAddress().getPort() + "/"); + respond(exchange, 302, ""); + }); + server.start(); + try { + String url = "http://127.0.0.1:" + server.getAddress().getPort(); + var auth = HttpAuthProvider.bearer("private.token"); + assertEquals( + 302, + assertThrows( + HttpServiceException.class, + () -> LoadPsop.load(url, "flow", auth, TlsPolicy.strict())) + .getStatusCode()); + assertEquals( + 302, + assertThrows( + HttpServiceException.class, () -> new RegistryClient(url, auth).fetchAgentCards()) + .getStatusCode()); + assertEquals(0, forwarded.get()); + } finally { + server.stop(0); + destination.stop(0); + } + } + + @Test + void failureMessagesDoNotExposeEchoedCredentials() { + Map auth = Map.of("Authorization", "Bearer private.token"); + for (int status : List.of(401, 403, 500)) { + var error = + new HttpServiceException( + "Registry", status, "Echoed private.token and Bearer private.token\r\n", auth); + assertFalse(error.getMessage().contains("private.token")); + assertFalse(error.getMessage().contains("\r")); + assertFalse(error.getMessage().contains("\n")); + assertEquals(status == 401 || status == 403, error.isAuthenticationFailure()); + } + } + + @Test + void providerAndMalformedUrlFailuresDoNotRetainCredentialBearingCauses() { + String secret = "SyntheticProviderCredential123"; + HttpAuthProvider failing = + uri -> { + throw new IllegalArgumentException("IAM rejected " + secret); + }; + var providerError = + assertThrows( + IllegalStateException.class, + () -> HttpAuthentication.headers(failing, java.net.URI.create("https://localhost/"))); + assertNull(providerError.getCause()); + assertFalse(providerError.getMessage().contains(secret)); + assertTrue(providerError.getMessage().contains("IllegalArgumentException")); + var urlError = + assertThrows( + IllegalArgumentException.class, + () -> new RegistryClient("https://user:" + secret + " @localhost", true)); + assertNull(urlError.getCause()); + assertFalse(urlError.getMessage().contains(secret)); + } + + private static void respond(com.sun.net.httpserver.HttpExchange exchange, int status, String body) + throws java.io.IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length); + try (var output = exchange.getResponseBody()) { + if (bytes.length > 0) output.write(bytes); + } finally { + exchange.close(); + } + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/CenterResponseSafetyTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/CenterResponseSafetyTest.java new file mode 100644 index 0000000..b059fbf --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/CenterResponseSafetyTest.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package dev.openan.workflow.engine.registry; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sun.net.httpserver.HttpServer; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class CenterResponseSafetyTest { + private static final String SECRET = "SyntheticResponseCredential12345"; + + static Stream operations() { + return Stream.of("load", "search", "list", "select", "register200", "register201") + .flatMap( + operation -> Stream.of(true, false).map(bearer -> Arguments.of(operation, bearer))); + } + + @ParameterizedTest + @MethodSource("operations") + void invalidSuccessResponsesNeverExposeCredentialsEvenInExceptionChains( + String operation, boolean bearer) throws Exception { + var body = new AtomicReference<>(SECRET); + var credential = new AtomicReference(); + int status = operation.equals("register201") ? 201 : 200; + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/", + exchange -> { + credential.set( + exchange + .getRequestHeaders() + .getFirst(bearer ? "Authorization" : "X-Customer-Credential")); + exchange.getRequestBody().readAllBytes(); + byte[] bytes = body.get().getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + try (var output = exchange.getResponseBody()) { + output.write(bytes); + } finally { + exchange.close(); + } + }); + server.start(); + try { + String url = "http://127.0.0.1:" + server.getAddress().getPort(); + HttpAuthProvider auth = + bearer ? HttpAuthProvider.bearer(SECRET) : uri -> Map.of("X-Customer-Credential", SECRET); + for (String invalid : List.of(SECRET, "\"" + SECRET + "\"", "[]", "null", "{} " + SECRET)) { + body.set(invalid); + assertSafe( + assertThrows(HttpResponseException.class, () -> invoke(operation, url, auth)), status); + assertEquals(bearer ? "Bearer " + SECRET : SECRET, credential.get()); + } + if (!operation.startsWith("register")) { + body.set("{\"data\":\"" + SECRET + "\",\"agentCards\":\"" + SECRET + "\"}"); + assertSafe( + assertThrows(HttpResponseException.class, () -> invoke(operation, url, auth)), status); + } + } finally { + server.stop(0); + } + } + + @Test + void validBusinessPayloadIsNotRedactedBeforeParsing() throws Exception { + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/", + exchange -> { + byte[] bytes = + ("{\"data\":{\"name\":\"flow\",\"description\":\"" + SECRET + "\",\"steps\":[]}}") + .getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); + try (var output = exchange.getResponseBody()) { + output.write(bytes); + } finally { + exchange.close(); + } + }); + server.start(); + try { + var workflow = + LoadPsop.load( + "http://127.0.0.1:" + server.getAddress().getPort(), + "flow", + HttpAuthProvider.bearer(SECRET), + TlsPolicy.strict()); + assertEquals(SECRET, workflow.getDescription()); + } finally { + server.stop(0); + } + } + + private static void invoke(String operation, String url, HttpAuthProvider auth) throws Exception { + switch (operation) { + case "load" -> LoadPsop.load(url, "flow", auth, TlsPolicy.strict()); + case "search" -> LoadPsop.search(url, "intent", 1, auth, TlsPolicy.strict()); + case "list" -> new RegistryClient(url, auth).fetchAgentCards(); + case "select" -> new RegistryClient(url, auth).fetchAgentCard("Host Agent", "Huawei"); + default -> new RegistryClient(url, auth).registerAgentCard(Map.of("name", "Host Agent")); + } + } + + private static void assertSafe(HttpResponseException error, int status) { + assertEquals(status, error.getStatusCode()); + assertNull(error.getCause()); + assertEquals(0, error.getSuppressed().length); + var stack = new StringWriter(); + error.printStackTrace(new PrintWriter(stack)); + assertFalse(stack.toString().contains(SECRET)); + assertTrue(error.getMessage().contains("invalid response")); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/CenterTlsPolicyTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/CenterTlsPolicyTest.java new file mode 100644 index 0000000..e4f0638 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/CenterTlsPolicyTest.java @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package dev.openan.workflow.engine.registry; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.TimeUnit; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class CenterTlsPolicyTest { + @TempDir Path directory; + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void bothClientsAuthenticateOverTlsAndOptOutIsLocal(boolean wrongSan) throws Exception { + var context = SSLContext.getDefault(); + var socketFactory = HttpsURLConnection.getDefaultSSLSocketFactory(); + var hostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier(); + String property = System.getProperty("jdk.internal.httpclient.disableHostnameVerification"); + try (var endpoint = endpoint(wrongSan, false)) { + var auth = HttpAuthProvider.bearer("test.token"); + assertEquals( + "flow", LoadPsop.load(endpoint.url(), "flow", auth, TlsPolicy.insecure()).getName()); + assertEquals( + "flow", + LoadPsop.search(endpoint.url(), "intent", 3, auth, TlsPolicy.insecure()) + .get(0) + .getWorkflowId()); + var registry = new RegistryClient(endpoint.url(), auth, TlsPolicy.insecure()); + assertEquals(1, registry.fetchAgentCards().size()); + assertEquals("Host", registry.fetchAgentCard("Host").get("name")); + assertNotNull(registry.registerAgentCard(java.util.Map.of("name", "Host"))); + assertEquals( + 401, + assertThrows( + HttpServiceException.class, + () -> + new RegistryClient( + endpoint.url(), HttpAuthProvider.none(), TlsPolicy.insecure()) + .fetchAgentCards()) + .getStatusCode()); + assertEquals( + 401, + assertThrows( + HttpServiceException.class, + () -> LoadPsop.load(endpoint.url(), "flow", "wrong.token", false)) + .getStatusCode()); + assertThrows( + Exception.class, () -> new RegistryClient(endpoint.url(), auth).fetchAgentCards()); + assertThrows( + Exception.class, () -> LoadPsop.load(endpoint.url(), "flow", auth, TlsPolicy.strict())); + + var trusted = TlsPolicy.trustedCa(endpoint.cert().toString()); + if (wrongSan) { + assertThrows( + Exception.class, + () -> new RegistryClient(endpoint.url(), auth, trusted).fetchAgentCards()); + assertThrows(Exception.class, () -> LoadPsop.load(endpoint.url(), "flow", auth, trusted)); + // Old boolean false deliberately remains chain-only and still rejects a wrong hostname. + assertThrows( + Exception.class, () -> new RegistryClient(endpoint.url(), false).fetchAgentCards()); + } else { + assertEquals(1, new RegistryClient(endpoint.url(), auth, trusted).fetchAgentCards().size()); + assertEquals("flow", LoadPsop.load(endpoint.url(), "flow", auth, trusted).getName()); + assertEquals( + 401, + assertThrows( + HttpServiceException.class, + () -> new RegistryClient(endpoint.url(), false).fetchAgentCards()) + .getStatusCode()); + } + } + assertSame(context, SSLContext.getDefault()); + assertSame(socketFactory, HttpsURLConnection.getDefaultSSLSocketFactory()); + assertSame(hostnameVerifier, HttpsURLConnection.getDefaultHostnameVerifier()); + assertEquals( + property, System.getProperty("jdk.internal.httpclient.disableHostnameVerification")); + } + + @Test + void insecureVerificationRetainsClientIdentityButCannotBypassRequiredMtls() throws Exception { + try (var endpoint = endpoint(false, true)) { + var auth = HttpAuthProvider.bearer("test.token"); + assertThrows( + Exception.class, () -> LoadPsop.load(endpoint.url(), "flow", auth, TlsPolicy.insecure())); + assertThrows( + Exception.class, + () -> new RegistryClient(endpoint.url(), auth, TlsPolicy.insecure()).fetchAgentCards()); + var insecureIdentity = + TlsPolicy.insecure(endpoint.cert().toString(), endpoint.key().toString(), null); + assertEquals("flow", LoadPsop.load(endpoint.url(), "flow", auth, insecureIdentity).getName()); + assertEquals( + 1, new RegistryClient(endpoint.url(), auth, insecureIdentity).fetchAgentCards().size()); + var strictIdentity = + TlsPolicy.mutualTls( + endpoint.cert().toString(), + endpoint.cert().toString(), + endpoint.key().toString(), + null); + assertEquals("flow", LoadPsop.load(endpoint.url(), "flow", auth, strictIdentity).getName()); + assertEquals( + 1, new RegistryClient(endpoint.url(), auth, strictIdentity).fetchAgentCards().size()); + } + assertThrows( + IllegalStateException.class, + () -> TlsPolicy.insecure(directory.resolve("missing.pem").toString(), null, null)); + assertThrows( + IllegalStateException.class, + () -> TlsPolicy.trustedCa(directory.resolve("missing-ca.pem").toString())); + } + + private Endpoint endpoint(boolean wrongSan, boolean clientAuth) throws Exception { + Path storeFile = directory.resolve("server.p12"); + Path keytool = + Path.of( + System.getProperty("java.home"), + "bin", + System.getProperty("os.name").startsWith("Windows") ? "keytool.exe" : "keytool"); + var process = + new ProcessBuilder( + List.of( + keytool.toString(), + "-genkeypair", + "-alias", + "test", + "-keyalg", + "RSA", + "-keysize", + "2048", + "-dname", + "CN=test", + "-validity", + "2", + "-ext", + wrongSan ? "SAN=dns:wrong.example.test" : "SAN=ip:127.0.0.1", + "-storetype", + "PKCS12", + "-keystore", + storeFile.toString(), + "-storepass", + "test-only-password", + "-keypass", + "test-only-password", + "-noprompt")) + .redirectErrorStream(true) + .redirectOutput(directory.resolve("keytool.log").toFile()) + .start(); + try { + assertTrue(process.waitFor(20, TimeUnit.SECONDS)); + assertEquals(0, process.exitValue()); + } finally { + if (process.isAlive()) process.destroyForcibly(); + } + var store = KeyStore.getInstance("PKCS12"); + try (var input = Files.newInputStream(storeFile)) { + store.load(input, "test-only-password".toCharArray()); + } + Path cert = directory.resolve("cert.pem"); + Path key = directory.resolve("key.pem"); + Files.writeString(cert, pem("CERTIFICATE", store.getCertificate("test").getEncoded())); + Files.writeString( + key, + pem("PRIVATE KEY", store.getKey("test", "test-only-password".toCharArray()).getEncoded())); + var keys = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keys.init(store, "test-only-password".toCharArray()); + var trust = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trust.init(store); + var context = SSLContext.getInstance("TLS"); + context.init(keys.getKeyManagers(), trust.getTrustManagers(), null); + var server = HttpsServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.setHttpsConfigurator( + new HttpsConfigurator(context) { + @Override + public void configure(HttpsParameters parameters) { + var ssl = context.getDefaultSSLParameters(); + ssl.setNeedClientAuth(clientAuth); + parameters.setSSLParameters(ssl); + } + }); + server.createContext( + "/", + exchange -> { + exchange.getRequestBody().readAllBytes(); + int status = + "Bearer test.token".equals(exchange.getRequestHeaders().getFirst("Authorization")) + ? 200 + : 401; + String path = exchange.getRequestURI().getPath(); + String body = + path.endsWith("/search") + ? "{\"data\":[{\"workflow_id\":\"flow\"}]}" + : path.contains("/psop/") + ? "{\"data\":{\"name\":\"flow\",\"steps\":[]}}" + : "{\"agentCards\":[{\"name\":\"Host\"}]}"; + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + try (var output = exchange.getResponseBody()) { + output.write(bytes); + } finally { + exchange.close(); + } + }); + server.start(); + return new Endpoint(server, cert, key); + } + + private static String pem(String type, byte[] bytes) { + return "-----BEGIN " + + type + + "-----\n" + + Base64.getMimeEncoder(64, new byte[] {'\n'}).encodeToString(bytes) + + "\n-----END " + + type + + "-----\n"; + } + + private record Endpoint(HttpsServer server, Path cert, Path key) implements AutoCloseable { + String url() { + return "https://127.0.0.1:" + server.getAddress().getPort(); + } + + @Override + public void close() { + server.stop(0); + } + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/LoadPsopHttpsTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/LoadPsopHttpsTest.java index 24d7fc1..8d7048b 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/LoadPsopHttpsTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/LoadPsopHttpsTest.java @@ -92,7 +92,47 @@ void skippingServerVerificationDoesNotBypassMutualTls() throws Exception { } } + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void accountLoginAndBothPsopCallsShareExplicitConnectionLocalTls(boolean wrongSan) + throws Exception { + var defaults = HttpsURLConnection.getDefaultSSLSocketFactory(); + var verifier = HttpsURLConnection.getDefaultHostnameVerifier(); + try (var endpoint = endpoint(wrongSan, false, true)) { + var tls = TlsPolicy.insecure(); + var auth = + new OrchestrationAccountAuth(endpoint.baseUrl(), "admin", "synthetic-password", tls); + assertEquals( + "tls-psop", + LoadPsop.search(endpoint.baseUrl(), "intent", 3, auth, tls).get(0).getWorkflowId()); + assertEquals("tls-psop", LoadPsop.load(endpoint.baseUrl(), "tls-psop", auth, tls).getName()); + var strict = + new OrchestrationAccountAuth( + endpoint.baseUrl(), "admin", "synthetic-password", TlsPolicy.strict()); + var error = + assertThrows( + IllegalStateException.class, + () -> LoadPsop.load(endpoint.baseUrl(), "tls-psop", strict, TlsPolicy.strict())); + assertNull(error.getCause()); + assertFalse(error.getMessage().contains("synthetic-password")); + // verify=False is not anonymous: TLS succeeds but the API still requires account Bearer. + assertEquals( + 401, + assertThrows( + HttpServiceException.class, + () -> LoadPsop.load(endpoint.baseUrl(), "tls-psop", HttpAuthProvider.none(), tls)) + .getStatusCode()); + } + assertSame(defaults, HttpsURLConnection.getDefaultSSLSocketFactory()); + assertSame(verifier, HttpsURLConnection.getDefaultHostnameVerifier()); + } + private Endpoint endpoint(boolean wrongSan, boolean clientAuth) throws Exception { + return endpoint(wrongSan, clientAuth, false); + } + + private Endpoint endpoint(boolean wrongSan, boolean clientAuth, boolean accountAuth) + throws Exception { Path keyStoreFile = directory.resolve("server.p12"); Path keytool = Path.of( @@ -161,13 +201,23 @@ public void configure(HttpsParameters parameters) { "/", exchange -> { exchange.getRequestBody().readAllBytes(); + boolean login = exchange.getRequestURI().getPath().equals("/api/v1/auth/token"); + int status = + accountAuth + && !login + && !"Bearer tls.session" + .equals(exchange.getRequestHeaders().getFirst("Authorization")) + ? 401 + : 200; String body = - exchange.getRequestURI().getPath().endsWith("/search") - ? "{\"data\":[{\"workflow_id\":\"tls-psop\",\"name\":\"tls-psop\"}]}" - : "{\"data\":{\"name\":\"tls-psop\",\"steps\":[]}}"; + login + ? "{\"access_token\":\"tls.session\",\"token_type\":\"Bearer\",\"expires_in\":60}" + : exchange.getRequestURI().getPath().endsWith("/search") + ? "{\"data\":[{\"workflow_id\":\"tls-psop\",\"name\":\"tls-psop\"}]}" + : "{\"data\":{\"name\":\"tls-psop\",\"steps\":[]}}"; byte[] bytes = body.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "application/json"); - exchange.sendResponseHeaders(200, bytes.length); + exchange.sendResponseHeaders(status, bytes.length); try (var output = exchange.getResponseBody()) { output.write(bytes); } finally { diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/LoadPsopSearchTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/LoadPsopSearchTest.java index a3ac1e6..a2eed59 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/LoadPsopSearchTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/LoadPsopSearchTest.java @@ -81,16 +81,6 @@ void customReadTimeoutIsApplied() throws Exception { } } - @Test - void tokenLogsRevealPresenceButNoTokenCharacters() { - assertEquals( - "https://example.test/search?access_token=", - LoadPsop.anonymousUrl( - "https://example.test/search?access_token=secret%2Bvalue", "secret+value")); - assertEquals( - "https://example.test/search", LoadPsop.anonymousUrl("https://example.test/search", null)); - } - /** * Verifies that the search method correctly parses the orchestration center's JSON response into * WorkflowSearchResult objects. diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/OrchestrationAccountAuthTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/OrchestrationAccountAuthTest.java new file mode 100644 index 0000000..53287ed --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/OrchestrationAccountAuthTest.java @@ -0,0 +1,348 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and limitations under the License. + */ +package dev.openan.workflow.engine.registry; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class OrchestrationAccountAuthTest { + private static final String PASSWORD = "SyntheticPassword9!"; + private static final LoadPsop.Timeouts TIMEOUTS = + new LoadPsop.Timeouts(Duration.ofSeconds(2), Duration.ofSeconds(2)); + + @Test + void searchAndLoadUseOneLoginWithoutQueryTokensCookiesOrCredentialReuse() throws Exception { + try (var service = new Service()) { + var auth = service.auth(); + assertEquals(0, service.logins.get(), "login is lazy"); + assertEquals( + "flow", + LoadPsop.search(service.url(), "intent", 3, auth, TlsPolicy.strict()) + .get(0) + .getWorkflowId()); + assertEquals( + "flow", LoadPsop.load(service.url(), "flow", auth, TlsPolicy.strict()).getName()); + assertEquals(1, service.logins.get()); + assertEquals(2, service.accepted.get()); + assertEquals(0, service.invalidRequests.get()); + } + } + + @Test + void advertisedLifetimeRenewsUsingAMonotonicClock() throws Exception { + try (var service = new Service()) { + var now = new AtomicLong(0); + var auth = + new OrchestrationAccountAuth( + service.url(), "admin", PASSWORD, TlsPolicy.strict(), TIMEOUTS, now::get); + URI uri = URI.create(service.url() + "/api/v1/orchestrate/search"); + assertEquals("Bearer session.1", auth.headers(uri).get("Authorization")); + now.set(Duration.ofSeconds(53).toNanos()); + assertEquals("Bearer session.1", auth.headers(uri).get("Authorization")); + now.set(Duration.ofSeconds(54).toNanos()); + assertEquals("Bearer session.2", auth.headers(uri).get("Authorization")); + assertEquals(2, service.logins.get()); + } + } + + @Test + void concurrentAcquisitionLogsInOnceAndOld401CannotEraseANewerSession() throws Exception { + try (var service = new Service()) { + var auth = service.auth(); + URI uri = URI.create(service.url() + "/api/v1/orchestrate/search"); + var pool = Executors.newFixedThreadPool(8); + try { + var futures = new ArrayList>>(); + for (int i = 0; i < 16; i++) futures.add(pool.submit(() -> auth.headers(uri))); + for (var future : futures) + assertEquals("Bearer session.1", future.get(3, TimeUnit.SECONDS).get("Authorization")); + assertEquals(1, service.logins.get()); + Map old = auth.headers(uri); + assertTrue(auth.onUnauthorized(uri, old)); + assertEquals("Bearer session.2", auth.headers(uri).get("Authorization")); + assertTrue(auth.onUnauthorized(uri, old)); + assertEquals("Bearer session.2", auth.headers(uri).get("Authorization")); + assertEquals(2, service.logins.get()); + } finally { + pool.shutdownNow(); + } + } + } + + @Test + void revokedTokenRetriesSearchOrLoadOnceAndRepeated401Stops() throws Exception { + try (var service = new Service()) { + var auth = service.auth(); + auth.headers(URI.create(service.url() + "/api/v1/orchestrate/search")); + service.session.incrementAndGet(); // The service restarted or revoked the cached session. + assertEquals( + "flow", + LoadPsop.search(service.url(), "intent", 3, auth, TlsPolicy.strict()) + .get(0) + .getWorkflowId()); + assertEquals(2, service.logins.get()); + service.session.incrementAndGet(); + assertEquals( + "flow", LoadPsop.load(service.url(), "flow", auth, TlsPolicy.strict()).getName()); + assertEquals(3, service.logins.get()); + service.apiStatus = 401; + int before = service.calls.get(); + assertEquals( + 401, + assertThrows( + HttpServiceException.class, + () -> LoadPsop.search(service.url(), "intent", 3, auth, TlsPolicy.strict())) + .getStatusCode()); + assertEquals(2, service.calls.get() - before); + assertEquals(4, service.logins.get()); + } + } + + @ParameterizedTest + @ValueSource(ints = {403, 429, 500, 503}) + void permissionRateLimitAndServiceErrorsNeverRetry(int status) throws Exception { + try (var service = new Service()) { + service.apiStatus = status; + assertEquals( + status, + assertThrows( + HttpServiceException.class, + () -> LoadPsop.load(service.url(), "flow", service.auth(), TlsPolicy.strict())) + .getStatusCode()); + assertEquals(1, service.logins.get()); + assertEquals(1, service.calls.get()); + } + } + + @Test + void externalBearerDoesNotOptIntoAutomaticRelogin() throws Exception { + try (var service = new Service()) { + service.apiStatus = 401; + assertEquals( + 401, + assertThrows( + HttpServiceException.class, + () -> + LoadPsop.load( + service.url(), + "flow", + HttpAuthProvider.bearer("external.token"), + TlsPolicy.strict())) + .getStatusCode()); + assertEquals(0, service.logins.get()); + assertEquals(1, service.calls.get()); + } + } + + @ParameterizedTest + @ValueSource(ints = {302, 401, 403, 429, 500, 503}) + void rejectedLoginNeverLeaksBodyOrRequestsWorkflow(int status) throws Exception { + try (var service = new Service()) { + service.loginStatus = status; + service.loginBody = PASSWORD + " echoed.opaque.token"; + var error = + assertThrows( + HttpServiceException.class, + () -> LoadPsop.load(service.url(), "flow", service.auth(), TlsPolicy.strict())); + assertEquals(status, error.getStatusCode()); + assertFalse(error.getMessage().contains(PASSWORD)); + assertFalse(error.getMessage().contains("echoed.opaque.token")); + assertNull(error.getCause()); + assertEquals(1, service.logins.get()); + assertEquals(0, service.calls.get()); + } + } + + @ParameterizedTest + @ValueSource( + strings = { + "SyntheticPassword9!", + "{}", + "{\"access_token\":\"bad token\",\"token_type\":\"Bearer\",\"expires_in\":60}", + "{\"access_token\":\"token\",\"token_type\":\"Basic\",\"expires_in\":60}", + "{\"access_token\":\"token\",\"token_type\":\"Bearer\",\"expires_in\":0}", + "{\"access_token\":\"token\",\"token_type\":\"Bearer\",\"expires_in\":1.5}", + "{\"access_token\":\"token\",\"token_type\":\"Bearer\",\"expires_in\":\"60\"}", + "{\"access_token\":\"token\",\"token_type\":\"Bearer\",\"expires_in\":9223372036854775807}", + "{\"access_token\":\"token\",\"token_type\":\"Bearer\",\"expires_in\":60} {}" + }) + void malformedSuccessfulLoginFailsClosedWithoutParserCause(String body) throws Exception { + try (var service = new Service()) { + service.loginBody = body; + var error = + assertThrows( + HttpResponseException.class, + () -> LoadPsop.load(service.url(), "flow", service.auth(), TlsPolicy.strict())); + assertNull(error.getCause()); + assertFalse(error.getMessage().contains(PASSWORD)); + assertEquals(0, service.calls.get()); + } + } + + @Test + void loginResponseIsBoundedAndCredentialScopeIsEnforcedBeforeNetwork() throws Exception { + try (var service = new Service()) { + var auth = service.auth(); + for (String path : + new String[] { + "/api/v1/registry", + "/api/v1/orchestrate/../auth/token", + "/api/v1/orchestrate/search?access_token=secret" + }) { + assertThrows( + IllegalArgumentException.class, () -> auth.headers(URI.create(service.url() + path))); + } + assertThrows( + IllegalArgumentException.class, + () -> auth.headers(URI.create("http://other.example.test/api/v1/orchestrate/search"))); + assertEquals(0, service.logins.get()); + service.loginBody = "x".repeat(65537); + assertThrows( + HttpResponseException.class, + () -> LoadPsop.load(service.url(), "flow", auth, TlsPolicy.strict())); + assertEquals(0, service.calls.get()); + } + } + + @Test + void loginTimeoutIsBoundedAndDoesNotSendPsopRequest() throws Exception { + try (var service = new Service()) { + service.loginDelayMillis = 300; + var auth = + new OrchestrationAccountAuth( + service.url(), + "admin", + PASSWORD, + TlsPolicy.strict(), + new LoadPsop.Timeouts(Duration.ofSeconds(1), Duration.ofMillis(50))); + var error = + assertThrows( + IllegalStateException.class, + () -> LoadPsop.load(service.url(), "flow", auth, TlsPolicy.strict())); + assertFalse(error.getMessage().contains(PASSWORD)); + assertNull(error.getCause()); + assertEquals(0, service.calls.get()); + } + } + + private static final class Service implements AutoCloseable { + final HttpServer server; + final AtomicInteger logins = new AtomicInteger(); + final AtomicInteger calls = new AtomicInteger(); + final AtomicInteger accepted = new AtomicInteger(); + final AtomicInteger session = new AtomicInteger(); + final AtomicInteger invalidRequests = new AtomicInteger(); + volatile int apiStatus = 200; + volatile int loginStatus = 200; + volatile long loginDelayMillis; + volatile String loginBody; + + Service() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::handle); + server.start(); + } + + String url() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + OrchestrationAccountAuth auth() { + return new OrchestrationAccountAuth(url(), "admin", PASSWORD, TlsPolicy.strict(), TIMEOUTS); + } + + void handle(HttpExchange exchange) throws java.io.IOException { + try { + String request = + new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + if (exchange.getRequestURI().getRawQuery() != null + || exchange.getRequestHeaders().containsKey("Cookie")) + invalidRequests.incrementAndGet(); + String path = exchange.getRequestURI().getPath(); + if (path.equals("/api/v1/auth/token")) { + logins.incrementAndGet(); + Map fields = new ObjectMapper().readValue(request, Map.class); + if (!"POST".equals(exchange.getRequestMethod()) + || !Map.of("username", "admin", "password", PASSWORD).equals(fields) + || exchange.getRequestHeaders().containsKey("Authorization")) + invalidRequests.incrementAndGet(); + if (loginDelayMillis > 0) { + try { + Thread.sleep(loginDelayMillis); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + } + } + exchange.getResponseHeaders().set("Location", url() + "/redirect-target"); + respond( + exchange, + loginStatus, + loginBody != null + ? loginBody + : "{\"access_token\":\"session." + + session.incrementAndGet() + + "\",\"token_type\":\"Bearer\",\"expires_in\":60}"); + } else { + calls.incrementAndGet(); + boolean valid = + ("Bearer session." + session.get()) + .equals(exchange.getRequestHeaders().getFirst("Authorization")); + int status = apiStatus == 200 && !valid ? 401 : apiStatus; + if (status == 200) accepted.incrementAndGet(); + if (request.contains(PASSWORD)) invalidRequests.incrementAndGet(); + respond( + exchange, + status, + status != 200 + ? "rejected" + : path.endsWith("/search") + ? "{\"data\":[{\"workflow_id\":\"flow\"}]}" + : "{\"data\":{\"name\":\"flow\",\"steps\":[]}}"); + } + } finally { + exchange.close(); + } + } + + private static void respond(HttpExchange exchange, int status, String body) + throws java.io.IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + try (var output = exchange.getResponseBody()) { + output.write(bytes); + } + } + + @Override + public void close() { + server.stop(0); + } + } +}