feat(server)!: inbound service-auth verification (proposal 0014) #166
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!166
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-125-service-auth"
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 #125.
The SDK could mint service auth tokens but had no general way to accept them: feed generators, labelers and AppViews on
ATProtoNet.Serverhad nothing to plug in, and the only verifier was the Spaces one fornotifyWrite. This PR adds a general verifier and an ASP.NET Core scheme that follow the April 2026 spec revision (proposal 0014), rebuilds the Spaces verifier on them, and fixes the out-of-rangeexp/iat500 reported on the issue.Generator (
ServiceAuthGenerator)CreateToken(string audience, Nsid lxm, TimeSpan? expiresIn):lxmis now required, because the spec makes it mandatory on XRPC calls.audis validated asdidordid#serviceIdwith the referenceisDidStringOrServicerule: exactly one non-empty fragment. Whitespace and control characters are refused too. The docs preferdid#serviceIdand describe the bare DID as deprecated.issis aDid, which cannot carry a fragment. A test pins this.keyId(for example#atproto_label) is sent as thekidheader. It must be a#fragmentwithout the DID. With nokeyId, nokidis sent, which means#atproto.jtiwas already always set (#118). A test now pins the exact claim set against@atproto/xrpc-server'screateServiceJwt:{typ, alg}in the header andiat, iss, aud, exp, lxm, jtiin the payload.Auth/ServiceAuthSyntax, which the generator and the verifier both use.Verifier (
ServiceAuthVerifier,ATProtoNet.Server.Authentication)VerifyAsync(token, acceptedAudiences, Nsid? method)returns aVerifiedServiceAuthor throwsServiceAuthException. That exception is anXrpcExceptionwith status 401 and uses the reference error names inServiceAuthErrors:BadJwt,BadJwtType,JwtExpired,BadJwtAudience,BadJwtLexiconMethod,BadJwtIss,BadJwtSignature. It checks, in order:algis ES256 or ES256K;typis notat+jwt,refresh+jwtordpop+jwt(as the reference refuses them).iss: a bare DID. A fragment is refused with a hint to usekid.kid: must be inAllowedKeyIds, which defaults to{#atproto}. A DID-URLkidnaming the issuer's own key is normalized; one naming another DID's key is refused.aud: exact match against the accepted audiences, for example{did#svc, did}during the transition.lxm: must equal the method called.RequireLexiconMethod(default true) refuses a missinglxm; turning it off tolerates a missing one but never a wrong one. Passingmethod: nullskips the binding, for calls that are not XRPC.expandiat: both are required, integral and range-checked. They are checked withClockSkew(30 s) andMaxTokenLifetime(5 min, matchinglex-serverand the generator's cap). The comparisons use differences, so a configured duration cannot overflowDateTimeOffset.jti: required, at most 255 characters (the EF column width).IDidResolvercache. On failure, or when the key is missing, the document is refetched once and the check retried, following the refetch-once pattern from #120. Thealgmust match the key's curve. A high-S signature is accepted, as@atproto/xrpc-serveraccepts it on a JWT (allowMalleableSig: true). This goes through a new internalAtProtoCrypto.VerifyJwtSignatureoverDidKeyCache. Commit verification keeps its low-S rule.jtiis spent inIJtiReplayStorelast, so a forged token cannot burn a genuine token's identifier.JTI replay store
ISpaceReplayStoreis generalized intoIJtiReplayStore, withInMemoryJtiReplayStoreandEfCoreJtiReplayStore<T>. The EF store's entity isJtiReplayEntityon tableAtProtoJtiReplay.JtiReplayDbContext(withConfigureJtiReplayModel) is for services without a space server;SpaceDbContextincludes the same table.AddAtProtoEfCoreJtiReplayStore<T>(). Service auth and the space server share one registered store, and a test pins that.RedisSpaceReplayStoreonly switches to the new interface so it keeps compiling; #136 deletes it.ASP.NET Core integration
AddAuthentication().AddAtProtoServiceAuth(o => o.Audiences.Add("did:web:x#bsky_fg")). It has a default or custom scheme name, and the options are validated at host start (ValidateOnStart), so a missing or malformed audience fails the start.did(also the identity's name),audandlxm. The names are constants onAtProtoServiceAuthDefaults.MapXrpcEndpoints()now attaches a publicXrpcMethodMetadata(Nsid)to every endpoint, and the handler bindslxmto it automatically.[RequireServiceAuth](anAuthorizeAttribute) on a handler, or.RequireServiceAuth()on the group.[AllowAnonymous]still opens a handler.WWW-Authenticate: Bearer, and the refusal's error name (AuthenticationRequiredwhen there was no token). Forbid answers 403Forbiddenwith the envelope.AddAtProtoIdentity()and an in-processIJtiReplayStore, each unless one is already registered.Spaces
SpaceServiceAuthVerifiernow delegates toServiceAuthVerifierwithClockSkewandMaxSingleUseTokenLifetimetaken fromSpaceServerOptions. It mapsServiceAuthExceptiontoSpaceVerificationException(NotAuthorized)and keeps the inner exception.#atproto_space_host, and the service DID. Tests cover them.IsRepoHostAsyncis unchanged.Bug fix: an out-of-range
exp/iatanswered 500JsonElementExtensions.TryGetNumericDatereads whole seconds within years 1–9999 and reports anything else as invalid.SpaceTokens.Parse(soTryParsereturns false again), inDPoPProofValidatorand in the new verifier, which the Spaces service-auth verifier now uses.iatthat is not a whole number now makesSpaceTokens.Parsefail. It used to be ignored silently, which matches howexpwas already handled.Docs
docs/xrpc-handlers.md: new "Serving XRPC to other services" section (setup, a table of checks and options, error names, replay store, DID-cache caveat, non-XRPC endpoints, direct verifier use).crypto.md(generator),spaces.md(store names, a replay-table upgrade note, type table),api-reference.md(new "Service auth" table, Spaces rows),server.md, andaspnet-core.md(a pointer from the legacy handler to service auth).Breaking changes and migration
CreateTokenrequireslxm. Migration: passNsid.Parse("…")for the method being called. An audience whose fragment contains a second#or whitespace is now refused.ISpaceReplayStore→IJtiReplayStore, now in theATProtoNet.Server.Authenticationnamespace.InMemorySpaceReplayStore→InMemoryJtiReplayStore, andEfCoreSpaceReplayStore<T>→EfCoreJtiReplayStore<T>.AddAtProtoEfCoreSpaceReplayStore<T>()→AddAtProtoEfCoreJtiReplayStore<T>().SpaceReplayEntity→JtiReplayEntity,SpaceDbContext.AtProtoSpaceReplay→AtProtoJtiReplay, andSpaceDbContext.ConfigureSpaceReplayModel→JtiReplayDbContext.ConfigureJtiReplayModel.docs/spaces.mdexplains.notifyWriteservice auth:jti,lxmandiatare now required,issmust have no fragment, and only a#atprotokey is accepted. The SDK's ownSpaceWriteNotifierand the reference PDS already send all of these.VerifiedServiceAuthmoved and changed shape: it moves toATProtoNet.Server.Authenticationand is a class with init-only properties instead of a positional record.Methodis now the token's ownlxm, and it addsKeyId,TokenId,IssuedAtandExpiresAt.Review fixes
An adversarial review found the following; each fix has a regression test that fails without it (checked by reverting each fix).
exp(high): a token is accepted untilexp + skew, but itsjtiwas kept only untilexp.IJtiReplayStoredocuments that invariant.iat + ProofLifetimeis reached.r/sfall outside [1, n−1] are refused before high-S normalization (AtProtoCrypto.HasScalarsInRange), andNormalizeLowSSignatureno longer throws.AtProtoKey.Verifychecks the range too, as defence in depth; .NET already answeredfalsethere.kidbypassedAllowedKeyIds: the implied#atprotois now checked against the allow-list too.AuthorizationPolicy.CombineAsync). It verifies only when that policy names the scheme, or names none while this is the default scheme.IAllowAnonymous, without authorization, or authorized by other schemes returnNoResult, and the token stays unspent.getSpaceCredential,listRepos,(un)registerNotify,notifyWrite) carry an internal[AuthenticatesItself]marker, which is anIAllowAnonymous. So a group-wide.RequireServiceAuth()orRequireAuthorization(), or a fallback policy, no longer stands in front of them.notifyWritethen checks and spends its token exactly once.simplespaceadmin endpoints, which read the caller from the host's own authentication, are unchanged.ServiceAuthVerifierdirectly.jtigave a 500: refused as malformed. The sharedJwt.IsUsableTokenIdrule applies to service auth, space tokens and DPoP proofs (the latter two were pre-existing, under Fixed).typ: any explicitly typed…+jwtis now refused (RFC 8725 §3.11), including space delegation tokens signed with the same#atprotokey.JWTor notypis still accepted.lex-server: aniatolder thanMaxTokenLifetime + ClockSkewis refused, andnbfis honoured with skew.fix(syntax): theDid,Handle,Nsid,RecordKey,Tidand did:web host patterns now anchor at\zinstead of$.AtUri,AtIdentifier,SpaceUri,CidandAtDatetimewere checked and already refuse line breaks through their components or parsers; tests cover every type.idmatch: a did:web document'sidmust now equal the requested DID exactly, as@atproto/identitycompares (val.id !== did). Previously it matched case-insensitively.Verification
810e2de): 4,219 passed, 0 failed, 5 skipped (4,224 total). New:Server/Authentication/ServiceAuthVerifierTests.cs: every check, refetch-once (rotated key verifies, forged key refused after exactly one refresh, valid token never refreshes), a genuinely high-S signature accepted, the forged-token-cannot-burn-a-jti case, option validation and copying, and error names pinned to the reference.ServiceAuthSchemeTests.cs: TestServer end to end. Covers attribute and group convention,[AllowAnonymous], lxm binding, audience forms, replay, the unmatched NSID, a non-XRPC route (refused without spending the token), a hand-mapped route withXrpcMethodMetadata, 403 via policy, host start failing on bad options, and DI.JtiReplayStoreTests.cs: in-memory store, EF over SQLite withJtiReplayDbContext, table and key pinned, registration order.exp/iatregressions inSpaceTokensTests,DPoPProofValidatorTests, the Spaces service-auth tests, andJwtTests.TryGetNumericDate.jti/lxm/iat, a refusedkid,isswith a fragment, and both authority audience forms.kid, and audience andkidsyntax.@atproto/xrpc-serverauth.ts(verifyJwt,createServiceJwt) and@atproto/lex-serverservice-auth.tsat theirmainas of 2026-09-26.Deliberately deferred or out of scope
AtProtoAuthenticationHandler/AddAtProto(AuthenticationBuilder)stays; #136 deletes it.aspnet-core.mdnow points to service auth.AtProtoServiceAuthDefaults; #136 plansAtProtoClaimTypesand can fold them in.RedisSpaceReplayStoreonly retargets the interface until #136 removes it.AddAtProtoSpaces()warns at startup about the in-process replay store. The service-auth scheme documents the multi-instance requirement but logs nothing.CachingDidResolverthen caches the failure for a minute. This is the same as the reference implementation and pre-existing in Spaces.🤖 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-26T05:59:41.809Z) — 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-26T06:26:17.710Z) — nothing to re-trigger.
bc630997a33f942b80e2@claude /review — security-critical, large (≈50 files); the description has the migration notes and a "Review fixes" section covering an independent adversarial review. The diff will be truncated, so please read these in full from the branch:
src/ATProtoNet.Server/Authentication/ServiceAuthVerifier.cs,AtProtoServiceAuthentication.cs(the handler: when it verifies vs returns NoResult, policy combination, challenge/forbid bodies),ServiceAuthOptions/AtProtoServiceAuthDefaults,IJtiReplayStore.cs+EfCoreJtiReplayStore.cs(retention ≥ acceptance window, atomic spend),src/ATProtoNet/Auth/ServiceAuthGenerator.cs+ServiceAuthSyntax, the changed parts ofCrypto/AtProtoCrypto.csandCrypto/DidKeyCache.cs(JWT signature check, r/s range),JsonElementExtensions.TryGetNumericDate, the SpacesSpaceServiceAuthVerifierand the[AuthenticatesItself]marker on the Space endpoints, and the identifier regex changes in thefix(syntax)commit. Compare against@atproto/xrpc-serververifyJwt. Focus on auth bypasses (alg/kid/iss/aud/lxm/typ/exp/iat/nbf), replay, tokens spent on endpoints that don't require them, and exceptions escaping as 500.🤖 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-26T06:34:23.481Z) — nothing to re-trigger.
✅ Verdict: approve — no blocking issues found.
This is a large, carefully engineered addition of inbound service-auth verification (
ServiceAuthVerifier, the ASP.NET Core scheme, the generalizedIJtiReplayStore, and the Spaces rebuild on top of it). I read through the core verification logic (ServiceAuthVerifier.VerifyAsync,ReadKeyId,ReadMethod,ReadLifetime,VerifySignatureAsync), the ASP.NET Core handler (AtProtoServiceAuthHandler, especiallyRequiresThisSchemeAsyncand the challenge/forbid handling), the replay-store rename/generalization, the[AuthenticatesItself]opt-out mechanism for space endpoints, and the various renamed EF Core artifacts.Notable things that check out:
jtion a forged token.RequiresThisSchemeAsynccorrectly special-casesIAllowAnonymous, explicit scheme lists on the combined policy, and the default-scheme fallback, and it's evaluated before token verification so an endpoint that doesn't ask for the scheme never has its token spent.HandleChallengeAsyncre-entry intoHandleAuthenticateOnceSafeAsync()is safe — ASP.NET Core'sAuthenticationHandlercaches the authenticateTaskper handler instance per request, so this does not double-verify/double-spend a token.expiresAt + clockSkewfor service auth,ReplayRetentionfor space tokens,<instead of<=in the EF sweep) is internally consistent with the "keep jti until the token stops being accepted" invariant described in the PR.ReadKeyId's DID-URL normalization only shortens akidthat names the issuer's own DID, correctly leaving akidnaming a different DID to fail the allow-list check as unknown.I could not review the tail of the diff that was truncated by the tool (the rest of
JsonElementExtensions.TryGetNumericDate,AtProtoCrypto.VerifyJwtSignature,ServiceAuthSyntax,DidDocument.GetVerificationKey), so I can't independently confirm the numeric-date bounds-checking or signature/curve-matching implementation details — but nothing in the visible portions contradicts the PR's description of them, and the PR states these are covered by dedicated regression tests (reverting each fix fails a test). Given the scope and the already-documented adversarial review pass, I don't have further blocking findings to add.⏱ 1m 39s · 1 turns · tokens: 79.2k in / 9.6k out (+23.9k cache-read)
📜 Full session transcript
Supplementary check of the parts the claude review couldn't see (truncated diff), at head
3f942b8:DidKeyCache.VerifyJws:algmust equal the key curve's JWS algorithm; length and r/s ∈ [1, n−1] are checked (HasScalarsInRange) beforeNormalizeLowSSignature, which also refuses to touch S ≥ n.AtProtoKey.Verify(commit path) gains the same range check and keeps low-S.Jwt.TryGetNumericDate: integral seconds only, range-bounded, absent → null;ReadLifetimecompares with differences only (no overflow). Acceptance ends at exp+skew, which matches the replay retention of exp+skew.ServiceAuthSyntax:didordid#fragment, with a single non-empty fragment that has no#, space or C0 controls. Audiences are then compared ordinally against the configured set anyway.\z;IdsMatchis ordinal, as in@atproto/identity.No findings. Merging on the claude approval plus green CI.