feat: Support content negotiation for IDP requests

This commit is contained in:
Joachim Van Herwegen
2021-08-10 15:55:49 +02:00
parent 7b42c72142
commit 80ebd02cc4
20 changed files with 483 additions and 209 deletions

View File

@@ -1,55 +1,67 @@
import urljoin from 'url-join';
import type { ErrorHandler } from '../ldp/http/ErrorHandler';
import type { RequestParser } from '../ldp/http/RequestParser';
import { OkResponseDescription } from '../ldp/http/response/OkResponseDescription';
import { RedirectResponseDescription } from '../ldp/http/response/RedirectResponseDescription';
import type { ResponseDescription } from '../ldp/http/response/ResponseDescription';
import type { ResponseWriter } from '../ldp/http/ResponseWriter';
import type { Operation } from '../ldp/operations/Operation';
import { BasicRepresentation } from '../ldp/representation/BasicRepresentation';
import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences';
import { getLoggerFor } from '../logging/LogUtil';
import type { HttpHandlerInput } from '../server/HttpHandler';
import { HttpHandler } from '../server/HttpHandler';
import type { HttpRequest } from '../server/HttpRequest';
import type { HttpResponse } from '../server/HttpResponse';
import type { TemplateHandler } from '../server/util/TemplateHandler';
import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter';
import { APPLICATION_JSON } from '../util/ContentTypes';
import { BadRequestHttpError } from '../util/errors/BadRequestHttpError';
import { assertError, createErrorMessage } from '../util/errors/ErrorUtil';
import { InternalServerError } from '../util/errors/InternalServerError';
import { trimTrailingSlashes } from '../util/PathUtil';
import { addTemplateMetadata } from '../util/ResourceUtil';
import type { ProviderFactory } from './configuration/ProviderFactory';
import type { Interaction,
import type {
Interaction,
InteractionHandler,
InteractionHandlerResult } from './interaction/email-password/handler/InteractionHandler';
InteractionHandlerResult,
InteractionResponseResult,
} from './interaction/email-password/handler/InteractionHandler';
import { IdpInteractionError } from './interaction/util/IdpInteractionError';
import type { InteractionCompleter } from './interaction/util/InteractionCompleter';
const API_VERSION = '0.1';
/**
* All the information that is required to handle a request to a custom IDP path.
*/
export class InteractionRoute {
public readonly route: RegExp;
public readonly handler: InteractionHandler;
public readonly viewTemplate: string;
public readonly viewTemplates: Record<string, string>;
public readonly prompt?: string;
public readonly responseTemplate?: string;
public readonly responseTemplates: Record<string, string>;
/**
* @param route - Regex to match this route.
* @param viewTemplate - Template to render on GET requests.
* @param viewTemplates - Templates to render on GET requests.
* Keys are content-types, values paths to a template.
* @param handler - Handler to call on POST requests.
* @param prompt - In case of requests to the IDP entry point, the session prompt will be compared to this.
* One entry should have a value of "default" here in case there are no prompt matches.
* @param responseTemplate - Template to render as a response to POST requests when required.
* @param responseTemplates - Templates to render as a response to POST requests when required.
* Keys are content-types, values paths to a template.
*/
public constructor(route: string,
viewTemplate: string,
viewTemplates: Record<string, string>,
handler: InteractionHandler,
prompt?: string,
responseTemplate?: string) {
responseTemplates: Record<string, string> = {}) {
this.route = new RegExp(route, 'u');
this.viewTemplate = viewTemplate;
this.viewTemplates = viewTemplates;
this.handler = handler;
this.prompt = prompt;
this.responseTemplate = responseTemplate;
this.responseTemplates = responseTemplates;
}
}
@@ -72,7 +84,7 @@ export class IdentityProviderHttpHandler extends HttpHandler {
private readonly requestParser: RequestParser;
private readonly providerFactory: ProviderFactory;
private readonly interactionRoutes: InteractionRoute[];
private readonly templateHandler: TemplateHandler;
private readonly converter: RepresentationConverter;
private readonly interactionCompleter: InteractionCompleter;
private readonly errorHandler: ErrorHandler;
private readonly responseWriter: ResponseWriter;
@@ -83,7 +95,7 @@ export class IdentityProviderHttpHandler extends HttpHandler {
* @param requestParser - Used for parsing requests.
* @param providerFactory - Used to generate the OIDC provider.
* @param interactionRoutes - All routes handling the custom IDP behaviour.
* @param templateHandler - Used for rendering responses.
* @param converter - Used for content negotiation..
* @param interactionCompleter - Used for POST requests that need to be handled by the OIDC library.
* @param errorHandler - Converts errors to responses.
* @param responseWriter - Renders error responses.
@@ -94,7 +106,7 @@ export class IdentityProviderHttpHandler extends HttpHandler {
requestParser: RequestParser,
providerFactory: ProviderFactory,
interactionRoutes: InteractionRoute[],
templateHandler: TemplateHandler,
converter: RepresentationConverter,
interactionCompleter: InteractionCompleter,
errorHandler: ErrorHandler,
responseWriter: ResponseWriter,
@@ -105,7 +117,7 @@ export class IdentityProviderHttpHandler extends HttpHandler {
this.requestParser = requestParser;
this.providerFactory = providerFactory;
this.interactionRoutes = interactionRoutes;
this.templateHandler = templateHandler;
this.converter = converter;
this.interactionCompleter = interactionCompleter;
this.errorHandler = errorHandler;
this.responseWriter = responseWriter;
@@ -142,30 +154,15 @@ export class IdentityProviderHttpHandler extends HttpHandler {
// If our own interaction handler does not support the input, it is either invalid or a request for the OIDC library
const route = await this.findRoute(operation, oidcInteraction);
if (!route) {
// Make sure the request stream still works in case the RequestParser read it
const provider = await this.providerFactory.getProvider();
this.logger.debug(`Sending request to oidc-provider: ${request.url}`);
return provider.callback(request, response);
}
const { result, templateFile } = await this.resolveRoute(operation, route, oidcInteraction);
if (result.type === 'complete') {
if (!oidcInteraction) {
// Once https://github.com/solid/community-server/pull/898 is merged
// we want to assign an error code here to have a more thorough explanation
throw new BadRequestHttpError(
'This action can only be executed as part of an authentication flow. It should not be used directly.',
);
}
// We need the original request object for the OIDC library
const location = await this.interactionCompleter.handleSafe({ ...result.details, request });
return await this.responseWriter.handleSafe({ response, result: new RedirectResponseDescription(location) });
}
if (result.type === 'response' && templateFile) {
return await this.handleTemplateResponse(response, templateFile, result.details, oidcInteraction);
}
throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`);
const { result, templateFiles } = await this.resolveRoute(operation, route, oidcInteraction);
const responseDescription =
await this.handleInteractionResult(operation, request, result, templateFiles, oidcInteraction);
await this.responseWriter.handleSafe({ response, result: responseDescription });
}
/**
@@ -190,29 +187,30 @@ export class IdentityProviderHttpHandler extends HttpHandler {
* Handles the behaviour of an InteractionRoute.
* Will error if the route does not support the given request.
*
* GET requests go to the templateHandler, POST requests to the specific InteractionHandler of the route.
* GET requests return a default response result,
* POST requests to the specific InteractionHandler of the route.
*/
private async resolveRoute(operation: Operation, route: InteractionRoute, oidcInteraction?: Interaction):
Promise<{ result: InteractionHandlerResult; templateFile?: string }> {
Promise<{ result: InteractionHandlerResult; templateFiles: Record<string, string> }> {
if (operation.method === 'GET') {
// .ejs templates errors on undefined variables
return {
result: { type: 'response', details: { errorMessage: '', prefilled: {}}},
templateFile: route.viewTemplate,
templateFiles: route.viewTemplates,
};
}
if (operation.method === 'POST') {
try {
const result = await route.handler.handleSafe({ operation, oidcInteraction });
return { result, templateFile: route.responseTemplate };
return { result, templateFiles: route.responseTemplates };
} catch (error: unknown) {
// Render error in the view
const prefilled = IdpInteractionError.isInstance(error) ? error.prefilled : {};
const errorMessage = createErrorMessage(error);
return {
result: { type: 'response', details: { errorMessage, prefilled }},
templateFile: route.viewTemplate,
templateFiles: route.viewTemplates,
};
}
}
@@ -220,11 +218,53 @@ export class IdentityProviderHttpHandler extends HttpHandler {
throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`);
}
private async handleTemplateResponse(response: HttpResponse, templateFile: string, data?: NodeJS.Dict<any>,
oidcInteraction?: Interaction): Promise<void> {
const contents = data ?? {};
contents.authenticating = Boolean(oidcInteraction);
await this.templateHandler.handleSafe({ response, templateFile, contents });
/**
* Creates a ResponseDescription based on the InteractionHandlerResult.
* This will either be a redirect if type is "complete" or a data stream if the type is "response".
*/
private async handleInteractionResult(operation: Operation, request: HttpRequest, result: InteractionHandlerResult,
templateFiles: Record<string, string>, oidcInteraction?: Interaction): Promise<ResponseDescription> {
let responseDescription: ResponseDescription | undefined;
if (result.type === 'complete') {
if (!oidcInteraction) {
// Once https://github.com/solid/community-server/pull/898 is merged
// we want to assign an error code here to have a more thorough explanation
throw new BadRequestHttpError(
'This action can only be executed as part of an authentication flow. It should not be used directly.',
);
}
// Create a redirect URL with the OIDC library
const location = await this.interactionCompleter.handleSafe({ ...result.details, request });
responseDescription = new RedirectResponseDescription(location);
} else {
// Convert the response object to a data stream
responseDescription = await this.handleResponseResult(result, templateFiles, operation, oidcInteraction);
}
return responseDescription;
}
/**
* Converts an InteractionResponseResult to a ResponseDescription by first converting to a Representation
* and applying necessary conversions.
*/
private async handleResponseResult(result: InteractionResponseResult, templateFiles: Record<string, string>,
operation: Operation, oidcInteraction?: Interaction): Promise<ResponseDescription> {
// Convert the object to a valid JSON representation
const json = { ...result.details, authenticating: Boolean(oidcInteraction), apiVersion: API_VERSION };
const representation = new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON);
// Template metadata is required for conversion
for (const [ type, templateFile ] of Object.entries(templateFiles)) {
addTemplateMetadata(representation.metadata, templateFile, type);
}
// Potentially convert the Representation based on the preferences
const args = { representation, preferences: operation.preferences, identifier: operation.target };
const converted = await this.converter.handleSafe(args);
return new OkResponseDescription(converted.metadata, converted.data);
}
/**

View File

@@ -208,7 +208,6 @@ export * from './server/middleware/WebSocketAdvertiser';
// Server/Util
export * from './server/util/RouterHandler';
export * from './server/util/TemplateHandler';
// Storage/Accessors
export * from './storage/accessors/DataAccessor';
@@ -222,6 +221,7 @@ export * from './storage/conversion/ConstantConverter';
export * from './storage/conversion/ContainerToTemplateConverter';
export * from './storage/conversion/ContentTypeReplacer';
export * from './storage/conversion/ConversionUtil';
export * from './storage/conversion/DynamicJsonToTemplateConverter';
export * from './storage/conversion/ErrorToQuadConverter';
export * from './storage/conversion/ErrorToTemplateConverter';
export * from './storage/conversion/IfNeededConverter';

View File

@@ -112,10 +112,10 @@ export class RepresentationMetadata {
* @returns All matching metadata quads.
*/
public quads(
subject: Term | null = null,
predicate: Term | null = null,
object: Term | null = null,
graph: Term | null = null,
subject: NamedNode | BlankNode | string | null = null,
predicate: NamedNode | string | null = null,
object: NamedNode | BlankNode | Literal | string | null = null,
graph: MetadataGraph | null = null,
): Quad[] {
return this.store.getQuads(subject, predicate, object, graph);
}

View File

@@ -1,36 +0,0 @@
import type { ResponseDescription } from '../../ldp/http/response/ResponseDescription';
import type { ResponseWriter } from '../../ldp/http/ResponseWriter';
import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import { guardedStreamFrom } from '../../util/StreamUtil';
import type { TemplateEngine } from '../../util/templates/TemplateEngine';
import type { HttpResponse } from '../HttpResponse';
import Dict = NodeJS.Dict;
/**
* A Render Handler that uses a template engine to render a response.
*/
export class TemplateHandler<T extends Dict<any> = Dict<any>>
extends AsyncHandler<{ response: HttpResponse; templateFile: string; contents: T }> {
private readonly responseWriter: ResponseWriter;
private readonly templateEngine: TemplateEngine;
private readonly contentType: string;
public constructor(responseWriter: ResponseWriter, templateEngine: TemplateEngine, contentType = 'text/html') {
super();
this.responseWriter = responseWriter;
this.templateEngine = templateEngine;
this.contentType = contentType;
}
public async handle({ response, templateFile, contents }:
{ response: HttpResponse; templateFile: string; contents: T }): Promise<void> {
const rendered = await this.templateEngine.render(contents, { templateFile });
const result: ResponseDescription = {
statusCode: 200,
data: guardedStreamFrom(rendered),
metadata: new RepresentationMetadata(this.contentType),
};
await this.responseWriter.handleSafe({ response, result });
}
}

View File

@@ -0,0 +1,96 @@
import type { Term, NamedNode } from 'rdf-js';
import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
import type { Representation } from '../../ldp/representation/Representation';
import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
import type { ValuePreferences } from '../../ldp/representation/RepresentationPreferences';
import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
import { APPLICATION_JSON } from '../../util/ContentTypes';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { readableToString } from '../../util/StreamUtil';
import type { TemplateEngine } from '../../util/templates/TemplateEngine';
import { CONTENT_TYPE, CONTENT_TYPE_TERM, SOLID_META } from '../../util/Vocabularies';
import { getConversionTarget } from './ConversionUtil';
import { RepresentationConverter } from './RepresentationConverter';
import type { RepresentationConverterArgs } from './RepresentationConverter';
/**
* Converts JSON data by using it as input parameters for rendering a template.
* The `extension` field can be used to only support a specific type of templates,
* such as ".ejs" for EJS templates.
*
* To find the templates it expects the Representation metadata to contain `SOLID_META.template` triples,
* with the objects being the template paths.
* For each of those templates there also needs to be a CONTENT_TYPE triple
* describing the content-type of that template.
*
* The output of the result depends on the content-type matched with the template.
*/
export class DynamicJsonToTemplateConverter extends RepresentationConverter {
private readonly templateEngine: TemplateEngine;
public constructor(templateEngine: TemplateEngine) {
super();
this.templateEngine = templateEngine;
}
public async canHandle(input: RepresentationConverterArgs): Promise<void> {
if (input.representation.metadata.contentType !== APPLICATION_JSON) {
throw new NotImplementedHttpError('Only JSON data is supported');
}
const { identifier, representation, preferences } = input;
// Can only handle this input if we can find a type to convert to
const typeMap = this.constructTypeMap(identifier, representation);
this.findType(typeMap, preferences.type);
}
public async handle(input: RepresentationConverterArgs): Promise<Representation> {
const { identifier, representation, preferences } = input;
const typeMap = this.constructTypeMap(identifier, representation);
const type = this.findType(typeMap, preferences.type);
const json = JSON.parse(await readableToString(representation.data));
const rendered = await this.templateEngine.render(json, { templateFile: typeMap[type] });
const metadata = new RepresentationMetadata(representation.metadata, { [CONTENT_TYPE]: type });
return new BasicRepresentation(rendered, metadata);
}
/**
* Uses the metadata of the Representation to create a map where each key is a content-type
* and each value is the path of the corresponding template.
*/
private constructTypeMap(identifier: ResourceIdentifier, representation: Representation): Record<string, string> {
// Finds the templates in the metadata
const templates: NamedNode[] = representation.metadata.quads(identifier.path, SOLID_META.terms.template)
.map((quad): Term => quad.object)
.filter((term: Term): boolean => term.termType === 'NamedNode') as NamedNode[];
// Maps all content-types to their template
const typeMap: Record<string, string> = {};
for (const template of templates) {
const types = representation.metadata.quads(template, CONTENT_TYPE_TERM).map((quad): string => quad.object.value);
for (const type of types) {
typeMap[type] = template.value;
}
}
return typeMap;
}
/**
* Finds the best content-type to convert to based on the provided templates and preferences.
*/
private findType(typeMap: Record<string, string>, typePreferences: ValuePreferences = {}): string {
const typeWeights = Object.fromEntries(Object.keys(typeMap).map((type: string): [ string, 1 ] => [ type, 1 ]));
const type = getConversionTarget(typeWeights, typePreferences);
if (!type) {
throw new NotImplementedHttpError(
`No templates found matching ${Object.keys(typePreferences)}, only ${Object.keys(typeMap)}`,
);
}
return type;
}
}

View File

@@ -1,11 +1,12 @@
import arrayifyStream from 'arrayify-stream';
import { DataFactory } from 'n3';
import { BasicRepresentation } from '../ldp/representation/BasicRepresentation';
import type { Representation } from '../ldp/representation/Representation';
import { RepresentationMetadata } from '../ldp/representation/RepresentationMetadata';
import { guardedStreamFrom } from './StreamUtil';
import { toLiteral } from './TermUtil';
import { DC, LDP, RDF, XSD } from './Vocabularies';
import { CONTENT_TYPE_TERM, DC, LDP, RDF, SOLID_META, XSD } from './Vocabularies';
import namedNode = DataFactory.namedNode;
/**
* Helper function to generate type quads for a Container or Resource.
@@ -34,6 +35,19 @@ export function updateModifiedDate(metadata: RepresentationMetadata, date = new
metadata.set(DC.terms.modified, toLiteral(lastModified.toISOString(), XSD.terms.dateTime));
}
/**
* Links a template file with a given content-type to the metadata using the SOLID_META.template predicate.
* @param metadata - Metadata to update.
* @param templateFile - Path to the template.
* @param contentType - Content-type of the template after it is rendered.
*/
export function addTemplateMetadata(metadata: RepresentationMetadata, templateFile: string, contentType: string):
void {
const templateNode = namedNode(templateFile);
metadata.add(SOLID_META.terms.template, templateNode);
metadata.addQuad(templateNode, CONTENT_TYPE_TERM, contentType);
}
/**
* Helper function to clone a representation, the original representation can still be used.
* This function loads the entire stream in memory.

View File

@@ -0,0 +1,72 @@
import arrayifyStream from 'arrayify-stream';
import { DataFactory } from 'n3';
import { BasicRepresentation } from '../ldp/representation/BasicRepresentation';
import type { Representation } from '../ldp/representation/Representation';
import { RepresentationMetadata } from '../ldp/representation/RepresentationMetadata';
import { guardedStreamFrom } from './StreamUtil';
<<<<<<< Updated upstream
import { toLiteral } from './TermUtil';
import { DC, LDP, RDF, XSD } from './Vocabularies';
=======
import { CONTENT_TYPE_TERM, LDP, RDF, SOLID_META } from './Vocabularies';
import namedNode = DataFactory.namedNode;
>>>>>>> Stashed changes
/**
* Helper function to generate type quads for a Container or Resource.
* @param metadata - Metadata to add to.
* @param isContainer - If the identifier corresponds to a container.
*
* @returns The generated quads.
*/
export function addResourceMetadata(metadata: RepresentationMetadata, isContainer: boolean): void {
if (isContainer) {
metadata.add(RDF.terms.type, LDP.terms.Container);
metadata.add(RDF.terms.type, LDP.terms.BasicContainer);
}
metadata.add(RDF.terms.type, LDP.terms.Resource);
}
/**
<<<<<<< Updated upstream
* Updates the dc:modified time to the given time.
* @param metadata - Metadata to update.
* @param date - Last modified date. Defaults to current time.
*/
export function updateModifiedDate(metadata: RepresentationMetadata, date = new Date()): void {
// Milliseconds get lost in some serializations, potentially causing mismatches
const lastModified = new Date(date);
lastModified.setMilliseconds(0);
metadata.set(DC.terms.modified, toLiteral(lastModified.toISOString(), XSD.terms.dateTime));
=======
* Links a template file with a given content-type to the metadata using the SOLID_META.template predicate.
* @param metadata - Metadata to update.
* @param templateFile - Path to the template.
* @param contentType - Content-type of the template after it is rendered.
*/
export function addTemplateMetadata(metadata: RepresentationMetadata, templateFile: string, contentType: string):
void {
const templateNode = namedNode(templateFile);
metadata.add(SOLID_META.terms.template, templateNode);
metadata.addQuad(templateNode, CONTENT_TYPE_TERM, contentType);
>>>>>>> Stashed changes
}
/**
* Helper function to clone a representation, the original representation can still be used.
* This function loads the entire stream in memory.
* @param representation - The representation to clone.
*
* @returns The cloned representation.
*/
export async function cloneRepresentation(representation: Representation): Promise<BasicRepresentation> {
const data = await arrayifyStream(representation.data);
const result = new BasicRepresentation(
data,
new RepresentationMetadata(representation.metadata),
representation.binary,
);
representation.data = guardedStreamFrom(data);
return result;
}

View File

@@ -135,6 +135,8 @@ export const SOLID_HTTP = createUriAndTermNamespace('urn:npm:solid:community-ser
export const SOLID_META = createUriAndTermNamespace('urn:npm:solid:community-server:meta:',
// This identifier is used as graph for all metadata that is generated on the fly and should not be stored
'ResponseMetadata',
// This is used to identify templates that can be used for the representation of a resource
'template',
);
export const VANN = createUriAndTermNamespace('http://purl.org/vocab/vann/',