mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 10:06:17 +08:00
feat(server): passkey pre-refactor (#15060)
#### PR Dependency Tree * **PR #15060** 👈 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** * OpenApp native sign-in and native session exchange (JWT) for mobile & desktop. * Centralized short-lived auth challenge store for one-time tokens. * Encrypted per-endpoint token storage and native token handlers (Android, iOS, Electron). * **Improvements** * Richer auth-method reporting (password, magic link, OAuth, passkey) and improved sign-in flows. * Hardened magic-link, OAuth, and session issuance; JWT-backed sessions and websocket JWT support. * UX tweaks: form-based password submit, OTP autocomplete, adjusted captcha flow. * **Bug Fixes** * Expanded tests and auth-state resets to avoid cross-test leakage. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
import { net, session } from 'electron';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import {
|
||||
deleteNativeAuthToken,
|
||||
getNativeAuthToken,
|
||||
setNativeAuthToken,
|
||||
} from './native-token';
|
||||
|
||||
interface SignInResponse {
|
||||
exchangeCode?: string;
|
||||
redirectUri?: string;
|
||||
}
|
||||
|
||||
interface ExchangeResponse {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
const authCookieNames = [
|
||||
'affine_session',
|
||||
'affine_user_id',
|
||||
'affine_csrf_token',
|
||||
];
|
||||
|
||||
function authUrl(endpoint: string, path: string) {
|
||||
return new URL(path, endpoint).toString();
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text || response.statusText);
|
||||
}
|
||||
|
||||
return text ? JSON.parse(text) : ({} as T);
|
||||
}
|
||||
|
||||
async function fetchAuth(endpoint: string, path: string, body?: unknown) {
|
||||
return await net.fetch(authUrl(endpoint, path), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-affine-client-kind': 'native',
|
||||
'x-affine-version': BUILD_CONFIG.appVersion,
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function clearAuthCookies(endpoint: string) {
|
||||
await Promise.all(
|
||||
authCookieNames.map(name =>
|
||||
session.defaultSession.cookies
|
||||
.remove(endpoint, name)
|
||||
.catch(error =>
|
||||
logger.debug(
|
||||
'failed to clear native auth cookie',
|
||||
endpoint,
|
||||
name,
|
||||
error
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function exchangeSession(endpoint: string, response: SignInResponse) {
|
||||
if (!response.exchangeCode) {
|
||||
throw new Error('Missing native auth exchange code.');
|
||||
}
|
||||
|
||||
const exchangeResponse = await fetchAuth(
|
||||
endpoint,
|
||||
'/api/auth/native/exchange',
|
||||
{ code: response.exchangeCode }
|
||||
);
|
||||
const body = await readJson<ExchangeResponse>(exchangeResponse);
|
||||
if (!body.token) {
|
||||
throw new Error('Missing native auth token.');
|
||||
}
|
||||
|
||||
setNativeAuthToken(endpoint, body.token);
|
||||
await clearAuthCookies(endpoint);
|
||||
}
|
||||
|
||||
export const authHandlers = {
|
||||
signInMagicLink: async (
|
||||
_,
|
||||
endpoint: string,
|
||||
email: string,
|
||||
token: string,
|
||||
clientNonce?: string
|
||||
) => {
|
||||
const response = await fetchAuth(endpoint, '/api/auth/magic-link', {
|
||||
email,
|
||||
token,
|
||||
client_nonce: clientNonce,
|
||||
});
|
||||
await exchangeSession(endpoint, await readJson(response));
|
||||
},
|
||||
|
||||
signInOauth: async (
|
||||
_,
|
||||
endpoint: string,
|
||||
code: string,
|
||||
state: string,
|
||||
clientNonce?: string
|
||||
) => {
|
||||
const response = await fetchAuth(endpoint, '/api/oauth/callback', {
|
||||
code,
|
||||
state,
|
||||
client_nonce: clientNonce,
|
||||
});
|
||||
const body = await readJson<SignInResponse>(response);
|
||||
await exchangeSession(endpoint, body);
|
||||
return { redirectUri: body.redirectUri };
|
||||
},
|
||||
|
||||
signInPassword: async (
|
||||
_,
|
||||
endpoint: string,
|
||||
credential: {
|
||||
email: string;
|
||||
password: string;
|
||||
verifyToken?: string;
|
||||
challenge?: string;
|
||||
}
|
||||
) => {
|
||||
const response = await net.fetch(authUrl(endpoint, '/api/auth/sign-in'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-affine-client-kind': 'native',
|
||||
'x-affine-version': BUILD_CONFIG.appVersion,
|
||||
...(credential.verifyToken
|
||||
? { 'x-captcha-token': credential.verifyToken }
|
||||
: {}),
|
||||
...(credential.challenge
|
||||
? { 'x-captcha-challenge': credential.challenge }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: credential.email,
|
||||
password: credential.password,
|
||||
}),
|
||||
});
|
||||
await exchangeSession(endpoint, await readJson(response));
|
||||
},
|
||||
|
||||
signInOpenAppSignInCode: async (_e, endpoint: string, code: string) => {
|
||||
const response = await fetchAuth(endpoint, '/api/auth/open-app/sign-in', {
|
||||
code,
|
||||
});
|
||||
await exchangeSession(endpoint, await readJson(response));
|
||||
},
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteNativeAuthToken(endpoint);
|
||||
await clearAuthCookies(endpoint);
|
||||
},
|
||||
|
||||
readEndpointToken: async (_e, endpoint: string) => {
|
||||
return { token: getNativeAuthToken(endpoint) };
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
@@ -0,0 +1,83 @@
|
||||
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;
|
||||
};
|
||||
|
||||
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 store = readStore();
|
||||
store[normalizeEndpoint(endpoint)] = encryptToken({ token });
|
||||
writeStore(store);
|
||||
}
|
||||
|
||||
export function deleteNativeAuthToken(endpoint: string) {
|
||||
const store = readStore();
|
||||
delete store[normalizeEndpoint(endpoint)];
|
||||
writeStore(store);
|
||||
}
|
||||
|
||||
export function getNativeAuthToken(endpoint: string) {
|
||||
const encrypted = readStore()[normalizeEndpoint(endpoint)];
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { I18n } from '@affine/i18n';
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
import { AFFINE_API_CHANNEL_NAME } from '../shared/type';
|
||||
import { authHandlers } from './auth/handlers';
|
||||
import { byokStorageHandlers } from './byok-storage/handlers';
|
||||
import { clipboardHandlers } from './clipboard';
|
||||
import { configStorageHandlers } from './config-storage';
|
||||
@@ -44,6 +45,7 @@ export const allHandlers = {
|
||||
popup: popupHandlers,
|
||||
i18n: i18nHandlers,
|
||||
byokStorage: byokStorageHandlers,
|
||||
auth: authHandlers,
|
||||
};
|
||||
|
||||
export const registerHandlers = () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import path, { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { app, net, protocol, session } from 'electron';
|
||||
import cookieParser from 'set-cookie-parser';
|
||||
|
||||
import { anotherHost, mainHost } from '../shared/internal-origin';
|
||||
import {
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
resolvePathInBase,
|
||||
resourcesPath,
|
||||
} from '../shared/utils';
|
||||
import { getAuthTokenForUrl } from './auth/native-token';
|
||||
import { buildType, isDev } from './config';
|
||||
import { logger } from './logger';
|
||||
|
||||
@@ -64,7 +64,27 @@ function buildTargetUrl(base: string, urlObject: URL) {
|
||||
return new URL(`${urlObject.pathname}${urlObject.search}`, base).toString();
|
||||
}
|
||||
|
||||
function proxyRequest(
|
||||
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,
|
||||
base: string,
|
||||
@@ -72,12 +92,13 @@ function proxyRequest(
|
||||
) {
|
||||
const { bypassCustomProtocolHandlers = true } = options;
|
||||
const targetUrl = buildTargetUrl(base, urlObject);
|
||||
const authorizedRequest = await buildAuthorizedRequest(request, targetUrl);
|
||||
const proxiedRequest = bypassCustomProtocolHandlers
|
||||
? Object.assign(request.clone(), {
|
||||
? Object.assign(authorizedRequest, {
|
||||
bypassCustomProtocolHandlers: true,
|
||||
})
|
||||
: request;
|
||||
return net.fetch(targetUrl, proxiedRequest);
|
||||
: authorizedRequest;
|
||||
return net.fetch(proxiedRequest);
|
||||
}
|
||||
|
||||
async function handleFileRequest(request: Request) {
|
||||
@@ -218,41 +239,6 @@ export function registerProtocol() {
|
||||
const { responseHeaders, url } = responseDetails;
|
||||
(async () => {
|
||||
if (responseHeaders) {
|
||||
const originalCookie =
|
||||
responseHeaders['set-cookie'] || responseHeaders['Set-Cookie'];
|
||||
|
||||
if (originalCookie) {
|
||||
// save the cookies, to support third party cookies
|
||||
for (const cookies of originalCookie) {
|
||||
const parsedCookies = cookieParser.parse(cookies);
|
||||
for (const parsedCookie of parsedCookies) {
|
||||
if (!parsedCookie.value) {
|
||||
await session.defaultSession.cookies.remove(
|
||||
responseDetails.url,
|
||||
parsedCookie.name
|
||||
);
|
||||
} else {
|
||||
await session.defaultSession.cookies.set({
|
||||
url: responseDetails.url,
|
||||
domain: parsedCookie.domain,
|
||||
expirationDate: parsedCookie.expires?.getTime(),
|
||||
httpOnly: parsedCookie.httpOnly,
|
||||
secure: parsedCookie.secure,
|
||||
value: parsedCookie.value,
|
||||
name: parsedCookie.name,
|
||||
path: parsedCookie.path,
|
||||
sameSite: parsedCookie.sameSite?.toLowerCase() as
|
||||
| 'unspecified'
|
||||
| 'no_restriction'
|
||||
| 'lax'
|
||||
| 'strict'
|
||||
| undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { protocol, hostname } = new URL(url);
|
||||
|
||||
// Adjust CORS for assets responses and allow blob redirects on affine domains
|
||||
@@ -284,23 +270,17 @@ export function registerProtocol() {
|
||||
const url = new URL(details.url);
|
||||
|
||||
(async () => {
|
||||
// session cookies are set to assets:// on production
|
||||
// if sending request to the cloud, attach the session cookie (to affine cloud server)
|
||||
if (
|
||||
url.protocol === 'http:' ||
|
||||
url.protocol === 'https:' ||
|
||||
url.protocol === 'ws:' ||
|
||||
url.protocol === 'wss:'
|
||||
) {
|
||||
const cookies = await session.defaultSession.cookies.get({
|
||||
url: details.url,
|
||||
});
|
||||
|
||||
const cookieString = cookies
|
||||
.map(c => `${c.name}=${c.value}`)
|
||||
.join('; ');
|
||||
delete details.requestHeaders['cookie'];
|
||||
details.requestHeaders['Cookie'] = cookieString;
|
||||
const token = getAuthTokenForUrl(details.url);
|
||||
if (token) {
|
||||
delete details.requestHeaders.authorization;
|
||||
details.requestHeaders.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
const hostname = url.hostname;
|
||||
|
||||
Reference in New Issue
Block a user