feat(core): improve login flow (#15219)

#### PR Dependency Tree


* **PR #15219** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added secure, automatic auth session token refresh and request replay
for expired-token responses across Android, iOS, and Electron.
* Updated sign-in flows to manage sessions without returning tokens to
the app layer.
* Added “Devices” management UI with sign out per device and sign out
all.
  * Enabled support for both Hashcash and Turnstile captcha providers.
* **Bug Fixes**
* Improved refresh de-duplication, inflight cancellation/clear behavior,
and recovery from corrupted/invalid sessions.
* **Tests**
* Expanded auth-session, refresh/revoke, and replay coverage (Electron
unit tests, Android instrumentation tests, iOS auth date parser tests).
* **Chores**
* Removed CAPTCHA site key from build-time configuration and adjusted CI
test execution.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-07-12 18:11:02 +08:00
committed by GitHub
parent 02b25e05d8
commit abf37d3dfa
57 changed files with 2919 additions and 789 deletions

View File

@@ -0,0 +1,237 @@
package app.affine.pro.plugin
import android.content.Context
import androidx.datastore.preferences.preferencesDataStore
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import app.affine.pro.utils.authDataStoreCorruptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
import java.util.concurrent.TimeUnit
private val Context.corruptionTestDataStore by preferencesDataStore(
name = "auth-session-corruption-test",
corruptionHandler = authDataStoreCorruptionHandler,
)
@RunWith(AndroidJUnit4::class)
class AuthSessionBrokerTest {
private lateinit var server: MockWebServer
private lateinit var endpoint: String
private lateinit var broker: AuthSessionBroker
@Before
fun setUp() = runBlocking {
server = MockWebServer()
server.start()
endpoint = server.url("/").toString().removeSuffix("/")
broker = AuthSessionBroker()
broker.clear(endpoint)
}
@After
fun tearDown() = runBlocking {
broker.clear(endpoint)
server.shutdown()
}
@Test
fun persistsEncryptedPairAcrossBrokerRecreation() = runBlocking {
broker.store(endpoint, tokenResponse("access-one", refreshToken('a'), 900))
assertEquals("access-one", AuthSessionBroker().validAccessToken(endpoint))
}
@Test
fun concurrentCallersShareOneRefresh() = runBlocking {
broker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
Thread.sleep(1_100)
server.enqueue(MockResponse().setBody(tokenResponse("fresh", refreshToken('b'), 900)))
val tokens = List(20) { async { broker.validAccessToken(endpoint) } }.awaitAll()
assertTrue(tokens.all { it == "fresh" })
assertEquals(1, server.requestCount)
}
@Test
fun retriesPendingRotationBeforeStartingAnotherRefresh() = runBlocking {
val storage = FakeStorage()
val testBroker = AuthSessionBroker(storage)
testBroker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
Thread.sleep(1_100)
storage.failWrites = 1
server.enqueue(MockResponse().setBody(tokenResponse("fresh", refreshToken('b'), 900)))
assertThrows(Exception::class.java) {
runBlocking { testBroker.refreshAccessToken(endpoint) }
}
assertEquals("fresh", testBroker.refreshAccessToken(endpoint))
assertEquals(1, server.requestCount)
}
@Test
fun corruptedCredentialBecomesLocalCredentialLoss() = runBlocking {
val storage = FakeStorage().apply { value = "not-json" }
assertNull(AuthSessionBroker(storage).validAccessToken(endpoint))
assertNull(storage.value)
}
@Test
fun corruptedDataStoreFileIsReplacedWithEmptyPreferences() = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val file = context.filesDir.resolve("datastore/auth-session-corruption-test.preferences_pb")
file.parentFile?.mkdirs()
file.writeBytes(byteArrayOf(0x7f, 0x01, 0x02, 0x03))
val preferences = withTimeout(5_000) {
context.corruptionTestDataStore.data.first()
}
assertTrue(preferences.asMap().isEmpty())
}
@Test
fun transientRefreshFailurePreservesStoredCredential() = runBlocking {
val storage = FakeStorage()
val testBroker = AuthSessionBroker(storage)
testBroker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
Thread.sleep(1_100)
repeat(3) { server.enqueue(MockResponse().setResponseCode(503)) }
assertThrows(Exception::class.java) {
runBlocking { testBroker.refreshAccessToken(endpoint) }
}
assertTrue(storage.value?.contains("expired") == true)
}
@Test
fun clearWinsAgainstInflightRefresh() = runBlocking {
broker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
Thread.sleep(1_100)
server.enqueue(
MockResponse()
.setBody(tokenResponse("stale", refreshToken('b'), 900))
.setBodyDelay(500, TimeUnit.MILLISECONDS),
)
val refresh = async { broker.refreshAccessToken(endpoint) }
assertNotNull(withContext(Dispatchers.IO) { server.takeRequest(5, TimeUnit.SECONDS) })
broker.clear(endpoint)
assertThrows(Exception::class.java) { runBlocking { refresh.await() } }
assertNull(broker.validAccessToken(endpoint))
}
@Test
fun newLoginWinsAgainstInflightRefresh() = runBlocking {
broker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
Thread.sleep(1_100)
server.enqueue(
MockResponse()
.setBody(tokenResponse("stale", refreshToken('b'), 900))
.setBodyDelay(500, TimeUnit.MILLISECONDS),
)
val refresh = async { broker.refreshAccessToken(endpoint) }
assertNotNull(withContext(Dispatchers.IO) { server.takeRequest(5, TimeUnit.SECONDS) })
broker.store(endpoint, tokenResponse("new-login", refreshToken('c'), 900))
assertThrows(Exception::class.java) { runBlocking { refresh.await() } }
assertEquals("new-login", broker.validAccessToken(endpoint))
}
@Test
fun signOutWinsAgainstInflightRefresh() = runBlocking {
broker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
Thread.sleep(1_100)
server.enqueue(
MockResponse()
.setBody(tokenResponse("stale", refreshToken('b'), 900))
.setBodyDelay(500, TimeUnit.MILLISECONDS),
)
server.enqueue(MockResponse().setBody("{}"))
val refresh = async { broker.refreshAccessToken(endpoint) }
assertNotNull(withContext(Dispatchers.IO) { server.takeRequest(5, TimeUnit.SECONDS) })
broker.signOut(endpoint)
assertThrows(Exception::class.java) { runBlocking { refresh.await() } }
assertNull(broker.validAccessToken(endpoint))
}
@Test
fun invalidatedEndpointKeyIsRecreatedWithoutPlaintextFallback() {
val cipher = TokenCipher()
val value = cipher.encrypt(endpoint, "secret")
assertEquals("secret", cipher.decrypt(endpoint, value))
cipher.reset(endpoint)
assertNull(cipher.decrypt(endpoint, value))
assertEquals("replacement", cipher.decrypt(endpoint, cipher.encrypt(endpoint, "replacement")))
}
private fun tokenResponse(access: String, refresh: String, expiresIn: Int) =
JSONObject()
.put("tokenType", "Bearer")
.put("accessToken", access)
.put("expiresIn", expiresIn)
.put("refreshToken", refresh)
.put("refreshExpiresAt", isoAfter(86_400))
.put(
"session",
JSONObject()
.put("id", UUID.randomUUID().toString())
.put("absoluteExpiresAt", isoAfter(172_800)),
)
.toString()
private fun refreshToken(seed: Char) =
"aff_rt_v1.${seed.toString().repeat(24)}.${seed.toString().repeat(43)}"
private fun isoAfter(seconds: Long) =
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(System.currentTimeMillis() + seconds * 1000))
private class FakeStorage : AuthCredentialStorage {
var value: String? = null
var failWrites = 0
override suspend fun read(endpoint: String) = value
override suspend fun write(endpoint: String, value: String) {
if (failWrites > 0) {
failWrites--
throw IllegalStateException("storage unavailable")
}
this.value = value
}
override suspend fun delete(endpoint: String) {
value = null
}
}
}

View File

@@ -21,6 +21,6 @@ public class ExampleInstrumentedTest {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
assertEquals("app.affine.pro", appContext.getPackageName());
}
}