From db149c7bc34c9378fbfad1edad8294a9ae51ae59 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Thu, 11 Jun 2026 20:33:16 +0800 Subject: [PATCH 1/4] fix(android-sdk): discard in-flight token responses after sign-out --- .../io/logto/sdk/android/LogtoClient.kt | 99 ++++++++-- .../io/logto/sdk/android/LogtoClientTest.kt | 182 ++++++++++++++++++ 2 files changed, 268 insertions(+), 13 deletions(-) diff --git a/android-sdk/android/src/main/kotlin/io/logto/sdk/android/LogtoClient.kt b/android-sdk/android/src/main/kotlin/io/logto/sdk/android/LogtoClient.kt index daa356f..774f73d 100644 --- a/android-sdk/android/src/main/kotlin/io/logto/sdk/android/LogtoClient.kt +++ b/android-sdk/android/src/main/kotlin/io/logto/sdk/android/LogtoClient.kt @@ -32,6 +32,13 @@ open class LogtoClient( val logtoConfig: LogtoConfig, application: Application, ) { + /** + * Guards the credential fields below: token flows that were in flight when + * [signOut] or [clearCredentials] dropped the credentials must not persist + * their (now stale) results. See [SessionGuard]. + */ + private val sessionGuard = SessionGuard() + /** * Cached access tokens. */ @@ -84,6 +91,11 @@ open class LogtoClient( /** * Sign in + * + * If a sign-out happens while the sign-in is still in progress, the sign-in result + * is discarded and the completion receives a + * [LogtoException.Type.NOT_AUTHENTICATED] error. + * * @param[context] the activity to perform a sign-in action * @param[options] the sign-in options * @param[completion] the completion which handles the result of signing in @@ -93,6 +105,8 @@ open class LogtoClient( options: SignInOptions, completion: EmptyCompletion, ) { + val sessionStamp = sessionGuard.stamp() + getOidcConfig { getOidcConfigException, oidcConfig -> getOidcConfigException?.let { completion.onComplete(it) @@ -119,6 +133,7 @@ open class LogtoClient( ) verifyAndSaveTokenResponse( + sessionStamp = sessionStamp, issuer = oidcConfig.issuer, responseIdToken = codeToken.idToken, responseRefreshToken = codeToken.refreshToken, @@ -162,6 +177,10 @@ open class LogtoClient( * re-enter the account through the existing browser session. Use [signOut] for a * complete sign-out. * + * Any token request that is still in flight when the credentials are cleared is + * discarded: its result is not persisted and its completion receives a + * [LogtoException.Type.NOT_AUTHENTICATED] error. + * * @param[completion] the completion invoked with any error that occurs while clearing * the credentials */ @@ -171,11 +190,7 @@ open class LogtoClient( return } - accessTokenMap.clear() - idToken = null - - refreshToken?.let { tokenToRevoke -> - refreshToken = null + dropCredentials()?.let { tokenToRevoke -> getOidcConfig { getOidcConfigException, oidcConfig -> getOidcConfigException?.let { completion?.onComplete(it) @@ -220,6 +235,10 @@ open class LogtoClient( * [LogtoException.Type.INVALID_REDIRECT_URI] without opening the browser; local * credentials are still cleared and the revocation is still attempted. * + * Any token request that is still in flight when the sign-out starts is discarded: + * its result is not persisted and its completion receives a + * [LogtoException.Type.NOT_AUTHENTICATED] error. + * * @param[context] the activity to perform the sign-out action * @param[postLogoutRedirectUri] one of the post sign-out redirect URIs of this * application, or `null` to let the user dismiss the browser manually after the @@ -241,10 +260,7 @@ open class LogtoClient( return } - val tokenToRevoke = refreshToken - accessTokenMap.clear() - idToken = null - refreshToken = null + val tokenToRevoke = dropCredentials() getOidcConfig { getOidcConfigException, oidcConfig -> getOidcConfigException?.let { @@ -328,6 +344,11 @@ open class LogtoClient( organizationId: String?, completion: Completion, ) { + // The stamp must be taken before any credential is read: a sign-out that lands + // between the read and the stamp would otherwise go unnoticed and the refreshed + // tokens would be committed against the already-cleared credentials. + val sessionStamp = sessionGuard.stamp() + if (!isAuthenticated) { completion.onComplete(LogtoException(LogtoException.Type.NOT_AUTHENTICATED), null) return @@ -396,6 +417,7 @@ open class LogtoClient( ) verifyAndSaveTokenResponse( + sessionStamp = sessionStamp, issuer = oidcConfig.issuer, responseIdToken = refreshedToken.idToken, responseRefreshToken = refreshedToken.refreshToken, @@ -489,7 +511,22 @@ open class LogtoClient( } } + /** + * Atomically drop the local credentials and invalidate the token flows that are + * still in flight, so that their responses can no longer be persisted. + * + * @return the refresh token that was current, for the caller to revoke + */ + private fun dropCredentials(): String? = sessionGuard.invalidate { + val tokenToRevoke = refreshToken + accessTokenMap.clear() + idToken = null + refreshToken = null + tokenToRevoke + } + private fun verifyAndSaveTokenResponse( + sessionStamp: Int, issuer: String, responseIdToken: String?, responseRefreshToken: String?, @@ -509,12 +546,17 @@ open class LogtoClient( completion.onComplete(LogtoException(LogtoException.Type.INVALID_ID_TOKEN, exception)) return@getJwks } - idToken = it } - accessTokenMap[accessTokenKey] = accessToken - refreshToken = responseRefreshToken - completion.onComplete(null) + val saved = sessionGuard.commit(sessionStamp) { + responseIdToken?.let { idToken = it } + accessTokenMap[accessTokenKey] = accessToken + refreshToken = responseRefreshToken + } + + completion.onComplete( + if (saved) null else LogtoException(LogtoException.Type.NOT_AUTHENTICATED), + ) } } @@ -599,3 +641,34 @@ open class LogtoClient( accessTokenMap.putAll(tokenMap) } } + +/** + * An optimistic guard for the local credential set — the in-memory equivalent of an + * optimistic lock's "UPDATE ... WHERE version = ?". + * + * Async token flows take a [stamp] when they start, and [commit] applies their writes + * only when no [invalidate] has happened in between. This keeps a token response that + * lands after a sign-out from resurrecting the cleared credentials, and a response + * from before a sign-out from clobbering the session of a later sign-in. + */ +private class SessionGuard { + private var version = 0 + + @Synchronized + fun stamp(): Int = version + + @Synchronized + fun invalidate(block: () -> T): T { + version++ + return block() + } + + @Synchronized + fun commit(stamp: Int, block: () -> Unit): Boolean { + if (stamp != version) { + return false + } + block() + return true + } +} diff --git a/android-sdk/android/src/test/kotlin/io/logto/sdk/android/LogtoClientTest.kt b/android-sdk/android/src/test/kotlin/io/logto/sdk/android/LogtoClientTest.kt index 8011734..6e34fb3 100644 --- a/android-sdk/android/src/test/kotlin/io/logto/sdk/android/LogtoClientTest.kt +++ b/android-sdk/android/src/test/kotlin/io/logto/sdk/android/LogtoClientTest.kt @@ -14,10 +14,12 @@ import io.logto.sdk.android.util.LogtoUtils import io.logto.sdk.core.Core import io.logto.sdk.core.http.HttpCompletion import io.logto.sdk.core.http.HttpEmptyCompletion +import io.logto.sdk.core.type.CodeTokenResponse import io.logto.sdk.core.type.IdTokenClaims import io.logto.sdk.core.type.OidcConfigResponse import io.logto.sdk.core.type.RefreshTokenTokenResponse import io.logto.sdk.core.type.UserInfoResponse +import io.logto.sdk.core.util.CallbackUriUtils import io.logto.sdk.core.util.TokenUtils import io.mockk.Runs import io.mockk.clearAllMocks @@ -45,6 +47,9 @@ class LogtoClientTest { private val timeBias = 10L + private val pendingRefreshCompletions = mutableListOf>() + private val usedRefreshTokens = mutableListOf() + companion object { private const val TEST_SCOPE = "scope" @@ -581,6 +586,118 @@ class LogtoClientTest { assertThat(logtoClient.isAuthenticated).isFalse() } + @Test + fun `signOut should discard the refresh token response that lands after it`() { + setupDeferredRefreshTestEnv() + + val accessTokenResults = mutableListOf>() + logtoClient.getAccessToken { logtoException, result -> + accessTokenResults.add(logtoException to result) + } + assertThat(pendingRefreshCompletions).hasSize(1) + + logtoClient.signOut(mockk(), "io.logto.android://io.logto.sample/callback") + assertThat(logtoClient.isAuthenticated).isFalse() + + // The in-flight refresh resolves with a freshly rotated, fully valid token set + pendingRefreshCompletions.last().onComplete( + null, + mockRefreshTokenTokenResponse(refreshToken = "rotatedRefreshToken"), + ) + + assertThat(accessTokenResults).hasSize(1) + assertThat(accessTokenResults.last().first) + .hasMessageThat() + .contains(LogtoException.Type.NOT_AUTHENTICATED.name) + assertThat(accessTokenResults.last().second).isNull() + assertThat(logtoClient.isAuthenticated).isFalse() + } + + @Test + fun `a refresh response from before signOut should not clobber the session of a later sign-in`() { + setupDeferredRefreshTestEnv() + + logtoClient.getAccessToken { _, _ -> } + assertThat(pendingRefreshCompletions).hasSize(1) + + logtoClient.signOut(mockk(), "io.logto.android://io.logto.sample/callback") + + // A new session is established after the sign-out + logtoClient.setupIdToken("newSessionIdToken") + logtoClient.setupRefreshToken("newSessionRefreshToken") + + // The pre-sign-out refresh resolves with a valid but obsolete token set + pendingRefreshCompletions.last().onComplete( + null, + mockRefreshTokenTokenResponse( + accessToken = "staleAccessToken", + refreshToken = "staleRefreshToken", + ), + ) + + // Nothing from the stale response may be picked up: no cached stale access + // token, and the next refresh must run on the new session's refresh token + val accessTokenResults = mutableListOf() + logtoClient.getAccessToken { _, result -> accessTokenResults.add(result) } + + assertThat(pendingRefreshCompletions).hasSize(2) + assertThat(usedRefreshTokens.last()).isEqualTo("newSessionRefreshToken") + + pendingRefreshCompletions.last().onComplete(null, mockRefreshTokenTokenResponse()) + + assertThat(accessTokenResults).hasSize(1) + assertThat(requireNotNull(accessTokenResults.last()).token).isEqualTo(TEST_ACCESS_TOKEN) + } + + @Test + fun `signOut during an ongoing sign-in should discard the sign-in result`() { + setupDeferredRefreshTestEnv() + + every { oidcConfigResponseMock.authorizationEndpoint } returns "https://logto.dev/oidc/auth" + every { logtoConfigMock.scopes } returns emptyList() + every { logtoConfigMock.resources } returns null + every { logtoConfigMock.prompt } returns "consent" + every { logtoConfigMock.includeReservedScopes } returns true + + val codeExchangeCompletions = mutableListOf>() + every { + Core.fetchTokenByAuthorizationCode(any(), any(), any(), any(), any(), any(), any()) + } answers { + codeExchangeCompletions.add(lastArg()) + } + + mockkObject(CallbackUriUtils) + every { + CallbackUriUtils.verifyAndParseCodeFromCallbackUri(any(), any(), any()) + } returns "testAuthCode" + + val mockActivity: Activity = mockk() + every { mockActivity.packageName } returns "logto.test" + every { mockActivity.startActivity(any()) } just Runs + + val signInResults = mutableListOf() + logtoClient.signIn(mockActivity, "io.logto.android://io.logto.sample/callback") { + signInResults.add(it) + } + + // The browser flow returns and the code exchange starts + LogtoAuthManager.handleCallbackUri( + Uri.parse("io.logto.android://io.logto.sample/callback?code=testAuthCode"), + ) + assertThat(codeExchangeCompletions).hasSize(1) + + // The previous session signs out while the code exchange is in flight + logtoClient.signOut(mockActivity, "io.logto.android://io.logto.sample/callback") + + codeExchangeCompletions.last().onComplete(null, mockCodeTokenResponse()) + + assertThat(signInResults).hasSize(1) + assertThat(signInResults.last()) + .hasMessageThat() + .contains(LogtoException.Type.NOT_AUTHENTICATED.name) + assertThat(logtoClient.isAuthenticated).isFalse() + } + @Test fun `getAccessToken should fail without being authenticated`() { logtoClient = LogtoClient(logtoConfigMock, mockk()) @@ -1030,6 +1147,71 @@ class LogtoClientTest { } } + /** + * Like [setupRefreshTokenTestEnv], but with a real (non-stubbed) authenticated state + * and a refresh request that stays in flight until its captured completion in + * [pendingRefreshCompletions] is invoked manually — for testing what happens when + * a sign-out lands while token requests are still in flight. + */ + private fun setupDeferredRefreshTestEnv() { + every { logtoConfigMock.appId } returns TEST_APP_ID + + logtoClient = LogtoClient(logtoConfigMock, mockk()) + mockkObject(logtoClient) + + logtoClient.setupRefreshToken(TEST_REFRESH_TOKEN) + logtoClient.setupIdToken(TEST_ID_TOKEN) + + every { oidcConfigResponseMock.tokenEndpoint } returns TEST_TOKEN_ENDPOINT + every { oidcConfigResponseMock.issuer } returns TEST_ISSUER + every { oidcConfigResponseMock.revocationEndpoint } returns TEST_REVOCATION_ENDPOINT + every { oidcConfigResponseMock.endSessionEndpoint } returns TEST_END_SESSION_ENDPOINT + every { logtoClient.getOidcConfig(any()) } answers { + firstArg>().onComplete(null, oidcConfigResponseMock) + } + every { logtoClient.getJwks(any()) } answers { + firstArg>().onComplete(null, jwksMock) + } + + mockkObject(Core) + every { Core.fetchTokenByRefreshToken(any(), any(), any(), any(), any(), any(), any()) } answers { + usedRefreshTokens.add(thirdArg()) + pendingRefreshCompletions.add(lastArg()) + } + every { Core.revoke(any(), any(), any(), any()) } answers { + lastArg().onComplete(null) + } + + mockkObject(TokenUtils) + every { TokenUtils.verifyIdToken(any(), any(), any(), any()) } just Runs + + mockkConstructor(LogtoSignOutSession::class) + every { anyConstructed().start() } just Runs + } + + private fun mockRefreshTokenTokenResponse( + accessToken: String = TEST_ACCESS_TOKEN, + refreshToken: String = TEST_REFRESH_TOKEN, + ): RefreshTokenTokenResponse { + val response: RefreshTokenTokenResponse = mockk() + every { response.accessToken } returns accessToken + every { response.scope } returns TEST_SCOPE + every { response.expiresIn } returns TEST_EXPIRE_IN + every { response.refreshToken } returns refreshToken + every { response.idToken } returns TEST_ID_TOKEN + return response + } + + private fun mockCodeTokenResponse(): CodeTokenResponse { + val response: CodeTokenResponse = mockk() + every { response.accessToken } returns TEST_ACCESS_TOKEN + every { response.scope } returns TEST_SCOPE + every { response.expiresIn } returns TEST_EXPIRE_IN + every { response.refreshToken } returns TEST_REFRESH_TOKEN + every { response.idToken } returns TEST_ID_TOKEN + return response + } + private fun setupRefreshTokenTestEnv() { every { logtoConfigMock.appId } returns TEST_APP_ID From fa4243668b9a74772dc4d4bc14acdde3ba1d729f Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Thu, 11 Jun 2026 21:07:31 +0800 Subject: [PATCH 2/4] fix(android-sdk): run in-flight token flows on credential snapshots --- .../io/logto/sdk/android/LogtoClient.kt | 14 +++++--- .../io/logto/sdk/android/LogtoClientTest.kt | 35 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/android-sdk/android/src/main/kotlin/io/logto/sdk/android/LogtoClient.kt b/android-sdk/android/src/main/kotlin/io/logto/sdk/android/LogtoClient.kt index 774f73d..ace783b 100644 --- a/android-sdk/android/src/main/kotlin/io/logto/sdk/android/LogtoClient.kt +++ b/android-sdk/android/src/main/kotlin/io/logto/sdk/android/LogtoClient.kt @@ -375,7 +375,11 @@ open class LogtoClient( } // MARK: If cannot refresh the access token, then return a NOT_AUTHENTICATED error - if (refreshToken == null) { + // Snapshot the refresh token: a concurrent sign-out can null the field while this + // flow is between its async hops; the flow runs on the snapshot and the session + // guard arbitrates at commit time. + val tokenForRefresh = refreshToken + if (tokenForRefresh == null) { completion.onComplete(LogtoException(LogtoException.Type.NOT_AUTHENTICATED), null) return } @@ -390,7 +394,7 @@ open class LogtoClient( Core.fetchTokenByRefreshToken( tokenEndpoint = requireNotNull(oidcConfig).tokenEndpoint, clientId = logtoConfig.appId, - refreshToken = requireNotNull(refreshToken), + refreshToken = tokenForRefresh, resource = resource, organizationId = organizationId, scopes = null, @@ -436,12 +440,14 @@ open class LogtoClient( * @param[completion] the completion which handles the retrieved result */ fun getIdTokenClaims(completion: Completion) { - if (!isAuthenticated) { + // Snapshot the ID token: a concurrent sign-out can null the field at any point + val currentIdToken = idToken + if (!isAuthenticated || currentIdToken == null) { completion.onComplete(LogtoException(LogtoException.Type.NOT_AUTHENTICATED), null) return } try { - val idTokenClaims = TokenUtils.decodeIdToken(requireNotNull(idToken)) + val idTokenClaims = TokenUtils.decodeIdToken(currentIdToken) completion.onComplete(null, idTokenClaims) } catch (exception: InvalidJwtException) { completion.onComplete( diff --git a/android-sdk/android/src/test/kotlin/io/logto/sdk/android/LogtoClientTest.kt b/android-sdk/android/src/test/kotlin/io/logto/sdk/android/LogtoClientTest.kt index 6e34fb3..6c9bd77 100644 --- a/android-sdk/android/src/test/kotlin/io/logto/sdk/android/LogtoClientTest.kt +++ b/android-sdk/android/src/test/kotlin/io/logto/sdk/android/LogtoClientTest.kt @@ -649,6 +649,41 @@ class LogtoClientTest { assertThat(requireNotNull(accessTokenResults.last()).token).isEqualTo(TEST_ACCESS_TOKEN) } + @Test + fun `signOut while the oidc config fetch is in flight should not crash the refresh flow`() { + setupDeferredRefreshTestEnv() + + // Defer the oidc config fetch as well, so the sign-out can land inside the + // window between the refresh-token null check and the token request + val oidcConfigCompletions = mutableListOf>() + every { logtoClient.getOidcConfig(any()) } answers { + oidcConfigCompletions.add(firstArg()) + } + + val accessTokenResults = mutableListOf>() + logtoClient.getAccessToken { logtoException, result -> + accessTokenResults.add(logtoException to result) + } + assertThat(oidcConfigCompletions).hasSize(1) + + logtoClient.signOut(mockk(), "io.logto.android://io.logto.sample/callback") + + // The oidc config arrives after the sign-out has already cleared the refresh token + oidcConfigCompletions.first().onComplete(null, oidcConfigResponseMock) + + // The refresh runs on the snapshot it started from, and its response is discarded + assertThat(usedRefreshTokens).containsExactly(TEST_REFRESH_TOKEN) + assertThat(pendingRefreshCompletions).hasSize(1) + pendingRefreshCompletions.last().onComplete(null, mockRefreshTokenTokenResponse()) + + assertThat(accessTokenResults).hasSize(1) + assertThat(accessTokenResults.last().first) + .hasMessageThat() + .contains(LogtoException.Type.NOT_AUTHENTICATED.name) + assertThat(accessTokenResults.last().second).isNull() + assertThat(logtoClient.isAuthenticated).isFalse() + } + @Test fun `signOut during an ongoing sign-in should discard the sign-in result`() { setupDeferredRefreshTestEnv() From 6770996c1c5f317cf06ef9e5500b8bbc76ef04bd Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Thu, 11 Jun 2026 21:19:14 +0800 Subject: [PATCH 3/4] fix(android-sdk): complete stale token flows with NOT_AUTHENTICATED consistently --- .../io/logto/sdk/android/LogtoClient.kt | 55 ++++++++----- .../io/logto/sdk/android/LogtoClientTest.kt | 79 +++++++++++++++++++ 2 files changed, 116 insertions(+), 18 deletions(-) diff --git a/android-sdk/android/src/main/kotlin/io/logto/sdk/android/LogtoClient.kt b/android-sdk/android/src/main/kotlin/io/logto/sdk/android/LogtoClient.kt index ace783b..65ec6d0 100644 --- a/android-sdk/android/src/main/kotlin/io/logto/sdk/android/LogtoClient.kt +++ b/android-sdk/android/src/main/kotlin/io/logto/sdk/android/LogtoClient.kt @@ -540,32 +540,48 @@ open class LogtoClient( accessToken: AccessToken, completion: EmptyCompletion, ) { + // Discard already-stale flows before fetching the JWKS or verifying the response + if (!sessionGuard.isCurrent(sessionStamp)) { + completion.onComplete(LogtoException(LogtoException.Type.NOT_AUTHENTICATED)) + return + } + getJwks { getJwksException, jwks -> - getJwksException?.let { - completion.onComplete(it) - return@getJwks - } - responseIdToken?.let { - try { - TokenUtils.verifyIdToken(it, logtoConfig.appId, issuer, requireNotNull(jwks)) - } catch (exception: InvalidJwtException) { - completion.onComplete(LogtoException(LogtoException.Type.INVALID_ID_TOKEN, exception)) - return@getJwks - } - } + val verificationException = getJwksException ?: verifyIdToken(responseIdToken, issuer, jwks) - val saved = sessionGuard.commit(sessionStamp) { - responseIdToken?.let { idToken = it } - accessTokenMap[accessTokenKey] = accessToken - refreshToken = responseRefreshToken - } + val saved = verificationException == null && + sessionGuard.commit(sessionStamp) { + responseIdToken?.let { idToken = it } + accessTokenMap[accessTokenKey] = accessToken + refreshToken = responseRefreshToken + } completion.onComplete( - if (saved) null else LogtoException(LogtoException.Type.NOT_AUTHENTICATED), + when { + saved -> null + // Stale flows always complete with NOT_AUTHENTICATED, even when the + // response would also have failed verification + !sessionGuard.isCurrent(sessionStamp) -> + LogtoException(LogtoException.Type.NOT_AUTHENTICATED) + else -> verificationException + }, ) } } + private fun verifyIdToken( + responseIdToken: String?, + issuer: String, + jwks: JsonWebKeySet?, + ): LogtoException? = responseIdToken?.let { + try { + TokenUtils.verifyIdToken(it, logtoConfig.appId, issuer, requireNotNull(jwks)) + null + } catch (exception: InvalidJwtException) { + LogtoException(LogtoException.Type.INVALID_ID_TOKEN, exception) + } + } + internal fun getOidcConfig(completion: Completion) { if (oidcConfig != null) { completion.onComplete(null, oidcConfig) @@ -663,6 +679,9 @@ private class SessionGuard { @Synchronized fun stamp(): Int = version + @Synchronized + fun isCurrent(stamp: Int): Boolean = stamp == version + @Synchronized fun invalidate(block: () -> T): T { version++ diff --git a/android-sdk/android/src/test/kotlin/io/logto/sdk/android/LogtoClientTest.kt b/android-sdk/android/src/test/kotlin/io/logto/sdk/android/LogtoClientTest.kt index 6c9bd77..b44a701 100644 --- a/android-sdk/android/src/test/kotlin/io/logto/sdk/android/LogtoClientTest.kt +++ b/android-sdk/android/src/test/kotlin/io/logto/sdk/android/LogtoClientTest.kt @@ -649,6 +649,85 @@ class LogtoClientTest { assertThat(requireNotNull(accessTokenResults.last()).token).isEqualTo(TEST_ACCESS_TOKEN) } + @Test + fun `a stale refresh response should be discarded without fetching the JWKS`() { + setupDeferredRefreshTestEnv() + + val accessTokenResults = mutableListOf() + logtoClient.getAccessToken { logtoException, _ -> + accessTokenResults.add(logtoException) + } + assertThat(pendingRefreshCompletions).hasSize(1) + + logtoClient.signOut(mockk(), "io.logto.android://io.logto.sample/callback") + + pendingRefreshCompletions.last().onComplete(null, mockRefreshTokenTokenResponse()) + + assertThat(accessTokenResults).hasSize(1) + assertThat(accessTokenResults.last()) + .hasMessageThat() + .contains(LogtoException.Type.NOT_AUTHENTICATED.name) + verify(exactly = 0) { logtoClient.getJwks(any()) } + } + + @Test + fun `signOut while the JWKS fetch is in flight should still discard the refresh response`() { + setupDeferredRefreshTestEnv() + + // Defer the JWKS fetch, so the sign-out can land between the staleness + // pre-check and the commit + val jwksCompletions = mutableListOf>() + every { logtoClient.getJwks(any()) } answers { + jwksCompletions.add(firstArg()) + } + + val accessTokenResults = mutableListOf() + logtoClient.getAccessToken { logtoException, _ -> + accessTokenResults.add(logtoException) + } + pendingRefreshCompletions.last().onComplete(null, mockRefreshTokenTokenResponse()) + assertThat(jwksCompletions).hasSize(1) + + logtoClient.signOut(mockk(), "io.logto.android://io.logto.sample/callback") + + jwksCompletions.first().onComplete(null, jwksMock) + + assertThat(accessTokenResults).hasSize(1) + assertThat(accessTokenResults.last()) + .hasMessageThat() + .contains(LogtoException.Type.NOT_AUTHENTICATED.name) + assertThat(logtoClient.isAuthenticated).isFalse() + } + + @Test + fun `a stale refresh response should report NOT_AUTHENTICATED even when its token is invalid`() { + setupDeferredRefreshTestEnv() + + val jwksCompletions = mutableListOf>() + every { logtoClient.getJwks(any()) } answers { + jwksCompletions.add(firstArg()) + } + every { TokenUtils.verifyIdToken(any(), any(), any(), any()) } throws mockk() + + val accessTokenResults = mutableListOf() + logtoClient.getAccessToken { logtoException, _ -> + accessTokenResults.add(logtoException) + } + pendingRefreshCompletions.last().onComplete(null, mockRefreshTokenTokenResponse()) + assertThat(jwksCompletions).hasSize(1) + + logtoClient.signOut(mockk(), "io.logto.android://io.logto.sample/callback") + + jwksCompletions.first().onComplete(null, jwksMock) + + assertThat(accessTokenResults).hasSize(1) + // Staleness dominates the verification failure: the flow was discarded, so it + // must not surface INVALID_ID_TOKEN + assertThat(accessTokenResults.last()) + .hasMessageThat() + .contains(LogtoException.Type.NOT_AUTHENTICATED.name) + } + @Test fun `signOut while the oidc config fetch is in flight should not crash the refresh flow`() { setupDeferredRefreshTestEnv() From 1078d965888c318bbc100cf5d81b23ccf4cea634 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Thu, 11 Jun 2026 21:30:41 +0800 Subject: [PATCH 4/4] refactor(android-sdk): rename SessionGuard to CredentialGuard --- .../io/logto/sdk/android/LogtoClient.kt | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/android-sdk/android/src/main/kotlin/io/logto/sdk/android/LogtoClient.kt b/android-sdk/android/src/main/kotlin/io/logto/sdk/android/LogtoClient.kt index 65ec6d0..b30cc3a 100644 --- a/android-sdk/android/src/main/kotlin/io/logto/sdk/android/LogtoClient.kt +++ b/android-sdk/android/src/main/kotlin/io/logto/sdk/android/LogtoClient.kt @@ -35,9 +35,9 @@ open class LogtoClient( /** * Guards the credential fields below: token flows that were in flight when * [signOut] or [clearCredentials] dropped the credentials must not persist - * their (now stale) results. See [SessionGuard]. + * their (now stale) results. See [CredentialGuard]. */ - private val sessionGuard = SessionGuard() + private val credentialGuard = CredentialGuard() /** * Cached access tokens. @@ -105,7 +105,7 @@ open class LogtoClient( options: SignInOptions, completion: EmptyCompletion, ) { - val sessionStamp = sessionGuard.stamp() + val credentialStamp = credentialGuard.stamp() getOidcConfig { getOidcConfigException, oidcConfig -> getOidcConfigException?.let { @@ -133,7 +133,7 @@ open class LogtoClient( ) verifyAndSaveTokenResponse( - sessionStamp = sessionStamp, + credentialStamp = credentialStamp, issuer = oidcConfig.issuer, responseIdToken = codeToken.idToken, responseRefreshToken = codeToken.refreshToken, @@ -347,7 +347,7 @@ open class LogtoClient( // The stamp must be taken before any credential is read: a sign-out that lands // between the read and the stamp would otherwise go unnoticed and the refreshed // tokens would be committed against the already-cleared credentials. - val sessionStamp = sessionGuard.stamp() + val credentialStamp = credentialGuard.stamp() if (!isAuthenticated) { completion.onComplete(LogtoException(LogtoException.Type.NOT_AUTHENTICATED), null) @@ -376,7 +376,7 @@ open class LogtoClient( // MARK: If cannot refresh the access token, then return a NOT_AUTHENTICATED error // Snapshot the refresh token: a concurrent sign-out can null the field while this - // flow is between its async hops; the flow runs on the snapshot and the session + // flow is between its async hops; the flow runs on the snapshot and the credential // guard arbitrates at commit time. val tokenForRefresh = refreshToken if (tokenForRefresh == null) { @@ -421,7 +421,7 @@ open class LogtoClient( ) verifyAndSaveTokenResponse( - sessionStamp = sessionStamp, + credentialStamp = credentialStamp, issuer = oidcConfig.issuer, responseIdToken = refreshedToken.idToken, responseRefreshToken = refreshedToken.refreshToken, @@ -523,7 +523,7 @@ open class LogtoClient( * * @return the refresh token that was current, for the caller to revoke */ - private fun dropCredentials(): String? = sessionGuard.invalidate { + private fun dropCredentials(): String? = credentialGuard.invalidate { val tokenToRevoke = refreshToken accessTokenMap.clear() idToken = null @@ -532,7 +532,7 @@ open class LogtoClient( } private fun verifyAndSaveTokenResponse( - sessionStamp: Int, + credentialStamp: Int, issuer: String, responseIdToken: String?, responseRefreshToken: String?, @@ -541,7 +541,7 @@ open class LogtoClient( completion: EmptyCompletion, ) { // Discard already-stale flows before fetching the JWKS or verifying the response - if (!sessionGuard.isCurrent(sessionStamp)) { + if (!credentialGuard.isCurrent(credentialStamp)) { completion.onComplete(LogtoException(LogtoException.Type.NOT_AUTHENTICATED)) return } @@ -550,7 +550,7 @@ open class LogtoClient( val verificationException = getJwksException ?: verifyIdToken(responseIdToken, issuer, jwks) val saved = verificationException == null && - sessionGuard.commit(sessionStamp) { + credentialGuard.commit(credentialStamp) { responseIdToken?.let { idToken = it } accessTokenMap[accessTokenKey] = accessToken refreshToken = responseRefreshToken @@ -561,7 +561,7 @@ open class LogtoClient( saved -> null // Stale flows always complete with NOT_AUTHENTICATED, even when the // response would also have failed verification - !sessionGuard.isCurrent(sessionStamp) -> + !credentialGuard.isCurrent(credentialStamp) -> LogtoException(LogtoException.Type.NOT_AUTHENTICATED) else -> verificationException }, @@ -673,7 +673,7 @@ open class LogtoClient( * lands after a sign-out from resurrecting the cleared credentials, and a response * from before a sign-out from clobbering the session of a later sign-in. */ -private class SessionGuard { +private class CredentialGuard { private var version = 0 @Synchronized