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
@@ -0,0 +1,293 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import {
AuthTokenBroker,
type AuthTokenPair,
type AuthTokenResponse,
classifyAuthError,
} from '@affine/auth';
import { app, net, safeStorage } from 'electron';
import { logger } from '../logger';
const FILEPATH = path.join(app.getPath('userData'), 'auth-sessions.json');
const TEMP_FILEPATH = `${FILEPATH}.tmp`;
const INSTALLATION_FILEPATH = path.join(
app.getPath('userData'),
'installation-id'
);
const brokers = new Map<string, AuthTokenBroker>();
const memoryStore = new Map<string, AuthTokenPair>();
let fileMutation = Promise.resolve();
let installationId: Promise<string> | undefined;
const AUTH_REQUEST_TIMEOUT = 10_000;
function secureStorageAvailable() {
return (
safeStorage.isEncryptionAvailable() &&
(process.platform !== 'linux' ||
safeStorage.getSelectedStorageBackend() !== 'basic_text')
);
}
export function normalizeEndpoint(endpoint: string) {
return new URL(endpoint).origin;
}
async function readStore(): Promise<Record<string, string>> {
try {
const value = JSON.parse(await fs.readFile(FILEPATH, 'utf8'));
return value && typeof value === 'object' && !Array.isArray(value)
? value
: {};
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error('failed to read auth session store', error);
}
return {};
}
}
async function mutateFile(mutator: (store: Record<string, string>) => void) {
const operation = fileMutation.then(async () => {
const store = await readStore();
mutator(store);
await fs.writeFile(TEMP_FILEPATH, JSON.stringify(store), { mode: 0o600 });
await fs.rename(TEMP_FILEPATH, FILEPATH);
});
fileMutation = operation.catch(() => {});
return await operation;
}
function encrypt(pair: AuthTokenPair) {
return safeStorage.encryptString(JSON.stringify(pair)).toString('base64');
}
function decrypt(value: string): AuthTokenPair | null {
try {
return JSON.parse(safeStorage.decryptString(Buffer.from(value, 'base64')));
} catch (error) {
logger.error('failed to decrypt auth session', error);
return null;
}
}
function storage(endpoint: string) {
return {
async load() {
if (!secureStorageAvailable()) {
return memoryStore.get(endpoint) ?? null;
}
const encrypted = (await readStore())[endpoint];
if (!encrypted) return null;
const pair = decrypt(encrypted);
if (!pair) await mutateFile(store => delete store[endpoint]);
return pair;
},
async save(pair: AuthTokenPair) {
if (!secureStorageAvailable()) {
await mutateFile(store => delete store[endpoint]);
memoryStore.set(endpoint, pair);
return;
}
await mutateFile(store => {
store[endpoint] = encrypt(pair);
});
memoryStore.delete(endpoint);
},
async clear() {
memoryStore.delete(endpoint);
await mutateFile(store => delete store[endpoint]);
},
};
}
async function refresh(endpoint: string, refreshToken: string) {
const response = await net.fetch(
new URL('/api/auth/session/refresh', endpoint).toString(),
{
method: 'POST',
headers: {
'content-type': 'application/json',
'x-affine-client-kind': 'native',
'x-affine-version': BUILD_CONFIG.appVersion,
},
body: JSON.stringify({ refreshToken }),
signal: AbortSignal.timeout(AUTH_REQUEST_TIMEOUT),
}
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw classifyAuthError({
code:
typeof body === 'object' && body && 'code' in body
? String(body.code)
: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE',
});
}
return body as AuthTokenResponse;
}
export function getAuthSessionBroker(endpoint: string) {
const normalized = normalizeEndpoint(endpoint);
let broker = brokers.get(normalized);
if (!broker) {
broker = new AuthTokenBroker(storage(normalized), {
refresh: (token: string) => refresh(normalized, token),
});
brokers.set(normalized, broker);
}
return broker;
}
export function isManagedAuthEndpoint(url: string) {
try {
const parsed = new URL(url);
if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
return brokers.has(parsed.origin);
} catch {
return false;
}
}
export async function setAuthSession(
endpoint: string,
response: AuthTokenResponse
) {
await getAuthSessionBroker(endpoint).set(response);
return { persistent: secureStorageAvailable() };
}
export function getInstallationId() {
if (installationId) return installationId;
const pending = fs
.readFile(INSTALLATION_FILEPATH, 'utf8')
.then(value => {
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value
)
) {
throw new Error('Invalid installation id');
}
return value;
})
.catch(async () => {
const value = randomUUID();
await fs.writeFile(INSTALLATION_FILEPATH, value, { mode: 0o600 });
return value;
});
installationId = pending;
void pending.catch(() => {
if (installationId === pending) installationId = undefined;
});
return pending;
}
export async function revokeAuthSession(endpoint: string) {
const normalized = normalizeEndpoint(endpoint);
await getAuthSessionBroker(normalized).revoke(
'sign-out',
async (refreshToken: string) => {
const response = await net.fetch(
new URL('/api/auth/session/revoke', normalized).toString(),
{
method: 'POST',
headers: {
'content-type': 'application/json',
'x-affine-client-kind': 'native',
'x-affine-version': BUILD_CONFIG.appVersion,
},
body: JSON.stringify({ refreshToken }),
signal: AbortSignal.timeout(AUTH_REQUEST_TIMEOUT),
}
);
if (!response.ok) throw new Error('Failed to revoke auth session');
}
);
}
export async function clearAuthSession(endpoint: string, reason: string) {
await getAuthSessionBroker(endpoint).clear(reason);
}
export async function getValidAccessToken(
endpoint: string,
minValidity = 60_000
) {
try {
return await getAuthSessionBroker(endpoint).getValidAccessToken(
minValidity
);
} catch (error) {
const classified = classifyAuthError(error);
if (classified.code === 'AUTH_SESSION_EMPTY' || !classified.transient) {
return null;
}
throw classified;
}
}
export async function getAccessTokenForUrl(url: string, minValidity = 60_000) {
try {
const parsed = new URL(url);
if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
return await getValidAccessToken(parsed.origin, minValidity);
} catch (error) {
if (error instanceof TypeError) return null;
throw error;
}
}
export async function refreshAccessTokenForUrl(url: string) {
const parsed = new URL(url);
if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
return (
await getAuthSessionBroker(parsed.origin).refresh('access-token-expired')
).accessToken;
}
async function authorizedRequest(
request: Request,
targetUrl: string,
accessToken?: string
) {
const cloned = request.clone();
const headers = new Headers(cloned.headers);
headers.delete('Authorization');
const token = accessToken ?? (await getAccessTokenForUrl(targetUrl));
if (token) headers.set('Authorization', `Bearer ${token}`);
return new Request(targetUrl, {
body:
cloned.method === 'GET' || cloned.method === 'HEAD'
? undefined
: cloned.body,
headers,
method: cloned.method,
redirect: cloned.redirect,
signal: cloned.signal,
duplex: 'half',
});
}
export async function executeAuthSessionRequest(
request: Request,
targetUrl: string,
execute: (request: Request) => Promise<Response>
) {
const retry = request.clone();
const response = await execute(await authorizedRequest(request, targetUrl));
if (response.status !== 401) return response;
const body = (await response
.clone()
.json()
.catch(() => null)) as { code?: string } | null;
if (body?.code !== 'ACCESS_TOKEN_EXPIRED') return response;
const token = await refreshAccessTokenForUrl(targetUrl);
return await execute(await authorizedRequest(retry, targetUrl, token));
}
@@ -1,12 +1,17 @@
import os from 'node:os';
import type { AuthTokenResponse } from '@affine/auth';
import { net, session } from 'electron';
import { logger } from '../logger';
import type { NamespaceHandlers } from '../type';
import {
deleteNativeAuthToken,
getNativeAuthToken,
setNativeAuthToken,
} from './native-token';
clearAuthSession,
getInstallationId,
getValidAccessToken,
revokeAuthSession,
setAuthSession,
} from './auth-session';
export interface SignInResponse {
id?: string;
@@ -29,10 +34,6 @@ export interface PasswordSignInResponse extends SignInResponse {
sessionOnly?: boolean;
}
interface ExchangeResponse {
token?: string;
}
const authCookieNames = [
'affine_session',
'affine_user_id',
@@ -88,15 +89,16 @@ async function exchangeSession(endpoint: string, response: SignInResponse) {
const exchangeResponse = await fetchAuth(
endpoint,
'/api/auth/native/exchange',
{ code: response.exchangeCode }
'/api/auth/session/exchange',
{
code: response.exchangeCode,
installationId: await getInstallationId(),
platform: 'electron',
deviceName: os.hostname(),
}
);
const body = await readJson<ExchangeResponse>(exchangeResponse);
if (!body.token) {
throw new Error('Missing native auth token.');
}
const persistent = setNativeAuthToken(endpoint, body.token);
const body = await readJson<AuthTokenResponse>(exchangeResponse);
const { persistent } = await setAuthSession(endpoint, body);
await clearAuthCookies(endpoint);
return { persistent };
}
@@ -155,6 +157,13 @@ export const authHandlers = {
...(credential.verifyToken
? { 'x-captcha-token': credential.verifyToken }
: {}),
...(credential.verifyToken
? {
'x-captcha-provider': credential.challenge
? 'hashcash'
: 'turnstile',
}
: {}),
...(credential.challenge
? { 'x-captcha-challenge': credential.challenge }
: {}),
@@ -181,22 +190,18 @@ export const authHandlers = {
},
signOut: async (_e, endpoint: string) => {
const token = getNativeAuthToken(endpoint);
if (token) {
await net.fetch(authUrl(endpoint, '/api/auth/sign-out'), {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'x-affine-version': BUILD_CONFIG.appVersion,
},
});
try {
await revokeAuthSession(endpoint);
} finally {
await clearAuthCookies(endpoint);
}
deleteNativeAuthToken(endpoint);
await clearAuthCookies(endpoint);
},
readEndpointToken: async (_e, endpoint: string) => {
return { token: getNativeAuthToken(endpoint) };
clearSession: async (_e, endpoint: string) => {
await clearAuthSession(endpoint, 'local-clear');
},
getValidAccessToken: async (_e, endpoint: string) => {
return { token: await getValidAccessToken(endpoint, 120_000) };
},
} satisfies NamespaceHandlers;
@@ -1,100 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { app, safeStorage } from 'electron';
import { logger } from '../logger';
const FILEPATH = path.join(app.getPath('userData'), 'native-auth-tokens.json');
type TokenRecord = {
token: string;
};
// safeStorage may not be available in some environments (e.g. Linux without a keyring), so we fall back to an in-memory store in that case
const memoryTokenStore: Record<string, string> = {};
function normalizeEndpoint(endpoint: string) {
return new URL(endpoint).origin;
}
function readStore(): Record<string, string> {
if (!fs.existsSync(FILEPATH)) return {};
try {
return JSON.parse(fs.readFileSync(FILEPATH, 'utf-8'));
} catch (error) {
logger.error('failed to read native auth token store', error);
return {};
}
}
function writeStore(store: Record<string, string>) {
fs.writeFileSync(FILEPATH, JSON.stringify(store, null, 2));
}
function encryptToken(record: TokenRecord) {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error('Secure native auth token storage is not available.');
}
return safeStorage.encryptString(JSON.stringify(record)).toString('base64');
}
function decryptToken(value: string): TokenRecord | null {
if (!safeStorage.isEncryptionAvailable()) {
return null;
}
try {
return JSON.parse(safeStorage.decryptString(Buffer.from(value, 'base64')));
} catch (error) {
logger.error('failed to decrypt native auth token', error);
return null;
}
}
export function setNativeAuthToken(endpoint: string, token: string) {
const normalizedEndpoint = normalizeEndpoint(endpoint);
if (!safeStorage.isEncryptionAvailable()) {
memoryTokenStore[normalizedEndpoint] = token;
return false;
}
const store = readStore();
store[normalizedEndpoint] = encryptToken({ token });
writeStore(store);
return true;
}
export function deleteNativeAuthToken(endpoint: string) {
const normalizedEndpoint = normalizeEndpoint(endpoint);
delete memoryTokenStore[normalizedEndpoint];
const store = readStore();
delete store[normalizedEndpoint];
writeStore(store);
}
export function getNativeAuthToken(endpoint: string) {
const normalizedEndpoint = normalizeEndpoint(endpoint);
const memoryToken = memoryTokenStore[normalizedEndpoint];
if (memoryToken) return memoryToken;
const encrypted = readStore()[normalizedEndpoint];
if (!encrypted) return null;
return decryptToken(encrypted)?.token ?? null;
}
export function getAuthTokenForUrl(url: string) {
try {
const parsed = new URL(url);
if (parsed.protocol === 'ws:') {
parsed.protocol = 'http:';
} else if (parsed.protocol === 'wss:') {
parsed.protocol = 'https:';
}
return getNativeAuthToken(parsed.origin);
} catch {
return null;
}
}
@@ -11,7 +11,11 @@ import {
resolvePathInBase,
resourcesPath,
} from '../shared/utils';
import { getAuthTokenForUrl } from './auth/native-token';
import {
executeAuthSessionRequest,
getAccessTokenForUrl,
isManagedAuthEndpoint,
} from './auth/auth-session';
import { buildType, isDev } from './config';
import { logger } from './logger';
@@ -64,26 +68,6 @@ function buildTargetUrl(base: string, urlObject: URL) {
return new URL(`${urlObject.pathname}${urlObject.search}`, base).toString();
}
async function buildAuthorizedRequest(request: Request, targetUrl: string) {
const clonedRequest = request.clone();
const headers = new Headers(clonedRequest.headers);
const token = getAuthTokenForUrl(targetUrl);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return new Request(targetUrl, {
body:
clonedRequest.method === 'GET' || clonedRequest.method === 'HEAD'
? undefined
: clonedRequest.body,
headers,
method: clonedRequest.method,
redirect: clonedRequest.redirect,
signal: clonedRequest.signal,
});
}
async function proxyRequest(
request: Request,
urlObject: URL,
@@ -92,13 +76,13 @@ async function proxyRequest(
) {
const { bypassCustomProtocolHandlers = true } = options;
const targetUrl = buildTargetUrl(base, urlObject);
const authorizedRequest = await buildAuthorizedRequest(request, targetUrl);
const proxiedRequest = bypassCustomProtocolHandlers
? Object.assign(authorizedRequest, {
bypassCustomProtocolHandlers: true,
})
: authorizedRequest;
return net.fetch(proxiedRequest);
return await executeAuthSessionRequest(request, targetUrl, request =>
net.fetch(
bypassCustomProtocolHandlers
? Object.assign(request, { bypassCustomProtocolHandlers: true })
: request
)
);
}
async function handleFileRequest(request: Request) {
@@ -268,17 +252,20 @@ export function registerProtocol() {
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
const url = new URL(details.url);
(async () => {
if (
url.protocol === 'http:' ||
const managedAuthRequest =
(url.protocol === 'http:' ||
url.protocol === 'https:' ||
url.protocol === 'ws:' ||
url.protocol === 'wss:'
) {
const token = getAuthTokenForUrl(details.url);
url.protocol === 'wss:') &&
isManagedAuthEndpoint(details.url);
let cancel = false;
(async () => {
if (managedAuthRequest) {
delete details.requestHeaders.authorization;
delete details.requestHeaders.Authorization;
const token = await getAccessTokenForUrl(details.url, 120_000);
if (token) {
delete details.requestHeaders.authorization;
details.requestHeaders.Authorization = `Bearer ${token}`;
}
}
@@ -292,11 +279,12 @@ export function registerProtocol() {
}
})()
.catch(err => {
cancel = managedAuthRequest;
logger.error('error handling before send headers', err);
})
.finally(() => {
callback({
cancel: false,
cancel,
requestHeaders: details.requestHeaders,
});
});