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
@@ -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
}
}
}
@@ -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());
}
}
@@ -1,13 +1,12 @@
package app.affine.pro.plugin
import android.annotation.SuppressLint
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import app.affine.pro.AFFiNEApp
import app.affine.pro.service.AuthHttp
import app.affine.pro.service.CookieStore
import app.affine.pro.utils.dataStore
import app.affine.pro.utils.authDataStore
import app.affine.pro.utils.del
import app.affine.pro.utils.get
import app.affine.pro.utils.set
@@ -16,352 +15,465 @@ import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.coroutines.executeAsync
import org.json.JSONObject
import timber.log.Timber
import java.security.KeyStore
import java.security.MessageDigest
import java.text.ParsePosition
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import kotlin.math.pow
import kotlin.random.Random
@OptIn(ExperimentalCoroutinesApi::class)
@CapacitorPlugin(name = "Auth")
class AuthPlugin : Plugin() {
private fun canonicalEndpoint(endpoint: String): String = try {
val url = endpoint.toHttpUrl()
val port = if (url.port == HttpUrl.defaultPort(url.scheme)) "" else ":${url.port}"
"${url.scheme}://${url.host}$port"
} catch (_: Exception) {
endpoint
private data class TokenPair(
val accessToken: String,
val accessExpiresAt: Long,
val refreshToken: String,
val json: String,
)
private class AuthServerException(val code: String?, val status: Int) : Exception(code)
private val permanentAuthErrors = setOf(
"ACCESS_TOKEN_INVALID", "AUTH_SESSION_EXPIRED", "AUTH_SESSION_REVOKED",
"REFRESH_TOKEN_INVALID", "REFRESH_TOKEN_REUSED", "UNSUPPORTED_CLIENT_VERSION",
)
private val publicAuthErrors = permanentAuthErrors + setOf(
"ACCESS_TOKEN_EXPIRED", "AUTH_SESSION_TEMPORARILY_UNAVAILABLE", "TOO_MANY_REQUESTS",
)
private val publicInternalAuthErrors = setOf("AUTH_SESSION_EMPTY")
internal class AuthSessionBroker(
private val storage: AuthCredentialStorage = AndroidAuthCredentialStorage(),
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val locks = ConcurrentHashMap<String, Mutex>()
private val refreshes = ConcurrentHashMap<String, Deferred<TokenPair>>()
private val epochs = ConcurrentHashMap<String, Long>()
private val pending = ConcurrentHashMap<String, TokenPair>()
suspend fun store(endpoint: String, response: String) = lock(endpoint).withLock {
invalidate(endpoint)
write(endpoint, parsePair(response))
}
private fun tokenKey(endpoint: String) = "auth-token:${canonicalEndpoint(endpoint)}"
private fun legacyTokenKey(endpoint: String) = "auth-token:$endpoint"
private val tokenCipher = TokenCipher()
@PluginMethod
fun readEndpointToken(call: PluginCall) {
launch(Dispatchers.IO) {
suspend fun validAccessToken(endpoint: String): String? {
repeat(2) {
val current = lock(endpoint).withLock { read(endpoint) } ?: return null
if (current.accessExpiresAt - System.currentTimeMillis() > 120_000) {
return current.accessToken
}
try {
val endpoint = call.getStringEnsure("endpoint")
val key = tokenKey(endpoint)
val legacyKey = legacyTokenKey(endpoint)
val store = AFFiNEApp.context().dataStore
val storedKey = key.takeIf { store.get(it).isNotEmpty() }
?: legacyKey.takeIf { it != key && store.get(it).isNotEmpty() }
val storedToken = storedKey?.let { store.get(it) }?.takeIf { it.isNotEmpty() }
val token = storedToken?.let {
tokenCipher.decrypt(it) ?: tokenCipher.legacyPlaintext(it)
}
if (
storedToken != null &&
token != null &&
(storedKey != key || !tokenCipher.isEncrypted(storedToken))
) {
store.set(key, tokenCipher.encrypt(token))
storedKey?.let {
if (it != key) {
store.del(it)
return refresh(endpoint).accessToken
} catch (error: Exception) {
if (error is CancellationException) currentCoroutineContext().ensureActive()
val mutationWon = error is CancellationException ||
error is IllegalStateException && error.message == "AUTH_SESSION_EMPTY"
if (!mutationWon) throw error
}
}
return lock(endpoint).withLock { read(endpoint) }?.accessToken
}
suspend fun refreshAccessToken(endpoint: String) = refresh(endpoint).accessToken
suspend fun clear(endpoint: String) = lock(endpoint).withLock {
invalidate(endpoint)
storage.delete(endpoint)
}
suspend fun signOut(endpoint: String) {
val pair = lock(endpoint).withLock {
val current = read(endpoint)
invalidate(endpoint)
storage.delete(endpoint)
current
} ?: return
request(endpoint, "/api/auth/session/revoke", JSONObject().put("refreshToken", pair.refreshToken))
}
private suspend fun refresh(endpoint: String): TokenPair {
val canonical = canonical(endpoint)
refreshes[canonical]?.let { return it.await() }
return lock(endpoint).withLock {
refreshes[canonical]?.let { return@withLock it }
val epoch = epochs[canonical] ?: 0
val pendingPair = pending[canonical]
val current = if (pendingPair == null) {
read(endpoint) ?: throw IllegalStateException("AUTH_SESSION_EMPTY")
} else {
null
}
scope.async {
try {
if (pendingPair != null) {
lock(endpoint).withLock {
if ((epochs[canonical] ?: 0) != epoch) throw CancellationException()
write(endpoint, pendingPair)
if ((epochs[canonical] ?: 0) != epoch) throw CancellationException()
pending.remove(canonical, pendingPair)
}
return@async pendingPair
}
val response = request(
endpoint,
"/api/auth/session/refresh",
JSONObject().put("refreshToken", current!!.refreshToken),
)
val pair = parsePair(response)
lock(endpoint).withLock {
if ((epochs[canonical] ?: 0) != epoch) throw CancellationException()
pending[canonical] = pair
write(endpoint, pair)
if ((epochs[canonical] ?: 0) != epoch) throw CancellationException()
pending.remove(canonical, pair)
}
pair
} catch (error: AuthServerException) {
if (error.code in permanentAuthErrors) {
lock(endpoint).withLock {
if ((epochs[canonical] ?: 0) == epoch) {
storage.delete(endpoint)
}
}
}
throw error
}
call.resolve(JSObject().put("token", token))
} catch (e: Exception) {
call.reject("Failed to read endpoint token.", null, e)
}.also { task ->
refreshes[canonical] = task
task.invokeOnCompletion { refreshes.remove(canonical, task) }
}
}
}.await()
}
@PluginMethod
fun writeEndpointToken(call: PluginCall) {
launch(Dispatchers.IO) {
private suspend fun request(endpoint: String, path: String, body: JSONObject): String {
var last: Exception? = null
repeat(3) { attempt ->
try {
val endpoint = call.getStringEnsure("endpoint")
val token = call.getStringEnsure("token")
AFFiNEApp.context().dataStore.set(
tokenKey(endpoint),
tokenCipher.encrypt(token)
)
call.resolve(JSObject().put("ok", true))
} catch (e: Exception) {
call.reject("Failed to write endpoint token.", null, e)
}
}
}
@PluginMethod
fun deleteEndpointToken(call: PluginCall) {
launch(Dispatchers.IO) {
try {
val endpoint = call.getStringEnsure("endpoint")
AFFiNEApp.context().dataStore.del(tokenKey(endpoint))
AFFiNEApp.context().dataStore.del(legacyTokenKey(endpoint))
call.resolve(JSObject().put("ok", true))
} catch (e: Exception) {
call.reject("Failed to delete endpoint token.", null, e)
}
}
}
@PluginMethod
fun signInMagicLink(call: PluginCall) {
processSignIn(call, SignInMethod.MagicLink)
}
@PluginMethod
fun signInOauth(call: PluginCall) {
processSignIn(call, SignInMethod.Oauth)
}
@PluginMethod
fun signInOpenApp(call: PluginCall) {
processSignIn(call, SignInMethod.OpenApp)
}
@SuppressLint("BuildListAdds")
@PluginMethod
fun signInPassword(call: PluginCall) {
processSignIn(call, SignInMethod.Password)
}
@PluginMethod
fun signOut(call: PluginCall) {
launch(Dispatchers.IO) {
try {
val endpoint = call.getStringEnsure("endpoint")
val token = call.getString("token")
val request = Request.Builder()
.url("$endpoint/api/auth/sign-out")
.post("".toRequestBody("application/json".toMediaTypeOrNull()))
.apply {
if (token != null) {
addHeader("Authorization", "Bearer $token")
}
}
.url("${canonical(endpoint)}$path")
.addHeader("x-affine-client-kind", "native")
.post(body.toString().toRequestBody(jsonMediaType))
.build()
AuthHttp.client.newCall(request).executeAsync().use { response ->
if (response.code >= 400) {
call.reject(response.body.string())
return@launch
}
CookieStore.clearAuthCookies(endpoint.toHttpUrl().host)
Timber.i("Sign out success.")
call.resolve(JSObject().put("ok", true))
val text = response.body.string()
if (response.code < 400) return text
val code = runCatching { JSONObject(text).optString("code").ifEmpty { null } }.getOrNull()
val error = AuthServerException(code, response.code)
if (response.code < 500 || attempt == 2) throw error
last = error
}
} catch (e: Exception) {
Timber.w(e, "Sign out fail.")
call.reject("Failed to sign out, $e", null, e)
} catch (error: AuthServerException) {
if (error.status < 500 || attempt == 2) throw error
last = error
} catch (error: Exception) {
if (attempt == 2) throw error
last = error
}
delay((200.0 * 2.0.pow(attempt) + Random.nextInt(151)).toLong())
}
throw last ?: IllegalStateException("AUTH_SESSION_TEMPORARILY_UNAVAILABLE")
}
private fun parsePair(text: String): TokenPair {
val value = JSONObject(text)
val tokenType = value.optString("tokenType")
val accessToken = value.optString("accessToken")
val refreshToken = value.optString("refreshToken")
val expiresIn = value.optLong("expiresIn")
require(isIso8601(value.getString("refreshExpiresAt")))
require(isIso8601(value.getJSONObject("session").getString("absoluteExpiresAt")))
require(tokenType == "Bearer" && accessToken.isNotEmpty() && refreshToken.isNotEmpty())
require(expiresIn in 1..86_400)
val storedValue = JSONObject(value.toString())
.put("version", 1)
.put("accessExpiresAt", System.currentTimeMillis() + expiresIn * 1000)
storedValue.remove("expiresIn")
val stored = storedValue.toString()
return TokenPair(accessToken, System.currentTimeMillis() + expiresIn * 1000, refreshToken, stored)
}
private suspend fun read(endpoint: String): TokenPair? {
val plaintext = storage.read(endpoint) ?: return null
return try {
val value = JSONObject(plaintext)
require(value.getInt("version") == 1 && value.getString("tokenType") == "Bearer")
require(isIso8601(value.getString("refreshExpiresAt")))
require(isIso8601(value.getJSONObject("session").getString("absoluteExpiresAt")))
TokenPair(
value.getString("accessToken").also { require(it.isNotEmpty()) },
value.getLong("accessExpiresAt"),
value.getString("refreshToken").also { require(it.isNotEmpty()) },
plaintext,
)
} catch (_: Exception) {
loseCredential(endpoint)
}
}
private enum class SignInMethod {
Password, Oauth, MagicLink, OpenApp
private suspend fun loseCredential(endpoint: String): TokenPair? {
storage.delete(endpoint)
return null
}
private fun processSignIn(call: PluginCall, method: SignInMethod) {
launch(Dispatchers.IO) {
try {
val endpoint = call.getStringEnsure("endpoint")
val request = when (method) {
SignInMethod.Password -> {
val email = call.getStringEnsure("email")
val password = call.getStringEnsure("password")
val verifyToken = call.getString("verifyToken")
val challenge = call.getString("challenge")
val body = JSONObject()
.apply {
put("email", email)
put("password", password)
}
.toString()
.toRequestBody("application/json".toMediaTypeOrNull())
private suspend fun write(endpoint: String, pair: TokenPair) {
storage.write(endpoint, pair.json)
}
val requestBuilder = Request.Builder()
.url("$endpoint/api/auth/sign-in")
.addHeader("x-affine-client-kind", "native")
.post(body)
if (verifyToken != null) {
requestBuilder.addHeader("x-captcha-token", verifyToken)
}
if (challenge != null) {
requestBuilder.addHeader("x-captcha-challenge", challenge)
}
requestBuilder.build()
}
private fun invalidate(endpoint: String) {
val canonical = canonical(endpoint)
epochs.compute(canonical) { _, value -> (value ?: 0) + 1 }
refreshes.remove(canonical)?.cancel()
pending.remove(canonical)
}
SignInMethod.Oauth -> {
val code = call.getStringEnsure("code")
val state = call.getStringEnsure("state")
val clientNonce = call.getString("clientNonce")
val body = JSONObject()
.apply {
put("code", code)
put("state", state)
put("client_nonce", clientNonce)
}
.toString()
.toRequestBody("application/json".toMediaTypeOrNull())
Request.Builder()
.url("$endpoint/api/oauth/callback")
.addHeader("x-affine-client-kind", "native")
.post(body)
.build()
}
SignInMethod.MagicLink -> {
val email = call.getStringEnsure("email")
val token = call.getStringEnsure("token")
val clientNonce = call.getString("clientNonce")
val body = JSONObject()
.apply {
put("email", email)
put("token", token)
put("client_nonce", clientNonce)
}
.toString()
.toRequestBody("application/json".toMediaTypeOrNull())
Request.Builder()
.url("$endpoint/api/auth/magic-link")
.addHeader("x-affine-client-kind", "native")
.post(body)
.build()
}
SignInMethod.OpenApp -> {
val code = call.getStringEnsure("code")
val body = JSONObject()
.apply { put("code", code) }
.toString()
.toRequestBody("application/json".toMediaTypeOrNull())
Request.Builder()
.url("$endpoint/api/auth/open-app/sign-in")
.addHeader("x-affine-client-kind", "native")
.post(body)
.build()
}
}
AuthHttp.client.newCall(request).executeAsync().use { response ->
if (response.code >= 400) {
call.reject(response.body.string())
return@launch
}
val exchangeCode = JSONObject(response.body.string()).optString("exchangeCode").takeIf { it.isNotEmpty() }
if (exchangeCode == null) {
Timber.w("$method sign in fail, exchange code not found.")
call.reject("$method sign in fail, exchange code not found")
return@launch
}
val token = exchangeSession(endpoint, exchangeCode)
token.takeIf { it.isNotEmpty() }?.let {
CookieStore.clearAuthCookies(endpoint.toHttpUrl().host)
Timber.i("$method sign in success.")
call.resolve(JSObject().put("token", it))
} ?: run {
Timber.w("$method sign in fail, token not found.")
call.reject("$method sign in fail, token not found")
}
}
} catch (e: Exception) {
Timber.w(e, "$method sign in fail.")
call.reject("$method sign in fail.", null, e)
}
private fun lock(endpoint: String) = locks.computeIfAbsent(canonical(endpoint)) { Mutex() }
companion object {
private val jsonMediaType = "application/json".toMediaType()
fun canonical(endpoint: String): String = try {
val url = endpoint.toHttpUrl()
val port = if (url.port == HttpUrl.defaultPort(url.scheme)) "" else ":${url.port}"
"${url.scheme}://${url.host}$port"
} catch (_: Exception) {
endpoint
}
}
private suspend fun exchangeSession(endpoint: String, code: String): String {
val body = JSONObject()
.apply { put("code", code) }
.toString()
.toRequestBody("application/json".toMediaTypeOrNull())
val request = Request.Builder()
.url("$endpoint/api/auth/native/exchange")
.addHeader("x-affine-client-kind", "native")
.post(body)
.build()
AuthHttp.client.newCall(request).executeAsync().use { response ->
if (response.code >= 400) {
throw Exception(response.body.string())
}
return JSONObject(response.body.string()).optString("token")
private fun isIso8601(value: String): Boolean = listOf(
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
"yyyy-MM-dd'T'HH:mm:ss'Z'",
).any { pattern ->
val position = ParsePosition(0)
val parsed = SimpleDateFormat(pattern, Locale.US).apply {
isLenient = false
timeZone = TimeZone.getTimeZone("UTC")
}.parse(value, position)
parsed != null && position.index == value.length
}
}
}
private class TokenCipher {
private val alias = "affine-native-auth-token"
private val transformation = "AES/GCM/NoPadding"
@CapacitorPlugin(name = "Auth")
class AuthPlugin : Plugin() {
private val broker = AuthSessionBroker()
fun encrypt(plaintext: String): String {
val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return listOf(
"v1",
Base64.encodeToString(cipher.iv, Base64.NO_WRAP),
Base64.encodeToString(ciphertext, Base64.NO_WRAP),
).joinToString(":")
@PluginMethod fun getValidAccessToken(call: PluginCall) = bridge(call) {
JSObject().put("token", broker.validAccessToken(call.getStringEnsure("endpoint")))
}
fun decrypt(encoded: String): String? {
val parts = encoded.split(":")
if (parts.size != 3 || parts[0] != "v1") {
return null
}
@PluginMethod fun refreshAccessToken(call: PluginCall) = bridge(call) {
JSObject().put("token", broker.refreshAccessToken(call.getStringEnsure("endpoint")))
}
return try {
val iv = Base64.decode(parts[1], Base64.NO_WRAP)
val ciphertext = Base64.decode(parts[2], Base64.NO_WRAP)
val cipher = Cipher.getInstance(transformation)
cipher.init(
Cipher.DECRYPT_MODE,
secretKey(),
GCMParameterSpec(128, iv)
)
String(cipher.doFinal(ciphertext), Charsets.UTF_8)
} catch (e: Exception) {
Timber.w(e, "Failed to decrypt auth token.")
@PluginMethod fun clearEndpointSession(call: PluginCall) = bridge(call) {
broker.clear(call.getStringEnsure("endpoint")); JSObject().put("ok", true)
}
@PluginMethod fun signOut(call: PluginCall) = bridge(call) {
val endpoint = call.getStringEnsure("endpoint")
broker.signOut(endpoint)
CookieStore.clearAuthCookies(endpoint.toHttpUrl().host)
JSObject().put("ok", true)
}
@PluginMethod fun signInMagicLink(call: PluginCall) = signIn(call, "magic")
@PluginMethod fun signInOauth(call: PluginCall) = signIn(call, "oauth")
@PluginMethod fun signInOpenApp(call: PluginCall) = signIn(call, "open-app")
@PluginMethod fun signInPassword(call: PluginCall) = signIn(call, "password")
private fun signIn(call: PluginCall, method: String) = bridge(call) {
val endpoint = call.getStringEnsure("endpoint")
val (path, body) = when (method) {
"password" -> "/api/auth/sign-in" to JSONObject()
.put("email", call.getStringEnsure("email"))
.put("password", call.getStringEnsure("password"))
"oauth" -> "/api/oauth/callback" to JSONObject()
.put("code", call.getStringEnsure("code"))
.put("state", call.getStringEnsure("state"))
.put("client_nonce", call.getString("clientNonce"))
"magic" -> "/api/auth/magic-link" to JSONObject()
.put("email", call.getStringEnsure("email"))
.put("token", call.getStringEnsure("token"))
.put("client_nonce", call.getString("clientNonce"))
else -> "/api/auth/open-app/sign-in" to JSONObject().put("code", call.getStringEnsure("code"))
}
val request = Request.Builder()
.url("${AuthSessionBroker.canonical(endpoint)}$path")
.addHeader("x-affine-client-kind", "native")
.post(body.toString().toRequestBody("application/json".toMediaType()))
.apply {
call.getString("verifyToken")?.let { addHeader("x-captcha-token", it) }
call.getString("challenge")?.let { addHeader("x-captcha-challenge", it) }
call.getString("verifyToken")?.let {
addHeader(
"x-captcha-provider",
if (call.getString("challenge") == null) "turnstile" else "hashcash"
)
}
}
.build()
val exchangeCode = AuthHttp.client.newCall(request).executeAsync().use { response ->
val text = response.body.string()
if (response.code >= 400) throw IllegalStateException(text)
JSONObject(text).getString("exchangeCode")
}
val exchangeBody = JSONObject()
.put("code", exchangeCode)
.put("installationId", installationId())
.put("platform", "android")
.put("deviceName", android.os.Build.MODEL)
val exchangeRequest = Request.Builder()
.url("${AuthSessionBroker.canonical(endpoint)}/api/auth/session/exchange")
.addHeader("x-affine-client-kind", "native")
.post(exchangeBody.toString().toRequestBody("application/json".toMediaType()))
.build()
val tokenResponse = AuthHttp.client.newCall(exchangeRequest).executeAsync().use { response ->
val text = response.body.string()
if (response.code >= 400) throw IllegalStateException(text)
text
}
broker.store(endpoint, tokenResponse)
CookieStore.clearAuthCookies(endpoint.toHttpUrl().host)
JSObject().put("ok", true)
}
private suspend fun installationId(): String {
val key = "auth-installation-id"
val store = AFFiNEApp.context().authDataStore
return store.get(key).takeIf { it.isNotEmpty() } ?: UUID.randomUUID().toString().also {
store.set(key, it)
}
}
private fun bridge(call: PluginCall, block: suspend () -> JSObject) {
launch(Dispatchers.IO) {
try { call.resolve(block()) } catch (error: Exception) {
Timber.w(error, "Auth operation failed")
val code = when (error) {
is AuthServerException -> when {
error.code in publicAuthErrors -> error.code!!
error.status == 429 -> "TOO_MANY_REQUESTS"
else -> "AUTH_SESSION_TEMPORARILY_UNAVAILABLE"
}
is CancellationException -> "AUTH_OPERATION_CANCELLED"
is IllegalStateException -> error.message
?.takeIf { it in publicInternalAuthErrors }
?: "AUTH_SESSION_TEMPORARILY_UNAVAILABLE"
else -> "AUTH_SESSION_TEMPORARILY_UNAVAILABLE"
}
call.reject("Auth operation failed", code, error)
}
}
}
}
internal interface AuthCredentialStorage {
suspend fun read(endpoint: String): String?
suspend fun write(endpoint: String, value: String)
suspend fun delete(endpoint: String)
}
private class AndroidAuthCredentialStorage : AuthCredentialStorage {
private val cipher = TokenCipher()
override suspend fun read(endpoint: String): String? {
val canonical = AuthSessionBroker.canonical(endpoint)
val encoded = AFFiNEApp.context().authDataStore.get(key(canonical)).takeIf { it.isNotEmpty() }
?: return null
return cipher.decrypt(canonical, encoded) ?: run {
cipher.reset(canonical)
delete(canonical)
null
}
}
fun isEncrypted(value: String) = value.startsWith("v1:")
fun legacyPlaintext(value: String) =
value.takeIf { !isEncrypted(it) && it.isNotBlank() }
private fun secretKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.let {
return it.secretKey
}
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore"
)
val spec = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
.build()
keyGenerator.init(spec)
return keyGenerator.generateKey()
override suspend fun write(endpoint: String, value: String) {
val canonical = AuthSessionBroker.canonical(endpoint)
AFFiNEApp.context().authDataStore.set(key(canonical), cipher.encrypt(canonical, value))
}
override suspend fun delete(endpoint: String) {
AFFiNEApp.context().authDataStore.del(key(AuthSessionBroker.canonical(endpoint)))
}
private fun key(endpoint: String) = "auth-token:$endpoint"
}
internal class TokenCipher {
fun encrypt(endpoint: String, plaintext: String): String {
return runCatching { encryptWithAlias(alias(endpoint), plaintext) }.getOrElse {
reset(endpoint)
encryptWithAlias(alias(endpoint), plaintext)
}
}
private fun encryptWithAlias(alias: String, plaintext: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey(alias))
return listOf(
"v1", Base64.encodeToString(cipher.iv, Base64.NO_WRAP),
Base64.encodeToString(cipher.doFinal(plaintext.toByteArray()), Base64.NO_WRAP),
).joinToString(":")
}
fun decrypt(endpoint: String, encoded: String): String? = runCatching {
val parts = encoded.split(":")
require(parts.size == 3 && parts[0] == "v1")
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(
Cipher.DECRYPT_MODE, secretKey(alias(endpoint)),
GCMParameterSpec(128, Base64.decode(parts[1], Base64.NO_WRAP)),
)
String(cipher.doFinal(Base64.decode(parts[2], Base64.NO_WRAP)))
}.getOrNull()
fun reset(endpoint: String) {
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
if (store.containsAlias(alias(endpoint))) store.deleteEntry(alias(endpoint))
}
private fun alias(endpoint: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(endpoint.toByteArray())
return "affine-auth-session-${Base64.encodeToString(digest, Base64.NO_WRAP or Base64.URL_SAFE).take(24)}"
}
private fun secretKey(alias: String): SecretKey {
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(store.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
generator.init(
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
.build(),
)
return generator.generateKey()
}
companion object { private const val TRANSFORMATION = "AES/GCM/NoPadding" }
}
@@ -4,13 +4,23 @@ import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "affine")
internal val authDataStoreCorruptionHandler =
ReplaceFileCorruptionHandler<Preferences> { emptyPreferences() }
val Context.authDataStore: DataStore<Preferences> by preferencesDataStore(
name = "auth-sessions",
corruptionHandler = authDataStoreCorruptionHandler,
)
suspend fun DataStore<Preferences>.set(key: String, value: String) {
edit {
it[stringPreferencesKey(key)] = value