mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add support for WebHookSubscription2021
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<T = Record<string, unknown>> {
|
||||
representation: Representation;
|
||||
info: SubscriptionInfo;
|
||||
info: SubscriptionInfo<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>> {}
|
||||
|
||||
@@ -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<void> {
|
||||
private async authorize(request: HttpRequest, subscription: Subscription): Promise<Credentials> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TSub extends typeof SUBSCRIBE_SCHEMA = typeof
|
||||
/**
|
||||
* Registers the given subscription.
|
||||
* @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}.
|
||||
*/
|
||||
subscribe: (subscription: InferType<TSub>) => Promise<SubscriptionResponse<TFeat>>;
|
||||
subscribe: (subscription: InferType<TSub>, credentials: Credentials) => Promise<SubscriptionResponse<TFeat>>;
|
||||
}
|
||||
|
||||
@@ -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()!);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user