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
@@ -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,