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,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) });
}