From 51eba174b38c4b3afb6ab4100b3fbe194740a6e9 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sun, 4 Oct 2026 02:44:40 -0700 Subject: [PATCH] fix: alert errors from timeouts (#4066) --- .../controllers/oidc/OIDCController.test.ts | 44 +++++++++++++++++++ .../controllers/oidc/OIDCController.ts | 40 ++++++++++------- src/backend/index.ts | 8 ++++ 3 files changed, 77 insertions(+), 15 deletions(-) diff --git a/src/backend/controllers/oidc/OIDCController.test.ts b/src/backend/controllers/oidc/OIDCController.test.ts index 646a1479f..ee8d98214 100644 --- a/src/backend/controllers/oidc/OIDCController.test.ts +++ b/src/backend/controllers/oidc/OIDCController.test.ts @@ -701,6 +701,26 @@ describe('OIDCController login callback', () => { expect(captured.redirectUrl).toContain('auth_error=1'); }); + it('redirects with auth_error when the provider is unreachable', async () => { + const state = oidc().signState({ + provider: 'custom', + redirect_uri: TEST_ORIGIN + '/', + }); + vi.spyOn(oidc(), 'exchangeCodeForTokens').mockRejectedValue( + new TypeError('fetch failed'), + ); + + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/auth/oidc/callback/login', + makeReq({ query: { code: 'c', state } }), + res, + ); + expect(captured.redirectStatus).toBe(302); + expect(captured.redirectUrl).toContain('auth_error=1'); + }); + it('parses code/state from the POST body (Apple form_post)', async () => { const state = oidc().signState({ provider: 'custom', @@ -1726,6 +1746,30 @@ describe('OIDCController revalidate callback', () => { expect(String(captured.body)).toContain('Missing'); }); + it('returns 400 when the userinfo request cannot reach the provider', async () => { + const state = oidc().signState({ + provider: 'custom', + flow: 'revalidate', + user_uuid: uuidv4(), + }); + vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({ + access_token: 'access', + } as never); + vi.spyOn(oidc(), 'getUserInfo').mockRejectedValue( + new TypeError('fetch failed'), + ); + + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/auth/oidc/callback/revalidate', + makeReq({ query: { code: 'c', state } }), + res, + ); + expect(captured.statusCode).toBe(400); + expect(String(captured.body)).toContain('Please try again'); + }); + it('returns 403 when the OIDC sub resolves to a different account than the session', async () => { // The linked account exists, but state.user_uuid points at someone else. const sub = `sub-${Math.random().toString(36).slice(2, 8)}`; diff --git a/src/backend/controllers/oidc/OIDCController.ts b/src/backend/controllers/oidc/OIDCController.ts index 56dd769b1..5492aee5e 100644 --- a/src/backend/controllers/oidc/OIDCController.ts +++ b/src/backend/controllers/oidc/OIDCController.ts @@ -963,23 +963,33 @@ if (window.opener) { const callbackUrl = this.services.oidc.getCallbackUrl(flow); if (!callbackUrl) return { error: 'Invalid flow.' }; - const tokens = await this.services.oidc.exchangeCodeForTokens( - provider, - code, - callbackUrl, - ); - if (!tokens || !tokens.access_token) - return { error: 'Token exchange failed.' }; + try { + const tokens = await this.services.oidc.exchangeCodeForTokens( + provider, + code, + callbackUrl, + ); + if (!tokens || !tokens.access_token) + return { error: 'Token exchange failed.' }; - const userinfo = await this.services.oidc.getUserInfo( - provider, - tokens.access_token, - typeof tokens.id_token === 'string' ? tokens.id_token : undefined, - ); - if (!userinfo || !userinfo.sub) - return { error: 'Could not get user info.' }; + const userinfo = await this.services.oidc.getUserInfo( + provider, + tokens.access_token, + typeof tokens.id_token === 'string' + ? tokens.id_token + : undefined, + ); + if (!userinfo || !userinfo.sub) + return { error: 'Could not get user info.' }; - return { provider, userinfo, stateDecoded }; + return { provider, userinfo, stateDecoded }; + } catch (e) { + // Network failures reaching the provider; the user can retry. + console.warn(`[oidc] ${provider} request failed`, e); + return { + error: 'Could not reach the sign-in provider. Please try again.', + }; + } } async #finishLogin( diff --git a/src/backend/index.ts b/src/backend/index.ts index c5d7b6078..dd4c8a907 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -18,6 +18,7 @@ */ import { isSpanContextValid, trace } from '@opentelemetry/api'; +import { setDefaultAutoSelectFamilyAttemptTimeout } from 'node:net'; import { puterClients } from './clients'; import { loadConfig } from './config'; import { puterControllers } from './controllers'; @@ -32,8 +33,15 @@ import { installJsonConsole } from './util/jsonConsole.js'; // it stops accepting connections. const GRACEFUL_DRAIN_MS = 90_000; +// Node's happy-eyeballs default (250 ms) abandons a slow IPv4 handshake and +// falls through to IPv6; on hosts with no IPv6 route, one dropped SYN fails +// the whole connect. 2 s outlasts a single SYN retransmit. +const CONNECT_ATTEMPT_TIMEOUT_MS = 2_000; + // if called directly, start the server if (require.main === module) { + setDefaultAutoSelectFamilyAttemptTimeout(CONNECT_ATTEMPT_TIMEOUT_MS); + const config = loadConfig(); // Structured logging: when `log_format: "json"`, replace the global console