From d24c17f300f7fe87cd9269ea321dd18555aaf508 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Sat, 18 Jul 2026 06:27:01 +0800 Subject: [PATCH] feat(core): improve auth handling (#15271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix #15270 fix #15260 fix #15257 #### PR Dependency Tree * **PR #15271** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) --- .docker/selfhost/schema.json | 11 ++- .../src/__tests__/oauth/controller.spec.ts | 41 +++++++++-- packages/backend/server/src/base/error/def.ts | 14 +++- .../server/src/plugins/oauth/config.ts | 23 ++++++- .../server/src/plugins/oauth/providers/def.ts | 17 +++-- .../src/plugins/oauth/providers/oidc.ts | 43 +++++++++--- packages/frontend/admin/src/config.json | 2 +- .../java/app/affine/pro/plugin/AuthPlugin.kt | 25 +++++-- .../electron/src/main/auth/auth-session.ts | 7 +- .../apps/electron/src/main/auth/handlers.ts | 39 +++++++---- .../apps/electron/src/main/auth/transport.ts | 11 +++ .../apps/electron/src/main/deep-link.ts | 49 +++++++++++-- .../apps/electron/src/main/handlers.ts | 2 +- .../electron/test/main/auth-session.spec.ts | 12 +++- .../ios/App/App/Plugins/Auth/AuthPlugin.swift | 68 +++++++++---------- packages/frontend/i18n/src/i18n.gen.ts | 6 +- packages/frontend/i18n/src/resources/en.json | 2 +- 17 files changed, 273 insertions(+), 99 deletions(-) create mode 100644 packages/frontend/apps/electron/src/main/auth/transport.ts diff --git a/.docker/selfhost/schema.json b/.docker/selfhost/schema.json index 936926fa2e..b93fee188e 100644 --- a/.docker/selfhost/schema.json +++ b/.docker/selfhost/schema.json @@ -1612,7 +1612,7 @@ }, "providers.oidc": { "type": "object", - "description": "OIDC OAuth provider config\n@default {\"clientId\":\"\",\"clientSecret\":\"\",\"issuer\":\"\",\"args\":{}}\n@link https://openid.net/specs/openid-connect-core-1_0.html", + "description": "OIDC OAuth provider config. Private network access requires allowPrivateNetwork: true\n@default {\"clientId\":\"\",\"clientSecret\":\"\",\"issuer\":\"\",\"allowPrivateNetwork\":false,\"args\":{}}\n@link https://openid.net/specs/openid-connect-core-1_0.html", "properties": { "clientId": { "type": "string" @@ -1622,12 +1622,21 @@ }, "args": { "type": "object" + }, + "issuer": { + "type": "string", + "description": "OIDC issuer URL" + }, + "allowPrivateNetwork": { + "type": "boolean", + "description": "Allow the OIDC issuer origin to resolve to private network addresses" } }, "default": { "clientId": "", "clientSecret": "", "issuer": "", + "allowPrivateNetwork": false, "args": {} } }, diff --git a/packages/backend/server/src/__tests__/oauth/controller.spec.ts b/packages/backend/server/src/__tests__/oauth/controller.spec.ts index 00aa61bd99..8735a16eb7 100644 --- a/packages/backend/server/src/__tests__/oauth/controller.spec.ts +++ b/packages/backend/server/src/__tests__/oauth/controller.spec.ts @@ -6,7 +6,12 @@ import ava, { TestFn } from 'ava'; import Sinon from 'sinon'; import { AppModule } from '../../app.module'; -import { ConfigFactory, InvalidOauthResponse, URLHelper } from '../../base'; +import { + ConfigFactory, + InvalidOauthResponse, + type SafeFetchOptions, + URLHelper, +} from '../../base'; import { SessionCache } from '../../base/cache'; import { ConfigModule } from '../../base/config'; import { CurrentUser } from '../../core/auth'; @@ -531,6 +536,7 @@ function createOidcRegistrationHarness(config?: { clientId?: string; clientSecret?: string; issuer?: string; + allowPrivateNetwork?: boolean; }) { const server = { enableFeature: Sinon.spy(), @@ -551,6 +557,7 @@ function createOidcRegistrationHarness(config?: { clientId: config?.clientId ?? 'oidc-client-id', clientSecret: config?.clientSecret ?? 'oidc-client-secret', issuer: config?.issuer ?? 'https://issuer.affine.dev', + allowPrivateNetwork: config?.allowPrivateNetwork ?? false, args: {}, }, }, @@ -565,6 +572,12 @@ function createOidcRegistrationHarness(config?: { provider, factory, server, + fetchOptions: (url: string) => + ( + provider as unknown as { + fetchOptions(url: string): SafeFetchOptions; + } + ).fetchOptions(url), }; } @@ -940,7 +953,16 @@ test('oidc should not fall back to default email claim when custom claim is conf }); test('oidc discovery should remove oauth feature on failure and restore it after backoff retry succeeds', async t => { - const { provider, factory, server } = createOidcRegistrationHarness(); + const issuer = 'https://auth.internal/application/o/affine/'; + const { fetchOptions: defaultFetchOptions } = createOidcRegistrationHarness({ + issuer, + }); + t.falsy(defaultFetchOptions(issuer).allowPrivateTargetOrigin); + + const { provider, factory, server } = createOidcRegistrationHarness({ + issuer, + allowPrivateNetwork: true, + }); const fetchStub = Sinon.stub(provider as any, 'oidcFetch'); const scheduledRetries: Array<() => void> = []; const retryDelays: number[] = []; @@ -967,11 +989,11 @@ test('oidc discovery should remove oauth feature on failure and restore it after .resolves( new Response( JSON.stringify({ - authorization_endpoint: 'https://issuer.affine.dev/auth', - token_endpoint: 'https://issuer.affine.dev/token', - userinfo_endpoint: 'https://issuer.affine.dev/userinfo', - issuer: 'https://issuer.affine.dev', - jwks_uri: 'https://issuer.affine.dev/jwks', + authorization_endpoint: `${issuer}authorize`, + token_endpoint: `${issuer}token`, + userinfo_endpoint: `${issuer}userinfo`, + issuer, + jwks_uri: `${issuer}jwks`, }), { status: 200, @@ -986,6 +1008,11 @@ test('oidc discovery should remove oauth feature on failure and restore it after t.deepEqual(factory.providers, []); t.true(server.disableFeature.calledWith(ServerFeature.OAuth)); t.is(fetchStub.callCount, 1); + t.is( + fetchStub.firstCall.args[0], + `${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration` + ); + t.true(fetchStub.firstCall.args[2].allowPrivateTargetOrigin); t.deepEqual(retryDelays, [1000]); const firstRetry = scheduledRetries.shift(); diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index 4a8622a0ff..cf74665575 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -280,12 +280,24 @@ export const USER_FRIENDLY_ERRORS = { args: { reason: 'string' }, message: ({ reason }) => { switch (reason) { + case 'invalid_url': + return 'Invalid URL'; + case 'disallowed_protocol': + return 'URL protocol is not allowed'; + case 'url_has_credentials': + return 'URL must not contain credentials'; + case 'blocked_hostname': + return 'URL hostname is not allowed'; + case 'host_not_allowed': + return 'URL hostname is outside the allowed hosts'; case 'unresolvable_hostname': return 'Failed to resolve hostname'; + case 'blocked_ip': + return 'URL resolves to a private or reserved IP address'; case 'too_many_redirects': return 'Too many redirects'; default: - return 'Invalid URL'; + return `URL blocked by SSRF protection: ${reason}`; } }, }, diff --git a/packages/backend/server/src/plugins/oauth/config.ts b/packages/backend/server/src/plugins/oauth/config.ts index ac6137c8f4..ab57490577 100644 --- a/packages/backend/server/src/plugins/oauth/config.ts +++ b/packages/backend/server/src/plugins/oauth/config.ts @@ -18,6 +18,7 @@ export type OIDCArgs = { export interface OAuthOIDCProviderConfig extends OAuthProviderConfig { issuer: string; + allowPrivateNetwork?: boolean; args?: OIDCArgs; } @@ -49,6 +50,22 @@ const schema: JSONSchema = { }, }; +const oidcSchema: JSONSchema = { + type: 'object', + properties: { + ...schema.properties, + issuer: { + type: 'string', + description: 'OIDC issuer URL', + }, + allowPrivateNetwork: { + type: 'boolean', + description: + 'Allow the OIDC issuer origin to resolve to private network addresses', + }, + }, +}; + defineModuleConfig('oauth', { 'providers.google': { desc: 'Google OAuth provider config', @@ -69,14 +86,15 @@ defineModuleConfig('oauth', { link: 'https://docs.github.com/en/apps/oauth-apps', }, 'providers.oidc': { - desc: 'OIDC OAuth provider config', + desc: 'OIDC OAuth provider config. Private network access requires allowPrivateNetwork: true', default: { clientId: '', clientSecret: '', issuer: '', + allowPrivateNetwork: false, args: {}, }, - schema, + schema: oidcSchema, link: 'https://openid.net/specs/openid-connect-core-1_0.html', shape: z.object({ issuer: z @@ -84,6 +102,7 @@ defineModuleConfig('oauth', { .url() .regex(/^https?:\/\//, 'issuer must be a valid URL') .or(z.string().length(0)), + allowPrivateNetwork: z.boolean().optional(), args: z.object({ scope: z.string().optional(), claim_id: z.string().optional(), diff --git a/packages/backend/server/src/plugins/oauth/providers/def.ts b/packages/backend/server/src/plugins/oauth/providers/def.ts index ccfba7797f..c90f7a3879 100644 --- a/packages/backend/server/src/plugins/oauth/providers/def.ts +++ b/packages/backend/server/src/plugins/oauth/providers/def.ts @@ -6,6 +6,7 @@ import { InvalidOauthResponse, OnEvent, safeFetch, + type SafeFetchOptions, } from '../../../base'; import { OAuthProviderName } from '../config'; import { OAuthProviderFactory } from '../factory'; @@ -79,6 +80,15 @@ export abstract class OAuthProvider { return false; } + protected fetchOptions(_url: string | URL): SafeFetchOptions { + return { + timeoutMs: 10_000, + maxRedirects: 3, + maxBytes: 1024 * 1024, + allowedHeaders: ['authorization', 'content-type', 'accept'], + }; + } + protected async fetchJson( url: string, init?: RequestInit, @@ -87,12 +97,7 @@ export abstract class OAuthProvider { const response = await safeFetch( url, { ...init, headers: { ...init?.headers, Accept: 'application/json' } }, - { - timeoutMs: 10_000, - maxRedirects: 3, - maxBytes: 1024 * 1024, - allowedHeaders: ['authorization', 'content-type', 'accept'], - } + this.fetchOptions(url) ); const body = await response.text(); diff --git a/packages/backend/server/src/plugins/oauth/providers/oidc.ts b/packages/backend/server/src/plugins/oauth/providers/oidc.ts index 455cb6e773..40e51312fe 100644 --- a/packages/backend/server/src/plugins/oauth/providers/oidc.ts +++ b/packages/backend/server/src/plugins/oauth/providers/oidc.ts @@ -13,6 +13,8 @@ import { InvalidAuthState, InvalidOauthResponse, safeFetch, + type SafeFetchOptions, + SsrfBlockedError, URLHelper, } from '../../../base'; import { OAuthOIDCProviderConfig, OAuthProviderName } from '../config'; @@ -65,13 +67,6 @@ type OIDCConfiguration = z.infer; const OIDC_DISCOVERY_INITIAL_RETRY_DELAY = 1000; const OIDC_DISCOVERY_MAX_RETRY_DELAY = 60_000; -const OIDC_FETCH_OPTIONS = { - timeoutMs: 10_000, - maxRedirects: 3, - maxBytes: 1024 * 1024, - allowedHeaders: ['accept'], -}; - @Injectable() export class OIDCProvider extends OAuthProvider implements OnModuleDestroy { override provider = OAuthProviderName.OIDC; @@ -96,6 +91,24 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy { return true; } + protected override fetchOptions(rawUrl: string | URL): SafeFetchOptions { + const options = super.fetchOptions(rawUrl); + const config = this.config as OAuthOIDCProviderConfig; + if (!config.allowPrivateNetwork) { + return options; + } + try { + const issuer = new URL(config.issuer); + const target = new URL(rawUrl); + if (target.origin === issuer.origin) { + return { ...options, allowPrivateTargetOrigin: true }; + } + } catch { + return options; + } + return options; + } + private get endpoints() { if (!this.#endpoints) { throw new Error('OIDC provider is not configured'); @@ -145,10 +158,11 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy { } try { + const discoveryUrl = `${this.normalizeIssuer(config.issuer)}/.well-known/openid-configuration`; const res = await this.oidcFetch( - `${config.issuer}/.well-known/openid-configuration`, + discoveryUrl, { method: 'GET', headers: { Accept: 'application/json' } }, - OIDC_FETCH_OPTIONS + this.fetchOptions(discoveryUrl) ); if (generation !== this.#validationGeneration) { @@ -176,7 +190,7 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy { this.#endpoints = configuration; this.#jwks = createRemoteJWKSet(new URL(configuration.jwks_uri), { [customFetch]: (url, init) => - this.oidcFetch(url, init, OIDC_FETCH_OPTIONS), + this.oidcFetch(url, init, this.fetchOptions(url)), }); this.#retryScheduler.reset(); super.setup(); @@ -184,7 +198,14 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy { if (generation !== this.#validationGeneration) { return; } - this.logger.error('Failed to validate OIDC configuration', e); + const reason = e instanceof SsrfBlockedError ? e.data?.reason : undefined; + const message = + reason === 'blocked_ip' && !config.allowPrivateNetwork + ? 'Failed to validate OIDC configuration: issuer resolves to a private network address; set oauth.providers.oidc.allowPrivateNetwork to true to trust this origin' + : reason + ? `Failed to validate OIDC configuration: ${reason}` + : 'Failed to validate OIDC configuration'; + this.logger.error(message, e); this.onValidationFailure(generation); } } diff --git a/packages/frontend/admin/src/config.json b/packages/frontend/admin/src/config.json index 79bbd825b4..f4ced9555c 100644 --- a/packages/frontend/admin/src/config.json +++ b/packages/frontend/admin/src/config.json @@ -482,7 +482,7 @@ }, "providers.oidc": { "type": "Object", - "desc": "OIDC OAuth provider config", + "desc": "OIDC OAuth provider config. Private network access requires allowPrivateNetwork: true", "link": "https://openid.net/specs/openid-connect-core-1_0.html" }, "providers.apple": { diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt index c689083779..de0e01640c 100644 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt @@ -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) } } } diff --git a/packages/frontend/apps/electron/src/main/auth/auth-session.ts b/packages/frontend/apps/electron/src/main/auth/auth-session.ts index e51eee9060..b5f3578f66 100644 --- a/packages/frontend/apps/electron/src/main/auth/auth-session.ts +++ b/packages/frontend/apps/electron/src/main/auth/auth-session.ts @@ -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', diff --git a/packages/frontend/apps/electron/src/main/auth/handlers.ts b/packages/frontend/apps/electron/src/main/auth/handlers.ts index b83bfd64c6..d4b2f3056f 100644 --- a/packages/frontend/apps/electron/src/main/auth/handlers.ts +++ b/packages/frontend/apps/electron/src/main/auth/handlers.ts @@ -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(response: Response): Promise { 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', diff --git a/packages/frontend/apps/electron/src/main/auth/transport.ts b/packages/frontend/apps/electron/src/main/auth/transport.ts new file mode 100644 index 0000000000..56ca57cfa8 --- /dev/null +++ b/packages/frontend/apps/electron/src/main/auth/transport.ts @@ -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); +} diff --git a/packages/frontend/apps/electron/src/main/deep-link.ts b/packages/frontend/apps/electron/src/main/deep-link.ts index c666c36514..56bef034af 100644 --- a/packages/frontend/apps/electron/src/main/deep-link.ts +++ b/packages/frontend/apps/electron/src/main/deep-link.ts @@ -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; } diff --git a/packages/frontend/apps/electron/src/main/handlers.ts b/packages/frontend/apps/electron/src/main/handlers.ts index e9f999bee3..ffbd142e3a 100644 --- a/packages/frontend/apps/electron/src/main/handlers.ts +++ b/packages/frontend/apps/electron/src/main/handlers.ts @@ -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; } }); diff --git a/packages/frontend/apps/electron/test/main/auth-session.spec.ts b/packages/frontend/apps/electron/test/main/auth-session.spec.ts index 3f597ea155..542236e760 100644 --- a/packages/frontend/apps/electron/test/main/auth-session.spec.ts +++ b/packages/frontend/apps/electron/test/main/auth-session.spec.ts @@ -11,6 +11,7 @@ const runtime = vi.hoisted(() => ({ failWrite: false, files: new Map(), 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 () => { diff --git a/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift index 67796e12f2..307eec38da 100644 --- a/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/Auth/AuthPlugin.swift @@ -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) diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 0c134eca7d..52a32adf62 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -9312,9 +9312,11 @@ export function useAFFiNEI18N(): { readonly message: string; }): string; /** - * `Invalid URL` + * `URL blocked by SSRF protection: {{reason}}` */ - ["error.SSRF_BLOCKED_ERROR"](): string; + ["error.SSRF_BLOCKED_ERROR"](options: { + readonly reason: string; + }): string; /** * `Response too large ({{receivedBytes}} bytes), limit is {{limitBytes}} bytes` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index eaae7cb772..0f8734e0f3 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -2325,7 +2325,7 @@ "error.BAD_REQUEST": "Bad request.", "error.GRAPHQL_BAD_REQUEST": "GraphQL bad request, code: {{code}}, {{message}}", "error.HTTP_REQUEST_ERROR": "HTTP request error, message: {{message}}", - "error.SSRF_BLOCKED_ERROR": "Invalid URL", + "error.SSRF_BLOCKED_ERROR": "URL blocked by SSRF protection: {{reason}}", "error.RESPONSE_TOO_LARGE_ERROR": "Response too large ({{receivedBytes}} bytes), limit is {{limitBytes}} bytes", "error.EMAIL_SERVICE_NOT_CONFIGURED": "Email service is not configured.", "error.IMAGE_FORMAT_NOT_SUPPORTED": "Image format not supported: {{format}}",