Accept the account's own service auth on uploadBlob #652

Merged ngerakines.me
targeting main from fix/uploadblob-service-auth
Merged opened by and assigned to ngerakines.me
targeting main from fix/uploadblob-service-auth

Video upload from the Bluesky client failed for every account on this server. The client reported "The video could not be uploaded to your hosting provider" and the video service's job record said pds_upload_failure at 70% -- after transcoding had already succeeded.

The flow has three legs and only the last was broken. The client asks its own PDS for a service-auth token (getServiceAuth, aud = that PDS, lxm = com.atproto.repo.uploadBlob), hands it to video.bsky.app, and the video service presents it back to deposit the transcoded blob. uploadBlob accepted only the two HS256 shapes this server issues, so the asymmetrically-signed token was refused at the algorithm check -- a credential this server had minted itself thirteen seconds earlier, addressed to itself, scoped to exactly the method being called.

The courier is not the principal, which is what keeps the fix small. iss is the account, the signature is the account's own #atproto key, aud is this server. Nothing here decides whether the video service is trustworthy and there is no allow-list to keep: whoever carries a token the account asked for may deposit a blob into that account's own repository, which is what the account authorized by asking.

Four checks, each load-bearing. aud must name this server, or a token minted for another service is replayable here by whoever received it. lxm must name this method, because lxm is the grant. The signature must verify against the key the issuer publishes at #atproto. And the issuer must be an account this server hosts -- aud only says a token was addressed here, and without that check a valid token from a DID hosted elsewhere would open an actor store for an account we do not have. The blob lands in iss's repository and nowhere else; service auth carries no sub, so reading one would address the empty string.

Claim checking is space::service_auth::verify_service_auth, already used by inbound migration and admin-by-DID. Scoped to uploadBlob alone: importRepo and listMissingBlobs share the session guard and are deliberately not widened.

Also: a refused access token now says what it was. These failures produced no log line at all, so an operator had a failing integration, a healthy server, and nothing connecting the two -- which is most of why this report needed a job-status lookup and log archaeology to explain. The debug record names the token's alg, typ, iss, aud, lxm, iat, exp and jti, never the token, and every value is unverified by construction. It is off by default because a lapsed token is ordinary traffic: RUST_LOG=info,atproto_pds::http::auth=debug. The refusal for an asymmetric token also stops reporting "unsupported alg ES256K", which read as this server lacking ES256K support rather than as the token being the wrong kind.

Verified end to end on a live deployment: one uploadBlob of 3,780,988 bytes accepted, and the resulting app.bsky.embed.video post written.

Commits 0