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