diff --git a/docs/lola-discovery.md b/docs/lola-discovery.md index 0618e95..cfffb86 100644 --- a/docs/lola-discovery.md +++ b/docs/lola-discovery.md @@ -337,14 +337,20 @@ curl -s https://localhost:8000/.well-known/oauth-authorization-server | \ While this implementation focuses on RFC8414 discovery, LOLA also supports Actor-based discovery. Both methods can coexist: **RFC8414 Discovery**: `/.well-known/oauth-authorization-server`\ -**Actor Discovery**: `accountPortabilityOauth` field in Actor objects +**Actor Discovery**: `endpoints.oauthMigrationEndpoint` field in Actor objects + +Supporting servers MUST advertise their portability authorization endpoint in Actor objects under +`endpoints.oauthMigrationEndpoint`, in parallel with `endpoints.oauthAuthorizationEndpoint`: ```json { "@context": "https://www.w3.org/ns/activitystreams", "type": "Person", "id": "https://source.example/actors/1", - "accountPortabilityOauth": "https://source.example/oauth/authorize/" + "endpoints": { + "oauthAuthorizationEndpoint": "https://source.example/oauth/authorize/", + "oauthMigrationEndpoint": "https://source.example/oauth/authorize/" + } } ``` diff --git a/docs/oauth/phase-5-protected-resource-access.md b/docs/oauth/phase-5-protected-resource-access.md index 474a9bf..cdb7621 100644 --- a/docs/oauth/phase-5-protected-resource-access.md +++ b/docs/oauth/phase-5-protected-resource-access.md @@ -87,14 +87,22 @@ auth_context = { ### 4. Enhanced Data Features #### **Actor Enhancements (LOLA Fields)** -When authenticated with portability scope, actor objects include: +The `endpoints` object is always public. When authenticated with portability scope, +actor objects additionally include the `migration` object plus the regular Actor collections: ```json { - "accountPortabilityOauth": "https://source.example.com/oauth/authorize/", - "content": "https://source.example.com/api/actors/1/content", - "blocked": "https://source.example.com/api/actors/1/blocked", - "migration": "https://source.example.com/api/actors/1/outbox" + "endpoints": { + "oauthAuthorizationEndpoint": "https://source.example.com/oauth/authorize/", + "oauthMigrationEndpoint": "https://source.example.com/oauth/authorize/" + }, + "liked": "https://source.example.com/api/actors/1/liked", + "migration": { + "outbox": "https://source.example.com/api/actors/1/migration/outbox", + "content": "https://source.example.com/api/actors/1/migration/content", + "following": "https://source.example.com/api/actors/1/migration/following", + "blocked": "https://source.example.com/api/actors/1/migration/blocked" + } } ``` @@ -155,23 +163,31 @@ def authenticate(self, request): ```python def build_actor_json_ld(actor, auth_context=None): - # Base ActivityPub Actor (always included) + # Base ActivityPub Actor (always included, public discovery surface). + # endpoints.oauthMigrationEndpoint MUST always be present. actor_data = { "@context": build_actor_context(), "type": "Person", "id": build_actor_id(actor.id), "preferredUsername": actor.username, - # Standard ActivityPub collections - "outbox": f"{build_actor_id(actor.id)}/outbox", - # ... + "endpoints": { + "oauthAuthorizationEndpoint": build_oauth_endpoint_url(auth_context['request']), + "oauthMigrationEndpoint": build_oauth_endpoint_url(auth_context['request']), + }, } # Add LOLA fields ONLY when authenticated with portability scope if auth_context and auth_context.get('has_portability_scope'): - actor_data["accountPortabilityOauth"] = build_oauth_endpoint_url(auth_context['request']) - actor_data["content"] = f"{build_actor_id(actor.id)}/content" - actor_data["blocked"] = f"{build_actor_id(actor.id)}/blocked" - actor_data["migration"] = f"{build_actor_id(actor.id)}/outbox" + # Regular Actor collections (liked/followers are NOT migration collections) + actor_data["outbox"] = f"{build_actor_id(actor.id)}/outbox" + actor_data["liked"] = f"{build_actor_id(actor.id)}/liked" + # Migration feature discovery -> dedicated migration/... routes + actor_data["migration"] = { + "outbox": f"{build_actor_id(actor.id)}/migration/outbox", + "content": f"{build_actor_id(actor.id)}/migration/content", + "following": f"{build_actor_id(actor.id)}/migration/following", + "blocked": f"{build_actor_id(actor.id)}/migration/blocked", + } return actor_data ``` @@ -208,6 +224,9 @@ def build_outbox_json_ld(outbox, auth_context=None): ## API Response Examples ### **Unauthenticated Actor Request** + +The public response exposes the `endpoints` discovery object but omits the privacy-sensitive Actor collections and the `migration` object: + ```json { "@context": ["https://www.w3.org/ns/activitystreams"], @@ -216,9 +235,10 @@ def build_outbox_json_ld(outbox, auth_context=None): "preferredUsername": "testuser", "name": "testuser", "inbox": "https://source.example.com/api/actors/1/inbox", - "outbox": "https://source.example.com/api/actors/1/outbox", - "followers": "https://source.example.com/api/actors/1/followers", - "following": "https://source.example.com/api/actors/1/following" + "endpoints": { + "oauthAuthorizationEndpoint": "https://source.example.com/oauth/authorize/", + "oauthMigrationEndpoint": "https://source.example.com/oauth/authorize/" + } } ``` @@ -231,13 +251,21 @@ def build_outbox_json_ld(outbox, auth_context=None): "preferredUsername": "testuser", "name": "testuser", "inbox": "https://source.example.com/api/actors/1/inbox", + "endpoints": { + "oauthAuthorizationEndpoint": "https://source.example.com/oauth/authorize/", + "oauthMigrationEndpoint": "https://source.example.com/oauth/authorize/" + }, "outbox": "https://source.example.com/api/actors/1/outbox", "followers": "https://source.example.com/api/actors/1/followers", "following": "https://source.example.com/api/actors/1/following", - "accountPortabilityOauth": "https://source.example.com/oauth/authorize/", - "content": "https://source.example.com/api/actors/1/content", + "liked": "https://source.example.com/api/actors/1/liked", "blocked": "https://source.example.com/api/actors/1/blocked", - "migration": "https://source.example.com/api/actors/1/outbox" + "migration": { + "outbox": "https://source.example.com/api/actors/1/migration/outbox", + "content": "https://source.example.com/api/actors/1/migration/content", + "following": "https://source.example.com/api/actors/1/migration/following", + "blocked": "https://source.example.com/api/actors/1/migration/blocked" + } } ``` diff --git a/testbed/core/json_ld_builders.py b/testbed/core/json_ld_builders.py index e376773..33bc0aa 100644 --- a/testbed/core/json_ld_builders.py +++ b/testbed/core/json_ld_builders.py @@ -10,11 +10,14 @@ # Build JSON-LD Actor with LOLA compliance. def build_actor_json_ld(actor, auth_context=None): """ - The accountPortabilityOauth field MUST always be present - for OAuth endpoint discovery (public visibility). - - The migration.* properties are conditionally included only - when the request includes a valid portability token (scoped access). + Build an ActivityPub Actor object with revised-LOLA portability discovery. + + - `endpoints.oauthMigrationEndpoint` MUST always be present + for OAuth endpoint discovery (public visibility). + - It is advertised in parallel with `endpoints.oauthAuthorizationEndpoint`. + Both point at `/oauth/authorize/` endpoint, which is also the URL advertised in the RFC8414 metadata. + - The `migration` object (outbox / content / following / blocked) is privacy-sensitive feature + discovery and is only included when the request carries a valid portability-scoped token. Args: actor: The Actor model instance @@ -33,6 +36,10 @@ def build_actor_json_ld(actor, auth_context=None): # Build actor URL actor_id = build_actor_id(actor.id, request) + # The migration OAuth endpoint and the general OAuth authorization endpoint are the same URL, + # so both `endpoints.*` fields resolve to it. Computed once and reused. + oauth_authorize_url = build_oauth_endpoint_url(request) + # Base ActivityPub Actor (always included) actor_data = { "@context": build_actor_context(), @@ -42,27 +49,28 @@ def build_actor_json_ld(actor, auth_context=None): "name": actor.username, "inbox": f"{actor_id}/inbox", "previously": actor.previously or [], - "accountPortabilityOauth": build_oauth_endpoint_url(request) + "endpoints": { + "oauthAuthorizationEndpoint": oauth_authorize_url, + "oauthMigrationEndpoint": oauth_authorize_url, + }, } - + # Privacy-sensitive fields ONLY with portability scope if auth_context and auth_context.get('has_portability_scope'): - # Standard ActivityPub collections (privacy-sensitive) actor_data["outbox"] = f"{actor_id}/outbox" actor_data["following"] = f"{actor_id}/following" actor_data["followers"] = f"{actor_id}/followers" actor_data["liked"] = f"{actor_id}/liked" actor_data["blocked"] = f"{actor_id}/blocked" - # LOLA migration endpoints (same URLs, scope-filtered responses) + # LOLA migration feature discovery actor_data["migration"] = { - "outbox": f"{actor_id}/outbox", - "content": f"{actor_id}/content", - "following": f"{actor_id}/following", - "blocked": f"{actor_id}/blocked", - "liked": f"{actor_id}/liked" + "outbox": f"{actor_id}/migration/outbox", + "content": f"{actor_id}/migration/content", + "following": f"{actor_id}/migration/following", + "blocked": f"{actor_id}/migration/blocked", } - + return actor_data def build_note_json_ld(note, auth_context=None): diff --git a/testbed/core/oauth/utils.py b/testbed/core/oauth/utils.py index ef91efe..24b3be5 100644 --- a/testbed/core/oauth/utils.py +++ b/testbed/core/oauth/utils.py @@ -355,10 +355,10 @@ def build_oauth_endpoint_url(request): The URL allows other ActivityPub services to discover where users can authorize access for account migration. - Per LOLA specification: "ActivityPub servers supporting this specification - MUST provide the URL for their portability authorization endpoint in Actor - objects, using the 'accountPortabilityOauth' field." - + Per LOLA specification: "Supporting servers MUST provide their portability authorization endpoint in Actor objects." + It is advertised under `endpoints.oauthMigrationEndpoint`." The Actor builder places this URL under + both `endpoints.oauthMigrationEndpoint` and the parallel `endpoints.oauthAuthorizationEndpoint`. + Args: request: The HTTP request object containing scheme and host information diff --git a/testbed/core/templates/oauth_token_exchange.html b/testbed/core/templates/oauth_token_exchange.html index 0fd0ced..0b8cf65 100644 --- a/testbed/core/templates/oauth_token_exchange.html +++ b/testbed/core/templates/oauth_token_exchange.html @@ -135,7 +135,7 @@

