Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions docs/lola-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,17 +174,17 @@ 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

### 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
Expand All @@ -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
Expand Down
3 changes: 1 addition & 2 deletions docs/oauth/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
27 changes: 15 additions & 12 deletions docs/oauth/phase-5-protected-resource-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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=<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
Expand Down
3 changes: 3 additions & 0 deletions testbed/core/oauth/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -18,6 +19,8 @@
"OAuthApplicationForm",
"ActivityPubOAuth2Validator",
"PortabilityAuthorizationView",
"LOLA_PORTABILITY_SCOPE",
"scope_grants_portability",
"clear_token_from_session",
"generate_secure_state",
"get_token_from_session",
Expand Down
28 changes: 12 additions & 16 deletions testbed/core/oauth/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.
Expand Down Expand Up @@ -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))
55 changes: 55 additions & 0 deletions testbed/core/oauth/scopes.py
Original file line number Diff line number Diff line change
@@ -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)
44 changes: 32 additions & 12 deletions testbed/core/oauth/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions testbed/core/oauth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.


Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading