OAuth handle verification re-throws cancellation on a timed-out .well-known/atproto-did probe (and resolution hangs ~100s) for DNS-only handles #42

Closed
opened 2026-06-21 02:08:38 +00:00 by Grandiras · 2 comments
Owner

Summary

When a handle's domain serves no (or an unreachable) /.well-known/atproto-did, OAuth login can hang ~100 s and then throw out of CompleteAuthorizationAsync, even though the identity was already resolvable via DNS. Two related behaviors:

1. CompleteAuthorizationAsync re-throws cancellations during handle verification.
The verification catch is catch (Exception ex) when (ex is not OperationCanceledException). So it swallows e.g. HttpRequestException (connection refused) and leaves IsHandleVerified = false — good — but a TaskCanceledException / OperationCanceledException from a timed-out well-known probe is not caught and propagates out of CompleteAuthorizationAsync. A slow/black-holing handle domain thus turns a "handle just couldn't be verified" case into a hard exception that aborts the whole login — even though the DID from the token exchange (the authoritative sub) is already in hand.

2. Handle resolution waits on the hung HTTP probe even after DNS already succeeded.
StartAuthorizationAsync fires the https://<handle-domain>/.well-known/atproto-did probe alongside the DNS/DoH method. DoH resolves the DID in ~150 ms, but the call still waits on the well-known probe until the default HttpClient.Timeout (100 s) — so resolution takes ~100 s for a perfectly valid DNS-only handle whose domain doesn't serve the well-known.

Repro

Use a handle whose domain has no reachable /.well-known/atproto-did (e.g. the apex points at a parking IP that black-holes the TCP/TLS connection) but does publish a _atproto.<domain> TXT record. StartAuthorizationAsync takes ~100 s. If a ConnectTimeout is set on the HttpClient, CompleteAuthorizationAsync then throws:

TaskCanceledException: The operation was canceled.
 ---> TimeoutException: A connection could not be established within the configured ConnectTimeout.

Suggested fix

  • During handle verification, treat OperationCanceledException / TaskCanceledException from the well-known probe the same as other failures (set IsHandleVerified = false instead of re-throwing). The caller's own CancellationToken cancellation can still be distinguished and propagated.
  • Short-circuit handle resolution on the first authoritative success (don't block on a still-pending well-known probe once DNS/DoH has returned a matching DID), and/or apply a small per-probe timeout independent of HttpClient.Timeout.

Workaround

Map the handle domain to a fast-refusing address for the resolver process, so the probe fails with HttpRequestException (swallowed) rather than a timeout (re-thrown).

Environment

ATProtoNet 0.4.0, net10.0.

### Summary When a handle's domain serves no (or an unreachable) `/.well-known/atproto-did`, OAuth login can **hang ~100 s** and then **throw out of `CompleteAuthorizationAsync`**, even though the identity was already resolvable via DNS. Two related behaviors: **1. `CompleteAuthorizationAsync` re-throws cancellations during handle verification.** The verification catch is `catch (Exception ex) when (ex is not OperationCanceledException)`. So it swallows e.g. `HttpRequestException` (connection refused) and leaves `IsHandleVerified = false` — good — but a **`TaskCanceledException` / `OperationCanceledException`** from a *timed-out* well-known probe is **not** caught and propagates out of `CompleteAuthorizationAsync`. A slow/black-holing handle domain thus turns a "handle just couldn't be verified" case into a hard exception that aborts the whole login — even though the DID from the token exchange (the authoritative `sub`) is already in hand. **2. Handle resolution waits on the hung HTTP probe even after DNS already succeeded.** `StartAuthorizationAsync` fires the `https://<handle-domain>/.well-known/atproto-did` probe alongside the DNS/DoH method. DoH resolves the DID in ~150 ms, but the call still **waits on the well-known probe** until the default `HttpClient.Timeout` (100 s) — so resolution takes ~100 s for a perfectly valid DNS-only handle whose domain doesn't serve the well-known. ### Repro Use a handle whose domain has **no** reachable `/.well-known/atproto-did` (e.g. the apex points at a parking IP that black-holes the TCP/TLS connection) but **does** publish a `_atproto.<domain>` TXT record. `StartAuthorizationAsync` takes ~100 s. If a `ConnectTimeout` is set on the `HttpClient`, `CompleteAuthorizationAsync` then throws: ``` TaskCanceledException: The operation was canceled. ---> TimeoutException: A connection could not be established within the configured ConnectTimeout. ``` ### Suggested fix - During handle verification, treat `OperationCanceledException` / `TaskCanceledException` from the well-known probe the same as other failures (set `IsHandleVerified = false` instead of re-throwing). The caller's own `CancellationToken` cancellation can still be distinguished and propagated. - Short-circuit handle resolution on the first authoritative success (don't block on a still-pending well-known probe once DNS/DoH has returned a matching DID), and/or apply a small per-probe timeout independent of `HttpClient.Timeout`. ### Workaround Map the handle domain to a fast-refusing address for the resolver process, so the probe fails with `HttpRequestException` (swallowed) rather than a timeout (re-thrown). ### Environment ATProtoNet 0.4.0, net10.0.
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/37); the reply will link the full session transcript.
Collaborator

Pushed. Final summary:

Fixed point 1 on branch claude/issue-42.

What was wrong

