mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-25 06:18:45 +08:00
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:
+237
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+411
-299
@@ -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" }
|
||||
}
|
||||
|
||||
+10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user