From c72b5898f910dbbc7372495c80e0b3f4937d1080 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Fri, 12 Jun 2026 08:17:02 +0800 Subject: [PATCH 1/3] fix(android-sdk): discard in-flight token responses after sign-out (#263) * fix(android-sdk): discard in-flight token responses after sign-out * fix(android-sdk): run in-flight token flows on credential snapshots * fix(android-sdk): complete stale token flows with NOT_AUTHENTICATED consistently * refactor(android-sdk): rename SessionGuard to CredentialGuard (cherry picked from commit 6a846011ce3ad77f15d329042186be295decca71) --- .../io/logto/sdk/android/LogtoClient.kt | 145 +++++++-- .../io/logto/sdk/android/LogtoClientTest.kt | 302 ++++++++++++++++++ 2 files changed, 423 insertions(+), 24 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 747058e0..8ef49d1c 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 @@ -31,6 +31,13 @@ open class LogtoClient( val logtoConfig: LogtoConfig, application: Application, ) { + /** + * Guards the credential fields below: token flows that were in flight when + * [signOut] dropped the credentials must not persist their (now stale) results. + * See [CredentialGuard]. + */ + private val credentialGuard = CredentialGuard() + /** * Cached access tokens. */ @@ -83,6 +90,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 @@ -92,6 +104,8 @@ open class LogtoClient( options: SignInOptions, completion: EmptyCompletion, ) { + val credentialStamp = credentialGuard.stamp() + getOidcConfig { getOidcConfigException, oidcConfig -> getOidcConfigException?.let { completion.onComplete(it) @@ -118,6 +132,7 @@ open class LogtoClient( ) verifyAndSaveTokenResponse( + credentialStamp = credentialStamp, issuer = oidcConfig.issuer, responseIdToken = codeToken.idToken, responseRefreshToken = codeToken.refreshToken, @@ -155,6 +170,10 @@ open class LogtoClient( * * Local credentials will be cleared even though there are errors occurred when signing 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 which handles the error occurred when signing out */ fun signOut(completion: EmptyCompletion? = null) { @@ -169,11 +188,7 @@ open class LogtoClient( flush() } - accessTokenMap.clear() - idToken = null - - refreshToken?.let { tokenToRevoke -> - refreshToken = null + dropCredentials()?.let { tokenToRevoke -> getOidcConfig { getOidcConfigException, oidcConfig -> getOidcConfigException?.let { completion?.onComplete(it) @@ -237,6 +252,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 credentialStamp = credentialGuard.stamp() + if (!isAuthenticated) { completion.onComplete(LogtoException(LogtoException.Type.NOT_AUTHENTICATED), null) return @@ -263,7 +283,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 credential + // guard arbitrates at commit time. + val tokenForRefresh = refreshToken + if (tokenForRefresh == null) { completion.onComplete(LogtoException(LogtoException.Type.NOT_AUTHENTICATED), null) return } @@ -278,7 +302,7 @@ open class LogtoClient( Core.fetchTokenByRefreshToken( tokenEndpoint = requireNotNull(oidcConfig).tokenEndpoint, clientId = logtoConfig.appId, - refreshToken = requireNotNull(refreshToken), + refreshToken = tokenForRefresh, resource = resource, organizationId = organizationId, scopes = null, @@ -305,6 +329,7 @@ open class LogtoClient( ) verifyAndSaveTokenResponse( + credentialStamp = credentialStamp, issuer = oidcConfig.issuer, responseIdToken = refreshedToken.idToken, responseRefreshToken = refreshedToken.refreshToken, @@ -323,12 +348,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( @@ -398,7 +425,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? = credentialGuard.invalidate { + val tokenToRevoke = refreshToken + accessTokenMap.clear() + idToken = null + refreshToken = null + tokenToRevoke + } + private fun verifyAndSaveTokenResponse( + credentialStamp: Int, issuer: String, responseIdToken: String?, responseRefreshToken: String?, @@ -406,24 +448,45 @@ open class LogtoClient( accessToken: AccessToken, completion: EmptyCompletion, ) { + // Discard already-stale flows before fetching the JWKS or verifying the response + if (!credentialGuard.isCurrent(credentialStamp)) { + 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 = verificationException == null && + credentialGuard.commit(credentialStamp) { + responseIdToken?.let { idToken = it } + accessTokenMap[accessTokenKey] = accessToken + refreshToken = responseRefreshToken } - idToken = it - } - accessTokenMap[accessTokenKey] = accessToken - refreshToken = responseRefreshToken - completion.onComplete(null) + completion.onComplete( + when { + saved -> null + // Stale flows always complete with NOT_AUTHENTICATED, even when the + // response would also have failed verification + !credentialGuard.isCurrent(credentialStamp) -> + 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) } } @@ -508,3 +571,37 @@ 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 CredentialGuard { + private var version = 0 + + @Synchronized + fun stamp(): Int = version + + @Synchronized + fun isCurrent(stamp: Int): Boolean = stamp == 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 fcf311a9..add2e060 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 @@ -1,7 +1,10 @@ package io.logto.sdk.android +import android.app.Activity +import android.net.Uri import android.webkit.CookieManager import com.google.common.truth.Truth.assertThat +import io.logto.sdk.android.auth.logto.LogtoAuthManager import io.logto.sdk.android.auth.logto.LogtoAuthSession import io.logto.sdk.android.completion.Completion import io.logto.sdk.android.exception.LogtoException @@ -11,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 @@ -32,6 +37,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +@Suppress("LargeClass") @RunWith(RobolectricTestRunner::class) class LogtoClientTest { private val oidcConfigResponseMock: OidcConfigResponse = mockk() @@ -41,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" @@ -81,6 +90,7 @@ class LogtoClientTest { @After fun tearDown() { clearAllMocks() + LogtoAuthManager.logtoAuthSession = null } @Test @@ -230,6 +240,232 @@ 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() + 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() + + // 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 `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() + + 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() + + 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() + + 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() + + // 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() + + // 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() + + 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 WebView 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() + + 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()) @@ -679,6 +915,72 @@ 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 { logtoClient.getOidcConfig(any()) } answers { + firstArg>().onComplete(null, oidcConfigResponseMock) + } + every { logtoClient.getJwks(any()) } answers { + firstArg>().onComplete(null, jwksMock) + } + + val cookieManagerInstance = CookieManager.getInstance() + mockkObject(cookieManagerInstance) + every { cookieManagerInstance.removeAllCookies(any()) } just Runs + every { cookieManagerInstance.flush() } just Runs + + 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 + } + + 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 abbd42b2766962594b556412d3bb35b844b7ba4c Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Fri, 12 Jun 2026 08:59:34 +0800 Subject: [PATCH 2/3] fix(android-sdk): invalidate unauthenticated sign-out flows --- .../io/logto/sdk/android/LogtoClient.kt | 6 +++ .../io/logto/sdk/android/LogtoClientTest.kt | 49 +++++++++++++++++++ 2 files changed, 55 insertions(+) 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 8ef49d1c..5d277838 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 @@ -178,6 +178,7 @@ open class LogtoClient( */ fun signOut(completion: EmptyCompletion? = null) { if (!isAuthenticated) { + credentialGuard.invalidate() completion?.onComplete(LogtoException(LogtoException.Type.NOT_AUTHENTICATED)) return } @@ -590,6 +591,11 @@ private class CredentialGuard { @Synchronized fun isCurrent(stamp: Int): Boolean = stamp == version + @Synchronized + fun invalidate() { + 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 add2e060..d10cbac9 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 @@ -466,6 +466,55 @@ class LogtoClientTest { assertThat(logtoClient.isAuthenticated).isFalse() } + @Test + fun `signOut during an unauthenticated ongoing sign-in should discard the sign-in result`() { + setupDeferredRefreshTestEnv() + logtoClient.setupIdToken(null) + logtoClient.setupRefreshToken(null) + + 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) + } + + LogtoAuthManager.handleCallbackUri( + Uri.parse("io.logto.android://io.logto.sample/callback?code=testAuthCode"), + ) + assertThat(codeExchangeCompletions).hasSize(1) + + logtoClient.signOut() + + 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()) From 9817125b87264f2556413873ecea64f2135f9d70 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Fri, 12 Jun 2026 09:13:38 +0800 Subject: [PATCH 3/3] fix(android-sdk): keep the refresh token when a refresh response omits it --- .../io/logto/sdk/android/LogtoClient.kt | 4 +++- .../io/logto/sdk/android/LogtoClientTest.kt | 23 ++++++++++++++++++- 2 files changed, 25 insertions(+), 2 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 5d277838..7276ba41 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 @@ -333,7 +333,9 @@ open class LogtoClient( credentialStamp = credentialStamp, issuer = oidcConfig.issuer, responseIdToken = refreshedToken.idToken, - responseRefreshToken = refreshedToken.refreshToken, + // RFC 6749 §6: keep the current refresh token when the response + // does not issue a new one + responseRefreshToken = refreshedToken.refreshToken ?: tokenForRefresh, accessTokenKey = buildAccessTokenKey(null, resource, organizationId), accessToken = refreshedAccessToken, ) { verifyException -> 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 d10cbac9..871cde4b 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 @@ -515,6 +515,27 @@ class LogtoClientTest { assertThat(logtoClient.isAuthenticated).isFalse() } + @Test + fun `a refresh response without a refresh token should keep the existing one`() { + setupDeferredRefreshTestEnv() + every { logtoConfigMock.resources } returns listOf(TEST_RESOURCE_1) + + // The first refresh succeeds, but its response does not issue a new refresh token + val accessTokenResults = mutableListOf() + logtoClient.getAccessToken { _, result -> accessTokenResults.add(result) } + assertThat(pendingRefreshCompletions).hasSize(1) + pendingRefreshCompletions.last().onComplete( + null, + mockRefreshTokenTokenResponse(refreshToken = null), + ) + assertThat(requireNotNull(accessTokenResults.last()).token).isEqualTo(TEST_ACCESS_TOKEN) + + // A refresh for another resource must still run, on the kept refresh token + logtoClient.getAccessToken(TEST_RESOURCE_1) { _, _ -> } + assertThat(pendingRefreshCompletions).hasSize(2) + assertThat(usedRefreshTokens.last()).isEqualTo(TEST_REFRESH_TOKEN) + } + @Test fun `getAccessToken should fail without being authenticated`() { logtoClient = LogtoClient(logtoConfigMock, mockk()) @@ -1009,7 +1030,7 @@ class LogtoClientTest { private fun mockRefreshTokenTokenResponse( accessToken: String = TEST_ACCESS_TOKEN, - refreshToken: String = TEST_REFRESH_TOKEN, + refreshToken: String? = TEST_REFRESH_TOKEN, ): RefreshTokenTokenResponse { val response: RefreshTokenTokenResponse = mockk() every { response.accessToken } returns accessToken