diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d0d8396a1..2c2b527e5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -7,6 +7,7 @@ - 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. - 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. ### 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. - A new `http/notifications` set of import options have been added 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. diff --git a/config/default.json b/config/default.json index 6cc0d60b1..eadcd87d0 100644 --- a/config/default.json +++ b/config/default.json @@ -7,7 +7,7 @@ "css:config/app/variables/default.json", "css:config/http/handler/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/static/default.json", "css:config/identity/access/public.json", diff --git a/config/http/README.md b/config/http/README.md index a602d2f32..ca3435536 100644 --- a/config/http/README.md +++ b/config/http/README.md @@ -20,9 +20,14 @@ and then pass the request along. 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. * *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). diff --git a/config/http/notifications/all.json b/config/http/notifications/all.json new file mode 100644 index 000000000..6cbab3320 --- /dev/null +++ b/config/http/notifications/all.json @@ -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." + } + ] +} diff --git a/config/http/notifications/webhooks.json b/config/http/notifications/webhooks.json new file mode 100644 index 000000000..32fb73d31 --- /dev/null +++ b/config/http/notifications/webhooks.json @@ -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." + } + ] +} diff --git a/config/http/notifications/webhooks/description.json b/config/http/notifications/webhooks/description.json new file mode 100644 index 000000000..4d508be2e --- /dev/null +++ b/config/http/notifications/webhooks/description.json @@ -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" } + } + ] + } + ] +} diff --git a/config/http/notifications/webhooks/handler.json b/config/http/notifications/webhooks/handler.json new file mode 100644 index 000000000..faa6e8298 --- /dev/null +++ b/config/http/notifications/webhooks/handler.json @@ -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" } + ] + }, + ] +} diff --git a/config/http/notifications/webhooks/routes.json b/config/http/notifications/webhooks/routes.json new file mode 100644 index 000000000..3f5a7dc34 --- /dev/null +++ b/config/http/notifications/webhooks/routes.json @@ -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" } + ] + } + ] +} diff --git a/config/http/notifications/webhooks/subscription.json b/config/http/notifications/webhooks/subscription.json new file mode 100644 index 000000000..4deb8bd60 --- /dev/null +++ b/config/http/notifications/webhooks/subscription.json @@ -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" } + } + } + ] +} diff --git a/documentation/markdown/architecture/features/notifications.md b/documentation/markdown/architecture/features/notifications.md index 6ac1fd947..422823994 100644 --- a/documentation/markdown/architecture/features/notifications.md +++ b/documentation/markdown/architecture/features/notifications.md @@ -95,7 +95,8 @@ that handles notifications for the specific type. ## 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. 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. 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 + +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. diff --git a/src/index.ts b/src/index.ts index 2411bace3..bd76bb528 100644 --- a/src/index.ts +++ b/src/index.ts @@ -320,6 +320,13 @@ 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/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 export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Emitter'; export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Handler'; diff --git a/src/server/notifications/NotificationDescriber.ts b/src/server/notifications/NotificationDescriber.ts index 2d46d7079..92e94243b 100644 --- a/src/server/notifications/NotificationDescriber.ts +++ b/src/server/notifications/NotificationDescriber.ts @@ -6,7 +6,7 @@ import { NOTIFY, RDF } from '../../util/Vocabularies'; import { StorageDescriber } from '../description/StorageDescriber'; const { namedNode, quad } = DataFactory; -const DEFAULT_FEATURES = [ +export const DEFAULT_NOTIFICATION_FEATURES = [ NOTIFY.accept, NOTIFY.expiration, 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. */ export class NotificationDescriber extends StorageDescriber { @@ -30,7 +30,8 @@ export class NotificationDescriber extends StorageDescriber { * @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. */ - 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(); this.path = namedNode(route.getPath()); this.relative = relative; diff --git a/src/server/notifications/NotificationEmitter.ts b/src/server/notifications/NotificationEmitter.ts index cc74b1ae6..907e09ba2 100644 --- a/src/server/notifications/NotificationEmitter.ts +++ b/src/server/notifications/NotificationEmitter.ts @@ -2,12 +2,13 @@ import type { Representation } from '../../http/representation/Representation'; import { AsyncHandler } from '../../util/handlers/AsyncHandler'; import type { SubscriptionInfo } from './SubscriptionStorage'; -export interface NotificationEmitterInput { +export interface NotificationEmitterInput> { representation: Representation; - info: SubscriptionInfo; + info: SubscriptionInfo; } /** * Emits a serialized Notification to the subscription defined by the info. */ -export abstract class NotificationEmitter extends AsyncHandler {} +export abstract class NotificationEmitter> + extends AsyncHandler> {} diff --git a/src/server/notifications/NotificationSubscriber.ts b/src/server/notifications/NotificationSubscriber.ts index e8685e4a6..cd85322af 100644 --- a/src/server/notifications/NotificationSubscriber.ts +++ b/src/server/notifications/NotificationSubscriber.ts @@ -1,3 +1,4 @@ +import type { Credentials } from '../../authentication/Credentials'; import type { CredentialsExtractor } from '../../authentication/CredentialsExtractor'; import type { Authorizer } from '../../authorization/Authorizer'; import type { PermissionReader } from '../../authorization/PermissionReader'; @@ -85,14 +86,14 @@ export class NotificationSubscriber extends OperationHttpHandler { } // 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); } - private async authorize(request: HttpRequest, subscription: Subscription): Promise { + private async authorize(request: HttpRequest, subscription: Subscription): Promise { const credentials = await this.credentialsExtractor.handleSafe(request); this.logger.debug(`Extracted credentials: ${JSON.stringify(credentials)}`); @@ -104,5 +105,7 @@ export class NotificationSubscriber extends OperationHttpHandler { await this.authorizer.handleSafe({ credentials, requestedModes, availablePermissions }); this.logger.verbose(`Authorization succeeded, creating subscription`); + + return credentials; } } diff --git a/src/server/notifications/SubscriptionType.ts b/src/server/notifications/SubscriptionType.ts index 5312bf6ab..4478b9207 100644 --- a/src/server/notifications/SubscriptionType.ts +++ b/src/server/notifications/SubscriptionType.ts @@ -1,4 +1,5 @@ import type { InferType } from 'yup'; +import type { Credentials } from '../../authentication/Credentials'; import type { AccessMap } from '../../authorization/permissions/Permissions'; import type { Representation } from '../../http/representation/Representation'; import type { SUBSCRIBE_SCHEMA } from './Subscription'; @@ -32,8 +33,9 @@ export interface SubscriptionType) => Promise>; + subscribe: (subscription: InferType, credentials: Credentials) => Promise>; } diff --git a/src/server/notifications/WebHookSubscription2021/WebHook2021Util.ts b/src/server/notifications/WebHookSubscription2021/WebHook2021Util.ts new file mode 100644 index 000000000..bfc4c4ca6 --- /dev/null +++ b/src/server/notifications/WebHookSubscription2021/WebHook2021Util.ts @@ -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()!); +} diff --git a/src/server/notifications/WebHookSubscription2021/WebHookDescriber.ts b/src/server/notifications/WebHookSubscription2021/WebHookDescriber.ts new file mode 100644 index 000000000..bb8b99de8 --- /dev/null +++ b/src/server/notifications/WebHookSubscription2021/WebHookDescriber.ts @@ -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 { + 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; + } +} diff --git a/src/server/notifications/WebHookSubscription2021/WebHookEmitter.ts b/src/server/notifications/WebHookSubscription2021/WebHookEmitter.ts new file mode 100644 index 000000000..93b8f4c80 --- /dev/null +++ b/src/server/notifications/WebHookSubscription2021/WebHookEmitter.ts @@ -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 { + 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): Promise { + 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()}`); + } + } +} diff --git a/src/server/notifications/WebHookSubscription2021/WebHookSubscription2021.ts b/src/server/notifications/WebHookSubscription2021/WebHookSubscription2021.ts new file mode 100644 index 000000000..f1ad86070 --- /dev/null +++ b/src/server/notifications/WebHookSubscription2021/WebHookSubscription2021.ts @@ -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 { + protected readonly logger = getLoggerFor(this); + + private readonly storage: SubscriptionStorage; + private readonly unsubscribePath: string; + private readonly stateHandler: StateHandler; + + public readonly type = type; + public readonly schema = schema; + + public constructor(storage: SubscriptionStorage, unsubscribeRoute: InteractionRoute, + stateHandler: StateHandler) { + this.storage = storage; + this.unsubscribePath = unsubscribeRoute.getPath(); + this.stateHandler = stateHandler; + } + + public async extractModes(subscription: InferType): Promise { + return new IdentifierSetMultiMap([[{ path: subscription.topic }, AccessMode.read ]]); + } + + public async subscribe(subscription: InferType, credentials: Credentials): + Promise> { + 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 => this.stateHandler.handleSafe({ info })) + .catch((error): void => { + this.logger.error(`Error emitting state notification: ${createErrorMessage(error)}`); + }); + + return { response, info }; + } +} diff --git a/src/server/notifications/WebHookSubscription2021/WebHookUnsubscriber.ts b/src/server/notifications/WebHookSubscription2021/WebHookUnsubscriber.ts new file mode 100644 index 000000000..1b14a3d52 --- /dev/null +++ b/src/server/notifications/WebHookSubscription2021/WebHookUnsubscriber.ts @@ -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; + + public constructor(credentialsExtractor: CredentialsExtractor, storage: SubscriptionStorage) { + super(); + this.credentialsExtractor = credentialsExtractor; + this.storage = storage; + } + + public async handle({ operation, request }: OperationHttpHandlerInput): Promise { + 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(); + } +} diff --git a/src/server/notifications/WebHookSubscription2021/WebHookWebId.ts b/src/server/notifications/WebHookSubscription2021/WebHookWebId.ts new file mode 100644 index 000000000..9e538167d --- /dev/null +++ b/src/server/notifications/WebHookSubscription2021/WebHookWebId.ts @@ -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: . +<> 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 { + const representation = new BasicRepresentation(this.turtle, input.operation.target, TEXT_TURTLE); + return new OkResponseDescription(representation.metadata, representation.data); + } +} diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index 6a2626417..2a418445a 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -199,6 +199,10 @@ export const NOTIFY = createVocabulary('http://www.w3.org/ns/solid/notifications 'rate', 'state', 'subscription', + 'webhookAuth', + 'webid', + + 'WebHookSubscription2021', ); export const OIDC = createVocabulary('http://www.w3.org/ns/solid/oidc#', diff --git a/test/integration/WebHookSubscription2021.test.ts b/test/integration/WebHookSubscription2021.test.ts new file mode 100644 index 000000000..f70ba26fa --- /dev/null +++ b/test/integration/WebHookSubscription2021.test.ts @@ -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 => 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 => { + 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; + ({ app } = instances); + + await app.start(); + + // Start client server + clientServer = createServer(); + clientServer.listen(clientPort); + }); + + afterAll(async(): Promise => { + clientServer.close(); + await app.stop(); + await teardown(); + }); + + it('links to the storage description.', async(): Promise => { + 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 => { + 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 => { + await subscribe(notificationType, webId, subscriptionUrl, topic, { target }); + }); + + it('emits Created events.', async(): Promise => { + 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 => { + 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(); + }); +}); diff --git a/test/integration/WebSocketSubscription2021.test.ts b/test/integration/WebSocketSubscription2021.test.ts index 2775f42e5..555591a1c 100644 --- a/test/integration/WebSocketSubscription2021.test.ts +++ b/test/integration/WebSocketSubscription2021.test.ts @@ -7,6 +7,7 @@ import type { App } from '../../src/init/App'; import type { ResourceStore } from '../../src/storage/ResourceStore'; import { joinUrl } from '../../src/util/PathUtil'; import { NOTIFY, RDF } from '../../src/util/Vocabularies'; +import { expectNotification, subscribe } from '../util/NotificationUtil'; import { getPort } from '../util/Util'; import { getDefaultVariables, @@ -20,6 +21,7 @@ import namedNode = DataFactory.namedNode; const port = getPort('WebSocketSubscription2021'); const baseUrl = `http://localhost:${port}/`; +const notificationType = 'WebSocketSubscription2021'; const rootFilePath = getTestFolder('WebSocketSubscription2021'); 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 = {}): -Promise { - 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 => { let app: App; let store: ResourceStore; @@ -140,7 +97,8 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s', }); it('supports subscribing.', async(): Promise => { - webSocketUrl = await subscribe(subscriptionUrl, topic); + const response = await subscribe(notificationType, webId, subscriptionUrl, topic); + webSocketUrl = (response as any).source; }); it('emits Created events.', async(): Promise => { @@ -242,7 +200,7 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s', }); 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 notificationPromise = new Promise((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 => { - 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 messagePromise = new Promise((resolve): any => socket.on('message', resolve)); diff --git a/test/integration/config/webhook-notifications.json b/test/integration/config/webhook-notifications.json new file mode 100644 index 000000000..1f161bf91 --- /dev/null +++ b/test/integration/config/webhook-notifications.json @@ -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" } + } + ] + } + ] +} diff --git a/test/unit/server/notifications/NotificationSubscriber.test.ts b/test/unit/server/notifications/NotificationSubscriber.test.ts index f142bac8d..f0730462e 100644 --- a/test/unit/server/notifications/NotificationSubscriber.test.ts +++ b/test/unit/server/notifications/NotificationSubscriber.test.ts @@ -111,7 +111,7 @@ describe('A NotificationSubscriber', (): void => { await subscriber.handle({ operation, request, response }); expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({ expiration: Date.now() + (60 * 60 * 1000), - })); + }), { public: {}}); operation.body.data = guardedStreamFrom(JSON.stringify({ ...subscriptionBody, @@ -120,7 +120,7 @@ describe('A NotificationSubscriber', (): void => { await subscriber.handle({ operation, request, response }); expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({ expiration: Date.now() + (60 * 60 * 1000), - })); + }), { public: {}}); operation.body.data = guardedStreamFrom(JSON.stringify({ ...subscriptionBody, @@ -129,7 +129,7 @@ describe('A NotificationSubscriber', (): void => { await subscriber.handle({ operation, request, response }); expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({ expiration: Date.now() + 5, - })); + }), { public: {}}); jest.useRealTimers(); }); diff --git a/test/unit/server/notifications/WebHookSubscription2021/WebHook2021Util.test.ts b/test/unit/server/notifications/WebHookSubscription2021/WebHook2021Util.test.ts new file mode 100644 index 000000000..42845a7f3 --- /dev/null +++ b/test/unit/server/notifications/WebHookSubscription2021/WebHook2021Util.test.ts @@ -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 => { + 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 => { + expect(parseWebHookUnsubscribeUrl('http://example.com/unsubscribe/123%24456')).toBe('123$456'); + }); + }); +}); diff --git a/test/unit/server/notifications/WebHookSubscription2021/WebHookDescriber.test.ts b/test/unit/server/notifications/WebHookSubscription2021/WebHookDescriber.test.ts new file mode 100644 index 000000000..febfbf34e --- /dev/null +++ b/test/unit/server/notifications/WebHookSubscription2021/WebHookDescriber.test.ts @@ -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 => { + describer = new WebHookDescriber({ route, webIdRoute, relative }); + }); + + it('outputs the expected quads.', async(): Promise => { + 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())), + ]); + }); +}); diff --git a/test/unit/server/notifications/WebHookSubscription2021/WebHookEmitter.test.ts b/test/unit/server/notifications/WebHookSubscription2021/WebHookEmitter.test.ts new file mode 100644 index 000000000..c3b60738f --- /dev/null +++ b/test/unit/server/notifications/WebHookSubscription2021/WebHookEmitter.test.ts @@ -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 = { + 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; + let emitter: WebHookEmitter; + + beforeEach(async(): Promise => { + 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 => { + 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 => { + const logger = getLoggerFor('mock'); + + fetchMock.mockResolvedValue({ status: 400, text: async(): Promise => '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`, + ); + }); +}); diff --git a/test/unit/server/notifications/WebHookSubscription2021/WebHookSubscription2021.test.ts b/test/unit/server/notifications/WebHookSubscription2021/WebHookSubscription2021.test.ts new file mode 100644 index 000000000..9b44b4e19 --- /dev/null +++ b/test/unit/server/notifications/WebHookSubscription2021/WebHookSubscription2021.test.ts @@ -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; + const unsubscribeRoute = new AbsolutePathInteractionRoute('http://example.com/unsubscribe'); + let storage: jest.Mocked>; + let stateHandler: jest.Mocked; + let subscriptionType: WebHookSubscription2021; + + beforeEach(async(): Promise => { + 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 => ({ + 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 => { + expect(subscriptionType.type).toBe('WebHookSubscription2021'); + }); + + it('correctly parses subscriptions.', async(): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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'); + }); +}); diff --git a/test/unit/server/notifications/WebHookSubscription2021/WebHookUnsubscriber.test.ts b/test/unit/server/notifications/WebHookSubscription2021/WebHookUnsubscriber.test.ts new file mode 100644 index 000000000..6a13298d5 --- /dev/null +++ b/test/unit/server/notifications/WebHookSubscription2021/WebHookUnsubscriber.test.ts @@ -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; + let storage: jest.Mocked>; + let unsubscriber: WebHookUnsubscriber; + + beforeEach(async(): Promise => { + 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 => { + 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 => { + 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 => { + await expect(unsubscriber.handle({ operation, request, response })) + .resolves.toEqual(new ResetResponseDescription()); + expect(storage.delete).toHaveBeenCalledTimes(1); + expect(storage.delete).toHaveBeenLastCalledWith('134'); + }); +}); diff --git a/test/unit/server/notifications/WebHookSubscription2021/WebHookWebId.test.ts b/test/unit/server/notifications/WebHookSubscription2021/WebHookWebId.test.ts new file mode 100644 index 000000000..3dca6d820 --- /dev/null +++ b/test/unit/server/notifications/WebHookSubscription2021/WebHookWebId.test.ts @@ -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 => { + 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 => { + 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 => { + expect((): any => new WebHookWebId('very invalid URL')) + .toThrow('Invalid issuer URL: Unexpected " = {}): Promise { + 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); +} diff --git a/test/util/Util.ts b/test/util/Util.ts index d3ee0d8cf..b92aadad3 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -29,6 +29,8 @@ const portNames = [ 'SetupMemory', 'SparqlStorage', 'Subdomains', + 'WebHookSubscription2021', + 'WebHookSubscription2021-client', 'WebSocketSubscription2021', // Unit