Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 120 additions & 8 deletions common/src/main/java/com/skyflow/BaseVaultClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.skyflow.errors.SkyflowException;
import com.skyflow.logs.ErrorLogs;
import com.skyflow.logs.InfoLogs;
import com.skyflow.logs.WarningLogs;
import com.skyflow.serviceaccount.util.Token;
import com.skyflow.utils.BaseConstants;
import com.skyflow.utils.BaseUtils;
Expand All @@ -17,9 +18,12 @@
import io.github.cdimascio.dotenv.Dotenv;
import io.github.cdimascio.dotenv.DotenvException;
import okhttp3.ConnectionPool;
import okhttp3.Interceptor;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

import java.net.HttpURLConnection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
Expand All @@ -33,6 +37,17 @@ class BaseVaultClient<V extends BaseVaultConfig> {
protected BaseCredentials finalCredentials;
protected String token;
protected String apiKey;
// The token whose regeneration after a 401 last failed. Guarded by this. Further 401s on that
// same token return without generating again, so an auth outage costs one generation per
// token instead of one per rejected request. Cleared whenever a token is stored.
private String refreshFailedFor;

/**
* A 401 is treated as token expiry only if the token the SDK sent has an {@code exp} at or
* before now plus this window. By the time the 401 arrives the gateway has already judged the
* token expired, so the window only has to absorb clock skew between this host and the gateway.
*/
protected static final long BEARER_TOKEN_EXPIRY_WINDOW_SECONDS = 60;

protected BaseVaultClient(V vaultConfig, BaseCredentials credentials) {
this.vaultConfig = vaultConfig;
Expand All @@ -43,18 +58,114 @@ protected V getVaultConfig() {
return vaultConfig;
}

/**
* The bearer token to send. Synchronized on the same monitor as {@link #setBearerToken(BaseCredentials)}
* and {@link #refreshBearerTokenAfterUnauthorized(String)}, so a request attempt on an OkHttp or
* batch thread sees the token those methods wrote, and waits for a regeneration in progress
* instead of sending the token it is replacing.
*/
protected synchronized String getToken() {
return token;
}

protected OkHttpClient buildSharedHttpClient(Supplier<String> tokenSupplier) {
return new OkHttpClient.Builder()
.connectionPool(new ConnectionPool(10, 1, TimeUnit.MINUTES))
.addInterceptor(chain -> {
Request requestWithAuth = chain.request().newBuilder()
.header("Authorization", "Bearer " + tokenSupplier.get())
.build();
return chain.proceed(requestWithAuth);
})
.addInterceptor(authInterceptor(tokenSupplier))
.build();
}

/**
* The auth interceptor shared by every OkHttp client in the SDK. It attaches the current bearer
* token to each attempt, reading it fresh from {@code tokenSupplier} so a token refreshed between
* attempts is picked up. When the vault answers {@code 401} and
* {@link #refreshBearerTokenAfterUnauthorized(String)} can supply a replacement, the rejected
* response is closed and the same request is sent once more with the new token. Any other
* status, and a {@code 401} the refresh declines, is returned as is.
*/
protected Interceptor authInterceptor(Supplier<String> tokenSupplier) {
return chain -> {
String sent = tokenSupplier.get();
Response response = chain.proceed(withBearer(chain.request(), sent));
if (response.code() != HttpURLConnection.HTTP_UNAUTHORIZED) {
return response;
}
String fresh = refreshBearerTokenAfterUnauthorized(sent);
Comment thread
yaswanth-pula-skyflow marked this conversation as resolved.
Resolved
if (fresh == null) {
return response;
}
response.close();
return chain.proceed(withBearer(chain.request(), fresh));
};
}

private static Request withBearer(Request request, String bearer) {
return request.newBuilder().header("Authorization", "Bearer " + bearer).build();
}

/**
* Called when the vault rejected {@code rejected} with {@code 401}. Returns the token to resend
* the request with, or {@code null} when the request must fail as it stands:
* <ul>
* <li>the effective credentials are a static token or an API key, which the SDK cannot replace;</li>
* <li>the rejected token is not at or past expiry (within {@link #BEARER_TOKEN_EXPIRY_WINDOW_SECONDS}),
* so the {@code 401} was not caused by expiry and a new token would not help;</li>
* <li>generating a replacement failed, or produced the very token that was rejected;</li>
* <li>a replacement for this same token was already attempted and failed, so the outcome is
* shared with every request still holding it. A later method call recovers through
* {@link #setBearerToken(BaseCredentials)}, which clears that record when it stores a token.</li>
* </ul>
* Synchronized with {@link #setBearerToken(BaseCredentials)} and {@link #getToken()} so concurrent
* {@code 401}s on one token share a single regeneration: a caller that finds the cached token
* already different from the one it sent reuses it without generating again.
*/
protected synchronized String refreshBearerTokenAfterUnauthorized(String rejected) {
BaseCredentials credentials = this.finalCredentials;
if (credentials == null || credentials.getApiKey() != null || credentials.getToken() != null) {
return null;
}
if (!Token.isExpiringWithin(rejected, BEARER_TOKEN_EXPIRY_WINDOW_SECONDS)) {
LogUtil.printInfoLog(InfoLogs.BEARER_TOKEN_REJECTED_NOT_DUE.getLog());
return null;
}
String cached = this.token;
if (cached != null && !cached.equals(rejected)) {
LogUtil.printInfoLog(InfoLogs.BEARER_TOKEN_REJECTED_REUSING_REGENERATED.getLog());
return cached;
}
// rejected is non-null here: a null token fails the expiry gate above.
if (rejected.equals(refreshFailedFor)) {
LogUtil.printInfoLog(InfoLogs.BEARER_TOKEN_REJECTED_REGENERATION_ALREADY_FAILED.getLog());
return null;
}
LogUtil.printInfoLog(InfoLogs.BEARER_TOKEN_REJECTED_REGENERATING.getLog());
try {
String fresh = generateBearerToken(credentials);
if (fresh == null || fresh.equals(rejected)) {
LogUtil.printWarningLog(WarningLogs.BEARER_TOKEN_REGENERATION_RETURNED_REJECTED_TOKEN.getLog());
refreshFailedFor = rejected;
return null;
Comment thread
yaswanth-pula-skyflow marked this conversation as resolved.
Resolved
}
storeToken(fresh);
return fresh;
} catch (SkyflowException e) {
LogUtil.printWarningLog(WarningLogs.BEARER_TOKEN_REGENERATION_AFTER_401_FAILED.getLog() + " " + e.getMessage());
refreshFailedFor = rejected;
return null;
}
}

/** Stores a token the SDK obtained and forgets any failed regeneration, so refresh can run again. */
private void storeToken(String fresh) {
this.token = fresh;
this.refreshFailedFor = null;
}

/** Single point that turns credentials into a bearer token; tests substitute it. */
protected String generateBearerToken(BaseCredentials credentials) throws SkyflowException {
return BaseUtils.generateBearerToken(credentials);
}

protected synchronized void prioritiseCredentials(BaseCredentials vaultSpecificCredentials) throws SkyflowException {
try {
BaseCredentials original = this.finalCredentials;
Expand All @@ -78,6 +189,7 @@ protected synchronized void prioritiseCredentials(BaseCredentials vaultSpecificC
if (original != null && !original.equals(this.finalCredentials)) {
token = null;
apiKey = null;
refreshFailedFor = null;
}
} catch (DotenvException e) {
throw new SkyflowException(ErrorCode.INVALID_INPUT.getCode(), ErrorMessage.EmptyCredentials.getMessage());
Expand All @@ -95,10 +207,10 @@ protected synchronized void setBearerToken(BaseCredentials vaultSpecificCredenti
LogUtil.printInfoLog(InfoLogs.USE_API_KEY.getLog());
token = this.finalCredentials.getApiKey();
} else if (token == null || token.trim().isEmpty()) {
token = BaseUtils.generateBearerToken(this.finalCredentials);
storeToken(generateBearerToken(this.finalCredentials));
} else if (Token.isExpired(token)) {
LogUtil.printInfoLog(InfoLogs.BEARER_TOKEN_EXPIRED.getLog());
token = BaseUtils.generateBearerToken(this.finalCredentials);
storeToken(generateBearerToken(this.finalCredentials));
} else {
LogUtil.printInfoLog(InfoLogs.REUSE_BEARER_TOKEN.getLog());
}
Expand Down
4 changes: 4 additions & 0 deletions common/src/main/java/com/skyflow/logs/InfoLogs.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ public enum InfoLogs {
GET_SIGNED_DATA_TOKENS_TRIGGERED("getSignedDataTokens method triggered."),
GET_SIGNED_DATA_TOKEN_SUCCESS("Signed data tokens generated."),
REUSE_BEARER_TOKEN("Reusing bearer token."),
BEARER_TOKEN_REJECTED_REGENERATING("Bearer token rejected with 401 and is past expiry; regenerating once and resending the request."),
BEARER_TOKEN_REJECTED_REUSING_REGENERATED("Bearer token rejected with 401; reusing the token another request already regenerated."),
BEARER_TOKEN_REJECTED_NOT_DUE("Bearer token rejected with 401 but is not near expiry; not retrying."),
BEARER_TOKEN_REJECTED_REGENERATION_ALREADY_FAILED("Bearer token rejected with 401; regeneration for this token already failed, not retrying."),
USE_CLIENT_PROVIDED_BEARER_TOKEN("Using bearer token provided by client."),
USE_API_KEY("Using api key."),
REUSE_API_KEY("Reusing api key."),
Expand Down
4 changes: 3 additions & 1 deletion common/src/main/java/com/skyflow/logs/WarningLogs.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ public enum WarningLogs {
EMPTY_DELETE_TOKENS_RESPONSE("DeleteTokens response did not include any token results."),
INCOMPLETE_DELETE_TOKENS_RESPONSE("DeleteTokens response did not account for all requested tokens."),
EMPTY_TOKENIZE_RESPONSE("Tokenize response did not include any record results."),
INCOMPLETE_TOKENIZE_RESPONSE("Tokenize response did not account for all requested records.")
INCOMPLETE_TOKENIZE_RESPONSE("Tokenize response did not account for all requested records."),
BEARER_TOKEN_REGENERATION_AFTER_401_FAILED("Bearer token regeneration after 401 failed; returning the 401 response."),
BEARER_TOKEN_REGENERATION_RETURNED_REJECTED_TOKEN("Bearer token regeneration after 401 returned the rejected token; not resending.")
;

private final String log;
Expand Down
23 changes: 23 additions & 0 deletions common/src/main/java/com/skyflow/serviceaccount/util/Token.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,29 @@ public static boolean isExpired(String token) {
return currentTime > expiryTime;
}

/**
* Returns true when the token's {@code exp} claim is at or before now plus {@code windowSeconds},
* that is, the token has expired or will within the window. Unlike {@link #isExpired(String)},
* a token that is empty, cannot be decoded, or carries no {@code exp} is reported as
* <em>not</em> expiring: callers use this to decide whether a {@code 401} was caused by expiry,
* and a malformed token is not an expiry.
*/
public static boolean isExpiringWithin(String token, long windowSeconds) {
if (token == null || token.trim().isEmpty()) {
return false;
}
try {
JsonElement exp = decoded(token).get("exp");
if (exp == null) {
return false;
}
long currentTime = new Date().getTime() / 1000;
return exp.getAsLong() <= currentTime + windowSeconds;
} catch (SkyflowException | RuntimeException e) {
return false;
}
}

static JsonObject decoded(String encodedToken) throws JsonSyntaxException, SkyflowException {
String[] split = encodedToken.split("\\.");
if (split.length < 3) {
Expand Down
Loading
Loading