🧪 Test LOLA ActivityPub Endpoints

🔍 What You Should See:

diff --git a/testbed/core/tests/test_lola_actor.py b/testbed/core/tests/test_lola_actor.py index 6c0be1a..b4ed6af 100644 --- a/testbed/core/tests/test_lola_actor.py +++ b/testbed/core/tests/test_lola_actor.py @@ -1,21 +1,28 @@ from rest_framework.test import APIClient from rest_framework import status from testbed.core.models import Actor -from testbed.core.factories import UserWithActorsFactory, AccessTokenFactory +from testbed.core.factories import ( + UserWithActorsFactory, + AccessTokenFactory, + TokenActorBindingFactory, +) """ LOLA Compliance Tests for Actor Endpoint -Actor.accountPortabilityOauth Field (Public OAuth Discovery) -- The accountPortabilityOauth field MUST always be present for OAuth discovery +Actor.endpoints.oauthMigrationEndpoint (Public OAuth Discovery) +- MUST always be present so destinations can discover where to authorize, + even from an unauthenticated Actor fetch (§4.2). +- Advertised in parallel with endpoints.oauthAuthorizationEndpoint. - No authentication required (public visibility) -Actor Migration Properties (Authenticated Access) +Actor.migration.* (Authenticated Feature Discovery) - Migration properties MUST only appear with valid portability scope -- Requires 'activitypub_account_portability' scope +- Contains exactly outbox / content / following / blocked, each pointing at a + dedicated actors//migration/... route (§4.4). """ -# Verify Actor without token includes OAuth endpoint but NO migration data +# Verify Actor without token includes OAuth migration endpoint but NO migration object def test_actor_without_token_includes_oauth_endpoint_but_no_migration(): client = APIClient() user = UserWithActorsFactory() @@ -27,12 +34,16 @@ def test_actor_without_token_includes_oauth_endpoint_but_no_migration(): assert response.status_code == status.HTTP_200_OK data = response.json() - # OAuth endpoint must be present for public discovery - assert 'accountPortabilityOauth' in data, \ - "OAuth endpoint must be present for public discovery" - assert data['accountPortabilityOauth'].endswith('/oauth/authorize/'), \ - "OAuth endpoint should point to authorization endpoint" - + # endpoints object must be present for public discovery + assert 'endpoints' in data, \ + "endpoints object must be present for public OAuth discovery" + endpoints = data['endpoints'] + assert endpoints['oauthMigrationEndpoint'].endswith('/oauth/authorize/'), \ + "oauthMigrationEndpoint should point to the authorization endpoint" + # Parallel authorization endpoint must also be advertised + assert endpoints['oauthAuthorizationEndpoint'].endswith('/oauth/authorize/'), \ + "oauthAuthorizationEndpoint should be advertised in parallel" + # Migration data must NOT be present without authentication assert 'migration' not in data, \ "migration object requires portability token" @@ -56,8 +67,8 @@ def test_actor_without_token_includes_oauth_endpoint_but_no_migration(): assert 'https://purl.archive.org/socialweb/blocked' in data['@context'] -# Verify OAuth endpoint URL is properly formatted -def test_oauth_endpoint_url_is_absolute_and_valid(): +# Verify OAuth migration endpoint URL is absolute and correctly formatted +def test_oauth_migration_endpoint_url_is_absolute_and_valid(): client = APIClient() user = UserWithActorsFactory() actor = Actor.objects.get(user=user, role=Actor.ROLE_SOURCE) @@ -65,18 +76,18 @@ def test_oauth_endpoint_url_is_absolute_and_valid(): response = client.get(f'/api/actors/{actor.id}/') data = response.json() - oauth_url = data['accountPortabilityOauth'] + oauth_url = data['endpoints']['oauthMigrationEndpoint'] # URL must be absolute with scheme assert oauth_url.startswith('http://') or oauth_url.startswith('https://'), \ - "OAuth endpoint must be absolute URL" + "oauthMigrationEndpoint must be an absolute URL" - # URL must point to correct path + # URL must point to the correct path assert oauth_url.endswith('/oauth/authorize/'), \ - "OAuth endpoint must point to /oauth/authorize/" + "oauthMigrationEndpoint must point to /oauth/authorize/" -# Verify Actor with portability token includes migration data +# Verify Actor with portability token includes corrected migration object def test_actor_with_portability_token_includes_migration(): client = APIClient() user = UserWithActorsFactory() @@ -92,18 +103,19 @@ def test_actor_with_portability_token_includes_migration(): assert response.status_code == status.HTTP_200_OK data = response.json() - # OAuth endpoint still present (always public) - assert 'accountPortabilityOauth' in data, \ - "OAuth endpoint must always be present" + # OAuth discovery endpoints still present (always public) + assert 'endpoints' in data, \ + "endpoints object must always be present" # Migration object must be present with authentication assert 'migration' in data, \ "migration object required with portability token" - # All migration properties must be present and absolute URLs + # Migration object must be present and absolute URLs migration = data['migration'] - for field in ['outbox', 'content', 'following', 'blocked', 'liked']: - assert field in migration, f"migration.{field} is required" + assert set(migration.keys()) == {'outbox', 'content', 'following', 'blocked'}, \ + "migration object must contain exactly outbox/content/following/blocked" + for field in ['outbox', 'content', 'following', 'blocked']: assert migration[field].startswith('http'), f"migration.{field} must be absolute URL" # Privacy-sensitive collections must be present @@ -134,8 +146,8 @@ def test_actor_with_wrong_scope_returns_public_response(): assert response.status_code == status.HTTP_200_OK data = response.json() - # Should have OAuth endpoint (public) - assert 'accountPortabilityOauth' in data + # Should have public OAuth discovery endpoints + assert 'endpoints' in data # Should NOT have migration data (insufficient scope) assert 'migration' not in data @@ -143,8 +155,8 @@ def test_actor_with_wrong_scope_returns_public_response(): assert 'followers' not in data -# Verify migration URLs point to correct endpoints -def test_migration_urls_point_to_correct_collection_endpoints(): +# Verify migration URLs point to the dedicated migration routes +def test_migration_urls_point_to_dedicated_migration_routes(): client = APIClient() user = UserWithActorsFactory() actor = Actor.objects.get(user=user, role=Actor.ROLE_SOURCE) @@ -159,12 +171,30 @@ def test_migration_urls_point_to_correct_collection_endpoints(): migration = data['migration'] actor_url = f'http://testserver/api/actors/{actor.id}' - # Verify each migration URL points to correct endpoint - assert migration['outbox'] == f'{actor_url}/outbox' - assert migration['content'] == f'{actor_url}/content' - assert migration['following'] == f'{actor_url}/following' - assert migration['blocked'] == f'{actor_url}/blocked' - assert migration['liked'] == f'{actor_url}/liked' + # Each migration URL points to its dedicated /migration/... route + assert migration['outbox'] == f'{actor_url}/migration/outbox' + assert migration['content'] == f'{actor_url}/migration/content' + assert migration['following'] == f'{actor_url}/migration/following' + assert migration['blocked'] == f'{actor_url}/migration/blocked' + + +# Verify every advertised dedicated migration route is real and resolves +def test_dedicated_migration_routes_resolve(): + client = APIClient() + user = UserWithActorsFactory() + actor = Actor.objects.get(user=user, role=Actor.ROLE_SOURCE) + + # LOLA-gated migration routes (content, blocked) enforce token-to-actor + # binding via validate_lola_access, so bind a portability token to this actor. + token = AccessTokenFactory(lola_scope=True, user=user) + TokenActorBindingFactory(token=token, actor=actor) + client.credentials(HTTP_AUTHORIZATION=f'Bearer {token.token}') + + # All four advertised migration URLs must resolve (routed and implemented) + for surface in ['outbox', 'content', 'following', 'blocked']: + response = client.get(f'/api/actors/{actor.id}/migration/{surface}/') + assert response.status_code == status.HTTP_200_OK, \ + f"migration/{surface} route must resolve for a bound portability token" # Compare public and authenticated responses side-by-side @@ -193,10 +223,10 @@ def test_public_vs_authenticated_response_comparison(): assert public_data[field] == auth_data[field], \ f"Basic field '{field}' should match in both responses" - # Both should have accountPortabilityOauth - assert 'accountPortabilityOauth' in public_data - assert 'accountPortabilityOauth' in auth_data - assert public_data['accountPortabilityOauth'] == auth_data['accountPortabilityOauth'] + # Both should expose the same public OAuth discovery endpoints + assert 'endpoints' in public_data + assert 'endpoints' in auth_data + assert public_data['endpoints'] == auth_data['endpoints'] # Only authenticated should have migration data assert 'migration' not in public_data diff --git a/testbed/core/urls/api_urls.py b/testbed/core/urls/api_urls.py index eddd979..5b459f1 100644 --- a/testbed/core/urls/api_urls.py +++ b/testbed/core/urls/api_urls.py @@ -48,4 +48,28 @@ blocked_collection, name="blocked-collection", ), + # Dedicated LOLA migration collection routes. These are the URLs advertised under the Actor `migration` object. + # Each route delegates to the existing collection view so the advertised URL is real and resolves. + # The behavioral update of the migration surface (migration-outbox activity filtering, pagination, and + # the public-vs-migration following/blocked gating decisions) is upcoming work and is intentionally not done here. + path( + "actors//migration/outbox/", + portability_outbox_detail, + name="migration-outbox", + ), + path( + "actors//migration/content/", + content_collection, + name="migration-content", + ), + path( + "actors//migration/following/", + following_collection, + name="migration-following", + ), + path( + "actors//migration/blocked/", + blocked_collection, + name="migration-blocked", + ), ]