fix(core): selfhosted auth handling (#15295)

fix #15284
fix #15266
fix #15268
fix #15267

#### PR Dependency Tree


* **PR #15295** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
DarkSky
2026-07-21 11:26:15 +08:00
committed by GitHub
parent bb55d6fd21
commit b1abd8db54
17 changed files with 169 additions and 65 deletions
Generated
+5 -4
View File
@@ -76,9 +76,9 @@ dependencies = [
[[package]]
name = "affine_doc_loader"
version = "0.1.2"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6af9fa0b8359900e4c150b717e837eec813f29ee6981fa72d83e671974c0f78f"
checksum = "1bd208d52725dd0c63583b171ffc3121a60d7686bacedfee5c135f9c0c58d00b"
dependencies = [
"nanoid 0.5.0",
"pulldown-cmark 0.13.1",
@@ -90,13 +90,14 @@ dependencies = [
[[package]]
name = "affine_importer"
version = "0.1.1"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a698069c4f0ae26bbc3c07deaf84bb0d55b0ed80dae44d6879ca1bf63eee8e9"
checksum = "5826d670f6d43faa7809ef6a06d4a8c5ff493b970399c5c3c5659d25e572746b"
dependencies = [
"affine_doc_loader",
"chrono",
"nanoid 0.5.0",
"pulldown-cmark 0.13.1",
"serde",
"serde_json",
"sha2 0.11.0",
+2 -2
View File
@@ -16,8 +16,8 @@ resolver = "3"
[workspace.dependencies]
aes-gcm = "0.10"
affine_common = { path = "./packages/common/native" }
affine_doc_loader = "0.1.2"
affine_importer = "0.1.1"
affine_doc_loader = "0.1.3"
affine_importer = "0.1.2"
affine_nbstore = { path = "./packages/frontend/native/nbstore" }
affine_preview = { version = "0.1.0", default-features = false }
anyhow = "1"
@@ -5,8 +5,7 @@ query getCurrentUser {
email
emailVerified
avatarUrl
token {
sessionToken
}
hasPassword
features
}
}
+2 -4
View File
@@ -1967,12 +1967,10 @@ export const getCurrentUserQuery = {
email
emailVerified
avatarUrl
token {
sessionToken
}
hasPassword
features
}
}`,
deprecations: ["'token' is deprecated: use auth session exchange instead"],
};
export const getDocCreatedByUpdatedByListQuery = {
+2 -1
View File
@@ -6519,7 +6519,8 @@ export type GetCurrentUserQuery = {
email: string;
emailVerified: boolean;
avatarUrl: string | null;
token: { __typename?: 'tokenType'; sessionToken: string | null };
hasPassword: boolean | null;
features: Array<FeatureType>;
} | null;
};
@@ -186,17 +186,27 @@ export abstract class AutoReconnectConnection<
return;
}
if (signal?.aborted) {
reject(signal.reason);
return;
}
const off = this.onStatusChanged(status => {
if (status === 'connected') {
resolve();
off();
cleanup();
}
});
signal?.addEventListener('abort', reason => {
reject(reason);
const onAbort = () => {
reject(signal?.reason);
cleanup();
};
const cleanup = () => {
off();
});
signal?.removeEventListener('abort', onAbort);
};
signal?.addEventListener('abort', onAbort, { once: true });
});
}
@@ -41,8 +41,9 @@ class FakeSocket {
}
}
const { resetSharedConnection } = vi.hoisted(() => ({
const { resetSharedConnection, waitForConnected } = vi.hoisted(() => ({
resetSharedConnection: vi.fn(),
waitForConnected: vi.fn(async () => {}),
}));
const socket = new FakeSocket();
@@ -56,7 +57,7 @@ vi.mock('../../impls/cloud/socket', () => ({
connect() {}
async waitForConnected() {}
waitForConnected = waitForConnected;
disconnect() {
socket.disconnected = true;
@@ -74,6 +75,8 @@ beforeEach(() => {
socket.connected = true;
socket.disconnected = false;
resetSharedConnection.mockClear();
waitForConnected.mockReset();
waitForConnected.mockResolvedValue(undefined);
});
test('getRealtimeInputKey is deterministic for realtime subscription inputs', () => {
@@ -157,7 +160,7 @@ test('non-bootstrap request still requires authenticated context', async () => {
);
});
test('request rejects when aborted', async () => {
test('request rejects when connection times out or is aborted', async () => {
const manager = new RealtimeManager();
manager.setContext({
endpoint: 'http://server',
@@ -175,6 +178,14 @@ test('request rejects when aborted', async () => {
controller.abort();
await expect(request).rejects.toThrow('Realtime request aborted');
waitForConnected.mockImplementationOnce(() => new Promise(() => {}));
await expect(
manager.request('notification.count.get', {}, { timeoutMs: 1 })
).rejects.toMatchObject({
name: 'RealtimeRequestTimeout',
message: 'Realtime request timed out: notification.count.get',
});
});
test('subscribe routes events by topic and stable input key', async () => {
+26 -18
View File
@@ -79,8 +79,8 @@ export class RealtimeManager {
input: RealtimeRequestInputOf<Op>,
options?: { timeoutMs?: number; signal?: AbortSignal }
): Promise<RealtimeRequestOutputOf<Op>> {
const socket = await this.connect(op === 'user.profile.get');
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT;
const connectAbort = new AbortController();
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let abortHandler: (() => void) | undefined;
const abort = () => {
@@ -104,28 +104,36 @@ export class RealtimeManager {
options?.signal?.addEventListener('abort', abortHandler, { once: true });
});
const ack = await Promise.race([
socket.emitWithAck('realtime:request', {
op,
input,
clientVersion: BUILD_CONFIG.appVersion,
}),
timeout,
aborted,
]).finally(() => {
try {
const socket = await Promise.race([
this.connect(op === 'user.profile.get', connectAbort.signal),
timeout,
aborted,
]);
const ack = await Promise.race([
socket.emitWithAck('realtime:request', {
op,
input,
clientVersion: BUILD_CONFIG.appVersion,
}),
timeout,
aborted,
]);
if ('error' in ack) {
throw rejectAck(ack.error);
}
return ack.data as unknown as RealtimeRequestOutputOf<Op>;
} finally {
connectAbort.abort();
if (timeoutId) {
clearTimeout(timeoutId);
}
if (abortHandler) {
options?.signal?.removeEventListener('abort', abortHandler);
}
});
if ('error' in ack) {
throw rejectAck(ack.error);
}
return ack.data as unknown as RealtimeRequestOutputOf<Op>;
}
subscribe<Topic extends RealtimeTopicName>(
@@ -224,7 +232,7 @@ export class RealtimeManager {
};
}
private async connect(allowUnauthenticated = false) {
private async connect(allowUnauthenticated = false, signal?: AbortSignal) {
if (
!this.context?.endpoint ||
(!this.context.authenticated && !allowUnauthenticated)
@@ -245,7 +253,7 @@ export class RealtimeManager {
this.socketConnection.connect();
}
await this.socketConnection.waitForConnected();
await this.socketConnection.waitForConnected(signal);
this.socketConnection.inner.socket.off('realtime:event', this.handleEvent);
this.socketConnection.inner.socket.on('realtime:event', this.handleEvent);
this.socketConnection.inner.socket.off('connect', this.handleReconnect);
@@ -1,4 +1,4 @@
import { toast } from '@affine/component';
import { notify, toast } from '@affine/component';
import {
pushGlobalLoadingEventAtom,
resolveGlobalLoadingEventAtom,
@@ -15,6 +15,7 @@ import {
AuthService,
EventSourceService,
GraphQLService,
RealtimeService,
} from '@affine/core/modules/cloud';
import {
GlobalDialogService,
@@ -39,6 +40,7 @@ import {
fromPromise,
onStart,
throwIfAborted,
useLiveData,
useService,
useServices,
} from '@toeverything/infra';
@@ -142,6 +144,20 @@ export const WorkspaceSideEffects = () => {
const eventSourceService = useService(EventSourceService);
const authService = useService(AuthService);
const nbstoreService = useService(NbstoreService);
const realtimeConnectionError = useLiveData(
useService(RealtimeService).connectionError$
);
useEffect(() => {
if (!realtimeConnectionError) return;
notify.warning(
{
title: t['com.affine.realtime.connection-error.title'](),
message: t['com.affine.realtime.connection-error.message'](),
},
{ id: `realtime-connection-error:${realtimeConnectionError.endpoint}` }
);
}, [realtimeConnectionError, t]);
useEffect(() => {
const dispose = setupAIProvider(
@@ -93,6 +93,14 @@ export class AuthSession extends Entity {
)
);
async revalidateOnce() {
const sessionInfo = await this.getSession();
if (!isEqual(this.store.getCachedAuthSession(), sessionInfo)) {
this.store.setCachedAuthSession(sessionInfo);
}
return sessionInfo;
}
private async getSession(): Promise<AuthSessionInfo | null> {
try {
const session = await this.store.fetchSession();
@@ -166,7 +166,7 @@ export class AuthService extends Service {
try {
await this.store.signInMagicLink(email, token);
this.session.revalidate();
await this.session.revalidateOnce();
track.$.$.auth.signedIn({ method });
} catch (e) {
track.$.$.auth.signInFail({
@@ -218,7 +218,7 @@ export class AuthService extends Service {
provider
);
this.session.revalidate();
await this.session.revalidateOnce();
track.$.$.auth.signedIn({ method: 'oauth', provider });
return { redirectUri };
@@ -251,7 +251,7 @@ export class AuthService extends Service {
async signInOpenAppSignInCode(code: string) {
await this.store.signInOpenAppSignInCode(code);
this.session.revalidate();
await this.session.revalidateOnce();
}
async signInPassword(credential: {
@@ -266,8 +266,10 @@ export class AuthService extends Service {
const user = await this.store.signInPassword(credential);
if (user) {
this.store.setCachedSignInUser(user);
this.session.revalidate();
} else {
await this.session.revalidateOnce();
}
this.session.revalidate();
track.$.$.auth.signedIn({ method: 'password' });
} catch (e) {
track.$.$.auth.signInFail({
@@ -10,6 +10,12 @@ import type { ServersService } from './servers';
@OnEvent(ApplicationStarted, service => service.onApplicationStarted)
export class RealtimeService extends Service {
readonly connectionError$ = new LiveData<{
endpoint: string;
error: unknown;
} | null>(null);
private contextGeneration = 0;
private readonly currentServer$ =
this.globalContextService.globalContext.serverId.$.selector(id =>
id
@@ -43,7 +49,8 @@ export class RealtimeService extends Service {
super();
const subscription = this.currentServer$.subscribe(context => {
this.nbstoreService.realtime.configure(context).catch(error => {
const generation = ++this.contextGeneration;
this.configure(context, generation).catch(error => {
console.error('Failed to configure realtime context', error);
});
});
@@ -51,4 +58,35 @@ export class RealtimeService extends Service {
}
onApplicationStarted() {}
private async configure(
context: {
endpoint: string;
authenticated: boolean;
isSelfHosted: boolean;
},
generation: number
) {
await this.nbstoreService.realtime.configure(context);
if (generation !== this.contextGeneration) return;
if (!context.endpoint || !context.authenticated) {
this.connectionError$.next(null);
return;
}
try {
await this.nbstoreService.realtime.request(
'user.profile.get',
{},
{ timeoutMs: 10_000 }
);
if (generation === this.contextGeneration) {
this.connectionError$.next(null);
}
} catch (error) {
if (generation === this.contextGeneration) {
this.connectionError$.next({ endpoint: context.endpoint, error });
}
}
}
}
@@ -9,14 +9,14 @@ import { describe, expect, test, vi } from 'vitest';
function createStore({
fetch,
request,
gql = vi.fn(),
}: {
fetch: (input: string, init?: RequestInit) => Promise<Response>;
request: (op: string, input: object) => Promise<unknown>;
gql?: () => Promise<unknown>;
}) {
const framework = new Framework();
framework.service(FetchService, { fetch } as any);
framework.service(GraphQLService, {} as any);
framework.service(GraphQLService, { gql } as any);
framework.impl(GlobalState, {} as any);
framework.service(ServerService, {
server: {
@@ -26,7 +26,7 @@ function createStore({
} as any);
framework.impl(AuthProvider, {} as any);
framework.service(NbstoreService, {
realtime: { request },
realtime: {},
} as any);
framework.store(AuthStore, [
FetchService,
@@ -57,7 +57,7 @@ describe('AuthStore', () => {
ok: true,
json: async () => ({ registered: true, methods: {} }),
});
const store = createStore({ fetch, request: vi.fn() });
const store = createStore({ fetch });
await expect(store.checkUserByEmail('user@affine.pro')).resolves.toEqual(
validResponse
@@ -70,7 +70,7 @@ describe('AuthStore', () => {
});
});
test('loads account profile from realtime after auth session bootstrap', async () => {
test('loads account profile from scoped GraphQL after auth session bootstrap', async () => {
const authMethods = {
password: { bound: true },
oauth: { bound: false, providers: [] },
@@ -90,8 +90,8 @@ describe('AuthStore', () => {
}
throw new Error(`Unexpected request: ${input}`);
});
const request = vi.fn(async () => ({
user: {
const gql = vi.fn(async () => ({
currentUser: {
id: 'u1',
email: 'u1@affine.pro',
name: 'User',
@@ -101,7 +101,7 @@ describe('AuthStore', () => {
features: ['Admin'],
},
}));
const store = createStore({ fetch, request });
const store = createStore({ fetch, gql });
await expect(store.fetchSession()).resolves.toEqual({
user: {
@@ -115,17 +115,17 @@ describe('AuthStore', () => {
authMethods,
},
});
expect(request).toHaveBeenCalledWith('user.profile.get', {});
expect(gql).toHaveBeenCalledOnce();
});
test('rejects mismatched realtime profile and auth session', async () => {
test('rejects mismatched GraphQL profile and auth session', async () => {
const fetch = vi.fn(async () => {
return {
json: async () => ({ user: { id: 'u1' } }),
} as Response;
});
const request = vi.fn(async () => ({
user: {
const gql = vi.fn(async () => ({
currentUser: {
id: 'u2',
email: 'u2@affine.pro',
name: 'User',
@@ -135,10 +135,10 @@ describe('AuthStore', () => {
features: [],
},
}));
const store = createStore({ fetch, request });
const store = createStore({ fetch, gql });
await expect(store.fetchSession()).rejects.toThrow(
'Realtime user profile does not match auth session'
'User profile does not match auth session'
);
});
});
@@ -1,5 +1,6 @@
import {
deleteAccountMutation,
getCurrentUserQuery,
removeAvatarMutation,
ServerDeploymentType,
updateUserProfileMutation,
@@ -101,12 +102,11 @@ export class AuthStore extends Store {
const session = await this.fetchAuthSession();
if (!session.user) return { user: null };
const { user } = await this.nbstoreService.realtime.request(
'user.profile.get',
{}
);
const { currentUser: user } = await this.gqlService.gql({
query: getCurrentUserQuery,
});
if (!user || user.id !== session.user.id) {
throw new Error('Realtime user profile does not match auth session');
throw new Error('User profile does not match auth session');
}
const authMethods = await this.fetchAuthMethods();
return { user: { ...user, authMethods } };
+8
View File
@@ -9278,6 +9278,14 @@ export function useAFFiNEI18N(): {
* `Failed to sign out devices`
*/
["com.affine.settings.devices.sign-out-all-failed"](): string;
/**
* `Real-time connection failed`
*/
["com.affine.realtime.connection-error.title"](): string;
/**
* `Check that your server proxy forwards /socket.io over WebSocket or HTTP polling.`
*/
["com.affine.realtime.connection-error.message"](): string;
/**
* `An internal error occurred.`
*/
@@ -2318,6 +2318,8 @@
"com.affine.settings.devices.load-failed": "Failed to load devices",
"com.affine.settings.devices.sign-out-failed": "Failed to sign out device",
"com.affine.settings.devices.sign-out-all-failed": "Failed to sign out devices",
"com.affine.realtime.connection-error.title": "Real-time connection failed",
"com.affine.realtime.connection-error.message": "Check that your server proxy forwards /socket.io over WebSocket or HTTP polling.",
"error.INTERNAL_SERVER_ERROR": "An internal error occurred.",
"error.NETWORK_ERROR": "Network error.",
"error.TOO_MANY_REQUEST": "Too many requests.",
@@ -2308,6 +2308,8 @@
"com.affine.docIconPicker.placeholder": "添加图标",
"error.INTERNAL_SERVER_ERROR": "发生内部错误。",
"error.NETWORK_ERROR": "网络错误。",
"com.affine.realtime.connection-error.title": "实时同步连接失败",
"com.affine.realtime.connection-error.message": "请检查服务器反向代理是否通过 WebSocket 或 HTTP polling 正确转发 /socket.io。",
"error.TOO_MANY_REQUEST": "请求过多。",
"error.SSRF_BLOCKED_ERROR": "无效的 URL",
"error.RESPONSE_TOO_LARGE_ERROR": "响应过大({{receivedBytes}} 字节),限制为 {{limitBytes}} 字节",