feat: Add support for WebHookSubscription2021

This commit is contained in:
Joachim Van Herwegen 2022-10-13 10:15:05 +02:00
parent cb619415fa
commit f54c34d1e0
34 changed files with 1305 additions and 64 deletions

View File

@ -7,6 +7,7 @@
- The server can be configured to use [ACP](https://solidproject.org/TR/acp) instead of WebACL. - The server can be configured to use [ACP](https://solidproject.org/TR/acp) instead of WebACL.
`config/file-acp.json` is an example of a configuration that uses this authorization scheme instead. `config/file-acp.json` is an example of a configuration that uses this authorization scheme instead.
- Support for the new [WebSocket Notification protocol](https://solidproject.org/TR/websocket-subscription-2021) - Support for the new [WebSocket Notification protocol](https://solidproject.org/TR/websocket-subscription-2021)
and the [WebHook Notification protocol draft](https://github.com/solid/notifications/blob/main/webhook-subscription-2021.md)
was added. was added.
### Data migration ### Data migration
@ -27,7 +28,7 @@ The following changes pertain to the imports in the default configs:
- All references to WebSockets have been removed from the `http/middleware` and `http/server-factory` imports. - All references to WebSockets have been removed from the `http/middleware` and `http/server-factory` imports.
- A new `http/notifications` set of import options have been added - A new `http/notifications` set of import options have been added
to determine which notification specification a CSS instance should use. to determine which notification specification a CSS instance should use.
All default configurations have been updated to use `http/notifications/websockets.json`. Most default configurations have been updated to use `http/notifications/websockets.json`.
The following changes are relevant for v5 custom configs that replaced certain features. The following changes are relevant for v5 custom configs that replaced certain features.

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json", "css:config/app/variables/default.json",
"css:config/http/handler/default.json", "css:config/http/handler/default.json",
"css:config/http/middleware/default.json", "css:config/http/middleware/default.json",
"css:config/http/notifications/websockets.json", "css:config/http/notifications/all.json",
"css:config/http/server-factory/http.json", "css:config/http/server-factory/http.json",
"css:config/http/static/default.json", "css:config/http/static/default.json",
"css:config/identity/access/public.json", "css:config/identity/access/public.json",

View File

@ -20,9 +20,14 @@ and then pass the request along.
Determines how notifications should be sent out from the server when resources change. Determines how notifications should be sent out from the server when resources change.
* *all*: Supports all available notification types of the Solid Notifications protocol
[specification](https://solidproject.org/TR/notifications-protocol).
Currently, this includes WebHookSubscription2021 and WebSocketSubscription2021.
* *disabled*: No notifications are sent out. * *disabled*: No notifications are sent out.
* *legacy-websocket*: Follows the legacy Solid WebSocket * *legacy-websocket*: Follows the legacy Solid WebSocket
[specification](https://github.com/solid/solid-spec/blob/master/api-websockets.md). [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 * *websockets*: Follows the WebSocketSubscription2021
[specification](https://solidproject.org/TR/websocket-subscription-2021). [specification](https://solidproject.org/TR/websocket-subscription-2021).

View File

@ -0,0 +1,22 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"import": [
"css:config/http/notifications/base/description.json",
"css:config/http/notifications/base/handler.json",
"css:config/http/notifications/base/listener.json",
"css:config/http/notifications/base/storage.json",
"css:config/http/notifications/websockets/description.json",
"css:config/http/notifications/websockets/handler.json",
"css:config/http/notifications/websockets/http.json",
"css:config/http/notifications/websockets/subscription.json",
"css:config/http/notifications/webhooks/description.json",
"css:config/http/notifications/webhooks/handler.json",
"css:config/http/notifications/webhooks/routes.json",
"css:config/http/notifications/webhooks/subscription.json"
],
"@graph": [
{
"comment": "All the relevant components are made in the specific imports seen above."
}
]
}

View File

@ -0,0 +1,18 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"import": [
"css:config/http/notifications/base/description.json",
"css:config/http/notifications/base/handler.json",
"css:config/http/notifications/base/listener.json",
"css:config/http/notifications/base/storage.json",
"css:config/http/notifications/webhooks/description.json",
"css:config/http/notifications/webhooks/handler.json",
"css:config/http/notifications/webhooks/routes.json",
"css:config/http/notifications/webhooks/subscription.json"
],
"@graph": [
{
"comment": "All the relevant components are made in the specific imports seen above."
}
]
}

View File

@ -0,0 +1,18 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"@graph": [
{
"@id": "urn:solid-server:default:StorageDescriber",
"@type": "ArrayUnionHandler",
"handlers": [
{
"comment": "Handles the storage description triples used for discovery of a WebHookSubscription2021 endpoint.",
"@type": "WebHookDescriber",
"route": { "@id": "urn:solid-server:default:WebHookRoute" },
"relative": "#webhookNotification",
"webIdRoute": { "@id": "urn:solid-server:default:WebHookWebIdRoute" }
}
]
}
]
}

View File

@ -0,0 +1,33 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles the generation and serialization of notifications for WebHookSubscription2021.",
"@id": "urn:solid-server:default:WebHookNotificationHandler",
"@type": "TypedNotificationHandler",
"type": "WebHookSubscription2021",
"source": {
"@type": "ComposedNotificationHandler",
"generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" },
"serializer": { "@id": "urn:solid-server:default:BaseNotificationSerializer" },
"emitter": { "@id": "urn:solid-server:default:WebHookEmitter" }
}
},
{
"comment": "Emits serialized notifications through HTTP requests to the WebHook.",
"@id": "urn:solid-server:default:WebHookEmitter",
"@type": "WebHookEmitter",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"webIdRoute": { "@id": "urn:solid-server:default:WebHookWebIdRoute" },
"jwkGenerator": { "@id": "urn:solid-server:default:JwkGenerator" }
},
{
"@id": "urn:solid-server:default:NotificationHandler",
"@type": "WaterfallHandler",
"handlers": [
{ "@id": "urn:solid-server:default:WebHookNotificationHandler" }
]
},
]
}

View File

@ -0,0 +1,59 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"@graph": [
{
"@id": "urn:solid-server:default:WebHookRoute",
"@type": "RelativePathInteractionRoute",
"base": { "@id": "urn:solid-server:default:NotificationRoute" },
"relativePath": "/WebHookSubscription2021/"
},
{
"@id": "urn:solid-server:default:WebHookUnsubscribeRoute",
"@type": "RelativePathInteractionRoute",
"base": { "@id": "urn:solid-server:default:WebHookRoute" },
"relativePath": "/unsubscribe/"
},
{
"@id": "urn:solid-server:default:WebHookWebIdRoute",
"@type": "RelativePathInteractionRoute",
"base": { "@id": "urn:solid-server:default:WebHookRoute" },
"relativePath": "/webId"
},
{
"comment": "Handles unsubscribing from a WebHookSubscription2021.",
"@id": "urn:solid-server:default:WebHookUnsubscriber",
"@type": "OperationRouterHandler",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"allowedMethods": [ "DELETE" ],
"allowedPathNames": [ "/WebHookSubscription2021/unsubscribe/" ],
"handler": {
"@type": "WebHookUnsubscriber",
"credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" },
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" }
}
},
{
"comment": "Handles the WebHookSubscription2021 WebID.",
"@id": "urn:solid-server:default:WebHookWebId",
"@type": "OperationRouterHandler",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"allowedPathNames": [ "/WebHookSubscription2021/webId" ],
"handler": {
"@type": "WebHookWebId",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}
},
{
"@id": "urn:solid-server:default:NotificationTypeHandler",
"@type": "WaterfallHandler",
"handlers": [
{ "@id": "urn:solid-server:default:WebHookSubscriber" },
{ "@id": "urn:solid-server:default:WebHookUnsubscriber" },
{ "@id": "urn:solid-server:default:WebHookWebId" }
]
}
]
}

