From 76bba368ac25c93f3b1acef47feebdda1898e28b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 11:10:56 +0000 Subject: [PATCH] fix(security): pin a keep-alive Agent to the Authentik origin for server-side login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every hop in the server-brokered password-login chain (flow-executor stages, authorize hops, admin-API set_password) lands on the same in-cluster Authentik origin, several times per request. The default fetch dispatcher's keep-alive window is tuned for general traffic and can lapse between hops when CoreDNS's documented multi-second stalls (see the recordHop comments in authentikPassword.ts) space them out, forcing a fresh connection — and a fresh DNS lookup — per hop. A dedicated undici Agent with a longer keep-alive holds one socket open across the whole chain, so only the first hop pays for DNS+connect. This was the last documented, unapplied mitigation in that file's own incident notes (#362, #371) for the intermittent 16-30s+ sign-in stalls that show up as an outright client-side timeout for some accounts. --- backend/security/package.json | 1 + .../src/services/authentikPassword.ts | 24 ++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/backend/security/package.json b/backend/security/package.json index 1a75729a..0119e555 100644 --- a/backend/security/package.json +++ b/backend/security/package.json @@ -27,6 +27,7 @@ "openid-client": "^5.6.5", "permitio": "^2.7.4", "pg": "^8.11.5", + "undici": "^7.28.0", "uuid": "^9.0.1", "zod": "3.22.4", "pino": "^9.5.0" diff --git a/backend/security/src/services/authentikPassword.ts b/backend/security/src/services/authentikPassword.ts index b49ed9ba..5fbf9a75 100644 --- a/backend/security/src/services/authentikPassword.ts +++ b/backend/security/src/services/authentikPassword.ts @@ -22,6 +22,7 @@ * fail closed with a clear error rather than trying to drive arbitrary stages. */ import { generators } from 'openid-client' +import { Agent } from 'undici' import { getOidcService } from './oidc' import { currentTenant } from '../providers/authentik/tenants' import { User } from '../types/shared' @@ -186,6 +187,23 @@ function drainBody(res: Response): void { void res.body?.cancel().catch(() => undefined) } +/** + * Every hop in this module lands on the SAME in-cluster Authentik origin + * (authentikBaseUrl()) — a handful of times per login, in quick succession. + * Node's default fetch dispatcher already pools keep-alive connections, but + * with a short keepAliveTimeout tuned for general-purpose traffic; back-to-back + * hops separated by CoreDNS's documented multi-second stalls (see recordHop + * above) can outlive that default and force a fresh connection — and a fresh + * DNS lookup — per hop. A dedicated Agent with a longer keep-alive holds the + * socket open across a whole login/signup chain, so at most the FIRST hop pays + * for DNS + connect. + */ +const authentikAgent = new Agent({ + keepAliveTimeout: 30_000, + keepAliveMaxTimeout: 60_000, + connections: 32, +}) + /** * `fetch` with a hard AbortController deadline. On timeout the AbortError is * normalised to a labelled AuthentikUnavailableError carrying the elapsed time @@ -208,7 +226,11 @@ async function fetchWithTimeout( const started = Date.now() const timer = setTimeout(() => controller.abort(), timeoutMs) try { - return await fetch(url, { ...init, signal: controller.signal }) + return await fetch(url, { + ...init, + signal: controller.signal, + dispatcher: authentikAgent, + }) } catch (err) { const e = err as Error if (e.name === 'AbortError') {