From 98f7811c79c544766c61af79e8485edba1a6da60 Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Thu, 9 Apr 2026 09:45:50 -0700 Subject: [PATCH 1/2] fix(api): refresh client after sign-out Perform a best-effort Client.getSkippingClientId() refresh after local sign-out cleanup so stale in-progress sign-in/sign-up state does not persist across remounts.\n\nAlso expands SignOutService tests to mock ClientApi and assert the refresh call occurs and failures are tolerated. --- .../com/clerk/api/signout/SignOutService.kt | 7 ++++ .../clerk/api/signout/SignOutServiceTest.kt | 40 ++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) 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) + } } From 848dfd8cbce17b4bd33344b64dbcc8658c51e03d Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Fri, 10 Apr 2026 11:31:31 -0700 Subject: [PATCH 2/2] feat(api): add public getDeviceToken() accessor Exposes the device token through the SDK's encrypted storage layer so external consumers (e.g. the Expo bridge) can read it without bypassing StorageCipher. Direct SharedPreferences reads break after the storage encryption change in #585. --- source/api/src/main/kotlin/com/clerk/api/Clerk.kt | 8 ++++++++ 1 file changed, 8 insertions(+) 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