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:
DarkSky
2026-06-01 17:11:15 +08:00
committed by GitHub
parent 5b9d51b41b
commit ce9841df9d
74 changed files with 3719 additions and 939 deletions
@@ -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.")
}
}
}
@@ -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()
}
}
@@ -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 }
@@ -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()
+32 -3
View File
@@ -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>;
}
+53 -24
View File
@@ -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
}
@@ -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
+35 -13
View File
@@ -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>;
}
+53 -24
View File
@@ -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) });
}