fix(login): un-break sign-in — right-size the timeouts, 503 for outages, and make the slow hop visible - #455
Merged
Conversation
Sign-in fails in prod with a bare `timeout of 15000ms exceeded` in the
browser console — no HTTP status, no message, nothing in the UI. Two
defects on the server-brokered login chain combine to produce that.
1. Nothing bounded the CHAIN, only each link.
A login is many hops: 2-3 flow-executor stages (each following up to 10
redirects) then the authorize->code chain (up to 10 more), then the
openid-client token exchange + userinfo. AUTHENTIK_FLOW_TIMEOUT_MS
(10s) capped each fetch INDIVIDUALLY and every hop got a fresh full
allowance, while OIDC_HTTP_TIMEOUT_MS (15s) applies per openid-client
call, twice. The browser gives the whole call 15s (LOGIN_TIMEOUT_MS).
So the server's budget exceeded the client's by a wide margin and the
client always aborted first — which is why the failure carries no
status and no message, and why the labelled server-side diagnostics
("hop timed out", naming the exact stage) never got produced. The
symptom was undiagnosable by construction.
Adds a Deadline shared by every hop of one attempt. Each hop's
allowance is min(per-hop cap, time actually remaining), and the token
exchange is held to what is left, so the server now answers INSIDE the
browser's window with a stage-labelled error instead of being raced.
2. Redirect hops leaked their connections.
undici keeps a socket checked out until the body is consumed or
cancelled. Every redirect hop reads only `location`, and authorize hops
never read a body at all, so each one pinned its connection for the
rest of the request and later hops queued behind their own
predecessors. That is the shape of the intermittent multi-second stalls
this module has been given timeout band-aids for twice (#362, #371) —
not one slow hop, but self-inflicted head-of-line blocking.
Cancels the body on every hop whose payload is discarded.
The deadline defaults to 12s, comfortably under the client's 15s, and is
tunable via AUTHENTIK_LOGIN_DEADLINE_MS without a rebuild.
Note this makes a stalled sign-in FAIL FAST AND LOUDLY rather than making
a slow hop fast: the remaining question is which hop is slow in prod, and
these changes are what put that in the logs. AuthentikUnavailableError
still maps to 401 PROVIDER_UNAVAILABLE, which the frontend does not
distinguish — worth a follow-up, left alone here as a contract change.
Tests: 4 new cases pin the chain-level bound (budget exhaustion names the
stage; a hop is clamped to remaining time, not the 10s cap; the token
exchange is bounded; bodies are released). Full backend/security suite is
unchanged against master — the 13 pre-existing suite failures (unrelated
referenceApp.ts type errors) neither grew nor shrank.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhfKNASYBD9aS3Wu6RUPfZ
Contributor
Automated code review (gate-code-review)Credit balance is too low Report-only — this check never blocks merge. |
…ideal one The client bound was 15s, chosen (#371) to fail FAST on a slow attempt rather than leave the submit button stuck on "Signing in…". That traded the wrong way. The documented slow path is 16-30s, so a 15s bound did not fail slow sign-ins fast — it failed sign-ins that would otherwise have SUCCEEDED. A bound below the known worst case is not a safety net, it is an outage, and that is what users are hitting. Client 15s -> 45s (VITE_LOGIN_TIMEOUT_MS), server budget 12s -> 40s (AUTHENTIK_LOGIN_DEADLINE_MS). 45s covers the documented worst case with real margin, and the server budget stays UNDER the client's so the server still answers first with a labelled error rather than being raced to an anonymous abort — that ordering is the invariant to preserve if either is retuned later. The wait is not pleasant. LoginPage already shows a "still working…" hint at 8s, so a slow-but-succeeding attempt does not look frozen. Both values stay env-overridable so the pair can be tightened again — without a rebuild — once the underlying slow hop is actually fixed, which is the real cure this is buying time for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QhfKNASYBD9aS3Wu6RUPfZ
Contributor
Automated code review (gate-code-review)Credit balance is too low Report-only — this check never blocks merge. |
A 401 is a statement about the caller's credentials. The Security API was
using it for "the identity provider is unreachable, too slow, or handed
us a flow we cannot drive server-side" — a SERVICE condition that says
nothing about what the user typed.
The cost is not academic. The login UI had no way to tell a real
rejection from a provider stall, so its 401 message had to hedge across
both ("Incorrect email or password, OR the sign-in service is temporarily
unavailable"). During an incident every user is told their password might
be wrong; some will change a password that was never the problem. And
because the outage is reported as an auth failure, it hides inside
auth-failure metrics instead of showing up as unavailability.
This aligns three things that had already drifted apart — the API is the
one that was wrong:
- the contract's own status map declares PROVIDER_UNAVAILABLE -> 503
(tests/security-api/referenceApp.ts)
- the legacy /api/auth/* route already returns 503 for both of these
errors (routes/auth.ts)
- routes/security.ts — the route the login form actually calls — was
the sole outlier at 401
Also adds Retry-After (both errors are transient from the caller's point
of view), declares 503 on createSession/signup in the frozen contract as
a documented ServiceUnavailable response, and splits the frontend's
hedged message in two so each case now says one true thing: 401 is a
plain rejected-credential message, 503 explicitly reassures the user
their details are fine.
Unblocks tests/security-routes.test.ts as a side effect. It failed to
COMPILE on master (`res.headers['set-cookie']` is `string | string[]`, so
the unguarded `.join` was a type error), which took the whole file down —
all 34 tests in it have been silently not running. Normalising the header
brings them back, plus the 2 added here.
Tests: 2 new API cases (both provider errors -> 503 + Retry-After, and
the provider's raw message is not echoed to an unauthenticated caller), 1
new UI case (a 503 never blames the user's credentials), and the existing
ambiguous-401 UI assertion updated to the now-unambiguous wording.
backend/security is unchanged against master except security-routes
FAIL -> PASS; frontend is unchanged with one additional passing test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhfKNASYBD9aS3Wu6RUPfZ
Task 3 of the sign-in investigation: find the slowness. The blocker was that the evidence has never existed when it mattered. Per-hop timings were already instrumented — at `logger.debug`. LOG_LEVEL defaults to `info` and is not set anywhere in the chart, so in production that detail has always been OFF. Answering "which hop is slow?" needed a config change or a redeploy, during an incident, on a path that only misbehaves intermittently. That is why this module has collected two timeout band-aids (#362, #371) and no diagnosis. Adds a slow-hop threshold (the slow-query-log pattern): every hop is still debug on the fast path, but a hop over AUTHENTIK_SLOW_HOP_WARN_MS (1s) is logged at WARN with its stage label, status and elapsed time. No LOG_LEVEL change, no redeploy, no spam on healthy logins. The token-exchange stage is now timed the same way — it is two openid-client round-trips and just as able to be the slow one, so it must not be the one stage missing from the breakdown. Also reports a slow SUCCESS. A login that succeeds at 25s is exactly the failure mode that broke sign-in: it never errors, so nothing alerts, and it only became visible once a client bound tripped underneath it. Over AUTHENTIK_LOGIN_WARN_MS (8s, above the ~5.5s fast path) that now logs at WARN, and the per-hop WARNs say which stage owned the time. Leading hypothesis, written into the code so the logs can refute it rather than confirm a guess: every hop targets the same in-cluster origin, and this pod's own dnsConfig documents CoreDNS "intermittently stalls lookups in 5s/10s retry multiples" (capped ~2s by timeout:1/ attempts:2) with the note that this service "resolves authentik-server on every auth flow". DNS resolves per NEW CONNECTION, and the leaked response bodies fixed earlier in this PR forced a new connection per hop — so the stall was multiplied by hop count. ~6 hops x ~2s accounts for most of the observed 16-30s, and draining bodies lets undici reuse one keep-alive socket per origin, collapsing it to at most one. If the labelled elapsedMs still shows connect/DNS dominating, the next step is an explicit keep-alive dispatcher pinned to the Authentik origin. This is instrumentation, not a proven cure: it is what turns the next slow sign-in into a named stage instead of another guess. Tests: 1 new case — a slow hop on a login that SUCCEEDS emits exactly one WARN carrying the stage label and elapsed time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QhfKNASYBD9aS3Wu6RUPfZ
Contributor
Automated code review (gate-code-review)Credit balance is too low Report-only — this check never blocks merge. |
Contributor
Automated code review (gate-code-review)Credit balance is too low Report-only — this check never blocks merge. |
33 tasks
izzywdev
pushed a commit
that referenced
this pull request
Jul 29, 2026
#455 ("un-break sign-in — right-size the timeouts, 503 for outages") rewrote authentikPassword.ts and its test heavily enough that git could not align them at all — both came through as whole-file conflicts (line 1 to EOF). Rather than hand-merge two ~900-line files, I took master's versions wholesale, so ALL of #455's login fix is preserved verbatim, then re-applied my tenant edits on top: - authentikBaseUrl(), redirectUri(), enrollmentFlowSlug() and the admin token resolve from the current tenant instead of process.env - oidcService -> getOidcService() - the test's oidc mock exposes getOidcService() backed by the same object #455's three NEW timeout knobs (AUTHENTIK_LOGIN_DEADLINE_MS, AUTHENTIK_SLOW_HOP_WARN_MS, AUTHENTIK_LOGIN_WARN_MS) are deliberately left on the environment — like AUTHENTIK_FLOW_TIMEOUT_MS they are tuning values, not tenant-identifying configuration. Verified after the merge: tsc --noEmit clean; 129 passed / 2 failed across the tenant registry plus the 8 Authentik/OIDC suites. The 2 are the same pre-existing failures that reproduce on clean master. The count rose from 124 to 129 because #455 added 5 tests to the password-login suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session-Id: 55a394fa-d5ba-4da4-b41f-b0ef56fa6ddf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📋 Description
Sign-in at https://app.fuzefront.com/login fails with a bare
AxiosError: timeout of 15000ms exceeded— no HTTP status, no message, nothing actionable. Four commits: un-break it, then fix the two design faults that made it break and made it undiagnosable, then instrument the underlying slowness so it can actually be found.1. The timeouts were sized to the ideal path, not the real one — this is the outage
The client bound was 15s, added in #371 to fail fast rather than leave the button on "Signing in…". That traded the wrong way. The documented slow path is 16–30s, so a 15s bound did not fail slow sign-ins fast — it failed sign-ins that would otherwise have succeeded. A bound below the known worst case is not a safety net, it is an outage.
Client 15s → 45s, server budget → 40s.
LoginPagealready shows a "still working…" hint at 8s. Both env-overridable so they can be tightened again, without a rebuild, once the slow hop is fixed.2. Nothing bounded the chain — only each individual link
A login is many sequential hops: 2–3 flow-executor stages (each following up to 10 redirects), the authorize→code chain (up to 10 more), then openid-client's token exchange + userinfo.
AUTHENTIK_FLOW_TIMEOUT_MSOIDC_HTTP_TIMEOUT_MSLOGIN_TIMEOUT_MS(browser)Every hop got a fresh full allowance, so the server's budget exceeded the client's many times over and the client always aborted first. That is why the failure carries no status and no message: the server was still working, and its labelled diagnostics never got produced. Undiagnosable by construction.
Adds a
Deadlineshared by every hop of one attempt — each hop getsmin(per-hop cap, time remaining), and the token exchange is held to what's left, so the server now answers inside the browser's window with a stage-labelled error.Also fixes a connection leak. undici keeps a socket checked out until the body is consumed or cancelled. Redirect hops read only
location, and authorize hops never read a body at all — so each hop pinned its connection and later hops queued behind their own predecessors. Bodies are now cancelled on every hop whose payload is discarded.3. A provider outage returned 401 — a lie about the user's credentials
401 is a statement about the caller's credentials; the API used it for "the identity provider is unreachable or too slow". The login UI therefore had to hedge — "Incorrect email or password, OR the sign-in service is temporarily unavailable" — telling every user during an incident that their password might be wrong. Some will change a password that was never the problem. And the outage hides inside auth-failure metrics instead of showing as unavailability.
The API was the one that was wrong; three things had already drifted apart:
PROVIDER_UNAVAILABLE: 503(tests/security-api/referenceApp.ts)/api/auth/*route already returns 503 for both errors (routes/auth.ts)routes/security.ts— the route the login form actually calls — was the sole outlier at 401Now 503 +
Retry-After, declared in the frozen contract as aServiceUnavailableresponse oncreateSession/signup, and the UI's hedged message split in two so each case says one true thing.4. The slow hop has never been visible in production
Per-hop timings were instrumented — at
logger.debug.LOG_LEVELdefaults toinfoand is not set anywhere in the chart, so in prod that detail has always been off. Finding the slow hop required a config change or redeploy, during an incident, on an intermittent path. That is why this module has two timeout band-aids and no diagnosis.Adds the slow-query-log pattern: a hop over
AUTHENTIK_SLOW_HOP_WARN_MS(1s) logs at WARN with its stage label; the fast path stays quiet. A login that succeeds overAUTHENTIK_LOGIN_WARN_MS(8s) also warns — a 25s success is exactly the failure mode here: it never errors, so nothing alerts, until a client bound trips underneath it.Leading hypothesis, written into the code so the logs can refute it rather than confirm a guess: every hop targets the same in-cluster origin, and this pod's own
dnsConfigdocuments CoreDNS "intermittently stalls lookups in 5s/10s retry multiples" (capped ~2s) with the note that this service "resolves authentik-server on every auth flow". DNS resolves per new connection, and the leaked bodies forced a new connection per hop — so the stall was multiplied by hop count. ~6 hops × ~2s accounts for most of the observed 16–30s. Draining bodies should let undici reuse one keep-alive socket, collapsing that to at most one. If the labelledelapsedMsstill shows connect/DNS dominating, the next step is an explicit keep-alive dispatcher pinned to the Authentik origin.🔄 Type of Change
PROVIDER_UNAVAILABLEmoving 401 → 503 is a status-code change onPOST /v1/security/session. Not marked breaking: it is an error path only, it aligns the implementation with the contract it already declared, and the sibling/api/auth/*route has always returned 503. Any client treating it as "credentials rejected" was already acting on a wrong signal.🧪 Testing
Test Configuration:
enginestargets ≥24; CI covers 24.x)Test Instructions
Results, measured by diffing full-suite results with and without this change:
security-routes.test.tsFAIL → PASS. That suite failed to compile on master (res.headers['set-cookie']isstring | string[], so the unguarded.joinwas a type error), which took the whole file down — all 34 of its tests have been silently not running. Normalising the header brings them back, plus 2 added here.No results with a severity of 'error' found!referenceApp.tstype errors) are untouched and neither grew nor shrank.🔧 Implementation Details
Changes Made
services/api.ts—LOGIN_TIMEOUT_MS15s → 45spages/LoginPage.tsx— handle 503PROVIDER_UNAVAILABLEwith an outage message that never mentions credentials; 401 becomes a plain rejected-credential message (still not naming which field — that would leak account existence)services/authentikPassword.ts—Deadline(whole-request budget,min(cap, remaining)per hop);withDeadlinebounds the token exchange, with a.catchon the in-flight promise so a late rejection can't become an unhandled rejection;drainBodyreleases discarded bodies;recordHopslow-hop WARN; slow-success WARNroutes/security.ts—PROVIDER_UNAVAILABLE→ 503 +Retry-Afterpackages/security/openapi.yaml— newServiceUnavailableresponse, referenced fromcreateSession+signuppackages/security/src/generated.tswas already 570 lines stale on master (verified by regenerating on a clean tree); it is rebuilt byprebuild, so it is deliberately left out rather than sweeping unrelated drift into this PR. Flagging it as separate cleanup.Code Quality
Prettier reports the two largest touched files as non-conformant — pre-existing on
master(verified on a clean tree).--writewould reformat whole files and bury the diff, so formatting is left as found.Documentation
📋 Checklist
Pre-submission
Code Quality
eslintcannot resolve a config here. CI'sgate-lintcovers it.Security Checklist
AuthentikUnavailableError, so a budget-exhausted sign-in is rejected, never allowed through. Off-origin redirect guards untouched. The 401 message still declines to say which field was wrong.📝 Additional Notes
Deployment Notes
masteris deploy-on-push — merge in a deploy window per the repo's hardening convention.New knobs, all optional:
AUTHENTIK_LOGIN_DEADLINE_MS(40s),AUTHENTIK_SLOW_HOP_WARN_MS(1s),AUTHENTIK_LOGIN_WARN_MS(8s),VITE_LOGIN_TIMEOUT_MS(45s).Future Work
authentikPassword: SLOW hopnames the stage. That is the input to the real fix.@claude, per repo policy).packages/security/src/generated.tsis 570 lines stale against its spec.backend/securitysuites (referenceApp.tsvsIdentityProvider) deserve their own PR; one more likesecurity-routesmay be hiding tests that aren't running.Questions for Reviewers
app.fuzefront.com(403 on CONNECT), so everything here is derived from the code and the repo's own recorded history, not a live reproduction. The DNS hypothesis in particular is unconfirmed — please treat the instrumentation as the way to settle it.withDeadlinestops waiting on the token exchange but cannot cancel it; the in-flight call completes in the background. Bounding the response was the goal — flagging the trade-off.📊 Performance Impact
Bundle Size Impact:
Runtime Performance:
elapsedMsfields in prod.🌐 Browser Compatibility
No new browser APIs — an error-branch and a constant.
🔄 Backwards Compatibility