View File

@ -0,0 +1,32 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles the subscriptions targeting a WebHookSubscription2021.",
"@id": "urn:solid-server:default:WebHookSubscriber",
"@type": "OperationRouterHandler",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"allowedMethods": [ "POST" ],
"allowedPathNames": [ "/WebHookSubscription2021/$" ],
"handler": {
"@type": "NotificationSubscriber",
"subscriptionType": { "@id": "urn:solid-server:default:WebHookSubscription2021" },
"credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" },
"permissionReader": { "@id": "urn:solid-server:default:PermissionReader" },
"authorizer": { "@id": "urn:solid-server:default:Authorizer" }
}
},
{
"comment": "Contains all the metadata relevant for a WebHookSubscription2021.",
"@id": "urn:solid-server:default:WebHookSubscription2021",
"@type": "WebHookSubscription2021",
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" },
"unsubscribeRoute": { "@id": "urn:solid-server:default:WebHookUnsubscribeRoute" },
"stateHandler": {
"@type": "BaseStateHandler",
"handler": { "@id": "urn:solid-server:default:WebHookNotificationHandler" },
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" }
}
}
]
}

View File

@ -95,7 +95,8 @@ that handles notifications for the specific type.
## WebSocketSubscription2021 ## WebSocketSubscription2021
To add support for WebSocketSubscription2021 notifications, To add support for [WebSocketSubscription2021](https://solidproject.org/TR/2022/websocket-subscription-2021-20220509)
notifications,
components were added as described in the documentation above. components were added as described in the documentation above.
For discovery a `NotificationDescriber` was added with the corresponding settings. For discovery a `NotificationDescriber` was added with the corresponding settings.
@ -170,3 +171,14 @@ The `WebSocket2021Storer` will store the WebSocket in the same map used by the `
so that class can later on emit events as mentioned above. so that class can later on emit events as mentioned above.
The state handler will make sure that a notification gets sent out if the subscription has a `state` feature request, 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. as defined in the notification specification.
## WebHookSubscription2021
The additions required to support
[WebHookSubscription2021](https://github.com/solid/notifications/blob/main/webhook-subscription-2021.md)
are quite similar to those needed for WebSocketSubscription2021:
* For discovery, there is a `WebHookDescriber`, which is an extension of a `NotificationDescriber`.
* The `WebHookSubscription2021` 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.

View File

@ -320,6 +320,13 @@ export * from './server/notifications/serialize/ConvertingNotificationSerializer
export * from './server/notifications/serialize/JsonLdNotificationSerializer'; export * from './server/notifications/serialize/JsonLdNotificationSerializer';
export * from './server/notifications/serialize/NotificationSerializer'; export * from './server/notifications/serialize/NotificationSerializer';
// Server/Notifications/WebHookSubscription2021
export * from './server/notifications/WebHookSubscription2021/WebHookDescriber';
export * from './server/notifications/WebHookSubscription2021/WebHookEmitter';
export * from './server/notifications/WebHookSubscription2021/WebHookSubscription2021';
export * from './server/notifications/WebHookSubscription2021/WebHookUnsubscriber';
export * from './server/notifications/WebHookSubscription2021/WebHookWebId';
// Server/Notifications/WebSocketSubscription2021 // Server/Notifications/WebSocketSubscription2021
export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Emitter'; export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Emitter';
export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Handler'; export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Handler';

View File

@ -6,7 +6,7 @@ import { NOTIFY, RDF } from '../../util/Vocabularies';
import { StorageDescriber } from '../description/StorageDescriber'; import { StorageDescriber } from '../description/StorageDescriber';
const { namedNode, quad } = DataFactory; const { namedNode, quad } = DataFactory;
const DEFAULT_FEATURES = [ export const DEFAULT_NOTIFICATION_FEATURES = [
NOTIFY.accept, NOTIFY.accept,
NOTIFY.expiration, NOTIFY.expiration,
NOTIFY.rate, NOTIFY.rate,
@ -14,7 +14,7 @@ const DEFAULT_FEATURES = [
]; ];
/** /**
* Outputs quads describing how to access a specific Notificaion Subscription type and its features, * Outputs quads describing how to access a specific Notification Subscription type and its features,
* as described in https://solidproject.org/TR/notifications-protocol#discovery. * as described in https://solidproject.org/TR/notifications-protocol#discovery.
*/ */
export class NotificationDescriber extends StorageDescriber { export class NotificationDescriber extends StorageDescriber {
@ -30,7 +30,8 @@ export class NotificationDescriber extends StorageDescriber {
* @param type - The rdf:type of the subscription type. * @param type - The rdf:type of the subscription type.
* @param features - Which features are enabled for this subscription type. Defaults to accept/expiration/rate/state. * @param features - Which features are enabled for this subscription type. Defaults to accept/expiration/rate/state.
*/ */
public constructor(route: InteractionRoute, relative: string, type: string, features: string[] = DEFAULT_FEATURES) { public constructor(route: InteractionRoute, relative: string, type: string,
features: string[] = DEFAULT_NOTIFICATION_FEATURES) {
super(); super();
this.path = namedNode(route.getPath()); this.path = namedNode(route.getPath());
this.relative = relative; this.relative = relative;

View File

@ -2,12 +2,13 @@ import type { Representation } from '../../http/representation/Representation';
import { AsyncHandler } from '../../util/handlers/AsyncHandler'; import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import type { SubscriptionInfo } from './SubscriptionStorage'; import type { SubscriptionInfo } from './SubscriptionStorage';
export interface NotificationEmitterInput { export interface NotificationEmitterInput<T = Record<string, unknown>> {
representation: Representation; representation: Representation;
info: SubscriptionInfo; info: SubscriptionInfo<T>;
} }
/** /**
* Emits a serialized Notification to the subscription defined by the info. * Emits a serialized Notification to the subscription defined by the info.
*/ */
export abstract class NotificationEmitter extends AsyncHandler<NotificationEmitterInput> {} export abstract class NotificationEmitter<T = Record<string, unknown>>
extends AsyncHandler<NotificationEmitterInput<T>> {}

View File

@ -1,3 +1,4 @@
import type { Credentials } from '../../authentication/Credentials';
import type { CredentialsExtractor } from '../../authentication/CredentialsExtractor'; import type { CredentialsExtractor } from '../../authentication/CredentialsExtractor';
import type { Authorizer } from '../../authorization/Authorizer'; import type { Authorizer } from '../../authorization/Authorizer';
import type { PermissionReader } from '../../authorization/PermissionReader'; import type { PermissionReader } from '../../authorization/PermissionReader';
@ -85,14 +86,14 @@ export class NotificationSubscriber extends OperationHttpHandler {
} }
// Verify if the client is allowed to subscribe // Verify if the client is allowed to subscribe
await this.authorize(request, subscription); const credentials = await this.authorize(request, subscription);
const { response } = await this.subscriptionType.subscribe(subscription); const { response } = await this.subscriptionType.subscribe(subscription, credentials);
return new OkResponseDescription(response.metadata, response.data); return new OkResponseDescription(response.metadata, response.data);
} }
private async authorize(request: HttpRequest, subscription: Subscription): Promise<void> { private async authorize(request: HttpRequest, subscription: Subscription): Promise<Credentials> {
const credentials = await this.credentialsExtractor.handleSafe(request); const credentials = await this.credentialsExtractor.handleSafe(request);
this.logger.debug(`Extracted credentials: ${JSON.stringify(credentials)}`); this.logger.debug(`Extracted credentials: ${JSON.stringify(credentials)}`);
@ -104,5 +105,7 @@ export class NotificationSubscriber extends OperationHttpHandler {
await this.authorizer.handleSafe({ credentials, requestedModes, availablePermissions }); await this.authorizer.handleSafe({ credentials, requestedModes, availablePermissions });
this.logger.verbose(`Authorization succeeded, creating subscription`); this.logger.verbose(`Authorization succeeded, creating subscription`);
return credentials;
} }
} }

View File

@ -1,4 +1,5 @@
import type { InferType } from 'yup'; import type { InferType } from 'yup';
import type { Credentials } from '../../authentication/Credentials';
import type { AccessMap } from '../../authorization/permissions/Permissions'; import type { AccessMap } from '../../authorization/permissions/Permissions';
import type { Representation } from '../../http/representation/Representation'; import type { Representation } from '../../http/representation/Representation';
import type { SUBSCRIBE_SCHEMA } from './Subscription'; import type { SUBSCRIBE_SCHEMA } from './Subscription';
@ -32,8 +33,9 @@ export interface SubscriptionType<TSub extends typeof SUBSCRIBE_SCHEMA = typeof
/** /**
* Registers the given subscription. * Registers the given subscription.
* @param subscription - The subscription to register. * @param subscription - The subscription to register.
* @param credentials - The credentials of the client trying to subscribe.
* *
* @returns A {@link Representation} to return as a response and the generated {@link SubscriptionInfo}. * @returns A {@link Representation} to return as a response and the generated {@link SubscriptionInfo}.
*/ */
subscribe: (subscription: InferType<TSub>) => Promise<SubscriptionResponse<TFeat>>; subscribe: (subscription: InferType<TSub>, credentials: Credentials) => Promise<SubscriptionResponse<TFeat>>;
} }

View File

@ -0,0 +1,20 @@
import { joinUrl } from '../../../util/PathUtil';
/**
* Generates a specific unsubscribe URL for a WebHookSubscription2021
* by combining the default unsubscribe URL with the given identifier.
* @param url - The default unsubscribe URL.
* @param id - The identifier.
*/
export function generateWebHookUnsubscribeUrl(url: string, id: string): string {
return joinUrl(url, encodeURIComponent(id));
}
/**
* Parses a WebHookSubscription2021 unsubscribe URL to extract the identifier.
* @param url - The unsubscribe URL that is being called.
*/
export function parseWebHookUnsubscribeUrl(url: string): string {
// Split always returns an array of at least length 1 so result can not be undefined
return decodeURIComponent(url.split(/\//u).pop()!);
}

View File

@ -0,0 +1,43 @@
import type { NamedNode } from '@rdfjs/types';
import { DataFactory } from 'n3';
import type { Quad } from 'rdf-js';
import type { ResourceIdentifier } from '../../../http/representation/ResourceIdentifier';
import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute';
import { NOTIFY, RDF } from '../../../util/Vocabularies';
import { DEFAULT_NOTIFICATION_FEATURES, NotificationDescriber } from '../NotificationDescriber';
const { namedNode, quad } = DataFactory;
export interface WebHookStorageDescriberArgs {
route: InteractionRoute;
relative: string;
webIdRoute: InteractionRoute;
features?: string[];
}
/**
* Handles the necessary triples for describing a WebHookSubcription2021 notification channel.
*
* Extends {@link NotificationDescriber} by adding the necessary `notify:webid` and `notify:webhookAuth` triples.
*/
export class WebHookDescriber extends NotificationDescriber {
private readonly webId: NamedNode;
public constructor(args: WebHookStorageDescriberArgs) {
const features = args.features ?? [ ...DEFAULT_NOTIFICATION_FEATURES ];
features.push(NOTIFY.webhookAuth);
super(args.route, args.relative, NOTIFY.WebHookSubscription2021, features);
this.webId = namedNode(args.webIdRoute.getPath());
}
public async handle(input: ResourceIdentifier): Promise<Quad[]> {
const quads = await super.handle(input);
// Find the notification channel subject
const typeQuad = quads.find((entry): boolean => entry.predicate.equals(RDF.terms.type) &&
entry.object.equals(NOTIFY.terms.WebHookSubscription2021));
quads.push(quad(typeQuad!.subject, NOTIFY.terms.webid, this.webId));
return quads;
}
}

View File

@ -0,0 +1,90 @@
import fetch from 'cross-fetch';
import { calculateJwkThumbprint, importJWK, SignJWT } from 'jose';
import { v4 } from 'uuid';
import type { JwkGenerator } from '../../../identity/configuration/JwkGenerator';
import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute';
import { getLoggerFor } from '../../../logging/LogUtil';
import { trimTrailingSlashes } from '../../../util/PathUtil';
import { readableToString } from '../../../util/StreamUtil';
import type { NotificationEmitterInput } from '../NotificationEmitter';
import { NotificationEmitter } from '../NotificationEmitter';
import type { WebHookFeatures } from './WebHookSubscription2021';
/**
* Emits a notification representation using the WebHookSubscription2021 specification.
*
* Generates a DPoP token and proof, and adds those to the HTTP request that is sent to the target.
*
* The `expiration` input parameter is how long the generated token should be valid in minutes.
* Default is 20.
*/
export class WebHookEmitter extends NotificationEmitter<WebHookFeatures> {
protected readonly logger = getLoggerFor(this);
private readonly issuer: string;
private readonly webId: string;
private readonly jwkGenerator: JwkGenerator;
private readonly expiration: number;
public constructor(baseUrl: string, webIdRoute: InteractionRoute, jwkGenerator: JwkGenerator, expiration = 20) {
super();
this.issuer = trimTrailingSlashes(baseUrl);
this.webId = webIdRoute.getPath();
this.jwkGenerator = jwkGenerator;
this.expiration = expiration * 60 * 1000;
}
public async handle({ info, representation }: NotificationEmitterInput<WebHookFeatures>): Promise<void> {
this.logger.debug(`Emitting WebHook notification with target ${info.features.target}`);
const privateKey = await this.jwkGenerator.getPrivateKey();
const publicKey = await this.jwkGenerator.getPublicKey();
const privateKeyObject = await importJWK(privateKey);
// 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.
// 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({
webid: this.webId,
azp: this.webId,
sub: this.webId,
cnf: {
jkt: await calculateJwkThumbprint(publicKey, 'sha256'),
},
}).setProtectedHeader({ alg: privateKey.alg })
.setIssuedAt(time)
.setExpirationTime(time + this.expiration)
.setAudience([ this.webId, 'solid' ])
.setIssuer(this.issuer)
.setJti(v4())
.sign(privateKeyObject);
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-4.2
const dpopProof = await new SignJWT({
htu: info.features.target,
htm: 'POST',
}).setProtectedHeader({ alg: privateKey.alg, jwk: publicKey, typ: 'dpop+jwt' })
.setIssuedAt(time)
.setJti(v4())
.sign(privateKeyObject);
const response = await fetch(info.features.target, {
method: 'POST',
headers: {
'content-type': representation.metadata.contentType!,
authorization: `DPoP ${dpopToken}`,
dpop: dpopProof,
},
body: await readableToString(representation.data),
});
if (response.status >= 400) {
this.logger.error(`There was an issue emitting a WebHook notification with target ${info.features.target}: ${
await response.text()}`);
}
}
}

View File

@ -0,0 +1,92 @@
import type { InferType } from 'yup';
import { string } from 'yup';
import type { Credentials } from '../../../authentication/Credentials';
import type { AccessMap } from '../../../authorization/permissions/Permissions';
import { AccessMode } from '../../../authorization/permissions/Permissions';
import { BasicRepresentation } from '../../../http/representation/BasicRepresentation';
import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute';
import { getLoggerFor } from '../../../logging/LogUtil';
import { APPLICATION_LD_JSON } from '../../../util/ContentTypes';
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
import { createErrorMessage } from '../../../util/errors/ErrorUtil';
import { IdentifierSetMultiMap } from '../../../util/map/IdentifierMap';
import { endOfStream } from '../../../util/StreamUtil';
import { CONTEXT_NOTIFICATION } from '../Notification';
import type { StateHandler } from '../StateHandler';
import { SUBSCRIBE_SCHEMA } from '../Subscription';
import type { SubscriptionStorage } from '../SubscriptionStorage';
import type { SubscriptionResponse, SubscriptionType } from '../SubscriptionType';
import { generateWebHookUnsubscribeUrl } from './WebHook2021Util';
const type = 'WebHookSubscription2021';
const schema = SUBSCRIBE_SCHEMA.shape({
type: string().required().oneOf([ type ]),
// Not using `.url()` validator since it does not support localhost URLs
target: string().required(),
});
export type WebHookFeatures = { target: string; webId: string };
/**
* The notification subscription type WebHookSubscription2021 as described in
* https://github.com/solid/notifications/blob/main/webhook-subscription-2021.md
*
* Requires read permissions on a resource to be able to receive notifications.
*
* Also handles the `state` feature if present.
*/
export class WebHookSubscription2021 implements SubscriptionType<typeof schema, WebHookFeatures> {
protected readonly logger = getLoggerFor(this);
private readonly storage: SubscriptionStorage<WebHookFeatures>;
private readonly unsubscribePath: string;
private readonly stateHandler: StateHandler;
public readonly type = type;
public readonly schema = schema;
public constructor(storage: SubscriptionStorage<WebHookFeatures>, unsubscribeRoute: InteractionRoute,
stateHandler: StateHandler) {
this.storage = storage;
this.unsubscribePath = unsubscribeRoute.getPath();
this.stateHandler = stateHandler;
}
public async extractModes(subscription: InferType<typeof schema>): Promise<AccessMap> {
return new IdentifierSetMultiMap<AccessMode>([[{ path: subscription.topic }, AccessMode.read ]]);
}
public async subscribe(subscription: InferType<typeof schema>, credentials: Credentials):
Promise<SubscriptionResponse<WebHookFeatures>> {
const webId = credentials.agent?.webId;
if (!webId) {
throw new BadRequestHttpError(
'A WebHookSubscription2021 subscription request needs to be authenticated with a WebID.',
);
}
const info = this.storage.create(subscription, { target: subscription.target, webId });
await this.storage.add(info);
const jsonld = {
'@context': [ CONTEXT_NOTIFICATION ],
type: this.type,
target: subscription.target,
// eslint-disable-next-line @typescript-eslint/naming-convention
unsubscribe_endpoint: generateWebHookUnsubscribeUrl(this.unsubscribePath, info.id),
};
const response = new BasicRepresentation(JSON.stringify(jsonld), APPLICATION_LD_JSON);
// We want to send the state notification, if there is one,
// right after we send the response for subscribing.
// We do this by waiting for the response to be closed.
endOfStream(response.data)
.then((): Promise<void> => this.stateHandler.handleSafe({ info }))
.catch((error): void => {
this.logger.error(`Error emitting state notification: ${createErrorMessage(error)}`);
});
return { response, info };
}
}

View File

@ -0,0 +1,46 @@
import type { CredentialsExtractor } from '../../../authentication/CredentialsExtractor';
import { ResetResponseDescription } from '../../../http/output/response/ResetResponseDescription';
import type { ResponseDescription } from '../../../http/output/response/ResponseDescription';
import { getLoggerFor } from '../../../logging/LogUtil';
import { ForbiddenHttpError } from '../../../util/errors/ForbiddenHttpError';
import { NotFoundHttpError } from '../../../util/errors/NotFoundHttpError';
import type { OperationHttpHandlerInput } from '../../OperationHttpHandler';
import { OperationHttpHandler } from '../../OperationHttpHandler';
import type { SubscriptionStorage } from '../SubscriptionStorage';
import { parseWebHookUnsubscribeUrl } from './WebHook2021Util';
import type { WebHookFeatures } from './WebHookSubscription2021';
/**
* Allows clients to unsubscribe from a WebHookSubscription2021.
* Should be wrapped in a route handler that only allows `DELETE` operations.
*/
export class WebHookUnsubscriber extends OperationHttpHandler {
protected readonly logger = getLoggerFor(this);
private readonly credentialsExtractor: CredentialsExtractor;
private readonly storage: SubscriptionStorage<WebHookFeatures>;
public constructor(credentialsExtractor: CredentialsExtractor, storage: SubscriptionStorage<WebHookFeatures>) {
super();
this.credentialsExtractor = credentialsExtractor;
this.storage = storage;
}
public async handle({ operation, request }: OperationHttpHandlerInput): Promise<ResponseDescription> {
const id = parseWebHookUnsubscribeUrl(operation.target.path);
const info = await this.storage.get(id);
if (!info) {
throw new NotFoundHttpError();
}
const credentials = await this.credentialsExtractor.handleSafe(request);
if (info.features.webId !== credentials.agent?.webId) {
throw new ForbiddenHttpError();
}
this.logger.debug(`Deleting WebHook subscription ${id}`);
await this.storage.delete(id);
return new ResetResponseDescription();
}
}

View File

@ -0,0 +1,40 @@
import { Parser } from 'n3';
import { OkResponseDescription } from '../../../http/output/response/OkResponseDescription';
import type { ResponseDescription } from '../../../http/output/response/ResponseDescription';
import { BasicRepresentation } from '../../../http/representation/BasicRepresentation';
import { TEXT_TURTLE } from '../../../util/ContentTypes';
import { createErrorMessage } from '../../../util/errors/ErrorUtil';
import { trimTrailingSlashes } from '../../../util/PathUtil';
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.
*/
export class WebHookWebId extends OperationHttpHandler {
private readonly turtle: string;
public constructor(baseUrl: string) {
super();
this.turtle = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
<> solid:oidcIssuer <${trimTrailingSlashes(baseUrl)}>.`;
// This will throw an error if something is wrong with the issuer URL
const parser = new Parser();
try {
parser.parse(this.turtle);
} catch (error: unknown) {
throw new Error(`Invalid issuer URL: ${createErrorMessage(error)}`);
}
}
public async handle(input: OperationHttpHandlerInput): Promise<ResponseDescription> {
const representation = new BasicRepresentation(this.turtle, input.operation.target, TEXT_TURTLE);
return new OkResponseDescription(representation.metadata, representation.data);
}
}

