fix(oauth): bound handle resolution so a silent authority can't stall sign-in #54
No reviewers
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!54
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix-52-handle-resolution-timeout"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #52.
What was broken
A handle whose domain drops packets on :443 — parked or firewalled apex domains do this routinely — neither answers nor refuses. Resolution therefore ran until
HttpClient.Timeoutelapsed, and sinceAtProtoOAuthServicebuilds its client as a barenew HttpClient(), that was always the 100 s BCL default.Two call sites, and the second one is fatal rather than merely slow:
ResolveHandleToDidAsync(login start) tried HTTPS before DNS, sequentially. A DNS TXT record that answers in ~100 ms went unread for a minute and a half:ResolveHandleAuthoritativeAsync(OAuth callback) awaited both sources and let the eventualTaskCanceledExceptionescape.CompleteAuthorizationAsyncstep 8 catcheswhen (ex is not OperationCanceledException), so this was not absorbed as "handle unverified" — it propagated out ofCompleteCallbackAsyncand bounced the user back to the login page. Measured directly, before this change:Handle verification is not optional in the callback, so any account whose handle sits on such a domain could not sign in at all — it authorized on the PDS, waited 100 s on the redirect, and landed back at
/login._atproto.grandiras.netTXT had the answer the whole time.The change
AuthorizationServerDiscovery.HandleResolutionTimeout(default 5 s, settable). Every handle lookup runs under it.OperationCanceledExceptionsemantics for callers are unchanged.ResolveHandleToDidAsynckeeps its try-in-order precedence (HTTPS, then DNS, then appview); each attempt is simply bounded. Not switched to a race, to avoid queryingdns.googleon every login when HTTPS answers fine.handle_resolution_conflict.AtProtoOAuthService'sHttpClientgets a 30 s timeout, sinceAtProtoOAuthServerOptionsgives a consuming app no way to supply one.Verification
Against the reporting handle, calling the real
ResolveHandleAuthoritativeAsync:grandiras.net(dead :443, DNS TXT present)TaskCanceledException@ 100.0 sdid:plc:vmdqoelhettxubov4hejncg2@ 5.0 sThe residual 5 s is the authoritative resolver waiting out the silent source so it can still detect a DNS/HTTPS conflict — deliberate, and now bounded.
9 new tests in
HandleResolutionTimeoutTestscover: silent HTTPS still resolving from DNS and vice versa, returning within budget, both-silent still raisinghandle_resolution_failed, conflicts still failing closed, caller cancellation still propagating, and the convenience resolver's fall-through order including the appview.Full unit suite: 930 passed.
Not addressed here
ResolveHandleViaHttpsAsyncstill follows redirects with default settings and buffers the whole response before checking thedid:prefix, on a host derived from untrusted input. Worth a separate look — flagged in #52.🤖 Generated with Claude Code
A handle whose domain drops packets on :443 — parked or firewalled apex domains do this routinely — neither answers nor refuses, so resolution ran until HttpClient.Timeout elapsed. 100 s by default, and the client was built as a bare `new HttpClient()`, so that default was always in force. Two paths were affected, and the second one was fatal: - ResolveHandleToDidAsync tried HTTPS before DNS, sequentially, so a DNS TXT record answering in ~100 ms went unread for a minute and a half. - ResolveHandleAuthoritativeAsync awaited both sources and let the eventual TaskCanceledException escape. CompleteAuthorizationAsync step 8 catches `when (ex is not OperationCanceledException)`, so this was not absorbed as "handle unverified" — it propagated and failed the OAuth callback, locking such accounts out of sign-in entirely. Each lookup now runs under HandleResolutionTimeout (default 5 s, settable) and a source that misses its deadline counts as silent, not failed, so the other authority can still supply the answer. Cancellation the caller passes in still propagates. Conflict detection between DNS and HTTPS is unchanged: both are still required to agree when both answer. The OAuth service's HttpClient also gets a 30 s timeout, since options expose no hook for a consuming app to supply its own. Verified against the reporting handle: ResolveHandleAuthoritativeAsync ("grandiras.net") went from throwing TaskCanceledException after 100.0 s to resolving in 5.0s via the DNS record. 930 unit tests pass, 9 new. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
@claude-bot Please resolve the conflicts.
🤖 On it — follow along in the action run; the reply will link the full session transcript.
Conflicts resolved and pushed to
fix-52-handle-resolution-timeout(262d7f2).What the conflict actually was
While this PR was open, #53 landed the same fix on main (
db6ab11), and its implementation is a strict superset of this branch's:HandleResolutionTimeoutbudgetTimeout.InfiniteTimeSpanopt-outHttpClienttimeoutAtProtoOAuthServerOptions.HttpClientTimeout/.HttpClientResolveHandleToDidAsyncSo all three conflicted files —
CHANGELOG.md,AtProtoOAuthService.cs,AuthorizationServerDiscovery.cs— resolve to main's side verbatim.git diff origin/main -- src/ CHANGELOG.mdis now empty.What's left of the PR
Only
HandleResolutionTimeoutTests, trimmed from 9 cases to the 5 that main'sHandleResolutionTestsdoesn't cover:handle_resolution_failedhandle_resolution_conflictResolveHandleAuthoritativeAsyncConvenience_PrefersHttpsOverDnsis dropped, not merged — main races the two lookups, so HTTPS precedence is no longer a property to assert; keeping it would have been a flaky test asserting superseded behaviour. Three others were straight duplicates of main's coverage. No CHANGELOG entry added since the merge leaves this test-only and main's #52 bullets already describe the shipped behaviour.Verified
Installed the .NET 10 SDK in the container (none was present), then:
dotnet build -p:EnableSourceControlManagerQueries=false— 0 errors. Also fixed aCS8602the test stub was emitting on the touched file.dotnet test tests/ATProtoNet.Tests/— 939 passed, 0 failed; the~HandleResolutionfilter runs 15 of them green.Worth deciding: the PR is now a test-only delta on top of a fix that already shipped. Merging it adds the five extra assertions; closing it as superseded by #53 loses only those. Your call — I've left it mergeable either way.
⏱ 3m 33s · 23 turns · tokens: 48.1k in / 10.9k out (+1116.6k cache-read)
📜 Full session transcript