microsoft-authentication-library-for-android icon indicating copy to clipboard operation
microsoft-authentication-library-for-android copied to clipboard

acquireTokenSilentAsync fails with AADB2C90080 error (expired grant) after interactive acquireToken is successfully called

Open dusanbartos opened this issue 9 months ago • 2 comments

Describe the bug acquireTokenSilentAsync fails with AADB2C90080 error (expired grant) after interactive acquireToken is successfully called and app is closed (removed from recent apps)

Smartphone (please complete the following information):

  • Device: Pixel 8a
  • Android Version: API 35
  • MSAL Version 4.10.0, 5.10.0

Stacktrace

com.microsoft.identity.client.exception.MsalUiRequiredException: AADB2C90080: The provided grant has expired. Please re-authenticate and try again. Current time: 1739891030, Grant issued time: 1737710842, Grant expiration time: 1738920442
                 Correlation ID: 57cc422c-941a-4e2d-b1b2-cc36456022e7
                 Timestamp: 2025-02-18 15:03:50Z
                 
                 	at com.microsoft.identity.client.internal.controllers.MsalExceptionAdapter.msalExceptionFromBaseExceptionInternal(MsalExceptionAdapter.java:78)
                 	at com.microsoft.identity.client.internal.controllers.MsalExceptionAdapter.msalExceptionFromBaseException(MsalExceptionAdapter.java:45)
                 	at com.microsoft.identity.client.PublicClientApplication$19.onError(PublicClientApplication.java:2264)
                 	at com.microsoft.identity.client.PublicClientApplication$19.onError(PublicClientApplication.java:2255)
                 	at com.microsoft.identity.common.java.controllers.CommandDispatcher.commandCallbackOnError(CommandDispatcher.java:639)
                 	at com.microsoft.identity.common.java.controllers.CommandDispatcher.access$900(CommandDispatcher.java:98)
                 	at com.microsoft.identity.common.java.controllers.CommandDispatcher$4.run(CommandDispatcher.java:619)
                 	at android.os.Handler.handleCallback(Handler.java:959)
                 	at android.os.Handler.dispatchMessage(Handler.java:100)
                 	at android.os.Looper.loopOnce(Looper.java:232)
                 	at android.os.Looper.loop(Looper.java:317)
                 	at android.app.ActivityThread.main(ActivityThread.java:8705)
                 	at java.lang.reflect.Method.invoke(Native Method)
                 	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:580)
                 	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:886)

To Reproduce Steps to reproduce the behavior:

  1. Open an app and log in interactively - app calls acquireToken, user authenticates and proceeds into the app
  2. Close the app and let the refresh token expire
  3. Open the app again after some time - app initialization calls getAccounts and tries to call acquireTokenSilentAsync when account is found, which fails with AADB2C90080 - at this point this is expected
  4. Open interactive login again - app calls acquireToken, user authenticates and proceeds into the app
  5. Kill the app (close and remove from recent apps)
  6. Open the app again right away
  7. The app initializes again and tries to call acquireTokenSilentAsync which fails with AADB2C90080 again with invalid grant message - at this point this is not expected, as user just logged in a few seconds ago and error message points to grant issued to previously expired refresh token

Expected behavior After successful interactive login, any cache regarding an expired refresh token should be cleared

Actual Behavior After successful interactive login, expired refresh token is preserved and used in further acquireTokenSilentAsync calls making them fail

correlation id: f6e40585-87a5-4660-94f7-5a815e3dbdb2

Pls let me know if you need more info to investigate. I found a few similar issues here - #1004 or #2043 but all of them are closed due to inactivity and lack of details, so I'm not sure if they are connected in any way

dusanbartos avatar Feb 18 '25 15:02 dusanbartos

FYI doing this sort of mumbo-jumbo in IPublicClientApplication.IMultipleAccountApplicationCreatedListener reveals, that in this corrupted state, the cache in fact holds two entries with refresh token for the same user, apparently one that's expired and a second one that's the most recent one. However it seems like the expired one is preferred over the valid one when calling acquireTokenSilentAsync therefore failing.

Note: Looking at RefreshTokenRecord.isExpired implementation, this always returns false regardless of the token validity. But again I'm not sure how related it is, just comparing it to other Credential.isExpired implementations in AccessTokenRecord and PrimaryRefreshTokenRecord

val cache = application.configuration.oAuth2TokenCache
Timber.v("cache $cache")
val accounts = cache.getAccounts(
    null,                               // @Nullable String environment
    application.configuration.clientId  // @NonNull String clientId
)
accounts.forEach {
    (it as? AccountCredentialBase)?.let { acb ->
        when (acb) {
            is AccountRecord -> {
                Timber.v(
                    "account\n" +
                            "\thomeAccountId=${acb.homeAccountId}\n" +
                            "\tenvironment=${acb.environment}\n" +
                            "\trealm=${acb.realm}\n" +
                            ""
                )

                cache.loadWithAggregatedAccountData(
                    application.configuration.clientId, // @NonNull String clientId
                    application.configuration.redirectUri, // @NonNull String applicationIdentifier
                    null, // @Nullable String mamEnrollmentIdentifier
                    null, // @Nullable String target
                    acb,  // @NonNull AccountRecord account
                    BearerAuthenticationSchemeInternal(), // @NonNull AbstractAuthenticationScheme authScheme
                ).map { it as ICacheRecord }.forEach { cr ->
                    if (cr.accessToken == null && cr.refreshToken != null) {
                        val rt = cr.refreshToken

                        Timber.v(
                            "cr\n" +
                                    "\tat=${cr.accessToken?.secret}\n" +
                                    "\trt=${rt.secret}\n" +
                                    "\tcachedAt=${rt?.cachedAt}\n" +
                                    ""
                        )
                    }
                }
            }

            else -> {
                Timber.v("account ${acb.javaClass.simpleName}")
            }
        }
    }
}

dusanbartos avatar Feb 20 '25 07:02 dusanbartos

🤷‍♂

dusanbartos avatar Mar 10 '25 09:03 dusanbartos

@dusanbartos did you resolve this issue somehow?

Frederikdam avatar Jun 30 '25 07:06 Frederikdam

@dusanbartos did you resolve this issue somehow?

@Frederikdam I haven't found any workaround, but I'm no longer working on the project, so I'm not sure if they solved it somehow eventually

dusanbartos avatar Jun 30 '25 08:06 dusanbartos