diff --git a/.gitignore b/.gitignore
index e7b4dc702..6a7802d89 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,5 +43,6 @@ skills
plan
skills-lock.json
channel-diff-tasks
-test
+/test
docs/superpowers/plans
+doc
diff --git a/CHANNEL.md b/CHANNEL.md
index 03ad571ca..e6adbe618 100644
--- a/CHANNEL.md
+++ b/CHANNEL.md
@@ -93,7 +93,7 @@ public class AgentBot {
com.larksuite.oapi
oapi-sdk
- 2.6.1
+ 2.7.3
```
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 487532c06..9c620c4c4 100644
--- a/larksuite-oapi/src/main/java/com/lark/oapi/Client.java
+++ b/larksuite-oapi/src/main/java/com/lark/oapi/Client.java
@@ -72,6 +72,7 @@
import com.lark.oapi.service.cardkit.CardkitService;
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;
@@ -169,6 +170,7 @@ public class Client {
private CardkitService cardkit;
private ExtService extService;
+ private com.lark.oapi.core.accesstoken.AccessToken accessToken;
public static Builder newBuilder(String appId, String appSecret) {
return new Builder(appId, appSecret);
@@ -178,6 +180,10 @@ public ExtService ext() {
return extService;
}
+ public com.lark.oapi.core.accesstoken.AccessToken accessToken() {
+ return accessToken;
+ }
+
public void setConfig(Config config) {
this.config = config;
}
@@ -538,6 +544,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;
@@ -585,6 +601,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.wiki = new WikiService(config);
client.workplace = new WorkplaceService(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..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
@@ -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();
}
@@ -39,6 +45,7 @@ static com.lark.oapi.ws.Client createWebSocketClient(
.eventHandler(eventDispatcher)
.domain(options.getDomain() == null ? BaseUrlEnum.FeiShu.getUrl() : options.getDomain())
.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 6aef39358..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
@@ -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;
@@ -28,6 +29,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;
@@ -43,6 +46,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) {
@@ -97,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.
*
@@ -131,6 +144,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;
@@ -191,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/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..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
@@ -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;
@@ -31,15 +33,46 @@
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) {
+ 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 +143,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 +223,8 @@ public static RawResponse send(Config config
// 确定token类型
AccessTokenType accessTokenType = determineTokenType(accessTokenTypeSet
, requestOptions
- , config.isDisableTokenCache());
+ , config.isDisableTokenCache()
+ , config);
// 参数校验
validate(config, requestOptions, accessTokenType);
@@ -203,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;
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..74d71255b
--- /dev/null
+++ b/larksuite-oapi/src/main/java/com/lark/oapi/core/accesstoken/AccessToken.java
@@ -0,0 +1,197 @@
+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);
+ }
+
+ 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(accessToken);
+ 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..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
@@ -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 {
@@ -39,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) {
@@ -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)) {
@@ -129,10 +146,14 @@ 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) {
+ return getTenantTokenByClientAssertion(config, tenantKey);
+ }
+
// 缓存中存在,则直接返回
String token = cache.get(getTenantAccessTokenKey(config.getAppId(), tenantKey));
if (Strings.isNotEmpty(token)) {
@@ -157,6 +178,129 @@ 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);
+ 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);
+ } 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(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();
+ }
+ 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 72e8b6a2f..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
@@ -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;
@@ -46,6 +53,7 @@ public class Client {
private final String domain;
private final String userAgent;
private final Map headers;
+ private final ClientAssertionProvider clientAssertionProvider;
private final OkHttpClient httpClient;
private final Cache cache;
private final Runnable onReconnecting;
@@ -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);
@@ -81,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();
@@ -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,61 @@ 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;
+ 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");
+ }
+ 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 +644,8 @@ public static class Builder {
private String source;
private Runnable onReconnecting;
private Runnable onReconnected;
+ private ClientAssertionProvider clientAssertionProvider;
+ private OkHttpClient httpClient;
public Builder(String appId, String appSecret) {
this.appId = appId;
@@ -610,6 +680,16 @@ public Builder header(String key, String value) {
return this;
}
+ public Builder clientAssertionProvider(ClientAssertionProvider provider) {
+ this.clientAssertionProvider = provider;
+ return this;
+ }
+
+ public Builder httpClient(OkHttpClient httpClient) {
+ this.httpClient = httpClient;
+ return this;
+ }
+
public Builder source(String source) {
this.source = source;
return this;
@@ -629,4 +709,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..e051ae920
--- /dev/null
+++ b/larksuite-oapi/src/test/java/com/lark/oapi/core/TestTransportClientAssertion.java
@@ -0,0 +1,257 @@
+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;
+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.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 {
+
+ @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 providerRetrieveFailureDoesNotRetryInsideTransport() throws Exception {
+ TokenManager previous = GlobalTokenManager.getTokenManager();
+ try {
+ CapturingTransport transport = new CapturingTransport();
+ Config config = config();
+ config.setHttpTransport(transport);
+ FlakyTokenManager tokenManager = new FlakyTokenManager();
+ GlobalTokenManager.setTokenManager(tokenManager);
+
+ 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();
+ 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..a2733dd3f
--- /dev/null
+++ b/larksuite-oapi/src/test/java/com/lark/oapi/core/accesstoken/TestAccessToken.java
@@ -0,0 +1,256 @@
+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 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);
+ 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 "{\"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 {
+ 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..bd8409ab1
--- /dev/null
+++ b/larksuite-oapi/src/test/java/com/lark/oapi/core/token/TestClientAssertionTokenManager.java
@@ -0,0 +1,277 @@
+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.HashMap;
+import java.util.Map;
+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_token:client_assertion:cli_a::accounts.feishu.cn", 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 appSecretTenantTokenUsesModeSpecificCacheKey() throws Exception {
+ CapturingCache cache = new CapturingCache();
+ 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);
+ 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_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);
+ }
+
+ @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 final Map values = new HashMap<>();
+ 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++;
+ String value = values.get(key);
+ return value == null ? "" : value;
+ }
+
+ @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/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