Caches an OAuth2 access token in memory, refreshes it before expiry, and retries on transient failures.
getToken() returns a valid token or throws a classified exception. getTokenAsync() returns a CompletableFuture<String> for non-blocking access. Call either from any thread.
TokenConfig config = TokenConfig.builder()
.clientId("your-client-id")
.clientSecret("your-client-secret")
.tokenEndpoint("https://auth.example.com/token")
.build();
try (OAuth2TokenManager tokenManager = new OAuth2TokenManager(config)) {
String token = tokenManager.getToken();
}Requires Java 21+. No framework dependency. getToken() returns a String — use it with any HTTP client.
<dependency>
<groupId>io.github.ajaygodbole7</groupId>
<artifactId>tokenmanager</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>Multiple threads calling getToken() at the same time trigger a single HTTP request. Everyone else blocks on the same CompletableFuture.
If a refresh fails with a transient error and the cached token is still valid, getToken() returns the cached token.
If a refresh fails with a permanent error (bad credentials, bad config, unreachable endpoint), the exception propagates immediately. Permanent errors are never masked by a stale cache.
Call invalidate() if a downstream service rejects the token (server-side revocation). The next getToken() fetches fresh.
tokenManager.invalidate();
String freshToken = tokenManager.getToken();A circuit breaker opens after consecutive transient failures. Default cooldown is 60 seconds. Credential and configuration errors do not trip the circuit breaker.
Retries use exponential backoff with ±50% jitter, up to 3 attempts. Only network-level failures are retried — connection resets and socket timeouts. Server errors (5xx), auth errors, and rate limits are not retried.
If the server returns 429 with a Retry-After header, the manager pauses refresh attempts for the specified duration. During the cooldown, getToken() returns the cached token if it hasn't expired, or throws RateLimitedException if it has.
getToken() throws TokenException. There are five sealed subclasses, so Java 21 switch is exhaustive:
try {
String token = tokenManager.getToken();
} catch (TokenException e) {
switch (e) {
case InvalidCredentialsException ex -> alertOps(ex);
case InvalidConfigurationException ex -> failFast(ex);
case InvalidEndpointException ex -> checkDns(ex);
case RateLimitedException ex -> backOff(ex.getRetryAfter());
case ServiceUnavailableException ex -> retryLater(ex);
}
}| Exception | Trigger |
|---|---|
InvalidCredentialsException |
401/403, invalid_client, invalid_grant |
InvalidConfigurationException |
invalid_request, invalid_scope, unsupported_grant_type |
InvalidEndpointException |
DNS failure, non-auth 4xx |
RateLimitedException |
429 (parses Retry-After) |
ServiceUnavailableException |
5xx, timeout, circuit breaker open |
| Setting | Default | Override |
|---|---|---|
| Grant type | CLIENT_CREDENTIALS |
.grantType(OAuth2GrantType.JWT_BEARER) |
| HTTP timeout | 10s | .httpTimeout(Duration.ofSeconds(15)) |
| Refresh threshold | 30s before expiry | .refreshThreshold(Duration.ofSeconds(60)) |
| Scope | none | .scope(Set.of("read", "write")) |
| Client auth | client_secret_post |
.clientAuthMethod(ClientAuthMethod.CLIENT_SECRET_BASIC) |
| Max retries | 3 | .maxRetryAttempts(2) |
| Initial retry delay | 1s | .initialRetryDelay(Duration.ofMillis(500)) |
| CB minimum calls | 3 | .circuitBreakerMinimumCalls(5) |
| CB wait duration | 60s | .circuitBreakerWaitDuration(Duration.ofSeconds(30)) |
Non-HTTPS endpoints are rejected at construction time.
TokenConfig.validate() runs in the OAuth2TokenManager constructor, not at .build().
Two grant types work across refresh cycles:
| Grant type | Enum | Required fields |
|---|---|---|
| Client Credentials | CLIENT_CREDENTIALS |
clientId + clientSecret (default) |
| JWT Bearer | JWT_BEARER |
assertion (backed by a Supplier<String>, called on every refresh) |
// JWT Bearer with assertion regenerated on each refresh
TokenConfig.builder()
.clientId("service-account")
.clientSecret("secret")
.grantType(OAuth2GrantType.JWT_BEARER)
.assertionSupplier(() -> signJwt(privateKey))
.tokenEndpoint("https://auth.example.com/token")
.build();The enum also includes PASSWORD, AUTHORIZATION_CODE, and REFRESH_TOKEN. These perform the first token exchange but break on subsequent refreshes because the library replays the original grant parameters.
Authorization codes are single-use. The second refresh fails with invalid_grant.
Refresh tokens may be rotated server-side. The library does not capture the new token from the response.
Password (ROPC) replays the credentials, but OAuth 2.1 drops this grant entirely.
IMPLICIT is rejected at validation time.
Default is client_secret_post (credentials in form body).
For providers that require HTTP Basic:
TokenConfig.builder()
// ...
.clientAuthMethod(ClientAuthMethod.CLIENT_SECRET_BASIC)
.build();Pass an OkHttp client for custom TLS, mTLS, proxy, or interceptors:
TokenConfig.builder()
// ...
.httpClient(yourOkHttpClient)
.build();If you provide your own client, you own its lifecycle. TokenManager will not close it.
Register as a bean. Spring calls close() on shutdown.
@Bean(destroyMethod = "close")
public OAuth2TokenManager paymentAuthManager(
@Value("${payment.oauth.client-id}") String clientId,
@Value("${payment.oauth.client-secret}") String clientSecret,
@Value("${payment.oauth.token-endpoint}") String tokenEndpoint) {
TokenConfig config = TokenConfig.builder()
.clientId(clientId)
.clientSecret(clientSecret)
.tokenEndpoint(tokenEndpoint)
.build();
return new OAuth2TokenManager(config);
}Use Spring Security for inbound user auth. Use TokenManager for outbound machine-to-machine calls. See spring-comparison.md for a detailed comparison.
toString() and exception messages do not include secrets or token values.
Tokens are held in memory only and are not written to logs or disk.
OAuth2TokenManager implements AutoCloseable.
close() is idempotent. It cancels in-flight refreshes and shuts down the internal executor.
getToken() after close() throws IllegalStateException.
This library manages a single OAuth2 token per instance. One manager, one client, one endpoint.
It does not handle login flows, token introspection, token revocation, OpenID Connect, DPoP, private_key_jwt, reactive streams, persistent caching, or metrics export.
For protocol coverage beyond token endpoint exchanges, see Nimbus OAuth2 SDK or Spring Security OAuth2 Client. Either can be used alongside TokenManager.
OkHttp 5.x, Jackson Databind, Resilience4j, SLF4J. See pom.xml for versions.
0.1.0-SNAPSHOT. Pre-release. API may change before 1.0.