Token-to-Actor binding following LOLA spec#254
Open
aaronjae22 wants to merge 10 commits into
Open
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #252
This PR implements token-to-actor binding for LOLA portability tokens.
From LOLA Section 5: "This scope MUST be limited to an account — if there is more than one account on the source server, the source server MUST NOT allow access to any other accounts than the one granted."
The changes is scoped to the four currently LOLA-gated collections endpoints (
followers,content,liked,blocked).What this PR does: at OAuth token issuance, the validator writes a TokenActorBinding row linking the new token to the user's source Actor. At request time, the decorator on the four LOLA-gated collection endpoints reads that binding and rejects requests for any other Actor with 403 actor_mismatch. Both halves run inside a fail-closed posture (rollback on issuance failure; reject on missing/mismatched binding). Together they enforce the LOLA Section 5 MUST.
This PR introduces a few things that are worth mention:
Change in settings
I added
OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken"tosettings/base.pyhttps://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#oauth2-provider-access-token-model
django-oauth-toolkit ships with an
AccessTokenmodel which uses it as default. By working with it we are able to reference this access-token model viasettings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODELwhich Django’s app registry can resolve lazily.The new
TokenActorBindingmodel I implemented has aOneToOnerelationship with the access token. For Django migrations to properly create that relationship, the migration system needs to know which model represents access tokens; this is done viamigrations.swappable_dependency(settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL)The first draft I did had something different. I hardcoded
from oauth2_provider.models import AccessTokenand usedAccessTokendirectly in the OneToOne. This worked but I encountered a frew drawbacks, it was bypassing the swappable-model contract and was generating a regular migration dependency rather than a swappable one.New model
In
tokenI setsettings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODELthat references the token model through the swappable setting that was previosuly declared. This is why we needed to added it inbase.py.Meanwhile, in
actorI have a ForeignKey, not OneToOne. I am assuming a single Actor can have multiple tokens bound to it (e.g., multiple destination servers pulling data from the same account, or token refreshes). The spec restricts tokens to one account, not accounts to one token.Error Code + Error Mismatch Builder
This mismatch function returns a standardized 403 response. I choose the 403 instead of 404 because its tells that token is valid but it’s not authorized for this specific actor. A 404 would hide the real problem and make debudding harder for destination server implementors.
Changes in
validators.pyActivityPubOAuth2Validator is registered as django-oauth-toolkit’s validator class via
OAUTH2_PROVIDER["OAUTH2_VALIDATOR_CLASS”]insettings/base.py. django-oauth-toolkit instantiates it once per request and routes all our policies callbacks (scope validation, redirect-URI validation, token persistencia) through this instance.validate_scopesruns during the authorization request, before any token is issued and basically enforces two things: scopes cannot be empty andactivitypub_account_portabilitymust be present. If both checks are ok,super().validate_scopes()goes next and this lets django-oauth-toolkit run its own standard scope validation, and checks the scopes that are registered in SCOPES settings, etc._save_bearer_tokenis the core of the Token-To-Actor binding feature.django-oauth-toolkit recommends overriding 
_save_bearer_token (notsave_bearer_token) for custom token-storage logic so the write rides the sametransaction.atomic()block django-oauth-toolkit already opens.By overriding the inner method we make sure that the binding creation participates in the same transaction as the token creation. This is also useful because if anything goes wrong (e.g, actor resolution fails, a binding conflict is detected), the entire transaction rolls back and the token is never issued.
The actor resolution in
_resolve_bound_actordetermines which actor the token should be bound to.This was placed before
AccessTokenlookup because if actor resolution fails, we should rollback and avoid the binding, which doesn’t exist yet and theAccessTokenrowsuper()just insterted.There are two paths. the explicit selection and the deterministic one which is the unique source actor, if no explicit id is provided, it looks up the user’s single source actor. The mode’ls
Actor.clean()enforces one source actor per user.This exception propagates out through the atomic block, which causes Django to roll back the transaction. The
AccessTokenrowsuper()inserted is undone. The destination receives an OAuth error response and is left with no token.The
.objects.get(token=token["access_token”]):super() just wrote a row whose token column equals the string intoken["access_token”]. We look it up by that string to get the model instance, which we need as the FK target for the binding. The lookup is guaranteed to succeed becausesuper()just inserted it inside the same transaction.get_or_createhandles an edge case, if a binding already exists for the token (possible during token refresh) and the existing binding points to a different actor that the one being resolved now raises anErrorbecause that’s a security violation. It rolls everything back.If the binding already existed and the actor it was originally bound to differs from the actor we need to resolve it and treat it as a security failure.
This could be adressed later on as it was mostly implemented for covering edge cases. Since a binding exists, it should have been created by us, on a previous issueance for the same user and resolving to the same source actor. The actor shouldn’t have changed unless something has gone wrong, like the user’s source actor was deleted and replaced, or other bugs I am not aware of yet.
This is the successful-path log.
Changes in the decorator (
validate_lola_access+ helpers)This is where the spec’s requirements actually gets enforced at request time.
validate_lola_accessreturns a dict.Layer 1: Scope check
This checks whether the token carries the
activitypub_account_portabilityscope. Thehas_portability_scopeattribute is set earlier in theOptionalOAuth2Authenticationclass.If it’s missing or
False, the request gets a 403insuffiencient_scopewhich means that the token exists but doesn’t have the right permissions.Layer 2: Actor binding check
This is the spec enforcement. It only runs when a portability-scoped token is present. There are three possible outcomes:
_check_actor_bindingfor the actual comparison.{“valid": True}.
_get_url_pk just pulls the actor pk from Django’s URL resolver. All LOLA endpoints follow the pattern /api/actors/int:pk/..., so the pk is always inresolver_match.kwargs["pk"]. Returns None if it can’t find one, which triggers the fail-closed path above.
_check_actor_bindingis the core comparison. It’s where the TokenActorBinding model gets queried at request time.binding = token.actor_binding # OneToOne reverse accessorThis works because of the
related_name="actor_binding" that was defined on theTokenActorBinding.token field. Django’s OneToOneField creates a reverse accessor.No binding exists (ObjectDoesNotExist) means that the token has the portability scope but no
TokenActorBindingrow. This shouldn’t happen if the validator is working as expected, but it could occur if something bypassed the validator. Rather than letting an unbound token access anything, it denies access.Binding points to a different actor (
binding.actor_id != int(url_pk)) means that the token is bound to Actor A but the request is trying to access Actor B. This is the exact scenario LOLA prohibits.In both cases, the same
build_actor_mismatch_errorproduces the 403 response.Returning
Noneon sucess is a clean pattern, the caller only needs to checkif mismatch is not None.The full chain is: validator creates the binding at issuance → decorator enforces the binding at access → error builder communicates violations. Each piece has a single job.
dosctring update on
authentication.pyand TokenActorBindingFactory onfactories.pyI currently have three authentication paths. Each serves it own purpose.
All three paths produce the same (user, token) shape and set
request.authto theAccessTokeninstance. The actor binding check invalidate_lola_access()operates onrequest.authand therefore covers all three paths uniformly.token = factory.SubFactory(AccessTokenFactory, lola_scope=True)resolves like this:
After this ends,
token.userexists and has exactly one source actor, created by the signal and this sets up the next field.actor = factory.LazyAttribute(lambda o: o.token.user.actors.get(role=Actor.ROLE_SOURCE)). The LazyAttribute evaluates after token resolves. It reads back the source actor the signal created fortoken.userand uses that as the binding's actor. The result:token.user == actor.userby construction.By the end,
binding.token,binding.actor, andbinding.token.user == binding.actor.userare all true. That's the invariant the production validator establishes; the factory replicates it without going through OAuth.I thought of several approaches at first, like
bound_actortrait onAccessTokenFactory, but then token creation and binding creation became entangled.Tests
I created a shared helper (
_make_lola_request) that construct a request object that looks tovalidate_lola_accessexactly as if it had come through the full Django request pipeline with a successful LOLA-scoped authentication.test_one_token_one_binding_enforcedasserts that the database refuses to create a secondTokenActorBindingrow pointing to a token that already has one.test_validator_creates_binding_for_portability_tokenasserts that when the validator’s_save_bearer_tokenruns with a portability-scoped token, aTokenActorBindingrow appears in the database linking to the token to the user’s source actor.test_validator_skips_binding_for_non_portability_tokenasserts that when a non-LOLA token is issued, no dingin row is created.test_same_actor_access_succeedsis the happy path that returns{“valid": True}with no error response.test_cross_actor_access_deniedis the LOLA section 5 enforcemnt that fires when a token is used to access an actor it isn’t bound to.test_bearer_same_actor_succeedsthis is a HTTP request with a Bearer header that produces a 200 OK from the LOLA-gated endpoint when the token is bound to the requested actor.test_bearer_cross_actor_denieda HTTP cross-actor request produces a 403 withactor_mismatcherror code.Test on live server
Using Factories directly on the terminal
Create actor A + actor B + a LOLA-scoped token bound to actor A via the shell
Resultst:
1. Bound actor should be 200 OK
{ "@context": "https://www.w3.org/ns/activitystreams", "type": "OrderedCollection", "id": "http://localhost:8000/api/actors/1/followers", "totalItems": 1, "orderedItems": [ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://purl.archive.org/socialweb/blocked", "https://swicg.github.io/activitypub-data-portability/lola" ], "type": "Person", "id": "http://localhost:8000/api/actors/3", "preferredUsername": "user_1_source", "name": "user_1_source", "inbox": "http://localhost:8000/api/actors/3/inbox", "previously": [], "accountPortabilityOauth": "http://localhost:8000/oauth/authorize/", "outbox": "http://localhost:8000/api/actors/3/outbox", "following": "http://localhost:8000/api/actors/3/following", "followers": "http://localhost:8000/api/actors/3/followers", "liked": "http://localhost:8000/api/actors/3/liked", "blocked": "http://localhost:8000/api/actors/3/blocked", "migration": { "outbox": "http://localhost:8000/api/actors/3/outbox", "content": "http://localhost:8000/api/actors/3/content", "following": "http://localhost:8000/api/actors/3/following", "blocked": "http://localhost:8000/api/actors/3/blocked", "liked": "http://localhost:8000/api/actors/3/liked" } } ] }2. Cross-actor | 403 actor_mismatch:
{ "error_code": "actor_mismatch", "detail": "This token is not authorized for the requested actor", "timestamp": "2026-04-24T22:17:17.904594+00:00", "hint": "LOLA portability tokens are bound to a single source actor at issuance and cannot access other actors", "remediation": "Request a new OAuth token for the target actor", "endpoint": "/api/actors/3/followers/", "method": "GET", "request_id": "a5f3205a-30df-4608-bd20-ac395111a340" }3. No token | 403 insufficient_scope
curl -sS -w "\nHTTP %{http_code}\n" \ "http://localhost:8000/api/actors/1/followers/"{ "error_code": "insufficient_scope", "detail": "This endpoint requires activitypub_account_portability scope", "timestamp": "2026-04-24T22:19:02.668499+00:00", "hint": "LOLA portability endpoints require specific OAuth scope for data access", "remediation": "Request OAuth token with 'activitypub_account_portability' scope to access /api/actors/1/followers/", "endpoint": "/api/actors/1/followers/", "method": "GET", "request_id": "d9196096-7c2c-4d87-9da7-04d1dbf9c4ba" }Running server
In our current full browser-based OAuth demo we can issues a real LOLA token and displays clickable links to the gated endpoints. That flow works with
_save_bearer_token, which creates the binding, and the links use ?auth_token= URL-param authFirst we have to go through the OAuth flow in the browser, sign up with a new user, fill the OAuth application form (just add a Redirect URL, other fields are automatically added).
Start the OAuth authorization flow from the index page
Then Authorize → callback → token exchange
http://127.0.0.1:8000/callback?code=4ao3SriuTysrfIiDUpDbiAHHLOqyBH&state=OzrprYJ7Qs0oxVE4L7rjNKrMNVHkUYdgIMMEiuLcXuoAnd lastly we land on the Test LOLA ActivityPub Endpoints page. At this point the validator's `_save_bearer_token` has already created a TokenActorBinding row for the source actor
Click on the link that proves the happy path which is the Followers Collection (LOLA-gated)
Opens /api/actors/19/followers/?format=json&auth_token= → 200 with an OrderedCollection.
If we change the actors so instead of 19 we try to access actor one