From ce85ea124f94c1198030f08454c993295d950fa4 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Mon, 12 Oct 2026 11:17:39 +0800 Subject: [PATCH 1/5] feat(registry): add independent HTTP authentication and TLS policies Protect request and response boundaries, support dynamic Bearer and custom headers, preserve explicit TLS contracts, and prevent malformed successful responses from exposing credentials. Refs #90 Signed-off-by: Zhoujie628 --- docs/en/API_REFERENCE.md | 23 +- docs/zh/API_REFERENCE.md | 22 +- .../engine/client/SslContextFactory.java | 50 ++++ .../engine/registry/HttpAuthProvider.java | 63 +++++ .../engine/registry/HttpAuthentication.java | 94 +++++++ .../registry/HttpResponseException.java | 39 +++ .../engine/registry/HttpResponseParser.java | 50 ++++ .../engine/registry/HttpServiceException.java | 59 ++++ .../workflow/engine/registry/LoadPsop.java | 190 ++++++++----- .../engine/registry/RegistryClient.java | 105 +++++-- .../workflow/engine/registry/TlsPolicy.java | 98 +++++++ .../registry/CenterAuthenticationTest.java | 250 +++++++++++++++++ .../registry/CenterResponseSafetyTest.java | 140 ++++++++++ .../engine/registry/CenterTlsPolicyTest.java | 261 ++++++++++++++++++ .../engine/registry/LoadPsopSearchTest.java | 10 - 15 files changed, 1342 insertions(+), 112 deletions(-) create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpAuthProvider.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpAuthentication.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpResponseException.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpResponseParser.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpServiceException.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/registry/TlsPolicy.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/registry/CenterAuthenticationTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/registry/CenterResponseSafetyTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/registry/CenterTlsPolicyTest.java diff --git a/docs/en/API_REFERENCE.md b/docs/en/API_REFERENCE.md index 2e1ac22b..3e8c54d8 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) @@ -420,6 +429,16 @@ default to 30 seconds. Supply ### 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 +448,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/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index 9ea32716..41b6fbab 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) @@ -393,6 +402,15 @@ LoadPsop 的简便重载默认 `sslVerify=true`,使用 JVM 信任库并校验 ### 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 +419,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/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 c3ed979b..feab3b2e 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/HttpAuthProvider.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpAuthProvider.java new file mode 100644 index 00000000..b8191b9b --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpAuthProvider.java @@ -0,0 +1,63 @@ +/* + * 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 token acquisition, caching and refresh, including OAuth2. + * 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); + + /** 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 00000000..da1cc7a9 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/HttpAuthentication.java @@ -0,0 +1,94 @@ +/* + * 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 (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); + } +} 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 00000000..ab3607cd --- /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 00000000..dd19f1d3 --- /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 00000000..2dca9c4c --- /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 5df62bd2..398231c7 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,10 +20,8 @@ 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; @@ -65,27 +63,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); + HttpResult 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,80 +128,103 @@ 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); + HttpResult 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(HttpResult 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) + 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()); - } + java.util.Objects.requireNonNull(auth, "auth"); + java.util.Objects.requireNonNull(tls, "tls"); + URI uri = URI.create(url); + Map headers = HttpAuthentication.headers(auth, uri); + HttpURLConnection 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 (jsonBody != null) { connection.setDoOutput(true); connection.setRequestProperty("Content-Type", "application/json"); @@ -196,7 +238,7 @@ private static HttpResult execute( if (input != null) { input.close(); } - return new HttpResult(status, body); + return new HttpResult(status, body, headers); } finally { connection.disconnect(); } @@ -241,5 +283,5 @@ int readTimeoutMillis() { } } - private record HttpResult(int statusCode, String body) {} + private record HttpResult(int statusCode, String body, Map credentials) {} } 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 ae245964..6849fba9 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 00000000..410212ac --- /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 00000000..22b01f85 --- /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 00000000..b059fbf2 --- /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 00000000..e4f06384 --- /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/LoadPsopSearchTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/LoadPsopSearchTest.java index a3ac1e66..a2eed594 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. From 8c065c52bfdf923c24a071329e7fa3070476f9ab Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Mon, 12 Oct 2026 11:17:40 +0800 Subject: [PATCH 2/5] fix(samples): separate center access and close preparation runtimes Fail closed on center errors, require explicit local workflow mode, inject independent center authentication and TLS, and cover response safety in the running Spring host. Refs #90 Signed-off-by: Zhoujie628 --- docs/en/INTEGRATION_GUIDE.md | 43 ++- docs/zh/INTEGRATION_GUIDE.md | 37 ++- .../config/WorkbenchClientProperties.java | 14 + .../engine/examples/demo/HostQuickStart.java | 15 +- .../workbench/SpringWorkbenchExecutor.java | 23 +- .../workbench/WorkbenchOrchestrator.java | 306 +++++++++++------- samples/src/main/resources/application.yml | 6 + .../config/WorkbenchClientPropertiesTest.java | 19 ++ .../demo/DocumentedCallbackExampleTest.java | 12 +- .../examples/demo/SpringSpnDemoE2ETest.java | 95 +++++- .../demo/SpringSpnDemoFailureE2ETest.java | 1 + .../workbench/WorkbenchCenterAccessTest.java | 256 +++++++++++++++ 12 files changed, 682 insertions(+), 145 deletions(-) create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchCenterAccessTest.java diff --git a/docs/en/INTEGRATION_GUIDE.md b/docs/en/INTEGRATION_GUIDE.md index f8d0ba57..cff055a1 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,16 @@ 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. +HttpAuthProvider orchAuth = HttpAuthProvider.bearer(() -> System.getenv("ORCH_API_TOKEN")); +TlsPolicy orchTls = TlsPolicy.strict(); // Or trustedCa("/external/certs/ca.pem"). // 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 +571,39 @@ 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_API_TOKEN`, `ORCH_VERIFY_SERVER` (default true), and optional +`ORCH_CA_CERTS_PATH`, independently of `A2A_SSL_VERIFY` and agent/Order credentials. +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/INTEGRATION_GUIDE.md b/docs/zh/INTEGRATION_GUIDE.md index eb490ae1..3af6ad26 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,16 @@ ExecutePsop.builder() ## 11. 从编排中心加载工作流 ```java +// 中心凭据与 A2A AuthProvider、智能体凭据独立。 +HttpAuthProvider orchAuth = HttpAuthProvider.bearer(() -> System.getenv("ORCH_API_TOKEN")); +TlsPolicy orchTls = TlsPolicy.strict(); // 私有 CA 可用 trustedCa("/external/certs/ca.pem")。 // 按意图搜索 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 +534,36 @@ 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_API_TOKEN`、`ORCH_VERIFY_SERVER`(默认 true)和可选 +`ORCH_CA_CERTS_PATH`,不复用 `A2A_SSL_VERIFY` 或智能体/Order 凭据。 +动态或自定义认证可提供名为 `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/WorkbenchClientProperties.java b/samples/src/main/java/dev/openan/workflow/engine/examples/config/WorkbenchClientProperties.java index 01b4034e..3df2c79c 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,10 @@ @ConfigurationProperties(prefix = "a2a") public class WorkbenchClientProperties { private String orchUrl = "https://127.0.0.1:5001"; + private String orchApiToken = ""; + private boolean orchVerifyServer = true; + private String orchCaCertsPath = ""; + private boolean localWorkflowEnabled; private String credentialsPath = ""; private boolean sslVerify; private String a2atEnvPath = ""; @@ -55,6 +59,16 @@ 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; } + 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 37c1dfbb..52327ddf 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 4e0bd6c5..e6be18d2 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,25 @@ 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.centerAuth = centerAuthProvider.getIfAvailable( + () -> HttpAuthProvider.bearer(properties::getOrchApiToken)); + this.centerTls = centerTlsProvider.getIfAvailable(() -> + !properties.isOrchVerifyServer() ? TlsPolicy.insecure() + : properties.getOrchCaCertsPath() == null || properties.getOrchCaCertsPath().isBlank() + ? TlsPolicy.strict() : TlsPolicy.trustedCa(properties.getOrchCaCertsPath())); } private static long elapsedMillis(long startedNanos) { @@ -109,7 +124,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 e9ea0d5d..fbf72818 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 c6b63042..e1d7622c 100644 --- a/samples/src/main/resources/application.yml +++ b/samples/src/main/resources/application.yml @@ -20,6 +20,12 @@ a2at: a2a: orch-url: https://127.0.0.1:5001 + # Center HTTP credentials/TLS are independent of agent credentials/TLS. + 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/WorkbenchClientPropertiesTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/config/WorkbenchClientPropertiesTest.java index bc8fcf8f..45d5a277 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 104c0770..40652ffe 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,18 @@ 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, "HttpAuthProvider orchAuth =") + "\n}}"; + for (String source : List.of(callbackSource, quickStartSource, centerSource)) { 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 7d5d0605..b90d338f 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 a9369b4c..ce6730ac 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 00000000..0251db5d --- /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); + } + } +} From d8a9178729795393c96bc43ac70b27595b2dfd89 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Mon, 12 Oct 2026 20:24:26 +0800 Subject: [PATCH 3/5] feat(auth): use shared accounts for PSOP loading Signed-off-by: Zhoujie628 --- docs/en/API_REFERENCE.md | 21 ++ docs/en/INTEGRATION_GUIDE.md | 25 +- docs/zh/API_REFERENCE.md | 20 + docs/zh/INTEGRATION_GUIDE.md | 22 +- .../examples/config/OrchestrationAccess.java | 46 +++ .../config/WorkbenchClientProperties.java | 8 + .../workbench/SpringWorkbenchExecutor.java | 5 +- samples/src/main/resources/application.yml | 4 + .../config/OrchestrationAccessTest.java | 68 ++++ .../demo/DocumentedCallbackExampleTest.java | 2 +- .../engine/registry/CenterHttpClient.java | 70 ++++ .../engine/registry/HttpAuthProvider.java | 14 +- .../engine/registry/HttpAuthentication.java | 13 + .../workflow/engine/registry/LoadPsop.java | 45 +-- .../registry/OrchestrationAccountAuth.java | 178 +++++++++ .../engine/registry/LoadPsopHttpsTest.java | 58 ++- .../OrchestrationAccountAuthTest.java | 348 ++++++++++++++++++ 17 files changed, 897 insertions(+), 50 deletions(-) create mode 100644 samples/src/main/java/dev/openan/workflow/engine/examples/config/OrchestrationAccess.java create mode 100644 samples/src/test/java/dev/openan/workflow/engine/examples/config/OrchestrationAccessTest.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/registry/CenterHttpClient.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/registry/OrchestrationAccountAuth.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/registry/OrchestrationAccountAuthTest.java diff --git a/docs/en/API_REFERENCE.md b/docs/en/API_REFERENCE.md index 3e8c54d8..ec587ff1 100644 --- a/docs/en/API_REFERENCE.md +++ b/docs/en/API_REFERENCE.md @@ -427,6 +427,27 @@ 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 diff --git a/docs/en/INTEGRATION_GUIDE.md b/docs/en/INTEGRATION_GUIDE.md index cff055a1..0cc5883b 100644 --- a/docs/en/INTEGRATION_GUIDE.md +++ b/docs/en/INTEGRATION_GUIDE.md @@ -550,8 +550,10 @@ Common event types: `START`, `STEP_START`, `TASK_REQUEST`, ```java // Center credentials are independent of A2A AuthProvider and agent credentials. -HttpAuthProvider orchAuth = HttpAuthProvider.bearer(() -> System.getenv("ORCH_API_TOKEN")); 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, orchAuth, orchTls); @@ -595,8 +597,25 @@ Custom authentication implements `headers(URI)` and returns business-defined hea 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_API_TOKEN`, `ORCH_VERIFY_SERVER` (default true), and optional -`ORCH_CA_CERTS_PATH`, independently of `A2A_SSL_VERIFY` and agent/Order credentials. +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. diff --git a/docs/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index 41b6fbab..e97c5ef6 100644 --- a/docs/zh/API_REFERENCE.md +++ b/docs/zh/API_REFERENCE.md @@ -400,6 +400,26 @@ 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) 每次请求读取当前凭据; diff --git a/docs/zh/INTEGRATION_GUIDE.md b/docs/zh/INTEGRATION_GUIDE.md index 3af6ad26..02046bd9 100644 --- a/docs/zh/INTEGRATION_GUIDE.md +++ b/docs/zh/INTEGRATION_GUIDE.md @@ -513,8 +513,10 @@ ExecutePsop.builder() ```java // 中心凭据与 A2A AuthProvider、智能体凭据独立。 -HttpAuthProvider orchAuth = HttpAuthProvider.bearer(() -> System.getenv("ORCH_API_TOKEN")); 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, orchAuth, orchTls); @@ -556,8 +558,22 @@ LoadPsop 不修改 JVM 全局 SSLContext、默认 SocketFactory 或 HostnameVeri `headers(URI)`,返回自定义请求头;大小写重复及保留的传输请求头会被拒绝。 `HttpAuthProvider.none()` 只用于服务明确允许匿名访问或使用 mTLS 身份的场景。 -SpringSpnDemo 使用 `ORCH_API_TOKEN`、`ORCH_VERIFY_SERVER`(默认 true)和可选 -`ORCH_CA_CERTS_PATH`,不复用 `A2A_SSL_VERIFY` 或智能体/Order 凭据。 +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` 完成注册中心发现认证。 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 00000000..3cb2d26a --- /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 3df2c79c..3e00af8d 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 @@ -28,6 +28,8 @@ 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; @@ -62,6 +64,12 @@ public String getOrchUrl() { /** 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; } 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 e6be18d2..d4cc897d 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 @@ -63,12 +63,13 @@ public SpringWorkbenchExecutor( @org.springframework.beans.factory.annotation.Qualifier("orchestrationTlsPolicy") org.springframework.beans.factory.ObjectProvider centerTlsProvider) { this.properties = properties; - this.centerAuth = centerAuthProvider.getIfAvailable( - () -> HttpAuthProvider.bearer(properties::getOrchApiToken)); 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) { diff --git a/samples/src/main/resources/application.yml b/samples/src/main/resources/application.yml index e1d7622c..c10ca5dc 100644 --- a/samples/src/main/resources/application.yml +++ b/samples/src/main/resources/application.yml @@ -21,6 +21,10 @@ 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:} 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 00000000..88351696 --- /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/demo/DocumentedCallbackExampleTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/DocumentedCallbackExampleTest.java index 40652ffe..e8c21a0e 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 @@ -86,7 +86,7 @@ void run() throws Exception { class DocumentedCenters { void run() throws Exception { """ - + block(integration, "HttpAuthProvider orchAuth =") + "\n}}"; + + block(integration, "TlsPolicy orchTls =") + "\n}}"; for (String source : List.of(callbackSource, quickStartSource, centerSource)) { JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); assertNotNull(compiler, "Tests require a JDK, not a JRE"); 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 00000000..22ce1845 --- /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 index b8191b9b..02fed701 100644 --- 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 @@ -26,14 +26,24 @@ /** * Supplies management HTTP credentials immediately before each request. Independent of A2A - * authentication: applications own token acquisition, caching and refresh, including OAuth2. - * Implementations shared across threads must be thread-safe and must not log credentials. + * 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(); 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 index da1cc7a9..bb638150 100644 --- 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 @@ -66,6 +66,8 @@ 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( @@ -91,4 +93,15 @@ static Map headers(HttpAuthProvider auth, URI uri) { }); 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/LoadPsop.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/LoadPsop.java index 398231c7..efae4d2b 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 @@ -22,16 +22,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import dev.openan.workflow.engine.model.Workflow; import dev.openan.workflow.engine.model.WorkflowSearchResult; -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; @@ -90,7 +86,7 @@ public static Workflow load( + "/api/v1/orchestrate/psop/" + URLEncoder.encode(psopId, StandardCharsets.UTF_8).replace("+", "%20"); log.info("[Registry] Loading PSOP from {}", url); - HttpResult resp = execute("GET", url, null, auth, tls, timeouts); + CenterHttpClient.Response resp = execute("GET", url, null, auth, tls, timeouts); if (resp.statusCode() != 200) { throw requestFailure(resp); } @@ -156,7 +152,7 @@ public static List search( 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, auth, tls, timeouts); + CenterHttpClient.Response resp = execute("POST", url, jsonBody, auth, tls, timeouts); if (resp.statusCode() != 200) { throw requestFailure(resp); } @@ -199,12 +195,12 @@ private static HttpAuthProvider tokenAuth(String token) { : HttpAuthProvider.bearer(token); } - private static HttpServiceException requestFailure(HttpResult response) { + private static HttpServiceException requestFailure(CenterHttpClient.Response response) { return new HttpServiceException( "Orchestration center", response.statusCode(), response.body(), response.credentials()); } - private static HttpResult execute( + private static CenterHttpClient.Response execute( String method, String url, String jsonBody, @@ -216,32 +212,13 @@ private static HttpResult execute( java.util.Objects.requireNonNull(auth, "auth"); java.util.Objects.requireNonNull(tls, "tls"); URI uri = URI.create(url); - Map headers = HttpAuthentication.headers(auth, uri); - HttpURLConnection 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 (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, headers); - } finally { - connection.disconnect(); + 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). */ @@ -282,6 +259,4 @@ int readTimeoutMillis() { return Math.toIntExact(readTimeout.toMillis()); } } - - private record HttpResult(int statusCode, String body, Map credentials) {} } 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 00000000..4f59adce --- /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/test/java/dev/openan/workflow/engine/registry/LoadPsopHttpsTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/LoadPsopHttpsTest.java index 24d7fc16..8d7048b2 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/OrchestrationAccountAuthTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/OrchestrationAccountAuthTest.java new file mode 100644 index 00000000..53287ed8 --- /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); + } + } +} From b90c761b2e1ba2734e1fae580e4c8981dfdf88b1 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Tue, 13 Oct 2026 06:35:40 +0800 Subject: [PATCH 4/5] docs: align host examples with account authentication Signed-off-by: Zhoujie628 --- README.md | 2 +- docs/en/DEVELOPER_GUIDE.md | 53 ++++++++++++------- docs/zh/DEVELOPER_GUIDE.md | 53 ++++++++++++------- .../demo/DocumentedCallbackExampleTest.java | 12 ++++- 4 files changed, 80 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index b80412de..06586ac8 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/DEVELOPER_GUIDE.md b/docs/en/DEVELOPER_GUIDE.md index 0094ed4c..33d74d8b 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); +``` + + + ```java @GetMapping("/execute/{psopId}") 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`; 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/zh/DEVELOPER_GUIDE.md b/docs/zh/DEVELOPER_GUIDE.md index 88258295..44c0b1c2 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 +// 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("/execute/{psopId}") 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`,由宿主设置 SSE 媒体类型及序列化,框架负责 SSE 帧格式。显式机器 Bearer 模式仍可用 `HttpAuthProvider.bearer(tokenSupplier)`,Token 生命周期由宿主负责。账号 PSOP 读取最多一次 401 续期/重试,不重放写请求。详见[账号接入](INTEGRATION_GUIDE.md)。 + ### 取消 `ExecutePsop.builder().execute()` 返回 `CompletableFuture`。`cancel(true)` 停止后续本地调度、忽略迟到回调并清理执行器拥有的资源。 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 e8c21a0e..223dc27b 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 @@ -87,7 +87,17 @@ class DocumentedCenters { void run() throws Exception { """ + block(integration, "TlsPolicy orchTls =") + "\n}}"; - for (String source : List.of(callbackSource, quickStartSource, centerSource)) { + 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())")); + 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<>(); From 181653ce38f5965cb75d309c7e08df2a8bf759b0 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Tue, 13 Oct 2026 06:42:09 +0800 Subject: [PATCH 5/5] docs: declare SSE media type in reactive host example Signed-off-by: Zhoujie628 --- docs/en/DEVELOPER_GUIDE.md | 4 ++-- docs/zh/DEVELOPER_GUIDE.md | 4 ++-- .../engine/examples/demo/DocumentedCallbackExampleTest.java | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/en/DEVELOPER_GUIDE.md b/docs/en/DEVELOPER_GUIDE.md index 33d74d8b..9801d85b 100644 --- a/docs/en/DEVELOPER_GUIDE.md +++ b/docs/en/DEVELOPER_GUIDE.md @@ -269,7 +269,7 @@ private final HttpAuthProvider orchAuth = new OrchestrationAccountAuth( ```java -@GetMapping("/execute/{psopId}") +@GetMapping(value = "/execute/{psopId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux execute(@PathVariable String psopId) { // LoadPsop is blocking; keep login/retrieval off the reactive event loop. return Mono.fromCallable(() -> LoadPsop.load(baseUrl, psopId, orchAuth, orchTls)) @@ -295,7 +295,7 @@ public Flux execute(@PathVariable String psopId) { } ``` -Imports for the reactive adapter: `reactor.core.publisher.Mono/Flux` and `reactor.core.scheduler.Schedulers`; 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). +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 diff --git a/docs/zh/DEVELOPER_GUIDE.md b/docs/zh/DEVELOPER_GUIDE.md index 44c0b1c2..ac7b4e1a 100644 --- a/docs/zh/DEVELOPER_GUIDE.md +++ b/docs/zh/DEVELOPER_GUIDE.md @@ -254,7 +254,7 @@ private final HttpAuthProvider orchAuth = new OrchestrationAccountAuth( ```java -@GetMapping("/execute/{psopId}") +@GetMapping(value = "/execute/{psopId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux execute(@PathVariable String psopId) { // LoadPsop is blocking; keep login/retrieval off the reactive event loop. return Mono.fromCallable(() -> LoadPsop.load(baseUrl, psopId, orchAuth, orchTls)) @@ -280,7 +280,7 @@ public Flux execute(@PathVariable String psopId) { } ``` -响应式适配使用 `reactor.core.publisher.Mono/Flux` 和 `reactor.core.scheduler.Schedulers`,由宿主设置 SSE 媒体类型及序列化,框架负责 SSE 帧格式。显式机器 Bearer 模式仍可用 `HttpAuthProvider.bearer(tokenSupplier)`,Token 生命周期由宿主负责。账号 PSOP 读取最多一次 401 续期/重试,不重放写请求。详见[账号接入](INTEGRATION_GUIDE.md)。 +响应式适配使用 `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)。 ### 取消 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 223dc27b..7093fbf5 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 @@ -91,6 +91,7 @@ void run() throws Exception { 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"