mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 04:26:23 +08:00
feat(server): passkey pre-refactor (#15060)
#### PR Dependency Tree * **PR #15060** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * OpenApp native sign-in and native session exchange (JWT) for mobile & desktop. * Centralized short-lived auth challenge store for one-time tokens. * Encrypted per-endpoint token storage and native token handlers (Android, iOS, Electron). * **Improvements** * Richer auth-method reporting (password, magic link, OAuth, passkey) and improved sign-in flows. * Hardened magic-link, OAuth, and session issuance; JWT-backed sessions and websocket JWT support. * UX tweaks: form-based password submit, OTP autocomplete, adjusted captcha flow. * **Bug Fixes** * Expanded tests and auth-state resets to avoid cross-test leakage. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
+3
-26
@@ -1,9 +1,6 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.webkit.WebView
|
||||
import app.affine.pro.service.CookieStore
|
||||
import app.affine.pro.utils.dataStore
|
||||
import app.affine.pro.utils.get
|
||||
import app.affine.pro.utils.getCurrentServerBaseUrl
|
||||
import app.affine.pro.utils.logger.FileTree
|
||||
import com.getcapacitor.Bridge
|
||||
@@ -11,7 +8,6 @@ import com.getcapacitor.WebViewListener
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -23,30 +19,11 @@ object AuthInitializer {
|
||||
bridge.removeWebViewListener(this)
|
||||
MainScope().launch(Dispatchers.IO) {
|
||||
try {
|
||||
val server = bridge.getCurrentServerBaseUrl().toHttpUrl()
|
||||
val sessionCookieStr = AFFiNEApp.context().dataStore
|
||||
.get(server.host + CookieStore.AFFINE_SESSION)
|
||||
val userIdCookieStr = AFFiNEApp.context().dataStore
|
||||
.get(server.host + CookieStore.AFFINE_USER_ID)
|
||||
val csrfCookieStr = AFFiNEApp.context().dataStore
|
||||
.get(server.host + CookieStore.AFFINE_CSRF_TOKEN)
|
||||
if (sessionCookieStr.isEmpty() || userIdCookieStr.isEmpty() || csrfCookieStr.isEmpty()) {
|
||||
Timber.i("[init] user has not signed in yet.")
|
||||
return@launch
|
||||
}
|
||||
Timber.i("[init] user already signed in.")
|
||||
val cookies = listOf(
|
||||
Cookie.parse(server, sessionCookieStr)
|
||||
?: error("Parse session cookie fail:[ cookie = $sessionCookieStr ]"),
|
||||
Cookie.parse(server, userIdCookieStr)
|
||||
?: error("Parse user id cookie fail:[ cookie = $userIdCookieStr ]"),
|
||||
Cookie.parse(server, csrfCookieStr)
|
||||
?: error("Parse csrf token cookie fail:[ cookie = $csrfCookieStr ]"),
|
||||
FileTree.get()?.checkAndUploadOldLogs(
|
||||
bridge.getCurrentServerBaseUrl().toHttpUrl()
|
||||
)
|
||||
CookieStore.saveCookies(server.host, cookies)
|
||||
FileTree.get()?.checkAndUploadOldLogs(server)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "[init] load persistent cookies fail.")
|
||||
Timber.w(e, "[init] auth initializer fail.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+212
-9
@@ -1,8 +1,16 @@
|
||||
package app.affine.pro.plugin
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import app.affine.pro.AFFiNEApp
|
||||
import app.affine.pro.service.AuthHttp
|
||||
import app.affine.pro.service.CookieStore
|
||||
import app.affine.pro.service.OkHttp
|
||||
import app.affine.pro.utils.dataStore
|
||||
import app.affine.pro.utils.del
|
||||
import app.affine.pro.utils.get
|
||||
import app.affine.pro.utils.set
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
@@ -10,6 +18,7 @@ import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.Request
|
||||
@@ -17,10 +26,90 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.coroutines.executeAsync
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@CapacitorPlugin(name = "Auth")
|
||||
class AuthPlugin : Plugin() {
|
||||
private fun canonicalEndpoint(endpoint: String): String = try {
|
||||
val url = endpoint.toHttpUrl()
|
||||
val port = if (url.port == HttpUrl.defaultPort(url.scheme)) "" else ":${url.port}"
|
||||
"${url.scheme}://${url.host}$port"
|
||||
} catch (_: Exception) {
|
||||
endpoint
|
||||
}
|
||||
|
||||
private fun tokenKey(endpoint: String) = "auth-token:${canonicalEndpoint(endpoint)}"
|
||||
private fun legacyTokenKey(endpoint: String) = "auth-token:$endpoint"
|
||||
private val tokenCipher = TokenCipher()
|
||||
|
||||
@PluginMethod
|
||||
fun readEndpointToken(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val endpoint = call.getStringEnsure("endpoint")
|
||||
val key = tokenKey(endpoint)
|
||||
val legacyKey = legacyTokenKey(endpoint)
|
||||
val store = AFFiNEApp.context().dataStore
|
||||
val storedKey = key.takeIf { store.get(it).isNotEmpty() }
|
||||
?: legacyKey.takeIf { it != key && store.get(it).isNotEmpty() }
|
||||
val storedToken = storedKey?.let { store.get(it) }?.takeIf { it.isNotEmpty() }
|
||||
val token = storedToken?.let {
|
||||
tokenCipher.decrypt(it) ?: tokenCipher.legacyPlaintext(it)
|
||||
}
|
||||
if (
|
||||
storedToken != null &&
|
||||
token != null &&
|
||||
(storedKey != key || !tokenCipher.isEncrypted(storedToken))
|
||||
) {
|
||||
store.set(key, tokenCipher.encrypt(token))
|
||||
storedKey?.let {
|
||||
if (it != key) {
|
||||
store.del(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
call.resolve(JSObject().put("token", token))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to read endpoint token.", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun writeEndpointToken(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val endpoint = call.getStringEnsure("endpoint")
|
||||
val token = call.getStringEnsure("token")
|
||||
AFFiNEApp.context().dataStore.set(
|
||||
tokenKey(endpoint),
|
||||
tokenCipher.encrypt(token)
|
||||
)
|
||||
call.resolve(JSObject().put("ok", true))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to write endpoint token.", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun deleteEndpointToken(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val endpoint = call.getStringEnsure("endpoint")
|
||||
AFFiNEApp.context().dataStore.del(tokenKey(endpoint))
|
||||
AFFiNEApp.context().dataStore.del(legacyTokenKey(endpoint))
|
||||
call.resolve(JSObject().put("ok", true))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to delete endpoint token.", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun signInMagicLink(call: PluginCall) {
|
||||
@@ -32,6 +121,11 @@ class AuthPlugin : Plugin() {
|
||||
processSignIn(call, SignInMethod.Oauth)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun signInOpenApp(call: PluginCall) {
|
||||
processSignIn(call, SignInMethod.OpenApp)
|
||||
}
|
||||
|
||||
@SuppressLint("BuildListAdds")
|
||||
@PluginMethod
|
||||
fun signInPassword(call: PluginCall) {
|
||||
@@ -43,21 +137,22 @@ class AuthPlugin : Plugin() {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val endpoint = call.getStringEnsure("endpoint")
|
||||
val csrfToken = CookieStore.getCookie(endpoint.toHttpUrl(), CookieStore.AFFINE_CSRF_TOKEN)
|
||||
val token = call.getString("token")
|
||||
val request = Request.Builder()
|
||||
.url("$endpoint/api/auth/sign-out")
|
||||
.post("".toRequestBody("application/json".toMediaTypeOrNull()))
|
||||
.apply {
|
||||
if (csrfToken != null) {
|
||||
addHeader("x-affine-csrf-token", csrfToken)
|
||||
if (token != null) {
|
||||
addHeader("Authorization", "Bearer $token")
|
||||
}
|
||||
}
|
||||
.build()
|
||||
OkHttp.client.newCall(request).executeAsync().use { response ->
|
||||
AuthHttp.client.newCall(request).executeAsync().use { response ->
|
||||
if (response.code >= 400) {
|
||||
call.reject(response.body.string())
|
||||
return@launch
|
||||
}
|
||||
CookieStore.clearAuthCookies(endpoint.toHttpUrl().host)
|
||||
Timber.i("Sign out success.")
|
||||
call.resolve(JSObject().put("ok", true))
|
||||
}
|
||||
@@ -69,7 +164,7 @@ class AuthPlugin : Plugin() {
|
||||
}
|
||||
|
||||
private enum class SignInMethod {
|
||||
Password, Oauth, MagicLink
|
||||
Password, Oauth, MagicLink, OpenApp
|
||||
}
|
||||
|
||||
private fun processSignIn(call: PluginCall, method: SignInMethod) {
|
||||
@@ -92,6 +187,7 @@ class AuthPlugin : Plugin() {
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
.url("$endpoint/api/auth/sign-in")
|
||||
.addHeader("x-affine-client-kind", "native")
|
||||
.post(body)
|
||||
if (verifyToken != null) {
|
||||
requestBuilder.addHeader("x-captcha-token", verifyToken)
|
||||
@@ -117,6 +213,7 @@ class AuthPlugin : Plugin() {
|
||||
|
||||
Request.Builder()
|
||||
.url("$endpoint/api/oauth/callback")
|
||||
.addHeader("x-affine-client-kind", "native")
|
||||
.post(body)
|
||||
.build()
|
||||
}
|
||||
@@ -136,19 +233,41 @@ class AuthPlugin : Plugin() {
|
||||
|
||||
Request.Builder()
|
||||
.url("$endpoint/api/auth/magic-link")
|
||||
.addHeader("x-affine-client-kind", "native")
|
||||
.post(body)
|
||||
.build()
|
||||
}
|
||||
|
||||
SignInMethod.OpenApp -> {
|
||||
val code = call.getStringEnsure("code")
|
||||
val body = JSONObject()
|
||||
.apply { put("code", code) }
|
||||
.toString()
|
||||
.toRequestBody("application/json".toMediaTypeOrNull())
|
||||
|
||||
Request.Builder()
|
||||
.url("$endpoint/api/auth/open-app/sign-in")
|
||||
.addHeader("x-affine-client-kind", "native")
|
||||
.post(body)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
OkHttp.client.newCall(request).executeAsync().use { response ->
|
||||
AuthHttp.client.newCall(request).executeAsync().use { response ->
|
||||
if (response.code >= 400) {
|
||||
call.reject(response.body.string())
|
||||
return@launch
|
||||
}
|
||||
CookieStore.getCookie(endpoint.toHttpUrl(), CookieStore.AFFINE_SESSION)?.let {
|
||||
val exchangeCode = JSONObject(response.body.string()).optString("exchangeCode").takeIf { it.isNotEmpty() }
|
||||
if (exchangeCode == null) {
|
||||
Timber.w("$method sign in fail, exchange code not found.")
|
||||
call.reject("$method sign in fail, exchange code not found")
|
||||
return@launch
|
||||
}
|
||||
val token = exchangeSession(endpoint, exchangeCode)
|
||||
token.takeIf { it.isNotEmpty() }?.let {
|
||||
CookieStore.clearAuthCookies(endpoint.toHttpUrl().host)
|
||||
Timber.i("$method sign in success.")
|
||||
Timber.d("Update session [$it]")
|
||||
call.resolve(JSObject().put("token", it))
|
||||
} ?: run {
|
||||
Timber.w("$method sign in fail, token not found.")
|
||||
@@ -161,4 +280,88 @@ class AuthPlugin : Plugin() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun exchangeSession(endpoint: String, code: String): String {
|
||||
val body = JSONObject()
|
||||
.apply { put("code", code) }
|
||||
.toString()
|
||||
.toRequestBody("application/json".toMediaTypeOrNull())
|
||||
val request = Request.Builder()
|
||||
.url("$endpoint/api/auth/native/exchange")
|
||||
.addHeader("x-affine-client-kind", "native")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
AuthHttp.client.newCall(request).executeAsync().use { response ->
|
||||
if (response.code >= 400) {
|
||||
throw Exception(response.body.string())
|
||||
}
|
||||
return JSONObject(response.body.string()).optString("token")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class TokenCipher {
|
||||
private val alias = "affine-native-auth-token"
|
||||
private val transformation = "AES/GCM/NoPadding"
|
||||
|
||||
fun encrypt(plaintext: String): String {
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
|
||||
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||
return listOf(
|
||||
"v1",
|
||||
Base64.encodeToString(cipher.iv, Base64.NO_WRAP),
|
||||
Base64.encodeToString(ciphertext, Base64.NO_WRAP),
|
||||
).joinToString(":")
|
||||
}
|
||||
|
||||
fun decrypt(encoded: String): String? {
|
||||
val parts = encoded.split(":")
|
||||
if (parts.size != 3 || parts[0] != "v1") {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
val iv = Base64.decode(parts[1], Base64.NO_WRAP)
|
||||
val ciphertext = Base64.decode(parts[2], Base64.NO_WRAP)
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
secretKey(),
|
||||
GCMParameterSpec(128, iv)
|
||||
)
|
||||
String(cipher.doFinal(ciphertext), Charsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Failed to decrypt auth token.")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun isEncrypted(value: String) = value.startsWith("v1:")
|
||||
|
||||
fun legacyPlaintext(value: String) =
|
||||
value.takeIf { !isEncrypted(it) && it.isNotBlank() }
|
||||
|
||||
private fun secretKey(): SecretKey {
|
||||
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
(keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.let {
|
||||
return it.secretKey
|
||||
}
|
||||
|
||||
val keyGenerator = KeyGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_AES,
|
||||
"AndroidKeyStore"
|
||||
)
|
||||
val spec = KeyGenParameterSpec.Builder(
|
||||
alias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.build()
|
||||
keyGenerator.init(spec)
|
||||
return keyGenerator.generateKey()
|
||||
}
|
||||
}
|
||||
|
||||
+27
-3
@@ -3,6 +3,7 @@ package app.affine.pro.service
|
||||
import app.affine.pro.AFFiNEApp
|
||||
import app.affine.pro.CapacitorConfig
|
||||
import app.affine.pro.utils.dataStore
|
||||
import app.affine.pro.utils.del
|
||||
import app.affine.pro.utils.set
|
||||
import com.google.firebase.crashlytics.ktx.crashlytics
|
||||
import com.google.firebase.ktx.Firebase
|
||||
@@ -50,6 +51,20 @@ object OkHttp {
|
||||
|
||||
}
|
||||
|
||||
object AuthHttp {
|
||||
val client = OkHttpClient.Builder()
|
||||
.cookieJar(CookieJar.NO_COOKIES)
|
||||
.addInterceptor {
|
||||
it.proceed(
|
||||
it.request()
|
||||
.newBuilder()
|
||||
.addHeader("x-affine-version", CapacitorConfig.getAffineVersion())
|
||||
.build()
|
||||
)
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
object CookieStore {
|
||||
|
||||
const val AFFINE_SESSION = "affine_session"
|
||||
@@ -61,9 +76,6 @@ object CookieStore {
|
||||
fun saveCookies(host: String, cookies: List<Cookie>) {
|
||||
_cookies[host] = cookies
|
||||
MainScope().launch(Dispatchers.IO) {
|
||||
cookies.find { it.name == AFFINE_SESSION }?.let {
|
||||
AFFiNEApp.context().dataStore.set(host + AFFINE_SESSION, it.toString())
|
||||
}
|
||||
cookies.find { it.name == AFFINE_USER_ID }?.let {
|
||||
Timber.d("Update user id [${it.value}]")
|
||||
AFFiNEApp.context().dataStore.set(host + AFFINE_USER_ID, it.toString())
|
||||
@@ -77,6 +89,18 @@ object CookieStore {
|
||||
|
||||
fun getCookies(host: String) = _cookies[host] ?: emptyList()
|
||||
|
||||
fun clearAuthCookies(host: String) {
|
||||
val cookies = _cookies[host] ?: emptyList()
|
||||
_cookies[host] = cookies.filter {
|
||||
it.name != AFFINE_SESSION && it.name != AFFINE_USER_ID && it.name != AFFINE_CSRF_TOKEN
|
||||
}
|
||||
MainScope().launch(Dispatchers.IO) {
|
||||
AFFiNEApp.context().dataStore.del(host + AFFINE_USER_ID)
|
||||
AFFiNEApp.context().dataStore.del(host + AFFINE_CSRF_TOKEN)
|
||||
Firebase.crashlytics.setUserId("")
|
||||
}
|
||||
}
|
||||
|
||||
fun getCookie(url: HttpUrl, name: String) = url.host
|
||||
.let { _cookies[it] }
|
||||
?.find { cookie -> cookie.name == name }
|
||||
|
||||
+7
-1
@@ -17,6 +17,12 @@ suspend fun DataStore<Preferences>.set(key: String, value: String) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun DataStore<Preferences>.del(key: String) {
|
||||
edit {
|
||||
it.remove(stringPreferencesKey(key))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun DataStore<Preferences>.get(key: String) = data.map {
|
||||
it[stringPreferencesKey(key)] ?: ""
|
||||
}.first()
|
||||
}.first()
|
||||
|
||||
@@ -57,7 +57,11 @@ import { Auth } from './plugins/auth';
|
||||
import { HashCash } from './plugins/hashcash';
|
||||
import { NbStoreNativeDBApis } from './plugins/nbstore';
|
||||
import { Preview } from './plugins/preview';
|
||||
import { writeEndpointToken } from './proxy';
|
||||
import {
|
||||
deleteEndpointToken,
|
||||
readEndpointToken,
|
||||
writeEndpointToken,
|
||||
} from './proxy';
|
||||
|
||||
const storeManagerClient = createStoreManagerClient();
|
||||
setTelemetryTransport(storeManagerClient.telemetry);
|
||||
@@ -206,10 +210,20 @@ framework.scope(ServerScope).override(AuthProvider, resolver => {
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
},
|
||||
async signOut() {
|
||||
await Auth.signOut({
|
||||
async signInOpenAppSignInCode(code) {
|
||||
const { token } = await Auth.signInOpenApp({
|
||||
endpoint,
|
||||
code,
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
},
|
||||
async signOut() {
|
||||
const token = await readEndpointToken(endpoint);
|
||||
try {
|
||||
await Auth.signOut({ endpoint, token });
|
||||
} finally {
|
||||
await deleteEndpointToken(endpoint);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -442,5 +456,20 @@ function createStoreManagerClient() {
|
||||
},
|
||||
[nativeDBApiChannelClient]
|
||||
);
|
||||
|
||||
const { port1: authTokenChannelServer, port2: authTokenChannelClient } =
|
||||
new MessageChannel();
|
||||
authTokenChannelServer.addEventListener('message', event => {
|
||||
const { id, endpoint } = event.data as { id?: string; endpoint?: string };
|
||||
if (!id || !endpoint) return;
|
||||
readEndpointToken(endpoint)
|
||||
.then(token => authTokenChannelServer.postMessage({ id, token }))
|
||||
.catch(() => authTokenChannelServer.postMessage({ id, token: null }));
|
||||
});
|
||||
authTokenChannelServer.start();
|
||||
worker.postMessage(
|
||||
{ type: 'native-auth-token-channel', port: authTokenChannelClient },
|
||||
[authTokenChannelClient]
|
||||
);
|
||||
return new StoreManagerClient(new OpClient(worker));
|
||||
}
|
||||
|
||||
@@ -18,19 +18,28 @@ import {
|
||||
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
|
||||
import { AsyncCall } from 'async-call-rpc';
|
||||
|
||||
import { readEndpointToken } from './proxy';
|
||||
let authTokenPort: MessagePort | undefined;
|
||||
const pendingTokenRequests = new Map<string, (token: string | null) => void>();
|
||||
|
||||
configureSocketAuthMethod((endpoint, cb) => {
|
||||
readEndpointToken(endpoint)
|
||||
.then(token => {
|
||||
cb({ token });
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
|
||||
.catch(() => cb({}));
|
||||
});
|
||||
|
||||
globalThis.addEventListener('message', e => {
|
||||
if (e.data.type === 'native-auth-token-channel') {
|
||||
authTokenPort = e.ports[0] as MessagePort;
|
||||
authTokenPort.addEventListener('message', e => {
|
||||
const { id, token } = e.data as { id?: string; token?: string | null };
|
||||
if (!id) return;
|
||||
pendingTokenRequests.get(id)?.(token ?? null);
|
||||
pendingTokenRequests.delete(id);
|
||||
});
|
||||
authTokenPort.start();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.data.type === 'native-db-api-channel') {
|
||||
const port = e.ports[0] as MessagePort;
|
||||
const rpc = AsyncCall<NativeDBApis>(
|
||||
@@ -57,6 +66,25 @@ globalThis.addEventListener('message', e => {
|
||||
}
|
||||
});
|
||||
|
||||
function readEndpointToken(endpoint: string) {
|
||||
if (!authTokenPort) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
const id = `${Date.now()}:${Math.random()}`;
|
||||
return new Promise<string | null>(resolve => {
|
||||
const timeout = setTimeout(() => {
|
||||
pendingTokenRequests.delete(id);
|
||||
resolve(null);
|
||||
}, 5000);
|
||||
pendingTokenRequests.set(id, token => {
|
||||
clearTimeout(timeout);
|
||||
resolve(token);
|
||||
});
|
||||
authTokenPort?.postMessage({ id, endpoint });
|
||||
});
|
||||
}
|
||||
|
||||
const consumer = new OpConsumer<WorkerManagerOps>(
|
||||
globalThis as MessageCommunicapable
|
||||
);
|
||||
|
||||
@@ -18,5 +18,17 @@ export interface AuthPlugin {
|
||||
verifyToken?: string;
|
||||
challenge?: string;
|
||||
}): Promise<{ token: string }>;
|
||||
signOut(options: { endpoint: string }): Promise<void>;
|
||||
signInOpenApp(options: {
|
||||
endpoint: string;
|
||||
code: string;
|
||||
}): Promise<{ token: string }>;
|
||||
signOut(options: { endpoint: string; token?: string | null }): Promise<void>;
|
||||
readEndpointToken(options: {
|
||||
endpoint: string;
|
||||
}): Promise<{ token?: string | null }>;
|
||||
writeEndpointToken(options: {
|
||||
endpoint: string;
|
||||
token: string;
|
||||
}): Promise<void>;
|
||||
deleteEndpointToken(options: { endpoint: string }): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
import { openDB } from 'idb';
|
||||
import { Auth } from './plugins/auth';
|
||||
|
||||
function authEndpointForUrl(url: string | URL) {
|
||||
try {
|
||||
const parsed = new URL(url, globalThis.location.origin);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||
? parsed.origin
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalEndpoint(endpoint: string) {
|
||||
return authEndpointForUrl(endpoint) ?? endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* the below code includes the custom fetch and xmlhttprequest implementation for ios webview.
|
||||
@@ -8,9 +23,11 @@ const rawFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
|
||||
const origin = new URL(request.url, globalThis.location.origin).origin;
|
||||
const origin = authEndpointForUrl(request.url);
|
||||
|
||||
const token = await readEndpointToken(origin);
|
||||
const token = origin
|
||||
? await readEndpointToken(origin).catch(() => null)
|
||||
: null;
|
||||
if (token) {
|
||||
request.headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
@@ -19,11 +36,30 @@ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
};
|
||||
|
||||
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
|
||||
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
|
||||
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
||||
const origin = new URL(this.responseURL, globalThis.location.origin).origin;
|
||||
override open(
|
||||
method: string,
|
||||
url: string | URL,
|
||||
async: boolean = true,
|
||||
username?: string | null,
|
||||
password?: string | null
|
||||
): void {
|
||||
xhrRequestUrls.set(this, url.toString());
|
||||
return super.open(
|
||||
method,
|
||||
url,
|
||||
async,
|
||||
username ?? undefined,
|
||||
password ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
readEndpointToken(origin).then(
|
||||
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
||||
const requestUrl = xhrRequestUrls.get(this);
|
||||
const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href);
|
||||
|
||||
(origin ? readEndpointToken(origin) : Promise.resolve(null)).then(
|
||||
token => {
|
||||
if (token) {
|
||||
this.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
@@ -31,7 +67,7 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
return super.send(body);
|
||||
},
|
||||
() => {
|
||||
throw new Error('Failed to read token');
|
||||
return super.send(body);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -40,26 +76,19 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
export async function readEndpointToken(
|
||||
endpoint: string
|
||||
): Promise<string | null> {
|
||||
const idb = await openDB('affine-token', 1, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains('tokens')) {
|
||||
db.createObjectStore('tokens', { keyPath: 'endpoint' });
|
||||
}
|
||||
},
|
||||
const { token } = await Auth.readEndpointToken({
|
||||
endpoint: canonicalEndpoint(endpoint),
|
||||
});
|
||||
|
||||
const token = await idb.get('tokens', endpoint);
|
||||
return token ? token.token : null;
|
||||
return token ?? null;
|
||||
}
|
||||
|
||||
export async function writeEndpointToken(endpoint: string, token: string) {
|
||||
const db = await openDB('affine-token', 1, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains('tokens')) {
|
||||
db.createObjectStore('tokens', { keyPath: 'endpoint' });
|
||||
}
|
||||
},
|
||||
await Auth.writeEndpointToken({
|
||||
endpoint: canonicalEndpoint(endpoint),
|
||||
token,
|
||||
});
|
||||
|
||||
await db.put('tokens', { endpoint, token });
|
||||
}
|
||||
|
||||
export async function deleteEndpointToken(endpoint: string) {
|
||||
await Auth.deleteEndpointToken({ endpoint: canonicalEndpoint(endpoint) });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ import { configureElectronStateStorageImpls } from '@affine/core/desktop/storage
|
||||
import { configureCommonModules } from '@affine/core/modules';
|
||||
import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header';
|
||||
import { configureDesktopBackupModule } from '@affine/core/modules/backup';
|
||||
import { ValidatorProvider } from '@affine/core/modules/cloud';
|
||||
import {
|
||||
AuthProvider,
|
||||
ServerScope,
|
||||
ServerService,
|
||||
ValidatorProvider,
|
||||
} from '@affine/core/modules/cloud';
|
||||
import {
|
||||
configureDesktopApiModule,
|
||||
DesktopApiService,
|
||||
@@ -63,6 +68,39 @@ export function setupModules() {
|
||||
},
|
||||
};
|
||||
});
|
||||
framework.scope(ServerScope).override(AuthProvider, p => {
|
||||
const apis = p.get(DesktopApiService).api;
|
||||
const serverService = p.get(ServerService);
|
||||
const endpoint = serverService.server.baseUrl;
|
||||
|
||||
return {
|
||||
async signInMagicLink(email, token, clientNonce) {
|
||||
await apis.handler.auth.signInMagicLink(
|
||||
endpoint,
|
||||
email,
|
||||
token,
|
||||
clientNonce
|
||||
);
|
||||
},
|
||||
async signInOauth(code, state, _provider, clientNonce) {
|
||||
return await apis.handler.auth.signInOauth(
|
||||
endpoint,
|
||||
code,
|
||||
state,
|
||||
clientNonce
|
||||
);
|
||||
},
|
||||
async signInPassword(credential) {
|
||||
await apis.handler.auth.signInPassword(endpoint, credential);
|
||||
},
|
||||
async signInOpenAppSignInCode(code) {
|
||||
await apis.handler.auth.signInOpenAppSignInCode(endpoint, code);
|
||||
},
|
||||
async signOut() {
|
||||
await apis.handler.auth.signOut(endpoint);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const frameworkProvider = framework.provider();
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import '@affine/core/bootstrap/electron';
|
||||
|
||||
import { apis } from '@affine/electron-api';
|
||||
import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel';
|
||||
import { cloudStorages } from '@affine/nbstore/cloud';
|
||||
import {
|
||||
cloudStorages,
|
||||
configureSocketAuthMethod,
|
||||
} from '@affine/nbstore/cloud';
|
||||
import { bindNativeDBApis, sqliteStorages } from '@affine/nbstore/sqlite';
|
||||
import {
|
||||
bindNativeDBV1Apis,
|
||||
@@ -18,6 +21,15 @@ import { OpConsumer } from '@toeverything/infra/op';
|
||||
bindNativeDBApis(apis!.nbstore);
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
bindNativeDBV1Apis(apis!.db);
|
||||
configureSocketAuthMethod((endpoint, cb) => {
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
apis!.auth
|
||||
.readEndpointToken(endpoint)
|
||||
.then(({ token }: { token?: string | null }) => {
|
||||
cb(token ? { token, tokenType: 'jwt' } : {});
|
||||
})
|
||||
.catch(() => cb({}));
|
||||
});
|
||||
|
||||
const storeManager = new StoreManagerConsumer([
|
||||
...sqliteStorages,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { net, session } from 'electron';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import {
|
||||
deleteNativeAuthToken,
|
||||
getNativeAuthToken,
|
||||
setNativeAuthToken,
|
||||
} from './native-token';
|
||||
|
||||
interface SignInResponse {
|
||||
exchangeCode?: string;
|
||||
redirectUri?: string;
|
||||
}
|
||||
|
||||
interface ExchangeResponse {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
const authCookieNames = [
|
||||
'affine_session',
|
||||
'affine_user_id',
|
||||
'affine_csrf_token',
|
||||
];
|
||||
|
||||
function authUrl(endpoint: string, path: string) {
|
||||
return new URL(path, endpoint).toString();
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text || response.statusText);
|
||||
}
|
||||
|
||||
return text ? JSON.parse(text) : ({} as T);
|
||||
}
|
||||
|
||||
async function fetchAuth(endpoint: string, path: string, body?: unknown) {
|
||||
return await net.fetch(authUrl(endpoint, path), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-affine-client-kind': 'native',
|
||||
'x-affine-version': BUILD_CONFIG.appVersion,
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function clearAuthCookies(endpoint: string) {
|
||||
await Promise.all(
|
||||
authCookieNames.map(name =>
|
||||
session.defaultSession.cookies
|
||||
.remove(endpoint, name)
|
||||
.catch(error =>
|
||||
logger.debug(
|
||||
'failed to clear native auth cookie',
|
||||
endpoint,
|
||||
name,
|
||||
error
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function exchangeSession(endpoint: string, response: SignInResponse) {
|
||||
if (!response.exchangeCode) {
|
||||
throw new Error('Missing native auth exchange code.');
|
||||
}
|
||||
|
||||
const exchangeResponse = await fetchAuth(
|
||||
endpoint,
|
||||
'/api/auth/native/exchange',
|
||||
{ code: response.exchangeCode }
|
||||
);
|
||||
const body = await readJson<ExchangeResponse>(exchangeResponse);
|
||||
if (!body.token) {
|
||||
throw new Error('Missing native auth token.');
|
||||
}
|
||||
|
||||
setNativeAuthToken(endpoint, body.token);
|
||||
await clearAuthCookies(endpoint);
|
||||
}
|
||||
|
||||
export const authHandlers = {
|
||||
signInMagicLink: async (
|
||||
_,
|
||||
endpoint: string,
|
||||
email: string,
|
||||
token: string,
|
||||
clientNonce?: string
|
||||
) => {
|
||||
const response = await fetchAuth(endpoint, '/api/auth/magic-link', {
|
||||
email,
|
||||
token,
|
||||
client_nonce: clientNonce,
|
||||
});
|
||||
await exchangeSession(endpoint, await readJson(response));
|
||||
},
|
||||
|
||||
signInOauth: async (
|
||||
_,
|
||||
endpoint: string,
|
||||
code: string,
|
||||
state: string,
|
||||
clientNonce?: string
|
||||
) => {
|
||||
const response = await fetchAuth(endpoint, '/api/oauth/callback', {
|
||||
code,
|
||||
state,
|
||||
client_nonce: clientNonce,
|
||||
});
|
||||
const body = await readJson<SignInResponse>(response);
|
||||
await exchangeSession(endpoint, body);
|
||||
return { redirectUri: body.redirectUri };
|
||||
},
|
||||
|
||||
signInPassword: async (
|
||||
_,
|
||||
endpoint: string,
|
||||
credential: {
|
||||
email: string;
|
||||
password: string;
|
||||
verifyToken?: string;
|
||||
challenge?: string;
|
||||
}
|
||||
) => {
|
||||
const response = await net.fetch(authUrl(endpoint, '/api/auth/sign-in'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-affine-client-kind': 'native',
|
||||
'x-affine-version': BUILD_CONFIG.appVersion,
|
||||
...(credential.verifyToken
|
||||
? { 'x-captcha-token': credential.verifyToken }
|
||||
: {}),
|
||||
...(credential.challenge
|
||||
? { 'x-captcha-challenge': credential.challenge }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: credential.email,
|
||||
password: credential.password,
|
||||
}),
|
||||
});
|
||||
await exchangeSession(endpoint, await readJson(response));
|
||||
},
|
||||
|
||||
signInOpenAppSignInCode: async (_e, endpoint: string, code: string) => {
|
||||
const response = await fetchAuth(endpoint, '/api/auth/open-app/sign-in', {
|
||||
code,
|
||||
});
|
||||
await exchangeSession(endpoint, await readJson(response));
|
||||
},
|
||||
|
||||
signOut: async (_e, endpoint: string) => {
|
||||
const token = getNativeAuthToken(endpoint);
|
||||
if (token) {
|
||||
await net.fetch(authUrl(endpoint, '/api/auth/sign-out'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'x-affine-version': BUILD_CONFIG.appVersion,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteNativeAuthToken(endpoint);
|
||||
await clearAuthCookies(endpoint);
|
||||
},
|
||||
|
||||
readEndpointToken: async (_e, endpoint: string) => {
|
||||
return { token: getNativeAuthToken(endpoint) };
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
@@ -0,0 +1,83 @@
|
||||
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;
|
||||
};
|
||||
|
||||
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 store = readStore();
|
||||
store[normalizeEndpoint(endpoint)] = encryptToken({ token });
|
||||
writeStore(store);
|
||||
}
|
||||
|
||||
export function deleteNativeAuthToken(endpoint: string) {
|
||||
const store = readStore();
|
||||
delete store[normalizeEndpoint(endpoint)];
|
||||
writeStore(store);
|
||||
}
|
||||
|
||||
export function getNativeAuthToken(endpoint: string) {
|
||||
const encrypted = readStore()[normalizeEndpoint(endpoint)];
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { I18n } from '@affine/i18n';
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
import { AFFINE_API_CHANNEL_NAME } from '../shared/type';
|
||||
import { authHandlers } from './auth/handlers';
|
||||
import { byokStorageHandlers } from './byok-storage/handlers';
|
||||
import { clipboardHandlers } from './clipboard';
|
||||
import { configStorageHandlers } from './config-storage';
|
||||
@@ -44,6 +45,7 @@ export const allHandlers = {
|
||||
popup: popupHandlers,
|
||||
i18n: i18nHandlers,
|
||||
byokStorage: byokStorageHandlers,
|
||||
auth: authHandlers,
|
||||
};
|
||||
|
||||
export const registerHandlers = () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import path, { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { app, net, protocol, session } from 'electron';
|
||||
import cookieParser from 'set-cookie-parser';
|
||||
|
||||
import { anotherHost, mainHost } from '../shared/internal-origin';
|
||||
import {
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
resolvePathInBase,
|
||||
resourcesPath,
|
||||
} from '../shared/utils';
|
||||
import { getAuthTokenForUrl } from './auth/native-token';
|
||||
import { buildType, isDev } from './config';
|
||||
import { logger } from './logger';
|
||||
|
||||
@@ -64,7 +64,27 @@ function buildTargetUrl(base: string, urlObject: URL) {
|
||||
return new URL(`${urlObject.pathname}${urlObject.search}`, base).toString();
|
||||
}
|
||||
|
||||
function proxyRequest(
|
||||
async function buildAuthorizedRequest(request: Request, targetUrl: string) {
|
||||
const clonedRequest = request.clone();
|
||||
const headers = new Headers(clonedRequest.headers);
|
||||
const token = getAuthTokenForUrl(targetUrl);
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
return new Request(targetUrl, {
|
||||
body:
|
||||
clonedRequest.method === 'GET' || clonedRequest.method === 'HEAD'
|
||||
? undefined
|
||||
: clonedRequest.body,
|
||||
headers,
|
||||
method: clonedRequest.method,
|
||||
redirect: clonedRequest.redirect,
|
||||
signal: clonedRequest.signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function proxyRequest(
|
||||
request: Request,
|
||||
urlObject: URL,
|
||||
base: string,
|
||||
@@ -72,12 +92,13 @@ function proxyRequest(
|
||||
) {
|
||||
const { bypassCustomProtocolHandlers = true } = options;
|
||||
const targetUrl = buildTargetUrl(base, urlObject);
|
||||
const authorizedRequest = await buildAuthorizedRequest(request, targetUrl);
|
||||
const proxiedRequest = bypassCustomProtocolHandlers
|
||||
? Object.assign(request.clone(), {
|
||||
? Object.assign(authorizedRequest, {
|
||||
bypassCustomProtocolHandlers: true,
|
||||
})
|
||||
: request;
|
||||
return net.fetch(targetUrl, proxiedRequest);
|
||||
: authorizedRequest;
|
||||
return net.fetch(proxiedRequest);
|
||||
}
|
||||
|
||||
async function handleFileRequest(request: Request) {
|
||||
@@ -218,41 +239,6 @@ export function registerProtocol() {
|
||||
const { responseHeaders, url } = responseDetails;
|
||||
(async () => {
|
||||
if (responseHeaders) {
|
||||
const originalCookie =
|
||||
responseHeaders['set-cookie'] || responseHeaders['Set-Cookie'];
|
||||
|
||||
if (originalCookie) {
|
||||
// save the cookies, to support third party cookies
|
||||
for (const cookies of originalCookie) {
|
||||
const parsedCookies = cookieParser.parse(cookies);
|
||||
for (const parsedCookie of parsedCookies) {
|
||||
if (!parsedCookie.value) {
|
||||
await session.defaultSession.cookies.remove(
|
||||
responseDetails.url,
|
||||
parsedCookie.name
|
||||
);
|
||||
} else {
|
||||
await session.defaultSession.cookies.set({
|
||||
url: responseDetails.url,
|
||||
domain: parsedCookie.domain,
|
||||
expirationDate: parsedCookie.expires?.getTime(),
|
||||
httpOnly: parsedCookie.httpOnly,
|
||||
secure: parsedCookie.secure,
|
||||
value: parsedCookie.value,
|
||||
name: parsedCookie.name,
|
||||
path: parsedCookie.path,
|
||||
sameSite: parsedCookie.sameSite?.toLowerCase() as
|
||||
| 'unspecified'
|
||||
| 'no_restriction'
|
||||
| 'lax'
|
||||
| 'strict'
|
||||
| undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { protocol, hostname } = new URL(url);
|
||||
|
||||
// Adjust CORS for assets responses and allow blob redirects on affine domains
|
||||
@@ -284,23 +270,17 @@ export function registerProtocol() {
|
||||
const url = new URL(details.url);
|
||||
|
||||
(async () => {
|
||||
// session cookies are set to assets:// on production
|
||||
// if sending request to the cloud, attach the session cookie (to affine cloud server)
|
||||
if (
|
||||
url.protocol === 'http:' ||
|
||||
url.protocol === 'https:' ||
|
||||
url.protocol === 'ws:' ||
|
||||
url.protocol === 'wss:'
|
||||
) {
|
||||
const cookies = await session.defaultSession.cookies.get({
|
||||
url: details.url,
|
||||
});
|
||||
|
||||
const cookieString = cookies
|
||||
.map(c => `${c.name}=${c.value}`)
|
||||
.join('; ');
|
||||
delete details.requestHeaders['cookie'];
|
||||
details.requestHeaders['Cookie'] = cookieString;
|
||||
const token = getAuthTokenForUrl(details.url);
|
||||
if (token) {
|
||||
delete details.requestHeaders.authorization;
|
||||
details.requestHeaders.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
const hostname = url.hostname;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Capacitor
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "AuthPlugin"
|
||||
@@ -7,10 +8,70 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "signInMagicLink", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "signInOauth", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "signInOpenApp", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "signInPassword", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "signOut", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "readEndpointToken", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "writeEndpointToken", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "deleteEndpointToken", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
private let tokenService = "app.affine.pro.auth-token"
|
||||
private let authCookieNames = Set(["affine_session", "affine_user_id", "affine_csrf_token"])
|
||||
|
||||
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 normalizedHost = host.lowercased()
|
||||
let defaultPort: Int?
|
||||
if normalizedScheme == "http" {
|
||||
defaultPort = 80
|
||||
} else if normalizedScheme == "https" {
|
||||
defaultPort = 443
|
||||
} else {
|
||||
defaultPort = nil
|
||||
}
|
||||
let port = url.port.flatMap { $0 == defaultPort ? nil : ":\($0)" } ?? ""
|
||||
return "\(normalizedScheme)://\(normalizedHost)\(port)"
|
||||
}
|
||||
|
||||
@objc public func readEndpointToken(_ call: CAPPluginCall) {
|
||||
do {
|
||||
let endpoint = try call.getStringEnsure("endpoint")
|
||||
if let token = try self.readToken(endpoint) {
|
||||
call.resolve(["token": token])
|
||||
} else {
|
||||
call.resolve(["token": NSNull()])
|
||||
}
|
||||
} catch {
|
||||
call.reject("Failed to read endpoint token, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func writeEndpointToken(_ call: CAPPluginCall) {
|
||||
do {
|
||||
let endpoint = try call.getStringEnsure("endpoint")
|
||||
let token = try call.getStringEnsure("token")
|
||||
try self.writeToken(endpoint, token)
|
||||
call.resolve(["ok": true])
|
||||
} catch {
|
||||
call.reject("Failed to write endpoint token, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func deleteEndpointToken(_ call: CAPPluginCall) {
|
||||
do {
|
||||
let endpoint = try call.getStringEnsure("endpoint")
|
||||
try self.deleteToken(endpoint)
|
||||
call.resolve(["ok": true])
|
||||
} catch {
|
||||
call.reject("Failed to delete endpoint token, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func signInMagicLink(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -19,7 +80,11 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
let token = try call.getStringEnsure("token")
|
||||
let clientNonce = call.getString("clientNonce")
|
||||
|
||||
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/magic-link", headers: [:], body: ["email": email, "token": token, "client_nonce": clientNonce])
|
||||
let (data, response) = try await self.fetch(
|
||||
endpoint, method: "POST", action: "/api/auth/magic-link",
|
||||
headers: [
|
||||
"x-affine-client-kind": "native"
|
||||
], body: ["email": email, "token": token, "client_nonce": clientNonce])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
@@ -30,12 +95,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return
|
||||
}
|
||||
|
||||
guard let token = try self.tokenFromCookie(endpoint) else {
|
||||
call.reject("token not found")
|
||||
return
|
||||
}
|
||||
|
||||
call.resolve(["token": token])
|
||||
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
@@ -50,7 +110,11 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
let state = try call.getStringEnsure("state")
|
||||
let clientNonce = call.getString("clientNonce")
|
||||
|
||||
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/oauth/callback", headers: [:], body: ["code": code, "state": state, "client_nonce": clientNonce])
|
||||
let (data, response) = try await self.fetch(
|
||||
endpoint, method: "POST", action: "/api/oauth/callback",
|
||||
headers: [
|
||||
"x-affine-client-kind": "native"
|
||||
], body: ["code": code, "state": state, "client_nonce": clientNonce])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
@@ -61,12 +125,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return
|
||||
}
|
||||
|
||||
guard let token = try self.tokenFromCookie(endpoint) else {
|
||||
call.reject("token not found")
|
||||
return
|
||||
}
|
||||
|
||||
call.resolve(["token": token])
|
||||
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
@@ -82,10 +141,13 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
let verifyToken = call.getString("verifyToken")
|
||||
let challenge = call.getString("challenge")
|
||||
|
||||
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/sign-in", headers: [
|
||||
"x-captcha-token": verifyToken,
|
||||
"x-captcha-challenge": challenge,
|
||||
], body: ["email": email, "password": password])
|
||||
let (data, response) = try await self.fetch(
|
||||
endpoint, method: "POST", action: "/api/auth/sign-in",
|
||||
headers: [
|
||||
"x-affine-client-kind": "native",
|
||||
"x-captcha-token": verifyToken,
|
||||
"x-captcha-challenge": challenge,
|
||||
], body: ["email": email, "password": password])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
@@ -96,12 +158,35 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return
|
||||
}
|
||||
|
||||
guard let token = try self.tokenFromCookie(endpoint) else {
|
||||
call.reject("token not found")
|
||||
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func signInOpenApp(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
let endpoint = try call.getStringEnsure("endpoint")
|
||||
let code = try call.getStringEnsure("code")
|
||||
|
||||
let (data, response) = try await self.fetch(
|
||||
endpoint, method: "POST", action: "/api/auth/open-app/sign-in",
|
||||
headers: [
|
||||
"x-affine-client-kind": "native"
|
||||
], body: ["code": code])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
call.reject(textBody)
|
||||
} else {
|
||||
call.reject("Failed to sign in")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
call.resolve(["token": token])
|
||||
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
@@ -112,11 +197,13 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
Task {
|
||||
do {
|
||||
let endpoint = try call.getStringEnsure("endpoint")
|
||||
let csrfToken = try self.csrfTokenFromCookie(endpoint)
|
||||
let token = call.getString("token")
|
||||
|
||||
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/sign-out", headers: [
|
||||
"x-affine-csrf-token": csrfToken,
|
||||
], body: nil)
|
||||
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) {
|
||||
@@ -127,6 +214,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return
|
||||
}
|
||||
|
||||
self.clearAuthCookies(endpoint)
|
||||
call.resolve(["ok": true])
|
||||
} catch {
|
||||
call.reject("Failed to sign out, \(error)", nil, error)
|
||||
@@ -134,38 +222,147 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private func tokenFromCookie(_ endpoint: String) throws -> String? {
|
||||
guard let endpointUrl = URL(string: endpoint) else {
|
||||
throw AuthError.invalidEndpoint
|
||||
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
|
||||
}
|
||||
|
||||
if let cookie = HTTPCookieStorage.shared.cookies(for: endpointUrl)?.first(where: {
|
||||
$0.name == "affine_session"
|
||||
}) {
|
||||
return cookie.value
|
||||
} else {
|
||||
return nil
|
||||
return token
|
||||
}
|
||||
|
||||
private func exchangeCodeFromResponse(_ data: Data) throws -> String {
|
||||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let code = json["exchangeCode"] as? String
|
||||
else {
|
||||
throw AuthError.exchangeCodeNotFound
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
private func exchangeSession(_ endpoint: String, _ signInData: Data) async throws -> String {
|
||||
let code = try exchangeCodeFromResponse(signInData)
|
||||
let (data, response) = try await self.fetch(
|
||||
endpoint, method: "POST", action: "/api/auth/native/exchange",
|
||||
headers: [
|
||||
"x-affine-client-kind": "native"
|
||||
], body: ["code": code])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
throw AuthError.exchangeFailed
|
||||
}
|
||||
|
||||
let token = try tokenFromResponse(data)
|
||||
self.clearAuthCookies(endpoint)
|
||||
return token
|
||||
}
|
||||
|
||||
private func clearAuthCookies(_ endpoint: String) {
|
||||
guard let url = URL(string: endpoint), let host = url.host else {
|
||||
return
|
||||
}
|
||||
let normalizedHost = host.lowercased()
|
||||
|
||||
HTTPCookieStorage.shared.cookies?.forEach { cookie in
|
||||
let domain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
let domainMatches = normalizedHost == domain || normalizedHost.hasSuffix(".\(domain)")
|
||||
if domainMatches && authCookieNames.contains(cookie.name) {
|
||||
HTTPCookieStorage.shared.deleteCookie(cookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func csrfTokenFromCookie(_ endpoint: String) throws -> String? {
|
||||
guard let endpointUrl = URL(string: endpoint) else {
|
||||
throw AuthError.invalidEndpoint
|
||||
}
|
||||
|
||||
return HTTPCookieStorage.shared.cookies(for: endpointUrl)?.first(where: {
|
||||
$0.name == "affine_csrf_token"
|
||||
})?.value
|
||||
private func tokenQuery(_ endpoint: String) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: tokenService,
|
||||
kSecAttrAccount as String: canonicalEndpoint(endpoint),
|
||||
]
|
||||
}
|
||||
|
||||
private func fetch(_ endpoint: String, method: String, action: String, headers: [String: String?], body: Encodable?) async throws -> (Data, HTTPURLResponse) {
|
||||
private func legacyTokenQuery(_ endpoint: String) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: tokenService,
|
||||
kSecAttrAccount as String: endpoint,
|
||||
]
|
||||
}
|
||||
|
||||
private func readToken(_ endpoint: String) throws -> String? {
|
||||
var query = tokenQuery(endpoint)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
if status == errSecItemNotFound {
|
||||
guard canonicalEndpoint(endpoint) != endpoint else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var legacyQuery = legacyTokenQuery(endpoint)
|
||||
legacyQuery[kSecReturnData as String] = true
|
||||
legacyQuery[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
let legacyStatus = SecItemCopyMatching(legacyQuery as CFDictionary, &item)
|
||||
if legacyStatus == errSecItemNotFound {
|
||||
return nil
|
||||
}
|
||||
guard legacyStatus == errSecSuccess, let data = item as? Data else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
let token = String(data: data, encoding: .utf8)
|
||||
if let token = token {
|
||||
try writeToken(endpoint, token)
|
||||
let deleteStatus = SecItemDelete(legacyTokenQuery(endpoint) as CFDictionary)
|
||||
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
}
|
||||
return token
|
||||
}
|
||||
guard status == errSecSuccess, let data = item as? Data else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func writeToken(_ endpoint: String, _ token: String) throws {
|
||||
try deleteToken(endpoint)
|
||||
var query = tokenQuery(endpoint)
|
||||
query[kSecValueData as String] = Data(token.utf8)
|
||||
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteToken(_ endpoint: String) throws {
|
||||
let status = SecItemDelete(tokenQuery(endpoint) as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
if canonicalEndpoint(endpoint) != endpoint {
|
||||
let legacyStatus = SecItemDelete(legacyTokenQuery(endpoint) as CFDictionary)
|
||||
guard legacyStatus == errSecSuccess || legacyStatus == errSecItemNotFound else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetch(
|
||||
_ endpoint: String, method: String, action: String, headers: [String: String?], body: Encodable?
|
||||
) async throws -> (Data, HTTPURLResponse) {
|
||||
guard let targetUrl = URL(string: "\(endpoint)\(action)") else {
|
||||
throw AuthError.invalidEndpoint
|
||||
}
|
||||
|
||||
var request = URLRequest(url: targetUrl)
|
||||
request.httpMethod = method
|
||||
request.httpShouldHandleCookies = true
|
||||
request.httpShouldHandleCookies = false
|
||||
for (key, value) in headers {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
@@ -174,7 +371,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
request.httpBody = try JSONEncoder().encode(body!)
|
||||
}
|
||||
request.setValue(AppConfigManager.getAffineVersion(), forHTTPHeaderField: "x-affine-version")
|
||||
request.timeoutInterval = 10 // time out 10s
|
||||
request.timeoutInterval = 10 // time out 10s
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
@@ -185,5 +382,5 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
|
||||
enum AuthError: Error {
|
||||
case invalidEndpoint, internalError
|
||||
case invalidEndpoint, internalError, tokenNotFound, exchangeCodeNotFound, exchangeFailed
|
||||
}
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ public class GetCurrentUserQuery: GraphQLQuery {
|
||||
public var emailVerified: Bool { __data["emailVerified"] }
|
||||
/// User avatar url
|
||||
public var avatarUrl: String? { __data["avatarUrl"] }
|
||||
@available(*, deprecated, message: "use [/api/auth/sign-in?native=true] instead")
|
||||
@available(*, deprecated, message: "use native session exchange instead")
|
||||
public var token: Token { __data["token"] }
|
||||
|
||||
/// CurrentUser.Token
|
||||
|
||||
@@ -74,7 +74,11 @@ import { Hashcash } from './plugins/hashcash';
|
||||
import { NbStoreNativeDBApis } from './plugins/nbstore';
|
||||
import { PayWall } from './plugins/paywall';
|
||||
import { Preview } from './plugins/preview';
|
||||
import { writeEndpointToken } from './proxy';
|
||||
import {
|
||||
deleteEndpointToken,
|
||||
readEndpointToken,
|
||||
writeEndpointToken,
|
||||
} from './proxy';
|
||||
import { enableNavigationGesture$ } from './web-navigation-control';
|
||||
|
||||
const storeManagerClient = createStoreManagerClient();
|
||||
@@ -204,10 +208,20 @@ framework.scope(ServerScope).override(AuthProvider, resolver => {
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
},
|
||||
async signOut() {
|
||||
await Auth.signOut({
|
||||
async signInOpenAppSignInCode(code) {
|
||||
const { token } = await Auth.signInOpenApp({
|
||||
endpoint,
|
||||
code,
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
},
|
||||
async signOut() {
|
||||
const token = await readEndpointToken(endpoint);
|
||||
try {
|
||||
await Auth.signOut({ endpoint, token });
|
||||
} finally {
|
||||
await deleteEndpointToken(endpoint);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -541,13 +555,9 @@ function createStoreManagerClient() {
|
||||
AsyncCall<typeof NbStoreNativeDBApis>(NbStoreNativeDBApis, {
|
||||
channel: {
|
||||
on(listener) {
|
||||
const f = (e: MessageEvent<any>) => {
|
||||
listener(e.data);
|
||||
};
|
||||
const f = (e: MessageEvent<any>) => listener(e.data);
|
||||
nativeDBApiChannelServer.addEventListener('message', f);
|
||||
return () => {
|
||||
nativeDBApiChannelServer.removeEventListener('message', f);
|
||||
};
|
||||
return () => nativeDBApiChannelServer.removeEventListener('message', f);
|
||||
},
|
||||
send(data) {
|
||||
nativeDBApiChannelServer.postMessage(data);
|
||||
@@ -557,11 +567,23 @@ function createStoreManagerClient() {
|
||||
});
|
||||
nativeDBApiChannelServer.start();
|
||||
worker.postMessage(
|
||||
{
|
||||
type: 'native-db-api-channel',
|
||||
port: nativeDBApiChannelClient,
|
||||
},
|
||||
{ type: 'native-db-api-channel', port: nativeDBApiChannelClient },
|
||||
[nativeDBApiChannelClient]
|
||||
);
|
||||
|
||||
const { port1: authTokenChannelServer, port2: authTokenChannelClient } =
|
||||
new MessageChannel();
|
||||
authTokenChannelServer.addEventListener('message', event => {
|
||||
const { id, endpoint } = event.data as { id?: string; endpoint?: string };
|
||||
if (!id || !endpoint) return;
|
||||
readEndpointToken(endpoint)
|
||||
.then(token => authTokenChannelServer.postMessage({ id, token }))
|
||||
.catch(() => authTokenChannelServer.postMessage({ id, token: null }));
|
||||
});
|
||||
authTokenChannelServer.start();
|
||||
worker.postMessage(
|
||||
{ type: 'native-auth-token-channel', port: authTokenChannelClient },
|
||||
[authTokenChannelClient]
|
||||
);
|
||||
return new StoreManagerClient(new OpClient(worker));
|
||||
}
|
||||
|
||||
@@ -18,19 +18,28 @@ import {
|
||||
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
|
||||
import { AsyncCall } from 'async-call-rpc';
|
||||
|
||||
import { readEndpointToken } from './proxy';
|
||||
let authTokenPort: MessagePort | undefined;
|
||||
const pendingTokenRequests = new Map<string, (token: string | null) => void>();
|
||||
|
||||
configureSocketAuthMethod((endpoint, cb) => {
|
||||
readEndpointToken(endpoint)
|
||||
.then(token => {
|
||||
cb({ token });
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
|
||||
.catch(() => cb({}));
|
||||
});
|
||||
|
||||
globalThis.addEventListener('message', e => {
|
||||
if (e.data.type === 'native-auth-token-channel') {
|
||||
authTokenPort = e.ports[0] as MessagePort;
|
||||
authTokenPort.addEventListener('message', e => {
|
||||
const { id, token } = e.data as { id?: string; token?: string | null };
|
||||
if (!id) return;
|
||||
pendingTokenRequests.get(id)?.(token ?? null);
|
||||
pendingTokenRequests.delete(id);
|
||||
});
|
||||
authTokenPort.start();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.data.type === 'native-db-api-channel') {
|
||||
const port = e.ports[0] as MessagePort;
|
||||
const rpc = AsyncCall<NativeDBApis>(
|
||||
@@ -57,6 +66,25 @@ globalThis.addEventListener('message', e => {
|
||||
}
|
||||
});
|
||||
|
||||
function readEndpointToken(endpoint: string) {
|
||||
if (!authTokenPort) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
const id = `${Date.now()}:${Math.random()}`;
|
||||
return new Promise<string | null>(resolve => {
|
||||
const timeout = setTimeout(() => {
|
||||
pendingTokenRequests.delete(id);
|
||||
resolve(null);
|
||||
}, 5000);
|
||||
pendingTokenRequests.set(id, token => {
|
||||
clearTimeout(timeout);
|
||||
resolve(token);
|
||||
});
|
||||
authTokenPort?.postMessage({ id, endpoint });
|
||||
});
|
||||
}
|
||||
|
||||
const consumer = new OpConsumer<WorkerManagerOps>(
|
||||
globalThis as MessageCommunicapable
|
||||
);
|
||||
|
||||
@@ -18,5 +18,17 @@ export interface AuthPlugin {
|
||||
verifyToken?: string;
|
||||
challenge?: string;
|
||||
}): Promise<{ token: string }>;
|
||||
signOut(options: { endpoint: string }): Promise<void>;
|
||||
signInOpenApp(options: {
|
||||
endpoint: string;
|
||||
code: string;
|
||||
}): Promise<{ token: string }>;
|
||||
signOut(options: { endpoint: string; token?: string | null }): Promise<void>;
|
||||
readEndpointToken(options: {
|
||||
endpoint: string;
|
||||
}): Promise<{ token?: string | null }>;
|
||||
writeEndpointToken(options: {
|
||||
endpoint: string;
|
||||
token: string;
|
||||
}): Promise<void>;
|
||||
deleteEndpointToken(options: { endpoint: string }): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
import { openDB } from 'idb';
|
||||
import { Auth } from './plugins/auth';
|
||||
|
||||
function authEndpointForUrl(url: string | URL) {
|
||||
try {
|
||||
const parsed = new URL(url, globalThis.location.origin);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||
? parsed.origin
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalEndpoint(endpoint: string) {
|
||||
return authEndpointForUrl(endpoint) ?? endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* the below code includes the custom fetch and xmlhttprequest implementation for ios webview.
|
||||
@@ -8,9 +23,11 @@ const rawFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
|
||||
const origin = new URL(request.url, globalThis.location.origin).origin;
|
||||
const origin = authEndpointForUrl(request.url);
|
||||
|
||||
const token = await readEndpointToken(origin);
|
||||
const token = origin
|
||||
? await readEndpointToken(origin).catch(() => null)
|
||||
: null;
|
||||
if (token) {
|
||||
request.headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
@@ -19,11 +36,30 @@ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
};
|
||||
|
||||
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
|
||||
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
|
||||
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
||||
const origin = new URL(this.responseURL, globalThis.location.origin).origin;
|
||||
override open(
|
||||
method: string,
|
||||
url: string | URL,
|
||||
async: boolean = true,
|
||||
username?: string | null,
|
||||
password?: string | null
|
||||
): void {
|
||||
xhrRequestUrls.set(this, url.toString());
|
||||
return super.open(
|
||||
method,
|
||||
url,
|
||||
async,
|
||||
username ?? undefined,
|
||||
password ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
readEndpointToken(origin).then(
|
||||
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
||||
const requestUrl = xhrRequestUrls.get(this);
|
||||
const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href);
|
||||
|
||||
(origin ? readEndpointToken(origin) : Promise.resolve(null)).then(
|
||||
token => {
|
||||
if (token) {
|
||||
this.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
@@ -31,7 +67,7 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
return super.send(body);
|
||||
},
|
||||
() => {
|
||||
throw new Error('Failed to read token');
|
||||
return super.send(body);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -40,26 +76,19 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
export async function readEndpointToken(
|
||||
endpoint: string
|
||||
): Promise<string | null> {
|
||||
const idb = await openDB('affine-token', 1, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains('tokens')) {
|
||||
db.createObjectStore('tokens', { keyPath: 'endpoint' });
|
||||
}
|
||||
},
|
||||
const { token } = await Auth.readEndpointToken({
|
||||
endpoint: canonicalEndpoint(endpoint),
|
||||
});
|
||||
|
||||
const token = await idb.get('tokens', endpoint);
|
||||
return token ? token.token : null;
|
||||
return token ?? null;
|
||||
}
|
||||
|
||||
export async function writeEndpointToken(endpoint: string, token: string) {
|
||||
const db = await openDB('affine-token', 1, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains('tokens')) {
|
||||
db.createObjectStore('tokens', { keyPath: 'endpoint' });
|
||||
}
|
||||
},
|
||||
await Auth.writeEndpointToken({
|
||||
endpoint: canonicalEndpoint(endpoint),
|
||||
token,
|
||||
});
|
||||
|
||||
await db.put('tokens', { endpoint, token });
|
||||
}
|
||||
|
||||
export async function deleteEndpointToken(endpoint: string) {
|
||||
await Auth.deleteEndpointToken({ endpoint: canonicalEndpoint(endpoint) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user