mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Replace WebHookSubscription2021 with WebHookChannel2023
This commit is contained in:
@@ -321,10 +321,10 @@ export * from './server/notifications/serialize/ConvertingNotificationSerializer
|
||||
export * from './server/notifications/serialize/JsonLdNotificationSerializer';
|
||||
export * from './server/notifications/serialize/NotificationSerializer';
|
||||
|
||||
// Server/Notifications/WebHookSubscription2021
|
||||
export * from './server/notifications/WebHookSubscription2021/WebHookEmitter';
|
||||
export * from './server/notifications/WebHookSubscription2021/WebHookSubscription2021';
|
||||
export * from './server/notifications/WebHookSubscription2021/WebHookWebId';
|
||||
// Server/Notifications/WebHookChannel2023
|
||||
export * from './server/notifications/WebHookChannel2023/WebhookChannel2023Type';
|
||||
export * from './server/notifications/WebHookChannel2023/WebHookEmitter';
|
||||
export * from './server/notifications/WebHookChannel2023/WebHookWebId';
|
||||
|
||||
// Server/Notifications/WebSocketChannel2023
|
||||
export * from './server/notifications/WebSocketChannel2023/WebSocket2023Emitter';
|
||||
|
||||
@@ -9,11 +9,14 @@ import { trimTrailingSlashes } from '../../../util/PathUtil';
|
||||
import { readableToString } from '../../../util/StreamUtil';
|
||||
import type { NotificationEmitterInput } from '../NotificationEmitter';
|
||||
import { NotificationEmitter } from '../NotificationEmitter';
|
||||
import type { WebHookSubscription2021Channel } from './WebHookSubscription2021';
|
||||
import { isWebHook2021Channel } from './WebHookSubscription2021';
|
||||
import type { WebhookChannel2023 } from './WebhookChannel2023Type';
|
||||
import { isWebHook2023Channel } from './WebhookChannel2023Type';
|
||||
|
||||
/**
|
||||
* Emits a notification representation using the WebHookSubscription2021 specification.
|
||||
* Emits a notification representation using the WebHookChannel2023 specification.
|
||||
*
|
||||
* At the time of writing it is not specified how exactly a notification sender should make its requests verifiable,
|
||||
* so for now we use a token similar to those from Solid-OIDC, signed by the server itself.
|
||||
*
|
||||
* Generates a DPoP token and proof, and adds those to the HTTP request that is sent to the target.
|
||||
*
|
||||
@@ -37,15 +40,15 @@ export class WebHookEmitter extends NotificationEmitter {
|
||||
}
|
||||
|
||||
public async canHandle({ channel }: NotificationEmitterInput): Promise<void> {
|
||||
if (!isWebHook2021Channel(channel)) {
|
||||
throw new NotImplementedHttpError(`${channel.id} is not a WebHookSubscription2021 channel.`);
|
||||
if (!isWebHook2023Channel(channel)) {
|
||||
throw new NotImplementedHttpError(`${channel.id} is not a WebHookChannel2023 channel.`);
|
||||
}
|
||||
}
|
||||
|
||||
public async handle({ channel, representation }: NotificationEmitterInput): Promise<void> {
|
||||
// Cast was checked in `canHandle`
|
||||
const webHookChannel = channel as WebHookSubscription2021Channel;
|
||||
this.logger.debug(`Emitting WebHook notification with target ${webHookChannel.target}`);
|
||||
const webHookChannel = channel as WebhookChannel2023;
|
||||
this.logger.debug(`Emitting WebHook notification with target ${webHookChannel.sendTo}`);
|
||||
|
||||
const privateKey = await this.jwkGenerator.getPrivateKey();
|
||||
const publicKey = await this.jwkGenerator.getPublicKey();
|
||||
@@ -55,8 +58,7 @@ export class WebHookEmitter extends NotificationEmitter {
|
||||
// Make sure both header and proof have the same timestamp
|
||||
const time = Date.now();
|
||||
|
||||
// The spec is not completely clear on which fields actually need to be present in the token,
|
||||
// only that it needs to contain the WebID somehow.
|
||||
// Currently the spec does not define how the notification sender should identify.
|
||||
// The format used here has been chosen to be similar
|
||||
// to how ID tokens are described in the Solid-OIDC specification for consistency.
|
||||
const dpopToken = await new SignJWT({
|
||||
@@ -76,14 +78,14 @@ export class WebHookEmitter extends NotificationEmitter {
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-4.2
|
||||
const dpopProof = await new SignJWT({
|
||||
htu: webHookChannel.target,
|
||||
htu: webHookChannel.sendTo,
|
||||
htm: 'POST',
|
||||
}).setProtectedHeader({ alg: privateKey.alg, jwk: publicKey, typ: 'dpop+jwt' })
|
||||
.setIssuedAt(time)
|
||||
.setJti(v4())
|
||||
.sign(privateKeyObject);
|
||||
|
||||
const response = await fetch(webHookChannel.target, {
|
||||
const response = await fetch(webHookChannel.sendTo, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': representation.metadata.contentType!,
|
||||
@@ -93,7 +95,7 @@ export class WebHookEmitter extends NotificationEmitter {
|
||||
body: await readableToString(representation.data),
|
||||
});
|
||||
if (response.status >= 400) {
|
||||
this.logger.error(`There was an issue emitting a WebHook notification with target ${webHookChannel.target}: ${
|
||||
this.logger.error(`There was an issue emitting a WebHook notification with target ${webHookChannel.sendTo}: ${
|
||||
await response.text()}`);
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,9 @@ import type { OperationHttpHandlerInput } from '../../OperationHttpHandler';
|
||||
import { OperationHttpHandler } from '../../OperationHttpHandler';
|
||||
|
||||
/**
|
||||
* The WebHookSubscription2021 requires the server to have a WebID
|
||||
* that is used during the generation of the DPoP headers.
|
||||
* There are no real specifications about what this should contain or look like,
|
||||
* so we just return a Turtle document that contains a solid:oidcIssuer triple for now.
|
||||
* This way we confirm that our server was allowed to sign the token.
|
||||
* Generates a fixed WebID that we use to identify the server for notifications sent using a WebHookChannel2023.
|
||||
* This is used in tandem with the tokens generated by the {@link WebHookEmitter}.
|
||||
* This is a minimal WebID with only the `solid:oidcIssuer` triple.
|
||||
*/
|
||||
export class WebHookWebId extends OperationHttpHandler {
|
||||
private readonly turtle: string;
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Store } from 'n3';
|
||||
import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute';
|
||||
import { getLoggerFor } from '../../../logging/LogUtil';
|
||||
import { createErrorMessage } from '../../../util/errors/ErrorUtil';
|
||||
import { NOTIFY } from '../../../util/Vocabularies';
|
||||
import { BaseChannelType } from '../BaseChannelType';
|
||||
import type { NotificationChannel } from '../NotificationChannel';
|
||||
import type { StateHandler } from '../StateHandler';
|
||||
|
||||
/**
|
||||
* A {@link NotificationChannel} containing the necessary fields for a WebHookChannel2023 channel.
|
||||
*/
|
||||
export interface WebhookChannel2023 extends NotificationChannel {
|
||||
/**
|
||||
* The "WebHookChannel2023" type.
|
||||
*/
|
||||
type: typeof NOTIFY.WebHookChannel2023;
|
||||
/**
|
||||
* Where the notifications have to be sent.
|
||||
*/
|
||||
sendTo: string;
|
||||
}
|
||||
|
||||
export function isWebHook2023Channel(channel: NotificationChannel): channel is WebhookChannel2023 {
|
||||
return channel.type === NOTIFY.WebHookChannel2023;
|
||||
}
|
||||
|
||||
/**
|
||||
* The notification channel type WebHookChannel2023 as described in
|
||||
* https://solid.github.io/notifications/webhook-channel-2023
|
||||
*
|
||||
* Requires read permissions on a resource to be able to receive notifications.
|
||||
*
|
||||
* Also handles the `state` feature if present.
|
||||
*/
|
||||
export class WebhookChannel2023Type extends BaseChannelType {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly stateHandler: StateHandler;
|
||||
private readonly webId: string;
|
||||
|
||||
/**
|
||||
* @param route - The route corresponding to the URL of the subscription service of this channel type.
|
||||
* @param webIdRoute - The route to the WebID that needs to be used when generating DPoP tokens for notifications.
|
||||
* @param stateHandler - The {@link StateHandler} that will be called after a successful subscription.
|
||||
* @param features - The features that need to be enabled for this channel type.
|
||||
*/
|
||||
public constructor(route: InteractionRoute, webIdRoute: InteractionRoute, stateHandler: StateHandler,
|
||||
features?: string[]) {
|
||||
super(NOTIFY.terms.WebHookChannel2023,
|
||||
route,
|
||||
features,
|
||||
[{ path: NOTIFY.sendTo, minCount: 1, maxCount: 1 }]);
|
||||
this.stateHandler = stateHandler;
|
||||
this.webId = webIdRoute.getPath();
|
||||
}
|
||||
|
||||
public async initChannel(data: Store): Promise<WebhookChannel2023> {
|
||||
const subject = await this.validateSubscription(data);
|
||||
const channel = await this.quadsToChannel(data, subject);
|
||||
const sendTo = data.getObjects(subject, NOTIFY.terms.sendTo, null)[0];
|
||||
|
||||
return {
|
||||
...channel,
|
||||
type: NOTIFY.WebHookChannel2023,
|
||||
sendTo: sendTo.value,
|
||||
};
|
||||
}
|
||||
|
||||
public async toJsonLd(channel: NotificationChannel): Promise<Record<string, unknown>> {
|
||||
const json = await super.toJsonLd(channel);
|
||||
|
||||
// Add the stored WebID as sender.
|
||||
// We don't store it in the channel object itself as we always know what it is anyway.
|
||||
json.sender = this.webId;
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
public async completeChannel(channel: NotificationChannel): Promise<void> {
|
||||
try {
|
||||
// Send the state notification, if there is one
|
||||
await this.stateHandler.handleSafe({ channel });
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Error emitting state notification: ${createErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import type { Store } from 'n3';
|
||||
import type { Credentials } from '../../../authentication/Credentials';
|
||||
import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute';
|
||||
import { getLoggerFor } from '../../../logging/LogUtil';
|
||||
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
|
||||
import { createErrorMessage } from '../../../util/errors/ErrorUtil';
|
||||
import { NOTIFY } from '../../../util/Vocabularies';
|
||||
import { BaseChannelType, DEFAULT_NOTIFICATION_FEATURES } from '../BaseChannelType';
|
||||
import type { NotificationChannel } from '../NotificationChannel';
|
||||
import type { SubscriptionService } from '../NotificationChannelType';
|
||||
import type { StateHandler } from '../StateHandler';
|
||||
|
||||
/**
|
||||
* A {@link NotificationChannel} containing the necessary fields for a WebHookSubscription2021 channel.
|
||||
*/
|
||||
export interface WebHookSubscription2021Channel extends NotificationChannel {
|
||||
/**
|
||||
* The "WebHookSubscription2021" type.
|
||||
*/
|
||||
type: typeof NOTIFY.WebHookSubscription2021;
|
||||
/**
|
||||
* Where the notifications have to be sent.
|
||||
*/
|
||||
target: string;
|
||||
/**
|
||||
* The WebID of the agent subscribing to the channel.
|
||||
*/
|
||||
webId: string;
|
||||
/**
|
||||
* Where the agent can unsubscribe from the channel.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
unsubscribe_endpoint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An extension of {@link SubscriptionService} adding the necessary `webid` field.
|
||||
* This is currently not part of a context so the terms are added in full to make sure the resulting RDF is valid.
|
||||
*/
|
||||
export interface WebHookSubscriptionService extends SubscriptionService {
|
||||
[NOTIFY.webid]: { id: string };
|
||||
}
|
||||
|
||||
export function isWebHook2021Channel(channel: NotificationChannel): channel is WebHookSubscription2021Channel {
|
||||
return channel.type === NOTIFY.WebHookSubscription2021;
|
||||
}
|
||||
|
||||
/**
|
||||
* The notification channel 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 extends BaseChannelType {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly stateHandler: StateHandler;
|
||||
private readonly webId: string;
|
||||
|
||||
/**
|
||||
* @param route - The route corresponding to the URL of the subscription service of this channel type.
|
||||
* @param webIdRoute - The route to the WebID that needs to be used when generating DPoP tokens for notifications.
|
||||
* @param stateHandler - The {@link StateHandler} that will be called after a successful subscription.
|
||||
* @param features - The features that need to be enabled for this channel type.
|
||||
*/
|
||||
public constructor(route: InteractionRoute, webIdRoute: InteractionRoute, stateHandler: StateHandler,
|
||||
features: string[] = DEFAULT_NOTIFICATION_FEATURES) {
|
||||
super(NOTIFY.terms.WebHookSubscription2021,
|
||||
route,
|
||||
[ ...features, NOTIFY.webhookAuth ],
|
||||
// Need to remember to remove `target` from the vocabulary again once this is updated to webhooks 2023,
|
||||
// as it is not actually part of the vocabulary.
|
||||
// Technically we should also require that this node is a named node,
|
||||
// but that would require clients to send `target: { '@id': 'http://example.com/target' }`,
|
||||
// which would make this more annoying so we are lenient here.
|
||||
// Could change in the future once this field is updated and part of the context.
|
||||
[{ path: NOTIFY.target, minCount: 1, maxCount: 1 }]);
|
||||
this.stateHandler = stateHandler;
|
||||
this.webId = webIdRoute.getPath();
|
||||
}
|
||||
|
||||
public getDescription(): WebHookSubscriptionService {
|
||||
const base = super.getDescription();
|
||||
|
||||
return {
|
||||
...base,
|
||||
[NOTIFY.webid]: { id: this.webId },
|
||||
};
|
||||
}
|
||||
|
||||
public async initChannel(data: Store, credentials: Credentials): Promise<WebHookSubscription2021Channel> {
|
||||
// The WebID is used to verify who can unsubscribe
|
||||
const webId = credentials.agent?.webId;
|
||||
|
||||
if (!webId) {
|
||||
throw new BadRequestHttpError(
|
||||
'A WebHookSubscription2021 subscription request needs to be authenticated with a WebID.',
|
||||
);
|
||||
}
|
||||
|
||||
const subject = await this.validateSubscription(data);
|
||||
const channel = await this.quadsToChannel(data, subject);
|
||||
const target = data.getObjects(subject, NOTIFY.terms.target, null)[0];
|
||||
|
||||
return {
|
||||
...channel,
|
||||
type: NOTIFY.WebHookSubscription2021,
|
||||
webId,
|
||||
target: target.value,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
unsubscribe_endpoint: channel.id,
|
||||
};
|
||||
}
|
||||
|
||||
public async toJsonLd(channel: NotificationChannel): Promise<Record<string, unknown>> {
|
||||
const json = await super.toJsonLd(channel);
|
||||
|
||||
// We don't want to expose the WebID that initialized the notification channel.
|
||||
// This is not really specified either way in the spec so this might change in the future.
|
||||
delete json.webId;
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
public async completeChannel(channel: NotificationChannel): Promise<void> {
|
||||
try {
|
||||
// Send the state notification, if there is one
|
||||
await this.stateHandler.handleSafe({ channel });
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Error emitting state notification: ${createErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function isWebSocket2023Channel(channel: NotificationChannel): channel is
|
||||
|
||||
/**
|
||||
* The notification channel type WebSocketChannel2023 as described in
|
||||
* https://solidproject.org/TR/websocket-subscription-2021
|
||||
* https://solid.github.io/notifications/websocket-channel-2023
|
||||
*
|
||||
* Requires read permissions on a resource to be able to receive notifications.
|
||||
*/
|
||||
|
||||
@@ -204,13 +204,13 @@ export const NOTIFY = createVocabulary('http://www.w3.org/ns/solid/notifications
|
||||
'receiveFrom',
|
||||
'startAt',
|
||||
'state',
|
||||
'sender',
|
||||
'sendTo',
|
||||
'subscription',
|
||||
'target',
|
||||
'topic',
|
||||
'webhookAuth',
|
||||
'webid',
|
||||
|
||||
'WebHookSubscription2021',
|
||||
'WebHookChannel2023',
|
||||
'WebSocketChannel2023',
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user