feat(core): improve login flow (#15219)

#### PR Dependency Tree


* **PR #15219** 👈

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**
* Added secure, automatic auth session token refresh and request replay
for expired-token responses across Android, iOS, and Electron.
* Updated sign-in flows to manage sessions without returning tokens to
the app layer.
* Added “Devices” management UI with sign out per device and sign out
all.
  * Enabled support for both Hashcash and Turnstile captcha providers.
* **Bug Fixes**
* Improved refresh de-duplication, inflight cancellation/clear behavior,
and recovery from corrupted/invalid sessions.
* **Tests**
* Expanded auth-session, refresh/revoke, and replay coverage (Electron
unit tests, Android instrumentation tests, iOS auth date parser tests).
* **Chores**
* Removed CAPTCHA site key from build-time configuration and adjusted CI
test execution.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-07-12 18:11:02 +08:00
committed by GitHub
parent 02b25e05d8
commit abf37d3dfa
57 changed files with 2919 additions and 789 deletions
@@ -10,13 +10,17 @@ export const Captcha = () => {
const hasCaptchaFeature = useLiveData(captchaService.needCaptcha$);
const isLoading = useLiveData(captchaService.isLoading$);
const verifyToken = useLiveData(captchaService.verifyToken$);
const provider = useLiveData(captchaService.provider$);
const turnstile = useLiveData(captchaService.turnstile$);
const error = useLiveData(captchaService.error$);
useEffect(() => {
captchaService.revalidate();
}, [captchaService]);
if (hasCaptchaFeature) captchaService.revalidate();
}, [captchaService, hasCaptchaFeature]);
const handleTurnstileSuccess = useCallback(
(token: string) => {
captchaService.challenge$.next(undefined);
captchaService.provider$.next('turnstile');
captchaService.verifyToken$.next(token);
},
[captchaService]
@@ -26,7 +30,11 @@ export const Captcha = () => {
return null;
}
if (isLoading) {
if (error) {
return <div className={style.captchaWrapper}>Verification unavailable</div>;
}
if (isLoading || !provider) {
return <div className={style.captchaWrapper}>Loading...</div>;
}
@@ -34,11 +42,18 @@ export const Captcha = () => {
return <div className={style.captchaWrapper}>Verified Client</div>;
}
if (provider !== 'turnstile' || !turnstile) {
return <div className={style.captchaWrapper}>Verification failed</div>;
}
return (
<Turnstile
className={style.captchaWrapper}
siteKey={BUILD_CONFIG.CAPTCHA_SITE_KEY || '1x00000000000000000000AA'}
siteKey={turnstile.siteKey}
options={{ action: turnstile.action }}
onSuccess={handleTurnstileSuccess}
onExpire={() => captchaService.verifyToken$.next(undefined)}
onError={() => captchaService.verifyToken$.next(undefined)}
/>
);
};
@@ -18,7 +18,11 @@ import { ArrowRightSmallIcon, CameraIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { AuthService, ServerService } from '../../../../modules/cloud';
import {
AuthService,
type DeviceAuthSession,
ServerService,
} from '../../../../modules/cloud';
import type { SettingState } from '../types';
import { AIUsagePanel } from './ai-usage-panel';
import { DeleteAccount } from './delete-account';
@@ -170,6 +174,114 @@ const StoragePanel = ({
);
};
const DevicesPanel = () => {
const t = useI18n();
const auth = useService(AuthService);
const [sessions, setSessions] = useState<DeviceAuthSession[]>([]);
const [loading, setLoading] = useState(true);
const reload = useCallback(async () => {
setLoading(true);
try {
setSessions(await auth.listDeviceSessions());
} catch (error) {
notify.error({
title: t['com.affine.settings.devices.load-failed'](),
message: String(error),
});
} finally {
setLoading(false);
}
}, [auth, t]);
useEffect(() => {
reload().catch(error => {
notify.error({
title: t['com.affine.settings.devices.load-failed'](),
message: String(error),
});
});
}, [reload, t]);
const revoke = useCallback(
async (session: DeviceAuthSession) => {
if (
!window.confirm(
t['com.affine.settings.devices.confirm']({
device: session.deviceName ?? session.platform,
})
)
) {
return;
}
try {
await auth.revokeDeviceSession(session.id, session.current);
if (!session.current) await reload();
} catch (error) {
notify.error({
title: t['com.affine.settings.devices.sign-out-failed'](),
message: String(error),
});
}
},
[auth, reload, t]
);
const revokeAll = useCallback(async () => {
if (!window.confirm(t['com.affine.settings.devices.confirm-all']())) return;
try {
await auth.revokeAllDeviceSessions();
} catch (error) {
notify.error({
title: t['com.affine.settings.devices.sign-out-all-failed'](),
message: String(error),
});
}
}, [auth, t]);
return (
<SettingRow
name={t['com.affine.settings.devices.title']()}
desc={t['com.affine.settings.devices.description']()}
spreadCol={false}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{loading ? (
<span>{t['com.affine.settings.devices.loading']()}</span>
) : null}
{sessions.map(session => (
<div
key={session.id}
style={{ display: 'flex', alignItems: 'center', gap: 12 }}
>
<div style={{ flex: 1 }}>
<div>
{session.deviceName ?? session.platform}
{session.current
? ` (${t['com.affine.settings.devices.current']()})`
: ''}
</div>
<div>
{session.platform}
{session.appVersion ? ` · ${session.appVersion}` : ''}
{` · ${t['com.affine.settings.devices.last-used']({ time: new Date(session.lastSeenAt).toLocaleString() })}`}
</div>
</div>
<Button onClick={() => void revoke(session)}>
{t['com.affine.settings.devices.sign-out']()}
</Button>
</div>
))}
{sessions.length > 1 ? (
<Button onClick={() => void revokeAll()}>
{t['com.affine.settings.devices.sign-out-all']()}
</Button>
) : null}
</div>
</SettingRow>
);
};
export const AccountSetting = ({
onChangeSettingState,
}: {
@@ -244,6 +356,7 @@ export const AccountSetting = ({
: t['com.affine.settings.password.action.set']()}
</Button>
</SettingRow>
<DevicesPanel />
<StoragePanel onChangeSettingState={onChangeSettingState} />
{serverFeatures?.copilot && (
<AIUsagePanel onChangeSettingState={onChangeSettingState} />
@@ -0,0 +1,95 @@
import { notify } from '@affine/component';
import {
AuthService,
type DeviceAuthSession,
} from '@affine/core/modules/cloud';
import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { SettingGroup } from '../group';
import { RowLayout } from '../row.layout';
export const DevicesGroup = () => {
const t = useI18n();
const auth = useService(AuthService);
const [sessions, setSessions] = useState<DeviceAuthSession[]>([]);
const reload = useCallback(() => {
void auth
.listDeviceSessions()
.then(setSessions)
.catch(error => {
notify.error({
title: t['com.affine.settings.devices.load-failed'](),
message: String(error),
});
});
}, [auth, t]);
useEffect(reload, [reload]);
const revoke = useCallback(
async (session: DeviceAuthSession) => {
if (
!window.confirm(
t['com.affine.settings.devices.confirm']({
device: session.deviceName ?? session.platform,
})
)
) {
return;
}
try {
await auth.revokeDeviceSession(session.id, session.current);
if (!session.current) reload();
} catch (error) {
notify.error({
title: t['com.affine.settings.devices.sign-out-failed'](),
message: String(error),
});
}
},
[auth, reload, t]
);
return (
<SettingGroup title={t['com.affine.settings.devices.title']()}>
{sessions.map(session => (
<RowLayout
key={session.id}
label={
<div>
<div>{`${session.deviceName ?? session.platform}${session.current ? ` (${t['com.affine.settings.devices.current']()})` : ''}`}</div>
<div>
{t['com.affine.settings.devices.last-used']({
time: new Date(session.lastSeenAt).toLocaleString(),
})}
</div>
</div>
}
onClick={() => void revoke(session)}
>
{t['com.affine.settings.devices.sign-out']()}
</RowLayout>
))}
{sessions.length > 1 ? (
<RowLayout
label={t['com.affine.settings.devices.sign-out-all']()}
onClick={() => {
if (
window.confirm(t['com.affine.settings.devices.confirm-all']())
) {
void auth.revokeAllDeviceSessions().catch(error => {
notify.error({
title: t['com.affine.settings.devices.sign-out-all-failed'](),
message: String(error),
});
});
}
}}
/>
) : null}
</SettingGroup>
);
};
@@ -9,6 +9,7 @@ import { useEffect } from 'react';
import { AboutGroup } from './about';
import { AppearanceGroup } from './appearance';
import { DevicesGroup } from './devices';
import { ExperimentalFeatureSetting } from './experimental';
import { OthersGroup } from './others';
import * as styles from './style.css';
@@ -26,6 +27,7 @@ const MobileSetting = () => {
<UserProfile />
<UserSubscription />
<UserUsage />
<DevicesGroup />
<AppearanceGroup />
<AboutGroup />
<ExperimentalFeatureSetting />
@@ -65,6 +65,9 @@ export function configureDefaultAuthProvider(framework: Framework) {
if (credential.verifyToken) {
headers['x-captcha-token'] = credential.verifyToken;
headers['x-captcha-provider'] = credential.challenge
? 'hashcash'
: 'turnstile';
}
if (credential.challenge) {
headers['x-captcha-challenge'] = credential.challenge;
@@ -72,7 +75,10 @@ export function configureDefaultAuthProvider(framework: Framework) {
const res = await fetchService.fetch('/api/auth/sign-in', {
method: 'POST',
body: JSON.stringify(credential),
body: JSON.stringify({
email: credential.email,
password: credential.password,
}),
headers: {
'content-type': 'application/json',
...headers,
@@ -94,6 +100,7 @@ export function configureDefaultAuthProvider(framework: Framework) {
headers: csrfToken ? { 'x-affine-csrf-token': csrfToken } : undefined,
});
},
async clearSession() {},
};
});
}
@@ -17,7 +17,7 @@ export {
getSelfHostedServerName,
} from './server-name';
export { AccessTokenService } from './services/access-token';
export { AuthService } from './services/auth';
export { AuthService, type DeviceAuthSession } from './services/auth';
export { CaptchaService } from './services/captcha';
export { DefaultServerService } from './services/default-server';
export { DocCreatedByUpdatedBySyncService } from './services/doc-created-by-updated-by-sync';
@@ -33,6 +33,8 @@ export interface AuthProvider {
signInOpenAppSignInCode(code: string): Promise<void>;
signOut(): Promise<void>;
clearSession(): Promise<void>;
}
export const AuthProvider = createIdentifier<AuthProvider>('AuthProvider');
@@ -19,6 +19,28 @@ import { assertSupportedServerVersion } from '../stores/server-config';
import type { FetchService } from './fetch';
import type { ServerService } from './server';
export interface DeviceAuthSession {
id: string;
installationId: string;
platform: 'ios' | 'android' | 'electron';
deviceName?: string | null;
appVersion?: string | null;
createdAt: string;
lastSeenAt: string;
idleExpiresAt: string;
absoluteExpiresAt: string;
current: boolean;
}
function csrfHeader(): Record<string, string> {
if (typeof document === 'undefined') return {};
const prefix = 'affine_csrf_token=';
const cookie = document.cookie
.split('; ')
.find(value => value.startsWith(prefix));
return cookie ? { 'x-affine-csrf-token': cookie.slice(prefix.length) } : {};
}
@OnEvent(ApplicationFocused, e => e.onApplicationFocused)
@OnEvent(ServerStarted, e => e.onServerStarted)
export class AuthService extends Service {
@@ -262,6 +284,42 @@ export class AuthService extends Service {
this.session.revalidate();
}
async listDeviceSessions() {
const response = await this.fetchService.fetch('/api/auth/sessions');
return (await response.json()) as DeviceAuthSession[];
}
async revokeDeviceSession(id: string, current: boolean) {
await this.fetchService.fetch(
`/api/auth/sessions/${encodeURIComponent(id)}`,
{
method: 'DELETE',
headers: csrfHeader(),
}
);
if (current) {
try {
await this.store.clearSession();
} finally {
this.store.setCachedAuthSession(null);
this.session.revalidate();
}
}
}
async revokeAllDeviceSessions() {
await this.fetchService.fetch('/api/auth/sessions/revoke-all', {
method: 'POST',
headers: csrfHeader(),
});
try {
await this.store.clearSession();
} finally {
this.store.setCachedAuthSession(null);
this.session.revalidate();
}
}
async deleteAccount() {
const res = await this.store.deleteAccount();
this.store.setCachedAuthSession(null);
@@ -277,6 +335,7 @@ export class AuthService extends Service {
captchaHeaders(token: string, challenge?: string) {
const headers: Record<string, string> = {
'x-captcha-token': token,
'x-captcha-provider': challenge ? 'hashcash' : 'turnstile',
};
if (challenge) {
@@ -0,0 +1,78 @@
import { Framework, LiveData } from '@toeverything/infra';
import { describe, expect, test, vi } from 'vitest';
import { ValidatorProvider } from '../provider/validator';
import { CaptchaService } from './captcha';
import { FetchService } from './fetch';
import { ServerService } from './server';
function createService(
response: Record<string, unknown>,
validate?: (challenge: string, resource: string) => Promise<string>
) {
const fetch = vi.fn(
async () =>
new Response(JSON.stringify(response), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
const framework = new Framework();
framework.service(ServerService, {
server: { features$: new LiveData({ captcha: true }) },
} as any);
framework.service(FetchService, { fetch } as any);
if (validate) framework.impl(ValidatorProvider, { validate });
framework.service(CaptchaService, f => {
return new CaptchaService(
f.get(ServerService),
f.get(FetchService),
f.getOptional(ValidatorProvider)
);
});
const service = framework.provider().get(CaptchaService);
return { fetch, service };
}
describe('CaptchaService', () => {
test('mints a Hashcash proof from the server challenge', async () => {
const validate = vi.fn(async () => 'hashcash-proof');
const { fetch, service } = createService(
{
provider: 'hashcash',
challenge: 'challenge-id',
resource: 'resource-id',
},
validate
);
service.revalidate();
await vi.waitFor(() => expect(service.isLoading$.value).toBe(false));
expect(fetch).toHaveBeenCalledWith('/api/auth/captcha', {
signal: expect.any(AbortSignal),
});
expect(validate).toHaveBeenCalledWith('challenge-id', 'resource-id');
expect(service.provider$.value).toBe('hashcash');
expect(service.challenge$.value).toBe('challenge-id');
expect(service.verifyToken$.value).toBe('hashcash-proof');
});
test('uses server-provided Turnstile configuration without Hashcash', async () => {
const { service } = createService({
provider: 'turnstile',
siteKey: 'site-key',
action: 'auth-sign-in',
});
service.revalidate();
await vi.waitFor(() => expect(service.isLoading$.value).toBe(false));
expect(service.provider$.value).toBe('turnstile');
expect(service.turnstile$.value).toEqual({
siteKey: 'site-key',
action: 'auth-sign-in',
});
expect(service.verifyToken$.value).toBeUndefined();
});
});
@@ -18,6 +18,10 @@ export class CaptchaService extends Service {
r => r?.captcha || false
);
challenge$ = new LiveData<string | undefined>(undefined);
provider$ = new LiveData<'hashcash' | 'turnstile' | undefined>(undefined);
turnstile$ = new LiveData<{ siteKey: string; action: string } | undefined>(
undefined
);
isLoading$ = new LiveData(false);
verifyToken$ = new LiveData<string | undefined>(undefined);
error$ = new LiveData<any | undefined>(undefined);
@@ -33,36 +37,59 @@ export class CaptchaService extends Service {
revalidate = effect(
exhaustMap(() => {
return fromPromise(async signal => {
if (!this.needCaptcha$.value || !this.validatorProvider) {
if (!this.needCaptcha$.value) {
return {};
}
const res = await this.fetchService.fetch('/api/auth/challenge', {
const res = await this.fetchService.fetch('/api/auth/captcha', {
signal,
});
const data = (await res.json()) as {
challenge: string;
resource: string;
provider: 'hashcash' | 'turnstile';
challenge?: string;
resource?: string;
siteKey?: string;
action?: string;
};
if (!data || !data.challenge || !data.resource) {
throw new Error('Invalid challenge');
if (data.provider === 'turnstile') {
if (!data.siteKey || !data.action) {
throw new Error('Invalid Turnstile configuration');
}
return {
provider: data.provider,
turnstile: { siteKey: data.siteKey, action: data.action },
};
}
if (
data.provider !== 'hashcash' ||
!data.challenge ||
!data.resource ||
!this.validatorProvider
) {
throw new Error('Invalid Hashcash challenge');
}
const token = await this.validatorProvider.validate(
data.challenge,
data.resource
);
return {
provider: data.provider,
token,
challenge: data.challenge,
};
}).pipe(
tap(({ challenge, token }) => {
tap(({ challenge, provider, token, turnstile }) => {
this.provider$.next(provider);
this.turnstile$.next(turnstile);
this.verifyToken$.next(token);
this.challenge$.next(challenge);
this.resetAfter5min();
if (token) this.resetAfter5min();
}),
catchErrorInto(this.error$),
onStart(() => {
this.error$.next(undefined);
this.challenge$.next(undefined);
this.provider$.next(undefined);
this.turnstile$.next(undefined);
this.verifyToken$.next(undefined);
this.isLoading$.next(true);
}),
@@ -81,6 +108,8 @@ export class CaptchaService extends Service {
}).pipe(
tap(_ => {
this.challenge$.next(undefined);
this.provider$.next(undefined);
this.turnstile$.next(undefined);
this.verifyToken$.next(undefined);
this.isLoading$.next(false);
})
@@ -59,6 +59,7 @@ export class FetchService extends Service {
headers: {
...init?.headers,
'x-affine-version': BUILD_CONFIG.appVersion,
'x-affine-client-kind': BUILD_CONFIG.isNative ? 'native' : 'web',
},
}
);
@@ -137,7 +137,22 @@ export class AuthStore extends Store {
}
async signOut() {
await this.authProvider.signOut();
try {
await this.authProvider.signOut();
} finally {
await this.deauthenticateRealtime();
}
}
async clearSession() {
try {
await this.authProvider.clearSession();
} finally {
await this.deauthenticateRealtime();
}
}
private async deauthenticateRealtime() {
await this.nbstoreService.realtime.configure({
endpoint: this.serverService.server.baseUrl,
authenticated: false,