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:
3
.github/workflows/build-images.yml
vendored
3
.github/workflows/build-images.yml
vendored
@@ -38,7 +38,6 @@ jobs:
|
|||||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
BUILD_TYPE: ${{ inputs.build-type }}
|
BUILD_TYPE: ${{ inputs.build-type }}
|
||||||
CAPTCHA_SITE_KEY: ${{ secrets.CAPTCHA_SITE_KEY }}
|
|
||||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: 'affine-web'
|
SENTRY_PROJECT: 'affine-web'
|
||||||
SENTRY_RELEASE: ${{ inputs.app-version }}
|
SENTRY_RELEASE: ${{ inputs.app-version }}
|
||||||
@@ -71,7 +70,6 @@ jobs:
|
|||||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
BUILD_TYPE: ${{ inputs.build-type }}
|
BUILD_TYPE: ${{ inputs.build-type }}
|
||||||
CAPTCHA_SITE_KEY: ${{ secrets.CAPTCHA_SITE_KEY }}
|
|
||||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: 'affine-admin'
|
SENTRY_PROJECT: 'affine-admin'
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
@@ -103,7 +101,6 @@ jobs:
|
|||||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
BUILD_TYPE: ${{ inputs.build-type }}
|
BUILD_TYPE: ${{ inputs.build-type }}
|
||||||
CAPTCHA_SITE_KEY: ${{ secrets.CAPTCHA_SITE_KEY }}
|
|
||||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: 'affine-mobile'
|
SENTRY_PROJECT: 'affine-mobile'
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
|||||||
109
.github/workflows/build-test.yml
vendored
109
.github/workflows/build-test.yml
vendored
@@ -239,6 +239,95 @@ jobs:
|
|||||||
working-directory: packages/frontend/apps/android/App
|
working-directory: packages/frontend/apps/android/App
|
||||||
run: ./gradlew :app:assembleCanaryDebug --no-daemon --stacktrace
|
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:
|
build-ios-app:
|
||||||
name: Build iOS app
|
name: Build iOS app
|
||||||
if: ${{ needs.mobile-native-build-filter.outputs.run-ios == 'true' }}
|
if: ${{ needs.mobile-native-build-filter.outputs.run-ios == 'true' }}
|
||||||
@@ -288,6 +377,25 @@ jobs:
|
|||||||
CODE_SIGNING_REQUIRED=NO \
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
build
|
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:
|
rust-test-filter:
|
||||||
name: Rust test filter
|
name: Rust test filter
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -1390,6 +1498,7 @@ jobs:
|
|||||||
- typecheck
|
- typecheck
|
||||||
- mobile-native-build-filter
|
- mobile-native-build-filter
|
||||||
- build-android-app
|
- build-android-app
|
||||||
|
- test-android-app
|
||||||
- build-ios-app
|
- build-ios-app
|
||||||
- lint-rust
|
- lint-rust
|
||||||
- check-git-status
|
- check-git-status
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ plugins {
|
|||||||
alias libs.plugins.hilt
|
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'
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -34,7 +37,7 @@ android {
|
|||||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
}
|
}
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters 'arm64-v8a'
|
abiFilters nativeAbi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
@@ -136,6 +139,8 @@ dependencies {
|
|||||||
testImplementation libs.junit
|
testImplementation libs.junit
|
||||||
androidTestImplementation libs.androidx.junit
|
androidTestImplementation libs.androidx.junit
|
||||||
androidTestImplementation libs.androidx.espresso.core
|
androidTestImplementation libs.androidx.espresso.core
|
||||||
|
androidTestImplementation platform(libs.okhttp.bom)
|
||||||
|
androidTestImplementation "com.squareup.okhttp3:mockwebserver"
|
||||||
}
|
}
|
||||||
|
|
||||||
cargo {
|
cargo {
|
||||||
@@ -144,7 +149,7 @@ cargo {
|
|||||||
features {
|
features {
|
||||||
defaultAnd("use-as-lib")
|
defaultAnd("use-as-lib")
|
||||||
}
|
}
|
||||||
targets = ["arm64"]
|
targets = [nativeTarget]
|
||||||
pythonCommand = "python3"
|
pythonCommand = "python3"
|
||||||
targetDirectory = "../../../../../../target"
|
targetDirectory = "../../../../../../target"
|
||||||
apiLevel = 28
|
apiLevel = 28
|
||||||
@@ -188,7 +193,7 @@ android.applicationVariants.configureEach { variant ->
|
|||||||
def t = tasks.register("generate${variantName}UniFFIBindings", Exec) {
|
def t = tasks.register("generate${variantName}UniFFIBindings", Exec) {
|
||||||
workingDir "${project.projectDir}"
|
workingDir "${project.projectDir}"
|
||||||
// Runs the bindings generation, note that you must have uniffi-bindgen installed and in your PATH environment variable
|
// 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")
|
dependsOn("cargoBuild")
|
||||||
}
|
}
|
||||||
variant.javaCompileProvider.get().dependsOn(t)
|
variant.javaCompileProvider.get().dependsOn(t)
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package app.affine.pro.plugin
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import app.affine.pro.utils.authDataStoreCorruptionHandler
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
private val Context.corruptionTestDataStore by preferencesDataStore(
|
||||||
|
name = "auth-session-corruption-test",
|
||||||
|
corruptionHandler = authDataStoreCorruptionHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class AuthSessionBrokerTest {
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
private lateinit var endpoint: String
|
||||||
|
private lateinit var broker: AuthSessionBroker
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() = runBlocking {
|
||||||
|
server = MockWebServer()
|
||||||
|
server.start()
|
||||||
|
endpoint = server.url("/").toString().removeSuffix("/")
|
||||||
|
broker = AuthSessionBroker()
|
||||||
|
broker.clear(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = runBlocking {
|
||||||
|
broker.clear(endpoint)
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun persistsEncryptedPairAcrossBrokerRecreation() = runBlocking {
|
||||||
|
broker.store(endpoint, tokenResponse("access-one", refreshToken('a'), 900))
|
||||||
|
|
||||||
|
assertEquals("access-one", AuthSessionBroker().validAccessToken(endpoint))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun concurrentCallersShareOneRefresh() = runBlocking {
|
||||||
|
broker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
|
||||||
|
Thread.sleep(1_100)
|
||||||
|
server.enqueue(MockResponse().setBody(tokenResponse("fresh", refreshToken('b'), 900)))
|
||||||
|
|
||||||
|
val tokens = List(20) { async { broker.validAccessToken(endpoint) } }.awaitAll()
|
||||||
|
|
||||||
|
assertTrue(tokens.all { it == "fresh" })
|
||||||
|
assertEquals(1, server.requestCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retriesPendingRotationBeforeStartingAnotherRefresh() = runBlocking {
|
||||||
|
val storage = FakeStorage()
|
||||||
|
val testBroker = AuthSessionBroker(storage)
|
||||||
|
testBroker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
|
||||||
|
Thread.sleep(1_100)
|
||||||
|
storage.failWrites = 1
|
||||||
|
server.enqueue(MockResponse().setBody(tokenResponse("fresh", refreshToken('b'), 900)))
|
||||||
|
|
||||||
|
assertThrows(Exception::class.java) {
|
||||||
|
runBlocking { testBroker.refreshAccessToken(endpoint) }
|
||||||
|
}
|
||||||
|
assertEquals("fresh", testBroker.refreshAccessToken(endpoint))
|
||||||
|
assertEquals(1, server.requestCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun corruptedCredentialBecomesLocalCredentialLoss() = runBlocking {
|
||||||
|
val storage = FakeStorage().apply { value = "not-json" }
|
||||||
|
|
||||||
|
assertNull(AuthSessionBroker(storage).validAccessToken(endpoint))
|
||||||
|
assertNull(storage.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun corruptedDataStoreFileIsReplacedWithEmptyPreferences() = runBlocking {
|
||||||
|
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
val file = context.filesDir.resolve("datastore/auth-session-corruption-test.preferences_pb")
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
file.writeBytes(byteArrayOf(0x7f, 0x01, 0x02, 0x03))
|
||||||
|
|
||||||
|
val preferences = withTimeout(5_000) {
|
||||||
|
context.corruptionTestDataStore.data.first()
|
||||||
|
}
|
||||||
|
assertTrue(preferences.asMap().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun transientRefreshFailurePreservesStoredCredential() = runBlocking {
|
||||||
|
val storage = FakeStorage()
|
||||||
|
val testBroker = AuthSessionBroker(storage)
|
||||||
|
testBroker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
|
||||||
|
Thread.sleep(1_100)
|
||||||
|
repeat(3) { server.enqueue(MockResponse().setResponseCode(503)) }
|
||||||
|
|
||||||
|
assertThrows(Exception::class.java) {
|
||||||
|
runBlocking { testBroker.refreshAccessToken(endpoint) }
|
||||||
|
}
|
||||||
|
assertTrue(storage.value?.contains("expired") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clearWinsAgainstInflightRefresh() = runBlocking {
|
||||||
|
broker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
|
||||||
|
Thread.sleep(1_100)
|
||||||
|
server.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setBody(tokenResponse("stale", refreshToken('b'), 900))
|
||||||
|
.setBodyDelay(500, TimeUnit.MILLISECONDS),
|
||||||
|
)
|
||||||
|
|
||||||
|
val refresh = async { broker.refreshAccessToken(endpoint) }
|
||||||
|
assertNotNull(withContext(Dispatchers.IO) { server.takeRequest(5, TimeUnit.SECONDS) })
|
||||||
|
broker.clear(endpoint)
|
||||||
|
|
||||||
|
assertThrows(Exception::class.java) { runBlocking { refresh.await() } }
|
||||||
|
assertNull(broker.validAccessToken(endpoint))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun newLoginWinsAgainstInflightRefresh() = runBlocking {
|
||||||
|
broker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
|
||||||
|
Thread.sleep(1_100)
|
||||||
|
server.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setBody(tokenResponse("stale", refreshToken('b'), 900))
|
||||||
|
.setBodyDelay(500, TimeUnit.MILLISECONDS),
|
||||||
|
)
|
||||||
|
|
||||||
|
val refresh = async { broker.refreshAccessToken(endpoint) }
|
||||||
|
assertNotNull(withContext(Dispatchers.IO) { server.takeRequest(5, TimeUnit.SECONDS) })
|
||||||
|
broker.store(endpoint, tokenResponse("new-login", refreshToken('c'), 900))
|
||||||
|
|
||||||
|
assertThrows(Exception::class.java) { runBlocking { refresh.await() } }
|
||||||
|
assertEquals("new-login", broker.validAccessToken(endpoint))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun signOutWinsAgainstInflightRefresh() = runBlocking {
|
||||||
|
broker.store(endpoint, tokenResponse("expired", refreshToken('a'), 1))
|
||||||
|
Thread.sleep(1_100)
|
||||||
|
server.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setBody(tokenResponse("stale", refreshToken('b'), 900))
|
||||||
|
.setBodyDelay(500, TimeUnit.MILLISECONDS),
|
||||||
|
)
|
||||||
|
server.enqueue(MockResponse().setBody("{}"))
|
||||||
|
|
||||||
|
val refresh = async { broker.refreshAccessToken(endpoint) }
|
||||||
|
assertNotNull(withContext(Dispatchers.IO) { server.takeRequest(5, TimeUnit.SECONDS) })
|
||||||
|
broker.signOut(endpoint)
|
||||||
|
|
||||||
|
assertThrows(Exception::class.java) { runBlocking { refresh.await() } }
|
||||||
|
assertNull(broker.validAccessToken(endpoint))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invalidatedEndpointKeyIsRecreatedWithoutPlaintextFallback() {
|
||||||
|
val cipher = TokenCipher()
|
||||||
|
val value = cipher.encrypt(endpoint, "secret")
|
||||||
|
assertEquals("secret", cipher.decrypt(endpoint, value))
|
||||||
|
|
||||||
|
cipher.reset(endpoint)
|
||||||
|
assertNull(cipher.decrypt(endpoint, value))
|
||||||
|
assertEquals("replacement", cipher.decrypt(endpoint, cipher.encrypt(endpoint, "replacement")))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tokenResponse(access: String, refresh: String, expiresIn: Int) =
|
||||||
|
JSONObject()
|
||||||
|
.put("tokenType", "Bearer")
|
||||||
|
.put("accessToken", access)
|
||||||
|
.put("expiresIn", expiresIn)
|
||||||
|
.put("refreshToken", refresh)
|
||||||
|
.put("refreshExpiresAt", isoAfter(86_400))
|
||||||
|
.put(
|
||||||
|
"session",
|
||||||
|
JSONObject()
|
||||||
|
.put("id", UUID.randomUUID().toString())
|
||||||
|
.put("absoluteExpiresAt", isoAfter(172_800)),
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
private fun refreshToken(seed: Char) =
|
||||||
|
"aff_rt_v1.${seed.toString().repeat(24)}.${seed.toString().repeat(43)}"
|
||||||
|
|
||||||
|
private fun isoAfter(seconds: Long) =
|
||||||
|
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}.format(Date(System.currentTimeMillis() + seconds * 1000))
|
||||||
|
|
||||||
|
private class FakeStorage : AuthCredentialStorage {
|
||||||
|
var value: String? = null
|
||||||
|
var failWrites = 0
|
||||||
|
|
||||||
|
override suspend fun read(endpoint: String) = value
|
||||||
|
|
||||||
|
override suspend fun write(endpoint: String, value: String) {
|
||||||
|
if (failWrites > 0) {
|
||||||
|
failWrites--
|
||||||
|
throw IllegalStateException("storage unavailable")
|
||||||
|
}
|
||||||
|
this.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(endpoint: String) {
|
||||||
|
value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,6 @@ public class ExampleInstrumentedTest {
|
|||||||
// Context of the app under test.
|
// Context of the app under test.
|
||||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
|
||||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
assertEquals("app.affine.pro", appContext.getPackageName());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
package app.affine.pro.plugin
|
package app.affine.pro.plugin
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.security.keystore.KeyGenParameterSpec
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import app.affine.pro.AFFiNEApp
|
import app.affine.pro.AFFiNEApp
|
||||||
import app.affine.pro.service.AuthHttp
|
import app.affine.pro.service.AuthHttp
|
||||||
import app.affine.pro.service.CookieStore
|
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.del
|
||||||
import app.affine.pro.utils.get
|
import app.affine.pro.utils.get
|
||||||
import app.affine.pro.utils.set
|
import app.affine.pro.utils.set
|
||||||
@@ -16,352 +15,465 @@ import com.getcapacitor.Plugin
|
|||||||
import com.getcapacitor.PluginCall
|
import com.getcapacitor.PluginCall
|
||||||
import com.getcapacitor.PluginMethod
|
import com.getcapacitor.PluginMethod
|
||||||
import com.getcapacitor.annotation.CapacitorPlugin
|
import com.getcapacitor.annotation.CapacitorPlugin
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
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
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.coroutines.executeAsync
|
import okhttp3.coroutines.executeAsync
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.security.KeyStore
|
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.Cipher
|
||||||
import javax.crypto.KeyGenerator
|
import javax.crypto.KeyGenerator
|
||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
private data class TokenPair(
|
||||||
@CapacitorPlugin(name = "Auth")
|
val accessToken: String,
|
||||||
class AuthPlugin : Plugin() {
|
val accessExpiresAt: Long,
|
||||||
private fun canonicalEndpoint(endpoint: String): String = try {
|
val refreshToken: String,
|
||||||
val url = endpoint.toHttpUrl()
|
val json: String,
|
||||||
val port = if (url.port == HttpUrl.defaultPort(url.scheme)) "" else ":${url.port}"
|
)
|
||||||
"${url.scheme}://${url.host}$port"
|
|
||||||
} catch (_: Exception) {
|
private class AuthServerException(val code: String?, val status: Int) : Exception(code)
|
||||||
endpoint
|
|
||||||
|
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)}"
|
suspend fun validAccessToken(endpoint: String): String? {
|
||||||
private fun legacyTokenKey(endpoint: String) = "auth-token:$endpoint"
|
repeat(2) {
|
||||||
private val tokenCipher = TokenCipher()
|
val current = lock(endpoint).withLock { read(endpoint) } ?: return null
|
||||||
|
if (current.accessExpiresAt - System.currentTimeMillis() > 120_000) {
|
||||||
@PluginMethod
|
return current.accessToken
|
||||||
fun readEndpointToken(call: PluginCall) {
|
}
|
||||||
launch(Dispatchers.IO) {
|
|
||||||
try {
|
try {
|
||||||
val endpoint = call.getStringEnsure("endpoint")
|
return refresh(endpoint).accessToken
|
||||||
val key = tokenKey(endpoint)
|
} catch (error: Exception) {
|
||||||
val legacyKey = legacyTokenKey(endpoint)
|
if (error is CancellationException) currentCoroutineContext().ensureActive()
|
||||||
val store = AFFiNEApp.context().dataStore
|
val mutationWon = error is CancellationException ||
|
||||||
val storedKey = key.takeIf { store.get(it).isNotEmpty() }
|
error is IllegalStateException && error.message == "AUTH_SESSION_EMPTY"
|
||||||
?: legacyKey.takeIf { it != key && store.get(it).isNotEmpty() }
|
if (!mutationWon) throw error
|
||||||
val storedToken = storedKey?.let { store.get(it) }?.takeIf { it.isNotEmpty() }
|
}
|
||||||
val token = storedToken?.let {
|
}
|
||||||
tokenCipher.decrypt(it) ?: tokenCipher.legacyPlaintext(it)
|
return lock(endpoint).withLock { read(endpoint) }?.accessToken
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
storedToken != null &&
|
suspend fun refreshAccessToken(endpoint: String) = refresh(endpoint).accessToken
|
||||||
token != null &&
|
|
||||||
(storedKey != key || !tokenCipher.isEncrypted(storedToken))
|
suspend fun clear(endpoint: String) = lock(endpoint).withLock {
|
||||||
) {
|
invalidate(endpoint)
|
||||||
store.set(key, tokenCipher.encrypt(token))
|
storage.delete(endpoint)
|
||||||
storedKey?.let {
|
}
|
||||||
if (it != key) {
|
|
||||||
store.del(it)
|
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))
|
}.also { task ->
|
||||||
} catch (e: Exception) {
|
refreshes[canonical] = task
|
||||||
call.reject("Failed to read endpoint token.", null, e)
|
task.invokeOnCompletion { refreshes.remove(canonical, task) }
|
||||||
}
|
}
|
||||||
}
|
}.await()
|
||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
private suspend fun request(endpoint: String, path: String, body: JSONObject): String {
|
||||||
fun writeEndpointToken(call: PluginCall) {
|
var last: Exception? = null
|
||||||
launch(Dispatchers.IO) {
|
repeat(3) { attempt ->
|
||||||
try {
|
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()
|
val request = Request.Builder()
|
||||||
.url("$endpoint/api/auth/sign-out")
|
.url("${canonical(endpoint)}$path")
|
||||||
.post("".toRequestBody("application/json".toMediaTypeOrNull()))
|
.addHeader("x-affine-client-kind", "native")
|
||||||
.apply {
|
.post(body.toString().toRequestBody(jsonMediaType))
|
||||||
if (token != null) {
|
|
||||||
addHeader("Authorization", "Bearer $token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.build()
|
.build()
|
||||||
AuthHttp.client.newCall(request).executeAsync().use { response ->
|
AuthHttp.client.newCall(request).executeAsync().use { response ->
|
||||||
if (response.code >= 400) {
|
val text = response.body.string()
|
||||||
call.reject(response.body.string())
|
if (response.code < 400) return text
|
||||||
return@launch
|
val code = runCatching { JSONObject(text).optString("code").ifEmpty { null } }.getOrNull()
|
||||||
}
|
val error = AuthServerException(code, response.code)
|
||||||
CookieStore.clearAuthCookies(endpoint.toHttpUrl().host)
|
if (response.code < 500 || attempt == 2) throw error
|
||||||
Timber.i("Sign out success.")
|
last = error
|
||||||
call.resolve(JSObject().put("ok", true))
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (error: AuthServerException) {
|
||||||
Timber.w(e, "Sign out fail.")
|
if (error.status < 500 || attempt == 2) throw error
|
||||||
call.reject("Failed to sign out, $e", null, e)
|
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 {
|
private suspend fun loseCredential(endpoint: String): TokenPair? {
|
||||||
Password, Oauth, MagicLink, OpenApp
|
storage.delete(endpoint)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun processSignIn(call: PluginCall, method: SignInMethod) {
|
private suspend fun write(endpoint: String, pair: TokenPair) {
|
||||||
launch(Dispatchers.IO) {
|
storage.write(endpoint, pair.json)
|
||||||
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())
|
|
||||||
|
|
||||||
val requestBuilder = Request.Builder()
|
private fun invalidate(endpoint: String) {
|
||||||
.url("$endpoint/api/auth/sign-in")
|
val canonical = canonical(endpoint)
|
||||||
.addHeader("x-affine-client-kind", "native")
|
epochs.compute(canonical) { _, value -> (value ?: 0) + 1 }
|
||||||
.post(body)
|
refreshes.remove(canonical)?.cancel()
|
||||||
if (verifyToken != null) {
|
pending.remove(canonical)
|
||||||
requestBuilder.addHeader("x-captcha-token", verifyToken)
|
}
|
||||||
}
|
|
||||||
if (challenge != null) {
|
|
||||||
requestBuilder.addHeader("x-captcha-challenge", challenge)
|
|
||||||
}
|
|
||||||
requestBuilder.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
SignInMethod.Oauth -> {
|
private fun lock(endpoint: String) = locks.computeIfAbsent(canonical(endpoint)) { Mutex() }
|
||||||
val code = call.getStringEnsure("code")
|
companion object {
|
||||||
val state = call.getStringEnsure("state")
|
private val jsonMediaType = "application/json".toMediaType()
|
||||||
val clientNonce = call.getString("clientNonce")
|
fun canonical(endpoint: String): String = try {
|
||||||
val body = JSONObject()
|
val url = endpoint.toHttpUrl()
|
||||||
.apply {
|
val port = if (url.port == HttpUrl.defaultPort(url.scheme)) "" else ":${url.port}"
|
||||||
put("code", code)
|
"${url.scheme}://${url.host}$port"
|
||||||
put("state", state)
|
} catch (_: Exception) {
|
||||||
put("client_nonce", clientNonce)
|
endpoint
|
||||||
}
|
|
||||||
.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 suspend fun exchangeSession(endpoint: String, code: String): String {
|
private fun isIso8601(value: String): Boolean = listOf(
|
||||||
val body = JSONObject()
|
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
|
||||||
.apply { put("code", code) }
|
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||||
.toString()
|
).any { pattern ->
|
||||||
.toRequestBody("application/json".toMediaTypeOrNull())
|
val position = ParsePosition(0)
|
||||||
val request = Request.Builder()
|
val parsed = SimpleDateFormat(pattern, Locale.US).apply {
|
||||||
.url("$endpoint/api/auth/native/exchange")
|
isLenient = false
|
||||||
.addHeader("x-affine-client-kind", "native")
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
.post(body)
|
}.parse(value, position)
|
||||||
.build()
|
parsed != null && position.index == value.length
|
||||||
|
|
||||||
AuthHttp.client.newCall(request).executeAsync().use { response ->
|
|
||||||
if (response.code >= 400) {
|
|
||||||
throw Exception(response.body.string())
|
|
||||||
}
|
|
||||||
return JSONObject(response.body.string()).optString("token")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class TokenCipher {
|
@CapacitorPlugin(name = "Auth")
|
||||||
private val alias = "affine-native-auth-token"
|
class AuthPlugin : Plugin() {
|
||||||
private val transformation = "AES/GCM/NoPadding"
|
private val broker = AuthSessionBroker()
|
||||||
|
|
||||||
fun encrypt(plaintext: String): String {
|
@PluginMethod fun getValidAccessToken(call: PluginCall) = bridge(call) {
|
||||||
val cipher = Cipher.getInstance(transformation)
|
JSObject().put("token", broker.validAccessToken(call.getStringEnsure("endpoint")))
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
|
|
||||||
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
|
||||||
return listOf(
|
|
||||||
"v1",
|
|
||||||
Base64.encodeToString(cipher.iv, Base64.NO_WRAP),
|
|
||||||
Base64.encodeToString(ciphertext, Base64.NO_WRAP),
|
|
||||||
).joinToString(":")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun decrypt(encoded: String): String? {
|
@PluginMethod fun refreshAccessToken(call: PluginCall) = bridge(call) {
|
||||||
val parts = encoded.split(":")
|
JSObject().put("token", broker.refreshAccessToken(call.getStringEnsure("endpoint")))
|
||||||
if (parts.size != 3 || parts[0] != "v1") {
|
}
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return try {
|
@PluginMethod fun clearEndpointSession(call: PluginCall) = bridge(call) {
|
||||||
val iv = Base64.decode(parts[1], Base64.NO_WRAP)
|
broker.clear(call.getStringEnsure("endpoint")); JSObject().put("ok", true)
|
||||||
val ciphertext = Base64.decode(parts[2], Base64.NO_WRAP)
|
}
|
||||||
val cipher = Cipher.getInstance(transformation)
|
|
||||||
cipher.init(
|
@PluginMethod fun signOut(call: PluginCall) = bridge(call) {
|
||||||
Cipher.DECRYPT_MODE,
|
val endpoint = call.getStringEnsure("endpoint")
|
||||||
secretKey(),
|
broker.signOut(endpoint)
|
||||||
GCMParameterSpec(128, iv)
|
CookieStore.clearAuthCookies(endpoint.toHttpUrl().host)
|
||||||
)
|
JSObject().put("ok", true)
|
||||||
String(cipher.doFinal(ciphertext), Charsets.UTF_8)
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.w(e, "Failed to decrypt auth token.")
|
@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
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isEncrypted(value: String) = value.startsWith("v1:")
|
override suspend fun write(endpoint: String, value: String) {
|
||||||
|
val canonical = AuthSessionBroker.canonical(endpoint)
|
||||||
fun legacyPlaintext(value: String) =
|
AFFiNEApp.context().authDataStore.set(key(canonical), cipher.encrypt(canonical, value))
|
||||||
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 delete(endpoint: String) {
|
||||||
|
AFFiNEApp.context().authDataStore.del(key(AuthSessionBroker.canonical(endpoint)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun key(endpoint: String) = "auth-token:$endpoint"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class TokenCipher {
|
||||||
|
fun encrypt(endpoint: String, plaintext: String): String {
|
||||||
|
return runCatching { encryptWithAlias(alias(endpoint), plaintext) }.getOrElse {
|
||||||
|
reset(endpoint)
|
||||||
|
encryptWithAlias(alias(endpoint), plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encryptWithAlias(alias: String, plaintext: String): String {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, secretKey(alias))
|
||||||
|
return listOf(
|
||||||
|
"v1", Base64.encodeToString(cipher.iv, Base64.NO_WRAP),
|
||||||
|
Base64.encodeToString(cipher.doFinal(plaintext.toByteArray()), Base64.NO_WRAP),
|
||||||
|
).joinToString(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decrypt(endpoint: String, encoded: String): String? = runCatching {
|
||||||
|
val parts = encoded.split(":")
|
||||||
|
require(parts.size == 3 && parts[0] == "v1")
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(
|
||||||
|
Cipher.DECRYPT_MODE, secretKey(alias(endpoint)),
|
||||||
|
GCMParameterSpec(128, Base64.decode(parts[1], Base64.NO_WRAP)),
|
||||||
|
)
|
||||||
|
String(cipher.doFinal(Base64.decode(parts[2], Base64.NO_WRAP)))
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
fun reset(endpoint: String) {
|
||||||
|
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
if (store.containsAlias(alias(endpoint))) store.deleteEntry(alias(endpoint))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alias(endpoint: String): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256").digest(endpoint.toByteArray())
|
||||||
|
return "affine-auth-session-${Base64.encodeToString(digest, Base64.NO_WRAP or Base64.URL_SAFE).take(24)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun secretKey(alias: String): SecretKey {
|
||||||
|
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
(store.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
|
||||||
|
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
||||||
|
generator.init(
|
||||||
|
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setRandomizedEncryptionRequired(true)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
return generator.generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object { private const val TRANSFORMATION = "AES/GCM/NoPadding" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,23 @@ import android.content.Context
|
|||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "affine")
|
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) {
|
suspend fun DataStore<Preferences>.set(key: String, value: String) {
|
||||||
edit {
|
edit {
|
||||||
it[stringPreferencesKey(key)] = value
|
it[stringPreferencesKey(key)] = value
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
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 {
|
defaultConfig {
|
||||||
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
|
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
|
||||||
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
|
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
|
||||||
versionCode 1
|
|
||||||
versionName "1.0"
|
|
||||||
}
|
}
|
||||||
lintOptions {
|
lintOptions {
|
||||||
abortOnError false
|
abortOnError false
|
||||||
@@ -56,4 +54,4 @@ apply from: "cordova.variables.gradle"
|
|||||||
|
|
||||||
for (def func : cdvPluginPostBuildExtras) {
|
for (def func : cdvPluginPostBuildExtras) {
|
||||||
func()
|
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]
|
[versions]
|
||||||
android-gradle-plugin = "8.10.0"
|
android-gradle-plugin = "8.13.2"
|
||||||
androidx-activity-compose = "1.10.1"
|
androidx-activity-compose = "1.10.1"
|
||||||
androidx-appcompat = "1.7.0"
|
androidx-appcompat = "1.7.0"
|
||||||
androidx-browser = "1.8.0"
|
androidx-browser = "1.8.0"
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ pluginManagement {
|
|||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
plugins {
|
||||||
|
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
|
||||||
|
}
|
||||||
|
|
||||||
include ':app'
|
include ':app'
|
||||||
include ':service'
|
include ':service'
|
||||||
|
|||||||
@@ -57,11 +57,7 @@ import { Auth } from './plugins/auth';
|
|||||||
import { HashCash } from './plugins/hashcash';
|
import { HashCash } from './plugins/hashcash';
|
||||||
import { NbStoreNativeDBApis } from './plugins/nbstore';
|
import { NbStoreNativeDBApis } from './plugins/nbstore';
|
||||||
import { Preview } from './plugins/preview';
|
import { Preview } from './plugins/preview';
|
||||||
import {
|
import { clearEndpointSession, getValidAccessToken } from './proxy';
|
||||||
deleteEndpointToken,
|
|
||||||
readEndpointToken,
|
|
||||||
writeEndpointToken,
|
|
||||||
} from './proxy';
|
|
||||||
|
|
||||||
const storeManagerClient = createStoreManagerClient();
|
const storeManagerClient = createStoreManagerClient();
|
||||||
setTelemetryTransport(storeManagerClient.telemetry);
|
setTelemetryTransport(storeManagerClient.telemetry);
|
||||||
@@ -185,46 +181,44 @@ framework.scope(ServerScope).override(AuthProvider, resolver => {
|
|||||||
const endpoint = serverService.server.baseUrl;
|
const endpoint = serverService.server.baseUrl;
|
||||||
return {
|
return {
|
||||||
async signInMagicLink(email, linkToken, clientNonce) {
|
async signInMagicLink(email, linkToken, clientNonce) {
|
||||||
const { token } = await Auth.signInMagicLink({
|
await Auth.signInMagicLink({
|
||||||
endpoint,
|
endpoint,
|
||||||
email,
|
email,
|
||||||
token: linkToken,
|
token: linkToken,
|
||||||
clientNonce,
|
clientNonce,
|
||||||
});
|
});
|
||||||
await writeEndpointToken(endpoint, token);
|
|
||||||
},
|
},
|
||||||
async signInOauth(code, state, _provider, clientNonce) {
|
async signInOauth(code, state, _provider, clientNonce) {
|
||||||
const { token } = await Auth.signInOauth({
|
await Auth.signInOauth({
|
||||||
endpoint,
|
endpoint,
|
||||||
code,
|
code,
|
||||||
state,
|
state,
|
||||||
clientNonce,
|
clientNonce,
|
||||||
});
|
});
|
||||||
await writeEndpointToken(endpoint, token);
|
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
async signInPassword(credential) {
|
async signInPassword(credential) {
|
||||||
const { token } = await Auth.signInPassword({
|
await Auth.signInPassword({
|
||||||
endpoint,
|
endpoint,
|
||||||
...credential,
|
...credential,
|
||||||
});
|
});
|
||||||
await writeEndpointToken(endpoint, token);
|
|
||||||
},
|
},
|
||||||
async signInOpenAppSignInCode(code) {
|
async signInOpenAppSignInCode(code) {
|
||||||
const { token } = await Auth.signInOpenApp({
|
await Auth.signInOpenApp({
|
||||||
endpoint,
|
endpoint,
|
||||||
code,
|
code,
|
||||||
});
|
});
|
||||||
await writeEndpointToken(endpoint, token);
|
|
||||||
},
|
},
|
||||||
async signOut() {
|
async signOut() {
|
||||||
const token = await readEndpointToken(endpoint);
|
|
||||||
try {
|
try {
|
||||||
await Auth.signOut({ endpoint, token });
|
await Auth.signOut({ endpoint });
|
||||||
} finally {
|
} 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).applicationFocus();
|
||||||
});
|
});
|
||||||
frameworkProvider.get(LifecycleService).applicationStart();
|
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) => {
|
const getErrorMessage = (error: unknown, fallback: string) => {
|
||||||
if (typeof error === 'string' && error) {
|
if (typeof error === 'string' && error) {
|
||||||
@@ -462,13 +463,21 @@ function createStoreManagerClient() {
|
|||||||
authTokenChannelServer.addEventListener('message', event => {
|
authTokenChannelServer.addEventListener('message', event => {
|
||||||
const { id, endpoint } = event.data as { id?: string; endpoint?: string };
|
const { id, endpoint } = event.data as { id?: string; endpoint?: string };
|
||||||
if (!id || !endpoint) return;
|
if (!id || !endpoint) return;
|
||||||
readEndpointToken(endpoint)
|
getValidAccessToken(endpoint)
|
||||||
.then(token => authTokenChannelServer.postMessage({ id, token }))
|
.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();
|
authTokenChannelServer.start();
|
||||||
worker.postMessage(
|
worker.postMessage(
|
||||||
{ type: 'native-auth-token-channel', port: authTokenChannelClient },
|
{ type: 'auth-access-token-channel', port: authTokenChannelClient },
|
||||||
[authTokenChannelClient]
|
[authTokenChannelClient]
|
||||||
);
|
);
|
||||||
return new StoreManagerClient(new OpClient(worker));
|
return new StoreManagerClient(new OpClient(worker));
|
||||||
|
|||||||
@@ -19,21 +19,50 @@ import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
|
|||||||
import { AsyncCall } from 'async-call-rpc';
|
import { AsyncCall } from 'async-call-rpc';
|
||||||
|
|
||||||
let authTokenPort: MessagePort | undefined;
|
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) => {
|
configureSocketAuthMethod((endpoint, cb) => {
|
||||||
readEndpointToken(endpoint)
|
getValidAccessToken(endpoint)
|
||||||
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
|
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
|
||||||
.catch(() => cb({}));
|
.catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
globalThis.addEventListener('message', e => {
|
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 = e.ports[0] as MessagePort;
|
||||||
authTokenPort.addEventListener('message', e => {
|
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;
|
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);
|
pendingTokenRequests.delete(id);
|
||||||
});
|
});
|
||||||
authTokenPort.start();
|
authTokenPort.start();
|
||||||
@@ -66,20 +95,26 @@ globalThis.addEventListener('message', e => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function readEndpointToken(endpoint: string) {
|
function getValidAccessToken(endpoint: string) {
|
||||||
if (!authTokenPort) {
|
if (!authTokenPort) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = `${Date.now()}:${Math.random()}`;
|
const id = `${Date.now()}:${Math.random()}`;
|
||||||
return new Promise<string | null>(resolve => {
|
return new Promise<string | null>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
pendingTokenRequests.delete(id);
|
pendingTokenRequests.delete(id);
|
||||||
resolve(null);
|
reject(new Error('AUTH_SESSION_TEMPORARILY_UNAVAILABLE'));
|
||||||
}, 5000);
|
}, 5000);
|
||||||
pendingTokenRequests.set(id, token => {
|
pendingTokenRequests.set(id, {
|
||||||
clearTimeout(timeout);
|
resolve: token => {
|
||||||
resolve(token);
|
clearTimeout(timeout);
|
||||||
|
resolve(token);
|
||||||
|
},
|
||||||
|
reject: error => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
authTokenPort?.postMessage({ id, endpoint });
|
authTokenPort?.postMessage({ id, endpoint });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,31 +4,25 @@ export interface AuthPlugin {
|
|||||||
email: string;
|
email: string;
|
||||||
token: string;
|
token: string;
|
||||||
clientNonce?: string;
|
clientNonce?: string;
|
||||||
}): Promise<{ token: string }>;
|
}): Promise<void>;
|
||||||
signInOauth(options: {
|
signInOauth(options: {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
code: string;
|
code: string;
|
||||||
state: string;
|
state: string;
|
||||||
clientNonce?: string;
|
clientNonce?: string;
|
||||||
}): Promise<{ token: string }>;
|
}): Promise<void>;
|
||||||
signInPassword(options: {
|
signInPassword(options: {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
verifyToken?: string;
|
verifyToken?: string;
|
||||||
challenge?: string;
|
challenge?: string;
|
||||||
}): Promise<{ token: string }>;
|
}): Promise<void>;
|
||||||
signInOpenApp(options: {
|
signInOpenApp(options: { endpoint: string; code: string }): Promise<void>;
|
||||||
endpoint: string;
|
signOut(options: { endpoint: string }): Promise<void>;
|
||||||
code: string;
|
getValidAccessToken(options: {
|
||||||
}): Promise<{ token: string }>;
|
|
||||||
signOut(options: { endpoint: string; token?: string | null }): Promise<void>;
|
|
||||||
readEndpointToken(options: {
|
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
}): Promise<{ token?: string | null }>;
|
}): Promise<{ token?: string | null }>;
|
||||||
writeEndpointToken(options: {
|
refreshAccessToken(options: { endpoint: string }): Promise<{ token: string }>;
|
||||||
endpoint: string;
|
clearEndpointSession(options: { endpoint: string }): Promise<void>;
|
||||||
token: string;
|
|
||||||
}): Promise<void>;
|
|
||||||
deleteEndpointToken(options: { endpoint: string }): Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { canonicalAuthEndpoint } from '@affine/mobile-shared/auth/endpoint';
|
||||||
|
|
||||||
import { Auth } from './plugins/auth';
|
import { Auth } from './plugins/auth';
|
||||||
|
|
||||||
function authEndpointForUrl(url: string | URL) {
|
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.
|
* 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.
|
* should be included in the entry file of the app or webworker.
|
||||||
@@ -22,22 +20,84 @@ function canonicalEndpoint(endpoint: string) {
|
|||||||
const rawFetch = globalThis.fetch;
|
const rawFetch = globalThis.fetch;
|
||||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const request = new Request(input, init);
|
const request = new Request(input, init);
|
||||||
|
const retry = request.clone();
|
||||||
|
|
||||||
const origin = authEndpointForUrl(request.url);
|
const origin = authEndpointForUrl(request.url);
|
||||||
|
|
||||||
const token = origin
|
const token = origin ? await getValidAccessToken(origin) : null;
|
||||||
? await readEndpointToken(origin).catch(() => null)
|
|
||||||
: null;
|
|
||||||
if (token) {
|
if (token) {
|
||||||
request.headers.set('Authorization', `Bearer ${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 rawXMLHttpRequest = globalThis.XMLHttpRequest;
|
||||||
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
|
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
|
||||||
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
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(
|
override open(
|
||||||
method: string,
|
method: string,
|
||||||
url: string | URL,
|
url: string | URL,
|
||||||
@@ -45,6 +105,11 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
|||||||
username?: string | null,
|
username?: string | null,
|
||||||
password?: string | null
|
password?: string | null
|
||||||
): void {
|
): 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());
|
xhrRequestUrls.set(this, url.toString());
|
||||||
return super.open(
|
return super.open(
|
||||||
method,
|
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 {
|
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
||||||
|
this.requestBody = body;
|
||||||
const requestUrl = xhrRequestUrls.get(this);
|
const requestUrl = xhrRequestUrls.get(this);
|
||||||
const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href);
|
const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href);
|
||||||
|
|
||||||
(origin ? readEndpointToken(origin) : Promise.resolve(null)).then(
|
(origin ? getValidAccessToken(origin) : Promise.resolve(null))
|
||||||
token => {
|
.then(token => {
|
||||||
if (token) {
|
if (token) {
|
||||||
this.setRequestHeader('Authorization', `Bearer ${token}`);
|
super.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||||
}
|
}
|
||||||
return super.send(body);
|
return super.send(body);
|
||||||
},
|
})
|
||||||
() => {
|
.catch(() => {
|
||||||
return super.send(body);
|
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
|
endpoint: string
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const { token } = await Auth.readEndpointToken({
|
const { token } = await Auth.getValidAccessToken({
|
||||||
endpoint: canonicalEndpoint(endpoint),
|
endpoint: canonicalAuthEndpoint(endpoint),
|
||||||
});
|
});
|
||||||
return token ?? null;
|
return token ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeEndpointToken(endpoint: string, token: string) {
|
export async function clearEndpointSession(endpoint: string) {
|
||||||
await Auth.writeEndpointToken({
|
await Auth.clearEndpointSession({
|
||||||
endpoint: canonicalEndpoint(endpoint),
|
endpoint: canonicalAuthEndpoint(endpoint),
|
||||||
token,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteEndpointToken(endpoint: string) {
|
|
||||||
await Auth.deleteEndpointToken({ endpoint: canonicalEndpoint(endpoint) });
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ export function setupModules() {
|
|||||||
async signOut() {
|
async signOut() {
|
||||||
await apis.handler.auth.signOut(endpoint);
|
await apis.handler.auth.signOut(endpoint);
|
||||||
},
|
},
|
||||||
|
async clearSession() {
|
||||||
|
await apis.handler.auth.clearSession(endpoint);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ bindNativeDBV1Apis(apis!.db);
|
|||||||
configureSocketAuthMethod((endpoint, cb) => {
|
configureSocketAuthMethod((endpoint, cb) => {
|
||||||
// oxlint-disable-next-line no-non-null-assertion
|
// oxlint-disable-next-line no-non-null-assertion
|
||||||
apis!.auth
|
apis!.auth
|
||||||
.readEndpointToken(endpoint)
|
.getValidAccessToken(endpoint)
|
||||||
.then(({ token }: { token?: string | null }) => {
|
.then(({ token }: { token?: string | null }) => {
|
||||||
cb(token ? { token, tokenType: 'jwt' } : {});
|
cb(token ? { token, tokenType: 'jwt' } : {});
|
||||||
})
|
})
|
||||||
.catch(() => cb({}));
|
.catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
const storeManager = new StoreManagerConsumer([
|
const storeManager = new StoreManagerConsumer([
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@affine/auth": "workspace:*",
|
||||||
"async-call-rpc": "^6.4.2",
|
"async-call-rpc": "^6.4.2",
|
||||||
"electron-updater": "^6.8.3",
|
"electron-updater": "^6.8.3",
|
||||||
"link-preview-js": "^4.0.0",
|
"link-preview-js": "^4.0.0",
|
||||||
|
|||||||
293
packages/frontend/apps/electron/src/main/auth/auth-session.ts
Normal file
293
packages/frontend/apps/electron/src/main/auth/auth-session.ts
Normal file
@@ -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 { net, session } from 'electron';
|
||||||
|
|
||||||
import { logger } from '../logger';
|
import { logger } from '../logger';
|
||||||
import type { NamespaceHandlers } from '../type';
|
import type { NamespaceHandlers } from '../type';
|
||||||
import {
|
import {
|
||||||
deleteNativeAuthToken,
|
clearAuthSession,
|
||||||
getNativeAuthToken,
|
getInstallationId,
|
||||||
setNativeAuthToken,
|
getValidAccessToken,
|
||||||
} from './native-token';
|
revokeAuthSession,
|
||||||
|
setAuthSession,
|
||||||
|
} from './auth-session';
|
||||||
|
|
||||||
export interface SignInResponse {
|
export interface SignInResponse {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -29,10 +34,6 @@ export interface PasswordSignInResponse extends SignInResponse {
|
|||||||
sessionOnly?: boolean;
|
sessionOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExchangeResponse {
|
|
||||||
token?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authCookieNames = [
|
const authCookieNames = [
|
||||||
'affine_session',
|
'affine_session',
|
||||||
'affine_user_id',
|
'affine_user_id',
|
||||||
@@ -88,15 +89,16 @@ async function exchangeSession(endpoint: string, response: SignInResponse) {
|
|||||||
|
|
||||||
const exchangeResponse = await fetchAuth(
|
const exchangeResponse = await fetchAuth(
|
||||||
endpoint,
|
endpoint,
|
||||||
'/api/auth/native/exchange',
|
'/api/auth/session/exchange',
|
||||||
{ code: response.exchangeCode }
|
{
|
||||||
|
code: response.exchangeCode,
|
||||||
|
installationId: await getInstallationId(),
|
||||||
|
platform: 'electron',
|
||||||
|
deviceName: os.hostname(),
|
||||||
|
}
|
||||||
);
|
);
|
||||||
const body = await readJson<ExchangeResponse>(exchangeResponse);
|
const body = await readJson<AuthTokenResponse>(exchangeResponse);
|
||||||
if (!body.token) {
|
const { persistent } = await setAuthSession(endpoint, body);
|
||||||
throw new Error('Missing native auth token.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const persistent = setNativeAuthToken(endpoint, body.token);
|
|
||||||
await clearAuthCookies(endpoint);
|
await clearAuthCookies(endpoint);
|
||||||
return { persistent };
|
return { persistent };
|
||||||
}
|
}
|
||||||
@@ -155,6 +157,13 @@ export const authHandlers = {
|
|||||||
...(credential.verifyToken
|
...(credential.verifyToken
|
||||||
? { 'x-captcha-token': credential.verifyToken }
|
? { 'x-captcha-token': credential.verifyToken }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(credential.verifyToken
|
||||||
|
? {
|
||||||
|
'x-captcha-provider': credential.challenge
|
||||||
|
? 'hashcash'
|
||||||
|
: 'turnstile',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(credential.challenge
|
...(credential.challenge
|
||||||
? { 'x-captcha-challenge': credential.challenge }
|
? { 'x-captcha-challenge': credential.challenge }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -181,22 +190,18 @@ export const authHandlers = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
signOut: async (_e, endpoint: string) => {
|
signOut: async (_e, endpoint: string) => {
|
||||||
const token = getNativeAuthToken(endpoint);
|
try {
|
||||||
if (token) {
|
await revokeAuthSession(endpoint);
|
||||||
await net.fetch(authUrl(endpoint, '/api/auth/sign-out'), {
|
} finally {
|
||||||
method: 'POST',
|
await clearAuthCookies(endpoint);
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'x-affine-version': BUILD_CONFIG.appVersion,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteNativeAuthToken(endpoint);
|
|
||||||
await clearAuthCookies(endpoint);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
readEndpointToken: async (_e, endpoint: string) => {
|
clearSession: async (_e, endpoint: string) => {
|
||||||
return { token: getNativeAuthToken(endpoint) };
|
await clearAuthSession(endpoint, 'local-clear');
|
||||||
|
},
|
||||||
|
|
||||||
|
getValidAccessToken: async (_e, endpoint: string) => {
|
||||||
|
return { token: await getValidAccessToken(endpoint, 120_000) };
|
||||||
},
|
},
|
||||||
} satisfies NamespaceHandlers;
|
} 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,
|
resolvePathInBase,
|
||||||
resourcesPath,
|
resourcesPath,
|
||||||
} from '../shared/utils';
|
} from '../shared/utils';
|
||||||
import { getAuthTokenForUrl } from './auth/native-token';
|
import {
|
||||||
|
executeAuthSessionRequest,
|
||||||
|
getAccessTokenForUrl,
|
||||||
|
isManagedAuthEndpoint,
|
||||||
|
} from './auth/auth-session';
|
||||||
import { buildType, isDev } from './config';
|
import { buildType, isDev } from './config';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
|
||||||
@@ -64,26 +68,6 @@ function buildTargetUrl(base: string, urlObject: URL) {
|
|||||||
return new URL(`${urlObject.pathname}${urlObject.search}`, base).toString();
|
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(
|
async function proxyRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
urlObject: URL,
|
urlObject: URL,
|
||||||
@@ -92,13 +76,13 @@ async function proxyRequest(
|
|||||||
) {
|
) {
|
||||||
const { bypassCustomProtocolHandlers = true } = options;
|
const { bypassCustomProtocolHandlers = true } = options;
|
||||||
const targetUrl = buildTargetUrl(base, urlObject);
|
const targetUrl = buildTargetUrl(base, urlObject);
|
||||||
const authorizedRequest = await buildAuthorizedRequest(request, targetUrl);
|
return await executeAuthSessionRequest(request, targetUrl, request =>
|
||||||
const proxiedRequest = bypassCustomProtocolHandlers
|
net.fetch(
|
||||||
? Object.assign(authorizedRequest, {
|
bypassCustomProtocolHandlers
|
||||||
bypassCustomProtocolHandlers: true,
|
? Object.assign(request, { bypassCustomProtocolHandlers: true })
|
||||||
})
|
: request
|
||||||
: authorizedRequest;
|
)
|
||||||
return net.fetch(proxiedRequest);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFileRequest(request: Request) {
|
async function handleFileRequest(request: Request) {
|
||||||
@@ -268,17 +252,20 @@ export function registerProtocol() {
|
|||||||
|
|
||||||
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||||
const url = new URL(details.url);
|
const url = new URL(details.url);
|
||||||
|
const managedAuthRequest =
|
||||||
(async () => {
|
(url.protocol === 'http:' ||
|
||||||
if (
|
|
||||||
url.protocol === 'http:' ||
|
|
||||||
url.protocol === 'https:' ||
|
url.protocol === 'https:' ||
|
||||||
url.protocol === 'ws:' ||
|
url.protocol === 'ws:' ||
|
||||||
url.protocol === 'wss:'
|
url.protocol === 'wss:') &&
|
||||||
) {
|
isManagedAuthEndpoint(details.url);
|
||||||
const token = getAuthTokenForUrl(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) {
|
if (token) {
|
||||||
delete details.requestHeaders.authorization;
|
|
||||||
details.requestHeaders.Authorization = `Bearer ${token}`;
|
details.requestHeaders.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,11 +279,12 @@ export function registerProtocol() {
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
|
cancel = managedAuthRequest;
|
||||||
logger.error('error handling before send headers', err);
|
logger.error('error handling before send headers', err);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
callback({
|
callback({
|
||||||
cancel: false,
|
cancel,
|
||||||
requestHeaders: details.requestHeaders,
|
requestHeaders: details.requestHeaders,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
311
packages/frontend/apps/electron/test/main/auth-session.spec.ts
Normal file
311
packages/frontend/apps/electron/test/main/auth-session.spec.ts
Normal file
@@ -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"],
|
"include": ["./src"],
|
||||||
"references": [
|
"references": [
|
||||||
|
{ "path": "../../../common/auth" },
|
||||||
{ "path": "../../../../tools/utils" },
|
{ "path": "../../../../tools/utils" },
|
||||||
{ "path": "../../i18n" },
|
{ "path": "../../i18n" },
|
||||||
{ "path": "../../native" },
|
{ "path": "../../native" },
|
||||||
|
|||||||
@@ -33,6 +33,8 @@
|
|||||||
C4C97C7C2D030BE000BC2AD1 /* affine_mobile_native.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C6F2D0307B700BC2AD1 /* affine_mobile_native.swift */; };
|
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 */; };
|
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 */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase 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>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@@ -94,6 +99,13 @@
|
|||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
AA0000070000000000000000 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
504EC3011FED79650016851F /* Frameworks */ = {
|
504EC3011FED79650016851F /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -127,6 +139,7 @@
|
|||||||
children = (
|
children = (
|
||||||
C4C97C722D0307B700BC2AD1 /* uniffi */,
|
C4C97C722D0307B700BC2AD1 /* uniffi */,
|
||||||
9D90BE242CCB9876006677DB /* App */,
|
9D90BE242CCB9876006677DB /* App */,
|
||||||
|
AA00000D0000000000000000 /* AppTests */,
|
||||||
50802D5F2D112F7D00694021 /* Packages */,
|
50802D5F2D112F7D00694021 /* Packages */,
|
||||||
504EC3051FED79650016851F /* Products */,
|
504EC3051FED79650016851F /* Products */,
|
||||||
7F8756D8B27F46E3366F6CEA /* Pods */,
|
7F8756D8B27F46E3366F6CEA /* Pods */,
|
||||||
@@ -140,6 +153,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
504EC3041FED79650016851F /* AFFiNE.app */,
|
504EC3041FED79650016851F /* AFFiNE.app */,
|
||||||
|
AA0000030000000000000000 /* AFFiNETests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -192,6 +206,15 @@
|
|||||||
path = App;
|
path = App;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
AA00000D0000000000000000 /* AppTests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AA0000010000000000000000 /* AuthDateParser.swift */,
|
||||||
|
AA0000020000000000000000 /* AuthDateParserTests.swift */,
|
||||||
|
);
|
||||||
|
path = AppTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
C4C97C722D0307B700BC2AD1 /* uniffi */ = {
|
C4C97C722D0307B700BC2AD1 /* uniffi */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -206,6 +229,23 @@
|
|||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget 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 */ = {
|
504EC3031FED79650016851F /* AFFiNE */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "AFFiNE" */;
|
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "AFFiNE" */;
|
||||||
@@ -240,6 +280,9 @@
|
|||||||
LastSwiftUpdateCheck = 0920;
|
LastSwiftUpdateCheck = 0920;
|
||||||
LastUpgradeCheck = 2600;
|
LastUpgradeCheck = 2600;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
|
AA0000090000000000000000 = {
|
||||||
|
CreatedOnToolsVersion = 26.0;
|
||||||
|
};
|
||||||
504EC3031FED79650016851F = {
|
504EC3031FED79650016851F = {
|
||||||
CreatedOnToolsVersion = 9.2;
|
CreatedOnToolsVersion = 9.2;
|
||||||
LastSwiftMigration = 1600;
|
LastSwiftMigration = 1600;
|
||||||
@@ -260,11 +303,19 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
504EC3031FED79650016851F /* AFFiNE */,
|
504EC3031FED79650016851F /* AFFiNE */,
|
||||||
|
AA0000090000000000000000 /* AFFiNETests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
AA0000080000000000000000 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
504EC3021FED79650016851F /* Resources */ = {
|
504EC3021FED79650016851F /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -340,6 +391,15 @@
|
|||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase 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 */ = {
|
504EC3001FED79650016851F /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -373,6 +433,38 @@
|
|||||||
/* End PBXVariantGroup section */
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration 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 */ = {
|
504EC3141FED79650016851F /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -574,6 +666,15 @@
|
|||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList 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" */ = {
|
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2620"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "NO"
|
||||||
|
buildForProfiling = "NO"
|
||||||
|
buildForArchiving = "NO"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A11F1E000000000000000005"
|
||||||
|
BuildableName = "AFFiNETests.xctest"
|
||||||
|
BlueprintName = "AFFiNETests"
|
||||||
|
ReferencedContainer = "container:App.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
<Testables>
|
||||||
|
<TestableReference skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A11F1E000000000000000005"
|
||||||
|
BuildableName = "AFFiNETests.xctest"
|
||||||
|
BlueprintName = "AFFiNETests"
|
||||||
|
ReferencedContainer = "container:App.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
</Scheme>
|
||||||
@@ -21,6 +21,20 @@
|
|||||||
ReferencedContainer = "container:App.xcodeproj">
|
ReferencedContainer = "container:App.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</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>
|
</BuildActionEntries>
|
||||||
</BuildAction>
|
</BuildAction>
|
||||||
<TestAction
|
<TestAction
|
||||||
@@ -29,6 +43,18 @@
|
|||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
shouldAutocreateTestPlan = "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>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
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 Capacitor
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
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 class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
public let identifier = "AuthPlugin"
|
public let identifier = "AuthPlugin"
|
||||||
@@ -11,12 +260,12 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
CAPPluginMethod(name: "signInOpenApp", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "signInOpenApp", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "signInPassword", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "signInPassword", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "signOut", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "signOut", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "readEndpointToken", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "getValidAccessToken", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "writeEndpointToken", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "refreshAccessToken", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "deleteEndpointToken", 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 let authCookieNames = Set(["affine_session", "affine_user_id", "affine_csrf_token"])
|
||||||
|
|
||||||
private func canonicalEndpoint(_ endpoint: String) -> String {
|
private func canonicalEndpoint(_ endpoint: String) -> String {
|
||||||
@@ -38,37 +287,37 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
return "\(normalizedScheme)://\(normalizedHost)\(port)"
|
return "\(normalizedScheme)://\(normalizedHost)\(port)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc public func readEndpointToken(_ call: CAPPluginCall) {
|
@objc public func getValidAccessToken(_ call: CAPPluginCall) {
|
||||||
do {
|
Task {
|
||||||
let endpoint = try call.getStringEnsure("endpoint")
|
do {
|
||||||
if let token = try self.readToken(endpoint) {
|
let endpoint = try call.getStringEnsure("endpoint")
|
||||||
call.resolve(["token": token])
|
let token = try await broker.validAccessToken(endpoint)
|
||||||
} else {
|
call.resolve(["token": token ?? NSNull()])
|
||||||
call.resolve(["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) {
|
@objc public func clearEndpointSession(_ call: CAPPluginCall) {
|
||||||
do {
|
Task {
|
||||||
let endpoint = try call.getStringEnsure("endpoint")
|
do {
|
||||||
let token = try call.getStringEnsure("token")
|
try await broker.clear(call.getStringEnsure("endpoint"))
|
||||||
try self.writeToken(endpoint, token)
|
call.resolve(["ok": true])
|
||||||
call.resolve(["ok": true])
|
} catch {
|
||||||
} catch {
|
call.reject("Failed to clear auth session, \(error)", nil, error)
|
||||||
call.reject("Failed to write endpoint token, \(error)", nil, error)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc public func deleteEndpointToken(_ call: CAPPluginCall) {
|
@objc public func refreshAccessToken(_ call: CAPPluginCall) {
|
||||||
do {
|
Task {
|
||||||
let endpoint = try call.getStringEnsure("endpoint")
|
do {
|
||||||
try self.deleteToken(endpoint)
|
let token = try await broker.refreshAccessToken(call.getStringEnsure("endpoint"))
|
||||||
call.resolve(["ok": true])
|
call.resolve(["token": token])
|
||||||
} catch {
|
} catch {
|
||||||
call.reject("Failed to delete endpoint token, \(error)", nil, error)
|
call.reject("Failed to refresh access token, \(error)", nil, error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +344,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
|
try await self.exchangeSession(endpoint, data)
|
||||||
|
call.resolve(["ok": true])
|
||||||
} catch {
|
} catch {
|
||||||
call.reject("Failed to sign in, \(error)", nil, error)
|
call.reject("Failed to sign in, \(error)", nil, error)
|
||||||
}
|
}
|
||||||
@@ -125,7 +375,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
|
try await self.exchangeSession(endpoint, data)
|
||||||
|
call.resolve(["ok": true])
|
||||||
} catch {
|
} catch {
|
||||||
call.reject("Failed to sign in, \(error)", nil, error)
|
call.reject("Failed to sign in, \(error)", nil, error)
|
||||||
}
|
}
|
||||||
@@ -147,6 +398,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
"x-affine-client-kind": "native",
|
"x-affine-client-kind": "native",
|
||||||
"x-captcha-token": verifyToken,
|
"x-captcha-token": verifyToken,
|
||||||
"x-captcha-challenge": challenge,
|
"x-captcha-challenge": challenge,
|
||||||
|
"x-captcha-provider": verifyToken == nil ? nil : (challenge == nil ? "turnstile" : "hashcash"),
|
||||||
], body: ["email": email, "password": password])
|
], body: ["email": email, "password": password])
|
||||||
|
|
||||||
if response.statusCode >= 400 {
|
if response.statusCode >= 400 {
|
||||||
@@ -158,7 +410,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
|
try await self.exchangeSession(endpoint, data)
|
||||||
|
call.resolve(["ok": true])
|
||||||
} catch {
|
} catch {
|
||||||
call.reject("Failed to sign in, \(error)", nil, error)
|
call.reject("Failed to sign in, \(error)", nil, error)
|
||||||
}
|
}
|
||||||
@@ -186,7 +439,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
|
try await self.exchangeSession(endpoint, data)
|
||||||
|
call.resolve(["ok": true])
|
||||||
} catch {
|
} catch {
|
||||||
call.reject("Failed to sign in, \(error)", nil, error)
|
call.reject("Failed to sign in, \(error)", nil, error)
|
||||||
}
|
}
|
||||||
@@ -197,23 +451,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let endpoint = try call.getStringEnsure("endpoint")
|
let endpoint = try call.getStringEnsure("endpoint")
|
||||||
let token = call.getString("token")
|
try await broker.signOut(endpoint)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
self.clearAuthCookies(endpoint)
|
self.clearAuthCookies(endpoint)
|
||||||
call.resolve(["ok": true])
|
call.resolve(["ok": true])
|
||||||
} catch {
|
} 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 {
|
private func exchangeCodeFromResponse(_ data: Data) throws -> String {
|
||||||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
let code = json["exchangeCode"] as? String
|
let code = json["exchangeCode"] as? String
|
||||||
@@ -242,21 +470,33 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
return code
|
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 code = try exchangeCodeFromResponse(signInData)
|
||||||
let (data, response) = try await self.fetch(
|
let (data, response) = try await self.fetch(
|
||||||
endpoint, method: "POST", action: "/api/auth/native/exchange",
|
endpoint, method: "POST", action: "/api/auth/session/exchange",
|
||||||
headers: [
|
headers: [
|
||||||
"x-affine-client-kind": "native"
|
"x-affine-client-kind": "native"
|
||||||
], body: ["code": code])
|
], body: [
|
||||||
|
"code": code,
|
||||||
|
"installationId": self.installationId(),
|
||||||
|
"platform": "ios",
|
||||||
|
"deviceName": UIDevice.current.name,
|
||||||
|
])
|
||||||
|
|
||||||
if response.statusCode >= 400 {
|
if response.statusCode >= 400 {
|
||||||
throw AuthError.exchangeFailed
|
throw AuthError.exchangeFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = try tokenFromResponse(data)
|
try await broker.store(endpoint, response: JSONDecoder().decode(AuthTokenResponse.self, from: data))
|
||||||
self.clearAuthCookies(endpoint)
|
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) {
|
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(
|
private func fetch(
|
||||||
_ endpoint: String, method: String, action: String, headers: [String: String?], body: Encodable?
|
_ endpoint: String, method: String, action: String, headers: [String: String?], body: Encodable?
|
||||||
) async throws -> (Data, HTTPURLResponse) {
|
) async throws -> (Data, HTTPURLResponse) {
|
||||||
@@ -382,5 +543,6 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum AuthError: Error {
|
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 { NbStoreNativeDBApis } from './plugins/nbstore';
|
||||||
import { PayWall } from './plugins/paywall';
|
import { PayWall } from './plugins/paywall';
|
||||||
import { Preview } from './plugins/preview';
|
import { Preview } from './plugins/preview';
|
||||||
import {
|
import { clearEndpointSession, getValidAccessToken } from './proxy';
|
||||||
deleteEndpointToken,
|
|
||||||
readEndpointToken,
|
|
||||||
writeEndpointToken,
|
|
||||||
} from './proxy';
|
|
||||||
import { enableNavigationGesture$ } from './web-navigation-control';
|
import { enableNavigationGesture$ } from './web-navigation-control';
|
||||||
|
|
||||||
const storeManagerClient = createStoreManagerClient();
|
const storeManagerClient = createStoreManagerClient();
|
||||||
@@ -187,46 +183,44 @@ framework.scope(ServerScope).override(AuthProvider, resolver => {
|
|||||||
const endpoint = serverService.server.baseUrl;
|
const endpoint = serverService.server.baseUrl;
|
||||||
return {
|
return {
|
||||||
async signInMagicLink(email, linkToken, clientNonce) {
|
async signInMagicLink(email, linkToken, clientNonce) {
|
||||||
const { token } = await Auth.signInMagicLink({
|
await Auth.signInMagicLink({
|
||||||
endpoint,
|
endpoint,
|
||||||
email,
|
email,
|
||||||
token: linkToken,
|
token: linkToken,
|
||||||
clientNonce,
|
clientNonce,
|
||||||
});
|
});
|
||||||
await writeEndpointToken(endpoint, token);
|
|
||||||
},
|
},
|
||||||
async signInOauth(code, state, _provider, clientNonce) {
|
async signInOauth(code, state, _provider, clientNonce) {
|
||||||
const { token } = await Auth.signInOauth({
|
await Auth.signInOauth({
|
||||||
endpoint,
|
endpoint,
|
||||||
code,
|
code,
|
||||||
state,
|
state,
|
||||||
clientNonce,
|
clientNonce,
|
||||||
});
|
});
|
||||||
await writeEndpointToken(endpoint, token);
|
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
async signInPassword(credential) {
|
async signInPassword(credential) {
|
||||||
const { token } = await Auth.signInPassword({
|
await Auth.signInPassword({
|
||||||
endpoint,
|
endpoint,
|
||||||
...credential,
|
...credential,
|
||||||
});
|
});
|
||||||
await writeEndpointToken(endpoint, token);
|
|
||||||
},
|
},
|
||||||
async signInOpenAppSignInCode(code) {
|
async signInOpenAppSignInCode(code) {
|
||||||
const { token } = await Auth.signInOpenApp({
|
await Auth.signInOpenApp({
|
||||||
endpoint,
|
endpoint,
|
||||||
code,
|
code,
|
||||||
});
|
});
|
||||||
await writeEndpointToken(endpoint, token);
|
|
||||||
},
|
},
|
||||||
async signOut() {
|
async signOut() {
|
||||||
const token = await readEndpointToken(endpoint);
|
|
||||||
try {
|
try {
|
||||||
await Auth.signOut({ endpoint, token });
|
await Auth.signOut({ endpoint });
|
||||||
} finally {
|
} finally {
|
||||||
await deleteEndpointToken(endpoint);
|
await clearEndpointSession(endpoint);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async clearSession() {
|
||||||
|
await clearEndpointSession(endpoint);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
framework.impl(NativePaywallProvider, {
|
framework.impl(NativePaywallProvider, {
|
||||||
@@ -463,6 +457,13 @@ window.addEventListener('focus', () => {
|
|||||||
frameworkProvider.get(LifecycleService).applicationFocus();
|
frameworkProvider.get(LifecycleService).applicationFocus();
|
||||||
});
|
});
|
||||||
frameworkProvider.get(LifecycleService).applicationStart();
|
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) => {
|
const getErrorMessage = (error: unknown, fallback: string) => {
|
||||||
if (typeof error === 'string' && error) {
|
if (typeof error === 'string' && error) {
|
||||||
@@ -628,13 +629,21 @@ function createStoreManagerClient() {
|
|||||||
authTokenChannelServer.addEventListener('message', event => {
|
authTokenChannelServer.addEventListener('message', event => {
|
||||||
const { id, endpoint } = event.data as { id?: string; endpoint?: string };
|
const { id, endpoint } = event.data as { id?: string; endpoint?: string };
|
||||||
if (!id || !endpoint) return;
|
if (!id || !endpoint) return;
|
||||||
readEndpointToken(endpoint)
|
getValidAccessToken(endpoint)
|
||||||
.then(token => authTokenChannelServer.postMessage({ id, token }))
|
.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();
|
authTokenChannelServer.start();
|
||||||
worker.postMessage(
|
worker.postMessage(
|
||||||
{ type: 'native-auth-token-channel', port: authTokenChannelClient },
|
{ type: 'auth-access-token-channel', port: authTokenChannelClient },
|
||||||
[authTokenChannelClient]
|
[authTokenChannelClient]
|
||||||
);
|
);
|
||||||
return new StoreManagerClient(new OpClient(worker));
|
return new StoreManagerClient(new OpClient(worker));
|
||||||
|
|||||||
@@ -19,21 +19,49 @@ import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
|
|||||||
import { AsyncCall } from 'async-call-rpc';
|
import { AsyncCall } from 'async-call-rpc';
|
||||||
|
|
||||||
let authTokenPort: MessagePort | undefined;
|
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) => {
|
configureSocketAuthMethod((endpoint, cb) => {
|
||||||
readEndpointToken(endpoint)
|
getValidAccessToken(endpoint)
|
||||||
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
|
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
|
||||||
.catch(() => cb({}));
|
.catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
globalThis.addEventListener('message', e => {
|
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 = e.ports[0] as MessagePort;
|
||||||
authTokenPort.addEventListener('message', e => {
|
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;
|
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);
|
pendingTokenRequests.delete(id);
|
||||||
});
|
});
|
||||||
authTokenPort.start();
|
authTokenPort.start();
|
||||||
@@ -66,20 +94,26 @@ globalThis.addEventListener('message', e => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function readEndpointToken(endpoint: string) {
|
function getValidAccessToken(endpoint: string) {
|
||||||
if (!authTokenPort) {
|
if (!authTokenPort) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = `${Date.now()}:${Math.random()}`;
|
const id = `${Date.now()}:${Math.random()}`;
|
||||||
return new Promise<string | null>(resolve => {
|
return new Promise<string | null>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
pendingTokenRequests.delete(id);
|
pendingTokenRequests.delete(id);
|
||||||
resolve(null);
|
reject(new Error('AUTH_SESSION_TEMPORARILY_UNAVAILABLE'));
|
||||||
}, 5000);
|
}, 5000);
|
||||||
pendingTokenRequests.set(id, token => {
|
pendingTokenRequests.set(id, {
|
||||||
clearTimeout(timeout);
|
resolve: token => {
|
||||||
resolve(token);
|
clearTimeout(timeout);
|
||||||
|
resolve(token);
|
||||||
|
},
|
||||||
|
reject: error => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
authTokenPort?.postMessage({ id, endpoint });
|
authTokenPort?.postMessage({ id, endpoint });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,31 +4,25 @@ export interface AuthPlugin {
|
|||||||
email: string;
|
email: string;
|
||||||
token: string;
|
token: string;
|
||||||
clientNonce?: string;
|
clientNonce?: string;
|
||||||
}): Promise<{ token: string }>;
|
}): Promise<void>;
|
||||||
signInOauth(options: {
|
signInOauth(options: {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
code: string;
|
code: string;
|
||||||
state: string;
|
state: string;
|
||||||
clientNonce?: string;
|
clientNonce?: string;
|
||||||
}): Promise<{ token: string }>;
|
}): Promise<void>;
|
||||||
signInPassword(options: {
|
signInPassword(options: {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
verifyToken?: string;
|
verifyToken?: string;
|
||||||
challenge?: string;
|
challenge?: string;
|
||||||
}): Promise<{ token: string }>;
|
}): Promise<void>;
|
||||||
signInOpenApp(options: {
|
signInOpenApp(options: { endpoint: string; code: string }): Promise<void>;
|
||||||
endpoint: string;
|
signOut(options: { endpoint: string }): Promise<void>;
|
||||||
code: string;
|
getValidAccessToken(options: {
|
||||||
}): Promise<{ token: string }>;
|
|
||||||
signOut(options: { endpoint: string; token?: string | null }): Promise<void>;
|
|
||||||
readEndpointToken(options: {
|
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
}): Promise<{ token?: string | null }>;
|
}): Promise<{ token?: string | null }>;
|
||||||
writeEndpointToken(options: {
|
refreshAccessToken(options: { endpoint: string }): Promise<{ token: string }>;
|
||||||
endpoint: string;
|
clearEndpointSession(options: { endpoint: string }): Promise<void>;
|
||||||
token: string;
|
|
||||||
}): Promise<void>;
|
|
||||||
deleteEndpointToken(options: { endpoint: string }): Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { canonicalAuthEndpoint } from '@affine/mobile-shared/auth/endpoint';
|
||||||
|
|
||||||
import { Auth } from './plugins/auth';
|
import { Auth } from './plugins/auth';
|
||||||
|
|
||||||
function authEndpointForUrl(url: string | URL) {
|
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.
|
* 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.
|
* should be included in the entry file of the app or webworker.
|
||||||
@@ -22,22 +20,84 @@ function canonicalEndpoint(endpoint: string) {
|
|||||||
const rawFetch = globalThis.fetch;
|
const rawFetch = globalThis.fetch;
|
||||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const request = new Request(input, init);
|
const request = new Request(input, init);
|
||||||
|
const retry = request.clone();
|
||||||
|
|
||||||
const origin = authEndpointForUrl(request.url);
|
const origin = authEndpointForUrl(request.url);
|
||||||
|
|
||||||
const token = origin
|
const token = origin ? await getValidAccessToken(origin) : null;
|
||||||
? await readEndpointToken(origin).catch(() => null)
|
|
||||||
: null;
|
|
||||||
if (token) {
|
if (token) {
|
||||||
request.headers.set('Authorization', `Bearer ${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 rawXMLHttpRequest = globalThis.XMLHttpRequest;
|
||||||
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
|
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
|
||||||
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
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(
|
override open(
|
||||||
method: string,
|
method: string,
|
||||||
url: string | URL,
|
url: string | URL,
|
||||||
@@ -45,6 +105,11 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
|||||||
username?: string | null,
|
username?: string | null,
|
||||||
password?: string | null
|
password?: string | null
|
||||||
): void {
|
): 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());
|
xhrRequestUrls.set(this, url.toString());
|
||||||
return super.open(
|
return super.open(
|
||||||
method,
|
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 {
|
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
||||||
|
this.requestBody = body;
|
||||||
const requestUrl = xhrRequestUrls.get(this);
|
const requestUrl = xhrRequestUrls.get(this);
|
||||||
const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href);
|
const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href);
|
||||||
|
|
||||||
(origin ? readEndpointToken(origin) : Promise.resolve(null)).then(
|
(origin ? getValidAccessToken(origin) : Promise.resolve(null))
|
||||||
token => {
|
.then(token => {
|
||||||
if (token) {
|
if (token) {
|
||||||
this.setRequestHeader('Authorization', `Bearer ${token}`);
|
super.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||||
}
|
}
|
||||||
return super.send(body);
|
return super.send(body);
|
||||||
},
|
})
|
||||||
() => {
|
.catch(() => {
|
||||||
return super.send(body);
|
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
|
endpoint: string
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const { token } = await Auth.readEndpointToken({
|
const { token } = await Auth.getValidAccessToken({
|
||||||
endpoint: canonicalEndpoint(endpoint),
|
endpoint: canonicalAuthEndpoint(endpoint),
|
||||||
});
|
});
|
||||||
return token ?? null;
|
return token ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeEndpointToken(endpoint: string, token: string) {
|
export async function clearEndpointSession(endpoint: string) {
|
||||||
await Auth.writeEndpointToken({
|
await Auth.clearEndpointSession({
|
||||||
endpoint: canonicalEndpoint(endpoint),
|
endpoint: canonicalAuthEndpoint(endpoint),
|
||||||
token,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteEndpointToken(endpoint: string) {
|
|
||||||
await Auth.deleteEndpointToken({ endpoint: canonicalEndpoint(endpoint) });
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
"./auth/endpoint": "./src/auth/endpoint.ts",
|
||||||
"./nbstore/payload": "./src/nbstore/payload.ts"
|
"./nbstore/payload": "./src/nbstore/payload.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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);
|
||||||
|
});
|
||||||
|
});
|
||||||
10
packages/frontend/apps/mobile-shared/src/auth/endpoint.ts
Normal file
10
packages/frontend/apps/mobile-shared/src/auth/endpoint.ts
Normal file
@@ -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';
|
export * from './nbstore/payload';
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ export const Captcha = () => {
|
|||||||
const hasCaptchaFeature = useLiveData(captchaService.needCaptcha$);
|
const hasCaptchaFeature = useLiveData(captchaService.needCaptcha$);
|
||||||
const isLoading = useLiveData(captchaService.isLoading$);
|
const isLoading = useLiveData(captchaService.isLoading$);
|
||||||
const verifyToken = useLiveData(captchaService.verifyToken$);
|
const verifyToken = useLiveData(captchaService.verifyToken$);
|
||||||
|
const provider = useLiveData(captchaService.provider$);
|
||||||
|
const turnstile = useLiveData(captchaService.turnstile$);
|
||||||
|
const error = useLiveData(captchaService.error$);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
captchaService.revalidate();
|
if (hasCaptchaFeature) captchaService.revalidate();
|
||||||
}, [captchaService]);
|
}, [captchaService, hasCaptchaFeature]);
|
||||||
|
|
||||||
const handleTurnstileSuccess = useCallback(
|
const handleTurnstileSuccess = useCallback(
|
||||||
(token: string) => {
|
(token: string) => {
|
||||||
captchaService.challenge$.next(undefined);
|
captchaService.challenge$.next(undefined);
|
||||||
|
captchaService.provider$.next('turnstile');
|
||||||
captchaService.verifyToken$.next(token);
|
captchaService.verifyToken$.next(token);
|
||||||
},
|
},
|
||||||
[captchaService]
|
[captchaService]
|
||||||
@@ -26,7 +30,11 @@ export const Captcha = () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (error) {
|
||||||
|
return <div className={style.captchaWrapper}>Verification unavailable</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !provider) {
|
||||||
return <div className={style.captchaWrapper}>Loading...</div>;
|
return <div className={style.captchaWrapper}>Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,11 +42,18 @@ export const Captcha = () => {
|
|||||||
return <div className={style.captchaWrapper}>Verified Client</div>;
|
return <div className={style.captchaWrapper}>Verified Client</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider !== 'turnstile' || !turnstile) {
|
||||||
|
return <div className={style.captchaWrapper}>Verification failed</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Turnstile
|
<Turnstile
|
||||||
className={style.captchaWrapper}
|
className={style.captchaWrapper}
|
||||||
siteKey={BUILD_CONFIG.CAPTCHA_SITE_KEY || '1x00000000000000000000AA'}
|
siteKey={turnstile.siteKey}
|
||||||
|
options={{ action: turnstile.action }}
|
||||||
onSuccess={handleTurnstileSuccess}
|
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 { useLiveData, useService, useServices } from '@toeverything/infra';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
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 type { SettingState } from '../types';
|
||||||
import { AIUsagePanel } from './ai-usage-panel';
|
import { AIUsagePanel } from './ai-usage-panel';
|
||||||
import { DeleteAccount } from './delete-account';
|
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 = ({
|
export const AccountSetting = ({
|
||||||
onChangeSettingState,
|
onChangeSettingState,
|
||||||
}: {
|
}: {
|
||||||
@@ -244,6 +356,7 @@ export const AccountSetting = ({
|
|||||||
: t['com.affine.settings.password.action.set']()}
|
: t['com.affine.settings.password.action.set']()}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<DevicesPanel />
|
||||||
<StoragePanel onChangeSettingState={onChangeSettingState} />
|
<StoragePanel onChangeSettingState={onChangeSettingState} />
|
||||||
{serverFeatures?.copilot && (
|
{serverFeatures?.copilot && (
|
||||||
<AIUsagePanel onChangeSettingState={onChangeSettingState} />
|
<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 { AboutGroup } from './about';
|
||||||
import { AppearanceGroup } from './appearance';
|
import { AppearanceGroup } from './appearance';
|
||||||
|
import { DevicesGroup } from './devices';
|
||||||
import { ExperimentalFeatureSetting } from './experimental';
|
import { ExperimentalFeatureSetting } from './experimental';
|
||||||
import { OthersGroup } from './others';
|
import { OthersGroup } from './others';
|
||||||
import * as styles from './style.css';
|
import * as styles from './style.css';
|
||||||
@@ -26,6 +27,7 @@ const MobileSetting = () => {
|
|||||||
<UserProfile />
|
<UserProfile />
|
||||||
<UserSubscription />
|
<UserSubscription />
|
||||||
<UserUsage />
|
<UserUsage />
|
||||||
|
<DevicesGroup />
|
||||||
<AppearanceGroup />
|
<AppearanceGroup />
|
||||||
<AboutGroup />
|
<AboutGroup />
|
||||||
<ExperimentalFeatureSetting />
|
<ExperimentalFeatureSetting />
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export function configureDefaultAuthProvider(framework: Framework) {
|
|||||||
|
|
||||||
if (credential.verifyToken) {
|
if (credential.verifyToken) {
|
||||||
headers['x-captcha-token'] = credential.verifyToken;
|
headers['x-captcha-token'] = credential.verifyToken;
|
||||||
|
headers['x-captcha-provider'] = credential.challenge
|
||||||
|
? 'hashcash'
|
||||||
|
: 'turnstile';
|
||||||
}
|
}
|
||||||
if (credential.challenge) {
|
if (credential.challenge) {
|
||||||
headers['x-captcha-challenge'] = 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', {
|
const res = await fetchService.fetch('/api/auth/sign-in', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(credential),
|
body: JSON.stringify({
|
||||||
|
email: credential.email,
|
||||||
|
password: credential.password,
|
||||||
|
}),
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
...headers,
|
...headers,
|
||||||
@@ -94,6 +100,7 @@ export function configureDefaultAuthProvider(framework: Framework) {
|
|||||||
headers: csrfToken ? { 'x-affine-csrf-token': csrfToken } : undefined,
|
headers: csrfToken ? { 'x-affine-csrf-token': csrfToken } : undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
async clearSession() {},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export {
|
|||||||
getSelfHostedServerName,
|
getSelfHostedServerName,
|
||||||
} from './server-name';
|
} from './server-name';
|
||||||
export { AccessTokenService } from './services/access-token';
|
export { AccessTokenService } from './services/access-token';
|
||||||
export { AuthService } from './services/auth';
|
export { AuthService, type DeviceAuthSession } from './services/auth';
|
||||||
export { CaptchaService } from './services/captcha';
|
export { CaptchaService } from './services/captcha';
|
||||||
export { DefaultServerService } from './services/default-server';
|
export { DefaultServerService } from './services/default-server';
|
||||||
export { DocCreatedByUpdatedBySyncService } from './services/doc-created-by-updated-by-sync';
|
export { DocCreatedByUpdatedBySyncService } from './services/doc-created-by-updated-by-sync';
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface AuthProvider {
|
|||||||
signInOpenAppSignInCode(code: string): Promise<void>;
|
signInOpenAppSignInCode(code: string): Promise<void>;
|
||||||
|
|
||||||
signOut(): Promise<void>;
|
signOut(): Promise<void>;
|
||||||
|
|
||||||
|
clearSession(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthProvider = createIdentifier<AuthProvider>('AuthProvider');
|
export const AuthProvider = createIdentifier<AuthProvider>('AuthProvider');
|
||||||
|
|||||||
@@ -19,6 +19,28 @@ import { assertSupportedServerVersion } from '../stores/server-config';
|
|||||||
import type { FetchService } from './fetch';
|
import type { FetchService } from './fetch';
|
||||||
import type { ServerService } from './server';
|
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(ApplicationFocused, e => e.onApplicationFocused)
|
||||||
@OnEvent(ServerStarted, e => e.onServerStarted)
|
@OnEvent(ServerStarted, e => e.onServerStarted)
|
||||||
export class AuthService extends Service {
|
export class AuthService extends Service {
|
||||||
@@ -262,6 +284,42 @@ export class AuthService extends Service {
|
|||||||
this.session.revalidate();
|
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() {
|
async deleteAccount() {
|
||||||
const res = await this.store.deleteAccount();
|
const res = await this.store.deleteAccount();
|
||||||
this.store.setCachedAuthSession(null);
|
this.store.setCachedAuthSession(null);
|
||||||
@@ -277,6 +335,7 @@ export class AuthService extends Service {
|
|||||||
captchaHeaders(token: string, challenge?: string) {
|
captchaHeaders(token: string, challenge?: string) {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'x-captcha-token': token,
|
'x-captcha-token': token,
|
||||||
|
'x-captcha-provider': challenge ? 'hashcash' : 'turnstile',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (challenge) {
|
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
|
r => r?.captcha || false
|
||||||
);
|
);
|
||||||
challenge$ = new LiveData<string | undefined>(undefined);
|
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);
|
isLoading$ = new LiveData(false);
|
||||||
verifyToken$ = new LiveData<string | undefined>(undefined);
|
verifyToken$ = new LiveData<string | undefined>(undefined);
|
||||||
error$ = new LiveData<any | undefined>(undefined);
|
error$ = new LiveData<any | undefined>(undefined);
|
||||||
@@ -33,36 +37,59 @@ export class CaptchaService extends Service {
|
|||||||
revalidate = effect(
|
revalidate = effect(
|
||||||
exhaustMap(() => {
|
exhaustMap(() => {
|
||||||
return fromPromise(async signal => {
|
return fromPromise(async signal => {
|
||||||
if (!this.needCaptcha$.value || !this.validatorProvider) {
|
if (!this.needCaptcha$.value) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
const res = await this.fetchService.fetch('/api/auth/challenge', {
|
const res = await this.fetchService.fetch('/api/auth/captcha', {
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
const data = (await res.json()) as {
|
const data = (await res.json()) as {
|
||||||
challenge: string;
|
provider: 'hashcash' | 'turnstile';
|
||||||
resource: string;
|
challenge?: string;
|
||||||
|
resource?: string;
|
||||||
|
siteKey?: string;
|
||||||
|
action?: string;
|
||||||
};
|
};
|
||||||
if (!data || !data.challenge || !data.resource) {
|
if (data.provider === 'turnstile') {
|
||||||
throw new Error('Invalid challenge');
|
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(
|
const token = await this.validatorProvider.validate(
|
||||||
data.challenge,
|
data.challenge,
|
||||||
data.resource
|
data.resource
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
provider: data.provider,
|
||||||
token,
|
token,
|
||||||
challenge: data.challenge,
|
challenge: data.challenge,
|
||||||
};
|
};
|
||||||
}).pipe(
|
}).pipe(
|
||||||
tap(({ challenge, token }) => {
|
tap(({ challenge, provider, token, turnstile }) => {
|
||||||
|
this.provider$.next(provider);
|
||||||
|
this.turnstile$.next(turnstile);
|
||||||
this.verifyToken$.next(token);
|
this.verifyToken$.next(token);
|
||||||
this.challenge$.next(challenge);
|
this.challenge$.next(challenge);
|
||||||
this.resetAfter5min();
|
if (token) this.resetAfter5min();
|
||||||
}),
|
}),
|
||||||
catchErrorInto(this.error$),
|
catchErrorInto(this.error$),
|
||||||
onStart(() => {
|
onStart(() => {
|
||||||
|
this.error$.next(undefined);
|
||||||
this.challenge$.next(undefined);
|
this.challenge$.next(undefined);
|
||||||
|
this.provider$.next(undefined);
|
||||||
|
this.turnstile$.next(undefined);
|
||||||
this.verifyToken$.next(undefined);
|
this.verifyToken$.next(undefined);
|
||||||
this.isLoading$.next(true);
|
this.isLoading$.next(true);
|
||||||
}),
|
}),
|
||||||
@@ -81,6 +108,8 @@ export class CaptchaService extends Service {
|
|||||||
}).pipe(
|
}).pipe(
|
||||||
tap(_ => {
|
tap(_ => {
|
||||||
this.challenge$.next(undefined);
|
this.challenge$.next(undefined);
|
||||||
|
this.provider$.next(undefined);
|
||||||
|
this.turnstile$.next(undefined);
|
||||||
this.verifyToken$.next(undefined);
|
this.verifyToken$.next(undefined);
|
||||||
this.isLoading$.next(false);
|
this.isLoading$.next(false);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export class FetchService extends Service {
|
|||||||
headers: {
|
headers: {
|
||||||
...init?.headers,
|
...init?.headers,
|
||||||
'x-affine-version': BUILD_CONFIG.appVersion,
|
'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() {
|
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({
|
await this.nbstoreService.realtime.configure({
|
||||||
endpoint: this.serverService.server.baseUrl,
|
endpoint: this.serverService.server.baseUrl,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
|
|||||||
@@ -11,12 +11,12 @@
|
|||||||
"fa": 90,
|
"fa": 90,
|
||||||
"fr": 94,
|
"fr": 94,
|
||||||
"hi": 1,
|
"hi": 1,
|
||||||
"it": 92,
|
"it": 91,
|
||||||
"ja": 90,
|
"ja": 90,
|
||||||
"kk": 98,
|
"kk": 98,
|
||||||
"ko": 91,
|
"ko": 91,
|
||||||
"nb-NO": 45,
|
"nb-NO": 45,
|
||||||
"pl": 92,
|
"pl": 91,
|
||||||
"pt-BR": 90,
|
"pt-BR": 90,
|
||||||
"ru": 92,
|
"ru": 92,
|
||||||
"sv-SE": 90,
|
"sv-SE": 90,
|
||||||
|
|||||||
@@ -9036,6 +9036,58 @@ export function useAFFiNEI18N(): {
|
|||||||
* `Add icon`
|
* `Add icon`
|
||||||
*/
|
*/
|
||||||
["com.affine.docIconPicker.placeholder"](): string;
|
["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.`
|
* `An internal error occurred.`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2262,6 +2262,18 @@
|
|||||||
"com.affine.context-menu.paste": "Paste",
|
"com.affine.context-menu.paste": "Paste",
|
||||||
"com.affine.context-menu.cut": "Cut",
|
"com.affine.context-menu.cut": "Cut",
|
||||||
"com.affine.docIconPicker.placeholder": "Add icon",
|
"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.INTERNAL_SERVER_ERROR": "An internal error occurred.",
|
||||||
"error.NETWORK_ERROR": "Network error.",
|
"error.NETWORK_ERROR": "Network error.",
|
||||||
"error.TOO_MANY_REQUEST": "Too many requests.",
|
"error.TOO_MANY_REQUEST": "Too many requests.",
|
||||||
|
|||||||
1
tools/@types/build-config/__all.d.ts
vendored
1
tools/@types/build-config/__all.d.ts
vendored
@@ -36,7 +36,6 @@ declare interface BUILD_CONFIG_TYPE {
|
|||||||
linkPreviewUrl: string;
|
linkPreviewUrl: string;
|
||||||
|
|
||||||
SENTRY_DSN: string;
|
SENTRY_DSN: string;
|
||||||
CAPTCHA_SITE_KEY: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare var BUILD_CONFIG: BUILD_CONFIG_TYPE;
|
declare var BUILD_CONFIG: BUILD_CONFIG_TYPE;
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ export function getBuildConfig(
|
|||||||
imageProxyUrl: '/api/worker/image-proxy',
|
imageProxyUrl: '/api/worker/image-proxy',
|
||||||
linkPreviewUrl: '/api/worker/link-preview',
|
linkPreviewUrl: '/api/worker/link-preview',
|
||||||
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
|
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
|
||||||
CAPTCHA_SITE_KEY: process.env.CAPTCHA_SITE_KEY ?? '',
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
get beta() {
|
get beta() {
|
||||||
|
|||||||
@@ -1209,6 +1209,7 @@ export const PackageList = [
|
|||||||
location: 'packages/frontend/apps/electron',
|
location: 'packages/frontend/apps/electron',
|
||||||
name: '@affine/electron',
|
name: '@affine/electron',
|
||||||
workspaceDependencies: [
|
workspaceDependencies: [
|
||||||
|
'packages/common/auth',
|
||||||
'tools/utils',
|
'tools/utils',
|
||||||
'packages/frontend/i18n',
|
'packages/frontend/i18n',
|
||||||
'packages/frontend/native',
|
'packages/frontend/native',
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ __metadata:
|
|||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
"@affine/auth@workspace:packages/common/auth":
|
"@affine/auth@workspace:*, @affine/auth@workspace:packages/common/auth":
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@affine/auth@workspace:packages/common/auth"
|
resolution: "@affine/auth@workspace:packages/common/auth"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -555,6 +555,7 @@ __metadata:
|
|||||||
resolution: "@affine/electron@workspace:packages/frontend/apps/electron"
|
resolution: "@affine/electron@workspace:packages/frontend/apps/electron"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@affine-tools/utils": "workspace:*"
|
"@affine-tools/utils": "workspace:*"
|
||||||
|
"@affine/auth": "workspace:*"
|
||||||
"@affine/i18n": "workspace:*"
|
"@affine/i18n": "workspace:*"
|
||||||
"@affine/native": "workspace:*"
|
"@affine/native": "workspace:*"
|
||||||
"@affine/nbstore": "workspace:*"
|
"@affine/nbstore": "workspace:*"
|
||||||
|
|||||||
Reference in New Issue
Block a user