Checklist
Description
continueGetCredentials lambda lets exceptions escape the executor — fatal app crash on token-refresh error paths
SDK: com.auth0.android:auth0:3.16.0
File: auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt
We've had two production fatal crashes that both originate in continueGetCredentials, and we can't catch them from our side because the throw is on Auth0's serialExecutor thread, not on the coroutine we're calling awaitCredentials() from. Our try/catch around awaitCredentials() never gets a chance.
The two Crashlytics stack traces
Crash 1 — token renewal during DNS outage
Fatal Exception: cp1: Failed to execute the network request.
at com.auth0.android.authentication.storage.SecureCredentialsManager.continueGetCredentials$lambda$11(SecureCredentialsManager.java:926)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1154)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:652)
at java.lang.Thread.run(Thread.java:1564)
Caused by z50: Failed to execute the network request
at com.auth0.android.Auth0Exception.<init>(Auth0Exception.java:7)
at com.auth0.android.authentication.AuthenticationException.<init>(AuthenticationException.java:30)
at com.auth0.android.authentication.AuthenticationAPIClient$Companion$createErrorAdapter$1.fromException(AuthenticationAPIClient.java:1188)
at com.auth0.android.authentication.AuthenticationAPIClient$Companion$createErrorAdapter$1.fromException(AuthenticationAPIClient.java:1171)
at com.auth0.android.request.internal.BaseRequest.execute(BaseRequest.java:151)
at com.auth0.android.authentication.storage.SecureCredentialsManager.continueGetCredentials$lambda$11(SecureCredentialsManager.java:878)
... (executor frames)
Caused by wl5: Failed to execute the network request
at com.auth0.android.NetworkErrorException.<init>(NetworkErrorException.java:7)
at com.auth0.android.authentication.AuthenticationAPIClient$Companion$createErrorAdapter$1.fromException(AuthenticationAPIClient.java:1189)
... (same chain)
Caused by java.net.UnknownHostException: Unable to resolve host "dev-***.us.auth0.com": No address associated with hostname
at java.net.Inet6AddressImpl.lookupHostByName(Inet6AddressImpl.java:156)
at java.net.InetAddress.getAllByName(InetAddress.java:1152)
at okhttp3.Dns$Companion$DnsSystem.lookup(Dns.java:50)
... (OkHttp internals)
at com.auth0.android.request.internal.BaseRequest.execute(BaseRequest.java:146)
at com.auth0.android.authentication.storage.SecureCredentialsManager.continueGetCredentials$lambda$11(SecureCredentialsManager.java:878)
Caused by android.system.GaiException: android_getaddrinfo failed: EAI_NODATA (No address associated with hostname)
Crash 2 — token renewal returned an auth server error
Fatal Exception: cp1: An error occurred while trying to use the Refresh Token to renew the Credentials.
at com.auth0.android.authentication.storage.SecureCredentialsManager.continueGetCredentials$lambda$11(SecureCredentialsManager.java:926)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1154)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:652)
at java.lang.Thread.run(Thread.java:1564)
Caused by z50: An error occurred when trying to authenticate with the server.
at com.auth0.android.authentication.AuthenticationException.<init>(AuthenticationException.java:47)
at com.auth0.android.authentication.AuthenticationAPIClient$Companion$createErrorAdapter$1.fromJsonResponse(AuthenticationAPIClient.java:1183)
at com.auth0.android.authentication.AuthenticationAPIClient$Companion$createErrorAdapter$1.fromJsonResponse(AuthenticationAPIClient.java:1171)
at com.auth0.android.request.internal.BaseRequest.execute(BaseRequest.java:171)
at com.auth0.android.authentication.storage.SecureCredentialsManager.continueGetCredentials$lambda$11(SecureCredentialsManager.java:878)
What the source shows
Both top frames land at line 926, which is inside the AuthenticationException handler:
// SecureCredentialsManager.kt @ tag 3.16.0
877: try {
878: val fresh = request.execute() // ← cause chain originates here
...
906: } catch (error: AuthenticationException) {
...
919: val exception = when {
920: error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED
921:
922: error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK
923: else -> CredentialsManagerException.Code.API_ERROR
924: }
925: callback.onFailure(
926: CredentialsManagerException( // ← top frame of both crashes
927: exception, error
928: )
929: )
930: return@execute
931: } catch (exception: RuntimeException) { // ← sibling, NOT outer
...
946: }
request.execute() at 878 throws AuthenticationException, the catch at 906 handles it, and inside the handler we hit line 926 — where a CredentialsManagerException is being constructed inside the callback.onFailure(...) call.
This is the expected error path. The Callback should receive the failure and the SDK should return cleanly. Instead, the CredentialsManagerException ends up uncaught on the executor's worker thread and the host process dies via Thread.run → default UncaughtExceptionHandler.
Why the existing RuntimeException catch doesn't save us
Lines 906 and 931 are sibling catches on the same try, not a nested defense. Anything that throws from inside the AuthenticationException handler (lines 907–930) — including from the callback.onFailure(...) invocation at 925 — escapes both catches and falls straight out of the serialExecutor.execute { } lambda. There is no top-level try/catch wrapping the lambda body, so the worker thread sees an uncaught throwable, and that's the crash.
Why we can't defend against this on our side
We use awaitCredentials(), which wraps getCredentials(callback) in a suspendCancellableCoroutine (line 494). Our coroutine is suspended on a different thread than serialExecutor. When the executor's lambda throws, the throw never reaches our coroutine — there's nothing to resume, nothing to try/catch. The process just dies.
Suggested fix
Wrap the entire serialExecutor.execute { } body in a defensive try/catch that routes any uncaught throwable through the existing failure callback:
serialExecutor.execute {
try {
// ... existing body unchanged ...
} catch (t: Throwable) {
callback.onFailure(
CredentialsManagerException(
CredentialsManagerException.Code.UNKNOWN_ERROR,
t
)
)
}
}
Same fix needed in continueGetApiCredentials (line 973), which has the identical structural defect.
What I haven't been able to pin down
The expected path is callback.onFailure(error) → continuation.resumeWithException(error) (via the wrapper at line 494), which under CancellableContinuationImpl in kotlinx-coroutines should never synchronously throw on the caller's thread — it dispatches the resume to the continuation's context. Yet the bytecode mapping clearly puts the topmost frame at line 926 (the CredentialsManagerException(...) constructor call inside onFailure(...)), and we have two real production hits with this signature. Either there's a path I'm missing where the resume is synchronous, or onFailure reaches something else that re-throws. Whatever the mechanism, a top-level try/catch at the executor boundary makes it moot.
Reproduction
Path A — network unavailable during refresh (matches Crash 1)
- Authenticate normally and store credentials via
SecureCredentialsManager.
- Force the access token into an expired state. Either wait it out, or set a short token lifetime on the Auth0 API and wait. (
hasValidCredentials(minTtl) returning true while the underlying token is past expiresAt is the state we need — i.e. the SDK decides a renewal is required.)
- Disable network so DNS for the tenant domain fails: airplane mode, or block
*.us.auth0.com (or your tenant's domain) at the router / via iptables / via a VPN with no DNS.
- Call
secureCredentialsManager.awaitCredentials() (or getCredentials(callback)).
request.execute() at line 878 throws AuthenticationException (wrapping NetworkErrorException wrapping UnknownHostException), the catch at 906 picks NO_NETWORK, builds a CredentialsManagerException at 926, and calls callback.onFailure(...).
Expected: callback receives the failure cleanly.
Actual: the CredentialsManagerException escapes the serialExecutor.execute { } lambda and the process is killed by the default UncaughtExceptionHandler.
Path B — server rejects the refresh token (matches Crash 2)
- Authenticate normally and store credentials.
- From the Auth0 dashboard, revoke the user's refresh token (or rotate the tenant's signing key — anything that causes the
/oauth/token refresh to come back as an error response).
- Force the access token into an expired state as in Path A step 2.
- Call
secureCredentialsManager.awaitCredentials().
request.execute() throws AuthenticationException (created by createErrorAdapter.fromJsonResponse at line 1183 of AuthenticationAPIClient), the same catch picks RENEW_FAILED / API_ERROR, and the same escape happens at line 926.
Caller-side setup notes
Both of our crashes come from a path that invokes awaitCredentials() from inside an OkHttp Interceptor via runBlocking { ... } — the interceptor needs the token to attach an Authorization header, and awaitCredentials() is the documented suspend equivalent of getCredentials(callback). If reproduction needs the same surrounding shape, this is what the call site looks like in our app:
class AuthInterceptor(private val tokenProvider: TokenProvider) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = runBlocking { tokenProvider.getAccessToken() }
return chain.proceed(chain.request().newBuilder().addHeader("Authorization", token).build())
}
}
// inside TokenProvider:
suspend fun getAccessToken(): String =
withTimeout(35_000L) { secureCredentialsManager.awaitCredentials() }.accessToken
That said: the line 926 throw is on serialExecutor's own thread (ThreadPoolExecutor.runWorker is in the fatal stack frame, not OkHttp's dispatcher), so the call-site dispatcher shouldn't matter. If reproduction with a plain coroutine on Dispatchers.IO doesn't trigger it, the OkHttp runBlocking shape may be a factor and worth probing.
What we don't know about timing
We have two production hits, not a 100% repeatable in-house repro. Many users hit the same network-down and refresh-failed conditions without crashing — callback.onFailure(...) evidently works most of the time. So there may be a race / coroutine-state condition (e.g. withTimeout having already cancelled the continuation when the executor's callback fires) that determines whether the path at 925–929 throws or returns cleanly. The fix proposed in the main report makes that irrelevant.
Additional context
No response
Auth0.Android version
3.16.0
Android version(s)
16
Checklist
Description
continueGetCredentialslambda lets exceptions escape the executor — fatal app crash on token-refresh error pathsSDK:
com.auth0.android:auth0:3.16.0File:
auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.ktWe've had two production fatal crashes that both originate in
continueGetCredentials, and we can't catch them from our side because the throw is on Auth0'sserialExecutorthread, not on the coroutine we're callingawaitCredentials()from. Ourtry/catcharoundawaitCredentials()never gets a chance.The two Crashlytics stack traces
Crash 1 — token renewal during DNS outage
Crash 2 — token renewal returned an auth server error
What the source shows
Both top frames land at line 926, which is inside the AuthenticationException handler:
request.execute()at 878 throwsAuthenticationException, the catch at 906 handles it, and inside the handler we hit line 926 — where aCredentialsManagerExceptionis being constructed inside thecallback.onFailure(...)call.This is the expected error path. The
Callbackshould receive the failure and the SDK should return cleanly. Instead, theCredentialsManagerExceptionends up uncaught on the executor's worker thread and the host process dies viaThread.run→ defaultUncaughtExceptionHandler.Why the existing
RuntimeExceptioncatch doesn't save usLines 906 and 931 are sibling catches on the same
try, not a nested defense. Anything that throws from inside theAuthenticationExceptionhandler (lines 907–930) — including from thecallback.onFailure(...)invocation at 925 — escapes both catches and falls straight out of theserialExecutor.execute { }lambda. There is no top-leveltry/catchwrapping the lambda body, so the worker thread sees an uncaught throwable, and that's the crash.Why we can't defend against this on our side
We use
awaitCredentials(), which wrapsgetCredentials(callback)in asuspendCancellableCoroutine(line 494). Our coroutine is suspended on a different thread thanserialExecutor. When the executor's lambda throws, the throw never reaches our coroutine — there's nothing to resume, nothing totry/catch. The process just dies.Suggested fix
Wrap the entire
serialExecutor.execute { }body in a defensivetry/catchthat routes any uncaught throwable through the existing failure callback:serialExecutor.execute { try { // ... existing body unchanged ... } catch (t: Throwable) { callback.onFailure( CredentialsManagerException( CredentialsManagerException.Code.UNKNOWN_ERROR, t ) ) } }Same fix needed in
continueGetApiCredentials(line 973), which has the identical structural defect.What I haven't been able to pin down
The expected path is
callback.onFailure(error)→continuation.resumeWithException(error)(via the wrapper at line 494), which underCancellableContinuationImplin kotlinx-coroutines should never synchronously throw on the caller's thread — it dispatches the resume to the continuation's context. Yet the bytecode mapping clearly puts the topmost frame at line 926 (theCredentialsManagerException(...)constructor call insideonFailure(...)), and we have two real production hits with this signature. Either there's a path I'm missing where the resume is synchronous, oronFailurereaches something else that re-throws. Whatever the mechanism, a top-leveltry/catchat the executor boundary makes it moot.Reproduction
Path A — network unavailable during refresh (matches Crash 1)
SecureCredentialsManager.hasValidCredentials(minTtl)returningtruewhile the underlying token is pastexpiresAtis the state we need — i.e. the SDK decides a renewal is required.)*.us.auth0.com(or your tenant's domain) at the router / viaiptables/ via a VPN with no DNS.secureCredentialsManager.awaitCredentials()(orgetCredentials(callback)).request.execute()at line 878 throwsAuthenticationException(wrappingNetworkErrorExceptionwrappingUnknownHostException), thecatchat 906 picksNO_NETWORK, builds aCredentialsManagerExceptionat 926, and callscallback.onFailure(...).Expected: callback receives the failure cleanly.
Actual: the
CredentialsManagerExceptionescapes theserialExecutor.execute { }lambda and the process is killed by the defaultUncaughtExceptionHandler.Path B — server rejects the refresh token (matches Crash 2)
/oauth/tokenrefresh to come back as an error response).secureCredentialsManager.awaitCredentials().request.execute()throwsAuthenticationException(created bycreateErrorAdapter.fromJsonResponseat line 1183 ofAuthenticationAPIClient), the same catch picksRENEW_FAILED/API_ERROR, and the same escape happens at line 926.Caller-side setup notes
Both of our crashes come from a path that invokes
awaitCredentials()from inside an OkHttpInterceptorviarunBlocking { ... }— the interceptor needs the token to attach anAuthorizationheader, andawaitCredentials()is the documented suspend equivalent ofgetCredentials(callback). If reproduction needs the same surrounding shape, this is what the call site looks like in our app:That said: the line 926 throw is on
serialExecutor's own thread (ThreadPoolExecutor.runWorkeris in the fatal stack frame, not OkHttp's dispatcher), so the call-site dispatcher shouldn't matter. If reproduction with a plain coroutine onDispatchers.IOdoesn't trigger it, the OkHttprunBlockingshape may be a factor and worth probing.What we don't know about timing
We have two production hits, not a 100% repeatable in-house repro. Many users hit the same network-down and refresh-failed conditions without crashing —
callback.onFailure(...)evidently works most of the time. So there may be a race / coroutine-state condition (e.g.withTimeouthaving already cancelled the continuation when the executor's callback fires) that determines whether the path at 925–929 throws or returns cleanly. The fix proposed in the main report makes that irrelevant.Additional context
No response
Auth0.Android version
3.16.0
Android version(s)
16