diff --git a/docs/lola-authentication.md b/docs/lola-authentication.md index 39b79d2..e727a85 100644 --- a/docs/lola-authentication.md +++ b/docs/lola-authentication.md @@ -7,6 +7,7 @@ This document provides comprehensive documentation for the LOLA authentication s - [Overview](#overview) - [OAuth Client Credentials Encryption System](#oauth-client-credentials-encryption-system) - [OptionalOAuth2Authentication Class](#optionaloauth2authentication-class) +- [Token-to-Actor Binding (LOLA Section 5)](#token-to-actor-binding-lola-section-5) - [Enhanced API Endpoints](#enhanced-api-endpoints) - [JSON-LD Builder System](#json-ld-builder-system) - [JSON-LD Utilities](#json-ld-utilities) @@ -366,6 +367,109 @@ The class handles various authentication scenarios across all three tiers: - **Database connection errors**: Continue as unauthenticated - **Network timeouts**: Continue as unauthenticated +## Token-to-Actor Binding (LOLA Section 5) + +LOLA portability tokens are bound to a single source Actor at issuance time and +MUST NOT grant access to any other account on the same server. + +> 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." + +### TokenActorBinding Model + +**Location**: `testbed/core/models.py` - `TokenActorBinding` + +A `OneToOneField` pair between `oauth2_provider.AccessToken` and a source `Actor`: + +```python +class TokenActorBinding(models.Model): + 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) +``` + +The `OneToOneField` ensures each token has at most one binding; attempts to +create a second row for the same token fail with `IntegrityError`. + +### Binding at Issuance + +**Location**: `testbed/core/oauth/validators.py` - `ActivityPubOAuth2Validator._save_bearer_token` + +The binding is persisted inside the same `transaction.atomic()` block DOT +(django-oauth-toolkit) opens for token creation. Steps: + +1. `super()._save_bearer_token(...)` lets DOT write the `AccessToken` row. +2. If the issued scope does not include `activitypub_account_portability`, + the validator early-returns (non-portability tokens are not actor-keyed). +3. The validator resolves the source Actor to bind via `_resolve_bound_actor`: + - Preferred: `request.activitypub_bound_actor_id` (set by the authorization + view; re-validated against `user` and `role=ROLE_SOURCE`). + - Fallback: `Actor.objects.get(user=request.user, role=ROLE_SOURCE)`. +4. If no valid source Actor is resolvable, the validator raises + `InvalidRequestFatalError`, which rolls back the transaction and prevents + an unbound portability token from being issued (fail closed). +5. Otherwise, `TokenActorBinding.objects.get_or_create(token=..., defaults={"actor": ...})` + records the binding. A pre-existing binding for a different actor is + treated as a hard security failure and the transaction is rolled back. + +### Enforcement at Request Time + +**Location**: `testbed/core/views/decorators.py` - `validate_lola_access` + +Every LOLA-gated endpoint calls `validate_lola_access(request)`, which now +runs two layers: + +- **Layer 1 - Scope check.** Rejects requests without + `activitypub_account_portability` scope with 403 `insufficient_scope`. +- **Layer 2 - Actor binding check.** For portability-scoped requests, reads + the requested actor pk from `request.resolver_match.kwargs["pk"]` and + compares it to `request.auth.actor_binding.actor_id`. Mismatches, missing + binding rows, and requests without a URL pk all return 403 `actor_mismatch` + (fail closed). + +The binding check covers all three `OptionalOAuth2Authentication` paths +(Authorization header, `?auth_token=` URL parameter, session) because all +three set `request.auth` to the same `AccessToken` instance. + +### Endpoints Enforcing Binding + +The four currently LOLA-gated endpoints (those calling `validate_lola_access(request, required_scope=True)`): + +- `GET /api/actors//followers/` +- `GET /api/actors//content/` +- `GET /api/actors//liked/` +- `GET /api/actors//blocked/` + +Actor-detail, outbox, and following are intentionally out of scope for this +enforcement pass (see `personal-development/TRACKING-NOTES.md`). + +### actor_mismatch Response + +A mismatched/missing binding produces a standardized 403 response +(`testbed/core/utils/errors.py` - `build_actor_mismatch_error`): + +```json +{ + "error_code": "actor_mismatch", + "detail": "This token is not authorized for the requested actor", + "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", + "timestamp": "2026-04-23T...", + "endpoint": "/api/actors/42/followers/", + "method": "GET", + "request_id": "..." +} +``` + ## Enhanced API Endpoints Two core ActivityPub endpoints have been enhanced with LOLA authentication support: diff --git a/testbed/core/factories.py b/testbed/core/factories.py index 4d1f775..0e83ae6 100644 --- a/testbed/core/factories.py +++ b/testbed/core/factories.py @@ -12,6 +12,7 @@ PortabilityOutbox, Following, Followers, + TokenActorBinding, ) # Base factory for creating Users without associated actors @@ -222,3 +223,43 @@ class Params: expired = factory.Trait( expires=factory.LazyFunction(lambda: datetime.now(timezone.utc) - timedelta(hours=1)) ) + + +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) + ) diff --git a/testbed/core/migrations/0009_token_actor_binding.py b/testbed/core/migrations/0009_token_actor_binding.py new file mode 100644 index 0000000..5c6ff34 --- /dev/null +++ b/testbed/core/migrations/0009_token_actor_binding.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.3 on 2026-04-22 23:40 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_blocked'), + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TokenActorBinding', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('actor', models.ForeignKey(help_text='The source Actor this token is authorized to access.', on_delete=django.db.models.deletion.CASCADE, related_name='portability_token_bindings', to='core.actor')), + ('token', models.OneToOneField(help_text='The OAuth access token this binding belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='actor_binding', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL)), + ], + ), + ] diff --git a/testbed/core/models.py b/testbed/core/models.py index a029da2..36e5956 100644 --- a/testbed/core/models.py +++ b/testbed/core/models.py @@ -546,6 +546,34 @@ class Meta: verbose_name_plural = "OAuth Client Credentials" +class TokenActorBinding(models.Model): + """ + Binds a LOLA OAuth access token to exactly one source Actor. + + LOLA Section 5 requires: "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." + """ + token = models.OneToOneField( + settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL, + on_delete=models.CASCADE, + related_name="actor_binding", + help_text="The OAuth access token this binding belongs to.", + + ) + actor = models.ForeignKey( + "Actor", + on_delete=models.CASCADE, + related_name="portability_token_bindings", + help_text="The source Actor this token is authorized to access.", + + ) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"TokenActorBinding(token_id={self.token_id}, actor_id={self.actor_id})" + + class PortabilityOutbox(models.Model): actor = models.OneToOneField( Actor, on_delete=models.CASCADE, related_name="portability_outbox" diff --git a/testbed/core/oauth/authentication.py b/testbed/core/oauth/authentication.py index 20da9d3..fe9bf07 100644 --- a/testbed/core/oauth/authentication.py +++ b/testbed/core/oauth/authentication.py @@ -31,22 +31,26 @@ class OptionalOAuth2Authentication(OAuth2Authentication): def authenticate(self, request): """ Attempt to authenticate the request using OAuth2 with multiple methods. - + Authentication priority: - 1. Authorization header (production federation) - 2. URL parameter (testing convenience) - 3. Session storage (demo enhancement) - - If authentication succeeds, check if the token has the portability scope. - If authentication fails, allow the request to continue as unauthenticated. - - This approach enables the same endpoint to serve both: - - Public data for unauthenticated requests (standard ActivityPub) - - Enhanced data for authenticated requests with portability scope (LOLA) - + 1. Authorization header (production - the normative LOLA path) + 2. URL parameter (?auth_token=) - developer testing convenience only + 3. Session storage - demo enhancement only + + Paths 2 and 3 are developer/demo conveniences that exist because this + testbed doubles as a destination-side demo tool. They are NOT part of the + normative LOLA source-server contract. See docs/lola-authentication.md. + + 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. + + If authentication succeeds, checks if the token has the portability scope. + If authentication fails, allows the request to continue as unauthenticated. + Args: request: The HTTP request object - + Returns: A tuple of (user, token) if authentication succeeds, None otherwise """ diff --git a/testbed/core/oauth/validators.py b/testbed/core/oauth/validators.py index 9c4b9e4..b16df83 100644 --- a/testbed/core/oauth/validators.py +++ b/testbed/core/oauth/validators.py @@ -1,40 +1,166 @@ import logging + +from oauthlib.oauth2.rfc6749.errors import InvalidRequestFatalError +from oauth2_provider.models import get_access_token_model from oauth2_provider.oauth2_validators import OAuth2Validator logger = logging.getLogger(__name__) + # 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(f"Client {client_id} requested OAuth with no 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 'activitypub_account_portability' not in scopes: + if self.LOLA_PORTABILITY_SCOPE not in scopes: logger.warning( - f"Client {client_id} requested OAuth without 'activitypub_account_portability' scope. " - f"Scopes: {scopes}" + "Client %s requested OAuth without %r scope. Scopes: %s", + client_id, + self.LOLA_PORTABILITY_SCOPE, + scopes, ) return False - - logger.info(f"Client {client_id} requested valid scopes: {scopes}") + + logger.info("Client %s requested valid scopes: %s", client_id, scopes) return super().validate_scopes(client_id, scopes, client, request, *args, **kwargs) - + # Additional validation for redirect URIs in ActivityPub context def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs): - + # Standard validation first valid = super().validate_redirect_uri(client_id, redirect_uri, request, *args, **kwargs) - + if not valid: - logger.warning(f"Client {client_id} requested invalid redirect URI: {redirect_uri}") + logger.warning("Client %s requested invalid redirect URI: %s", client_id, redirect_uri) return False - + # We could add additional validation here if needed later on # For example, checking for HTTPS in production - - logger.info(f"Client {client_id} requested valid redirect URI: {redirect_uri}") + + logger.info("Client %s requested valid redirect URI: %s", client_id, redirect_uri) + return True + + def _save_bearer_token(self, token, request, *args, **kwargs): + """ + Persist a TokenActorBinding alongside LOLA-scoped access tokens. + + DOT (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 DOT already opens. This is important for + security: if binding resolution or creation fails, the whole transaction + rolls back and no portability token is issued — i.e., we fail closed + at issuance rather than leaving an unbound LOLA token in the database. + + For non-portability scopes we skip binding entirely so normal + ActivityPub federation tokens keep working unchanged. + + Args: + token: OAuthLib token dict. `token["access_token"]` is the + final access-token string DOT has written to the DB row by + the time super() returns. + request: OAuthLib Request object. `request.user` is the + authenticated Django User for authorization-code grants. + """ + # Let DOT persist the AccessToken first. Any FatalClientError raised by + # super() will propagate out of the atomic block and prevent any write. + super()._save_bearer_token(token, request, *args, **kwargs) + + scope_string = token.get("scope") or "" + if self.LOLA_PORTABILITY_SCOPE not in scope_string.split(): + # Non-LOLA scopes don't get a binding: regular ActivityPub tokens + # are not actor-keyed. Explicit early-return keeps behavior clear. + return + + # Resolve the Actor to bind BEFORE looking up the access token row, so a + # resolution failure rolls back the AccessToken DOT just inserted. + actor = self._resolve_bound_actor(request) + if actor is None: + # Fail closed: refuse to issue an unbound portability token. + # Raising an oauthlib fatal error aborts the token response and + # rolls back the enclosing transaction.atomic() block. + 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") + + # Import the binding model lazily so this module stays importable + # before Django app registry is ready (DOT loads validators early). + from testbed.core.models import TokenActorBinding + + access_token_model = get_access_token_model() + access_token = access_token_model.objects.get(token=token["access_token"]) + + # get_or_create defends the refresh-token-reuse path (where DOT may + # update an existing AccessToken in place and an old binding already + # exists). Under DOT's default ROTATE_REFRESH_TOKEN=True, every refresh + # creates a brand-new AccessToken and this is effectively a create. + binding, created = TokenActorBinding.objects.get_or_create( + token=access_token, + defaults={"actor": actor}, + ) + + if not created and binding.actor_id != actor.pk: + # Pre-existing binding disagrees with the actor we just resolved — + # treat as a hard security failure. Roll back via raise; never + # silently rebind the token to a different actor. + 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") + + # Safe log: we record actor id and user id, never the access-token string. + 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, + ) + + def _resolve_bound_actor(self, request): + """ + Resolve which source Actor a newly-issued LOLA token should be bound to. + + Preference order: + 1. `request.activitypub_bound_actor_id` — the actor id the user + explicitly selected at the authorization step. Populated by + following PR's authorization view; absent today. Re-validated here + against (user, role=ROLE_SOURCE) so a client-supplied id cannot + bind to another user's actor. + 2. Fallback: the authenticated user's unique source Actor. + `Actor.clean()` enforces one source actor per user, so this + lookup is deterministic when an actor exists. + + Returns None when no valid source actor can be resolved; the caller + treats that as a hard failure (refuse to issue the token). + """ + # Import lazily — this module is imported by DOT at app startup before + # the core app's models are fully available in some test configs. + 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 diff --git a/testbed/core/tests/test_api.py b/testbed/core/tests/test_api.py index b3e51a4..99c9a45 100644 --- a/testbed/core/tests/test_api.py +++ b/testbed/core/tests/test_api.py @@ -6,7 +6,12 @@ from django.contrib.auth import get_user_model from oauth2_provider.models import Application, AccessToken from testbed.core.models import Actor, Following, Followers -from testbed.core.factories import ActorFactory, ApplicationFactory, AccessTokenFactory +from testbed.core.factories import ( + ActorFactory, + ApplicationFactory, + AccessTokenFactory, + TokenActorBindingFactory, +) from testbed.core.tests.conftest import create_isolated_actor from testbed.core.json_ld_utils import ( build_basic_context, @@ -342,11 +347,14 @@ def test_followers_collection_requires_lola_authentication(self): @pytest.mark.django_db def test_followers_collection_with_lola_token(self): target_actor, follower1, follower2 = self.setup_followers_data() - lola_token = AccessTokenFactory(lola_scope=True) - + # Bind the token to target_actor so the Task 9.2 token-to-actor + # binding check (LOLA Section 5 MUST) succeeds for this request. + binding = TokenActorBindingFactory(actor=target_actor) + lola_token = binding.token + client = APIClient() client.credentials(HTTP_AUTHORIZATION=f'Bearer {lola_token.token}') - + response = client.get(reverse("followers-collection", kwargs={"pk": target_actor.id})) assert response.status_code == status.HTTP_200_OK @@ -378,13 +386,14 @@ def test_followers_collection_rejects_basic_oauth_token(self): # Validate Followers collection includes full Actor objects for local followers @pytest.mark.django_db - def test_followers_collection_includes_complete_actor_data(self): + def test_followers_collection_includes_complete_actor_data(self): target_actor, follower1, follower2 = self.setup_followers_data() - lola_token = AccessTokenFactory(lola_scope=True) - + binding = TokenActorBindingFactory(actor=target_actor) + lola_token = binding.token + client = APIClient() client.credentials(HTTP_AUTHORIZATION=f'Bearer {lola_token.token}') - + response = client.get(reverse("followers-collection", kwargs={"pk": target_actor.id})) data = response.data diff --git a/testbed/core/tests/test_token_actor_binding.py b/testbed/core/tests/test_token_actor_binding.py new file mode 100644 index 0000000..2980ae8 --- /dev/null +++ b/testbed/core/tests/test_token_actor_binding.py @@ -0,0 +1,209 @@ +import pytest +from unittest.mock import MagicMock, patch + +from django.db import IntegrityError +from django.test import RequestFactory +from django.urls import reverse + +from oauth2_provider.oauth2_validators import OAuth2Validator +from rest_framework import status +from rest_framework.test import APIClient + +from testbed.core.factories import ( + AccessTokenFactory, + TokenActorBindingFactory, + UserOnlyFactory, +) +from testbed.core.models import Actor, TokenActorBinding +from testbed.core.oauth.validators import ActivityPubOAuth2Validator +from testbed.core.views.decorators import validate_lola_access + + +# Model + +@pytest.mark.django_db +def test_one_token_one_binding_enforced(): + """OneToOneField prevents a second binding for the same token.""" + binding = TokenActorBindingFactory() + + other_user = UserOnlyFactory() + other_actor = Actor.objects.create( + user=other_user, + username=f"{other_user.username}_src", + role=Actor.ROLE_SOURCE, + ) + + with pytest.raises(IntegrityError): + TokenActorBinding.objects.create(token=binding.token, actor=other_actor) + + +# Validator + +@pytest.mark.django_db +def test_validator_creates_binding_for_portability_token(): + """_save_bearer_token creates a TokenActorBinding for portability-scoped tokens.""" + user = UserOnlyFactory() + # Use the source Actor the post_save signal auto-created for this user. + # _resolve_bound_actor's fallback path does Actor.objects.get(user=..., + # role=ROLE_SOURCE) and requires exactly one source actor per user. + actor = user.actors.get(role=Actor.ROLE_SOURCE) + access_token = AccessTokenFactory(user=user, lola_scope=True) + + mock_request = MagicMock() + mock_request.user = user + # MagicMock auto-creates attributes, which would push _resolve_bound_actor + # into the preferred-id branch. Force the fallback path explicitly. + mock_request.activitypub_bound_actor_id = None + + token_dict = {"access_token": access_token.token, "scope": access_token.scope} + + validator = ActivityPubOAuth2Validator() + + # Patch super() — AccessToken already exists, avoid duplicate creation. + with patch.object(OAuth2Validator, "_save_bearer_token"): + validator._save_bearer_token(token_dict, mock_request) + + assert TokenActorBinding.objects.filter(token=access_token, actor=actor).exists() + + +@pytest.mark.django_db +def test_validator_skips_binding_for_non_portability_token(): + """_save_bearer_token does not create a binding for tokens without portability scope.""" + user = UserOnlyFactory() + # The signal already created a source actor; don't duplicate it. + user.actors.get(role=Actor.ROLE_SOURCE) + access_token = AccessTokenFactory(user=user, scope="read write") + + mock_request = MagicMock() + mock_request.user = user + mock_request.activitypub_bound_actor_id = None + + token_dict = {"access_token": access_token.token, "scope": "read write"} + + validator = ActivityPubOAuth2Validator() + + with patch.object(OAuth2Validator, "_save_bearer_token"): + validator._save_bearer_token(token_dict, mock_request) + + assert not TokenActorBinding.objects.filter(token=access_token).exists() + + +# Decorator + +def _make_lola_request(actor, token): + """Build a fake request with portability scope targeting the given actor pk.""" + rf = RequestFactory() + request = rf.get(f"/api/actors/{actor.pk}/followers/") + request.is_oauth_authenticated = True + request.has_portability_scope = True + request.auth = token + request.resolver_match = MagicMock() + request.resolver_match.kwargs = {"pk": actor.pk} + return request + + +@pytest.mark.django_db +def test_same_actor_access_succeeds(): + """Token bound to actor A may access actor A.""" + binding = TokenActorBindingFactory() + request = _make_lola_request(binding.actor, binding.token) + + result = validate_lola_access(request) + assert result["valid"] is True + + +@pytest.mark.django_db +def test_cross_actor_access_denied(): + """Token bound to actor A is rejected when accessing actor B.""" + binding = TokenActorBindingFactory() + + user_b = UserOnlyFactory() + actor_b = Actor.objects.create( + user=user_b, username=f"{user_b.username}_src", role=Actor.ROLE_SOURCE + ) + + rf = RequestFactory() + request = rf.get(f"/api/actors/{actor_b.pk}/followers/") + request.is_oauth_authenticated = True + request.has_portability_scope = True + request.auth = binding.token + request.resolver_match = MagicMock() + request.resolver_match.kwargs = {"pk": actor_b.pk} + + result = validate_lola_access(request) + assert result["valid"] is False + assert result["error_response"].status_code == 403 + assert result["error_response"].data["error_code"] == "actor_mismatch" + + +@pytest.mark.django_db +def test_unbound_token_denied(): + """A portability token with no binding row is rejected (fail closed).""" + user = UserOnlyFactory() + actor = Actor.objects.create( + user=user, username=f"{user.username}_src", role=Actor.ROLE_SOURCE + ) + token = AccessTokenFactory(user=user, lola_scope=True) + # Intentionally: no TokenActorBinding created. + + request = _make_lola_request(actor, token) + + result = validate_lola_access(request) + assert result["valid"] is False + assert result["error_response"].status_code == 403 + assert result["error_response"].data["error_code"] == "actor_mismatch" + + +@pytest.mark.django_db +def test_missing_url_pk_fails_closed(): + """A LOLA request without a URL pk is rejected (fail closed, no silent skip).""" + binding = TokenActorBindingFactory() + + rf = RequestFactory() + request = rf.get("/api/unexpected/") + request.is_oauth_authenticated = True + request.has_portability_scope = True + request.auth = binding.token + request.resolver_match = MagicMock() + request.resolver_match.kwargs = {} # no "pk" key + + result = validate_lola_access(request) + assert result["valid"] is False + assert result["error_response"].status_code == 403 + assert result["error_response"].data["error_code"] == "actor_mismatch" + + +# Integration + +@pytest.mark.django_db +def test_bearer_same_actor_succeeds(): + """End-to-end: Bearer token bound to actor A can access actor A's followers.""" + binding = TokenActorBindingFactory() + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {binding.token.token}") + response = client.get( + reverse("followers-collection", kwargs={"pk": binding.actor.pk}) + ) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_bearer_cross_actor_denied(): + """End-to-end: Bearer token bound to actor A cannot access actor B's followers.""" + binding = TokenActorBindingFactory() + + user_b = UserOnlyFactory() + actor_b = Actor.objects.create( + user=user_b, username=f"{user_b.username}_src", role=Actor.ROLE_SOURCE + ) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {binding.token.token}") + response = client.get( + reverse("followers-collection", kwargs={"pk": actor_b.pk}) + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data["error_code"] == "actor_mismatch" diff --git a/testbed/core/utils/errors.py b/testbed/core/utils/errors.py index 7e69215..658ac38 100644 --- a/testbed/core/utils/errors.py +++ b/testbed/core/utils/errors.py @@ -17,9 +17,10 @@ class ErrorCodes: # Authentication & Authorization Errors (4xx) INSUFFICIENT_SCOPE = "insufficient_scope" - ACTOR_NOT_FOUND = "actor_not_found" + ACTOR_NOT_FOUND = "actor_not_found" FORBIDDEN_ACCESS = "forbidden_access" UNAUTHORIZED = "unauthorized" + ACTOR_MISMATCH = "actor_mismatch" # Rate Limiting Errors (429) RATE_LIMIT_EXCEEDED = "rate_limit_exceeded" @@ -141,6 +142,27 @@ def build_insufficient_scope_error(required_scope, endpoint_path, request=None): ) +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. + + Args: + request (HttpRequest, optional): Django request object for context + + Returns: + Response: 403 error response with actor_mismatch error code. + """ + 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", + ) + + def build_rate_limit_error(retry_after_seconds, request=None): """ Build standardized 429 error for rate limiting with Retry-After header. diff --git a/testbed/core/views/decorators.py b/testbed/core/views/decorators.py index 37807eb..006c2dc 100644 --- a/testbed/core/views/decorators.py +++ b/testbed/core/views/decorators.py @@ -1,7 +1,7 @@ """ Shared decorators and helpers for LOLA views. -- validate_lola_access: OAuth scope gate for LOLA-protected endpoints +- validate_lola_access: OAuth scope + token-to-actor binding gate for LOLA-protected endpoints - build_auth_context: standardized auth context dict passed to JSON-LD builders - activitypub_content: sets ActivityPub content-type + CORS headers """ @@ -9,30 +9,48 @@ import logging from functools import wraps -from ..utils.errors import build_insufficient_scope_error +from django.core.exceptions import ObjectDoesNotExist + +from ..utils.errors import build_actor_mismatch_error, build_insufficient_scope_error logger = logging.getLogger(__name__) def validate_lola_access(request, required_scope=True): """ - Scope gate for LOLA-protected endpoints. + Scope gate and actor binding check for LOLA-protected endpoints. + + Two-layer check (both layers apply when required_scope=True): + + Layer 1 - Scope validation: + Confirms the request carries a token with the activitypub_account_portability + scope. Returns 403 insufficient_scope if not. + + Layer 2 - Actor binding enforcement (LOLA Section 5 MUST): + Confirms the token is bound to the Actor whose appears in the URL. + Returns 403 actor_mismatch if not. + + 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." + + This check covers all three authentication paths because + OptionalOAuth2Authentication (header, URL param, session) all produce the + same request.auth object, so request.auth.actor_binding is available + whenever request.has_portability_scope is True. Args: - request: HTTP request with OAuth authentication attributes set by OptionalOAuth2Authentication. + request: HTTP request with OAuth authentication attributes set by + OptionalOAuth2Authentication. request.auth is the AccessToken. required_scope: Whether the LOLA portability scope is required (default: True). Returns: - dict: Validation result with 'valid' boolean and, on failure, 'error_response'. - - What each validation layer does: - Layer 1: OAuth Scope Validation - Ensures proper LOLA scope - Layer 2: Trust Settings Framework - Ready for TR1 trust controls - Layer 3: Security Event Logging - Audit trail for monitoring - Layer 4: Structured Error Responses - Consistent JSON error format + dict: {'valid': True} on success, or + {'valid': False, 'error_response': Response} on any failure. """ + # Layer 1: scope check if required_scope and not getattr(request, "has_portability_scope", False): - logger.warning(f"LOLA access denied: insufficient_scope for {request.path}") + logger.warning("LOLA access denied: insufficient_scope for %s", request.path) return { "valid": False, "error_response": build_insufficient_scope_error( @@ -42,14 +60,103 @@ def validate_lola_access(request, required_scope=True): ), } + # Layer 2: actor binding check (only when a portability token is present) if required_scope and getattr(request, "has_portability_scope", False): + token = getattr(request, "auth", None) + url_pk = _get_url_pk(request) + + if url_pk is None: + # All LOLA-gated endpoints are actor-scoped with in the + # URL. A missing pk here means the decorator is being invoked from an + # unexpected endpoint shape. Fail closed rather than silently skip + # a LOLA Section 5 MUST. + logger.warning( + "LOLA access denied: actor binding check invoked without URL pk " + "path=%s", + request.path, + ) + return { + "valid": False, + "error_response": build_actor_mismatch_error(request=request), + } + + if token is not None: + mismatch = _check_actor_binding(request, token, url_pk) + if mismatch is not None: + return mismatch + logger.info( - f"LOLA access granted: scope=activitypub_account_portability endpoint={request.path}" + "LOLA access granted: scope=activitypub_account_portability " + "endpoint=%s actor_pk=%s", + request.path, + url_pk, ) return {"valid": True} +def _get_url_pk(request): + """ + Extract the actor primary key from the URL resolver kwargs. + + All LOLA-protected actor endpoints use the URL pattern + /api/actors//... so the actor pk is always available as + request.resolver_match.kwargs["pk"] when this decorator is called + from an actor-scoped view. + + Returns None if the pk is not present (e.g. unexpected endpoint shape). + The caller treats None as a fail-closed signal. + """ + resolver_match = getattr(request, "resolver_match", None) + if resolver_match is None: + return None + return resolver_match.kwargs.get("pk") + + +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. + """ + try: + binding = token.actor_binding # OneToOne reverse accessor + 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), + } + + 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), + } + + return None + + def build_auth_context(request): """ Build the standardized authentication context dict passed to all JSON-LD builders. diff --git a/testbed/settings/base.py b/testbed/settings/base.py index b36c6ca..02922c6 100644 --- a/testbed/settings/base.py +++ b/testbed/settings/base.py @@ -250,6 +250,8 @@ 'PKCE_REQUIRED': False, } +OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" + # Configure REST framework to use OAuth2 authentication REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [