mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-09 21:25:45 +08:00
feat(core): improve auth handling (#15271)
fix #15270 fix #15260 fix #15257 #### PR Dependency Tree * **PR #15271** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
+19
-6
@@ -56,7 +56,20 @@ private data class TokenPair(
|
||||
val json: String,
|
||||
)
|
||||
|
||||
private class AuthServerException(val code: String?, val status: Int) : Exception(code)
|
||||
private class AuthServerException(
|
||||
val code: String?,
|
||||
val status: Int,
|
||||
override val message: String,
|
||||
) : Exception(message)
|
||||
|
||||
private fun authServerException(status: Int, text: String): AuthServerException {
|
||||
val body = runCatching { JSONObject(text) }.getOrNull()
|
||||
val code = body?.optString("code")?.takeIf { it.isNotEmpty() }
|
||||
?: body?.optString("name")?.takeIf { it.isNotEmpty() }
|
||||
val message = body?.optString("message")?.takeIf { it.isNotEmpty() }
|
||||
?: "Authentication request failed with status $status"
|
||||
return AuthServerException(code, status, message)
|
||||
}
|
||||
|
||||
private val permanentAuthErrors = setOf(
|
||||
"ACCESS_TOKEN_INVALID", "AUTH_SESSION_EXPIRED", "AUTH_SESSION_REVOKED",
|
||||
@@ -183,8 +196,7 @@ internal class AuthSessionBroker(
|
||||
AuthHttp.client.newCall(request).executeAsync().use { response ->
|
||||
val text = response.body.string()
|
||||
if (response.code < 400) return text
|
||||
val code = runCatching { JSONObject(text).optString("code").ifEmpty { null } }.getOrNull()
|
||||
val error = AuthServerException(code, response.code)
|
||||
val error = authServerException(response.code, text)
|
||||
if (response.code < 500 || attempt == 2) throw error
|
||||
last = error
|
||||
}
|
||||
@@ -338,7 +350,7 @@ class AuthPlugin : Plugin() {
|
||||
.build()
|
||||
val exchangeCode = AuthHttp.client.newCall(request).executeAsync().use { response ->
|
||||
val text = response.body.string()
|
||||
if (response.code >= 400) throw IllegalStateException(text)
|
||||
if (response.code >= 400) throw authServerException(response.code, text)
|
||||
JSONObject(text).getString("exchangeCode")
|
||||
}
|
||||
val exchangeBody = JSONObject()
|
||||
@@ -353,7 +365,7 @@ class AuthPlugin : Plugin() {
|
||||
.build()
|
||||
val tokenResponse = AuthHttp.client.newCall(exchangeRequest).executeAsync().use { response ->
|
||||
val text = response.body.string()
|
||||
if (response.code >= 400) throw IllegalStateException(text)
|
||||
if (response.code >= 400) throw authServerException(response.code, text)
|
||||
text
|
||||
}
|
||||
broker.store(endpoint, tokenResponse)
|
||||
@@ -385,7 +397,8 @@ class AuthPlugin : Plugin() {
|
||||
?: "AUTH_SESSION_TEMPORARILY_UNAVAILABLE"
|
||||
else -> "AUTH_SESSION_TEMPORARILY_UNAVAILABLE"
|
||||
}
|
||||
call.reject("Auth operation failed", code, error)
|
||||
val message = if (error is AuthServerException) error.message else "Auth operation failed"
|
||||
call.reject(message, code, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ import {
|
||||
type AuthTokenResponse,
|
||||
classifyAuthError,
|
||||
} from '@affine/auth';
|
||||
import { app, net, safeStorage } from 'electron';
|
||||
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`;
|
||||
@@ -105,7 +106,7 @@ function storage(endpoint: string) {
|
||||
}
|
||||
|
||||
async function refresh(endpoint: string, refreshToken: string) {
|
||||
const response = await net.fetch(
|
||||
const response = await authFetch(
|
||||
new URL('/api/auth/session/refresh', endpoint).toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -192,7 +193,7 @@ export async function revokeAuthSession(endpoint: string) {
|
||||
await getAuthSessionBroker(normalized).revoke(
|
||||
'sign-out',
|
||||
async (refreshToken: string) => {
|
||||
const response = await net.fetch(
|
||||
const response = await authFetch(
|
||||
new URL('/api/auth/session/revoke', normalized).toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os from 'node:os';
|
||||
|
||||
import type { AuthTokenResponse } from '@affine/auth';
|
||||
import { net, session } from 'electron';
|
||||
import { session } from 'electron';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
revokeAuthSession,
|
||||
setAuthSession,
|
||||
} from './auth-session';
|
||||
import { authFetch, getAuthTransportSession } from './transport';
|
||||
|
||||
export interface SignInResponse {
|
||||
id?: string;
|
||||
@@ -47,14 +48,21 @@ function authUrl(endpoint: string, path: string) {
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text || response.statusText);
|
||||
let message = text || response.statusText;
|
||||
try {
|
||||
const error = JSON.parse(text);
|
||||
if (typeof error.message === 'string') {
|
||||
message = error.message;
|
||||
}
|
||||
} catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return text ? JSON.parse(text) : ({} as T);
|
||||
}
|
||||
|
||||
async function fetchAuth(endpoint: string, path: string, body?: unknown) {
|
||||
return await net.fetch(authUrl(endpoint, path), {
|
||||
return await authFetch(authUrl(endpoint, path), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
@@ -66,18 +74,21 @@ async function fetchAuth(endpoint: string, path: string, body?: unknown) {
|
||||
}
|
||||
|
||||
async function clearAuthCookies(endpoint: string) {
|
||||
const sessions = [session.defaultSession, getAuthTransportSession()];
|
||||
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
|
||||
sessions.flatMap(authSession =>
|
||||
authCookieNames.map(name =>
|
||||
authSession.cookies
|
||||
.remove(endpoint, name)
|
||||
.catch(error =>
|
||||
logger.debug(
|
||||
'failed to clear native auth cookie',
|
||||
endpoint,
|
||||
name,
|
||||
error
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -148,7 +159,7 @@ export const authHandlers = {
|
||||
challenge?: string;
|
||||
}
|
||||
) => {
|
||||
const response = await net.fetch(authUrl(endpoint, '/api/auth/sign-in'), {
|
||||
const response = await authFetch(authUrl(endpoint, '/api/auth/sign-in'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { session } from 'electron';
|
||||
|
||||
const AUTH_TRANSPORT_PARTITION = 'affine-auth-transport';
|
||||
|
||||
export function getAuthTransportSession() {
|
||||
return session.fromPartition(AUTH_TRANSPORT_PARTITION, { cache: false });
|
||||
}
|
||||
|
||||
export function authFetch(input: string | Request, init?: RequestInit) {
|
||||
return getAuthTransportSession().fetch(input, init);
|
||||
}
|
||||
@@ -18,6 +18,36 @@ if (isDev) {
|
||||
protocol = 'affine-dev';
|
||||
}
|
||||
|
||||
const authMethods = new Set(['magic-link', 'oauth', 'open-app-signin']);
|
||||
|
||||
function summarizeDeepLink(rawUrl: string) {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
const method = url.searchParams.get('method');
|
||||
const server = url.searchParams.get('server');
|
||||
let serverOrigin: string | undefined;
|
||||
try {
|
||||
serverOrigin = server ? new URL(server).origin : undefined;
|
||||
} catch {
|
||||
serverOrigin = undefined;
|
||||
}
|
||||
return {
|
||||
protocol: url.protocol,
|
||||
action: url.hostname,
|
||||
method: method && authMethods.has(method) ? method : undefined,
|
||||
serverOrigin,
|
||||
};
|
||||
} catch {
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
|
||||
function logDeepLinkFailure(rawUrl: string, error: unknown) {
|
||||
logger.error('failed to handle affine url', summarizeDeepLink(rawUrl), {
|
||||
error: error instanceof Error ? error.name : typeof error,
|
||||
});
|
||||
}
|
||||
|
||||
export function setupDeepLink(app: App) {
|
||||
if (process.defaultApp) {
|
||||
if (process.argv.length >= 2) {
|
||||
@@ -30,14 +60,14 @@ export function setupDeepLink(app: App) {
|
||||
}
|
||||
|
||||
app.on('open-url', (event, url) => {
|
||||
logger.log('open-url', url);
|
||||
logger.log('open-url', summarizeDeepLink(url));
|
||||
if (url.startsWith(`${protocol}://`)) {
|
||||
event.preventDefault();
|
||||
app
|
||||
.whenReady()
|
||||
.then(() => handleAffineUrl(url))
|
||||
.catch(e => {
|
||||
logger.error('failed to handle affine url', e);
|
||||
logDeepLinkFailure(url, e);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -55,7 +85,7 @@ export function setupDeepLink(app: App) {
|
||||
if (url?.startsWith(`${protocol}://`)) {
|
||||
event.preventDefault();
|
||||
handleAffineUrl(url).catch(e => {
|
||||
logger.error('failed to handle affine url', e);
|
||||
logDeepLinkFailure(url, e);
|
||||
});
|
||||
}
|
||||
})
|
||||
@@ -66,10 +96,15 @@ export function setupDeepLink(app: App) {
|
||||
// app may be brought up without having a running instance
|
||||
// need to read the url from the command line
|
||||
const url = process.argv.at(-1);
|
||||
logger.log('url from argv', process.argv, url);
|
||||
logger.log(
|
||||
'url from argv',
|
||||
url?.startsWith(`${protocol}://`)
|
||||
? summarizeDeepLink(url)
|
||||
: { deepLink: false, argumentCount: process.argv.length }
|
||||
);
|
||||
if (url?.startsWith(`${protocol}://`)) {
|
||||
handleAffineUrl(url).catch(e => {
|
||||
logger.error('failed to handle affine url', e);
|
||||
logDeepLinkFailure(url, e);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -78,7 +113,7 @@ export function setupDeepLink(app: App) {
|
||||
async function handleAffineUrl(url: string) {
|
||||
await showMainWindow();
|
||||
|
||||
logger.info('open affine url', url);
|
||||
logger.info('open affine url', summarizeDeepLink(url));
|
||||
const urlObj = new URL(url);
|
||||
|
||||
if (urlObj.hostname === 'authentication') {
|
||||
@@ -93,7 +128,7 @@ async function handleAffineUrl(url: string) {
|
||||
method !== 'open-app-signin') ||
|
||||
!payload
|
||||
) {
|
||||
logger.error('Invalid authentication url', url);
|
||||
logger.error('Invalid authentication url', summarizeDeepLink(url));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ export const registerHandlers = () => {
|
||||
return await handleIpcMessage(e, ...args);
|
||||
} catch (error) {
|
||||
logger.error(`error in ipc handler when calling ${args[0]}`, error);
|
||||
return null;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const runtime = vi.hoisted(() => ({
|
||||
failWrite: false,
|
||||
files: new Map<string, string>(),
|
||||
fetch: vi.fn(),
|
||||
fromPartition: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -41,7 +42,12 @@ vi.mock('node:fs/promises', () => ({
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => '/test-user-data' },
|
||||
net: { fetch: runtime.fetch },
|
||||
session: {
|
||||
fromPartition: (...args: unknown[]) => {
|
||||
runtime.fromPartition(...args);
|
||||
return { fetch: runtime.fetch };
|
||||
},
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => runtime.encryptionAvailable,
|
||||
getSelectedStorageBackend: () => runtime.backend,
|
||||
@@ -68,6 +74,7 @@ beforeEach(() => {
|
||||
runtime.backend = 'unknown';
|
||||
runtime.failWrite = false;
|
||||
runtime.fetch.mockReset();
|
||||
runtime.fromPartition.mockClear();
|
||||
runtime.rename.mockClear();
|
||||
});
|
||||
|
||||
@@ -138,6 +145,9 @@ test('shares one refresh across concurrent main-process callers', async () => {
|
||||
|
||||
expect(tokens.every(token => token === 'fresh')).toBe(true);
|
||||
expect(runtime.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.fromPartition).toHaveBeenCalledWith('affine-auth-transport', {
|
||||
cache: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves credentials for unknown refresh errors', async () => {
|
||||
|
||||
@@ -29,11 +29,16 @@ private struct StoredAuthTokenPair: Codable {
|
||||
|
||||
private struct AuthErrorResponse: Decodable {
|
||||
let code: String?
|
||||
let name: String?
|
||||
let message: String?
|
||||
}
|
||||
|
||||
private struct AuthServerError: Error {
|
||||
private struct AuthServerError: Error, CustomStringConvertible {
|
||||
let code: String?
|
||||
let statusCode: Int
|
||||
let message: String
|
||||
|
||||
var description: String { message }
|
||||
|
||||
var permanentlyInvalidatesSession: Bool {
|
||||
switch code {
|
||||
@@ -46,6 +51,14 @@ private struct AuthServerError: Error {
|
||||
}
|
||||
}
|
||||
|
||||
private func authServerError(_ data: Data, statusCode: Int) -> AuthServerError {
|
||||
let response = try? JSONDecoder().decode(AuthErrorResponse.self, from: data)
|
||||
return AuthServerError(
|
||||
code: response?.code ?? response?.name,
|
||||
statusCode: statusCode,
|
||||
message: response?.message ?? "Authentication request failed with status \(statusCode)")
|
||||
}
|
||||
|
||||
private struct AuthOperationCancelled: Error {}
|
||||
|
||||
private struct AuthRefreshOperation {
|
||||
@@ -63,7 +76,8 @@ private actor AuthSessionBroker {
|
||||
try write(endpoint, tokenPair(response))
|
||||
}
|
||||
|
||||
func validAccessToken(_ endpoint: String, minValidity: TimeInterval = 120) async throws -> String? {
|
||||
func validAccessToken(_ endpoint: String, minValidity: TimeInterval = 120) async throws -> String?
|
||||
{
|
||||
guard let pair = try read(endpoint) else { return nil }
|
||||
if pair.accessExpiresAt.timeIntervalSinceNow > minValidity {
|
||||
return pair.accessToken
|
||||
@@ -149,7 +163,9 @@ private actor AuthSessionBroker {
|
||||
session: response.session)
|
||||
}
|
||||
|
||||
private func request(_ endpoint: String, action: String, body: [String: String]) async throws -> Data {
|
||||
private func request(_ endpoint: String, action: String, body: [String: String]) async throws
|
||||
-> Data
|
||||
{
|
||||
guard let url = URL(string: "\(canonicalEndpoint(endpoint))\(action)") else {
|
||||
throw AuthError.invalidEndpoint
|
||||
}
|
||||
@@ -168,9 +184,7 @@ private actor AuthSessionBroker {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
if response.statusCode < 400 { return data }
|
||||
let error = AuthServerError(
|
||||
code: try? JSONDecoder().decode(AuthErrorResponse.self, from: data).code,
|
||||
statusCode: response.statusCode)
|
||||
let error = authServerError(data, statusCode: response.statusCode)
|
||||
guard response.statusCode >= 500, attempt < 2 else { throw error }
|
||||
} catch let error as AuthServerError {
|
||||
if error.statusCode < 500 || attempt == 2 { throw error }
|
||||
@@ -336,12 +350,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
], body: ["email": email, "token": token, "client_nonce": clientNonce])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
call.reject(textBody)
|
||||
} else {
|
||||
call.reject("Failed to sign in")
|
||||
}
|
||||
return
|
||||
throw authServerError(data, statusCode: response.statusCode)
|
||||
}
|
||||
|
||||
try await self.exchangeSession(endpoint, data)
|
||||
@@ -367,12 +376,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
], body: ["code": code, "state": state, "client_nonce": clientNonce])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
call.reject(textBody)
|
||||
} else {
|
||||
call.reject("Failed to sign in")
|
||||
}
|
||||
return
|
||||
throw authServerError(data, statusCode: response.statusCode)
|
||||
}
|
||||
|
||||
try await self.exchangeSession(endpoint, data)
|
||||
@@ -398,16 +402,12 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
"x-affine-client-kind": "native",
|
||||
"x-captcha-token": verifyToken,
|
||||
"x-captcha-challenge": challenge,
|
||||
"x-captcha-provider": verifyToken == nil ? nil : (challenge == nil ? "turnstile" : "hashcash"),
|
||||
"x-captcha-provider": verifyToken == nil
|
||||
? nil : (challenge == nil ? "turnstile" : "hashcash"),
|
||||
], body: ["email": email, "password": password])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
call.reject(textBody)
|
||||
} else {
|
||||
call.reject("Failed to sign in")
|
||||
}
|
||||
return
|
||||
throw authServerError(data, statusCode: response.statusCode)
|
||||
}
|
||||
|
||||
try await self.exchangeSession(endpoint, data)
|
||||
@@ -431,12 +431,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
], body: ["code": code])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
call.reject(textBody)
|
||||
} else {
|
||||
call.reject("Failed to sign in")
|
||||
}
|
||||
return
|
||||
throw authServerError(data, statusCode: response.statusCode)
|
||||
}
|
||||
|
||||
try await self.exchangeSession(endpoint, data)
|
||||
@@ -476,7 +471,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
endpoint, method: "POST", action: "/api/auth/session/exchange",
|
||||
headers: [
|
||||
"x-affine-client-kind": "native"
|
||||
], body: [
|
||||
],
|
||||
body: [
|
||||
"code": code,
|
||||
"installationId": self.installationId(),
|
||||
"platform": "ios",
|
||||
@@ -484,10 +480,11 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
throw AuthError.exchangeFailed
|
||||
throw authServerError(data, statusCode: response.statusCode)
|
||||
}
|
||||
|
||||
try await broker.store(endpoint, response: JSONDecoder().decode(AuthTokenResponse.self, from: data))
|
||||
try await broker.store(
|
||||
endpoint, response: JSONDecoder().decode(AuthTokenResponse.self, from: data))
|
||||
self.clearAuthCookies(endpoint)
|
||||
}
|
||||
|
||||
@@ -506,7 +503,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
let normalizedHost = host.lowercased()
|
||||
|
||||
HTTPCookieStorage.shared.cookies?.forEach { cookie in
|
||||
let domain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
let domain = cookie.domain.lowercased().trimmingCharacters(
|
||||
in: CharacterSet(charactersIn: "."))
|
||||
let domainMatches = normalizedHost == domain || normalizedHost.hasSuffix(".\(domain)")
|
||||
if domainMatches && authCookieNames.contains(cookie.name) {
|
||||
HTTPCookieStorage.shared.deleteCookie(cookie)
|
||||
|
||||
Reference in New Issue
Block a user