Skip to content

Token-to-Actor binding following LOLA spec#254

Open
aaronjae22 wants to merge 10 commits into
mainfrom
aaronaej/feat/review/token-actor-binding
Open

Token-to-Actor binding following LOLA spec#254
aaronjae22 wants to merge 10 commits into
mainfrom
aaronaej/feat/review/token-actor-binding

Conversation

@aaronjae22
Copy link
Copy Markdown
Collaborator

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" to settings/base.py

https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#oauth2-provider-access-token-model

django-oauth-toolkit ships with an AccessToken model which uses it as default. By working with it we are able to reference this access-token model via settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL which Django’s app registry can resolve lazily.

The new TokenActorBinding model I implemented has a OneToOne relationship 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 via migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL)⁠

The first draft I did had something different. I hardcoded from oauth2_provider.models import AccessToken and used AccessToken directly 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

class TokenActorBinding(models.Model):
	# Binds a LOLA OAuth access token to exactly one source Actor
	token = models.OneToOneField(
		settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL,
		on_delete=models.CASCADE,
		related_name="actor_binding",
	)
	actor = models.ForeignKey(
		"Actor",
		on_delete=models.CASCADE,
		related_name="portability_token_bindings",
	)
	created_at = models.DateTimeField(auto_now_add=True)

In token I set ‎⁠settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL that references the token model through the swappable setting that was previosuly declared. This is why we needed to added it in base.py.

Meanwhile, in actor I 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

class ErrorCodes:
	...
	ACTOR_MISMATCH = "actor_mismatch"
	...

