diff --git a/packages/common/nbstore/src/impls/cloud/awareness.ts b/packages/common/nbstore/src/impls/cloud/awareness.ts index 65c60cedb..2523c6099 100644 --- a/packages/common/nbstore/src/impls/cloud/awareness.ts +++ b/packages/common/nbstore/src/impls/cloud/awareness.ts @@ -22,7 +22,7 @@ export class CloudAwarenessStorage extends AwarenessStorageBase { super(); } - connection = new SocketConnection(`${this.options.serverBaseUrl}/`); + connection = new SocketConnection(this.options.serverBaseUrl); private get socket() { return this.connection.inner.socket; diff --git a/packages/common/nbstore/src/impls/cloud/doc.ts b/packages/common/nbstore/src/impls/cloud/doc.ts index 353b8e91b..b5fee8b37 100644 --- a/packages/common/nbstore/src/impls/cloud/doc.ts +++ b/packages/common/nbstore/src/impls/cloud/doc.ts @@ -185,7 +185,7 @@ class CloudDocStorageConnection extends SocketConnection { private readonly options: CloudDocStorageOptions, private readonly onServerUpdate: ServerEventsMap['space:broadcast-doc-update'] ) { - super(`${options.serverBaseUrl}/`); + super(options.serverBaseUrl); } idConverter: IdConverter | null = null; diff --git a/packages/common/nbstore/src/impls/cloud/index.ts b/packages/common/nbstore/src/impls/cloud/index.ts index bb5586006..c8b0cda3e 100644 --- a/packages/common/nbstore/src/impls/cloud/index.ts +++ b/packages/common/nbstore/src/impls/cloud/index.ts @@ -8,6 +8,7 @@ export * from './awareness'; export * from './blob'; export * from './doc'; export * from './doc-static'; +export * from './socket'; export const cloudStorages = [ CloudDocStorage, diff --git a/packages/common/nbstore/src/impls/cloud/socket.ts b/packages/common/nbstore/src/impls/cloud/socket.ts index b60a595c6..238ef83b9 100644 --- a/packages/common/nbstore/src/impls/cloud/socket.ts +++ b/packages/common/nbstore/src/impls/cloud/socket.ts @@ -150,6 +150,16 @@ export function base64ToUint8Array(base64: string) { return new Uint8Array(binaryArray); } +let authMethod: + | ((endpoint: string, cb: (data: object) => void) => void) + | undefined; + +export function configureSocketAuthMethod( + cb: (endpoint: string, cb: (data: object) => void) => void +) { + authMethod = cb; +} + class SocketManager { private readonly socketIOManager: SocketIOManager; socket: Socket; @@ -158,12 +168,20 @@ class SocketManager { constructor(endpoint: string) { this.socketIOManager = new SocketIOManager(endpoint, { autoConnect: false, - transports: [BUILD_CONFIG.isIOS ? 'polling' : 'websocket'], + transports: ['websocket'], secure: new URL(endpoint).protocol === 'https:', // we will handle reconnection by ourselves reconnection: false, }); - this.socket = this.socketIOManager.socket('/'); + this.socket = this.socketIOManager.socket('/', { + auth(cb) { + if (authMethod) { + authMethod(endpoint, cb); + } else { + cb({}); + } + }, + }); } connect() { diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj index 1563a60a7..9bf036dc4 100644 --- a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj +++ b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 50A285D82D112A5E000D5A6D /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */; }; 50A285DC2D112B24000D5A6D /* Intelligents in Frameworks */ = {isa = PBXBuildFile; productRef = 50A285DB2D112B24000D5A6D /* Intelligents */; }; 9D52FC432D26CDBF00105D0A /* JSValueContainerExt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */; }; + 9D5622962D64A6A5009F1BE4 /* AuthPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5622952D64A6A4009F1BE4 /* AuthPlugin.swift */; }; 9D6A85332CCF6DA700DAB35F /* HashcashPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D6A85322CCF6DA700DAB35F /* HashcashPlugin.swift */; }; 9D90BE252CCB9876006677DB /* CookieManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D90BE172CCB9876006677DB /* CookieManager.swift */; }; 9D90BE262CCB9876006677DB /* CookiePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D90BE182CCB9876006677DB /* CookiePlugin.swift */; }; @@ -24,7 +25,6 @@ 9D90BE2B2CCB9876006677DB /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 9D90BE1F2CCB9876006677DB /* config.xml */; }; 9D90BE2D2CCB9876006677DB /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9D90BE222CCB9876006677DB /* Main.storyboard */; }; 9D90BE2E2CCB9876006677DB /* public in Resources */ = {isa = PBXBuildFile; fileRef = 9D90BE232CCB9876006677DB /* public */; }; - 9DEC593B2D3002E70027CEBD /* AffineHttpHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DEC593A2D3002C70027CEBD /* AffineHttpHandler.swift */; }; 9DEC59432D323EE40027CEBD /* Mutex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DEC59422D323EE00027CEBD /* Mutex.swift */; }; 9DFCD1462D27D1D70028C92B /* libaffine_mobile_native.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */; }; C4C413792CBE705D00337889 /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; @@ -43,6 +43,7 @@ 50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSValueContainerExt.swift; sourceTree = ""; }; + 9D5622952D64A6A4009F1BE4 /* AuthPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthPlugin.swift; sourceTree = ""; }; 9D6A85322CCF6DA700DAB35F /* HashcashPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashcashPlugin.swift; sourceTree = ""; }; 9D90BE172CCB9876006677DB /* CookieManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieManager.swift; sourceTree = ""; }; 9D90BE182CCB9876006677DB /* CookiePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookiePlugin.swift; sourceTree = ""; }; @@ -54,7 +55,6 @@ 9D90BE202CCB9876006677DB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 9D90BE212CCB9876006677DB /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 9D90BE232CCB9876006677DB /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; - 9DEC593A2D3002C70027CEBD /* AffineHttpHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AffineHttpHandler.swift; sourceTree = ""; }; 9DEC59422D323EE00027CEBD /* Mutex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mutex.swift; sourceTree = ""; }; 9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libaffine_mobile_native.a; sourceTree = ""; }; AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -136,6 +136,14 @@ name = Pods; sourceTree = ""; }; + 9D5622942D64A69C009F1BE4 /* Auth */ = { + isa = PBXGroup; + children = ( + 9D5622952D64A6A4009F1BE4 /* AuthPlugin.swift */, + ); + path = Auth; + sourceTree = ""; + }; 9D90BE192CCB9876006677DB /* Cookie */ = { isa = PBXGroup; children = ( @@ -150,6 +158,7 @@ 9D90BE1A2CCB9876006677DB /* Plugins */ = { isa = PBXGroup; children = ( + 9D5622942D64A69C009F1BE4 /* Auth */, C45499AB2D140B5000E21978 /* NBStore */, E93B276A2CED9298001409B8 /* NavigationGesture */, 9D90BE192CCB9876006677DB /* Cookie */, @@ -161,7 +170,6 @@ isa = PBXGroup; children = ( 9DEC59422D323EE00027CEBD /* Mutex.swift */, - 9DEC593A2D3002C70027CEBD /* AffineHttpHandler.swift */, 9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */, 9D90BE1A2CCB9876006677DB /* Plugins */, 9D90BE1C2CCB9876006677DB /* AppDelegate.swift */, @@ -337,9 +345,9 @@ 9D52FC432D26CDBF00105D0A /* JSValueContainerExt.swift in Sources */, 5075136E2D1925BC00AD60C0 /* IntelligentsPlugin.swift in Sources */, 5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */, - 9DEC593B2D3002E70027CEBD /* AffineHttpHandler.swift in Sources */, C4C97C7C2D030BE000BC2AD1 /* affine_mobile_native.swift in Sources */, C4C97C7D2D030BE000BC2AD1 /* affine_mobile_nativeFFI.h in Sources */, + 9D5622962D64A6A5009F1BE4 /* AuthPlugin.swift in Sources */, C4C97C7E2D030BE000BC2AD1 /* affine_mobile_nativeFFI.modulemap in Sources */, E93B276C2CED92B1001409B8 /* NavigationGesturePlugin.swift in Sources */, 9DEC59432D323EE40027CEBD /* Mutex.swift in Sources */, diff --git a/packages/frontend/apps/ios/App/App/AffineHttpHandler.swift b/packages/frontend/apps/ios/App/App/AffineHttpHandler.swift deleted file mode 100644 index 47cfcc553..000000000 --- a/packages/frontend/apps/ios/App/App/AffineHttpHandler.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// RequestUrlSchemeHandler.swift -// App -// -// Created by EYHN on 2025/1/9. -// - -import WebKit - -enum AffineHttpError: Error { - case invalidOperation(reason: String), invalidState(reason: String) -} - -class AffineHttpHandler: NSObject, WKURLSchemeHandler { - func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { - urlSchemeTask.stopped = false - guard let rawUrl = urlSchemeTask.request.url else { - urlSchemeTask.didFailWithError(AffineHttpError.invalidOperation(reason: "bad request")) - return - } - guard let scheme = rawUrl.scheme else { - urlSchemeTask.didFailWithError(AffineHttpError.invalidOperation(reason: "bad request")) - return - } - let httpProtocol = scheme == "affine-http" ? "http" : "https" - guard let urlComponents = URLComponents(url: rawUrl, resolvingAgainstBaseURL: true) else { - urlSchemeTask.didFailWithError(AffineHttpError.invalidOperation(reason: "bad request")) - return - } - guard let host = urlComponents.host else { - urlSchemeTask.didFailWithError(AffineHttpError.invalidOperation(reason: "bad url")) - return - } - let path = urlComponents.path - let query = urlComponents.query != nil ? "?\(urlComponents.query!)" : "" - let port = urlComponents.port != nil ? ":\(urlComponents.port!)" : "" - guard let targetUrl = URL(string: "\(httpProtocol)://\(host)\(port)\(path)\(query)") else { - urlSchemeTask.didFailWithError(AffineHttpError.invalidOperation(reason: "bad url")) - return - } - - var request = URLRequest(url: targetUrl); - request.httpMethod = urlSchemeTask.request.httpMethod; - request.httpShouldHandleCookies = true - request.httpBody = urlSchemeTask.request.httpBody - urlSchemeTask.request.allHTTPHeaderFields?.filter({ - key, value in - let normalizedKey = key.lowercased() - return normalizedKey == "content-type" || - normalizedKey == "content-length" || - normalizedKey == "accept" - }).forEach { - key, value in - request.setValue(value, forHTTPHeaderField: key) - } - - let task = URLSession.shared.dataTask(with: request) { - (rawData, rawResponse, error) in - DispatchQueue.main.async { - if urlSchemeTask.stopped { - return - } - - if error != nil { - urlSchemeTask.didFailWithError(error!) - } else { - guard let httpResponse = rawResponse as? HTTPURLResponse else { - urlSchemeTask.didFailWithError(AffineHttpError.invalidState(reason: "bad response")) - return - } - let inheritedHeaders = httpResponse.allHeaderFields.filter({ - key, value in - let normalizedKey = (key as? String)?.lowercased() - return normalizedKey == "content-type" || - normalizedKey == "content-length" - }) as? [String: String] ?? [:] - let newHeaders: [String: String] = [ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "*" - ] - - guard let response = HTTPURLResponse.init(url: rawUrl, statusCode: httpResponse.statusCode, httpVersion: nil, headerFields: inheritedHeaders.merging(newHeaders, uniquingKeysWith: { (_, newHeaders) in newHeaders })) else { - urlSchemeTask.didFailWithError(AffineHttpError.invalidState(reason: "failed to create response")) - return - } - - urlSchemeTask.didReceive(response) - if rawData != nil { - urlSchemeTask.didReceive(rawData!) - } - urlSchemeTask.didFinish() - } - } - } - task.resume() - - urlSchemeTask.dataTask = task - } - - func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { - urlSchemeTask.stopped = true - urlSchemeTask.dataTask?.cancel() - } -} - -private extension WKURLSchemeTask { - var stopped: Bool { - get { - return objc_getAssociatedObject(self, &stoppedKey) as? Bool ?? false - } - set { - objc_setAssociatedObject(self, &stoppedKey, newValue, .OBJC_ASSOCIATION_ASSIGN) - } - } - var dataTask: URLSessionDataTask? { - get { - return objc_getAssociatedObject(self, &dataTaskKey) as? URLSessionDataTask - } - set { - objc_setAssociatedObject(self, &dataTaskKey, newValue, .OBJC_ASSOCIATION_RETAIN) - } - } -} - -private var stoppedKey = malloc(1) -private var dataTaskKey = malloc(1) diff --git a/packages/frontend/apps/ios/App/App/AffineViewController.swift b/packages/frontend/apps/ios/App/App/AffineViewController.swift index eaffede75..9550fd5d1 100644 --- a/packages/frontend/apps/ios/App/App/AffineViewController.swift +++ b/packages/frontend/apps/ios/App/App/AffineViewController.swift @@ -20,13 +20,12 @@ class AFFiNEViewController: CAPBridgeViewController { } override func webView(with frame: CGRect, configuration: WKWebViewConfiguration) -> WKWebView { - configuration.setURLSchemeHandler(AffineHttpHandler(), forURLScheme: "affine-http") - configuration.setURLSchemeHandler(AffineHttpHandler(), forURLScheme: "affine-https") return super.webView(with: frame, configuration: configuration) } override func capacitorDidLoad() { let plugins: [CAPPlugin] = [ + AuthPlugin(), CookiePlugin(), HashcashPlugin(), NavigationGesturePlugin(), diff --git a/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift new file mode 100644 index 000000000..f6ddac39e --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift @@ -0,0 +1,170 @@ +import Capacitor +import Foundation + +public class AuthPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "AuthPlugin" + public let jsName = "Auth" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "signInMagicLink", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "signInOauth", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "signInPassword", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "signOut", returnType: CAPPluginReturnPromise), + ] + + @objc public func signInMagicLink(_ call: CAPPluginCall) { + Task { + do { + let endpoint = try call.getStringEnsure("endpoint") + let email = try call.getStringEnsure("email") + let token = try call.getStringEnsure("token") + + let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/magic-link", headers: [:], body: ["email": email, "token": token]) + + if response.statusCode != 200 { + if let textBody = String(data: data, encoding: .utf8) { + call.reject(textBody) + } else { + call.reject("Failed to sign in") + } + } + + guard let token = try self.tokenFromCookie(endpoint) else { + call.reject("token not found") + return + } + + call.resolve(["token": token]) + } catch { + call.reject("Failed to sign in, \(error)", nil, error) + } + } + } + + @objc public func signInOauth(_ call: CAPPluginCall) { + Task { + do { + let endpoint = try call.getStringEnsure("endpoint") + let code = try call.getStringEnsure("code") + let state = try call.getStringEnsure("state") + + let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/oauth/callback", headers: [:], body: ["code": code, "state": state]) + + if response.statusCode != 200 { + if let textBody = String(data: data, encoding: .utf8) { + call.reject(textBody) + } else { + call.reject("Failed to sign in") + } + } + + guard let token = try self.tokenFromCookie(endpoint) else { + call.reject("token not found") + return + } + + call.resolve(["token": token]) + } catch { + call.reject("Failed to sign in, \(error)", nil, error) + } + } + } + + @objc public func signInPassword(_ call: CAPPluginCall) { + Task { + do { + let endpoint = try call.getStringEnsure("endpoint") + let email = try call.getStringEnsure("email") + let password = try call.getStringEnsure("password") + 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]) + + if response.statusCode != 200 { + if let textBody = String(data: data, encoding: .utf8) { + call.reject(textBody) + } else { + call.reject("Failed to sign in") + } + } + + guard let token = try self.tokenFromCookie(endpoint) else { + call.reject("token not found") + return + } + + call.resolve(["token": token]) + } catch { + call.reject("Failed to sign in, \(error)", nil, error) + } + } + } + + @objc public func signOut(_ call: CAPPluginCall) { + Task { + do { + let endpoint = try call.getStringEnsure("endpoint") + + let (data, response) = try await self.fetch(endpoint, method: "GET", action: "/api/auth/sign-out", headers: [:], body: nil) + + if response.statusCode != 200 { + if let textBody = String(data: data, encoding: .utf8) { + call.reject(textBody) + } else { + call.reject("Failed to sign in") + } + } + + + call.resolve(["ok": true]) + } catch { + call.reject("Failed to sign in, \(error)", nil, error) + } + } + } + + private func tokenFromCookie(_ endpoint: String) throws -> String? { + guard let endpointUrl = URL(string: endpoint) else { + throw AuthError.invalidEndpoint + } + + if let cookie = HTTPCookieStorage.shared.cookies(for: endpointUrl)?.first(where: { + $0.name == "affine_session" + }) { + return cookie.value + } else { + return nil + } + } + + private func fetch(_ endpoint: String, method: String, action: String, headers: Dictionary, 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 + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + if body != nil { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body!) + } + request.timeoutInterval = 10 // time out 10s + + let (data, response) = try await URLSession.shared.data(for: request); + guard let httpResponse = response as? HTTPURLResponse else { + throw AuthError.internalError + } + return (data, httpResponse) + } +} + +enum AuthError: Error { + case invalidEndpoint, internalError +} diff --git a/packages/frontend/apps/ios/package.json b/packages/frontend/apps/ios/package.json index d10599a4e..e8a0f13f4 100644 --- a/packages/frontend/apps/ios/package.json +++ b/packages/frontend/apps/ios/package.json @@ -26,6 +26,7 @@ "@sentry/react": "^8.44.0", "@toeverything/infra": "workspace:^", "async-call-rpc": "^6.4.2", + "idb": "^8.0.0", "next-themes": "^0.4.4", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/frontend/apps/ios/src/app.tsx b/packages/frontend/apps/ios/src/app.tsx index 68a0761f1..faa8e509f 100644 --- a/packages/frontend/apps/ios/src/app.tsx +++ b/packages/frontend/apps/ios/src/app.tsx @@ -8,8 +8,11 @@ import { router } from '@affine/core/mobile/router'; import { configureCommonModules } from '@affine/core/modules'; import { AIButtonProvider } from '@affine/core/modules/ai-button'; import { + AuthProvider, AuthService, DefaultServerService, + ServerScope, + ServerService, ServersService, ValidatorProvider, } from '@affine/core/modules/cloud'; @@ -51,9 +54,11 @@ import { RouterProvider } from 'react-router-dom'; import { BlocksuiteMenuConfigProvider } from './bs-menu-config'; import { ModalConfigProvider } from './modal-config'; +import { Auth } from './plugins/auth'; import { Hashcash } from './plugins/hashcash'; import { Intelligents } from './plugins/intelligents'; import { NbStoreNativeDBApis } from './plugins/nbstore'; +import { writeEndpointToken } from './proxy'; import { enableNavigationGesture$ } from './web-navigation-control'; const storeManagerClient = createStoreManagerClient(); @@ -134,6 +139,41 @@ framework.impl(AIButtonProvider, { return Intelligents.dismissIntelligentsButton(); }, }); +framework.scope(ServerScope).override(AuthProvider, resolver => { + const serverService = resolver.get(ServerService); + const endpoint = serverService.server.baseUrl; + return { + async signInMagicLink(email, linkToken) { + const { token } = await Auth.signInMagicLink({ + endpoint, + email, + token: linkToken, + }); + await writeEndpointToken(endpoint, token); + }, + async signInOauth(code, state, _provider) { + const { token } = await Auth.signInOauth({ + endpoint, + code, + state, + }); + await writeEndpointToken(endpoint, token); + return {}; + }, + async signInPassword(credential) { + const { token } = await Auth.signInPassword({ + endpoint, + ...credential, + }); + await writeEndpointToken(endpoint, token); + }, + async signOut() { + await Auth.signOut({ + endpoint, + }); + }, + }; +}); const frameworkProvider = framework.provider(); diff --git a/packages/frontend/apps/ios/src/plugins/auth/definitions.ts b/packages/frontend/apps/ios/src/plugins/auth/definitions.ts new file mode 100644 index 000000000..b59bf30c0 --- /dev/null +++ b/packages/frontend/apps/ios/src/plugins/auth/definitions.ts @@ -0,0 +1,20 @@ +export interface AuthPlugin { + signInMagicLink(options: { + endpoint: string; + email: string; + token: string; + }): Promise<{ token: string }>; + signInOauth(options: { + endpoint: string; + code: string; + state: string; + }): Promise<{ token: string }>; + signInPassword(options: { + endpoint: string; + email: string; + password: string; + verifyToken?: string; + challenge?: string; + }): Promise<{ token: string }>; + signOut(options: { endpoint: string }): Promise; +} diff --git a/packages/frontend/apps/ios/src/plugins/auth/index.ts b/packages/frontend/apps/ios/src/plugins/auth/index.ts new file mode 100644 index 000000000..460cf9895 --- /dev/null +++ b/packages/frontend/apps/ios/src/plugins/auth/index.ts @@ -0,0 +1,8 @@ +import { registerPlugin } from '@capacitor/core'; + +import type { AuthPlugin } from './definitions'; + +const Auth = registerPlugin('Auth'); + +export * from './definitions'; +export { Auth }; diff --git a/packages/frontend/apps/ios/src/proxy.ts b/packages/frontend/apps/ios/src/proxy.ts new file mode 100644 index 000000000..396dad354 --- /dev/null +++ b/packages/frontend/apps/ios/src/proxy.ts @@ -0,0 +1,65 @@ +import { openDB } from 'idb'; + +/** + * the below code includes the custom fetch and xmlhttprequest implementation for ios webview. + * should be included in the entry file of the app or webworker. + */ +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 token = await readEndpointToken(origin); + if (token) { + request.headers.set('Authorization', `Bearer ${token}`); + } + + return rawFetch(request); +}; + +const rawXMLHttpRequest = globalThis.XMLHttpRequest; +globalThis.XMLHttpRequest = class extends rawXMLHttpRequest { + override send(body?: Document | XMLHttpRequestBodyInit | null): void { + const origin = new URL(this.responseURL, globalThis.location.origin).origin; + + readEndpointToken(origin).then( + token => { + if (token) { + this.setRequestHeader('Authorization', `Bearer ${token}`); + } + return super.send(body); + }, + () => { + throw new Error('Failed to read token'); + } + ); + } +}; + +export async function readEndpointToken( + endpoint: string +): Promise { + const idb = await openDB('affine-token', 1, { + upgrade(db) { + if (!db.objectStoreNames.contains('tokens')) { + db.createObjectStore('tokens', { keyPath: 'endpoint' }); + } + }, + }); + + const token = await idb.get('tokens', endpoint); + return token ? token.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 db.put('tokens', { endpoint, token }); +} diff --git a/packages/frontend/apps/ios/src/setup.ts b/packages/frontend/apps/ios/src/setup.ts index a554e4b0c..baefae9fa 100644 --- a/packages/frontend/apps/ios/src/setup.ts +++ b/packages/frontend/apps/ios/src/setup.ts @@ -1,62 +1,2 @@ import '@affine/core/bootstrap/browser'; - -/** - * the below code includes the custom fetch and xmlhttprequest implementation for ios webview. - * should be included in the entry file of the app or webworker. - */ - -/* - * we override the browser's fetch function with our custom fetch function to - * overcome the restrictions of cross-domain and third-party cookies in ios webview. - * - * the custom fetch function will convert the request to `affine-http://` or `affine-https://` - * and send the request to the server. - */ -const rawFetch = globalThis.fetch; -globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - let url = new URL( - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : input.url, - globalThis.location.origin - ).href; - - if (url.startsWith('capacitor:')) { - return rawFetch(input, init); - } - - if (url.startsWith('http:')) { - url = 'affine-http:' + url.slice(5); - } - - if (url.startsWith('https:')) { - url = 'affine-https:' + url.slice(6); - } - - return rawFetch(url, input instanceof Request ? input : init); -}; - -const rawXMLHttpRequest = globalThis.XMLHttpRequest; -globalThis.XMLHttpRequest = class extends rawXMLHttpRequest { - override open( - method: string, - url: string, - async?: boolean, - user?: string, - password?: string - ) { - let normalizedUrl = new URL(url, globalThis.location.origin).href; - - if (normalizedUrl.startsWith('http:')) { - url = 'affine-http:' + url.slice(5); - } - - if (normalizedUrl.startsWith('https:')) { - url = 'affine-https:' + url.slice(6); - } - - (super.open as any)(method, url, async, user, password); - } -}; +import './proxy'; diff --git a/packages/frontend/apps/ios/src/worker.ts b/packages/frontend/apps/ios/src/worker.ts index e55b69dd8..f1be461b6 100644 --- a/packages/frontend/apps/ios/src/worker.ts +++ b/packages/frontend/apps/ios/src/worker.ts @@ -1,7 +1,10 @@ import './setup'; import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel'; -import { cloudStorages } from '@affine/nbstore/cloud'; +import { + cloudStorages, + configureSocketAuthMethod, +} from '@affine/nbstore/cloud'; import { bindNativeDBApis, type NativeDBApis, @@ -14,6 +17,18 @@ import { import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op'; import { AsyncCall } from 'async-call-rpc'; +import { readEndpointToken } from './proxy'; + +configureSocketAuthMethod((endpoint, cb) => { + readEndpointToken(endpoint) + .then(token => { + cb({ token }); + }) + .catch(e => { + console.error(e); + }); +}); + globalThis.addEventListener('message', e => { if (e.data.type === 'native-db-api-channel') { const port = e.ports[0] as MessagePort; diff --git a/packages/frontend/core/src/modules/cloud/impl/auth.ts b/packages/frontend/core/src/modules/cloud/impl/auth.ts new file mode 100644 index 000000000..9ec8e9d0e --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/impl/auth.ts @@ -0,0 +1,60 @@ +import type { Framework } from '@toeverything/infra'; + +import { AuthProvider } from '../provider/auth'; +import { ServerScope } from '../scopes/server'; +import { FetchService } from '../services/fetch'; + +export function configureDefaultAuthProvider(framework: Framework) { + framework.scope(ServerScope).override(AuthProvider, resolver => { + const fetchService = resolver.get(FetchService); + return { + async signInMagicLink(email: string, token: string) { + await fetchService.fetch('/api/auth/magic-link', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, token }), + }); + }, + + async signInOauth(code: string, state: string, _provider: string) { + const res = await fetchService.fetch('/api/oauth/callback', { + method: 'POST', + body: JSON.stringify({ code, state }), + headers: { + 'content-type': 'application/json', + }, + }); + return await res.json(); + }, + async signInPassword(credential: { + email: string; + password: string; + verifyToken?: string; + challenge?: string; + }) { + const headers: Record = {}; + + if (credential.verifyToken) { + headers['x-captcha-token'] = credential.verifyToken; + } + if (credential.challenge) { + headers['x-captcha-challenge'] = credential.challenge; + } + + await fetchService.fetch('/api/auth/sign-in', { + method: 'POST', + body: JSON.stringify(credential), + headers: { + 'content-type': 'application/json', + ...headers, + }, + }); + }, + async signOut() { + await fetchService.fetch('/api/auth/sign-out'); + }, + }; + }); +} diff --git a/packages/frontend/core/src/modules/cloud/index.ts b/packages/frontend/core/src/modules/cloud/index.ts index a60c0e4af..573b11d35 100644 --- a/packages/frontend/core/src/modules/cloud/index.ts +++ b/packages/frontend/core/src/modules/cloud/index.ts @@ -11,7 +11,9 @@ export { AccountChanged } from './events/account-changed'; export { AccountLoggedIn } from './events/account-logged-in'; export { AccountLoggedOut } from './events/account-logged-out'; export { ServerInitialized } from './events/server-initialized'; +export { AuthProvider } from './provider/auth'; export { ValidatorProvider } from './provider/validator'; +export { ServerScope } from './scopes/server'; export { AcceptInviteService } from './services/accept-invite'; export { AuthService } from './services/auth'; export { CaptchaService } from './services/captcha'; @@ -51,6 +53,8 @@ import { UserFeature } from './entities/user-feature'; import { UserQuota } from './entities/user-quota'; import { WorkspaceInvoices } from './entities/workspace-invoices'; import { WorkspaceSubscription } from './entities/workspace-subscription'; +import { configureDefaultAuthProvider } from './impl/auth'; +import { AuthProvider } from './provider/auth'; import { ValidatorProvider } from './provider/validator'; import { ServerScope } from './scopes/server'; import { AcceptInviteService } from './services/accept-invite'; @@ -88,6 +92,8 @@ import { UserFeatureStore } from './stores/user-feature'; import { UserQuotaStore } from './stores/user-quota'; export function configureCloudModule(framework: Framework) { + configureDefaultAuthProvider(framework); + framework .service(ServersService, [ServerListStore, ServerConfigStore]) .service(DefaultServerService, [ServersService]) @@ -112,6 +118,7 @@ export function configureCloudModule(framework: Framework) { GraphQLService, GlobalState, ServerService, + AuthProvider, ]) .entity(AuthSession, [AuthStore]) .service(SubscriptionService, [SubscriptionStore]) diff --git a/packages/frontend/core/src/modules/cloud/provider/auth.ts b/packages/frontend/core/src/modules/cloud/provider/auth.ts new file mode 100644 index 000000000..cd93107e7 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/provider/auth.ts @@ -0,0 +1,22 @@ +import { createIdentifier } from '@toeverything/infra'; + +export interface AuthProvider { + signInMagicLink(email: string, token: string): Promise; + + signInOauth( + code: string, + state: string, + provider: string + ): Promise<{ redirectUri?: string }>; + + signInPassword(credential: { + email: string; + password: string; + verifyToken?: string; + challenge?: string; + }): Promise; + + signOut(): Promise; +} + +export const AuthProvider = createIdentifier('AuthProvider'); diff --git a/packages/frontend/core/src/modules/cloud/services/auth.ts b/packages/frontend/core/src/modules/cloud/services/auth.ts index 79e771131..9ac6e7927 100644 --- a/packages/frontend/core/src/modules/cloud/services/auth.ts +++ b/packages/frontend/core/src/modules/cloud/services/auth.ts @@ -112,13 +112,7 @@ export class AuthService extends Service { async signInMagicLink(email: string, token: string, byLink = true) { const method = byLink ? 'magic-link' : 'otp'; try { - await this.fetchService.fetch('/api/auth/magic-link', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ email, token }), - }); + await this.store.signInMagicLink(email, token); this.session.revalidate(); track.$.$.auth.signedIn({ method }); @@ -160,7 +154,7 @@ export class AuthService extends Service { ); url = oauthUrl.toString(); - return url; + return url as string; } catch (e) { track.$.$.auth.signInFail({ method: 'oauth', @@ -173,18 +167,16 @@ export class AuthService extends Service { async signInOauth(code: string, state: string, provider: string) { try { - const res = await this.fetchService.fetch('/api/oauth/callback', { - method: 'POST', - body: JSON.stringify({ code, state }), - headers: { - 'content-type': 'application/json', - }, - }); + const { redirectUri } = await this.store.signInOauth( + code, + state, + provider + ); this.session.revalidate(); track.$.$.auth.signedIn({ method: 'oauth', provider }); - return await res.json(); + return { redirectUri }; } catch (e) { track.$.$.auth.signInFail({ method: 'oauth', @@ -203,16 +195,7 @@ export class AuthService extends Service { }) { track.$.$.auth.signIn({ method: 'password' }); try { - await this.fetchService.fetch('/api/auth/sign-in', { - method: 'POST', - body: JSON.stringify(credential), - headers: { - 'content-type': 'application/json', - ...(credential.verifyToken - ? this.captchaHeaders(credential.verifyToken, credential.challenge) - : {}), - }, - }); + await this.store.signInPassword(credential); this.session.revalidate(); track.$.$.auth.signedIn({ method: 'password' }); } catch (e) { @@ -225,7 +208,7 @@ export class AuthService extends Service { } async signOut() { - await this.fetchService.fetch('/api/auth/sign-out'); + await this.store.signOut(); this.store.setCachedAuthSession(null); this.session.revalidate(); } diff --git a/packages/frontend/core/src/modules/cloud/stores/auth.ts b/packages/frontend/core/src/modules/cloud/stores/auth.ts index b909e3885..f9ecda2a4 100644 --- a/packages/frontend/core/src/modules/cloud/stores/auth.ts +++ b/packages/frontend/core/src/modules/cloud/stores/auth.ts @@ -7,6 +7,7 @@ import { Store } from '@toeverything/infra'; import type { GlobalState } from '../../storage'; import type { AuthSessionInfo } from '../entities/session'; +import type { AuthProvider } from '../provider/auth'; import type { FetchService } from '../services/fetch'; import type { GraphQLService } from '../services/graphql'; import type { ServerService } from '../services/server'; @@ -25,7 +26,8 @@ export class AuthStore extends Store { private readonly fetchService: FetchService, private readonly gqlService: GraphQLService, private readonly globalState: GlobalState, - private readonly serverService: ServerService + private readonly serverService: ServerService, + private readonly authProvider: AuthProvider ) { super(); } @@ -63,6 +65,27 @@ export class AuthStore extends Store { return data; // Return null if data empty } + async signInMagicLink(email: string, token: string) { + await this.authProvider.signInMagicLink(email, token); + } + + async signInOauth(code: string, state: string, provider: string) { + return await this.authProvider.signInOauth(code, state, provider); + } + + async signInPassword(credential: { + email: string; + password: string; + verifyToken?: string; + challenge?: string; + }) { + await this.authProvider.signInPassword(credential); + } + + async signOut() { + await this.authProvider.signOut(); + } + async uploadAvatar(file: File) { await this.gqlService.gql({ query: uploadAvatarMutation, diff --git a/yarn.lock b/yarn.lock index 299fcffbd..8024c0dca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -628,6 +628,7 @@ __metadata: "@types/react-dom": "npm:^19.0.2" async-call-rpc: "npm:^6.4.2" cross-env: "npm:^7.0.3" + idb: "npm:^8.0.0" next-themes: "npm:^0.4.4" react: "npm:^19.0.0" react-dom: "npm:^19.0.0"