Skip to content

Commit

Permalink
Authorized fetch for actor/collection dispatchers
Browse files Browse the repository at this point in the history
  • Loading branch information
dahlia committed Apr 11, 2024
1 parent 6b29720 commit e78b836
Show file tree
Hide file tree
Showing 5 changed files with 312 additions and 48 deletions.
14 changes: 12 additions & 2 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,18 @@ To be released.

- Added `PUBLIC_COLLECTION` constant for [public addressing].

- Added `RequestContext.getSignedKey()` method for [authorized fetch]
(also known as secure mode).
- `Federation` now supports [authorized fetch] for actor dispatcher and
collection dispatchers.

- Added `ActorCallbackSetters.authorize()` method.
- Added `CollectionCallbackSetters.authorize()` method.
- Added `AuthorizedPredicate` type.
- Added `RequestContext.getSignedKey()` method.
- Added `FederationFetchOptions.onUnauthorized` option for handling
unauthorized fetches.

- The default implementation of `FederationFetchOptions.onNotAcceptable`
option now responds with `Vary: Accept, Signature` header.

[public addressing]: https://www.w3.org/TR/activitypub/#public-addressing
[authorized fetch]: https://swicg.github.io/activitypub-http-signature/#authorized-fetch
Expand Down
17 changes: 17 additions & 0 deletions federation/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,20 @@ export type OutboxErrorHandler = (
error: Error,
activity: Activity | null,
) => void | Promise<void>;