def build_actor_mismatch_error(request=None):
	"""
	Build standardized 403 error when a portability token is used to access an
	Actor other than the one it was bound to at authorization time.
	"""
	return build_error_response(
		error_code=ErrorCodes.ACTOR_MISMATCH,
		detail="This token is not authorized for the requested actor",
		status_code=403,
		request=request,
		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", )

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.py

ActivityPubOAuth2Validator is registered as django-oauth-toolkit’s validator class via OAUTH2_PROVIDER["OAUTH2_VALIDATOR_CLASS”] in settings/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.

# Custom validator for ActivityPub-specific OAuth requirements
class ActivityPubOAuth2Validator(OAuth2Validator):
    # The OAuth scope that marks a token as a LOLA portability token.
    LOLA_PORTABILITY_SCOPE = 'activitypub_account_portability'

    # Ensure the client is requesting valid scopes for ActivityPub portability
    def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
        if not scopes:
            logger.warning("Client %s requested OAuth with no scopes", client_id)
            return False

        # For account portability, it requires the 'activitypub_account_portability' scope
        if self.LOLA_PORTABILITY_SCOPE not in scopes:
            logger.warning(
                "Client %s requested OAuth without %r scope. Scopes: %s",
                client_id,
                self.LOLA_PORTABILITY_SCOPE,
                scopes,
            )
            return False

        logger.info("Client %s requested valid scopes: %s", client_id, scopes)
        return super().validate_scopes(client_id, scopes, client, request, *args, **kwargs)

validate_scopes runs during the authorization request, before any token is issued and basically enforces two things: scopes cannot be empty and activitypub_account_portability must 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.

# In settings/base.py
# OAuth2 provider configuration
OAUTH2_PROVIDER = {
    'SCOPES': {
        'activitypub_account_portability': 'ActivityPub Account Portability',
    },
	...,
	...,
    'OAUTH2_VALIDATOR_CLASS': 'testbed.core.oauth.validators.ActivityPubOAuth2Validator',
}

_save_bearer_token is the core of the Token-To-Actor binding feature.

django-oauth-toolkit recommends overriding _save_bearer_token (not save_bearer_token) for custom token-storage logic so the write rides the same transaction.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.

super()._save_bearer_token()     ← DOT creates the AccessToken row
				↓
Is this a LOLA-scoped token?     ← Check scope string
				↓ NO → return (skip binding)
				↓ YES
_resolve_bound_actor()           ← Find the actor for this user
				↓ None → raise Fatal → rollback (no token issued)
				↓ found
get_or_create(token, actor)      ← Create the binding
				↓ conflict → raise Fatal → rollback
				↓ success → log and done

The actor resolution in _resolve_bound_actor determines which actor the token should be bound to.

actor = self._resolve_bound_actor(request)
if actor is None:
    logger.warning(
        "LOLA token issuance rejected: no resolvable source actor for user_id=%s",
        getattr(getattr(request, "user", None), "pk", None),
    )
    raise InvalidRequestFatalError(description="actor_binding_unavailable")

This was placed before AccessToken lookup because if actor resolution fails, we should rollback and avoid the binding, which doesn’t exist yet and the AccessToken row super() just insterted.

def _resolve_bound_actor(self, request):
	from testbed.core.models import Actor

	user = getattr(request, "user", None)
	if user is None or not getattr(user, "is_authenticated", False):
		return None

	bound_id = getattr(request, "activitypub_bound_actor_id", None)
	try:
		if bound_id is not None:
			return Actor.objects.get(
				pk=bound_id,
				user=user,
				role=Actor.ROLE_SOURCE,
			)
		return Actor.objects.get(user=user, role=Actor.ROLE_SOURCE)
	except Actor.DoesNotExist:
		return None

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 AccessToken row super() inserted is undone. The destination receives an OAuth error response and is left with no token.


access_token_model = get_access_token_model()
access_token = access_token_model.objects.get(token=token["access_token"])

The .objects.get(token=token["access_token”]): super() just wrote a row whose token column equals the string in token["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 because super() just inserted it inside the same transaction.


get_or_create⁠ handles 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 an Error because that’s a security violation. It rolls everything back.

binding, created = TokenActorBinding.objects.get_or_create(
	token=access_token,
	defaults={"actor": actor},

)
if not created and binding.actor_id != actor.pk:
	raise InvalidRequestFatalError(description="actor_binding_conflict")

if not created and binding.actor_id != actor.pk:
	logger.error(
    	"LOLA token binding conflict: existing bound_actor_id=%s does not match resolved actor_id=%s for user_id=%s",
        binding.actor_id,
        actor.pk,
        getattr(getattr(request, "user", None), "pk", None),
	)
    raise InvalidRequestFatalError(description="actor_binding_conflict")

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.


logger.info(
	"LOLA token bound to actor: actor_id=%s user_id=%s created=%s",
    actor.pk,
    getattr(getattr(request, "user", None), "pk", None),
    created,
)

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_access⁠ returns a dict.

Layer 1: Scope check

if required_scope and not getattr(request, "has_portability_scope", False):

This checks whether the token carries the activitypub_account_portability scope. The has_portability_scope attribute is set earlier in the OptionalOAuth2Authentication class.

If it’s missing or False, the request gets a 403 insuffiencient_scope which means that the token exists but doesn’t have the right permissions.

Layer 2: Actor binding check

if required_scope and getattr(request, "has_portability_scope", False):

This is the spec enforcement. It only runs when a portability-scoped token is present. There are three possible outcomes:

  1. No ‎⁠pk⁠ in the URL: It’s a fail closed. This catches misconfiguration. If someone applies this check to a non-actor-scoped endpoint, it refuses access rather than silently skipping the check.
  2. Token exists: delegate to ‎⁠_check_actor_binding⁠ for the actual comparison.
  3. Everything passes: log success, return ‎⁠{“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 in ‎⁠resolver_match.kwargs["pk"]⁠. Returns ‎⁠None⁠ if it can’t find one, which triggers the fail-closed path above.

def _get_url_pk(request):
    """
    Extract the actor primary key from the URL resolver kwargs.
    """
    resolver_match = getattr(request, "resolver_match", None)
    if resolver_match is None:
        return None
    return resolver_match.kwargs.get("pk")

‎⁠_check_actor_binding⁠ is the core comparison. It’s where the ‎⁠TokenActorBinding⁠ model gets queried at request time.

def _check_actor_binding(request, token, url_pk):
    """
    Compare the token's bound Actor against the actor pk in the URL.

    Returns a validation failure dict (with error_response) on mismatch or
    missing binding, or None when the binding is valid.

    Failure modes:
    - Missing binding row (ObjectDoesNotExist on token.actor_binding):
      the token has no TokenActorBinding.
    - binding.actor_id != url_pk: the token is bound to a different actor
      than the one being requested. Fail closed: actor_mismatch.
    """
		....
		....
		....
    
    return None

binding = token.actor_binding  # OneToOne reverse accessor

This works because of the ‎⁠related_name="actor_binding"⁠ that was defined on the ‎⁠TokenActorBinding.token⁠ field. Django’s OneToOneField creates a reverse accessor.

No binding exists (‎⁠ObjectDoesNotExist⁠) means that the token has the portability scope but no ‎⁠TokenActorBinding⁠ row. 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.

except ObjectDoesNotExist:
	logger.warning(
    	"LOLA access denied: portability token has no actor_binding "
        "token_id=%s path=%s",
        getattr(token, "pk", None),
        request.path,
    )
    return {
    	"valid": False,
        "error_response": build_actor_mismatch_error(request=request),
    }

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.

if binding.actor_id != int(url_pk):
	logger.warning(
		"LOLA access denied: actor_mismatch token_id=%s bound_actor_id=%s "
        "requested_pk=%s path=%s",
        getattr(token, "pk", None),
        binding.actor_id,
        url_pk,
        request.path,
    )
    return {
    	"valid": False,
        "error_response": build_actor_mismatch_error(request=request),
    }

In both cases, the same ‎⁠build_actor_mismatch_error⁠ produces the 403 response.
Returning None on sucess is a clean pattern, the caller only needs to check if mismatch is not None.

Request with bearer token
	↓
OptionalOAuth2Authentication sets request.auth, request.has_portability_scope
	↓
View calls validate_lola_access(request)
	↓
Layer 1: has_portability_scope? → NO → 403 insufficient_scope
	↓ YES
Layer 2: _get_url_pk → _check_actor_binding
	↓
token.actor_binding.actor_id == url pk? → NO → 403 actor_mismatch
	↓ YES
{"valid": True} → view proceeds to serve data

The full chain is: validator creates the binding at issuancedecorator enforces the binding at accesserror builder communicates violations. Each piece has a single job.

dosctring update on authentication.py and TokenActorBindingFactory on factories.py

I currently have three authentication paths. Each serves it own purpose.

Path Method Purpose
1. Authorization header ⁠Authorization: Bearer ⁠ Production — how LOLA spec expects tokens to be presented
2. URL parameter ⁠?auth_token=...⁠ Developer testing convenience
3. Session storage Django session Demo/UI convenience

All three paths produce the same (user, token) shape and set request.auth to the AccessToken instance. The actor binding check in validate_lola_access() operates on request.auth and therefore covers all three paths uniformly.

class TokenActorBindingFactory(DjangoModelFactory):
    """
    Creates a TokenActorBinding that associates an AccessToken with a source Actor.

    Default behavior:
      - Creates a fresh LOLA-scoped AccessToken (token.user is a fresh UserOnlyFactory).
      - Creates a fresh source Actor whose user matches the token's user, so the
        resulting binding is self-consistent with what the OAuth validator
        enforces at runtime (actor resolved from request.user).

    Usage:
        # Fresh token + fresh source actor, same user (self-consistent).
        binding = TokenActorBindingFactory()

        # Binding for a specific actor and token.
        binding = TokenActorBindingFactory(actor=my_actor, token=my_token)

        # Portability token already bound to an existing source actor.
        actor = create_isolated_actor("test")
        token = AccessTokenFactory(lola_scope=True, user=actor.user)
        binding = TokenActorBindingFactory(token=token, actor=actor)

        # Mismatched binding for negative/security tests: pass actor and token
        # with different users explicitly.
    """

    class Meta:
        model = TokenActorBinding

    token = factory.SubFactory(AccessTokenFactory, lola_scope=True)

    # Reuse the source Actor the post_save signal auto-created for token.user.
    # Creating a new Actor here would collide with the signal-created one on the
    # unique username constraint and violate the "one source actor per user"
    # invariant. Tests that need a different actor can override this explicitly.
    actor = factory.LazyAttribute(
        lambda o: o.token.user.actors.get(role=Actor.ROLE_SOURCE)
    )

token = factory.SubFactory(AccessTokenFactory, lola_scope=True)
resolves like this:

TokenActorBindingFactory()
  └── token: SubFactory(AccessTokenFactory, lola_scope=True)
        └── AccessTokenFactory(lola_scope=True)
              └── user: SubFactory(UserOnlyFactory)
                    └── UserOnlyFactory()
                          └── creates User row
                                └── post_save signal fires
                                      └── creates source Actor (username "user_N_source")
                                      └── creates destination Actor (username "user_N_dest")
                          └── User instance available
              └── application: SubFactory(ApplicationFactory)
              └── token: "test-token-N"
              └── scope: "activitypub_account_portability read write"
              └── creates AccessToken row

After this ends, token.user exists 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 for token.user and uses that as the binding's actor. The result: token.user == actor.user by construction.

By the end, binding.token, binding.actor, and binding.token.user == binding.actor.user are 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_actor trait on AccessTokenFactory, 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 to validate_lola_access exactly as if it had come through the full Django request pipeline with a successful LOLA-scoped authentication.

test_one_token_one_binding_enforced asserts that the database refuses to create a second TokenActorBinding row pointing to a token that already has one.

test_validator_creates_binding_for_portability_token asserts that when the validator’s _save_bearer_token runs with a portability-scoped token, a TokenActorBinding row appears in the database linking to the token to the user’s source actor.

test_validator_skips_binding_for_non_portability_token asserts that when a non-LOLA token is issued, no dingin row is created.

test_same_actor_access_succeeds is the happy path that returns {“valid": True} with no error response.

test_cross_actor_access_denied is 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_succeeds this 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_denied a HTTP cross-actor request produces a 403 with actor_mismatch error 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

image

Resultst:

ACTOR_A_ID = 1
ACTOR_B_ID = 3
TOKEN = test-token-0
1. Bound actor should be 200 OK
curl -sS -w "\nHTTP %{http_code}\n" \
  -H "Authorization: Bearer test-token-0" \
  "http://localhost:8000/api/actors/1/followers/"
image
{
  "@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:
curl -sS -w "\nHTTP %{http_code}\n" \
  -H "Authorization: Bearer test-token-0" \
  "http://localhost:8000/api/actors/3/followers/"
image
{
  "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_scopecurl -sS -w "\nHTTP %{http_code}\n" \ "http://localhost:8000/api/actors/1/followers/" image
{
  "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 auth

First 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
http://127.0.0.1:8000/oauth/authorize/?client_id=LdQAstzdOh&response_type=code&scope=activitypub_account_portability&redirect_uri=http://127.0.0.1:8000/callback&state=OzrprYJ7Qs0oxVE4L7rjNKrMNVHkUYdgIMMEiuLcXuo
image
Then Authorize → callback → token exchangehttp://127.0.0.1:8000/callback?code=4ao3SriuTysrfIiDUpDbiAHHLOqyBH&state=OzrprYJ7Qs0oxVE4L7rjNKrMNVHkUYdgIMMEiuLcXuo image
And 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 image

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.

image

If we change the actors so instead of 19 we try to access actor one

http://127.0.0.1:8000/api/actors/1/followers/?format=json&auth_token=UrdVV4DwHwQlnpHlOryt53CSDZQ5yh
image

@aaronjae22 aaronjae22 self-assigned this Apr 27, 2026
@aaronjae22 aaronjae22 requested a review from lisad April 28, 2026 15:03
Copy link
Copy Markdown
Contributor

@alexbainter alexbainter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Token-to-Actor Binding

2 participants