Description
WorkloadIdentityAuth.getTokenAsync() can permanently poison its token-refresh state when a custom SubjectTokenProvider.getTokenAsync() throws synchronously instead of returning an exceptionally completed future.
The method stores a new CompletableFuture in refreshInFlight before calling refreshTokenAsync(). If the provider throws synchronously, the exception escapes before finishRefresh() is registered, so refreshInFlight is never cleared or completed.
Reproduction
Implement a provider whose asynchronous method throws directly:
val provider = object : SubjectTokenProvider {
override fun tokenType() = SubjectTokenType.JWT
override fun getToken(httpClient: HttpClient, jsonMapper: JsonMapper): String =
"subject-token"
override fun getTokenAsync(
httpClient: HttpClient,
jsonMapper: JsonMapper,
): CompletableFuture<String> {
throw IllegalStateException("provider failed")
}
}
Use it in a WorkloadIdentityAuth and call:
assertThrows<IllegalStateException> { auth.getTokenAsync() }
val second = auth.getTokenAsync()
assertTimeoutPreemptively(Duration.ofSeconds(1)) { second.join() }
Code reference
openai-java-core/src/main/kotlin/com/openai/auth/WorkloadIdentityAuth.kt:134-152 stores refreshInFlight before starting a refresh.
WorkloadIdentityAuth.kt:157-174 calls performRefreshAndComplete() or refreshTokenAsync() without catching synchronous exceptions.
WorkloadIdentityAuth.kt:178-182 only clears the state from the asynchronous completion callback.
SubjectTokenProvider.kt:31-38 allows custom implementations of the async provider method.
Expected behavior
A synchronous provider exception should be converted into a failed future, and refreshInFlight should be cleared so later calls can retry or fail normally.
Actual behavior
The first call throws synchronously. Subsequent callers observe the abandoned refreshInFlight future and wait forever.
The same issue can occur on the background refresh path when refreshTokenAsync() throws before registering its completion callback.
Why it matters
A transient or poorly behaved custom credential provider can permanently hang all future asynchronous authentication attempts in a long-lived client. This is especially problematic because the failure is not limited to the request that encountered the provider exception.
Description
WorkloadIdentityAuth.getTokenAsync()can permanently poison its token-refresh state when a customSubjectTokenProvider.getTokenAsync()throws synchronously instead of returning an exceptionally completed future.The method stores a new
CompletableFutureinrefreshInFlightbefore callingrefreshTokenAsync(). If the provider throws synchronously, the exception escapes beforefinishRefresh()is registered, sorefreshInFlightis never cleared or completed.Reproduction
Implement a provider whose asynchronous method throws directly:
Use it in a
WorkloadIdentityAuthand call:Code reference
openai-java-core/src/main/kotlin/com/openai/auth/WorkloadIdentityAuth.kt:134-152storesrefreshInFlightbefore starting a refresh.WorkloadIdentityAuth.kt:157-174callsperformRefreshAndComplete()orrefreshTokenAsync()without catching synchronous exceptions.WorkloadIdentityAuth.kt:178-182only clears the state from the asynchronous completion callback.SubjectTokenProvider.kt:31-38allows custom implementations of the async provider method.Expected behavior
A synchronous provider exception should be converted into a failed future, and
refreshInFlightshould be cleared so later calls can retry or fail normally.Actual behavior
The first call throws synchronously. Subsequent callers observe the abandoned
refreshInFlightfuture and wait forever.The same issue can occur on the background refresh path when
refreshTokenAsync()throws before registering its completion callback.Why it matters
A transient or poorly behaved custom credential provider can permanently hang all future asynchronous authentication attempts in a long-lived client. This is especially problematic because the failure is not limited to the request that encountered the provider exception.