CompleteAuthorizationAsync distinguished verification failures by exception type: catch (Exception ex) when (ex is not OperationCanceledException). A refused connection (HttpRequestException) left IsHandleVerified = false, but a timed-out probe (TaskCanceledException — parked handle domain, or any host once a ConnectTimeout is set on the HttpClient) escaped and failed the whole login, even though the authoritative DID (the token response's sub) was already in hand.

The still-live path for that today is the DID-document fetch at the top of step 8 (plc.directory), which is unbounded by HandleResolutionTimeout; the well-known probe itself is already OCE-safe inside the discovery layer after #52.

Changes (src/ATProtoNet/Auth/OAuth/OAuthClient.cs)

  • Both verification catch guards now key on who cancelled, not on exception type: catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) → rethrow; everything else (including probe timeouts) → log and leave the handle unverified.
  • Caller cancellation on that path now disposes the pending DPoP key, which leaked whenever an exception escaped step 8.
  • VerifyDidToAuthServerConsistencyAsync got the same distinction — caller cancellation surfaces as cancellation instead of being wrapped in a verification_failed OAuthException. A probe that merely timed out still fails closed there, since that check is a real security boundary.

Point 2 (the ~100 s hang) was already fixed

StartAuthorizationAsync short-circuits on first authoritative success via RaceAuthoritativeLookupsAsync, and every round is bounded by HandleResolutionTimeout (5 s default, OAuthOptions.HandleResolutionTimeout) — that landed for #52, covered by HandleResolutionTests / HandleResolutionTimeoutTests. This commit covers the callback half: a hung probe there now ends in an unverified handle rather than an exception. Note the callback's DID-document fetch still inherits HttpClient.Timeout; if you'd like that bounded too, it's a small follow-up, but it would silently downgrade verification on slow-but-working networks, so I left that call to you.

Verified

  • New tests/ATProtoNet.Tests/Auth/OAuth/OAuthCallbackHandleVerificationTests.cs — 5 tests driving the real flow end-to-end (discovery → PAR → token exchange → callback) against a stub deployment: DID-doc timeout completes unverified; both handle authorities timing out completes unverified; silent well-known + answering DNS still verifies (the reported DNS-only case); handle resolving to another DID stays unverified; caller cancellation propagates.
  • Confirmed the tests aren't vacuous: reverting OAuthClient.cs to main makes Complete_DidDocumentFetchTimesOut_CompletesUnverifiedInsteadOfThrowing fail with the propagated TaskCanceledException.
  • Full unit suite: 999 passed, 0 failed (dotnet test tests/ATProtoNet.Tests/, .NET SDK 10.0.302 installed in the container).
  • CHANGELOG.md updated under [Unreleased] → Fixed.

⏱ 7m 0s · 37 turns · tokens: 82.9k in / 26.2k out (+2587.0k cache-read)
📜 Full session transcript
→ Opened PR: #56

Pushed. Final summary: Fixed **point 1** on branch `claude/issue-42`. ## What was wrong `CompleteAuthorizationAsync` distinguished verification failures by *exception type*: `catch (Exception ex) when (ex is not OperationCanceledException)`. A refused connection (`HttpRequestException`) left `IsHandleVerified = false`, but a **timed-out** probe (`TaskCanceledException` — parked handle domain, or any host once a `ConnectTimeout` is set on the `HttpClient`) escaped and failed the whole login, even though the authoritative DID (the token response's `sub`) was already in hand. The still-live path for that today is the DID-document fetch at the top of step 8 (`plc.directory`), which is unbounded by `HandleResolutionTimeout`; the well-known probe itself is already OCE-safe inside the discovery layer after #52. ## Changes (`src/ATProtoNet/Auth/OAuth/OAuthClient.cs`) - Both verification catch guards now key on **who cancelled**, not on exception type: `catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)` → rethrow; everything else (including probe timeouts) → log and leave the handle unverified. - Caller cancellation on that path now **disposes the pending DPoP key**, which leaked whenever an exception escaped step 8. - `VerifyDidToAuthServerConsistencyAsync` got the same distinction — caller cancellation surfaces as cancellation instead of being wrapped in a `verification_failed` `OAuthException`. A probe that merely timed out still fails closed there, since that check is a real security boundary. ## Point 2 (the ~100 s hang) was already fixed `StartAuthorizationAsync` short-circuits on first authoritative success via `RaceAuthoritativeLookupsAsync`, and every round is bounded by `HandleResolutionTimeout` (5 s default, `OAuthOptions.HandleResolutionTimeout`) — that landed for #52, covered by `HandleResolutionTests` / `HandleResolutionTimeoutTests`. This commit covers the callback half: a hung probe there now ends in an unverified handle rather than an exception. Note the callback's DID-document fetch still inherits `HttpClient.Timeout`; if you'd like that bounded too, it's a small follow-up, but it would silently downgrade verification on slow-but-working networks, so I left that call to you. ## Verified - New `tests/ATProtoNet.Tests/Auth/OAuth/OAuthCallbackHandleVerificationTests.cs` — 5 tests driving the real flow end-to-end (discovery → PAR → token exchange → callback) against a stub deployment: DID-doc timeout completes unverified; both handle authorities timing out completes unverified; silent well-known + answering DNS still verifies (the reported DNS-only case); handle resolving to another DID stays unverified; caller cancellation propagates. - Confirmed the tests aren't vacuous: reverting `OAuthClient.cs` to `main` makes `Complete_DidDocumentFetchTimesOut_CompletesUnverifiedInsteadOfThrowing` fail with the propagated `TaskCanceledException`. - Full unit suite: **999 passed, 0 failed** (`dotnet test tests/ATProtoNet.Tests/`, .NET SDK 10.0.302 installed in the container). - `CHANGELOG.md` updated under `[Unreleased] → Fixed`. --- ⏱ 7m 0s · 37 turns · tokens: 82.9k in / 26.2k out (+2587.0k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-42/run-37.md) → Opened PR: https://git.grandiras.net/Grandiras/ATProto.NET/pulls/56
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/ATProto.NET#42
No description provided.