Skip to content
Open
104 changes: 104 additions & 0 deletions docs/lola-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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/<pk>/followers/`
- `GET /api/actors/<pk>/content/`
- `GET /api/actors/<pk>/liked/`
- `GET /api/actors/<pk>/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:
Expand Down
41 changes: 41 additions & 0 deletions testbed/core/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
PortabilityOutbox,
Following,
Followers,
TokenActorBinding,
)

# Base factory for creating Users without associated actors
Expand Down Expand Up @@ -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)
)
25 changes: 25 additions & 0 deletions testbed/core/migrations/0009_token_actor_binding.py
Original file line number Diff line number Diff line change
@@ -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)),
],
),
]
28 changes: 28 additions & 0 deletions testbed/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
30 changes: 17 additions & 13 deletions testbed/core/oauth/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand Down
Loading
Loading