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:
DarkSky
2026-07-18 06:27:01 +08:00
committed by GitHub
parent 427db39862
commit d24c17f300
17 changed files with 273 additions and 99 deletions

View File

@@ -1612,7 +1612,7 @@
}, },
"providers.oidc": { "providers.oidc": {
"type": "object", "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": { "properties": {
"clientId": { "clientId": {
"type": "string" "type": "string"
@@ -1622,12 +1622,21 @@
}, },
"args": { "args": {
"type": "object" "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": { "default": {
"clientId": "", "clientId": "",
"clientSecret": "", "clientSecret": "",
"issuer": "", "issuer": "",
"allowPrivateNetwork": false,
"args": {} "args": {}
} }
}, },

View File

@@ -6,7 +6,12 @@ import ava, { TestFn } from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
import { AppModule } from '../../app.module'; 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 { SessionCache } from '../../base/cache';
import { ConfigModule } from '../../base/config'; import { ConfigModule } from '../../base/config';
import { CurrentUser } from '../../core/auth'; import { CurrentUser } from '../../core/auth';
@@ -531,6 +536,7 @@ function createOidcRegistrationHarness(config?: {
clientId?: string; clientId?: string;
clientSecret?: string; clientSecret?: string;
issuer?: string; issuer?: string;
allowPrivateNetwork?: boolean;
}) { }) {
const server = { const server = {
enableFeature: Sinon.spy(), enableFeature: Sinon.spy(),
@@ -551,6 +557,7 @@ function createOidcRegistrationHarness(config?: {
clientId: config?.clientId ?? 'oidc-client-id', clientId: config?.clientId ?? 'oidc-client-id',
clientSecret: config?.clientSecret ?? 'oidc-client-secret', clientSecret: config?.clientSecret ?? 'oidc-client-secret',
issuer: config?.issuer ?? 'https://issuer.affine.dev', issuer: config?.issuer ?? 'https://issuer.affine.dev',
allowPrivateNetwork: config?.allowPrivateNetwork ?? false,
args: {}, args: {},
}, },
}, },
@@ -565,6 +572,12 @@ function createOidcRegistrationHarness(config?: {
provider, provider,
factory, factory,
server, 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 => { 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 fetchStub = Sinon.stub(provider as any, 'oidcFetch');
const scheduledRetries: Array<() => void> = []; const scheduledRetries: Array<() => void> = [];
const retryDelays: number[] = []; const retryDelays: number[] = [];
@@ -967,11 +989,11 @@ test('oidc discovery should remove oauth feature on failure and restore it after
.resolves( .resolves(
new Response( new Response(
JSON.stringify({ JSON.stringify({
authorization_endpoint: 'https://issuer.affine.dev/auth', authorization_endpoint: `${issuer}authorize`,
token_endpoint: 'https://issuer.affine.dev/token', token_endpoint: `${issuer}token`,
userinfo_endpoint: 'https://issuer.affine.dev/userinfo', userinfo_endpoint: `${issuer}userinfo`,
issuer: 'https://issuer.affine.dev', issuer,
jwks_uri: 'https://issuer.affine.dev/jwks', jwks_uri: `${issuer}jwks`,
}), }),
{ {
status: 200, status: 200,
@@ -986,6 +1008,11 @@ test('oidc discovery should remove oauth feature on failure and restore it after
t.deepEqual(factory.providers, []); t.deepEqual(factory.providers, []);
t.true(server.disableFeature.calledWith(ServerFeature.OAuth)); t.true(server.disableFeature.calledWith(ServerFeature.OAuth));
t.is(fetchStub.callCount, 1); 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]); t.deepEqual(retryDelays, [1000]);
const firstRetry = scheduledRetries.shift(); const firstRetry = scheduledRetries.shift();

View File

@@ -280,12 +280,24 @@ export const USER_FRIENDLY_ERRORS = {
args: { reason: 'string' }, args: { reason: 'string' },
message: ({ reason }) => { message: ({ reason }) => {
switch (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': case 'unresolvable_hostname':
return 'Failed to resolve hostname'; return 'Failed to resolve hostname';
case 'blocked_ip':
return 'URL resolves to a private or reserved IP address';
case 'too_many_redirects': case 'too_many_redirects':
return 'Too many redirects'; return 'Too many redirects';
default: default:
return 'Invalid URL'; return `URL blocked by SSRF protection: ${reason}`;
} }
}, },
}, },

View File

@@ -18,6 +18,7 @@ export type OIDCArgs = {
export interface OAuthOIDCProviderConfig extends OAuthProviderConfig { export interface OAuthOIDCProviderConfig extends OAuthProviderConfig {
issuer: string; issuer: string;
allowPrivateNetwork?: boolean;
args?: OIDCArgs; 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', { defineModuleConfig('oauth', {
'providers.google': { 'providers.google': {
desc: 'Google OAuth provider config', desc: 'Google OAuth provider config',
@@ -69,14 +86,15 @@ defineModuleConfig('oauth', {
link: 'https://docs.github.com/en/apps/oauth-apps', link: 'https://docs.github.com/en/apps/oauth-apps',
}, },
'providers.oidc': { 'providers.oidc': {
desc: 'OIDC OAuth provider config', desc: 'OIDC OAuth provider config. Private network access requires allowPrivateNetwork: true',
default: { default: {
clientId: '', clientId: '',
clientSecret: '', clientSecret: '',
issuer: '', issuer: '',
allowPrivateNetwork: false,
args: {}, args: {},
}, },
schema, schema: oidcSchema,
link: 'https://openid.net/specs/openid-connect-core-1_0.html', link: 'https://openid.net/specs/openid-connect-core-1_0.html',
shape: z.object({ shape: z.object({
issuer: z issuer: z
@@ -84,6 +102,7 @@ defineModuleConfig('oauth', {
.url() .url()
.regex(/^https?:\/\//, 'issuer must be a valid URL') .regex(/^https?:\/\//, 'issuer must be a valid URL')
.or(z.string().length(0)), .or(z.string().length(0)),
allowPrivateNetwork: z.boolean().optional(),
args: z.object({ args: z.object({
scope: z.string().optional(), scope: z.string().optional(),
claim_id: z.string().optional(), claim_id: z.string().optional(),

View File

@@ -6,6 +6,7 @@ import {
InvalidOauthResponse, InvalidOauthResponse,
OnEvent, OnEvent,
safeFetch, safeFetch,
type SafeFetchOptions,
} from '../../../base'; } from '../../../base';
import { OAuthProviderName } from '../config'; import { OAuthProviderName } from '../config';
import { OAuthProviderFactory } from '../factory'; import { OAuthProviderFactory } from '../factory';
@@ -79,6 +80,15 @@ export abstract class OAuthProvider {
return false; 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<T>( protected async fetchJson<T>(
url: string, url: string,
init?: RequestInit, init?: RequestInit,
@@ -87,12 +97,7 @@ export abstract class OAuthProvider {
const response = await safeFetch( const response = await safeFetch(
url, url,
{ ...init, headers: { ...init?.headers, Accept: 'application/json' } }, { ...init, headers: { ...init?.headers, Accept: 'application/json' } },
{ this.fetchOptions(url)
timeoutMs: 10_000,
maxRedirects: 3,
maxBytes: 1024 * 1024,
allowedHeaders: ['authorization', 'content-type', 'accept'],
}
); );
const body = await response.text(); const body = await response.text();

View File

@@ -13,6 +13,8 @@ import {
InvalidAuthState, InvalidAuthState,
InvalidOauthResponse, InvalidOauthResponse,
safeFetch, safeFetch,
type SafeFetchOptions,
SsrfBlockedError,
URLHelper, URLHelper,
} from '../../../base'; } from '../../../base';
import { OAuthOIDCProviderConfig, OAuthProviderName } from '../config'; import { OAuthOIDCProviderConfig, OAuthProviderName } from '../config';
@@ -65,13 +67,6 @@ type OIDCConfiguration = z.infer<typeof OIDCConfigurationSchema>;
const OIDC_DISCOVERY_INITIAL_RETRY_DELAY = 1000; const OIDC_DISCOVERY_INITIAL_RETRY_DELAY = 1000;
const OIDC_DISCOVERY_MAX_RETRY_DELAY = 60_000; const OIDC_DISCOVERY_MAX_RETRY_DELAY = 60_000;
const OIDC_FETCH_OPTIONS = {
timeoutMs: 10_000,
maxRedirects: 3,
maxBytes: 1024 * 1024,
allowedHeaders: ['accept'],
};
@Injectable() @Injectable()
export class OIDCProvider extends OAuthProvider implements OnModuleDestroy { export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
override provider = OAuthProviderName.OIDC; override provider = OAuthProviderName.OIDC;
@@ -96,6 +91,24 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
return true; 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() { private get endpoints() {
if (!this.#endpoints) { if (!this.#endpoints) {
throw new Error('OIDC provider is not configured'); throw new Error('OIDC provider is not configured');
@@ -145,10 +158,11 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
} }
try { try {
const discoveryUrl = `${this.normalizeIssuer(config.issuer)}/.well-known/openid-configuration`;
const res = await this.oidcFetch( const res = await this.oidcFetch(
`${config.issuer}/.well-known/openid-configuration`, discoveryUrl,
{ method: 'GET', headers: { Accept: 'application/json' } }, { method: 'GET', headers: { Accept: 'application/json' } },
OIDC_FETCH_OPTIONS this.fetchOptions(discoveryUrl)
); );
if (generation !== this.#validationGeneration) { if (generation !== this.#validationGeneration) {
@@ -176,7 +190,7 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
this.#endpoints = configuration; this.#endpoints = configuration;
this.#jwks = createRemoteJWKSet(new URL(configuration.jwks_uri), { this.#jwks = createRemoteJWKSet(new URL(configuration.jwks_uri), {
[customFetch]: (url, init) => [customFetch]: (url, init) =>
this.oidcFetch(url, init, OIDC_FETCH_OPTIONS), this.oidcFetch(url, init, this.fetchOptions(url)),
}); });
this.#retryScheduler.reset(); this.#retryScheduler.reset();
super.setup(); super.setup();
@@ -184,7 +198,14 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
if (generation !== this.#validationGeneration) { if (generation !== this.#validationGeneration) {
return; 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); this.onValidationFailure(generation);
} }
} }

View File

@@ -482,7 +482,7 @@
}, },
"providers.oidc": { "providers.oidc": {
"type": "Object", "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" "link": "https://openid.net/specs/openid-connect-core-1_0.html"
}, },
"providers.apple": { "providers.apple": {

View File

@@ -56,7 +56,20 @@ private data class TokenPair(
val json: String, 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( private val permanentAuthErrors = setOf(
"ACCESS_TOKEN_INVALID", "AUTH_SESSION_EXPIRED", "AUTH_SESSION_REVOKED", "ACCESS_TOKEN_INVALID", "AUTH_SESSION_EXPIRED", "AUTH_SESSION_REVOKED",
@@ -183,8 +196,7 @@ internal class AuthSessionBroker(
AuthHttp.client.newCall(request).executeAsync().use { response -> AuthHttp.client.newCall(request).executeAsync().use { response ->
val text = response.body.string() val text = response.body.string()
if (response.code < 400) return text if (response.code < 400) return text
val code = runCatching { JSONObject(text).optString("code").ifEmpty { null } }.getOrNull() val error = authServerException(response.code, text)
val error = AuthServerException(code, response.code)
if (response.code < 500 || attempt == 2) throw error if (response.code < 500 || attempt == 2) throw error
last = error last = error
} }
@@ -338,7 +350,7 @@ class AuthPlugin : Plugin() {
.build() .build()
val exchangeCode = AuthHttp.client.newCall(request).executeAsync().use { response -> val exchangeCode = AuthHttp.client.newCall(request).executeAsync().use { response ->
val text = response.body.string() 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") JSONObject(text).getString("exchangeCode")
} }
val exchangeBody = JSONObject() val exchangeBody = JSONObject()
@@ -353,7 +365,7 @@ class AuthPlugin : Plugin() {
.build() .build()
val tokenResponse = AuthHttp.client.newCall(exchangeRequest).executeAsync().use { response -> val tokenResponse = AuthHttp.client.newCall(exchangeRequest).executeAsync().use { response ->
val text = response.body.string() val text = response.body.string()
if (response.code >= 400) throw IllegalStateException(text) if (response.code >= 400) throw authServerException(response.code, text)
text text
} }
broker.store(endpoint, tokenResponse) broker.store(endpoint, tokenResponse)
@@ -385,7 +397,8 @@ class AuthPlugin : Plugin() {
?: "AUTH_SESSION_TEMPORARILY_UNAVAILABLE" ?: "AUTH_SESSION_TEMPORARILY_UNAVAILABLE"
else -> "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)
} }
} }
} }

View File

@@ -8,9 +8,10 @@ import {
type AuthTokenResponse, type AuthTokenResponse,
classifyAuthError, classifyAuthError,
} from '@affine/auth'; } from '@affine/auth';
import { app, net, safeStorage } from 'electron'; import { app, safeStorage } from 'electron';
import { logger } from '../logger'; import { logger } from '../logger';
import { authFetch } from './transport';
const FILEPATH = path.join(app.getPath('userData'), 'auth-sessions.json'); const FILEPATH = path.join(app.getPath('userData'), 'auth-sessions.json');
const TEMP_FILEPATH = `${FILEPATH}.tmp`; const TEMP_FILEPATH = `${FILEPATH}.tmp`;
@@ -105,7 +106,7 @@ function storage(endpoint: string) {
} }
async function refresh(endpoint: string, refreshToken: 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(), new URL('/api/auth/session/refresh', endpoint).toString(),
{ {
method: 'POST', method: 'POST',
@@ -192,7 +193,7 @@ export async function revokeAuthSession(endpoint: string) {
await getAuthSessionBroker(normalized).revoke( await getAuthSessionBroker(normalized).revoke(
'sign-out', 'sign-out',
async (refreshToken: string) => { async (refreshToken: string) => {
const response = await net.fetch( const response = await authFetch(
new URL('/api/auth/session/revoke', normalized).toString(), new URL('/api/auth/session/revoke', normalized).toString(),
{ {
method: 'POST', method: 'POST',

View File

@@ -1,7 +1,7 @@
import os from 'node:os'; import os from 'node:os';
import type { AuthTokenResponse } from '@affine/auth'; import type { AuthTokenResponse } from '@affine/auth';
import { net, session } from 'electron'; import { session } from 'electron';
import { logger } from '../logger'; import { logger } from '../logger';
import type { NamespaceHandlers } from '../type'; import type { NamespaceHandlers } from '../type';
@@ -12,6 +12,7 @@ import {
revokeAuthSession, revokeAuthSession,
setAuthSession, setAuthSession,
} from './auth-session'; } from './auth-session';
import { authFetch, getAuthTransportSession } from './transport';
export interface SignInResponse { export interface SignInResponse {
id?: string; id?: string;
@@ -47,14 +48,21 @@ function authUrl(endpoint: string, path: string) {
async function readJson<T>(response: Response): Promise<T> { async function readJson<T>(response: Response): Promise<T> {
const text = await response.text(); const text = await response.text();
if (!response.ok) { 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); return text ? JSON.parse(text) : ({} as T);
} }
async function fetchAuth(endpoint: string, path: string, body?: unknown) { async function fetchAuth(endpoint: string, path: string, body?: unknown) {
return await net.fetch(authUrl(endpoint, path), { return await authFetch(authUrl(endpoint, path), {
method: 'POST', method: 'POST',
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
@@ -66,9 +74,11 @@ async function fetchAuth(endpoint: string, path: string, body?: unknown) {
} }
async function clearAuthCookies(endpoint: string) { async function clearAuthCookies(endpoint: string) {
const sessions = [session.defaultSession, getAuthTransportSession()];
await Promise.all( await Promise.all(
sessions.flatMap(authSession =>
authCookieNames.map(name => authCookieNames.map(name =>
session.defaultSession.cookies authSession.cookies
.remove(endpoint, name) .remove(endpoint, name)
.catch(error => .catch(error =>
logger.debug( logger.debug(
@@ -79,6 +89,7 @@ async function clearAuthCookies(endpoint: string) {
) )
) )
) )
)
); );
} }
@@ -148,7 +159,7 @@ export const authHandlers = {
challenge?: string; 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', method: 'POST',
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',

View File

@@ -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);
}

View File

@@ -18,6 +18,36 @@ if (isDev) {
protocol = 'affine-dev'; 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) { export function setupDeepLink(app: App) {
if (process.defaultApp) { if (process.defaultApp) {
if (process.argv.length >= 2) { if (process.argv.length >= 2) {
@@ -30,14 +60,14 @@ export function setupDeepLink(app: App) {
} }
app.on('open-url', (event, url) => { app.on('open-url', (event, url) => {
logger.log('open-url', url); logger.log('open-url', summarizeDeepLink(url));
if (url.startsWith(`${protocol}://`)) { if (url.startsWith(`${protocol}://`)) {
event.preventDefault(); event.preventDefault();
app app
.whenReady() .whenReady()
.then(() => handleAffineUrl(url)) .then(() => handleAffineUrl(url))
.catch(e => { .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}://`)) { if (url?.startsWith(`${protocol}://`)) {
event.preventDefault(); event.preventDefault();
handleAffineUrl(url).catch(e => { 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 // app may be brought up without having a running instance
// need to read the url from the command line // need to read the url from the command line
const url = process.argv.at(-1); 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}://`)) { if (url?.startsWith(`${protocol}://`)) {
handleAffineUrl(url).catch(e => { 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) { async function handleAffineUrl(url: string) {
await showMainWindow(); await showMainWindow();
logger.info('open affine url', url); logger.info('open affine url', summarizeDeepLink(url));
const urlObj = new URL(url); const urlObj = new URL(url);
if (urlObj.hostname === 'authentication') { if (urlObj.hostname === 'authentication') {
@@ -93,7 +128,7 @@ async function handleAffineUrl(url: string) {
method !== 'open-app-signin') || method !== 'open-app-signin') ||
!payload !payload
) { ) {
logger.error('Invalid authentication url', url); logger.error('Invalid authentication url', summarizeDeepLink(url));
return; return;
} }

View File

@@ -99,7 +99,7 @@ export const registerHandlers = () => {
return await handleIpcMessage(e, ...args); return await handleIpcMessage(e, ...args);
} catch (error) { } catch (error) {
logger.error(`error in ipc handler when calling ${args[0]}`, error); logger.error(`error in ipc handler when calling ${args[0]}`, error);
return null; throw error;
} }
}); });

View File

@@ -11,6 +11,7 @@ const runtime = vi.hoisted(() => ({
failWrite: false, failWrite: false,
files: new Map<string, string>(), files: new Map<string, string>(),
fetch: vi.fn(), fetch: vi.fn(),
fromPartition: vi.fn(),
rename: vi.fn(), rename: vi.fn(),
})); }));
@@ -41,7 +42,12 @@ vi.mock('node:fs/promises', () => ({
vi.mock('electron', () => ({ vi.mock('electron', () => ({
app: { getPath: () => '/test-user-data' }, app: { getPath: () => '/test-user-data' },
net: { fetch: runtime.fetch }, session: {
fromPartition: (...args: unknown[]) => {
runtime.fromPartition(...args);
return { fetch: runtime.fetch };
},
},
safeStorage: { safeStorage: {
isEncryptionAvailable: () => runtime.encryptionAvailable, isEncryptionAvailable: () => runtime.encryptionAvailable,
getSelectedStorageBackend: () => runtime.backend, getSelectedStorageBackend: () => runtime.backend,
@@ -68,6 +74,7 @@ beforeEach(() => {
runtime.backend = 'unknown'; runtime.backend = 'unknown';
runtime.failWrite = false; runtime.failWrite = false;
runtime.fetch.mockReset(); runtime.fetch.mockReset();
runtime.fromPartition.mockClear();
runtime.rename.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(tokens.every(token => token === 'fresh')).toBe(true);
expect(runtime.fetch).toHaveBeenCalledTimes(1); expect(runtime.fetch).toHaveBeenCalledTimes(1);
expect(runtime.fromPartition).toHaveBeenCalledWith('affine-auth-transport', {
cache: false,
});
}); });
test('preserves credentials for unknown refresh errors', async () => { test('preserves credentials for unknown refresh errors', async () => {

View File

@@ -29,11 +29,16 @@ private struct StoredAuthTokenPair: Codable {
private struct AuthErrorResponse: Decodable { private struct AuthErrorResponse: Decodable {
let code: String? let code: String?
let name: String?
let message: String?
} }
private struct AuthServerError: Error { private struct AuthServerError: Error, CustomStringConvertible {
let code: String? let code: String?
let statusCode: Int let statusCode: Int
let message: String
var description: String { message }
var permanentlyInvalidatesSession: Bool { var permanentlyInvalidatesSession: Bool {
switch code { 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 AuthOperationCancelled: Error {}
private struct AuthRefreshOperation { private struct AuthRefreshOperation {
@@ -63,7 +76,8 @@ private actor AuthSessionBroker {
try write(endpoint, tokenPair(response)) 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 } guard let pair = try read(endpoint) else { return nil }
if pair.accessExpiresAt.timeIntervalSinceNow > minValidity { if pair.accessExpiresAt.timeIntervalSinceNow > minValidity {
return pair.accessToken return pair.accessToken
@@ -149,7 +163,9 @@ private actor AuthSessionBroker {
session: response.session) 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 { guard let url = URL(string: "\(canonicalEndpoint(endpoint))\(action)") else {
throw AuthError.invalidEndpoint throw AuthError.invalidEndpoint
} }
@@ -168,9 +184,7 @@ private actor AuthSessionBroker {
throw AuthError.internalError throw AuthError.internalError
} }
if response.statusCode < 400 { return data } if response.statusCode < 400 { return data }
let error = AuthServerError( let error = authServerError(data, statusCode: response.statusCode)
code: try? JSONDecoder().decode(AuthErrorResponse.self, from: data).code,
statusCode: response.statusCode)
guard response.statusCode >= 500, attempt < 2 else { throw error } guard response.statusCode >= 500, attempt < 2 else { throw error }
} catch let error as AuthServerError { } catch let error as AuthServerError {
if error.statusCode < 500 || attempt == 2 { throw error } 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]) ], body: ["email": email, "token": token, "client_nonce": clientNonce])
if response.statusCode >= 400 { if response.statusCode >= 400 {
if let textBody = String(data: data, encoding: .utf8) { throw authServerError(data, statusCode: response.statusCode)
call.reject(textBody)
} else {
call.reject("Failed to sign in")
}
return
} }
try await self.exchangeSession(endpoint, data) try await self.exchangeSession(endpoint, data)
@@ -367,12 +376,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
], body: ["code": code, "state": state, "client_nonce": clientNonce]) ], body: ["code": code, "state": state, "client_nonce": clientNonce])
if response.statusCode >= 400 { if response.statusCode >= 400 {
if let textBody = String(data: data, encoding: .utf8) { throw authServerError(data, statusCode: response.statusCode)
call.reject(textBody)
} else {
call.reject("Failed to sign in")
}
return
} }
try await self.exchangeSession(endpoint, data) try await self.exchangeSession(endpoint, data)
@@ -398,16 +402,12 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
"x-affine-client-kind": "native", "x-affine-client-kind": "native",
"x-captcha-token": verifyToken, "x-captcha-token": verifyToken,
"x-captcha-challenge": challenge, "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]) ], body: ["email": email, "password": password])
if response.statusCode >= 400 { if response.statusCode >= 400 {
if let textBody = String(data: data, encoding: .utf8) { throw authServerError(data, statusCode: response.statusCode)
call.reject(textBody)
} else {
call.reject("Failed to sign in")
}
return
} }
try await self.exchangeSession(endpoint, data) try await self.exchangeSession(endpoint, data)
@@ -431,12 +431,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
], body: ["code": code]) ], body: ["code": code])
if response.statusCode >= 400 { if response.statusCode >= 400 {
if let textBody = String(data: data, encoding: .utf8) { throw authServerError(data, statusCode: response.statusCode)
call.reject(textBody)
} else {
call.reject("Failed to sign in")
}
return
} }
try await self.exchangeSession(endpoint, data) try await self.exchangeSession(endpoint, data)
@@ -476,7 +471,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
endpoint, method: "POST", action: "/api/auth/session/exchange", endpoint, method: "POST", action: "/api/auth/session/exchange",
headers: [ headers: [
"x-affine-client-kind": "native" "x-affine-client-kind": "native"
], body: [ ],
body: [
"code": code, "code": code,
"installationId": self.installationId(), "installationId": self.installationId(),
"platform": "ios", "platform": "ios",
@@ -484,10 +480,11 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
]) ])
if response.statusCode >= 400 { 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) self.clearAuthCookies(endpoint)
} }
@@ -506,7 +503,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
let normalizedHost = host.lowercased() let normalizedHost = host.lowercased()
HTTPCookieStorage.shared.cookies?.forEach { cookie in 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)") let domainMatches = normalizedHost == domain || normalizedHost.hasSuffix(".\(domain)")
if domainMatches && authCookieNames.contains(cookie.name) { if domainMatches && authCookieNames.contains(cookie.name) {
HTTPCookieStorage.shared.deleteCookie(cookie) HTTPCookieStorage.shared.deleteCookie(cookie)

View File

@@ -9312,9 +9312,11 @@ export function useAFFiNEI18N(): {
readonly message: string; readonly message: string;
}): 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` * `Response too large ({{receivedBytes}} bytes), limit is {{limitBytes}} bytes`
*/ */

View File

@@ -2325,7 +2325,7 @@
"error.BAD_REQUEST": "Bad request.", "error.BAD_REQUEST": "Bad request.",
"error.GRAPHQL_BAD_REQUEST": "GraphQL bad request, code: {{code}}, {{message}}", "error.GRAPHQL_BAD_REQUEST": "GraphQL bad request, code: {{code}}, {{message}}",
"error.HTTP_REQUEST_ERROR": "HTTP request error, message: {{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.RESPONSE_TOO_LARGE_ERROR": "Response too large ({{receivedBytes}} bytes), limit is {{limitBytes}} bytes",
"error.EMAIL_SERVICE_NOT_CONFIGURED": "Email service is not configured.", "error.EMAIL_SERVICE_NOT_CONFIGURED": "Email service is not configured.",
"error.IMAGE_FORMAT_NOT_SUPPORTED": "Image format not supported: {{format}}", "error.IMAGE_FORMAT_NOT_SUPPORTED": "Image format not supported: {{format}}",