/**
* A callback that determines if a request is authorized or not.
*
* @typeParam TContextData The context data to pass to the {@link Context}.
* @param context The request context.
* @param handle The handle of the actor that is being requested.
* @param signedKey The key that was used to sign the request, or `null` if
* the request was not signed or the signature was invalid.
* @returns `true` if the request is authorized, `false` otherwise.
* @since 0.7.0
*/
export type AuthorizePredicate<TContextData> = (
context: RequestContext<TContextData>,
handle: string,
signedKey: CryptographicKey | null,
) => boolean | Promise<boolean>;
165 changes: 165 additions & 0 deletions federation/handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { assert, assertEquals, assertFalse } from "@std/assert";
import { createRequestContext } from "../testing/context.ts";
import { mockDocumentLoader } from "../testing/docloader.ts";
import { publicKey2 } from "../testing/keys.ts";
import { type Activity, Create, Note, Person } from "../vocab/vocab.ts";
import type {
ActorDispatcher,
Expand Down Expand Up @@ -71,18 +72,25 @@ Deno.test("handleActor()", async () => {
onNotAcceptableCalled = request;
return new Response("Not acceptable", { status: 406 });
};
let onUnauthorizedCalled: Request | null = null;
const onUnauthorized = (request: Request) => {
onUnauthorizedCalled = request;
return new Response("Unauthorized", { status: 401 });
};
let response = await handleActor(
context.request,
{
context,
handle: "someone",
onNotFound,
onNotAcceptable,
onUnauthorized,
},
);
assertEquals(response.status, 404);
assertEquals(onNotFoundCalled, context.request);
assertEquals(onNotAcceptableCalled, null);
assertEquals(onUnauthorizedCalled, null);

onNotFoundCalled = null;
response = await handleActor(
Expand All @@ -93,11 +101,13 @@ Deno.test("handleActor()", async () => {
actorDispatcher,
onNotFound,
onNotAcceptable,
onUnauthorized,
},
);
assertEquals(response.status, 406);
assertEquals(onNotFoundCalled, null);
assertEquals(onNotAcceptableCalled, context.request);
assertEquals(onUnauthorizedCalled, null);

onNotAcceptableCalled = null;
response = await handleActor(
Expand All @@ -108,11 +118,13 @@ Deno.test("handleActor()", async () => {
actorDispatcher,
onNotFound,
onNotAcceptable,
onUnauthorized,
},
);
assertEquals(response.status, 404);
assertEquals(onNotFoundCalled, context.request);
assertEquals(onNotAcceptableCalled, null);
assertEquals(onUnauthorizedCalled, null);

onNotFoundCalled = null;
context = createRequestContext<void>({
Expand All @@ -131,6 +143,7 @@ Deno.test("handleActor()", async () => {
actorDispatcher,
onNotFound,
onNotAcceptable,
onUnauthorized,
},
);
assertEquals(response.status, 200);
Expand Down Expand Up @@ -160,6 +173,7 @@ Deno.test("handleActor()", async () => {
});
assertEquals(onNotFoundCalled, null);
assertEquals(onNotAcceptableCalled, null);
assertEquals(onUnauthorizedCalled, null);

response = await handleActor(
context.request,
Expand All @@ -169,11 +183,85 @@ Deno.test("handleActor()", async () => {
actorDispatcher,
onNotFound,
onNotAcceptable,
onUnauthorized,
},
);
assertEquals(response.status, 404);
assertEquals(onNotFoundCalled, context.request);
assertEquals(onNotAcceptableCalled, null);
assertEquals(onUnauthorizedCalled, null);

onNotFoundCalled = null;
context = createRequestContext<void>({
...context,
request: new Request(context.url, {
headers: {
Accept: "application/activity+json",
},
}),
});
response = await handleActor(
context.request,
{
context,
handle: "someone",
actorDispatcher,
authorizePredicate: (_ctx, _handle, signedKey) => signedKey != null,
onNotFound,
onNotAcceptable,
onUnauthorized,
},
);
assertEquals(response.status, 401);
assertEquals(onNotFoundCalled, null);
assertEquals(onNotAcceptableCalled, null);
assertEquals(onUnauthorizedCalled, context.request);

onUnauthorizedCalled = null;
context = createRequestContext<void>({
...context,
getSignedKey: () => Promise.resolve(publicKey2),
});
response = await handleActor(
context.request,
{
context,
handle: "someone",
actorDispatcher,
authorizePredicate: (_ctx, _handle, signedKey) => signedKey != null,
onNotFound,
onNotAcceptable,
onUnauthorized,
},
);
assertEquals(response.status, 200);
assertEquals(
response.headers.get("Content-Type"),
"application/activity+json",
);
assertEquals(await response.json(), {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
manuallyApprovesFollowers: "as:manuallyApprovesFollowers",
discoverable: "toot:discoverable",
indexable: "toot:indexable",
memorial: "toot:memorial",
suspended: "toot:suspended",
toot: "http://joinmastodon.org/ns#",
schema: "http://schema.org#",
PropertyValue: "schema:PropertyValue",
value: "schema:value",
},
],
id: "https://example.com/users/someone",
type: "Person",
name: "Someone",
});
assertEquals(onNotFoundCalled, null);
assertEquals(onNotAcceptableCalled, null);
assertEquals(onUnauthorizedCalled, null);
});

Deno.test("handleCollection()", async () => {
Expand Down Expand Up @@ -221,18 +309,25 @@ Deno.test("handleCollection()", async () => {
onNotAcceptableCalled = request;
return new Response("Not acceptable", { status: 406 });
};
let onUnauthorizedCalled: Request | null = null;
const onUnauthorized = (request: Request) => {
onUnauthorizedCalled = request;
return new Response("Unauthorized", { status: 401 });
};
let response = await handleCollection(
context.request,
{
context,
handle: "someone",
onNotFound,
onNotAcceptable,
onUnauthorized,
},
);
assertEquals(response.status, 404);
assertEquals(onNotFoundCalled, context.request);
assertEquals(onNotAcceptableCalled, null);
assertEquals(onUnauthorizedCalled, null);

onNotFoundCalled = null;
response = await handleCollection(
Expand All @@ -243,11 +338,13 @@ Deno.test("handleCollection()", async () => {
collectionCallbacks: { dispatcher },
onNotFound,
onNotAcceptable,
onUnauthorized,
},
);
assertEquals(response.status, 406);
assertEquals(onNotFoundCalled, null);
assertEquals(onNotAcceptableCalled, context.request);
assertEquals(onUnauthorizedCalled, null);

onNotAcceptableCalled = null;
response = await handleCollection(
Expand All @@ -258,11 +355,13 @@ Deno.test("handleCollection()", async () => {
collectionCallbacks: { dispatcher },
onNotFound,
onNotAcceptable,
onUnauthorized,
},
);
assertEquals(response.status, 404);
assertEquals(onNotFoundCalled, context.request);
assertEquals(onNotAcceptableCalled, null);
assertEquals(onUnauthorizedCalled, null);

onNotFoundCalled = null;
context = createRequestContext<void>({
Expand All @@ -281,11 +380,13 @@ Deno.test("handleCollection()", async () => {
collectionCallbacks: { dispatcher },
onNotFound,
onNotAcceptable,
onUnauthorized,
},
);
assertEquals(response.status, 404);
assertEquals(onNotFoundCalled, context.request);
assertEquals(onNotAcceptableCalled, null);
assertEquals(onUnauthorizedCalled, null);

onNotFoundCalled = null;
response = await handleCollection(
Expand All @@ -296,6 +397,63 @@ Deno.test("handleCollection()", async () => {
collectionCallbacks: { dispatcher },
onNotFound,
onNotAcceptable,
onUnauthorized,
},
);
assertEquals(response.status, 200);
assertEquals(
response.headers.get("Content-Type"),
"application/activity+json",
);
assertEquals(await response.json(), {
"@context": "https://www.w3.org/ns/activitystreams",
type: "OrderedCollection",
items: [
{ type: "Create", id: "https://example.com/activities/1" },
{ type: "Create", id: "https://example.com/activities/2" },
{ type: "Create", id: "https://example.com/activities/3" },
],
});
assertEquals(onNotFoundCalled, null);
assertEquals(onNotAcceptableCalled, null);
assertEquals(onUnauthorizedCalled, null);

response = await handleCollection(
context.request,
{
context,
handle: "someone",
collectionCallbacks: {
dispatcher,
authorizePredicate: (_ctx, _handle, key) => key != null,
},
onNotFound,
onNotAcceptable,
onUnauthorized,
},
);
assertEquals(response.status, 401);
assertEquals(onNotFoundCalled, null);
assertEquals(onNotAcceptableCalled, null);
assertEquals(onUnauthorizedCalled, context.request);

onUnauthorizedCalled = null;
context = createRequestContext<void>({
...context,
getSignedKey: () => Promise.resolve(publicKey2),
});
response = await handleCollection(
context.request,
{
context,
handle: "someone",
collectionCallbacks: {
dispatcher,
authorizePredicate: (_ctx, _handle, key) => key != null,
},
onNotFound,
onNotAcceptable,
onUnauthorized,
},
);
assertEquals(response.status, 200);
Expand All @@ -314,6 +472,7 @@ Deno.test("handleCollection()", async () => {
});
assertEquals(onNotFoundCalled, null);
assertEquals(onNotAcceptableCalled, null);
assertEquals(onUnauthorizedCalled, null);

response = await handleCollection(
context.request,
Expand All @@ -328,6 +487,7 @@ Deno.test("handleCollection()", async () => {
},
onNotFound,
onNotAcceptable,
onUnauthorized,
},
);
assertEquals(response.status, 200);
Expand All @@ -344,6 +504,7 @@ Deno.test("handleCollection()", async () => {
});
assertEquals(onNotFoundCalled, null);
assertEquals(onNotAcceptableCalled, null);
assertEquals(onUnauthorizedCalled, null);

let url = new URL("https://example.com/?cursor=0");
context = createRequestContext({
Expand All @@ -368,6 +529,7 @@ Deno.test("handleCollection()", async () => {
},
onNotFound,
onNotAcceptable,
onUnauthorized,
},
);
assertEquals(response.status, 200);
Expand All @@ -387,6 +549,7 @@ Deno.test("handleCollection()", async () => {
});
assertEquals(onNotFoundCalled, null);
assertEquals(onNotAcceptableCalled, null);
assertEquals(onUnauthorizedCalled, null);

url = new URL("https://example.com/?cursor=2");
context = createRequestContext({
Expand All @@ -411,6 +574,7 @@ Deno.test("handleCollection()", async () => {
},
onNotFound,
onNotAcceptable,
onUnauthorized,
},
);
assertEquals(response.status, 200);
Expand All @@ -430,6 +594,7 @@ Deno.test("handleCollection()", async () => {
});
assertEquals(onNotFoundCalled, null);
assertEquals(onNotAcceptableCalled, null);
assertEquals(onUnauthorizedCalled, null);
});

Deno.test("respondWithObject()", async () => {
Expand Down
Loading

0 comments on commit e78b836

Please sign in to comment.