refactor(auth): shared JWT, DPoP and base64url primitives #151
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
5 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!151
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-118-primitives"
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 #118.
Core and Server each had their own base64url, JWT signing and parsing, RFC 7638 thumbprint and DPoP
htunormalization, and the client and server DPoP copies had drifted. Each primitive now has one internal implementation in core, which the Server uses throughInternalsVisibleTo.Two commits: the characterization tests first (
test:, green against the old code), then the refactor.What changed
Shared primitives
InternalsVisibleTo ATProtoNet.Serveronsrc/ATProtoNet/ATProtoNet.csproj. This is the only csproj change.System.Buffers.Text.Base64Urlis used everywhere. That replaces 6 encoders and 5 decoders, including the internalBase64Urlclass inPlcOperationBuilderthat shadowed the BCL type. Decoding goes throughJwt.TryDecodeBase64Url, a strict wrapper (details under Security below).Jwt(src/ATProtoNet/Auth/Jwt.cs, internal) hasEncodeHeader,Sign(encodedHeader, payload, AtProtoKey),TryDecode,WriteTokenIdand aGetStringOrNullextension. They replace the signers inDPoPProofGenerator,ServiceAuthGeneratorandSpaceTokens.Create, and the parsers inSpaceTokens.Parse,DPoPProofValidatorandSpaceServiceAuthVerifier. Each caller keeps its own exception type, its message prefix and every rationale comment.jtischeme for every token: 128 bits from the system CSPRNG as 32 lower-case hex characters. DPoP and service auth usedGuid.NewGuid().ToString("N"), which has the same shape.alglabel comes fromKeyCurve.JwsAlgorithm(), which #150 added. It is now called only fromJwt.EncodeHeader.DPoP
DPoP(src/ATProtoNet/Auth/OAuth/DPoP.cs, internal) hasNormalizeHtu,AccessTokenHash, the RFC 7638ThumbprintandTokenType. The generator andDPoPProofValidatorboth use it, so the two thumbprint implementations and the three SHA-256→base64url copies are gone.htudrift, resolved the RFC way.htuisscheme://host[:port]/path: scheme and host lower-cased, no default port, and no query, fragment or userinfo (RFC 9449 §4.2 and §4.3, and RFC 9110 §4.2.4 for userinfo). It is implemented withUri.GetComponents(SchemeAndServer | Path, UriEscaped). The client used to emituser:pw@and so sent the credentials inside the proof. The server already stripped userinfo, and its output is unchanged for every other input I probed (IPv6, IDN, percent-encoding, dot segments).GenerateProofnow throwsArgumentExceptionfor a URL that is not absolute with a host. Before, it sent the URL verbatim, and on Linux/xrpc/abecamefile:///xrpc/a.new DPoPProofGenerator(byte[])now throwsArgumentExceptionfor any key that is not P-256, because atproto DPoP is ES256 only. The generator wraps anAtProtoKeybuilt from the importedECDsa, keeps a typedJsonWebKey, and computes its thumbprint once.JsonWebKeyafter thed-present check, which still runs first.JsonWebKeyVerifier.Verify(JsonElement…)is removed.JsonWebKeyVerifierstays separate fromAtProtoCrypto, with its rationale comment unchanged, because JWS must accept high-S signatures.athis cached per access token, the payload is written withUtf8JsonWriter, and the signing input and output are built in one pooled buffer. The same pattern now applies toServiceAuthGeneratorandSpaceTokens.Create.Space tokens
SpaceTokens.Verify(SpaceToken parsed, …)is a new public overload.SpaceDelegationTokenVerifierandSpaceCredentialVerifieruse it, so each token is parsed once.Bounded reader
BoundedContent.ReadBoundedAsync(src/ATProtoNet/Auth/OAuth/BoundedContent.cs, internal) replacesLengthLimitedStreaminHttpSpaceClientMetadataResolverandReadCappedStringAsyncinAuthorizationServerDiscovery. It refuses a declaredContent-Lengthover the cap before reading, reads at most cap + 1 bytes, and grows its buffer from the declared length instead of allocating the whole cap up front. The old attestation path would have streamed 256 KiB. Exceptions are unchanged: an oversized document is stillSpaceVerificationException(InvalidClientAttestation)or an ignoredatproto-didanswer.Security
+and/, embedded whitespace (whichBase64Urlalone would skip) and non-zero trailing bits are rejected, so a token has exactly one spelling apart from padding. Correct padding is still accepted, as it was before, and is pinned by tests. A token's header and claims must be JSON objects, and the service-auth verifier now decodes the header as well.Breaking changes
None binary- or source-breaking. The removed members were all internal:
DPoPProofGenerator.Base64UrlEncode,ComputeS256HashandNormalizeHtu;DPoPProofValidator.NormalizeUri; theIdentity.Base64Urlclass; andJsonWebKeyVerifier.Verify(JsonElement…). Three behaviour changes are listed under Fixed and Security in the CHANGELOG:DPoPProofGenerator(byte[])throwsArgumentException.GenerateProofthrowsArgumentExceptionfor a URL that is not absolute with a host.Verification
Unit tests: 2711 total, 2706 passed, 0 failed, 5 skipped (the environment-gated ones). The full solution build (
--no-incremental) has 0 warnings and 0 errors.Characterization first. Commit 1 adds 85 test cases that pass against the unchanged source. That commit on its own runs 2636 tests: 2631 passed, 5 skipped. The refactor commit adds 75 more.
DPoPProofGeneratoroutput validated byDPoPProofValidator, with a bound thumbprint andath. This covers the credential-exchange shape, a query-string URL, the wrong token, the wrong key, export/import and freshjtis.SpaceTokens,DPoPProofValidator, the service-auth verifier, JWK coordinates on bothJsonWebKeyVerifierpaths, and the bearer handler's pre-check (through the handler, with no network).SpaceTokensand the service-auth verifier.HttpSpaceClientMetadataResolver's ceiling, which had no tests, declared, undeclared and at the exact boundary, for both the metadata and the JWKS fetch. Also the exact 1024/1025 boundary of theatproto-didcap.Pinned RFC vectors. Both example DPoP proofs from RFC 9449 validate through
DPoPProofValidatorwith a fixed clock: §4.1 Figure 2, the token request, and §7.1 Figure 13, the resource request withathand a boundjkt. Their signatures verify, which I checked independently before using them. The §6.1 thumbprint0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4Iand the §7.1athfUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEoare asserted againstDPoP.Thumbprint,DPoP.AccessTokenHashand the generator's ownath.Regression tests for the K-256 refusal,
htuwithout userinfo (generator claim and interop), relative-URL rejection,athcache invalidation when the token changes, and standard-alphabet rejection. Plus unit tests forJwt(decode table, malformed reasons, sign/verify, ES256K label,jtishape),DPoP.NormalizeHtu, andReadBoundedAsync, including the buffer-growth path and an understatedContent-Length.Benchmarks. BenchmarkDotNet 0.14 ShortRun, in-process, .NET 10.0.12, Ryzen 5 7600X. "Old" is the
origin/maincode copied verbatim:athand nonceServiceAuthGenerator.CreateTokenSpaceTokens.Create(credential)These match the F18 prototype (18.6 → 15.0 µs, 11.2 → 2.1 KB). The remaining gap to the prototype comes from
AtProtoKey.Signreturning a new array.Deliberately deferred or flagged
AtProtoKey.Signreturns abyte[]and applies low-S normalization (harmless for JWS). Signing into a span needs anAtProtoKey.TrySign(ReadOnlySpan<byte>, Span<byte>)inCrypto/*, so I left it for a follow-up there rather than reaching into the key'sECDsa.AtProtoAuthenticationHandler: only its base64url decoder moved to the shared one, and that is pinned by new tests. #136 deletes the class, so its JWT parsing was not rebuilt onJwt.TryDecode.GetStringcopies inStreaming/JetstreamEventParserandJetstreamSegmentReaderbelong to #117's files. They can switch toGetStringOrNulllater.DateTimeOffset.FromUnixTimeSecondsthrowsArgumentOutOfRangeExceptionfor anexporiatoutside years 1–9999. This affectsSpaceTokens.Parse(soTryParsethrows instead of returningfalse),DPoPProofValidator(iat) andSpaceServiceAuthVerifier(exp,iat). A crafted token therefore produces a 500 instead of a 401 on the space endpoints. There is no bypass. ATryGetNumericDatehelper next toGetStringOrNullwould fix it; #133 or #125 is the natural home.🤖 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.
@claude /review
🤖 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: approve — no blocking issues found.
Solid, well-scoped refactor. I checked out the PR head (origin/issue-118-primitives) and read the actual resulting files rather than relying on the diff alone, and spot-checked the highest-risk pieces:
DPoP.NormalizeHtuusesuri.GetComponents(UriComponents.SchemeAndServer | UriComponents.Path, UriFormat.UriEscaped). I verified (docs + source) thatSchemeAndServer = Scheme | Host | Portand does not includeUserInfo— so the claimed userinfo-stripping behavior is correct, not a regression. Default-port dropping and lower-casing of scheme/host also check out against .NET'sUrinormalization behavior, matching the newDPoPTests/DPoPProofGeneratorTestscases (including the RFC 9449 vectors).Jwt.TryDecodeBase64Urlcorrectly rejects the standard alphabet, embedded whitespace, and non-zero trailing bits, while accepting both padded and unpadded input — confirmed against theSystem.Buffers.Text.Base64UrlAPI surface (available since .NET 9, sonet10.0is fine) and the test table inJwtTests.Jwt.Signbuilds the token into a single pooled buffer with sizes computed fromBase64Url.GetEncodedLength, and validates the returned signature is exactly 64 bytes (AtProtoKey.Signalways returns a normalized fixed-widthr || ssignature), so there's no buffer-size mismatch.DPoPProofGenerator's new private constructor path disposes theECDsaon every failure branch ofImportP256(including the P-256 curve check), so the K-256 rejection doesn't leak a handle.BoundedContent.ReadBoundedAsync's growth loop correctly detects an oversized body whether or notContent-Lengthis declared or truthful, and the boundary tests (Cap/Cap+1) line up with the implementation.JsonWebKeyVerifier.Verify(JsonElement…), the oldNormalizeUri, the ad hoc decoders) are gone cleanly, and every call site (DPoPProofValidator,SpaceServiceAuthVerifier,SpaceTokens,AtProtoAuthenticationHandler) was updated consistently with the new shared primitives and produces the same exception types/messages as before.I didn't find correctness or security issues. The CHANGELOG entries are properly categorized (Added/Changed/Fixed/Security) and cite #118, and the deferred items (signing into a stack buffer, the pre-existing
FromUnixTimeSecondsrange bug) are reasonably scoped out with clear rationale rather than silently ignored. Characterization-tests-first approach is a good practice and the interop tests (client generator against server validator, plus RFC 9449 pinned vectors) give real confidence the two independently-written sides still agree after the refactor.⏱ 3m 8s · 31 turns · tokens: 121.4k in / 17.7k out (+3712.0k cache-read)
📜 Full session transcript
✅ Verdict: approve — no blocking issues found.
Solid consolidation refactor — the primitives are correct, the two behavior fixes are real, and nothing is broken by the consolidation. Reviewed the final state of all touched files.
Verified against the .NET runtime source, not just the tests:
Jwt.TryDecodeBase64Url's strictness claims all hold:Base64Url.DecodeFromCharsruns withignoreWhiteSpace: true(so theSearchValuespre-check is genuinely required to reject"Y Q"), accepts correct trailing padding, and rejects misplaced padding,+//(invalid in its decoding map), and non-zero trailing bits (Base64Helper.DecodeFromchecks(i0 & 0x8000F000) != 0→InvalidData). Padding acceptance/strictness is what keeps RFC 7515 compat while pinning one spelling — correctly implemented.GetMaxDecodedLength+bytesWrittenslicing is right.Jwt.TryDecode's signing input is the exact ASCII of{header}.{payload}as sent (header/payload segments pass the alphabet check, so ASCII is exact, and padding — when present — is covered by the signature as sent). JSON-object enforcement on header and payload with per-caller exception types and messages preserved.Jwt.Sign's pooled-buffer arithmetic is correct (86-char signature for a 64-byte fixed r||s, matching the JWS shape), the signature-length check is sound for both ES256 and ES256K, and the buffer is returned infinally.Jwt.EncodeHeaderembeds exactlykty/crv/x/y: the SDK'sJsonWebKeycarries[JsonPropertyName]andJsonIgnore(WhenWritingNull)onCrv/X/Y/Kid/Use/Alg, soJsonSerializer.Serializeproduces the minimal RFC-shaped header.DPoP.NormalizeHtuis the RFC-correct shape:SchemeAndServer(bits 1|4|8) excludes userinfo (bit 2), query and fragment; the userinfo drop is justified by RFC 9110 §4.2.4 and is a real fix (the oldGetLeftPart(UriPartial.Path)did includeuser:pw@). Both generator and validator call the one implementation, so comparisons are self-consistent; the RFC 9449 §7.1 vector pins the exact output. Every in-repo call site (OAuthClient,XrpcClientvianew Uri(BaseAddress, RequestUri),SpaceCredentialProvider) passes absolute URLs, so the newArgumentExceptiononly fires for genuinely malformed input, and the behavior change is documented in CHANGELOG anddocs/oauth.md.1.3.132.0.10 != 1.2.840.10045.3.1.7, the ECDsa is disposed on the failure path, and garbage input still surfacesCryptographicException.d-presence check still runs on the raw element before deserialization; typedJsonWebKeydeserialization ignores unknown members and turns type mismatches into a 401, and wrong-length signatures stay a failed verification rather than a 500 viaJsonWebKeyVerifier.VerifyData's catch).BoundedContenthandles the boundaries correctly (declared-length pre-refusal, cap+1 headroom, doubling growth capped at maxBytes+1, exact-cap acceptance, understated Content-Length), and the metadata resolver's catch list correctly dropsInvalidDataException, which the replacement no longer throws.InternalsVisibleTois the only csproj change; grep confirms no remaining references to any removed member (DPoPProofGenerator.Base64UrlEncode/ComputeS256Hash/NormalizeHtu,DPoPProofValidator.NormalizeUri,Identity.Base64Url, theJsonElementverifier overload), so "no binary/source breaking" holds. Singlenet10.0target, soBase64Url,SearchValues,Convert.TryToHexStringLowerandThrowIfGreaterThanOrEqualare all available.CachedAthcache inDPoPProofGeneratoris fine under concurrency: each call uses its own immutable snapshot, so a benign race can only duplicate a hash computation, never mix tokens.Minor note, non-blocking:
ImportP256identifies the curve viaCurve.Oid.Value, so a P-256 PKCS#8 that encodes explicit curve parameters instead of the named OID would be refused as "(unnamed)". Keys exported by the SDK (ExportPrivateKey) always carry the named OID, so the documented session-resumption round trip is unaffected; only third-party explicit-parameter encodings would be rejected, which is an acceptable narrowing for an ES256-only DPoP key.The pre-existing numeric-date
FromUnixTimeSecondsissue is correctly deferred (no bypass, verifier semantics unchanged, follow-up home named), and the deliberately-deferred span-based signing andAtProtoAuthenticationHandlernotes are honest scoping.⏱ 24m 51s · 42 steps · $0.2706 · tokens: 636.6k in / 24.6k out (+4777.3k cache-read) · Go: 5h 6% · wk 98% · mo 81%
📜 Full session transcript