feat(server): passkey pre-refactor (#15060)

#### PR Dependency Tree


* **PR #15060** 👈

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**
* OpenApp native sign-in and native session exchange (JWT) for mobile &
desktop.
  * Centralized short-lived auth challenge store for one-time tokens.
* Encrypted per-endpoint token storage and native token handlers
(Android, iOS, Electron).

* **Improvements**
* Richer auth-method reporting (password, magic link, OAuth, passkey)
and improved sign-in flows.
* Hardened magic-link, OAuth, and session issuance; JWT-backed sessions
and websocket JWT support.
* UX tweaks: form-based password submit, OTP autocomplete, adjusted
captcha flow.

* **Bug Fixes**
  * Expanded tests and auth-state resets to avoid cross-test leakage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-06-01 17:11:15 +08:00
committed by GitHub
parent 5b9d51b41b
commit ce9841df9d
74 changed files with 3719 additions and 939 deletions
@@ -1,9 +1,6 @@
package app.affine.pro
import android.webkit.WebView
import app.affine.pro.service.CookieStore
import app.affine.pro.utils.dataStore
import app.affine.pro.utils.get
import app.affine.pro.utils.getCurrentServerBaseUrl
import app.affine.pro.utils.logger.FileTree
import com.getcapacitor.Bridge
@@ -11,7 +8,6 @@ import com.getcapacitor.WebViewListener
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import okhttp3.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl
import timber.log.Timber
@@ -23,30 +19,11 @@ object AuthInitializer {
bridge.removeWebViewListener(this)
MainScope().launch(Dispatchers.IO) {
try {
val server = bridge.getCurrentServerBaseUrl().toHttpUrl()
val sessionCookieStr = AFFiNEApp.context().dataStore
.get(server.host + CookieStore.AFFINE_SESSION)
val userIdCookieStr = AFFiNEApp.context().dataStore
.get(server.host + CookieStore.AFFINE_USER_ID)
val csrfCookieStr = AFFiNEApp.context().dataStore
.get(server.host + CookieStore.AFFINE_CSRF_TOKEN)
if (sessionCookieStr.isEmpty() || userIdCookieStr.isEmpty() || csrfCookieStr.isEmpty()) {
Timber.i("[init] user has not signed in yet.")
return@launch
}
Timber.i("[init] user already signed in.")
val cookies = listOf(
Cookie.parse(server, sessionCookieStr)
?: error("Parse session cookie fail:[ cookie = $sessionCookieStr ]"),
Cookie.parse(server, userIdCookieStr)
?: error("Parse user id cookie fail:[ cookie = $userIdCookieStr ]"),
Cookie.parse(server, csrfCookieStr)
?: error("Parse csrf token cookie fail:[ cookie = $csrfCookieStr ]"),
FileTree.get()?.checkAndUploadOldLogs(
bridge.getCurrentServerBaseUrl().toHttpUrl()
)
CookieStore.saveCookies(server.host, cookies)
FileTree.get()?.checkAndUploadOldLogs(server)
} catch (e: Exception) {
Timber.w(e, "[init] load persistent cookies fail.")
Timber.w(e, "[init] auth initializer fail.")
}
}
}
@@ -1,8 +1,16 @@
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.service.OkHttp
import app.affine.pro.utils.dataStore
import app.affine.pro.utils.del
import app.affine.pro.utils.get
import app.affine.pro.utils.set
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
@@ -10,6 +18,7 @@ import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Request
@@ -17,10 +26,90 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.coroutines.executeAsync
import org.json.JSONObject
import timber.log.Timber
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
@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 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) {
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)
}
}
}
call.resolve(JSObject().put("token", token))
} catch (e: Exception) {
call.reject("Failed to read endpoint token.", null, e)
}
}
}
@PluginMethod
fun writeEndpointToken(call: PluginCall) {
launch(Dispatchers.IO) {
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) {
@@ -32,6 +121,11 @@ class AuthPlugin : Plugin() {
processSignIn(call, SignInMethod.Oauth)
}
@PluginMethod
fun signInOpenApp(call: PluginCall) {
processSignIn(call, SignInMethod.OpenApp)
}
@SuppressLint("BuildListAdds")
@PluginMethod
fun signInPassword(call: PluginCall) {
@@ -43,21 +137,22 @@ class AuthPlugin : Plugin() {
launch(Dispatchers.IO) {
try {
val endpoint = call.getStringEnsure("endpoint")
val csrfToken = CookieStore.getCookie(endpoint.toHttpUrl(), CookieStore.AFFINE_CSRF_TOKEN)
val token = call.getString("token")
val request = Request.Builder()
.url("$endpoint/api/auth/sign-out")
.post("".toRequestBody("application/json".toMediaTypeOrNull()))
.apply {
if (csrfToken != null) {
addHeader("x-affine-csrf-token", csrfToken)
if (token != null) {
addHeader("Authorization", "Bearer $token")
}
}
.build()
OkHttp.client.newCall(request).executeAsync().use { response ->
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))
}
@@ -69,7 +164,7 @@ class AuthPlugin : Plugin() {
}
private enum class SignInMethod {
Password, Oauth, MagicLink
Password, Oauth, MagicLink, OpenApp
}
private fun processSignIn(call: PluginCall, method: SignInMethod) {
@@ -92,6 +187,7 @@ class AuthPlugin : Plugin() {
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)
@@ -117,6 +213,7 @@ class AuthPlugin : Plugin() {
Request.Builder()
.url("$endpoint/api/oauth/callback")
.addHeader("x-affine-client-kind", "native")
.post(body)
.build()
}
@@ -136,19 +233,41 @@ class AuthPlugin : Plugin() {
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()
}
}
OkHttp.client.newCall(request).executeAsync().use { response ->
AuthHttp.client.newCall(request).executeAsync().use { response ->
if (response.code >= 400) {
call.reject(response.body.string())
return@launch
}
CookieStore.getCookie(endpoint.toHttpUrl(), CookieStore.AFFINE_SESSION)?.let {
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.")
Timber.d("Update session [$it]")
call.resolve(JSObject().put("token", it))
} ?: run {
Timber.w("$method sign in fail, token not found.")
@@ -161,4 +280,88 @@ class AuthPlugin : Plugin() {
}
}
}
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 class TokenCipher {
private val alias = "affine-native-auth-token"
private val transformation = "AES/GCM/NoPadding"
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(":")
}
fun decrypt(encoded: String): String? {
val parts = encoded.split(":")
if (parts.size != 3 || parts[0] != "v1") {
return null
}
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.")
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()
}
}
@@ -3,6 +3,7 @@ package app.affine.pro.service
import app.affine.pro.AFFiNEApp
import app.affine.pro.CapacitorConfig
import app.affine.pro.utils.dataStore
import app.affine.pro.utils.del
import app.affine.pro.utils.set
import com.google.firebase.crashlytics.ktx.crashlytics
import com.google.firebase.ktx.Firebase
@@ -50,6 +51,20 @@ object OkHttp {
}
object AuthHttp {
val client = OkHttpClient.Builder()
.cookieJar(CookieJar.NO_COOKIES)
.addInterceptor {
it.proceed(
it.request()
.newBuilder()
.addHeader("x-affine-version", CapacitorConfig.getAffineVersion())
.build()
)
}
.build()
}
object CookieStore {
const val AFFINE_SESSION = "affine_session"
@@ -61,9 +76,6 @@ object CookieStore {
fun saveCookies(host: String, cookies: List<Cookie>) {
_cookies[host] = cookies
MainScope().launch(Dispatchers.IO) {
cookies.find { it.name == AFFINE_SESSION }?.let {
AFFiNEApp.context().dataStore.set(host + AFFINE_SESSION, it.toString())
}
cookies.find { it.name == AFFINE_USER_ID }?.let {
Timber.d("Update user id [${it.value}]")
AFFiNEApp.context().dataStore.set(host + AFFINE_USER_ID, it.toString())
@@ -77,6 +89,18 @@ object CookieStore {
fun getCookies(host: String) = _cookies[host] ?: emptyList()
fun clearAuthCookies(host: String) {
val cookies = _cookies[host] ?: emptyList()
_cookies[host] = cookies.filter {
it.name != AFFINE_SESSION && it.name != AFFINE_USER_ID && it.name != AFFINE_CSRF_TOKEN
}
MainScope().launch(Dispatchers.IO) {
AFFiNEApp.context().dataStore.del(host + AFFINE_USER_ID)
AFFiNEApp.context().dataStore.del(host + AFFINE_CSRF_TOKEN)
Firebase.crashlytics.setUserId("")
}
}
fun getCookie(url: HttpUrl, name: String) = url.host
.let { _cookies[it] }
?.find { cookie -> cookie.name == name }
@@ -17,6 +17,12 @@ suspend fun DataStore<Preferences>.set(key: String, value: String) {
}
}
suspend fun DataStore<Preferences>.del(key: String) {
edit {
it.remove(stringPreferencesKey(key))
}
}
suspend fun DataStore<Preferences>.get(key: String) = data.map {
it[stringPreferencesKey(key)] ?: ""
}.first()
}.first()