Permissioned Data Token Exchange

Over the last few weeks I’ve been building against the 0016 Permissioned Data proposal and its reference implementation. I like it, big fan. The spaces model, the two-hop trust structure between an account’s PDS and a space authority, and the dynamic space scope all fit together. Working on it both from the implementor and integrator’s point of view, I’ve put some thought into a narrow proposal for one specific thing: how the delegation token and the space credential get minted, and what claims they carry.

I shared a spec-shaped version of this with Bryan and Daniel last week. Their feedback, which I think is fair, was that a change like this should get chewed on in public with input from other implementers before anything becomes a PR against the proposal, and that the conversation should start with motivation and tradeoffs rather than wire details. So that’s what this post is. The full proposal and an end-to-end walk-through are linked at the bottom for anyone who wants the particulars.

What 0016 does today

An application holding an OAuth session with a valid space: scope calls com.atproto.space.getDelegationToken on the user’s PDS and gets back a short-lived, single-use JWT signed with the account’s #atproto key. It presents that token to com.atproto.space.getSpaceCredential on the space host, and gets back a credential signed by the space authority to use with any PDS for that space. These two XRPC methods take a provided security token and exchange it for another.

Proposal

Mint both tokens at token endpoints using the RFC 8693 token-exchange grant instead of at XRPC methods.