View File

@ -199,6 +199,10 @@ export const NOTIFY = createVocabulary('http://www.w3.org/ns/solid/notifications
'rate', 'rate',
'state', 'state',
'subscription', 'subscription',
'webhookAuth',
'webid',
'WebHookSubscription2021',
); );
export const OIDC = createVocabulary('http://www.w3.org/ns/solid/oidc#', export const OIDC = createVocabulary('http://www.w3.org/ns/solid/oidc#',

View File

@ -0,0 +1,185 @@
import { createServer } from 'http';
import type { Server, IncomingMessage, ServerResponse } from 'http';
import { fetch } from 'cross-fetch';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import type { NamedNode } from 'n3';
import { DataFactory, Parser, Store } from 'n3';
import type { App } from '../../src/init/App';
import { matchesAuthorizationScheme } from '../../src/util/HeaderUtil';
import { joinUrl, trimTrailingSlashes } from '../../src/util/PathUtil';
import { readJsonStream } from '../../src/util/StreamUtil';
import { NOTIFY, RDF } from '../../src/util/Vocabularies';
import { expectNotification, subscribe } from '../util/NotificationUtil';
import { getPort } from '../util/Util';
import {
getDefaultVariables,
getPresetConfigPath,
getTestConfigPath,
getTestFolder,
instantiateFromConfig, removeFolder,
} from './Config';
import quad = DataFactory.quad;
import namedNode = DataFactory.namedNode;
const port = getPort('WebHookSubscription2021');
const baseUrl = `http://localhost:${port}/`;
const clientPort = getPort('WebHookSubscription2021-client');
const target = `http://localhost:${clientPort}/`;
const webId = 'http://example.com/card/#me';
const notificationType = 'WebHookSubscription2021';
const rootFilePath = getTestFolder('WebHookSubscription2021');
const stores: [string, any][] = [
[ 'in-memory storage', {
configs: [ 'storage/backend/memory.json', 'util/resource-locker/memory.json' ],
teardown: jest.fn(),
}],
[ 'on-disk storage', {
// Switch to file locker after https://github.com/CommunitySolidServer/CommunitySolidServer/issues/1452
configs: [ 'storage/backend/file.json', 'util/resource-locker/memory.json' ],
teardown: async(): Promise<void> => removeFolder(rootFilePath),
}],
];
describe.each(stores)('A server supporting WebHookSubscription2021 using %s', (name, { configs, teardown }): void => {
let app: App;
const topic = joinUrl(baseUrl, '/foo');
let storageDescriptionUrl: string;
let subscriptionUrl: string;
let clientServer: Server;
let serverWebId: string;
beforeAll(async(): Promise<void> => {
const variables = {
...getDefaultVariables(port, baseUrl),
'urn:solid-server:default:variable:rootFilePath': rootFilePath,
};
// Create and start the server
const instances = await instantiateFromConfig(
'urn:solid-server:test:Instances',
[
...configs.map(getPresetConfigPath),
getTestConfigPath('webhook-notifications.json'),
],
variables,
) as Record<string, any>;
({ app } = instances);
await app.start();
// Start client server
clientServer = createServer();
clientServer.listen(clientPort);
});
afterAll(async(): Promise<void> => {
clientServer.close();
await app.stop();
await teardown();
});
it('links to the storage description.', async(): Promise<void> => {
const response = await fetch(baseUrl);
expect(response.status).toBe(200);
const linkHeader = response.headers.get('link');
expect(linkHeader).not.toBeNull();
const match = /<([^>]+)>; rel="http:\/\/www\.w3\.org\/ns\/solid\/terms#storageDescription"/u.exec(linkHeader!);
expect(match).not.toBeNull();
storageDescriptionUrl = match![1];
});
it('exposes metadata on how to subscribe in the storage description.', async(): Promise<void> => {
const response = await fetch(storageDescriptionUrl, { headers: { accept: 'text/turtle' }});
expect(response.status).toBe(200);
const quads = new Store(new Parser().parse(await response.text()));
// Find the notification channel for websockets
const channels = quads.getObjects(storageDescriptionUrl, NOTIFY.terms.notificationChannel, null);
const webHookChannels = channels.filter((channel): boolean => quads.has(
quad(channel as NamedNode, RDF.terms.type, namedNode(`${NOTIFY.namespace}WebHookSubscription2021`)),
));
expect(webHookChannels).toHaveLength(1);
const subscriptionUrls = quads.getObjects(webHookChannels[0], NOTIFY.terms.subscription, null);
expect(subscriptionUrls).toHaveLength(1);
subscriptionUrl = subscriptionUrls[0].value;
// It should also link to the server WebID
const webIds = quads.getObjects(webHookChannels[0], NOTIFY.terms.webid, null);
expect(webIds).toHaveLength(1);
serverWebId = webIds[0].value;
});
it('supports subscribing.', async(): Promise<void> => {
await subscribe(notificationType, webId, subscriptionUrl, topic, { target });
});
it('emits Created events.', async(): Promise<void> => {
const clientPromise = new Promise<{ request: IncomingMessage; response: ServerResponse }>((resolve): void => {
clientServer.on('request', (request, response): void => {
resolve({ request, response });
});
});
let res = await fetch(topic, {
method: 'PUT',
headers: { 'content-type': 'text/plain' },
body: 'abc',
});
expect(res.status).toBe(201);
const { request, response } = await clientPromise;
expect(request.headers['content-type']).toBe('application/ld+json');
const notification = await readJsonStream(request);
expectNotification(notification, topic, 'Create');
// Find the JWKS of the server
res = await fetch(joinUrl(baseUrl, '.well-known/openid-configuration'));
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('application/json');
const resJson = await res.json();
expect(typeof resJson.jwks_uri).toBe('string');
const jwks = createRemoteJWKSet(new URL(resJson.jwks_uri));
// Check validity of DPoP headers
// Note that this is not a comprehensive validation of the headers,
// only some of the basics are checked.
const { authorization, dpop } = request.headers;
expect(matchesAuthorizationScheme('DPoP', authorization)).toBe(true);
const encodedDpopToken = authorization!.slice('dpop '.length);
// These will throw if they can not be decoded with the JWKS from the server
const decodedDpopToken = await jwtVerify(encodedDpopToken, jwks, { issuer: trimTrailingSlashes(baseUrl) });
expect(decodedDpopToken.payload).toMatchObject({
webid: serverWebId,
});
const decodedDpopProof = await jwtVerify(dpop as string, jwks);
expect(decodedDpopProof.payload).toMatchObject({
htu: target,
htm: 'POST',
});
// Close the connection so the server can shut down
response.end();
});
it('sends a notification if a state value was sent along.', async(): Promise<void> => {
const clientPromise = new Promise<{ request: IncomingMessage; response: ServerResponse }>((resolve): void => {
clientServer.on('request', (request, response): void => {
resolve({ request, response });
});
});
await subscribe(notificationType, webId, subscriptionUrl, topic, { target, state: 'abc' });
// Will resolve even though the resource did not change since subscribing
const { request, response } = await clientPromise;
expect(request.headers['content-type']).toBe('application/ld+json');
const notification = await readJsonStream(request);
expectNotification(notification, topic, 'Update');
// Close the connection so the server can shut down
response.end();
});
});

View File

@ -7,6 +7,7 @@ import type { App } from '../../src/init/App';
import type { ResourceStore } from '../../src/storage/ResourceStore'; import type { ResourceStore } from '../../src/storage/ResourceStore';
import { joinUrl } from '../../src/util/PathUtil'; import { joinUrl } from '../../src/util/PathUtil';
import { NOTIFY, RDF } from '../../src/util/Vocabularies'; import { NOTIFY, RDF } from '../../src/util/Vocabularies';
import { expectNotification, subscribe } from '../util/NotificationUtil';
import { getPort } from '../util/Util'; import { getPort } from '../util/Util';
import { import {
getDefaultVariables, getDefaultVariables,
@ -20,6 +21,7 @@ import namedNode = DataFactory.namedNode;
const port = getPort('WebSocketSubscription2021'); const port = getPort('WebSocketSubscription2021');
const baseUrl = `http://localhost:${port}/`; const baseUrl = `http://localhost:${port}/`;
const notificationType = 'WebSocketSubscription2021';
const rootFilePath = getTestFolder('WebSocketSubscription2021'); const rootFilePath = getTestFolder('WebSocketSubscription2021');
const stores: [string, any][] = [ const stores: [string, any][] = [
@ -34,51 +36,6 @@ const stores: [string, any][] = [
}], }],
]; ];
// Send the subscribe request and check the response
async function subscribe(subscriptionUrl: string, topic: string, features: Record<string, unknown> = {}):
Promise<string> {
const subscription = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebSocketSubscription2021',
topic,
...features,
};
const response = await fetch(subscriptionUrl, {
method: 'POST',
headers: { 'content-type': 'application/ld+json' },
body: JSON.stringify(subscription),
});
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('application/ld+json');
const { type, source } = await response.json();
expect(type).toBe('WebSocketSubscription2021');
return source;
}
// Check if a notification has the correct format
function expectNotification(notification: unknown, topic: string, type: 'Create' | 'Update' | 'Delete'): void {
const expected: any = {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://www.w3.org/ns/solid/notification/v1',
],
id: expect.stringContaining(topic),
type: [ type ],
object: {
id: topic,
type: [],
},
published: expect.anything(),
};
if (type !== 'Delete') {
expected.state = expect.anything();
expected.object.type.push('http://www.w3.org/ns/ldp#Resource');
}
expect(notification).toEqual(expected);
}
describe.each(stores)('A server supporting WebSocketSubscription2021 using %s', (name, { configs, teardown }): void => { describe.each(stores)('A server supporting WebSocketSubscription2021 using %s', (name, { configs, teardown }): void => {
let app: App; let app: App;
let store: ResourceStore; let store: ResourceStore;
@ -140,7 +97,8 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s',
}); });
it('supports subscribing.', async(): Promise<void> => { it('supports subscribing.', async(): Promise<void> => {
webSocketUrl = await subscribe(subscriptionUrl, topic); const response = await subscribe(notificationType, webId, subscriptionUrl, topic);
webSocketUrl = (response as any).source;
}); });
it('emits Created events.', async(): Promise<void> => { it('emits Created events.', async(): Promise<void> => {
@ -242,7 +200,7 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s',
}); });
expect(response.status).toBe(201); expect(response.status).toBe(201);
const source = await subscribe(subscriptionUrl, topic, { state: 'abc' }); const { source } = await subscribe(notificationType, webId, subscriptionUrl, topic, { state: 'abc' }) as any;
const socket = new WebSocket(source); const socket = new WebSocket(source);
const notificationPromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve)); const notificationPromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));
@ -256,7 +214,7 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s',
}); });
it('removes expired subscriptions.', async(): Promise<void> => { it('removes expired subscriptions.', async(): Promise<void> => {
const source = await subscribe(subscriptionUrl, topic, { expiration: 1 }); const { source } = await subscribe(notificationType, webId, subscriptionUrl, topic, { expiration: 1 }) as any;
const socket = new WebSocket(source); const socket = new WebSocket(source);
const messagePromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve)); const messagePromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));

