mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 22:09:08 +08:00
feat(server): passkey pre-refactor (#15060)
#### PR Dependency Tree * **PR #15060** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * OpenApp native sign-in and native session exchange (JWT) for mobile & desktop. * Centralized short-lived auth challenge store for one-time tokens. * Encrypted per-endpoint token storage and native token handlers (Android, iOS, Electron). * **Improvements** * Richer auth-method reporting (password, magic link, OAuth, passkey) and improved sign-in flows. * Hardened magic-link, OAuth, and session issuance; JWT-backed sessions and websocket JWT support. * UX tweaks: form-based password submit, OTP autocomplete, adjusted captcha flow. * **Bug Fixes** * Expanded tests and auth-state resets to avoid cross-test leakage. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import Capacitor
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "AuthPlugin"
|
||||
@@ -7,10 +8,70 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "signInMagicLink", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "signInOauth", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "signInOpenApp", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "signInPassword", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "signOut", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "readEndpointToken", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "writeEndpointToken", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "deleteEndpointToken", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
private let tokenService = "app.affine.pro.auth-token"
|
||||
private let authCookieNames = Set(["affine_session", "affine_user_id", "affine_csrf_token"])
|
||||
|
||||
private func canonicalEndpoint(_ endpoint: String) -> String {
|
||||
guard let url = URL(string: endpoint), let scheme = url.scheme, let host = url.host else {
|
||||
return endpoint
|
||||
}
|
||||
|
||||
let normalizedScheme = scheme.lowercased()
|
||||
let normalizedHost = host.lowercased()
|
||||
let defaultPort: Int?
|
||||
if normalizedScheme == "http" {
|
||||
defaultPort = 80
|
||||
} else if normalizedScheme == "https" {
|
||||
defaultPort = 443
|
||||
} else {
|
||||
defaultPort = nil
|
||||
}
|
||||
let port = url.port.flatMap { $0 == defaultPort ? nil : ":\($0)" } ?? ""
|
||||
return "\(normalizedScheme)://\(normalizedHost)\(port)"
|
||||
}
|
||||
|
||||
@objc public func readEndpointToken(_ call: CAPPluginCall) {
|
||||
do {
|
||||
let endpoint = try call.getStringEnsure("endpoint")
|
||||
if let token = try self.readToken(endpoint) {
|
||||
call.resolve(["token": token])
|
||||
} else {
|
||||
call.resolve(["token": NSNull()])
|
||||
}
|
||||
} catch {
|
||||
call.reject("Failed to read endpoint token, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func writeEndpointToken(_ call: CAPPluginCall) {
|
||||
do {
|
||||
let endpoint = try call.getStringEnsure("endpoint")
|
||||
let token = try call.getStringEnsure("token")
|
||||
try self.writeToken(endpoint, token)
|
||||
call.resolve(["ok": true])
|
||||
} catch {
|
||||
call.reject("Failed to write endpoint token, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func deleteEndpointToken(_ call: CAPPluginCall) {
|
||||
do {
|
||||
let endpoint = try call.getStringEnsure("endpoint")
|
||||
try self.deleteToken(endpoint)
|
||||
call.resolve(["ok": true])
|
||||
} catch {
|
||||
call.reject("Failed to delete endpoint token, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func signInMagicLink(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -19,7 +80,11 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
let token = try call.getStringEnsure("token")
|
||||
let clientNonce = call.getString("clientNonce")
|
||||
|
||||
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/magic-link", headers: [:], body: ["email": email, "token": token, "client_nonce": clientNonce])
|
||||
let (data, response) = try await self.fetch(
|
||||
endpoint, method: "POST", action: "/api/auth/magic-link",
|
||||
headers: [
|
||||
"x-affine-client-kind": "native"
|
||||
], body: ["email": email, "token": token, "client_nonce": clientNonce])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
@@ -30,12 +95,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return
|
||||
}
|
||||
|
||||
guard let token = try self.tokenFromCookie(endpoint) else {
|
||||
call.reject("token not found")
|
||||
return
|
||||
}
|
||||
|
||||
call.resolve(["token": token])
|
||||
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
@@ -50,7 +110,11 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
let state = try call.getStringEnsure("state")
|
||||
let clientNonce = call.getString("clientNonce")
|
||||
|
||||
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/oauth/callback", headers: [:], body: ["code": code, "state": state, "client_nonce": clientNonce])
|
||||
let (data, response) = try await self.fetch(
|
||||
endpoint, method: "POST", action: "/api/oauth/callback",
|
||||
headers: [
|
||||
"x-affine-client-kind": "native"
|
||||
], body: ["code": code, "state": state, "client_nonce": clientNonce])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
@@ -61,12 +125,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return
|
||||
}
|
||||
|
||||
guard let token = try self.tokenFromCookie(endpoint) else {
|
||||
call.reject("token not found")
|
||||
return
|
||||
}
|
||||
|
||||
call.resolve(["token": token])
|
||||
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
@@ -82,10 +141,13 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
let verifyToken = call.getString("verifyToken")
|
||||
let challenge = call.getString("challenge")
|
||||
|
||||
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/sign-in", headers: [
|
||||
"x-captcha-token": verifyToken,
|
||||
"x-captcha-challenge": challenge,
|
||||
], body: ["email": email, "password": password])
|
||||
let (data, response) = try await self.fetch(
|
||||
endpoint, method: "POST", action: "/api/auth/sign-in",
|
||||
headers: [
|
||||
"x-affine-client-kind": "native",
|
||||
"x-captcha-token": verifyToken,
|
||||
"x-captcha-challenge": challenge,
|
||||
], body: ["email": email, "password": password])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
@@ -96,12 +158,35 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return
|
||||
}
|
||||
|
||||
guard let token = try self.tokenFromCookie(endpoint) else {
|
||||
call.reject("token not found")
|
||||
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func signInOpenApp(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
let endpoint = try call.getStringEnsure("endpoint")
|
||||
let code = try call.getStringEnsure("code")
|
||||
|
||||
let (data, response) = try await self.fetch(
|
||||
endpoint, method: "POST", action: "/api/auth/open-app/sign-in",
|
||||
headers: [
|
||||
"x-affine-client-kind": "native"
|
||||
], body: ["code": code])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
call.reject(textBody)
|
||||
} else {
|
||||
call.reject("Failed to sign in")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
call.resolve(["token": token])
|
||||
call.resolve(["token": try await self.exchangeSession(endpoint, data)])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
@@ -112,11 +197,13 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
Task {
|
||||
do {
|
||||
let endpoint = try call.getStringEnsure("endpoint")
|
||||
let csrfToken = try self.csrfTokenFromCookie(endpoint)
|
||||
let token = call.getString("token")
|
||||
|
||||
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/sign-out", headers: [
|
||||
"x-affine-csrf-token": csrfToken,
|
||||
], body: nil)
|
||||
let (data, response) = try await self.fetch(
|
||||
endpoint, method: "POST", action: "/api/auth/sign-out",
|
||||
headers: [
|
||||
"Authorization": token.map { "Bearer \($0)" }
|
||||
], body: nil)
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
@@ -127,6 +214,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return
|
||||
}
|
||||
|
||||
self.clearAuthCookies(endpoint)
|
||||
call.resolve(["ok": true])
|
||||
} catch {
|
||||
call.reject("Failed to sign out, \(error)", nil, error)
|
||||
@@ -134,38 +222,147 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private func tokenFromCookie(_ endpoint: String) throws -> String? {
|
||||
guard let endpointUrl = URL(string: endpoint) else {
|
||||
throw AuthError.invalidEndpoint
|
||||
private func tokenFromResponse(_ data: Data) throws -> String {
|
||||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let token = json["token"] as? String
|
||||
else {
|
||||
throw AuthError.tokenNotFound
|
||||
}
|
||||
|
||||
if let cookie = HTTPCookieStorage.shared.cookies(for: endpointUrl)?.first(where: {
|
||||
$0.name == "affine_session"
|
||||
}) {
|
||||
return cookie.value
|
||||
} else {
|
||||
return nil
|
||||
return token
|
||||
}
|
||||
|
||||
private func exchangeCodeFromResponse(_ data: Data) throws -> String {
|
||||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let code = json["exchangeCode"] as? String
|
||||
else {
|
||||
throw AuthError.exchangeCodeNotFound
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
private func exchangeSession(_ endpoint: String, _ signInData: Data) async throws -> String {
|
||||
let code = try exchangeCodeFromResponse(signInData)
|
||||
let (data, response) = try await self.fetch(
|
||||
endpoint, method: "POST", action: "/api/auth/native/exchange",
|
||||
headers: [
|
||||
"x-affine-client-kind": "native"
|
||||
], body: ["code": code])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
throw AuthError.exchangeFailed
|
||||
}
|
||||
|
||||
let token = try tokenFromResponse(data)
|
||||
self.clearAuthCookies(endpoint)
|
||||
return token
|
||||
}
|
||||
|
||||
private func clearAuthCookies(_ endpoint: String) {
|
||||
guard let url = URL(string: endpoint), let host = url.host else {
|
||||
return
|
||||
}
|
||||
let normalizedHost = host.lowercased()
|
||||
|
||||
HTTPCookieStorage.shared.cookies?.forEach { cookie in
|
||||
let domain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
let domainMatches = normalizedHost == domain || normalizedHost.hasSuffix(".\(domain)")
|
||||
if domainMatches && authCookieNames.contains(cookie.name) {
|
||||
HTTPCookieStorage.shared.deleteCookie(cookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func csrfTokenFromCookie(_ endpoint: String) throws -> String? {
|
||||
guard let endpointUrl = URL(string: endpoint) else {
|
||||
throw AuthError.invalidEndpoint
|
||||
}
|
||||
|
||||
return HTTPCookieStorage.shared.cookies(for: endpointUrl)?.first(where: {
|
||||
$0.name == "affine_csrf_token"
|
||||
})?.value
|
||||
private func tokenQuery(_ endpoint: String) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: tokenService,
|
||||
kSecAttrAccount as String: canonicalEndpoint(endpoint),
|
||||
]
|
||||
}
|
||||
|
||||
private func fetch(_ endpoint: String, method: String, action: String, headers: [String: String?], body: Encodable?) async throws -> (Data, HTTPURLResponse) {
|
||||
private func legacyTokenQuery(_ endpoint: String) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: tokenService,
|
||||
kSecAttrAccount as String: endpoint,
|
||||
]
|
||||
}
|
||||
|
||||
private func readToken(_ endpoint: String) throws -> String? {
|
||||
var query = tokenQuery(endpoint)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
if status == errSecItemNotFound {
|
||||
guard canonicalEndpoint(endpoint) != endpoint else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var legacyQuery = legacyTokenQuery(endpoint)
|
||||
legacyQuery[kSecReturnData as String] = true
|
||||
legacyQuery[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
let legacyStatus = SecItemCopyMatching(legacyQuery as CFDictionary, &item)
|
||||
if legacyStatus == errSecItemNotFound {
|
||||
return nil
|
||||
}
|
||||
guard legacyStatus == errSecSuccess, let data = item as? Data else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
let token = String(data: data, encoding: .utf8)
|
||||
if let token = token {
|
||||
try writeToken(endpoint, token)
|
||||
let deleteStatus = SecItemDelete(legacyTokenQuery(endpoint) as CFDictionary)
|
||||
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
}
|
||||
return token
|
||||
}
|
||||
guard status == errSecSuccess, let data = item as? Data else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func writeToken(_ endpoint: String, _ token: String) throws {
|
||||
try deleteToken(endpoint)
|
||||
var query = tokenQuery(endpoint)
|
||||
query[kSecValueData as String] = Data(token.utf8)
|
||||
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteToken(_ endpoint: String) throws {
|
||||
let status = SecItemDelete(tokenQuery(endpoint) as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
if canonicalEndpoint(endpoint) != endpoint {
|
||||
let legacyStatus = SecItemDelete(legacyTokenQuery(endpoint) as CFDictionary)
|
||||
guard legacyStatus == errSecSuccess || legacyStatus == errSecItemNotFound else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetch(
|
||||
_ endpoint: String, method: String, action: String, headers: [String: String?], body: Encodable?
|
||||
) async throws -> (Data, HTTPURLResponse) {
|
||||
guard let targetUrl = URL(string: "\(endpoint)\(action)") else {
|
||||
throw AuthError.invalidEndpoint
|
||||
}
|
||||
|
||||
var request = URLRequest(url: targetUrl)
|
||||
request.httpMethod = method
|
||||
request.httpShouldHandleCookies = true
|
||||
request.httpShouldHandleCookies = false
|
||||
for (key, value) in headers {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
@@ -174,7 +371,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
request.httpBody = try JSONEncoder().encode(body!)
|
||||
}
|
||||
request.setValue(AppConfigManager.getAffineVersion(), forHTTPHeaderField: "x-affine-version")
|
||||
request.timeoutInterval = 10 // time out 10s
|
||||
request.timeoutInterval = 10 // time out 10s
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
@@ -185,5 +382,5 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
|
||||
enum AuthError: Error {
|
||||
case invalidEndpoint, internalError
|
||||
case invalidEndpoint, internalError, tokenNotFound, exchangeCodeNotFound, exchangeFailed
|
||||
}
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ public class GetCurrentUserQuery: GraphQLQuery {
|
||||
public var emailVerified: Bool { __data["emailVerified"] }
|
||||
/// User avatar url
|
||||
public var avatarUrl: String? { __data["avatarUrl"] }
|
||||
@available(*, deprecated, message: "use [/api/auth/sign-in?native=true] instead")
|
||||
@available(*, deprecated, message: "use native session exchange instead")
|
||||
public var token: Token { __data["token"] }
|
||||
|
||||
/// CurrentUser.Token
|
||||
|
||||
@@ -74,7 +74,11 @@ import { Hashcash } from './plugins/hashcash';
|
||||
import { NbStoreNativeDBApis } from './plugins/nbstore';
|
||||
import { PayWall } from './plugins/paywall';
|
||||
import { Preview } from './plugins/preview';
|
||||
import { writeEndpointToken } from './proxy';
|
||||
import {
|
||||
deleteEndpointToken,
|
||||
readEndpointToken,
|
||||
writeEndpointToken,
|
||||
} from './proxy';
|
||||
import { enableNavigationGesture$ } from './web-navigation-control';
|
||||
|
||||
const storeManagerClient = createStoreManagerClient();
|
||||
@@ -204,10 +208,20 @@ framework.scope(ServerScope).override(AuthProvider, resolver => {
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
},
|
||||
async signOut() {
|
||||
await Auth.signOut({
|
||||
async signInOpenAppSignInCode(code) {
|
||||
const { token } = await Auth.signInOpenApp({
|
||||
endpoint,
|
||||
code,
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
},
|
||||
async signOut() {
|
||||
const token = await readEndpointToken(endpoint);
|
||||
try {
|
||||
await Auth.signOut({ endpoint, token });
|
||||
} finally {
|
||||
await deleteEndpointToken(endpoint);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -541,13 +555,9 @@ function createStoreManagerClient() {
|
||||
AsyncCall<typeof NbStoreNativeDBApis>(NbStoreNativeDBApis, {
|
||||
channel: {
|
||||
on(listener) {
|
||||
const f = (e: MessageEvent<any>) => {
|
||||
listener(e.data);
|
||||
};
|
||||
const f = (e: MessageEvent<any>) => listener(e.data);
|
||||
nativeDBApiChannelServer.addEventListener('message', f);
|
||||
return () => {
|
||||
nativeDBApiChannelServer.removeEventListener('message', f);
|
||||
};
|
||||
return () => nativeDBApiChannelServer.removeEventListener('message', f);
|
||||
},
|
||||
send(data) {
|
||||
nativeDBApiChannelServer.postMessage(data);
|
||||
@@ -557,11 +567,23 @@ function createStoreManagerClient() {
|
||||
});
|
||||
nativeDBApiChannelServer.start();
|
||||
worker.postMessage(
|
||||
{
|
||||
type: 'native-db-api-channel',
|
||||
port: nativeDBApiChannelClient,
|
||||
},
|
||||
{ type: 'native-db-api-channel', port: nativeDBApiChannelClient },
|
||||
[nativeDBApiChannelClient]
|
||||
);
|
||||
|
||||
const { port1: authTokenChannelServer, port2: authTokenChannelClient } =
|
||||
new MessageChannel();
|
||||
authTokenChannelServer.addEventListener('message', event => {
|
||||
const { id, endpoint } = event.data as { id?: string; endpoint?: string };
|
||||
if (!id || !endpoint) return;
|
||||
readEndpointToken(endpoint)
|
||||
.then(token => authTokenChannelServer.postMessage({ id, token }))
|
||||
.catch(() => authTokenChannelServer.postMessage({ id, token: null }));
|
||||
});
|
||||
authTokenChannelServer.start();
|
||||
worker.postMessage(
|
||||
{ type: 'native-auth-token-channel', port: authTokenChannelClient },
|
||||
[authTokenChannelClient]
|
||||
);
|
||||
return new StoreManagerClient(new OpClient(worker));
|
||||
}
|
||||
|
||||
@@ -18,19 +18,28 @@ import {
|
||||
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
|
||||
import { AsyncCall } from 'async-call-rpc';
|
||||
|
||||
import { readEndpointToken } from './proxy';
|
||||
let authTokenPort: MessagePort | undefined;
|
||||
const pendingTokenRequests = new Map<string, (token: string | null) => void>();
|
||||
|
||||
configureSocketAuthMethod((endpoint, cb) => {
|
||||
readEndpointToken(endpoint)
|
||||
.then(token => {
|
||||
cb({ token });
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
|
||||
.catch(() => cb({}));
|
||||
});
|
||||
|
||||
globalThis.addEventListener('message', e => {
|
||||
if (e.data.type === 'native-auth-token-channel') {
|
||||
authTokenPort = e.ports[0] as MessagePort;
|
||||
authTokenPort.addEventListener('message', e => {
|
||||
const { id, token } = e.data as { id?: string; token?: string | null };
|
||||
if (!id) return;
|
||||
pendingTokenRequests.get(id)?.(token ?? null);
|
||||
pendingTokenRequests.delete(id);
|
||||
});
|
||||
authTokenPort.start();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.data.type === 'native-db-api-channel') {
|
||||
const port = e.ports[0] as MessagePort;
|
||||
const rpc = AsyncCall<NativeDBApis>(
|
||||
@@ -57,6 +66,25 @@ globalThis.addEventListener('message', e => {
|
||||
}
|
||||
});
|
||||
|
||||
function readEndpointToken(endpoint: string) {
|
||||
if (!authTokenPort) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
const id = `${Date.now()}:${Math.random()}`;
|
||||
return new Promise<string | null>(resolve => {
|
||||
const timeout = setTimeout(() => {
|
||||
pendingTokenRequests.delete(id);
|
||||
resolve(null);
|
||||
}, 5000);
|
||||
pendingTokenRequests.set(id, token => {
|
||||
clearTimeout(timeout);
|
||||
resolve(token);
|
||||
});
|
||||
authTokenPort?.postMessage({ id, endpoint });
|
||||
});
|
||||
}
|
||||
|
||||
const consumer = new OpConsumer<WorkerManagerOps>(
|
||||
globalThis as MessageCommunicapable
|
||||
);
|
||||
|
||||
@@ -18,5 +18,17 @@ export interface AuthPlugin {
|
||||
verifyToken?: string;
|
||||
challenge?: string;
|
||||
}): Promise<{ token: string }>;
|
||||
signOut(options: { endpoint: string }): Promise<void>;
|
||||
signInOpenApp(options: {
|
||||
endpoint: string;
|
||||
code: string;
|
||||
}): Promise<{ token: string }>;
|
||||
signOut(options: { endpoint: string; token?: string | null }): Promise<void>;
|
||||
readEndpointToken(options: {
|
||||
endpoint: string;
|
||||
}): Promise<{ token?: string | null }>;
|
||||
writeEndpointToken(options: {
|
||||
endpoint: string;
|
||||
token: string;
|
||||
}): Promise<void>;
|
||||
deleteEndpointToken(options: { endpoint: string }): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
import { openDB } from 'idb';
|
||||
import { Auth } from './plugins/auth';
|
||||
|
||||
function authEndpointForUrl(url: string | URL) {
|
||||
try {
|
||||
const parsed = new URL(url, globalThis.location.origin);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||
? parsed.origin
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalEndpoint(endpoint: string) {
|
||||
return authEndpointForUrl(endpoint) ?? endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* the below code includes the custom fetch and xmlhttprequest implementation for ios webview.
|
||||
@@ -8,9 +23,11 @@ const rawFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
|
||||
const origin = new URL(request.url, globalThis.location.origin).origin;
|
||||
const origin = authEndpointForUrl(request.url);
|
||||
|
||||
const token = await readEndpointToken(origin);
|
||||
const token = origin
|
||||
? await readEndpointToken(origin).catch(() => null)
|
||||
: null;
|
||||
if (token) {
|
||||
request.headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
@@ -19,11 +36,30 @@ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
};
|
||||
|
||||
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
|
||||
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
|
||||
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
||||
const origin = new URL(this.responseURL, globalThis.location.origin).origin;
|
||||
override open(
|
||||
method: string,
|
||||
url: string | URL,
|
||||
async: boolean = true,
|
||||
username?: string | null,
|
||||
password?: string | null
|
||||
): void {
|
||||
xhrRequestUrls.set(this, url.toString());
|
||||
return super.open(
|
||||
method,
|
||||
url,
|
||||
async,
|
||||
username ?? undefined,
|
||||
password ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
readEndpointToken(origin).then(
|
||||
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
||||
const requestUrl = xhrRequestUrls.get(this);
|
||||
const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href);
|
||||
|
||||
(origin ? readEndpointToken(origin) : Promise.resolve(null)).then(
|
||||
token => {
|
||||
if (token) {
|
||||
this.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
@@ -31,7 +67,7 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
return super.send(body);
|
||||
},
|
||||
() => {
|
||||
throw new Error('Failed to read token');
|
||||
return super.send(body);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -40,26 +76,19 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
export async function readEndpointToken(
|
||||
endpoint: string
|
||||
): Promise<string | null> {
|
||||
const idb = await openDB('affine-token', 1, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains('tokens')) {
|
||||
db.createObjectStore('tokens', { keyPath: 'endpoint' });
|
||||
}
|
||||
},
|
||||
const { token } = await Auth.readEndpointToken({
|
||||
endpoint: canonicalEndpoint(endpoint),
|
||||
});
|
||||
|
||||
const token = await idb.get('tokens', endpoint);
|
||||
return token ? token.token : null;
|
||||
return token ?? null;
|
||||
}
|
||||
|
||||
export async function writeEndpointToken(endpoint: string, token: string) {
|
||||
const db = await openDB('affine-token', 1, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains('tokens')) {
|
||||
db.createObjectStore('tokens', { keyPath: 'endpoint' });
|
||||
}
|
||||
},
|
||||
await Auth.writeEndpointToken({
|
||||
endpoint: canonicalEndpoint(endpoint),
|
||||
token,
|
||||
});
|
||||
|
||||
await db.put('tokens', { endpoint, token });
|
||||
}
|
||||
|
||||
export async function deleteEndpointToken(endpoint: string) {
|
||||
await Auth.deleteEndpointToken({ endpoint: canonicalEndpoint(endpoint) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user