feat(identity)!: resolver abstraction, identity caching and SSRF-safe fetching #160
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
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!160
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-120-identity"
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 #120.
Identity resolution was uncached, interface-less, written three times (OAuth discovery,
DidResolver, the space server's cache) and not SSRF-hardened. The worst consequence:FirehoseVerifierresolved the DID document over HTTP for every commit. This PR gives it one abstraction, one cache, one fetch policy and one exception type, and rewires every consumer.Abstraction
IDidResolver(ResolveAsync, plus default-implementedRefreshAsync/InvalidateAsync),IHandleResolver,IIdentityResolverandResolvedIdentity(Did, Handle?, HandleVerified, Uri? PdsEndpoint, DidDocument). Implementations:DidResolver(plc/web dispatch),PlcClient,DidWebResolver,HandleResolver,IdentityResolver(bidirectional check;Handle.Invalid=handle.invalidon failure;CreateDefault()builds the stack).DidDocument(ATProtoNet.Identity):Idis aDid, init-onlyIReadOnlyLists,DidDocumentService(wasServiceEndpoint),GetServiceEndpoint(fragment, type?),GetVerificationKey(fragment),GetHandle()(the firstat://entry, validated),GetPdsEndpoint()→Uri?; lookups take the first entry by id, as the reference implementation does. Tolerates a single-string@contextand structuredserviceEndpoint; anulllist reads as empty, anullentry is malformed. The OAuthDidDocument/DidServicecopies are deleted.TryGetServiceEndpoint(fragment, type, out endpoint)andTryGetVerificationKey(fragment, out didKey)returnDidDocumentEntryStatus.Found/Absent/Malformed(a non-http(s) or structured endpoint, or the first entry with the id having another type; an unknown key type, missing or undecodable key material). TheGet…methods keep the lenient "unusable reads as absent" behaviour; Spaces does not enforce the strict rule yet (left to #133).DidResolutionException(kind)(anAtProtoException) replacesPlcException/DidWebException, and covers network failures, timeouts and bodies that break off or do not decode too.dns.googleDoH code, hard-codedplc.directoryand thebsky.socialAppView fallback),OAuthClientidentity call sites (DID→PDS→AS consistency and handle verification now come from oneResolvedIdentity),FirehoseVerifier,TypedFirehoseConsumer,SpaceCredentialProvider,SpaceSyncer,SpaceAuthority(usesGetServiceEndpoint), and the space server (verifiers,SpaceWriteNotifier,SimpleSpaceManagingAppClient).ISpaceDidDocumentResolver/CachingSpaceDidDocumentResolverare replaced by anIDidResolver(see Caching for the space server's own cache); resolution failures still surface asSpaceVerificationException(NotAuthorized)/SpaceCredentialException, which fixesPlcExceptionescaping the Spaces error contracts (debloat bug 1.7).AddAtProtoIdentity()(Server) registers the resolvers as singletons;AddAtProtoSpacescalls it, and Blazor'sAtProtoOAuthServicetakes a registeredIIdentityResolver.Caching
CachingDidResolver: bounded LRU (10k), stale-after 1 h (served + background refresh) / expire-after 1 day (defaults of@atproto/identity), failures remembered 1 min in a separate LRU (FailureCapacity, 1,000, so they never evict documents), single-flight per DID (a shared fetch survives one caller cancelling),InvalidateAsync(detaches an in-flight fetch so a pre-invalidation result is not stored), stale-on-error with a retry back-off, optionalIDistributedCache(read on memory miss, written after fetch, errors logged and ignored, entries under the wrong key rejected; a write still in flight when the DID is invalidated is removed again once it lands; invalidation does not reach other instances' memory, documented).FirehoseVerifier,SpaceSyncerand the delegation-token, credential and service-auth verifiers.RefreshAsyncis rate-limited per DID (MinRefreshInterval, 30 s, counted from the last attempt, failed ones included) so forged signatures cannot turn into a directory request each.TypedFirehoseConsumerinvalidates on#identity(processing extracted into an internalAcceptAsyncto test it).AtProtoCrypto.VerifySignature→DidKeyCache; verified, no change needed.CachingDidResolver, registered keyed underSpaceServerExtensions.DidResolverKeyand configured by the newSpaceServerOptions.DidCache: a hard 5-minute lifetime (StaleAfter = ExpireAfter = 5 min), so a rotated-out key is never served past 5 minutes, because its documents back credential checks and a space server rarely follows#identity. RaisingExpireAfteropts into stale-while-revalidate (fetch off the request path, but the old key keeps verifying until the background fetch lands — up toExpireAfteron an idle server); the option's docs anddocs/spaces.mdexplain the trade-off. It fetches under the sharedIdentityResolverOptions. Everything else sharesAddAtProtoIdentity's general cache (1 h / 1 day).SpaceServiceAuthVerifier.IsRepoHostAsyncresolves both DIDs concurrently (keeps returningfalsewithout awaiting the service when the repo has no host).Hardened fetching (SSRF)
IdentityNetworkPolicy: a sharedSocketsHttpHandlerwhoseConnectCallbackresolves the host, refuses if any address is non-public (loopback, RFC1918, link-local/metadata, CGNAT, multicast, documentation, benchmarking, 6to4/Teredo, ULA, …; IPv4-mapped and NAT64 judged by the IPv4) and connects to exactly the checked addresses (no rebinding window). No proxy and no redirects on the hardened handler (a caller client that followed one is refused too). HTTPS only. At most 64 identity fetches in flight per process.did:web: hostname only (FQDN, no IP literal, no path), port only onlocalhost, andlocalhostonly under the opt-out.did:web:internal.corp%3A6379is now refused (debloat bug 1.6).OAuthExceptions), withOAuthOptions.AllowPrivateNetworks/MetadataHttpClientandAtProtoOAuthServerOptions.AllowPrivateNetworks. PAR/token/revocation requests are not yet covered (#132). The space server'sAtProtoSpacesnamed client runs on the hardened handler too.IdentityResolverOptions.AllowPrivateNetworks(plain HTTP, private addresses,did:web:localhost%3Aport,.testhandles). A caller-suppliedHttpClientis used as is (the upstream "inject your own fetch" escape hatch), which is also how a single trusted private PLC mirror is reached without opening the policy globally.DnsOverHttpsUrl, defaulthttps://dns.google/resolve,nulldisables) plus HTTPS well-known, concurrently under one budget; fail closed on DNS/HTTPS conflict (kept) and now also on two distinctdid=TXT values; the well-known follows up to 3 same-host HTTPS redirects itself; split TXT character-strings reassembled; reserved TLDs never looked up.Lexicon and PLC
IdentityClient.ResolveIdentityAsync,ResolveDidAsync,RefreshIdentityAsync;IdentityInfo(defs#identityInfo, aLexObject),ResolveDidResponse;XrpcErrors.HandleNotFound/DidNotFound/DidDeactivated. Models kept inLexicon/Com/AtProto/Identity/.PlcClient(IdentityResolverOptions?)/PlcClient(HttpClient, Uri directoryUrl, IdentityResolverOptions?); noBaseAddressreliance. Audit, log, last, data and health APIs kept and now tested against plc.directory fixtures. AddedExportAsync(after, count)(sequenced JSONL) andStreamExportAsync(cursor)(WebSocket through the hardened handler;PlcExportStreamExceptioncarriesOutdatedCursor/FutureCursor/ConsumerTooSlow).PlcData(→PlcOperation),PlcOperationService(→ LexiconDidService);PlcAuditEntryalso carries export entries (Seq,Type,Nullifiednowbool?).PlcOperationBuildertyped (Handle,Did), andATProtoNet.Identityis now covered byTypedIdentifierGuardTests.Breaking changes (migration)
Auth.OAuth.DidDocument/DidServiceremoved;ServiceEndpoint→DidDocumentService;DidDocumentis init-only and typed → build with initializers;GetPdsEndpoint()?.OriginalStringfor a string.PlcException/DidWebException(+ kinds) →DidResolutionException/DidResolutionErrorKind.ResolveDidAsync(string)→ResolveAsync(Did);FirehoseVerifier(DidResolver)→FirehoseVerifier(IDidResolver),FirehoseVerifier()→FirehoseVerifier(IdentityResolverOptions? = null);SpaceCredentialProvidertakesIDidResolver?.PlcClientconstructors (explicit directory URL),Did-typed methods,GetPlcDataAsync→PlcOperation,SubmitOperationAsync→Did,PlcData/PlcOperationServiceremoved,PlcAuditEntry.Nullified→bool?.PlcOperationBuilder.CreateGenesisOperation(…, Handle, …),DeriveDid→Did,PlcSignedOperation.Did→Did.AuthorizationServerDiscovery:ResolveHandleToDidAsync,ResolveHandleAuthoritativeAsync,ResolvePdsFromDidAsync,FetchDidDocumentAsync,HandleResolutionTimeoutremoved →discovery.IdentityResolver.ResolveAsync(…); optionalIIdentityResolverctor arg;OAuthOptions.IdentityResolver;AtProtoOAuthServicector gains an optionalIIdentityResolver.HttpClientpassed toOAuthClient;AuthorizationServerDiscoveryisIDisposableand takesHttpClient?(null = policy) plusallowPrivateNetworks; metadata failures areOAuthException(invalid_server_url/metadata_fetch_failed/invalid_metadata) instead ofHttpRequestException/JsonException→ setAllowPrivateNetworksfor a local PDS,MetadataHttpClientfor a custom/test client.ISpaceDidDocumentResolver,CachingSpaceDidDocumentResolver,SpaceDidDocumentResolverExtensions,SpaceServerOptions.DidDocumentCacheLifetimeremoved → register a keyedIDidResolverunderSpaceServerExtensions.DidResolverKey; setSpaceServerOptions.DidCache.StaleAfterwhereDidDocumentCacheLifetimewas set.SpaceSyncer(space, store, IDidResolver);SpaceSyncer.ResolveSigningKeyAsyncremoved;SpaceAuthority.GetHostEndpoint/GetServiceEndpoint→Uri?.Behaviour changes (agreed with the coordinator): OAuth login no longer falls back to the
bsky.socialAppView and waits for both handle authorities within the budget (a parked HTTPS apex costs up to 5 s instead of first-answer-wins; listed under Changed);GetServiceEndpointtreats a value that is not an absolute http(s) URL as absent (so a malformed#atproto_space_hoststill falls back to#atproto_pdsuntil #133 uses the strict lookup); the space server keeps its previous hard 5-minute document lifetime by default, with stale-while-revalidate available as an opt-in.Rebased onto
06a4285(#123, #124, #161, #162, #163, #164, #165). Merge notes:OAuthClientcombines #124's session model (the callback returns anOAuthSessionwhose handle ishandle.invalidwhen unverified) with this PR's identity resolution; #124's internalAtProtoSession.InvalidHandleis replaced by the publicHandle.Invalid. The new identity models pass #123's drift and JSON-name convention tests.Verification
dotnet build --no-incremental(solution): 0 warnings, 0 errors.06a4285): 3914 passed, 0 failed, 5 skipped (3919), including #123's lexicon drift and convention tests. The identity/OAuth/spaces/firehose subset (1998 tests) was re-run 3× without flakes.IdentityFetchPolicyTests): 53 address cases (v4/v6/mapped/NAT64), 26did:webURL cases with and without the opt-out, 9 service-URL cases, 12 handle-TLD cases, plus connect-callback tests against a real loopbackTcpListener(a name resolving to loopback is refused before any connection; the opt-out reaches it).CachingDidResolver+PlcClientmake 1 directory request (zero on cache hits); key rotation verifies after one refetch; 20 forged commits cost one refetch;#identitythroughTypedFirehoseConsumerinvalidates.IsRepoHostAsyncconcurrency proven with a two-party barrier;SpaceSyncerrotation; the verifiers resolve through the keyed resolver (a custom one registered under the key is used), which is distinct from the shared one, andSpaceServerOptions.DidCachedefaults to a hard 5-minute lifetime; a cache test pins that a hard lifetime never serves a document past it.TryGetServiceEndpoint(found, malformed string, non-http scheme, structured endpoint, absent, wrong type) andTryGetVerificationKey(valid Multikey, undecodable key, missing key material, unknown type, absent)./data,/export?after=0&count=2); the export stream is tested over a TestServer WebSocket, including close reasons.atproto.com/bsky.apphandles verify bidirectionally,did:web:api.bsky.appresolves,did:web:localhost→Blocked,/exportpages and/export/streamdelivers operations through the hardened handler.Review follow-up
Every finding from the PR #160 review, with its fix and the regression test (most tests are the review's adversarial probes, made permanent:
IdentityHardeningTests,MetadataFetchPolicyTests).OAuthClientleaked theIdentityResolverit createdOAuthOptionsis left to the callerMetadataFetchPolicyTests.Dispose_ResolverTheClientCreated_IsDisposedWithIt,…_ResolverTheOptionsSupplied_IsLeftToTheCallerIOException/HttpIOExceptionand brokenContent-Encodingescaped as non-DidResolutionExceptions, and were not cachedIdentityFetch: read failures →NetworkError, undecodable bodies →InvalidDocument, so they are remembered like any failure; PLC error bodies read boundedDidWeb_BodyCutShort_IsANetworkErrorAndIsRemembered(real socket; 3 resolves → 1 connection),DidWeb_BodyThatDoesNotDecode_IsAnInvalidDocument(gzip/deflate/br)nulllists /nullentries in a DID document threw from lookupsnulllist → empty (converter and init),nullentry →JsonException→InvalidDocument; lookups skip nulls defensivelyDidDocument_NullList_ReadsAsEmptyAndLookupsWork,…_NullOrMisshapenEntry_FailsToParse,DidWeb_DocumentWithANullEntry_IsAnInvalidDocument,DidDocument_NullAssignedInCode_ReadsAsEmpty{"service":null}, a bad endpoint, a throwing handler) could makeNotifySpaceDeletedAsyncthrow, sodeleteSpacenever deletedSpaceWriteNotifier.IsDeliveryFailure: any exception but the caller's cancellation is an undelivered notificationNotifySpaceDeletedAsync_OneHostileSubscriber_DoesNotStopTheOthers(3 cases),NotifyWriteAsync_ResolverThatFailsAnyOtherWay_IsSkippedWithoutThrowingDidResolutionExceptionsurfaced as a 500SpaceDidResolution.ResolveOrRefuseAsync: any failure but the caller's cancellation →NotAuthorizedSpaceIdentityRefreshTests.DelegationToken_ResolverThatFailsAnyOtherWay_IsARefusalNotAFaultHandleResolver/IdentityResolvercould throw on a handle's authorities misbehavingHandle.Invalid; a handle input whose resolver throws isHandleNotFoundHandleResolver_WellKnownBodyCutShort_IsNoAnswer,IdentityResolver_HandleResolverThatThrows_IsHandleInvalidNotAFailure,…_HandleInputWhoseResolverThrows_IsHandleNotFoundLastAttemptAt; the floor counts from the last attempt, failed or notRefreshAsync_AfterAFailedRefresh_TheFloorStillHolds(1 resolve + 10 refreshes over 10 s → 2 fetches)HttpClient: any URL from a DID document, redirects followed, body uncappedOAuthException;AllowPrivateNetworksandMetadataHttpClientoptions; Blazor passes itsHttpClientthrough. PAR/token/revocation left to #132StartAuthorization_PdsOnALoopbackAddress_IsRefusedWithoutConnecting,ResolveFromIdentifier_DocumentNamingAnInternalUrl_IsRefusedWithoutConnecting(the probe),FetchMetadata_UnusableUrl_IsRefusedWithoutARequest(http, query, fragment, ftp),FetchMetadata_UnusableAnswer_IsAnOAuthException(500, oversize, 302, followed redirect, malformed),FetchMetadata_DevelopmentOptOut_ReachesALocalPdsAtProtoSpacesnamed client reached any addressIdentityNetworkPolicy.CreateHandlerunder the shared opt-outAddAtProtoSpaces_OutboundClient_ReachesNoPrivateAddress,…_FollowsNoRedirectsdid:webfollowed redirectsHttpError; the handle well-known follows ≤3 same-host HTTPS redirects itselfDidWeb_RedirectToAnotherOrigin_IsRefusedWithoutFollowing(target never contacted),DidWeb_CallersClientThatFollowedARedirect_IsRefused,HandleWellKnown_OnlySameHostHttpsRedirectsAreFollowed(4 cases)InvalidateAsync_WhileTheDistributedWriteIsInFlight_TheOldDocumentDoesNotSurviveGetHandlefell through to later entries and matchedAT://Malformed/null);GetHandlereads only the firstat://entry (ordinal)Lookups_WithDuplicateIds_TakeTheFirstEntry,DidDocumentTests.GetHandle_ReadsOnlyTheFirstAtUriEntryFetches_AreCappedProcessWide(128 concurrent → peak ≤ 64)DidCacheOptions.FailureCapacity, 1,000)Failures_DoNotEvictDocuments(50 failures, capacity 2 documents: both still cached)06a4285; CHANGELOG merged withchangelog-merge.py(3 blocks, all 24 #120 bullets kept)--no-incremental0/0, unit suite greenAlso found while turning the probes into tests:
AuthorizationServerDiscoveryprefixedhttps://to any URL not starting withhttp(s)://, softp://hostwas fetched ashttps://ftp//host; only a bare host is prefixed now, and other schemes areinvalid_server_url.Deferred / not done
HttpClientwithout the address policy (#132). Spaces does not yet use the strictTryGet…lookups (#133).GetRecommendedDidCredentialsResponse/SignPlcOperationRequestshare a shape withPlcOperation(debloat 10.9 tail) — left alone, they are #123's Lexicon models.TypedFirehoseConsumer(perf F1 "optional"), andSpaceCredentialProvider's global mint lock / superseded-credential growth (F17, non-host part) are untouched.Enumerate*helper (the shared paginator is string-cursor and Lexicon-only); the docs show the page loop.🤖 Generated with Claude Code
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-26T01:47:36.895Z) — nothing to re-trigger.
@claude /review — this is security-sensitive. Please read these in full from the branch: the SSRF policy (the
ConnectCallbackaddress filtering after DNS, did:web port/path rules, size caps), the caching resolver (single-flight, negative cache, stale/expire, invalidation), and the refetch-once-on-signature-failure paths inFirehoseVerifierand the Spaces verifiers.🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
🛑 Verdict: request changes — blocking issues below.
This is a large, well-executed consolidation: one
IDidResolver/IHandleResolver/IIdentityResolverabstraction, a real bounded LRU cache with single-flight and stale-on-error handling, and a genuinely careful SSRF policy (IdentityNetworkPolicy) that checks every resolved address in the connect callback and connects only to the addresses it checked. The space-server refresh-once-on-signature-failure logic (SpaceDidResolution.VerifyWithKeyRefreshAsync,SpaceServiceAuthVerifier.VerifyServiceAuthAsync) and the concurrentIsRepoHostAsyncresolution are correct and match the PR description. I verified several call sites against the actual PR branch (origin/issue-120-identity) rather than relying on the diff alone, since the diff was truncated.One real defect:
OAuthClientleaks theIdentityResolverit creates for itself. WhenOAuthOptions.IdentityResolveris null (the documented default),OAuthClient's constructor callsIdentityResolver.CreateDefault(...), which builds and owns aCachingDidResolver→DidResolver→PlcClient+DidWebResolver, each with its ownHttpClient/SocketsHttpHandler(confirmed inPlcClient.PlcClient(IdentityResolverOptions?)andIdentityResolver.CreateDefault, whose own doc says "Dispose it to release its clients").OAuthClientstores this only inside_discoveryand never disposes it —OAuthClient.Dispose()(line ~868 ofOAuthClient.cs) only disposes pending DPoP keys. This silently contradicts the disposal discipline the rest of this release cycle established (AtProtoClient"disposes only what it created"). It's not catastrophic in the common singleton usage (AtProtoOAuthServicebuilds oneOAuthClientand reuses it), but any code path that constructs more than oneOAuthClientwithout supplying a sharedIdentityResolver(tests, multi-tenant apps, samples) leaks aSocketsHttpHandler/connection pool per instance for the process lifetime.Everything else I could verify — the
DidDocument.SigningKeyIdconstant,SpaceTokenException.IsSignatureFailure, the DI registrations inIdentityServiceCollectionExtensionsandSpaceServerExtensions, and the address-checking logic inIdentityNetworkPolicy.ConnectToPublicAddressAsync— matches what the PR description claims and looks correct.⏱ 2m 20s · 34 turns · tokens: 108.0k in / 13.7k out (+3797.5k cache-read)
📜 Full session transcript
@ -60,3 +59,1 @@{HandleResolutionTimeout = _options.HandleResolutionTimeout,};_discovery = new AuthorizationServerDiscovery(When
_options.IdentityResolveris null, this constructs an ownedIdentityResolver.CreateDefault(...), which per its own XML doc (src/ATProtoNet/Identity/IdentityResolver.cs) owns aCachingDidResolverandHandleResolver, each of which owns its ownHttpClient/SocketsHttpHandler(seePlcClient,DidWebResolver).OAuthClient.Dispose()(further down in this file) never disposes_discoveryor itsIdentityResolver— it only disposes pending DPoP keys. EveryOAuthClientbuilt without an explicit sharedIdentityResolvertherefore leaks a connection pool for the process lifetime. Either dispose the resolver inOAuthClient.Dispose()when this constructor created it (tracking ownership likeAtProtoClientdoes elsewhere in this release), or expose/document the leak explicitly.🤖 Resolving the merge conflicts — the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the fix runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-26T02:00:33.556Z) — nothing to re-trigger.
🤖 Resolving the merge conflicts — the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the fix runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-26T04:57:05.468Z) — nothing to re-trigger.
Follow-up in
95b8eca, rebased onto main. It addresses the REQUEST_CHANGES finding and an independent adversarial security review of the SSRF policy, caching and refetch paths. That review found nothing blocking and confirmed the core address filtering across 34 address classes, rebinding and proxy bypass. See Review follow-up in the description for each finding with its fix and regression test. In short:OAuthClientnow disposes the identity resolver (and the metadata client) it creates.nullDID-document collections becomeDidResolutionException. A hostileregisterNotifytarget can no longer blockdeleteSpace. The Spaces verifiers answerNotAuthorizedinstead of 500. Handle failures yieldhandle.invalid.AtProtoSpacesclient uses the hardened handler.@claude /review
d4139ab8b595b8eca335🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-26T05:23:15.312Z) — nothing to re-trigger.
✅ Verdict: approve — no blocking issues found.
This is a re-review after my previous request_changes pass. The one blocking issue I raised then —
OAuthClientleaking theIdentityResolverit creates internally (viaIdentityResolver.CreateDefault), never disposed, silently leaking aSocketsHttpHandler/connection pool per instance — is now fixed. I verified this directly againstorigin/issue-120-identity(rebased onto currentmainat06a4285, confirmed viagit merge-base):OAuthClientnow tracks ownership with a_ownedIdentityResolverfield, set only when_options.IdentityResolveris null, andDispose()calls both_discovery.Dispose()and_ownedIdentityResolver?.Dispose().AuthorizationServerDiscoverymirrors the same pattern for its own metadataHttpClient(_ownsMetadataClient) and, separately, an identity resolver it might own when constructed directly without one.OAuthClientpasses its already-resolvedidentityResolverintoAuthorizationServerDiscovery's constructor, that value is non-null, soAuthorizationServerDiscovery's own_ownedIdentityResolverstays null and onlyOAuthClientdisposes it.MetadataFetchPolicyTests.Dispose_ResolverTheClientCreated_IsDisposedWithItasserts aResolveAsynccall on the client-created resolver throwsObjectDisposedExceptionafterclient.Dispose();Dispose_ResolverTheOptionsSupplied_IsLeftToTheCallerasserts a caller-supplied resolver'sDispose()is never called.I also spot-checked
IdentityNetworkPolicy(the SSRF connect-callback and address classification) against the PR's description and it matches: DNS resolved once, every returned address checked before connecting to exactly those addresses, no proxy, IPv4-mapped/NAT64 addresses judged by their embedded IPv4, and a solid set of reserved ranges (RFC1918, CGNAT, link-local/metadata, documentation, benchmarking, 6to4/Teredo, multicast) is excluded on both v4 and v6.I was unable to run a full build/test pass in this environment (sandbox denied
git fetch/git worktree/network commands), so I relied on direct source inspection of the actual PR branch rather than test execution. Given the previously blocking issue is resolved with real regression coverage and nothing new I inspected raised a concern, this is good to merge.⏱ 0m 57s · 21 turns · tokens: 93.2k in / 5.2k out (+2212.6k cache-read)
📜 Full session transcript