diff --git a/source/api/src/main/kotlin/com/clerk/api/Clerk.kt b/source/api/src/main/kotlin/com/clerk/api/Clerk.kt index d3cdbcd8d..9d28a6500 100644 --- a/source/api/src/main/kotlin/com/clerk/api/Clerk.kt +++ b/source/api/src/main/kotlin/com/clerk/api/Clerk.kt @@ -556,6 +556,14 @@ object Clerk { suspend fun updateDeviceToken(deviceToken: String): ClerkResult = configurationManager.updateDeviceToken(deviceToken) + /** + * Returns the current device token from encrypted storage, or null if unavailable. + * + * This is used by the Expo bridge to sync the native client token with the JS SDK. + */ + fun getDeviceToken(): String? = + com.clerk.api.storage.StorageHelper.loadValue(com.clerk.api.storage.StorageKey.DEVICE_TOKEN) + // endregion // region Internal Methods diff --git a/source/api/src/main/kotlin/com/clerk/api/signout/SignOutService.kt b/source/api/src/main/kotlin/com/clerk/api/signout/SignOutService.kt index 920c2ba38..04fdf7f4b 100644 --- a/source/api/src/main/kotlin/com/clerk/api/signout/SignOutService.kt +++ b/source/api/src/main/kotlin/com/clerk/api/signout/SignOutService.kt @@ -3,6 +3,7 @@ package com.clerk.api.signout import com.clerk.api.Clerk import com.clerk.api.log.ClerkLog import com.clerk.api.network.ClerkApi +import com.clerk.api.network.model.client.Client import com.clerk.api.network.model.error.ClerkErrorResponse import com.clerk.api.network.serialization.ClerkResult import com.clerk.api.session.delete @@ -48,6 +49,12 @@ internal object SignOutService { // Always clear local credentials regardless of server response StorageHelper.deleteValue(StorageKey.DEVICE_TOKEN) Clerk.clearSessionAndUserState() + + // Best-effort refresh of the in-memory client while skipping current client id. + // This clears stale in-progress sign-in/sign-up state that can otherwise persist after + // sign-out when the host remounts AuthView within the same process/activity lifecycle. + runCatching { Client.getSkippingClientId() } + .onFailure { ClerkLog.w("Client refresh after sign-out failed: ${it.message}") } } return if (serverError != null) { diff --git a/source/api/src/test/java/com/clerk/api/signout/SignOutServiceTest.kt b/source/api/src/test/java/com/clerk/api/signout/SignOutServiceTest.kt index bbd79d25b..a352d281c 100644 --- a/source/api/src/test/java/com/clerk/api/signout/SignOutServiceTest.kt +++ b/source/api/src/test/java/com/clerk/api/signout/SignOutServiceTest.kt @@ -3,6 +3,7 @@ package com.clerk.api.signout import android.content.Context import com.clerk.api.Clerk import com.clerk.api.network.ClerkApi +import com.clerk.api.network.api.ClientApi import com.clerk.api.network.api.SessionApi import com.clerk.api.network.model.client.Client import com.clerk.api.network.model.environment.DisplayConfig @@ -14,6 +15,7 @@ import com.clerk.api.storage.StorageHelper import com.clerk.api.storage.StorageKey import com.clerk.api.user.User import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -43,6 +45,7 @@ class SignOutServiceTest { private lateinit var mockSession: Session private lateinit var mockUser: User private lateinit var mockSessionApi: SessionApi + private lateinit var mockClientApi: ClientApi @OptIn(ExperimentalCoroutinesApi::class) @Before @@ -60,10 +63,12 @@ class SignOutServiceTest { mockSession = mockk(relaxed = true) mockUser = mockk(relaxed = true) mockSessionApi = mockk() + mockClientApi = mockk() - // Mock ClerkApi.session + // Mock ClerkApi apis mockkObject(ClerkApi) every { ClerkApi.session } returns mockSessionApi + every { ClerkApi.client } returns mockClientApi // Setup environment mock val mockDisplayConfig = mockk() @@ -114,6 +119,7 @@ class SignOutServiceTest { // Mock successful server sign-out coEvery { mockSessionApi.removeSession(any()) } returns ClerkResult.success(mockSession) + coEvery { mockClientApi.getSkippingClientId(any()) } returns ClerkResult.success(mockClient) // Verify device token exists before sign-out assertTrue(StorageHelper.loadValue(StorageKey.DEVICE_TOKEN) != null) @@ -137,6 +143,7 @@ class SignOutServiceTest { // Mock server sign-out failure (network error) coEvery { mockSessionApi.removeSession(any()) } throws Exception("Network error") + coEvery { mockClientApi.getSkippingClientId(any()) } returns ClerkResult.success(mockClient) // Verify device token exists before sign-out assertTrue(StorageHelper.loadValue(StorageKey.DEVICE_TOKEN) != null) @@ -162,6 +169,7 @@ class SignOutServiceTest { // Mock successful server sign-out coEvery { mockSessionApi.removeSession(any()) } returns ClerkResult.success(mockSession) + coEvery { mockClientApi.getSkippingClientId(any()) } returns ClerkResult.success(mockClient) // Verify session exists before sign-out assertTrue("Session should exist before sign-out", Clerk.session != null) @@ -184,6 +192,7 @@ class SignOutServiceTest { // Mock server sign-out failure coEvery { mockSessionApi.removeSession(any()) } throws Exception("Server error") + coEvery { mockClientApi.getSkippingClientId(any()) } returns ClerkResult.success(mockClient) // Verify session exists before sign-out assertTrue("Session should exist before sign-out", Clerk.session != null) @@ -212,6 +221,8 @@ class SignOutServiceTest { Clerk.updateClient(emptyClient) Clerk.environment = mockEnvironment + coEvery { mockClientApi.getSkippingClientId(any()) } returns ClerkResult.success(emptyClient) + // When val result = SignOutService.signOut() @@ -228,6 +239,7 @@ class SignOutServiceTest { // Mock successful server sign-out coEvery { mockSessionApi.removeSession(any()) } returns ClerkResult.success(mockSession) + coEvery { mockClientApi.getSkippingClientId(any()) } returns ClerkResult.success(mockClient) // When SignOutService.signOut() @@ -239,4 +251,30 @@ class SignOutServiceTest { StorageHelper.loadValue(StorageKey.DEVICE_ID) == "test_device_id", ) } + + @Test + fun `signOut refreshes client after local sign-out cleanup`() = runTest { + setupActiveSession() + coEvery { mockSessionApi.removeSession(any()) } returns ClerkResult.success(mockSession) + coEvery { mockClientApi.getSkippingClientId(any()) } returns ClerkResult.success(mockClient) + + val result = SignOutService.signOut() + + assertTrue("Sign-out should succeed", result is ClerkResult.Success) + coVerify(exactly = 1) { mockClientApi.getSkippingClientId(any()) } + } + + @Test + fun `signOut tolerates client refresh failure`() = runTest { + setupActiveSession() + coEvery { mockSessionApi.removeSession(any()) } returns ClerkResult.success(mockSession) + coEvery { mockClientApi.getSkippingClientId(any()) } throws Exception("Refresh failed") + + val result = SignOutService.signOut() + + assertTrue("Sign-out should still succeed", result is ClerkResult.Success) + coVerify(exactly = 1) { mockClientApi.getSkippingClientId(any()) } + assertNull("Session should still be cleared", Clerk.session) + assertNull("User should still be cleared", Clerk.user) + } }