View File

@ -0,0 +1,52 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"import": [
"css:config/app/main/default.json",
"css:config/app/init/initialize-root.json",
"css:config/app/setup/disabled.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/webhooks.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",
"css:config/identity/email/default.json",
"css:config/identity/handler/default.json",
"css:config/identity/ownership/token.json",
"css:config/identity/pod/static.json",
"css:config/identity/registration/enabled.json",
"css:config/ldp/authentication/debug-auth-header.json",
"css:config/ldp/authorization/webacl.json",
"css:config/ldp/handler/default.json",
"css:config/ldp/metadata-parser/default.json",
"css:config/ldp/metadata-writer/default.json",
"css:config/ldp/modes/default.json",
"css:config/storage/key-value/resource-store.json",
"css:config/storage/middleware/default.json",
"css:config/util/auxiliary/acl.json",
"css:config/util/identifiers/suffix.json",
"css:config/util/index/default.json",
"css:config/util/logging/winston.json",
"css:config/util/representation-conversion/default.json",
"css:config/util/variables/default.json"
],
"@graph": [
{
"comment": "WebSocket notifications with debug authentication.",
"@id": "urn:solid-server:test:Instances",
"@type": "RecordObject",
"record": [
{
"RecordObject:_record_key": "app",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:App" }
},
{
"RecordObject:_record_key": "store",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ResourceStore" }
}
]
}
]
}

