From abf37d3dfa4711f74895316096a7cf0d983637b3 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:11:02 +0800 Subject: [PATCH] feat(core): improve login flow (#15219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### PR Dependency Tree * **PR #15219** šŸ‘ˆ This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## 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. --- .github/workflows/build-images.yml | 3 - .github/workflows/build-test.yml | 109 +++ .../apps/android/App/app/build.gradle | 11 +- .../pro/plugin/AuthSessionBrokerTest.kt | 237 ++++++ .../myapp/ExampleInstrumentedTest.java | 2 +- .../java/app/affine/pro/plugin/AuthPlugin.kt | 710 ++++++++++-------- .../java/app/affine/pro/utils/DataStore.kt | 10 + .../build.gradle | 6 +- .../App/gradle/gradle-daemon-jvm.properties | 13 + .../android/App/gradle/libs.versions.toml | 2 +- .../frontend/apps/android/App/settings.gradle | 3 + packages/frontend/apps/android/src/app.tsx | 47 +- .../apps/android/src/nbstore.worker.ts | 59 +- .../android/src/plugins/auth/definitions.ts | 22 +- packages/frontend/apps/android/src/proxy.ts | 160 +++- .../src/app/effects/modules.ts | 3 + .../src/background-worker/index.ts | 4 +- packages/frontend/apps/electron/package.json | 1 + .../electron/src/main/auth/auth-session.ts | 293 ++++++++ .../apps/electron/src/main/auth/handlers.ts | 65 +- .../electron/src/main/auth/native-token.ts | 100 --- .../apps/electron/src/main/protocol.ts | 62 +- .../electron/test/main/auth-session.spec.ts | 311 ++++++++ packages/frontend/apps/electron/tsconfig.json | 1 + .../ios/App/App.xcodeproj/project.pbxproj | 101 +++ .../xcschemes/AFFiNETests.xcscheme | 44 ++ .../xcshareddata/xcschemes/App.xcscheme | 26 + .../App/App/Plugins/Auth/AuthDateParser.swift | 7 + .../ios/App/App/Plugins/Auth/AuthPlugin.swift | 450 +++++++---- .../App/AppTests/AuthDateParserTests.swift | 9 + packages/frontend/apps/ios/src/app.tsx | 47 +- .../frontend/apps/ios/src/nbstore.worker.ts | 58 +- .../apps/ios/src/plugins/auth/definitions.ts | 22 +- packages/frontend/apps/ios/src/proxy.ts | 160 +++- .../frontend/apps/mobile-shared/package.json | 1 + .../mobile-shared/src/auth/endpoint.spec.ts | 16 + .../apps/mobile-shared/src/auth/endpoint.ts | 10 + .../frontend/apps/mobile-shared/src/index.ts | 1 + .../core/src/components/sign-in/captcha.tsx | 23 +- .../dialogs/setting/account-setting/index.tsx | 115 ++- .../mobile/dialogs/setting/devices/index.tsx | 95 +++ .../core/src/mobile/dialogs/setting/index.tsx | 2 + .../core/src/modules/cloud/impl/auth.ts | 9 +- .../frontend/core/src/modules/cloud/index.ts | 2 +- .../core/src/modules/cloud/provider/auth.ts | 2 + .../core/src/modules/cloud/services/auth.ts | 59 ++ .../modules/cloud/services/captcha.spec.ts | 78 ++ .../src/modules/cloud/services/captcha.ts | 45 +- .../core/src/modules/cloud/services/fetch.ts | 1 + .../core/src/modules/cloud/stores/auth.ts | 17 +- .../i18n/src/i18n-completenesses.json | 4 +- packages/frontend/i18n/src/i18n.gen.ts | 52 ++ packages/frontend/i18n/src/resources/en.json | 12 + tools/@types/build-config/__all.d.ts | 1 - tools/utils/src/build-config.ts | 1 - tools/utils/src/workspace.gen.ts | 1 + yarn.lock | 3 +- 57 files changed, 2919 insertions(+), 789 deletions(-) create mode 100644 packages/frontend/apps/android/App/app/src/androidTest/java/app/affine/pro/plugin/AuthSessionBrokerTest.kt create mode 100644 packages/frontend/apps/android/App/gradle/gradle-daemon-jvm.properties create mode 100644 packages/frontend/apps/electron/src/main/auth/auth-session.ts delete mode 100644 packages/frontend/apps/electron/src/main/auth/native-token.ts create mode 100644 packages/frontend/apps/electron/test/main/auth-session.spec.ts create mode 100644 packages/frontend/apps/ios/App/App.xcodeproj/xcshareddata/xcschemes/AFFiNETests.xcscheme create mode 100644 packages/frontend/apps/ios/App/App/Plugins/Auth/AuthDateParser.swift create mode 100644 packages/frontend/apps/ios/App/AppTests/AuthDateParserTests.swift create mode 100644 packages/frontend/apps/mobile-shared/src/auth/endpoint.spec.ts create mode 100644 packages/frontend/apps/mobile-shared/src/auth/endpoint.ts create mode 100644 packages/frontend/core/src/mobile/dialogs/setting/devices/index.tsx create mode 100644 packages/frontend/core/src/modules/cloud/services/captcha.spec.ts diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index 01a2bc2cb9..0ef4aec889 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -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 }} diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index e172ffef3a..6ff1af7fb9 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -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 diff --git a/packages/frontend/apps/android/App/app/build.gradle b/packages/frontend/apps/android/App/app/build.gradle index 822b3cdadd..75c0529da8 100644 --- a/packages/frontend/apps/android/App/app/build.gradle +++ b/packages/frontend/apps/android/App/app/build.gradle @@ -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) diff --git a/packages/frontend/apps/android/App/app/src/androidTest/java/app/affine/pro/plugin/AuthSessionBrokerTest.kt b/packages/frontend/apps/android/App/app/src/androidTest/java/app/affine/pro/plugin/AuthSessionBrokerTest.kt new file mode 100644 index 0000000000..d85bf21b73 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/androidTest/java/app/affine/pro/plugin/AuthSessionBrokerTest.kt @@ -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 + } + } +} diff --git a/packages/frontend/apps/android/App/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/packages/frontend/apps/android/App/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java index f2c2217efa..1a4f392ca5 100644 --- a/packages/frontend/apps/android/App/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java +++ b/packages/frontend/apps/android/App/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -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()); } } diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt index 64a6dc10af..c689083779 100644 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt @@ -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() + private val refreshes = ConcurrentHashMap>() + private val epochs = ConcurrentHashMap() + private val pending = ConcurrentHashMap() + + 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" } } diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/utils/DataStore.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/utils/DataStore.kt index 31304f3f24..19577771af 100644 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/utils/DataStore.kt +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/utils/DataStore.kt @@ -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 by preferencesDataStore(name = "affine") +internal val authDataStoreCorruptionHandler = + ReplaceFileCorruptionHandler { emptyPreferences() } + +val Context.authDataStore: DataStore by preferencesDataStore( + name = "auth-sessions", + corruptionHandler = authDataStoreCorruptionHandler, +) + suspend fun DataStore.set(key: String, value: String) { edit { it[stringPreferencesKey(key)] = value diff --git a/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/build.gradle b/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/build.gradle index b6bb5f1602..d949bbd35b 100644 --- a/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/build.gradle +++ b/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/build.gradle @@ -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() -} \ No newline at end of file +} diff --git a/packages/frontend/apps/android/App/gradle/gradle-daemon-jvm.properties b/packages/frontend/apps/android/App/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000000..baa28d154c --- /dev/null +++ b/packages/frontend/apps/android/App/gradle/gradle-daemon-jvm.properties @@ -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 diff --git a/packages/frontend/apps/android/App/gradle/libs.versions.toml b/packages/frontend/apps/android/App/gradle/libs.versions.toml index e1605b5848..2b2c4279ef 100644 --- a/packages/frontend/apps/android/App/gradle/libs.versions.toml +++ b/packages/frontend/apps/android/App/gradle/libs.versions.toml @@ -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" diff --git a/packages/frontend/apps/android/App/settings.gradle b/packages/frontend/apps/android/App/settings.gradle index c00cf3aebe..dfd0d62217 100644 --- a/packages/frontend/apps/android/App/settings.gradle +++ b/packages/frontend/apps/android/App/settings.gradle @@ -11,6 +11,9 @@ pluginManagement { gradlePluginPortal() } } +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0' +} include ':app' include ':service' diff --git a/packages/frontend/apps/android/src/app.tsx b/packages/frontend/apps/android/src/app.tsx index 9aa8953d48..35af06d90a 100644 --- a/packages/frontend/apps/android/src/app.tsx +++ b/packages/frontend/apps/android/src/app.tsx @@ -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)); diff --git a/packages/frontend/apps/android/src/nbstore.worker.ts b/packages/frontend/apps/android/src/nbstore.worker.ts index 1ababc38c3..9031a75aa0 100644 --- a/packages/frontend/apps/android/src/nbstore.worker.ts +++ b/packages/frontend/apps/android/src/nbstore.worker.ts @@ -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 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(resolve => { + return new Promise((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 }); }); diff --git a/packages/frontend/apps/android/src/plugins/auth/definitions.ts b/packages/frontend/apps/android/src/plugins/auth/definitions.ts index f432fcfaa0..59ac9ee97a 100644 --- a/packages/frontend/apps/android/src/plugins/auth/definitions.ts +++ b/packages/frontend/apps/android/src/plugins/auth/definitions.ts @@ -4,31 +4,25 @@ export interface AuthPlugin { email: string; token: string; clientNonce?: string; - }): Promise<{ token: string }>; + }): Promise; signInOauth(options: { endpoint: string; code: string; state: string; clientNonce?: string; - }): Promise<{ token: string }>; + }): Promise; 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; - readEndpointToken(options: { + }): Promise; + signInOpenApp(options: { endpoint: string; code: string }): Promise; + signOut(options: { endpoint: string }): Promise; + getValidAccessToken(options: { endpoint: string; }): Promise<{ token?: string | null }>; - writeEndpointToken(options: { - endpoint: string; - token: string; - }): Promise; - deleteEndpointToken(options: { endpoint: string }): Promise; + refreshAccessToken(options: { endpoint: string }): Promise<{ token: string }>; + clearEndpointSession(options: { endpoint: string }): Promise; } diff --git a/packages/frontend/apps/android/src/proxy.ts b/packages/frontend/apps/android/src/proxy.ts index 963d3d1936..b1cb0370b5 100644 --- a/packages/frontend/apps/android/src/proxy.ts +++ b/packages/frontend/apps/android/src/proxy.ts @@ -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(); 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(); + 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 { - 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) }); -} diff --git a/packages/frontend/apps/electron-renderer/src/app/effects/modules.ts b/packages/frontend/apps/electron-renderer/src/app/effects/modules.ts index b0b004ce96..f2e0315eb4 100644 --- a/packages/frontend/apps/electron-renderer/src/app/effects/modules.ts +++ b/packages/frontend/apps/electron-renderer/src/app/effects/modules.ts @@ -122,6 +122,9 @@ export function setupModules() { async signOut() { await apis.handler.auth.signOut(endpoint); }, + async clearSession() { + await apis.handler.auth.clearSession(endpoint); + }, }; }); diff --git a/packages/frontend/apps/electron-renderer/src/background-worker/index.ts b/packages/frontend/apps/electron-renderer/src/background-worker/index.ts index fd81001331..d2d2c4c7cc 100644 --- a/packages/frontend/apps/electron-renderer/src/background-worker/index.ts +++ b/packages/frontend/apps/electron-renderer/src/background-worker/index.ts @@ -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([ diff --git a/packages/frontend/apps/electron/package.json b/packages/frontend/apps/electron/package.json index 605ab4002d..ac0bfabf27 100644 --- a/packages/frontend/apps/electron/package.json +++ b/packages/frontend/apps/electron/package.json @@ -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", diff --git a/packages/frontend/apps/electron/src/main/auth/auth-session.ts b/packages/frontend/apps/electron/src/main/auth/auth-session.ts new file mode 100644 index 0000000000..e51eee9060 --- /dev/null +++ b/packages/frontend/apps/electron/src/main/auth/auth-session.ts @@ -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(); +const memoryStore = new Map(); +let fileMutation = Promise.resolve(); +let installationId: Promise | 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> { + 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) => 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 +) { + 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)); +} diff --git a/packages/frontend/apps/electron/src/main/auth/handlers.ts b/packages/frontend/apps/electron/src/main/auth/handlers.ts index e8a7cc0e15..b83bfd64c6 100644 --- a/packages/frontend/apps/electron/src/main/auth/handlers.ts +++ b/packages/frontend/apps/electron/src/main/auth/handlers.ts @@ -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); - if (!body.token) { - throw new Error('Missing native auth token.'); - } - - const persistent = setNativeAuthToken(endpoint, body.token); + const body = await readJson(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; diff --git a/packages/frontend/apps/electron/src/main/auth/native-token.ts b/packages/frontend/apps/electron/src/main/auth/native-token.ts deleted file mode 100644 index 6a6b5c39cb..0000000000 --- a/packages/frontend/apps/electron/src/main/auth/native-token.ts +++ /dev/null @@ -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 = {}; - -function normalizeEndpoint(endpoint: string) { - return new URL(endpoint).origin; -} - -function readStore(): Record { - 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) { - 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; - } -} diff --git a/packages/frontend/apps/electron/src/main/protocol.ts b/packages/frontend/apps/electron/src/main/protocol.ts index 46db8356e0..c50a1f05e2 100644 --- a/packages/frontend/apps/electron/src/main/protocol.ts +++ b/packages/frontend/apps/electron/src/main/protocol.ts @@ -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, }); }); diff --git a/packages/frontend/apps/electron/test/main/auth-session.spec.ts b/packages/frontend/apps/electron/test/main/auth-session.spec.ts new file mode 100644 index 0000000000..3f597ea155 --- /dev/null +++ b/packages/frontend/apps/electron/test/main/auth-session.spec.ts @@ -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(), + 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>() + .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', + }, + }; +} diff --git a/packages/frontend/apps/electron/tsconfig.json b/packages/frontend/apps/electron/tsconfig.json index 1fad61568a..a1c3d7d801 100644 --- a/packages/frontend/apps/electron/tsconfig.json +++ b/packages/frontend/apps/electron/tsconfig.json @@ -7,6 +7,7 @@ }, "include": ["./src"], "references": [ + { "path": "../../../common/auth" }, { "path": "../../../../tools/utils" }, { "path": "../../i18n" }, { "path": "../../native" }, diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj index b2fa77d8f0..9541054267 100644 --- a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj +++ b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; 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 = ""; }; + AA0000010000000000000000 /* AuthDateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../App/Plugins/Auth/AuthDateParser.swift; sourceTree = ""; }; + AA0000020000000000000000 /* AuthDateParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthDateParserTests.swift; sourceTree = ""; }; + 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 = ""; @@ -192,6 +206,15 @@ path = App; sourceTree = ""; }; + AA00000D0000000000000000 /* AppTests */ = { + isa = PBXGroup; + children = ( + AA0000010000000000000000 /* AuthDateParser.swift */, + AA0000020000000000000000 /* AuthDateParserTests.swift */, + ); + path = AppTests; + sourceTree = ""; + }; 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 = ( diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/xcshareddata/xcschemes/AFFiNETests.xcscheme b/packages/frontend/apps/ios/App/App.xcodeproj/xcshareddata/xcschemes/AFFiNETests.xcscheme new file mode 100644 index 0000000000..9fd7c1eaf8 --- /dev/null +++ b/packages/frontend/apps/ios/App/App.xcodeproj/xcshareddata/xcschemes/AFFiNETests.xcscheme @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/packages/frontend/apps/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme index 2e3b7fcded..e7fc009067 100644 --- a/packages/frontend/apps/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +++ b/packages/frontend/apps/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -21,6 +21,20 @@ ReferencedContainer = "container:App.xcodeproj"> + + + + + + + + + + Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fractional.date(from: value) ?? ISO8601DateFormatter().date(from: value) +} diff --git a/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift index fa2f26c032..67796e12f2 100644 --- a/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift @@ -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 +} + +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 } diff --git a/packages/frontend/apps/ios/App/AppTests/AuthDateParserTests.swift b/packages/frontend/apps/ios/App/AppTests/AuthDateParserTests.swift new file mode 100644 index 0000000000..b14246c3d2 --- /dev/null +++ b/packages/frontend/apps/ios/App/AppTests/AuthDateParserTests.swift @@ -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")) + } +} diff --git a/packages/frontend/apps/ios/src/app.tsx b/packages/frontend/apps/ios/src/app.tsx index 62097c591e..a613ef2709 100644 --- a/packages/frontend/apps/ios/src/app.tsx +++ b/packages/frontend/apps/ios/src/app.tsx @@ -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)); diff --git a/packages/frontend/apps/ios/src/nbstore.worker.ts b/packages/frontend/apps/ios/src/nbstore.worker.ts index 1ababc38c3..5fb1d5c925 100644 --- a/packages/frontend/apps/ios/src/nbstore.worker.ts +++ b/packages/frontend/apps/ios/src/nbstore.worker.ts @@ -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 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(resolve => { + return new Promise((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 }); }); diff --git a/packages/frontend/apps/ios/src/plugins/auth/definitions.ts b/packages/frontend/apps/ios/src/plugins/auth/definitions.ts index f432fcfaa0..59ac9ee97a 100644 --- a/packages/frontend/apps/ios/src/plugins/auth/definitions.ts +++ b/packages/frontend/apps/ios/src/plugins/auth/definitions.ts @@ -4,31 +4,25 @@ export interface AuthPlugin { email: string; token: string; clientNonce?: string; - }): Promise<{ token: string }>; + }): Promise; signInOauth(options: { endpoint: string; code: string; state: string; clientNonce?: string; - }): Promise<{ token: string }>; + }): Promise; 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; - readEndpointToken(options: { + }): Promise; + signInOpenApp(options: { endpoint: string; code: string }): Promise; + signOut(options: { endpoint: string }): Promise; + getValidAccessToken(options: { endpoint: string; }): Promise<{ token?: string | null }>; - writeEndpointToken(options: { - endpoint: string; - token: string; - }): Promise; - deleteEndpointToken(options: { endpoint: string }): Promise; + refreshAccessToken(options: { endpoint: string }): Promise<{ token: string }>; + clearEndpointSession(options: { endpoint: string }): Promise; } diff --git a/packages/frontend/apps/ios/src/proxy.ts b/packages/frontend/apps/ios/src/proxy.ts index 963d3d1936..b1cb0370b5 100644 --- a/packages/frontend/apps/ios/src/proxy.ts +++ b/packages/frontend/apps/ios/src/proxy.ts @@ -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(); 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(); + 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 { - 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) }); -} diff --git a/packages/frontend/apps/mobile-shared/package.json b/packages/frontend/apps/mobile-shared/package.json index 5cdeac663f..328001066e 100644 --- a/packages/frontend/apps/mobile-shared/package.json +++ b/packages/frontend/apps/mobile-shared/package.json @@ -6,6 +6,7 @@ "sideEffects": false, "exports": { ".": "./src/index.ts", + "./auth/endpoint": "./src/auth/endpoint.ts", "./nbstore/payload": "./src/nbstore/payload.ts" }, "dependencies": { diff --git a/packages/frontend/apps/mobile-shared/src/auth/endpoint.spec.ts b/packages/frontend/apps/mobile-shared/src/auth/endpoint.spec.ts new file mode 100644 index 0000000000..62d76a34b7 --- /dev/null +++ b/packages/frontend/apps/mobile-shared/src/auth/endpoint.spec.ts @@ -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); + }); +}); diff --git a/packages/frontend/apps/mobile-shared/src/auth/endpoint.ts b/packages/frontend/apps/mobile-shared/src/auth/endpoint.ts new file mode 100644 index 0000000000..6086a6b479 --- /dev/null +++ b/packages/frontend/apps/mobile-shared/src/auth/endpoint.ts @@ -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; + } +} diff --git a/packages/frontend/apps/mobile-shared/src/index.ts b/packages/frontend/apps/mobile-shared/src/index.ts index a203d83dd0..50516b4a08 100644 --- a/packages/frontend/apps/mobile-shared/src/index.ts +++ b/packages/frontend/apps/mobile-shared/src/index.ts @@ -1 +1,2 @@ +export * from './auth/endpoint'; export * from './nbstore/payload'; diff --git a/packages/frontend/core/src/components/sign-in/captcha.tsx b/packages/frontend/core/src/components/sign-in/captcha.tsx index 908dc12837..94ac5ba87f 100644 --- a/packages/frontend/core/src/components/sign-in/captcha.tsx +++ b/packages/frontend/core/src/components/sign-in/captcha.tsx @@ -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
Verification unavailable
; + } + + if (isLoading || !provider) { return
Loading...
; } @@ -34,11 +42,18 @@ export const Captcha = () => { return
Verified Client
; } + if (provider !== 'turnstile' || !turnstile) { + return
Verification failed
; + } + return ( captchaService.verifyToken$.next(undefined)} + onError={() => captchaService.verifyToken$.next(undefined)} /> ); }; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx index 23d3e5e3f0..f01be4cc92 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx @@ -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([]); + 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 ( + +
+ {loading ? ( + {t['com.affine.settings.devices.loading']()} + ) : null} + {sessions.map(session => ( +
+
+
+ {session.deviceName ?? session.platform} + {session.current + ? ` (${t['com.affine.settings.devices.current']()})` + : ''} +
+
+ {session.platform} + {session.appVersion ? ` Ā· ${session.appVersion}` : ''} + {` Ā· ${t['com.affine.settings.devices.last-used']({ time: new Date(session.lastSeenAt).toLocaleString() })}`} +
+
+ +
+ ))} + {sessions.length > 1 ? ( + + ) : null} +
+
+ ); +}; + export const AccountSetting = ({ onChangeSettingState, }: { @@ -244,6 +356,7 @@ export const AccountSetting = ({ : t['com.affine.settings.password.action.set']()} + {serverFeatures?.copilot && ( diff --git a/packages/frontend/core/src/mobile/dialogs/setting/devices/index.tsx b/packages/frontend/core/src/mobile/dialogs/setting/devices/index.tsx new file mode 100644 index 0000000000..be2bc77963 --- /dev/null +++ b/packages/frontend/core/src/mobile/dialogs/setting/devices/index.tsx @@ -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([]); + + 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 ( + + {sessions.map(session => ( + +
{`${session.deviceName ?? session.platform}${session.current ? ` (${t['com.affine.settings.devices.current']()})` : ''}`}
+
+ {t['com.affine.settings.devices.last-used']({ + time: new Date(session.lastSeenAt).toLocaleString(), + })} +
+ + } + onClick={() => void revoke(session)} + > + {t['com.affine.settings.devices.sign-out']()} +
+ ))} + {sessions.length > 1 ? ( + { + 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} +
+ ); +}; diff --git a/packages/frontend/core/src/mobile/dialogs/setting/index.tsx b/packages/frontend/core/src/mobile/dialogs/setting/index.tsx index 4db02b137c..f6d970ae84 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/index.tsx +++ b/packages/frontend/core/src/mobile/dialogs/setting/index.tsx @@ -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 = () => { + diff --git a/packages/frontend/core/src/modules/cloud/impl/auth.ts b/packages/frontend/core/src/modules/cloud/impl/auth.ts index b0e1962be8..14df5d272f 100644 --- a/packages/frontend/core/src/modules/cloud/impl/auth.ts +++ b/packages/frontend/core/src/modules/cloud/impl/auth.ts @@ -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() {}, }; }); } diff --git a/packages/frontend/core/src/modules/cloud/index.ts b/packages/frontend/core/src/modules/cloud/index.ts index 1c33d4ef18..87dd42420a 100644 --- a/packages/frontend/core/src/modules/cloud/index.ts +++ b/packages/frontend/core/src/modules/cloud/index.ts @@ -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'; diff --git a/packages/frontend/core/src/modules/cloud/provider/auth.ts b/packages/frontend/core/src/modules/cloud/provider/auth.ts index 6776548a4a..7c1326ad31 100644 --- a/packages/frontend/core/src/modules/cloud/provider/auth.ts +++ b/packages/frontend/core/src/modules/cloud/provider/auth.ts @@ -33,6 +33,8 @@ export interface AuthProvider { signInOpenAppSignInCode(code: string): Promise; signOut(): Promise; + + clearSession(): Promise; } export const AuthProvider = createIdentifier('AuthProvider'); diff --git a/packages/frontend/core/src/modules/cloud/services/auth.ts b/packages/frontend/core/src/modules/cloud/services/auth.ts index 8030f79bbe..5c2f41f2d2 100644 --- a/packages/frontend/core/src/modules/cloud/services/auth.ts +++ b/packages/frontend/core/src/modules/cloud/services/auth.ts @@ -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 { + 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 = { 'x-captcha-token': token, + 'x-captcha-provider': challenge ? 'hashcash' : 'turnstile', }; if (challenge) { diff --git a/packages/frontend/core/src/modules/cloud/services/captcha.spec.ts b/packages/frontend/core/src/modules/cloud/services/captcha.spec.ts new file mode 100644 index 0000000000..9dd9062e5a --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/captcha.spec.ts @@ -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, + validate?: (challenge: string, resource: string) => Promise +) { + 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(); + }); +}); diff --git a/packages/frontend/core/src/modules/cloud/services/captcha.ts b/packages/frontend/core/src/modules/cloud/services/captcha.ts index 6031be95f9..c4d53be664 100644 --- a/packages/frontend/core/src/modules/cloud/services/captcha.ts +++ b/packages/frontend/core/src/modules/cloud/services/captcha.ts @@ -18,6 +18,10 @@ export class CaptchaService extends Service { r => r?.captcha || false ); challenge$ = new LiveData(undefined); + provider$ = new LiveData<'hashcash' | 'turnstile' | undefined>(undefined); + turnstile$ = new LiveData<{ siteKey: string; action: string } | undefined>( + undefined + ); isLoading$ = new LiveData(false); verifyToken$ = new LiveData(undefined); error$ = new LiveData(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); }) diff --git a/packages/frontend/core/src/modules/cloud/services/fetch.ts b/packages/frontend/core/src/modules/cloud/services/fetch.ts index 18319dbc05..b4cb0be098 100644 --- a/packages/frontend/core/src/modules/cloud/services/fetch.ts +++ b/packages/frontend/core/src/modules/cloud/services/fetch.ts @@ -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', }, } ); diff --git a/packages/frontend/core/src/modules/cloud/stores/auth.ts b/packages/frontend/core/src/modules/cloud/stores/auth.ts index bf853faab2..f5330f7e08 100644 --- a/packages/frontend/core/src/modules/cloud/stores/auth.ts +++ b/packages/frontend/core/src/modules/cloud/stores/auth.ts @@ -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, diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index ca82c967b1..d455b4a905 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -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, diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 9890724a7e..68d930043c 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -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.` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 2ef0237a98..7ddd7f0930 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -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.", diff --git a/tools/@types/build-config/__all.d.ts b/tools/@types/build-config/__all.d.ts index 84fd47acfe..7526f66d3f 100644 --- a/tools/@types/build-config/__all.d.ts +++ b/tools/@types/build-config/__all.d.ts @@ -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; diff --git a/tools/utils/src/build-config.ts b/tools/utils/src/build-config.ts index a02f4cbeb5..8f4d017919 100644 --- a/tools/utils/src/build-config.ts +++ b/tools/utils/src/build-config.ts @@ -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() { diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index fc6c1d9389..743abd14cc 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -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', diff --git a/yarn.lock b/yarn.lock index 5ead01cef6..bf0c0053c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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:*"