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
@@ -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 } };