Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
305 lines
8.7 KiB
TypeScript
305 lines
8.7 KiB
TypeScript
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, safeStorage } from 'electron';
|
|
|
|
import { logger } from '../logger';
|
|
import { authFetch } from './transport';
|
|
|
|
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 authFetch(
|
|
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 async function initializeAuthSessions() {
|
|
for (const endpoint of Object.keys(await readStore())) {
|
|
try {
|
|
getAuthSessionBroker(endpoint);
|
|
} catch (error) {
|
|
logger.error('failed to initialize auth session', endpoint, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 authFetch(
|
|
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));
|
|
}
|