View File

@ -111,7 +111,7 @@ describe('A NotificationSubscriber', (): void => {
await subscriber.handle({ operation, request, response }); await subscriber.handle({ operation, request, response });
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({ expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
expiration: Date.now() + (60 * 60 * 1000), expiration: Date.now() + (60 * 60 * 1000),
})); }), { public: {}});
operation.body.data = guardedStreamFrom(JSON.stringify({ operation.body.data = guardedStreamFrom(JSON.stringify({
...subscriptionBody, ...subscriptionBody,
@ -120,7 +120,7 @@ describe('A NotificationSubscriber', (): void => {
await subscriber.handle({ operation, request, response }); await subscriber.handle({ operation, request, response });
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({ expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
expiration: Date.now() + (60 * 60 * 1000), expiration: Date.now() + (60 * 60 * 1000),
})); }), { public: {}});
operation.body.data = guardedStreamFrom(JSON.stringify({ operation.body.data = guardedStreamFrom(JSON.stringify({
...subscriptionBody, ...subscriptionBody,
@ -129,7 +129,7 @@ describe('A NotificationSubscriber', (): void => {
await subscriber.handle({ operation, request, response }); await subscriber.handle({ operation, request, response });
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({ expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
expiration: Date.now() + 5, expiration: Date.now() + 5,
})); }), { public: {}});
jest.useRealTimers(); jest.useRealTimers();
}); });

View File

@ -0,0 +1,18 @@
import {
generateWebHookUnsubscribeUrl, parseWebHookUnsubscribeUrl,
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHook2021Util';
describe('WebHook2021Util', (): void => {
describe('#generateWebHookUnsubscribeUrl', (): void => {
it('generates the URL with the identifier.', async(): Promise<void> => {
expect(generateWebHookUnsubscribeUrl('http://example.com/unsubscribe', '123$456'))
.toBe('http://example.com/unsubscribe/123%24456');
});
});
describe('#parseWebHookUnsubscribeUrl', (): void => {
it('returns the parsed identifier from the URL.', async(): Promise<void> => {
expect(parseWebHookUnsubscribeUrl('http://example.com/unsubscribe/123%24456')).toBe('123$456');
});
});
});

View File

@ -0,0 +1,38 @@
import 'jest-rdf';
import { DataFactory } from 'n3';
import type { ResourceIdentifier } from '../../../../../src/http/representation/ResourceIdentifier';
import {
AbsolutePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
import { WebHookDescriber } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookDescriber';
import { NOTIFY, RDF } from '../../../../../src/util/Vocabularies';
const { namedNode, quad } = DataFactory;
describe('A WebHookDescriber', (): void => {
const identifier: ResourceIdentifier = { path: 'http://example.com/foo' };
const route = new AbsolutePathInteractionRoute('http://example.com/.notifications/webhooks/');
const webIdRoute = new AbsolutePathInteractionRoute('http://example.com/.notifications/webhooks/webId');
const relative = '#webhookNotification';
const type = 'http://www.w3.org/ns/solid/notifications#WebHookSubscription2021';
let describer: WebHookDescriber;
beforeEach(async(): Promise<void> => {
describer = new WebHookDescriber({ route, webIdRoute, relative });
});
it('outputs the expected quads.', async(): Promise<void> => {
const subscription = namedNode('http://example.com/foo#webhookNotification');
const quads = await describer.handle(identifier);
expect(quads).toBeRdfIsomorphic([
quad(namedNode(identifier.path), NOTIFY.terms.notificationChannel, subscription),
quad(subscription, RDF.terms.type, namedNode(type)),
quad(subscription, NOTIFY.terms.subscription, namedNode('http://example.com/.notifications/webhooks/')),
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.accept),
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.expiration),
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.rate),
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.state),
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.webhookAuth),
quad(subscription, NOTIFY.terms.webid, namedNode(webIdRoute.getPath())),
]);
});
});

View File

@ -0,0 +1,142 @@
import fetch from 'cross-fetch';
import { calculateJwkThumbprint, exportJWK, generateKeyPair, importJWK, jwtVerify } from 'jose';
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
import type { Representation } from '../../../../../src/http/representation/Representation';
import type { AlgJwk, JwkGenerator } from '../../../../../src/identity/configuration/JwkGenerator';
import {
AbsolutePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
import type { Logger } from '../../../../../src/logging/Logger';
import { getLoggerFor } from '../../../../../src/logging/LogUtil';
import type { Notification } from '../../../../../src/server/notifications/Notification';
import type { SubscriptionInfo } from '../../../../../src/server/notifications/SubscriptionStorage';
import { WebHookEmitter } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookEmitter';
import type {
WebHookFeatures,
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
import { matchesAuthorizationScheme } from '../../../../../src/util/HeaderUtil';
import { trimTrailingSlashes } from '../../../../../src/util/PathUtil';
jest.mock('cross-fetch');
jest.mock('../../../../../src/logging/LogUtil', (): any => {
const logger: Logger =
{ error: jest.fn(), debug: jest.fn() } as any;
return { getLoggerFor: (): Logger => logger };
});
describe('A WebHookEmitter', (): void => {
const fetchMock: jest.Mock = fetch as any;
const baseUrl = 'http://example.com/';
const webIdRoute = new AbsolutePathInteractionRoute('http://example.com/.notifcations/webhooks/webid');
const notification: Notification = {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://www.w3.org/ns/solid/notification/v1',
],
id: `urn:123:http://example.com/foo`,
type: [ 'Update' ],
object: {
id: 'http://example.com/foo',
type: [],
},
published: '123',
};
let representation: Representation;
const info: SubscriptionInfo<WebHookFeatures> = {
id: 'id',
topic: 'http://example.com/foo',
type: 'type',
features: {
target: 'http://example.org/somewhere-else',
webId: webIdRoute.getPath(),
},
lastEmit: 0,
};
let privateJwk: AlgJwk;
let publicJwk: AlgJwk;
let jwkGenerator: jest.Mocked<JwkGenerator>;
let emitter: WebHookEmitter;
beforeEach(async(): Promise<void> => {
fetchMock.mockResolvedValue({ status: 200 });
representation = new BasicRepresentation(JSON.stringify(notification), 'application/ld+json');
const { privateKey, publicKey } = await generateKeyPair('ES256');
privateJwk = { ...await exportJWK(privateKey), alg: 'ES256' };
publicJwk = { ...await exportJWK(publicKey), alg: 'ES256' };
jwkGenerator = {
alg: 'ES256',
getPrivateKey: jest.fn().mockResolvedValue(privateJwk),
getPublicKey: jest.fn().mockResolvedValue(publicJwk),
};
emitter = new WebHookEmitter(baseUrl, webIdRoute, jwkGenerator);
});
it('sends out the necessary data and headers.', async(): Promise<void> => {
const now = Date.now();
jest.useFakeTimers();
jest.setSystemTime(now);
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0];
expect(call[0]).toBe('http://example.org/somewhere-else');
const { authorization, dpop, 'content-type': contentType } = call[1].headers;
expect(contentType).toBe('application/ld+json');
expect(matchesAuthorizationScheme('DPoP', authorization)).toBe(true);
const encodedDpopToken = authorization.slice('DPoP '.length);
const publicObject = await importJWK(publicJwk);
// Check all the DPoP token fields
const decodedDpopToken = await jwtVerify(encodedDpopToken, publicObject, { issuer: trimTrailingSlashes(baseUrl) });
expect(decodedDpopToken.payload).toMatchObject({
webid: info.features.webId,
azp: info.features.webId,
sub: info.features.webId,
cnf: { jkt: await calculateJwkThumbprint(publicJwk, 'sha256') },
iat: now,
exp: now + (20 * 60 * 1000),
aud: [ info.features.webId, 'solid' ],
jti: expect.stringContaining('-'),
});
expect(decodedDpopToken.protectedHeader).toMatchObject({
alg: 'ES256',
});
// CHeck the DPoP proof
const decodedDpopProof = await jwtVerify(dpop, publicObject);
expect(decodedDpopProof.payload).toMatchObject({
htu: info.features.target,
htm: 'POST',
iat: now,
jti: expect.stringContaining('-'),
});
expect(decodedDpopProof.protectedHeader).toMatchObject({
alg: 'ES256',
typ: 'dpop+jwt',
jwk: publicJwk,
});
jest.useRealTimers();
});
it('logs an error if the fetch request receives an invalid status code.', async(): Promise<void> => {
const logger = getLoggerFor('mock');
fetchMock.mockResolvedValue({ status: 400, text: async(): Promise<string> => 'invalid request' });
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenLastCalledWith(
`There was an issue emitting a WebHook notification with target ${info.features.target}: invalid request`,
);
});
});

View File

@ -0,0 +1,129 @@
import type { InferType } from 'yup';
import type { Credentials } from '../../../../../src/authentication/Credentials';
import { AccessMode } from '../../../../../src/authorization/permissions/Permissions';
import {
AbsolutePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
import type { Logger } from '../../../../../src/logging/Logger';
import { getLoggerFor } from '../../../../../src/logging/LogUtil';
import type { StateHandler } from '../../../../../src/server/notifications/StateHandler';
import type {
SubscriptionInfo,
SubscriptionStorage,
} from '../../../../../src/server/notifications/SubscriptionStorage';
import type {
WebHookFeatures,
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
import {
WebHookSubscription2021,
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
import { IdentifierSetMultiMap } from '../../../../../src/util/map/IdentifierMap';
import { joinUrl } from '../../../../../src/util/PathUtil';
import { readableToString, readJsonStream } from '../../../../../src/util/StreamUtil';
import { flushPromises } from '../../../../util/Util';
jest.mock('../../../../../src/logging/LogUtil', (): any => {
const logger: Logger =
{ error: jest.fn() } as any;
return { getLoggerFor: (): Logger => logger };
});
describe('A WebHookSubscription2021', (): void => {
const credentials: Credentials = { agent: { webId: 'http://example.org/alice' }};
const target = 'http://example.org/somewhere-else';
let subscription: InferType<WebHookSubscription2021['schema']>;
const unsubscribeRoute = new AbsolutePathInteractionRoute('http://example.com/unsubscribe');
let storage: jest.Mocked<SubscriptionStorage<WebHookFeatures>>;
let stateHandler: jest.Mocked<StateHandler>;
let subscriptionType: WebHookSubscription2021;
beforeEach(async(): Promise<void> => {
subscription = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebHookSubscription2021',
topic: 'https://storage.example/resource',
target,
state: undefined,
expiration: undefined,
accept: undefined,
rate: undefined,
};
storage = {
create: jest.fn((features: WebHookFeatures): SubscriptionInfo<WebHookFeatures> => ({
id: '123',
topic: 'http://example.com/foo',
type: 'WebHookSubscription2021',
lastEmit: 0,
features,
})),
add: jest.fn(),
} as any;
stateHandler = {
handleSafe: jest.fn(),
} as any;
subscriptionType = new WebHookSubscription2021(storage, unsubscribeRoute, stateHandler);
});
it('has the correct type.', async(): Promise<void> => {
expect(subscriptionType.type).toBe('WebHookSubscription2021');
});
it('correctly parses subscriptions.', async(): Promise<void> => {
await expect(subscriptionType.schema.isValid(subscription)).resolves.toBe(true);
subscription.type = 'something else';
await expect(subscriptionType.schema.isValid(subscription)).resolves.toBe(false);
});
it('requires Read permissions on the topic.', async(): Promise<void> => {
await expect(subscriptionType.extractModes(subscription)).resolves
.toEqual(new IdentifierSetMultiMap([[{ path: subscription.topic }, AccessMode.read ]]));
});
it('stores the info and returns a valid response when subscribing.', async(): Promise<void> => {
const { response } = await subscriptionType.subscribe(subscription, credentials);
expect(response.metadata.contentType).toBe('application/ld+json');
await expect(readJsonStream(response.data)).resolves.toEqual({
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebHookSubscription2021',
target,
// eslint-disable-next-line @typescript-eslint/naming-convention
unsubscribe_endpoint: joinUrl(unsubscribeRoute.getPath(), '123'),
});
});
it('errors if the credentials do not contain a WebID.', async(): Promise<void> => {
await expect(subscriptionType.subscribe(subscription, {})).rejects
.toThrow('A WebHookSubscription2021 subscription request needs to be authenticated with a WebID.');
});
it('calls the state handler once the response has been read.', async(): Promise<void> => {
const { response, info } = await subscriptionType.subscribe(subscription, credentials);
expect(stateHandler.handleSafe).toHaveBeenCalledTimes(0);
// Read out data to end stream correctly
await readableToString(response.data);
expect(stateHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(stateHandler.handleSafe).toHaveBeenLastCalledWith({ info });
});
it('logs an error if something went wrong emitting the state notification.', async(): Promise<void> => {
const logger = getLoggerFor('mock');
stateHandler.handleSafe.mockRejectedValue(new Error('notification error'));
const { response } = await subscriptionType.subscribe(subscription, credentials);
expect(logger.error).toHaveBeenCalledTimes(0);
// Read out data to end stream correctly
await readableToString(response.data);
await flushPromises();
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenLastCalledWith('Error emitting state notification: notification error');
});
});

View File

@ -0,0 +1,64 @@
import type { CredentialsExtractor } from '../../../../../src/authentication/CredentialsExtractor';
import type { Operation } from '../../../../../src/http/Operation';
import { ResetResponseDescription } from '../../../../../src/http/output/response/ResetResponseDescription';
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../src/server/HttpResponse';
import type { SubscriptionStorage } from '../../../../../src/server/notifications/SubscriptionStorage';
import type {
WebHookFeatures,
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
import {
WebHookUnsubscriber,
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookUnsubscriber';
import { ForbiddenHttpError } from '../../../../../src/util/errors/ForbiddenHttpError';
import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError';
describe('A WebHookUnsubscriber', (): void => {
const request: HttpRequest = {} as any;
const response: HttpResponse = {} as any;
let operation: Operation;
const webId = 'http://example.com/alice';
let credentialsExtractor: jest.Mocked<CredentialsExtractor>;
let storage: jest.Mocked<SubscriptionStorage<WebHookFeatures>>;
let unsubscriber: WebHookUnsubscriber;
beforeEach(async(): Promise<void> => {
operation = {
method: 'DELETE',
target: { path: 'http://example.com/.notifications/webhooks/unsubscribe/134' },
preferences: {},
body: new BasicRepresentation(),
};
credentialsExtractor = {
handleSafe: jest.fn().mockResolvedValue({ agent: { webId }}),
} as any;
storage = {
get: jest.fn().mockResolvedValue({ features: { webId }}),
delete: jest.fn(),
} as any;
unsubscriber = new WebHookUnsubscriber(credentialsExtractor, storage);
});
it('rejects if the id does not match any stored info.', async(): Promise<void> => {
storage.get.mockResolvedValue(undefined);
await expect(unsubscriber.handle({ operation, request, response })).rejects.toThrow(NotFoundHttpError);
expect(storage.delete).toHaveBeenCalledTimes(0);
});
it('rejects if credentials are wrong.', async(): Promise<void> => {
credentialsExtractor.handleSafe.mockResolvedValue({ agent: { webId: 'http://example.com/bob' }});
await expect(unsubscriber.handle({ operation, request, response })).rejects.toThrow(ForbiddenHttpError);
expect(storage.delete).toHaveBeenCalledTimes(0);
});
it('deletes the corresponding info.', async(): Promise<void> => {
await expect(unsubscriber.handle({ operation, request, response }))
.resolves.toEqual(new ResetResponseDescription());
expect(storage.delete).toHaveBeenCalledTimes(1);
expect(storage.delete).toHaveBeenLastCalledWith('134');
});
});

View File

@ -0,0 +1,47 @@
import { DataFactory, Parser } from 'n3';
import type { Operation } from '../../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../src/server/HttpResponse';
import { WebHookWebId } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookWebId';
import { readableToString } from '../../../../../src/util/StreamUtil';
import { SOLID } from '../../../../../src/util/Vocabularies';
const { namedNode, quad } = DataFactory;
describe('A WebHookWebId', (): void => {
const request: HttpRequest = {} as any;
const response: HttpResponse = {} as any;
let operation: Operation;
const baseUrl = 'http://example.com/';
let webIdHandler: WebHookWebId;
beforeEach(async(): Promise<void> => {
operation = {
method: 'GET',
target: { path: 'http://example.com/.notifications/webhooks/webid' },
preferences: {},
body: new BasicRepresentation(),
};
webIdHandler = new WebHookWebId(baseUrl);
});
it('returns a solid:oidcIssuer triple.', async(): Promise<void> => {
const turtle = await webIdHandler.handle({ operation, request, response });
expect(turtle.statusCode).toBe(200);
expect(turtle.metadata?.contentType).toBe('text/turtle');
expect(turtle.data).toBeDefined();
const quads = new Parser({ baseIRI: operation.target.path }).parse(await readableToString(turtle.data!));
expect(quads).toHaveLength(1);
expect(quads).toEqual([ quad(
namedNode('http://example.com/.notifications/webhooks/webid'),
SOLID.terms.oidcIssuer,
namedNode('http://example.com'),
) ]);
});
it('errors if the base URL is invalid.', async(): Promise<void> => {
expect((): any => new WebHookWebId('very invalid URL'))
.toThrow('Invalid issuer URL: Unexpected "<very" on line 2.');
});
});

View File

@ -0,0 +1,57 @@
import { fetch } from 'cross-fetch';
/**
* Subscribes to a notification channel.
* @param type - The type of the notification channel. E.g. "WebSocketSubscription2021".
* @param webId - The WebID to spoof in the authorization header. This assumes the config uses the debug auth import.
* @param subscriptionUrl - The URL where the subscription request needs to be sent to.
* @param topic - The topic to subscribe to.
* @param features - Any extra fields that need to be added to the subscription body.
*/
export async function subscribe(type: string, webId: string, subscriptionUrl: string, topic: string,
features: Record<string, unknown> = {}): Promise<unknown> {
const subscription = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type,
topic,
...features,
};
const response = await fetch(subscriptionUrl, {
method: 'POST',
headers: { authorization: `WebID ${webId}`, 'content-type': 'application/ld+json' },
body: JSON.stringify(subscription),
});
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('application/ld+json');
const jsonResponse = await response.json();
expect(jsonResponse.type).toBe(type);
return jsonResponse;
}
/**
* Verifies if a notification has the expected format.
* @param notification - The (parsed) notification.
* @param topic - The topic of the notification.
* @param type - What type of notification is expected.
*/
export function expectNotification(notification: unknown, topic: string, type: 'Create' | 'Update' | 'Delete'): void {
const expected: any = {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://www.w3.org/ns/solid/notification/v1',
],
id: expect.stringContaining(topic),
type: [ type ],
object: {
id: topic,
type: [],
},
published: expect.anything(),
};
if (type !== 'Delete') {
expected.state = expect.anything();
expected.object.type.push('http://www.w3.org/ns/ldp#Resource');
}
expect(notification).toEqual(expected);
}

View File

@ -29,6 +29,8 @@ const portNames = [
'SetupMemory', 'SetupMemory',
'SparqlStorage', 'SparqlStorage',
'Subdomains', 'Subdomains',
'WebHookSubscription2021',
'WebHookSubscription2021-client',
'WebSocketSubscription2021', 'WebSocketSubscription2021',
// Unit // Unit