Skip to content

Commit

Permalink
feat: Replace WebHookSubscription2021 with WebHookChannel2023
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Apr 24, 2023
1 parent e946348 commit d59a159
Show file tree
Hide file tree
Showing 19 changed files with 188 additions and 270 deletions.
10 changes: 5 additions & 5 deletions config/http/README.md
Expand Up @@ -22,14 +22,14 @@ Determines how notifications should be sent out from the server when resources c

* *all*: Supports all available notification types of the Solid Notifications protocol
[specification](https://solidproject.org/TR/notifications-protocol).
Currently, this includes WebHookSubscription2021 and WebSocketSubscription2021.
Currently, this includes WebHookChannel2023 and WebSocketChannel2023.
* *disabled*: No notifications are sent out.
* *legacy-websocket*: Follows the legacy Solid WebSocket
[specification](https://github.com/solid/solid-spec/blob/master/api-websockets.md).
* *webhooks*: Follows the WebHookSubscription2021
[specification](https://github.com/solid/notifications/blob/main/webhook-subscription-2021.md) draft.
* *websockets*: Follows the WebSocketSubscription2021
[specification](https://solidproject.org/TR/websocket-subscription-2021).
* *webhooks*: Follows the WebHookChannel2023
[specification](https://solid.github.io/notifications/webhook-channel-2023) draft.
* *websockets*: Follows the WebSocketChannel2023
[specification](https://solid.github.io/notifications/websocket-channel-2023).

## Server-Factory

Expand Down
6 changes: 3 additions & 3 deletions config/http/notifications/webhooks/handler.json
Expand Up @@ -2,10 +2,10 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles the generation and serialization of notifications for WebHookSubscription2021.",
"comment": "Handles the generation and serialization of notifications for WebHookChannel2023.",
"@id": "urn:solid-server:default:WebHookNotificationHandler",
"@type": "TypedNotificationHandler",
"type": "http://www.w3.org/ns/solid/notifications#WebHookSubscription2021",
"type": "http://www.w3.org/ns/solid/notifications#WebHookChannel2023",
"source": {
"@type": "ComposedNotificationHandler",
"generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" },
Expand All @@ -28,6 +28,6 @@
"handlers": [
{ "@id": "urn:solid-server:default:WebHookNotificationHandler" }
]
},
}
]
}
6 changes: 3 additions & 3 deletions config/http/notifications/webhooks/routes.json
Expand Up @@ -5,7 +5,7 @@
"@id": "urn:solid-server:default:WebHookRoute",
"@type": "RelativePathInteractionRoute",
"base": { "@id": "urn:solid-server:default:NotificationRoute" },
"relativePath": "/WebHookSubscription2021/"
"relativePath": "/WebHookChannel2023/"
},
{
"@id": "urn:solid-server:default:WebHookWebIdRoute",
Expand All @@ -15,11 +15,11 @@
},

{
"comment": "Handles the WebHookSubscription2021 WebID.",
"comment": "Handles the WebHookChannel2023 WebID.",
"@id": "urn:solid-server:default:WebHookWebId",
"@type": "OperationRouterHandler",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"allowedPathNames": [ "/WebHookSubscription2021/webId$" ],
"allowedPathNames": [ "/WebHookChannel2023/webId$" ],
"handler": {
"@type": "WebHookWebId",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
Expand Down
14 changes: 7 additions & 7 deletions config/http/notifications/webhooks/subscription.json
Expand Up @@ -2,16 +2,16 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles the subscriptions targeting a WebHookSubscription2021.",
"comment": "Handles the subscriptions targeting a WebHookChannel2023.",
"@id": "urn:solid-server:default:WebHookRouter",
"@type": "OperationRouterHandler",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"allowedMethods": [ "HEAD", "GET", "POST" ],
"allowedPathNames": [ "/WebHookSubscription2021/$" ],
"allowedPathNames": [ "/WebHookChannel2023/$" ],
"handler": {
"@id": "urn:solid-server:default:WebHookSubscriber",
"@type": "NotificationSubscriber",
"channelType": { "@id": "urn:solid-server:default:WebHookSubscription2021" },
"channelType": { "@id": "urn:solid-server:default:WebHookChannel2023Type" },
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" },
"permissionReader": { "@id": "urn:solid-server:default:PermissionReader" },
Expand All @@ -20,9 +20,9 @@
}
},
{
"comment": "Contains all the metadata relevant for a WebHookSubscription2021.",
"@id": "urn:solid-server:default:WebHookSubscription2021",
"@type": "WebHookSubscription2021",
"comment": "Contains all the metadata relevant for a WebHookChannel2023.",
"@id": "urn:solid-server:default:WebHookChannel2023Type",
"@type": "WebhookChannel2023Type",
"route": { "@id": "urn:solid-server:default:WebHookRoute" },
"webIdRoute": { "@id": "urn:solid-server:default:WebHookWebIdRoute" },
"stateHandler": {
Expand All @@ -37,7 +37,7 @@
"@type": "NotificationDescriber",
"subscriptions": [
{
"@id": "urn:solid-server:default:WebHookSubscription2021"
"@id": "urn:solid-server:default:WebHookChannel2023Type"
}
]
}
Expand Down
6 changes: 3 additions & 3 deletions documentation/markdown/architecture/features/notifications.md
Expand Up @@ -174,13 +174,13 @@ so that class can emit events later on, as mentioned above.
The state handler will make sure that a notification gets sent out if the subscription has a `state` feature request,
as defined in the notification specification.

## WebHookSubscription2021
## WebHookChannel2023

The additions required to support
[WebHookSubscription2021](https://github.com/solid/notifications/blob/main/webhook-subscription-2021.md)
[WebHookChannel2023](https://solid.github.io/notifications/webhook-channel-2023)
are quite similar to those needed for WebSocketChannel2023:

* For discovery, there is a `WebHookDescriber`, which is an extension of a `NotificationDescriber`.
* The `WebHookSubscription2021` class contains all the necessary typing information.
* The `WebHookChannel2023Type` class contains all the necessary typing information.
* `WebHookEmitter` is the `NotificationEmitter` that sends the request.
* `WebHookUnsubscriber` and `WebHookWebId` are additional utility classes to support the spec requirements.
28 changes: 10 additions & 18 deletions documentation/markdown/usage/notifications.md
Expand Up @@ -27,7 +27,7 @@ Doing a GET to `http://localhost:3000/.well-known/solid` then gives the followin
<http://localhost:3000/.well-known/solid>
a <http://www.w3.org/ns/pim/space#Storage> ;
notify:subscription <http://localhost:3000/.notifications/WebSocketChannel2023/> ,
<http://localhost:3000/.notifications/WebHookSubscription2021/> .
<http://localhost:3000/.notifications/WebHookChannel2023/> .
<http://localhost:3000/.notifications/WebSocketChannel2023/>
notify:channelType notify:WebSocketChannel2023 ;
notify:feature notify:accept ,
Expand All @@ -36,7 +36,7 @@ Doing a GET to `http://localhost:3000/.well-known/solid` then gives the followin
notify:startAt ,
notify:state .
<http://localhost:3000/.notifications/WebSocketChannel2023/>
notify:channelType notify:WebHookSubscription2021;
notify:channelType notify:WebHookChannel2023;
notify:feature notify:accept ,
notify:endAt ,
notify:rate ,
Expand All @@ -61,10 +61,7 @@ Requests without `Read` permission will be rejected.

There are currently up to two supported ways to get notifications in CSS, depending on your configuration:
the notification channel types [`WebSocketChannel2023`](https://solid.github.io/notifications/websocket-channel-2023);
and [`WebHookSubscription2021`](https://github.com/solid/notifications/blob/main/webhook-subscription-2021.md).
_**Note:** `WebHookSubscription2021` has been deprecated, and will be replaced
by the newer `WebHookChannel2023` implementation once that specification is published;
the practical differences are expected to be minor._
and [`WebHookChannel2023`](https://solid.github.io/notifications/webhook-channel-2023).

### WebSockets

Expand Down Expand Up @@ -104,37 +101,32 @@ ws.on('message', (notification) => console.log(notification));
### WebHooks

Similar to the WebSocket subscription, below is sample JSON-LD
that would be sent to `http://localhost:3000/.notifications/WebHookSubscription2021/`:
that would be sent to `http://localhost:3000/.notifications/WebHookChannel2023/`:

```json
{
"@context": [ "https://www.w3.org/ns/solid/notification/v1" ],
"type": "http://www.w3.org/ns/solid/notifications#WebHookSubscription2021",
"type": "http://www.w3.org/ns/solid/notifications#WebHookChannel2023",
"topic": "http://localhost:3000/foo",
"target": "https://example.com/webhook"
"sendTo": "https://example.com/webhook"
}
```

Note that this document has an additional `target` field.
Note that this document has an additional `sendTo` field.
This is the WebHook URL of your server, the URL to which you want the notifications to be sent.

The response would then be something like this:

```json
{
"@context": [ "https://www.w3.org/ns/solid/notification/v1" ],
"id": "http://localhost:3000/.notifications/WebHookSubscription2021/eeaf2c17-699a-4e53-8355-e91d13807e5f",
"type": "http://www.w3.org/ns/solid/notifications#WebHookSubscription2021",
"id": "http://localhost:3000/.notifications/WebHookChannel2023/eeaf2c17-699a-4e53-8355-e91d13807e5f",
"type": "http://www.w3.org/ns/solid/notifications#WebHookChannel2023",
"topic": "http://localhost:3000/foo",
"target": "https://example.com/webhook",
"unsubscribe_endpoint": "http://localhost:3000/.notifications/WebHookSubscription2021/eeaf2c17-699a-4e53-8355-e91d13807e5f"
"sendTo": "https://example.com/webhook"
}
```

The `unsubscribe_endpoint` field is new here.
Once created, the notification channel can be removed and notifications stopped
by sending a `DELETE` request to the URL found in that field.

## Unsubscribing from a notification channel

!!! note
Expand Down
8 changes: 4 additions & 4 deletions src/index.ts
Expand Up @@ -321,10 +321,10 @@ export * from './server/notifications/serialize/ConvertingNotificationSerializer
export * from './server/notifications/serialize/JsonLdNotificationSerializer';
export * from './server/notifications/serialize/NotificationSerializer';

// Server/Notifications/WebHookSubscription2021
export * from './server/notifications/WebHookSubscription2021/WebHookEmitter';
export * from './server/notifications/WebHookSubscription2021/WebHookSubscription2021';
export * from './server/notifications/WebHookSubscription2021/WebHookWebId';
// Server/Notifications/WebHookChannel2023
export * from './server/notifications/WebHookChannel2023/WebhookChannel2023Type';
export * from './server/notifications/WebHookChannel2023/WebHookEmitter';
export * from './server/notifications/WebHookChannel2023/WebHookWebId';

// Server/Notifications/WebSocketChannel2023
export * from './server/notifications/WebSocketChannel2023/WebSocket2023Emitter';
Expand Down
Expand Up @@ -9,11 +9,14 @@ import { trimTrailingSlashes } from '../../../util/PathUtil';
import { readableToString } from '../../../util/StreamUtil';
import type { NotificationEmitterInput } from '../NotificationEmitter';
import { NotificationEmitter } from '../NotificationEmitter';
import type { WebHookSubscription2021Channel } from './WebHookSubscription2021';
import { isWebHook2021Channel } from './WebHookSubscription2021';
import type { WebhookChannel2023 } from './WebhookChannel2023Type';
import { isWebHook2023Channel } from './WebhookChannel2023Type';

/**
* Emits a notification representation using the WebHookSubscription2021 specification.
* Emits a notification representation using the WebHookChannel2023 specification.
*
* At the time of writing it is not specified how exactly a notification sender should make its requests verifiable,
* so for now we use a token similar to those from Solid-OIDC, signed by the server itself.
*
* Generates a DPoP token and proof, and adds those to the HTTP request that is sent to the target.
*
Expand All @@ -37,15 +40,15 @@ export class WebHookEmitter extends NotificationEmitter {
}

public async canHandle({ channel }: NotificationEmitterInput): Promise<void> {
if (!isWebHook2021Channel(channel)) {
throw new NotImplementedHttpError(`${channel.id} is not a WebHookSubscription2021 channel.`);
if (!isWebHook2023Channel(channel)) {
throw new NotImplementedHttpError(`${channel.id} is not a WebHookChannel2023 channel.`);
}
}

public async handle({ channel, representation }: NotificationEmitterInput): Promise<void> {
// Cast was checked in `canHandle`
const webHookChannel = channel as WebHookSubscription2021Channel;
this.logger.debug(`Emitting WebHook notification with target ${webHookChannel.target}`);
const webHookChannel = channel as WebhookChannel2023;
this.logger.debug(`Emitting WebHook notification with target ${webHookChannel.sendTo}`);

const privateKey = await this.jwkGenerator.getPrivateKey();
const publicKey = await this.jwkGenerator.getPublicKey();
Expand All @@ -55,8 +58,7 @@ export class WebHookEmitter extends NotificationEmitter {
// Make sure both header and proof have the same timestamp
const time = Date.now();

// The spec is not completely clear on which fields actually need to be present in the token,
// only that it needs to contain the WebID somehow.
// Currently the spec does not define how the notification sender should identify.
// The format used here has been chosen to be similar
// to how ID tokens are described in the Solid-OIDC specification for consistency.
const dpopToken = await new SignJWT({
Expand All @@ -76,14 +78,14 @@ export class WebHookEmitter extends NotificationEmitter {

// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-4.2
const dpopProof = await new SignJWT({
htu: webHookChannel.target,
htu: webHookChannel.sendTo,
htm: 'POST',
}).setProtectedHeader({ alg: privateKey.alg, jwk: publicKey, typ: 'dpop+jwt' })
.setIssuedAt(time)
.setJti(v4())
.sign(privateKeyObject);

const response = await fetch(webHookChannel.target, {
const response = await fetch(webHookChannel.sendTo, {
method: 'POST',
headers: {
'content-type': representation.metadata.contentType!,
Expand All @@ -93,7 +95,7 @@ export class WebHookEmitter extends NotificationEmitter {
body: await readableToString(representation.data),
});
if (response.status >= 400) {
this.logger.error(`There was an issue emitting a WebHook notification with target ${webHookChannel.target}: ${
this.logger.error(`There was an issue emitting a WebHook notification with target ${webHookChannel.sendTo}: ${
await response.text()}`);
}
}
Expand Down
Expand Up @@ -9,11 +9,9 @@ import type { OperationHttpHandlerInput } from '../../OperationHttpHandler';
import { OperationHttpHandler } from '../../OperationHttpHandler';

/**
* The WebHookSubscription2021 requires the server to have a WebID
* that is used during the generation of the DPoP headers.
* There are no real specifications about what this should contain or look like,
* so we just return a Turtle document that contains a solid:oidcIssuer triple for now.
* This way we confirm that our server was allowed to sign the token.
* Generates a fixed WebID that we use to identify the server for notifications sent using a WebHookChannel2023.
* This is used in tandem with the tokens generated by the {@link WebHookEmitter}.
* This is a minimal WebID with only the `solid:oidcIssuer` triple.
*/
export class WebHookWebId extends OperationHttpHandler {
private readonly turtle: string;
Expand Down
@@ -0,0 +1,88 @@
import type { Store } from 'n3';
import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute';
import { getLoggerFor } from '../../../logging/LogUtil';
import { createErrorMessage } from '../../../util/errors/ErrorUtil';
import { NOTIFY } from '../../../util/Vocabularies';
import { BaseChannelType } from '../BaseChannelType';
import type { NotificationChannel } from '../NotificationChannel';
import type { StateHandler } from '../StateHandler';

/**
* A {@link NotificationChannel} containing the necessary fields for a WebHookChannel2023 channel.
*/
export interface WebhookChannel2023 extends NotificationChannel {
/**
* The "WebHookChannel2023" type.
*/
type: typeof NOTIFY.WebHookChannel2023;
/**
* Where the notifications have to be sent.
*/
sendTo: string;
}

export function isWebHook2023Channel(channel: NotificationChannel): channel is WebhookChannel2023 {
return channel.type === NOTIFY.WebHookChannel2023;
}

/**
* The notification channel type WebHookChannel2023 as described in
* https://solid.github.io/notifications/webhook-channel-2023
*
* Requires read permissions on a resource to be able to receive notifications.
*
* Also handles the `state` feature if present.
*/
export class WebhookChannel2023Type extends BaseChannelType {
protected readonly logger = getLoggerFor(this);

private readonly stateHandler: StateHandler;
private readonly webId: string;

/**
* @param route - The route corresponding to the URL of the subscription service of this channel type.
* @param webIdRoute - The route to the WebID that needs to be used when generating DPoP tokens for notifications.
* @param stateHandler - The {@link StateHandler} that will be called after a successful subscription.
* @param features - The features that need to be enabled for this channel type.
*/
public constructor(route: InteractionRoute, webIdRoute: InteractionRoute, stateHandler: StateHandler,
features?: string[]) {
super(NOTIFY.terms.WebHookChannel2023,
route,
features,
[{ path: NOTIFY.sendTo, minCount: 1, maxCount: 1 }]);
this.stateHandler = stateHandler;
this.webId = webIdRoute.getPath();
}

public async initChannel(data: Store): Promise<WebhookChannel2023> {
const subject = await this.validateSubscription(data);
const channel = await this.quadsToChannel(data, subject);
const sendTo = data.getObjects(subject, NOTIFY.terms.sendTo, null)[0];

return {
...channel,
type: NOTIFY.WebHookChannel2023,
sendTo: sendTo.value,
};
}

public async toJsonLd(channel: NotificationChannel): Promise<Record<string, unknown>> {
const json = await super.toJsonLd(channel);

// Add the stored WebID as sender.
// We don't store it in the channel object itself as we always know what it is anyway.
json.sender = this.webId;

return json;
}

public async completeChannel(channel: NotificationChannel): Promise<void> {
try {
// Send the state notification, if there is one
await this.stateHandler.handleSafe({ channel });
} catch (error: unknown) {
this.logger.error(`Error emitting state notification: ${createErrorMessage(error)}`);
}
}
}

0 comments on commit d59a159

Please sign in to comment.