diff --git a/src/main/java/org/zendesk/client/v2/HttpTokenMinter.java b/src/main/java/org/zendesk/client/v2/HttpTokenMinter.java
new file mode 100644
index 00000000..d4bcffb5
--- /dev/null
+++ b/src/main/java/org/zendesk/client/v2/HttpTokenMinter.java
@@ -0,0 +1,199 @@
+package org.zendesk.client.v2;
+
+import com.fasterxml.jackson.core.StreamReadFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import java.io.IOException;
+import java.time.Clock;
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import org.asynchttpclient.AsyncHttpClient;
+import org.asynchttpclient.Request;
+import org.asynchttpclient.RequestBuilder;
+import org.asynchttpclient.Response;
+
+/**
+ * Mints access tokens with the OAuth {@code client_credentials} grant.
+ *
+ *
Bypasses {@code Zendesk.reqBuilder} because this endpoint is at the host root, not under
+ * {@code /api/v2}, and must not authenticate the call that produces its own credentials. Bypasses
+ * the shared body logging because the request carries the client secret and the response the access
+ * token; neither this class nor the exceptions it throws logs either.
+ *
+ *
Validates the token response by type rather than coercing through {@code asText}/{@code
+ * asLong} as the pagination handlers in {@code Zendesk} do. A malformed pagination field degrades
+ * benignly, since pagination just stops, but a malformed lifetime can corrupt the refresh schedule
+ * for every request that follows. Jackson coerces rather than rejects ({@code true} would read as a
+ * one-second lifetime, a non-numeric string as zero), so the type checks make a bad grant a loud
+ * {@link ZendeskOAuthException} instead of propagating a potentially incorrect value.
+ *
+ * @see
+ * OAuth grant type tokens
+ * @since 1.6.0
+ */
+final class HttpTokenMinter implements TokenMinter {
+
+ private static final String GRANT_TYPE = "client_credentials";
+ private static final String OAUTH_TOKEN_PATH = "/oauth/tokens";
+ private static final String EXPIRES_IN = "expires_in";
+ private static final String ACCESS_TOKEN = "access_token";
+
+ private final AsyncHttpClient client;
+ private final String tokenUrl;
+ private final String clientId;
+ private final String clientSecret;
+ private final String scope;
+ private final int requestedLifetimeSeconds;
+ private final Clock clock;
+
+ /**
+ * Isolated mapper for OAuth request and response bodies. Source-in-location is disabled so parse
+ * errors cannot include token-bearing response bodies.
+ */
+ private final ObjectMapper mapper =
+ JsonMapper.builder().disable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION).build();
+
+ /**
+ * Creates a token minter for one OAuth client.
+ *
+ * @param client HTTP client used to call Zendesk
+ * @param baseHostUrl Zendesk host URL without the {@code /api/v2} suffix
+ * @param clientId OAuth client identifier
+ * @param clientSecret OAuth client secret
+ * @param scope space-separated OAuth scopes to request
+ * @param requestedLifetimeSeconds requested token lifetime in seconds
+ * @param clock clock used to compute token issue and expiry instants
+ */
+ HttpTokenMinter(
+ AsyncHttpClient client,
+ String baseHostUrl,
+ String clientId,
+ String clientSecret,
+ String scope,
+ int requestedLifetimeSeconds,
+ Clock clock) {
+ this.client = client;
+ this.tokenUrl = baseHostUrl + OAUTH_TOKEN_PATH;
+ this.clientId = clientId;
+ this.clientSecret = clientSecret;
+ this.scope = scope;
+ this.requestedLifetimeSeconds = requestedLifetimeSeconds;
+ this.clock = clock;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public OAuthToken mint() {
+ Request request = buildRequest();
+
+ // Anchor issue time before the call, so the computed expiry skews early rather than late.
+ Instant issuedAt = clock.instant();
+
+ Response response = execute(request);
+
+ int statusCode = response.getStatusCode();
+ if (statusCode < 200 || statusCode >= 300) {
+ throw new ZendeskOAuthException(
+ "Failed to mint an OAuth access token: HTTP/"
+ + statusCode
+ + " "
+ + response.getStatusText());
+ }
+
+ return parseToken(response, issuedAt);
+ }
+
+ private Request buildRequest() {
+ Map body = new LinkedHashMap<>();
+ body.put("grant_type", GRANT_TYPE);
+ body.put("client_id", clientId);
+ body.put("client_secret", clientSecret);
+ body.put("scope", scope);
+
+ // Always set so the token lifetime is ours rather than the server default.
+ body.put(EXPIRES_IN, requestedLifetimeSeconds);
+
+ byte[] serialized;
+ try {
+ serialized = mapper.writeValueAsBytes(body);
+ } catch (IOException e) {
+ throw new ZendeskOAuthException("Failed to serialize the OAuth token request", e);
+ }
+
+ return new RequestBuilder("POST")
+ .setUrl(tokenUrl)
+ .addHeader("Content-Type", "application/json")
+ .setBody(serialized)
+ .build();
+ }
+
+ private Response execute(Request request) {
+ try {
+ return client.executeRequest(request).get();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new ZendeskOAuthException("Interrupted while minting an OAuth access token", e);
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause();
+ throw new ZendeskOAuthException(
+ "Failed to mint an OAuth access token", cause != null ? cause : e);
+ }
+ }
+
+ private OAuthToken parseToken(Response response, Instant issuedAt) {
+ JsonNode parsed;
+ try {
+ parsed = mapper.readTree(response.getResponseBodyAsStream());
+ } catch (IOException e) {
+ throw new ZendeskOAuthException("Failed to parse the OAuth token response", e);
+ }
+
+ if (parsed == null || parsed.isMissingNode()) {
+ throw new ZendeskOAuthException("OAuth token response was empty");
+ } else if (!parsed.isObject()) {
+ throw new ZendeskOAuthException("OAuth token response was not a JSON object");
+ }
+
+ JsonNode accessToken = parsed.get(ACCESS_TOKEN);
+ if (accessToken == null || !accessToken.isTextual() || accessToken.asText().trim().isEmpty()) {
+ throw new ZendeskOAuthException("OAuth token response had no usable access_token");
+ }
+
+ return new OAuthToken(
+ accessToken.asText(), issuedAt, issuedAt.plusSeconds(resolveLifetimeSeconds(parsed)));
+ }
+
+ private long resolveLifetimeSeconds(JsonNode parsed) {
+ JsonNode grantedLifetime = parsed.get(EXPIRES_IN);
+
+ // Fallback to the requested expiry only if Zendesk did not return one.
+ if (grantedLifetime == null || grantedLifetime.isNull()) {
+ return requestedLifetimeSeconds;
+ }
+
+ if (!grantedLifetime.isIntegralNumber()) {
+ throw new ZendeskOAuthException(
+ "OAuth token response had a non-integer expires_in of type "
+ + grantedLifetime.getNodeType());
+ } else if (!grantedLifetime.canConvertToLong()) {
+ throw new ZendeskOAuthException(
+ "OAuth token response reported an out-of-range expires_in of "
+ + grantedLifetime.asText());
+ }
+
+ // Guard against unusable grants: a token born expired would mean re-minting on every request,
+ // and one beyond the documented maximum is not credible. The upper bound is intentionally
+ // inclusive here even though Builder.build() rejects a requested value of MAX. This means we
+ // are strict in what we ask for, but liberal in what the server hands back.
+ long granted = grantedLifetime.asLong();
+ if (granted <= 0 || Zendesk.Builder.MAX_OAUTH_TOKEN_LIFETIME_SECONDS < granted) {
+ throw new ZendeskOAuthException(
+ "OAuth token response reported an out-of-range expires_in of " + granted);
+ }
+
+ return granted;
+ }
+}
diff --git a/src/main/java/org/zendesk/client/v2/OAuthToken.java b/src/main/java/org/zendesk/client/v2/OAuthToken.java
new file mode 100644
index 00000000..021fc9af
--- /dev/null
+++ b/src/main/java/org/zendesk/client/v2/OAuthToken.java
@@ -0,0 +1,66 @@
+package org.zendesk.client.v2;
+
+import java.time.Instant;
+
+/**
+ * An immutable OAuth access token with the instants it was issued and expires.
+ *
+ * @since 1.6.0
+ */
+final class OAuthToken {
+
+ /** Bearer token value. */
+ private final String accessToken;
+
+ /** Instant from which the client treats this token as issued. */
+ private final Instant issuedAt;
+
+ /** Instant at which the token must no longer be served. */
+ private final Instant expiresAt;
+
+ /**
+ * Creates an OAuth access token with its local issue and expiry instants.
+ *
+ * @param accessToken bearer token value
+ * @param issuedAt instant from which the client treats this token as issued
+ * @param expiresAt instant at which the token must no longer be served
+ */
+ OAuthToken(String accessToken, Instant issuedAt, Instant expiresAt) {
+ this.accessToken = accessToken;
+ this.issuedAt = issuedAt;
+ this.expiresAt = expiresAt;
+ }
+
+ /**
+ * Returns the bearer token value.
+ *
+ * @return bearer token value
+ */
+ String accessToken() {
+ return accessToken;
+ }
+
+ /**
+ * Returns the issue instant.
+ *
+ * @return issue instant
+ */
+ Instant issuedAt() {
+ return issuedAt;
+ }
+
+ /**
+ * Returns the expiry instant.
+ *
+ * @return expiry instant
+ */
+ Instant expiresAt() {
+ return expiresAt;
+ }
+
+ /** Does not include the access token so logging a token cannot leak the credential. */
+ @Override
+ public String toString() {
+ return "OAuthToken{issuedAt=" + issuedAt + ", expiresAt=" + expiresAt + '}';
+ }
+}
diff --git a/src/main/java/org/zendesk/client/v2/SharedFutureTokenProvider.java b/src/main/java/org/zendesk/client/v2/SharedFutureTokenProvider.java
new file mode 100644
index 00000000..106ada77
--- /dev/null
+++ b/src/main/java/org/zendesk/client/v2/SharedFutureTokenProvider.java
@@ -0,0 +1,198 @@
+package org.zendesk.client.v2;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicReference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Caches an access token and keeps it fresh, allowing only one mint in flight at a time. A fresh
+ * token is read lock-free; a stale but unexpired one keeps being served while one thread refreshes
+ * it; threads left with no servable token await the shared mint attempt.
+ *
+ * Refresh is single-flight but synchronous: the elected thread performs the mint before
+ * returning. If the cached token is stale but still servable, that thread refreshes it while other
+ * callers may continue using the cached token. If no servable token exists, the elected thread
+ * always attempts to mint. After a mint failure, the provider backs off from further refresh
+ * attempts only while a servable cached token remains.
+ *
+ *
Two invariants a future change must preserve:
+ *
+ *
+ * - The {@code synchronized} block decides only who mints; the mint runs outside it, so threads
+ * never queue behind a network round trip.
+ *
- Both the leader and awaiter recovery paths re-check the cached token against the live clock
+ * via {@link #isServable} before giving up. A failed refresh round does not cascade failures
+ * if the cache contains a servable token, and a token that expires during a mint is
+ * not handed out.
+ *
+ *
+ * @since 1.6.0
+ */
+final class SharedFutureTokenProvider implements TokenProvider {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(SharedFutureTokenProvider.class);
+ private static final Duration REFRESH_BACKOFF_DURATION = Duration.ofSeconds(10);
+
+ private final TokenMinter minter;
+ private final Clock clock;
+ private final double refreshThreshold;
+
+ /** Read lock-free on the hot path; the token is immutable, so publication is safe. */
+ private final AtomicReference currentToken = new AtomicReference<>();
+
+ /**
+ * Private monitor guarding {@link #inFlightRefresh}, so nothing outside this class can stall or
+ * deadlock a refresh round. Held only long enough to elect a leader and not during mint.
+ */
+ private final Object lock = new Object();
+
+ /** Null when no refresh is in progress. Guarded by {@link #lock}. */
+ private CompletableFuture inFlightRefresh;
+
+ /* Instant until when mint refreshes should backoff on failures if there is a servable token. */
+ private volatile Instant backOffRefreshUntil = Instant.MIN;
+
+ /**
+ * Creates a provider backed by a single-flight token minter.
+ *
+ * @param minter mints tokens when the cache is empty, expired, or past the refresh threshold
+ * @param clock used to make freshness and expiry decisions
+ * @param refreshThreshold fraction of token lifetime remaining below which refresh begins
+ * @throws IllegalArgumentException if {@code refreshThreshold} is not between 0 and 1 exclusive
+ */
+ SharedFutureTokenProvider(TokenMinter minter, Clock clock, double refreshThreshold) {
+ if (!(refreshThreshold > 0.0 && refreshThreshold < 1.0)) {
+ throw new IllegalArgumentException(
+ "refreshThreshold must be between 0 and 1 exclusive, but was " + refreshThreshold);
+ }
+ this.minter = minter;
+ this.clock = clock;
+ this.refreshThreshold = refreshThreshold;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public String provideBearerToken() {
+ OAuthToken token = currentToken.get();
+ if (isFresh(token)) {
+ return token.accessToken();
+ }
+
+ // Elect the refreshing thread.
+ CompletableFuture refreshRound;
+ boolean isLeader;
+ synchronized (lock) {
+ if (inFlightRefresh != null) {
+ refreshRound = inFlightRefresh;
+ isLeader = false;
+ } else {
+ inFlightRefresh = new CompletableFuture<>();
+ refreshRound = inFlightRefresh;
+ isLeader = true;
+ }
+ }
+
+ if (isLeader) {
+ return mintAsLeader(refreshRound);
+ } else if (isServable(token)) {
+ return token.accessToken();
+ } else {
+ return await(refreshRound);
+ }
+ }
+
+ private String mintAsLeader(CompletableFuture refreshRound) {
+ try {
+ // If another thread has already refreshed, just use that result.
+ OAuthToken cached = currentToken.get();
+ if (isFresh(cached)) {
+ refreshRound.complete(cached);
+ return cached.accessToken();
+ }
+
+ if (isServable(cached) && shouldBackOffRefresh()) {
+ refreshRound.complete(cached);
+ return cached.accessToken();
+ }
+
+ // Ensure we publish the token before completing the round.
+ OAuthToken minted = minter.mint();
+ LOGGER.debug("Minted a new OAuth access token expiring at {}", minted.expiresAt());
+
+ currentToken.set(minted);
+ refreshRound.complete(minted);
+ return minted.accessToken();
+ } catch (RuntimeException e) {
+ backOffRefreshUntil = clock.instant().plus(REFRESH_BACKOFF_DURATION);
+ refreshRound.completeExceptionally(e);
+
+ OAuthToken cached = currentToken.get();
+ if (isServable(cached)) {
+ LOGGER.debug("OAuth access token mint failure", e);
+ LOGGER.warn(
+ "Failed to mint an OAuth access token. Serving the cached token expiring at {}",
+ cached.expiresAt());
+ return cached.accessToken();
+ }
+
+ LOGGER.warn("Failed to mint an OAuth access token. No servable cached token remains", e);
+ throw e;
+ } finally {
+ // Clear out the slot so that the next refresh can run when needed, and ensure
+ // that the round completes if no other path has done it yet. Otherwise, its
+ // waiting threads will block indefinitely.
+ synchronized (lock) {
+ inFlightRefresh = null;
+ }
+
+ if (!refreshRound.isDone()) {
+ refreshRound.completeExceptionally(
+ new ZendeskOAuthException("OAuth token refresh did not complete"));
+ }
+ }
+ }
+
+ private String await(CompletableFuture refreshRound) {
+ try {
+ return refreshRound.get().accessToken();
+ } catch (ExecutionException e) {
+ OAuthToken cached = currentToken.get();
+ if (isServable(cached)) {
+ return cached.accessToken();
+ }
+
+ Throwable cause = e.getCause();
+ if (cause instanceof RuntimeException) {
+ throw (RuntimeException) cause;
+ }
+
+ throw new ZendeskOAuthException("Failed to obtain an OAuth access token", cause);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new ZendeskOAuthException("Interrupted while awaiting an OAuth access token", e);
+ }
+ }
+
+ private boolean isFresh(OAuthToken token) {
+ if (token == null) {
+ return false;
+ }
+
+ long lifetimeMillis = Duration.between(token.issuedAt(), token.expiresAt()).toMillis();
+ long remainingMillis = Duration.between(clock.instant(), token.expiresAt()).toMillis();
+ return remainingMillis > (long) (lifetimeMillis * refreshThreshold);
+ }
+
+ private boolean isServable(OAuthToken token) {
+ return token != null && clock.instant().isBefore(token.expiresAt());
+ }
+
+ private boolean shouldBackOffRefresh() {
+ return clock.instant().isBefore(backOffRefreshUntil);
+ }
+}
diff --git a/src/main/java/org/zendesk/client/v2/TokenMinter.java b/src/main/java/org/zendesk/client/v2/TokenMinter.java
new file mode 100644
index 00000000..c8e9dbf6
--- /dev/null
+++ b/src/main/java/org/zendesk/client/v2/TokenMinter.java
@@ -0,0 +1,15 @@
+package org.zendesk.client.v2;
+
+/**
+ * Acquires a brand-new access token. Does no caching, no coordination and no retry.
+ *
+ * @since 1.6.0
+ */
+interface TokenMinter {
+
+ /**
+ * @return a freshly minted access token
+ * @throws ZendeskOAuthException if a token could not be minted
+ */
+ OAuthToken mint();
+}
diff --git a/src/main/java/org/zendesk/client/v2/TokenProvider.java b/src/main/java/org/zendesk/client/v2/TokenProvider.java
new file mode 100644
index 00000000..c1906e65
--- /dev/null
+++ b/src/main/java/org/zendesk/client/v2/TokenProvider.java
@@ -0,0 +1,16 @@
+package org.zendesk.client.v2;
+
+/**
+ * Supplies a currently-usable bearer token. Called on every request from many threads at once, so
+ * implementations must be thread-safe and may block while a token is minted.
+ *
+ * @since 1.6.0
+ */
+interface TokenProvider {
+
+ /**
+ * @return a token that is valid at the moment of return
+ * @throws ZendeskOAuthException if no usable token exists and minting one fails
+ */
+ String provideBearerToken();
+}
diff --git a/src/main/java/org/zendesk/client/v2/Zendesk.java b/src/main/java/org/zendesk/client/v2/Zendesk.java
index 0321cbc3..22bea050 100644
--- a/src/main/java/org/zendesk/client/v2/Zendesk.java
+++ b/src/main/java/org/zendesk/client/v2/Zendesk.java
@@ -15,6 +15,7 @@
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
+import java.time.Clock;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -123,6 +124,7 @@ public class Zendesk implements Closeable {
private final Realm realm;
private final String url;
private final String oauthToken;
+ private final TokenProvider tokenProvider;
private final Map headers;
private final int cbpPageSize;
private final ObjectMapper mapper;
@@ -172,6 +174,7 @@ private Zendesk(
this.logger = LoggerFactory.getLogger(Zendesk.class);
this.closeClient = client == null;
this.oauthToken = null;
+ this.tokenProvider = null;
this.client =
client == null ? new DefaultAsyncHttpClient(DEFAULT_ASYNC_HTTP_CLIENT_CONFIG) : client;
this.url = url.endsWith("/") ? url + "api/v2" : url + "/api/v2";
@@ -204,6 +207,7 @@ private Zendesk(
this.logger = LoggerFactory.getLogger(Zendesk.class);
this.closeClient = client == null;
this.realm = null;
+ this.tokenProvider = null;
this.client =
client == null ? new DefaultAsyncHttpClient(DEFAULT_ASYNC_HTTP_CLIENT_CONFIG) : client;
this.url = url.endsWith("/") ? url + "api/v2" : url + "/api/v2";
@@ -219,6 +223,63 @@ private Zendesk(
this.mapper = createMapper(objectMapperCustomizer);
}
+ /**
+ * Client-credentials OAuth: instead of a pre-minted token, hold the app credentials and let a
+ * {@link TokenProvider} mint short-lived access tokens on demand for the client's whole lifetime.
+ *
+ * This constructor performs no network I/O.
+ */
+ private Zendesk(
+ AsyncHttpClient client,
+ String url,
+ String clientId,
+ String clientSecret,
+ String scope,
+ int tokenLifetimeSeconds,
+ double refreshThreshold,
+ Map headers,
+ int cbpPageSize,
+ Function objectMapperCustomizer) {
+ this.logger = LoggerFactory.getLogger(Zendesk.class);
+ this.closeClient = client == null;
+ this.realm = null;
+ this.oauthToken = null;
+ this.client =
+ client == null ? new DefaultAsyncHttpClient(DEFAULT_ASYNC_HTTP_CLIENT_CONFIG) : client;
+ this.url = url.endsWith("/") ? url + "api/v2" : url + "/api/v2";
+ headers.putIfAbsent(USER_AGENT_HEADER, new DefaultUserAgent().toString());
+ this.headers = Collections.unmodifiableMap(headers);
+ this.cbpPageSize = cbpPageSize;
+ this.mapper = createMapper(objectMapperCustomizer);
+ String baseHostUrl = url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
+ this.tokenProvider =
+ new SharedFutureTokenProvider(
+ new HttpTokenMinter(
+ this.client,
+ baseHostUrl,
+ clientId,
+ clientSecret,
+ scope,
+ tokenLifetimeSeconds,
+ Clock.systemUTC()),
+ Clock.systemUTC(),
+ refreshThreshold);
+ }
+
+ /**
+ * Prepares authentication up front, so the first request does not pay for it: a
+ * client-credentials client mints an access token here. May perform network I/O and throw, and
+ * does nothing when there is nothing to prepare.
+ *
+ * @throws ZendeskOAuthException if minting fails
+ * @since 1.6.0
+ */
+ public void warmUp() {
+ if (tokenProvider != null) {
+ tokenProvider.provideBearerToken();
+ }
+ }
+
//////////////////////////////////////////////////////////////////////
// Closeable interface methods
//////////////////////////////////////////////////////////////////////
@@ -3543,6 +3604,8 @@ private RequestBuilder reqBuilder(String method, String url) {
RequestBuilder builder = new RequestBuilder(method);
if (realm != null) {
builder.setRealm(realm);
+ } else if (tokenProvider != null) {
+ builder.addHeader("Authorization", "Bearer " + tokenProvider.provideBearerToken());
} else {
builder.addHeader("Authorization", "Bearer " + oauthToken);
}
@@ -4343,12 +4406,38 @@ public void remove() {
public static class Builder {
private static final Integer DEFAULT_CBP_PAGE_SIZE = 100;
+
+ /**
+ * Default requested token lifetime. 30 minutes: short enough to limit the exposure of a leaked
+ * token, long enough to keep minting infrequent.
+ *
+ * @since 1.6.0
+ */
+ public static final int DEFAULT_OAUTH_TOKEN_LIFETIME_SECONDS = 1800;
+
+ /**
+ * Default refresh threshold. Refreshes when the current token has half its lifetime left.
+ *
+ * @since 1.6.0
+ */
+ public static final double DEFAULT_OAUTH_REFRESH_THRESHOLD = 0.5;
+
+ private static final int MIN_OAUTH_TOKEN_LIFETIME_SECONDS = 300;
+
+ static final int MAX_OAUTH_TOKEN_LIFETIME_SECONDS = 172_800;
+
private AsyncHttpClient client = null;
private final String url;
private String username = null;
private String password = null;
private String token = null;
private String oauthToken = null;
+ private String oauthClientId = null;
+ private String oauthClientSecret = null;
+ private String oauthScope = null;
+ private boolean oauthClientCredentialsConfigured = false;
+ private int oauthTokenLifetimeSeconds = DEFAULT_OAUTH_TOKEN_LIFETIME_SECONDS;
+ private double oauthRefreshThreshold = DEFAULT_OAUTH_REFRESH_THRESHOLD;
private int cbpPageSize = DEFAULT_CBP_PAGE_SIZE;
private Function objectMapperCustomizer;
private final Map headers;
@@ -4374,6 +4463,7 @@ public Builder setPassword(String password) {
if (password != null) {
this.token = null;
this.oauthToken = null;
+ clearOauthClientCredentials();
}
return this;
}
@@ -4383,6 +4473,7 @@ public Builder setToken(String token) {
if (token != null) {
this.password = null;
this.oauthToken = null;
+ clearOauthClientCredentials();
}
return this;
}
@@ -4392,10 +4483,76 @@ public Builder setOauthToken(String oauthToken) {
if (oauthToken != null) {
this.password = null;
this.token = null;
+ clearOauthClientCredentials();
}
return this;
}
+ /**
+ * Authenticate with an OAuth {@code client_credentials} grant, so the client mints and
+ * refreshes its own short-lived access tokens instead of using a pre-minted one.
+ *
+ * Mutually exclusive with {@link #setPassword(String)}, {@link #setToken(String)} and {@link
+ * #setOauthToken(String)}. {@link #build()} performs no network I/O, and minting is
+ * synchronous, so one thread per refresh round waits for the token endpoint, including in the
+ * {@code *Async} methods. Call {@link Zendesk#warmUp()} at startup to keep the first request
+ * off that path.
+ *
+ *
All arguments must be non-null and nonblank, validated by {@link #build()}.
+ *
+ * @param clientId the OAuth client's unique identifier
+ * @param clientSecret the OAuth client's secret
+ * @param scope space-separated scopes to request, for example {@code "tickets:read"}
+ * @return this builder instance
+ * @since 1.6.0
+ */
+ public Builder setOauthClientCredentials(String clientId, String clientSecret, String scope) {
+ this.oauthClientId = clientId;
+ this.oauthClientSecret = clientSecret;
+ this.oauthScope = scope;
+ this.oauthClientCredentialsConfigured = true;
+ this.password = null;
+ this.token = null;
+ this.oauthToken = null;
+ return this;
+ }
+
+ /**
+ * Requested lifetime for minted access tokens, in seconds, defaulting to {@link
+ * #DEFAULT_OAUTH_TOKEN_LIFETIME_SECONDS}. Must be strictly between 300 (5 minutes) and 172,800
+ * (2 days), validated by {@link #build()}. Zendesk may grant a shorter lifetime than requested,
+ * in which case the granted one is honored.
+ *
+ * @param oauthTokenLifetimeSeconds seconds a minted token should remain valid
+ * @return this builder instance
+ * @since 1.6.0
+ */
+ public Builder setOauthTokenLifetimeSeconds(int oauthTokenLifetimeSeconds) {
+ this.oauthTokenLifetimeSeconds = oauthTokenLifetimeSeconds;
+ return this;
+ }
+
+ /**
+ * Fraction of a token's lifetime that may remain before it is refreshed, defaulting to {@link
+ * #DEFAULT_OAUTH_REFRESH_THRESHOLD}. Must be strictly between 0 and 1, validated by {@link
+ * #build()}.
+ *
+ * @param oauthRefreshThreshold fraction of the lifetime that may remain before refreshing
+ * @return this builder instance
+ * @since 1.6.0
+ */
+ public Builder setOauthRefreshThreshold(double oauthRefreshThreshold) {
+ this.oauthRefreshThreshold = oauthRefreshThreshold;
+ return this;
+ }
+
+ private void clearOauthClientCredentials() {
+ this.oauthClientId = null;
+ this.oauthClientSecret = null;
+ this.oauthScope = null;
+ this.oauthClientCredentialsConfigured = false;
+ }
+
public Builder setRetry(boolean retry) {
return this;
}
@@ -4428,6 +4585,37 @@ public Builder customizeObjectMapper(Function custom
}
public Zendesk build() {
+ if (oauthClientCredentialsConfigured) {
+ requireNonBlank(oauthClientId, "OAuth client id");
+ requireNonBlank(oauthClientSecret, "OAuth client secret");
+ requireNonBlank(oauthScope, "OAuth scope");
+ if (oauthTokenLifetimeSeconds <= MIN_OAUTH_TOKEN_LIFETIME_SECONDS
+ || oauthTokenLifetimeSeconds >= MAX_OAUTH_TOKEN_LIFETIME_SECONDS) {
+ throw new IllegalArgumentException(
+ "OAuth token lifetime must be between "
+ + MIN_OAUTH_TOKEN_LIFETIME_SECONDS
+ + " and "
+ + MAX_OAUTH_TOKEN_LIFETIME_SECONDS
+ + " seconds exclusive, but was "
+ + oauthTokenLifetimeSeconds);
+ }
+ if (!(oauthRefreshThreshold > 0.0 && oauthRefreshThreshold < 1.0)) {
+ throw new IllegalArgumentException(
+ "OAuth refresh threshold must be between 0 and 1 exclusive, but was "
+ + oauthRefreshThreshold);
+ }
+ return new Zendesk(
+ client,
+ url,
+ oauthClientId,
+ oauthClientSecret,
+ oauthScope,
+ oauthTokenLifetimeSeconds,
+ oauthRefreshThreshold,
+ headers,
+ cbpPageSize,
+ objectMapperCustomizer);
+ }
if (token != null) {
return new Zendesk(
client, url, username + "/token", token, headers, cbpPageSize, objectMapperCustomizer);
@@ -4437,5 +4625,12 @@ public Zendesk build() {
return new Zendesk(
client, url, username, password, headers, cbpPageSize, objectMapperCustomizer);
}
+
+ private static void requireNonBlank(String value, String name) {
+ Objects.requireNonNull(value, name + " cannot be null");
+ if (value.trim().isEmpty()) {
+ throw new IllegalArgumentException(name + " cannot be blank");
+ }
+ }
}
}
diff --git a/src/main/java/org/zendesk/client/v2/ZendeskOAuthException.java b/src/main/java/org/zendesk/client/v2/ZendeskOAuthException.java
new file mode 100644
index 00000000..b9822ef8
--- /dev/null
+++ b/src/main/java/org/zendesk/client/v2/ZendeskOAuthException.java
@@ -0,0 +1,33 @@
+package org.zendesk.client.v2;
+
+/**
+ * {@link ZendeskException} for failures to obtain an OAuth access token.
+ *
+ * Distinct from {@link ZendeskResponseException} so callers can tell authentication issues apart
+ * from actual API exceptions.
+ *
+ * @since 1.6.0
+ */
+public class ZendeskOAuthException extends ZendeskException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Creates an OAuth exception with a message.
+ *
+ * @param message exception message
+ */
+ public ZendeskOAuthException(String message) {
+ super(message);
+ }
+
+ /**
+ * Creates an OAuth exception with a message and cause.
+ *
+ * @param message exception message
+ * @param cause underlying failure
+ */
+ public ZendeskOAuthException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/test/java/org/zendesk/client/v2/HttpTokenMinterTest.java b/src/test/java/org/zendesk/client/v2/HttpTokenMinterTest.java
new file mode 100644
index 00000000..8a785169
--- /dev/null
+++ b/src/test/java/org/zendesk/client/v2/HttpTokenMinterTest.java
@@ -0,0 +1,289 @@
+package org.zendesk.client.v2;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath;
+import static com.github.tomakehurst.wiremock.client.WireMock.ok;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.github.tomakehurst.wiremock.junit.WireMockClassRule;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import org.apache.commons.text.RandomStringGenerator;
+import org.asynchttpclient.AsyncHttpClient;
+import org.asynchttpclient.DefaultAsyncHttpClient;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+
+public class HttpTokenMinterTest {
+
+ private static final RandomStringGenerator RANDOM_STRING_GENERATOR =
+ new RandomStringGenerator.Builder().withinRange('a', 'z').build();
+ private static final String CLIENT_ID = RANDOM_STRING_GENERATOR.generate(12);
+ private static final String CLIENT_SECRET = RANDOM_STRING_GENERATOR.generate(24);
+ private static final String SCOPE = "tickets:read users:read";
+ private static final String ACCESS_TOKEN = RANDOM_STRING_GENERATOR.generate(30);
+ private static final int LIFETIME_SECONDS = 1800;
+
+ @ClassRule
+ public static WireMockClassRule zendeskApiClass =
+ new WireMockClassRule(options().dynamicPort().dynamicHttpsPort());
+
+ @Rule public WireMockClassRule zendeskApiMock = zendeskApiClass;
+
+ private AsyncHttpClient httpClient;
+ private String baseHostUrl;
+
+ @Before
+ public void setUp() {
+ httpClient = new DefaultAsyncHttpClient();
+ baseHostUrl = String.format("http://localhost:%d", zendeskApiMock.port());
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ httpClient.close();
+ }
+
+ @Test
+ public void mintPostsClientCredentialsGrantToHostRoot() {
+ stubTokenResponse(ok(tokenBody(ACCESS_TOKEN, LIFETIME_SECONDS)));
+
+ var token = minter().mint();
+
+ assertThat(token.accessToken()).isEqualTo(ACCESS_TOKEN);
+
+ zendeskApiMock.verify(
+ postRequestedFor(urlPathEqualTo("/oauth/tokens"))
+ .withHeader("Content-Type", equalTo("application/json"))
+ .withRequestBody(
+ equalToJson(
+ "{\"grant_type\":\"client_credentials\","
+ + "\"client_id\":\""
+ + CLIENT_ID
+ + "\","
+ + "\"client_secret\":\""
+ + CLIENT_SECRET
+ + "\","
+ + "\"scope\":\""
+ + SCOPE
+ + "\","
+ + "\"expires_in\":"
+ + LIFETIME_SECONDS
+ + "}")));
+ }
+
+ @Test
+ public void mintAlwaysSendsExpiresIn() {
+ stubTokenResponse(ok(tokenBody(ACCESS_TOKEN, LIFETIME_SECONDS)));
+
+ minter(600).mint();
+
+ zendeskApiMock.verify(
+ postRequestedFor(urlPathEqualTo("/oauth/tokens"))
+ .withRequestBody(matchingJsonPath("$.expires_in", equalTo("600"))));
+ }
+
+ @Test
+ public void mintFallsBackToRequestedLifetimeWhenExpiresInAbsentOrNull() {
+ String[] absent = {
+ "{\"access_token\":\"" + ACCESS_TOKEN + "\"}",
+ "{\"access_token\":\"" + ACCESS_TOKEN + "\",\"expires_in\":null}"
+ };
+
+ for (String body : absent) {
+ zendeskApiMock.resetAll();
+ stubTokenResponse(ok(body));
+
+ var token = minter().mint();
+
+ assertThat(token.expiresAt())
+ .as("body %s", body)
+ .isEqualTo(token.issuedAt().plusSeconds(LIFETIME_SECONDS));
+ }
+ }
+
+ @Test
+ public void mintHonorsGrantedLifetimeOverRequested() {
+ stubTokenResponse(ok(tokenBody(ACCESS_TOKEN, 900)));
+
+ var token = minter().mint();
+
+ assertThat(token.expiresAt()).isEqualTo(token.issuedAt().plusSeconds(900));
+ }
+
+ @Test
+ public void mintAnchorsExpiryBeforeRequest() {
+ var t0 = Instant.parse("2026-01-01T00:00:00Z");
+
+ stubTokenResponse(ok(tokenBody(ACCESS_TOKEN, LIFETIME_SECONDS)));
+
+ var token = minter(Clock.fixed(t0, ZoneOffset.UTC)).mint();
+
+ assertThat(token.issuedAt()).isEqualTo(t0);
+ assertThat(token.expiresAt()).isEqualTo(t0.plusSeconds(LIFETIME_SECONDS));
+ }
+
+ @Test
+ public void mintNonSuccessStatusExpectException() {
+ int[] statuses = {400, 401, 403, 429, 500};
+
+ for (int status : statuses) {
+ zendeskApiMock.resetAll();
+ stubTokenResponse(aResponse().withStatus(status).withBody("{\"error\":\"invalid_client\"}"));
+
+ assertThatThrownBy(() -> minter().mint())
+ .as("HTTP %d", status)
+ .isInstanceOf(ZendeskOAuthException.class)
+ .hasMessageContaining(String.valueOf(status));
+ }
+ }
+
+ @Test
+ public void mintMissingAccessTokenExpectException() {
+ String[] bodies = {
+ "{}",
+ "{\"expires_in\":1800}",
+ "{\"access_token\":null}",
+ "{\"access_token\":\"\"}",
+ "{\"access_token\":\" \"}"
+ };
+
+ for (String body : bodies) {
+ zendeskApiMock.resetAll();
+ stubTokenResponse(ok(body));
+
+ assertThatThrownBy(() -> minter().mint())
+ .as("body %s", body)
+ .isInstanceOf(ZendeskOAuthException.class)
+ .hasMessageContaining("no usable access_token");
+ }
+ }
+
+ @Test
+ public void mintNonIntegerExpiresInExpectException() {
+ // A stringified "900" must not fall back to the requested 1800, which would outlive what the
+ // server granted.
+ String[] malformed = {"\"900\"", "\"soon\"", "1800.75", "true", "{}", "[]", "\"\""};
+
+ for (String expiresIn : malformed) {
+ zendeskApiMock.resetAll();
+ stubTokenResponse(
+ ok("{\"access_token\":\"" + ACCESS_TOKEN + "\",\"expires_in\":" + expiresIn + "}"));
+
+ assertThatThrownBy(() -> minter().mint())
+ .as("expires_in %s", expiresIn)
+ .isInstanceOf(ZendeskOAuthException.class)
+ .hasMessageContaining("non-integer");
+ }
+ }
+
+ @Test
+ public void mintOutOfRangeExpiresInExpectException() {
+ String[] outOfRange = {
+ "0", "-1", "-1800", "172801", "9223372036854775807", "99999999999999999999999"
+ };
+
+ for (String expiresIn : outOfRange) {
+ zendeskApiMock.resetAll();
+ stubTokenResponse(
+ ok("{\"access_token\":\"" + ACCESS_TOKEN + "\",\"expires_in\":" + expiresIn + "}"));
+
+ assertThatThrownBy(() -> minter().mint())
+ .as("expires_in %s", expiresIn)
+ .isInstanceOf(ZendeskOAuthException.class)
+ .hasMessageContaining("out-of-range");
+ }
+ }
+
+ @Test
+ public void mintAcceptsGrantedLifetimeAtTheUpperBound() {
+ stubTokenResponse(ok(tokenBody(ACCESS_TOKEN, 172_800)));
+
+ var token = minter().mint();
+
+ assertThat(token.expiresAt()).isEqualTo(token.issuedAt().plusSeconds(172_800));
+ }
+
+ @Test
+ public void mintEmptyBodyExpectException() {
+ String[] empty = {"", " "};
+
+ for (String body : empty) {
+ zendeskApiMock.resetAll();
+ stubTokenResponse(ok(body));
+
+ assertThatThrownBy(() -> minter().mint())
+ .as("body '%s'", body)
+ .isInstanceOf(ZendeskOAuthException.class)
+ .hasMessageContaining("empty");
+ }
+ }
+
+ @Test
+ public void mintNonObjectBodyExpectException() {
+ String[] bodies = {"null", "[]", "[{\"access_token\":\"x\"}]", "42", "\"str\"", "true"};
+
+ for (String body : bodies) {
+ zendeskApiMock.resetAll();
+ stubTokenResponse(ok(body));
+
+ assertThatThrownBy(() -> minter().mint())
+ .as("body %s", body)
+ .isInstanceOf(ZendeskOAuthException.class)
+ .hasMessageContaining("not a JSON object");
+ }
+ }
+
+ @Test
+ public void mintMalformedBodyExpectException() {
+ stubTokenResponse(ok("not json at all"));
+
+ assertThatThrownBy(() -> minter().mint()).isInstanceOf(ZendeskOAuthException.class);
+ }
+
+ @Test
+ public void mintExceptionOmitsClientSecret() {
+ stubTokenResponse(aResponse().withStatus(401).withBody("{\"error\":\"invalid_client\"}"));
+
+ assertThatThrownBy(() -> minter().mint())
+ .isInstanceOf(ZendeskOAuthException.class)
+ .hasMessageNotContaining(CLIENT_SECRET);
+ }
+
+ private void stubTokenResponse(
+ com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder response) {
+ zendeskApiMock.stubFor(post(urlPathEqualTo("/oauth/tokens")).willReturn(response));
+ }
+
+ private static String tokenBody(String accessToken, int expiresIn) {
+ return "{\"access_token\":\"" + accessToken + "\",\"expires_in\":" + expiresIn + "}";
+ }
+
+ private HttpTokenMinter minter() {
+ return minter(LIFETIME_SECONDS);
+ }
+
+ private HttpTokenMinter minter(int requestedLifetimeSeconds) {
+ return minter(requestedLifetimeSeconds, Clock.systemUTC());
+ }
+
+ private HttpTokenMinter minter(Clock clock) {
+ return minter(LIFETIME_SECONDS, clock);
+ }
+
+ private HttpTokenMinter minter(int requestedLifetimeSeconds, Clock clock) {
+ return new HttpTokenMinter(
+ httpClient, baseHostUrl, CLIENT_ID, CLIENT_SECRET, SCOPE, requestedLifetimeSeconds, clock);
+ }
+}
diff --git a/src/test/java/org/zendesk/client/v2/RealSmokeTest.java b/src/test/java/org/zendesk/client/v2/RealSmokeTest.java
index 092775aa..d8c12e19 100644
--- a/src/test/java/org/zendesk/client/v2/RealSmokeTest.java
+++ b/src/test/java/org/zendesk/client/v2/RealSmokeTest.java
@@ -161,6 +161,19 @@ public void assumeHaveToken() {
assumeThat("We have a token", config.getProperty("token"), not(isEmptyOrNullString()));
}
+ public void assumeHaveOauthClientCredentials() {
+ assumeThat(
+ "We have an OAuth client id",
+ config.getProperty("oauth.client.id"),
+ not(isEmptyOrNullString()));
+ assumeThat(
+ "We have an OAuth client secret",
+ config.getProperty("oauth.client.secret"),
+ not(isEmptyOrNullString()));
+ assumeThat(
+ "We have an OAuth scope", config.getProperty("oauth.scope"), not(isEmptyOrNullString()));
+ }
+
public void assumeHavePassword() {
assumeThat("We have a username", config.getProperty("username"), not(isEmptyOrNullString()));
assumeThat("We have a password", config.getProperty("password"), not(isEmptyOrNullString()));
@@ -182,6 +195,29 @@ public void closeClient() {
instance = null;
}
+ /**
+ * Enable by setting {@code oauth.client.id}, {@code oauth.client.secret} and {@code oauth.scope}
+ * (or the corresponding {@code ZENDESK_JAVA_CLIENT_TEST_OAUTH_*} environment variables). The
+ * scope must cover reading tickets, since this exercises the minted token against a real endpoint
+ * too: a scope that can mint but cannot read tickets fails on the API call, after {@code
+ * warmUp()} has already succeeded.
+ */
+ @Test
+ public void createClientWithOauthClientCredentials() throws Exception {
+ assumeHaveOauthClientCredentials();
+ instance =
+ new Zendesk.Builder(config.getProperty("url"))
+ .setOauthClientCredentials(
+ config.getProperty("oauth.client.id"),
+ config.getProperty("oauth.client.secret"),
+ config.getProperty("oauth.scope"))
+ .build();
+
+ instance.warmUp(); // forces credential validation before any API call
+
+ assertThat("A ticket count is returned", instance.getTicketsCount(), notNullValue());
+ }
+
@Test
public void createClientWithToken() throws Exception {
assumeHaveToken();
diff --git a/src/test/java/org/zendesk/client/v2/SharedFutureTokenProviderTest.java b/src/test/java/org/zendesk/client/v2/SharedFutureTokenProviderTest.java
new file mode 100644
index 00000000..c4da94de
--- /dev/null
+++ b/src/test/java/org/zendesk/client/v2/SharedFutureTokenProviderTest.java
@@ -0,0 +1,760 @@
+package org.zendesk.client.v2;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.awaitility.Awaitility.await;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.After;
+import org.junit.Test;
+
+public class SharedFutureTokenProviderTest {
+
+ private static final Instant T0 = Instant.parse("2026-01-01T00:00:00Z");
+ private static final double REFRESH_THRESHOLD = 0.5;
+ private static final Duration LIFETIME = Duration.ofMinutes(30);
+ private static final Duration INTO_REFRESH_WINDOW = Duration.ofMinutes(20);
+ private static final Duration PAST_EXPIRY = LIFETIME.plusMinutes(1);
+
+ /** Mirrors {@code SharedFutureTokenProvider.REFRESH_BACKOFF_DURATION}. */
+ private static final Duration REFRESH_BACKOFF_DURATION = Duration.ofSeconds(10);
+
+ private final MutableClock clock = new MutableClock(T0);
+ private final List pools = new ArrayList<>();
+
+ @After
+ public void shutdownPools() {
+ pools.forEach(ExecutorService::shutdownNow);
+ }
+
+ //////////////////////////////////////////////////////////////////////
+ // Refresh policy
+ //////////////////////////////////////////////////////////////////////
+
+ @Test
+ public void serveFreshTokenWithoutMinting() {
+ var minter = new FakeMinter();
+ var provider = provider(minter);
+
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+ clock.advance(Duration.ofMinutes(5));
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+
+ assertThat(minter.mintCount).hasValue(1);
+ }
+
+ @Test
+ public void reMintOnceInRefreshWindow() {
+ var minter = new FakeMinter();
+ var provider = provider(minter);
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+
+ clock.advance(INTO_REFRESH_WINDOW);
+
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-2");
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-2");
+ assertThat(minter.mintCount).hasValue(2);
+ }
+
+ @Test
+ public void reMintOnlyPastRefreshWindow() {
+ // With a 0.5 threshold on a 30-minute lifetime, the boundary is 15 minutes remaining.
+ long[][] cases = {{14, 1}, {15, 2}, {16, 2}};
+
+ for (long[] testCase : cases) {
+ var elapsedMinutes = testCase[0];
+ var expectedMints = (int) testCase[1];
+ var caseClock = new MutableClock(T0);
+ var minter = new FakeMinter(caseClock);
+ var provider = new SharedFutureTokenProvider(minter, caseClock, REFRESH_THRESHOLD);
+ provider.provideBearerToken();
+
+ caseClock.advance(Duration.ofMinutes(elapsedMinutes));
+ provider.provideBearerToken();
+
+ assertThat(minter.mintCount)
+ .as("mints after %d minutes elapsed", elapsedMinutes)
+ .hasValue(expectedMints);
+ }
+ }
+
+ @Test
+ public void servableTokenBoundary() {
+ var minter = new FakeMinter();
+ var provider = provider(minter);
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+
+ minter.failWith(new ZendeskOAuthException("mint failed"));
+
+ // Right before expiry: cached token is still servable on mint failure.
+ clock.advance(LIFETIME.minusNanos(1));
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+
+ // At expiry: no longer servable, so the mint failure surfaces.
+ clock.advance(Duration.ofNanos(1));
+ assertThatThrownBy(provider::provideBearerToken).isInstanceOf(ZendeskOAuthException.class);
+ }
+
+ @Test
+ public void invalidRefreshThresholdExpectException() {
+ double[] invalid = {
+ 0.0, 1.0, -0.1, 1.5, Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY
+ };
+
+ for (double threshold : invalid) {
+ assertThatThrownBy(() -> new SharedFutureTokenProvider(new FakeMinter(), clock, threshold))
+ .as("threshold %s", threshold)
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+ }
+
+ //////////////////////////////////////////////////////////////////////
+ // Single-flight and blocking
+ //////////////////////////////////////////////////////////////////////
+
+ @Test
+ public void coldStartMintsOnceAcrossConcurrentThreads() {
+ var gate = new CountDownLatch(1);
+ var minter = new FakeMinter();
+ minter.freezeMintUntil(gate);
+ var provider = provider(minter);
+
+ var calls = startRacingThreads(8, provider);
+ await().atMost(5, TimeUnit.SECONDS).until(() -> minter.threadsInsideMint.get() == 1);
+ gate.countDown();
+ var results = awaitResults(calls);
+
+ assertThat(minter.mintCount).hasValue(1);
+ assertThat(minter.peakConcurrentMints).hasValueLessThanOrEqualTo(1);
+ assertThat(results).allMatch(Result::succeeded).extracting(Result::value).containsOnly("tok-1");
+ }
+
+ @Test
+ public void expiredTokenBlocksOnSharedMint() throws Exception {
+ var minter = new FakeMinter();
+ var provider = provider(minter);
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+
+ clock.advance(PAST_EXPIRY);
+ var gate = new CountDownLatch(1);
+ minter.freezeMintUntil(gate);
+
+ var pool = pool(2);
+ var leader = startMintingLeader(pool, provider, minter);
+
+ // The cached token is unusable, so neither thread may be handed anything until the mint lands.
+ var follower = startParkedFollower(pool, provider);
+ assertThat(leader.isDone()).isFalse();
+ assertThat(follower.isDone()).isFalse();
+
+ gate.countDown();
+ assertThat(leader.get(5, TimeUnit.SECONDS).value()).isEqualTo("tok-2");
+ assertThat(follower.get(5, TimeUnit.SECONDS).value()).isEqualTo("tok-2");
+ assertThat(minter.mintCount).hasValue(2);
+ assertThat(minter.peakConcurrentMints).hasValueLessThanOrEqualTo(1);
+ }
+
+ @Test
+ public void followersInRefreshWindowDoNotBlock() throws Exception {
+ var minter = new FakeMinter();
+ var provider = provider(minter);
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+
+ clock.advance(INTO_REFRESH_WINDOW);
+ var gate = new CountDownLatch(1);
+ minter.freezeMintUntil(gate);
+
+ var pool = pool(2);
+ var leader = startMintingLeader(pool, provider, minter);
+ var follower = pool.submit(() -> call(provider));
+
+ // The cached token is stale but still valid, so the follower keeps using it.
+ assertThat(follower.get(2, TimeUnit.SECONDS).value()).isEqualTo("tok-1");
+ assertThat(leader.isDone()).isFalse();
+
+ gate.countDown();
+ assertThat(leader.get(5, TimeUnit.SECONDS).value()).isEqualTo("tok-2");
+ assertThat(minter.mintCount).hasValue(2);
+ assertThat(minter.peakConcurrentMints).hasValueLessThanOrEqualTo(1);
+ }
+
+ @Test
+ public void leaderBlocksWhileRefreshingInWindow() throws Exception {
+ var minter = new FakeMinter();
+ var provider = provider(minter);
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+
+ clock.advance(INTO_REFRESH_WINDOW);
+ var gate = new CountDownLatch(1);
+ minter.freezeMintUntil(gate);
+
+ var pool = pool(1);
+ var leader = startMintingLeader(pool, provider, minter);
+
+ // Inside the mint with the gate still closed, so the leader cannot have returned: it is
+ // minting synchronously even though its cached token is still usable.
+ assertThat(leader.isDone()).isFalse();
+
+ gate.countDown();
+ assertThat(leader.get(5, TimeUnit.SECONDS).value()).isEqualTo("tok-2");
+ }
+
+ //////////////////////////////////////////////////////////////////////
+ // Failure handling
+ //////////////////////////////////////////////////////////////////////
+
+ @Test
+ public void failedMintExpectSameExceptionForAllAwaiters() throws Exception {
+ var gate = new CountDownLatch(1);
+ var minter = new FakeMinter();
+ minter.freezeMintUntil(gate);
+ minter.failWith(new ZendeskOAuthException("mint failed"));
+ var provider = provider(minter);
+
+ var pool = pool(2);
+ var leader = startMintingLeader(pool, provider, minter);
+ var follower = startParkedFollower(pool, provider);
+
+ gate.countDown();
+ var leaderResult = leader.get(5, TimeUnit.SECONDS);
+ var followerResult = follower.get(5, TimeUnit.SECONDS);
+
+ assertThat(leaderResult.failed()).isTrue();
+ assertThat(followerResult.failed()).isTrue();
+
+ // No thread in the round attempts to re-mint.
+ assertThat(followerResult.error()).isSameAs(leaderResult.error());
+ assertThat(minter.mintCount).hasValue(1);
+ }
+
+ @Test
+ public void failedMintServesStillValidToken() {
+ var minter = new FakeMinter();
+ var provider = provider(minter);
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+
+ minter.failWith(new ZendeskOAuthException("mint failed"));
+
+ clock.advance(INTO_REFRESH_WINDOW);
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+ }
+
+ @Test
+ public void failedMintExpectExceptionWhenTokenExpiredDuringMint() {
+ var minter = new FakeMinter();
+ var provider = provider(minter);
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+
+ // The cached token is no longer servable by the end of the mint process.
+ minter.advanceClockDuringMint(Duration.ofMinutes(11));
+ minter.failWith(new ZendeskOAuthException("mint failed"));
+
+ clock.advance(INTO_REFRESH_WINDOW);
+ assertThatThrownBy(provider::provideBearerToken).isInstanceOf(ZendeskOAuthException.class);
+ }
+
+ @Test
+ public void failedMintClearsInFlightForNextWave() {
+ var minter = new FakeMinter();
+ var provider = provider(minter);
+
+ minter.failWith(new ZendeskOAuthException("mint failed"));
+ assertThatThrownBy(provider::provideBearerToken).isInstanceOf(ZendeskOAuthException.class);
+
+ minter.stopFailing();
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-2");
+ assertThat(minter.mintCount).hasValue(2);
+ }
+
+ @Test
+ public void mintErrorFailsAwaitingThreadsInsteadOfHangingThem() throws Exception {
+ var gate = new CountDownLatch(1);
+ var minter = new FakeMinter();
+ var provider = provider(minter);
+
+ // An Error is outside the catch, so only the finally block can release a parked thread.
+ minter.failWith(new Error("boom"));
+ minter.freezeMintUntil(gate);
+
+ var pool = pool(2);
+ var leader = startMintingLeader(pool, provider, minter);
+ var follower = startParkedFollower(pool, provider);
+
+ gate.countDown();
+
+ // This get() is the assertion: without the finally guard nothing completes the round, so the
+ // follower stays parked and this times out.
+ var followerResult = follower.get(5, TimeUnit.SECONDS);
+ assertThat(followerResult.error())
+ .isInstanceOf(ZendeskOAuthException.class)
+ .hasMessageContaining("did not complete");
+
+ assertThat(leader.get(5, TimeUnit.SECONDS).error()).isInstanceOf(Error.class);
+ }
+
+ @Test
+ public void mintErrorClearsInFlightForNextWave() {
+ var minter = new FakeMinter();
+ var provider = provider(minter);
+
+ minter.failWith(new Error("boom"));
+ assertThatThrownBy(provider::provideBearerToken).isInstanceOf(Error.class);
+
+ minter.stopFailing();
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-2");
+ assertThat(minter.mintCount).hasValue(2);
+ }
+
+ @Test
+ public void interruptedAwaitRestoresInterruptFlag() throws Exception {
+ var gate = new CountDownLatch(1);
+ var minter = new FakeMinter();
+ minter.freezeMintUntil(gate);
+ var provider = provider(minter);
+ var followerThread = new AtomicReference();
+ var wasInterrupted = new AtomicBoolean();
+
+ var pool = pool(2);
+ startMintingLeader(pool, provider, minter);
+
+ var follower =
+ pool.submit(
+ () -> {
+ followerThread.set(Thread.currentThread());
+ try {
+ return call(provider);
+ } finally {
+ wasInterrupted.set(Thread.currentThread().isInterrupted());
+ }
+ });
+ awaitParkedOnSharedResult(followerThread);
+
+ followerThread.get().interrupt();
+ var result = follower.get(5, TimeUnit.SECONDS);
+
+ assertThat(result.failed()).isTrue();
+ assertThat(result.error()).isInstanceOf(ZendeskOAuthException.class);
+ assertThat(wasInterrupted).isTrue();
+ gate.countDown();
+ }
+
+ @Test
+ public void awaiterFallsBackToServableTokenWhenLeaderMintFails() throws Exception {
+ var minter = new FakeMinter();
+ var provider = provider(minter);
+
+ // Reproduces a caller that captured an expired token, then waited on a failed refresh after
+ // another thread had already published a servable token.
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+ clock.advance(PAST_EXPIRY);
+
+ var pool = pool(2);
+ var awaiter =
+ pool.submit(
+ () -> {
+ clock.stopThreadAtNextFreshnessCheck(Thread.currentThread());
+ return call(provider);
+ });
+ clock.awaitThreadStoppedAfterTokenSnapshot();
+
+ // Publish a fresh token while the awaiter still holds its expired snapshot.
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-2");
+
+ // Start a failed refresh round that the awaiter will join.
+ clock.advance(INTO_REFRESH_WINDOW);
+ var gate = new CountDownLatch(1);
+ minter.freezeMintUntil(gate);
+ minter.failWith(new ZendeskOAuthException("mint failed"));
+ var leader = startMintingLeader(pool, provider, minter);
+
+ // Release the awaiter after it rejects its expired snapshot and joins the in-flight round.
+ clock.resumeStoppedThread();
+ clock.awaitExpiredSnapshotRejected();
+
+ gate.countDown();
+
+ var leaderResult = leader.get(5, TimeUnit.SECONDS);
+ var awaiterResult = awaiter.get(5, TimeUnit.SECONDS);
+
+ assertThat(leaderResult.value())
+ .as("leader serves the servable cached token")
+ .isEqualTo("tok-2");
+ assertThat(awaiterResult.succeeded())
+ .as("awaiter must fall back to the servable cached token, not inherit the failed mint")
+ .isTrue();
+ assertThat(awaiterResult.value()).isEqualTo("tok-2");
+ }
+
+ //////////////////////////////////////////////////////////////////////
+ // Backoff after failed mints
+ //////////////////////////////////////////////////////////////////////
+
+ @Test
+ public void failedMintSchedulesBackoffSuppressingNextMintWhileServable() {
+ var minter = new FakeMinter();
+ var provider = provider(minter);
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+
+ minter.failWith(new ZendeskOAuthException("mint failed"));
+ clock.advance(INTO_REFRESH_WINDOW);
+
+ // The leader fails, serves the still-servable token, and schedules backoff.
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+ assertThat(minter.mintCount).hasValue(2);
+
+ // Inside the backoff with the token still servable
+ clock.advance(REFRESH_BACKOFF_DURATION.dividedBy(2));
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+ assertThat(minter.mintCount).hasValue(2);
+ }
+
+ @Test
+ public void backoffElapsesThenReMints() {
+ var minter = new FakeMinter();
+ var provider = provider(minter);
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+
+ minter.failWith(new ZendeskOAuthException("mint failed"));
+ clock.advance(INTO_REFRESH_WINDOW);
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+ assertThat(minter.mintCount).hasValue(2);
+
+ minter.stopFailing();
+ clock.advance(REFRESH_BACKOFF_DURATION.plusSeconds(1));
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-3");
+ assertThat(minter.mintCount).hasValue(3);
+ }
+
+ @Test
+ public void coldStartMintFailureKeepsRetryingWithoutBackoff() {
+ var minter = new FakeMinter();
+ var provider = provider(minter);
+ minter.failWith(new ZendeskOAuthException("mint failed"));
+
+ assertThatThrownBy(provider::provideBearerToken).isInstanceOf(ZendeskOAuthException.class);
+ assertThat(minter.mintCount).hasValue(1);
+
+ assertThatThrownBy(provider::provideBearerToken).isInstanceOf(ZendeskOAuthException.class);
+ assertThat(minter.mintCount).hasValue(2);
+ }
+
+ @Test
+ public void backoffStopsSuppressingOnceServableTokenExpires() {
+ var minter = new FakeMinter();
+ var provider = provider(minter);
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+
+ clock.advance(LIFETIME.minusNanos(1));
+ minter.failWith(new ZendeskOAuthException("mint failed"));
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+ assertThat(minter.mintCount).hasValue(2);
+
+ // Still servable and inside backoff at the same instant: suppressed.
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+ assertThat(minter.mintCount).hasValue(2);
+
+ // Once the cached token expires, backoff no longer suppresses minting.
+ minter.stopFailing();
+ clock.advance(Duration.ofNanos(1));
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-3");
+ assertThat(minter.mintCount).hasValue(3);
+ }
+
+ @Test
+ public void backoffBoundaryAttemptsMintAtBackoffExpiry() {
+ var minter = new FakeMinter();
+ var provider = provider(minter);
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+
+ minter.failWith(new ZendeskOAuthException("mint failed"));
+ clock.advance(INTO_REFRESH_WINDOW);
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+ assertThat(minter.mintCount).hasValue(2);
+
+ clock.advance(REFRESH_BACKOFF_DURATION.minusNanos(1));
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+ assertThat(minter.mintCount).hasValue(2);
+
+ clock.advance(Duration.ofNanos(1));
+ assertThat(provider.provideBearerToken()).isEqualTo("tok-1");
+ assertThat(minter.mintCount).hasValue(3);
+ }
+
+ //////////////////////////////////////////////////////////////////////
+ // Test helpers
+ //////////////////////////////////////////////////////////////////////
+
+ /** Submits a thread that wins the election, returning once it is frozen inside the mint. */
+ private Future startMintingLeader(
+ ExecutorService pool, TokenProvider provider, FakeMinter minter) {
+ var leader = pool.submit(() -> call(provider));
+ await().atMost(5, TimeUnit.SECONDS).until(() -> minter.threadsInsideMint.get() == 1);
+ return leader;
+ }
+
+ /**
+ * Submits a thread that loses the election, returning once it is parked on the leader's round.
+ */
+ private Future startParkedFollower(ExecutorService pool, TokenProvider provider) {
+ var followerThread = new AtomicReference();
+ var follower =
+ pool.submit(
+ () -> {
+ followerThread.set(Thread.currentThread());
+ return call(provider);
+ });
+ awaitParkedOnSharedResult(followerThread);
+ return follower;
+ }
+
+ /**
+ * Waits until the given thread has actually parked awaiting the shared mint result, so a test can
+ * act on a genuine follower rather than sleeping and hoping.
+ */
+ private void awaitParkedOnSharedResult(AtomicReference threadRef) {
+ await()
+ .atMost(5, TimeUnit.SECONDS)
+ .until(
+ () -> {
+ var thread = threadRef.get();
+ return thread != null && thread.getState() == Thread.State.WAITING;
+ });
+ }
+
+ private SharedFutureTokenProvider provider(FakeMinter minter) {
+ return new SharedFutureTokenProvider(minter, clock, REFRESH_THRESHOLD);
+ }
+
+ private ExecutorService pool(int threads) {
+ var pool = Executors.newFixedThreadPool(threads);
+ pools.add(pool);
+ return pool;
+ }
+
+ /**
+ * Submits threads held at a barrier, so they are released together and contend as hard as
+ * possible for the single-flight probe.
+ */
+ private List> startRacingThreads(int threads, TokenProvider provider) {
+ var pool = pool(threads);
+ var startLine = new CyclicBarrier(threads);
+ var futures = new ArrayList>(threads);
+ for (var i = 0; i < threads; i++) {
+ futures.add(
+ pool.submit(
+ () -> {
+ startLine.await();
+ return call(provider);
+ }));
+ }
+ return futures;
+ }
+
+ private static List awaitResults(List> futures) {
+ var results = new ArrayList(futures.size());
+ for (var future : futures) {
+ try {
+ results.add(future.get(10, TimeUnit.SECONDS));
+ } catch (Exception e) {
+ throw new AssertionError("thread did not finish", e);
+ }
+ }
+ return results;
+ }
+
+ private static Result call(TokenProvider provider) {
+ try {
+ return new Result(provider.provideBearerToken(), null);
+ } catch (Throwable t) {
+ return new Result(null, t);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void sneakyThrow(Throwable t) throws T {
+ throw (T) t;
+ }
+
+ private static void awaitLatch(CountDownLatch latch) {
+ try {
+ latch.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private static final class Result {
+ private final String value;
+ private final Throwable error;
+
+ Result(String value, Throwable error) {
+ this.value = value;
+ this.error = error;
+ }
+
+ String value() {
+ return value;
+ }
+
+ Throwable error() {
+ return error;
+ }
+
+ boolean succeeded() {
+ return value != null && error == null;
+ }
+
+ boolean failed() {
+ return value == null && error != null;
+ }
+ }
+
+ /**
+ * A fully controllable mint. {@code peakConcurrentMints} records the peak number of threads
+ * inside {@link #mint()} at once.
+ */
+ private final class FakeMinter implements TokenMinter {
+ private final AtomicInteger mintCount = new AtomicInteger();
+ private final AtomicInteger threadsInsideMint = new AtomicInteger();
+ private final AtomicInteger peakConcurrentMints = new AtomicInteger();
+ private final AtomicReference behavior =
+ new AtomicReference<>(new MintBehavior(() -> {}, null));
+ private final Clock mintClock;
+
+ FakeMinter() {
+ this(SharedFutureTokenProviderTest.this.clock);
+ }
+
+ FakeMinter(Clock mintClock) {
+ this.mintClock = mintClock;
+ }
+
+ /** Parks every mint inside {@link #mint()} until the latch opens. */
+ void freezeMintUntil(CountDownLatch gate) {
+ behavior.updateAndGet(current -> new MintBehavior(() -> awaitLatch(gate), current.failure));
+ }
+
+ /** Moves the clock on while a mint is in progress, to age a token mid-refresh. */
+ void advanceClockDuringMint(Duration delta) {
+ behavior.updateAndGet(
+ current -> new MintBehavior(() -> clock.advance(delta), current.failure));
+ }
+
+ void failWith(Throwable failure) {
+ behavior.updateAndGet(current -> new MintBehavior(current.insideMint, failure));
+ }
+
+ void stopFailing() {
+ behavior.updateAndGet(current -> new MintBehavior(current.insideMint, null));
+ }
+
+ @Override
+ public OAuthToken mint() {
+ peakConcurrentMints.accumulateAndGet(threadsInsideMint.incrementAndGet(), Math::max);
+ try {
+ var n = mintCount.incrementAndGet();
+
+ var currentBehavior = behavior.get();
+ currentBehavior.insideMint.run();
+
+ if (currentBehavior.failure != null) {
+ sneakyThrow(currentBehavior.failure);
+ }
+
+ var issuedAt = mintClock.instant();
+ return new OAuthToken("tok-" + n, issuedAt, issuedAt.plus(LIFETIME));
+ } finally {
+ threadsInsideMint.decrementAndGet();
+ }
+ }
+ }
+
+ private static final class MintBehavior {
+ private final Runnable insideMint;
+ private final Throwable failure;
+
+ MintBehavior(Runnable insideMint, Throwable failure) {
+ this.insideMint = insideMint;
+ this.failure = failure;
+ }
+ }
+
+ /*A clock the test moves by hand, to drive a token fresh -> stale -> expired with no waiting. */
+ private static final class MutableClock extends Clock {
+ private final AtomicReference now;
+ private final CountDownLatch stoppedAfterTokenSnapshot = new CountDownLatch(1);
+ private final CountDownLatch resumeStoppedThread = new CountDownLatch(1);
+ private final CountDownLatch expiredSnapshotRejected = new CountDownLatch(1);
+ private final AtomicInteger stoppedThreadClockReads = new AtomicInteger();
+ private volatile Thread stoppedThread;
+
+ MutableClock(Instant start) {
+ this.now = new AtomicReference<>(start);
+ }
+
+ void advance(Duration delta) {
+ now.updateAndGet(instant -> instant.plus(delta));
+ }
+
+ /**
+ * Stops {@code thread} when it next checks token freshness, after it has captured its token
+ * snapshot, but before it can elect or join a refresh round.
+ */
+ void stopThreadAtNextFreshnessCheck(Thread thread) {
+ stoppedThread = thread;
+ }
+
+ void awaitThreadStoppedAfterTokenSnapshot() {
+ awaitLatch(stoppedAfterTokenSnapshot);
+ }
+
+ void resumeStoppedThread() {
+ resumeStoppedThread.countDown();
+ }
+
+ /** Blocks until the stopped thread re-checks and rejects its expired token snapshot. */
+ void awaitExpiredSnapshotRejected() {
+ awaitLatch(expiredSnapshotRejected);
+ }
+
+ @Override
+ public Instant instant() {
+ if (Thread.currentThread() == stoppedThread) {
+ int read = stoppedThreadClockReads.incrementAndGet();
+ if (read == 1) {
+ stoppedAfterTokenSnapshot.countDown();
+ awaitLatch(resumeStoppedThread);
+ } else if (read == 2) {
+ expiredSnapshotRejected.countDown();
+ }
+ }
+ return now.get();
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ return this;
+ }
+ }
+}
diff --git a/src/test/java/org/zendesk/client/v2/ZendeskOAuthClientCredentialsTest.java b/src/test/java/org/zendesk/client/v2/ZendeskOAuthClientCredentialsTest.java
new file mode 100644
index 00000000..404933fd
--- /dev/null
+++ b/src/test/java/org/zendesk/client/v2/ZendeskOAuthClientCredentialsTest.java
@@ -0,0 +1,385 @@
+package org.zendesk.client.v2;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath;
+import static com.github.tomakehurst.wiremock.client.WireMock.ok;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.github.tomakehurst.wiremock.client.BasicCredentials;
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.junit.WireMockClassRule;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import org.apache.commons.text.RandomStringGenerator;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+
+public class ZendeskOAuthClientCredentialsTest {
+
+ private static final RandomStringGenerator RANDOM_STRING_GENERATOR =
+ new RandomStringGenerator.Builder().withinRange('a', 'z').build();
+ private static final String CLIENT_ID = RANDOM_STRING_GENERATOR.generate(12);
+ private static final String CLIENT_SECRET = RANDOM_STRING_GENERATOR.generate(24);
+ private static final String SCOPE = "tickets:read";
+ private static final String ACCESS_TOKEN = RANDOM_STRING_GENERATOR.generate(30);
+ private static final String STATIC_TOKEN = RANDOM_STRING_GENERATOR.generate(15);
+ private static final String USERNAME = RANDOM_STRING_GENERATOR.generate(10) + "@cloudbees.com";
+
+ @ClassRule
+ public static WireMockClassRule zendeskApiClass =
+ new WireMockClassRule(options().dynamicPort().dynamicHttpsPort());
+
+ @Rule public WireMockClassRule zendeskApiMock = zendeskApiClass;
+
+ private String hostname;
+ private Zendesk client;
+
+ @Before
+ public void setUp() {
+ hostname = String.format("http://localhost:%d", zendeskApiMock.port());
+ }
+
+ @After
+ public void closeClient() {
+ if (client != null) {
+ client.close();
+ client = null;
+ }
+ }
+
+ @Test
+ public void buildPerformsNoNetworkIo() {
+ stubToken();
+
+ client = oauthBuilder().build();
+
+ zendeskApiMock.verify(0, postRequestedFor(urlPathEqualTo("/oauth/tokens")));
+ }
+
+ @Test
+ public void apiCallSendsMintedBearerToken() {
+ stubToken();
+ stubTicketCount();
+ client = oauthBuilder().build();
+
+ client.getTicketsCount();
+
+ zendeskApiMock.verify(1, postRequestedFor(urlPathEqualTo("/oauth/tokens")));
+ zendeskApiMock.verify(
+ getRequestedFor(urlPathEqualTo("/api/v2/tickets/count.json"))
+ .withHeader("Authorization", equalTo("Bearer " + ACCESS_TOKEN)));
+ }
+
+ @Test
+ public void apiCallReusesMintedToken() {
+ stubToken();
+ stubTicketCount();
+ client = oauthBuilder().build();
+
+ client.getTicketsCount();
+ client.getTicketsCount();
+ client.getTicketsCount();
+
+ zendeskApiMock.verify(1, postRequestedFor(urlPathEqualTo("/oauth/tokens")));
+ }
+
+ @Test
+ public void tokenRequestIgnoresObjectMapperCustomizer() {
+ stubToken();
+ // Uppercases every serialized String. If the credential path shared the client's customized
+ // mapper, the client secret would be corrupted on the wire; its own mapper keeps it verbatim.
+ Function uppercaseStrings =
+ mapper -> {
+ SimpleModule module = new SimpleModule();
+ module.addSerializer(
+ String.class,
+ new JsonSerializer() {
+ @Override
+ public void serialize(
+ String value, JsonGenerator gen, SerializerProvider serializers)
+ throws IOException {
+ gen.writeString(value.toUpperCase(Locale.ROOT));
+ }
+ });
+ return mapper.registerModule(module);
+ };
+ client = oauthBuilder().customizeObjectMapper(uppercaseStrings).build();
+
+ client.warmUp();
+
+ zendeskApiMock.verify(
+ postRequestedFor(urlPathEqualTo("/oauth/tokens"))
+ .withRequestBody(matchingJsonPath("$.client_secret", equalTo(CLIENT_SECRET)))
+ .withRequestBody(matchingJsonPath("$.grant_type", equalTo("client_credentials"))));
+ }
+
+ @Test
+ public void warmUpMintsUpFront() {
+ stubToken();
+ stubTicketCount();
+ client = oauthBuilder().build();
+
+ client.warmUp();
+
+ zendeskApiMock.verify(1, postRequestedFor(urlPathEqualTo("/oauth/tokens")));
+
+ client.getTicketsCount();
+
+ // No second call to mint
+ zendeskApiMock.verify(1, postRequestedFor(urlPathEqualTo("/oauth/tokens")));
+ }
+
+ @Test
+ public void warmUpFailureExpectException() {
+ zendeskApiMock.stubFor(
+ post(urlPathEqualTo("/oauth/tokens"))
+ .willReturn(aResponse().withStatus(401).withBody("{\"error\":\"invalid_client\"}")));
+ client = oauthBuilder().build();
+
+ assertThatThrownBy(client::warmUp).isInstanceOf(ZendeskOAuthException.class);
+ }
+
+ @Test
+ public void warmUpIsNoOpForStaticCredentials() {
+ stubToken();
+
+ for (Consumer credentials : staticCredentialSetters()) {
+ var builder = new Zendesk.Builder(hostname);
+ credentials.accept(builder);
+ try (var staticClient = builder.build()) {
+ staticClient.warmUp();
+ }
+ }
+
+ zendeskApiMock.verify(0, postRequestedFor(urlPathEqualTo("/oauth/tokens")));
+ }
+
+ @Test
+ public void setOauthClientCredentialsClearsStaticCredentials() {
+ stubToken();
+ stubTicketCount();
+ client =
+ new Zendesk.Builder(hostname)
+ .setUsername(USERNAME)
+ .setToken(STATIC_TOKEN)
+ .setOauthToken(STATIC_TOKEN)
+ .setOauthClientCredentials(CLIENT_ID, CLIENT_SECRET, SCOPE)
+ .build();
+
+ client.getTicketsCount();
+
+ zendeskApiMock.verify(
+ getRequestedFor(urlPathEqualTo("/api/v2/tickets/count.json"))
+ .withHeader("Authorization", equalTo("Bearer " + ACCESS_TOKEN)));
+ }
+
+ @Test
+ public void staticCredentialSettersClearClientCredentials() {
+ stubToken();
+ stubTicketCount();
+
+ for (Consumer override : staticCredentialSetters()) {
+ var builder =
+ new Zendesk.Builder(hostname).setOauthClientCredentials(CLIENT_ID, CLIENT_SECRET, SCOPE);
+ override.accept(builder);
+ try (var overridden = builder.build()) {
+ overridden.getTicketsCount();
+ }
+ }
+
+ // Whichever static credential won, no client-credentials mint should ever have happened.
+ zendeskApiMock.verify(0, postRequestedFor(urlPathEqualTo("/oauth/tokens")));
+ }
+
+ @Test
+ public void nullOauthClientCredentialsExpectException() {
+ String[][] invalidCredentials = {
+ {null, CLIENT_SECRET, SCOPE, "client id", "OAuth client id cannot be null"},
+ {CLIENT_ID, null, SCOPE, "client secret", "OAuth client secret cannot be null"},
+ {CLIENT_ID, CLIENT_SECRET, null, "scope", "OAuth scope cannot be null"}
+ };
+
+ for (String[] testCase : invalidCredentials) {
+ var builder =
+ new Zendesk.Builder(hostname)
+ .setOauthClientCredentials(testCase[0], testCase[1], testCase[2]);
+
+ assertThatThrownBy(builder::build)
+ .as("null OAuth %s", testCase[3])
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage(testCase[4]);
+ }
+ }
+
+ @Test
+ public void nullOauthClientIdWithPriorCredentialsExpectException() {
+ var builder =
+ new Zendesk.Builder(hostname)
+ .setUsername(USERNAME)
+ .setPassword(STATIC_TOKEN)
+ .setOauthClientCredentials(null, CLIENT_SECRET, SCOPE);
+
+ assertThatThrownBy(builder::build)
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("OAuth client id cannot be null");
+ }
+
+ @Test
+ public void blankOauthClientCredentialsExpectException() {
+ String[][] invalidCredentials = {
+ {"", CLIENT_SECRET, SCOPE, "client id"},
+ {" \t", CLIENT_SECRET, SCOPE, "client id"},
+ {CLIENT_ID, "", SCOPE, "client secret"},
+ {CLIENT_ID, "\t\r\n", SCOPE, "client secret"},
+ {CLIENT_ID, CLIENT_SECRET, "", "scope"},
+ {CLIENT_ID, CLIENT_SECRET, " \t\r\n", "scope"}
+ };
+
+ for (String[] testCase : invalidCredentials) {
+ var builder =
+ new Zendesk.Builder(hostname)
+ .setOauthClientCredentials(testCase[0], testCase[1], testCase[2]);
+
+ assertThatThrownBy(builder::build)
+ .as("blank OAuth %s", testCase[3])
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("OAuth " + testCase[3] + " cannot be blank");
+ }
+ }
+
+ @Test
+ public void existingAuthPathsUnaffected() {
+ stubTicketCount();
+
+ try (var tokenClient =
+ new Zendesk.Builder(hostname).setUsername(USERNAME).setToken(STATIC_TOKEN).build()) {
+ tokenClient.getTicketsCount();
+ }
+ zendeskApiMock.verify(
+ getRequestedFor(urlPathEqualTo("/api/v2/tickets/count.json"))
+ .withBasicAuth(new BasicCredentials(USERNAME + "/token", STATIC_TOKEN)));
+
+ WireMock.reset();
+ stubTicketCount();
+
+ try (var bearerClient = new Zendesk.Builder(hostname).setOauthToken(STATIC_TOKEN).build()) {
+ bearerClient.getTicketsCount();
+ }
+ zendeskApiMock.verify(
+ getRequestedFor(urlPathEqualTo("/api/v2/tickets/count.json"))
+ .withHeader("Authorization", equalTo("Bearer " + STATIC_TOKEN)));
+ zendeskApiMock.verify(0, postRequestedFor(urlPathEqualTo("/oauth/tokens")));
+ }
+
+ @Test
+ public void anonymousClientStillSendsBearerNull() {
+ stubTicketCount();
+
+ try (var anonymous = new Zendesk.Builder(hostname).build()) {
+ anonymous.getTicketsCount();
+ }
+
+ // Pre-existing behavior: the new branch must not change what an anonymous client sends.
+ zendeskApiMock.verify(
+ getRequestedFor(urlPathEqualTo("/api/v2/tickets/count.json"))
+ .withHeader("Authorization", equalTo("Bearer null")));
+ }
+
+ @Test
+ public void invalidLifetimeExpectException() {
+ int[] invalid = {0, -1, 300, 299, 172_800, 200_000};
+
+ for (int lifetime : invalid) {
+ assertThatThrownBy(() -> oauthBuilder().setOauthTokenLifetimeSeconds(lifetime).build())
+ .as("lifetime %d", lifetime)
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+ }
+
+ @Test
+ public void validLifetimeBoundaries() {
+ int[] valid = {301, 1800, 172_799};
+
+ for (int lifetime : valid) {
+ try (var built = oauthBuilder().setOauthTokenLifetimeSeconds(lifetime).build()) {
+ assertThat(built).as("lifetime %d", lifetime).isNotNull();
+ }
+ }
+ }
+
+ @Test
+ public void invalidRefreshThresholdExpectException() {
+ double[] invalid = {
+ 0.0,
+ 1.0,
+ -0.1,
+ 1.5,
+ Double.NaN,
+ Double.POSITIVE_INFINITY,
+ Double.NEGATIVE_INFINITY,
+ -Double.MIN_VALUE
+ };
+
+ for (double threshold : invalid) {
+ assertThatThrownBy(() -> oauthBuilder().setOauthRefreshThreshold(threshold).build())
+ .as("threshold %s", threshold)
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+ }
+
+ @Test
+ public void lifetimeValidationOnlyAppliesToClientCredentials() {
+ try (var tokenClient =
+ new Zendesk.Builder(hostname)
+ .setUsername(USERNAME)
+ .setToken(STATIC_TOKEN)
+ .setOauthTokenLifetimeSeconds(-1)
+ .build()) {
+ assertThat(tokenClient).isNotNull();
+ }
+ }
+
+ /** Every pre-existing way to supply credentials, each of which excludes client credentials. */
+ private static List> staticCredentialSetters() {
+ return Arrays.asList(
+ builder -> builder.setOauthToken(STATIC_TOKEN),
+ builder -> builder.setUsername(USERNAME).setToken(STATIC_TOKEN),
+ builder -> builder.setUsername(USERNAME).setPassword(STATIC_TOKEN));
+ }
+
+ private Zendesk.Builder oauthBuilder() {
+ return new Zendesk.Builder(hostname).setOauthClientCredentials(CLIENT_ID, CLIENT_SECRET, SCOPE);
+ }
+
+ private void stubToken() {
+ zendeskApiMock.stubFor(
+ post(urlPathEqualTo("/oauth/tokens"))
+ .willReturn(ok("{\"access_token\":\"" + ACCESS_TOKEN + "\",\"expires_in\":1800}")));
+ }
+
+ private void stubTicketCount() {
+ zendeskApiMock.stubFor(
+ WireMock.get(urlPathEqualTo("/api/v2/tickets/count.json"))
+ .willReturn(
+ ok("{\"count\":{\"value\":42,\"refreshed_at\":\"2026-01-01T00:00:00Z\"}}")));
+ }
+}