diff --git a/docs/lola-authentication.md b/docs/lola-authentication.md index 578b055..d61a104 100644 --- a/docs/lola-authentication.md +++ b/docs/lola-authentication.md @@ -174,7 +174,7 @@ def authenticate(self, request): ### Key Features - **Optional Authentication**: Unlike standard OAuth2Authentication, this doesn't fail requests without tokens -- **Scope Validation**: Checks for `activitypub_account_portability` scope +- **Scope Validation**: Checks for `activitypub_account_portability` via `scope_grants_portability()` in `oauth/scopes.py` — the same helper the OAuth validator uses at authorization time, so the two cannot drift - **Two Auth Paths**: the normative `Authorization` header plus a demo-scoped session path - **Request Flag Setting**: Adds authentication status flags to request objects - **Graceful Error Handling**: Invalid/expired tokens fall back to unauthenticated behavior @@ -182,9 +182,9 @@ def authenticate(self, request): ### Implementation Details ```python +from .scopes import scope_grants_portability + class OptionalOAuth2Authentication(OAuth2Authentication): - LOLA_PORTABILITY_SCOPE = 'activitypub_account_portability' - def authenticate(self, request): # Initialize flags request.is_oauth_authenticated = False @@ -193,6 +193,11 @@ class OptionalOAuth2Authentication(OAuth2Authentication): # Try authentication, gracefully handle failures # Set flags based on results return result_or_none + + def _has_portability_scope(self, token): + # Delegates to the shared helper so this request-time check cannot + # drift from the authorization-time one in validate_scopes. + return scope_grants_portability(getattr(token, "scope", None)) ``` ### Request Flags diff --git a/docs/oauth/overview.md b/docs/oauth/overview.md index b86f4f4..9e3f6d8 100644 --- a/docs/oauth/overview.md +++ b/docs/oauth/overview.md @@ -91,8 +91,7 @@ Our implementation uses a custom OptionalOAuth2Authentication class that enabl - **Graceful Degradation:** Invalid or missing tokens don't cause failures; requests continue as unauthenticated - **Scope-Based Enhancement:** Only tokens with `activitypub_account_portability` scope unlock LOLA-specific data - **Request Flags:** Adds `is_oauth_authenticated` and `has_portability_scope` flags to request objects -- **URL Parameter Authentication:** Supports `auth_token` URL parameter for testing convenience -- **Flexible Authentication:** Supports both Authorization header and URL parameter authentication +- **Two Credential Paths:** the normative `Authorization: Bearer` header, plus a demo-only session-stored token (cookie-bound, never in a URL). ### **Benefits** diff --git a/docs/oauth/phase-5-protected-resource-access.md b/docs/oauth/phase-5-protected-resource-access.md index cdb7621..c9662fa 100644 --- a/docs/oauth/phase-5-protected-resource-access.md +++ b/docs/oauth/phase-5-protected-resource-access.md @@ -43,15 +43,17 @@ class OptionalOAuth2Authentication(OAuth2Authentication): 1. Unauthenticated Mode: For standard ActivityPub federation 2. Authenticated Mode: For LOLA account portability with proper OAuth scope """ - - LOLA_PORTABILITY_SCOPE = 'activitypub_account_portability' + + def _has_portability_scope(self, token): + # The scope string and this membership test are owned by oauth/scopes.py + return scope_grants_portability(getattr(token, "scope", None)) ``` **Key Features:** - **Graceful Degradation:** Authentication failures don't cause API errors; requests continue as unauthenticated -- **Scope Validation:** Only tokens with `activitypub_account_portability` scope unlock LOLA features +- **Scope Validation:** Only tokens with `activitypub_account_portability` scope unlock LOLA features, checked via `scope_grants_portability()` in `oauth/scopes.py` - **Request Flags:** Adds `is_oauth_authenticated` and `has_portability_scope` flags to request objects -- **Flexible Authentication:** Supports both Authorization header and URL parameter authentication +- **Two Credential Paths:** the normative `Authorization: Bearer` header, plus a demo-only session-stored token. ### 2. Protected Endpoints @@ -121,10 +123,11 @@ actor objects additionally include the `migration` object plus the regular Actor Accept: application/activity+json ``` -2. **URL Parameter Authentication** (Testing): - ```http - GET /api/actors/1/?auth_token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... - ``` +2. **Session-stored token** (demo only, no header needed): + + After the demo token-exchange flow stores the token in the Django session, the + browser can open LOLA collection links directly. Cookie-bound, re-validated + against the DB on every request, and suppressed by `?public_only`. ### Authentication Flow @@ -135,12 +138,12 @@ def authenticate(self, request): request.has_portability_scope = False try: - # Try Authorization header first + # 1. Normative path: Authorization: Bearer header result = super().authenticate(request) - # Fallback to URL parameter for testing + # 2. Demo fallback: session-stored token (cookie-bound, demo-scoped) if result is None: - result = self._authenticate_with_url_token(request) + result = self._try_session_auth(request) if result is not None: user, token = result @@ -282,7 +285,7 @@ The public response exposes the `endpoints` discovery object but omits the priva Our implementation includes comprehensive testing features: -- **URL Parameter Authentication:** `?auth_token=` for easy browser-based testing +- **Session-stored token:** the demo token exchange stores the token in the session, so browser-based testing needs no header (cookie-bound, never in a URL) - **Side-by-side Comparison:** Same endpoint with/without authentication shows data differences - **Scope Validation Testing:** Tokens without portability scope behave like unauthenticated requests - **DRF Browsable API:** Web interface for testing with proper content-type handling diff --git a/testbed/core/oauth/__init__.py b/testbed/core/oauth/__init__.py index 23a56d3..7133b1f 100644 --- a/testbed/core/oauth/__init__.py +++ b/testbed/core/oauth/__init__.py @@ -1,5 +1,6 @@ from .authentication import OptionalOAuth2Authentication from .forms import OAuthApplicationForm +from .scopes import LOLA_PORTABILITY_SCOPE, scope_grants_portability from .utils import ( clear_token_from_session, generate_secure_state, @@ -18,6 +19,8 @@ "OAuthApplicationForm", "ActivityPubOAuth2Validator", "PortabilityAuthorizationView", + "LOLA_PORTABILITY_SCOPE", + "scope_grants_portability", "clear_token_from_session", "generate_secure_state", "get_token_from_session", diff --git a/testbed/core/oauth/authentication.py b/testbed/core/oauth/authentication.py index 626a301..5553a89 100644 --- a/testbed/core/oauth/authentication.py +++ b/testbed/core/oauth/authentication.py @@ -10,6 +10,8 @@ from oauth2_provider.contrib.rest_framework import OAuth2Authentication from rest_framework import exceptions +from .scopes import scope_grants_portability + logger = logging.getLogger(__name__) class OptionalOAuth2Authentication(OAuth2Authentication): @@ -26,9 +28,6 @@ class OptionalOAuth2Authentication(OAuth2Authentication): compatibility with standard ActivityPub clients. """ - # The specific OAuth scope required for LOLA account portability - LOLA_PORTABILITY_SCOPE = 'activitypub_account_portability' - def authenticate(self, request): """ Attempt to authenticate the request using OAuth2 with multiple methods. @@ -175,22 +174,19 @@ def _resolve_valid_access_token(self, token_string): def _has_portability_scope(self, token): """ - Check if the token has the LOLA portability scope. + Check whether an authenticated token carries LOLA portability scope. + + Delegates to oauth/scopes.py so this request-time check cannot drift + from the authorization-time one in ActivityPubOAuth2Validator.validate_scopes. + + `getattr(token, "scope", None)` covers tokens with no `scope` attribute + at all, and scope_grants_portability() treats None/empty as "no scope", so + both degrade to False rather than raising. Args: token: The OAuth token object - + Returns: Boolean indicating whether the token has the portability scope """ - if not hasattr(token, 'scope'): - return False - - # Handle both string and None scope values safely - scope = getattr(token, 'scope', '') - if not scope: - return False - - # Split the scope string and check if it contains the portability scope - scopes = scope.split() - return self.LOLA_PORTABILITY_SCOPE in scopes + return scope_grants_portability(getattr(token, "scope", None)) diff --git a/testbed/core/oauth/scopes.py b/testbed/core/oauth/scopes.py new file mode 100644 index 0000000..37db779 --- /dev/null +++ b/testbed/core/oauth/scopes.py @@ -0,0 +1,55 @@ +# LOLA §5: "The advertised OAuth endpoint MUST support the `activitypub_account_portability` scope." + +LOLA_PORTABILITY_SCOPE = "activitypub_account_portability" + + +def _normalize_scopes(scopes): + """ + Normalize any scope shape seen in the OAuth flow into a set of scope tokens. + + Shapes actually produced by the OAuth flow: + - None / "" -> empty set (missing scope on a token or request) + - "a b c" -> {"a", "b", "c"} (token dict, AccessToken.scope, form/querystring) + - ["a", "b"] -> {"a", "b"} (oauthlib, at validate_scopes) + + Args: + scopes: None, a space-delimited scope string, or an iterable of scope tokens. + + Returns: + set: the exact scope tokens present. + """ + if not scopes: + return set() + + if isinstance(scopes, str): + # RFC 6749 §3.3 separates scope tokens with a single space. + # Split first so comparisons match whole tokens, never substrings. + tokens = set() + + for token in scopes.strip().split(" "): + if token: + tokens.add(token) + + return tokens + + # Already an iterable of tokens + return set(scopes) + + +def scope_grants_portability(scopes): + """ + Return True when `scopes` grants the LOLA portability scope. + + Called by four scope decisions: + - authorization-time validation (validate_scopes) + - token-issuance binding (_save_bearer_token) + - request-time authentication (_has_portability_scope) + - Section 5.3 `activitypub_actor` redirect decision (_prepare_actor_binding). + + Args: + scopes: None, a space-delimited scope string, or an iterable of scope tokens. + + Returns: + bool: True only when an exact `activitypub_account_portability` scope token is present. + """ + return LOLA_PORTABILITY_SCOPE in _normalize_scopes(scopes) diff --git a/testbed/core/oauth/validators.py b/testbed/core/oauth/validators.py index 8d8d3d2..7adb3db 100644 --- a/testbed/core/oauth/validators.py +++ b/testbed/core/oauth/validators.py @@ -4,26 +4,48 @@ from oauth2_provider.models import get_access_token_model from oauth2_provider.oauth2_validators import OAuth2Validator +from .scopes import LOLA_PORTABILITY_SCOPE, scope_grants_portability + 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): + """ + Gate every OAuth grant on the LOLA portability scope. + + LOLA §5: "The advertised OAuth endpoint MUST support the `activitypub_account_portability` scope." + This method is where that support is enforced, and it is the decision that makes a token a + portability token -- which in turn is what makes `_save_bearer_token` bind it to a single Actor + (the Section 5 one-account MUST) and what makes the request-time gate in views/decorators.py engage. + + Fail-closed: both local checks run BEFORE delegating to django-oauth-toolkit, + so a request that misses the portability scope is rejected. `super()` then + applies DOT's requested scopes must be a subset of available scopes check. + + Args: + client_id: OAuth client identifier. + scopes: requested scopes. oauthlib passes a list here today, but + `scope_grants_portability` accepts a space-delimited string too so the check cannot + silently degrade to a substring match if that ever changes. See oauth/scopes.py. + client: the DOT Application the request is for. + request: the oauthlib Request object. + + Returns: + bool: True only when the portability scope is present AND DOT's own + scope validation passes. + """ 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: + if not scope_grants_portability(scopes): logger.warning( "Client %s requested OAuth without %r scope. Scopes: %s", client_id, - self.LOLA_PORTABILITY_SCOPE, + LOLA_PORTABILITY_SCOPE, scopes, ) return False @@ -59,8 +81,7 @@ def _save_bearer_token(self, token, request, *args, **kwargs): 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. + Binding is skipped for any token without the portability scope. Args: token: OAuthLib token dict. `token["access_token"]` is the @@ -73,10 +94,9 @@ def _save_bearer_token(self, token, request, *args, **kwargs): # 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. + if not scope_grants_portability(token.get("scope")): + # validate_scopes() rejects every grant that lacks the portability scope, + # so no non-portability token can be issued. return # Resolve the Actor to bind BEFORE looking up the access token row, so a diff --git a/testbed/core/oauth/views.py b/testbed/core/oauth/views.py index bf007cb..9885fc4 100644 --- a/testbed/core/oauth/views.py +++ b/testbed/core/oauth/views.py @@ -20,11 +20,11 @@ from oauth2_provider.views import AuthorizationView from ..json_ld_utils import build_actor_id +from .scopes import scope_grants_portability logger = logging.getLogger(__name__) -LOLA_PORTABILITY_SCOPE = "activitypub_account_portability" ACTIVITYPUB_ACTOR_PARAM = "activitypub_actor" # Query parameter name defined by LOLA §5.3 for the granted source Actor URL. @@ -73,7 +73,7 @@ def _prepare_actor_binding(self, scope_string): untouched). Non-LOLA authorizations always return None so regular OAuth flows are unaffected. """ - if LOLA_PORTABILITY_SCOPE not in scope_string.split(): + if not scope_grants_portability(scope_string): return None actor = self._resolve_source_actor() diff --git a/testbed/core/tests/test_oauth_validators.py b/testbed/core/tests/test_oauth_validators.py index b374c8c..3ba963b 100644 --- a/testbed/core/tests/test_oauth_validators.py +++ b/testbed/core/tests/test_oauth_validators.py @@ -1,12 +1,11 @@ import pytest from unittest.mock import MagicMock, patch -from django.contrib.auth import get_user_model -from oauth2_provider.models import get_application_model -from testbed.core.oauth.validators import ActivityPubOAuth2Validator +from django.conf import settings -User = get_user_model() -Application = get_application_model() +from testbed.core.factories import ApplicationFactory +from testbed.core.oauth.scopes import LOLA_PORTABILITY_SCOPE, scope_grants_portability +from testbed.core.oauth.validators import ActivityPubOAuth2Validator # The validator must ensure that clients request the appropriate scopes and use registered redirect URI @@ -19,21 +18,19 @@ def oauth_validator(): # Represents a client service registered with the testbed @pytest.fixture def oauth_application(user): - return Application.objects.create( - name='Test ActivityPub Service', + return ApplicationFactory( user=user, - client_type='confidential', - authorization_grant_type='authorization-code', - client_id='test-client-id', - client_secret='test-client-secret', + name='Test ActivityPub Service', redirect_uris='https://example.com/callback' ) # Simulates the client making the request @pytest.fixture -def oauth_client(): +def oauth_client(oauth_application): + # client_id is read back from the application because the factory generates it, + # so the mock cannot drift from the registered client. client = MagicMock() - client.client_id = 'test-client-id' + client.client_id = oauth_application.client_id return client # Simulates the HTTP request in the OAuth flow @@ -103,3 +100,67 @@ def test_validate_redirect_uri_with_invalid_uri(oauth_validator, oauth_applicati mock_request ) assert not result, "The validator should reject an invalid redirect URI" + +# scope_grants_portability() is the single membership test behind all four LOLA scope decisions, +# so it has to give the same answer for every shape the OAuth flow produces. +@pytest.mark.parametrize( + "scopes, expected", + [ + # Shapes that DO grant portability + ([LOLA_PORTABILITY_SCOPE], True), + (LOLA_PORTABILITY_SCOPE, True), + (f"{LOLA_PORTABILITY_SCOPE} read write", True), + ([LOLA_PORTABILITY_SCOPE, "read"], True), + (f" {LOLA_PORTABILITY_SCOPE} read ", True), + # Shapes that do NOT + (None, False), + ("", False), + ([], False), + ("read write", False), + (["read", "write"], False), + ], +) +def test_scope_grants_portability_across_input_shapes(scopes, expected): + assert scope_grants_portability(scopes) is expected + + +# A scope whose name merely CONTAINS the portability scope must not pass. + +""" +This guard matters because the shapes reaching validate_scopes are normalized by django-oauth-toolkit, +not by us: DOT's OAuthLibMixin does # `scopes.split(" ")` on a line carrying a "TO DO: move this scopes conversion" comment. + +If a future DOT release drops that, this test fails here rather than silently widening the LOLA gate. +""" +@pytest.mark.parametrize( + "lookalike", + [ + f"{LOLA_PORTABILITY_SCOPE}_admin", # suffix, as a string + [f"{LOLA_PORTABILITY_SCOPE}_admin"], # suffix, as a list + f"x{LOLA_PORTABILITY_SCOPE}", # prefix + f"{LOLA_PORTABILITY_SCOPE}_admin read", # suffix alongside a real token + ], +) +def test_lookalike_scope_is_rejected(lookalike): + assert scope_grants_portability(lookalike) is False + + +# The scope literal in OAUTH2_PROVIDER["SCOPES"] cannot import LOLA_PORTABILITY_SCOPE +# so the two are kept in sync by this guard instead. +def test_settings_scope_registry_matches_constant(): + assert LOLA_PORTABILITY_SCOPE in settings.OAUTH2_PROVIDER["SCOPES"], ( + "OAUTH2_PROVIDER['SCOPES'] must advertise the scope LOLA_PORTABILITY_SCOPE names " + "(LOLA Section 5: the advertised endpoint MUST support this scope)" + ) + +# The lookalike must be rejected by OUR check, not incidentally by DOT. +@pytest.mark.django_db +def test_validate_scopes_rejects_lookalike_scope(oauth_validator, oauth_application, oauth_client, mock_request): + with patch.object(oauth_validator.__class__.__bases__[0], 'validate_scopes', return_value=True): + result = oauth_validator.validate_scopes( + oauth_application.client_id, + [f"{LOLA_PORTABILITY_SCOPE}_admin"], + oauth_client, + mock_request + ) + assert not result, "A scope that merely contains the portability scope must not be accepted" diff --git a/testbed/core/views/decorators.py b/testbed/core/views/decorators.py index 236a813..c28681a 100644 --- a/testbed/core/views/decorators.py +++ b/testbed/core/views/decorators.py @@ -18,6 +18,7 @@ from django.core.exceptions import ObjectDoesNotExist from ..models import Actor +from ..oauth.scopes import LOLA_PORTABILITY_SCOPE from ..utils.errors import ( build_actor_mismatch_error, build_actor_not_found_error, @@ -69,7 +70,7 @@ def lola_access_error(request, required_scope, url_pk): if required_scope and not has_scope: logger.warning("LOLA access denied: insufficient_scope for %s", request.path) return build_insufficient_scope_error( - required_scope="activitypub_account_portability", + required_scope=LOLA_PORTABILITY_SCOPE, endpoint_path=request.path, request=request, ) @@ -104,7 +105,8 @@ def lola_access_error(request, required_scope, url_pk): return error logger.info( - "LOLA access granted: scope=activitypub_account_portability endpoint=%s actor_pk=%s", + "LOLA access granted: scope=%s endpoint=%s actor_pk=%s", + LOLA_PORTABILITY_SCOPE, request.path, url_pk, )