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
-3
View File
@@ -38,7 +38,6 @@ jobs:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
BUILD_TYPE: ${{ inputs.build-type }}
CAPTCHA_SITE_KEY: ${{ secrets.CAPTCHA_SITE_KEY }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: 'affine-web'
SENTRY_RELEASE: ${{ inputs.app-version }}
@@ -71,7 +70,6 @@ jobs:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
BUILD_TYPE: ${{ inputs.build-type }}
CAPTCHA_SITE_KEY: ${{ secrets.CAPTCHA_SITE_KEY }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: 'affine-admin'
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
@@ -103,7 +101,6 @@ jobs:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
BUILD_TYPE: ${{ inputs.build-type }}
CAPTCHA_SITE_KEY: ${{ secrets.CAPTCHA_SITE_KEY }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: 'affine-mobile'
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+109
View File
@@ -239,6 +239,95 @@ jobs:
working-directory: packages/frontend/apps/android/App
run: ./gradlew :app:assembleCanaryDebug --no-daemon --stacktrace
test-android-app:
name: Test Android app
if: ${{ needs.mobile-native-build-filter.outputs.run-android == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 40
needs:
- mobile-native-build-filter
env:
AFFINE_ANDROID_NATIVE_TARGET: x86_64
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine-tools/cli @affine/android
electron-install: false
- uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
cache: 'gradle'
- name: Setup Rust
uses: ./.github/actions/build-rust
with:
target: 'x86_64-linux-android'
package: 'affine_mobile_native'
no-build: 'true'
- name: Build Android web assets
run: yarn affine @affine/android build
env:
PUBLIC_PATH: '/'
- name: Write CI Firebase config
run: |
cat > packages/frontend/apps/android/App/app/google-services.json <<'JSON'
{
"project_info": {
"project_number": "1",
"project_id": "affine-ci",
"storage_bucket": "affine-ci.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:1:android:0000000000000000",
"android_client_info": {
"package_name": "app.affine.pro"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "ci-placeholder"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
JSON
- name: Cap sync
run: yarn workspace @affine/android cap sync
- name: Run Android unit tests
working-directory: packages/frontend/apps/android/App
run: ./gradlew :app:testCanaryDebugUnitTest --no-daemon --stacktrace
- name: Enable KVM
run: sudo chmod 666 /dev/kvm
- name: Run Android instrumentation tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 35
arch: x86_64
profile: pixel_6
disable-animations: true
script: cd packages/frontend/apps/android/App && ./gradlew :app:connectedCanaryDebugAndroidTest --no-daemon --stacktrace
build-ios-app:
name: Build iOS app
if: ${{ needs.mobile-native-build-filter.outputs.run-ios == 'true' }}
@@ -288,6 +377,25 @@ jobs:
CODE_SIGNING_REQUIRED=NO \
build
- name: Run iOS unit tests
run: |
device_id="$(xcrun simctl list devices available -j | ruby -rjson -e '
devices = JSON.parse(STDIN.read).fetch("devices").values.flatten
device = devices.find { |candidate| candidate.fetch("name").start_with?("iPhone") }
abort "No available iPhone simulator" unless device
puts device.fetch("udid")
')"
xcrun simctl boot "$device_id" || true
xcodebuild \
-project packages/frontend/apps/ios/App/App.xcodeproj \
-scheme AFFiNETests \
-configuration Debug \
-sdk iphonesimulator \
-destination "platform=iOS Simulator,id=$device_id" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
test
rust-test-filter:
name: Rust test filter
runs-on: ubuntu-latest
@@ -1390,6 +1498,7 @@ jobs:
- typecheck
- mobile-native-build-filter
- build-android-app
- test-android-app
- build-ios-app
- lint-rust
- check-git-status
@@ -14,6 +14,9 @@ plugins {
alias libs.plugins.hilt
}
def nativeTarget = System.getenv('AFFINE_ANDROID_NATIVE_TARGET') ?: 'arm64'
def nativeAbi = nativeTarget == 'x86_64' ? 'x86_64' : 'arm64-v8a'
apply from: 'capacitor.build.gradle'
android {
@@ -34,7 +37,7 @@ android {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
ndk {
abiFilters 'arm64-v8a'
abiFilters nativeAbi
}
}
buildFeatures {
@@ -136,6 +139,8 @@ dependencies {
testImplementation libs.junit
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core
androidTestImplementation platform(libs.okhttp.bom)
androidTestImplementation "com.squareup.okhttp3:mockwebserver"
}
cargo {
@@ -144,7 +149,7 @@ cargo {
features {
defaultAnd("use-as-lib")
}
targets = ["arm64"]
targets = [nativeTarget]
pythonCommand = "python3"
targetDirectory = "../../../../../../target"
apiLevel = 28
@@ -188,7 +193,7 @@ android.applicationVariants.configureEach { variant ->
def t = tasks.register("generate${variantName}UniFFIBindings", Exec) {
workingDir "${project.projectDir}"
// Runs the bindings generation, note that you must have uniffi-bindgen installed and in your PATH environment variable
commandLine "${cargoPath}", 'run', '--bin', 'uniffi-bindgen', 'generate', '--library', "${buildDir}/rustJniLibs/android/arm64-v8a/libaffine_mobile_native.so", '--language', 'kotlin', '--out-dir', "${project.projectDir}/src/main/java"
commandLine "${cargoPath}", 'run', '--bin', 'uniffi-bindgen', 'generate', '--library', "${buildDir}/rustJniLibs/android/${nativeAbi}/libaffine_mobile_native.so", '--language', 'kotlin', '--out-dir', "${project.projectDir}/src/main/java"
dependsOn("cargoBuild")
}
variant.javaCompileProvider.get().dependsOn(t)
@@ -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
@@ -9,7 +9,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.10.0'
classpath 'com.android.tools.build:gradle:8.13.2'
}
}
@@ -21,8 +21,6 @@ android {
defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
versionCode 1
versionName "1.0"
}
lintOptions {
abortOnError false
@@ -56,4 +54,4 @@ apply from: "cordova.variables.gradle"
for (def func : cdvPluginPostBuildExtras) {
func()
}
}
@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21
@@ -1,5 +1,5 @@
[versions]
android-gradle-plugin = "8.10.0"
android-gradle-plugin = "8.13.2"
androidx-activity-compose = "1.10.1"
androidx-appcompat = "1.7.0"
androidx-browser = "1.8.0"
@@ -11,6 +11,9 @@ pluginManagement {
gradlePluginPortal()
}
}
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
}
include ':app'
include ':service'
+28 -19
View File
@@ -57,11 +57,7 @@ import { Auth } from './plugins/auth';
import { HashCash } from './plugins/hashcash';
import { NbStoreNativeDBApis } from './plugins/nbstore';
import { Preview } from './plugins/preview';
import {
deleteEndpointToken,
readEndpointToken,
writeEndpointToken,
} from './proxy';
import { clearEndpointSession, getValidAccessToken } from './proxy';
const storeManagerClient = createStoreManagerClient();
setTelemetryTransport(storeManagerClient.telemetry);
@@ -185,46 +181,44 @@ framework.scope(ServerScope).override(AuthProvider, resolver => {
const endpoint = serverService.server.baseUrl;
return {
async signInMagicLink(email, linkToken, clientNonce) {
const { token } = await Auth.signInMagicLink({
await Auth.signInMagicLink({
endpoint,
email,
token: linkToken,
clientNonce,
});
await writeEndpointToken(endpoint, token);
},
async signInOauth(code, state, _provider, clientNonce) {
const { token } = await Auth.signInOauth({
await Auth.signInOauth({
endpoint,
code,
state,
clientNonce,
});
await writeEndpointToken(endpoint, token);
return {};
},
async signInPassword(credential) {
const { token } = await Auth.signInPassword({
await Auth.signInPassword({
endpoint,
...credential,
});
await writeEndpointToken(endpoint, token);
},
async signInOpenAppSignInCode(code) {
const { token } = await Auth.signInOpenApp({
await Auth.signInOpenApp({
endpoint,
code,
});
await writeEndpointToken(endpoint, token);
},
async signOut() {
const token = await readEndpointToken(endpoint);
try {
await Auth.signOut({ endpoint, token });
await Auth.signOut({ endpoint });
} finally {
await deleteEndpointToken(endpoint);
await clearEndpointSession(endpoint);
}
},
async clearSession() {
await clearEndpointSession(endpoint);
},
};
});
@@ -310,6 +304,13 @@ window.addEventListener('focus', () => {
frameworkProvider.get(LifecycleService).applicationFocus();
});
frameworkProvider.get(LifecycleService).applicationStart();
CapacitorApp.addListener('appStateChange', ({ isActive }) => {
if (!isActive) return;
const servers = frameworkProvider.get(ServersService).servers$.value;
Promise.allSettled(
servers.map(server => getValidAccessToken(server.baseUrl))
).catch(console.error);
}).catch(console.error);
const getErrorMessage = (error: unknown, fallback: string) => {
if (typeof error === 'string' && error) {
@@ -462,13 +463,21 @@ function createStoreManagerClient() {
authTokenChannelServer.addEventListener('message', event => {
const { id, endpoint } = event.data as { id?: string; endpoint?: string };
if (!id || !endpoint) return;
readEndpointToken(endpoint)
getValidAccessToken(endpoint)
.then(token => authTokenChannelServer.postMessage({ id, token }))
.catch(() => authTokenChannelServer.postMessage({ id, token: null }));
.catch(error =>
authTokenChannelServer.postMessage({
id,
error:
typeof error === 'object' && error && 'code' in error
? error.code
: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE',
})
);
});
authTokenChannelServer.start();
worker.postMessage(
{ type: 'native-auth-token-channel', port: authTokenChannelClient },
{ type: 'auth-access-token-channel', port: authTokenChannelClient },
[authTokenChannelClient]
);
return new StoreManagerClient(new OpClient(worker));
@@ -19,21 +19,50 @@ import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
import { AsyncCall } from 'async-call-rpc';
let authTokenPort: MessagePort | undefined;
const pendingTokenRequests = new Map<string, (token: string | null) => void>();
const pendingTokenRequests = new Map<
string,
{
resolve: (token: string | null) => void;
reject: (error: Error) => void;
}
>();
configureSocketAuthMethod((endpoint, cb) => {
readEndpointToken(endpoint)
getValidAccessToken(endpoint)
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
.catch(() => cb({}));
.catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' }));
});
globalThis.addEventListener('message', e => {
if (e.data.type === 'native-auth-token-channel') {
if (e.data.type === 'auth-access-token-channel') {
authTokenPort = e.ports[0] as MessagePort;
authTokenPort.addEventListener('message', e => {
const { id, token } = e.data as { id?: string; token?: string | null };
const { id, token, error } = e.data as {
id?: string;
token?: string | null;
error?: string;
};
if (!id) return;
pendingTokenRequests.get(id)?.(token ?? null);
const pending = pendingTokenRequests.get(id);
if (error) {
if (
[
'ACCESS_TOKEN_INVALID',
'AUTH_SESSION_EXPIRED',
'AUTH_SESSION_REVOKED',
'REFRESH_TOKEN_INVALID',
'REFRESH_TOKEN_REUSED',
'UNSUPPORTED_CLIENT_VERSION',
'AUTH_SESSION_EMPTY',
].includes(error)
) {
pending?.resolve(null);
} else {
pending?.reject(new Error(error));
}
} else {
pending?.resolve(token ?? null);
}
pendingTokenRequests.delete(id);
});
authTokenPort.start();
@@ -66,20 +95,26 @@ globalThis.addEventListener('message', e => {
}
});
function readEndpointToken(endpoint: string) {
function getValidAccessToken(endpoint: string) {
if (!authTokenPort) {
return Promise.resolve(null);
}
const id = `${Date.now()}:${Math.random()}`;
return new Promise<string | null>(resolve => {
return new Promise<string | null>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingTokenRequests.delete(id);
resolve(null);
reject(new Error('AUTH_SESSION_TEMPORARILY_UNAVAILABLE'));
}, 5000);
pendingTokenRequests.set(id, token => {
clearTimeout(timeout);
resolve(token);
pendingTokenRequests.set(id, {
resolve: token => {
clearTimeout(timeout);
resolve(token);
},
reject: error => {
clearTimeout(timeout);
reject(error);
},
});
authTokenPort?.postMessage({ id, endpoint });
});
@@ -4,31 +4,25 @@ export interface AuthPlugin {
email: string;
token: string;
clientNonce?: string;
}): Promise<{ token: string }>;
}): Promise<void>;
signInOauth(options: {
endpoint: string;
code: string;
state: string;
clientNonce?: string;
}): Promise<{ token: string }>;
}): Promise<void>;
signInPassword(options: {
endpoint: string;
email: string;
password: string;
verifyToken?: string;
challenge?: string;
}): Promise<{ token: string }>;
signInOpenApp(options: {
endpoint: string;
code: string;
}): Promise<{ token: string }>;
signOut(options: { endpoint: string; token?: string | null }): Promise<void>;
readEndpointToken(options: {
}): Promise<void>;
signInOpenApp(options: { endpoint: string; code: string }): Promise<void>;
signOut(options: { endpoint: string }): Promise<void>;
getValidAccessToken(options: {
endpoint: string;
}): Promise<{ token?: string | null }>;
writeEndpointToken(options: {
endpoint: string;
token: string;
}): Promise<void>;
deleteEndpointToken(options: { endpoint: string }): Promise<void>;
refreshAccessToken(options: { endpoint: string }): Promise<{ token: string }>;
clearEndpointSession(options: { endpoint: string }): Promise<void>;
}
+133 -27
View File
@@ -1,3 +1,5 @@
import { canonicalAuthEndpoint } from '@affine/mobile-shared/auth/endpoint';
import { Auth } from './plugins/auth';
function authEndpointForUrl(url: string | URL) {
@@ -11,10 +13,6 @@ function authEndpointForUrl(url: string | URL) {
}
}
function canonicalEndpoint(endpoint: string) {
return authEndpointForUrl(endpoint) ?? endpoint;
}
/**
* the below code includes the custom fetch and xmlhttprequest implementation for ios webview.
* should be included in the entry file of the app or webworker.
@@ -22,22 +20,84 @@ function canonicalEndpoint(endpoint: string) {
const rawFetch = globalThis.fetch;
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
const retry = request.clone();
const origin = authEndpointForUrl(request.url);
const token = origin
? await readEndpointToken(origin).catch(() => null)
: null;
const token = origin ? await getValidAccessToken(origin) : null;
if (token) {
request.headers.set('Authorization', `Bearer ${token}`);
}
return rawFetch(request);
const response = await rawFetch(request);
if (response.status !== 401 || !origin) return response;
const body = await response
.clone()
.json()
.catch(() => null);
if (body?.code !== 'ACCESS_TOKEN_EXPIRED') return response;
const { token: refreshed } = await Auth.refreshAccessToken({
endpoint: origin,
});
retry.headers.set('Authorization', `Bearer ${refreshed}`);
return rawFetch(retry);
};
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
private request:
| {
method: string;
url: string | URL;
async: boolean;
username?: string | null;
password?: string | null;
}
| undefined;
private readonly headers = new Map<string, string>();
private requestBody?: Document | XMLHttpRequestBodyInit | null;
private replaying = false;
private hasReplayed = false;
constructor() {
super();
const suppressExpiredResponse = (event: Event) => {
if (this.replaying) event.stopImmediatePropagation();
};
this.addEventListener('load', suppressExpiredResponse, true);
this.addEventListener('loadend', suppressExpiredResponse, true);
this.addEventListener(
'readystatechange',
event => {
if (
this.readyState !== rawXMLHttpRequest.DONE ||
this.status !== 401 ||
this.replaying ||
this.hasReplayed ||
!this.request?.async
) {
return;
}
let code: unknown;
try {
code =
this.responseType === 'json'
? this.response?.code
: JSON.parse(this.responseText)?.code;
} catch {
return;
}
if (code !== 'ACCESS_TOKEN_EXPIRED') return;
event.stopImmediatePropagation();
this.replaying = true;
this.hasReplayed = true;
this.replayWithFreshToken().catch(() => {});
},
true
);
}
override open(
method: string,
url: string | URL,
@@ -45,6 +105,11 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
username?: string | null,
password?: string | null
): void {
this.request = { method, url, async, username, password };
this.headers.clear();
this.requestBody = undefined;
this.replaying = false;
this.hasReplayed = false;
xhrRequestUrls.set(this, url.toString());
return super.open(
method,
@@ -55,40 +120,81 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
);
}
override setRequestHeader(name: string, value: string): void {
this.headers.set(name, value);
super.setRequestHeader(name, value);
}
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
this.requestBody = body;
const requestUrl = xhrRequestUrls.get(this);
const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href);
(origin ? readEndpointToken(origin) : Promise.resolve(null)).then(
token => {
(origin ? getValidAccessToken(origin) : Promise.resolve(null))
.then(token => {
if (token) {
this.setRequestHeader('Authorization', `Bearer ${token}`);
super.setRequestHeader('Authorization', `Bearer ${token}`);
}
return super.send(body);
},
() => {
return super.send(body);
}
);
})
.catch(() => {
this.dispatchEvent(new Event('error'));
this.dispatchEvent(new Event('loadend'));
});
}
private async replayWithFreshToken() {
const request = this.request;
if (!request) return this.failReplay();
const origin = authEndpointForUrl(request.url);
if (!origin) return this.failReplay();
try {
const { token } = await Auth.refreshAccessToken({ endpoint: origin });
const responseType = this.responseType;
const timeout = this.timeout;
const withCredentials = this.withCredentials;
super.open(
request.method,
request.url,
true,
request.username ?? undefined,
request.password ?? undefined
);
this.replaying = false;
this.headers.forEach((value, name) => {
if (name.toLowerCase() !== 'authorization') {
super.setRequestHeader(name, value);
}
});
super.setRequestHeader('Authorization', `Bearer ${token}`);
this.responseType = responseType;
this.timeout = timeout;
this.withCredentials = withCredentials;
super.send(this.requestBody);
} catch {
this.failReplay();
}
}
private failReplay() {
this.replaying = false;
this.dispatchEvent(new Event('readystatechange'));
this.dispatchEvent(new Event('error'));
this.dispatchEvent(new Event('loadend'));
}
};
export async function readEndpointToken(
export async function getValidAccessToken(
endpoint: string
): Promise<string | null> {
const { token } = await Auth.readEndpointToken({
endpoint: canonicalEndpoint(endpoint),
const { token } = await Auth.getValidAccessToken({
endpoint: canonicalAuthEndpoint(endpoint),
});
return token ?? null;
}
export async function writeEndpointToken(endpoint: string, token: string) {
await Auth.writeEndpointToken({
endpoint: canonicalEndpoint(endpoint),
token,
export async function clearEndpointSession(endpoint: string) {
await Auth.clearEndpointSession({
endpoint: canonicalAuthEndpoint(endpoint),
});
}
export async function deleteEndpointToken(endpoint: string) {
await Auth.deleteEndpointToken({ endpoint: canonicalEndpoint(endpoint) });
}
@@ -122,6 +122,9 @@ export function setupModules() {
async signOut() {
await apis.handler.auth.signOut(endpoint);
},
async clearSession() {
await apis.handler.auth.clearSession(endpoint);
},
};
});
@@ -24,11 +24,11 @@ bindNativeDBV1Apis(apis!.db);
configureSocketAuthMethod((endpoint, cb) => {
// oxlint-disable-next-line no-non-null-assertion
apis!.auth
.readEndpointToken(endpoint)
.getValidAccessToken(endpoint)
.then(({ token }: { token?: string | null }) => {
cb(token ? { token, tokenType: 'jwt' } : {});
})
.catch(() => cb({}));
.catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' }));
});
const storeManager = new StoreManagerConsumer([
@@ -75,6 +75,7 @@
"zod": "^3.25.76"
},
"dependencies": {
"@affine/auth": "workspace:*",
"async-call-rpc": "^6.4.2",
"electron-updater": "^6.8.3",
"link-preview-js": "^4.0.0",
@@ -0,0 +1,293 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import {
AuthTokenBroker,
type AuthTokenPair,
type AuthTokenResponse,
classifyAuthError,
} from '@affine/auth';
import { app, net, safeStorage } from 'electron';
import { logger } from '../logger';
const FILEPATH = path.join(app.getPath('userData'), 'auth-sessions.json');
const TEMP_FILEPATH = `${FILEPATH}.tmp`;
const INSTALLATION_FILEPATH = path.join(
app.getPath('userData'),
'installation-id'
);
const brokers = new Map<string, AuthTokenBroker>();
const memoryStore = new Map<string, AuthTokenPair>();
let fileMutation = Promise.resolve();
let installationId: Promise<string> | undefined;
const AUTH_REQUEST_TIMEOUT = 10_000;
function secureStorageAvailable() {
return (
safeStorage.isEncryptionAvailable() &&
(process.platform !== 'linux' ||
safeStorage.getSelectedStorageBackend() !== 'basic_text')
);
}
export function normalizeEndpoint(endpoint: string) {
return new URL(endpoint).origin;
}
async function readStore(): Promise<Record<string, string>> {
try {
const value = JSON.parse(await fs.readFile(FILEPATH, 'utf8'));
return value && typeof value === 'object' && !Array.isArray(value)
? value
: {};
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error('failed to read auth session store', error);
}
return {};
}
}
async function mutateFile(mutator: (store: Record<string, string>) => void) {
const operation = fileMutation.then(async () => {
const store = await readStore();
mutator(store);
await fs.writeFile(TEMP_FILEPATH, JSON.stringify(store), { mode: 0o600 });
await fs.rename(TEMP_FILEPATH, FILEPATH);
});
fileMutation = operation.catch(() => {});
return await operation;
}
function encrypt(pair: AuthTokenPair) {
return safeStorage.encryptString(JSON.stringify(pair)).toString('base64');
}
function decrypt(value: string): AuthTokenPair | null {
try {
return JSON.parse(safeStorage.decryptString(Buffer.from(value, 'base64')));
} catch (error) {
logger.error('failed to decrypt auth session', error);
return null;
}
}
function storage(endpoint: string) {
return {
async load() {
if (!secureStorageAvailable()) {
return memoryStore.get(endpoint) ?? null;
}
const encrypted = (await readStore())[endpoint];
if (!encrypted) return null;
const pair = decrypt(encrypted);
if (!pair) await mutateFile(store => delete store[endpoint]);
return pair;
},
async save(pair: AuthTokenPair) {
if (!secureStorageAvailable()) {
await mutateFile(store => delete store[endpoint]);
memoryStore.set(endpoint, pair);
return;
}
await mutateFile(store => {
store[endpoint] = encrypt(pair);
});
memoryStore.delete(endpoint);
},
async clear() {
memoryStore.delete(endpoint);
await mutateFile(store => delete store[endpoint]);
},
};
}
async function refresh(endpoint: string, refreshToken: string) {
const response = await net.fetch(
new URL('/api/auth/session/refresh', endpoint).toString(),
{
method: 'POST',
headers: {
'content-type': 'application/json',
'x-affine-client-kind': 'native',
'x-affine-version': BUILD_CONFIG.appVersion,
},
body: JSON.stringify({ refreshToken }),
signal: AbortSignal.timeout(AUTH_REQUEST_TIMEOUT),
}
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw classifyAuthError({
code:
typeof body === 'object' && body && 'code' in body
? String(body.code)
: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE',
});
}
return body as AuthTokenResponse;
}
export function getAuthSessionBroker(endpoint: string) {
const normalized = normalizeEndpoint(endpoint);
let broker = brokers.get(normalized);
if (!broker) {
broker = new AuthTokenBroker(storage(normalized), {
refresh: (token: string) => refresh(normalized, token),
});
brokers.set(normalized, broker);
}
return broker;
}
export function isManagedAuthEndpoint(url: string) {
try {
const parsed = new URL(url);
if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
return brokers.has(parsed.origin);
} catch {
return false;
}
}
export async function setAuthSession(
endpoint: string,
response: AuthTokenResponse
) {
await getAuthSessionBroker(endpoint).set(response);
return { persistent: secureStorageAvailable() };
}
export function getInstallationId() {
if (installationId) return installationId;
const pending = fs
.readFile(INSTALLATION_FILEPATH, 'utf8')
.then(value => {
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value
)
) {
throw new Error('Invalid installation id');
}
return value;
})
.catch(async () => {
const value = randomUUID();
await fs.writeFile(INSTALLATION_FILEPATH, value, { mode: 0o600 });
return value;
});
installationId = pending;
void pending.catch(() => {
if (installationId === pending) installationId = undefined;
});
return pending;
}
export async function revokeAuthSession(endpoint: string) {
const normalized = normalizeEndpoint(endpoint);
await getAuthSessionBroker(normalized).revoke(
'sign-out',
async (refreshToken: string) => {
const response = await net.fetch(
new URL('/api/auth/session/revoke', normalized).toString(),
{
method: 'POST',
headers: {
'content-type': 'application/json',
'x-affine-client-kind': 'native',
'x-affine-version': BUILD_CONFIG.appVersion,
},
body: JSON.stringify({ refreshToken }),
signal: AbortSignal.timeout(AUTH_REQUEST_TIMEOUT),
}
);
if (!response.ok) throw new Error('Failed to revoke auth session');
}
);
}
export async function clearAuthSession(endpoint: string, reason: string) {
await getAuthSessionBroker(endpoint).clear(reason);
}
export async function getValidAccessToken(
endpoint: string,
minValidity = 60_000
) {
try {
return await getAuthSessionBroker(endpoint).getValidAccessToken(
minValidity
);
} catch (error) {
const classified = classifyAuthError(error);
if (classified.code === 'AUTH_SESSION_EMPTY' || !classified.transient) {
return null;
}
throw classified;
}
}
export async function getAccessTokenForUrl(url: string, minValidity = 60_000) {
try {
const parsed = new URL(url);
if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
return await getValidAccessToken(parsed.origin, minValidity);
} catch (error) {
if (error instanceof TypeError) return null;
throw error;
}
}
export async function refreshAccessTokenForUrl(url: string) {
const parsed = new URL(url);
if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
return (
await getAuthSessionBroker(parsed.origin).refresh('access-token-expired')
).accessToken;
}
async function authorizedRequest(
request: Request,
targetUrl: string,
accessToken?: string
) {
const cloned = request.clone();
const headers = new Headers(cloned.headers);
headers.delete('Authorization');
const token = accessToken ?? (await getAccessTokenForUrl(targetUrl));
if (token) headers.set('Authorization', `Bearer ${token}`);
return new Request(targetUrl, {
body:
cloned.method === 'GET' || cloned.method === 'HEAD'
? undefined
: cloned.body,
headers,
method: cloned.method,
redirect: cloned.redirect,
signal: cloned.signal,
duplex: 'half',
});
}
export async function executeAuthSessionRequest(
request: Request,
targetUrl: string,
execute: (request: Request) => Promise<Response>
) {
const retry = request.clone();
const response = await execute(await authorizedRequest(request, targetUrl));
if (response.status !== 401) return response;
const body = (await response
.clone()
.json()
.catch(() => null)) as { code?: string } | null;
if (body?.code !== 'ACCESS_TOKEN_EXPIRED') return response;
const token = await refreshAccessTokenForUrl(targetUrl);
return await execute(await authorizedRequest(retry, targetUrl, token));
}
@@ -1,12 +1,17 @@
import os from 'node:os';
import type { AuthTokenResponse } from '@affine/auth';
import { net, session } from 'electron';
import { logger } from '../logger';
import type { NamespaceHandlers } from '../type';
import {
deleteNativeAuthToken,
getNativeAuthToken,
setNativeAuthToken,
} from './native-token';
clearAuthSession,
getInstallationId,
getValidAccessToken,
revokeAuthSession,
setAuthSession,
} from './auth-session';
export interface SignInResponse {
id?: string;
@@ -29,10 +34,6 @@ export interface PasswordSignInResponse extends SignInResponse {
sessionOnly?: boolean;
}
interface ExchangeResponse {
token?: string;
}
const authCookieNames = [
'affine_session',
'affine_user_id',
@@ -88,15 +89,16 @@ async function exchangeSession(endpoint: string, response: SignInResponse) {
const exchangeResponse = await fetchAuth(
endpoint,
'/api/auth/native/exchange',
{ code: response.exchangeCode }
'/api/auth/session/exchange',
{
code: response.exchangeCode,
installationId: await getInstallationId(),
platform: 'electron',
deviceName: os.hostname(),
}
);
const body = await readJson<ExchangeResponse>(exchangeResponse);
if (!body.token) {
throw new Error('Missing native auth token.');
}
const persistent = setNativeAuthToken(endpoint, body.token);
const body = await readJson<AuthTokenResponse>(exchangeResponse);
const { persistent } = await setAuthSession(endpoint, body);
await clearAuthCookies(endpoint);
return { persistent };
}
@@ -155,6 +157,13 @@ export const authHandlers = {
...(credential.verifyToken
? { 'x-captcha-token': credential.verifyToken }
: {}),
...(credential.verifyToken
? {
'x-captcha-provider': credential.challenge
? 'hashcash'
: 'turnstile',
}
: {}),
...(credential.challenge
? { 'x-captcha-challenge': credential.challenge }
: {}),
@@ -181,22 +190,18 @@ export const authHandlers = {
},
signOut: async (_e, endpoint: string) => {
const token = getNativeAuthToken(endpoint);
if (token) {
await net.fetch(authUrl(endpoint, '/api/auth/sign-out'), {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'x-affine-version': BUILD_CONFIG.appVersion,
},
});
try {
await revokeAuthSession(endpoint);
} finally {
await clearAuthCookies(endpoint);
}
deleteNativeAuthToken(endpoint);
await clearAuthCookies(endpoint);
},
readEndpointToken: async (_e, endpoint: string) => {
return { token: getNativeAuthToken(endpoint) };
clearSession: async (_e, endpoint: string) => {
await clearAuthSession(endpoint, 'local-clear');
},
getValidAccessToken: async (_e, endpoint: string) => {
return { token: await getValidAccessToken(endpoint, 120_000) };
},
} satisfies NamespaceHandlers;
@@ -1,100 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { app, safeStorage } from 'electron';
import { logger } from '../logger';
const FILEPATH = path.join(app.getPath('userData'), 'native-auth-tokens.json');
type TokenRecord = {
token: string;
};
// safeStorage may not be available in some environments (e.g. Linux without a keyring), so we fall back to an in-memory store in that case
const memoryTokenStore: Record<string, string> = {};
function normalizeEndpoint(endpoint: string) {
return new URL(endpoint).origin;
}
function readStore(): Record<string, string> {
if (!fs.existsSync(FILEPATH)) return {};
try {
return JSON.parse(fs.readFileSync(FILEPATH, 'utf-8'));
} catch (error) {
logger.error('failed to read native auth token store', error);
return {};
}
}
function writeStore(store: Record<string, string>) {
fs.writeFileSync(FILEPATH, JSON.stringify(store, null, 2));
}
function encryptToken(record: TokenRecord) {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error('Secure native auth token storage is not available.');
}
return safeStorage.encryptString(JSON.stringify(record)).toString('base64');
}
function decryptToken(value: string): TokenRecord | null {
if (!safeStorage.isEncryptionAvailable()) {
return null;
}
try {
return JSON.parse(safeStorage.decryptString(Buffer.from(value, 'base64')));
} catch (error) {
logger.error('failed to decrypt native auth token', error);
return null;
}
}
export function setNativeAuthToken(endpoint: string, token: string) {
const normalizedEndpoint = normalizeEndpoint(endpoint);
if (!safeStorage.isEncryptionAvailable()) {
memoryTokenStore[normalizedEndpoint] = token;
return false;
}
const store = readStore();
store[normalizedEndpoint] = encryptToken({ token });
writeStore(store);
return true;
}
export function deleteNativeAuthToken(endpoint: string) {
const normalizedEndpoint = normalizeEndpoint(endpoint);
delete memoryTokenStore[normalizedEndpoint];
const store = readStore();
delete store[normalizedEndpoint];
writeStore(store);
}
export function getNativeAuthToken(endpoint: string) {
const normalizedEndpoint = normalizeEndpoint(endpoint);
const memoryToken = memoryTokenStore[normalizedEndpoint];
if (memoryToken) return memoryToken;
const encrypted = readStore()[normalizedEndpoint];
if (!encrypted) return null;
return decryptToken(encrypted)?.token ?? null;
}
export function getAuthTokenForUrl(url: string) {
try {
const parsed = new URL(url);
if (parsed.protocol === 'ws:') {
parsed.protocol = 'http:';
} else if (parsed.protocol === 'wss:') {
parsed.protocol = 'https:';
}
return getNativeAuthToken(parsed.origin);
} catch {
return null;
}
}
@@ -11,7 +11,11 @@ import {
resolvePathInBase,
resourcesPath,
} from '../shared/utils';
import { getAuthTokenForUrl } from './auth/native-token';
import {
executeAuthSessionRequest,
getAccessTokenForUrl,
isManagedAuthEndpoint,
} from './auth/auth-session';
import { buildType, isDev } from './config';
import { logger } from './logger';
@@ -64,26 +68,6 @@ function buildTargetUrl(base: string, urlObject: URL) {
return new URL(`${urlObject.pathname}${urlObject.search}`, base).toString();
}
async function buildAuthorizedRequest(request: Request, targetUrl: string) {
const clonedRequest = request.clone();
const headers = new Headers(clonedRequest.headers);
const token = getAuthTokenForUrl(targetUrl);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return new Request(targetUrl, {
body:
clonedRequest.method === 'GET' || clonedRequest.method === 'HEAD'
? undefined
: clonedRequest.body,
headers,
method: clonedRequest.method,
redirect: clonedRequest.redirect,
signal: clonedRequest.signal,
});
}
async function proxyRequest(
request: Request,
urlObject: URL,
@@ -92,13 +76,13 @@ async function proxyRequest(
) {
const { bypassCustomProtocolHandlers = true } = options;
const targetUrl = buildTargetUrl(base, urlObject);
const authorizedRequest = await buildAuthorizedRequest(request, targetUrl);
const proxiedRequest = bypassCustomProtocolHandlers
? Object.assign(authorizedRequest, {
bypassCustomProtocolHandlers: true,
})
: authorizedRequest;
return net.fetch(proxiedRequest);
return await executeAuthSessionRequest(request, targetUrl, request =>
net.fetch(
bypassCustomProtocolHandlers
? Object.assign(request, { bypassCustomProtocolHandlers: true })
: request
)
);
}
async function handleFileRequest(request: Request) {
@@ -268,17 +252,20 @@ export function registerProtocol() {
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
const url = new URL(details.url);
(async () => {
if (
url.protocol === 'http:' ||
const managedAuthRequest =
(url.protocol === 'http:' ||
url.protocol === 'https:' ||
url.protocol === 'ws:' ||
url.protocol === 'wss:'
) {
const token = getAuthTokenForUrl(details.url);
url.protocol === 'wss:') &&
isManagedAuthEndpoint(details.url);
let cancel = false;
(async () => {
if (managedAuthRequest) {
delete details.requestHeaders.authorization;
delete details.requestHeaders.Authorization;
const token = await getAccessTokenForUrl(details.url, 120_000);
if (token) {
delete details.requestHeaders.authorization;
details.requestHeaders.Authorization = `Bearer ${token}`;
}
}
@@ -292,11 +279,12 @@ export function registerProtocol() {
}
})()
.catch(err => {
cancel = managedAuthRequest;
logger.error('error handling before send headers', err);
})
.finally(() => {
callback({
cancel: false,
cancel,
requestHeaders: details.requestHeaders,
});
});
@@ -0,0 +1,311 @@
import path from 'node:path';
import { beforeEach, expect, test, vi } from 'vitest';
const sessionFile = path.join('/test-user-data', 'auth-sessions.json');
const temporarySessionFile = `${sessionFile}.tmp`;
const runtime = vi.hoisted(() => ({
encryptionAvailable: true,
backend: 'unknown',
failWrite: false,
files: new Map<string, string>(),
fetch: vi.fn(),
rename: vi.fn(),
}));
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async (file: string) => {
const value = runtime.files.get(file);
if (value === undefined) {
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
}
return value;
}),
writeFile: vi.fn(async (file: string, value: string) => {
if (runtime.failWrite) throw new Error('disk unavailable');
runtime.files.set(file, value);
}),
rename: vi.fn(async (source: string, target: string) => {
runtime.rename(source, target);
const value = runtime.files.get(source);
if (value !== undefined) runtime.files.set(target, value);
runtime.files.delete(source);
}),
rm: vi.fn(async (file: string) => {
runtime.files.delete(file);
}),
},
}));
vi.mock('electron', () => ({
app: { getPath: () => '/test-user-data' },
net: { fetch: runtime.fetch },
safeStorage: {
isEncryptionAvailable: () => runtime.encryptionAvailable,
getSelectedStorageBackend: () => runtime.backend,
encryptString: (value: string) => Buffer.from(value),
decryptString: (value: Buffer) => value.toString(),
},
}));
vi.mock('../../src/main/logger', () => ({
logger: { error: vi.fn() },
}));
import {
clearAuthSession,
executeAuthSessionRequest,
getValidAccessToken,
normalizeEndpoint,
revokeAuthSession,
setAuthSession,
} from '../../src/main/auth/auth-session';
beforeEach(() => {
runtime.encryptionAvailable = true;
runtime.backend = 'unknown';
runtime.failWrite = false;
runtime.fetch.mockReset();
runtime.rename.mockClear();
});
test.each([
['https://AFFINE.PRO/path?query=1', 'https://affine.pro'],
['https://affine.pro:443', 'https://affine.pro'],
['http://localhost:80/path', 'http://localhost'],
['http://localhost:8080/path', 'http://localhost:8080'],
])('normalizes auth endpoint %s', (endpoint, expected) => {
expect(normalizeEndpoint(endpoint)).toBe(expected);
});
test('atomically persists one encrypted token-pair record', async () => {
const endpoint = 'https://persistent.example';
const pair = tokenResponse('access-persistent', 'p', 900);
await expect(setAuthSession(endpoint, pair)).resolves.toEqual({
persistent: true,
});
expect(await getValidAccessToken(endpoint)).toBe('access-persistent');
expect(runtime.rename).toHaveBeenCalledWith(
temporarySessionFile,
sessionFile
);
const file = runtime.files.get(sessionFile) ?? '';
expect(file).not.toContain(pair.refreshToken);
});
test('keeps a session in main-process memory when safeStorage is unavailable', async () => {
const endpoint = 'https://session-only.example';
await setAuthSession(endpoint, tokenResponse('old-persistent', 'o', 900));
runtime.encryptionAvailable = false;
await expect(
setAuthSession(endpoint, tokenResponse('session-access', 's', 900))
).resolves.toEqual({ persistent: false });
expect(await getValidAccessToken(endpoint)).toBe('session-access');
expect(runtime.files.get(sessionFile)).not.toContain(endpoint);
runtime.encryptionAvailable = true;
});
test('rejects Linux basic_text persistence and removes an older disk session', async () => {
const platform = vi
.spyOn(process, 'platform', 'get')
.mockReturnValue('linux');
const endpoint = 'https://basic-text.example';
await setAuthSession(endpoint, tokenResponse('old', 'o', 900));
runtime.backend = 'basic_text';
await expect(
setAuthSession(endpoint, tokenResponse('memory', 'm', 900))
).resolves.toEqual({ persistent: false });
expect(await getValidAccessToken(endpoint)).toBe('memory');
expect(runtime.files.get(sessionFile)).not.toContain(endpoint);
platform.mockRestore();
});
test('shares one refresh across concurrent main-process callers', async () => {
const endpoint = 'https://refresh.example';
await setAuthSession(endpoint, tokenResponse('expiring', 'a', 1));
runtime.fetch.mockResolvedValueOnce(
new Response(JSON.stringify(tokenResponse('fresh', 'b', 900)))
);
const tokens = await Promise.all(
Array.from({ length: 50 }, () => getValidAccessToken(endpoint, 120_000))
);
expect(tokens.every(token => token === 'fresh')).toBe(true);
expect(runtime.fetch).toHaveBeenCalledTimes(1);
});
test('preserves credentials for unknown refresh errors', async () => {
const endpoint = 'https://unknown-error.example';
await setAuthSession(endpoint, tokenResponse('still-valid', 'u', 900));
runtime.fetch.mockImplementation(
async () =>
new Response(JSON.stringify({ code: 'UNKNOWN_SELF_HOSTED_ERROR' }), {
status: 401,
})
);
await expect(getValidAccessToken(endpoint, 1_000_000)).rejects.toMatchObject({
code: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE',
transient: true,
});
await expect(getValidAccessToken(endpoint, 0)).resolves.toBe('still-valid');
});
test('clears credentials only for an allowlisted permanent refresh error', async () => {
const endpoint = 'https://permanent-error.example';
await setAuthSession(endpoint, tokenResponse('expired', 'e', 1));
runtime.fetch.mockResolvedValueOnce(
new Response(JSON.stringify({ code: 'AUTH_SESSION_REVOKED' }), {
status: 401,
})
);
await expect(getValidAccessToken(endpoint, 120_000)).resolves.toBeNull();
await expect(getValidAccessToken(endpoint, 0)).resolves.toBeNull();
expect(runtime.files.get(sessionFile)).not.toContain(endpoint);
});
test('cleans a decrypted record that does not match the token-pair schema', async () => {
const endpoint = 'https://corrupt.example';
runtime.files.set(
sessionFile,
JSON.stringify({
[endpoint]: Buffer.from(JSON.stringify({ token: 'legacy' })).toString(
'base64'
),
})
);
await expect(getValidAccessToken(endpoint)).resolves.toBeNull();
expect(runtime.files.get(sessionFile)).not.toContain(endpoint);
});
test('recovers an unreadable session file on the next atomic write', async () => {
runtime.files.set(sessionFile, '{not-json');
const endpoint = 'https://file-corruption.example';
await expect(getValidAccessToken(endpoint)).resolves.toBeNull();
await setAuthSession(endpoint, tokenResponse('recovered', 'c', 900));
expect(JSON.parse(runtime.files.get(sessionFile)!)).toHaveProperty(endpoint);
await expect(getValidAccessToken(endpoint)).resolves.toBe('recovered');
});
test('does not publish a rotated pair until an atomic save succeeds', async () => {
const endpoint = 'https://save-failure.example';
await setAuthSession(endpoint, tokenResponse('old', 'a', 1));
runtime.fetch.mockResolvedValueOnce(
new Response(JSON.stringify(tokenResponse('rotated', 'b', 900)))
);
runtime.failWrite = true;
await expect(getValidAccessToken(endpoint, 120_000)).rejects.toMatchObject({
code: 'AUTH_TOKEN_STORAGE_UNAVAILABLE',
});
runtime.failWrite = false;
await expect(getValidAccessToken(endpoint, 120_000)).resolves.toBe('rotated');
expect(runtime.fetch).toHaveBeenCalledTimes(1);
});
test('main replaces renderer bearer and strips it after local clear', async () => {
const endpoint = 'https://owner.example';
await setAuthSession(endpoint, tokenResponse('main-token', 'm', 900));
const execute = vi.fn(async () => new Response('{}'));
await executeAuthSessionRequest(
new Request(`${endpoint}/graphql`, {
headers: { Authorization: 'Bearer renderer-stale' },
}),
`${endpoint}/graphql`,
execute
);
expect(execute.mock.calls[0]?.[0].headers.get('Authorization')).toBe(
'Bearer main-token'
);
await clearAuthSession(endpoint, 'test-clear');
await executeAuthSessionRequest(
new Request(`${endpoint}/graphql`, {
headers: { Authorization: 'Bearer renderer-stale' },
}),
`${endpoint}/graphql`,
execute
);
expect(execute.mock.calls[1]?.[0].headers.get('Authorization')).toBeNull();
});
test.each([
{
code: 'ACCESS_TOKEN_EXPIRED',
expectedRequests: 2,
method: 'POST',
},
{ code: 'FORBIDDEN', expectedRequests: 1, method: 'PUT' },
])('replays a protocol request once for $code only', async item => {
const endpoint = `https://${item.code.toLowerCase()}.example`;
await setAuthSession(endpoint, tokenResponse('initial', 'i', 900));
runtime.fetch.mockResolvedValueOnce(
new Response(JSON.stringify(tokenResponse('refreshed', 'f', 900)))
);
const execute = vi
.fn<(request: Request) => Promise<Response>>()
.mockResolvedValueOnce(
new Response(JSON.stringify({ code: item.code }), { status: 401 })
)
.mockResolvedValueOnce(new Response('{}'));
await executeAuthSessionRequest(
new Request(`${endpoint}/api/test`, {
method: item.method,
body: JSON.stringify({ value: item.code }),
duplex: 'half',
}),
`${endpoint}/api/test`,
execute
);
expect(execute).toHaveBeenCalledTimes(item.expectedRequests);
expect(runtime.fetch).toHaveBeenCalledTimes(
item.code === 'ACCESS_TOKEN_EXPIRED' ? 1 : 0
);
for (const [request] of execute.mock.calls) {
await expect(request.text()).resolves.toBe(
JSON.stringify({ value: item.code })
);
}
});
test('clears locally before revoking without exposing refresh token over IPC', async () => {
const endpoint = 'https://revoke.example';
const pair = tokenResponse('revoke-access', 'r', 900);
await setAuthSession(endpoint, pair);
runtime.fetch.mockResolvedValueOnce(new Response('{}'));
await revokeAuthSession(endpoint);
expect(await getValidAccessToken(endpoint)).toBeNull();
const request = runtime.fetch.mock.calls.at(-1)?.[1] as RequestInit;
expect(request.body).toBe(
JSON.stringify({ refreshToken: pair.refreshToken })
);
});
function tokenResponse(accessToken: string, seed: string, expiresIn: number) {
return {
tokenType: 'Bearer' as const,
accessToken,
expiresIn,
refreshToken: `aff_rt_v1.${seed.repeat(24)}.${seed.repeat(43)}`,
refreshExpiresAt: '2030-01-01T00:00:00.000Z',
session: {
id: '00000000-0000-4000-8000-000000000001',
absoluteExpiresAt: '2031-01-01T00:00:00.000Z',
},
};
}
@@ -7,6 +7,7 @@
},
"include": ["./src"],
"references": [
{ "path": "../../../common/auth" },
{ "path": "../../../../tools/utils" },
{ "path": "../../i18n" },
{ "path": "../../native" },
@@ -33,6 +33,8 @@
C4C97C7C2D030BE000BC2AD1 /* affine_mobile_native.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C6F2D0307B700BC2AD1 /* affine_mobile_native.swift */; };
C4C97C7D2D030BE000BC2AD1 /* affine_mobile_nativeFFI.h in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C702D0307B700BC2AD1 /* affine_mobile_nativeFFI.h */; };
C4C97C7E2D030BE000BC2AD1 /* affine_mobile_nativeFFI.modulemap in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C712D0307B700BC2AD1 /* affine_mobile_nativeFFI.modulemap */; };
AA0000040000000000000000 /* AuthDateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000010000000000000000 /* AuthDateParser.swift */; };
AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000020000000000000000 /* AuthDateParserTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -83,6 +85,9 @@
C4C97C712D0307B700BC2AD1 /* affine_mobile_nativeFFI.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = affine_mobile_nativeFFI.modulemap; sourceTree = "<group>"; };
E5E5070D1CA1200D4964D91F /* Pods-AFFiNE.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AFFiNE.release.xcconfig"; path = "Pods/Target Support Files/Pods-AFFiNE/Pods-AFFiNE.release.xcconfig"; sourceTree = "<group>"; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
AA0000010000000000000000 /* AuthDateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../App/Plugins/Auth/AuthDateParser.swift; sourceTree = "<group>"; };
AA0000020000000000000000 /* AuthDateParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthDateParserTests.swift; sourceTree = "<group>"; };
AA0000030000000000000000 /* AFFiNETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AFFiNETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -94,6 +99,13 @@
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
AA0000070000000000000000 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -127,6 +139,7 @@
children = (
C4C97C722D0307B700BC2AD1 /* uniffi */,
9D90BE242CCB9876006677DB /* App */,
AA00000D0000000000000000 /* AppTests */,
50802D5F2D112F7D00694021 /* Packages */,
504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */,
@@ -140,6 +153,7 @@
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* AFFiNE.app */,
AA0000030000000000000000 /* AFFiNETests.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -192,6 +206,15 @@
path = App;
sourceTree = "<group>";
};
AA00000D0000000000000000 /* AppTests */ = {
isa = PBXGroup;
children = (
AA0000010000000000000000 /* AuthDateParser.swift */,
AA0000020000000000000000 /* AuthDateParserTests.swift */,
);
path = AppTests;
sourceTree = "<group>";
};
C4C97C722D0307B700BC2AD1 /* uniffi */ = {
isa = PBXGroup;
children = (
@@ -206,6 +229,23 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
AA0000090000000000000000 /* AFFiNETests */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA00000C0000000000000000 /* Build configuration list for PBXNativeTarget "AFFiNETests" */;
buildPhases = (
AA0000060000000000000000 /* Sources */,
AA0000070000000000000000 /* Frameworks */,
AA0000080000000000000000 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = AFFiNETests;
productName = AFFiNETests;
productReference = AA0000030000000000000000 /* AFFiNETests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
504EC3031FED79650016851F /* AFFiNE */ = {
isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "AFFiNE" */;
@@ -240,6 +280,9 @@
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 2600;
TargetAttributes = {
AA0000090000000000000000 = {
CreatedOnToolsVersion = 26.0;
};
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1600;
@@ -260,11 +303,19 @@
projectRoot = "";
targets = (
504EC3031FED79650016851F /* AFFiNE */,
AA0000090000000000000000 /* AFFiNETests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
AA0000080000000000000000 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3021FED79650016851F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -340,6 +391,15 @@
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
AA0000060000000000000000 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA0000040000000000000000 /* AuthDateParser.swift in Sources */,
AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -373,6 +433,38 @@
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
AA00000A0000000000000000 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGNING_ALLOWED = NO;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AA00000B0000000000000000 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGNING_ALLOWED = NO;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -574,6 +666,15 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
AA00000C0000000000000000 /* Build configuration list for PBXNativeTarget "AFFiNETests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA00000A0000000000000000 /* Debug */,
AA00000B0000000000000000 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A11F1E000000000000000005"
BuildableName = "AFFiNETests.xctest"
BlueprintName = "AFFiNETests"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A11F1E000000000000000005"
BuildableName = "AFFiNETests.xctest"
BlueprintName = "AFFiNETests"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
</Scheme>
@@ -21,6 +21,20 @@
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000090000000000000000"
BuildableName = "AFFiNETests.xctest"
BlueprintName = "AFFiNETests"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
@@ -29,6 +43,18 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000090000000000000000"
BuildableName = "AFFiNETests.xctest"
BlueprintName = "AFFiNETests"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -0,0 +1,7 @@
import Foundation
func parseAuthISO8601Date(_ value: String) -> Date? {
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return fractional.date(from: value) ?? ISO8601DateFormatter().date(from: value)
}
@@ -1,6 +1,255 @@
import Capacitor
import Foundation
import Security
import UIKit
private struct AuthSessionInfo: Codable {
let id: String
let absoluteExpiresAt: String
}
private struct AuthTokenResponse: Codable {
let tokenType: String
let accessToken: String
let expiresIn: Int
let refreshToken: String
let refreshExpiresAt: String
let session: AuthSessionInfo
}
private struct StoredAuthTokenPair: Codable {
let version: Int
let tokenType: String
let accessToken: String
let accessExpiresAt: Date
let refreshToken: String
let refreshExpiresAt: String
let session: AuthSessionInfo
}
private struct AuthErrorResponse: Decodable {
let code: String?
}
private struct AuthServerError: Error {
let code: String?
let statusCode: Int
var permanentlyInvalidatesSession: Bool {
switch code {
case "AUTH_SESSION_EXPIRED", "AUTH_SESSION_REVOKED", "REFRESH_TOKEN_INVALID",
"REFRESH_TOKEN_REUSED", "UNSUPPORTED_CLIENT_VERSION", "ACCESS_TOKEN_INVALID":
return true
default:
return false
}
}
}
private struct AuthOperationCancelled: Error {}
private struct AuthRefreshOperation {
let id: UUID
let task: Task<StoredAuthTokenPair, Error>
}
private actor AuthSessionBroker {
private let tokenService = "app.affine.pro.auth-token"
private var refreshTasks: [String: AuthRefreshOperation] = [:]
private var mutationEpochs: [String: UInt] = [:]
func store(_ endpoint: String, response: AuthTokenResponse) throws {
invalidateRefresh(canonicalEndpoint(endpoint))
try write(endpoint, tokenPair(response))
}
func validAccessToken(_ endpoint: String, minValidity: TimeInterval = 120) async throws -> String? {
guard let pair = try read(endpoint) else { return nil }
if pair.accessExpiresAt.timeIntervalSinceNow > minValidity {
return pair.accessToken
}
return try await refresh(endpoint).accessToken
}
func refreshAccessToken(_ endpoint: String) async throws -> String {
try await refresh(endpoint).accessToken
}
func signOut(_ endpoint: String) async throws {
let key = canonicalEndpoint(endpoint)
let pair = try read(endpoint)
invalidateRefresh(key)
try delete(endpoint)
guard let pair else { return }
_ = try await request(
endpoint, action: "/api/auth/session/revoke",
body: ["refreshToken": pair.refreshToken])
}
func clear(_ endpoint: String) throws {
invalidateRefresh(canonicalEndpoint(endpoint))
try delete(endpoint)
}
private func refresh(_ endpoint: String) async throws -> StoredAuthTokenPair {
let key = canonicalEndpoint(endpoint)
if let operation = refreshTasks[key] { return try await operation.task.value }
guard let current = try read(endpoint) else { throw AuthError.tokenNotFound }
let epoch = mutationEpochs[key, default: 0]
let operationId = UUID()
let task = Task {
do {
let data = try await self.request(
endpoint, action: "/api/auth/session/refresh",
body: ["refreshToken": current.refreshToken])
let response = try JSONDecoder().decode(AuthTokenResponse.self, from: data)
let pair = try self.tokenPair(response)
guard !Task.isCancelled, self.mutationEpochs[key, default: 0] == epoch else {
throw AuthOperationCancelled()
}
try self.write(endpoint, pair)
return pair
} catch let error as AuthServerError where error.permanentlyInvalidatesSession {
if self.mutationEpochs[key, default: 0] == epoch {
try? self.delete(endpoint)
}
throw error
}
}
refreshTasks[key] = AuthRefreshOperation(id: operationId, task: task)
defer {
if refreshTasks[key]?.id == operationId {
refreshTasks[key] = nil
}
}
return try await task.value
}
private func invalidateRefresh(_ key: String) {
mutationEpochs[key, default: 0] &+= 1
refreshTasks[key]?.task.cancel()
refreshTasks[key] = nil
}
private func tokenPair(_ response: AuthTokenResponse) throws -> StoredAuthTokenPair {
guard response.tokenType == "Bearer", !response.accessToken.isEmpty,
!response.refreshToken.isEmpty, (1...86_400).contains(response.expiresIn),
parseAuthISO8601Date(response.refreshExpiresAt) != nil,
parseAuthISO8601Date(response.session.absoluteExpiresAt) != nil
else {
throw AuthError.invalidTokenResponse
}
return StoredAuthTokenPair(
version: 1,
tokenType: response.tokenType,
accessToken: response.accessToken,
accessExpiresAt: Date().addingTimeInterval(TimeInterval(response.expiresIn)),
refreshToken: response.refreshToken,
refreshExpiresAt: response.refreshExpiresAt,
session: response.session)
}
private func request(_ endpoint: String, action: String, body: [String: String]) async throws -> Data {
guard let url = URL(string: "\(canonicalEndpoint(endpoint))\(action)") else {
throw AuthError.invalidEndpoint
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpShouldHandleCookies = false
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("native", forHTTPHeaderField: "x-affine-client-kind")
request.setValue(AppConfigManager.getAffineVersion(), forHTTPHeaderField: "x-affine-version")
request.httpBody = try JSONEncoder().encode(body)
request.timeoutInterval = 10
for attempt in 0..<3 {
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw AuthError.internalError
}
if response.statusCode < 400 { return data }
let error = AuthServerError(
code: try? JSONDecoder().decode(AuthErrorResponse.self, from: data).code,
statusCode: response.statusCode)
guard response.statusCode >= 500, attempt < 2 else { throw error }
} catch let error as AuthServerError {
if error.statusCode < 500 || attempt == 2 { throw error }
} catch {
if Task.isCancelled { throw AuthOperationCancelled() }
if attempt == 2 { throw error }
}
let delay = UInt64((200 * (1 << attempt)) + Int.random(in: 0...150)) * 1_000_000
try await Task.sleep(nanoseconds: delay)
}
throw AuthError.internalError
}
private func canonicalEndpoint(_ endpoint: String) -> String {
guard let url = URL(string: endpoint), let scheme = url.scheme, let host = url.host else {
return endpoint
}
let normalizedScheme = scheme.lowercased()
let defaultPort = normalizedScheme == "http" ? 80 : normalizedScheme == "https" ? 443 : nil
let port = url.port.flatMap { $0 == defaultPort ? nil : ":\($0)" } ?? ""
return "\(normalizedScheme)://\(host.lowercased())\(port)"
}
private func query(_ endpoint: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: tokenService,
kSecAttrAccount as String: canonicalEndpoint(endpoint),
]
}
private func read(_ endpoint: String) throws -> StoredAuthTokenPair? {
var query = query(endpoint)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecItemNotFound { return nil }
if status == errSecInteractionNotAllowed || status == errSecNotAvailable {
throw AuthError.credentialStoreUnavailable
}
guard status == errSecSuccess, let data = item as? Data else {
throw AuthError.internalError
}
guard let pair = try? JSONDecoder().decode(StoredAuthTokenPair.self, from: data),
pair.version == 1, pair.tokenType == "Bearer", !pair.accessToken.isEmpty,
!pair.refreshToken.isEmpty, pair.accessExpiresAt.timeIntervalSince1970.isFinite,
parseAuthISO8601Date(pair.refreshExpiresAt) != nil,
parseAuthISO8601Date(pair.session.absoluteExpiresAt) != nil
else {
try delete(endpoint)
return nil
}
return pair
}
private func write(_ endpoint: String, _ pair: StoredAuthTokenPair) throws {
let data = try JSONEncoder().encode(pair)
var add = query(endpoint)
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let update = [kSecValueData as String: data]
let status = SecItemUpdate(query(endpoint) as CFDictionary, update as CFDictionary)
if status == errSecItemNotFound {
guard SecItemAdd(add as CFDictionary, nil) == errSecSuccess else {
throw AuthError.internalError
}
} else if status != errSecSuccess {
throw AuthError.internalError
}
}
private func delete(_ endpoint: String) throws {
let status = SecItemDelete(query(endpoint) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw AuthError.internalError
}
}
}
public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "AuthPlugin"
@@ -11,12 +260,12 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
CAPPluginMethod(name: "signInOpenApp", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "signInPassword", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "signOut", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "readEndpointToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "writeEndpointToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "deleteEndpointToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getValidAccessToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "refreshAccessToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "clearEndpointSession", returnType: CAPPluginReturnPromise),
]
private let tokenService = "app.affine.pro.auth-token"
private let broker = AuthSessionBroker()
private let authCookieNames = Set(["affine_session", "affine_user_id", "affine_csrf_token"])
private func canonicalEndpoint(_ endpoint: String) -> String {
@@ -38,37 +287,37 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
return "\(normalizedScheme)://\(normalizedHost)\(port)"
}
@objc public func readEndpointToken(_ call: CAPPluginCall) {
do {
let endpoint = try call.getStringEnsure("endpoint")
if let token = try self.readToken(endpoint) {
call.resolve(["token": token])
} else {
call.resolve(["token": NSNull()])
@objc public func getValidAccessToken(_ call: CAPPluginCall) {
Task {
do {
let endpoint = try call.getStringEnsure("endpoint")
let token = try await broker.validAccessToken(endpoint)
call.resolve(["token": token ?? NSNull()])
} catch {
call.reject("Failed to get access token, \(error)", nil, error)
}
} catch {
call.reject("Failed to read endpoint token, \(error)", nil, error)
}
}
@objc public func writeEndpointToken(_ call: CAPPluginCall) {
do {
let endpoint = try call.getStringEnsure("endpoint")
let token = try call.getStringEnsure("token")
try self.writeToken(endpoint, token)
call.resolve(["ok": true])
} catch {
call.reject("Failed to write endpoint token, \(error)", nil, error)
@objc public func clearEndpointSession(_ call: CAPPluginCall) {
Task {
do {
try await broker.clear(call.getStringEnsure("endpoint"))
call.resolve(["ok": true])
} catch {
call.reject("Failed to clear auth session, \(error)", nil, error)
}
}
}
@objc public func deleteEndpointToken(_ call: CAPPluginCall) {
do {
let endpoint = try call.getStringEnsure("endpoint")
try self.deleteToken(endpoint)
call.resolve(["ok": true])
} catch {
call.reject("Failed to delete endpoint token, \(error)", nil, error)
@objc public func refreshAccessToken(_ call: CAPPluginCall) {
Task {
do {
let token = try await broker.refreshAccessToken(call.getStringEnsure("endpoint"))
call.resolve(["token": token])
} catch {
call.reject("Failed to refresh access token, \(error)", nil, error)
}
}
}
@@ -95,7 +344,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
return
}
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
try await self.exchangeSession(endpoint, data)
call.resolve(["ok": true])
} catch {
call.reject("Failed to sign in, \(error)", nil, error)
}
@@ -125,7 +375,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
return
}
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
try await self.exchangeSession(endpoint, data)
call.resolve(["ok": true])
} catch {
call.reject("Failed to sign in, \(error)", nil, error)
}
@@ -147,6 +398,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
"x-affine-client-kind": "native",
"x-captcha-token": verifyToken,
"x-captcha-challenge": challenge,
"x-captcha-provider": verifyToken == nil ? nil : (challenge == nil ? "turnstile" : "hashcash"),
], body: ["email": email, "password": password])
if response.statusCode >= 400 {
@@ -158,7 +410,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
return
}
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
try await self.exchangeSession(endpoint, data)
call.resolve(["ok": true])
} catch {
call.reject("Failed to sign in, \(error)", nil, error)
}
@@ -186,7 +439,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
return
}
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
try await self.exchangeSession(endpoint, data)
call.resolve(["ok": true])
} catch {
call.reject("Failed to sign in, \(error)", nil, error)
}
@@ -197,23 +451,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
Task {
do {
let endpoint = try call.getStringEnsure("endpoint")
let token = call.getString("token")
let (data, response) = try await self.fetch(
endpoint, method: "POST", action: "/api/auth/sign-out",
headers: [
"Authorization": token.map { "Bearer \($0)" }
], body: nil)
if response.statusCode >= 400 {
if let textBody = String(data: data, encoding: .utf8) {
call.reject(textBody)
} else {
call.reject("Failed to sign out")
}
return
}
try await broker.signOut(endpoint)
self.clearAuthCookies(endpoint)
call.resolve(["ok": true])
} catch {
@@ -222,16 +460,6 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
}
}
private func tokenFromResponse(_ data: Data) throws -> String {
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let token = json["token"] as? String
else {
throw AuthError.tokenNotFound
}
return token
}
private func exchangeCodeFromResponse(_ data: Data) throws -> String {
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let code = json["exchangeCode"] as? String
@@ -242,21 +470,33 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
return code
}
private func exchangeSession(_ endpoint: String, _ signInData: Data) async throws -> String {
private func exchangeSession(_ endpoint: String, _ signInData: Data) async throws {
let code = try exchangeCodeFromResponse(signInData)
let (data, response) = try await self.fetch(
endpoint, method: "POST", action: "/api/auth/native/exchange",
endpoint, method: "POST", action: "/api/auth/session/exchange",
headers: [
"x-affine-client-kind": "native"
], body: ["code": code])
], body: [
"code": code,
"installationId": self.installationId(),
"platform": "ios",
"deviceName": UIDevice.current.name,
])
if response.statusCode >= 400 {
throw AuthError.exchangeFailed
}
let token = try tokenFromResponse(data)
try await broker.store(endpoint, response: JSONDecoder().decode(AuthTokenResponse.self, from: data))
self.clearAuthCookies(endpoint)
return token
}
private func installationId() -> String {
let key = "app.affine.pro.auth-installation-id"
if let value = UserDefaults.standard.string(forKey: key) { return value }
let value = UUID().uuidString
UserDefaults.standard.set(value, forKey: key)
return value
}
private func clearAuthCookies(_ endpoint: String) {
@@ -274,85 +514,6 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
}
}
private func tokenQuery(_ endpoint: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: tokenService,
kSecAttrAccount as String: canonicalEndpoint(endpoint),
]
}
private func legacyTokenQuery(_ endpoint: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: tokenService,
kSecAttrAccount as String: endpoint,
]
}
private func readToken(_ endpoint: String) throws -> String? {
var query = tokenQuery(endpoint)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecItemNotFound {
guard canonicalEndpoint(endpoint) != endpoint else {
return nil
}
var legacyQuery = legacyTokenQuery(endpoint)
legacyQuery[kSecReturnData as String] = true
legacyQuery[kSecMatchLimit as String] = kSecMatchLimitOne
let legacyStatus = SecItemCopyMatching(legacyQuery as CFDictionary, &item)
if legacyStatus == errSecItemNotFound {
return nil
}
guard legacyStatus == errSecSuccess, let data = item as? Data else {
throw AuthError.internalError
}
let token = String(data: data, encoding: .utf8)
if let token = token {
try writeToken(endpoint, token)
let deleteStatus = SecItemDelete(legacyTokenQuery(endpoint) as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
throw AuthError.internalError
}
}
return token
}
guard status == errSecSuccess, let data = item as? Data else {
throw AuthError.internalError
}
return String(data: data, encoding: .utf8)
}
private func writeToken(_ endpoint: String, _ token: String) throws {
try deleteToken(endpoint)
var query = tokenQuery(endpoint)
query[kSecValueData as String] = Data(token.utf8)
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw AuthError.internalError
}
}
private func deleteToken(_ endpoint: String) throws {
let status = SecItemDelete(tokenQuery(endpoint) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw AuthError.internalError
}
if canonicalEndpoint(endpoint) != endpoint {
let legacyStatus = SecItemDelete(legacyTokenQuery(endpoint) as CFDictionary)
guard legacyStatus == errSecSuccess || legacyStatus == errSecItemNotFound else {
throw AuthError.internalError
}
}
}
private func fetch(
_ endpoint: String, method: String, action: String, headers: [String: String?], body: Encodable?
) async throws -> (Data, HTTPURLResponse) {
@@ -382,5 +543,6 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
}
enum AuthError: Error {
case invalidEndpoint, internalError, tokenNotFound, exchangeCodeNotFound, exchangeFailed
case invalidEndpoint, internalError, credentialStoreUnavailable, tokenNotFound,
exchangeCodeNotFound, exchangeFailed, invalidTokenResponse
}
@@ -0,0 +1,9 @@
import XCTest
final class AuthDateParserTests: XCTestCase {
func testAcceptsServerTimestamps() {
XCTAssertNotNil(parseAuthISO8601Date("2026-07-12T03:14:37.000Z"))
XCTAssertNotNil(parseAuthISO8601Date("2026-07-12T03:14:37Z"))
XCTAssertNil(parseAuthISO8601Date("not-a-date"))
}
}
+28 -19
View File
@@ -78,11 +78,7 @@ import { ImagePicker } from './plugins/image-picker';
import { NbStoreNativeDBApis } from './plugins/nbstore';
import { PayWall } from './plugins/paywall';
import { Preview } from './plugins/preview';
import {
deleteEndpointToken,
readEndpointToken,
writeEndpointToken,
} from './proxy';
import { clearEndpointSession, getValidAccessToken } from './proxy';
import { enableNavigationGesture$ } from './web-navigation-control';
const storeManagerClient = createStoreManagerClient();
@@ -187,46 +183,44 @@ framework.scope(ServerScope).override(AuthProvider, resolver => {
const endpoint = serverService.server.baseUrl;
return {
async signInMagicLink(email, linkToken, clientNonce) {
const { token } = await Auth.signInMagicLink({
await Auth.signInMagicLink({
endpoint,
email,
token: linkToken,
clientNonce,
});
await writeEndpointToken(endpoint, token);
},
async signInOauth(code, state, _provider, clientNonce) {
const { token } = await Auth.signInOauth({
await Auth.signInOauth({
endpoint,
code,
state,
clientNonce,
});
await writeEndpointToken(endpoint, token);
return {};
},
async signInPassword(credential) {
const { token } = await Auth.signInPassword({
await Auth.signInPassword({
endpoint,
...credential,
});
await writeEndpointToken(endpoint, token);
},
async signInOpenAppSignInCode(code) {
const { token } = await Auth.signInOpenApp({
await Auth.signInOpenApp({
endpoint,
code,
});
await writeEndpointToken(endpoint, token);
},
async signOut() {
const token = await readEndpointToken(endpoint);
try {
await Auth.signOut({ endpoint, token });
await Auth.signOut({ endpoint });
} finally {
await deleteEndpointToken(endpoint);
await clearEndpointSession(endpoint);
}
},
async clearSession() {
await clearEndpointSession(endpoint);
},
};
});
framework.impl(NativePaywallProvider, {
@@ -463,6 +457,13 @@ window.addEventListener('focus', () => {
frameworkProvider.get(LifecycleService).applicationFocus();
});
frameworkProvider.get(LifecycleService).applicationStart();
CapacitorApp.addListener('appStateChange', ({ isActive }) => {
if (!isActive) return;
const servers = frameworkProvider.get(ServersService).servers$.value;
Promise.allSettled(
servers.map(server => getValidAccessToken(server.baseUrl))
).catch(console.error);
}).catch(console.error);
const getErrorMessage = (error: unknown, fallback: string) => {
if (typeof error === 'string' && error) {
@@ -628,13 +629,21 @@ function createStoreManagerClient() {
authTokenChannelServer.addEventListener('message', event => {
const { id, endpoint } = event.data as { id?: string; endpoint?: string };
if (!id || !endpoint) return;
readEndpointToken(endpoint)
getValidAccessToken(endpoint)
.then(token => authTokenChannelServer.postMessage({ id, token }))
.catch(() => authTokenChannelServer.postMessage({ id, token: null }));
.catch(error =>
authTokenChannelServer.postMessage({
id,
error:
typeof error === 'object' && error && 'code' in error
? error.code
: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE',
})
);
});
authTokenChannelServer.start();
worker.postMessage(
{ type: 'native-auth-token-channel', port: authTokenChannelClient },
{ type: 'auth-access-token-channel', port: authTokenChannelClient },
[authTokenChannelClient]
);
return new StoreManagerClient(new OpClient(worker));
@@ -19,21 +19,49 @@ import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
import { AsyncCall } from 'async-call-rpc';
let authTokenPort: MessagePort | undefined;
const pendingTokenRequests = new Map<string, (token: string | null) => void>();
const terminalAuthErrors = new Set([
'ACCESS_TOKEN_INVALID',
'AUTH_SESSION_EXPIRED',
'AUTH_SESSION_REVOKED',
'REFRESH_TOKEN_INVALID',
'REFRESH_TOKEN_REUSED',
'UNSUPPORTED_CLIENT_VERSION',
'AUTH_SESSION_EMPTY',
]);
const pendingTokenRequests = new Map<
string,
{
resolve: (token: string | null) => void;
reject: (error: Error) => void;
}
>();
configureSocketAuthMethod((endpoint, cb) => {
readEndpointToken(endpoint)
getValidAccessToken(endpoint)
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
.catch(() => cb({}));
.catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' }));
});
globalThis.addEventListener('message', e => {
if (e.data.type === 'native-auth-token-channel') {
if (e.data.type === 'auth-access-token-channel') {
authTokenPort = e.ports[0] as MessagePort;
authTokenPort.addEventListener('message', e => {
const { id, token } = e.data as { id?: string; token?: string | null };
const { id, token, error } = e.data as {
id?: string;
token?: string | null;
error?: string;
};
if (!id) return;
pendingTokenRequests.get(id)?.(token ?? null);
const pending = pendingTokenRequests.get(id);
if (error) {
if (terminalAuthErrors.has(error)) {
pending?.resolve(null);
} else {
pending?.reject(new Error(error));
}
} else {
pending?.resolve(token ?? null);
}
pendingTokenRequests.delete(id);
});
authTokenPort.start();
@@ -66,20 +94,26 @@ globalThis.addEventListener('message', e => {
}
});
function readEndpointToken(endpoint: string) {
function getValidAccessToken(endpoint: string) {
if (!authTokenPort) {
return Promise.resolve(null);
}
const id = `${Date.now()}:${Math.random()}`;
return new Promise<string | null>(resolve => {
return new Promise<string | null>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingTokenRequests.delete(id);
resolve(null);
reject(new Error('AUTH_SESSION_TEMPORARILY_UNAVAILABLE'));
}, 5000);
pendingTokenRequests.set(id, token => {
clearTimeout(timeout);
resolve(token);
pendingTokenRequests.set(id, {
resolve: token => {
clearTimeout(timeout);
resolve(token);
},
reject: error => {
clearTimeout(timeout);
reject(error);
},
});
authTokenPort?.postMessage({ id, endpoint });
});
@@ -4,31 +4,25 @@ export interface AuthPlugin {
email: string;
token: string;
clientNonce?: string;
}): Promise<{ token: string }>;
}): Promise<void>;
signInOauth(options: {
endpoint: string;
code: string;
state: string;
clientNonce?: string;
}): Promise<{ token: string }>;
}): Promise<void>;
signInPassword(options: {
endpoint: string;
email: string;
password: string;
verifyToken?: string;
challenge?: string;
}): Promise<{ token: string }>;
signInOpenApp(options: {
endpoint: string;
code: string;
}): Promise<{ token: string }>;
signOut(options: { endpoint: string; token?: string | null }): Promise<void>;
readEndpointToken(options: {
}): Promise<void>;
signInOpenApp(options: { endpoint: string; code: string }): Promise<void>;
signOut(options: { endpoint: string }): Promise<void>;
getValidAccessToken(options: {
endpoint: string;
}): Promise<{ token?: string | null }>;
writeEndpointToken(options: {
endpoint: string;
token: string;
}): Promise<void>;
deleteEndpointToken(options: { endpoint: string }): Promise<void>;
refreshAccessToken(options: { endpoint: string }): Promise<{ token: string }>;
clearEndpointSession(options: { endpoint: string }): Promise<void>;
}
+133 -27
View File
@@ -1,3 +1,5 @@
import { canonicalAuthEndpoint } from '@affine/mobile-shared/auth/endpoint';
import { Auth } from './plugins/auth';
function authEndpointForUrl(url: string | URL) {
@@ -11,10 +13,6 @@ function authEndpointForUrl(url: string | URL) {
}
}
function canonicalEndpoint(endpoint: string) {
return authEndpointForUrl(endpoint) ?? endpoint;
}
/**
* the below code includes the custom fetch and xmlhttprequest implementation for ios webview.
* should be included in the entry file of the app or webworker.
@@ -22,22 +20,84 @@ function canonicalEndpoint(endpoint: string) {
const rawFetch = globalThis.fetch;
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
const retry = request.clone();
const origin = authEndpointForUrl(request.url);
const token = origin
? await readEndpointToken(origin).catch(() => null)
: null;
const token = origin ? await getValidAccessToken(origin) : null;
if (token) {
request.headers.set('Authorization', `Bearer ${token}`);
}
return rawFetch(request);
const response = await rawFetch(request);
if (response.status !== 401 || !origin) return response;
const body = await response
.clone()
.json()
.catch(() => null);
if (body?.code !== 'ACCESS_TOKEN_EXPIRED') return response;
const { token: refreshed } = await Auth.refreshAccessToken({
endpoint: origin,
});
retry.headers.set('Authorization', `Bearer ${refreshed}`);
return rawFetch(retry);
};
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
private request:
| {
method: string;
url: string | URL;
async: boolean;
username?: string | null;
password?: string | null;
}
| undefined;
private readonly headers = new Map<string, string>();
private requestBody?: Document | XMLHttpRequestBodyInit | null;
private replaying = false;
private hasReplayed = false;
constructor() {
super();
const suppressExpiredResponse = (event: Event) => {
if (this.replaying) event.stopImmediatePropagation();
};
this.addEventListener('load', suppressExpiredResponse, true);
this.addEventListener('loadend', suppressExpiredResponse, true);
this.addEventListener(
'readystatechange',
event => {
if (
this.readyState !== rawXMLHttpRequest.DONE ||
this.status !== 401 ||
this.replaying ||
this.hasReplayed ||
!this.request?.async
) {
return;
}
let code: unknown;
try {
code =
this.responseType === 'json'
? this.response?.code
: JSON.parse(this.responseText)?.code;
} catch {
return;
}
if (code !== 'ACCESS_TOKEN_EXPIRED') return;
event.stopImmediatePropagation();
this.replaying = true;
this.hasReplayed = true;
this.replayWithFreshToken().catch(() => {});
},
true
);
}
override open(
method: string,
url: string | URL,
@@ -45,6 +105,11 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
username?: string | null,
password?: string | null
): void {
this.request = { method, url, async, username, password };
this.headers.clear();
this.requestBody = undefined;
this.replaying = false;
this.hasReplayed = false;
xhrRequestUrls.set(this, url.toString());
return super.open(
method,
@@ -55,40 +120,81 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
);
}
override setRequestHeader(name: string, value: string): void {
this.headers.set(name, value);
super.setRequestHeader(name, value);
}
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
this.requestBody = body;
const requestUrl = xhrRequestUrls.get(this);
const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href);
(origin ? readEndpointToken(origin) : Promise.resolve(null)).then(
token => {
(origin ? getValidAccessToken(origin) : Promise.resolve(null))
.then(token => {
if (token) {
this.setRequestHeader('Authorization', `Bearer ${token}`);
super.setRequestHeader('Authorization', `Bearer ${token}`);
}
return super.send(body);
},
() => {
return super.send(body);
}
);
})
.catch(() => {
this.dispatchEvent(new Event('error'));
this.dispatchEvent(new Event('loadend'));
});
}
private async replayWithFreshToken() {
const request = this.request;
if (!request) return this.failReplay();
const origin = authEndpointForUrl(request.url);
if (!origin) return this.failReplay();
try {
const { token } = await Auth.refreshAccessToken({ endpoint: origin });
const responseType = this.responseType;
const timeout = this.timeout;
const withCredentials = this.withCredentials;
super.open(
request.method,
request.url,
true,
request.username ?? undefined,
request.password ?? undefined
);
this.replaying = false;
this.headers.forEach((value, name) => {
if (name.toLowerCase() !== 'authorization') {
super.setRequestHeader(name, value);
}
});
super.setRequestHeader('Authorization', `Bearer ${token}`);
this.responseType = responseType;
this.timeout = timeout;
this.withCredentials = withCredentials;
super.send(this.requestBody);
} catch {
this.failReplay();
}
}
private failReplay() {
this.replaying = false;
this.dispatchEvent(new Event('readystatechange'));
this.dispatchEvent(new Event('error'));
this.dispatchEvent(new Event('loadend'));
}
};
export async function readEndpointToken(
export async function getValidAccessToken(
endpoint: string
): Promise<string | null> {
const { token } = await Auth.readEndpointToken({
endpoint: canonicalEndpoint(endpoint),
const { token } = await Auth.getValidAccessToken({
endpoint: canonicalAuthEndpoint(endpoint),
});
return token ?? null;
}
export async function writeEndpointToken(endpoint: string, token: string) {
await Auth.writeEndpointToken({
endpoint: canonicalEndpoint(endpoint),
token,
export async function clearEndpointSession(endpoint: string) {
await Auth.clearEndpointSession({
endpoint: canonicalAuthEndpoint(endpoint),
});
}
export async function deleteEndpointToken(endpoint: string) {
await Auth.deleteEndpointToken({ endpoint: canonicalEndpoint(endpoint) });
}
@@ -6,6 +6,7 @@
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./auth/endpoint": "./src/auth/endpoint.ts",
"./nbstore/payload": "./src/nbstore/payload.ts"
},
"dependencies": {
@@ -0,0 +1,16 @@
import { describe, expect, test } from 'vitest';
import { canonicalAuthEndpoint } from './endpoint';
describe('canonicalAuthEndpoint', () => {
test.each([
['https://AFFINE.PRO/path?query=1', 'https://affine.pro'],
['https://affine.pro:443', 'https://affine.pro'],
['http://localhost:80/path', 'http://localhost'],
['http://localhost:8080/path', 'http://localhost:8080'],
['capacitor://localhost/path', 'capacitor://localhost/path'],
['invalid endpoint', 'invalid endpoint'],
])('normalizes %s', (endpoint, expected) => {
expect(canonicalAuthEndpoint(endpoint)).toBe(expected);
});
});
@@ -0,0 +1,10 @@
export function canonicalAuthEndpoint(endpoint: string) {
try {
const url = new URL(endpoint);
return url.protocol === 'http:' || url.protocol === 'https:'
? url.origin
: endpoint;
} catch {
return endpoint;
}
}
@@ -1 +1,2 @@
export * from './auth/endpoint';
export * from './nbstore/payload';
@@ -10,13 +10,17 @@ export const Captcha = () => {
const hasCaptchaFeature = useLiveData(captchaService.needCaptcha$);
const isLoading = useLiveData(captchaService.isLoading$);
const verifyToken = useLiveData(captchaService.verifyToken$);
const provider = useLiveData(captchaService.provider$);
const turnstile = useLiveData(captchaService.turnstile$);
const error = useLiveData(captchaService.error$);
useEffect(() => {
captchaService.revalidate();
}, [captchaService]);
if (hasCaptchaFeature) captchaService.revalidate();
}, [captchaService, hasCaptchaFeature]);
const handleTurnstileSuccess = useCallback(
(token: string) => {
captchaService.challenge$.next(undefined);
captchaService.provider$.next('turnstile');
captchaService.verifyToken$.next(token);
},
[captchaService]
@@ -26,7 +30,11 @@ export const Captcha = () => {
return null;
}
if (isLoading) {
if (error) {
return <div className={style.captchaWrapper}>Verification unavailable</div>;
}
if (isLoading || !provider) {
return <div className={style.captchaWrapper}>Loading...</div>;
}
@@ -34,11 +42,18 @@ export const Captcha = () => {
return <div className={style.captchaWrapper}>Verified Client</div>;
}
if (provider !== 'turnstile' || !turnstile) {
return <div className={style.captchaWrapper}>Verification failed</div>;
}
return (
<Turnstile
className={style.captchaWrapper}
siteKey={BUILD_CONFIG.CAPTCHA_SITE_KEY || '1x00000000000000000000AA'}
siteKey={turnstile.siteKey}
options={{ action: turnstile.action }}
onSuccess={handleTurnstileSuccess}
onExpire={() => captchaService.verifyToken$.next(undefined)}
onError={() => captchaService.verifyToken$.next(undefined)}
/>
);
};
@@ -18,7 +18,11 @@ import { ArrowRightSmallIcon, CameraIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { AuthService, ServerService } from '../../../../modules/cloud';
import {
AuthService,
type DeviceAuthSession,
ServerService,
} from '../../../../modules/cloud';
import type { SettingState } from '../types';
import { AIUsagePanel } from './ai-usage-panel';
import { DeleteAccount } from './delete-account';
@@ -170,6 +174,114 @@ const StoragePanel = ({
);
};
const DevicesPanel = () => {
const t = useI18n();
const auth = useService(AuthService);
const [sessions, setSessions] = useState<DeviceAuthSession[]>([]);
const [loading, setLoading] = useState(true);
const reload = useCallback(async () => {
setLoading(true);
try {
setSessions(await auth.listDeviceSessions());
} catch (error) {
notify.error({
title: t['com.affine.settings.devices.load-failed'](),
message: String(error),
});
} finally {
setLoading(false);
}
}, [auth, t]);
useEffect(() => {
reload().catch(error => {
notify.error({
title: t['com.affine.settings.devices.load-failed'](),
message: String(error),
});
});
}, [reload, t]);
const revoke = useCallback(
async (session: DeviceAuthSession) => {
if (
!window.confirm(
t['com.affine.settings.devices.confirm']({
device: session.deviceName ?? session.platform,
})
)
) {
return;
}
try {
await auth.revokeDeviceSession(session.id, session.current);
if (!session.current) await reload();
} catch (error) {
notify.error({
title: t['com.affine.settings.devices.sign-out-failed'](),
message: String(error),
});
}
},
[auth, reload, t]
);
const revokeAll = useCallback(async () => {
if (!window.confirm(t['com.affine.settings.devices.confirm-all']())) return;
try {
await auth.revokeAllDeviceSessions();
} catch (error) {
notify.error({
title: t['com.affine.settings.devices.sign-out-all-failed'](),
message: String(error),
});
}
}, [auth, t]);
return (
<SettingRow
name={t['com.affine.settings.devices.title']()}
desc={t['com.affine.settings.devices.description']()}
spreadCol={false}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{loading ? (
<span>{t['com.affine.settings.devices.loading']()}</span>
) : null}
{sessions.map(session => (
<div
key={session.id}
style={{ display: 'flex', alignItems: 'center', gap: 12 }}
>
<div style={{ flex: 1 }}>
<div>
{session.deviceName ?? session.platform}
{session.current
? ` (${t['com.affine.settings.devices.current']()})`
: ''}
</div>
<div>
{session.platform}
{session.appVersion ? ` · ${session.appVersion}` : ''}
{` · ${t['com.affine.settings.devices.last-used']({ time: new Date(session.lastSeenAt).toLocaleString() })}`}
</div>
</div>
<Button onClick={() => void revoke(session)}>
{t['com.affine.settings.devices.sign-out']()}
</Button>
</div>
))}
{sessions.length > 1 ? (
<Button onClick={() => void revokeAll()}>
{t['com.affine.settings.devices.sign-out-all']()}
</Button>
) : null}
</div>
</SettingRow>
);
};
export const AccountSetting = ({
onChangeSettingState,
}: {
@@ -244,6 +356,7 @@ export const AccountSetting = ({
: t['com.affine.settings.password.action.set']()}
</Button>
</SettingRow>
<DevicesPanel />
<StoragePanel onChangeSettingState={onChangeSettingState} />
{serverFeatures?.copilot && (
<AIUsagePanel onChangeSettingState={onChangeSettingState} />
@@ -0,0 +1,95 @@
import { notify } from '@affine/component';
import {
AuthService,
type DeviceAuthSession,
} from '@affine/core/modules/cloud';
import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { SettingGroup } from '../group';
import { RowLayout } from '../row.layout';
export const DevicesGroup = () => {
const t = useI18n();
const auth = useService(AuthService);
const [sessions, setSessions] = useState<DeviceAuthSession[]>([]);
const reload = useCallback(() => {
void auth
.listDeviceSessions()
.then(setSessions)
.catch(error => {
notify.error({
title: t['com.affine.settings.devices.load-failed'](),
message: String(error),
});
});
}, [auth, t]);
useEffect(reload, [reload]);
const revoke = useCallback(
async (session: DeviceAuthSession) => {
if (
!window.confirm(
t['com.affine.settings.devices.confirm']({
device: session.deviceName ?? session.platform,
})
)
) {
return;
}
try {
await auth.revokeDeviceSession(session.id, session.current);
if (!session.current) reload();
} catch (error) {
notify.error({
title: t['com.affine.settings.devices.sign-out-failed'](),
message: String(error),
});
}
},
[auth, reload, t]
);
return (
<SettingGroup title={t['com.affine.settings.devices.title']()}>
{sessions.map(session => (
<RowLayout
key={session.id}
label={
<div>
<div>{`${session.deviceName ?? session.platform}${session.current ? ` (${t['com.affine.settings.devices.current']()})` : ''}`}</div>
<div>
{t['com.affine.settings.devices.last-used']({
time: new Date(session.lastSeenAt).toLocaleString(),
})}
</div>
</div>
}
onClick={() => void revoke(session)}
>
{t['com.affine.settings.devices.sign-out']()}
</RowLayout>
))}
{sessions.length > 1 ? (
<RowLayout
label={t['com.affine.settings.devices.sign-out-all']()}
onClick={() => {
if (
window.confirm(t['com.affine.settings.devices.confirm-all']())
) {
void auth.revokeAllDeviceSessions().catch(error => {
notify.error({
title: t['com.affine.settings.devices.sign-out-all-failed'](),
message: String(error),
});
});
}
}}
/>
) : null}
</SettingGroup>
);
};
@@ -9,6 +9,7 @@ import { useEffect } from 'react';
import { AboutGroup } from './about';
import { AppearanceGroup } from './appearance';
import { DevicesGroup } from './devices';
import { ExperimentalFeatureSetting } from './experimental';
import { OthersGroup } from './others';
import * as styles from './style.css';
@@ -26,6 +27,7 @@ const MobileSetting = () => {
<UserProfile />
<UserSubscription />
<UserUsage />
<DevicesGroup />
<AppearanceGroup />
<AboutGroup />
<ExperimentalFeatureSetting />
@@ -65,6 +65,9 @@ export function configureDefaultAuthProvider(framework: Framework) {
if (credential.verifyToken) {
headers['x-captcha-token'] = credential.verifyToken;
headers['x-captcha-provider'] = credential.challenge
? 'hashcash'
: 'turnstile';
}
if (credential.challenge) {
headers['x-captcha-challenge'] = credential.challenge;
@@ -72,7 +75,10 @@ export function configureDefaultAuthProvider(framework: Framework) {
const res = await fetchService.fetch('/api/auth/sign-in', {
method: 'POST',
body: JSON.stringify(credential),
body: JSON.stringify({
email: credential.email,
password: credential.password,
}),
headers: {
'content-type': 'application/json',
...headers,
@@ -94,6 +100,7 @@ export function configureDefaultAuthProvider(framework: Framework) {
headers: csrfToken ? { 'x-affine-csrf-token': csrfToken } : undefined,
});
},
async clearSession() {},
};
});
}
@@ -17,7 +17,7 @@ export {
getSelfHostedServerName,
} from './server-name';
export { AccessTokenService } from './services/access-token';
export { AuthService } from './services/auth';
export { AuthService, type DeviceAuthSession } from './services/auth';
export { CaptchaService } from './services/captcha';
export { DefaultServerService } from './services/default-server';
export { DocCreatedByUpdatedBySyncService } from './services/doc-created-by-updated-by-sync';
@@ -33,6 +33,8 @@ export interface AuthProvider {
signInOpenAppSignInCode(code: string): Promise<void>;
signOut(): Promise<void>;
clearSession(): Promise<void>;
}
export const AuthProvider = createIdentifier<AuthProvider>('AuthProvider');
@@ -19,6 +19,28 @@ import { assertSupportedServerVersion } from '../stores/server-config';
import type { FetchService } from './fetch';
import type { ServerService } from './server';
export interface DeviceAuthSession {
id: string;
installationId: string;
platform: 'ios' | 'android' | 'electron';
deviceName?: string | null;
appVersion?: string | null;
createdAt: string;
lastSeenAt: string;
idleExpiresAt: string;
absoluteExpiresAt: string;
current: boolean;
}
function csrfHeader(): Record<string, string> {
if (typeof document === 'undefined') return {};
const prefix = 'affine_csrf_token=';
const cookie = document.cookie
.split('; ')
.find(value => value.startsWith(prefix));
return cookie ? { 'x-affine-csrf-token': cookie.slice(prefix.length) } : {};
}
@OnEvent(ApplicationFocused, e => e.onApplicationFocused)
@OnEvent(ServerStarted, e => e.onServerStarted)
export class AuthService extends Service {
@@ -262,6 +284,42 @@ export class AuthService extends Service {
this.session.revalidate();
}
async listDeviceSessions() {
const response = await this.fetchService.fetch('/api/auth/sessions');
return (await response.json()) as DeviceAuthSession[];
}
async revokeDeviceSession(id: string, current: boolean) {
await this.fetchService.fetch(
`/api/auth/sessions/${encodeURIComponent(id)}`,
{
method: 'DELETE',
headers: csrfHeader(),
}
);
if (current) {
try {
await this.store.clearSession();
} finally {
this.store.setCachedAuthSession(null);
this.session.revalidate();
}
}
}
async revokeAllDeviceSessions() {
await this.fetchService.fetch('/api/auth/sessions/revoke-all', {
method: 'POST',
headers: csrfHeader(),
});
try {
await this.store.clearSession();
} finally {
this.store.setCachedAuthSession(null);
this.session.revalidate();
}
}
async deleteAccount() {
const res = await this.store.deleteAccount();
this.store.setCachedAuthSession(null);
@@ -277,6 +335,7 @@ export class AuthService extends Service {
captchaHeaders(token: string, challenge?: string) {
const headers: Record<string, string> = {
'x-captcha-token': token,
'x-captcha-provider': challenge ? 'hashcash' : 'turnstile',
};
if (challenge) {
@@ -0,0 +1,78 @@
import { Framework, LiveData } from '@toeverything/infra';
import { describe, expect, test, vi } from 'vitest';
import { ValidatorProvider } from '../provider/validator';
import { CaptchaService } from './captcha';
import { FetchService } from './fetch';
import { ServerService } from './server';
function createService(
response: Record<string, unknown>,
validate?: (challenge: string, resource: string) => Promise<string>
) {
const fetch = vi.fn(
async () =>
new Response(JSON.stringify(response), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
const framework = new Framework();
framework.service(ServerService, {
server: { features$: new LiveData({ captcha: true }) },
} as any);
framework.service(FetchService, { fetch } as any);
if (validate) framework.impl(ValidatorProvider, { validate });
framework.service(CaptchaService, f => {
return new CaptchaService(
f.get(ServerService),
f.get(FetchService),
f.getOptional(ValidatorProvider)
);
});
const service = framework.provider().get(CaptchaService);
return { fetch, service };
}
describe('CaptchaService', () => {
test('mints a Hashcash proof from the server challenge', async () => {
const validate = vi.fn(async () => 'hashcash-proof');
const { fetch, service } = createService(
{
provider: 'hashcash',
challenge: 'challenge-id',
resource: 'resource-id',
},
validate
);
service.revalidate();
await vi.waitFor(() => expect(service.isLoading$.value).toBe(false));
expect(fetch).toHaveBeenCalledWith('/api/auth/captcha', {
signal: expect.any(AbortSignal),
});
expect(validate).toHaveBeenCalledWith('challenge-id', 'resource-id');
expect(service.provider$.value).toBe('hashcash');
expect(service.challenge$.value).toBe('challenge-id');
expect(service.verifyToken$.value).toBe('hashcash-proof');
});
test('uses server-provided Turnstile configuration without Hashcash', async () => {
const { service } = createService({
provider: 'turnstile',
siteKey: 'site-key',
action: 'auth-sign-in',
});
service.revalidate();
await vi.waitFor(() => expect(service.isLoading$.value).toBe(false));
expect(service.provider$.value).toBe('turnstile');
expect(service.turnstile$.value).toEqual({
siteKey: 'site-key',
action: 'auth-sign-in',
});
expect(service.verifyToken$.value).toBeUndefined();
});
});
@@ -18,6 +18,10 @@ export class CaptchaService extends Service {
r => r?.captcha || false
);
challenge$ = new LiveData<string | undefined>(undefined);
provider$ = new LiveData<'hashcash' | 'turnstile' | undefined>(undefined);
turnstile$ = new LiveData<{ siteKey: string; action: string } | undefined>(
undefined
);
isLoading$ = new LiveData(false);
verifyToken$ = new LiveData<string | undefined>(undefined);
error$ = new LiveData<any | undefined>(undefined);
@@ -33,36 +37,59 @@ export class CaptchaService extends Service {
revalidate = effect(
exhaustMap(() => {
return fromPromise(async signal => {
if (!this.needCaptcha$.value || !this.validatorProvider) {
if (!this.needCaptcha$.value) {
return {};
}
const res = await this.fetchService.fetch('/api/auth/challenge', {
const res = await this.fetchService.fetch('/api/auth/captcha', {
signal,
});
const data = (await res.json()) as {
challenge: string;
resource: string;
provider: 'hashcash' | 'turnstile';
challenge?: string;
resource?: string;
siteKey?: string;
action?: string;
};
if (!data || !data.challenge || !data.resource) {
throw new Error('Invalid challenge');
if (data.provider === 'turnstile') {
if (!data.siteKey || !data.action) {
throw new Error('Invalid Turnstile configuration');
}
return {
provider: data.provider,
turnstile: { siteKey: data.siteKey, action: data.action },
};
}
if (
data.provider !== 'hashcash' ||
!data.challenge ||
!data.resource ||
!this.validatorProvider
) {
throw new Error('Invalid Hashcash challenge');
}
const token = await this.validatorProvider.validate(
data.challenge,
data.resource
);
return {
provider: data.provider,
token,
challenge: data.challenge,
};
}).pipe(
tap(({ challenge, token }) => {
tap(({ challenge, provider, token, turnstile }) => {
this.provider$.next(provider);
this.turnstile$.next(turnstile);
this.verifyToken$.next(token);
this.challenge$.next(challenge);
this.resetAfter5min();
if (token) this.resetAfter5min();
}),
catchErrorInto(this.error$),
onStart(() => {
this.error$.next(undefined);
this.challenge$.next(undefined);
this.provider$.next(undefined);
this.turnstile$.next(undefined);
this.verifyToken$.next(undefined);
this.isLoading$.next(true);
}),
@@ -81,6 +108,8 @@ export class CaptchaService extends Service {
}).pipe(
tap(_ => {
this.challenge$.next(undefined);
this.provider$.next(undefined);
this.turnstile$.next(undefined);
this.verifyToken$.next(undefined);
this.isLoading$.next(false);
})
@@ -59,6 +59,7 @@ export class FetchService extends Service {
headers: {
...init?.headers,
'x-affine-version': BUILD_CONFIG.appVersion,
'x-affine-client-kind': BUILD_CONFIG.isNative ? 'native' : 'web',
},
}
);
@@ -137,7 +137,22 @@ export class AuthStore extends Store {
}
async signOut() {
await this.authProvider.signOut();
try {
await this.authProvider.signOut();
} finally {
await this.deauthenticateRealtime();
}
}
async clearSession() {
try {
await this.authProvider.clearSession();
} finally {
await this.deauthenticateRealtime();
}
}
private async deauthenticateRealtime() {
await this.nbstoreService.realtime.configure({
endpoint: this.serverService.server.baseUrl,
authenticated: false,
@@ -11,12 +11,12 @@
"fa": 90,
"fr": 94,
"hi": 1,
"it": 92,
"it": 91,
"ja": 90,
"kk": 98,
"ko": 91,
"nb-NO": 45,
"pl": 92,
"pl": 91,
"pt-BR": 90,
"ru": 92,
"sv-SE": 90,
+52
View File
@@ -9036,6 +9036,58 @@ export function useAFFiNEI18N(): {
* `Add icon`
*/
["com.affine.docIconPicker.placeholder"](): string;
/**
* `Devices`
*/
["com.affine.settings.devices.title"](): string;
/**
* `Devices with an active sign-in session`
*/
["com.affine.settings.devices.description"](): string;
/**
* `current`
*/
["com.affine.settings.devices.current"](): string;
/**
* `Last used {{time}}`
*/
["com.affine.settings.devices.last-used"](options: {
readonly time: string;
}): string;
/**
* `Loading…`
*/
["com.affine.settings.devices.loading"](): string;
/**
* `Sign out`
*/
["com.affine.settings.devices.sign-out"](): string;
/**
* `Sign out all devices`
*/
["com.affine.settings.devices.sign-out-all"](): string;
/**
* `Sign out {{device}}?`
*/
["com.affine.settings.devices.confirm"](options: {
readonly device: string;
}): string;
/**
* `Sign out every device?`
*/
["com.affine.settings.devices.confirm-all"](): string;
/**
* `Failed to load devices`
*/
["com.affine.settings.devices.load-failed"](): string;
/**
* `Failed to sign out device`
*/
["com.affine.settings.devices.sign-out-failed"](): string;
/**
* `Failed to sign out devices`
*/
["com.affine.settings.devices.sign-out-all-failed"](): string;
/**
* `An internal error occurred.`
*/
@@ -2262,6 +2262,18 @@
"com.affine.context-menu.paste": "Paste",
"com.affine.context-menu.cut": "Cut",
"com.affine.docIconPicker.placeholder": "Add icon",
"com.affine.settings.devices.title": "Devices",
"com.affine.settings.devices.description": "Devices with an active sign-in session",
"com.affine.settings.devices.current": "current",
"com.affine.settings.devices.last-used": "Last used {{time}}",
"com.affine.settings.devices.loading": "Loading…",
"com.affine.settings.devices.sign-out": "Sign out",
"com.affine.settings.devices.sign-out-all": "Sign out all devices",
"com.affine.settings.devices.confirm": "Sign out {{device}}?",
"com.affine.settings.devices.confirm-all": "Sign out every device?",
"com.affine.settings.devices.load-failed": "Failed to load devices",
"com.affine.settings.devices.sign-out-failed": "Failed to sign out device",
"com.affine.settings.devices.sign-out-all-failed": "Failed to sign out devices",
"error.INTERNAL_SERVER_ERROR": "An internal error occurred.",
"error.NETWORK_ERROR": "Network error.",
"error.TOO_MANY_REQUEST": "Too many requests.",
-1
View File
@@ -36,7 +36,6 @@ declare interface BUILD_CONFIG_TYPE {
linkPreviewUrl: string;
SENTRY_DSN: string;
CAPTCHA_SITE_KEY: string;
}
declare var BUILD_CONFIG: BUILD_CONFIG_TYPE;
-1
View File
@@ -52,7 +52,6 @@ export function getBuildConfig(
imageProxyUrl: '/api/worker/image-proxy',
linkPreviewUrl: '/api/worker/link-preview',
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
CAPTCHA_SITE_KEY: process.env.CAPTCHA_SITE_KEY ?? '',
};
},
get beta() {
+1
View File
@@ -1209,6 +1209,7 @@ export const PackageList = [
location: 'packages/frontend/apps/electron',
name: '@affine/electron',
workspaceDependencies: [
'packages/common/auth',
'tools/utils',
'packages/frontend/i18n',
'packages/frontend/native',
+2 -1
View File
@@ -262,7 +262,7 @@ __metadata:
languageName: unknown
linkType: soft
"@affine/auth@workspace:packages/common/auth":
"@affine/auth@workspace:*, @affine/auth@workspace:packages/common/auth":
version: 0.0.0-use.local
resolution: "@affine/auth@workspace:packages/common/auth"
dependencies:
@@ -555,6 +555,7 @@ __metadata:
resolution: "@affine/electron@workspace:packages/frontend/apps/electron"
dependencies:
"@affine-tools/utils": "workspace:*"
"@affine/auth": "workspace:*"
"@affine/i18n": "workspace:*"
"@affine/native": "workspace:*"
"@affine/nbstore": "workspace:*"