The delegation token comes from the token-exchange grant at the PDS token endpoint, with the session’s access token as the subject token and the space named in scope. The space credential comes from the token-exchange grant at a token endpoint the space authority operates, with the delegation token as the subject token and the client attestation sent as an ordinary client_assertion, which is what it already is structurally. The space moves out of sub and into scope, using the space: resource 0016 already defines. The credential gains a cnf.jkt binding to the client’s session DPoP key. Each minted class gets a token type identifier (https://atproto.com/oauth/token-type/space-delegation and .../space-credential) that the client requests at the exchange and the server echoes in issued_token_type, alongside the typ header values 0016 already defines.

Here is the credential as 0016 specifies it:

{
  "iss": "did:web:clubs.example",
  "sub": "at://did:web:clubs.example/space/com.example.club/3lz5j2kq",
  "iat": 1788012300,
  "exp": 1788019500,
  "jti": "01M16XF3ZT4G8W2NQ5RDJHV9AC"
}

And under this proposal:

{
  "iss": "did:web:clubs.example",
  "iat": 1788012300,
  "exp": 1788019500,
  "jti": "01M16XF3ZT4G8W2NQ5RDJHV9AC",
  "scope": "space:com.example.club?authority=did:web:clubs.example&skey=3lz5j2kq"
}

Everything else stays where it is. The record and event vocabulary, simplespace, membership decisions, token lifetimes, typ values, two-hop acquisition, notifications, etc. are all untouched. The data flow and the network architecture don’t change.

Rationale

The first reason is that both mint operations already are token exchanges. getDelegationToken and getSpaceCredential each take a security token, validate it, and issue a different one, which is the exact shape RFC 8693 standardizes. When the overlap is that close, doing it differently needs a justification, and I haven’t found one that holds up once you get into the details.

The second reason is reuse, though not the kind Bryan shared his skepticism of. Generic OAuth SDKs have been a mixed bag with atproto, and client metadata URLs, mandatory PAR, and DPoP mean client developers do a lot of work regardless. The reuse I care about is server side and internal. A PDS token endpoint already validates private_key_jwt assertions, enforces DPoP with server nonces, evaluates scopes, and returns structured errors. Adding an exchange grant is another branch in a handler that exists. Two new XRPC methods mean two new handlers that each re-implement client authentication, proof of possession, replay protection, rate limiting, error handling, etc.

The third reason is the boundary between minting credentials and using them. Issuing tokens is one problem and one threat surface: authenticate the requester, prove possession, prevent replay, make the endpoint discoverable. A token endpoint solves that once, in one place that’s easy to rate limit, log, and apply policy to. When an XRPC method mints tokens, it becomes its own small authentication endpoint and has to solve all of that again. Service auth has this shape today: the OAuth session’s scopes decide what can be minted, and then getServiceAuth mints it in a different format through a different path. The service auth discussion already calls that indirect, and 0016 repeats the pattern twice more.

The fourth reason is vocabulary. Everything in the three custom claim sets can be said with registered claims, so any JWT library can read these tokens and the claim validation code atproto endpoints already have applies to them. RFC 7519 defines sub as a principal, and a space is a resource, so it belongs in scope. That also means one addressing scheme runs from the session grant through both exchanges into the credential. The credential deliberately carries neither sub nor aud, because it states a client’s access to a space and gets presented to many repo hosts. RFC 7519 only applies the audience check when the claim is present.

The fifth reason is that cnf.jkt gets us the thing “Boring Auth” was after, a credential tied to the OAuth client that asked for it. The DPoP proof on the exchange supplies the key, and every presentation pairs the credential with a proof over that key. Replay handling becomes clean by class: the delegation token and the attestation burn on first use, while the 2+ hour credential can be reused freely because holding it spends nothing without the key.

Feedback and Concerns

Bryan and Daniel gave some really good feedback, and I’m sharing my understanding of that feedback and the responses I gave.

Daniel’s first shared concern is that the spaces spec would become reliant on RFC 8693, a heavier spec, and get further entwined with OAuth. My response is that the RFC stack is already load bearing in 0016. The attestation is a private_key_jwt (RFC 7523), every token is an RFC 7519 JWT with typ per RFC 8725, and DPoP is RFC 9449, whose title begins with “OAuth 2.0”. The slice of 8693 used here is small, with no actor tokens, no multi-resource requests, and nothing about refresh. Everything 8693 says about minting still has to be written down somewhere, and without the RFC the spaces spec gets bigger by re-specifying known things under new names.

Daniel also wants to push the OAuth boundary out as far as possible because OAuth has historically been hard for a lot of developers. I feel that and I’ve personally put a lot of work into helping tackle that. Where we differ is the minting step. To me, 0016 as written pushes OAuth mechanics (client assertions, DPoP, scope checks) into XRPC handlers, and moving the two mints to token endpoints contains them in the one route that is already OAuth. The hard parts of OAuth are the authorization code flow: redirects, PAR, PKCE, consent, refresh. None of that is involved here. A space authority handles one POST endpoint using concepts its implementer already needs for everything else in 0016.

Bryan doesn’t want to commit to “OAuth for everything, forever” at the sync layer, with scoped API tokens still to come and something like UCANs possible further out. I think the proposal is compatible with that. The only OAuth involved is the token endpoint, tokens in and tokens out. There is no authorization endpoint, PAR, refresh token, or consent screen at the authority. If (and when) UCANs arrive, a typed exchange with a subject_token_type naming a UCAN is a natural place to plug them in.

Bryan wants client_id and the client attestation to remain the space authority’s choice rather than a hard requirement. Agreed, and the proposal keeps it that way. An ungated space takes no assertion and issues a credential without client_id, and the cnf binding alone names the holder.

Request For Comments

This proposal addresses a core feature of the 0016 Permissioned Data proposal and needs your feedback and support. Please chime:

  • about the boundary between sources (XRPC) and authz/authn
  • about the design and shape of the two XRPC methods
  • about the use of scopes to reference resources instead of a JWT subject claim
  • if you care about registered claims and generic JWT tooling
  • or anything adjacent or inbetween

Proposal Reference

Links

2 Likes

In the interest of clarity, can you change “account’s” to “user’s” in “account’s #atproto key,” because I can see someone misunderstanding it as some other account.

:slight_smile:

From a “not a hardcore developer” perspective,

is easier to reason about than

There’s more available context that folks can use when working through errors.

Hi Nick,

I understand your reuse argument and personally would like to do it as much as possible. And converting the getDelegationToken and getSpaceCredential endpoints to a “standard” token endpoint is something I would consider, my concern lies in how the token endpoint is discovered. We need a way then to distinguish between the authorization server token endpoint and a resource server token endpoint, because at least in my setup those are separate concerns , the authorization server does not have access to the repo’s private key so cannot mint a delegation token or space credential .

1 Like

I love moving the authorized space into the scope claim! Makes implementing the authz layers for space endpoints that support both user OAuth tokens and space credentials way more streamlined.

I had a concern that this would be abusing the semantics of the OAuth /token endpoint by returning an “authorization grant” that is intended to be presented to and verified by other hosts rather than a traditional “access token”. However, the RFC 8693 spec explicitly says

The identifier access_token is used for historical reasons and the issued token need not be an OAuth access token.

so that’s neat.

I was thinking we could use the scope field from the RFC 8693 token request instead of putting it in the delegation token, but we do need to verify that the client was granted that scope by the user PDS so it does need to be included in the JWT signed by the user PDS.

1 Like

I’m a full enthusiastic supporter of this. A space is a resource capability so scope is more accurate. Stratos treats a space as a resource capability so the authority is deriving the corresponding boundary from it then checks membership. The resulting credential is sued to admin a caller only to that spaces surface so scope is a much more accurate expression of that then treating the entire URI as a subject.

I love delegated tokens as it creates an explicit verifiable chain: PDS authorized user session > user-did delegation > space cred. This has the potential to replace proxied space red/sync for user authorized space access.

There’s a few areas where I think it’s worth discussing (or just me raising my own use cases issues):slight_smile:

  • canonical parsing and single-space cardinality for space: grants

The scope proposal already implies a fixed set of values (space type, authority, skey) so it would need to be tightly structured and validated as sub is. I’m mapping this to boundaries and validated enrollment but it doesn’t prove membership so would need to have same expectation as sub where the structure is guaranteed.

  • Explicit exchange-endpoint discovery when authority and auth server differ

It’s already in the space that the space authority can be different from the pds and so it needs to account for how a client discovers the authorities token exchange endpoint without implying that it required the full OAuth flow. This could also cover deployments where the auth server doesn’t have the authorities signing key. This is probably just me thinking out loud as yes we can define the service endpoint in the did doc but it doesn’t define how it exposes its token-exchange endpoint.

  • Typed non-PDS issuer path

I simply use did:web for a dedicated service with no PDS but there is pds “like” behavior in stratos and the associated services. It currently generates its own delegation tokens then exchanges it for a credentials. A PDS token endpoint only design could break such a syncer topology.

2 Likes

Thanks for prompting the discussion @ngerakines.me & this post which I think does a good job giving context & expressing motivation.

I’m still not totally sure how I feel about this. I do see the motivation, but while I think it clears up one boundary, I also think it obscures some other boundaries. I also encouraged @matthieu.bsky.team (the resident OAuth guru at Bluesky) to take a look.


Basically my concern is that this change isn’t really semantically neutral. It moves the token exchange flow to an OAuth-specific standard which uses OAuth terminology to describe a set of actors and tokens that don’t match OAuth semantics.

Most clearly, RFC 8693 describes “defining how to request and obtain security tokens from OAuth 2.0 authorization servers”. But the authority for space credentials is not an Authorization Server! It’s a DID (the space authority). This is a pretty different authority model than RFC 8693. Keys also function differently than is common in OAuth (DID documents vs published JWKs), tho I think this is probably still technically compatible.

If we use RFC 8693, we end up having to redefine a lot of core terms used in the specification. Even the required grant type (urn:ietf:params:oauth:grant-type:token-exchange) I believe ends up being misleading as it recasts an atproto space credential as an OAuth grant.

We also lose the resemblance to service auth. I’d like to revamp service auth in the future (delegate-able attenuated service auth?!). Keeping the resemblance makes that feel a bit more in-reach.


Some specific feedback:

The “RFC stack” is just IETF protocols. I’m by no means opposed to using IETF protocols! And hopefully this protocol itself eventually becomes an RFC!

I get that our story is already muddled to some degree by our use of DPoP. I’m not thrilled about that personally, I’d prefer to use a non-OAuth spec. But our use of DPoP is fairly constrained. We’re just using it as a specified construction for a proof of possession. If there was a non-OAuth specified proof of possession scheme, I’d want to use that! Unfortunately I think it’s OAuth or defining our own. I am actually pretty open to arguments that we should define our own. I do think DPoP brings baggage that I don’t really care for.

Worth clarifying that the DPoP for spaces uses totally ephemeral keys and no server nonces. It truly is just a proof of possession construction.

The client attestation bit I think is justified by the fact that that actually is talking about an OAuth client. So that attestation serves as a sort of “bridge” between atproto & OAuth worlds.

Parts of this re-use is dangerous. As the client assertions are addressed to a space authority not a PDS. And DPoP doesn’t enforce server nonces. Scopes include different semantics (no wildcards!). Over-re-using code paths in these different contexts could include footguns if not done carefully.

Similarly, the access pattern for OAuth tokens vs space credentials I expect to be very different! The reasons for rate limiting are quite different. An active space with lots of readers while have much more token requests than any given account. I’m not qutie sure what “policy” refers to here, but I expect policies for these two different contexts will be quite different.


On the scope thing:

I don’t dislike the idea of referring to that claim as scope instead of sub! I’m not thrilled about sub and I agree scope is more descriptive of what the claim is for. This is definitely worth talking about independent of whatever decision we come to on the token exchange.

I’m a bit worried about changing the semantics of the value to the OAuth scope string (with the query params etc). That string is different from the normal space: resource syntax, since wildcards don’t work, multiple values don’t work, and the action and manage values are meaningless. We would need to specify a limited variant of these scope strings.

That ends up being a third thing alongside the OAuth scope strings. and space refs (the at://… format).

Routes that verify space credentials then have to do a transform step between that constrained scope string and the space ref in the parameter. I find this to be simpler & more straightforward if it’s a simple string-equality check.

1 Like