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: + * + *

    + *
  1. The {@code synchronized} block decides only who mints; the mint runs outside it, so threads + * never queue behind a network round trip. + *
  2. 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\"}}"))); + } +}