mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-13 16:16:46 +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:
@@ -57,7 +57,11 @@ import { Auth } from './plugins/auth';
|
||||
import { HashCash } from './plugins/hashcash';
|
||||
import { NbStoreNativeDBApis } from './plugins/nbstore';
|
||||
import { Preview } from './plugins/preview';
|
||||
import { writeEndpointToken } from './proxy';
|
||||
import {
|
||||
deleteEndpointToken,
|
||||
readEndpointToken,
|
||||
writeEndpointToken,
|
||||
} from './proxy';
|
||||
|
||||
const storeManagerClient = createStoreManagerClient();
|
||||
setTelemetryTransport(storeManagerClient.telemetry);
|
||||
@@ -206,10 +210,20 @@ framework.scope(ServerScope).override(AuthProvider, resolver => {
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
},
|
||||
async signOut() {
|
||||
await Auth.signOut({
|
||||
async signInOpenAppSignInCode(code) {
|
||||
const { token } = await Auth.signInOpenApp({
|
||||
endpoint,
|
||||
code,
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
},
|
||||
async signOut() {
|
||||
const token = await readEndpointToken(endpoint);
|
||||
try {
|
||||
await Auth.signOut({ endpoint, token });
|
||||
} finally {
|
||||
await deleteEndpointToken(endpoint);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -442,5 +456,20 @@ function createStoreManagerClient() {
|
||||
},
|
||||
[nativeDBApiChannelClient]
|
||||
);
|
||||
|
||||
const { port1: authTokenChannelServer, port2: authTokenChannelClient } =
|
||||
new MessageChannel();
|
||||
authTokenChannelServer.addEventListener('message', event => {
|
||||
const { id, endpoint } = event.data as { id?: string; endpoint?: string };
|
||||
if (!id || !endpoint) return;
|
||||
readEndpointToken(endpoint)
|
||||
.then(token => authTokenChannelServer.postMessage({ id, token }))
|
||||
.catch(() => authTokenChannelServer.postMessage({ id, token: null }));
|
||||
});
|
||||
authTokenChannelServer.start();
|
||||
worker.postMessage(
|
||||
{ type: 'native-auth-token-channel', port: authTokenChannelClient },
|
||||
[authTokenChannelClient]
|
||||
);
|
||||
return new StoreManagerClient(new OpClient(worker));
|
||||
}
|
||||
|
||||
@@ -18,19 +18,28 @@ import {
|
||||
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
|
||||
import { AsyncCall } from 'async-call-rpc';
|
||||
|
||||
import { readEndpointToken } from './proxy';
|
||||
let authTokenPort: MessagePort | undefined;
|
||||
const pendingTokenRequests = new Map<string, (token: string | null) => void>();
|
||||
|
||||
configureSocketAuthMethod((endpoint, cb) => {
|
||||
readEndpointToken(endpoint)
|
||||
.then(token => {
|
||||
cb({ token });
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
|
||||
.catch(() => cb({}));
|
||||
});
|
||||
|
||||
globalThis.addEventListener('message', e => {
|
||||
if (e.data.type === 'native-auth-token-channel') {
|
||||
authTokenPort = e.ports[0] as MessagePort;
|
||||
authTokenPort.addEventListener('message', e => {
|
||||
const { id, token } = e.data as { id?: string; token?: string | null };
|
||||
if (!id) return;
|
||||
pendingTokenRequests.get(id)?.(token ?? null);
|
||||
pendingTokenRequests.delete(id);
|
||||
});
|
||||
authTokenPort.start();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.data.type === 'native-db-api-channel') {
|
||||
const port = e.ports[0] as MessagePort;
|
||||
const rpc = AsyncCall<NativeDBApis>(
|
||||
@@ -57,6 +66,25 @@ globalThis.addEventListener('message', e => {
|
||||
}
|
||||
});
|
||||
|
||||
function readEndpointToken(endpoint: string) {
|
||||
if (!authTokenPort) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
const id = `${Date.now()}:${Math.random()}`;
|
||||
return new Promise<string | null>(resolve => {
|
||||
const timeout = setTimeout(() => {
|
||||
pendingTokenRequests.delete(id);
|
||||
resolve(null);
|
||||
}, 5000);
|
||||
pendingTokenRequests.set(id, token => {
|
||||
clearTimeout(timeout);
|
||||
resolve(token);
|
||||
});
|
||||
authTokenPort?.postMessage({ id, endpoint });
|
||||
});
|
||||
}
|
||||
|
||||
const consumer = new OpConsumer<WorkerManagerOps>(
|
||||
globalThis as MessageCommunicapable
|
||||
);
|
||||
|
||||
@@ -18,5 +18,17 @@ export interface AuthPlugin {
|
||||
verifyToken?: string;
|
||||
challenge?: string;
|
||||
}): Promise<{ token: string }>;
|
||||
signOut(options: { endpoint: string }): Promise<void>;
|
||||
signInOpenApp(options: {
|
||||
endpoint: string;
|
||||
code: string;
|
||||
}): Promise<{ token: string }>;
|
||||
signOut(options: { endpoint: string; token?: string | null }): Promise<void>;
|
||||
readEndpointToken(options: {
|
||||
endpoint: string;
|
||||
}): Promise<{ token?: string | null }>;
|
||||
writeEndpointToken(options: {
|
||||
endpoint: string;
|
||||
token: string;
|
||||
}): Promise<void>;
|
||||
deleteEndpointToken(options: { endpoint: string }): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
import { openDB } from 'idb';
|
||||
import { Auth } from './plugins/auth';
|
||||
|
||||
function authEndpointForUrl(url: string | URL) {
|
||||
try {
|
||||
const parsed = new URL(url, globalThis.location.origin);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||
? parsed.origin
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalEndpoint(endpoint: string) {
|
||||
return authEndpointForUrl(endpoint) ?? endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* the below code includes the custom fetch and xmlhttprequest implementation for ios webview.
|
||||
@@ -8,9 +23,11 @@ const rawFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
|
||||
const origin = new URL(request.url, globalThis.location.origin).origin;
|
||||
const origin = authEndpointForUrl(request.url);
|
||||
|
||||
const token = await readEndpointToken(origin);
|
||||
const token = origin
|
||||
? await readEndpointToken(origin).catch(() => null)
|
||||
: null;
|
||||
if (token) {
|
||||
request.headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
@@ -19,11 +36,30 @@ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
};
|
||||
|
||||
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
|
||||
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
|
||||
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
||||
const origin = new URL(this.responseURL, globalThis.location.origin).origin;
|
||||
override open(
|
||||
method: string,
|
||||
url: string | URL,
|
||||
async: boolean = true,
|
||||
username?: string | null,
|
||||
password?: string | null
|
||||
): void {
|
||||
xhrRequestUrls.set(this, url.toString());
|
||||
return super.open(
|
||||
method,
|
||||
url,
|
||||
async,
|
||||
username ?? undefined,
|
||||
password ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
readEndpointToken(origin).then(
|
||||
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
||||
const requestUrl = xhrRequestUrls.get(this);
|
||||
const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href);
|
||||
|
||||
(origin ? readEndpointToken(origin) : Promise.resolve(null)).then(
|
||||
token => {
|
||||
if (token) {
|
||||
this.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
@@ -31,7 +67,7 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
return super.send(body);
|
||||
},
|
||||
() => {
|
||||
throw new Error('Failed to read token');
|
||||
return super.send(body);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -40,26 +76,19 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
export async function readEndpointToken(
|
||||
endpoint: string
|
||||
): Promise<string | null> {
|
||||
const idb = await openDB('affine-token', 1, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains('tokens')) {
|
||||
db.createObjectStore('tokens', { keyPath: 'endpoint' });
|
||||
}
|
||||
},
|
||||
const { token } = await Auth.readEndpointToken({
|
||||
endpoint: canonicalEndpoint(endpoint),
|
||||
});
|
||||
|
||||
const token = await idb.get('tokens', endpoint);
|
||||
return token ? token.token : null;
|
||||
return token ?? null;
|
||||
}
|
||||
|
||||
export async function writeEndpointToken(endpoint: string, token: string) {
|
||||
const db = await openDB('affine-token', 1, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains('tokens')) {
|
||||
db.createObjectStore('tokens', { keyPath: 'endpoint' });
|
||||
}
|
||||
},
|
||||
await Auth.writeEndpointToken({
|
||||
endpoint: canonicalEndpoint(endpoint),
|
||||
token,
|
||||
});
|
||||
|
||||
await db.put('tokens', { endpoint, token });
|
||||
}
|
||||
|
||||
export async function deleteEndpointToken(endpoint: string) {
|
||||
await Auth.deleteEndpointToken({ endpoint: canonicalEndpoint(endpoint) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user