From 82123a843697ba796ea010dc5d0989bc78de9b2d Mon Sep 17 00:00:00 2001 From: WangWeijia Date: Thu, 28 May 2026 10:43:52 +0800 Subject: [PATCH 1/5] feat: sup secretless Change-Id: I8f71ae833c457dbe1f824f5e626c6550dc8b0009 --- .gitignore | 11 + CHANNEL.md | 2 +- .../src/main/java/com/lark/oapi/Client.java | 17 ++ .../oapi/channel/ChannelClientFactory.java | 7 + .../channel/config/LarkChannelOptions.java | 11 + .../main/java/com/lark/oapi/core/Config.java | 19 ++ .../java/com/lark/oapi/core/Constants.java | 11 + .../java/com/lark/oapi/core/Transport.java | 54 +++- .../oapi/core/accesstoken/AccessToken.java | 179 +++++++++++++ .../core/accesstoken/AccessTokenError.java | 52 ++++ .../core/accesstoken/AccessTokenResp.java | 27 ++ .../core/accesstoken/AccessTokenRespData.java | 58 +++++ .../AuthorizationCodeTokenRequest.java | 66 +++++ .../core/accesstoken/RefreshTokenRequest.java | 42 +++ .../core/auth/ClientAssertionProvider.java | 5 + .../oapi/core/auth/ClientAssertionToken.java | 34 +++ .../oapi/core/auth/ClientAssertionUtils.java | 58 +++++ .../com/lark/oapi/core/auth/TargetInfo.java | 30 +++ .../exception/ClientAssertionException.java | 24 ++ .../lark/oapi/core/token/TokenManager.java | 131 ++++++++++ .../main/java/com/lark/oapi/ws/Client.java | 93 ++++++- .../lark/oapi/ws/model/BootstrapRequest.java | 36 +++ .../TestClientAssertionClientBuilder.java | 22 ++ .../TestClientAssertionChannelFactory.java | 184 +++++++++++++ .../core/TestTransportClientAssertion.java | 203 +++++++++++++++ .../core/accesstoken/TestAccessToken.java | 219 ++++++++++++++++ .../core/auth/TestClientAssertionUtils.java | 68 +++++ .../TestClientAssertionTokenManager.java | 216 +++++++++++++++ .../oapi/e2e/TestClientAssertionLocalE2E.java | 244 +++++++++++++++++ .../oapi/ws/TestClientAssertionWsClient.java | 246 ++++++++++++++++++ 30 files changed, 2359 insertions(+), 10 deletions(-) create mode 100644 larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessToken.java create mode 100644 larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessTokenError.java create mode 100644 larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessTokenResp.java create mode 100644 larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessTokenRespData.java create mode 100644 larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AuthorizationCodeTokenRequest.java create mode 100644 larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/RefreshTokenRequest.java create mode 100644 larksuite-oapi/src/main/java/com/lark/oapi/core/auth/ClientAssertionProvider.java create mode 100644 larksuite-oapi/src/main/java/com/lark/oapi/core/auth/ClientAssertionToken.java create mode 100644 larksuite-oapi/src/main/java/com/lark/oapi/core/auth/ClientAssertionUtils.java create mode 100644 larksuite-oapi/src/main/java/com/lark/oapi/core/auth/TargetInfo.java create mode 100644 larksuite-oapi/src/main/java/com/lark/oapi/core/exception/ClientAssertionException.java create mode 100644 larksuite-oapi/src/main/java/com/lark/oapi/ws/model/BootstrapRequest.java create mode 100644 larksuite-oapi/src/test/java/com/lark/oapi/TestClientAssertionClientBuilder.java create mode 100644 larksuite-oapi/src/test/java/com/lark/oapi/channel/TestClientAssertionChannelFactory.java create mode 100644 larksuite-oapi/src/test/java/com/lark/oapi/core/TestTransportClientAssertion.java create mode 100644 larksuite-oapi/src/test/java/com/lark/oapi/core/accesstoken/TestAccessToken.java create mode 100644 larksuite-oapi/src/test/java/com/lark/oapi/core/auth/TestClientAssertionUtils.java create mode 100644 larksuite-oapi/src/test/java/com/lark/oapi/core/token/TestClientAssertionTokenManager.java create mode 100644 larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLocalE2E.java create mode 100644 larksuite-oapi/src/test/java/com/lark/oapi/ws/TestClientAssertionWsClient.java diff --git a/.gitignore b/.gitignore index 7c10293de..c83f929a1 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,14 @@ plan skills-lock.json channel-diff-tasks test +!larksuite-oapi/src/test/ +larksuite-oapi/src/test/** +!larksuite-oapi/src/test/**/ +!larksuite-oapi/src/test/java/com/lark/oapi/TestClientAssertionClientBuilder.java +!larksuite-oapi/src/test/java/com/lark/oapi/channel/TestClientAssertionChannelFactory.java +!larksuite-oapi/src/test/java/com/lark/oapi/core/TestTransportClientAssertion.java +!larksuite-oapi/src/test/java/com/lark/oapi/core/accesstoken/TestAccessToken.java +!larksuite-oapi/src/test/java/com/lark/oapi/core/auth/TestClientAssertionUtils.java +!larksuite-oapi/src/test/java/com/lark/oapi/core/token/TestClientAssertionTokenManager.java +!larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLocalE2E.java +!larksuite-oapi/src/test/java/com/lark/oapi/ws/TestClientAssertionWsClient.java diff --git a/CHANNEL.md b/CHANNEL.md index 03ad571ca..32e557e49 100644 --- a/CHANNEL.md +++ b/CHANNEL.md @@ -93,7 +93,7 @@ public class AgentBot { com.larksuite.oapi oapi-sdk - 2.6.1 + 2.7.1 ``` diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/Client.java b/larksuite-oapi/src/main/java/com/lark/oapi/Client.java index 2aabe4c0f..f88ac9aff 100644 --- a/larksuite-oapi/src/main/java/com/lark/oapi/Client.java +++ b/larksuite-oapi/src/main/java/com/lark/oapi/Client.java @@ -69,6 +69,7 @@ import com.lark.oapi.service.passport.PassportService; import com.lark.oapi.service.ext.ExtService; +import com.lark.oapi.core.auth.ClientAssertionProvider; import com.lark.oapi.core.httpclient.IHttpTransport; import com.lark.oapi.core.httpclient.OkHttpTransport; import com.lark.oapi.core.Transport; @@ -163,6 +164,7 @@ public class Client { private PassportService passport; private ExtService extService; + private com.lark.oapi.core.accesstoken.AccessToken accessToken; public static Builder newBuilder(String appId, String appSecret) { return new Builder(appId, appSecret); @@ -172,6 +174,10 @@ public ExtService ext() { return extService; } + public com.lark.oapi.core.accesstoken.AccessToken accessToken() { + return accessToken; + } + public void setConfig(Config config) { this.config = config; } @@ -520,6 +526,16 @@ public Builder openBaseUrl(BaseUrlEnum baseUrl) { return this; } + public Builder oauthBaseUrl(String oauthBaseUrl) { + config.setOAuthBaseUrl(oauthBaseUrl); + return this; + } + + public Builder clientAssertionProvider(ClientAssertionProvider provider) { + config.setClientAssertionProvider(provider); + return this; + } + public Builder tokenCache(ICache cache) { config.setCache(cache); return this; @@ -567,6 +583,7 @@ public Client build() { client.setConfig(config); initCache(config); initHttpTransport(config); + client.accessToken = new com.lark.oapi.core.accesstoken.AccessToken(config); client.extService = new ExtService(config); client.minutes = new MinutesService(config); client.admin = new AdminService(config); diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/channel/ChannelClientFactory.java b/larksuite-oapi/src/main/java/com/lark/oapi/channel/ChannelClientFactory.java index c6bbf2f92..e8d4f6f06 100644 --- a/larksuite-oapi/src/main/java/com/lark/oapi/channel/ChannelClientFactory.java +++ b/larksuite-oapi/src/main/java/com/lark/oapi/channel/ChannelClientFactory.java @@ -25,6 +25,12 @@ static Client createRawClient(LarkChannelOptions options) { if (options.getSource() != null) { builder.source(options.getSource()); } + if (options.getClientAssertionProvider() != null) { + builder.clientAssertionProvider(options.getClientAssertionProvider()); + } + if (options.getOAuthBaseUrl() != null) { + builder.oauthBaseUrl(options.getOAuthBaseUrl()); + } return builder.build(); } @@ -38,6 +44,7 @@ static com.lark.oapi.ws.Client createWebSocketClient( return new com.lark.oapi.ws.Client.Builder(options.getAppId(), options.getAppSecret()) .eventHandler(eventDispatcher) .domain(options.getDomain() == null ? BaseUrlEnum.FeiShu.getUrl() : options.getDomain()) + .clientAssertionProvider(options.getClientAssertionProvider()) .source(options.getSource()) .onReconnecting(new Runnable() { @Override diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/channel/config/LarkChannelOptions.java b/larksuite-oapi/src/main/java/com/lark/oapi/channel/config/LarkChannelOptions.java index 69d3b93c0..833c10be8 100644 --- a/larksuite-oapi/src/main/java/com/lark/oapi/channel/config/LarkChannelOptions.java +++ b/larksuite-oapi/src/main/java/com/lark/oapi/channel/config/LarkChannelOptions.java @@ -1,5 +1,6 @@ package com.lark.oapi.channel.config; +import com.lark.oapi.core.auth.ClientAssertionProvider; import com.lark.oapi.core.cache.ICache; import com.lark.oapi.core.httpclient.IHttpTransport; import com.lark.oapi.core.request.RequestOptions; @@ -27,6 +28,8 @@ public class LarkChannelOptions { private final RequestOptions httpInstance; private final String source; private final boolean includeRawInMessage; + private final ClientAssertionProvider clientAssertionProvider; + private final String oauthBaseUrl; private LarkChannelOptions(Builder builder) { this.appId = builder.appId; @@ -42,6 +45,8 @@ private LarkChannelOptions(Builder builder) { this.httpInstance = builder.httpInstance; this.source = builder.source; this.includeRawInMessage = builder.includeRawInMessage; + this.clientAssertionProvider = builder.clientAssertionProvider; + this.oauthBaseUrl = builder.oauthBaseUrl; } public static Builder newBuilder(String appId, String appSecret) { @@ -60,6 +65,8 @@ public static Builder newBuilder(String appId, String appSecret) { public IHttpTransport getHttpTransport() { return httpTransport; } public RequestOptions getHttpInstance() { return httpInstance; } public String getSource() { return source; } + public ClientAssertionProvider getClientAssertionProvider() { return clientAssertionProvider; } + public String getOAuthBaseUrl() { return oauthBaseUrl; } /** * Whether normalized events should carry the original Feishu event body. * @@ -90,6 +97,8 @@ public static final class Builder { private RequestOptions httpInstance; private String source; private boolean includeRawInMessage; + private ClientAssertionProvider clientAssertionProvider; + private String oauthBaseUrl; private Builder(String appId, String appSecret) { this.appId = appId; @@ -110,6 +119,8 @@ private Builder(String appId, String appSecret) { public Builder httpTransport(IHttpTransport httpTransport) { this.httpTransport = httpTransport; return this; } public Builder httpInstance(RequestOptions httpInstance) { this.httpInstance = httpInstance; return this; } public Builder source(String source) { this.source = source; return this; } + public Builder clientAssertionProvider(ClientAssertionProvider clientAssertionProvider) { this.clientAssertionProvider = clientAssertionProvider; return this; } + public Builder oauthBaseUrl(String oauthBaseUrl) { this.oauthBaseUrl = oauthBaseUrl; return this; } /** * Attach the raw Feishu event body to normalized events. Useful when a diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/Config.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/Config.java index ed7d73a0a..c5df1c7d7 100644 --- a/larksuite-oapi/src/main/java/com/lark/oapi/core/Config.java +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/Config.java @@ -14,6 +14,7 @@ import com.lark.oapi.core.cache.ICache; +import com.lark.oapi.core.auth.ClientAssertionProvider; import com.lark.oapi.core.enums.AppType; import com.lark.oapi.core.enums.BaseUrlEnum; import com.lark.oapi.core.httpclient.IHttpTransport; @@ -37,6 +38,8 @@ public class Config { private IHttpTransport httpTransport; private boolean logReqAtDebug; private String source; + private String oauthBaseUrl; + private ClientAssertionProvider clientAssertionProvider; public Config() { this.baseUrl = BaseUrlEnum.FeiShu.getUrl(); @@ -167,4 +170,20 @@ public void setSource(String source) { this.source = source; } + public String getOAuthBaseUrl() { + return oauthBaseUrl; + } + + public void setOAuthBaseUrl(String oauthBaseUrl) { + this.oauthBaseUrl = oauthBaseUrl; + } + + public ClientAssertionProvider getClientAssertionProvider() { + return clientAssertionProvider; + } + + public void setClientAssertionProvider(ClientAssertionProvider clientAssertionProvider) { + this.clientAssertionProvider = clientAssertionProvider; + } + } diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/Constants.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/Constants.java index ad40758bd..e95a2eda7 100644 --- a/larksuite-oapi/src/main/java/com/lark/oapi/core/Constants.java +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/Constants.java @@ -40,6 +40,17 @@ public interface Constants { String APP_ACCESS_TOKEN_ISV_URL_PATH = "/open-apis/auth/v3/app_access_token"; String TENANT_ACCESS_TOKEN_INTERNAL_URL_PATH = "/open-apis/auth/v3/tenant_access_token/internal"; String TENANT_ACCESS_TOKEN_ISV_URL_PATH = "/open-apis/auth/v3/tenant_access_token"; + String OAUTH_TOKEN_URL_PATH = "/oauth/v3/token"; + String GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"; + String GRANT_TYPE_REFRESH_TOKEN = "refresh_token"; + String GRANT_TYPE_JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + String CLIENT_ASSERTION_TYPE_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + String HEADER_X_TARGET_SERVICE = "X-Target-Service"; + int ERR_CODE_CLIENT_ASSERTION_PROVIDER_NOT_CONFIGURED = 7100; + int ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY = 7101; + int ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED = 7102; + int ERR_CODE_CLIENT_ASSERTION_MODE_NOT_SUPPORTED = 7103; + int ERR_CODE_APP_SECRET_AND_CLIENT_ASSERTION_EMPTY = 7104; String APPLY_APP_TICKET_PATH = "/open-apis/auth/v3/app_ticket/resend"; String GET_AUTHEN_ACCESS_TOKEN = "/open-apis/authen/v1/access_token"; String REFRESH_AUTHEN_ACCESS_TOKEN = "/open-apis/authen/v1/refresh_access_token"; diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/Transport.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/Transport.java index 763d6f541..b03d2f250 100644 --- a/larksuite-oapi/src/main/java/com/lark/oapi/core/Transport.java +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/Transport.java @@ -14,6 +14,7 @@ import com.lark.oapi.core.exception.AccessTokenNotGivenException; import com.lark.oapi.core.exception.ClientTimeoutException; +import com.lark.oapi.core.exception.ClientAssertionException; import com.lark.oapi.core.exception.IllegalAccessTokenTypeException; import com.lark.oapi.core.exception.ServerTimeoutException; import com.lark.oapi.core.httpclient.IHttpTransport; @@ -23,6 +24,7 @@ import com.lark.oapi.core.request.RequestOptions; import com.lark.oapi.core.response.RawResponse; import com.lark.oapi.core.token.AccessTokenType; +import com.lark.oapi.core.enums.AppType; import com.lark.oapi.core.utils.Jsons; import com.lark.oapi.core.utils.OKHttps; import com.lark.oapi.core.utils.Strings; @@ -39,7 +41,33 @@ public class Transport { private static final ReqTranslator REQ_TRANSLATOR = new ReqTranslator(); private static AccessTokenType determineTokenType(Set accessTokenTypeSet, - RequestOptions requestOptions, boolean disableTokenCache) { + RequestOptions requestOptions, boolean disableTokenCache, + Config config) { + if (config.getClientAssertionProvider() != null) { + validateTokenType(accessTokenTypeSet, requestOptions); + + if (Strings.isNotEmpty(requestOptions.getUserAccessToken()) + && accessTokenTypeSet.contains(AccessTokenType.User)) { + return AccessTokenType.User; + } + + if (accessTokenTypeSet.contains(AccessTokenType.Tenant)) { + return AccessTokenType.Tenant; + } + + if (accessTokenTypeSet.contains(AccessTokenType.App)) { + throw new ClientAssertionException( + Constants.ERR_CODE_CLIENT_ASSERTION_MODE_NOT_SUPPORTED, + "AppAccessToken APIs are not available in ClientAssertion mode"); + } + + if (accessTokenTypeSet.contains(AccessTokenType.None)) { + return AccessTokenType.None; + } + + throw new IllegalAccessTokenTypeException(); + } + if (accessTokenTypeSet.contains(AccessTokenType.None)) { return AccessTokenType.None; } @@ -110,7 +138,21 @@ private static void validate(Config config, RequestOptions requestOptions, throw new IllegalArgumentException("appId is blank"); } - if (Strings.isEmpty(config.getAppSecret())) { + if (config.getClientAssertionProvider() != null + && config.getAppType() == AppType.MARKETPLACE) { + throw new ClientAssertionException( + Constants.ERR_CODE_CLIENT_ASSERTION_PROVIDER_NOT_CONFIGURED, + "ClientAssertion mode is not supported for marketplace apps"); + } + + boolean hasManualAccessToken = + (accessTokenType == AccessTokenType.User && Strings.isNotEmpty(requestOptions.getUserAccessToken())) + || (accessTokenType == AccessTokenType.Tenant && Strings.isNotEmpty(requestOptions.getTenantAccessToken())) + || (accessTokenType == AccessTokenType.App && Strings.isNotEmpty(requestOptions.getAppAccessToken())); + + if (config.getClientAssertionProvider() == null + && Strings.isEmpty(config.getAppSecret()) + && !hasManualAccessToken) { throw new IllegalArgumentException("appSecret is blank"); } @@ -176,7 +218,8 @@ public static RawResponse send(Config config // 确定token类型 AccessTokenType accessTokenType = determineTokenType(accessTokenTypeSet , requestOptions - , config.isDisableTokenCache()); + , config.isDisableTokenCache() + , config); // 参数校验 validate(config, requestOptions, accessTokenType); @@ -256,6 +299,11 @@ private static RawResponse doSend(Config config, String httpMethod, String httpP return rawResponse; } catch (Exception e) { error = e; + if (e instanceof ClientAssertionException + && ((ClientAssertionException) e).getCode() == Constants.ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED + && i == 0) { + continue; + } // 获取token失败,重试一次,其他请求不重试 if (accessTokenType != AccessTokenType.None) { throw e; diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessToken.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessToken.java new file mode 100644 index 000000000..5518bde53 --- /dev/null +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessToken.java @@ -0,0 +1,179 @@ +package com.lark.oapi.core.accesstoken; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.lark.oapi.core.Config; +import com.lark.oapi.core.Constants; +import com.lark.oapi.core.Transport; +import com.lark.oapi.core.auth.ClientAssertionToken; +import com.lark.oapi.core.auth.ClientAssertionUtils; +import com.lark.oapi.core.auth.TargetInfo; +import com.lark.oapi.core.exception.ClientAssertionException; +import com.lark.oapi.core.request.RequestOptions; +import com.lark.oapi.core.response.RawResponse; +import com.lark.oapi.core.token.AccessTokenType; +import com.lark.oapi.core.utils.Lists; +import com.lark.oapi.core.utils.Sets; +import com.lark.oapi.core.utils.Strings; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class AccessToken { + private final Config config; + + public AccessToken(Config config) { + this.config = config; + } + + public Config getConfig() { + return config; + } + + public AccessTokenResp retrieveByAuthorizationCode(AuthorizationCodeTokenRequest request) throws Exception { + return retrieveByAuthorizationCode(request, new RequestOptions()); + } + + public AccessTokenResp retrieveByAuthorizationCode(AuthorizationCodeTokenRequest request, + RequestOptions options) throws Exception { + PreparedRequest prepared = prepareRequest(Constants.GRANT_TYPE_AUTHORIZATION_CODE, options); + putIfNotEmpty(prepared.body, "code", request.getCode()); + putIfNotEmpty(prepared.body, "redirect_uri", request.getRedirectUri()); + putIfNotEmpty(prepared.body, "code_verifier", request.getCodeVerifier()); + putIfNotEmpty(prepared.body, "scope", request.getScope()); + return send(prepared, options); + } + + public AccessTokenResp refresh(RefreshTokenRequest request) throws Exception { + return refresh(request, new RequestOptions()); + } + + public AccessTokenResp refresh(RefreshTokenRequest request, RequestOptions options) throws Exception { + PreparedRequest prepared = prepareRequest(Constants.GRANT_TYPE_REFRESH_TOKEN, options); + putIfNotEmpty(prepared.body, "refresh_token", request.getRefreshToken()); + putIfNotEmpty(prepared.body, "scope", request.getScope()); + return send(prepared, options); + } + + private PreparedRequest prepareRequest(String grantType, RequestOptions options) throws Exception { + Map body = new HashMap<>(); + body.put("grant_type", grantType); + body.put("client_id", config.getAppId()); + String reqUrl = ClientAssertionUtils.resolveOAuthBaseUrl(config) + Constants.OAUTH_TOKEN_URL_PATH; + + if (config.getClientAssertionProvider() != null) { + String aud = ClientAssertionUtils.resolveOAuthAud(config); + ClientAssertionToken token; + try { + token = config.getClientAssertionProvider().retrieveToken(aud); + } catch (Exception e) { + throw new ClientAssertionException( + Constants.ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED, + e.getMessage(), + e); + } + if (token == null || Strings.isEmpty(token.getValue())) { + throw new ClientAssertionException( + Constants.ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY, + "client assertion token is empty"); + } + body.put("client_assertion_type", Constants.CLIENT_ASSERTION_TYPE_JWT_BEARER); + body.put("client_assertion", token.getValue()); + applyTargetInfo(options, aud, token.getTargetInfo()); + if (token.getTargetInfo() != null && Strings.isNotEmpty(token.getTargetInfo().getTargetService())) { + reqUrl = ClientAssertionUtils.buildProxyUrl( + token.getTargetInfo().getTargetService(), + token.getTargetInfo().getTargetPrefix(), + Constants.OAUTH_TOKEN_URL_PATH); + } + return new PreparedRequest(body, reqUrl); + } + + if (Strings.isNotEmpty(config.getAppSecret())) { + body.put("client_secret", config.getAppSecret()); + return new PreparedRequest(body, reqUrl); + } + + throw new ClientAssertionException( + Constants.ERR_CODE_APP_SECRET_AND_CLIENT_ASSERTION_EMPTY, + "AppSecret and ClientAssertionProvider cannot both be empty for AccessToken APIs"); + } + + private void applyTargetInfo(RequestOptions options, String aud, TargetInfo targetInfo) { + if (targetInfo == null || Strings.isEmpty(targetInfo.getTargetService())) { + return; + } + Map> headers = options.getHeaders(); + if (headers == null) { + headers = new HashMap<>(); + options.setHeaders(headers); + } + headers.put(Constants.HEADER_X_TARGET_SERVICE, Lists.newArrayList(aud)); + } + + private AccessTokenResp send(PreparedRequest prepared, RequestOptions options) throws Exception { + RawResponse rawResponse = Transport.send(config, + options, + "POST", + prepared.reqUrl, + Sets.newHashSet(AccessTokenType.None), + prepared.body); + + JsonObject jsonObject = parseBody(rawResponse); + if (rawResponse.getStatusCode() < 200 || rawResponse.getStatusCode() >= 300) { + throw new AccessTokenError(rawResponse.getStatusCode(), + getInt(jsonObject, "code"), + getString(jsonObject, "error"), + getString(jsonObject, "error_description"), + rawResponse); + } + + AccessTokenRespData data = new AccessTokenRespData(); + data.setAccessToken(getString(jsonObject, "access_token")); + data.setTokenType(getString(jsonObject, "token_type")); + data.setExpiresIn(getInt(jsonObject, "expires_in")); + data.setRefreshToken(getString(jsonObject, "refresh_token")); + data.setRefreshTokenExpiresIn(getInt(jsonObject, "refresh_token_expires_in")); + data.setScope(getString(jsonObject, "scope")); + return new AccessTokenResp(rawResponse.getStatusCode(), rawResponse, data); + } + + private JsonObject parseBody(RawResponse rawResponse) { + if (rawResponse.getBody() == null || rawResponse.getBody().length == 0) { + return new JsonObject(); + } + return JsonParser.parseString(new String(rawResponse.getBody(), StandardCharsets.UTF_8)).getAsJsonObject(); + } + + private void putIfNotEmpty(Map body, String key, String value) { + if (Strings.isNotEmpty(value)) { + body.put(key, value); + } + } + + private String getString(JsonObject jsonObject, String key) { + if (jsonObject == null || !jsonObject.has(key) || jsonObject.get(key).isJsonNull()) { + return ""; + } + return jsonObject.get(key).getAsString(); + } + + private int getInt(JsonObject jsonObject, String key) { + if (jsonObject == null || !jsonObject.has(key) || jsonObject.get(key).isJsonNull()) { + return 0; + } + return jsonObject.get(key).getAsInt(); + } + + private static class PreparedRequest { + private final Map body; + private final String reqUrl; + + private PreparedRequest(Map body, String reqUrl) { + this.body = body; + this.reqUrl = reqUrl; + } + } +} diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessTokenError.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessTokenError.java new file mode 100644 index 000000000..95d9b9eab --- /dev/null +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessTokenError.java @@ -0,0 +1,52 @@ +package com.lark.oapi.core.accesstoken; + +import com.lark.oapi.core.response.RawResponse; +import com.lark.oapi.core.utils.Strings; + +public class AccessTokenError extends Exception { + private final int statusCode; + private final int code; + private final String errorType; + private final String errorDescription; + private final RawResponse rawResponse; + + public AccessTokenError(int statusCode, int code, String errorType, String errorDescription, + RawResponse rawResponse) { + super(bestMessage(errorDescription, errorType)); + this.statusCode = statusCode; + this.code = code; + this.errorType = errorType; + this.errorDescription = errorDescription; + this.rawResponse = rawResponse; + } + + private static String bestMessage(String errorDescription, String errorType) { + if (Strings.isNotEmpty(errorDescription)) { + return errorDescription; + } + if (Strings.isNotEmpty(errorType)) { + return errorType; + } + return "access token request failed"; + } + + public int getStatusCode() { + return statusCode; + } + + public int getCode() { + return code; + } + + public String getErrorType() { + return errorType; + } + + public String getErrorDescription() { + return errorDescription; + } + + public RawResponse getRawResponse() { + return rawResponse; + } +} diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessTokenResp.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessTokenResp.java new file mode 100644 index 000000000..cd33e7762 --- /dev/null +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessTokenResp.java @@ -0,0 +1,27 @@ +package com.lark.oapi.core.accesstoken; + +import com.lark.oapi.core.response.RawResponse; + +public class AccessTokenResp { + private final int statusCode; + private final RawResponse rawResponse; + private final AccessTokenRespData data; + + public AccessTokenResp(int statusCode, RawResponse rawResponse, AccessTokenRespData data) { + this.statusCode = statusCode; + this.rawResponse = rawResponse; + this.data = data; + } + + public int getStatusCode() { + return statusCode; + } + + public RawResponse getRawResponse() { + return rawResponse; + } + + public AccessTokenRespData getData() { + return data; + } +} diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessTokenRespData.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessTokenRespData.java new file mode 100644 index 000000000..a39a1250a --- /dev/null +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessTokenRespData.java @@ -0,0 +1,58 @@ +package com.lark.oapi.core.accesstoken; + +public class AccessTokenRespData { + private String accessToken; + private String tokenType; + private int expiresIn; + private String refreshToken; + private int refreshTokenExpiresIn; + private String scope; + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getTokenType() { + return tokenType; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + + public int getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(int expiresIn) { + this.expiresIn = expiresIn; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public int getRefreshTokenExpiresIn() { + return refreshTokenExpiresIn; + } + + public void setRefreshTokenExpiresIn(int refreshTokenExpiresIn) { + this.refreshTokenExpiresIn = refreshTokenExpiresIn; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } +} diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AuthorizationCodeTokenRequest.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AuthorizationCodeTokenRequest.java new file mode 100644 index 000000000..74e48421e --- /dev/null +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AuthorizationCodeTokenRequest.java @@ -0,0 +1,66 @@ +package com.lark.oapi.core.accesstoken; + +public class AuthorizationCodeTokenRequest { + private final String code; + private final String redirectUri; + private final String codeVerifier; + private final String scope; + + private AuthorizationCodeTokenRequest(Builder builder) { + this.code = builder.code; + this.redirectUri = builder.redirectUri; + this.codeVerifier = builder.codeVerifier; + this.scope = builder.scope; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public String getCode() { + return code; + } + + public String getRedirectUri() { + return redirectUri; + } + + public String getCodeVerifier() { + return codeVerifier; + } + + public String getScope() { + return scope; + } + + public static final class Builder { + private String code; + private String redirectUri; + private String codeVerifier; + private String scope; + + public Builder code(String code) { + this.code = code; + return this; + } + + public Builder redirectUri(String redirectUri) { + this.redirectUri = redirectUri; + return this; + } + + public Builder codeVerifier(String codeVerifier) { + this.codeVerifier = codeVerifier; + return this; + } + + public Builder scope(String scope) { + this.scope = scope; + return this; + } + + public AuthorizationCodeTokenRequest build() { + return new AuthorizationCodeTokenRequest(this); + } + } +} diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/RefreshTokenRequest.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/RefreshTokenRequest.java new file mode 100644 index 000000000..c439fcb34 --- /dev/null +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/RefreshTokenRequest.java @@ -0,0 +1,42 @@ +package com.lark.oapi.core.accesstoken; + +public class RefreshTokenRequest { + private final String refreshToken; + private final String scope; + + private RefreshTokenRequest(Builder builder) { + this.refreshToken = builder.refreshToken; + this.scope = builder.scope; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public String getRefreshToken() { + return refreshToken; + } + + public String getScope() { + return scope; + } + + public static final class Builder { + private String refreshToken; + private String scope; + + public Builder refreshToken(String refreshToken) { + this.refreshToken = refreshToken; + return this; + } + + public Builder scope(String scope) { + this.scope = scope; + return this; + } + + public RefreshTokenRequest build() { + return new RefreshTokenRequest(this); + } + } +} diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/auth/ClientAssertionProvider.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/auth/ClientAssertionProvider.java new file mode 100644 index 000000000..5d5b0a5ea --- /dev/null +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/auth/ClientAssertionProvider.java @@ -0,0 +1,5 @@ +package com.lark.oapi.core.auth; + +public interface ClientAssertionProvider { + ClientAssertionToken retrieveToken(String aud) throws Exception; +} diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/auth/ClientAssertionToken.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/auth/ClientAssertionToken.java new file mode 100644 index 000000000..b4db4bf31 --- /dev/null +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/auth/ClientAssertionToken.java @@ -0,0 +1,34 @@ +package com.lark.oapi.core.auth; + +public class ClientAssertionToken { + private String value; + private TargetInfo targetInfo; + + public ClientAssertionToken() { + } + + public ClientAssertionToken(String value) { + this.value = value; + } + + public ClientAssertionToken(String value, TargetInfo targetInfo) { + this.value = value; + this.targetInfo = targetInfo; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public TargetInfo getTargetInfo() { + return targetInfo; + } + + public void setTargetInfo(TargetInfo targetInfo) { + this.targetInfo = targetInfo; + } +} diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/auth/ClientAssertionUtils.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/auth/ClientAssertionUtils.java new file mode 100644 index 000000000..b12889fd9 --- /dev/null +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/auth/ClientAssertionUtils.java @@ -0,0 +1,58 @@ +package com.lark.oapi.core.auth; + +import com.lark.oapi.core.Config; +import com.lark.oapi.core.enums.BaseUrlEnum; +import com.lark.oapi.core.utils.Strings; + +import java.net.URI; + +public final class ClientAssertionUtils { + private ClientAssertionUtils() { + } + + public static String resolveOAuthBaseUrl(Config config) { + if (config != null && Strings.isNotEmpty(config.getOAuthBaseUrl())) { + return normalizeBaseUrl(config.getOAuthBaseUrl()); + } + + String aud = extractAudFromUrl(config == null ? null : config.getBaseUrl()); + if (extractAudFromUrl(BaseUrlEnum.FeiShu.getUrl()).equals(aud)) { + return "https://accounts.feishu.cn"; + } + if (extractAudFromUrl(BaseUrlEnum.LarkSuite.getUrl()).equals(aud)) { + return "https://accounts.larksuite.com"; + } + + throw new IllegalArgumentException("OAuthBaseUrl is not configured. When BaseUrl is set to a non-default value (neither open.feishu.cn nor open.larksuite.com), you must explicitly configure OAuthBaseUrl via oauthBaseUrl(...)"); + } + + public static String resolveOAuthAud(Config config) { + return extractAudFromUrl(resolveOAuthBaseUrl(config)); + } + + public static String extractAudFromUrl(String rawUrl) { + if (Strings.isEmpty(rawUrl)) { + throw new IllegalArgumentException("invalid url : " + rawUrl); + } + String normalized = rawUrl.contains("://") ? rawUrl : "https://" + rawUrl; + URI uri = URI.create(normalized); + if (uri.getHost() == null) { + throw new IllegalArgumentException("invalid url : " + rawUrl); + } + return uri.getPort() > 0 ? uri.getHost() + ":" + uri.getPort() : uri.getHost(); + } + + public static String buildProxyUrl(String targetService, String targetPrefix, String apiPath) { + String service = targetService.contains("://") ? targetService : "https://" + targetService; + String prefix = targetPrefix == null ? "" : targetPrefix; + return service + prefix + apiPath; + } + + public static String normalizeBaseUrl(String baseUrl) { + String normalized = baseUrl.contains("://") ? baseUrl : "https://" + baseUrl; + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } +} diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/auth/TargetInfo.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/auth/TargetInfo.java new file mode 100644 index 000000000..642189e13 --- /dev/null +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/auth/TargetInfo.java @@ -0,0 +1,30 @@ +package com.lark.oapi.core.auth; + +public class TargetInfo { + private String targetService; + private String targetPrefix; + + public TargetInfo() { + } + + public TargetInfo(String targetService, String targetPrefix) { + this.targetService = targetService; + this.targetPrefix = targetPrefix; + } + + public String getTargetService() { + return targetService; + } + + public void setTargetService(String targetService) { + this.targetService = targetService; + } + + public String getTargetPrefix() { + return targetPrefix; + } + + public void setTargetPrefix(String targetPrefix) { + this.targetPrefix = targetPrefix; + } +} diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/exception/ClientAssertionException.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/exception/ClientAssertionException.java new file mode 100644 index 000000000..e51f85b6f --- /dev/null +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/exception/ClientAssertionException.java @@ -0,0 +1,24 @@ +package com.lark.oapi.core.exception; + +public class ClientAssertionException extends RuntimeException { + private final int code; + + public ClientAssertionException(int code, String message) { + super(message); + this.code = code; + } + + public ClientAssertionException(int code, String message, Throwable cause) { + super(message, cause); + this.code = code; + } + + public int getCode() { + return code; + } + + @Override + public String toString() { + return String.format("ClientAssertionException: %d: %s", code, getMessage()); + } +} diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/token/TokenManager.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/token/TokenManager.java index f6efbce32..0a42ba47a 100644 --- a/larksuite-oapi/src/main/java/com/lark/oapi/core/token/TokenManager.java +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/token/TokenManager.java @@ -12,12 +12,18 @@ package com.lark.oapi.core.token; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import com.lark.oapi.core.Config; import com.lark.oapi.core.Constants; import com.lark.oapi.core.Transport; +import com.lark.oapi.core.auth.ClientAssertionToken; +import com.lark.oapi.core.auth.ClientAssertionUtils; +import com.lark.oapi.core.auth.TargetInfo; import com.lark.oapi.core.cache.ICache; import com.lark.oapi.core.enums.AppType; import com.lark.oapi.core.exception.AppTicketIsEmptyException; +import com.lark.oapi.core.exception.ClientAssertionException; import com.lark.oapi.core.exception.ObtainAccessTokenException; import com.lark.oapi.core.request.MarketplaceAppAccessTokenReq; import com.lark.oapi.core.request.MarketplaceTenantAccessTokenReq; @@ -28,10 +34,15 @@ import com.lark.oapi.core.response.TenantAccessTokenResp; import com.lark.oapi.core.utils.Sets; import com.lark.oapi.core.utils.Strings; +import com.lark.oapi.core.utils.Lists; import com.lark.oapi.core.utils.UnmarshalRespUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; public class TokenManager { @@ -51,6 +62,12 @@ private String getAppAccessTokenKey(String appID) { } public String getAppAccessToken(Config config) throws Exception { + if (config.getClientAssertionProvider() != null) { + throw new ClientAssertionException( + Constants.ERR_CODE_CLIENT_ASSERTION_PROVIDER_NOT_CONFIGURED, + "AppAccessToken is not available in ClientAssertion mode"); + } + // 缓存里存在则直接返回 String token = cache.get(getAppAccessTokenKey(config.getAppId())); if (Strings.isNotEmpty(token)) { @@ -133,6 +150,16 @@ private String getTenantAccessTokenKey(String appID, String tenantKey) { } public String getTenantAccessToken(Config config, String tenantKey) throws Exception { + if (config.getClientAssertionProvider() != null) { + if (!config.isDisableTokenCache()) { + String token = cache.get(getTenantAccessTokenKey(config.getAppId(), tenantKey)); + if (Strings.isNotEmpty(token)) { + return token; + } + } + return getTenantTokenByClientAssertion(config, tenantKey); + } + // 缓存中存在,则直接返回 String token = cache.get(getTenantAccessTokenKey(config.getAppId(), tenantKey)); if (Strings.isNotEmpty(token)) { @@ -157,6 +184,110 @@ public String getTenantAccessToken(Config config, String tenantKey) throws Excep return token; } + private String getTenantTokenByClientAssertion(Config config, String tenantKey) throws Exception { + String oauthBaseUrl = ClientAssertionUtils.resolveOAuthBaseUrl(config); + String aud = ClientAssertionUtils.resolveOAuthAud(config); + ClientAssertionToken assertionToken; + try { + assertionToken = config.getClientAssertionProvider().retrieveToken(aud); + } catch (Exception e) { + throw new ClientAssertionException( + Constants.ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED, + e.getMessage(), + e); + } + + if (assertionToken == null || Strings.isEmpty(assertionToken.getValue())) { + throw new ClientAssertionException( + Constants.ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY, + "client assertion token is empty"); + } + + String reqUrl = oauthBaseUrl + Constants.OAUTH_TOKEN_URL_PATH; + RequestOptions requestOptions = new RequestOptions(); + TargetInfo targetInfo = assertionToken.getTargetInfo(); + if (targetInfo != null && Strings.isNotEmpty(targetInfo.getTargetService())) { + reqUrl = ClientAssertionUtils.buildProxyUrl( + targetInfo.getTargetService(), + targetInfo.getTargetPrefix(), + Constants.OAUTH_TOKEN_URL_PATH); + Map> headers = new HashMap<>(); + headers.put(Constants.HEADER_X_TARGET_SERVICE, Lists.newArrayList(aud)); + requestOptions.setHeaders(headers); + } + + Map body = new HashMap<>(); + body.put("grant_type", Constants.GRANT_TYPE_JWT_BEARER); + body.put("client_assertion_type", Constants.CLIENT_ASSERTION_TYPE_JWT_BEARER); + body.put("client_assertion", assertionToken.getValue()); + body.put("client_id", config.getAppId()); + + RawResponse resp = Transport.send(config + , requestOptions, "POST" + , reqUrl + , Sets.newHashSet(AccessTokenType.None), body); + + JsonObject respBody = parseBody(resp); + String token = getString(respBody, "access_token"); + if (Strings.isEmpty(token)) { + throw new ClientAssertionException(getErrorCode(resp, respBody), getErrorMessage(respBody)); + } + + int expiresIn = getInt(respBody, "expires_in"); + if (!config.isDisableTokenCache()) { + cache.set(getTenantAccessTokenKey(config.getAppId(), tenantKey), token, + Math.max(expiresIn - expiryDeltaOfSecond, 0), TimeUnit.SECONDS); + } + return token; + } + + private JsonObject parseBody(RawResponse resp) { + if (resp.getBody() == null || resp.getBody().length == 0) { + return new JsonObject(); + } + return JsonParser.parseString(new String(resp.getBody(), StandardCharsets.UTF_8)).getAsJsonObject(); + } + + private String getString(JsonObject jsonObject, String key) { + if (jsonObject == null || !jsonObject.has(key) || jsonObject.get(key).isJsonNull()) { + return ""; + } + return jsonObject.get(key).getAsString(); + } + + private int getInt(JsonObject jsonObject, String key) { + if (jsonObject == null || !jsonObject.has(key) || jsonObject.get(key).isJsonNull()) { + return 0; + } + return jsonObject.get(key).getAsInt(); + } + + private int getErrorCode(RawResponse resp, JsonObject jsonObject) { + if (jsonObject != null && jsonObject.has("code") && !jsonObject.get("code").isJsonNull()) { + return jsonObject.get("code").getAsInt(); + } + if (resp.getStatusCode() != 0) { + return resp.getStatusCode(); + } + return Constants.ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED; + } + + private String getErrorMessage(JsonObject jsonObject) { + String description = getString(jsonObject, "error_description"); + if (Strings.isNotEmpty(description)) { + return description; + } + String msg = getString(jsonObject, "msg"); + if (Strings.isNotEmpty(msg)) { + return msg; + } + String error = getString(jsonObject, "error"); + if (Strings.isNotEmpty(error)) { + return error; + } + return "obtain tenant access token by client assertion failure"; + } + // get internal tenant access token private TenantAccessTokenResp getInternalTenantAccessToken(Config config) throws Exception { SelfBuiltAppAccessTokenReq internalAccessTokenReq = new SelfBuiltAppAccessTokenReq(); diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/ws/Client.java b/larksuite-oapi/src/main/java/com/lark/oapi/ws/Client.java index 0b59ccdc8..fdbde6c03 100644 --- a/larksuite-oapi/src/main/java/com/lark/oapi/ws/Client.java +++ b/larksuite-oapi/src/main/java/com/lark/oapi/ws/Client.java @@ -3,9 +3,15 @@ import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import com.lark.oapi.google.protobuf.ByteString; +import com.lark.oapi.core.Constants; import com.lark.oapi.core.UserAgent; +import com.lark.oapi.core.auth.ClientAssertionProvider; +import com.lark.oapi.core.auth.ClientAssertionToken; +import com.lark.oapi.core.auth.ClientAssertionUtils; +import com.lark.oapi.core.auth.TargetInfo; import com.lark.oapi.core.enums.BaseUrlEnum; import com.lark.oapi.core.utils.Jsons; +import com.lark.oapi.core.utils.Strings; import com.lark.oapi.event.EventDispatcher; import com.lark.oapi.event.exception.HandlerNotFoundException; import com.lark.oapi.okhttp.*; @@ -15,6 +21,7 @@ import com.lark.oapi.ws.exception.HeaderNotFoundException; import com.lark.oapi.ws.exception.ServerException; import com.lark.oapi.ws.exception.ServerUnreachableException; +import com.lark.oapi.ws.model.BootstrapRequest; import com.lark.oapi.ws.model.ClientConfig; import com.lark.oapi.ws.model.Endpoint; import com.lark.oapi.ws.model.EndpointResp; @@ -51,6 +58,7 @@ public class Client { private final String domain; private final String userAgent; private final Map headers; + private final ClientAssertionProvider clientAssertionProvider; private String serviceId; private String connId; private Integer reconnectNonce; @@ -73,6 +81,7 @@ private Client(Builder builder) { this.autoReconnect = builder.autoReconnect != null ? builder.autoReconnect : true; this.domain = builder.domain != null ? builder.domain : BaseUrlEnum.FeiShu.getUrl(); this.userAgent = UserAgent.build(builder.source); + this.clientAssertionProvider = builder.clientAssertionProvider; this.headers = new HashMap<>(); if (builder.headers != null) { this.headers.putAll(builder.headers); @@ -306,22 +315,27 @@ protected boolean shouldReconnect() { return this.autoReconnect && !this.userClosed; } - private String getConnUrl() throws IOException { - String body = String.format("{\"AppID\": \"%s\", \"AppSecret\": \"%s\"}", this.appId, this.appSecret); + private String getConnUrl() throws Exception { + BootstrapPreparedRequest preparedRequest = prepareBootstrapRequest(); + String body = Jsons.DEFAULT.toJson(preparedRequest.body); Request.Builder requestBuilder = new Request.Builder() - .url(this.domain + GEN_ENDPOINT_URI); + .url(preparedRequest.reqUrl); applyHeaders(requestBuilder); + if (Strings.isNotEmpty(preparedRequest.targetServiceHeader)) { + requestBuilder.header(Constants.HEADER_X_TARGET_SERVICE, preparedRequest.targetServiceHeader); + } Request request = requestBuilder .header("locale", "zh") .header("User-Agent", resolvedUserAgent()) .post(RequestBody.create(MediaType.parse("application/json; charset=utf-8"), body)) .build(); try (Response response = this.httpClient.newCall(request).execute()) { + String responseBody = response.body() == null ? "" : response.body().string(); if (response.code() != 200 || response.body() == null) { - throw new ServerException(response.code(), "system busy"); + throw new ServerException(response.code(), bestEndpointMessage(responseBody, "system busy")); } - EndpointResp resp = Jsons.DEFAULT.fromJson(response.body().string(), EndpointResp.class); + EndpointResp resp = Jsons.DEFAULT.fromJson(responseBody, EndpointResp.class); if (resp.getCode() == SYSTEM_BUSY) { throw new ServerException(resp.getCode(), "system busy"); } else if (resp.getCode() == INTERNAL_ERROR) { @@ -339,7 +353,56 @@ private String getConnUrl() throws IOException { } } - private synchronized void connect() throws IOException { + private BootstrapPreparedRequest prepareBootstrapRequest() throws Exception { + BootstrapRequest body = new BootstrapRequest(); + body.setAppId(this.appId); + body.setAppSecret(""); + body.setClientAssertion(""); + String reqUrl = this.domain + GEN_ENDPOINT_URI; + String targetServiceHeader = ""; + + if (this.clientAssertionProvider == null) { + if (Strings.isEmpty(this.appSecret)) { + throw new ClientException(Constants.ERR_CODE_APP_SECRET_AND_CLIENT_ASSERTION_EMPTY, + "appSecret and clientAssertionProvider cannot both be empty"); + } + body.setAppSecret(this.appSecret); + return new BootstrapPreparedRequest(body, reqUrl, targetServiceHeader); + } + + String aud = ClientAssertionUtils.extractAudFromUrl(this.domain); + ClientAssertionToken token = this.clientAssertionProvider.retrieveToken(aud); + if (token == null || Strings.isEmpty(token.getValue())) { + throw new ClientException(Constants.ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY, + "client assertion token is empty"); + } + body.setClientAssertion(token.getValue()); + TargetInfo targetInfo = token.getTargetInfo(); + if (targetInfo != null && Strings.isNotEmpty(targetInfo.getTargetService())) { + reqUrl = ClientAssertionUtils.buildProxyUrl( + targetInfo.getTargetService(), + targetInfo.getTargetPrefix(), + GEN_ENDPOINT_URI); + targetServiceHeader = aud; + } + return new BootstrapPreparedRequest(body, reqUrl, targetServiceHeader); + } + + private String bestEndpointMessage(String responseBody, String fallback) { + if (Strings.isEmpty(responseBody)) { + return fallback; + } + try { + EndpointResp resp = Jsons.DEFAULT.fromJson(responseBody, EndpointResp.class); + if (resp != null && Strings.isNotEmpty(resp.getMsg())) { + return resp.getMsg(); + } + } catch (Throwable ignored) { + } + return fallback; + } + + private synchronized void connect() throws Exception { if (this.conn != null) { return; } @@ -576,6 +639,7 @@ public static class Builder { private String source; private Runnable onReconnecting; private Runnable onReconnected; + private ClientAssertionProvider clientAssertionProvider; public Builder(String appId, String appSecret) { this.appId = appId; @@ -610,6 +674,11 @@ public Builder header(String key, String value) { return this; } + public Builder clientAssertionProvider(ClientAssertionProvider provider) { + this.clientAssertionProvider = provider; + return this; + } + public Builder source(String source) { this.source = source; return this; @@ -629,4 +698,16 @@ public Client build() { return new Client(this); } } + + private static class BootstrapPreparedRequest { + private final BootstrapRequest body; + private final String reqUrl; + private final String targetServiceHeader; + + private BootstrapPreparedRequest(BootstrapRequest body, String reqUrl, String targetServiceHeader) { + this.body = body; + this.reqUrl = reqUrl; + this.targetServiceHeader = targetServiceHeader; + } + } } diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/ws/model/BootstrapRequest.java b/larksuite-oapi/src/main/java/com/lark/oapi/ws/model/BootstrapRequest.java new file mode 100644 index 000000000..4e415ffa5 --- /dev/null +++ b/larksuite-oapi/src/main/java/com/lark/oapi/ws/model/BootstrapRequest.java @@ -0,0 +1,36 @@ +package com.lark.oapi.ws.model; + +import com.google.gson.annotations.SerializedName; + +public class BootstrapRequest { + @SerializedName("AppID") + private String appId; + @SerializedName("AppSecret") + private String appSecret; + @SerializedName("ClientAssertion") + private String clientAssertion; + + public String getAppId() { + return appId; + } + + public void setAppId(String appId) { + this.appId = appId; + } + + public String getAppSecret() { + return appSecret; + } + + public void setAppSecret(String appSecret) { + this.appSecret = appSecret; + } + + public String getClientAssertion() { + return clientAssertion; + } + + public void setClientAssertion(String clientAssertion) { + this.clientAssertion = clientAssertion; + } +} diff --git a/larksuite-oapi/src/test/java/com/lark/oapi/TestClientAssertionClientBuilder.java b/larksuite-oapi/src/test/java/com/lark/oapi/TestClientAssertionClientBuilder.java new file mode 100644 index 000000000..c62dc83a6 --- /dev/null +++ b/larksuite-oapi/src/test/java/com/lark/oapi/TestClientAssertionClientBuilder.java @@ -0,0 +1,22 @@ +package com.lark.oapi; + +import com.lark.oapi.core.auth.ClientAssertionProvider; +import com.lark.oapi.core.auth.ClientAssertionToken; +import org.junit.Test; + +import static org.junit.Assert.assertNotNull; + +public class TestClientAssertionClientBuilder { + + @Test + public void builderExposesClientAssertionOptionsAndAccessTokenService() { + ClientAssertionProvider provider = aud -> new ClientAssertionToken("assertion"); + + Client client = Client.newBuilder("cli_a", "") + .oauthBaseUrl("https://accounts.feishu.cn") + .clientAssertionProvider(provider) + .build(); + + assertNotNull(client.accessToken()); + } +} diff --git a/larksuite-oapi/src/test/java/com/lark/oapi/channel/TestClientAssertionChannelFactory.java b/larksuite-oapi/src/test/java/com/lark/oapi/channel/TestClientAssertionChannelFactory.java new file mode 100644 index 000000000..91feaf395 --- /dev/null +++ b/larksuite-oapi/src/test/java/com/lark/oapi/channel/TestClientAssertionChannelFactory.java @@ -0,0 +1,184 @@ +package com.lark.oapi.channel; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.lark.oapi.Client; +import com.lark.oapi.channel.config.LarkChannelOptions; +import com.lark.oapi.core.auth.ClientAssertionProvider; +import com.lark.oapi.core.auth.ClientAssertionToken; +import com.lark.oapi.core.cache.ICache; +import com.lark.oapi.core.httpclient.IHttpTransport; +import com.lark.oapi.core.request.RawRequest; +import com.lark.oapi.core.response.RawResponse; +import com.lark.oapi.core.token.AccessTokenType; +import com.lark.oapi.event.EventDispatcher; +import com.sun.net.httpserver.HttpServer; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.Method; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class TestClientAssertionChannelFactory { + + @Test + public void optionsExposeClientAssertionProviderAndOAuthBaseUrl() { + ClientAssertionProvider provider = aud -> new ClientAssertionToken("assertion"); + + LarkChannelOptions options = LarkChannelOptions.newBuilder("cli_a", "") + .clientAssertionProvider(provider) + .oauthBaseUrl("https://accounts.feishu.cn") + .domain("https://open.feishu.cn") + .build(); + + assertEquals(provider, options.getClientAssertionProvider()); + assertEquals("https://accounts.feishu.cn", options.getOAuthBaseUrl()); + } + + @Test + public void rawClientReceivesClientAssertionOptions() throws Exception { + CapturingTransport transport = new CapturingTransport(); + CapturingCache cache = new CapturingCache(); + AtomicReference audRef = new AtomicReference<>(); + LarkChannelOptions options = LarkChannelOptions.newBuilder("cli_a", "") + .clientAssertionProvider(aud -> { + audRef.set(aud); + return new ClientAssertionToken("client-assertion"); + }) + .oauthBaseUrl("http://accounts.local:8080") + .domain("https://open.feishu.cn") + .httpTransport(transport) + .cache(cache) + .build(); + + Client rawClient = ChannelClientFactory.createRawClient(options); + + rawClient.get("/open-apis/test", null, AccessTokenType.Tenant); + + assertEquals("accounts.local:8080", audRef.get()); + assertEquals("http://accounts.local:8080/oauth/v3/token", transport.oauthRequest.getReqUrl()); + assertEquals("Bearer tenant-token", transport.apiRequest.getHeaders().get("Authorization").get(0)); + } + + @Test + public void websocketClientReceivesClientAssertionProvider() throws Exception { + CapturingServer server = CapturingServer.start(); + try { + LarkChannelOptions options = LarkChannelOptions.newBuilder("cli_a", "") + .clientAssertionProvider(aud -> new ClientAssertionToken("client-assertion")) + .domain(server.domain()) + .build(); + + com.lark.oapi.ws.Client wsClient = ChannelClientFactory.createWebSocketClient( + options, + new EventDispatcher.Builder("", "").build(), + new ChannelEventBus()); + + assertNotNull(wsClient); + assertEquals("wss://example.test/callback?device_id=device&service_id=42", invokeGetConnUrl(wsClient)); + JsonObject body = server.requestBody(); + assertEquals("cli_a", body.get("AppID").getAsString()); + assertEquals("", body.get("AppSecret").getAsString()); + assertEquals("client-assertion", body.get("ClientAssertion").getAsString()); + } finally { + server.stop(); + } + } + + private static String invokeGetConnUrl(com.lark.oapi.ws.Client client) throws Exception { + Method method = com.lark.oapi.ws.Client.class.getDeclaredMethod("getConnUrl"); + method.setAccessible(true); + try { + return (String) method.invoke(client); + } catch (ReflectiveOperationException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Exception) { + throw (Exception) cause; + } + throw e; + } + } + + private static class CapturingTransport implements IHttpTransport { + private RawRequest oauthRequest; + private RawRequest apiRequest; + + @Override + public RawResponse execute(RawRequest request) { + RawResponse response = new RawResponse(); + response.setStatusCode(200); + if (request.getReqUrl().contains("/oauth/v3/token")) { + this.oauthRequest = request; + response.setBody("{\"access_token\":\"tenant-token\",\"expires_in\":7200}".getBytes(StandardCharsets.UTF_8)); + } else { + this.apiRequest = request; + response.setBody("{\"code\":0}".getBytes(StandardCharsets.UTF_8)); + } + return response; + } + } + + private static class CapturingCache implements ICache { + @Override + public String get(String key) { + return ""; + } + + @Override + public void set(String key, String value, int expire, TimeUnit timeUnit) { + } + } + + private static class CapturingServer { + private final HttpServer server; + private final AtomicReference body = new AtomicReference<>(); + + private CapturingServer(HttpServer server) { + this.server = server; + } + + private static CapturingServer start() throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + CapturingServer capturingServer = new CapturingServer(server); + server.createContext(com.lark.oapi.ws.Constant.GEN_ENDPOINT_URI, exchange -> { + ByteArrayOutputStream requestBuffer = new ByteArrayOutputStream(); + byte[] requestChunk = new byte[1024]; + int read; + while ((read = exchange.getRequestBody().read(requestChunk)) != -1) { + requestBuffer.write(requestChunk, 0, read); + } + capturingServer.body.set(new String(requestBuffer.toByteArray(), StandardCharsets.UTF_8)); + byte[] response = "{\"code\":0,\"data\":{\"URL\":\"wss://example.test/callback?device_id=device&service_id=42\"}}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, response.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response); + } + }); + server.start(); + return capturingServer; + } + + private String domain() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + private JsonObject requestBody() { + return JsonParser.parseString(body.get()).getAsJsonObject(); + } + + private void stop() { + server.stop(0); + } + } +} diff --git a/larksuite-oapi/src/test/java/com/lark/oapi/core/TestTransportClientAssertion.java b/larksuite-oapi/src/test/java/com/lark/oapi/core/TestTransportClientAssertion.java new file mode 100644 index 000000000..e218a9fa1 --- /dev/null +++ b/larksuite-oapi/src/test/java/com/lark/oapi/core/TestTransportClientAssertion.java @@ -0,0 +1,203 @@ +package com.lark.oapi.core; + +import com.lark.oapi.core.auth.ClientAssertionToken; +import com.lark.oapi.core.cache.ICache; +import com.lark.oapi.core.enums.AppType; +import com.lark.oapi.core.exception.ClientAssertionException; +import com.lark.oapi.core.httpclient.IHttpTransport; +import com.lark.oapi.core.request.RawRequest; +import com.lark.oapi.core.request.RequestOptions; +import com.lark.oapi.core.response.RawResponse; +import com.lark.oapi.core.token.AccessTokenType; +import com.lark.oapi.core.token.GlobalTokenManager; +import com.lark.oapi.core.token.TokenManager; +import com.lark.oapi.core.utils.Sets; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class TestTransportClientAssertion { + + @Test + public void clientAssertionRejectsAppOnlyApi() throws Exception { + Config config = config(); + + try { + Transport.send(config, null, "GET", "/resource", Sets.newHashSet(AccessTokenType.App), null); + } catch (ClientAssertionException e) { + assertEquals(Constants.ERR_CODE_CLIENT_ASSERTION_MODE_NOT_SUPPORTED, e.getCode()); + return; + } + throw new AssertionError("expected ClientAssertionException"); + } + + @Test + public void clientAssertionAllowsEmptyAppSecretForNoneTokenRequests() throws Exception { + CapturingTransport transport = new CapturingTransport(); + Config config = config(); + config.setHttpTransport(transport); + + RawResponse response = Transport.send(config, null, "POST", "/oauth/v3/token", Sets.newHashSet(AccessTokenType.None), new Object()); + + assertEquals(200, response.getStatusCode()); + assertNull(transport.lastRequest.getHeaders().get("Authorization")); + } + + @Test + public void marketplaceClientAssertionModeFails() throws Exception { + Config config = config(); + config.setAppType(AppType.MARKETPLACE); + + try { + Transport.send(config, null, "GET", "/resource", Sets.newHashSet(AccessTokenType.Tenant), null); + } catch (ClientAssertionException e) { + assertEquals(Constants.ERR_CODE_CLIENT_ASSERTION_PROVIDER_NOT_CONFIGURED, e.getCode()); + return; + } + throw new AssertionError("expected ClientAssertionException"); + } + + @Test + public void manualUserTokenWinsOverTenantInClientAssertionMode() throws Exception { + CapturingTransport transport = new CapturingTransport(); + Config config = config(); + config.setHttpTransport(transport); + AtomicInteger providerCalls = new AtomicInteger(); + config.setClientAssertionProvider(aud -> { + providerCalls.incrementAndGet(); + return new ClientAssertionToken("assertion"); + }); + RequestOptions options = RequestOptions.newBuilder() + .userAccessToken("user-token") + .build(); + + Transport.send(config, options, "GET", "/resource", Sets.newHashSet(AccessTokenType.User, AccessTokenType.Tenant), null); + + assertEquals("Bearer user-token", transport.lastRequest.getHeaders().get("Authorization").get(0)); + assertEquals(0, providerCalls.get()); + } + + @Test + public void emptyAppSecretAllowedWithMatchingManualTenantToken() throws Exception { + CapturingTransport transport = new CapturingTransport(); + Config config = config(); + config.setClientAssertionProvider(null); + config.setDisableTokenCache(true); + config.setHttpTransport(transport); + RequestOptions options = RequestOptions.newBuilder() + .tenantAccessToken("tenant-token") + .build(); + + Transport.send(config, options, "GET", "/resource", Sets.newHashSet(AccessTokenType.Tenant), null); + + assertEquals("Bearer tenant-token", transport.lastRequest.getHeaders().get("Authorization").get(0)); + } + + @Test + public void providerRetrieveFailureRetriesOnce() throws Exception { + TokenManager previous = GlobalTokenManager.getTokenManager(); + try { + CapturingTransport transport = new CapturingTransport(); + Config config = config(); + config.setHttpTransport(transport); + FlakyTokenManager tokenManager = new FlakyTokenManager(); + GlobalTokenManager.setTokenManager(tokenManager); + + Transport.send(config, null, "GET", "/resource", Sets.newHashSet(AccessTokenType.Tenant), null); + + assertEquals(2, tokenManager.calls.get()); + assertEquals("Bearer tenant-token", transport.lastRequest.getHeaders().get("Authorization").get(0)); + } finally { + GlobalTokenManager.setTokenManager(previous); + } + } + + @Test + public void emptyAssertionFailureDoesNotRetry() throws Exception { + TokenManager previous = GlobalTokenManager.getTokenManager(); + try { + Config config = config(); + EmptyAssertionTokenManager tokenManager = new EmptyAssertionTokenManager(); + GlobalTokenManager.setTokenManager(tokenManager); + + try { + Transport.send(config, null, "GET", "/resource", Sets.newHashSet(AccessTokenType.Tenant), null); + } catch (ClientAssertionException e) { + assertEquals(Constants.ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY, e.getCode()); + assertEquals(1, tokenManager.calls.get()); + return; + } + throw new AssertionError("expected ClientAssertionException"); + } finally { + GlobalTokenManager.setTokenManager(previous); + } + } + + private Config config() { + Config config = new Config(); + config.setAppId("cli_a"); + config.setAppSecret(""); + config.setClientAssertionProvider(aud -> new ClientAssertionToken("assertion")); + config.setHttpTransport(new CapturingTransport()); + return config; + } + + private static class CapturingTransport implements IHttpTransport { + private RawRequest lastRequest; + + @Override + public RawResponse execute(RawRequest request) { + this.lastRequest = request; + RawResponse response = new RawResponse(); + response.setStatusCode(200); + response.setBody("{\"code\":0}".getBytes(StandardCharsets.UTF_8)); + return response; + } + } + + private static class FlakyTokenManager extends TokenManager { + private final AtomicInteger calls = new AtomicInteger(); + + private FlakyTokenManager() { + super(new NoopCache()); + } + + @Override + public String getTenantAccessToken(Config config, String tenantKey) { + if (calls.incrementAndGet() == 1) { + throw new ClientAssertionException(Constants.ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED, "kms down"); + } + return "tenant-token"; + } + } + + private static class EmptyAssertionTokenManager extends TokenManager { + private final AtomicInteger calls = new AtomicInteger(); + + private EmptyAssertionTokenManager() { + super(new NoopCache()); + } + + @Override + public String getTenantAccessToken(Config config, String tenantKey) { + calls.incrementAndGet(); + throw new ClientAssertionException(Constants.ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY, "client assertion token is empty"); + } + } + + private static class NoopCache implements ICache { + @Override + public String get(String key) { + return ""; + } + + @Override + public void set(String key, String value, int expire, TimeUnit timeUnit) { + } + } +} diff --git a/larksuite-oapi/src/test/java/com/lark/oapi/core/accesstoken/TestAccessToken.java b/larksuite-oapi/src/test/java/com/lark/oapi/core/accesstoken/TestAccessToken.java new file mode 100644 index 000000000..9b30d0b34 --- /dev/null +++ b/larksuite-oapi/src/test/java/com/lark/oapi/core/accesstoken/TestAccessToken.java @@ -0,0 +1,219 @@ +package com.lark.oapi.core.accesstoken; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.lark.oapi.core.Config; +import com.lark.oapi.core.Constants; +import com.lark.oapi.core.auth.ClientAssertionToken; +import com.lark.oapi.core.auth.TargetInfo; +import com.lark.oapi.core.exception.ClientAssertionException; +import com.lark.oapi.core.httpclient.IHttpTransport; +import com.lark.oapi.core.request.RawRequest; +import com.lark.oapi.core.response.RawResponse; +import com.lark.oapi.core.utils.Jsons; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class TestAccessToken { + + @Test + public void authorizationCodeProviderRequestUsesJwtBearerFields() throws Exception { + CapturingTransport transport = new CapturingTransport(successBody(), 200); + AtomicReference audRef = new AtomicReference<>(); + Config config = providerConfig(transport); + config.setClientAssertionProvider(aud -> { + audRef.set(aud); + return new ClientAssertionToken("client-assertion"); + }); + + AccessTokenResp resp = new AccessToken(config).retrieveByAuthorizationCode( + AuthorizationCodeTokenRequest.newBuilder() + .code("auth-code") + .redirectUri("https://example.com/callback") + .codeVerifier("verifier") + .scope("contact:user.base:readonly") + .build()); + + assertEquals("oauth-access-token", resp.getData().getAccessToken()); + assertEquals("accounts.feishu.cn", audRef.get()); + assertEquals("https://accounts.feishu.cn/oauth/v3/token", transport.request.getReqUrl()); + JsonObject body = requestBody(transport); + assertEquals(Constants.GRANT_TYPE_AUTHORIZATION_CODE, body.get("grant_type").getAsString()); + assertEquals("cli_a", body.get("client_id").getAsString()); + assertEquals(Constants.CLIENT_ASSERTION_TYPE_JWT_BEARER, body.get("client_assertion_type").getAsString()); + assertEquals("client-assertion", body.get("client_assertion").getAsString()); + assertEquals("auth-code", body.get("code").getAsString()); + assertEquals("https://example.com/callback", body.get("redirect_uri").getAsString()); + assertEquals("verifier", body.get("code_verifier").getAsString()); + assertEquals("contact:user.base:readonly", body.get("scope").getAsString()); + assertFalse(body.has("client_secret")); + } + + @Test + public void refreshProviderRequestUsesRefreshTokenGrant() throws Exception { + CapturingTransport transport = new CapturingTransport(successBody(), 200); + Config config = providerConfig(transport); + config.setClientAssertionProvider(aud -> new ClientAssertionToken("client-assertion")); + + new AccessToken(config).refresh( + RefreshTokenRequest.newBuilder() + .refreshToken("refresh-token") + .scope("contact:user.base:readonly") + .build()); + + JsonObject body = requestBody(transport); + assertEquals(Constants.GRANT_TYPE_REFRESH_TOKEN, body.get("grant_type").getAsString()); + assertEquals("refresh-token", body.get("refresh_token").getAsString()); + assertEquals("contact:user.base:readonly", body.get("scope").getAsString()); + assertFalse(body.has("code")); + } + + @Test + public void appSecretFallbackUsesClientSecret() throws Exception { + CapturingTransport transport = new CapturingTransport(successBody(), 200); + Config config = new Config(); + config.setAppId("cli_a"); + config.setAppSecret("app-secret"); + config.setHttpTransport(transport); + + new AccessToken(config).refresh( + RefreshTokenRequest.newBuilder() + .refreshToken("refresh-token") + .build()); + + JsonObject body = requestBody(transport); + assertEquals("app-secret", body.get("client_secret").getAsString()); + assertFalse(body.has("client_assertion")); + assertFalse(body.has("client_assertion_type")); + } + + @Test + public void missingCredentialsFailWith7104() throws Exception { + Config config = new Config(); + config.setAppId("cli_a"); + config.setAppSecret(""); + config.setHttpTransport(new CapturingTransport(successBody(), 200)); + + try { + new AccessToken(config).refresh(RefreshTokenRequest.newBuilder().refreshToken("refresh-token").build()); + } catch (ClientAssertionException e) { + assertEquals(Constants.ERR_CODE_APP_SECRET_AND_CLIENT_ASSERTION_EMPTY, e.getCode()); + return; + } + throw new AssertionError("expected ClientAssertionException"); + } + + @Test + public void targetInfoUsesProxyAndTargetServiceHeader() throws Exception { + CapturingTransport transport = new CapturingTransport(successBody(), 200); + Config config = providerConfig(transport); + config.setClientAssertionProvider(aud -> new ClientAssertionToken( + "client-assertion", + new TargetInfo("proxy.example.com", "/proxy"))); + + new AccessToken(config).refresh(RefreshTokenRequest.newBuilder().refreshToken("refresh-token").build()); + + assertEquals("https://proxy.example.com/proxy/oauth/v3/token", transport.request.getReqUrl()); + assertEquals("accounts.feishu.cn", transport.request.getHeaders().get(Constants.HEADER_X_TARGET_SERVICE).get(0)); + } + + @Test + public void targetInfoProviderIsCalledOnce() throws Exception { + CapturingTransport transport = new CapturingTransport(successBody(), 200); + Config config = providerConfig(transport); + AtomicInteger calls = new AtomicInteger(); + config.setClientAssertionProvider(aud -> { + calls.incrementAndGet(); + return new ClientAssertionToken("client-assertion", new TargetInfo("proxy.example.com", "/proxy")); + }); + + new AccessToken(config).refresh(RefreshTokenRequest.newBuilder().refreshToken("refresh-token").build()); + + assertEquals(1, calls.get()); + } + + @Test + public void non200OAuthErrorThrowsAccessTokenError() throws Exception { + CapturingTransport transport = new CapturingTransport( + "{\"code\":400,\"error\":\"invalid_client\",\"error_description\":\"bad assertion\"}", + 400); + Config config = providerConfig(transport); + config.setClientAssertionProvider(aud -> new ClientAssertionToken("client-assertion")); + + try { + new AccessToken(config).refresh(RefreshTokenRequest.newBuilder().refreshToken("refresh-token").build()); + } catch (AccessTokenError e) { + assertEquals(400, e.getStatusCode()); + assertEquals(400, e.getCode()); + assertEquals("invalid_client", e.getErrorType()); + assertEquals("bad assertion", e.getErrorDescription()); + assertTrue(e.getMessage().contains("bad assertion")); + return; + } + throw new AssertionError("expected AccessTokenError"); + } + + @Test + public void successResponseMapsAllTokenFields() throws Exception { + CapturingTransport transport = new CapturingTransport(successBody(), 200); + Config config = providerConfig(transport); + config.setClientAssertionProvider(aud -> new ClientAssertionToken("client-assertion")); + + AccessTokenResp resp = new AccessToken(config).refresh( + RefreshTokenRequest.newBuilder() + .refreshToken("refresh-token") + .build()); + + assertEquals(200, resp.getStatusCode()); + assertEquals("oauth-access-token", resp.getData().getAccessToken()); + assertEquals("Bearer", resp.getData().getTokenType()); + assertEquals(7200, resp.getData().getExpiresIn()); + assertEquals("new-refresh-token", resp.getData().getRefreshToken()); + assertEquals(604800, resp.getData().getRefreshTokenExpiresIn()); + assertEquals("contact:user.base:readonly", resp.getData().getScope()); + } + + private Config providerConfig(IHttpTransport transport) { + Config config = new Config(); + config.setAppId("cli_a"); + config.setAppSecret(""); + config.setBaseUrl("https://open.feishu.cn"); + config.setHttpTransport(transport); + return config; + } + + private JsonObject requestBody(CapturingTransport transport) { + return JsonParser.parseString(Jsons.DEFAULT.toJson(transport.request.getBody())).getAsJsonObject(); + } + + private String successBody() { + return "{\"access_token\":\"oauth-access-token\",\"token_type\":\"Bearer\",\"expires_in\":7200,\"refresh_token\":\"new-refresh-token\",\"refresh_token_expires_in\":604800,\"scope\":\"contact:user.base:readonly\"}"; + } + + private static class CapturingTransport implements IHttpTransport { + private final String body; + private final int statusCode; + private RawRequest request; + + private CapturingTransport(String body, int statusCode) { + this.body = body; + this.statusCode = statusCode; + } + + @Override + public RawResponse execute(RawRequest request) { + this.request = request; + RawResponse response = new RawResponse(); + response.setStatusCode(statusCode); + response.setBody(body.getBytes(StandardCharsets.UTF_8)); + return response; + } + } +} diff --git a/larksuite-oapi/src/test/java/com/lark/oapi/core/auth/TestClientAssertionUtils.java b/larksuite-oapi/src/test/java/com/lark/oapi/core/auth/TestClientAssertionUtils.java new file mode 100644 index 000000000..a38036369 --- /dev/null +++ b/larksuite-oapi/src/test/java/com/lark/oapi/core/auth/TestClientAssertionUtils.java @@ -0,0 +1,68 @@ +package com.lark.oapi.core.auth; + +import com.lark.oapi.core.Config; +import com.lark.oapi.core.Constants; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class TestClientAssertionUtils { + + @Test + public void defaultFeishuOpenApiHostMapsToFeishuOAuthHost() { + Config config = new Config(); + config.setBaseUrl("https://open.feishu.cn"); + + assertEquals("https://accounts.feishu.cn", ClientAssertionUtils.resolveOAuthBaseUrl(config)); + assertEquals("accounts.feishu.cn", ClientAssertionUtils.resolveOAuthAud(config)); + } + + @Test + public void defaultLarkOpenApiHostMapsToLarkOAuthHost() { + Config config = new Config(); + config.setBaseUrl("https://open.larksuite.com"); + + assertEquals("https://accounts.larksuite.com", ClientAssertionUtils.resolveOAuthBaseUrl(config)); + assertEquals("accounts.larksuite.com", ClientAssertionUtils.resolveOAuthAud(config)); + } + + @Test + public void explicitOAuthBaseUrlIsNormalizedAndUsedAsAudience() { + Config config = new Config(); + config.setBaseUrl("https://custom.example.com"); + config.setOAuthBaseUrl("accounts.example.com/"); + + assertEquals("https://accounts.example.com", ClientAssertionUtils.resolveOAuthBaseUrl(config)); + assertEquals("accounts.example.com", ClientAssertionUtils.resolveOAuthAud(config)); + } + + @Test + public void customOpenApiHostWithoutOAuthBaseUrlFails() { + Config config = new Config(); + config.setBaseUrl("https://open.feishu-boe.cn"); + + try { + ClientAssertionUtils.resolveOAuthBaseUrl(config); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("OAuthBaseUrl is not configured")); + return; + } + throw new AssertionError("expected IllegalArgumentException"); + } + + @Test + public void audiencePreservesPort() { + Config config = new Config(); + config.setOAuthBaseUrl("http://127.0.0.1:8080"); + + assertEquals("127.0.0.1:8080", ClientAssertionUtils.resolveOAuthAud(config)); + } + + @Test + public void proxyUrlAddsSchemeWhenMissing() { + assertEquals( + "https://proxy.example.com/proxy/oauth/v3/token", + ClientAssertionUtils.buildProxyUrl("proxy.example.com", "/proxy", Constants.OAUTH_TOKEN_URL_PATH)); + } +} diff --git a/larksuite-oapi/src/test/java/com/lark/oapi/core/token/TestClientAssertionTokenManager.java b/larksuite-oapi/src/test/java/com/lark/oapi/core/token/TestClientAssertionTokenManager.java new file mode 100644 index 000000000..41bc3d27a --- /dev/null +++ b/larksuite-oapi/src/test/java/com/lark/oapi/core/token/TestClientAssertionTokenManager.java @@ -0,0 +1,216 @@ +package com.lark.oapi.core.token; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.lark.oapi.core.Config; +import com.lark.oapi.core.Constants; +import com.lark.oapi.core.auth.ClientAssertionToken; +import com.lark.oapi.core.auth.TargetInfo; +import com.lark.oapi.core.cache.ICache; +import com.lark.oapi.core.exception.ClientAssertionException; +import com.lark.oapi.core.httpclient.IHttpTransport; +import com.lark.oapi.core.request.RawRequest; +import com.lark.oapi.core.response.RawResponse; +import com.lark.oapi.core.utils.Jsons; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class TestClientAssertionTokenManager { + + @Test + public void tenantTokenUsesOAuthJwtBearerRequestAndCachesToken() throws Exception { + CapturingCache cache = new CapturingCache(); + CapturingTransport transport = new CapturingTransport("{\"access_token\":\"tenant-token\",\"expires_in\":7200}"); + AtomicReference audRef = new AtomicReference<>(); + Config config = config(transport); + config.setClientAssertionProvider(aud -> { + audRef.set(aud); + return new ClientAssertionToken("client-assertion"); + }); + + String token = new TokenManager(cache).getTenantAccessToken(config, ""); + + assertEquals("tenant-token", token); + assertEquals("accounts.feishu.cn", audRef.get()); + assertEquals("https://accounts.feishu.cn/oauth/v3/token", transport.request.getReqUrl()); + JsonObject body = JsonParser.parseString(Jsons.DEFAULT.toJson(transport.request.getBody())).getAsJsonObject(); + assertEquals(Constants.GRANT_TYPE_JWT_BEARER, body.get("grant_type").getAsString()); + assertEquals(Constants.CLIENT_ASSERTION_TYPE_JWT_BEARER, body.get("client_assertion_type").getAsString()); + assertEquals("client-assertion", body.get("client_assertion").getAsString()); + assertEquals("cli_a", body.get("client_id").getAsString()); + assertEquals("tenant_access_token-cli_a-", cache.key); + assertEquals("tenant-token", cache.value); + assertEquals(7020, cache.expire); + } + + @Test + public void targetInfoUsesProxyUrlAndTargetServiceHeader() throws Exception { + CapturingTransport transport = new CapturingTransport("{\"access_token\":\"tenant-token\",\"expires_in\":7200}"); + Config config = config(transport); + config.setClientAssertionProvider(aud -> new ClientAssertionToken( + "client-assertion", + new TargetInfo("proxy.example.com", "/proxy"))); + + new TokenManager(new CapturingCache()).getTenantAccessToken(config, ""); + + assertEquals("https://proxy.example.com/proxy/oauth/v3/token", transport.request.getReqUrl()); + assertEquals("accounts.feishu.cn", transport.request.getHeaders().get(Constants.HEADER_X_TARGET_SERVICE).get(0)); + } + + @Test + public void cacheHitAvoidsProviderAndTransport() throws Exception { + CapturingCache cache = new CapturingCache(); + cache.cached = "tenant-token"; + CapturingTransport transport = new CapturingTransport("{}"); + AtomicInteger calls = new AtomicInteger(); + Config config = config(transport); + config.setClientAssertionProvider(aud -> { + calls.incrementAndGet(); + return new ClientAssertionToken("client-assertion"); + }); + + String token = new TokenManager(cache).getTenantAccessToken(config, "tenant-key"); + + assertEquals("tenant-token", token); + assertEquals("tenant_access_token-cli_a-tenant-key", cache.getKey); + assertEquals(0, calls.get()); + assertNull(transport.request); + } + + @Test + public void disableTokenCacheSkipsCacheReadAndWrite() throws Exception { + CapturingCache cache = new CapturingCache(); + CapturingTransport transport = new CapturingTransport("{\"access_token\":\"tenant-token\",\"expires_in\":7200}"); + Config config = config(transport); + config.setDisableTokenCache(true); + config.setClientAssertionProvider(aud -> new ClientAssertionToken("client-assertion")); + + String token = new TokenManager(cache).getTenantAccessToken(config, ""); + + assertEquals("tenant-token", token); + assertEquals(0, cache.getCount); + assertEquals(0, cache.setCount); + assertEquals("https://accounts.feishu.cn/oauth/v3/token", transport.request.getReqUrl()); + } + + @Test + public void emptyAssertionFailsWith7101() throws Exception { + Config config = config(new CapturingTransport("{}")); + config.setClientAssertionProvider(aud -> new ClientAssertionToken("")); + + try { + new TokenManager(new CapturingCache()).getTenantAccessToken(config, ""); + } catch (ClientAssertionException e) { + assertEquals(Constants.ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY, e.getCode()); + return; + } + throw new AssertionError("expected ClientAssertionException"); + } + + @Test + public void providerExceptionIsWrappedWith7102() throws Exception { + Config config = config(new CapturingTransport("{}")); + config.setClientAssertionProvider(aud -> { + throw new IllegalStateException("kms down"); + }); + + try { + new TokenManager(new CapturingCache()).getTenantAccessToken(config, ""); + } catch (ClientAssertionException e) { + assertEquals(Constants.ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED, e.getCode()); + assertTrue(e.getMessage().contains("kms down")); + return; + } + throw new AssertionError("expected ClientAssertionException"); + } + + @Test + public void appTokenIsBlockedInClientAssertionMode() throws Exception { + Config config = config(new CapturingTransport("{}")); + config.setClientAssertionProvider(aud -> new ClientAssertionToken("client-assertion")); + + try { + new TokenManager(new CapturingCache()).getAppAccessToken(config); + } catch (ClientAssertionException e) { + assertEquals(Constants.ERR_CODE_CLIENT_ASSERTION_PROVIDER_NOT_CONFIGURED, e.getCode()); + return; + } + throw new AssertionError("expected ClientAssertionException"); + } + + @Test + public void missingAccessTokenUsesServerMessage() throws Exception { + CapturingTransport transport = new CapturingTransport("{\"code\":400,\"error\":\"invalid_client\",\"error_description\":\"bad assertion\"}"); + Config config = config(transport); + config.setClientAssertionProvider(aud -> new ClientAssertionToken("client-assertion")); + + try { + new TokenManager(new CapturingCache()).getTenantAccessToken(config, ""); + } catch (ClientAssertionException e) { + assertEquals(400, e.getCode()); + assertTrue(e.getMessage().contains("bad assertion")); + return; + } + throw new AssertionError("expected ClientAssertionException"); + } + + private Config config(IHttpTransport transport) { + Config config = new Config(); + config.setAppId("cli_a"); + config.setAppSecret(""); + config.setBaseUrl("https://open.feishu.cn"); + config.setHttpTransport(transport); + return config; + } + + private static class CapturingTransport implements IHttpTransport { + private final String body; + private RawRequest request; + + private CapturingTransport(String body) { + this.body = body; + } + + @Override + public RawResponse execute(RawRequest request) { + this.request = request; + RawResponse response = new RawResponse(); + response.setStatusCode(200); + response.setBody(body.getBytes(StandardCharsets.UTF_8)); + return response; + } + } + + private static class CapturingCache implements ICache { + private String cached = ""; + private String getKey; + private String key; + private String value; + private int expire; + private int getCount; + private int setCount; + + @Override + public String get(String key) { + this.getKey = key; + this.getCount++; + return cached; + } + + @Override + public void set(String key, String value, int expire, TimeUnit timeUnit) { + this.key = key; + this.value = value; + this.expire = expire; + this.setCount++; + } + } +} diff --git a/larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLocalE2E.java b/larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLocalE2E.java new file mode 100644 index 000000000..1620b2f21 --- /dev/null +++ b/larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLocalE2E.java @@ -0,0 +1,244 @@ +package com.lark.oapi.e2e; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.lark.oapi.Client; +import com.lark.oapi.core.Constants; +import com.lark.oapi.core.accesstoken.AuthorizationCodeTokenRequest; +import com.lark.oapi.core.accesstoken.RefreshTokenRequest; +import com.lark.oapi.core.auth.ClientAssertionToken; +import com.lark.oapi.core.auth.TargetInfo; +import com.lark.oapi.core.cache.ICache; +import com.lark.oapi.core.response.RawResponse; +import com.lark.oapi.core.token.AccessTokenType; +import com.lark.oapi.ws.Constant; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpServer; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.Method; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; + +public class TestClientAssertionLocalE2E { + + @Test + public void ordinaryOpenApiRequestAutoAuthenticatesWithLocalOAuthServer() throws Exception { + LocalServer server = LocalServer.start(); + AtomicReference audRef = new AtomicReference<>(); + try { + Client client = Client.newBuilder("cli_a", "") + .openBaseUrl(server.domain()) + .oauthBaseUrl(server.domain()) + .clientAssertionProvider(aud -> { + audRef.set(aud); + return new ClientAssertionToken("client-assertion"); + }) + .tokenCache(new NoopCache()) + .build(); + + RawResponse response = client.get("/open-apis/e2e", null, AccessTokenType.Tenant); + + assertEquals(200, response.getStatusCode()); + assertEquals("127.0.0.1:" + server.port(), audRef.get()); + JsonObject oauthBody = server.jsonBody("/oauth/v3/token"); + assertEquals(Constants.GRANT_TYPE_JWT_BEARER, oauthBody.get("grant_type").getAsString()); + assertEquals("client-assertion", oauthBody.get("client_assertion").getAsString()); + assertEquals("Bearer tenant-token", server.headers("/open-apis/e2e").getFirst("Authorization")); + } finally { + server.stop(); + } + } + + @Test + public void accessTokenAuthorizationCodeAndRefreshUseLocalOAuthServer() throws Exception { + LocalServer server = LocalServer.start(); + try { + Client client = Client.newBuilder("cli_a", "") + .openBaseUrl(server.domain()) + .oauthBaseUrl(server.domain()) + .clientAssertionProvider(aud -> new ClientAssertionToken("client-assertion")) + .tokenCache(new NoopCache()) + .build(); + + client.accessToken().retrieveByAuthorizationCode( + AuthorizationCodeTokenRequest.newBuilder() + .code("auth-code") + .redirectUri("https://example.com/callback") + .codeVerifier("verifier") + .scope("contact:user.base:readonly") + .build()); + client.accessToken().refresh( + RefreshTokenRequest.newBuilder() + .refreshToken("refresh-token") + .scope("contact:user.base:readonly") + .build()); + + JsonObject authCodeBody = JsonParser.parseString(server.bodies("/oauth/v3/token").get(0)).getAsJsonObject(); + JsonObject refreshBody = JsonParser.parseString(server.bodies("/oauth/v3/token").get(1)).getAsJsonObject(); + assertEquals(Constants.GRANT_TYPE_AUTHORIZATION_CODE, authCodeBody.get("grant_type").getAsString()); + assertEquals("auth-code", authCodeBody.get("code").getAsString()); + assertEquals(Constants.GRANT_TYPE_REFRESH_TOKEN, refreshBody.get("grant_type").getAsString()); + assertEquals("refresh-token", refreshBody.get("refresh_token").getAsString()); + } finally { + server.stop(); + } + } + + @Test + public void websocketBootstrapUsesLocalProviderFlow() throws Exception { + LocalServer server = LocalServer.start(); + try { + com.lark.oapi.ws.Client client = new com.lark.oapi.ws.Client.Builder("cli_a", "") + .domain(server.domain()) + .clientAssertionProvider(aud -> new ClientAssertionToken("client-assertion")) + .build(); + + assertEquals("wss://example.test/callback?device_id=device&service_id=42", invokeGetConnUrl(client)); + JsonObject body = server.jsonBody(Constant.GEN_ENDPOINT_URI); + assertEquals("cli_a", body.get("AppID").getAsString()); + assertEquals("", body.get("AppSecret").getAsString()); + assertEquals("client-assertion", body.get("ClientAssertion").getAsString()); + } finally { + server.stop(); + } + } + + @Test + public void targetInfoProxyWorksForTenantTokenAndWebsocketBootstrap() throws Exception { + LocalServer proxy = LocalServer.start(); + try { + Client client = Client.newBuilder("cli_a", "") + .openBaseUrl(proxy.domain()) + .oauthBaseUrl("https://accounts.feishu.cn") + .clientAssertionProvider(aud -> new ClientAssertionToken( + "client-assertion", + new TargetInfo(proxy.domain(), "/proxy"))) + .tokenCache(new NoopCache()) + .build(); + + client.get("/open-apis/e2e", null, AccessTokenType.Tenant); + + assertEquals("accounts.feishu.cn", proxy.headers("/proxy/oauth/v3/token").getFirst(Constants.HEADER_X_TARGET_SERVICE)); + assertEquals("Bearer tenant-token", proxy.headers("/open-apis/e2e").getFirst("Authorization")); + + com.lark.oapi.ws.Client wsClient = new com.lark.oapi.ws.Client.Builder("cli_a", "") + .domain("https://open.feishu.cn") + .clientAssertionProvider(aud -> new ClientAssertionToken( + "client-assertion", + new TargetInfo(proxy.domain(), "/proxy"))) + .build(); + + invokeGetConnUrl(wsClient); + + assertEquals("open.feishu.cn", proxy.headers("/proxy" + Constant.GEN_ENDPOINT_URI).getFirst(Constants.HEADER_X_TARGET_SERVICE)); + } finally { + proxy.stop(); + } + } + + private static String invokeGetConnUrl(com.lark.oapi.ws.Client client) throws Exception { + Method method = com.lark.oapi.ws.Client.class.getDeclaredMethod("getConnUrl"); + method.setAccessible(true); + try { + return (String) method.invoke(client); + } catch (ReflectiveOperationException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Exception) { + throw (Exception) cause; + } + throw e; + } + } + + private static class LocalServer { + private final HttpServer server; + private final java.util.Map> bodies = new java.util.concurrent.ConcurrentHashMap<>(); + private final java.util.Map headers = new java.util.concurrent.ConcurrentHashMap<>(); + + private LocalServer(HttpServer server) { + this.server = server; + } + + private static LocalServer start() throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + LocalServer localServer = new LocalServer(server); + localServer.createJsonContext("/oauth/v3/token", "{\"access_token\":\"tenant-token\",\"expires_in\":7200,\"token_type\":\"Bearer\",\"refresh_token\":\"new-refresh-token\",\"refresh_token_expires_in\":604800,\"scope\":\"contact:user.base:readonly\"}"); + localServer.createJsonContext("/proxy/oauth/v3/token", "{\"access_token\":\"tenant-token\",\"expires_in\":7200}"); + localServer.createJsonContext("/open-apis/e2e", "{\"code\":0,\"msg\":\"ok\"}"); + localServer.createJsonContext(Constant.GEN_ENDPOINT_URI, "{\"code\":0,\"data\":{\"URL\":\"wss://example.test/callback?device_id=device&service_id=42\"}}"); + localServer.createJsonContext("/proxy" + Constant.GEN_ENDPOINT_URI, "{\"code\":0,\"data\":{\"URL\":\"wss://example.test/callback?device_id=device&service_id=42\"}}"); + server.start(); + return localServer; + } + + private void createJsonContext(String path, String responseBody) { + server.createContext(path, exchange -> { + headers.put(path, exchange.getRequestHeaders()); + bodies.computeIfAbsent(path, ignored -> new ArrayList<>()).add(readBody(exchange.getRequestBody())); + byte[] response = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, response.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response); + } + }); + } + + private String readBody(java.io.InputStream inputStream) throws IOException { + ByteArrayOutputStream requestBuffer = new ByteArrayOutputStream(); + byte[] requestChunk = new byte[1024]; + int read; + while ((read = inputStream.read(requestChunk)) != -1) { + requestBuffer.write(requestChunk, 0, read); + } + return new String(requestBuffer.toByteArray(), StandardCharsets.UTF_8); + } + + private int port() { + return server.getAddress().getPort(); + } + + private String domain() { + return "http://127.0.0.1:" + port(); + } + + private JsonObject jsonBody(String path) { + return JsonParser.parseString(bodies(path).get(bodies(path).size() - 1)).getAsJsonObject(); + } + + private List bodies(String path) { + return bodies.get(path); + } + + private Headers headers(String path) { + return headers.get(path); + } + + private void stop() { + server.stop(0); + } + } + + private static class NoopCache implements ICache { + @Override + public String get(String key) { + return ""; + } + + @Override + public void set(String key, String value, int expire, TimeUnit timeUnit) { + } + } +} diff --git a/larksuite-oapi/src/test/java/com/lark/oapi/ws/TestClientAssertionWsClient.java b/larksuite-oapi/src/test/java/com/lark/oapi/ws/TestClientAssertionWsClient.java new file mode 100644 index 000000000..fad3ef96b --- /dev/null +++ b/larksuite-oapi/src/test/java/com/lark/oapi/ws/TestClientAssertionWsClient.java @@ -0,0 +1,246 @@ +package com.lark.oapi.ws; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.lark.oapi.core.Constants; +import com.lark.oapi.core.auth.ClientAssertionToken; +import com.lark.oapi.core.auth.TargetInfo; +import com.lark.oapi.ws.exception.ClientException; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpServer; +import org.junit.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.ByteArrayOutputStream; +import java.lang.reflect.Method; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class TestClientAssertionWsClient { + + @Test + public void providerBootstrapBodyIncludesRequiredEmptyAppSecret() throws Exception { + CapturingServer server = CapturingServer.start(Constant.GEN_ENDPOINT_URI, 200, okBody()); + AtomicReference audRef = new AtomicReference<>(); + try { + Client client = new Client.Builder("cli_a", "") + .domain(server.domain()) + .clientAssertionProvider(aud -> { + audRef.set(aud); + return new ClientAssertionToken("client-assertion"); + }) + .build(); + + assertEquals("wss://example.test/callback?device_id=device&service_id=42", invokeGetConnUrl(client)); + JsonObject body = server.requestBody(); + assertEquals("cli_a", body.get("AppID").getAsString()); + assertEquals("", body.get("AppSecret").getAsString()); + assertEquals("client-assertion", body.get("ClientAssertion").getAsString()); + assertEquals("127.0.0.1:" + server.port(), audRef.get()); + } finally { + server.stop(); + } + } + + @Test + public void appSecretBootstrapBodyIncludesEmptyClientAssertion() throws Exception { + CapturingServer server = CapturingServer.start(Constant.GEN_ENDPOINT_URI, 200, okBody()); + try { + Client client = new Client.Builder("cli_a", "app-secret") + .domain(server.domain()) + .build(); + + invokeGetConnUrl(client); + JsonObject body = server.requestBody(); + assertEquals("cli_a", body.get("AppID").getAsString()); + assertEquals("app-secret", body.get("AppSecret").getAsString()); + assertEquals("", body.get("ClientAssertion").getAsString()); + } finally { + server.stop(); + } + } + + @Test + public void targetInfoUsesProxyUrlAndTargetServiceHeaderWinsConflict() throws Exception { + CapturingServer proxy = CapturingServer.start("/proxy" + Constant.GEN_ENDPOINT_URI, 200, okBody()); + try { + Map headers = new HashMap<>(); + headers.put(Constants.HEADER_X_TARGET_SERVICE, "user-value"); + Client client = new Client.Builder("cli_a", "") + .domain("https://open.feishu.cn") + .headers(headers) + .clientAssertionProvider(aud -> new ClientAssertionToken( + "client-assertion", + new TargetInfo(proxy.domain(), "/proxy"))) + .build(); + + invokeGetConnUrl(client); + + assertEquals("open.feishu.cn", proxy.headers().getFirst(Constants.HEADER_X_TARGET_SERVICE)); + assertEquals("/proxy" + Constant.GEN_ENDPOINT_URI, proxy.path()); + } finally { + proxy.stop(); + } + } + + @Test + public void emptyAssertionFailsWith7101() throws Exception { + Client client = new Client.Builder("cli_a", "") + .domain("https://open.feishu.cn") + .clientAssertionProvider(aud -> new ClientAssertionToken("")) + .build(); + + try { + invokeGetConnUrl(client); + } catch (ClientException e) { + assertTrue(e.toString().contains("7101")); + return; + } + throw new AssertionError("expected ClientException"); + } + + @Test + public void missingCredentialsFailsWith7104() throws Exception { + Client client = new Client.Builder("cli_a", "") + .domain("https://open.feishu.cn") + .build(); + + try { + invokeGetConnUrl(client); + } catch (ClientException e) { + assertTrue(e.toString().contains("7104")); + return; + } + throw new AssertionError("expected ClientException"); + } + + @Test + public void non200BootstrapUsesServerMessage() throws Exception { + CapturingServer server = CapturingServer.start(Constant.GEN_ENDPOINT_URI, 503, "{\"msg\":\"target service unavailable\"}"); + try { + Client client = new Client.Builder("cli_a", "app-secret") + .domain(server.domain()) + .build(); + + try { + invokeGetConnUrl(client); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains("target service unavailable")); + return; + } + throw new AssertionError("expected RuntimeException"); + } finally { + server.stop(); + } + } + + @Test + public void customHeadersArePreserved() throws Exception { + CapturingServer server = CapturingServer.start(Constant.GEN_ENDPOINT_URI, 200, okBody()); + try { + Map headers = new HashMap<>(); + headers.put("x-tt-env", "boe"); + headers.put("User-Agent", "custom-agent"); + Client client = new Client.Builder("cli_a", "app-secret") + .domain(server.domain()) + .headers(headers) + .header("x-use-ppe", "1") + .build(); + + invokeGetConnUrl(client); + + assertEquals("boe", server.headers().getFirst("x-tt-env")); + assertEquals("1", server.headers().getFirst("x-use-ppe")); + assertEquals("zh", server.headers().getFirst("locale")); + assertEquals("custom-agent", server.headers().getFirst("User-Agent")); + } finally { + server.stop(); + } + } + + private static String invokeGetConnUrl(Client client) throws Exception { + Method method = Client.class.getDeclaredMethod("getConnUrl"); + method.setAccessible(true); + try { + return (String) method.invoke(client); + } catch (ReflectiveOperationException e) { + Throwable cause = e.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw e; + } + } + + private static String okBody() { + return "{\"code\":0,\"data\":{\"URL\":\"wss://example.test/callback?device_id=device&service_id=42\"}}"; + } + + private static class CapturingServer { + private final HttpServer server; + private final AtomicReference body = new AtomicReference<>(); + private final AtomicReference headers = new AtomicReference<>(); + private final AtomicReference path = new AtomicReference<>(); + + private CapturingServer(HttpServer server) { + this.server = server; + } + + private static CapturingServer start(String path, int statusCode, String responseBody) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + CapturingServer capturingServer = new CapturingServer(server); + server.createContext(path, exchange -> { + capturingServer.path.set(exchange.getRequestURI().getPath()); + capturingServer.headers.set(exchange.getRequestHeaders()); + ByteArrayOutputStream requestBuffer = new ByteArrayOutputStream(); + byte[] requestChunk = new byte[1024]; + int read; + while ((read = exchange.getRequestBody().read(requestChunk)) != -1) { + requestBuffer.write(requestChunk, 0, read); + } + capturingServer.body.set(new String(requestBuffer.toByteArray(), StandardCharsets.UTF_8)); + byte[] response = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(statusCode, response.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response); + } + }); + server.start(); + return capturingServer; + } + + private int port() { + return server.getAddress().getPort(); + } + + private String domain() { + return "http://127.0.0.1:" + port(); + } + + private JsonObject requestBody() { + return JsonParser.parseString(body.get()).getAsJsonObject(); + } + + private Headers headers() { + return headers.get(); + } + + private String path() { + return path.get(); + } + + private void stop() { + server.stop(0); + } + } +} From 2dff96d2a03ee4f72c3fe93a444b535ec41f5c72 Mon Sep 17 00:00:00 2001 From: WangWeijia Date: Wed, 10 Jun 2026 18:17:40 +0800 Subject: [PATCH 2/5] feat: SUP CLientAssertion Change-Id: I35098a8543100355f5893fc3192dadc8fbe6dc9e --- .gitignore | 3 + .../oapi/channel/ChannelClientFactory.java | 2 +- .../channel/config/LarkChannelOptions.java | 18 + .../main/java/com/lark/oapi/ws/Client.java | 8 +- .../oapi/e2e/ClientAssertionLiveHarness.java | 539 ++++++++++++++++++ .../oapi/e2e/TestClientAssertionLiveE2E.java | 401 +++++++++++++ .../e2e/TestClientAssertionLiveHarness.java | 125 ++++ 7 files changed, 1094 insertions(+), 2 deletions(-) create mode 100644 larksuite-oapi/src/test/java/com/lark/oapi/e2e/ClientAssertionLiveHarness.java create mode 100644 larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLiveE2E.java create mode 100644 larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLiveHarness.java diff --git a/.gitignore b/.gitignore index c1b788044..18d3e60b2 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,9 @@ larksuite-oapi/src/test/** !larksuite-oapi/src/test/java/com/lark/oapi/core/accesstoken/TestAccessToken.java !larksuite-oapi/src/test/java/com/lark/oapi/core/auth/TestClientAssertionUtils.java !larksuite-oapi/src/test/java/com/lark/oapi/core/token/TestClientAssertionTokenManager.java +!larksuite-oapi/src/test/java/com/lark/oapi/e2e/ClientAssertionLiveHarness.java +!larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLiveHarness.java !larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLocalE2E.java +!larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLiveE2E.java !larksuite-oapi/src/test/java/com/lark/oapi/ws/TestClientAssertionWsClient.java docs/superpowers/plans diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/channel/ChannelClientFactory.java b/larksuite-oapi/src/main/java/com/lark/oapi/channel/ChannelClientFactory.java index e8d4f6f06..aa05fd801 100644 --- a/larksuite-oapi/src/main/java/com/lark/oapi/channel/ChannelClientFactory.java +++ b/larksuite-oapi/src/main/java/com/lark/oapi/channel/ChannelClientFactory.java @@ -44,8 +44,8 @@ static com.lark.oapi.ws.Client createWebSocketClient( return new com.lark.oapi.ws.Client.Builder(options.getAppId(), options.getAppSecret()) .eventHandler(eventDispatcher) .domain(options.getDomain() == null ? BaseUrlEnum.FeiShu.getUrl() : options.getDomain()) - .clientAssertionProvider(options.getClientAssertionProvider()) .source(options.getSource()) + .clientAssertionProvider(options.getClientAssertionProvider()) .onReconnecting(new Runnable() { @Override public void run() { diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/channel/config/LarkChannelOptions.java b/larksuite-oapi/src/main/java/com/lark/oapi/channel/config/LarkChannelOptions.java index c87e59338..9c75dd63b 100644 --- a/larksuite-oapi/src/main/java/com/lark/oapi/channel/config/LarkChannelOptions.java +++ b/larksuite-oapi/src/main/java/com/lark/oapi/channel/config/LarkChannelOptions.java @@ -102,6 +102,14 @@ public String getSource() { return source; } + public ClientAssertionProvider getClientAssertionProvider() { + return clientAssertionProvider; + } + + public String getOAuthBaseUrl() { + return oauthBaseUrl; + } + /** * Whether normalized events should carry the original Feishu event body. * @@ -198,6 +206,16 @@ public Builder source(String source) { return this; } + public Builder clientAssertionProvider(ClientAssertionProvider clientAssertionProvider) { + this.clientAssertionProvider = clientAssertionProvider; + return this; + } + + public Builder oauthBaseUrl(String oauthBaseUrl) { + this.oauthBaseUrl = oauthBaseUrl; + return this; + } + /** * Attach the raw Feishu event body to normalized events. Useful when a * handler needs fields that the normalizer intentionally drops, such as diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/ws/Client.java b/larksuite-oapi/src/main/java/com/lark/oapi/ws/Client.java index 174b71717..297b5ff19 100644 --- a/larksuite-oapi/src/main/java/com/lark/oapi/ws/Client.java +++ b/larksuite-oapi/src/main/java/com/lark/oapi/ws/Client.java @@ -90,7 +90,7 @@ private Client(Builder builder) { this.reconnectCount = -1; this.reconnectInterval = 120; this.pingInterval = 120; - this.httpClient = new OkHttpClient(); + this.httpClient = builder.httpClient != null ? builder.httpClient : new OkHttpClient(); this.isReconnecting = false; this.userClosed = false; this.cache = CacheBuilder.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build(); @@ -640,6 +640,7 @@ public static class Builder { private Runnable onReconnecting; private Runnable onReconnected; private ClientAssertionProvider clientAssertionProvider; + private OkHttpClient httpClient; public Builder(String appId, String appSecret) { this.appId = appId; @@ -679,6 +680,11 @@ public Builder clientAssertionProvider(ClientAssertionProvider provider) { return this; } + public Builder httpClient(OkHttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + public Builder source(String source) { this.source = source; return this; diff --git a/larksuite-oapi/src/test/java/com/lark/oapi/e2e/ClientAssertionLiveHarness.java b/larksuite-oapi/src/test/java/com/lark/oapi/e2e/ClientAssertionLiveHarness.java new file mode 100644 index 000000000..19081364f --- /dev/null +++ b/larksuite-oapi/src/test/java/com/lark/oapi/e2e/ClientAssertionLiveHarness.java @@ -0,0 +1,539 @@ +package com.lark.oapi.e2e; + +import com.lark.oapi.core.auth.ClientAssertionProvider; +import com.lark.oapi.core.auth.ClientAssertionToken; +import com.lark.oapi.core.auth.TargetInfo; +import com.lark.oapi.core.cache.ICache; +import com.lark.oapi.core.utils.Strings; +import com.lark.oapi.okhttp.Dns; +import com.lark.oapi.okhttp.OkHttpClient; +import com.sun.net.httpserver.HttpServer; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.InetSocketAddress; +import java.net.InetAddress; +import java.net.URI; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.TimeUnit; + +final class ClientAssertionLiveHarness { + static final DeployDomains ONLINE_DOMAINS = new DeployDomains( + "https://open.feishu.cn", + "https://accounts.feishu.cn"); + static final DeployDomains BOE_DOMAINS = new DeployDomains( + "https://open.feishu-boe.cn", + "https://accounts.feishu-boe.cn"); + + private ClientAssertionLiveHarness() { + } + + static boolean parseBool(String value, boolean defaultValue) { + if (value == null) { + return defaultValue; + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + if ("1".equals(normalized) + || "true".equals(normalized) + || "yes".equals(normalized) + || "y".equals(normalized) + || "on".equals(normalized)) { + return true; + } + if ("0".equals(normalized) + || "false".equals(normalized) + || "no".equals(normalized) + || "n".equals(normalized) + || "off".equals(normalized)) { + return false; + } + return defaultValue; + } + + static DeployDomains deployDomains(String deployEnv) { + String normalized = deployEnv == null ? "online" : deployEnv.trim().toLowerCase(Locale.ROOT); + if ("online".equals(normalized) + || "prod".equals(normalized) + || "production".equals(normalized) + || "cn".equals(normalized)) { + return ONLINE_DOMAINS; + } + if ("boe".equals(normalized)) { + return BOE_DOMAINS; + } + throw new IllegalArgumentException("LARK_DEPLOY_ENV must be online or boe"); + } + + static boolean loadEnvFile(File file, Map env, boolean override) throws IOException { + if (file == null || !file.isFile()) { + return false; + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader( + new FileInputStream(file), StandardCharsets.UTF_8))) { + String rawLine; + while ((rawLine = reader.readLine()) != null) { + String line = rawLine.trim(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + if (line.startsWith("export ")) { + line = line.substring("export ".length()).trim(); + } + int equals = line.indexOf('='); + if (equals <= 0) { + continue; + } + String key = line.substring(0, equals).trim(); + String value = parseEnvValue(line.substring(equals + 1).trim()); + if (!override && env.containsKey(key)) { + continue; + } + env.put(key, value); + } + } + return true; + } + + static Env loadEnv() throws IOException { + Map values = new LinkedHashMap<>(); + values.putAll(System.getenv()); + + String envFile = firstNonEmpty( + System.getProperty("lark.e2e.envFile"), + System.getenv("LARK_E2E_ENV_FILE")); + if (Strings.isNotEmpty(envFile)) { + loadEnvFile(new File(envFile), values, false); + } + return new Env(values); + } + + static String buildAuthorizeUrl(String oauthBaseUrl, + String appId, + String redirectUri, + String scope, + String state, + String codeChallenge) { + Map query = new LinkedHashMap<>(); + query.put("app_id", appId); + query.put("redirect_uri", redirectUri); + query.put("scope", scope); + query.put("state", state); + if (Strings.isNotEmpty(codeChallenge)) { + query.put("code_challenge", codeChallenge); + query.put("code_challenge_method", "S256"); + } + + StringBuilder url = new StringBuilder(trimTrailingSlash(oauthBaseUrl)) + .append("/open-apis/authen/v1/authorize?"); + boolean first = true; + for (Map.Entry entry : query.entrySet()) { + if (!first) { + url.append('&'); + } + first = false; + url.append(urlEncode(entry.getKey())).append('=').append(urlEncode(entry.getValue())); + } + return url.toString(); + } + + static String randomState() { + byte[] bytes = new byte[24]; + new SecureRandom().nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + static String randomCodeVerifier() { + byte[] bytes = new byte[32]; + new SecureRandom().nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + static String codeChallenge(String verifier) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(digest.digest(verifier.getBytes(StandardCharsets.US_ASCII))); + } + + static String caseId(String prefix) { + return prefix + "-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + } + + static String extractHost(String url) { + URI uri = URI.create(url); + if (Strings.isNotEmpty(uri.getHost())) { + return uri.getHost(); + } + String noScheme = url.replaceFirst("^https?://", ""); + int slash = noScheme.indexOf('/'); + String hostPort = slash >= 0 ? noScheme.substring(0, slash) : noScheme; + int colon = hostPort.indexOf(':'); + return colon >= 0 ? hostPort.substring(0, colon) : hostPort; + } + + static OAuthCallbackServer startCallbackServer(String redirectUri, String expectedState) throws IOException { + return OAuthCallbackServer.start(redirectUri, expectedState); + } + + static OkHttpClient okHttpClient(Env env, long callTimeout, TimeUnit timeUnit) { + OkHttpClient.Builder builder = new OkHttpClient.Builder() + .callTimeout(callTimeout, timeUnit); + Dns dns = proxyDnsOverride(env); + if (dns != null) { + builder.dns(dns); + } + return builder.build(); + } + + private static Dns proxyDnsOverride(Env env) { + String proxyService = env.get("LARK_GDPR_PROXY_SERVICE"); + String resolveIp = env.get("LARK_GDPR_PROXY_RESOLVE_IP"); + if (Strings.isEmpty(proxyService) || Strings.isEmpty(resolveIp)) { + return null; + } + String proxyHost = extractHost(proxyService); + return hostname -> { + if (proxyHost.equalsIgnoreCase(hostname)) { + return Collections.singletonList(InetAddress.getByName(resolveIp)); + } + return Dns.SYSTEM.lookup(hostname); + }; + } + + private static String parseEnvValue(String rawValue) { + String withoutComment = stripComment(rawValue).trim(); + if (withoutComment.length() >= 2) { + char first = withoutComment.charAt(0); + char last = withoutComment.charAt(withoutComment.length() - 1); + if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) { + return unescapeQuoted(withoutComment.substring(1, withoutComment.length() - 1), first); + } + } + return withoutComment; + } + + private static String stripComment(String value) { + boolean inSingle = false; + boolean inDouble = false; + boolean escaped = false; + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + if (escaped) { + escaped = false; + continue; + } + if (ch == '\\' && inDouble) { + escaped = true; + continue; + } + if (ch == '\'' && !inDouble) { + inSingle = !inSingle; + continue; + } + if (ch == '"' && !inSingle) { + inDouble = !inDouble; + continue; + } + if (ch == '#' && !inSingle && !inDouble && (i == 0 || Character.isWhitespace(value.charAt(i - 1)))) { + return value.substring(0, i); + } + } + return value; + } + + private static String unescapeQuoted(String value, char quote) { + if (quote == '\'') { + return value; + } + StringBuilder builder = new StringBuilder(); + boolean escaped = false; + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + if (escaped) { + if (ch == 'n') { + builder.append('\n'); + } else if (ch == 't') { + builder.append('\t'); + } else { + builder.append(ch); + } + escaped = false; + continue; + } + if (ch == '\\') { + escaped = true; + continue; + } + builder.append(ch); + } + if (escaped) { + builder.append('\\'); + } + return builder.toString(); + } + + private static String trimTrailingSlash(String value) { + if (value.endsWith("/")) { + return value.substring(0, value.length() - 1); + } + return value; + } + + private static String urlEncode(String value) { + try { + return URLEncoder.encode(value, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } + + private static String urlDecode(String value) { + try { + return URLDecoder.decode(value, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } + + private static String firstNonEmpty(String first, String second) { + return Strings.isNotEmpty(first) ? first : second; + } + + static final class DeployDomains { + private final String openApiDomain; + private final String oauthBaseUrl; + + private DeployDomains(String openApiDomain, String oauthBaseUrl) { + this.openApiDomain = openApiDomain; + this.oauthBaseUrl = oauthBaseUrl; + } + + String getOpenApiDomain() { + return openApiDomain; + } + + String getOAuthBaseUrl() { + return oauthBaseUrl; + } + } + + static final class Env { + private final Map values; + + Env(Map values) { + this.values = new LinkedHashMap<>(values); + } + + boolean enabled() { + return "1".equals(get("LARK_CLIENT_ASSERTION_E2E")); + } + + String get(String key) { + return values.get(key); + } + + String require(String key) { + String value = get(key); + if (Strings.isEmpty(value)) { + throw new IllegalStateException("missing env: " + key); + } + return value; + } + + boolean bool(String key, boolean defaultValue) { + return parseBool(get(key), defaultValue); + } + + int intValue(String key, int defaultValue) { + String value = get(key); + if (Strings.isEmpty(value)) { + return defaultValue; + } + return Integer.parseInt(value); + } + + boolean modeEnabled(String mode) { + String modes = get("LARK_E2E_MODES"); + if (Strings.isEmpty(modes)) { + return true; + } + String expected = normalizeMode(mode); + String[] parts = modes.split("[,\\s]+"); + for (String part : parts) { + if (expected.equals(normalizeMode(part))) { + return true; + } + } + return false; + } + + DeployDomains domains() { + DeployDomains defaults = deployDomains(get("LARK_DEPLOY_ENV")); + return new DeployDomains( + firstNonEmpty(get("LARK_OPEN_BASE_URL"), defaults.getOpenApiDomain()), + firstNonEmpty(get("LARK_OAUTH_BASE_URL"), defaults.getOAuthBaseUrl())); + } + + String authorizeBaseUrl() { + return firstNonEmpty(get("LARK_AUTHORIZE_BASE_URL"), domains().getOAuthBaseUrl()); + } + + private String normalizeMode(String mode) { + if (mode == null) { + return ""; + } + String normalized = mode.toLowerCase(Locale.ROOT).replace("-", "_"); + if ("secret".equals(normalized) || "appsecret".equals(normalized)) { + return "app_secret"; + } + return normalized; + } + } + + static final class ModeEnvProvider implements ClientAssertionProvider { + private final String mode; + private final Env env; + private final List auds = Collections.synchronizedList(new ArrayList<>()); + + ModeEnvProvider(String mode, Env env) { + if (!"zti".equals(mode) && !"gdpr".equals(mode)) { + throw new IllegalArgumentException("mode must be zti or gdpr"); + } + this.mode = mode; + this.env = env; + } + + @Override + public ClientAssertionToken retrieveToken(String aud) { + auds.add(aud); + if ("zti".equals(mode)) { + return new ClientAssertionToken(env.require("LARK_ZTI_CLIENT_ASSERTION")); + } + String prefix = env.require("LARK_GDPR_PROXY_PREFIX"); + if (!prefix.startsWith("/")) { + throw new IllegalStateException("LARK_GDPR_PROXY_PREFIX must start with /"); + } + return new ClientAssertionToken( + env.require("LARK_GDPR_CLIENT_ASSERTION"), + new TargetInfo(env.require("LARK_GDPR_PROXY_SERVICE"), prefix)); + } + + List getAuds() { + return auds; + } + + String getMode() { + return mode; + } + } + + static final class NoopCache implements ICache { + @Override + public String get(String key) { + return ""; + } + + @Override + public void set(String key, String value, int expire, TimeUnit timeUnit) { + } + } + + static final class OAuthCallbackServer implements AutoCloseable { + private final HttpServer server; + private final ArrayBlockingQueue> queue; + + private OAuthCallbackServer(HttpServer server, + ArrayBlockingQueue> queue) { + this.server = server; + this.queue = queue; + } + + static OAuthCallbackServer start(String redirectUri, String expectedState) throws IOException { + URI uri = URI.create(redirectUri); + if (!"http".equals(uri.getScheme()) + || (!"127.0.0.1".equals(uri.getHost()) && !"localhost".equals(uri.getHost()))) { + throw new IllegalArgumentException("LARK_OAUTH_REDIRECT_URI must be a local http callback"); + } + if (uri.getPort() <= 0) { + throw new IllegalArgumentException("LARK_OAUTH_REDIRECT_URI must include a port"); + } + + ArrayBlockingQueue> queue = new ArrayBlockingQueue<>(1); + HttpServer server = HttpServer.create(new InetSocketAddress(uri.getHost(), uri.getPort()), 0); + server.createContext(uri.getPath(), exchange -> { + Map params = parseQuery(exchange.getRequestURI().getRawQuery()); + if (!expectedState.equals(params.get("state"))) { + params.put("error", "state_mismatch"); + queue.offer(params); + respond(exchange, 400, "OAuth state mismatch."); + return; + } + if (!params.containsKey("code")) { + params.putIfAbsent("error", "missing_code"); + queue.offer(params); + respond(exchange, 400, "OAuth callback missing code."); + return; + } + queue.offer(params); + respond(exchange, 200, "OAuth callback received. You can close this tab."); + }); + + server.start(); + return new OAuthCallbackServer(server, queue); + } + + Map await(int timeoutSeconds) throws InterruptedException { + return queue.poll(timeoutSeconds, TimeUnit.SECONDS); + } + + @Override + public void close() { + server.stop(0); + } + + private static void respond(com.sun.net.httpserver.HttpExchange exchange, + int status, + String message) throws IOException { + byte[] body = message.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + } + + private static Map parseQuery(String rawQuery) { + Map params = new HashMap<>(); + if (rawQuery == null || rawQuery.isEmpty()) { + return params; + } + String[] parts = rawQuery.split("&"); + for (String part : parts) { + int equals = part.indexOf('='); + if (equals < 0) { + params.put(urlDecode(part), ""); + } else { + params.put(urlDecode(part.substring(0, equals)), urlDecode(part.substring(equals + 1))); + } + } + return params; + } + } +} diff --git a/larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLiveE2E.java b/larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLiveE2E.java new file mode 100644 index 000000000..aa0baa2b2 --- /dev/null +++ b/larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLiveE2E.java @@ -0,0 +1,401 @@ +package com.lark.oapi.e2e; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.lark.oapi.Client; +import com.lark.oapi.core.accesstoken.AccessTokenResp; +import com.lark.oapi.core.accesstoken.AccessTokenRespData; +import com.lark.oapi.core.accesstoken.AuthorizationCodeTokenRequest; +import com.lark.oapi.core.accesstoken.RefreshTokenRequest; +import com.lark.oapi.core.httpclient.OkHttpTransport; +import com.lark.oapi.core.request.RequestOptions; +import com.lark.oapi.core.response.RawResponse; +import com.lark.oapi.core.token.AccessTokenType; +import com.lark.oapi.core.utils.Jsons; +import com.lark.oapi.core.utils.Strings; +import com.lark.oapi.okhttp.OkHttpClient; +import com.lark.oapi.okhttp.Request; +import com.lark.oapi.okhttp.Response; +import com.lark.oapi.okhttp.WebSocket; +import com.lark.oapi.okhttp.WebSocketListener; +import org.junit.Assume; +import org.junit.Test; + +import java.awt.Desktop; +import java.lang.reflect.Method; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class TestClientAssertionLiveE2E { + + @Test + public void liveTenantTokenTatAndWebSocketMatrix() throws Exception { + ClientAssertionLiveHarness.Env env = enabledEnv(); + ClientAssertionLiveHarness.DeployDomains domains = env.domains(); + int executed = 0; + + if (env.modeEnabled("app_secret")) { + executed++; + Client appSecretClient = buildAppSecretClient(env, domains); + String secretMessageId = sendTenantMessage(appSecretClient, env, + ClientAssertionLiveHarness.caseId("SECRET-TAT")); + printPass("SECRET-02", "tenant message_id=" + secretMessageId); + } + + if (env.modeEnabled("zti")) { + executed++; + ClientAssertionLiveHarness.ModeEnvProvider ztiProvider = + new ClientAssertionLiveHarness.ModeEnvProvider("zti", env); + Client ztiClient = buildClientAssertionClient(env, domains, ztiProvider, ""); + String ztiMessageId = sendTenantMessage(ztiClient, env, + ClientAssertionLiveHarness.caseId("ZTI-TAT")); + assertProviderReceivedAud(ztiProvider, ClientAssertionLiveHarness.extractHost(domains.getOAuthBaseUrl())); + printPass("ZTI-02", "tenant message_id=" + ztiMessageId); + } + + if (env.modeEnabled("gdpr")) { + executed++; + ClientAssertionLiveHarness.ModeEnvProvider gdprProvider = + new ClientAssertionLiveHarness.ModeEnvProvider("gdpr", env); + Client gdprClient = buildClientAssertionClient(env, domains, gdprProvider, ""); + String gdprMessageId = sendTenantMessage(gdprClient, env, + ClientAssertionLiveHarness.caseId("GDPR-TAT")); + assertProviderReceivedAud(gdprProvider, ClientAssertionLiveHarness.extractHost(domains.getOAuthBaseUrl())); + printPass("GDPR-TAT", "tenant message_id=" + gdprMessageId); + } + + if (env.modeEnabled("zti")) { + executed++; + ClientAssertionLiveHarness.ModeEnvProvider precedenceProvider = + new ClientAssertionLiveHarness.ModeEnvProvider("zti", env); + Client precedenceClient = buildClientAssertionClient(env, domains, precedenceProvider, env.require("LARK_APP_SECRET")); + String precedenceMessageId = sendTenantMessage(precedenceClient, env, + ClientAssertionLiveHarness.caseId("ENV-PRECEDENCE")); + assertProviderReceivedAud(precedenceProvider, ClientAssertionLiveHarness.extractHost(domains.getOAuthBaseUrl())); + printPass("ENV-05", "provider precedence message_id=" + precedenceMessageId); + } + + if (env.modeEnabled("app_secret")) { + executed++; + runWebSocketCase("SECRET-04", env, domains, env.require("LARK_APP_SECRET"), null); + } + + if (env.modeEnabled("zti")) { + executed++; + ClientAssertionLiveHarness.ModeEnvProvider wsZtiProvider = + new ClientAssertionLiveHarness.ModeEnvProvider("zti", env); + runWebSocketCase("WS-01/WS-03-ZTI", env, domains, "", wsZtiProvider); + assertProviderReceivedAud(wsZtiProvider, ClientAssertionLiveHarness.extractHost(domains.getOpenApiDomain())); + } + + if (env.modeEnabled("gdpr")) { + executed++; + ClientAssertionLiveHarness.ModeEnvProvider wsGdprProvider = + new ClientAssertionLiveHarness.ModeEnvProvider("gdpr", env); + runWebSocketCase("WS-02/WS-03-GDPR", env, domains, "", wsGdprProvider); + assertProviderReceivedAud(wsGdprProvider, ClientAssertionLiveHarness.extractHost(domains.getOpenApiDomain())); + } + Assume.assumeTrue("no live E2E mode selected by LARK_E2E_MODES", executed > 0); + } + + @Test + public void liveOAuthAuthorizationCodeRefreshAndBasicBatchMatrix() throws Exception { + ClientAssertionLiveHarness.Env env = enabledEnv(); + ClientAssertionLiveHarness.DeployDomains domains = env.domains(); + int executed = 0; + + if (env.modeEnabled("app_secret")) { + executed++; + runOAuthCase("OAUTH-APPSECRET", buildAppSecretClient(env, domains), env); + } + + if (env.modeEnabled("zti")) { + executed++; + ClientAssertionLiveHarness.ModeEnvProvider ztiProvider = + new ClientAssertionLiveHarness.ModeEnvProvider("zti", env); + runOAuthCase("ZTI-03/04/05/06", buildClientAssertionClient(env, domains, ztiProvider, ""), env); + assertProviderReceivedAud(ztiProvider, ClientAssertionLiveHarness.extractHost(domains.getOAuthBaseUrl())); + } + + if (env.modeEnabled("gdpr")) { + executed++; + ClientAssertionLiveHarness.ModeEnvProvider gdprProvider = + new ClientAssertionLiveHarness.ModeEnvProvider("gdpr", env); + runOAuthCase("GDPR-03/04/05", buildClientAssertionClient(env, domains, gdprProvider, ""), env); + assertProviderReceivedAud(gdprProvider, ClientAssertionLiveHarness.extractHost(domains.getOAuthBaseUrl())); + } + Assume.assumeTrue("no live E2E mode selected by LARK_E2E_MODES", executed > 0); + } + + private ClientAssertionLiveHarness.Env enabledEnv() throws Exception { + ClientAssertionLiveHarness.Env env = ClientAssertionLiveHarness.loadEnv(); + Assume.assumeTrue("set LARK_CLIENT_ASSERTION_E2E=1 to run live ClientAssertion E2E", env.enabled()); + return env; + } + + private Client buildAppSecretClient(ClientAssertionLiveHarness.Env env, + ClientAssertionLiveHarness.DeployDomains domains) { + return Client.newBuilder(env.require("LARK_APP_ID"), env.require("LARK_APP_SECRET")) + .openBaseUrl(domains.getOpenApiDomain()) + .oauthBaseUrl(domains.getOAuthBaseUrl()) + .tokenCache(new ClientAssertionLiveHarness.NoopCache()) + .httpTransport(new OkHttpTransport(ClientAssertionLiveHarness.okHttpClient(env, 10, TimeUnit.SECONDS))) + .requestTimeout(10, TimeUnit.SECONDS) + .build(); + } + + private Client buildClientAssertionClient(ClientAssertionLiveHarness.Env env, + ClientAssertionLiveHarness.DeployDomains domains, + ClientAssertionLiveHarness.ModeEnvProvider provider, + String appSecret) { + if ("gdpr".equals(provider.getMode())) { + String deployEnv = env.get("LARK_DEPLOY_ENV"); + String normalized = deployEnv == null ? "online" : deployEnv.toLowerCase(); + if (!"online".equals(normalized) && !"prod".equals(normalized) + && !"production".equals(normalized) && !"cn".equals(normalized)) { + throw new IllegalStateException("GDPR proxy E2E must use LARK_DEPLOY_ENV=online"); + } + } + return Client.newBuilder(env.require("LARK_APP_ID"), appSecret) + .openBaseUrl(domains.getOpenApiDomain()) + .oauthBaseUrl(domains.getOAuthBaseUrl()) + .clientAssertionProvider(provider) + .tokenCache(new ClientAssertionLiveHarness.NoopCache()) + .httpTransport(new OkHttpTransport(ClientAssertionLiveHarness.okHttpClient(env, 10, TimeUnit.SECONDS))) + .requestTimeout(10, TimeUnit.SECONDS) + .build(); + } + + private String sendTenantMessage(Client client, + ClientAssertionLiveHarness.Env env, + String caseId) throws Exception { + Map content = new HashMap<>(); + content.put("text", "ClientAssertion E2E TAT message: " + caseId); + + Map body = new HashMap<>(); + body.put("receive_id", env.require("LARK_OPEN_ID")); + body.put("msg_type", "text"); + body.put("content", Jsons.DEFAULT.toJson(content)); + body.put("uuid", UUID.randomUUID().toString()); + + RawResponse response = client.post( + "/open-apis/im/v1/messages?receive_id_type=open_id", + body, + AccessTokenType.Tenant); + JsonObject json = assertSuccessResponse(response, "send tenant message " + caseId); + JsonObject data = json.getAsJsonObject("data"); + assertNotNull("send message response data", data); + String messageId = getString(data, "message_id"); + assertTrue("message_id must not be empty", Strings.isNotEmpty(messageId)); + return messageId; + } + + private void runOAuthCase(String caseId, Client client, ClientAssertionLiveHarness.Env env) throws Exception { + AccessTokenResp token = authorizeAndExchangeCode(client, env, caseId); + AccessTokenRespData tokenData = token.getData(); + assertTrue("authorization code exchange did not return access_token", + Strings.isNotEmpty(tokenData.getAccessToken())); + callBasicBatchWithUat(client, env, tokenData.getAccessToken(), caseId + "-initial"); + + assertTrue("authorization code exchange did not return refresh_token; verify offline_access is enabled", + Strings.isNotEmpty(tokenData.getRefreshToken())); + AccessTokenResp refreshed = client.accessToken().refresh( + RefreshTokenRequest.newBuilder() + .refreshToken(tokenData.getRefreshToken()) + .scope(env.get("LARK_OAUTH_SCOPE")) + .build()); + assertTrue("refresh token exchange did not return access_token", + Strings.isNotEmpty(refreshed.getData().getAccessToken())); + callBasicBatchWithUat(client, env, refreshed.getData().getAccessToken(), caseId + "-refreshed"); + printPass(caseId, "oauth status=" + refreshed.getStatusCode()); + } + + private AccessTokenResp authorizeAndExchangeCode(Client client, + ClientAssertionLiveHarness.Env env, + String caseId) throws Exception { + String redirectUri = env.require("LARK_OAUTH_REDIRECT_URI"); + String scope = env.require("LARK_OAUTH_SCOPE"); + String state = ClientAssertionLiveHarness.randomState(); + String codeVerifier = ""; + String codeChallenge = ""; + if (env.bool("LARK_OAUTH_PKCE_REQUIRED", false)) { + codeVerifier = ClientAssertionLiveHarness.randomCodeVerifier(); + codeChallenge = ClientAssertionLiveHarness.codeChallenge(codeVerifier); + } + + String authorizeUrl = ClientAssertionLiveHarness.buildAuthorizeUrl( + env.authorizeBaseUrl(), + env.require("LARK_APP_ID"), + redirectUri, + scope, + state, + codeChallenge); + + Map params; + try (ClientAssertionLiveHarness.OAuthCallbackServer callbackServer = + ClientAssertionLiveHarness.startCallbackServer(redirectUri, state)) { + System.out.println("\n[E2E] Open this URL to authorize " + caseId + + " (OAuth code/token will not be logged):\n" + authorizeUrl); + openBrowser(authorizeUrl); + params = callbackServer.await(env.intValue("LARK_OAUTH_TIMEOUT_SECONDS", 180)); + } + + if (params == null) { + fail("OAuth callback timed out for " + caseId); + } + if (params.containsKey("error")) { + fail("OAuth callback failed for " + caseId + ": " + params.get("error")); + } + return client.accessToken().retrieveByAuthorizationCode( + AuthorizationCodeTokenRequest.newBuilder() + .code(params.get("code")) + .redirectUri(redirectUri) + .codeVerifier(codeVerifier) + .scope(scope) + .build()); + } + + private void callBasicBatchWithUat(Client client, + ClientAssertionLiveHarness.Env env, + String userAccessToken, + String caseId) throws Exception { + Map body = new HashMap<>(); + body.put("user_ids", Collections.singletonList(env.require("LARK_OPEN_ID"))); + RequestOptions options = RequestOptions.newBuilder() + .userAccessToken(userAccessToken) + .build(); + RawResponse response = client.post( + "/open-apis/contact/v3/users/basic_batch?user_id_type=open_id", + body, + AccessTokenType.User, + options); + JsonObject json = assertSuccessResponse(response, "basic_batch " + caseId); + JsonObject data = json.getAsJsonObject("data"); + assertNotNull("basic_batch data", data); + JsonArray users = data.getAsJsonArray("users"); + assertTrue("basic_batch users must not be empty", users != null && users.size() > 0); + } + + private void runWebSocketCase(String caseId, + ClientAssertionLiveHarness.Env env, + ClientAssertionLiveHarness.DeployDomains domains, + String appSecret, + ClientAssertionLiveHarness.ModeEnvProvider provider) throws Exception { + com.lark.oapi.ws.Client.Builder builder = + new com.lark.oapi.ws.Client.Builder(env.require("LARK_APP_ID"), appSecret) + .domain(domains.getOpenApiDomain()) + .httpClient(ClientAssertionLiveHarness.okHttpClient(env, 10, TimeUnit.SECONDS)) + .autoReconnect(false); + if (provider != null) { + builder.clientAssertionProvider(provider); + } + com.lark.oapi.ws.Client client = builder.build(); + + String connUrl = invokeGetConnUrl(client); + assertTrue("websocket endpoint should return ws/wss url", + connUrl.startsWith("ws://") || connUrl.startsWith("wss://")); + if (env.bool("LARK_WS_CONNECT_E2E", false)) { + connectWebSocketOnce(env, connUrl, env.intValue("LARK_WS_LISTEN_SECONDS", 30)); + } + printPass(caseId, "ws_endpoint_host=" + URI.create(connUrl.replace("wss://", "https://").replace("ws://", "http://")).getHost()); + } + + private String invokeGetConnUrl(com.lark.oapi.ws.Client client) throws Exception { + Method method = com.lark.oapi.ws.Client.class.getDeclaredMethod("getConnUrl"); + method.setAccessible(true); + return (String) method.invoke(client); + } + + private void connectWebSocketOnce(ClientAssertionLiveHarness.Env env, + String connUrl, + int listenSeconds) throws Exception { + CountDownLatch opened = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + AtomicReference socketRef = new AtomicReference<>(); + OkHttpClient httpClient = ClientAssertionLiveHarness.okHttpClient(env, 15, TimeUnit.SECONDS); + try { + Request request = new Request.Builder().url(connUrl).build(); + WebSocket socket = httpClient.newWebSocket(request, new WebSocketListener() { + @Override + public void onOpen(WebSocket webSocket, Response response) { + socketRef.set(webSocket); + opened.countDown(); + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + failure.set(t); + opened.countDown(); + } + }); + socketRef.compareAndSet(null, socket); + if (!opened.await(15, TimeUnit.SECONDS)) { + fail("websocket handshake timed out"); + } + if (failure.get() != null) { + throw new AssertionError("websocket handshake failed: " + failure.get().getMessage(), failure.get()); + } + Thread.sleep(Math.max(1, listenSeconds) * 1000L); + WebSocket connected = socketRef.get(); + if (connected != null) { + connected.close(1000, "client assertion live e2e done"); + } + } finally { + httpClient.dispatcher().executorService().shutdown(); + httpClient.connectionPool().evictAll(); + } + } + + private JsonObject assertSuccessResponse(RawResponse response, String action) { + String body = new String(response.getBody(), StandardCharsets.UTF_8); + JsonObject json = JsonParser.parseString(body).getAsJsonObject(); + if (response.getStatusCode() < 200 || response.getStatusCode() >= 300) { + fail(action + " failed, http_status=" + response.getStatusCode() + + ", request_id=" + response.getRequestID()); + } + int code = json.has("code") && !json.get("code").isJsonNull() ? json.get("code").getAsInt() : 0; + if (code != 0) { + fail(action + " failed, code=" + code + + ", msg=" + getString(json, "msg") + + ", request_id=" + response.getRequestID()); + } + return json; + } + + private void assertProviderReceivedAud(ClientAssertionLiveHarness.ModeEnvProvider provider, String expectedAud) { + assertTrue("provider did not receive aud " + expectedAud + ", actual=" + provider.getAuds(), + provider.getAuds().contains(expectedAud)); + } + + private void openBrowser(String url) throws Exception { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(URI.create(url)); + return; + } + new ProcessBuilder("open", url).start(); + } + + private String getString(JsonObject json, String key) { + if (json == null || !json.has(key) || json.get(key).isJsonNull()) { + return ""; + } + return json.get(key).getAsString(); + } + + private void printPass(String caseId, String detail) { + System.out.println("[E2E] " + caseId + " PASS " + detail); + } +} diff --git a/larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLiveHarness.java b/larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLiveHarness.java new file mode 100644 index 000000000..789fecd1f --- /dev/null +++ b/larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLiveHarness.java @@ -0,0 +1,125 @@ +package com.lark.oapi.e2e; + +import com.lark.oapi.core.auth.ClientAssertionToken; +import com.lark.oapi.core.auth.TargetInfo; +import org.junit.Test; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class TestClientAssertionLiveHarness { + + @Test + public void parseBoolAcceptsCommonValues() { + assertTrue(ClientAssertionLiveHarness.parseBool("true", false)); + assertTrue(ClientAssertionLiveHarness.parseBool("1", false)); + assertTrue(ClientAssertionLiveHarness.parseBool("yes", false)); + assertFalse(ClientAssertionLiveHarness.parseBool("false", true)); + assertFalse(ClientAssertionLiveHarness.parseBool("0", true)); + assertTrue(ClientAssertionLiveHarness.parseBool(null, true)); + } + + @Test + public void deployDomainsSupportOnlineAndBoe() { + assertEquals("https://open.feishu.cn", + ClientAssertionLiveHarness.deployDomains("online").getOpenApiDomain()); + assertEquals("https://accounts.feishu.cn", + ClientAssertionLiveHarness.deployDomains("cn").getOAuthBaseUrl()); + assertEquals("https://open.feishu-boe.cn", + ClientAssertionLiveHarness.deployDomains("boe").getOpenApiDomain()); + } + + @Test + public void loadEnvFilePreservesExistingEnvAndHandlesQuotedScope() throws Exception { + File envFile = File.createTempFile("client-assertion-live", ".env"); + try { + String content = "# comment\n" + + "export LARK_APP_ID=cli_file\n" + + "LARK_OAUTH_SCOPE=\"contact:user.basic_profile:readonly offline_access\"\n" + + "LARK_APP_SECRET=from_file # inline comment\n"; + try (FileOutputStream os = new FileOutputStream(envFile)) { + os.write(content.getBytes(StandardCharsets.UTF_8)); + } + + Map env = new HashMap<>(); + env.put("LARK_APP_ID", "cli_existing"); + + ClientAssertionLiveHarness.loadEnvFile(envFile, env, false); + + assertEquals("cli_existing", env.get("LARK_APP_ID")); + assertEquals("contact:user.basic_profile:readonly offline_access", env.get("LARK_OAUTH_SCOPE")); + assertEquals("from_file", env.get("LARK_APP_SECRET")); + } finally { + envFile.delete(); + } + } + + @Test + public void modeEnvProviderReturnsZtiWithoutTargetInfo() throws Exception { + Map values = new HashMap<>(); + values.put("LARK_ZTI_CLIENT_ASSERTION", "zti-token"); + ClientAssertionLiveHarness.ModeEnvProvider provider = + new ClientAssertionLiveHarness.ModeEnvProvider("zti", new ClientAssertionLiveHarness.Env(values)); + + ClientAssertionToken token = provider.retrieveToken("accounts.feishu.cn"); + + assertEquals("zti-token", token.getValue()); + assertNull(token.getTargetInfo()); + assertEquals("accounts.feishu.cn", provider.getAuds().get(0)); + } + + @Test + public void modeEnvProviderReturnsGdprWithTargetInfo() throws Exception { + Map values = new HashMap<>(); + values.put("LARK_GDPR_CLIENT_ASSERTION", "gdpr-token"); + values.put("LARK_GDPR_PROXY_SERVICE", "proxy.example.com"); + values.put("LARK_GDPR_PROXY_PREFIX", "/proxy"); + ClientAssertionLiveHarness.ModeEnvProvider provider = + new ClientAssertionLiveHarness.ModeEnvProvider("gdpr", new ClientAssertionLiveHarness.Env(values)); + + ClientAssertionToken token = provider.retrieveToken("open.feishu.cn"); + TargetInfo targetInfo = token.getTargetInfo(); + + assertEquals("gdpr-token", token.getValue()); + assertEquals("proxy.example.com", targetInfo.getTargetService()); + assertEquals("/proxy", targetInfo.getTargetPrefix()); + assertEquals("open.feishu.cn", provider.getAuds().get(0)); + } + + @Test + public void buildAuthorizeUrlUsesLocalRedirectWithoutPkce() { + String url = ClientAssertionLiveHarness.buildAuthorizeUrl( + "https://accounts.feishu.cn", + "cli_xxx", + "http://127.0.0.1:8765/uat_e2e/callback", + "contact:user.basic_profile:readonly offline_access", + "state-123", + ""); + + assertTrue(url.startsWith("https://accounts.feishu.cn/open-apis/authen/v1/authorize?")); + assertTrue(url.contains("app_id=cli_xxx")); + assertTrue(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fuat_e2e%2Fcallback")); + assertTrue(url.contains("scope=contact%3Auser.basic_profile%3Areadonly+offline_access")); + assertTrue(url.contains("state=state-123")); + assertFalse(url.contains("code_challenge")); + } + + @Test + public void modeEnabledSupportsCommaSeparatedAliases() { + Map values = new HashMap<>(); + values.put("LARK_E2E_MODES", "appsecret, gdpr"); + ClientAssertionLiveHarness.Env env = new ClientAssertionLiveHarness.Env(values); + + assertTrue(env.modeEnabled("app_secret")); + assertTrue(env.modeEnabled("gdpr")); + assertFalse(env.modeEnabled("zti")); + } +} From f847a234aebaeba14cc242eba64b27c5ec329350 Mon Sep 17 00:00:00 2001 From: WangWeijia Date: Wed, 10 Jun 2026 20:25:45 +0800 Subject: [PATCH 3/5] fix client assertion token handling Change-Id: I649ef2c5074e4556dfae43bfb06129a9aa8412c1 --- .gitignore | 16 +- CHANNEL.md | 2 +- ...ertion_java_e2e_result_20260610_2018.zh.md | 212 ++++++++++++++++++ .../java/com/lark/oapi/core/Transport.java | 60 ++++- .../oapi/core/accesstoken/AccessToken.java | 20 +- .../lark/oapi/core/token/TokenManager.java | 31 ++- .../main/java/com/lark/oapi/ws/Client.java | 7 +- .../core/TestTransportClientAssertion.java | 64 +++++- .../core/accesstoken/TestAccessToken.java | 39 +++- .../TestClientAssertionTokenManager.java | 73 +++++- .../oapi/ws/TestClientAssertionWsClient.java | 19 ++ 11 files changed, 496 insertions(+), 47 deletions(-) create mode 100644 doc/client_assertion_java_e2e_result_20260610_2018.zh.md diff --git a/.gitignore b/.gitignore index 18d3e60b2..4a970c333 100644 --- a/.gitignore +++ b/.gitignore @@ -43,19 +43,5 @@ skills plan skills-lock.json channel-diff-tasks -test -!larksuite-oapi/src/test/ -larksuite-oapi/src/test/** -!larksuite-oapi/src/test/**/ -!larksuite-oapi/src/test/java/com/lark/oapi/TestClientAssertionClientBuilder.java -!larksuite-oapi/src/test/java/com/lark/oapi/channel/TestClientAssertionChannelFactory.java -!larksuite-oapi/src/test/java/com/lark/oapi/core/TestTransportClientAssertion.java -!larksuite-oapi/src/test/java/com/lark/oapi/core/accesstoken/TestAccessToken.java -!larksuite-oapi/src/test/java/com/lark/oapi/core/auth/TestClientAssertionUtils.java -!larksuite-oapi/src/test/java/com/lark/oapi/core/token/TestClientAssertionTokenManager.java -!larksuite-oapi/src/test/java/com/lark/oapi/e2e/ClientAssertionLiveHarness.java -!larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLiveHarness.java -!larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLocalE2E.java -!larksuite-oapi/src/test/java/com/lark/oapi/e2e/TestClientAssertionLiveE2E.java -!larksuite-oapi/src/test/java/com/lark/oapi/ws/TestClientAssertionWsClient.java +/test docs/superpowers/plans diff --git a/CHANNEL.md b/CHANNEL.md index 32e557e49..e6adbe618 100644 --- a/CHANNEL.md +++ b/CHANNEL.md @@ -93,7 +93,7 @@ public class AgentBot { com.larksuite.oapi oapi-sdk - 2.7.1 + 2.7.3 ``` diff --git a/doc/client_assertion_java_e2e_result_20260610_2018.zh.md b/doc/client_assertion_java_e2e_result_20260610_2018.zh.md new file mode 100644 index 000000000..368b531a5 --- /dev/null +++ b/doc/client_assertion_java_e2e_result_20260610_2018.zh.md @@ -0,0 +1,212 @@ +# Java SDK ClientAssertion 修改与 E2E 测试报告 + +## 1. 结论 + +本轮 Java SDK `codex/keyless-logic` 分支修改后,ClientAssertion 相关本地回归、完整模块测试和真实 live E2E 均通过。 + +结论:通过。 + +理由: + +- ClientAssertion tenant token cache 已改为可在 Provider 执行前计算的方案 B,cache hit 时不会调用 Provider,也不会发起 token exchange。 +- AppSecret 与 ClientAssertion cache namespace 已隔离,避免旧 AppSecret cache 命中 ClientAssertion 链路。 +- 新增负向用例覆盖 V3 token API 业务错误、缺失 access token、Provider 抛错、不应重试、日志敏感字段省略、WS 7102 包装。 +- 真实 live E2E 覆盖 AppSecret、ZTI、GDPR 三种链路,包含 OAuth authorization code、refresh token、UAT 调用、tenant token 发消息和 WebSocket endpoint/connect,结果全部通过。 +- 本轮执行中未将原始 AppSecret、ClientAssertion、authorization code、access token、refresh token 写入代码、报告或命令行参数。 + +## 2. 本轮修改摘要 + +### 2.1 Cache Key 方案 + +采用方案 B,并保留旧逻辑已有的 `tenant_key` 维度,避免 ISV 多租户场景串用 tenant token。 + +- AppSecret tenant token key: + - `tenant_token:app_secret:{app_id}:{tenant_key}` +- ClientAssertion tenant token key: + - `tenant_token:client_assertion:{app_id}:{tenant_key}:{aud}` + +行为变化: + +- ClientAssertion 模式下先通过 SDK 配置推导 `aud`,再查 cache。 +- cache hit 时直接返回 tenant token,不执行 `ClientAssertionProvider.retrieveToken(aud)`。 +- cache miss 时才执行 Provider,并按 `TargetInfo` 决定是否走 proxy。 +- `TargetInfo` 不再参与 cache key,符合“不要让 Provider 先执行”的要求。 + +### 2.2 安全与错误语义 + +- debug 请求日志中,敏感 header 和 body 字段直接省略: + - `Authorization` + - `client_assertion` + - `client_secret` + - `refresh_token` + - `access_token` + - `tenant_access_token` + - `app_access_token` +- OAuth V3 token response 按 root `code` 判断业务成功失败: + - HTTP 200 但 `code != 0` 抛 `AccessTokenError` + - `code == 0` 但缺少 `access_token` 抛 `AccessTokenError` +- Transport 不再对 ClientAssertion Provider retrieve 失败做内部重试。 +- WebSocket Provider retrieve 失败包装为 `ClientException(7102, ...)`。 + +## 3. 新增/调整测试 + +### 3.1 Cache 测试 + +- `appSecretTenantTokenUsesModeSpecificCacheKey` + - 验证 AppSecret 使用 `tenant_token:app_secret:{app_id}:{tenant_key}`。 +- `clientAssertionCacheHitUsesModeSpecificKeyBeforeProviderAndAvoidsTransport` + - 验证 ClientAssertion cache hit 时 Provider 调用次数为 0,Transport 不发请求。 +- `legacyAppSecretCacheDoesNotBypassClientAssertionProvider` + - 验证 AppSecret 旧 namespace 不会绕过 ClientAssertion Provider。 +- `targetInfoDoesNotChangeClientAssertionCacheKey` + - 验证 cache key 不依赖 Provider 返回的 `TargetInfo`。 + +### 3.2 负向测试 + +- `v3BusinessErrorWithHttp200ThrowsAccessTokenError` + - HTTP 200 但 V3 `code != 0` 必须抛错。 +- `v3SuccessCodeWithoutAccessTokenThrowsAccessTokenError` + - V3 `code == 0` 但缺少 `access_token` 必须抛错。 +- `providerRetrieveFailureDoesNotRetryInsideTransport` + - Provider retrieve 失败不在 Transport 内部重试。 +- `debugRequestLogOmitsSensitiveHeadersAndBody` + - debug log 不出现 raw assertion、secret、token。 +- `providerRetrieveFailureIsWrappedWith7102` + - WS Provider 抛错时包装为 7102。 + +## 4. 验证记录 + +### 4.1 RED 验证 + +命令: + +```bash +mvn -pl larksuite-oapi -DskipTests=false -Dmaven.test.skip=false -Dtest=TestClientAssertionTokenManager test +``` + +修改测试后、实现前,结果符合预期失败: + +- Tests run: 10 +- Failures: 3 +- Errors: 1 +- Skipped: 0 + +失败原因与预期一致: + +- 旧实现仍使用 `tenant_access_token-ca-...` key。 +- cache hit 前仍执行 Provider。 +- `TargetInfo` 仍参与 cache key。 +- 命中方案 B cache 时仍继续走 token exchange,导致空响应错误。 + +### 4.2 TokenManager 定向回归 + +命令: + +```bash +mvn -pl larksuite-oapi -DskipTests=false -Dmaven.test.skip=false -Dtest=TestClientAssertionTokenManager test +``` + +结果: + +- Tests run: 11 +- Failures: 0 +- Errors: 0 +- Skipped: 0 + +### 4.3 ClientAssertion 相关定向回归 + +命令: + +```bash +mvn -pl larksuite-oapi -DskipTests=false -Dmaven.test.skip=false -Dtest=TestClientAssertionTokenManager,TestTransportClientAssertion,TestAccessToken,TestClientAssertionWsClient test +``` + +结果: + +- Tests run: 37 +- Failures: 0 +- Errors: 0 +- Skipped: 0 + +### 4.4 完整模块测试 + +命令: + +```bash +mvn -pl larksuite-oapi -DskipTests=false -Dmaven.test.skip=false test +``` + +结果: + +- Tests run: 225 +- Failures: 0 +- Errors: 0 +- Skipped: 4 + +Skipped 原因: + +- `TestLarkChannelIntegration` 2 个用例: + - 需要显式设置 `LARK_CHANNEL_IT_ENABLED=true`、`LARK_CHANNEL_IT_APP_ID`、`LARK_CHANNEL_IT_APP_SECRET`。 + - 属于真实 Feishu channel integration 测试,默认不在模块测试中启用。 +- `TestClientAssertionLiveE2E` 2 个用例: + - 完整模块测试未注入 live E2E 环境,因此被 `LARK_CLIENT_ASSERTION_E2E=1` 门控跳过。 + - 下方已通过单独 live E2E 命令完整执行,未跳过。 + +### 4.5 完整 Live E2E + +命令: + +```bash +bash -lc 'set +x +IFS= read -rs LARK_ZTI_CLIENT_ASSERTION +printf "\n" +export LARK_ZTI_CLIENT_ASSERTION +mvn -pl larksuite-oapi -DskipTests=false -Dmaven.test.skip=false \ + -Dlark.e2e.envFile=/Users/bytedance/Documents/oapi_sdk/oapi-sdk-python/.env.e2e \ + -Dtest=TestClientAssertionLiveE2E test' +``` + +说明: + +- ZTI ClientAssertion 通过 stdin 注入,不进入命令行参数。 +- `.env.e2e` 提供 AppID、AppSecret、GDPR assertion、Proxy、OAuth redirect、scope、open_id 等环境。 +- 真实 OAuth 授权通过浏览器完成。 + +结果: + +- Tests run: 2 +- Failures: 0 +- Errors: 0 +- Skipped: 0 +- Total time: 02:38 min +- Finished at: 2026-06-10T20:18:12+08:00 + +通过的 live case: + +| Case | 覆盖点 | 结果 | +| --- | --- | --- | +| `OAUTH-APPSECRET` | AppSecret authorization code 换 UAT、refresh、UAT basic_batch | PASS | +| `ZTI-03/04/05/06` | ZTI ClientAssertion authorization code、refresh、UAT basic_batch | PASS | +| `GDPR-03/04/05` | GDPR ClientAssertion + Proxy authorization code、refresh、UAT basic_batch | PASS | +| `SECRET-02` | AppSecret tenant token 发 IM 消息 | PASS | +| `ZTI-02` | ZTI ClientAssertion tenant token 发 IM 消息 | PASS | +| `GDPR-TAT` | GDPR ClientAssertion + Proxy tenant token 发 IM 消息 | PASS | +| `ENV-05` | Provider 优先于 AppSecret | PASS | +| `SECRET-04` | AppSecret WS endpoint/connect | PASS | +| `WS-01/WS-03-ZTI` | ZTI WS endpoint/connect | PASS | +| `WS-02/WS-03-GDPR` | GDPR WS endpoint/connect | PASS | + +## 5. 结论依据 + +本轮可以给出“通过”结论,是因为: + +- 本地测试覆盖了本轮改动的关键分支和负向语义,且先观察到 RED,再通过实现修复变为 GREEN。 +- 完整模块测试通过,说明改动没有破坏现有 SDK 单测、Channel、事件、WS 和 E2E harness 的默认测试面。 +- live E2E 真实命中了飞书线上 OAuth/OpenAPI/WS 链路,并覆盖 AppSecret、ZTI、GDPR 三种凭证路径。 +- ZTI live case 已使用本轮用户更新后的 ClientAssertion,上一轮过期错误不再出现。 +- 敏感信息处理通过单测断言,且执行过程中没有把原始凭证写入报告或仓库文件。 + +## 6. 备注 + +- Maven 仍提示 `gson` 依赖重复声明 warning:`com.google.code.gson:gson` 存在 `${gson.version}` 与 `2.9.0` 两处声明。本轮未修改依赖管理,建议后续单独清理。 +- `rg` 扫描 JWT 模式时命中仓库既有 Javadoc/sample 示例 page_token,不是本轮新增的 ZTI ClientAssertion。 diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/Transport.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/Transport.java index b03d2f250..69bbf42fa 100644 --- a/larksuite-oapi/src/main/java/com/lark/oapi/core/Transport.java +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/Transport.java @@ -33,12 +33,17 @@ import java.io.InterruptedIOException; import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Set; public class Transport { private static final Logger log = LoggerFactory.getLogger(Transport.class); private static final ReqTranslator REQ_TRANSLATOR = new ReqTranslator(); + private static final String OMITTED = ""; private static AccessTokenType determineTokenType(Set accessTokenTypeSet, RequestOptions requestOptions, boolean disableTokenCache, @@ -246,16 +251,60 @@ private static void logReq(RawRequest req, String httpPath, boolean isUpload) { if (!isUpload) { log.debug("req,path:{},header:{},body:{}", httpPath - , Jsons.DEFAULT.toJson(req.getHeaders()) - , req.getBody() == null ? "" : Jsons.DEFAULT.toJson(req.getBody())); + , Jsons.DEFAULT.toJson(safeHeaders(req.getHeaders())) + , safeBody(req.getBody())); } else { - log.debug("req,path:{},header:{}", httpPath, req.getHeaders()); + log.debug("req,path:{},header:{}", httpPath, safeHeaders(req.getHeaders())); } } catch (Throwable e) { log.error("logReq error:{}", e); } } + private static Map> safeHeaders(Map> headers) { + Map> safeHeaders = new HashMap<>(); + if (headers == null) { + return safeHeaders; + } + headers.entrySet().stream().forEach(entry -> { + if (!isSensitiveKey(entry.getKey())) { + safeHeaders.put(entry.getKey(), entry.getValue()); + } + }); + return safeHeaders; + } + + private static String safeBody(Object body) { + if (body == null) { + return ""; + } + String json = Jsons.DEFAULT.toJson(body); + return containsSensitiveField(json) ? OMITTED : json; + } + + private static boolean containsSensitiveField(String json) { + if (Strings.isEmpty(json)) { + return false; + } + String normalized = json.toLowerCase(Locale.ROOT); + return normalized.contains("\"client_secret\"") + || normalized.contains("\"clientassertion\"") + || normalized.contains("\"client_assertion\"") + || normalized.contains("\"refresh_token\"") + || normalized.contains("\"access_token\"") + || normalized.contains("\"tenant_access_token\"") + || normalized.contains("\"app_access_token\""); + } + + private static boolean isSensitiveKey(String key) { + if (Strings.isEmpty(key)) { + return false; + } + String normalized = key.toLowerCase(Locale.ROOT); + return "authorization".equals(normalized) + || Constants.X_HELPDESK_AUTHORIZATION.toLowerCase(Locale.ROOT).equals(normalized); + } + private static RawResponse doSend(Config config, String httpMethod, String httpPath, AccessTokenType accessTokenType, Object req, RequestOptions requestOptions) throws Exception { Exception error = null; @@ -299,11 +348,6 @@ private static RawResponse doSend(Config config, String httpMethod, String httpP return rawResponse; } catch (Exception e) { error = e; - if (e instanceof ClientAssertionException - && ((ClientAssertionException) e).getCode() == Constants.ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED - && i == 0) { - continue; - } // 获取token失败,重试一次,其他请求不重试 if (accessTokenType != AccessTokenType.None) { throw e; diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessToken.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessToken.java index 5518bde53..74d71255b 100644 --- a/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessToken.java +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessToken.java @@ -130,8 +130,26 @@ private AccessTokenResp send(PreparedRequest prepared, RequestOptions options) t rawResponse); } + int code = getInt(jsonObject, "code"); + if (code != 0) { + throw new AccessTokenError(rawResponse.getStatusCode(), + code, + getString(jsonObject, "error"), + getString(jsonObject, "error_description"), + rawResponse); + } + + String accessToken = getString(jsonObject, "access_token"); + if (Strings.isEmpty(accessToken)) { + throw new AccessTokenError(rawResponse.getStatusCode(), + code, + getString(jsonObject, "error"), + "access_token is empty", + rawResponse); + } + AccessTokenRespData data = new AccessTokenRespData(); - data.setAccessToken(getString(jsonObject, "access_token")); + data.setAccessToken(accessToken); data.setTokenType(getString(jsonObject, "token_type")); data.setExpiresIn(getInt(jsonObject, "expires_in")); data.setRefreshToken(getString(jsonObject, "refresh_token")); diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/core/token/TokenManager.java b/larksuite-oapi/src/main/java/com/lark/oapi/core/token/TokenManager.java index 0a42ba47a..db32b26c3 100644 --- a/larksuite-oapi/src/main/java/com/lark/oapi/core/token/TokenManager.java +++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/token/TokenManager.java @@ -50,7 +50,7 @@ public class TokenManager { private static final Logger log = LoggerFactory.getLogger(TokenManager.class); private static final int expiryDeltaOfSecond = 3 * 60; private static final String appAccessTokenKeyPrefix = "app_access_token"; - private static final String tenantAccessTokenKeyPrefix = "tenant_access_token"; + private static final String tenantAccessTokenKeyPrefix = "tenant_token"; private ICache cache; public TokenManager(ICache cache) { @@ -146,17 +146,11 @@ private AppAccessTokenResp getIsvAppAccessToken(Config config) throws Exception } private String getTenantAccessTokenKey(String appID, String tenantKey) { - return tenantAccessTokenKeyPrefix + "-" + appID + "-" + tenantKey; + return tenantAccessTokenKeyPrefix + ":app_secret:" + appID + ":" + normalizeTenantKey(tenantKey); } public String getTenantAccessToken(Config config, String tenantKey) throws Exception { if (config.getClientAssertionProvider() != null) { - if (!config.isDisableTokenCache()) { - String token = cache.get(getTenantAccessTokenKey(config.getAppId(), tenantKey)); - if (Strings.isNotEmpty(token)) { - return token; - } - } return getTenantTokenByClientAssertion(config, tenantKey); } @@ -187,6 +181,14 @@ public String getTenantAccessToken(Config config, String tenantKey) throws Excep private String getTenantTokenByClientAssertion(Config config, String tenantKey) throws Exception { String oauthBaseUrl = ClientAssertionUtils.resolveOAuthBaseUrl(config); String aud = ClientAssertionUtils.resolveOAuthAud(config); + String tokenKey = getClientAssertionTenantAccessTokenKey(config.getAppId(), tenantKey, aud); + if (!config.isDisableTokenCache()) { + String cachedToken = cache.get(tokenKey); + if (Strings.isNotEmpty(cachedToken)) { + return cachedToken; + } + } + ClientAssertionToken assertionToken; try { assertionToken = config.getClientAssertionProvider().retrieveToken(aud); @@ -235,12 +237,23 @@ private String getTenantTokenByClientAssertion(Config config, String tenantKey) int expiresIn = getInt(respBody, "expires_in"); if (!config.isDisableTokenCache()) { - cache.set(getTenantAccessTokenKey(config.getAppId(), tenantKey), token, + cache.set(tokenKey, token, Math.max(expiresIn - expiryDeltaOfSecond, 0), TimeUnit.SECONDS); } return token; } + private String getClientAssertionTenantAccessTokenKey(String appID, + String tenantKey, + String aud) { + return tenantAccessTokenKeyPrefix + ":client_assertion:" + appID + ":" + + normalizeTenantKey(tenantKey) + ":" + aud; + } + + private String normalizeTenantKey(String tenantKey) { + return tenantKey == null ? "" : tenantKey; + } + private JsonObject parseBody(RawResponse resp) { if (resp.getBody() == null || resp.getBody().length == 0) { return new JsonObject(); diff --git a/larksuite-oapi/src/main/java/com/lark/oapi/ws/Client.java b/larksuite-oapi/src/main/java/com/lark/oapi/ws/Client.java index 297b5ff19..139b4790b 100644 --- a/larksuite-oapi/src/main/java/com/lark/oapi/ws/Client.java +++ b/larksuite-oapi/src/main/java/com/lark/oapi/ws/Client.java @@ -371,7 +371,12 @@ private BootstrapPreparedRequest prepareBootstrapRequest() throws Exception { } String aud = ClientAssertionUtils.extractAudFromUrl(this.domain); - ClientAssertionToken token = this.clientAssertionProvider.retrieveToken(aud); + ClientAssertionToken token; + try { + token = this.clientAssertionProvider.retrieveToken(aud); + } catch (Exception e) { + throw new ClientException(Constants.ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED, e.getMessage(), e); + } if (token == null || Strings.isEmpty(token.getValue())) { throw new ClientException(Constants.ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY, "client assertion token is empty"); diff --git a/larksuite-oapi/src/test/java/com/lark/oapi/core/TestTransportClientAssertion.java b/larksuite-oapi/src/test/java/com/lark/oapi/core/TestTransportClientAssertion.java index e218a9fa1..e051ae920 100644 --- a/larksuite-oapi/src/test/java/com/lark/oapi/core/TestTransportClientAssertion.java +++ b/larksuite-oapi/src/test/java/com/lark/oapi/core/TestTransportClientAssertion.java @@ -1,5 +1,8 @@ package com.lark.oapi.core; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import com.lark.oapi.core.auth.ClientAssertionToken; import com.lark.oapi.core.cache.ICache; import com.lark.oapi.core.enums.AppType; @@ -11,15 +14,22 @@ import com.lark.oapi.core.token.AccessTokenType; import com.lark.oapi.core.token.GlobalTokenManager; import com.lark.oapi.core.token.TokenManager; +import com.lark.oapi.core.utils.Lists; import com.lark.oapi.core.utils.Sets; import org.junit.Test; +import org.slf4j.LoggerFactory; import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; public class TestTransportClientAssertion { @@ -99,7 +109,7 @@ public void emptyAppSecretAllowedWithMatchingManualTenantToken() throws Exceptio } @Test - public void providerRetrieveFailureRetriesOnce() throws Exception { + public void providerRetrieveFailureDoesNotRetryInsideTransport() throws Exception { TokenManager previous = GlobalTokenManager.getTokenManager(); try { CapturingTransport transport = new CapturingTransport(); @@ -108,15 +118,59 @@ public void providerRetrieveFailureRetriesOnce() throws Exception { FlakyTokenManager tokenManager = new FlakyTokenManager(); GlobalTokenManager.setTokenManager(tokenManager); - Transport.send(config, null, "GET", "/resource", Sets.newHashSet(AccessTokenType.Tenant), null); - - assertEquals(2, tokenManager.calls.get()); - assertEquals("Bearer tenant-token", transport.lastRequest.getHeaders().get("Authorization").get(0)); + try { + Transport.send(config, null, "GET", "/resource", Sets.newHashSet(AccessTokenType.Tenant), null); + } catch (ClientAssertionException e) { + assertEquals(Constants.ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED, e.getCode()); + assertEquals(1, tokenManager.calls.get()); + assertNull(transport.lastRequest); + return; + } + throw new AssertionError("expected ClientAssertionException"); } finally { GlobalTokenManager.setTokenManager(previous); } } + @Test + public void debugRequestLogOmitsSensitiveHeadersAndBody() throws Exception { + ch.qos.logback.classic.Logger logger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(Transport.class); + Level previousLevel = logger.getLevel(); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.DEBUG); + try { + Config config = config(); + config.setLogReqAtDebug(true); + config.setHttpTransport(new CapturingTransport()); + Map> headers = new HashMap<>(); + headers.put("Authorization", Lists.newArrayList("Bearer raw-access-token")); + RequestOptions options = RequestOptions.newBuilder() + .headers(headers) + .build(); + Map body = new HashMap<>(); + body.put("client_assertion", "raw-client-assertion"); + body.put("client_secret", "raw-client-secret"); + body.put("refresh_token", "raw-refresh-token"); + + Transport.send(config, options, "POST", "/oauth/v3/token", Sets.newHashSet(AccessTokenType.None), body); + + String logs = appender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .collect(Collectors.joining("\n")); + assertFalse(logs.contains("raw-client-assertion")); + assertFalse(logs.contains("raw-client-secret")); + assertFalse(logs.contains("raw-refresh-token")); + assertFalse(logs.contains("raw-access-token")); + assertTrue(logs.contains("body:")); + } finally { + logger.detachAppender(appender); + logger.setLevel(previousLevel); + } + } + @Test public void emptyAssertionFailureDoesNotRetry() throws Exception { TokenManager previous = GlobalTokenManager.getTokenManager(); diff --git a/larksuite-oapi/src/test/java/com/lark/oapi/core/accesstoken/TestAccessToken.java b/larksuite-oapi/src/test/java/com/lark/oapi/core/accesstoken/TestAccessToken.java index 9b30d0b34..a2733dd3f 100644 --- a/larksuite-oapi/src/test/java/com/lark/oapi/core/accesstoken/TestAccessToken.java +++ b/larksuite-oapi/src/test/java/com/lark/oapi/core/accesstoken/TestAccessToken.java @@ -160,6 +160,43 @@ public void non200OAuthErrorThrowsAccessTokenError() throws Exception { throw new AssertionError("expected AccessTokenError"); } + @Test + public void v3BusinessErrorWithHttp200ThrowsAccessTokenError() throws Exception { + CapturingTransport transport = new CapturingTransport( + "{\"code\":20050,\"error\":\"server_error\",\"error_description\":\"retry later\"}", + 200); + Config config = providerConfig(transport); + config.setClientAssertionProvider(aud -> new ClientAssertionToken("client-assertion")); + + try { + new AccessToken(config).refresh(RefreshTokenRequest.newBuilder().refreshToken("refresh-token").build()); + } catch (AccessTokenError e) { + assertEquals(200, e.getStatusCode()); + assertEquals(20050, e.getCode()); + assertEquals("server_error", e.getErrorType()); + assertEquals("retry later", e.getErrorDescription()); + return; + } + throw new AssertionError("expected AccessTokenError"); + } + + @Test + public void v3SuccessCodeWithoutAccessTokenThrowsAccessTokenError() throws Exception { + CapturingTransport transport = new CapturingTransport("{\"code\":0,\"expires_in\":7200}", 200); + Config config = providerConfig(transport); + config.setClientAssertionProvider(aud -> new ClientAssertionToken("client-assertion")); + + try { + new AccessToken(config).refresh(RefreshTokenRequest.newBuilder().refreshToken("refresh-token").build()); + } catch (AccessTokenError e) { + assertEquals(200, e.getStatusCode()); + assertEquals(0, e.getCode()); + assertTrue(e.getMessage().contains("access_token")); + return; + } + throw new AssertionError("expected AccessTokenError"); + } + @Test public void successResponseMapsAllTokenFields() throws Exception { CapturingTransport transport = new CapturingTransport(successBody(), 200); @@ -194,7 +231,7 @@ private JsonObject requestBody(CapturingTransport transport) { } private String successBody() { - return "{\"access_token\":\"oauth-access-token\",\"token_type\":\"Bearer\",\"expires_in\":7200,\"refresh_token\":\"new-refresh-token\",\"refresh_token_expires_in\":604800,\"scope\":\"contact:user.base:readonly\"}"; + return "{\"code\":0,\"access_token\":\"oauth-access-token\",\"token_type\":\"Bearer\",\"expires_in\":7200,\"refresh_token\":\"new-refresh-token\",\"refresh_token_expires_in\":604800,\"scope\":\"contact:user.base:readonly\"}"; } private static class CapturingTransport implements IHttpTransport { diff --git a/larksuite-oapi/src/test/java/com/lark/oapi/core/token/TestClientAssertionTokenManager.java b/larksuite-oapi/src/test/java/com/lark/oapi/core/token/TestClientAssertionTokenManager.java index 41bc3d27a..bd8409ab1 100644 --- a/larksuite-oapi/src/test/java/com/lark/oapi/core/token/TestClientAssertionTokenManager.java +++ b/larksuite-oapi/src/test/java/com/lark/oapi/core/token/TestClientAssertionTokenManager.java @@ -15,6 +15,8 @@ import org.junit.Test; import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -46,7 +48,7 @@ public void tenantTokenUsesOAuthJwtBearerRequestAndCachesToken() throws Exceptio assertEquals(Constants.CLIENT_ASSERTION_TYPE_JWT_BEARER, body.get("client_assertion_type").getAsString()); assertEquals("client-assertion", body.get("client_assertion").getAsString()); assertEquals("cli_a", body.get("client_id").getAsString()); - assertEquals("tenant_access_token-cli_a-", cache.key); + assertEquals("tenant_token:client_assertion:cli_a::accounts.feishu.cn", cache.key); assertEquals("tenant-token", cache.value); assertEquals(7020, cache.expire); } @@ -66,9 +68,25 @@ public void targetInfoUsesProxyUrlAndTargetServiceHeader() throws Exception { } @Test - public void cacheHitAvoidsProviderAndTransport() throws Exception { + public void appSecretTenantTokenUsesModeSpecificCacheKey() throws Exception { CapturingCache cache = new CapturingCache(); - cache.cached = "tenant-token"; + CapturingTransport transport = new CapturingTransport( + "{\"code\":0,\"tenant_access_token\":\"app-secret-token\",\"expire\":7200}"); + Config config = config(transport); + config.setAppSecret("app-secret"); + + String token = new TokenManager(cache).getTenantAccessToken(config, "tenant-key"); + + assertEquals("app-secret-token", token); + assertEquals("tenant_token:app_secret:cli_a:tenant-key", cache.getKey); + assertEquals("tenant_token:app_secret:cli_a:tenant-key", cache.key); + assertEquals(7020, cache.expire); + } + + @Test + public void clientAssertionCacheHitUsesModeSpecificKeyBeforeProviderAndAvoidsTransport() throws Exception { + CapturingCache cache = new CapturingCache(); + cache.values.put("tenant_token:client_assertion:cli_a:tenant-key:accounts.feishu.cn", "tenant-token"); CapturingTransport transport = new CapturingTransport("{}"); AtomicInteger calls = new AtomicInteger(); Config config = config(transport); @@ -80,7 +98,49 @@ public void cacheHitAvoidsProviderAndTransport() throws Exception { String token = new TokenManager(cache).getTenantAccessToken(config, "tenant-key"); assertEquals("tenant-token", token); - assertEquals("tenant_access_token-cli_a-tenant-key", cache.getKey); + assertEquals("tenant_token:client_assertion:cli_a:tenant-key:accounts.feishu.cn", cache.getKey); + assertEquals(0, calls.get()); + assertNull(transport.request); + } + + @Test + public void legacyAppSecretCacheDoesNotBypassClientAssertionProvider() throws Exception { + CapturingCache cache = new CapturingCache(); + cache.values.put("tenant_token:app_secret:cli_a:tenant-key", "legacy-appsecret-token"); + CapturingTransport transport = new CapturingTransport("{\"access_token\":\"client-assertion-token\",\"expires_in\":7200}"); + AtomicInteger calls = new AtomicInteger(); + Config config = config(transport); + config.setClientAssertionProvider(aud -> { + calls.incrementAndGet(); + return new ClientAssertionToken("client-assertion"); + }); + + String token = new TokenManager(cache).getTenantAccessToken(config, "tenant-key"); + + assertEquals("client-assertion-token", token); + assertEquals(1, calls.get()); + assertEquals("tenant_token:client_assertion:cli_a:tenant-key:accounts.feishu.cn", cache.getKey); + assertEquals("tenant_token:client_assertion:cli_a:tenant-key:accounts.feishu.cn", cache.key); + } + + @Test + public void targetInfoDoesNotChangeClientAssertionCacheKey() throws Exception { + CapturingCache cache = new CapturingCache(); + cache.values.put("tenant_token:client_assertion:cli_a::accounts.feishu.cn", "cached-token"); + CapturingTransport transport = new CapturingTransport("{\"access_token\":\"proxy-token\",\"expires_in\":7200}"); + AtomicInteger calls = new AtomicInteger(); + Config config = config(transport); + config.setClientAssertionProvider(aud -> { + calls.incrementAndGet(); + return new ClientAssertionToken( + "client-assertion", + new TargetInfo("proxy.example.com", "/proxy")); + }); + + String token = new TokenManager(cache).getTenantAccessToken(config, ""); + + assertEquals("cached-token", token); + assertEquals("tenant_token:client_assertion:cli_a::accounts.feishu.cn", cache.getKey); assertEquals(0, calls.get()); assertNull(transport.request); } @@ -190,7 +250,7 @@ public RawResponse execute(RawRequest request) { } private static class CapturingCache implements ICache { - private String cached = ""; + private final Map values = new HashMap<>(); private String getKey; private String key; private String value; @@ -202,7 +262,8 @@ private static class CapturingCache implements ICache { public String get(String key) { this.getKey = key; this.getCount++; - return cached; + String value = values.get(key); + return value == null ? "" : value; } @Override diff --git a/larksuite-oapi/src/test/java/com/lark/oapi/ws/TestClientAssertionWsClient.java b/larksuite-oapi/src/test/java/com/lark/oapi/ws/TestClientAssertionWsClient.java index fad3ef96b..52f51d628 100644 --- a/larksuite-oapi/src/test/java/com/lark/oapi/ws/TestClientAssertionWsClient.java +++ b/larksuite-oapi/src/test/java/com/lark/oapi/ws/TestClientAssertionWsClient.java @@ -106,6 +106,25 @@ public void emptyAssertionFailsWith7101() throws Exception { throw new AssertionError("expected ClientException"); } + @Test + public void providerRetrieveFailureIsWrappedWith7102() throws Exception { + Client client = new Client.Builder("cli_a", "") + .domain("https://open.feishu.cn") + .clientAssertionProvider(aud -> { + throw new IllegalStateException("kms down"); + }) + .build(); + + try { + invokeGetConnUrl(client); + } catch (ClientException e) { + assertTrue(e.toString().contains("7102")); + assertTrue(e.getMessage().contains("kms down")); + return; + } + throw new AssertionError("expected ClientException"); + } + @Test public void missingCredentialsFailsWith7104() throws Exception { Client client = new Client.Builder("cli_a", "") From 803688dc78626944f04065de948a58d5d8fa86ad Mon Sep 17 00:00:00 2001 From: WangWeijia Date: Wed, 10 Jun 2026 20:29:41 +0800 Subject: [PATCH 4/5] feat: del test doc Change-Id: I132e1486b55fb1c1b2b2a7d4322c566c3cd97a8e --- ...ertion_java_e2e_result_20260610_2018.zh.md | 212 ------------------ 1 file changed, 212 deletions(-) delete mode 100644 doc/client_assertion_java_e2e_result_20260610_2018.zh.md diff --git a/doc/client_assertion_java_e2e_result_20260610_2018.zh.md b/doc/client_assertion_java_e2e_result_20260610_2018.zh.md deleted file mode 100644 index 368b531a5..000000000 --- a/doc/client_assertion_java_e2e_result_20260610_2018.zh.md +++ /dev/null @@ -1,212 +0,0 @@ -# Java SDK ClientAssertion 修改与 E2E 测试报告 - -## 1. 结论 - -本轮 Java SDK `codex/keyless-logic` 分支修改后,ClientAssertion 相关本地回归、完整模块测试和真实 live E2E 均通过。 - -结论:通过。 - -理由: - -- ClientAssertion tenant token cache 已改为可在 Provider 执行前计算的方案 B,cache hit 时不会调用 Provider,也不会发起 token exchange。 -- AppSecret 与 ClientAssertion cache namespace 已隔离,避免旧 AppSecret cache 命中 ClientAssertion 链路。 -- 新增负向用例覆盖 V3 token API 业务错误、缺失 access token、Provider 抛错、不应重试、日志敏感字段省略、WS 7102 包装。 -- 真实 live E2E 覆盖 AppSecret、ZTI、GDPR 三种链路,包含 OAuth authorization code、refresh token、UAT 调用、tenant token 发消息和 WebSocket endpoint/connect,结果全部通过。 -- 本轮执行中未将原始 AppSecret、ClientAssertion、authorization code、access token、refresh token 写入代码、报告或命令行参数。 - -## 2. 本轮修改摘要 - -### 2.1 Cache Key 方案 - -采用方案 B,并保留旧逻辑已有的 `tenant_key` 维度,避免 ISV 多租户场景串用 tenant token。 - -- AppSecret tenant token key: - - `tenant_token:app_secret:{app_id}:{tenant_key}` -- ClientAssertion tenant token key: - - `tenant_token:client_assertion:{app_id}:{tenant_key}:{aud}` - -行为变化: - -- ClientAssertion 模式下先通过 SDK 配置推导 `aud`,再查 cache。 -- cache hit 时直接返回 tenant token,不执行 `ClientAssertionProvider.retrieveToken(aud)`。 -- cache miss 时才执行 Provider,并按 `TargetInfo` 决定是否走 proxy。 -- `TargetInfo` 不再参与 cache key,符合“不要让 Provider 先执行”的要求。 - -### 2.2 安全与错误语义 - -- debug 请求日志中,敏感 header 和 body 字段直接省略: - - `Authorization` - - `client_assertion` - - `client_secret` - - `refresh_token` - - `access_token` - - `tenant_access_token` - - `app_access_token` -- OAuth V3 token response 按 root `code` 判断业务成功失败: - - HTTP 200 但 `code != 0` 抛 `AccessTokenError` - - `code == 0` 但缺少 `access_token` 抛 `AccessTokenError` -- Transport 不再对 ClientAssertion Provider retrieve 失败做内部重试。 -- WebSocket Provider retrieve 失败包装为 `ClientException(7102, ...)`。 - -## 3. 新增/调整测试 - -### 3.1 Cache 测试 - -- `appSecretTenantTokenUsesModeSpecificCacheKey` - - 验证 AppSecret 使用 `tenant_token:app_secret:{app_id}:{tenant_key}`。 -- `clientAssertionCacheHitUsesModeSpecificKeyBeforeProviderAndAvoidsTransport` - - 验证 ClientAssertion cache hit 时 Provider 调用次数为 0,Transport 不发请求。 -- `legacyAppSecretCacheDoesNotBypassClientAssertionProvider` - - 验证 AppSecret 旧 namespace 不会绕过 ClientAssertion Provider。 -- `targetInfoDoesNotChangeClientAssertionCacheKey` - - 验证 cache key 不依赖 Provider 返回的 `TargetInfo`。 - -### 3.2 负向测试 - -- `v3BusinessErrorWithHttp200ThrowsAccessTokenError` - - HTTP 200 但 V3 `code != 0` 必须抛错。 -- `v3SuccessCodeWithoutAccessTokenThrowsAccessTokenError` - - V3 `code == 0` 但缺少 `access_token` 必须抛错。 -- `providerRetrieveFailureDoesNotRetryInsideTransport` - - Provider retrieve 失败不在 Transport 内部重试。 -- `debugRequestLogOmitsSensitiveHeadersAndBody` - - debug log 不出现 raw assertion、secret、token。 -- `providerRetrieveFailureIsWrappedWith7102` - - WS Provider 抛错时包装为 7102。 - -## 4. 验证记录 - -### 4.1 RED 验证 - -命令: - -```bash -mvn -pl larksuite-oapi -DskipTests=false -Dmaven.test.skip=false -Dtest=TestClientAssertionTokenManager test -``` - -修改测试后、实现前,结果符合预期失败: - -- Tests run: 10 -- Failures: 3 -- Errors: 1 -- Skipped: 0 - -失败原因与预期一致: - -- 旧实现仍使用 `tenant_access_token-ca-...` key。 -- cache hit 前仍执行 Provider。 -- `TargetInfo` 仍参与 cache key。 -- 命中方案 B cache 时仍继续走 token exchange,导致空响应错误。 - -### 4.2 TokenManager 定向回归 - -命令: - -```bash -mvn -pl larksuite-oapi -DskipTests=false -Dmaven.test.skip=false -Dtest=TestClientAssertionTokenManager test -``` - -结果: - -- Tests run: 11 -- Failures: 0 -- Errors: 0 -- Skipped: 0 - -### 4.3 ClientAssertion 相关定向回归 - -命令: - -```bash -mvn -pl larksuite-oapi -DskipTests=false -Dmaven.test.skip=false -Dtest=TestClientAssertionTokenManager,TestTransportClientAssertion,TestAccessToken,TestClientAssertionWsClient test -``` - -结果: - -- Tests run: 37 -- Failures: 0 -- Errors: 0 -- Skipped: 0 - -### 4.4 完整模块测试 - -命令: - -```bash -mvn -pl larksuite-oapi -DskipTests=false -Dmaven.test.skip=false test -``` - -结果: - -- Tests run: 225 -- Failures: 0 -- Errors: 0 -- Skipped: 4 - -Skipped 原因: - -- `TestLarkChannelIntegration` 2 个用例: - - 需要显式设置 `LARK_CHANNEL_IT_ENABLED=true`、`LARK_CHANNEL_IT_APP_ID`、`LARK_CHANNEL_IT_APP_SECRET`。 - - 属于真实 Feishu channel integration 测试,默认不在模块测试中启用。 -- `TestClientAssertionLiveE2E` 2 个用例: - - 完整模块测试未注入 live E2E 环境,因此被 `LARK_CLIENT_ASSERTION_E2E=1` 门控跳过。 - - 下方已通过单独 live E2E 命令完整执行,未跳过。 - -### 4.5 完整 Live E2E - -命令: - -```bash -bash -lc 'set +x -IFS= read -rs LARK_ZTI_CLIENT_ASSERTION -printf "\n" -export LARK_ZTI_CLIENT_ASSERTION -mvn -pl larksuite-oapi -DskipTests=false -Dmaven.test.skip=false \ - -Dlark.e2e.envFile=/Users/bytedance/Documents/oapi_sdk/oapi-sdk-python/.env.e2e \ - -Dtest=TestClientAssertionLiveE2E test' -``` - -说明: - -- ZTI ClientAssertion 通过 stdin 注入,不进入命令行参数。 -- `.env.e2e` 提供 AppID、AppSecret、GDPR assertion、Proxy、OAuth redirect、scope、open_id 等环境。 -- 真实 OAuth 授权通过浏览器完成。 - -结果: - -- Tests run: 2 -- Failures: 0 -- Errors: 0 -- Skipped: 0 -- Total time: 02:38 min -- Finished at: 2026-06-10T20:18:12+08:00 - -通过的 live case: - -| Case | 覆盖点 | 结果 | -| --- | --- | --- | -| `OAUTH-APPSECRET` | AppSecret authorization code 换 UAT、refresh、UAT basic_batch | PASS | -| `ZTI-03/04/05/06` | ZTI ClientAssertion authorization code、refresh、UAT basic_batch | PASS | -| `GDPR-03/04/05` | GDPR ClientAssertion + Proxy authorization code、refresh、UAT basic_batch | PASS | -| `SECRET-02` | AppSecret tenant token 发 IM 消息 | PASS | -| `ZTI-02` | ZTI ClientAssertion tenant token 发 IM 消息 | PASS | -| `GDPR-TAT` | GDPR ClientAssertion + Proxy tenant token 发 IM 消息 | PASS | -| `ENV-05` | Provider 优先于 AppSecret | PASS | -| `SECRET-04` | AppSecret WS endpoint/connect | PASS | -| `WS-01/WS-03-ZTI` | ZTI WS endpoint/connect | PASS | -| `WS-02/WS-03-GDPR` | GDPR WS endpoint/connect | PASS | - -## 5. 结论依据 - -本轮可以给出“通过”结论,是因为: - -- 本地测试覆盖了本轮改动的关键分支和负向语义,且先观察到 RED,再通过实现修复变为 GREEN。 -- 完整模块测试通过,说明改动没有破坏现有 SDK 单测、Channel、事件、WS 和 E2E harness 的默认测试面。 -- live E2E 真实命中了飞书线上 OAuth/OpenAPI/WS 链路,并覆盖 AppSecret、ZTI、GDPR 三种凭证路径。 -- ZTI live case 已使用本轮用户更新后的 ClientAssertion,上一轮过期错误不再出现。 -- 敏感信息处理通过单测断言,且执行过程中没有把原始凭证写入报告或仓库文件。 - -## 6. 备注 - -- Maven 仍提示 `gson` 依赖重复声明 warning:`com.google.code.gson:gson` 存在 `${gson.version}` 与 `2.9.0` 两处声明。本轮未修改依赖管理,建议后续单独清理。 -- `rg` 扫描 JWT 模式时命中仓库既有 Javadoc/sample 示例 page_token,不是本轮新增的 ZTI ClientAssertion。 From ff32f283ca47009e239c3759e76011418117c8b5 Mon Sep 17 00:00:00 2001 From: WangWeijia Date: Wed, 10 Jun 2026 20:30:22 +0800 Subject: [PATCH 5/5] feat: add test client Change-Id: I7fc50a6700e7ff832b115591b6fd571c676def03 --- .gitignore | 1 + .../java/com/lark/oapi/ws/TestClient.java | 72 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 larksuite-oapi/src/test/java/com/lark/oapi/ws/TestClient.java diff --git a/.gitignore b/.gitignore index 4a970c333..6a7802d89 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ skills-lock.json channel-diff-tasks /test docs/superpowers/plans +doc diff --git a/larksuite-oapi/src/test/java/com/lark/oapi/ws/TestClient.java b/larksuite-oapi/src/test/java/com/lark/oapi/ws/TestClient.java new file mode 100644 index 000000000..0d62aa15a --- /dev/null +++ b/larksuite-oapi/src/test/java/com/lark/oapi/ws/TestClient.java @@ -0,0 +1,72 @@ +package com.lark.oapi.ws; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpServer; +import org.junit.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.Method; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; + +public class TestClient { + + @Test + public void getConnUrlShouldSendCustomHeaders() throws Exception { + AtomicReference requestHeaders = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext(Constant.GEN_ENDPOINT_URI, exchange -> { + requestHeaders.set(exchange.getRequestHeaders()); + byte[] body = "{\"code\":0,\"data\":{\"URL\":\"wss://example.test/callback?device_id=device&service_id=42\"}}" + .getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + }); + server.start(); + + try { + Map headers = new HashMap<>(); + headers.put("x-tt-env", "boe"); + headers.put("locale", "en"); + headers.put("User-Agent", "custom-agent"); + + Client client = new Client.Builder("app_id", "app_secret") + .domain("http://127.0.0.1:" + server.getAddress().getPort()) + .headers(headers) + .header("x-use-ppe", "1") + .build(); + + assertEquals( + "wss://example.test/callback?device_id=device&service_id=42", + invokeGetConnUrl(client)); + assertEquals("boe", requestHeaders.get().getFirst("x-tt-env")); + assertEquals("1", requestHeaders.get().getFirst("x-use-ppe")); + assertEquals("zh", requestHeaders.get().getFirst("locale")); + assertEquals("custom-agent", requestHeaders.get().getFirst("User-Agent")); + } finally { + server.stop(0); + } + } + + private String invokeGetConnUrl(Client client) throws Exception { + Method method = Client.class.getDeclaredMethod("getConnUrl"); + method.setAccessible(true); + try { + return (String) method.invoke(client); + } catch (ReflectiveOperationException e) { + Throwable cause = e.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } + throw e; + } + } +}