Skip to content

SecureCredentialsManager crashes the host process on corrupt stored credentials (Auth0.Android 3.16.0) #968

@aujrocket

Description

@aujrocket

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)

  1. Authenticate normally and store credentials via SecureCredentialsManager.
  2. 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.)
  3. 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.
  4. 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)

  1. Authenticate normally and store credentials.
  2. 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).
  3. Force the access token into an expired state as in Path A step 2.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugThis points to a verified bug in the code

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions