Files
AFFiNE-Mirror/packages/frontend/apps/electron/test/main/auth-session.spec.ts
T
DarkSky abf37d3dfa 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 -->
2026-07-12 18:11:02 +08:00

312 lines
10 KiB
TypeScript

import path from 'node:path';
import { beforeEach, expect, test, vi } from 'vitest';
const sessionFile = path.join('/test-user-data', 'auth-sessions.json');
const temporarySessionFile = `${sessionFile}.tmp`;
const runtime = vi.hoisted(() => ({
encryptionAvailable: true,
backend: 'unknown',
failWrite: false,
files: new Map<string, string>(),
fetch: vi.fn(),
rename: vi.fn(),
}));
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async (file: string) => {
const value = runtime.files.get(file);
if (value === undefined) {
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
}
return value;
}),
writeFile: vi.fn(async (file: string, value: string) => {
if (runtime.failWrite) throw new Error('disk unavailable');
runtime.files.set(file, value);
}),
rename: vi.fn(async (source: string, target: string) => {
runtime.rename(source, target);
const value = runtime.files.get(source);
if (value !== undefined) runtime.files.set(target, value);
runtime.files.delete(source);
}),
rm: vi.fn(async (file: string) => {
runtime.files.delete(file);
}),
},
}));
vi.mock('electron', () => ({
app: { getPath: () => '/test-user-data' },
net: { fetch: runtime.fetch },
safeStorage: {
isEncryptionAvailable: () => runtime.encryptionAvailable,
getSelectedStorageBackend: () => runtime.backend,
encryptString: (value: string) => Buffer.from(value),
decryptString: (value: Buffer) => value.toString(),
},
}));
vi.mock('../../src/main/logger', () => ({
logger: { error: vi.fn() },
}));
import {
clearAuthSession,
executeAuthSessionRequest,
getValidAccessToken,
normalizeEndpoint,
revokeAuthSession,
setAuthSession,
} from '../../src/main/auth/auth-session';
beforeEach(() => {
runtime.encryptionAvailable = true;
runtime.backend = 'unknown';
runtime.failWrite = false;
runtime.fetch.mockReset();
runtime.rename.mockClear();
});
test.each([
['https://AFFINE.PRO/path?query=1', 'https://affine.pro'],
['https://affine.pro:443', 'https://affine.pro'],
['http://localhost:80/path', 'http://localhost'],
['http://localhost:8080/path', 'http://localhost:8080'],
])('normalizes auth endpoint %s', (endpoint, expected) => {
expect(normalizeEndpoint(endpoint)).toBe(expected);
});
test('atomically persists one encrypted token-pair record', async () => {
const endpoint = 'https://persistent.example';
const pair = tokenResponse('access-persistent', 'p', 900);
await expect(setAuthSession(endpoint, pair)).resolves.toEqual({
persistent: true,
});
expect(await getValidAccessToken(endpoint)).toBe('access-persistent');
expect(runtime.rename).toHaveBeenCalledWith(
temporarySessionFile,
sessionFile
);
const file = runtime.files.get(sessionFile) ?? '';
expect(file).not.toContain(pair.refreshToken);
});
test('keeps a session in main-process memory when safeStorage is unavailable', async () => {
const endpoint = 'https://session-only.example';
await setAuthSession(endpoint, tokenResponse('old-persistent', 'o', 900));
runtime.encryptionAvailable = false;
await expect(
setAuthSession(endpoint, tokenResponse('session-access', 's', 900))
).resolves.toEqual({ persistent: false });
expect(await getValidAccessToken(endpoint)).toBe('session-access');
expect(runtime.files.get(sessionFile)).not.toContain(endpoint);
runtime.encryptionAvailable = true;
});
test('rejects Linux basic_text persistence and removes an older disk session', async () => {
const platform = vi
.spyOn(process, 'platform', 'get')
.mockReturnValue('linux');
const endpoint = 'https://basic-text.example';
await setAuthSession(endpoint, tokenResponse('old', 'o', 900));
runtime.backend = 'basic_text';
await expect(
setAuthSession(endpoint, tokenResponse('memory', 'm', 900))
).resolves.toEqual({ persistent: false });
expect(await getValidAccessToken(endpoint)).toBe('memory');
expect(runtime.files.get(sessionFile)).not.toContain(endpoint);
platform.mockRestore();
});
test('shares one refresh across concurrent main-process callers', async () => {
const endpoint = 'https://refresh.example';
await setAuthSession(endpoint, tokenResponse('expiring', 'a', 1));
runtime.fetch.mockResolvedValueOnce(
new Response(JSON.stringify(tokenResponse('fresh', 'b', 900)))
);
const tokens = await Promise.all(
Array.from({ length: 50 }, () => getValidAccessToken(endpoint, 120_000))
);
expect(tokens.every(token => token === 'fresh')).toBe(true);
expect(runtime.fetch).toHaveBeenCalledTimes(1);
});
test('preserves credentials for unknown refresh errors', async () => {
const endpoint = 'https://unknown-error.example';
await setAuthSession(endpoint, tokenResponse('still-valid', 'u', 900));
runtime.fetch.mockImplementation(
async () =>
new Response(JSON.stringify({ code: 'UNKNOWN_SELF_HOSTED_ERROR' }), {
status: 401,
})
);
await expect(getValidAccessToken(endpoint, 1_000_000)).rejects.toMatchObject({
code: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE',
transient: true,
});
await expect(getValidAccessToken(endpoint, 0)).resolves.toBe('still-valid');
});
test('clears credentials only for an allowlisted permanent refresh error', async () => {
const endpoint = 'https://permanent-error.example';
await setAuthSession(endpoint, tokenResponse('expired', 'e', 1));
runtime.fetch.mockResolvedValueOnce(
new Response(JSON.stringify({ code: 'AUTH_SESSION_REVOKED' }), {
status: 401,
})
);
await expect(getValidAccessToken(endpoint, 120_000)).resolves.toBeNull();
await expect(getValidAccessToken(endpoint, 0)).resolves.toBeNull();
expect(runtime.files.get(sessionFile)).not.toContain(endpoint);
});
test('cleans a decrypted record that does not match the token-pair schema', async () => {
const endpoint = 'https://corrupt.example';
runtime.files.set(
sessionFile,
JSON.stringify({
[endpoint]: Buffer.from(JSON.stringify({ token: 'legacy' })).toString(
'base64'
),
})
);
await expect(getValidAccessToken(endpoint)).resolves.toBeNull();
expect(runtime.files.get(sessionFile)).not.toContain(endpoint);
});
test('recovers an unreadable session file on the next atomic write', async () => {
runtime.files.set(sessionFile, '{not-json');
const endpoint = 'https://file-corruption.example';
await expect(getValidAccessToken(endpoint)).resolves.toBeNull();
await setAuthSession(endpoint, tokenResponse('recovered', 'c', 900));
expect(JSON.parse(runtime.files.get(sessionFile)!)).toHaveProperty(endpoint);
await expect(getValidAccessToken(endpoint)).resolves.toBe('recovered');
});
test('does not publish a rotated pair until an atomic save succeeds', async () => {
const endpoint = 'https://save-failure.example';
await setAuthSession(endpoint, tokenResponse('old', 'a', 1));
runtime.fetch.mockResolvedValueOnce(
new Response(JSON.stringify(tokenResponse('rotated', 'b', 900)))
);
runtime.failWrite = true;
await expect(getValidAccessToken(endpoint, 120_000)).rejects.toMatchObject({
code: 'AUTH_TOKEN_STORAGE_UNAVAILABLE',
});
runtime.failWrite = false;
await expect(getValidAccessToken(endpoint, 120_000)).resolves.toBe('rotated');
expect(runtime.fetch).toHaveBeenCalledTimes(1);
});
test('main replaces renderer bearer and strips it after local clear', async () => {
const endpoint = 'https://owner.example';
await setAuthSession(endpoint, tokenResponse('main-token', 'm', 900));
const execute = vi.fn(async () => new Response('{}'));
await executeAuthSessionRequest(
new Request(`${endpoint}/graphql`, {
headers: { Authorization: 'Bearer renderer-stale' },
}),
`${endpoint}/graphql`,
execute
);
expect(execute.mock.calls[0]?.[0].headers.get('Authorization')).toBe(
'Bearer main-token'
);
await clearAuthSession(endpoint, 'test-clear');
await executeAuthSessionRequest(
new Request(`${endpoint}/graphql`, {
headers: { Authorization: 'Bearer renderer-stale' },
}),
`${endpoint}/graphql`,
execute
);
expect(execute.mock.calls[1]?.[0].headers.get('Authorization')).toBeNull();
});
test.each([
{
code: 'ACCESS_TOKEN_EXPIRED',
expectedRequests: 2,
method: 'POST',
},
{ code: 'FORBIDDEN', expectedRequests: 1, method: 'PUT' },
])('replays a protocol request once for $code only', async item => {
const endpoint = `https://${item.code.toLowerCase()}.example`;
await setAuthSession(endpoint, tokenResponse('initial', 'i', 900));
runtime.fetch.mockResolvedValueOnce(
new Response(JSON.stringify(tokenResponse('refreshed', 'f', 900)))
);
const execute = vi
.fn<(request: Request) => Promise<Response>>()
.mockResolvedValueOnce(
new Response(JSON.stringify({ code: item.code }), { status: 401 })
)
.mockResolvedValueOnce(new Response('{}'));
await executeAuthSessionRequest(
new Request(`${endpoint}/api/test`, {
method: item.method,
body: JSON.stringify({ value: item.code }),
duplex: 'half',
}),
`${endpoint}/api/test`,
execute
);
expect(execute).toHaveBeenCalledTimes(item.expectedRequests);
expect(runtime.fetch).toHaveBeenCalledTimes(
item.code === 'ACCESS_TOKEN_EXPIRED' ? 1 : 0
);
for (const [request] of execute.mock.calls) {
await expect(request.text()).resolves.toBe(
JSON.stringify({ value: item.code })
);
}
});
test('clears locally before revoking without exposing refresh token over IPC', async () => {
const endpoint = 'https://revoke.example';
const pair = tokenResponse('revoke-access', 'r', 900);
await setAuthSession(endpoint, pair);
runtime.fetch.mockResolvedValueOnce(new Response('{}'));
await revokeAuthSession(endpoint);
expect(await getValidAccessToken(endpoint)).toBeNull();
const request = runtime.fetch.mock.calls.at(-1)?.[1] as RequestInit;
expect(request.body).toBe(
JSON.stringify({ refreshToken: pair.refreshToken })
);
});
function tokenResponse(accessToken: string, seed: string, expiresIn: number) {
return {
tokenType: 'Bearer' as const,
accessToken,
expiresIn,
refreshToken: `aff_rt_v1.${seed.repeat(24)}.${seed.repeat(43)}`,
refreshExpiresAt: '2030-01-01T00:00:00.000Z',
session: {
id: '00000000-0000-4000-8000-000000000001',
absoluteExpiresAt: '2031-01-01T00:00:00.000Z',
},
};
}